canopycms 0.0.68-int.113 → 0.0.68-int.114

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 (170) hide show
  1. package/dist/ai/generate.d.ts +4 -4
  2. package/dist/ai/generate.js +75 -14
  3. package/dist/ai/types.d.ts +12 -0
  4. package/dist/api/branch.js +13 -13
  5. package/dist/auth/cache.d.ts +1 -1
  6. package/dist/auth/cache.js +1 -1
  7. package/dist/auth/file-based-auth-cache.d.ts +8 -0
  8. package/dist/auth/file-based-auth-cache.js +11 -0
  9. package/dist/authorization/branch.d.ts +1 -1
  10. package/dist/authorization/branch.js +2 -1
  11. package/dist/cli/cli.js +160 -28
  12. package/dist/cli/generate-ai-content.js +156 -24
  13. package/dist/content-reader.js +2 -1
  14. package/dist/context.js +2 -1
  15. package/dist/editor/BranchManager.js +13 -17
  16. package/dist/editor/CommentsPanel.js +7 -6
  17. package/dist/editor/Editor.js +152 -30
  18. package/dist/editor/EditorAuthGate.js +2 -2
  19. package/dist/editor/EditorPanes.d.ts +10 -0
  20. package/dist/editor/EditorPanes.js +32 -8
  21. package/dist/editor/FormRenderer.d.ts +10 -0
  22. package/dist/editor/FormRenderer.js +92 -73
  23. package/dist/editor/PreviewFrame.d.ts +3 -1
  24. package/dist/editor/PreviewFrame.js +72 -9
  25. package/dist/editor/admin/SystemHealthPanel.js +27 -24
  26. package/dist/editor/branch-status.d.ts +16 -0
  27. package/dist/editor/branch-status.js +28 -0
  28. package/dist/editor/comments/BranchComments.js +1 -1
  29. package/dist/editor/comments/EntryComments.d.ts +3 -0
  30. package/dist/editor/comments/EntryComments.js +3 -3
  31. package/dist/editor/comments/FieldWrapper.d.ts +3 -0
  32. package/dist/editor/comments/FieldWrapper.js +2 -2
  33. package/dist/editor/comments/InlineCommentThread.js +2 -1
  34. package/dist/editor/comments/ThreadCarousel.js +2 -2
  35. package/dist/editor/components/ConfirmDeleteModal.js +1 -1
  36. package/dist/editor/components/EditorCrashScreen.js +1 -1
  37. package/dist/editor/components/EditorHeader.d.ts +2 -0
  38. package/dist/editor/components/EditorHeader.js +15 -36
  39. package/dist/editor/components/EditorSidebar.d.ts +5 -6
  40. package/dist/editor/components/EditorSidebar.js +2 -2
  41. package/dist/editor/components/EntryCreateModal.js +1 -1
  42. package/dist/editor/components/NoEditPermissionNotice.d.ts +3 -2
  43. package/dist/editor/components/NoEditPermissionNotice.js +3 -2
  44. package/dist/editor/components/ReadOnlyDraftNotice.d.ts +11 -0
  45. package/dist/editor/components/ReadOnlyDraftNotice.js +12 -0
  46. package/dist/editor/components/RenameEntryModal.js +1 -1
  47. package/dist/editor/components/StagedChangesDrawer.js +3 -3
  48. package/dist/editor/components/UndoToastMessage.d.ts +14 -0
  49. package/dist/editor/components/UndoToastMessage.js +9 -0
  50. package/dist/editor/components/UserBadge.js +9 -5
  51. package/dist/editor/copy.d.ts +14 -0
  52. package/dist/editor/copy.js +14 -0
  53. package/dist/editor/fields/BlockField.d.ts +5 -0
  54. package/dist/editor/fields/BlockField.js +38 -32
  55. package/dist/editor/fields/CodeField.d.ts +2 -0
  56. package/dist/editor/fields/CodeField.js +5 -2
  57. package/dist/editor/fields/DateTimeField.d.ts +2 -0
  58. package/dist/editor/fields/DateTimeField.js +5 -2
  59. package/dist/editor/fields/FieldCrashFallback.d.ts +3 -3
  60. package/dist/editor/fields/FieldCrashFallback.js +4 -4
  61. package/dist/editor/fields/FieldLabel.d.ts +24 -0
  62. package/dist/editor/fields/FieldLabel.js +9 -0
  63. package/dist/editor/fields/ImageField.d.ts +4 -0
  64. package/dist/editor/fields/ImageField.js +17 -8
  65. package/dist/editor/fields/InlineGroupField.js +4 -3
  66. package/dist/editor/fields/MarkdownField.d.ts +6 -0
  67. package/dist/editor/fields/MarkdownField.js +41 -11
  68. package/dist/editor/fields/MdxImageDialog.js +1 -1
  69. package/dist/editor/fields/NumberField.d.ts +2 -0
  70. package/dist/editor/fields/NumberField.js +4 -2
  71. package/dist/editor/fields/NumberListField.d.ts +2 -0
  72. package/dist/editor/fields/NumberListField.js +4 -2
  73. package/dist/editor/fields/ObjectField.d.ts +1 -0
  74. package/dist/editor/fields/ObjectField.js +5 -4
  75. package/dist/editor/fields/ReferenceField.d.ts +2 -0
  76. package/dist/editor/fields/ReferenceField.js +9 -3
  77. package/dist/editor/fields/SelectField.d.ts +2 -0
  78. package/dist/editor/fields/SelectField.js +8 -2
  79. package/dist/editor/fields/StringListField.d.ts +2 -0
  80. package/dist/editor/fields/StringListField.js +5 -2
  81. package/dist/editor/fields/TextField.d.ts +2 -0
  82. package/dist/editor/fields/TextField.js +5 -2
  83. package/dist/editor/fields/ToggleField.d.ts +2 -0
  84. package/dist/editor/fields/ToggleField.js +6 -3
  85. package/dist/editor/fields/entry-link/InsertEntryLink.js +1 -1
  86. package/dist/editor/fields/list-item-keys.d.ts +8 -0
  87. package/dist/editor/fields/list-item-keys.js +50 -0
  88. package/dist/editor/group-manager/GroupCard.js +1 -1
  89. package/dist/editor/group-manager/GroupForm.js +1 -1
  90. package/dist/editor/group-manager/InternalGroupsTab.js +1 -1
  91. package/dist/editor/group-manager/MemberList.js +1 -1
  92. package/dist/editor/group-manager/hooks/useGroupState.d.ts +1 -0
  93. package/dist/editor/group-manager/hooks/useGroupState.js +1 -1
  94. package/dist/editor/group-manager/index.js +26 -2
  95. package/dist/editor/hooks/useBranchActions.js +2 -2
  96. package/dist/editor/hooks/useBranchManager.js +36 -22
  97. package/dist/editor/hooks/useDraftManager.d.ts +8 -0
  98. package/dist/editor/hooks/useDraftManager.js +27 -5
  99. package/dist/editor/hooks/useEditorLayout.d.ts +45 -3
  100. package/dist/editor/hooks/useEditorLayout.js +97 -7
  101. package/dist/editor/hooks/useUserMetadata.js +12 -1
  102. package/dist/editor/media/AssetCard.js +1 -1
  103. package/dist/editor/media/CropStep.js +6 -1
  104. package/dist/editor/media/MediaLibraryBody.js +3 -3
  105. package/dist/editor/permission-manager/PermissionEditor.js +3 -3
  106. package/dist/editor/permission-manager/index.js +1 -1
  107. package/dist/editor/preview-bridge.js +2 -2
  108. package/dist/editor/schema-editor/CollectionEditor.js +1 -1
  109. package/dist/editor/schema-editor/EntryTypeEditor.js +1 -1
  110. package/dist/editor/theme.d.ts +1 -1
  111. package/dist/editor/theme.js +11 -11
  112. package/dist/editor/undo-removal.d.ts +44 -0
  113. package/dist/editor/undo-removal.js +129 -0
  114. package/dist/editor/user-display.d.ts +4 -0
  115. package/dist/editor/user-display.js +5 -0
  116. package/dist/editor/utils/confirm-modal.d.ts +8 -0
  117. package/dist/editor/utils/confirm-modal.js +12 -0
  118. package/dist/entry-link-resolver.d.ts +11 -1
  119. package/dist/entry-link-resolver.js +65 -5
  120. package/dist/git-manager.d.ts +6 -6
  121. package/dist/git-manager.js +16 -15
  122. package/dist/github-service.d.ts +25 -1
  123. package/dist/github-service.js +65 -6
  124. package/dist/http/handler.js +2 -1
  125. package/dist/static/seo.d.ts +5 -5
  126. package/dist/static/seo.js +5 -5
  127. package/dist/system-users.d.ts +12 -0
  128. package/dist/system-users.js +12 -0
  129. package/dist/types.d.ts +9 -1
  130. package/dist/utils/error.js +4 -3
  131. package/dist/utils/escape-text.d.ts +7 -0
  132. package/dist/utils/escape-text.js +27 -0
  133. package/dist/version.d.ts +1 -1
  134. package/dist/version.js +1 -1
  135. package/dist/worker/branch-policy.d.ts +124 -0
  136. package/dist/worker/branch-policy.js +243 -0
  137. package/dist/worker/bundle-header.d.ts +117 -0
  138. package/dist/worker/bundle-header.js +253 -0
  139. package/dist/worker/cms-worker.d.ts +68 -17
  140. package/dist/worker/cms-worker.js +116 -33
  141. package/dist/worker/gateway-egress.d.ts +43 -0
  142. package/dist/worker/gateway-egress.js +209 -0
  143. package/dist/worker/gateway-instance.d.ts +42 -0
  144. package/dist/worker/gateway-instance.js +129 -0
  145. package/dist/worker/git-process.d.ts +81 -0
  146. package/dist/worker/git-process.js +234 -0
  147. package/dist/worker/git-sync.d.ts +8 -7
  148. package/dist/worker/git-sync.js +184 -74
  149. package/dist/worker/github-gateway-http.d.ts +62 -0
  150. package/dist/worker/github-gateway-http.js +977 -0
  151. package/dist/worker/github-gateway-wire.d.ts +589 -0
  152. package/dist/worker/github-gateway-wire.js +484 -0
  153. package/dist/worker/github-gateway.d.ts +152 -58
  154. package/dist/worker/github-gateway.js +485 -129
  155. package/dist/worker/github-mirror-upkeep.d.ts +75 -0
  156. package/dist/worker/github-mirror-upkeep.js +266 -0
  157. package/dist/worker/github-mirror.d.ts +271 -71
  158. package/dist/worker/github-mirror.js +755 -238
  159. package/dist/worker/github-transfer.d.ts +56 -0
  160. package/dist/worker/github-transfer.js +444 -0
  161. package/dist/worker/self-checks.d.ts +107 -0
  162. package/dist/worker/self-checks.js +358 -0
  163. package/dist/worker/shared-repo-git.d.ts +20 -7
  164. package/dist/worker/shared-repo-git.js +51 -22
  165. package/dist/worker/stale-mirror.d.ts +82 -0
  166. package/dist/worker/stale-mirror.js +130 -0
  167. package/dist/worker/task-runner.d.ts +2 -0
  168. package/dist/worker/task-runner.js +49 -20
  169. package/dist/worker/worker-context.d.ts +6 -0
  170. package/package.json +1 -1
@@ -95,5 +95,5 @@ export function EntryCreateModal({ isOpen, collectionLabel, entryTypes: allEntry
95
95
  return (_jsx(Modal, { opened: isOpen, onClose: onClose, title: _jsx(Text, { fw: 600, children: "Create New Entry" }), size: "md", closeOnClickOutside: !isCreating, closeOnEscape: !isCreating, children: _jsxs(Stack, { gap: "md", "data-testid": "create-entry-modal", children: [error && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", variant: "light", children: error })), _jsxs(Text, { size: "sm", c: "dimmed", children: ["Creating in: ", collectionLabel] }), nothingToCreate && (_jsx(Alert, { color: "yellow", icon: _jsx(IconAlertCircle, { size: 16 }), "data-testid": "no-creatable-types", children: _jsx(UnavailableTypeMessage, { schemaRefs: skippedSchemaRefs }) })), entryTypes.length > 1 && (_jsx(Select, { label: "Entry Type", description: "Select the type of entry to create", value: entryTypeName, onChange: (value) => setEntryTypeName(value || ''), data: entryTypes.map((et) => ({
96
96
  value: et.name,
97
97
  label: et.label || et.name,
98
- })), required: true, disabled: isCreating, searchable: true, placeholder: "Select entry type..." })), entryTypes.length === 1 && (_jsxs(Text, { size: "sm", c: "dimmed", children: ["Entry type: ", _jsx("strong", { children: typeLabel })] })), _jsx(TextInput, { label: "Slug", description: "URL-friendly identifier (lowercase, alphanumeric + hyphens)", value: slug, onChange: (e) => handleSlugChange(e.currentTarget.value), error: validationError, placeholder: "my-entry-slug", required: true, disabled: isCreating, "data-autofocus": true, "data-testid": "entry-slug-input" }), _jsxs(Text, { size: "xs", c: "dimmed", children: ["Preview: ", _jsx("code", { children: slug })] }), _jsxs(Group, { justify: "flex-end", gap: "sm", children: [_jsx(Button, { variant: "subtle", onClick: onClose, disabled: isCreating, children: "Cancel" }), _jsx(Button, { onClick: handleCreate, loading: isCreating, disabled: !canCreate, "data-testid": "create-entry-submit", children: "Create" })] })] }) }));
98
+ })), required: true, disabled: isCreating, searchable: true, placeholder: "Select entry type..." })), entryTypes.length === 1 && (_jsxs(Text, { size: "sm", c: "dimmed", children: ["Entry type: ", _jsx("strong", { children: typeLabel })] })), _jsx(TextInput, { label: "Slug", description: "URL-friendly identifier (lowercase, alphanumeric + hyphens)", value: slug, onChange: (e) => handleSlugChange(e.currentTarget.value), error: validationError, placeholder: "my-entry-slug", required: true, disabled: isCreating, "data-autofocus": true, "data-testid": "entry-slug-input" }), _jsxs(Text, { size: "xs", c: "dimmed", children: ["Preview: ", _jsx("code", { children: slug })] }), _jsxs(Group, { justify: "flex-end", gap: "sm", children: [_jsx(Button, { size: "sm", variant: "subtle", onClick: onClose, disabled: isCreating, children: "Cancel" }), _jsx(Button, { size: "sm", onClick: handleCreate, loading: isCreating, disabled: !canCreate, "data-testid": "create-entry-submit", children: "Create" })] })] }) }));
99
99
  }
@@ -4,7 +4,8 @@ export interface NoEditPermissionNoticeProps {
4
4
  }
5
5
  /**
6
6
  * Explains why an entry cannot be edited and whom to ask. An entry's `canEdit` is false only
7
- * when a path rule (or `defaultPathAccess`) denies edit, so that is the reason it gives; a
8
- * locked or protected branch shows its own banner in the header and a read-only form instead.
7
+ * when a path rule (or `defaultPathAccess`) denies edit, so that is the reason it gives. A locked
8
+ * or protected branch instead shows its banner in the header over a read-only form, the rule
9
+ * `contentReadOnly` in `Editor.tsx` holds.
9
10
  */
10
11
  export declare function NoEditPermissionNotice({ entryPath }: NoEditPermissionNoticeProps): import("react/jsx-runtime").JSX.Element;
@@ -2,8 +2,9 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Text } from '@mantine/core';
3
3
  /**
4
4
  * Explains why an entry cannot be edited and whom to ask. An entry's `canEdit` is false only
5
- * when a path rule (or `defaultPathAccess`) denies edit, so that is the reason it gives; a
6
- * locked or protected branch shows its own banner in the header and a read-only form instead.
5
+ * when a path rule (or `defaultPathAccess`) denies edit, so that is the reason it gives. A locked
6
+ * or protected branch instead shows its banner in the header over a read-only form, the rule
7
+ * `contentReadOnly` in `Editor.tsx` holds.
7
8
  */
8
9
  export function NoEditPermissionNotice({ entryPath }) {
9
10
  return (_jsx("div", { role: "status", "data-testid": "no-edit-permission-notice", style: {
@@ -0,0 +1,11 @@
1
+ export interface ReadOnlyDraftNoticeProps {
2
+ /** The branch never becomes editable again: the protected base branch, or an archived one. */
3
+ permanentlyReadOnly: boolean;
4
+ /** Discards the entry's kept draft; the caller confirms first, since the draft is unsaved work. */
5
+ onDiscard: () => void;
6
+ }
7
+ /**
8
+ * Says that this entry has a draft the read-only form is not showing. The draft is kept until it
9
+ * is discarded, so it comes back as the editable value if the branch unlocks.
10
+ */
11
+ export declare function ReadOnlyDraftNotice({ permanentlyReadOnly, onDiscard }: ReadOnlyDraftNoticeProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Alert, Button, Group, Stack, Text } from '@mantine/core';
3
+ import { IconAlertTriangle } from '@tabler/icons-react';
4
+ /**
5
+ * Says that this entry has a draft the read-only form is not showing. The draft is kept until it
6
+ * is discarded, so it comes back as the editable value if the branch unlocks.
7
+ */
8
+ export function ReadOnlyDraftNotice({ permanentlyReadOnly, onDiscard }) {
9
+ return (_jsx(Alert, { role: "status", icon: _jsx(IconAlertTriangle, { size: 16 }), color: "yellow", variant: "light", mb: "md", "data-testid": "read-only-draft-notice", children: _jsxs(Group, { justify: "space-between", align: "center", gap: "sm", wrap: "wrap", children: [_jsxs(Stack, { gap: 2, children: [_jsx(Text, { size: "sm", children: "You have unsaved changes from earlier on this read-only branch." }), _jsx(Text, { size: "sm", children: permanentlyReadOnly
10
+ ? "This branch won't become editable, so they can only be discarded."
11
+ : "They're kept on this device and come back when the branch is editable again." })] }), _jsx(Button, { variant: "default", onClick: onDiscard, children: "Discard changes" })] }) }));
12
+ }
@@ -53,5 +53,5 @@ export function RenameEntryModal({ isOpen, entryLabel, currentSlug, onSave, onCl
53
53
  await onSave(newSlug);
54
54
  };
55
55
  const canSave = !validationError && newSlug.trim() !== '' && newSlug !== currentSlug && !isSaving;
56
- return (_jsx(Modal, { opened: isOpen, onClose: onClose, title: _jsx(Text, { fw: 600, children: "Rename Entry" }), size: "md", closeOnClickOutside: !isSaving, closeOnEscape: !isSaving, children: _jsxs(Stack, { gap: "md", "data-testid": "rename-entry-modal", children: [error && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", variant: "light", children: error })), _jsxs(Text, { size: "sm", c: "dimmed", children: ["Renaming \"", entryLabel, "\""] }), _jsx(TextInput, { label: "Slug", description: "URL-friendly identifier (lowercase, alphanumeric + hyphens)", value: newSlug, onChange: (e) => handleSlugChange(e.currentTarget.value), error: validationError, placeholder: "my-entry-slug", required: true, disabled: isSaving, "data-autofocus": true, "data-testid": "rename-slug-input" }), _jsxs(Text, { size: "xs", c: "dimmed", children: ["Current: ", _jsx("code", { children: currentSlug }), newSlug !== currentSlug && (_jsxs(_Fragment, { children: [' → ', "New: ", _jsx("code", { children: newSlug })] }))] }), _jsxs(Group, { justify: "flex-end", gap: "sm", children: [_jsx(Button, { variant: "subtle", onClick: onClose, disabled: isSaving, children: "Cancel" }), _jsx(Button, { onClick: handleSave, loading: isSaving, disabled: !canSave, "data-testid": "rename-entry-submit", children: "Rename" })] })] }) }));
56
+ return (_jsx(Modal, { opened: isOpen, onClose: onClose, title: _jsx(Text, { fw: 600, children: "Rename Entry" }), size: "md", closeOnClickOutside: !isSaving, closeOnEscape: !isSaving, children: _jsxs(Stack, { gap: "md", "data-testid": "rename-entry-modal", children: [error && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", variant: "light", children: error })), _jsxs(Text, { size: "sm", c: "dimmed", children: ["Renaming \"", entryLabel, "\""] }), _jsx(TextInput, { label: "Slug", description: "URL-friendly identifier (lowercase, alphanumeric + hyphens)", value: newSlug, onChange: (e) => handleSlugChange(e.currentTarget.value), error: validationError, placeholder: "my-entry-slug", required: true, disabled: isSaving, "data-autofocus": true, "data-testid": "rename-slug-input" }), _jsxs(Text, { size: "xs", c: "dimmed", children: ["Current: ", _jsx("code", { children: currentSlug }), newSlug !== currentSlug && (_jsxs(_Fragment, { children: [' → ', "New: ", _jsx("code", { children: newSlug })] }))] }), _jsxs(Group, { justify: "flex-end", gap: "sm", children: [_jsx(Button, { size: "sm", variant: "subtle", onClick: onClose, disabled: isSaving, children: "Cancel" }), _jsx(Button, { size: "sm", onClick: handleSave, loading: isSaving, disabled: !canSave, "data-testid": "rename-entry-submit", children: "Rename" })] })] }) }));
57
57
  }
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useReducer, useRef } from 'react';
4
4
  import { Badge, Button, Drawer, Fieldset, Group, Text, Title } from '@mantine/core';
5
- import { modals } from '@mantine/modals';
5
+ import { openConfirm } from '../utils/confirm-modal.js';
6
6
  /**
7
7
  * A key that changes each time the drawer finishes closing. A panel keyed with it, and passing
8
8
  * `onExited` through, gets fresh state for each opening, as the drawer's own children do. A
@@ -47,7 +47,7 @@ export const StagedChangesDrawer = ({ opened, onClose, title, description, size,
47
47
  return;
48
48
  }
49
49
  confirmOpenRef.current = true;
50
- modals.openConfirmModal({
50
+ openConfirm({
51
51
  title: 'Discard unsaved changes?',
52
52
  children: _jsx(Text, { size: "sm", children: "Closing now drops the changes you have not saved." }),
53
53
  labels: { confirm: 'Discard', cancel: 'Keep editing' },
@@ -71,5 +71,5 @@ export const StagedChangesDrawer = ({ opened, onClose, title, description, size,
71
71
  zIndex: 1,
72
72
  background: 'var(--mantine-color-body)',
73
73
  borderTop: '1px solid var(--mantine-color-gray-3)',
74
- }, children: [_jsx(Text, { size: "sm", c: "dimmed", children: "Changes take effect only after you save." }), _jsxs(Group, { gap: "sm", children: [_jsx(Button, { variant: "subtle", color: "neutral", onClick: onDiscard, disabled: isSaving, children: "Discard Changes" }), _jsx(Button, { onClick: onSave, loading: isSaving, disabled: isSaving, children: saveLabel })] })] }))] }));
74
+ }, children: [_jsx(Text, { size: "sm", c: "dimmed", children: "Changes take effect only after you save." }), _jsxs(Group, { gap: "sm", children: [_jsx(Button, { size: "sm", variant: "subtle", color: "neutral", onClick: onDiscard, disabled: isSaving, children: "Discard changes" }), _jsx(Button, { size: "sm", onClick: onSave, loading: isSaving, disabled: isSaving, children: saveLabel })] })] }))] }));
75
75
  };
@@ -0,0 +1,14 @@
1
+ export interface UndoToastMessageProps {
2
+ /** Names what was removed: `"Branch-aware" from Features`, `item 2 from Features`. */
3
+ label: string;
4
+ onUndo: () => void;
5
+ /**
6
+ * Called when keyboard focus reaches the toast. Mantine pauses its timer on hover only, so the
7
+ * caller stops it here or the toast can close under the focused Undo.
8
+ */
9
+ onHold?: () => void;
10
+ /** Called when focus leaves the toast, so its timer can run again. */
11
+ onRelease?: () => void;
12
+ }
13
+ /** The body of the toast a form Remove raises in place of a confirmation. */
14
+ export declare function UndoToastMessage({ label, onUndo, onHold, onRelease }: UndoToastMessageProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button, Group, Text } from '@mantine/core';
3
+ /** The body of the toast a form Remove raises in place of a confirmation. */
4
+ export function UndoToastMessage({ label, onUndo, onHold, onRelease }) {
5
+ return (_jsxs(Group, { justify: "space-between", align: "center", gap: "sm", wrap: "nowrap", onFocus: onHold, onBlur: (event) => {
6
+ if (!event.currentTarget.contains(event.relatedTarget))
7
+ onRelease?.();
8
+ }, children: [_jsx(Text, { size: "sm", miw: 0, children: `Removed ${label}` }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: onUndo, "aria-label": `Undo removing ${label}`, children: "Undo" })] }));
9
+ }
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Avatar, Badge, Tooltip, Group, Text, Skeleton, ActionIcon } from '@mantine/core';
3
- import { IconUserOff, IconX } from '@tabler/icons-react';
3
+ import { IconRobot, IconUserOff, IconX } from '@tabler/icons-react';
4
4
  import { useUserMetadata } from '../hooks/useUserMetadata.js';
5
+ import { isSystemUserId } from '../../system-users.js';
5
6
  export const UserBadge = ({ userId, getUserMetadata, variant = 'avatar-name', showEmailTooltip = true, onRemove, color, size = 'sm', badgeVariant = 'filled', showBadge = false, loading: loadingOverride, cachedUser, }) => {
6
7
  const { userMetadata, isLoading, error } = useUserMetadata(userId, getUserMetadata, cachedUser);
7
8
  const loading = loadingOverride ?? isLoading;
@@ -34,6 +35,9 @@ export const UserBadge = ({ userId, getUserMetadata, variant = 'avatar-name', sh
34
35
  if (userId === 'anonymous') {
35
36
  return (_jsx(Avatar, { size: avatarSize, color: "orange", children: _jsx(IconUserOff, { size: avatarSize * 0.6 }) }));
36
37
  }
38
+ if (isSystemUserId(userId)) {
39
+ return (_jsx(Avatar, { size: avatarSize, color: "gray", children: _jsx(IconRobot, { size: avatarSize * 0.6 }) }));
40
+ }
37
41
  // When inside Badge wrapper with filled/light variant, use white background for visibility
38
42
  // For outline badges or no badge, use the passed color
39
43
  let avatarColor = color || 'blue';
@@ -42,7 +46,7 @@ export const UserBadge = ({ userId, getUserMetadata, variant = 'avatar-name', sh
42
46
  if (badgeVariant === 'filled') {
43
47
  // Filled badges: white background with dark text for maximum contrast
44
48
  avatarStyles = {
45
- backgroundColor: 'white',
49
+ backgroundColor: 'var(--mantine-color-white)',
46
50
  color: 'var(--mantine-color-dark-6)',
47
51
  };
48
52
  }
@@ -68,7 +72,7 @@ export const UserBadge = ({ userId, getUserMetadata, variant = 'avatar-name', sh
68
72
  };
69
73
  const content = renderContent();
70
74
  if (onRemove || showBadge) {
71
- const badgeContent = (_jsx(Badge, { variant: badgeVariant, color: color, pr: onRemove ? 3 : 8, pl: 4, rightSection: onRemove ? (_jsx(ActionIcon, { size: "xs", radius: "xl", variant: "transparent", onClick: () => onRemove(userId), "aria-label": "Remove user", style: { color: 'white' }, children: _jsx(IconX, { size: 12, stroke: 2.5 }) })) : undefined, styles: {
75
+ const badgeContent = (_jsx(Badge, { variant: badgeVariant, color: color, pr: onRemove ? 3 : 8, pl: 4, rightSection: onRemove ? (_jsx(ActionIcon, { size: "xs", radius: "xl", variant: "transparent", onClick: () => onRemove(userId), "aria-label": "Remove user", style: { color: 'var(--mantine-color-white)' }, children: _jsx(IconX, { size: 12, stroke: 2.5 }) })) : undefined, styles: {
72
76
  label: {
73
77
  textTransform: 'none',
74
78
  display: 'flex',
@@ -76,7 +80,7 @@ export const UserBadge = ({ userId, getUserMetadata, variant = 'avatar-name', sh
76
80
  gap: '4px',
77
81
  },
78
82
  }, children: content }));
79
- return showEmailTooltip && variant !== 'full' ? (_jsx(Tooltip, { label: userMetadata.email, children: badgeContent })) : (badgeContent);
83
+ return showEmailTooltip && variant !== 'full' ? (_jsx(Tooltip, { label: userMetadata.email, disabled: !userMetadata.email, children: badgeContent })) : (badgeContent);
80
84
  }
81
- return showEmailTooltip && variant !== 'full' ? (_jsx(Tooltip, { label: userMetadata.email, children: content })) : (content);
85
+ return showEmailTooltip && variant !== 'full' ? (_jsx(Tooltip, { label: userMetadata.email, disabled: !userMetadata.email, children: content })) : (content);
82
86
  };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The editor's fixed action verbs, sentence case and verb first. "…" is appended only where
3
+ * the command asks for more before it acts ("Switch branch…"); see docs/ux-guidelines.md.
4
+ * New UI uses these instead of writing the strings inline.
5
+ */
6
+ export declare const EDITOR_ACTIONS: {
7
+ readonly save: "Save";
8
+ readonly submitForReview: "Submit for review";
9
+ readonly withdrawFromReview: "Withdraw from review";
10
+ readonly requestChanges: "Request changes";
11
+ readonly switchBranch: "Switch branch";
12
+ readonly newBranch: "New branch";
13
+ readonly remove: "Remove";
14
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The editor's fixed action verbs, sentence case and verb first. "…" is appended only where
3
+ * the command asks for more before it acts ("Switch branch…"); see docs/ux-guidelines.md.
4
+ * New UI uses these instead of writing the strings inline.
5
+ */
6
+ export const EDITOR_ACTIONS = {
7
+ save: 'Save',
8
+ submitForReview: 'Submit for review',
9
+ withdrawFromReview: 'Withdraw from review',
10
+ requestChanges: 'Request changes',
11
+ switchBranch: 'Switch branch',
12
+ newBranch: 'New branch',
13
+ remove: 'Remove',
14
+ };
@@ -7,6 +7,7 @@ export interface BlockInstance {
7
7
  type RenderField = (field: FieldConfig, value: unknown, onChange: (v: unknown) => void, path: Array<string | number>) => React.ReactNode;
8
8
  export interface BlockFieldProps {
9
9
  label?: string;
10
+ required?: boolean;
10
11
  description?: string;
11
12
  templates: BlockConfig[];
12
13
  value: BlockInstance[];
@@ -14,6 +15,10 @@ export interface BlockFieldProps {
14
15
  renderField: RenderField;
15
16
  path: Array<string | number>;
16
17
  dataCanopyField?: string;
18
+ /** No add, remove, reorder or drag. */
19
+ readOnly?: boolean;
20
+ /** Told of each block Remove takes out, so the caller can offer Undo. */
21
+ onRemoved?: (index: number, block: BlockInstance) => void;
17
22
  }
18
23
  export declare const BlockField: React.FC<BlockFieldProps>;
19
24
  export {};
@@ -1,65 +1,67 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import React, { useId, useMemo, useState } from 'react';
4
- let blockKeyCounter = 0;
5
4
  import { ActionIcon, Button, Group, Paper, Select, Stack, Text } from '@mantine/core';
6
5
  import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core';
7
6
  import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable';
8
7
  import { CSS } from '@dnd-kit/utilities';
9
8
  import { isPlainRecord } from '../../validation/field-traversal.js';
10
9
  import { formatCanopyPath } from '../canopy-path.js';
11
- import { FieldDescription, groupDescriptionProps } from './FieldDescription.js';
10
+ import { EDITOR_ACTIONS } from '../copy.js';
11
+ import { groupDescriptionProps } from './FieldDescription.js';
12
+ import { FieldLabel } from './FieldLabel.js';
13
+ import { useListItemKeys } from './list-item-keys.js';
12
14
  const findTemplate = (templates, name) => templates.find((t) => t.name === name);
13
- const SortableBlock = ({ id, canopyPath, children }) => {
15
+ const SortableBlock = ({ id, canopyPath, readOnly, children }) => {
14
16
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
15
17
  id,
18
+ disabled: readOnly,
16
19
  });
17
20
  const style = {
18
21
  transform: CSS.Transform.toString(transform),
19
22
  transition,
20
23
  opacity: isDragging ? 0.85 : 1,
21
24
  };
22
- return (_jsx(Paper, { ref: setNodeRef, withBorder: true, radius: "md", p: "sm", shadow: "xs", style: style, "data-canopy-field": canopyPath, children: _jsxs(Group, { align: "flex-start", gap: "sm", children: [_jsx(ActionIcon, { variant: "subtle", "aria-label": "Drag to reorder", ...attributes, ...listeners, style: { cursor: 'grab' }, children: "\u21C5" }, "drag-handle"), _jsx("div", { style: { flex: 1, minWidth: 0, width: '100%' }, children: children }, "content")] }) }));
25
+ return (_jsx(Paper, { ref: setNodeRef, withBorder: true, p: "sm", shadow: "xs", style: style, "data-canopy-field": canopyPath, children: _jsxs(Group, { align: "flex-start", gap: "sm", children: [!readOnly && (_jsx(ActionIcon, { size: "md", variant: "subtle", "aria-label": "Drag to reorder", ...attributes, ...listeners, style: { cursor: 'grab' }, children: "\u21C5" }, "drag-handle")), _jsx("div", { style: { flex: 1, minWidth: 0, width: '100%' }, children: children }, "content")] }) }));
23
26
  };
24
- export const BlockField = ({ label, description, templates, value, onChange, renderField, path, dataCanopyField, }) => {
27
+ export const BlockField = ({ label, required, description, templates, value, onChange, renderField, path, dataCanopyField, readOnly = false, onRemoved, }) => {
25
28
  const descriptionBaseId = useId();
26
- const [itemKeys, setItemKeys] = useState(() => value.map(() => `block-${blockKeyCounter++}`));
29
+ const keysFor = useListItemKeys();
30
+ const itemKeys = keysFor(value, 'blocks');
27
31
  const [pendingTemplate, setPendingTemplate] = useState(null);
28
- // Keeps itemKeys in sync with value's length; setState runs directly in render, not an effect.
29
- if (value.length > itemKeys.length) {
30
- const extras = Array.from({ length: value.length - itemKeys.length }, () => `block-${blockKeyCounter++}`);
31
- setItemKeys((prev) => [...prev, ...extras]);
32
- }
33
- else if (value.length < itemKeys.length) {
34
- setItemKeys((prev) => prev.slice(0, value.length));
35
- }
32
+ // Constant: DndContext's hooks depend on the sensor count; read-only disables each item instead.
36
33
  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(KeyboardSensor, {
37
34
  coordinateGetter: sortableKeyboardCoordinates,
38
35
  }));
36
+ const emit = (next) => {
37
+ if (!readOnly)
38
+ onChange(next);
39
+ };
39
40
  const addBlock = (templateName) => {
40
41
  const template = findTemplate(templates, templateName);
41
42
  if (!template)
42
43
  return;
43
- onChange([...value, { template: templateName, value: {} }]);
44
- setItemKeys((prev) => [
45
- ...prev,
46
- `block-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
47
- ]);
44
+ if (readOnly)
45
+ return;
46
+ emit([...value, { template: templateName, value: {} }]);
48
47
  };
49
48
  const moveBlock = (from, to) => {
50
49
  if (from === to || from < 0 || to < 0 || from >= value.length || to >= value.length)
51
50
  return;
52
- onChange(arrayMove(value, from, to));
53
- setItemKeys((prev) => arrayMove(prev, from, to));
51
+ if (readOnly)
52
+ return;
53
+ emit(arrayMove(value, from, to));
54
54
  };
55
55
  const removeBlock = (index) => {
56
- onChange(value.filter((_, idx) => idx !== index));
57
- setItemKeys((prev) => prev.filter((_, idx) => idx !== index));
56
+ if (readOnly)
57
+ return;
58
+ emit(value.filter((_, idx) => idx !== index));
59
+ onRemoved?.(index, value[index]);
58
60
  };
59
61
  const updateBlockValue = (index, val) => {
60
62
  const next = [...value];
61
63
  next[index] = { ...next[index], value: val };
62
- onChange(next);
64
+ emit(next);
63
65
  };
64
66
  const handleDragEnd = (event) => {
65
67
  const { active, over } = event;
@@ -72,15 +74,19 @@ export const BlockField = ({ label, description, templates, value, onChange, ren
72
74
  moveBlock(oldIndex, newIndex);
73
75
  };
74
76
  const selectableTemplates = useMemo(() => templates.map((t) => ({ value: t.name, label: t.label ?? t.name })), [templates]);
75
- return (_jsx(Paper, { withBorder: true, radius: "md", p: "md", bg: "gray.0", "data-canopy-field": dataCanopyField ?? formatCanopyPath(path), shadow: "xs", ...groupDescriptionProps(descriptionBaseId, description), children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "xs", fw: 700, c: "dimmed", children: label ?? 'Blocks' }), _jsx(Select, { "aria-label": "Add block", placeholder: "Add block...", data: selectableTemplates, value: pendingTemplate, onChange: (next) => {
76
- if (next) {
77
- addBlock(next);
78
- }
79
- setPendingTemplate(null);
80
- }, allowDeselect: true, size: "xs", w: 180 })] }), _jsx(FieldDescription, { baseId: descriptionBaseId, description: description }), _jsx(DndContext, { sensors: sensors, onDragEnd: handleDragEnd, children: _jsx(SortableContext, { items: itemKeys, strategy: verticalListSortingStrategy, children: _jsx(Stack, { gap: "sm", children: value.map((block, idx) => {
77
+ return (_jsx(Paper, { withBorder: true, p: "md", bg: "gray.0", "data-canopy-field": dataCanopyField ?? formatCanopyPath(path), role: "group", "aria-label": label ?? 'Blocks', "data-removal-focus": true, tabIndex: -1, shadow: "xs", ...groupDescriptionProps(descriptionBaseId, description), children: _jsxs(Stack, { gap: "sm", children: [_jsx(FieldLabel, { label: label ?? 'Blocks', required: required, description: description, descriptionBaseId: descriptionBaseId, actions: !readOnly && (_jsx(Select, { "aria-label": "Add block", placeholder: "Add block...", data: selectableTemplates, value: pendingTemplate, onChange: (next) => {
78
+ if (next) {
79
+ addBlock(next);
80
+ }
81
+ setPendingTemplate(null);
82
+ }, allowDeselect: true, size: "xs", w: 180 })) }), _jsx(DndContext, { sensors: sensors, onDragEnd: handleDragEnd, children: _jsx(SortableContext, { items: itemKeys, strategy: verticalListSortingStrategy, children: _jsx(Stack, { gap: "sm", children: value.map((block, idx) => {
81
83
  const template = findTemplate(templates, block.template);
82
84
  const currentPath = [...path, idx];
83
- return (_jsx(SortableBlock, { id: itemKeys[idx], canopyPath: formatCanopyPath(currentPath), children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", align: "flex-start", children: [_jsx(Text, { size: "sm", fw: 600, children: template?.label ?? block.template ?? 'Unknown block' }), _jsxs(Group, { gap: 4, children: [_jsx(ActionIcon, { variant: "light", "aria-label": "Move block up", disabled: idx === 0, onClick: () => moveBlock(idx, idx - 1), children: "\u2191" }), _jsx(ActionIcon, { variant: "light", "aria-label": "Move block down", disabled: idx === value.length - 1, onClick: () => moveBlock(idx, idx + 1), children: "\u2193" }), _jsx(Button, { variant: "subtle", color: "red", size: "xs", onClick: () => removeBlock(idx), children: "Remove" })] })] }), template ? (_jsx(Stack, { gap: "sm", children: template.fields.map((f) => (_jsx(React.Fragment, { children: renderField(f, block.value?.[f.name], (next) => updateBlockValue(idx, {
85
+ return (_jsx(SortableBlock, { id: itemKeys[idx], canopyPath: formatCanopyPath(currentPath), readOnly: readOnly, children: _jsxs(Stack, { gap: "xs", children: [_jsx(FieldLabel, { label: template?.label ?? block.template ?? 'Unknown block', actions: !readOnly && (_jsxs(_Fragment, { children: [_jsx(ActionIcon, { size: "md", variant: "light", "aria-label": "Move block up", disabled: idx === 0, onClick: () => moveBlock(idx, idx - 1), children: "\u2191" }), _jsx(ActionIcon, { size: "md", variant: "light", "aria-label": "Move block down", disabled: idx === value.length - 1, onClick: () => moveBlock(idx, idx + 1), children: "\u2193" }), _jsx(Button, { variant: "subtle", color: "red", onClick: (event) => {
86
+ const list = event.currentTarget.closest('[data-removal-focus]');
87
+ removeBlock(idx);
88
+ list?.focus();
89
+ }, children: EDITOR_ACTIONS.remove })] })) }), template ? (_jsx(Stack, { gap: "sm", children: template.fields.map((f) => (_jsx(React.Fragment, { children: renderField(f, block.value?.[f.name], (next) => updateBlockValue(idx, {
84
90
  ...(isPlainRecord(block.value) ? block.value : {}),
85
91
  [f.name]: next,
86
92
  }), [...currentPath, f.name]) }, f.name))) })) : (_jsxs(Text, { size: "xs", c: "red", children: ["No template found for \"", block.template, "\""] }))] }) }, itemKeys[idx]));
@@ -7,5 +7,7 @@ export interface CodeFieldProps {
7
7
  onChange: (value: string) => void;
8
8
  language?: string;
9
9
  dataCanopyField?: string;
10
+ /** Shows the value without accepting edits. */
11
+ readOnly?: boolean;
10
12
  }
11
13
  export declare const CodeField: React.FC<CodeFieldProps>;
@@ -2,8 +2,11 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useId } from 'react';
3
3
  import { Textarea } from '@mantine/core';
4
4
  // Placeholder for Monaco integration; host app can provide custom renderer for production.
5
- export const CodeField = ({ id, label, description, value, onChange, language, dataCanopyField, }) => {
5
+ export const CodeField = ({ id, label, description, value, onChange, language, dataCanopyField, readOnly = false, }) => {
6
6
  const generatedId = useId();
7
7
  const inputId = id ?? generatedId;
8
- return (_jsx(Textarea, { id: inputId, label: label, description: description, value: value, onChange: (e) => onChange(e.currentTarget.value), placeholder: language ? `Code (${language})` : 'Code', autosize: true, minRows: 6, size: "sm", "data-canopy-field": dataCanopyField, styles: { input: { fontFamily: 'Menlo, Consolas, monospace' } } }));
8
+ return (_jsx(Textarea, { id: inputId, label: label, description: description, value: value, readOnly: readOnly, onChange: (e) => {
9
+ if (!readOnly)
10
+ onChange(e.currentTarget.value);
11
+ }, placeholder: language ? `Code (${language})` : 'Code', autosize: true, minRows: 6, size: "sm", "data-canopy-field": dataCanopyField, styles: { input: { fontFamily: 'Menlo, Consolas, monospace' } } }));
9
12
  };
@@ -6,6 +6,8 @@ export interface DateTimeFieldProps {
6
6
  value: string;
7
7
  onChange: (value: string) => void;
8
8
  dataCanopyField?: string;
9
+ /** Shows the value without accepting edits. */
10
+ readOnly?: boolean;
9
11
  }
10
12
  /**
11
13
  * Converts a stored value (ISO 8601 UTC, e.g. `YYYY-MM-DDTHH:mm:ss.sssZ`)
@@ -49,8 +49,11 @@ export function datetimeLocalValueToIso(local) {
49
49
  * round-trip; sub-second precision is not preserved (no milliseconds field),
50
50
  * so a value with non-zero milliseconds is zeroed out once resaved.
51
51
  */
52
- export const DateTimeField = ({ id, label, description, value, onChange, dataCanopyField, }) => {
52
+ export const DateTimeField = ({ id, label, description, value, onChange, dataCanopyField, readOnly = false, }) => {
53
53
  const generatedId = useId();
54
54
  const inputId = id ?? generatedId;
55
- return (_jsx(Input.Wrapper, { id: inputId, label: label, description: description, size: "sm", children: _jsx(Input, { type: "datetime-local", step: 1, size: "sm", value: isoToDatetimeLocalValue(value), onChange: (e) => onChange(datetimeLocalValueToIso(e.currentTarget.value)), "data-canopy-field": dataCanopyField }) }));
55
+ return (_jsx(Input.Wrapper, { id: inputId, label: label, description: description, size: "sm", children: _jsx(Input, { type: "datetime-local", step: 1, size: "sm", value: isoToDatetimeLocalValue(value), readOnly: readOnly, onChange: (e) => {
56
+ if (!readOnly)
57
+ onChange(datetimeLocalValueToIso(e.currentTarget.value));
58
+ }, "data-canopy-field": dataCanopyField }) }));
56
59
  };
@@ -4,14 +4,14 @@ export interface FieldCrashFallbackProps {
4
4
  label: string;
5
5
  fieldType: string;
6
6
  value: unknown;
7
- /** Used only by markdown and mdx fields, whose raw text the author edits directly. */
8
- onChange: (value: unknown) => void;
7
+ /** Used only by markdown and mdx fields, whose raw text is edited directly; absent when read-only. */
8
+ onChange?: (value: unknown) => void;
9
9
  caught: CaughtEditorError;
10
10
  dataCanopyField?: string;
11
11
  }
12
12
  /**
13
13
  * What a field that threw while rendering shows: its value read-only, so Save writes back
14
14
  * exactly what is on screen, or, for a markdown or mdx field holding text, that text, still
15
- * editable.
15
+ * editable when there is an `onChange`.
16
16
  */
17
17
  export declare const FieldCrashFallback: React.FC<FieldCrashFallbackProps>;
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { Alert, Code, Group, Paper, Stack, Text } from '@mantine/core';
4
4
  import { IconAlertTriangle } from '@tabler/icons-react';
5
5
  import { CopyErrorDetailsButton } from '../components/EditorErrorBoundary.js';
@@ -20,11 +20,11 @@ const displayValue = (value) => {
20
20
  /**
21
21
  * What a field that threw while rendering shows: its value read-only, so Save writes back
22
22
  * exactly what is on screen, or, for a markdown or mdx field holding text, that text, still
23
- * editable.
23
+ * editable when there is an `onChange`.
24
24
  */
25
25
  export const FieldCrashFallback = ({ label, fieldType, value, onChange, caught, dataCanopyField, }) => {
26
26
  // Only text is editable as source; any other value would show as an empty box to type over.
27
- const editableSource = (fieldType === 'markdown' || fieldType === 'mdx') &&
27
+ const textSource = (fieldType === 'markdown' || fieldType === 'mdx') &&
28
28
  (typeof value === 'string' || value === undefined || value === null);
29
- return (_jsx(Paper, { withBorder: true, radius: "sm", p: "sm", "data-testid": "field-crash-fallback", "data-canopy-field": dataCanopyField, children: _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", fw: 500, children: label }), editableSource ? (_jsx(MarkdownSourceEditor, { label: label, value: typeof value === 'string' ? value : '', onChange: onChange, failure: sanitizeErrorMessage(getErrorMessage(caught.error)) })) : (_jsxs(_Fragment, { children: [_jsx(Alert, { color: "red", variant: "light", icon: _jsx(IconAlertTriangle, { size: 16 }), children: _jsx(Text, { size: "sm", children: "This field couldn't be shown because of an error. Its value is below and won't be changed. You can keep editing the other fields and save." }) }), _jsx(Code, { block: true, "data-testid": "field-crash-value", style: { whiteSpace: 'pre-wrap' }, children: displayValue(value) })] })), _jsx(Group, { children: _jsx(CopyErrorDetailsButton, { caught: caught }) })] }) }));
29
+ return (_jsx(Paper, { withBorder: true, radius: "sm", p: "sm", "data-testid": "field-crash-fallback", "data-canopy-field": dataCanopyField, children: _jsxs(Stack, { gap: "xs", children: [_jsx(Text, { size: "sm", fw: 500, children: label }), textSource ? (_jsx(MarkdownSourceEditor, { label: label, value: typeof value === 'string' ? value : '', onChange: (next) => onChange?.(next), readOnly: !onChange, failure: sanitizeErrorMessage(getErrorMessage(caught.error)) })) : (_jsxs(_Fragment, { children: [_jsx(Alert, { color: "red", variant: "light", icon: _jsx(IconAlertTriangle, { size: 16 }), children: _jsxs(Text, { size: "sm", children: ["This field couldn't be shown because of an error. Its value is below and won't be changed.", onChange && ' You can keep editing the other fields and save.'] }) }), _jsx(Code, { block: true, "data-testid": "field-crash-value", style: { whiteSpace: 'pre-wrap' }, children: displayValue(value) })] })), _jsx(Group, { children: _jsx(CopyErrorDetailsButton, { caught: caught }) })] }) }));
30
30
  };
@@ -0,0 +1,24 @@
1
+ import React from 'react';
2
+ /**
3
+ * The label row shared by fields and field groups: label, comment-control slot, right-aligned
4
+ * actions slot, and the description beneath.
5
+ */
6
+ export interface FieldLabelProps {
7
+ /** Visible label text. When absent and there are no actions or commentControl, only the description renders. */
8
+ label?: React.ReactNode;
9
+ /** Shows Mantine's required asterisk. */
10
+ required?: boolean;
11
+ /** Guidance under the label; rendered via FieldDescription so its id matches groupDescriptionProps(baseId, ...). */
12
+ description?: string;
13
+ /** Base id shared with groupDescriptionProps/fieldDescriptionId; without it, no description renders. */
14
+ descriptionBaseId?: string;
15
+ /** When set, the label is a <label for=...>; otherwise it is a <div> (groups have no single input). */
16
+ htmlFor?: string;
17
+ /** id on the label element, so a container can aria-labelledby it. */
18
+ labelId?: string;
19
+ /** Slot right after the label text, for the field's comment control. */
20
+ commentControl?: React.ReactNode;
21
+ /** Right-aligned slot for field actions (Add item, Remove, Add block). */
22
+ actions?: React.ReactNode;
23
+ }
24
+ export declare const FieldLabel: React.FC<FieldLabelProps>;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Group, Input, Stack } from '@mantine/core';
3
+ import { FieldDescription } from './FieldDescription.js';
4
+ export const FieldLabel = ({ label, required, description, descriptionBaseId, htmlFor, labelId, commentControl, actions, }) => {
5
+ const hasRow = Boolean(label) || Boolean(commentControl) || Boolean(actions);
6
+ if (!hasRow && !description)
7
+ return null;
8
+ return (_jsxs(Stack, { gap: 2, children: [hasRow && (_jsxs(Group, { justify: "space-between", wrap: "nowrap", gap: "xs", align: "center", children: [_jsxs(Group, { gap: 4, wrap: "nowrap", style: { minWidth: 0 }, children: [label && (_jsx(Input.Label, { required: required, htmlFor: htmlFor, id: labelId, labelElement: htmlFor ? 'label' : 'div', children: label })), commentControl] }), actions && (_jsx(Group, { gap: "xs", wrap: "nowrap", style: { flexShrink: 0 }, children: actions }))] })), descriptionBaseId && (_jsx(FieldDescription, { baseId: descriptionBaseId, description: description }))] }));
9
+ };
@@ -17,6 +17,10 @@ export interface ImageFieldProps {
17
17
  altOptional?: boolean;
18
18
  dataCanopyField?: string;
19
19
  errors?: ImageFieldErrors;
20
+ /** No upload, pick, crop or remove; the alt text is read-only. */
21
+ readOnly?: boolean;
22
+ /** Told of the image Remove takes out, so the caller can offer Undo. */
23
+ onRemoved?: (removed: ImageFieldValue) => void;
20
24
  }
21
25
  export declare const ImageField: React.FC<ImageFieldProps>;
22
26
  export {};
@@ -14,7 +14,7 @@ import { useAssetUpload } from '../media/useAssetUpload.js';
14
14
  import { ACCEPTED_IMAGE_MIME_TYPES, MAX_UPLOAD_BYTES } from '../media/upload-constants.js';
15
15
  import { FieldDescription, groupDescriptionProps } from './FieldDescription.js';
16
16
  const PREVIEW_WIDTH = 320;
17
- export const ImageField = ({ id, label, description, value, onChange, aspect, altOptional, dataCanopyField, errors, }) => {
17
+ export const ImageField = ({ id, label, description, value, onChange, aspect, altOptional, dataCanopyField, errors, readOnly = false, onRemoved, }) => {
18
18
  const generatedId = useId();
19
19
  const inputId = id ?? generatedId;
20
20
  const { baseUrl } = useAssetContext();
@@ -50,9 +50,13 @@ export const ImageField = ({ id, label, description, value, onChange, aspect, al
50
50
  altInputRef.current?.focus();
51
51
  }
52
52
  }, [value?.src]);
53
+ const edit = (next) => {
54
+ if (!readOnly)
55
+ onChange(next);
56
+ };
53
57
  const commitAsset = (asset, crop) => {
54
58
  justCommittedRef.current = true;
55
- onChange({
59
+ edit({
56
60
  src: asset.src,
57
61
  // Preserves the current alt text on replace (it often still fits); an
58
62
  // empty field has no prior alt, so it stays ''.
@@ -85,17 +89,22 @@ export const ImageField = ({ id, label, description, value, onChange, aspect, al
85
89
  const handleAltChange = (nextAlt) => {
86
90
  if (!value)
87
91
  return;
88
- onChange({ ...value, alt: nextAlt });
92
+ edit({ ...value, alt: nextAlt });
89
93
  };
90
- const handleRemove = () => {
91
- onChange(undefined);
94
+ const handleRemove = (event) => {
95
+ if (readOnly || !value)
96
+ return;
97
+ const field = event.currentTarget.closest('[data-removal-focus]');
98
+ edit(undefined);
99
+ onRemoved?.(value);
100
+ field?.focus();
92
101
  };
93
102
  const handleCropConfirm = (rect) => {
94
103
  if (cropRequest?.kind === 'new') {
95
104
  commitAsset(cropRequest.asset, rect);
96
105
  }
97
106
  else if (cropRequest?.kind === 'existing' && value) {
98
- onChange({ ...value, crop: rect });
107
+ edit({ ...value, crop: rect });
99
108
  }
100
109
  setCropRequest(null);
101
110
  };
@@ -105,7 +114,7 @@ export const ImageField = ({ id, label, description, value, onChange, aspect, al
105
114
  ? editorImageSrc(value.src, baseUrl)
106
115
  : '';
107
116
  const cropInitial = cropRequest?.kind === 'existing' ? value?.crop : undefined;
108
- return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `image-field-${dataCanopyField}`, ...groupDescriptionProps(inputId, description), children: [label && (_jsx(Text, { size: "sm", fw: 500, children: label })), _jsx(FieldDescription, { baseId: inputId, description: description }), !hasValue ? (_jsxs(Stack, { gap: "xs", children: [_jsx(Dropzone, { onDrop: (files) => void handleDrop(files), onReject: handleReject, maxSize: MAX_UPLOAD_BYTES, accept: ACCEPTED_IMAGE_MIME_TYPES, loading: upload.uploading, multiple: false, "data-testid": `image-field-dropzone-${dataCanopyField}`, children: _jsxs(Group, { justify: "center", gap: "xs", py: "xs", style: { pointerEvents: 'none' }, children: [_jsx(IconUpload, { size: 18 }), _jsx(Text, { size: "xs", children: "Drop an image here, or click to browse" })] }) }), _jsx(Group, { justify: "center", children: _jsx(Button, { variant: "light", size: "xs", leftSection: _jsx(IconPhoto, { size: 14 }), onClick: () => setPickerOpen(true), "data-testid": `image-field-browse-library-${dataCanopyField}`, children: "Browse library" }) }), (upload.error || dropError) && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", children: upload.error || dropError })), errors?.src && (_jsx(Text, { size: "xs", c: "red", children: errors.src }))] })) : (_jsxs(Stack, { gap: "xs", children: [previewFailed ? (_jsxs("div", { "data-testid": `image-field-preview-fallback-${dataCanopyField}`, style: {
117
+ return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `image-field-${dataCanopyField}`, ...groupDescriptionProps(inputId, description), role: "group", "aria-label": label, "data-removal-focus": true, tabIndex: -1, children: [label && (_jsx(Text, { size: "sm", fw: 500, children: label })), _jsx(FieldDescription, { baseId: inputId, description: description }), !hasValue && readOnly ? (_jsx(Text, { size: "sm", c: "dimmed", "data-testid": `image-field-empty-${dataCanopyField}`, children: "No image" })) : !hasValue ? (_jsxs(Stack, { gap: "xs", children: [_jsx(Dropzone, { onDrop: (files) => void handleDrop(files), onReject: handleReject, maxSize: MAX_UPLOAD_BYTES, accept: ACCEPTED_IMAGE_MIME_TYPES, loading: upload.uploading, multiple: false, "data-testid": `image-field-dropzone-${dataCanopyField}`, children: _jsxs(Group, { justify: "center", gap: "xs", py: "xs", style: { pointerEvents: 'none' }, children: [_jsx(IconUpload, { size: 18 }), _jsx(Text, { size: "xs", children: "Drop an image here, or click to browse" })] }) }), _jsx(Group, { justify: "center", children: _jsx(Button, { variant: "light", leftSection: _jsx(IconPhoto, { size: 14 }), onClick: () => setPickerOpen(true), "data-testid": `image-field-browse-library-${dataCanopyField}`, children: "Browse library" }) }), (upload.error || dropError) && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", children: upload.error || dropError })), errors?.src && (_jsx(Text, { size: "xs", c: "red", children: errors.src }))] })) : (_jsxs(Stack, { gap: "xs", children: [previewFailed ? (_jsxs("div", { "data-testid": `image-field-preview-fallback-${dataCanopyField}`, style: {
109
118
  width: PREVIEW_WIDTH,
110
119
  height: 160,
111
120
  display: 'flex',
@@ -120,5 +129,5 @@ export const ImageField = ({ id, label, description, value, onChange, aspect, al
120
129
  maxHeight: PREVIEW_WIDTH,
121
130
  borderRadius: 'var(--mantine-radius-sm)',
122
131
  display: 'block',
123
- }, onError: () => setPreviewFailed(true) })), errors?.src && (_jsx(Text, { size: "xs", c: "red", children: errors.src })), _jsx(TextInput, { id: inputId, ref: altInputRef, label: "Alt text", required: !altOptional, value: value.alt, onChange: (event) => handleAltChange(event.currentTarget.value), error: errors?.alt, size: "sm", "data-testid": `image-field-alt-${dataCanopyField}` }), _jsxs(Group, { gap: "xs", children: [_jsx(Button, { variant: "light", size: "xs", onClick: () => setPickerOpen(true), "data-testid": `image-field-replace-${dataCanopyField}`, children: "Replace" }), canCrop && (_jsx(Button, { variant: "light", size: "xs", onClick: () => setCropRequest({ kind: 'existing' }), "data-testid": `image-field-crop-${dataCanopyField}`, children: "Crop" })), _jsx(Button, { variant: "subtle", color: "red", size: "xs", onClick: handleRemove, "data-testid": `image-field-remove-${dataCanopyField}`, children: "Remove" })] }), errors?.crop && (_jsx(Text, { size: "xs", c: "red", children: errors.crop }))] })), _jsx(MediaLibrary, { opened: pickerOpen, onClose: () => setPickerOpen(false), mode: "picker", onSelect: handleAssetReady }), aspectRatio !== undefined && (_jsx(CropStep, { opened: cropRequest !== null, onClose: () => setCropRequest(null), imageSrc: cropImageSrc, aspect: aspectRatio, initialCrop: cropInitial, onConfirm: handleCropConfirm }))] }));
132
+ }, onError: () => setPreviewFailed(true) })), errors?.src && (_jsx(Text, { size: "xs", c: "red", children: errors.src })), _jsx(TextInput, { id: inputId, ref: altInputRef, label: "Alt text", required: !altOptional, readOnly: readOnly, value: value.alt, onChange: (event) => handleAltChange(event.currentTarget.value), error: errors?.alt, size: "sm", "data-testid": `image-field-alt-${dataCanopyField}` }), !readOnly && (_jsxs(Group, { gap: "xs", children: [_jsx(Button, { variant: "light", onClick: () => setPickerOpen(true), "data-testid": `image-field-replace-${dataCanopyField}`, children: "Replace" }), canCrop && (_jsx(Button, { variant: "light", onClick: () => setCropRequest({ kind: 'existing' }), "data-testid": `image-field-crop-${dataCanopyField}`, children: "Crop" })), _jsx(Button, { variant: "subtle", color: "red", onClick: handleRemove, "data-testid": `image-field-remove-${dataCanopyField}`, children: "Remove" })] })), errors?.crop && (_jsx(Text, { size: "xs", c: "red", children: errors.crop }))] })), !readOnly && (_jsx(MediaLibrary, { opened: pickerOpen, onClose: () => setPickerOpen(false), mode: "picker", onSelect: handleAssetReady })), aspectRatio !== undefined && !readOnly && (_jsx(CropStep, { opened: cropRequest !== null, onClose: () => setCropRequest(null), imageSrc: cropImageSrc, aspect: aspectRatio, initialCrop: cropInitial, onConfirm: handleCropConfirm }))] }));
124
133
  };
@@ -1,11 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useId } from 'react';
3
- import { Paper, Stack, Text } from '@mantine/core';
3
+ import { Paper, Stack } from '@mantine/core';
4
4
  import { formatCanopyPath } from '../canopy-path.js';
5
- import { FieldDescription, groupDescriptionProps } from './FieldDescription.js';
5
+ import { groupDescriptionProps } from './FieldDescription.js';
6
+ import { FieldLabel } from './FieldLabel.js';
6
7
  export const InlineGroupField = ({ label, description, fields, value, onChange, renderField, path, }) => {
7
8
  const descriptionBaseId = useId();
8
- return (_jsx(Paper, { withBorder: true, radius: "md", p: "md", bg: "gray.0", shadow: "xs", ...groupDescriptionProps(descriptionBaseId, description), children: _jsxs(Stack, { gap: "sm", children: [(label || description) && (_jsxs(Stack, { gap: 2, children: [label && (_jsx(Text, { size: "xs", fw: 700, c: "dimmed", children: label })), _jsx(FieldDescription, { baseId: descriptionBaseId, description: description })] })), _jsx(Stack, { gap: "sm", children: fields.map((field) => {
9
+ return (_jsx(Paper, { withBorder: true, p: "md", bg: "gray.0", shadow: "xs", ...groupDescriptionProps(descriptionBaseId, description), children: _jsxs(Stack, { gap: "sm", children: [_jsx(FieldLabel, { label: label, description: description, descriptionBaseId: descriptionBaseId }), _jsx(Stack, { gap: "sm", children: fields.map((field) => {
9
10
  // Nested inline groups are also transparent — pass the same parent value/onChange
10
11
  if (field.type === 'group') {
11
12
  const childGroup = field;