@nonext/husk 0.2.3

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 (89) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/LICENSE +166 -0
  3. package/README.md +140 -0
  4. package/api/public-api.md +1070 -0
  5. package/dist/adapter-C_0mhENy.d.ts +320 -0
  6. package/dist/admin/admin.css +5 -0
  7. package/dist/admin/index.d.ts +265 -0
  8. package/dist/admin/index.js +393 -0
  9. package/dist/admin/index.js.map +1 -0
  10. package/dist/admin/tokens.css +597 -0
  11. package/dist/admin/ui.d.ts +3013 -0
  12. package/dist/admin/ui.js +9783 -0
  13. package/dist/admin/ui.js.map +1 -0
  14. package/dist/auth/index.d.ts +4 -0
  15. package/dist/auth/index.js +9 -0
  16. package/dist/auth/index.js.map +1 -0
  17. package/dist/auth-B2gBtHl5.d.ts +28 -0
  18. package/dist/can-Chy7zmx7.d.ts +69 -0
  19. package/dist/chunk-3I6337WJ.js +83 -0
  20. package/dist/chunk-3I6337WJ.js.map +1 -0
  21. package/dist/chunk-4Z6P3XCE.js +62 -0
  22. package/dist/chunk-4Z6P3XCE.js.map +1 -0
  23. package/dist/chunk-6E3ZS5Z5.js +1007 -0
  24. package/dist/chunk-6E3ZS5Z5.js.map +1 -0
  25. package/dist/chunk-77JDRXXU.js +3 -0
  26. package/dist/chunk-77JDRXXU.js.map +1 -0
  27. package/dist/chunk-FHYWJCG6.js +171 -0
  28. package/dist/chunk-FHYWJCG6.js.map +1 -0
  29. package/dist/chunk-JAE7HHMM.js +506 -0
  30. package/dist/chunk-JAE7HHMM.js.map +1 -0
  31. package/dist/chunk-NPDBQBLW.js +68 -0
  32. package/dist/chunk-NPDBQBLW.js.map +1 -0
  33. package/dist/chunk-ORMYARXD.js +6 -0
  34. package/dist/chunk-ORMYARXD.js.map +1 -0
  35. package/dist/chunk-R4O7APIX.js +51 -0
  36. package/dist/chunk-R4O7APIX.js.map +1 -0
  37. package/dist/chunk-ROLKRJWI.js +187 -0
  38. package/dist/chunk-ROLKRJWI.js.map +1 -0
  39. package/dist/chunk-SOTQ3WP5.js +31 -0
  40. package/dist/chunk-SOTQ3WP5.js.map +1 -0
  41. package/dist/chunk-TCFFHTY3.js +97 -0
  42. package/dist/chunk-TCFFHTY3.js.map +1 -0
  43. package/dist/chunk-TS7Y7GEZ.js +7 -0
  44. package/dist/chunk-TS7Y7GEZ.js.map +1 -0
  45. package/dist/chunk-VLGR5PZX.js +3 -0
  46. package/dist/chunk-VLGR5PZX.js.map +1 -0
  47. package/dist/chunk-ZW2PUSVS.js +40 -0
  48. package/dist/chunk-ZW2PUSVS.js.map +1 -0
  49. package/dist/chunk-ZYNBMYJ6.js +392 -0
  50. package/dist/chunk-ZYNBMYJ6.js.map +1 -0
  51. package/dist/cli/nonext-husk.js +3360 -0
  52. package/dist/cli/nonext-husk.js.map +1 -0
  53. package/dist/cli/templates/firestore.rules +393 -0
  54. package/dist/cli/templates/storage.rules +165 -0
  55. package/dist/client/index.d.ts +27 -0
  56. package/dist/client/index.js +15 -0
  57. package/dist/client/index.js.map +1 -0
  58. package/dist/cms-9fSA3uQV.d.ts +158 -0
  59. package/dist/core/index.d.ts +1267 -0
  60. package/dist/core/index.js +12 -0
  61. package/dist/core/index.js.map +1 -0
  62. package/dist/entry-D3kiE8T1.d.ts +163 -0
  63. package/dist/field-Diyf5siF.d.ts +158 -0
  64. package/dist/field-type-D25Vojb0.d.ts +157 -0
  65. package/dist/firebase/index.d.ts +251 -0
  66. package/dist/firebase/index.js +991 -0
  67. package/dist/firebase/index.js.map +1 -0
  68. package/dist/index-5ylaMBUH.d.ts +155 -0
  69. package/dist/index.d.ts +14 -0
  70. package/dist/index.js +17 -0
  71. package/dist/index.js.map +1 -0
  72. package/dist/permissions/index.d.ts +101 -0
  73. package/dist/permissions/index.js +7 -0
  74. package/dist/permissions/index.js.map +1 -0
  75. package/dist/registry-CUC25qay.d.ts +59 -0
  76. package/dist/richtext/index.d.ts +85 -0
  77. package/dist/richtext/index.js +127 -0
  78. package/dist/richtext/index.js.map +1 -0
  79. package/dist/schema/index.d.ts +50 -0
  80. package/dist/schema/index.js +5 -0
  81. package/dist/schema/index.js.map +1 -0
  82. package/dist/server/index.d.ts +39 -0
  83. package/dist/server/index.js +25 -0
  84. package/dist/server/index.js.map +1 -0
  85. package/dist/types/index.d.ts +36 -0
  86. package/dist/types/index.js +6 -0
  87. package/dist/types/index.js.map +1 -0
  88. package/dist/write-limits-Djmm6L3R.d.ts +215 -0
  89. package/package.json +136 -0
@@ -0,0 +1,3013 @@
1
+ import * as react from 'react';
2
+ import { ReactNode, ButtonHTMLAttributes, Ref, InputHTMLAttributes, TextareaHTMLAttributes, TdHTMLAttributes, ThHTMLAttributes, ReactElement, KeyboardEvent, ComponentType } from 'react';
3
+ import { LucideIcon } from 'lucide-react';
4
+ import * as lucide from 'lucide';
5
+
6
+ /**
7
+ * The avatar. Canonical source: .docs/styleguide/04-components.md section 9.
8
+ *
9
+ * Three sizes, nothing else. The fallback is initials rather than a generic silhouette,
10
+ * because a sidebar full of identical silhouettes tells a reader nothing.
11
+ */
12
+ type AvatarSize = 20 | 24 | 32;
13
+ interface AvatarProps {
14
+ /** The display name or email the initials come from, and the image's alt text. */
15
+ name: string;
16
+ src?: string;
17
+ size?: AvatarSize;
18
+ }
19
+ /** At most two letters, from the first two words, so a long name stays inside the circle. */
20
+ declare function initials(name: string): string;
21
+ declare function Avatar({ name, src, size }: AvatarProps): react.JSX.Element;
22
+
23
+ /**
24
+ * The icon vocabulary as plain data, with no Lucide import.
25
+ *
26
+ * `icon.tsx` maps every name here to a glyph, and the admin only ever asks for a name. The
27
+ * list lives in its own module because things that are not components need it: the
28
+ * navigation builder resolves a content type's declared icon against this vocabulary, and
29
+ * it runs on the server, where dragging a React component module in would put the icon set
30
+ * into a bundle that never renders one.
31
+ *
32
+ * Canonical source: .docs/styleguide/05-icons.md section 1.3.
33
+ */
34
+ declare const ICON_NAMES: readonly ["brand", "create", "edit", "delete", "save", "close", "search", "filter", "sort-asc", "sort-desc", "navigate-into", "page-previous", "page-next", "expand", "collapse", "selected", "indeterminate", "more-actions", "drag-handle", "locked", "dashboard", "collection", "singleton", "media", "settings", "users", "permissions", "content-types", "system", "success", "warning", "error", "info", "external-link", "upload", "copy", "retry", "open-navigation", "bold", "italic", "strikethrough", "link", "unlink", "bullet-list", "numbered-list", "quote", "undo", "redo"];
35
+ type IconName = (typeof ICON_NAMES)[number];
36
+
37
+ /**
38
+ * The only place in the admin that may import from lucide-react.
39
+ *
40
+ * Two things are enforced here rather than trusted to a reviewer. Stroke width is 1.5
41
+ * (Lucide defaults to 2), and size is one of the six the guide allows, so no screen can
42
+ * introduce a seventh by typing a number. Canonical source:
43
+ * .docs/styleguide/05-icons.md sections 1 and 1.1.
44
+ *
45
+ * Icons are keyed by concept, not by drawing. A screen asks for `delete`, not for
46
+ * `trash-2`, so the same concept cannot pick up two different glyphs in two places, and
47
+ * changing the glyph for a concept is one edit here instead of a search across the app.
48
+ */
49
+ declare const ICONS: {
50
+ readonly brand: LucideIcon;
51
+ readonly create: LucideIcon;
52
+ readonly edit: LucideIcon;
53
+ readonly delete: LucideIcon;
54
+ readonly save: LucideIcon;
55
+ readonly close: LucideIcon;
56
+ readonly search: LucideIcon;
57
+ readonly filter: LucideIcon;
58
+ readonly "sort-asc": LucideIcon;
59
+ readonly "sort-desc": LucideIcon;
60
+ readonly "navigate-into": LucideIcon;
61
+ readonly "page-previous": LucideIcon;
62
+ readonly "page-next": LucideIcon;
63
+ readonly expand: LucideIcon;
64
+ readonly collapse: LucideIcon;
65
+ readonly selected: LucideIcon;
66
+ readonly indeterminate: LucideIcon;
67
+ readonly "more-actions": LucideIcon;
68
+ readonly "drag-handle": LucideIcon;
69
+ readonly locked: LucideIcon;
70
+ readonly dashboard: LucideIcon;
71
+ readonly collection: LucideIcon;
72
+ readonly singleton: LucideIcon;
73
+ readonly media: LucideIcon;
74
+ readonly settings: LucideIcon;
75
+ readonly users: LucideIcon;
76
+ readonly permissions: LucideIcon;
77
+ readonly "content-types": LucideIcon;
78
+ readonly system: LucideIcon;
79
+ readonly success: LucideIcon;
80
+ readonly warning: LucideIcon;
81
+ readonly error: LucideIcon;
82
+ readonly info: LucideIcon;
83
+ readonly "external-link": LucideIcon;
84
+ readonly upload: LucideIcon;
85
+ readonly copy: LucideIcon;
86
+ readonly retry: LucideIcon;
87
+ readonly "open-navigation": LucideIcon;
88
+ readonly bold: LucideIcon;
89
+ readonly italic: LucideIcon;
90
+ readonly strikethrough: LucideIcon;
91
+ readonly link: LucideIcon;
92
+ readonly unlink: LucideIcon;
93
+ readonly "bullet-list": LucideIcon;
94
+ readonly "numbered-list": LucideIcon;
95
+ readonly quote: LucideIcon;
96
+ readonly undo: LucideIcon;
97
+ readonly redo: LucideIcon;
98
+ };
99
+
100
+ /** 12 badge and check, 14 compact controls, 16 default, 18 section heading, 20 page header, 24 empty state. */
101
+ type IconSize = 12 | 14 | 16 | 18 | 20 | 24;
102
+ interface IconProps {
103
+ name: IconName;
104
+ size?: IconSize;
105
+ /**
106
+ * Set only where the icon is the whole message. An icon next to a text label is
107
+ * decorative and stays hidden from assistive technology.
108
+ */
109
+ label?: string;
110
+ className?: string;
111
+ }
112
+ declare function Icon({ name, size, label, className }: IconProps): react.JSX.Element;
113
+
114
+ /**
115
+ * The badge. Canonical source: .docs/styleguide/04-components.md section 7.
116
+ *
117
+ * Not interactive, ever. A badge that needs a click is a button or a filter chip, and
118
+ * neither of those exists in this system yet.
119
+ */
120
+ type BadgeVariant = "neutral" | "success" | "warning" | "danger" | "info" | "accent";
121
+ /**
122
+ * Entry status maps fixed, so no screen decides for itself what colour Published is.
123
+ * Canonical source: .docs/styleguide/04-components.md section 7.
124
+ */
125
+ declare const ENTRY_STATUS_VARIANT: {
126
+ readonly draft: "neutral";
127
+ readonly published: "success";
128
+ readonly archived: "warning";
129
+ readonly error: "danger";
130
+ };
131
+ /**
132
+ * How a status is written on screen. Stored lowercase, displayed capitalized, decided once
133
+ * so two screens cannot disagree about whether it is "Published" or "published".
134
+ */
135
+ declare const ENTRY_STATUS_LABEL: {
136
+ readonly draft: "Draft";
137
+ readonly published: "Published";
138
+ readonly archived: "Archived";
139
+ };
140
+ interface BadgeProps {
141
+ children: ReactNode;
142
+ variant?: BadgeVariant;
143
+ icon?: IconName;
144
+ }
145
+ declare function Badge({ children, variant, icon }: BadgeProps): react.JSX.Element;
146
+ /**
147
+ * The status dot. Canonical source: .docs/styleguide/04-components.md section 8.
148
+ * Never larger, never animated.
149
+ */
150
+ type StatusTone = "neutral" | "success" | "warning" | "danger" | "info";
151
+ interface StatusDotProps {
152
+ tone?: StatusTone;
153
+ /** Set only where the dot is the whole message; otherwise the text beside it says it. */
154
+ label?: string;
155
+ }
156
+ declare function StatusDot({ tone, label }: StatusDotProps): react.JSX.Element;
157
+
158
+ /**
159
+ * The breadcrumb. Canonical source: .docs/styleguide/03-layout.md section 3.1.
160
+ *
161
+ * `caption`, `text-faint`, a `chevron-right` separator at 14px. It sits above the page
162
+ * title and is part of the page header, never a free-standing element: a trail with no
163
+ * destination under it is decoration.
164
+ *
165
+ * Items are data, not nodes, so the page header cannot be handed a styled link that
166
+ * disagrees with the trail beside it. The last item is the current page and is never a
167
+ * link, because a link to the page you are on is a dead control.
168
+ */
169
+ interface BreadcrumbItem {
170
+ label: string;
171
+ /** Absent on the last item, and on any ancestor that has no screen of its own. */
172
+ href?: string;
173
+ }
174
+ interface BreadcrumbProps {
175
+ items: readonly BreadcrumbItem[];
176
+ className?: string;
177
+ }
178
+ declare function Breadcrumb({ items, className }: BreadcrumbProps): react.JSX.Element | null;
179
+
180
+ /**
181
+ * The button, with the four variants and three sizes the style guide allows and nothing
182
+ * else. Canonical source: .docs/styleguide/04-components.md section 2.
183
+ *
184
+ * A screen that needs a fifth appearance is a style guide change, not a prop.
185
+ *
186
+ * The leading icon is named, not passed as a node, so a caller cannot hand it a 20px
187
+ * glyph or an icon outside the vocabulary. Size follows the button size: 14px at `sm`,
188
+ * 16px otherwise.
189
+ */
190
+ type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
191
+ type ButtonSize = "sm" | "md" | "lg";
192
+ interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type"> {
193
+ variant?: ButtonVariant;
194
+ size?: ButtonSize;
195
+ /** Replaces the leading icon with a spinner and takes the control out of the tab flow. */
196
+ loading?: boolean;
197
+ icon?: IconName;
198
+ type?: "button" | "submit" | "reset";
199
+ ref?: Ref<HTMLButtonElement>;
200
+ }
201
+ declare function Button({ variant, size, loading, icon, children, className, disabled, type, ref, ...rest }: ButtonProps): react.JSX.Element;
202
+
203
+ /**
204
+ * The checkbox. Canonical source: .docs/styleguide/04-components.md section 3.4.
205
+ *
206
+ * Controlled only, and `checked` carries the third state rather than a separate
207
+ * `indeterminate` prop, because "indeterminate and unchecked" is not a state a table
208
+ * header selection can be in and a two-prop API lets a caller express it anyway.
209
+ *
210
+ * The visible box is a span; the real input stays in the accessibility tree and keeps the
211
+ * native label association, Space activation and form participation. Hiding it visually
212
+ * rather than replacing it is what makes the whole label a click target for free.
213
+ */
214
+ interface CheckboxProps {
215
+ checked: boolean | "indeterminate";
216
+ onChange: (checked: boolean) => void;
217
+ /** Sits to the right of the box. Omit it only when a nearby cell header names the column. */
218
+ label?: string;
219
+ /** Required when there is no visible label, for example the selection cell in a table. */
220
+ ariaLabel?: string;
221
+ name?: string;
222
+ value?: string;
223
+ disabled?: boolean;
224
+ id?: string;
225
+ "aria-describedby"?: string | undefined;
226
+ }
227
+ declare function Checkbox({ checked, onChange, label, ariaLabel, name, value, disabled, id, "aria-describedby": describedBy, }: CheckboxProps): react.JSX.Element;
228
+
229
+ /**
230
+ * The empty state. Canonical source: .docs/styleguide/04-components.md section 15.
231
+ *
232
+ * One icon, one heading, one description, at most one action. `action` is a single node
233
+ * rather than an array on purpose: the guide calls a second action a design error, and a
234
+ * type that cannot express it is cheaper than a review that has to catch it.
235
+ */
236
+ interface EmptyStateProps {
237
+ icon?: IconName;
238
+ heading: string;
239
+ description?: string;
240
+ /** One primary or secondary button. */
241
+ action?: ReactNode;
242
+ }
243
+ declare function EmptyState({ icon, heading, description, action }: EmptyStateProps): react.JSX.Element;
244
+
245
+ /**
246
+ * Field composition: label, 8px, control, 6px, help or error.
247
+ * Canonical source: .docs/styleguide/04-components.md section 3.1.
248
+ *
249
+ * The error replaces the help text rather than joining it, so a field never shows two
250
+ * competing sentences about the same value.
251
+ */
252
+ interface FieldProps {
253
+ label: string;
254
+ /** Renders the `*` marker and wires `aria-required` on the control. */
255
+ required?: boolean;
256
+ /** Renders the right-aligned "Optional" marker. Ignored when `required` is set. */
257
+ optional?: boolean;
258
+ help?: string | undefined;
259
+ error?: string | undefined;
260
+ children: (control: {
261
+ id: string;
262
+ describedBy: string | undefined;
263
+ invalid: boolean;
264
+ }) => ReactNode;
265
+ }
266
+ declare function Field({ label, required, optional, help, error, children, }: FieldProps): react.JSX.Element;
267
+ interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
268
+ invalid?: boolean;
269
+ /**
270
+ * Inline validation in flight. Shows the spinner inside the trailing edge and takes the
271
+ * control read-only rather than disabled, so the value stays selectable and the label
272
+ * stays associated.
273
+ */
274
+ loading?: boolean;
275
+ }
276
+ declare function TextInput({ invalid, loading, className, readOnly, ...rest }: TextInputProps): react.JSX.Element;
277
+
278
+ /**
279
+ * Form-level error: the failure that belongs to the submission rather than to one field.
280
+ * Canonical source: .docs/styleguide/04-components.md section 3.8.
281
+ *
282
+ * It is a live region because it usually appears after a submit, when focus is still on
283
+ * the button and nothing else on screen announces the refusal.
284
+ */
285
+ declare function FormError({ children }: {
286
+ children: ReactNode;
287
+ }): react.JSX.Element;
288
+
289
+ declare const ROLES: readonly ["admin", "editor", "author"];
290
+ type Role = (typeof ROLES)[number];
291
+ /** Actions performed on the entries of one content type. */
292
+ type EntryAction = "read" | "create" | "update" | "delete";
293
+ /** Actions that are not scoped to a content type. */
294
+ type AdminAction = "manage-schema" | "manage-users" | "manage-settings" | "manage-media";
295
+ type Action = EntryAction | AdminAction;
296
+ /**
297
+ * Who may do what with one content type. A missing action falls back to the role defaults
298
+ * in `can()`, so an empty matrix means "use the defaults", not "deny everything".
299
+ */
300
+ type PermissionMatrix = Partial<Record<EntryAction, readonly Role[]>>;
301
+ interface AuthUser {
302
+ uid: string;
303
+ email: string;
304
+ displayName: string | null;
305
+ role: Role;
306
+ disabled: boolean;
307
+ }
308
+
309
+ /**
310
+ * A reference from an entry to an item in the media library.
311
+ *
312
+ * Entries store the reference, not the media document, so renaming a file or editing its
313
+ * alt text does not require rewriting every entry that uses it. The denormalized `url` is
314
+ * a read convenience for the SDK and is refreshed by the adapter when the media item
315
+ * changes; `mediaId` is the source of truth.
316
+ */
317
+ interface MediaReference {
318
+ mediaId: string;
319
+ url: string;
320
+ }
321
+ interface MediaItem {
322
+ id: string;
323
+ name: string;
324
+ /** MIME type, for example `image/webp`. */
325
+ type: string;
326
+ /** Bytes. */
327
+ size: number;
328
+ url: string;
329
+ /** Storage object path, relative to the bucket root. */
330
+ path: string;
331
+ alt: string | null;
332
+ /** Pixels. Null for non-images. */
333
+ width: number | null;
334
+ height: number | null;
335
+ createdAt: string;
336
+ createdBy: string;
337
+ }
338
+
339
+ declare const FIELD_TYPES: readonly ["text", "textarea", "number", "boolean", "select", "date", "slug", "richtext", "image", "file", "relation", "repeater"];
340
+ type FieldType = (typeof FIELD_TYPES)[number];
341
+ /**
342
+ * The full rule vocabulary. Every rule compiles to the same runtime validator, which the
343
+ * admin form and the adapter both run, so a rule can never be enforced in one place only.
344
+ */
345
+ interface ValidationRules {
346
+ required?: boolean;
347
+ minLength?: number;
348
+ maxLength?: number;
349
+ min?: number;
350
+ max?: number;
351
+ /** Serialized as a string, not a RegExp, because rules travel through Firestore. */
352
+ pattern?: string;
353
+ /** Rejects a value already held by another entry of the same content type. */
354
+ unique?: boolean;
355
+ }
356
+ interface FieldCommon {
357
+ label: string;
358
+ description?: string;
359
+ placeholder?: string;
360
+ validation?: ValidationRules;
361
+ /** Hidden fields still store and validate; they are simply not rendered in the form. */
362
+ hidden?: boolean;
363
+ readonly?: boolean;
364
+ }
365
+ interface TextField extends FieldCommon {
366
+ type: "text";
367
+ defaultValue?: string;
368
+ }
369
+ interface TextareaField extends FieldCommon {
370
+ type: "textarea";
371
+ rows?: number;
372
+ defaultValue?: string;
373
+ }
374
+ interface NumberField extends FieldCommon {
375
+ type: "number";
376
+ step?: number;
377
+ defaultValue?: number;
378
+ }
379
+ interface BooleanField extends FieldCommon {
380
+ type: "boolean";
381
+ defaultValue?: boolean;
382
+ }
383
+ interface SelectOption$1<TValue extends string = string> {
384
+ value: TValue;
385
+ label: string;
386
+ }
387
+ interface SelectField<TValue extends string = string> extends FieldCommon {
388
+ type: "select";
389
+ options: readonly SelectOption$1<TValue>[];
390
+ multiple?: boolean;
391
+ defaultValue?: TValue;
392
+ }
393
+ interface DateField extends FieldCommon {
394
+ type: "date";
395
+ /** `date` stores `YYYY-MM-DD`; `datetime` stores a full ISO 8601 instant. */
396
+ precision?: "date" | "datetime";
397
+ }
398
+ interface SlugField extends FieldCommon {
399
+ type: "slug";
400
+ /** Field key the slug is derived from until the editor overrides it. */
401
+ from?: string;
402
+ }
403
+ /**
404
+ * Rich text is stored as a serialized document, not as an HTML string, so the editor choice
405
+ * (settled in M5) stays an implementation detail and the value can be re-rendered rather
406
+ * than re-parsed.
407
+ */
408
+ interface RichTextValue {
409
+ format: "husk-richtext-v1";
410
+ content: unknown;
411
+ }
412
+ interface RichtextField extends FieldCommon {
413
+ type: "richtext";
414
+ /** Marks and blocks the toolbar offers. Absent means the default set. */
415
+ features?: readonly string[];
416
+ }
417
+ interface ImageField extends FieldCommon {
418
+ type: "image";
419
+ accept?: readonly string[];
420
+ }
421
+ interface FileField extends FieldCommon {
422
+ type: "file";
423
+ accept?: readonly string[];
424
+ }
425
+ interface RelationField<TTo extends string = string> extends FieldCommon {
426
+ type: "relation";
427
+ /** Slug of the referenced content type. */
428
+ to: TTo;
429
+ many?: boolean;
430
+ }
431
+ interface RepeaterField<TFields extends FieldMap = FieldMap> extends FieldCommon {
432
+ type: "repeater";
433
+ fields: TFields;
434
+ min?: number;
435
+ max?: number;
436
+ }
437
+ type FieldDefinition = TextField | TextareaField | NumberField | BooleanField | SelectField | DateField | SlugField | RichtextField | ImageField | FileField | RelationField | RepeaterField;
438
+ type FieldMap = {
439
+ readonly [key: string]: FieldDefinition;
440
+ };
441
+ /** The value one field definition produces, before required-ness is applied. */
442
+ type FieldValue<TField extends FieldDefinition> = TField extends TextField | TextareaField | SlugField ? string : TField extends NumberField ? number : TField extends BooleanField ? boolean : TField extends DateField ? string : TField extends SelectField<infer TOption> ? TField extends {
443
+ multiple: true;
444
+ } ? TOption[] : TOption : TField extends RichtextField ? RichTextValue : TField extends ImageField | FileField ? MediaReference : TField extends RelationField ? TField extends {
445
+ many: true;
446
+ } ? string[] : string : TField extends RepeaterField<infer TFields> ? FieldValues<TFields>[] : never;
447
+ type IsRequired<TField extends FieldDefinition> = TField extends {
448
+ validation: {
449
+ required: true;
450
+ };
451
+ } ? true : false;
452
+ /**
453
+ * Read shape: every key is present. An optional field reads as `null` rather than
454
+ * `undefined`, so a consumer never has to distinguish "absent" from "empty".
455
+ */
456
+ type FieldValues<TFields extends FieldMap> = {
457
+ [K in keyof TFields]: IsRequired<TFields[K]> extends true ? FieldValue<TFields[K]> : FieldValue<TFields[K]> | null;
458
+ };
459
+
460
+ type ContentTypeKind = "collection" | "singleton";
461
+ /**
462
+ * A field that was removed from a content type while its stored values were kept.
463
+ *
464
+ * Removal is a state, not an absence. The definition is parked here instead of being
465
+ * deleted, so the admin can name the field in a warning, offer it back unchanged, and so a
466
+ * later purge knows what it is about to destroy. Nothing downstream of the registry reads
467
+ * this map: forms, validation, list columns and the SDK all read `fields`, which is why a
468
+ * removed field is inert everywhere without a single consumer having to filter it out.
469
+ */
470
+ interface RemovedField {
471
+ /** Exactly the definition the field had at the moment it was removed. */
472
+ field: FieldDefinition;
473
+ /** ISO 8601. A plain string, because a stored content type is plain JSON. */
474
+ removedAt: string;
475
+ /** uid of whoever removed it. */
476
+ removedBy: string;
477
+ }
478
+ type RemovedFieldMap = {
479
+ readonly [key: string]: RemovedField;
480
+ };
481
+ /** Where a content type came from. Code always wins a slug conflict. See the registry. */
482
+ type ContentTypeSource = "code" | "database";
483
+ /**
484
+ * A content type with its field map erased to `FieldMap`.
485
+ *
486
+ * `ContentType<TFields>` is invariant in `TFields`, so a concrete content type produced by
487
+ * `defineContentType` is not assignable to `ContentType<FieldMap>`. Everything that handles
488
+ * content types generically (the registry, the adapter, the Schema Builder) takes this
489
+ * structural shape instead, which a concrete content type does satisfy. Authoring and
490
+ * inference keep the generic form.
491
+ */
492
+ interface ContentTypeLike {
493
+ slug: string;
494
+ kind: ContentTypeKind;
495
+ label: string;
496
+ labelPlural?: string;
497
+ description?: string;
498
+ icon?: string;
499
+ fields: FieldMap;
500
+ /**
501
+ * The order the fields are shown in, as a list of field keys.
502
+ *
503
+ * It exists because `fields` is a map and a map does not keep an order once it has been
504
+ * through Firestore: a stored document returns its map keys sorted, not in the order they
505
+ * were written. A code-defined type never round trips and keeps its literal's order, so
506
+ * this key is absent there and the object order is used; the Schema Builder writes it on
507
+ * every save, which is what makes a reorder survive a reload.
508
+ *
509
+ * It is a hint, not a second source of truth. An unknown key in it is ignored and a field
510
+ * missing from it is appended, so a `fieldOrder` that has drifted from `fields` still
511
+ * renders every field exactly once.
512
+ */
513
+ fieldOrder?: readonly string[];
514
+ removedFields?: RemovedFieldMap;
515
+ locked?: boolean;
516
+ titleField?: string;
517
+ defaultSort?: {
518
+ field: string;
519
+ direction: "asc" | "desc";
520
+ };
521
+ permissions?: PermissionMatrix;
522
+ schemaVersion?: number;
523
+ }
524
+ type ContentTypeValues<TType extends {
525
+ fields: FieldMap;
526
+ }> = FieldValues<TType["fields"]>;
527
+
528
+ declare const ENTRY_STATUSES: readonly ["draft", "published", "archived"];
529
+ type EntryStatus = (typeof ENTRY_STATUSES)[number];
530
+ /**
531
+ * Everything the adapter owns on an entry. Written on every mutation, never by a form.
532
+ * Timestamps are ISO 8601 strings at this boundary; the Firestore converter turns them
533
+ * into Timestamps on the way in and back on the way out.
534
+ */
535
+ interface EntryMeta {
536
+ id: string;
537
+ status: EntryStatus;
538
+ createdAt: string;
539
+ createdBy: string;
540
+ updatedAt: string;
541
+ updatedBy: string;
542
+ publishedAt: string | null;
543
+ schemaVersion: number;
544
+ }
545
+ /** What a read returns: the metadata plus the typed field values. */
546
+ type Entry<TType extends AnyContentTypeShape = AnyContentTypeShape> = EntryMeta & ContentTypeValues<TType>;
547
+ /**
548
+ * Structural stand-in for `ContentType<TFields>`. `ContentType` is invariant in its field
549
+ * map (it uses `keyof TFields` for titleField and defaultSort), so a concrete content type
550
+ * is not assignable to `ContentType<FieldMap>` and cannot be used as a constraint here.
551
+ */
552
+ type AnyContentTypeShape = {
553
+ fields: FieldMap;
554
+ };
555
+ interface FieldError {
556
+ /** Dotted path, so a repeater reports `blocks.2.title` rather than `blocks`. */
557
+ path: string;
558
+ rule: string;
559
+ message: string;
560
+ }
561
+
562
+ /**
563
+ * The one error taxonomy the admin renders from.
564
+ *
565
+ * Before this existed the treatments were real but scattered: the entry editor routed a
566
+ * `ValidationError` to the offending field and fell back to a form-level message, the
567
+ * Schema Builder put every failure in one banner, the media screens put every failure in a
568
+ * toast, and a permission refusal read as "something went wrong" in all three. Each of
569
+ * those was defensible on its own and none of them agreed with the others.
570
+ *
571
+ * This module is not a second system layered over them. It is the first one, factored out:
572
+ * the M6 rule that a validation error lands on the field and only an unroutable path
573
+ * reaches the form is preserved exactly, as the `field` treatment, and every other screen
574
+ * now asks the same question instead of answering it privately.
575
+ *
576
+ * Five categories, one treatment each:
577
+ *
578
+ * | category | treatment | what the user sees |
579
+ * |--------------|-----------|-----------------------------------------------------------|
580
+ * | `validation` | `field` | the message on the control that owns the path; never a bare toast |
581
+ * | `permission` | `banner` | a form-level notice naming the action, the subject and the role |
582
+ * | `not-found` | `state` | an empty state with a way back; no alert, no red |
583
+ * | `conflict` | `resolve` | a banner that names both resolutions and offers them |
584
+ * | `transport` | `retry` | a banner or state carrying the one action that can help |
585
+ *
586
+ * `unknown` is the sixth, for a failure that fits none of them. It takes the `banner`
587
+ * treatment and says plainly that the cause is unknown, rather than guessing at a category
588
+ * and offering a retry that cannot work.
589
+ *
590
+ * The toast is deliberately not a treatment. It is the announcement channel for a failure
591
+ * whose surface has already closed (a dialog that dismissed itself, a background upload),
592
+ * and when it is used it prints this same `title` and `message`, so a failure can never be
593
+ * worded twice.
594
+ *
595
+ * No React and no Firebase here, so the classification is testable in the node project and
596
+ * callable from the adapter as well as from a render.
597
+ */
598
+ declare const ERROR_CATEGORIES: readonly ["validation", "permission", "not-found", "conflict", "transport", "unknown"];
599
+ type ErrorCategory = (typeof ERROR_CATEGORIES)[number];
600
+ /** The chrome a category is rendered in. Exactly one per category; see the table above. */
601
+ type ErrorTreatment = "field" | "banner" | "state" | "resolve" | "retry";
602
+ interface ClassifiedError {
603
+ category: ErrorCategory;
604
+ treatment: ErrorTreatment;
605
+ /** A short heading. Used as the banner's first line and as a toast title. */
606
+ title: string;
607
+ /** One or two sentences: what happened, and what the user can do about it. */
608
+ message: string;
609
+ /**
610
+ * The field errors, for `validation` only. Every other category leaves this empty, which
611
+ * is what stops a non-validation failure from being routed onto a control.
612
+ */
613
+ fieldErrors: readonly FieldError[];
614
+ /** True only for `transport`. A retry of anything else repeats the same refusal. */
615
+ retryable: boolean;
616
+ /** What was thrown, kept for a boundary that wants to log it. Never rendered. */
617
+ cause: unknown;
618
+ }
619
+
620
+ /**
621
+ * What covers the failure a React error boundary cannot see.
622
+ *
623
+ * A boundary catches a throw during render, in an effect, or in a lifecycle. It does not
624
+ * catch a rejected promise, and almost everything the admin does is a rejected promise: an
625
+ * adapter call, an upload, a listener. Every one of those is inside a `try`/`catch` today,
626
+ * but "every one" is a property of the code as it stands, not a mechanism, and the first
627
+ * handler somebody forgets is a failure that reaches nobody. The browser's own answer to
628
+ * an unhandled rejection is a console line the user will never see.
629
+ *
630
+ * So the admin listens at the window. An unhandled rejection is classified through the same
631
+ * taxonomy as a caught one and raised as an error toast, which does not auto-dismiss.
632
+ * `preventDefault` stops the default console report, because the toast now owns it.
633
+ *
634
+ * `error` is listened to for the same reason: a throw inside a `setTimeout` or an event
635
+ * handler registered outside React is not on any component's stack either.
636
+ *
637
+ * This is a net, not a treatment. Anything it catches is a missing `catch` somewhere, and
638
+ * the toast says so in the one place that was not meant to have to.
639
+ */
640
+ interface ErrorCaptureProps {
641
+ /**
642
+ * Called for every captured failure, before the toast. For a host that ships errors to
643
+ * a reporter; the admin itself does not, because it has no service to ship them to.
644
+ */
645
+ onCapture?: ((error: ClassifiedError) => void) | undefined;
646
+ }
647
+ declare function ErrorCapture({ onCapture }: ErrorCaptureProps): null;
648
+
649
+ /**
650
+ * The in-place treatment for a classified error that is not a field error.
651
+ * Canonical source: .docs/styleguide/04-components.md section 19.
652
+ *
653
+ * One component for the `banner`, `resolve` and `retry` treatments, because they are one
654
+ * piece of chrome with a different actions slot, not three designs. Splitting them would
655
+ * be how a permission refusal and a transport failure end up looking like two products.
656
+ *
657
+ * `validation` never reaches here. Its treatment is the field itself, and the entry editor
658
+ * routes each error to the control that owns its path; only a path naming no rendered field
659
+ * falls back to a banner, which is the M6 rule this component inherits rather than replaces.
660
+ * `not-found` never reaches here either: it is a state, rendered by `ErrorScreen`.
661
+ */
662
+ interface ErrorNoticeProps {
663
+ error: ClassifiedError;
664
+ /**
665
+ * The retry. Rendered only when the taxonomy says a retry is meaningful, so a screen
666
+ * cannot offer one for a refusal that will refuse again.
667
+ */
668
+ onRetry?: (() => void) | undefined;
669
+ /** The resolutions for a `conflict`, which has two and must name both. */
670
+ actions?: ReactNode;
671
+ className?: string;
672
+ }
673
+ declare function ErrorNotice({ error, onRetry, actions, className }: ErrorNoticeProps): react.JSX.Element;
674
+
675
+ /**
676
+ * The animated half of the icon system. Canonical source:
677
+ * .docs/styleguide/05-icons.md section 2.
678
+ *
679
+ * Morphicons is not a second icon family: it animates between two Lucide drawings. It
680
+ * consumes icon *data* rather than components, which is why this is the one file that
681
+ * imports the vanilla `lucide` package as well as `lucide-react` in `icon.tsx`. Both are
682
+ * pinned to the same version, so a morph and the static icon next to it are the same
683
+ * drawing.
684
+ *
685
+ * A morph is allowed only in the cases section 2.1 lists, so the glyph map is a closed set
686
+ * of four names rather than the whole vocabulary: a screen cannot animate an icon the
687
+ * guide says should not move. Names are checked against the static vocabulary, so a morph
688
+ * pair can never drift onto a concept that does not exist.
689
+ */
690
+ declare const MORPH_GLYPHS: {
691
+ readonly "open-navigation": lucide.IconNode;
692
+ readonly close: lucide.IconNode;
693
+ readonly "navigate-into": lucide.IconNode;
694
+ readonly expand: lucide.IconNode;
695
+ readonly copy: lucide.IconNode;
696
+ readonly save: lucide.IconNode;
697
+ };
698
+ type MorphIconName = keyof typeof MORPH_GLYPHS;
699
+ interface MorphingIconProps {
700
+ name: MorphIconName;
701
+ /** The same six sizes the static icon allows. */
702
+ size?: 12 | 14 | 16 | 18 | 20 | 24;
703
+ /** Set only where the icon is the whole message; otherwise it stays decorative. */
704
+ label?: string;
705
+ className?: string;
706
+ }
707
+ declare function MorphingIcon({ name, size, label, className }: MorphingIconProps): react.JSX.Element;
708
+
709
+ interface IconButtonBase extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type" | "children" | "aria-label"> {
710
+ /** The accessible name, and the tooltip text unless `tooltip` overrides it. */
711
+ label: string;
712
+ tooltip?: string;
713
+ tooltipPlacement?: "top" | "bottom";
714
+ variant?: ButtonVariant;
715
+ size?: ButtonSize;
716
+ /**
717
+ * Turns the button into a toggle that reports a state: `aria-pressed` plus the pressed
718
+ * appearance from .docs/styleguide/04-components.md section 2.3. The guide allows it on the
719
+ * `ghost` variant only, which is the default here.
720
+ */
721
+ pressed?: boolean;
722
+ loading?: boolean;
723
+ ref?: Ref<HTMLButtonElement>;
724
+ type?: "button" | "submit" | "reset";
725
+ }
726
+ type IconButtonProps = IconButtonBase & ({
727
+ icon: IconName;
728
+ morph?: false;
729
+ } | {
730
+ icon: MorphIconName;
731
+ morph: true;
732
+ });
733
+ declare function IconButton(props: IconButtonProps): react.JSX.Element;
734
+
735
+ /**
736
+ * The dropdown menu. Canonical source: .docs/styleguide/04-components.md section 11.
737
+ *
738
+ * Items are data rather than children, because every menu in the admin is generated: the
739
+ * row actions come from what the user may do to that entry, the sidebar overflow comes
740
+ * from the registry. A children-based API would invite a screen to hand-build one.
741
+ *
742
+ * Keyboard behaviour follows the menu button pattern: the trigger opens on Enter, Space or
743
+ * ArrowDown, focus moves into the list, arrows move the highlight past disabled items and
744
+ * separators, Escape closes and returns focus to the trigger, and Tab closes without
745
+ * grabbing focus back so the page's tab order is preserved.
746
+ */
747
+ type MenuEntry = {
748
+ kind: "item";
749
+ id: string;
750
+ label: string;
751
+ icon?: IconName;
752
+ /** Renders the trailing check from section 11. */
753
+ selected?: boolean;
754
+ destructive?: boolean;
755
+ disabled?: boolean;
756
+ /** Right-aligned hint, for example "Ctrl S". Display only; the menu binds nothing. */
757
+ shortcut?: string;
758
+ } | {
759
+ kind: "separator";
760
+ id: string;
761
+ } | {
762
+ kind: "section";
763
+ id: string;
764
+ label: string;
765
+ };
766
+ interface MenuProps {
767
+ items: readonly MenuEntry[];
768
+ onSelect: (id: string) => void;
769
+ /** The trigger's accessible name, and its tooltip when the trigger is icon-only. */
770
+ label: string;
771
+ /** Set to render a labelled trigger instead of the icon-only default. */
772
+ triggerLabel?: string;
773
+ triggerIcon?: IconName;
774
+ triggerVariant?: ButtonVariant;
775
+ triggerSize?: ButtonSize;
776
+ /** Which edge of the trigger the menu lines up with. */
777
+ align?: "start" | "end";
778
+ /**
779
+ * Which side of the trigger the menu opens toward. Defaults to `"bottom"`, unchanged from
780
+ * every existing call site. Set to `"top"` for a trigger pinned to the bottom of a
781
+ * scrollable region, for example the sidebar's account menu, so the panel opens into the
782
+ * viewport instead of past it.
783
+ */
784
+ side?: "top" | "bottom";
785
+ }
786
+ declare function Menu({ items, onSelect, label, triggerLabel, triggerIcon, triggerVariant, triggerSize, align, side, }: MenuProps): react.JSX.Element;
787
+
788
+ /**
789
+ * The metric tile: one label, one number.
790
+ * Canonical source: .docs/styleguide/03-layout.md section 4.2, with the internal spacing
791
+ * added to that section in the same change that added this component.
792
+ *
793
+ * Three states, and the third is the point. A figure the data layer could not produce
794
+ * renders as `Unavailable` with the reason beside it, because a plausible-looking wrong
795
+ * number on a dashboard is worse than an admitted gap: nobody checks a number that looks
796
+ * right.
797
+ *
798
+ * The value row keeps the `metric-lg` line height in every state, so a tile that is
799
+ * loading, resolved or unavailable occupies the same box and the grid never reflows.
800
+ */
801
+ interface MetricProps {
802
+ label: string;
803
+ /** Ignored while `loading`, and when `unavailable` is set. */
804
+ value?: string | number;
805
+ /** One line under the value: the unit, or what the number counts. */
806
+ caption?: string;
807
+ icon?: IconName;
808
+ loading?: boolean;
809
+ /** Why there is no figure. Replaces the value and is shown in place of the caption. */
810
+ unavailable?: string;
811
+ className?: string;
812
+ }
813
+ declare function Metric({ label, value, caption, icon, loading, unavailable, className, }: MetricProps): react.JSX.Element;
814
+
815
+ /**
816
+ * The modal. Canonical source: .docs/styleguide/04-components.md section 12.
817
+ *
818
+ * The visual half is small; the behaviour is the component. Focus lands on the first
819
+ * interactive element, Tab cycles inside the dialog instead of walking out into the page
820
+ * behind it, Escape closes, closing returns focus to whatever opened it, and page scroll
821
+ * is locked while it is open.
822
+ *
823
+ * `dismissible` is false for destructive dialogs, which is what the guide means by "the
824
+ * backdrop click closes a non-destructive dialog only". Escape still works there: a user
825
+ * who wants out must always have a way out, and cancelling a delete is the safe outcome.
826
+ */
827
+ type ModalWidth = "sm" | "md" | "lg" | "xl";
828
+ interface ModalProps {
829
+ open: boolean;
830
+ onClose: () => void;
831
+ title: string;
832
+ description?: string;
833
+ width?: ModalWidth;
834
+ /** Whether a backdrop click closes. False for destructive confirmations. */
835
+ dismissible?: boolean;
836
+ /** Right-aligned actions: cancel as secondary, confirm last. */
837
+ footer?: ReactNode;
838
+ children?: ReactNode;
839
+ }
840
+ declare function Modal({ open, onClose, title, description, width, dismissible, footer, children, }: ModalProps): react.ReactPortal | null;
841
+
842
+ /**
843
+ * The pagination row under a table.
844
+ * Canonical source: .docs/styleguide/04-components.md section 6.
845
+ *
846
+ * Cursor paging, not page numbers: the guide rules numbered page lists out, and Firestore
847
+ * cannot answer "page 7" anyway without reading the six pages before it. Previous and next
848
+ * are therefore the only moves, and the caller holds the cursors.
849
+ *
850
+ * The range reads "1-25 of 248" when the total is known and "1-25" when it is not. A total
851
+ * is a separate count read that can be refused on its own, and inventing one would mislead
852
+ * the reader about how much is behind the list.
853
+ */
854
+ interface PaginationProps {
855
+ /** 1-based index of the first row on this page. */
856
+ from: number;
857
+ /** 1-based index of the last row on this page. */
858
+ to: number;
859
+ total?: number | undefined;
860
+ hasPrevious: boolean;
861
+ hasNext: boolean;
862
+ onPrevious: () => void;
863
+ onNext: () => void;
864
+ /** Names what is being paged, for the arrows' accessible names. */
865
+ label: string;
866
+ }
867
+ declare function Pagination({ from, to, total, hasPrevious, hasNext, onPrevious, onNext, label, }: PaginationProps): react.JSX.Element;
868
+
869
+ /**
870
+ * The page header every admin screen opens with.
871
+ * Canonical source: .docs/styleguide/03-layout.md section 3.1.
872
+ *
873
+ * It owns the 24px gap to the first section, so a screen never restates it, and it is the
874
+ * only place the breadcrumb may appear. The title is an `h1`: one per document, and the
875
+ * shell around it contributes none.
876
+ *
877
+ * Title and description are strings rather than nodes. Every later screen is generated
878
+ * from the registry, and a header that accepts arbitrary markup is an invitation for one
879
+ * content type to look different from the rest.
880
+ */
881
+ interface PageHeaderProps {
882
+ title: string;
883
+ description?: string;
884
+ /** The trail above the title. The last item is the current page. */
885
+ breadcrumb?: readonly BreadcrumbItem[];
886
+ /** Right side. Primary action last, per the guide. */
887
+ actions?: ReactNode;
888
+ className?: string;
889
+ }
890
+ declare function PageHeader({ title, description, breadcrumb, actions, className, }: PageHeaderProps): react.JSX.Element;
891
+
892
+ /**
893
+ * The panel, in the two forms the system has.
894
+ * Canonical source: .docs/styleguide/03-layout.md sections 4.1, 4.2 and 4.3.
895
+ *
896
+ * Standard is the content container; `raised` is the metric tile. Neither carries a
897
+ * shadow, and the raised one always carries `border-subtle`, so a panel edge stays legible
898
+ * against `surface-base`.
899
+ *
900
+ * A panel is the second and last level of container the system allows. Nesting one inside
901
+ * another inside a third is a design error, not a layout choice.
902
+ */
903
+ type PanelPadding = "none" | "compact" | "standard" | "large";
904
+ interface PanelProps {
905
+ children: ReactNode;
906
+ /**
907
+ * `none` is for a body that supplies its own inset: a table, or a row list separated by
908
+ * `border-subtle`. Padding there would sit on top of the row padding and double it.
909
+ */
910
+ padding?: PanelPadding;
911
+ /** The raised metric panel from section 4.2. */
912
+ raised?: boolean;
913
+ className?: string;
914
+ }
915
+ declare function Panel({ children, padding, raised, className, }: PanelProps): react.JSX.Element;
916
+
917
+ /**
918
+ * The upload progress bar.
919
+ * Canonical source: .docs/styleguide/04-components.md section 16.
920
+ *
921
+ * Uploads are the only thing in the system that gets one. Everywhere else the wait has no
922
+ * measurable length and a skeleton or a spinner is the honest control.
923
+ *
924
+ * The fill width is an inline style rather than a class, because it is a measured value
925
+ * and not a design decision: there is no token for "43 percent", and writing one as an
926
+ * arbitrary Tailwind class would be exactly the ad-hoc styling the lint rule forbids.
927
+ * Every other value on this element is a token.
928
+ */
929
+ interface ProgressProps {
930
+ /** 0 to 1. Clamped, because a transferred byte count can briefly exceed the total. */
931
+ value: number;
932
+ /** Names what is progressing, for a reader who cannot see the bar. */
933
+ label: string;
934
+ className?: string;
935
+ }
936
+ declare function Progress({ value, label, className }: ProgressProps): react.JSX.Element;
937
+
938
+ /**
939
+ * Sections and the vertical rhythm between them.
940
+ * Canonical source: .docs/styleguide/03-layout.md sections 3.1 and 3.2.
941
+ *
942
+ * The rhythm is owned in exactly two places: the page header carries the 24px to the first
943
+ * section, and `PageSections` carries the 32px between two sections. A `Section` therefore
944
+ * has no outer margin of its own, which is what stops a screen from adding one and drifting
945
+ * out of the system.
946
+ */
947
+ declare function PageSections({ children, className, }: {
948
+ children: ReactNode;
949
+ className?: string;
950
+ }): react.JSX.Element;
951
+ interface SectionProps {
952
+ /** Omitted for a section that needs no heading; the rhythm is unchanged either way. */
953
+ heading?: string;
954
+ description?: string;
955
+ /** Section-level controls, right-aligned beside the heading. */
956
+ actions?: ReactNode;
957
+ children: ReactNode;
958
+ className?: string;
959
+ }
960
+ declare function Section({ heading, description, actions, children, className, }: SectionProps): react.JSX.Element;
961
+
962
+ /**
963
+ * The select. Canonical source: .docs/styleguide/04-components.md section 3.3.
964
+ *
965
+ * It is a listbox rather than a native `<select>`, because the guide requires the trigger
966
+ * to match the text input exactly and the open list to be the menu surface from section
967
+ * 11 with a check on the selected option. A native select renders the operating system's
968
+ * popup, which no stylesheet reaches.
969
+ *
970
+ * That trade has a cost: everything the platform gives away for free (focus, typeahead,
971
+ * Escape, activedescendant) has to be implemented here, which is why the keyboard handler
972
+ * is the longest part of the file. A hidden input carries the value so the control still
973
+ * submits inside a plain form.
974
+ */
975
+ interface SelectOption {
976
+ value: string;
977
+ label: string;
978
+ disabled?: boolean;
979
+ }
980
+ interface SelectProps {
981
+ options: readonly SelectOption[];
982
+ value: string | null;
983
+ onChange: (value: string) => void;
984
+ id?: string;
985
+ name?: string;
986
+ placeholder?: string;
987
+ invalid?: boolean;
988
+ disabled?: boolean;
989
+ "aria-describedby"?: string | undefined;
990
+ "aria-label"?: string | undefined;
991
+ }
992
+ declare function Select({ options, value, onChange, id, name, placeholder, invalid, disabled, "aria-describedby": describedBy, "aria-label": ariaLabel, }: SelectProps): react.JSX.Element;
993
+
994
+ /**
995
+ * The search field. Canonical source: .docs/styleguide/04-components.md section 3.2,
996
+ * placed by .docs/styleguide/03-layout.md section 5.1.
997
+ *
998
+ * The text input with a leading `search` icon and a trailing clear button that appears
999
+ * once there is something to clear. It holds no state and runs no query: what a search
1000
+ * means is the screen's business, and the screen is the one that has to be honest about
1001
+ * what its query can match.
1002
+ */
1003
+ interface SearchInputProps {
1004
+ value: string;
1005
+ onChange: (value: string) => void;
1006
+ /** The accessible name. There is no visible label in a toolbar. */
1007
+ label: string;
1008
+ placeholder?: string;
1009
+ disabled?: boolean;
1010
+ /** Id of the caption that states what the search matches. */
1011
+ describedBy?: string | undefined;
1012
+ className?: string;
1013
+ }
1014
+ declare function SearchInput({ value, onChange, label, placeholder, disabled, describedBy, className, }: SearchInputProps): react.JSX.Element;
1015
+
1016
+ /**
1017
+ * The skeleton. Canonical source: .docs/styleguide/04-components.md section 16.
1018
+ *
1019
+ * Skeletons are the default loading treatment and spinners are the exception, so this
1020
+ * component mirrors the shape of what it replaces rather than offering a generic grey
1021
+ * box: a text line is as tall as its type token's line height, a block carries the radius
1022
+ * of the component it stands in for.
1023
+ *
1024
+ * The pulse is behind `motion-safe`, because a reduced-motion user gets the layout
1025
+ * without the animation, which is the whole benefit of a skeleton anyway.
1026
+ */
1027
+ type SkeletonLine = "heading-md" | "heading-sm" | "body" | "body-sm" | "label" | "caption" | "micro" | "metric-lg" | "metric-md";
1028
+ declare const RADII: {
1029
+ readonly xs: "rounded-xs";
1030
+ readonly sm: "rounded-sm";
1031
+ readonly md: "rounded-md";
1032
+ readonly lg: "rounded-lg";
1033
+ readonly full: "rounded-full";
1034
+ };
1035
+ interface SkeletonProps {
1036
+ /** Renders a text line at the given type token's line height and `radius-xs`. */
1037
+ line?: SkeletonLine;
1038
+ /** Ignored when `line` is set, which always uses `radius-xs`. */
1039
+ radius?: keyof typeof RADII;
1040
+ /** Width and, for a block, height. Token utilities only. */
1041
+ className?: string;
1042
+ /**
1043
+ * Announces the wait. Set it once per loading region, not once per skeleton, or a
1044
+ * screen reader reads "Loading" for every line on the page.
1045
+ */
1046
+ label?: string;
1047
+ }
1048
+ declare function Skeleton({ line, radius, className, label }: SkeletonProps): react.JSX.Element;
1049
+
1050
+ /**
1051
+ * The only spinner in the system.
1052
+ *
1053
+ * A spinner is allowed where the final structure is unknown or the wait sits inside a
1054
+ * control: skeletons cover everything else. See .docs/styleguide/04-components.md
1055
+ * section 16.
1056
+ */
1057
+ interface SpinnerProps {
1058
+ /** 14px inside a field, 16px inside a button. The guide allows no other size. */
1059
+ size?: 14 | 16;
1060
+ label?: string;
1061
+ }
1062
+ declare function Spinner({ size, label }: SpinnerProps): react.JSX.Element;
1063
+
1064
+ /**
1065
+ * The switch. Canonical source: .docs/styleguide/04-components.md section 3.6.
1066
+ *
1067
+ * Use it only for a boolean that takes effect the moment it is flipped. A boolean that is
1068
+ * saved with a form is a checkbox, because a switch promises the change already happened
1069
+ * and a pending form has not.
1070
+ *
1071
+ * It is a button with `role="switch"`, not a checkbox: screen readers announce "on" and
1072
+ * "off" for a switch and "checked" for a checkbox, and the difference is the whole point
1073
+ * of the control.
1074
+ */
1075
+ interface SwitchProps {
1076
+ checked: boolean;
1077
+ onChange: (checked: boolean) => void;
1078
+ /** The accessible name. Required, since the track carries no text. */
1079
+ label: string;
1080
+ /** Renders `label` next to the track. Leave false where a field label already names it. */
1081
+ showLabel?: boolean;
1082
+ disabled?: boolean;
1083
+ id?: string;
1084
+ "aria-describedby"?: string | undefined;
1085
+ }
1086
+ declare function Switch({ checked, onChange, label, showLabel, disabled, id, "aria-describedby": describedBy, }: SwitchProps): react.JSX.Element;
1087
+
1088
+ /**
1089
+ * The multi-line text control.
1090
+ * Canonical source: .docs/styleguide/04-components.md section 3.2.
1091
+ *
1092
+ * It repeats the input's box rather than reusing `INPUT_BASE`, because the guide gives the
1093
+ * textarea a different height, block padding and resize behaviour, and a shared base string
1094
+ * plus four overrides is harder to read than the eight declarations it replaces.
1095
+ *
1096
+ * Resize is vertical only: a control that can be dragged wider than its column breaks the
1097
+ * form grid, and no editor has ever wanted that.
1098
+ */
1099
+ interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
1100
+ invalid?: boolean;
1101
+ }
1102
+ declare function Textarea({ invalid, className, readOnly, rows, ...rest }: TextareaProps): react.JSX.Element;
1103
+
1104
+ /**
1105
+ * The table shell. Canonical source: .docs/styleguide/04-components.md section 5.
1106
+ *
1107
+ * A shell, not a data grid: it fixes geometry, density and state styling, and knows
1108
+ * nothing about content types, sorting logic or selection logic. The list view generated
1109
+ * from the registry owns all of that and composes these parts.
1110
+ *
1111
+ * Density is fixed by the guide, so there is no size prop and no comfortable/compact
1112
+ * toggle. The table carries no outer border either: it lives inside a panel and shares
1113
+ * the panel's edge.
1114
+ */
1115
+ interface TableProps {
1116
+ children: ReactNode;
1117
+ /** Names the table for screen readers when no caption is visible. */
1118
+ label?: string;
1119
+ className?: string;
1120
+ }
1121
+ declare function Table({ children, label, className }: TableProps): react.JSX.Element;
1122
+ declare function TableHead({ children }: {
1123
+ children: ReactNode;
1124
+ }): react.JSX.Element;
1125
+ declare function TableBody({ children }: {
1126
+ children: ReactNode;
1127
+ }): react.JSX.Element;
1128
+ type SortDirection = "asc" | "desc";
1129
+ interface TableHeaderCellProps extends Omit<ThHTMLAttributes<HTMLTableCellElement>, "onClick"> {
1130
+ children?: ReactNode;
1131
+ /** Numeric columns are right-aligned so their cells line up under the header. */
1132
+ numeric?: boolean;
1133
+ /** Fixed-width columns: the selection cell and the action cell. */
1134
+ width?: "selection" | "action";
1135
+ /** Passing this makes the header a sort button. `null` is the unsorted state. */
1136
+ sort?: {
1137
+ direction: SortDirection | null;
1138
+ onSort: () => void;
1139
+ };
1140
+ }
1141
+ declare function TableHeaderCell({ children, numeric, width, sort, className, ...rest }: TableHeaderCellProps): react.JSX.Element;
1142
+ interface TableRowProps {
1143
+ children: ReactNode;
1144
+ selected?: boolean;
1145
+ disabled?: boolean;
1146
+ /** Set only when the whole row performs one action; it is what earns the pointer cursor. */
1147
+ onActivate?: () => void;
1148
+ className?: string;
1149
+ }
1150
+ declare function TableRow({ children, selected, disabled, onActivate, className, }: TableRowProps): react.JSX.Element;
1151
+ interface TableCellProps extends TdHTMLAttributes<HTMLTableCellElement> {
1152
+ children?: ReactNode;
1153
+ /** The title column: brighter and medium weight. */
1154
+ primary?: boolean;
1155
+ /** Right-aligned, tabular figures. */
1156
+ numeric?: boolean;
1157
+ /** Muted, tabular figures. */
1158
+ date?: boolean;
1159
+ width?: "selection" | "action";
1160
+ }
1161
+ declare function TableCell({ children, primary, numeric, date, width, className, ...rest }: TableCellProps): react.JSX.Element;
1162
+ /** The empty state spanning the whole table body. */
1163
+ declare function TableEmptyRow({ colSpan, children, }: {
1164
+ colSpan: number;
1165
+ children: ReactNode;
1166
+ }): react.JSX.Element;
1167
+
1168
+ /**
1169
+ * Toasts. Canonical source: .docs/styleguide/04-components.md section 14.
1170
+ *
1171
+ * A provider plus a hook rather than a component a screen renders, because the thing that
1172
+ * raises a toast is usually a save handler three levels down from the region that shows
1173
+ * it, and threading state up through those levels is how toast stacks end up duplicated.
1174
+ *
1175
+ * Error toasts do not auto-dismiss and are announced as alerts. Everything else disappears
1176
+ * after five seconds and is announced politely, so a background save does not interrupt
1177
+ * what the user is reading.
1178
+ */
1179
+ type ToastTone = "success" | "warning" | "error" | "info";
1180
+ interface ToastOptions {
1181
+ title: string;
1182
+ description?: string;
1183
+ tone?: ToastTone;
1184
+ }
1185
+ interface ToastApi {
1186
+ /** Returns the id, so a long-running action can dismiss its own toast when it finishes. */
1187
+ notify: (options: ToastOptions) => string;
1188
+ dismiss: (id: string) => void;
1189
+ }
1190
+ declare function useToast(): ToastApi;
1191
+ /**
1192
+ * The toast API, or null when no provider is mounted.
1193
+ *
1194
+ * For a screen where a toast is the secondary channel rather than the message itself: the
1195
+ * entry editor renders every failure it can attribute on the field that owns it and every
1196
+ * other failure at form level, and raises a toast on top of that. A host that forgot the
1197
+ * provider should lose the toast, not the screen.
1198
+ */
1199
+ declare function useOptionalToast(): ToastApi | null;
1200
+ declare function ToastProvider({ children }: {
1201
+ children: ReactNode;
1202
+ }): react.JSX.Element;
1203
+
1204
+ interface TooltipProps {
1205
+ content: string;
1206
+ /** Placed above by default; `bottom` is for triggers near the top edge of the viewport. */
1207
+ placement?: "top" | "bottom";
1208
+ /** A single element that can receive `aria-describedby`, normally a button. */
1209
+ children: ReactElement<{
1210
+ "aria-describedby"?: string | undefined;
1211
+ }>;
1212
+ }
1213
+ declare function Tooltip({ content, placement, children }: TooltipProps): react.JSX.Element;
1214
+
1215
+ /**
1216
+ * The unsaved-changes guard.
1217
+ *
1218
+ * Leaving an editor with unsaved work has two halves and they are two different
1219
+ * mechanisms, so they are written separately here rather than pretended to be one thing:
1220
+ *
1221
+ * - **Closing the tab or reloading** is `beforeunload`. The browser owns the wording, the
1222
+ * buttons and whether it asks at all; all a page can do is say that it has something to
1223
+ * lose. It is attached only while the form is dirty, because a listener that is always
1224
+ * attached makes every reload of a clean page ask a question nobody needed.
1225
+ * - **Navigating inside the admin** is ours. A sidebar link, the cancel button and any
1226
+ * other in-app route change are intercepted, the user is asked in the admin's own
1227
+ * dialog, and the navigation is resumed or dropped by their answer.
1228
+ *
1229
+ * The done criterion this exists to satisfy is exact: leaving a dirty form warns, leaving
1230
+ * a clean form does not. Which means a save has to make the warning stop. It does, because
1231
+ * `dirty` is a comparison against the values the adapter confirmed (`valuesChanged` in
1232
+ * core/entry-form.ts) and not a flag somebody has to remember to clear: the save writes
1233
+ * the new baseline, the comparison goes false, and both halves detach on the same render.
1234
+ */
1235
+ /**
1236
+ * Warns before the tab closes or reloads while there is unsaved work.
1237
+ *
1238
+ * Standalone, so an editor still guards the unload path in a host that mounted no
1239
+ * provider. The listener exists only while `dirty` is true.
1240
+ */
1241
+ declare function useBeforeUnloadWarning(dirty: boolean): void;
1242
+ interface UnsavedChangesProviderProps {
1243
+ /**
1244
+ * Resumes an intercepted link navigation. The admin cannot know the host's router, and
1245
+ * a host that supplies none falls back to a full document load, which is correct but
1246
+ * slower. The playground passes `router.push`.
1247
+ */
1248
+ navigate?: ((href: string) => void) | undefined;
1249
+ children: ReactNode;
1250
+ }
1251
+ declare function UnsavedChangesProvider({ navigate, children, }: UnsavedChangesProviderProps): react.JSX.Element;
1252
+ /**
1253
+ * Registers a form's dirty state with the guard, and warns on unload while it is set.
1254
+ *
1255
+ * Called by the editor with the same `dirty` the save bar reads, so the two can never
1256
+ * disagree about whether there is anything to lose. Unmounting clears the registration:
1257
+ * a form that is gone has nothing left unsaved, and leaving the flag set would make every
1258
+ * later navigation ask.
1259
+ */
1260
+ declare function useUnsavedChanges(dirty: boolean): void;
1261
+ /**
1262
+ * The gate an in-app navigation goes through.
1263
+ *
1264
+ * Without a provider it is the identity: the navigation happens. A host that mounted no
1265
+ * guard gets no dialog, not a broken button.
1266
+ */
1267
+ declare function useConfirmLeave(): (proceed: () => void) => void;
1268
+
1269
+ /** A database-defined type that failed validation. Surfaced, never silently dropped. */
1270
+ interface InvalidContentType {
1271
+ slug: string;
1272
+ reason: string;
1273
+ definition: unknown;
1274
+ }
1275
+ interface RegistryEntry {
1276
+ type: ContentTypeLike;
1277
+ source: ContentTypeSource;
1278
+ locked: boolean;
1279
+ /**
1280
+ * A database-defined type with the same slug as this code-defined one. Code wins, but the
1281
+ * database copy is kept so the admin can show the collision instead of silently
1282
+ * discarding what an editor created.
1283
+ */
1284
+ conflict?: {
1285
+ source: "database";
1286
+ type: ContentTypeLike;
1287
+ };
1288
+ }
1289
+ interface Registry {
1290
+ readonly types: readonly RegistryEntry[];
1291
+ readonly conflicts: readonly RegistryEntry[];
1292
+ readonly invalid: readonly InvalidContentType[];
1293
+ get(slug: string): RegistryEntry | undefined;
1294
+ require(slug: string): RegistryEntry;
1295
+ has(slug: string): boolean;
1296
+ /** Entries whose winning definition came from this source. */
1297
+ bySource(source: ContentTypeSource): readonly RegistryEntry[];
1298
+ }
1299
+
1300
+ /**
1301
+ * The sidebar as data. Canonical source: .docs/styleguide/03-layout.md section 2.1.
1302
+ *
1303
+ * The whole point of the registry is that nothing in the interface knows the name of a
1304
+ * content type. This builder is the only place navigation is decided, it reads the
1305
+ * registry and `can()`, and it returns plain serializable data so a server component can
1306
+ * compute it and hand it to the client shell. Adding a content type therefore changes
1307
+ * data and nothing else: no route table, no switch, no new file.
1308
+ *
1309
+ * It is deliberately free of React, so the server entry can export it without pulling a
1310
+ * component into a module graph that never renders one.
1311
+ */
1312
+ interface AdminNavItem {
1313
+ /** Stable across renders and unique inside the whole navigation. */
1314
+ key: string;
1315
+ label: string;
1316
+ href: string;
1317
+ icon: IconName;
1318
+ /**
1319
+ * Active on an exact path match only. The dashboard owns the admin root, so every
1320
+ * deeper route would otherwise light it up as well.
1321
+ */
1322
+ exact?: boolean;
1323
+ }
1324
+ interface AdminNavGroup {
1325
+ key: string;
1326
+ /** Absent for the leading group and the static group, which carry no heading. */
1327
+ label?: string;
1328
+ items: readonly AdminNavItem[];
1329
+ }
1330
+
1331
+ interface AdminShellProps {
1332
+ navigation: readonly AdminNavGroup[];
1333
+ user: AuthUser;
1334
+ /** The sidebar footer's menu trigger. The host owns it: the session is the host's. */
1335
+ accountMenu?: ReactNode;
1336
+ children: ReactNode;
1337
+ }
1338
+ declare function AdminShell({ navigation, user, accountMenu, children }: AdminShellProps): react.JSX.Element;
1339
+
1340
+ interface AdminSidebarProps {
1341
+ navigation: readonly AdminNavGroup[];
1342
+ /** The route the browser is on, for the active item. */
1343
+ pathname: string;
1344
+ user: AuthUser;
1345
+ /** The footer row's menu trigger. The host owns it: signing out is the host's session. */
1346
+ accountMenu?: ReactNode;
1347
+ /** Display and position only. The shell decides whether this is a grid column or a drawer. */
1348
+ className?: string;
1349
+ /** Set while the sidebar is the open drawer, so Escape and Tab can be handled there. */
1350
+ panelRef?: Ref<HTMLElement>;
1351
+ /** Called when a navigation item is followed, so the drawer can close behind it. */
1352
+ onNavigate?: () => void;
1353
+ /** Set while the sidebar is the open drawer, to keep Tab inside it. */
1354
+ onKeyDown?: (event: KeyboardEvent<HTMLElement>) => void;
1355
+ }
1356
+ declare function AdminSidebar({ navigation, pathname, user, accountMenu, className, panelRef, onNavigate, onKeyDown, }: AdminSidebarProps): react.JSX.Element;
1357
+
1358
+ /**
1359
+ * The seam between the CMS and its storage.
1360
+ *
1361
+ * Everything above this interface (the admin, the SDK, the registry, the validator) is
1362
+ * written against `CmsAdapter` and knows nothing about Firestore. Everything below it lives
1363
+ * under `src/firebase/**`, which is the only place the Firebase SDK may be imported. If a
1364
+ * screen or an SDK method needs something this interface does not offer, the interface
1365
+ * grows; it is never bypassed.
1366
+ *
1367
+ * Values crossing this boundary are already in their read shape: timestamps are ISO 8601
1368
+ * strings, references are ids, and field values match what `FieldValues` describes. The
1369
+ * converters on the Firestore side own the translation in both directions.
1370
+ */
1371
+ type FilterOperator = "==" | "!=" | "<" | "<=" | ">" | ">=" | "in" | "not-in" | "array-contains" | "array-contains-any";
1372
+ interface EntryFilter {
1373
+ field: string;
1374
+ op: FilterOperator;
1375
+ value: unknown;
1376
+ }
1377
+ interface SortSpec {
1378
+ field: string;
1379
+ direction: "asc" | "desc";
1380
+ }
1381
+ interface ListQuery {
1382
+ filters?: readonly EntryFilter[];
1383
+ sort?: SortSpec;
1384
+ /**
1385
+ * Free text matched against the content type's `titleField`, as a prefix.
1386
+ *
1387
+ * What it does, exactly, because a search box that promises more than it delivers is
1388
+ * worse than one that says what it is: the adapter turns the term into a range scan
1389
+ * (`>= term`, `<= term + U+F8FF`) over the title field. That matches from the beginning
1390
+ * of the title and it is case sensitive, because Firestore compares strings by their
1391
+ * UTF-8 bytes and offers no substring, no fuzzy and no case-insensitive operator.
1392
+ * "Portfolio" is found by "Port", not by "port" and not by "folio".
1393
+ *
1394
+ * Two consequences a caller has to know about:
1395
+ *
1396
+ * - A range filter forces the first ordering onto the field it ranges over, so a search
1397
+ * is always ordered by title ascending and `sort` is ignored while one is active.
1398
+ * - A content type with no `titleField` has nothing to match against, so the adapter
1399
+ * rejects the query instead of silently returning everything. Ask `canSearch()` in
1400
+ * core/list-view.ts before offering the box.
1401
+ */
1402
+ search?: string;
1403
+ /** Page size. The adapter caps it; see DEFAULT_PAGE_SIZE and MAX_PAGE_SIZE. */
1404
+ limit?: number;
1405
+ /** Opaque cursor from a previous page. Never constructed by a caller. */
1406
+ cursor?: string | null;
1407
+ /**
1408
+ * `"any"` reads every status and is the admin default. The public SDK passes
1409
+ * `"published"`, which is what keeps drafts off a customer's website.
1410
+ */
1411
+ status?: EntryStatus | "any";
1412
+ }
1413
+ interface Page<TItem> {
1414
+ items: readonly TItem[];
1415
+ /** Pass back as `ListQuery.cursor` to read the next page. Null when the page is the last. */
1416
+ cursor: string | null;
1417
+ hasMore: boolean;
1418
+ }
1419
+ interface MediaQuery {
1420
+ limit?: number;
1421
+ cursor?: string | null;
1422
+ /** MIME prefix, for example `image/`. */
1423
+ type?: string;
1424
+ }
1425
+ interface MediaInput {
1426
+ name: string;
1427
+ type: string;
1428
+ size: number;
1429
+ url: string;
1430
+ path: string;
1431
+ alt?: string | null;
1432
+ width?: number | null;
1433
+ height?: number | null;
1434
+ }
1435
+ /**
1436
+ * What the media library occupies, summed from the metadata documents.
1437
+ *
1438
+ * `bytes` is the sum of the `size` recorded on each media document, not a reading of the
1439
+ * Storage bucket itself. Everything Husk uploads records its size, so the two agree; an
1440
+ * object placed in the bucket by something other than Husk is not counted, because Husk
1441
+ * has no document for it. Stated rather than hidden: a dashboard figure that silently
1442
+ * means something narrower than its label is worse than no figure.
1443
+ */
1444
+ interface MediaUsage {
1445
+ /** Media documents. */
1446
+ count: number;
1447
+ /** Total bytes across those documents. */
1448
+ bytes: number;
1449
+ }
1450
+ /**
1451
+ * One entry that references a media item.
1452
+ *
1453
+ * `title` is what the warning prints. It is null when the entry's content type declares no
1454
+ * title field, or when the value is empty: the dialog then prints the id, which is the one
1455
+ * name that is always correct.
1456
+ */
1457
+ interface MediaUsageReference {
1458
+ contentType: string;
1459
+ entryId: string;
1460
+ title: string | null;
1461
+ }
1462
+ interface MediaUsageQuery {
1463
+ /** How many referencing entries to name. Defaults to MEDIA_USAGE_LIMIT. */
1464
+ limit?: number;
1465
+ }
1466
+ /** What a reference refresh managed to rewrite, and what it did not. */
1467
+ interface MediaRefreshResult {
1468
+ updated: number;
1469
+ /** Entries whose denormalized url could not be rewritten. Named, never swallowed. */
1470
+ failed: readonly MediaUsageReference[];
1471
+ }
1472
+ interface MediaAdapter {
1473
+ list(query?: MediaQuery): Promise<Page<MediaItem>>;
1474
+ getById(mediaId: string): Promise<MediaItem | null>;
1475
+ getMany(mediaIds: readonly string[]): Promise<readonly MediaItem[]>;
1476
+ /**
1477
+ * Allocates a media id without writing anything.
1478
+ *
1479
+ * The upload path needs the id before the document exists, because the Storage object
1480
+ * path contains it and the bytes are written first. Nothing is reserved on the server:
1481
+ * the id is generated locally, and an id that is never used costs nothing.
1482
+ */
1483
+ reserveId(): string;
1484
+ /**
1485
+ * Writes the metadata document. With `mediaId`, it is written at that id, which is how
1486
+ * an upload's bytes and its document end up naming each other.
1487
+ */
1488
+ create(input: MediaInput, mediaId?: string): Promise<MediaItem>;
1489
+ update(mediaId: string, patch: Partial<MediaInput>): Promise<MediaItem>;
1490
+ delete(mediaId: string): Promise<void>;
1491
+ /**
1492
+ * Entries referencing this media item, so a delete can warn instead of breaking pages.
1493
+ *
1494
+ * Answered from the `mediaRefs` reverse index each entry write maintains, not from a
1495
+ * scan: see core/media-references.ts for what that costs and where it is blind.
1496
+ */
1497
+ usages(mediaId: string, query?: MediaUsageQuery): Promise<readonly MediaUsageReference[]>;
1498
+ /**
1499
+ * Rewrites the denormalized `url` on every entry that references this media item.
1500
+ *
1501
+ * Only replace needs it. Overwriting the bytes of an existing item mints a new download
1502
+ * URL, and the copy of the old one denormalized into each entry would otherwise stay
1503
+ * until that entry is next saved. `mediaId` remains the source of truth either way, so a
1504
+ * refresh that fails degrades a stale convenience rather than breaking a reference.
1505
+ */
1506
+ refreshReferences(mediaId: string, url: string): Promise<MediaRefreshResult>;
1507
+ /**
1508
+ * Count and total size of the whole library, for the dashboard's storage figure.
1509
+ * One aggregate read, not a walk over every page of media.
1510
+ */
1511
+ usage(): Promise<MediaUsage>;
1512
+ }
1513
+ /**
1514
+ * Schema operations act on `schemas/`, never on entries. A locked or code-defined type is
1515
+ * rejected here as well as in the UI, so bypassing the Schema Builder changes nothing.
1516
+ */
1517
+ interface SchemaAdapter {
1518
+ list(): Promise<readonly ContentTypeLike[]>;
1519
+ get(slug: string): Promise<ContentTypeLike | null>;
1520
+ create(type: ContentTypeLike): Promise<ContentTypeLike>;
1521
+ update(slug: string, type: ContentTypeLike): Promise<ContentTypeLike>;
1522
+ delete(slug: string): Promise<void>;
1523
+ /**
1524
+ * Live view of every database-defined type, so the sidebar and the routes follow a
1525
+ * structure change without a rebuild and without a reload.
1526
+ *
1527
+ * One listener for the whole collection, feeding `RegistryStore.setDatabaseTypes`. What
1528
+ * it costs is written down on that interface. `onError` is called rather than thrown,
1529
+ * because a listener that dies silently leaves an admin quietly stale.
1530
+ */
1531
+ watch(listener: (types: readonly ContentTypeLike[]) => void, onError?: (error: Error) => void): () => void;
1532
+ }
1533
+ interface FieldPurgeOptions {
1534
+ /** How many entries to clear in one call. Capped at MAX_FIELD_PURGE. */
1535
+ limit?: number;
1536
+ }
1537
+ interface FieldPurgeResult {
1538
+ /** Entries whose stored value was deleted by this call. */
1539
+ purged: number;
1540
+ /** True when the cap was hit and another call is needed to finish. */
1541
+ remaining: boolean;
1542
+ }
1543
+ /**
1544
+ * The stored values of a **removed** field, which is the only data the Schema Builder is
1545
+ * ever allowed to reach.
1546
+ *
1547
+ * It is a separate interface, and not two more methods on `SchemaAdapter`, so the M8
1548
+ * guardrail is structural rather than a comment: `SchemaAdapter` has no method that can
1549
+ * touch an entry, and this one refuses any key the content type still declares. The Schema
1550
+ * Builder writes schemas; purging is a different, separately named, separately confirmed
1551
+ * operation that happens to be reachable from the same screen.
1552
+ */
1553
+ interface FieldDataAdapter {
1554
+ /**
1555
+ * How many entries of this content type hold a non-null value for this top-level field
1556
+ * key, so the removal warning can say "14 entries" rather than "some entries".
1557
+ *
1558
+ * One count aggregation, not a walk: `where(key, "!=", null)` plus `count()`. Firestore
1559
+ * indexes every top-level field automatically, so no composite index is needed, and an
1560
+ * aggregation is billed per thousand matched index entries rather than per document.
1561
+ *
1562
+ * Two limits, stated rather than hidden. A key nested inside a repeater row is not a
1563
+ * top-level field and cannot be counted this way, so `count` rejects one instead of
1564
+ * answering zero. And an entry written before the field existed simply has no such key,
1565
+ * which Firestore treats as not matching, which is the answer the warning wants.
1566
+ */
1567
+ count(slug: string, key: string): Promise<number>;
1568
+ /**
1569
+ * Deletes the stored values of a removed field. The only destructive schema operation.
1570
+ *
1571
+ * Refused unless the content type lists the key under `removedFields`: a key that is
1572
+ * still live, or that the type has never heard of, is never purged. Admin only, and
1573
+ * bounded per call.
1574
+ */
1575
+ purge(slug: string, key: string, options?: FieldPurgeOptions): Promise<FieldPurgeResult>;
1576
+ }
1577
+ /**
1578
+ * One stored entry as the migration runner sees it: its id, the version it is stamped at,
1579
+ * and its values in their stored form.
1580
+ *
1581
+ * Deliberately not an `Entry`. Reading an entry runs it through the content type's field
1582
+ * specs, and the whole point of a migration is that the stored document does not match
1583
+ * those specs yet, so converting it first would either fail or quietly drop the values the
1584
+ * migration was written to move.
1585
+ */
1586
+ interface StoredEntryRecord {
1587
+ id: string;
1588
+ schemaVersion: number;
1589
+ values: Record<string, unknown>;
1590
+ }
1591
+ interface MigrationWrite {
1592
+ /** The stored version the transform read. The write is refused if it has moved. */
1593
+ expectedVersion: number;
1594
+ nextVersion: number;
1595
+ }
1596
+ /**
1597
+ * The storage half of the migration runner. See core/migrations.ts for the runner itself.
1598
+ *
1599
+ * Kept off `SchemaAdapter` for the same reason `FieldDataAdapter` is: a schema operation
1600
+ * must not be able to reach an entry. A migration reaches every entry of a type, which is
1601
+ * precisely why it is a separately named, separately permissioned port rather than two
1602
+ * more methods on the interface the Schema Builder holds.
1603
+ */
1604
+ interface MigrationAdapter {
1605
+ /** How many entries are stamped below the target version. One aggregation. */
1606
+ countBehind(slug: string, targetVersion: number): Promise<number>;
1607
+ /** A page of entries stamped below the target version, oldest version first. */
1608
+ listBehind(slug: string, targetVersion: number, query?: {
1609
+ limit?: number;
1610
+ }): Promise<Page<StoredEntryRecord>>;
1611
+ /**
1612
+ * Writes migrated values and restamps the entry, but only while the stored version is
1613
+ * still the one the transform read.
1614
+ *
1615
+ * Returns false when it has moved, which is a second runner having got there first and
1616
+ * is not an error. That conditional is what makes a migration safe to run twice and safe
1617
+ * to run from two places at once.
1618
+ */
1619
+ applyMigration(slug: string, entryId: string, values: Record<string, unknown>, write: MigrationWrite): Promise<boolean>;
1620
+ }
1621
+ interface SettingsAdapter {
1622
+ get(key: string): Promise<Record<string, unknown> | null>;
1623
+ set(key: string, value: Record<string, unknown>): Promise<void>;
1624
+ }
1625
+ interface EntryInputData {
1626
+ /** Field values, already validated by the caller and validated again by the adapter. */
1627
+ values: Record<string, unknown>;
1628
+ status?: EntryStatus;
1629
+ }
1630
+ interface CmsAdapter {
1631
+ list(slug: string, query?: ListQuery): Promise<Page<Entry>>;
1632
+ get(slug: string, entryId: string): Promise<Entry | null>;
1633
+ /**
1634
+ * Looks an entry up by the value of the content type's `slug` field, not by document id.
1635
+ *
1636
+ * `status` narrows the lookup at the storage layer rather than after it, and on a public
1637
+ * website that is not an optimization. A visitor's read is unauthenticated, and the
1638
+ * security rules only let an unauthenticated query through when the query itself says
1639
+ * `status == "published"`; a lookup that asked for any status and filtered afterwards
1640
+ * would be refused outright. Defaults to `"any"`, which is the admin's uniqueness check.
1641
+ */
1642
+ getBySlug(slug: string, value: string, options?: Pick<ListQuery, "status">): Promise<Entry | null>;
1643
+ /** Reads the one entry of a singleton. Rejects a collection. */
1644
+ getSingle(slug: string): Promise<Entry | null>;
1645
+ create(slug: string, input: EntryInputData): Promise<Entry>;
1646
+ update(slug: string, entryId: string, input: EntryInputData): Promise<Entry>;
1647
+ delete(slug: string, entryId: string): Promise<void>;
1648
+ /**
1649
+ * How many entries the same narrowing would return. Takes `search` as well, so the
1650
+ * list view's "1-25 of 248" counts the rows the reader is actually paging through.
1651
+ */
1652
+ count(slug: string, query?: Pick<ListQuery, "filters" | "status" | "search">): Promise<number>;
1653
+ readonly media: MediaAdapter;
1654
+ readonly schemas: SchemaAdapter;
1655
+ readonly fieldData: FieldDataAdapter;
1656
+ readonly migrations: MigrationAdapter;
1657
+ readonly settings: SettingsAdapter;
1658
+ }
1659
+
1660
+ /**
1661
+ * What a collection list is, derived from a content type.
1662
+ *
1663
+ * The screen renders this; it never decides it. Columns, the sortable set, the filter
1664
+ * controls and the query that reads a page are all computed here from the schema, which is
1665
+ * what makes a new content type a data change and not a screen change.
1666
+ *
1667
+ * Nothing in this file imports React, Firebase or the field registry, so it is domain
1668
+ * logic: it runs in the node test project, in the SDK, and in a server component.
1669
+ */
1670
+ /** How a column's cells are typeset. The cell itself never inspects the field type. */
1671
+ type ColumnAlign = "text" | "numeric" | "date";
1672
+
1673
+ /**
1674
+ * Bytes as a short human-readable string: `0 B`, `940 B`, `1.5 KB`, `12 MB`.
1675
+ *
1676
+ * One decimal below ten so a small library does not round to a flat `1 MB`, none above it
1677
+ * where the extra digit is noise in a dashboard tile.
1678
+ */
1679
+ declare function formatBytes(bytes: number): string;
1680
+ /**
1681
+ * An ISO 8601 timestamp as a date and time.
1682
+ *
1683
+ * An unparsable value comes back unchanged instead of as `Invalid Date`: a timestamp the
1684
+ * adapter could not convert is a data problem, and showing the raw value is what lets
1685
+ * somebody diagnose it.
1686
+ */
1687
+ declare function formatDateTime(iso: string): string;
1688
+ /**
1689
+ * A date value as a reader sees it, at the precision it was stored with.
1690
+ *
1691
+ * A day-only value is formatted as a day: turning `2026-09-18` into a time would invent a
1692
+ * midnight nobody chose, and would move the date across a timezone boundary.
1693
+ */
1694
+ declare function formatDate(value: string): string;
1695
+ /**
1696
+ * One field value as one table cell, decided by the value it got rather than by the field
1697
+ * type that produced it.
1698
+ *
1699
+ * This is why the list view has no `switch (field.type)`: the column already knows how it
1700
+ * is typeset (core/list-view.ts decides that once, from the schema), and the cell only has
1701
+ * to turn whatever the adapter returned into a line of text. A value the column cannot
1702
+ * render, and a value that was never set, both come back as `null`, which the cell renders
1703
+ * as its "not set" state rather than as a blank nobody can interpret.
1704
+ */
1705
+ declare function formatCellValue(value: unknown, align: ColumnAlign): string | null;
1706
+
1707
+ interface CollectionListProps {
1708
+ adapter: CmsAdapter;
1709
+ type: ContentTypeLike;
1710
+ /** The signed-in user, for the permission answers behind the actions. */
1711
+ user: AuthUser;
1712
+ /** Opens an entry. Absent means the list is read-only in this host. */
1713
+ onOpen?: (entryId: string) => void;
1714
+ /** Starts a new entry. Shown only when `can()` allows creating one. */
1715
+ onCreate?: () => void;
1716
+ /**
1717
+ * Deletes entries. The list never deletes anything itself: it collects the selection,
1718
+ * checks the permission per entry, and hands the ids to the host's confirmation flow.
1719
+ */
1720
+ onDelete?: (entryIds: readonly string[]) => void;
1721
+ /**
1722
+ * Re-reads the current page when it changes.
1723
+ *
1724
+ * A delete happens outside this screen (the confirmation dialog is the host's to mount,
1725
+ * because only the host knows where the flow lands), so the list needs a way to be told
1726
+ * that what it is showing is stale. A changed token re-runs the same query with the same
1727
+ * cursor, which is the honest refresh: remounting would silently drop the reader's
1728
+ * search, filters and page.
1729
+ */
1730
+ refreshToken?: unknown;
1731
+ pageSize?: number;
1732
+ }
1733
+ declare function CollectionList({ adapter, type, user, onOpen, onCreate, onDelete, refreshToken, pageSize, }: CollectionListProps): react.JSX.Element;
1734
+
1735
+ /**
1736
+ * The destructive confirmation for deleting entries.
1737
+ * Canonical source: .docs/styleguide/04-components.md section 12, "Destructive
1738
+ * confirmation".
1739
+ *
1740
+ * One dialog for every content type and for both the row action and the bulk selection,
1741
+ * because the only thing that differs between them is what is printed and how many
1742
+ * documents the adapter is asked to remove.
1743
+ *
1744
+ * What it is deliberate about:
1745
+ *
1746
+ * - **The phrase.** One entry asks for its name (its title, or its id when it has no
1747
+ * usable title); several ask for the count and the content type. `core/delete-
1748
+ * confirmation.ts` decides both and decides what counts as typing them.
1749
+ * - **The way out.** `dismissible` is false, so a stray backdrop click cannot start a
1750
+ * delete flow's only irreversible step. Escape still closes, because cancelling a
1751
+ * delete is the safe outcome and a user must always have a way out.
1752
+ * - **Partial failure.** Several deletes are several writes, and the second can fail
1753
+ * after the first succeeded. What did go through is reported to the caller anyway, so
1754
+ * the list does not keep showing rows that are gone, and the dialog stays open naming
1755
+ * what did not.
1756
+ */
1757
+ interface DeleteEntriesDialogProps {
1758
+ adapter: CmsAdapter;
1759
+ type: ContentTypeLike;
1760
+ /** The entries to delete. Empty means the dialog is closed. */
1761
+ entryIds: readonly string[];
1762
+ onCancel: () => void;
1763
+ /**
1764
+ * The ids the adapter actually removed, and whether that was the whole batch.
1765
+ *
1766
+ * A batch is several writes and the second can fail after the first succeeded, so what
1767
+ * did go through is reported either way: the caller drops those rows, and leaves the
1768
+ * dialog open when `complete` is false, because it is still naming what did not.
1769
+ */
1770
+ onDeleted: (entryIds: readonly string[], complete: boolean) => void;
1771
+ /** The signed-in role, so a refused delete can name who was refused. */
1772
+ role?: Role | undefined;
1773
+ }
1774
+ declare function DeleteEntriesDialog({ adapter, type, entryIds, onCancel, onDeleted, role, }: DeleteEntriesDialogProps): react.JSX.Element | null;
1775
+ /**
1776
+ * Whether this content type may be deleted at all.
1777
+ *
1778
+ * A singleton exists exactly once and its absence is not a state the schema describes, so
1779
+ * it has no delete flow. The domain guard `assertDeletable` refuses the write as well;
1780
+ * this only keeps the admin from offering a button the adapter would reject.
1781
+ */
1782
+ declare function isDeletableType(type: ContentTypeLike): boolean;
1783
+
1784
+ /** Props every admin field input receives. Field types never read from a store directly. */
1785
+ interface FieldInputProps<TValue, TField extends FieldDefinition = FieldDefinition> {
1786
+ /** Dotted path, unique within the form: `title`, `blocks.2.heading`. */
1787
+ path: string;
1788
+ field: TField;
1789
+ value: TValue | null;
1790
+ onChange: (value: TValue | null) => void;
1791
+ onBlur?: (() => void) | undefined;
1792
+ errors: readonly FieldError[];
1793
+ disabled?: boolean | undefined;
1794
+ readonly?: boolean | undefined;
1795
+ }
1796
+ type FieldInputComponent<TValue, TField extends FieldDefinition = FieldDefinition> = ComponentType<FieldInputProps<TValue, TField>>;
1797
+ /** Everything a validator may know without touching the database. */
1798
+ interface ValidateCtx {
1799
+ path: string;
1800
+ contentType: AnyContentTypeShape;
1801
+ /** All values of the entry being validated, for cross-field rules. */
1802
+ entry: Record<string, unknown>;
1803
+ }
1804
+ /**
1805
+ * Recursion, for the two field types that contain other fields.
1806
+ *
1807
+ * `repeater` holds a whole field map, and it cannot convert or name what is inside it
1808
+ * without applying the same field type contract one level down. It must not reach for the
1809
+ * registry itself: the registry imports every spec, so a spec that imports the registry
1810
+ * closes a cycle. The caller that already holds the registry passes the recursion in
1811
+ * instead, and both members below are optional, so a spec that nests nothing ignores them
1812
+ * and a caller that has no registry still gets a working round trip.
1813
+ */
1814
+ interface NestedConverter {
1815
+ toStored(fields: FieldMap, values: Record<string, unknown>): Record<string, unknown>;
1816
+ fromStored(fields: FieldMap, data: Record<string, unknown>): Record<string, unknown>;
1817
+ }
1818
+ /** Names the SDK-facing type of any field definition, one level down. */
1819
+ type TsTypeResolver = (field: FieldDefinition) => string;
1820
+ /**
1821
+ * Layer 5, as a value or as a function of the field.
1822
+ *
1823
+ * A plain string is the common case and stays exactly as it was. A function is what lets a
1824
+ * field type whose SDK type depends on its own options say so: `select` narrows on
1825
+ * `multiple`, `relation` on `many`, and `repeater` names the object its rows actually
1826
+ * hold. `FieldValue<TField>` in types/field.ts already narrows all three for a code-defined
1827
+ * type; this is what codegen can say for a type created in the Schema Builder, where the
1828
+ * field definition exists only at runtime.
1829
+ */
1830
+ type FieldTsType<TField extends FieldDefinition = FieldDefinition> = string | ((field: TField, resolve: TsTypeResolver) => string);
1831
+ /**
1832
+ * How one per-type option is edited in the Schema Builder.
1833
+ *
1834
+ * A control, not a field type: `"choice"` is a fixed list of values, `"content-type"` is a
1835
+ * slug picked from the live registry, `"field-key"` is a sibling field of the same level.
1836
+ * The field editor implements this small vocabulary once, and every field type describes
1837
+ * its own options in it, which is what keeps `switch (field.type)` out of the screen.
1838
+ */
1839
+ type FieldOptionControl = "text" | "number" | "boolean" | "choice" | "select-options" | "content-type" | "field-key" | "string-list" | "fields";
1840
+ interface FieldOptionDescriptor {
1841
+ /** The property this option writes on the field definition. */
1842
+ key: string;
1843
+ label: string;
1844
+ control: FieldOptionControl;
1845
+ help?: string;
1846
+ /** A field of this type is incomplete until the option has a value. */
1847
+ required?: boolean;
1848
+ /** The values a `"choice"` control offers. */
1849
+ choices?: readonly {
1850
+ value: string;
1851
+ label: string;
1852
+ }[];
1853
+ }
1854
+ /**
1855
+ * Layer 7: what the Schema Builder may configure on this field type, and what it starts as.
1856
+ *
1857
+ * It exists so that the field editor is generated from the registry exactly as every other
1858
+ * admin screen is. Without it, one screen would hold a list of which options belong to
1859
+ * which field type, and a field type added later would render as a bare label until
1860
+ * somebody remembered to edit that list.
1861
+ */
1862
+ interface FieldEditorSpec {
1863
+ /** Name shown in the field type picker. */
1864
+ label: string;
1865
+ /** One line, shown under the name in the picker. */
1866
+ description: string;
1867
+ /** The shared validation rules this field type honours. See VALIDATION_RULE_CONTROLS. */
1868
+ rules: readonly (keyof ValidationRules)[];
1869
+ /** The per-type options, in the order the editor shows them. */
1870
+ options: readonly FieldOptionDescriptor[];
1871
+ /**
1872
+ * Everything a new field of this type needs besides `type` and `label`.
1873
+ *
1874
+ * A select with no options and a repeater with no nested fields are both invalid, so a
1875
+ * newly added field cannot start as `{ type, label }` alone.
1876
+ */
1877
+ defaults: Record<string, unknown>;
1878
+ }
1879
+ /**
1880
+ * The seven-layer contract. A field type is not "added" until every member here is
1881
+ * implemented; a partial field type is a field type that behaves differently in the form
1882
+ * than it does on the server.
1883
+ *
1884
+ * 1. `name` schema definition: the discriminator a content type writes
1885
+ * 2. `toStored` /
1886
+ * `fromStored` stored shape: the round trip through Firestore
1887
+ * 3. `Input` admin input component
1888
+ * 4. `validate` validation, run identically by the form and by the adapter
1889
+ * 5. `tsType` SDK-facing type, used by codegen and by the Developer area
1890
+ * 6. `defaultValue` registration data: what a new entry starts with
1891
+ * 7. `editor` Schema Builder data: the options, rules and defaults of the type
1892
+ */
1893
+ interface FieldTypeSpec<TValue, TField extends FieldDefinition = FieldDefinition> {
1894
+ name: FieldType;
1895
+ toStored(value: TValue | null, field: TField, nested?: NestedConverter): unknown;
1896
+ fromStored(raw: unknown, field: TField, nested?: NestedConverter): TValue | null;
1897
+ Input: FieldInputComponent<TValue, TField>;
1898
+ validate(value: TValue | null, rules: ValidationRules, ctx: ValidateCtx, field: TField): FieldError[];
1899
+ tsType: FieldTsType<TField>;
1900
+ defaultValue(field: TField): TValue | null;
1901
+ editor: FieldEditorSpec;
1902
+ }
1903
+ /**
1904
+ * A spec whose value and field types have been erased, for storage in the registry.
1905
+ *
1906
+ * The registry is heterogeneous by construction: one spec per field type, each with a
1907
+ * different value type. The erasure stops at this alias; every consumer goes back through
1908
+ * the typed `FieldTypeSpec` for the field it is actually handling.
1909
+ */
1910
+ type AnyFieldTypeSpec = FieldTypeSpec<any, any>;
1911
+ interface FieldTypeRegistry {
1912
+ get(name: string): AnyFieldTypeSpec | undefined;
1913
+ require(name: string): AnyFieldTypeSpec;
1914
+ has(name: string): boolean;
1915
+ readonly names: readonly FieldType[];
1916
+ }
1917
+
1918
+ /**
1919
+ * What the status control may offer, and to whom.
1920
+ *
1921
+ * Status is the one piece of entry metadata an editor writes, so it is not a display: it
1922
+ * is a set of transitions out of the status the entry is in, each one answered by `can()`
1923
+ * against the entry itself. A denied transition keeps its reason rather than disappearing,
1924
+ * because "you cannot publish this" is information and an empty panel is not.
1925
+ *
1926
+ * Hiding or disabling a transition here is presentation. The adapter validates the write
1927
+ * and the security rules check the same matrix again, so a user who reaches past this list
1928
+ * gains nothing.
1929
+ */
1930
+ interface StatusTransition {
1931
+ to: EntryStatus;
1932
+ /** Imperative label for the control: "Publish", "Unpublish", "Archive". */
1933
+ label: string;
1934
+ /** What it does to the public site, in one sentence. */
1935
+ effect: string;
1936
+ allowed: boolean;
1937
+ /** Why it is not offered. Null when it is. */
1938
+ reason: string | null;
1939
+ }
1940
+
1941
+ /**
1942
+ * The entry editor metadata sidebar.
1943
+ * Canonical source: .docs/styleguide/03-layout.md section 5.2.1.
1944
+ *
1945
+ * Everything on it is owned by the adapter rather than by the form: status, the three
1946
+ * timestamps, and the uid that created the entry. The one control is the status
1947
+ * transition, and it is a control rather than a display because status is the single piece
1948
+ * of entry metadata an editor writes.
1949
+ */
1950
+ /** What the admin can say about a uid. Enough to name someone, and nothing more. */
1951
+ interface AuthorInfo {
1952
+ uid: string;
1953
+ displayName?: string | null;
1954
+ email?: string | null;
1955
+ }
1956
+ /**
1957
+ * Resolves a uid to a person.
1958
+ *
1959
+ * An entry stores who created it as a uid, and a uid is not a name. Turning one into a
1960
+ * name is a read against the user documents, which the `CmsAdapter` does not expose and
1961
+ * the Users area (M9) owns. So it is a seam: a host that can answer supplies this, and a
1962
+ * host that cannot simply does not.
1963
+ */
1964
+ type AuthorLookup = (uid: string) => Promise<AuthorInfo | null>;
1965
+ interface EntryMetadataProps {
1966
+ user: AuthUser;
1967
+ /** Null while the entry has not been created yet. */
1968
+ entry: Entry | null;
1969
+ /** The stored status. The badge reports what is saved, never what is intended. */
1970
+ status: EntryStatus;
1971
+ /**
1972
+ * The status the next save will write, when that is not the stored one. Set only after a
1973
+ * transition was refused by validation, so the editor can see why the badge has not
1974
+ * moved.
1975
+ */
1976
+ pending?: EntryStatus | null;
1977
+ transitions: readonly StatusTransition[];
1978
+ onTransition: (to: EntryStatus) => void;
1979
+ /** A write is in flight, so no transition may start a second one. */
1980
+ busy?: boolean;
1981
+ resolveAuthor?: AuthorLookup | undefined;
1982
+ /**
1983
+ * The destructive actions, below the rule. A seam: the delete flow supplies the button,
1984
+ * and a host without one renders no section rather than a button that does nothing.
1985
+ */
1986
+ actions?: ReactNode;
1987
+ }
1988
+ declare function EntryMetadata({ user, entry, status, pending, transitions, onTransition, busy, resolveAuthor, actions, }: EntryMetadataProps): react.JSX.Element;
1989
+
1990
+ /**
1991
+ * The entry editor, generated from the content type.
1992
+ * Canonical source: .docs/styleguide/03-layout.md section 5.2.
1993
+ *
1994
+ * One screen shape serves creating and editing, because they differ in exactly two things:
1995
+ * whether there is an entry behind the form, and which adapter method the save calls.
1996
+ * Everything else (which fields exist, what they start at, what is valid, what the status
1997
+ * control offers) is read from the schema and from `can()`.
1998
+ *
1999
+ * There is no `switch (field.type)` here and there never may be. A field is rendered by
2000
+ * looking its spec up in the registry and mounting `spec.Input`, so a field type added in
2001
+ * `src/fields/index.ts` works in this form the day it is registered.
2002
+ *
2003
+ * Three things it is deliberate about:
2004
+ *
2005
+ * - **Where an error lands.** The shared validator and the adapter both report a
2006
+ * `FieldError` with a dotted path, and every input is mounted at its path, so an error
2007
+ * is routed to the control that owns it, including one inside a repeater row. Only an
2008
+ * error naming a field this form does not render reaches the form-level message, and
2009
+ * only a failure that is not about a field at all reaches a toast.
2010
+ * - **Which error is newer.** A client error is recomputed on every keystroke; a server
2011
+ * error is a verdict about what was submitted and no keystroke can retract it. See
2012
+ * `mergeIssues` in core/entry-form.ts.
2013
+ * - **Draft versus published.** A draft skips `required` and nothing else, and that is the
2014
+ * same option the adapter passes to the same validator on the write path.
2015
+ *
2016
+ * A singleton is the same screen with two answers changed, not a second screen. It reads
2017
+ * through `getSingle` instead of `get`, and a singleton that was never saved is a form with
2018
+ * no entry behind it rather than a missing-entry state: the document simply does not exist
2019
+ * yet, and the first save is the `create` that brings it into being at the id
2020
+ * `singletonEntryId` derives from the slug. It offers no delete, because `assertDeletable`
2021
+ * refuses one, and no list to go back to, because `assertListable` refuses that too.
2022
+ *
2023
+ * One thing is optimistic and it is named here so the rest is known not to be: a status
2024
+ * transition paints the new badge before the adapter answers, and a failure puts the exact
2025
+ * stored status back. See `optimistic` in EntryForm.
2026
+ */
2027
+ interface EntryEditorProps {
2028
+ adapter: CmsAdapter;
2029
+ type: ContentTypeLike;
2030
+ user: AuthUser;
2031
+ /** Absent for a create screen. Present for an edit screen. */
2032
+ entryId?: string | undefined;
2033
+ /** Called after a successful write, so the host can route from create to edit. */
2034
+ onSaved?: ((entry: Entry, created: boolean) => void) | undefined;
2035
+ onCancel?: (() => void) | undefined;
2036
+ /** The seam the unsaved-changes guard attaches to. */
2037
+ onDirtyChange?: ((dirty: boolean) => void) | undefined;
2038
+ /** The seam the delete flow attaches to. No callback means no destructive action. */
2039
+ onDelete?: ((entryId: string) => void) | undefined;
2040
+ resolveAuthor?: AuthorLookup | undefined;
2041
+ /** Resolves a relation target slug, for the relation picker. */
2042
+ getType?: ((slug: string) => ContentTypeLike | undefined) | undefined;
2043
+ /** The field type registry. Defaults to the one every field type registers into. */
2044
+ registry?: FieldTypeRegistry | undefined;
2045
+ }
2046
+ declare function EntryEditor({ adapter, type, user, entryId, onSaved, onCancel, onDirtyChange, onDelete, resolveAuthor, getType, registry, }: EntryEditorProps): react.JSX.Element;
2047
+
2048
+ interface DashboardProps {
2049
+ adapter: CmsAdapter;
2050
+ /**
2051
+ * The content types this user may read. The caller filters with `can()`; a tile counting
2052
+ * something the adapter will refuse is a broken promise, not a preview.
2053
+ */
2054
+ types: readonly ContentTypeLike[];
2055
+ /** How many recent entries to list. */
2056
+ recentLimit?: number;
2057
+ }
2058
+ declare function Dashboard({ adapter, types, recentLimit }: DashboardProps): react.JSX.Element;
2059
+
2060
+ /**
2061
+ * What the dashboard reads, and how.
2062
+ *
2063
+ * Separated from the component on purpose: this is the only part of the dashboard with a
2064
+ * data path, it goes through `CmsAdapter` and nothing else, and it is testable against a
2065
+ * stub adapter without rendering anything.
2066
+ *
2067
+ * Nothing here knows the name of a content type. The tiles and the recent list
2068
+ * are derived from the types it is handed, so a new content type changes the dashboard by
2069
+ * appearing in the registry and in no other way.
2070
+ */
2071
+ interface DashboardCount {
2072
+ slug: string;
2073
+ label: string;
2074
+ kind: ContentTypeKind;
2075
+ icon: IconName;
2076
+ count: number;
2077
+ }
2078
+ interface DashboardRecentEntry {
2079
+ /** Unique across content types, which an entry id alone is not. */
2080
+ key: string;
2081
+ entryId: string;
2082
+ title: string;
2083
+ typeSlug: string;
2084
+ typeLabel: string;
2085
+ status: EntryStatus;
2086
+ updatedAt: string;
2087
+ }
2088
+ interface DashboardData {
2089
+ counts: readonly DashboardCount[];
2090
+ recent: readonly DashboardRecentEntry[];
2091
+ /**
2092
+ * Null when the adapter could not answer. The tile then says the figure is unavailable
2093
+ * rather than showing a zero that reads like an empty library.
2094
+ */
2095
+ media: MediaUsage | null;
2096
+ }
2097
+ interface LoadDashboardOptions {
2098
+ /** How many recent entries the list shows. */
2099
+ recentLimit?: number;
2100
+ }
2101
+ declare const DEFAULT_RECENT_LIMIT = 8;
2102
+ /**
2103
+ * Reads everything the dashboard shows.
2104
+ *
2105
+ * Counts are one aggregate read per content type, including singletons: a singleton counts
2106
+ * 0 or 1, which is exactly the "is it filled in yet" answer its tile is asking.
2107
+ *
2108
+ * Recent entries are one small page per collection, merged and re-sorted here. The adapter
2109
+ * has no cross-content-type query and should not grow one for a dashboard: entries live in
2110
+ * a subcollection per type, so a single query over all of them would need a collection
2111
+ * group index and a shape the security rules cannot gate per type. Singletons are excluded
2112
+ * because they cannot be listed at all.
2113
+ *
2114
+ * A failure in any count or list rejects, and the screen shows its error state. A
2115
+ * dashboard that silently drops the one content type the reader came to check is worse
2116
+ * than one that says it could not load.
2117
+ */
2118
+ declare function loadDashboard(adapter: CmsAdapter, types: readonly ContentTypeLike[], { recentLimit }?: LoadDashboardOptions): Promise<DashboardData>;
2119
+
2120
+ /**
2121
+ * How the media library starts an upload, without knowing that Storage exists.
2122
+ *
2123
+ * The same seam as `RelationSource` for the relation picker, and for the same reason: the
2124
+ * screen lives under `src/admin/**`, where the Firebase SDK may not be imported, and it
2125
+ * still has to offer progress, cancel and retry. The host builds one with
2126
+ * `createMediaUploader` from `@nonext/husk/firebase` and hands it in.
2127
+ *
2128
+ * Every member below mirrors what `createMediaUpload` already returns, so the real
2129
+ * implementation is a pass-through and a test implementation is a few lines.
2130
+ */
2131
+ type MediaUploadPhase = "idle" | "uploading" | "storing" | "done" | "cancelled" | "failed";
2132
+ interface MediaUploadProgress {
2133
+ phase: MediaUploadPhase;
2134
+ bytesTransferred: number;
2135
+ totalBytes: number;
2136
+ /** 0 to 1. Exactly 1 only once every byte is acknowledged. */
2137
+ fraction: number;
2138
+ /** 1 for the first run, 2 for the first retry, and so on. */
2139
+ attempt: number;
2140
+ }
2141
+ interface MediaUploadHandle {
2142
+ readonly mediaId: string;
2143
+ readonly path: string;
2144
+ readonly fileName: string;
2145
+ progress(): MediaUploadProgress;
2146
+ start(): Promise<MediaItem>;
2147
+ /** Runs it again to the same id and the same path, so remains are overwritten. */
2148
+ retry(): Promise<MediaItem>;
2149
+ cancel(): void;
2150
+ }
2151
+ interface MediaUploadRequest {
2152
+ onProgress?: (progress: MediaUploadProgress) => void;
2153
+ alt?: string | null;
2154
+ }
2155
+ interface MediaUploader {
2156
+ create(file: File, options?: MediaUploadRequest): MediaUploadHandle;
2157
+ /**
2158
+ * New bytes for an item that already exists, keeping its id and its object path.
2159
+ *
2160
+ * The MIME type may not change: the path is fixed for the life of the item and the rules
2161
+ * tie the path's extension to the declared type. The implementation refuses a mismatch
2162
+ * with a sentence rather than letting it surface as a permission error.
2163
+ */
2164
+ replace(existing: MediaItem, file: File, options?: MediaUploadRequest): MediaUploadHandle;
2165
+ }
2166
+
2167
+ /**
2168
+ * The media library, and the body of the media picker.
2169
+ * Canonical source: .docs/styleguide/03-layout.md section 5.3.
2170
+ *
2171
+ * One screen serves both because they differ in exactly two things: whether choosing an
2172
+ * item does anything, and whether a field's `accept` narrows what is offered. Everything
2173
+ * else (the grid, the upload rows, the detail panel, the delete flow) is the same code, so
2174
+ * the picker cannot fall behind the library or offer something the library would not.
2175
+ *
2176
+ * `accept` is honoured here and not only passed along. Every page the picker renders is
2177
+ * filtered through `pickableItems`, so a file of the wrong type is never on screen to be
2178
+ * clicked. The field re-checks the item that comes back, and that check is a backstop for
2179
+ * a picker somebody else supplied, not the thing that catches this one.
2180
+ *
2181
+ * Orphans are surfaced, never swept. "Unused only" runs a usage lookup for each item the
2182
+ * grid has loaded and marks the ones nothing references. Nothing is deleted as a
2183
+ * consequence, and nothing is deleted on a schedule: a file that no entry uses today may
2184
+ * be what tomorrow's entry needs, and that is not a judgement a CMS gets to make.
2185
+ */
2186
+ interface MediaLibraryProps {
2187
+ adapter: CmsAdapter;
2188
+ user: AuthUser;
2189
+ /** Absent means the library is read-only: no upload, no replace. */
2190
+ uploader?: MediaUploader | undefined;
2191
+ /** Picker mode: the MIME patterns this slot accepts. */
2192
+ accept?: readonly string[] | undefined;
2193
+ /** Picker mode: choosing a tile calls this instead of opening the detail panel. */
2194
+ onPick?: ((item: MediaItem) => void) | undefined;
2195
+ /** Names a content type in a usage list. Defaults to the raw slug. */
2196
+ contentTypeLabel?: ((slug: string) => string) | undefined;
2197
+ }
2198
+ declare function MediaLibrary({ adapter, user, uploader, accept, onPick, contentTypeLabel, }: MediaLibraryProps): react.JSX.Element;
2199
+
2200
+ /**
2201
+ * The real media picker, over the adapter and the library UI.
2202
+ *
2203
+ * The `image` and `file` field types have been written against `MediaPickerApi` since M5,
2204
+ * with an absent provider as a deliberate, rendered state. This is the implementation that
2205
+ * makes the seam real:
2206
+ *
2207
+ * - `resolve` is `adapter.media.getMany`. Ids with no document are simply absent from the
2208
+ * result, which is exactly how the control learns a reference is broken.
2209
+ * - `pick` opens the library in a modal and resolves when a tile is chosen, or with null
2210
+ * when the editor closes it. The promise is settled exactly once in either direction,
2211
+ * so a field that awaited it never hangs: the deferred is cleared the moment it is
2212
+ * settled, and closing the modal settles it.
2213
+ *
2214
+ * `accept` is honoured by the library itself, which filters every page it renders through
2215
+ * `pickableItems`. The field re-checks the item that comes back, and that check exists for
2216
+ * a picker somebody else supplied; against this one it can never be the thing that catches
2217
+ * a wrong type, because a wrong type was never on screen.
2218
+ */
2219
+ interface AdminMediaPickerProviderProps {
2220
+ adapter: CmsAdapter;
2221
+ user: AuthUser;
2222
+ /** Absent means the picker can choose but not upload. */
2223
+ uploader?: MediaUploader | undefined;
2224
+ contentTypeLabel?: ((slug: string) => string) | undefined;
2225
+ children: ReactNode;
2226
+ }
2227
+ declare function AdminMediaPickerProvider({ adapter, user, uploader, contentTypeLabel, children, }: AdminMediaPickerProviderProps): react.JSX.Element;
2228
+
2229
+ /**
2230
+ * The destructive confirmation for deleting a media item.
2231
+ * Canonical source: .docs/styleguide/04-components.md section 12, "Destructive
2232
+ * confirmation".
2233
+ *
2234
+ * It differs from the entry version in the one way that matters: a media item can be
2235
+ * referenced from somewhere else, and deleting it breaks whatever referenced it. The
2236
+ * dialog therefore reads the reverse index before it offers the button, and names every
2237
+ * entry it finds. A file with references is not refused, because sometimes removing it is
2238
+ * exactly the intention; it is made impossible to do by accident.
2239
+ *
2240
+ * Two things it says out loud rather than implies:
2241
+ *
2242
+ * - Where the list comes from. The count is the `mediaRefs` index each entry write
2243
+ * maintains, so an entry that has not been saved since the index existed will not be in
2244
+ * it. Claiming an unqualified "nothing uses this" would be a stronger statement than
2245
+ * the data supports.
2246
+ * - What deleting does not do. It removes the library record. The bytes in Storage are a
2247
+ * separate object with its own rules, and the caller is told whether that part also
2248
+ * went through.
2249
+ *
2250
+ * The typed confirmation is the file name, the same mechanic the entry dialog uses.
2251
+ */
2252
+ interface MediaDeleteDialogProps {
2253
+ adapter: CmsAdapter;
2254
+ /** The item to delete. Null means the dialog is closed. */
2255
+ item: MediaItem | null;
2256
+ onCancel: () => void;
2257
+ onDeleted: (mediaId: string) => void;
2258
+ contentTypeLabel?: ((slug: string) => string) | undefined;
2259
+ }
2260
+ declare function MediaDeleteDialog({ adapter, item, onCancel, onDeleted, contentTypeLabel, }: MediaDeleteDialogProps): react.JSX.Element | null;
2261
+
2262
+ /**
2263
+ * One media item, opened from the grid.
2264
+ * Canonical source: .docs/styleguide/03-layout.md section 5.3.
2265
+ *
2266
+ * A modal rather than a third column, because the grid is already the widest thing on the
2267
+ * page and a column would squeeze the tiles below their minimum measure.
2268
+ *
2269
+ * What it is deliberate about:
2270
+ *
2271
+ * - **Alt text is the only editable field.** Everything else about a media item is a fact
2272
+ * about the file: changing the name, the type or the size here would describe bytes
2273
+ * that did not change. Replacing the bytes is a separate, named action.
2274
+ * - **Usage is read once, when the panel opens, and shown whether or not it is zero.**
2275
+ * An orphan is surfaced, never swept: a file nothing references today may be exactly
2276
+ * what tomorrow's entry needs, and the CMS does not get to decide that for an editor.
2277
+ * - **Copy reports what happened.** `navigator.clipboard` is absent outside a secure
2278
+ * context and rejects on a refused permission, so the button says "Copied" only when
2279
+ * the clipboard confirmed it, and points at the URL field otherwise.
2280
+ */
2281
+ interface MediaDetailProps {
2282
+ adapter: CmsAdapter;
2283
+ user: AuthUser;
2284
+ item: MediaItem;
2285
+ onClose: () => void;
2286
+ /** Called with the updated item after alt text is saved. */
2287
+ onUpdated: (item: MediaItem) => void;
2288
+ /** Opens the destructive confirmation. Absent when this account may not delete. */
2289
+ onDelete?: ((item: MediaItem) => void) | undefined;
2290
+ /** Starts a replace. Absent when no uploader was supplied. */
2291
+ onReplace?: ((item: MediaItem) => void) | undefined;
2292
+ /** Names a content type in the usage list. Defaults to the raw slug. */
2293
+ contentTypeLabel?: ((slug: string) => string) | undefined;
2294
+ }
2295
+ declare function MediaDetail({ adapter, user, item, onClose, onUpdated, onDelete, onReplace, contentTypeLabel, }: MediaDetailProps): react.JSX.Element;
2296
+
2297
+ /**
2298
+ * One item in the media grid.
2299
+ * Canonical source: .docs/styleguide/03-layout.md section 5.3.
2300
+ *
2301
+ * The whole tile is one button, not a card with a button in it: the only thing a tile does
2302
+ * is open the item, and a click target that is smaller than the thing it represents is a
2303
+ * click target people miss.
2304
+ *
2305
+ * The preview is the original file. Husk stores originals only and resizes at render time
2306
+ * through `next/image` on a customer's site (see .docs/decisions/2026-09-18-media-variants.md),
2307
+ * so there is no thumbnail to point at here. `loading="lazy"` is what keeps a grid of
2308
+ * originals from being a page of full-size downloads.
2309
+ */
2310
+ interface MediaTileProps {
2311
+ item: MediaItem;
2312
+ selected?: boolean;
2313
+ /** True when a usage check ran and found no entry referencing this item. */
2314
+ unused?: boolean;
2315
+ onOpen: (item: MediaItem) => void;
2316
+ }
2317
+ declare function MediaTile({ item, selected, unused, onOpen, }: MediaTileProps): react.JSX.Element;
2318
+
2319
+ /**
2320
+ * The entries that reference a media item, by name.
2321
+ *
2322
+ * Shared by the detail panel and the delete confirmation, because a warning that lists
2323
+ * three entries and a panel that lists four would be two different claims about the same
2324
+ * question.
2325
+ *
2326
+ * An entry with no usable title prints its id. A content type the registry no longer knows
2327
+ * prints its slug. Neither case is hidden: a reference the admin cannot name is still a
2328
+ * reference, and "one of these is somewhere" is more useful than a shorter list.
2329
+ */
2330
+ interface UsageListProps {
2331
+ usages: readonly MediaUsageReference[];
2332
+ contentTypeLabel?: ((slug: string) => string) | undefined;
2333
+ }
2334
+ declare function UsageList({ usages, contentTypeLabel }: UsageListProps): react.JSX.Element | null;
2335
+
2336
+ /**
2337
+ * Copying a string to the clipboard, and admitting when it did not happen.
2338
+ *
2339
+ * `navigator.clipboard` is not always there. It is undefined outside a secure context,
2340
+ * which includes a customer previewing their admin over plain http on a LAN address, and
2341
+ * `writeText` rejects when the document is not focused or when the browser's permission
2342
+ * prompt is refused. A "Copied" toast in any of those cases is a lie the reader only
2343
+ * discovers after pasting nothing, so the result is reported instead of assumed.
2344
+ *
2345
+ * The fallback is the old `document.execCommand("copy")` path over an off-screen textarea.
2346
+ * It is deprecated and it is still the only thing that works without the async API, so it
2347
+ * is tried second rather than not at all. When both fail the caller is told, and the
2348
+ * library shows the URL in a field the reader can select by hand.
2349
+ */
2350
+ type ClipboardOutcome = "copied" | "unavailable";
2351
+ declare function copyText(text: string): Promise<ClipboardOutcome>;
2352
+
2353
+ /**
2354
+ * The decisions the media library makes that are not rendering.
2355
+ *
2356
+ * They live outside the components so each one can be tested without a DOM, and so the
2357
+ * picker and the standalone library cannot answer the same question two different ways.
2358
+ */
2359
+ /**
2360
+ * The `accept` attribute for the library's file input, narrowed by what a field asks for.
2361
+ *
2362
+ * Two constraints meet here and both are real: `core/media-constraints.ts` says what the
2363
+ * product accepts at all, and a field's `accept` says what this particular slot accepts.
2364
+ * The intersection is what the browser's file chooser should offer. An intersection that
2365
+ * comes out empty falls back to the full product allowlist rather than to `accept=""`,
2366
+ * which browsers read as "anything": a chooser that offers everything and a refusal
2367
+ * afterwards is friendlier than a chooser that offers nothing and explains nothing.
2368
+ */
2369
+ declare function uploadAccept(accept: readonly string[] | undefined): string;
2370
+ /**
2371
+ * The MIME prefix the adapter can narrow its query by, or undefined when it cannot.
2372
+ *
2373
+ * `MediaQuery.type` is a prefix range, which is the only "starts with" Firestore offers, so
2374
+ * it can serve `image/*` and nothing else. A field accepting exactly `image/jpeg`, or a mix
2375
+ * of families, gets no server-side narrowing and is filtered per page instead.
2376
+ *
2377
+ * This is an optimization and never the enforcement. `pickableItems` filters every page it
2378
+ * renders, whatever the query did, which is why a field's own re-check on the way back is
2379
+ * never the thing that catches a wrong type.
2380
+ */
2381
+ declare function queryTypeFilter(accept: readonly string[] | undefined): string | undefined;
2382
+ /**
2383
+ * The items a picker may offer, out of a page it just read.
2384
+ *
2385
+ * The filter is applied to what is rendered rather than to what is chosen, so a field's
2386
+ * `accept` is honoured by the picker rather than merely passed to it. A page that filters
2387
+ * down to nothing is a short page, not an error: the cursor still points past it and the
2388
+ * next page is read on demand.
2389
+ */
2390
+ declare function pickableItems(items: readonly MediaItem[], accept: readonly string[] | undefined): readonly MediaItem[];
2391
+ /** Whether the tile shows an image preview or its concept icon. */
2392
+ declare function isImageItem(item: MediaItem): boolean;
2393
+ /**
2394
+ * Whether this account may delete library items.
2395
+ *
2396
+ * Mirrors `allow delete: if isEditor()` in firestore.rules. Hiding the button is
2397
+ * presentation only; the rules refuse the write regardless of what the UI renders.
2398
+ */
2399
+ declare function canDeleteMedia(user: AuthUser): boolean;
2400
+ /**
2401
+ * Whether this account may put new bytes over an existing item.
2402
+ *
2403
+ * Mirrors `mayWrite` in storage.rules: an editor may overwrite any object, an author only
2404
+ * one whose custom metadata names them, which for a Husk upload is the account that
2405
+ * created the media document.
2406
+ */
2407
+ declare function canReplaceMedia(user: AuthUser, item: MediaItem): boolean;
2408
+ /** `1600 x 900`, or null for an item whose dimensions were never recorded. */
2409
+ declare function formatDimensions(item: MediaItem): string | null;
2410
+ /**
2411
+ * What the usage list says, in one sentence.
2412
+ *
2413
+ * The count is deliberately phrased as "at least" once the list hit its limit, because the
2414
+ * lookup stopped counting there and claiming an exact number it did not count would be the
2415
+ * one thing a delete warning must not do.
2416
+ */
2417
+ declare function describeUsage(usages: readonly MediaUsageReference[], limit?: number): string;
2418
+ /**
2419
+ * The type filter the library toolbar offers.
2420
+ *
2421
+ * Each value is a MIME prefix, which is what `MediaQuery.type` narrows by: a range scan is
2422
+ * the only "starts with" Firestore has. The categories are the ones `ALLOWED_MEDIA_TYPES`
2423
+ * actually contains, so no option can come back empty for a structural reason.
2424
+ */
2425
+ declare const MEDIA_FILTERS: readonly {
2426
+ value: string;
2427
+ label: string;
2428
+ }[];
2429
+
2430
+ /**
2431
+ * A registry that can change while the admin is running.
2432
+ *
2433
+ * Creating a content type in the Schema Builder has to put it in the sidebar, give it
2434
+ * routes and give it working CRUD without a rebuild and without a reload. The code half of
2435
+ * the registry is fixed for the lifetime of the process; only the database half moves, so
2436
+ * this holds the code half and swaps the database half.
2437
+ *
2438
+ * **How the change arrives, and what it costs.** `SchemaAdapter.watch` opens one Firestore
2439
+ * listener on the whole `schemas` collection and hands every snapshot to `setDatabaseTypes`.
2440
+ * One listener per open admin tab. It bills the documents of the initial snapshot once, then
2441
+ * one document read per changed schema document, and nothing at all while nobody is editing
2442
+ * structure. `schemas` holds one small document per database-defined content type, so a
2443
+ * customer with twenty of them pays twenty reads when an admin tab opens.
2444
+ *
2445
+ * The alternative considered was refetching the collection on every navigation inside the
2446
+ * admin. It needs no listener, but it bills the whole collection again on every screen
2447
+ * change, which for one editor clicking through twenty screens in an hour is already an
2448
+ * order of magnitude more reads than the listener, and it is still stale in the tab where
2449
+ * nobody navigated. The listener is both cheaper in the steady state and the only one of
2450
+ * the two that actually refreshes without an interaction. See
2451
+ * .docs/decisions/2026-09-18-schema-field-removal.md.
2452
+ *
2453
+ * A snapshot that produces an identical registry is dropped rather than published, because
2454
+ * Firestore replays a local write as a second snapshot once the server acknowledges it and
2455
+ * a re-render of every admin screen for a change nobody made is a visible cost.
2456
+ */
2457
+ interface RegistryStore {
2458
+ /** The current registry. Replaced, never mutated, so a React snapshot stays stable. */
2459
+ readonly registry: Registry;
2460
+ /**
2461
+ * Swaps the database half. Returns true when the registry actually changed, which is what
2462
+ * the caller subscribes for.
2463
+ */
2464
+ setDatabaseTypes(types: readonly ContentTypeLike[]): boolean;
2465
+ /** Notified after every real change. Returns the unsubscribe. */
2466
+ subscribe(listener: (registry: Registry) => void): () => void;
2467
+ /**
2468
+ * Changes counter, monotonic. `useSyncExternalStore` compares it instead of deep
2469
+ * comparing the registry, and a server render can send it to the client to detect that
2470
+ * the two disagree.
2471
+ */
2472
+ readonly revision: number;
2473
+ }
2474
+
2475
+ /**
2476
+ * The React half of the live registry. The store itself is in core and knows no React.
2477
+ *
2478
+ * This is what makes the M8 promise concrete: a content type created in the Schema Builder
2479
+ * reaches the sidebar, the routes and every screen of every open tab without a rebuild and
2480
+ * without a reload, because the registry the admin renders from is a subscription rather
2481
+ * than a value computed once on the server.
2482
+ *
2483
+ * Firebase does not appear here. `watch` arrives as an argument, exactly as the media
2484
+ * uploader and the relation source do, so nothing under `src/admin/**` imports the SDK.
2485
+ */
2486
+ /** The shape of `SchemaAdapter.watch`, without naming the adapter. */
2487
+ type SchemaWatch = (listener: (types: readonly ContentTypeLike[]) => void, onError?: (error: Error) => void) => () => void;
2488
+ interface LiveRegistry {
2489
+ registry: Registry;
2490
+ /**
2491
+ * Whether the database half has been read at least once.
2492
+ *
2493
+ * It matters for exactly one question, and it is the question a generated admin has to
2494
+ * get right: a slug that is in no content type yet may be an unknown route or may be a
2495
+ * database-defined type whose first snapshot has not landed. Answering 404 before the
2496
+ * listener has spoken would show a not-found screen for a content type that exists.
2497
+ * True immediately when no listener is running, because then there is nothing to wait for.
2498
+ */
2499
+ synced: boolean;
2500
+ /**
2501
+ * The last listener error, or null.
2502
+ *
2503
+ * Surfaced rather than swallowed: a dead listener leaves the admin quietly stale, which
2504
+ * looks exactly like a working admin until somebody wonders where their content type
2505
+ * went.
2506
+ */
2507
+ error: Error | null;
2508
+ }
2509
+ /**
2510
+ * Subscribes to a registry store, and feeds it from a schema listener when one is given.
2511
+ *
2512
+ * `watch` must be stable across renders (memoize it, or take it from a memoized adapter):
2513
+ * a new function every render would close and reopen the Firestore listener every render.
2514
+ */
2515
+ declare function useLiveRegistry(store: RegistryStore, watch?: SchemaWatch): LiveRegistry;
2516
+
2517
+ /**
2518
+ * The Developer area's Content Types list.
2519
+ *
2520
+ * Every content type the admin knows about, from either source, in one table. The two
2521
+ * columns that only exist here are the point of the screen: **source** says whether a
2522
+ * definition lives in git or in the database, and **lock** says whether the Schema Builder
2523
+ * may touch it. Both are answered by the registry, never by the row.
2524
+ *
2525
+ * Two things that are easy to hide and must not be:
2526
+ *
2527
+ * - A slug defined in both sources. Code wins, the database definition is preserved on
2528
+ * `entry.conflict`, and the editor who created it is told it exists, is not live, and
2529
+ * why.
2530
+ * - A stored definition that failed validation. It is in `registry.invalid` rather than in
2531
+ * the table, and a definition that silently disappeared is indistinguishable from one
2532
+ * that was deleted.
2533
+ */
2534
+ interface ContentTypesScreenProps {
2535
+ registry: Registry;
2536
+ adapter: CmsAdapter;
2537
+ user: AuthUser;
2538
+ /** Opens one content type. Read-only for a code-defined one; the screen says so. */
2539
+ onOpen: (slug: string) => void;
2540
+ /** Called with the slug of a freshly created type, so the host can route to it. */
2541
+ onCreated?: (slug: string) => void;
2542
+ /**
2543
+ * A listener error from the live registry, if the host is running one.
2544
+ *
2545
+ * Shown here rather than swallowed, because this screen is where somebody creates a type
2546
+ * and then waits for it to appear.
2547
+ */
2548
+ liveError?: Error | null;
2549
+ }
2550
+ declare function ContentTypesScreen({ registry, adapter, user, onOpen, onCreated, liveError, }: ContentTypesScreenProps): react.JSX.Element;
2551
+
2552
+ /**
2553
+ * One content type in the Schema Builder: its settings, its fields, and what it has lost.
2554
+ *
2555
+ * `isSchemaReadOnly` is the single question this screen asks about whether it may edit
2556
+ * anything, and it is true for every code-sourced type, not only the ones marked `locked`.
2557
+ * A read-only type renders as a read-only type: the lock icon, a banner that says which of
2558
+ * the two reasons applies, no drag handles, no inputs, no remove buttons. The adapter
2559
+ * refuses the same writes independently, so the UI half is presentation and the rule lives
2560
+ * below it.
2561
+ *
2562
+ * Everything else on this screen is generated. The field list, the per-type options and the
2563
+ * validation controls all come from the field type registry, so this file names no field
2564
+ * type.
2565
+ */
2566
+ interface ContentTypeEditorProps {
2567
+ adapter: CmsAdapter;
2568
+ registry: Registry;
2569
+ entry: RegistryEntry;
2570
+ user: AuthUser;
2571
+ /** Where the Content Types list is, for the breadcrumb. */
2572
+ backHref?: string;
2573
+ }
2574
+ declare function ContentTypeEditor({ adapter, registry, entry, user, backHref, }: ContentTypeEditorProps): react.JSX.Element;
2575
+
2576
+ interface CreateContentTypeDialogProps {
2577
+ open: boolean;
2578
+ registry: Registry;
2579
+ adapter: CmsAdapter;
2580
+ onClose: () => void;
2581
+ onCreated: (slug: string) => void;
2582
+ }
2583
+ declare function CreateContentTypeDialog(props: CreateContentTypeDialogProps): react.JSX.Element | null;
2584
+
2585
+ interface PermissionMatrixEditorProps {
2586
+ /** The content type being edited. Only `permissions` and the labels are read. */
2587
+ type: ContentTypeLike;
2588
+ /** True for a code-defined or locked type, and for a user without manage-schema. */
2589
+ readOnly: boolean;
2590
+ /** The next matrix, already normalized. `undefined` means "back to the role defaults". */
2591
+ onChange: (next: PermissionMatrix | undefined) => void;
2592
+ }
2593
+ declare function PermissionMatrixEditor({ type, readOnly, onChange, }: PermissionMatrixEditorProps): react.JSX.Element;
2594
+
2595
+ /**
2596
+ * The field editor as data: the order fields are shown in, what a new field starts as,
2597
+ * whether a key may be used, and which options are still unanswered.
2598
+ *
2599
+ * Nothing here renders and nothing here writes. The Schema Builder screen calls it to
2600
+ * decide what to show and what to enable, and the same functions are callable from a test
2601
+ * or a script without a DOM.
2602
+ *
2603
+ * None of it switches on a field type. The per-type answers come from each field type's own
2604
+ * `editor` spec through the registry, which is why adding a thirteenth field type adds it
2605
+ * to the Schema Builder with no edit in this file and none in any screen.
2606
+ */
2607
+ interface FieldRow {
2608
+ key: string;
2609
+ field: FieldDefinition;
2610
+ }
2611
+ type FieldKeyRejection = "invalid" | "reserved" | "live" | "removed";
2612
+ interface FieldKeyAvailability {
2613
+ available: boolean;
2614
+ reason?: FieldKeyRejection;
2615
+ /** Ready to print, and it names what is in the way. */
2616
+ message?: string;
2617
+ }
2618
+ interface FieldOptionIssue {
2619
+ /** The option key the issue belongs to, so the control can show it. */
2620
+ option: string;
2621
+ message: string;
2622
+ }
2623
+
2624
+ /**
2625
+ * The field editor: one list of fields, reorderable, each expanding into its own options.
2626
+ *
2627
+ * It renders no field type. It renders **descriptors**: every per-type option and every
2628
+ * validation rule on this screen comes from the field type's own `editor` spec through the
2629
+ * registry, and the only switch in this file is over the small control vocabulary those
2630
+ * descriptors are written in. Adding a thirteenth field type therefore adds its options
2631
+ * here with no edit to this file, which is the same rule that makes the list views and the
2632
+ * entry forms generated rather than written.
2633
+ *
2634
+ * Reordering works from the pointer and from the keyboard, and neither is a fallback for
2635
+ * the other: the handle is a real button, the arrow keys move the field, focus travels with
2636
+ * the row because the rows are keyed by field key, and every move is announced.
2637
+ *
2638
+ * A read-only field list has no handles, no inputs and no remove buttons. Not disabled
2639
+ * controls that still look like controls: a code-defined type is not edited here at all,
2640
+ * and the banner above the list says why.
2641
+ */
2642
+ interface FieldRowsProps {
2643
+ rows: readonly FieldRow[];
2644
+ registry: FieldTypeRegistry;
2645
+ /** Relation targets, from the live registry. The editor never reads the registry itself. */
2646
+ contentTypes: readonly SelectOption[];
2647
+ readOnly: boolean;
2648
+ /**
2649
+ * Whether a row can be moved.
2650
+ *
2651
+ * False for a repeater's nested fields: their order has nowhere to be stored, because
2652
+ * `fieldOrder` describes a content type and a nested map is part of one field.
2653
+ */
2654
+ reorderable?: boolean;
2655
+ /** Whether a key may be used here. Differs at the top level and inside a repeater. */
2656
+ checkKey: (key: string) => FieldKeyAvailability;
2657
+ onChange: (rows: readonly FieldRow[]) => void;
2658
+ /**
2659
+ * Removal, when it is not a plain list edit.
2660
+ *
2661
+ * At the top level it never is: removing a field has to go through the warning that
2662
+ * counts the entries holding a value, so the owner handles it. Inside a repeater the row
2663
+ * simply leaves the list.
2664
+ */
2665
+ onRemove?: (key: string) => void;
2666
+ /** Names the group for assistive technology, and the add button. */
2667
+ label: string;
2668
+ issues?: readonly (FieldOptionIssue & {
2669
+ key: string;
2670
+ })[];
2671
+ }
2672
+ declare function FieldRows({ rows, registry, contentTypes, readOnly, reorderable, checkKey, onChange, onRemove, label, issues, }: FieldRowsProps): react.JSX.Element;
2673
+
2674
+ /**
2675
+ * The removal warning, with a number in it.
2676
+ *
2677
+ * "Some entries may hold data" is not a warning, it is a shrug. `fieldData.count` asks
2678
+ * Firestore how many entries of this content type actually hold a value for this key, and
2679
+ * the dialog prints that number, because it is also exactly the number a later purge would
2680
+ * destroy: count and purge describe the same population by construction. See
2681
+ * .docs/decisions/2026-09-18-schema-field-removal.md.
2682
+ *
2683
+ * The promise the copy makes is the one the code keeps: removing a field changes the
2684
+ * definition and touches no entry. Deleting the values is a separate, separately confirmed
2685
+ * action, which is why there is no checkbox here that does it in passing.
2686
+ */
2687
+ interface RemoveFieldDialogProps {
2688
+ adapter: CmsAdapter;
2689
+ type: ContentTypeLike;
2690
+ /** The field to remove. Null means the dialog is closed. */
2691
+ fieldKey: string | null;
2692
+ onCancel: () => void;
2693
+ /** The definition change is the caller's: this dialog reads and confirms, never writes. */
2694
+ onConfirm: (fieldKey: string) => void;
2695
+ }
2696
+ declare function RemoveFieldDialog(props: RemoveFieldDialogProps): react.JSX.Element | null;
2697
+
2698
+ /**
2699
+ * Deleting the stored values of a removed field. The only destructive schema operation.
2700
+ *
2701
+ * It is its own dialog, reached from its own place, with its own typed confirmation. That
2702
+ * separation is the design: removal and purge are two decisions with as much time between
2703
+ * them as the user wants, and no single confirmation can take a field from "this exists" to
2704
+ * "its data is gone".
2705
+ *
2706
+ * One bounded call clears at most `MAX_FIELD_PURGE` entries and reports whether more
2707
+ * remain, so a large purge is resumed rather than run as one unbounded write. The dialog
2708
+ * says how many went and offers the next pass rather than looping silently.
2709
+ */
2710
+ interface PurgeFieldDialogProps {
2711
+ adapter: CmsAdapter;
2712
+ type: ContentTypeLike;
2713
+ /** The removed field whose values are being deleted. Null means the dialog is closed. */
2714
+ fieldKey: string | null;
2715
+ onCancel: () => void;
2716
+ /** Every value is gone: the caller drops the parked definition and saves. */
2717
+ onPurged: (fieldKey: string) => void;
2718
+ }
2719
+ declare function PurgeFieldDialog(props: PurgeFieldDialogProps): react.JSX.Element | null;
2720
+
2721
+ interface ErrorScreenProps {
2722
+ error: ClassifiedError;
2723
+ /** Shown above the panel. Omitted inside a boundary that already sits under a header. */
2724
+ title?: string | undefined;
2725
+ /** Rendered only when the taxonomy says a retry can help. */
2726
+ onRetry?: (() => void) | undefined;
2727
+ /** One action at most, per the empty-state rule: a link back, or a sign-out. */
2728
+ children?: ReactNode;
2729
+ }
2730
+ declare function ErrorScreen({ error, title, onRetry, children }: ErrorScreenProps): react.JSX.Element;
2731
+
2732
+ /**
2733
+ * The body of an admin route's error boundary.
2734
+ * Canonical source: .docs/styleguide/04-components.md section 19.
2735
+ *
2736
+ * A host mounts it from an `error.tsx`, which is where Next puts the boundary; this is the
2737
+ * fallback the boundary renders, classified through the same taxonomy as an error a screen
2738
+ * caught for itself. The host supplies one `error.tsx` per route segment so a failure in
2739
+ * the media library does not blank the whole admin, plus a `global-error.tsx` for the one
2740
+ * case a segment boundary cannot cover: a throw in the root layout itself.
2741
+ *
2742
+ * Two things it deliberately does not do:
2743
+ *
2744
+ * - **It never prints a stack trace or a raw digest string on its own line.** A boundary
2745
+ * that shows a blank page is bad; one that shows a stack is worse, because it looks
2746
+ * like the product broke open. In production Next replaces a server error's message
2747
+ * with a generic one and a digest, and the digest is appended to the sentence as a
2748
+ * reference rather than displayed as an error.
2749
+ * - **It never offers a retry the taxonomy refuses.** `reset` re-renders the segment,
2750
+ * which resolves a dropped connection and does nothing at all for a permission refusal.
2751
+ * Only a `transport` failure gets the button; everything else gets the way back the
2752
+ * host passed in, and the sidebar, which a route boundary keeps.
2753
+ */
2754
+ interface RouteErrorProps {
2755
+ /** Exactly what Next hands an `error.tsx`. */
2756
+ error: Error & {
2757
+ digest?: string;
2758
+ };
2759
+ /** Next's re-render of the failed segment. */
2760
+ reset: () => void;
2761
+ /** Shown above the panel. Name the area, so a boundary says where it fired. */
2762
+ title?: string | undefined;
2763
+ /** The signed-in role, so a permission refusal can name who was refused. */
2764
+ role?: Role | undefined;
2765
+ /** One way out, for a failure a retry cannot fix. A link back to the dashboard. */
2766
+ children?: ReactNode;
2767
+ }
2768
+ declare function RouteError({ error, reset, title, role, children }: RouteErrorProps): react.JSX.Element;
2769
+
2770
+ /**
2771
+ * The 404 inside the admin.
2772
+ *
2773
+ * It renders inside the shell, with the sidebar still there, because a missing admin page
2774
+ * is a wrong turn inside the product and not an exit from it. Dropping someone onto a bare
2775
+ * framework error page takes away the one thing they need, which is a way back.
2776
+ *
2777
+ * Built from the empty state, centered in a panel, per .docs/styleguide/03-layout.md
2778
+ * section 5.5. The description deliberately says nothing about what does exist: a 404 is
2779
+ * also what an unauthenticated probe gets, and a list of real routes is an answer.
2780
+ */
2781
+ interface NotFoundScreenProps {
2782
+ /** One action: a link back to the dashboard. */
2783
+ children?: ReactNode;
2784
+ }
2785
+ declare function NotFoundScreen({ children }: NotFoundScreenProps): react.JSX.Element;
2786
+
2787
+ /**
2788
+ * The Husk login screen.
2789
+ *
2790
+ * It owns no auth. The host passes `onSignIn`, which is expected to reject with an error
2791
+ * whose message can be shown, so the same screen serves the emulator, a real project, and
2792
+ * a test double without branching.
2793
+ *
2794
+ * Errors arrive from two directions and are rendered differently on purpose: a missing
2795
+ * field is the field's problem, a refused credential is the form's.
2796
+ */
2797
+ interface LoginCredentials {
2798
+ email: string;
2799
+ password: string;
2800
+ }
2801
+ interface LoginScreenProps {
2802
+ onSignIn: (credentials: LoginCredentials) => Promise<void>;
2803
+ /** Set when the guard bounced an expired session here, so the user is told why. */
2804
+ expired?: boolean;
2805
+ }
2806
+ declare function LoginScreen({ onSignIn, expired }: LoginScreenProps): react.JSX.Element;
2807
+
2808
+ interface UnauthorizedScreenProps {
2809
+ /** The permission that was denied, used only to phrase the sentence. */
2810
+ action?: Action;
2811
+ /** The signed-in role, so the user can tell an access problem from a wrong account. */
2812
+ role?: string;
2813
+ /** One action at most: a link back, or a sign-out. */
2814
+ children?: ReactNode;
2815
+ }
2816
+ declare function UnauthorizedScreen({ action, role, children }: UnauthorizedScreenProps): react.JSX.Element;
2817
+
2818
+ /**
2819
+ * Where the `relation` field type gets its entries.
2820
+ *
2821
+ * The field type never touches Firestore, and it never touches the `CmsAdapter` directly
2822
+ * either: it asks this interface, and the admin supplies an implementation. That is what
2823
+ * lets the picker be rendered in a test, in the Schema Builder preview, or against a
2824
+ * different backend without the field type knowing.
2825
+ */
2826
+ /** One row in the picker: the entry as it is named on screen, not the entry itself. */
2827
+ interface RelationOption {
2828
+ id: string;
2829
+ title: string;
2830
+ status: EntryStatus;
2831
+ }
2832
+ interface RelationSearchRequest {
2833
+ contentType: string;
2834
+ /** Free text. An empty string asks for the first page unfiltered. */
2835
+ search: string;
2836
+ limit?: number;
2837
+ cursor?: string | null;
2838
+ }
2839
+ interface RelationTypeInfo {
2840
+ /** Singular label of the target type, for "Choose an Article". */
2841
+ label: string;
2842
+ /**
2843
+ * Whether `search` narrows the result server-side. False when the target type declares
2844
+ * no `titleField`, because there is then no field to match against and the picker must
2845
+ * say so rather than silently ignore what was typed.
2846
+ */
2847
+ searchable: boolean;
2848
+ /** False when the schema names a content type that does not exist. */
2849
+ known: boolean;
2850
+ }
2851
+ interface RelationSource {
2852
+ describe(contentType: string): RelationTypeInfo;
2853
+ search(request: RelationSearchRequest): Promise<Page<RelationOption>>;
2854
+ /**
2855
+ * Resolves stored ids to their display titles. Ids with no entry are absent from the
2856
+ * result, which is how the control learns a reference is broken.
2857
+ */
2858
+ resolve(contentType: string, ids: readonly string[]): Promise<readonly RelationOption[]>;
2859
+ }
2860
+ interface RelationSourceOptions {
2861
+ adapter: CmsAdapter;
2862
+ /** The content type registry, as a lookup. Unknown slugs return undefined. */
2863
+ getType: (slug: string) => ContentTypeLike | undefined;
2864
+ }
2865
+ /**
2866
+ * The real source, over the adapter.
2867
+ *
2868
+ * Two things it deliberately does not do.
2869
+ *
2870
+ * It never lists a whole content type. Every read is one bounded page with a cursor, so a
2871
+ * picker over a type with fifty thousand entries costs the same as one over ten. The
2872
+ * search is a prefix range on the type's `titleField`, which Firestore answers from the
2873
+ * automatic single-field index; it is therefore case-sensitive and matches from the start
2874
+ * of the title, and `describe()` reports when it cannot narrow at all so the picker can say
2875
+ * so instead of appearing to ignore the box.
2876
+ *
2877
+ * It never resolves more ids than it was given. A single relation asks about one id and a
2878
+ * `many` relation asks about the ids already stored on the entry, which the field's
2879
+ * `maxLength` bounds. Nothing here fans out per customer project.
2880
+ */
2881
+ declare function relationSourceFromAdapter({ adapter, getType, }: RelationSourceOptions): RelationSource;
2882
+
2883
+ interface RelationSourceProviderProps {
2884
+ value: RelationSource;
2885
+ children: ReactNode;
2886
+ }
2887
+ declare function RelationSourceProvider({ value, children }: RelationSourceProviderProps): react.JSX.Element;
2888
+ declare function useRelationSource(): RelationSource | null;
2889
+
2890
+ /**
2891
+ * What a field input may know about the form around it.
2892
+ *
2893
+ * `FieldInputProps` deliberately carries only one field's value, because a field type that
2894
+ * reaches into the form is a field type that cannot be rendered anywhere else. Two field
2895
+ * types genuinely need more than their own value: `slug` derives from a sibling, and any
2896
+ * field carrying `unique` needs someone who can ask the database. Both arrive through this
2897
+ * context rather than through props, so every other field type keeps its narrow surface.
2898
+ *
2899
+ * The context has a working default: no sibling values, no uniqueness check. A field type
2900
+ * rendered without a provider degrades to manual entry rather than throwing.
2901
+ */
2902
+ /** Resolves to `true` when no other entry of this content type holds the value. */
2903
+ type UniqueCheck = (path: string, value: string) => Promise<boolean>;
2904
+ interface FieldFormContextValue {
2905
+ /** Current values of the entry being edited, keyed by field key. */
2906
+ values: Record<string, unknown>;
2907
+ /** The content type's fields, so a field can name the sibling it derives from. */
2908
+ fields?: FieldMap | undefined;
2909
+ checkUnique?: UniqueCheck | undefined;
2910
+ }
2911
+ interface FieldFormProviderProps {
2912
+ value: FieldFormContextValue;
2913
+ children: ReactNode;
2914
+ }
2915
+ declare function FieldFormProvider({ value, children }: FieldFormProviderProps): react.JSX.Element;
2916
+ declare function useFieldForm(): FieldFormContextValue;
2917
+
2918
+ /**
2919
+ * The label, help text and error wrapper every field type renders.
2920
+ *
2921
+ * It is one component rather than five copies of the same six lines, so the composition
2922
+ * from .docs/styleguide/04-components.md section 3.1 cannot drift between field types.
2923
+ * Only the control inside it differs.
2924
+ *
2925
+ * The first error wins: the guide allows exactly one sentence under a field, and a field
2926
+ * that is both too short and badly formatted is not clearer for saying so twice.
2927
+ */
2928
+ interface FieldShellProps {
2929
+ field: FieldDefinition;
2930
+ errors: readonly FieldError[];
2931
+ /** Replaces `field.description`, for a field type that has something better to say. */
2932
+ help?: string | undefined;
2933
+ children: FieldProps["children"];
2934
+ }
2935
+ declare function FieldShell({ field, errors, help, children }: FieldShellProps): react.JSX.Element;
2936
+
2937
+ /**
2938
+ * The seam between the `image` and `file` field types and the media library.
2939
+ *
2940
+ * The library itself is M7. The field types are M5, and they must be complete before it
2941
+ * exists, so they are written against this interface rather than against Storage, the
2942
+ * `MediaAdapter`, or a screen. Two consequences, both deliberate:
2943
+ *
2944
+ * 1. The field type never learns that Firebase exists. It asks for an item by id and asks
2945
+ * someone else to open a picker; whoever supplies the context decides how.
2946
+ * 2. Until M7 supplies one, the context is absent, and the control renders the
2947
+ * "library not available" state instead of a dead button. A stored reference is still
2948
+ * shown and still saved, so an entry edited in that window loses nothing.
2949
+ */
2950
+ interface MediaPickRequest {
2951
+ /** MIME patterns the field accepts, for example `["image/*"]`. Absent means anything. */
2952
+ accept?: readonly string[] | undefined;
2953
+ }
2954
+ interface MediaPickerApi {
2955
+ /**
2956
+ * Resolves stored references. Ids with no media document are simply absent from the
2957
+ * result, which is how the control learns a reference is broken.
2958
+ */
2959
+ resolve(mediaIds: readonly string[]): Promise<readonly MediaItem[]>;
2960
+ /** Opens the library and resolves to the chosen item, or null when the editor cancels. */
2961
+ pick(request: MediaPickRequest): Promise<MediaItem | null>;
2962
+ }
2963
+ interface MediaPickerProviderProps {
2964
+ value: MediaPickerApi;
2965
+ children: ReactNode;
2966
+ }
2967
+ declare function MediaPickerProvider({ value, children }: MediaPickerProviderProps): react.JSX.Element;
2968
+ /** The installed picker, or null while the media library has not been mounted. */
2969
+ declare function useMediaPicker(): MediaPickerApi | null;
2970
+ /**
2971
+ * What the control knows about the item behind the stored reference.
2972
+ *
2973
+ * `missing` and `loading` are separate states for one reason: a control that showed
2974
+ * "missing" while the lookup was still in flight would accuse every reference of being
2975
+ * broken for as long as the network takes.
2976
+ */
2977
+ type MediaLookup = {
2978
+ kind: "empty";
2979
+ } | {
2980
+ kind: "unavailable";
2981
+ } | {
2982
+ kind: "loading";
2983
+ } | {
2984
+ kind: "found";
2985
+ item: MediaItem;
2986
+ } | {
2987
+ kind: "missing";
2988
+ };
2989
+ /**
2990
+ * Resolves one stored reference to its media item.
2991
+ *
2992
+ * The resolved item is never written back into the value. A lookup is a read, and a field
2993
+ * whose lookup failed must save exactly what it was given: see the `missing` state in
2994
+ * `media-input.tsx`, which keeps the reference and never calls `onChange`.
2995
+ */
2996
+ declare function useMediaLookup(reference: MediaReference | null): MediaLookup;
2997
+
2998
+ /**
2999
+ * The one registration point.
3000
+ *
3001
+ * The admin looks a field up here and renders `spec.Input`; it never switches on a field
3002
+ * name. Adding a field type means adding it to this array and nowhere else, and a screen
3003
+ * that needs `switch (field.type)` is telling you the abstraction has failed.
3004
+ *
3005
+ * Every field type the MVP promises is now here, which is what makes the rule checkable
3006
+ * rather than aspirational.
3007
+ *
3008
+ * `repeater` imports this module back, because it renders its nested fields through the
3009
+ * registry. The cycle is deliberate and safe: see the note in `repeater/input.tsx`.
3010
+ */
3011
+ declare const fieldTypes: FieldTypeRegistry;
3012
+
3013
+ export { AdminMediaPickerProvider, type AdminMediaPickerProviderProps, AdminShell, type AdminShellProps, AdminSidebar, type AdminSidebarProps, type AuthorInfo, type AuthorLookup, Avatar, type AvatarProps, type AvatarSize, Badge, type BadgeProps, type BadgeVariant, Breadcrumb, type BreadcrumbItem, type BreadcrumbProps, Button, type ButtonProps, type ButtonSize, type ButtonVariant, Checkbox, type CheckboxProps, type ClipboardOutcome, CollectionList, type CollectionListProps, ContentTypeEditor, type ContentTypeEditorProps, ContentTypesScreen, type ContentTypesScreenProps, CreateContentTypeDialog, type CreateContentTypeDialogProps, DEFAULT_RECENT_LIMIT, Dashboard, type DashboardCount, type DashboardData, type DashboardProps, type DashboardRecentEntry, DeleteEntriesDialog, type DeleteEntriesDialogProps, ENTRY_STATUS_LABEL, ENTRY_STATUS_VARIANT, EmptyState, type EmptyStateProps, EntryEditor, type EntryEditorProps, EntryMetadata, type EntryMetadataProps, ErrorCapture, type ErrorCaptureProps, ErrorNotice, type ErrorNoticeProps, ErrorScreen, type ErrorScreenProps, Field, type FieldFormContextValue, FieldFormProvider, type FieldFormProviderProps, type FieldProps, FieldRows, type FieldRowsProps, FieldShell, type FieldShellProps, FormError, ICONS, Icon, IconButton, type IconButtonProps, type IconName, type IconProps, type IconSize, type LiveRegistry, type LoadDashboardOptions, type LoginCredentials, LoginScreen, type LoginScreenProps, MEDIA_FILTERS, MediaDeleteDialog, type MediaDeleteDialogProps, MediaDetail, type MediaDetailProps, MediaLibrary, type MediaLibraryProps, type MediaLookup, type MediaPickRequest, type MediaPickerApi, MediaPickerProvider, type MediaPickerProviderProps, MediaTile, type MediaTileProps, type MediaUploadHandle, type MediaUploadPhase, type MediaUploadProgress, type MediaUploadRequest, type MediaUploader, Menu, type MenuEntry, type MenuProps, Metric, type MetricProps, Modal, type ModalProps, type ModalWidth, type MorphIconName, MorphingIcon, type MorphingIconProps, NotFoundScreen, type NotFoundScreenProps, PageHeader, type PageHeaderProps, PageSections, Pagination, type PaginationProps, Panel, type PanelPadding, type PanelProps, PermissionMatrixEditor, type PermissionMatrixEditorProps, Progress, type ProgressProps, PurgeFieldDialog, type PurgeFieldDialogProps, type RelationOption, type RelationSearchRequest, type RelationSource, type RelationSourceOptions, RelationSourceProvider, type RelationSourceProviderProps, type RelationTypeInfo, RemoveFieldDialog, type RemoveFieldDialogProps, RouteError, type RouteErrorProps, type SchemaWatch, SearchInput, type SearchInputProps, Section, type SectionProps, Select, type SelectOption, type SelectProps, Skeleton, type SkeletonLine, type SkeletonProps, type SortDirection, Spinner, type SpinnerProps, StatusDot, type StatusDotProps, type StatusTone, Switch, type SwitchProps, Table, TableBody, TableCell, type TableCellProps, TableEmptyRow, TableHead, TableHeaderCell, type TableHeaderCellProps, type TableProps, TableRow, type TableRowProps, TextInput, type TextInputProps, Textarea, type TextareaProps, type ToastApi, type ToastOptions, ToastProvider, type ToastTone, Tooltip, type TooltipProps, UnauthorizedScreen, type UnauthorizedScreenProps, type UniqueCheck, UnsavedChangesProvider, type UnsavedChangesProviderProps, UsageList, type UsageListProps, canDeleteMedia, canReplaceMedia, copyText, describeUsage, fieldTypes, formatBytes, formatCellValue, formatDate, formatDateTime, formatDimensions, initials, isDeletableType, isImageItem, loadDashboard, pickableItems, queryTypeFilter, relationSourceFromAdapter, uploadAccept, useBeforeUnloadWarning, useConfirmLeave, useFieldForm, useLiveRegistry, useMediaLookup, useMediaPicker, useOptionalToast, useRelationSource, useToast, useUnsavedChanges };