canopycms 0.1.0 → 0.2.0-int.119

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 (113) hide show
  1. package/CHANGELOG.md +144 -41
  2. package/dist/api/assets.d.ts +27 -7
  3. package/dist/api/assets.js +98 -37
  4. package/dist/api/client.d.ts +5 -3
  5. package/dist/api/client.js +7 -3
  6. package/dist/api/index.d.ts +1 -1
  7. package/dist/assets/asset-prefixes.d.ts +4 -1
  8. package/dist/assets/asset-prefixes.js +3 -1
  9. package/dist/assets/finalize.js +14 -1
  10. package/dist/assets/keys.d.ts +4 -1
  11. package/dist/assets/keys.js +4 -1
  12. package/dist/assets/store-local.d.ts +18 -9
  13. package/dist/assets/store-local.js +58 -26
  14. package/dist/assets/store-s3.d.ts +19 -4
  15. package/dist/assets/store-s3.js +56 -13
  16. package/dist/assets/types.d.ts +26 -2
  17. package/dist/cli/cli.js +112 -34
  18. package/dist/editor/BranchManager.js +2 -1
  19. package/dist/editor/CommentsPanel.js +67 -29
  20. package/dist/editor/Editor.d.ts +3 -0
  21. package/dist/editor/Editor.js +58 -36
  22. package/dist/editor/EntryNavigator.d.ts +8 -1
  23. package/dist/editor/EntryNavigator.js +31 -16
  24. package/dist/editor/FormHeader.d.ts +2 -1
  25. package/dist/editor/FormHeader.js +9 -5
  26. package/dist/editor/FormRenderer.d.ts +7 -2
  27. package/dist/editor/FormRenderer.js +51 -16
  28. package/dist/editor/PreviewFrame.d.ts +11 -2
  29. package/dist/editor/PreviewFrame.js +20 -4
  30. package/dist/editor/PreviewPane.d.ts +15 -1
  31. package/dist/editor/PreviewPane.js +55 -8
  32. package/dist/editor/PreviewToolbarTools.d.ts +8 -0
  33. package/dist/editor/PreviewToolbarTools.js +9 -1
  34. package/dist/editor/branch-status.d.ts +6 -0
  35. package/dist/editor/branch-status.js +10 -0
  36. package/dist/editor/comments/BranchComments.d.ts +2 -4
  37. package/dist/editor/comments/BranchComments.js +7 -11
  38. package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
  39. package/dist/editor/comments/ThreadCarousel.js +4 -49
  40. package/dist/editor/comments/ThreadReplyControls.js +5 -2
  41. package/dist/editor/components/BranchLockBanner.d.ts +2 -0
  42. package/dist/editor/components/BranchLockBanner.js +6 -2
  43. package/dist/editor/components/BranchPill.d.ts +5 -4
  44. package/dist/editor/components/BranchPill.js +6 -8
  45. package/dist/editor/components/BranchesDrawer.js +3 -3
  46. package/dist/editor/components/EditorHeader.d.ts +2 -6
  47. package/dist/editor/components/EditorHeader.js +29 -28
  48. package/dist/editor/components/EditorRail.d.ts +3 -4
  49. package/dist/editor/components/EditorRail.js +3 -4
  50. package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
  51. package/dist/editor/components/EntryBreadcrumb.js +8 -11
  52. package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
  53. package/dist/editor/components/QuickOpenPalette.js +6 -1
  54. package/dist/editor/components/RenameEntryModal.d.ts +6 -1
  55. package/dist/editor/components/RenameEntryModal.js +20 -5
  56. package/dist/editor/components/SaveSplitButton.d.ts +3 -2
  57. package/dist/editor/components/SaveSplitButton.js +25 -12
  58. package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
  59. package/dist/editor/components/StagedChangesDrawer.js +5 -4
  60. package/dist/editor/components/UndoToastMessage.d.ts +6 -5
  61. package/dist/editor/components/UndoToastMessage.js +6 -4
  62. package/dist/editor/components/UnsavedMark.d.ts +9 -0
  63. package/dist/editor/components/UnsavedMark.js +20 -0
  64. package/dist/editor/components/UserBadge.js +13 -11
  65. package/dist/editor/copy.d.ts +4 -0
  66. package/dist/editor/copy.js +4 -0
  67. package/dist/editor/editor-utils.d.ts +49 -9
  68. package/dist/editor/editor-utils.js +117 -16
  69. package/dist/editor/entry-search.d.ts +4 -2
  70. package/dist/editor/entry-search.js +2 -3
  71. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  72. package/dist/editor/fields/ObjectField.d.ts +13 -2
  73. package/dist/editor/fields/ObjectField.js +38 -9
  74. package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
  75. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
  76. package/dist/editor/group-manager/index.js +1 -1
  77. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  78. package/dist/editor/hooks/useEditorLayout.js +5 -0
  79. package/dist/editor/hooks/useEntryManager.d.ts +2 -0
  80. package/dist/editor/hooks/useEntryManager.js +10 -8
  81. package/dist/editor/media/AssetCard.d.ts +6 -3
  82. package/dist/editor/media/AssetCard.js +20 -7
  83. package/dist/editor/media/AssetListRow.d.ts +9 -4
  84. package/dist/editor/media/AssetListRow.js +14 -7
  85. package/dist/editor/media/MediaLibrary.js +5 -4
  86. package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
  87. package/dist/editor/media/MediaLibraryBody.js +65 -37
  88. package/dist/editor/media/useAssetLibrary.d.ts +6 -3
  89. package/dist/editor/media/useAssetLibrary.js +17 -10
  90. package/dist/editor/pane-header.d.ts +6 -0
  91. package/dist/editor/pane-header.js +6 -0
  92. package/dist/editor/permission-manager/index.js +1 -1
  93. package/dist/editor/preview-bridge.d.ts +16 -0
  94. package/dist/editor/preview-bridge.js +12 -3
  95. package/dist/editor/preview-path.d.ts +11 -4
  96. package/dist/editor/preview-path.js +62 -19
  97. package/dist/editor/shell/ActivityRail.d.ts +1 -3
  98. package/dist/editor/shell/ActivityRail.js +3 -3
  99. package/dist/editor/shell/EditorShell.d.ts +15 -0
  100. package/dist/editor/shell/EditorShell.js +17 -1
  101. package/dist/editor/shell/OverlayHeader.d.ts +19 -0
  102. package/dist/editor/shell/OverlayHeader.js +15 -0
  103. package/dist/editor/shell/PaneLayout.d.ts +6 -1
  104. package/dist/editor/shell/PaneLayout.js +68 -13
  105. package/dist/editor/shell/SidePanelHost.d.ts +8 -6
  106. package/dist/editor/shell/SidePanelHost.js +70 -33
  107. package/dist/editor/shell/TopBar.js +2 -1
  108. package/dist/editor/theme.js +2 -0
  109. package/dist/editor/undo-removal.d.ts +19 -10
  110. package/dist/editor/undo-removal.js +44 -16
  111. package/dist/version.d.ts +1 -1
  112. package/dist/version.js +1 -1
  113. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
- import { ActionIcon, Alert, Button, Center, Group, Progress, ScrollArea, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput, Tooltip, VisuallyHidden, } from '@mantine/core';
4
+ import { ActionIcon, Alert, Button, Center, Group, Progress, ScrollArea, SegmentedControl, Select, SimpleGrid, Stack, Switch, Text, TextInput, Tooltip, VisuallyHidden, } from '@mantine/core';
5
5
  import { Dropzone } from '@mantine/dropzone';
6
6
  import { openConfirm } from '../utils/confirm-modal.js';
7
7
  import { IconAlertCircle, IconGridDots, IconLayoutGrid, IconList, IconSearch, IconUpload, IconX, } from '@tabler/icons-react';
@@ -42,8 +42,14 @@ const DENSITY_OPTIONS = [
42
42
  },
43
43
  { value: 'list', label: _jsx(DensityLabel, { icon: _jsx(IconList, { size: 16 }), text: "List" }) },
44
44
  ];
45
+ /** A restored file's record: the same asset, no longer marked `hidden`. */
46
+ function withoutHidden(asset) {
47
+ const restored = { ...asset };
48
+ delete restored.hidden;
49
+ return restored;
50
+ }
45
51
  /**
46
- * Shared grid/list/upload/search/delete core for both MediaLibrary
52
+ * Shared grid/list/upload/search/remove core for both MediaLibrary
47
53
  * presentations (Drawer "manage" mode, Modal "picker" mode) - see
48
54
  * MediaLibrary.tsx for the two wrappers.
49
55
  */
@@ -51,40 +57,49 @@ export const MediaLibraryBody = ({ opened, mode, onSelect }) => {
51
57
  const client = useApiClient();
52
58
  const { baseUrl } = useAssetContext();
53
59
  const { userContext } = useUserContext();
54
- // Mirrors the server's rule in api/assets.ts's deleteAssetHandler: an admin
55
- // may delete anything, anyone else only what they uploaded. Kept per-asset
60
+ const admin = isAdmin(userContext?.groups);
61
+ // Mirrors the server's rule in api/assets.ts's visibilityRefusal: an admin may
62
+ // remove or restore any file, anyone else only what they uploaded. Kept per-asset
56
63
  // (not a single boolean) because ownership varies row by row. The server is
57
64
  // still the authority - this only decides whether to render the control.
58
- const canDeleteAsset = (asset) => {
65
+ const canChangeVisibility = (asset) => {
59
66
  if (mode !== 'manage')
60
67
  return false;
61
- if (isAdmin(userContext?.groups))
68
+ if (admin)
62
69
  return true;
63
70
  return !!asset.uploadedBy && asset.uploadedBy === userContext?.userId;
64
71
  };
72
+ // Only an admin in the manage drawer can list removed files, so a picker never offers one.
73
+ const [showRemovedChoice, setShowRemoved] = useState(false);
74
+ const showRemoved = showRemovedChoice && admin && mode === 'manage';
65
75
  const upload = useAssetUpload();
66
76
  // `picker` mode is used exclusively by ImageField and the MDX image dialog
67
77
  // (see MediaLibraryProps.mode's doc comment) - both image-only contexts.
68
78
  // `manage` mode (the Editor's Media Library drawer) keeps accepting PDFs.
69
79
  const imageOnly = mode === 'picker';
70
- const library = useAssetLibrary(opened);
80
+ const library = useAssetLibrary(opened, showRemoved);
71
81
  const { sort, density, setSort, setDensity } = useMediaLibraryPrefs();
72
82
  const [kind, setKind] = useState('all');
73
83
  const [filterText, setFilterText] = useState('');
74
84
  const [actionError, setActionError] = useState(null);
75
- // The result of the last remove, for the live region; the removed card itself is gone.
76
- // `id` re-keys the region's text, so a second remove with the same words is announced too.
85
+ // The result of the last remove or restore, for the live region; the card may be gone.
86
+ // `id` re-keys the region's text, so a second action with the same words is announced too.
77
87
  const [actionNotice, setActionNotice] = useState(null);
78
88
  const searchRef = useRef(null);
79
- // A removed card takes its focused button with it, so focus moves to the search field once the
80
- // removal has rendered (Mantine returns focus to the trash button when the confirm closes).
81
- const focusAfterRemove = useRef(false);
89
+ // A remove or restore takes the focused button with it (the card goes, or its button swaps), so
90
+ // focus moves to the search field once that has rendered (Mantine returns focus to the remove
91
+ // button when the confirm closes).
92
+ const focusAfterAction = useRef(false);
82
93
  useEffect(() => {
83
- if (!focusAfterRemove.current)
94
+ if (!focusAfterAction.current)
84
95
  return;
85
- focusAfterRemove.current = false;
96
+ focusAfterAction.current = false;
86
97
  searchRef.current?.focus();
87
98
  });
99
+ const announce = (text) => {
100
+ setActionNotice((prev) => ({ id: (prev?.id ?? 0) + 1, text }));
101
+ focusAfterAction.current = true;
102
+ };
88
103
  // Clearing hides the control that did it, so focus moves to the search field.
89
104
  const clearSearch = () => {
90
105
  setFilterText('');
@@ -142,39 +157,52 @@ export const MediaLibraryBody = ({ opened, mode, onSelect }) => {
142
157
  const handleReject = (rejections) => {
143
158
  setActionError(rejections[0]?.errors[0]?.message ?? 'The file was rejected.');
144
159
  };
145
- const handleDelete = async (asset) => {
160
+ /**
161
+ * Runs a remove or restore; `failure` words its error, and the raw server message is for admins.
162
+ * A non-admin's 403 from the API is the ownership refusal, so it says so (`refusal`) rather than
163
+ * to retry. Restore shows only to admins, so it has none.
164
+ */
165
+ const changeVisibility = async (request, failure, refusal, onDone) => {
146
166
  setActionError(null);
147
- const failure = (detail) => `Couldn't remove ${asset.filename} from the library. ${detail}`;
148
- // The raw server message is a diagnostic, shown to admins only.
149
- const generic = (serverMessage) => failure((isAdmin(userContext?.groups) && serverMessage) || 'Try again.');
167
+ const fail = (serverMessage) => setActionError(`${failure} ${(admin && serverMessage) || 'Try again.'}`);
150
168
  try {
151
- const result = await client.assets.delete({ key: asset.hash32 });
169
+ const result = await request();
152
170
  if (!result.ok) {
153
- // An editor sees this button only for their own upload, and the endpoint answers a
154
- // missing file with the same 403 as someone else's, so their 403 means it is gone.
155
- const alreadyGone = result.status === 403 && !isNonApiResponse(result) && !isAdmin(userContext?.groups);
156
- setActionError(alreadyGone ? failure('It may have been removed already.') : generic(result.error));
171
+ if (refusal && result.status === 403 && !admin && !isNonApiResponse(result)) {
172
+ setActionError(`${failure} ${refusal}`);
173
+ }
174
+ else {
175
+ fail(result.error);
176
+ }
157
177
  return;
158
178
  }
159
- library.remove(asset.hash32);
160
- setActionNotice((prev) => ({
161
- id: (prev?.id ?? 0) + 1,
162
- text: `Removed ${asset.filename} from library`,
163
- }));
164
- focusAfterRemove.current = true;
179
+ onDone();
165
180
  }
166
181
  catch (err) {
167
- setActionError(generic(getErrorMessage(err)));
182
+ fail(getErrorMessage(err));
168
183
  }
169
184
  };
170
- const handleDeleteClick = (asset) => {
185
+ const handleRemove = (asset) => changeVisibility(() => client.assets.hide({ key: asset.hash32 }), `Couldn't remove ${asset.filename} from the library.`, 'You can only remove files you uploaded. Ask an admin to remove it.', () => {
186
+ if (showRemoved)
187
+ library.replace({ ...asset, hidden: true });
188
+ else
189
+ library.remove(asset.hash32);
190
+ announce(`Removed ${asset.filename} from library`);
191
+ });
192
+ const handleRestore = (asset) => changeVisibility(() => client.assets.restore({ key: asset.hash32 }), `Couldn't restore ${asset.filename} to the library.`, null, () => {
193
+ library.replace(withoutHidden(asset));
194
+ announce(`Restored ${asset.filename} to library`);
195
+ });
196
+ // A confirm, not an Undo: only an admin can restore, from the "Show removed files" view.
197
+ const handleRemoveClick = (asset) => {
171
198
  openConfirm({
172
199
  title: 'Remove from library',
173
- children: (_jsxs(Text, { size: "sm", children: ["Remove \u201C", asset.filename, "\u201D from the media library? Content that already uses this file keeps working, and the file itself is not deleted."] })),
200
+ children: (_jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "sm", children: ["\u201C", asset.filename, "\u201D will no longer be listed in the media library or offered when choosing an image. Entries that already use it keep working, including published and earlier versions of the site."] }), _jsx(Text, { size: "sm", children: admin
201
+ ? 'To restore it, turn on “Show removed files”, or upload it again.'
202
+ : 'To restore it, upload it again or ask an admin.' })] })),
174
203
  labels: { confirm: 'Remove from library', cancel: 'Cancel' },
175
- confirmProps: { color: 'red' },
176
204
  onConfirm: () => {
177
- void handleDelete(asset);
205
+ void handleRemove(asset);
178
206
  },
179
207
  });
180
208
  };
@@ -228,9 +256,9 @@ export const MediaLibraryBody = ({ opened, mode, onSelect }) => {
228
256
  })();
229
257
  const renderList = () => {
230
258
  if (density === 'list') {
231
- return (_jsx(Stack, { gap: 4, children: visibleAssets.map((asset) => (_jsx(AssetListRow, { asset: asset, baseUrl: baseUrl, onSelect: mode === 'picker' ? onSelect : undefined, onDelete: canDeleteAsset(asset) ? () => handleDeleteClick(asset) : undefined }, asset.hash32))) }));
259
+ return (_jsx(Stack, { gap: 4, children: visibleAssets.map((asset) => (_jsx(AssetListRow, { asset: asset, baseUrl: baseUrl, onSelect: mode === 'picker' ? onSelect : undefined, onRemove: canChangeVisibility(asset) ? handleRemoveClick : undefined, onRestore: canChangeVisibility(asset) ? handleRestore : undefined }, asset.hash32))) }));
232
260
  }
233
- return (_jsx(SimpleGrid, { cols: GRID_COLUMNS[density], spacing: "sm", children: visibleAssets.map((asset) => (_jsx(AssetCard, { asset: asset, baseUrl: baseUrl, density: density, onSelect: mode === 'picker' ? onSelect : undefined, onDelete: canDeleteAsset(asset) ? () => handleDeleteClick(asset) : undefined }, asset.hash32))) }));
261
+ return (_jsx(SimpleGrid, { cols: GRID_COLUMNS[density], spacing: "sm", children: visibleAssets.map((asset) => (_jsx(AssetCard, { asset: asset, baseUrl: baseUrl, density: density, onSelect: mode === 'picker' ? onSelect : undefined, onRemove: canChangeVisibility(asset) ? handleRemoveClick : undefined, onRestore: canChangeVisibility(asset) ? handleRestore : undefined }, asset.hash32))) }));
234
262
  };
235
263
  const listContent = (_jsxs(_Fragment, { children: [!library.firstPageLoaded && !library.error && visibleAssets.length === 0 && (_jsx(MediaLibrarySkeleton, { density: density })), visibleAssets.length > 0 && renderList(), emptyState && (_jsxs(Stack, { align: "center", gap: "xs", py: "xl", "data-testid": "media-library-empty", children: [_jsx(Text, { size: "sm", c: "dimmed", ta: "center", children: emptyState }), _jsxs(Group, { gap: "xs", justify: "center", children: [needle && (_jsx(Button, { variant: "light", size: "xs", onClick: clearSearch, children: "Clear search" })), kind !== 'all' && !libraryEmpty && (_jsx(Button, { variant: "light", size: "xs", onClick: () => setKind('all'), children: "Show all files" }))] })] })), hasMore && (_jsxs(_Fragment, { children: [_jsx("div", { ref: setSentinel, "aria-hidden": "true", style: { height: 1 } }), _jsx(Group, { justify: "center", py: "sm", children: _jsx(Button, { variant: "light", onClick: showMore, "data-testid": "media-library-show-more", children: "Show more" }) })] }))] }));
236
264
  return (
@@ -242,7 +270,7 @@ export const MediaLibraryBody = ({ opened, mode, onSelect }) => {
242
270
  (upload.progress !== null ? (_jsx(Progress, { value: upload.progress * 100, size: "sm", "data-testid": "upload-progress" })) : (_jsx(Progress, { value: 100, size: "sm", animated: true, "data-testid": "upload-progress-indeterminate" }))), upload.error && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", "data-testid": "upload-error", children: upload.error })), _jsx(TextInput, { placeholder: "Search by filename", "aria-label": "Search by filename", leftSection: _jsx(IconSearch, { size: 16 }), rightSection: filterText ? (_jsx(Tooltip, { label: "Clear search", children: _jsx(ActionIcon, { variant: "subtle", color: "gray", size: "sm", "aria-label": "Clear search", onClick: clearSearch, children: _jsx(IconX, { size: 14 }) }) })) : null, ref: searchRef, value: filterText, onChange: (event) => setFilterText(event.currentTarget.value), "data-mantine-stop-propagation": filterText ? 'true' : undefined, onKeyDown: (event) => {
243
271
  if (event.key === 'Escape' && filterText)
244
272
  setFilterText('');
245
- }, "data-testid": "media-library-filter" }), _jsxs(Group, { justify: "space-between", wrap: "wrap", gap: "xs", children: [imageOnly ? (_jsx("span", {})) : (_jsx(SegmentedControl, { "aria-label": "File type", data: KIND_OPTIONS, value: kind, onChange: (value) => setKind(KIND_OPTIONS.find((o) => o.value === value)?.value ?? 'all'), "data-testid": "media-library-kind" })), _jsxs(Group, { gap: "xs", wrap: "wrap", children: [_jsx(Select, { "aria-label": "Sort by", data: SORT_OPTIONS, value: sort, onChange: (value) => {
273
+ }, "data-testid": "media-library-filter" }), _jsxs(Group, { justify: "space-between", wrap: "wrap", gap: "xs", children: [imageOnly ? (_jsx("span", {})) : (_jsxs(Group, { gap: "md", wrap: "wrap", children: [_jsx(SegmentedControl, { "aria-label": "File type", data: KIND_OPTIONS, value: kind, onChange: (value) => setKind(KIND_OPTIONS.find((o) => o.value === value)?.value ?? 'all'), "data-testid": "media-library-kind" }), admin && mode === 'manage' && (_jsx(Switch, { label: "Show removed files", checked: showRemoved, onChange: (event) => setShowRemoved(event.currentTarget.checked), "data-testid": "media-library-show-removed" }))] })), _jsxs(Group, { gap: "xs", wrap: "wrap", children: [_jsx(Select, { "aria-label": "Sort by", data: SORT_OPTIONS, value: sort, onChange: (value) => {
246
274
  const next = SORT_OPTIONS.find((option) => option.value === value);
247
275
  if (next)
248
276
  setSort(next.value);
@@ -15,16 +15,19 @@ export interface UseAssetLibraryResult {
15
15
  retry: () => void;
16
16
  /** Puts an uploaded asset in the list; the one already loaded under its hash is replaced. */
17
17
  prepend: (asset: AssetRecord) => void;
18
- /** Drops a deleted asset, including from a page still on its way. */
18
+ /** Drops a removed asset, including from a page still on its way. */
19
19
  remove: (hash32: string) => void;
20
+ /** Replaces the loaded asset with the same hash in place, such as one just removed or restored. */
21
+ replace: (asset: AssetRecord) => void;
20
22
  }
21
23
  /**
22
24
  * Loads the whole library in the background while `opened`: page 1 first, then each following
23
25
  * page in turn, so search and sort see every asset. Closing (or unmounting) stops the walk, and
24
- * reopening starts again from page 1.
26
+ * reopening, or changing `includeHidden`, starts again from page 1. `includeHidden` (admins only)
27
+ * also lists the files removed from the library, each marked `hidden`.
25
28
  *
26
29
  * Finalize dedups content, so a re-uploaded file comes back as an existing record that may also
27
30
  * arrive in a later page; assets are merged by `hash32`.
28
31
  */
29
- export declare function useAssetLibrary(opened: boolean): UseAssetLibraryResult;
32
+ export declare function useAssetLibrary(opened: boolean, includeHidden?: boolean): UseAssetLibraryResult;
30
33
  export {};
@@ -18,12 +18,13 @@ function mergeAssets(existing, incoming, removed) {
18
18
  /**
19
19
  * Loads the whole library in the background while `opened`: page 1 first, then each following
20
20
  * page in turn, so search and sort see every asset. Closing (or unmounting) stops the walk, and
21
- * reopening starts again from page 1.
21
+ * reopening, or changing `includeHidden`, starts again from page 1. `includeHidden` (admins only)
22
+ * also lists the files removed from the library, each marked `hidden`.
22
23
  *
23
24
  * Finalize dedups content, so a re-uploaded file comes back as an existing record that may also
24
25
  * arrive in a later page; assets are merged by `hash32`.
25
26
  */
26
- export function useAssetLibrary(opened) {
27
+ export function useAssetLibrary(opened, includeHidden = false) {
27
28
  const client = useApiClient();
28
29
  const [assets, setAssets] = useState([]);
29
30
  const [firstPageLoaded, setFirstPageLoaded] = useState(false);
@@ -44,9 +45,11 @@ export function useAssetLibrary(opened) {
44
45
  let page;
45
46
  let message = '';
46
47
  try {
47
- const result = await client.assets.list(cursor === undefined
48
- ? { limit: String(PAGE_SIZE) }
49
- : { cursor, limit: String(PAGE_SIZE) });
48
+ const result = await client.assets.list({
49
+ limit: String(PAGE_SIZE),
50
+ ...(cursor !== undefined && { cursor }),
51
+ ...(includeHidden && { includeHidden: 'true' }),
52
+ });
50
53
  if (result.ok && result.data)
51
54
  page = result.data;
52
55
  else
@@ -74,7 +77,7 @@ export function useAssetLibrary(opened) {
74
77
  return;
75
78
  }
76
79
  // A cursor seen before means the listing moved under the walk (the local store restarts
77
- // from page 1 when its anchor asset is deleted), so pages may be missing: never report
80
+ // from page 1 when its anchor's meta file is gone), so pages may be missing: never report
78
81
  // that as complete. Retry starts over.
79
82
  if (requested.current.has(next)) {
80
83
  failedCursor.current = undefined;
@@ -83,7 +86,7 @@ export function useAssetLibrary(opened) {
83
86
  }
84
87
  cursor = next;
85
88
  }
86
- }, [client]);
89
+ }, [client, includeHidden]);
87
90
  const start = useCallback(() => {
88
91
  generation.current += 1;
89
92
  requested.current = new Set();
@@ -102,8 +105,9 @@ export function useAssetLibrary(opened) {
102
105
  return () => {
103
106
  generation.current += 1;
104
107
  };
105
- // Restart only when the drawer/modal (re)opens, not when `start`'s identity changes.
106
- }, [opened]);
108
+ // Restart only when the drawer/modal (re)opens or the listing changes, not when `start`'s
109
+ // identity changes.
110
+ }, [opened, includeHidden]);
107
111
  const retry = useCallback(() => {
108
112
  const cursor = failedCursor.current;
109
113
  if (cursor === undefined) {
@@ -122,5 +126,8 @@ export function useAssetLibrary(opened) {
122
126
  removed.current.add(hash32);
123
127
  setAssets((prev) => prev.filter((existing) => existing.hash32 !== hash32));
124
128
  }, []);
125
- return { assets, firstPageLoaded, complete, error, retry, prepend, remove };
129
+ const replace = useCallback((asset) => {
130
+ setAssets((prev) => prev.map((existing) => (existing.hash32 === asset.hash32 ? asset : existing)));
131
+ }, []);
132
+ return { assets, firstPageLoaded, complete, error, retry, prepend, remove, replace };
126
133
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The height of the bar atop each half of the main region, the preview toolbar and the form
3
+ * header, so their bottom borders line up side by side. Border-box: it includes the bar's border,
4
+ * and the preview pane's own top border above its toolbar.
5
+ */
6
+ export declare const PANE_HEADER_HEIGHT = 40;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The height of the bar atop each half of the main region, the preview toolbar and the form
3
+ * header, so their bottom borders line up side by side. Border-box: it includes the bar's border,
4
+ * and the preview pane's own top border above its toolbar.
5
+ */
6
+ export const PANE_HEADER_HEIGHT = 40;
@@ -111,7 +111,7 @@ const PermissionManagerDrawer = ({ opened, onClose, onExited, returnFocusTo, col
111
111
  allowedUsers: currentTarget.allowedUsers,
112
112
  });
113
113
  }, [annotatedTree, localPermissions, updateNodePermission]);
114
- return (_jsx(StagedChangesDrawer, { opened: opened, onClose: onClose, title: "Permissions", description: "Manage content access by path (read, edit, review)", size: 700, isDirty: isDirty, isSaving: isSaving, canEdit: canEdit, saveLabel: "Save permissions", onSave: handleSave, onDiscard: handleDiscard, onExited: onExited, returnFocusTo: returnFocusTo, children: _jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, children: [!canEdit && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "yellow", mb: "sm", title: "Read-only", children: "You need admin access to edit permissions" })), error && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: errorIsConflict ? 'yellow' : 'red', mb: "sm", title: errorIsConflict ? 'Not saved' : 'Error', withCloseButton: true, onClose: () => setError(null), children: error })), groupLoadError && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "orange", mb: "sm", title: "Warning", withCloseButton: true, onClose: clearGroupLoadError, children: groupLoadError })), _jsxs(Group, { gap: "xs", pb: "sm", children: [_jsx(Button, { variant: "subtle", onClick: expandAll, children: "Expand all" }), _jsx(Button, { variant: "subtle", onClick: collapseAll, children: "Collapse all" })] }), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: loading ? (_jsxs(Group, { justify: "center", py: "xl", children: [_jsx(Loader, { size: "md" }), _jsx(Text, { size: "sm", c: "dimmed", children: "Loading permissions..." })] })) : (_jsx(PermissionTree, { node: annotatedTree, expandedNodes: expandedNodes, selectedNode: selectedNode, canEdit: canEdit, groups: groupSelectData, activeLevel: activeLevel, userSearchResults: userSearchResults, isSearchingUsers: isSearchingUsers, showUserSearch: showUserSearch, userSearchQuery: userSearchQuery, userSearchError: userSearchError, showGroupSearch: showGroupSearch, groupSearchQuery: groupSearchQuery, filteredGroups: filteredGroups, onToggle: toggleNode, onSelect: selectNode, onSetActiveLevel: setActiveLevel, onUpdatePermission: updateNodePermission, onSearchUsers: setUserSearchQuery, onGetUserMetadata: onGetUserMetadata, onToggleUserSearch: toggleUserSearch, onAddUser: handleAddUser, onRemoveUser: handleRemoveUser, onSearchGroups: setGroupSearchQuery, onToggleGroupSearch: setShowGroupSearch, onAddGroup: handleAddGroup, onRemoveGroup: handleRemoveGroup })) })] }) }));
114
+ return (_jsx(StagedChangesDrawer, { opened: opened, onClose: onClose, title: "Permissions", size: 700, isDirty: isDirty, isSaving: isSaving, canEdit: canEdit, saveLabel: "Save permissions", onSave: handleSave, onDiscard: handleDiscard, onExited: onExited, returnFocusTo: returnFocusTo, children: _jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, children: [!canEdit && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "yellow", mb: "sm", title: "Read-only", children: "You need admin access to edit permissions" })), error && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: errorIsConflict ? 'yellow' : 'red', mb: "sm", title: errorIsConflict ? 'Not saved' : 'Error', withCloseButton: true, onClose: () => setError(null), children: error })), groupLoadError && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "orange", mb: "sm", title: "Warning", withCloseButton: true, onClose: clearGroupLoadError, children: groupLoadError })), _jsxs(Group, { gap: "xs", pb: "sm", children: [_jsx(Button, { variant: "subtle", onClick: expandAll, children: "Expand all" }), _jsx(Button, { variant: "subtle", onClick: collapseAll, children: "Collapse all" })] }), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: loading ? (_jsxs(Group, { justify: "center", py: "xl", children: [_jsx(Loader, { size: "md" }), _jsx(Text, { size: "sm", c: "dimmed", children: "Loading permissions..." })] })) : (_jsx(PermissionTree, { node: annotatedTree, expandedNodes: expandedNodes, selectedNode: selectedNode, canEdit: canEdit, groups: groupSelectData, activeLevel: activeLevel, userSearchResults: userSearchResults, isSearchingUsers: isSearchingUsers, showUserSearch: showUserSearch, userSearchQuery: userSearchQuery, userSearchError: userSearchError, showGroupSearch: showGroupSearch, groupSearchQuery: groupSearchQuery, filteredGroups: filteredGroups, onToggle: toggleNode, onSelect: selectNode, onSetActiveLevel: setActiveLevel, onUpdatePermission: updateNodePermission, onSearchUsers: setUserSearchQuery, onGetUserMetadata: onGetUserMetadata, onToggleUserSearch: toggleUserSearch, onAddUser: handleAddUser, onRemoveUser: handleRemoveUser, onSearchGroups: setGroupSearchQuery, onToggleGroupSearch: setShowGroupSearch, onAddGroup: handleAddGroup, onRemoveGroup: handleRemoveGroup })) })] }) }));
115
115
  };
116
116
  export { usePermissionTree } from './hooks/usePermissionTree.js';
117
117
  export { useGroupsAndUsers } from './hooks/useGroupsAndUsers.js';
@@ -49,6 +49,16 @@ export declare const isOpaqueOrigin: (origin: string) => boolean;
49
49
  * expected editor origin (same-origin unless `editorOrigin` is configured).
50
50
  */
51
51
  export declare const isTrustedEditorMessage: (event: MessageEvent, editorOrigin?: string) => boolean;
52
+ /**
53
+ * Preview → editor, once the page listens for drafts: the editor answers with the current draft.
54
+ * `siteChrome` says the page renders the site's header and footer when its URL carries
55
+ * `chrome=1`, so the editor offers the toggle that adds it.
56
+ */
57
+ export interface PreviewReadyMessage {
58
+ type: typeof CANOPY_PREVIEW_READY;
59
+ path: string;
60
+ siteChrome?: boolean;
61
+ }
52
62
  export interface PreviewFocusMessage {
53
63
  type: typeof CANOPY_PREVIEW_FOCUS;
54
64
  entryPath: string;
@@ -93,6 +103,11 @@ export declare const useCanopyPreview: <T>(opts: {
93
103
  initialData: T;
94
104
  /** Editor origin to trust for preview messages. Defaults to this page's own origin. */
95
105
  editorOrigin?: string;
106
+ /**
107
+ * This page renders the site's header and footer when its URL carries `chrome=1`, so the editor
108
+ * offers a toggle for them; see `PreviewReadyMessage`.
109
+ */
110
+ siteChrome?: boolean;
96
111
  }) => {
97
112
  data: T;
98
113
  isLoading: PreviewLoadingState<T>;
@@ -106,6 +121,7 @@ export declare const useCanopyPreview: <T>(opts: {
106
121
  */
107
122
  export declare const usePreviewData: <T>(path: string, initialData: T, opts?: {
108
123
  editorOrigin?: string;
124
+ siteChrome?: boolean;
109
125
  }) => {
110
126
  data: T;
111
127
  isLoading: PreviewLoadingState<T>;
@@ -73,7 +73,10 @@ export const useCanopyPreview = (opts) => {
73
73
  const resolvedPath = resolvePreviewPath(opts.path);
74
74
  const editorOrigin = opts.editorOrigin;
75
75
  const bridgeOpts = { editorOrigin };
76
- const { data, isLoading } = usePreviewData(resolvedPath, opts.initialData, bridgeOpts);
76
+ const { data, isLoading } = usePreviewData(resolvedPath, opts.initialData, {
77
+ editorOrigin,
78
+ siteChrome: opts.siteChrome,
79
+ });
77
80
  const highlightEnabled = usePreviewHighlight(bridgeOpts);
78
81
  usePreviewFocusEmitter(resolvedPath, bridgeOpts);
79
82
  const fieldProps = createFieldProps();
@@ -107,6 +110,7 @@ export const usePreviewData = (path, initialData, opts) => {
107
110
  // Every key of the loading state is optional, so an empty object is a valid one.
108
111
  const [isLoading, setIsLoading] = useState({});
109
112
  const editorOrigin = opts?.editorOrigin;
113
+ const siteChrome = opts?.siteChrome === true;
110
114
  useEffect(() => {
111
115
  // Only listen when actually framed by an editor; a standalone page (including
112
116
  // one opened via window.open from a hostile site) must never accept drafts.
@@ -133,10 +137,15 @@ export const usePreviewData = (path, initialData, opts) => {
133
137
  // so the first postMessage from the parent may arrive before this listener is set up.
134
138
  const target = resolveMessageOrigin(editorOrigin);
135
139
  if (!isOpaqueOrigin(target)) {
136
- window.parent.postMessage({ type: CANOPY_PREVIEW_READY, path }, target);
140
+ const ready = {
141
+ type: CANOPY_PREVIEW_READY,
142
+ path,
143
+ ...(siteChrome ? { siteChrome } : {}),
144
+ };
145
+ window.parent.postMessage(ready, target);
137
146
  }
138
147
  return () => window.removeEventListener('message', handler);
139
- }, [path, editorOrigin]);
148
+ }, [path, editorOrigin, siteChrome]);
140
149
  return { data, isLoading };
141
150
  };
142
151
  /** React 18 warns about a layout effect in a server render, where no effect runs. */
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Reduces a preview URL to what identifies the page, its path and query, with no origin,
3
- * fragment or trailing slash. The editor names a page by the `src` it built and the framed page
4
- * by its own location. An absolute `src`, or a host redirect that adds or drops a trailing slash,
5
- * spells the same page two ways, and the bridge must still match them.
3
+ * fragment, trailing slash or `chrome=1`. The editor names a page by the `src` it built and the
4
+ * framed page by its own location. An absolute `src`, a host redirect that adds or drops a
5
+ * trailing slash, or the site header and footer toggle, spells the same page two ways, and the
6
+ * bridge must still match them.
6
7
  * @internal Exported for tests.
7
8
  */
8
9
  export declare const normalizePreviewPath: (url: string) => string;
@@ -13,9 +14,15 @@ export interface PreviewMount {
13
14
  basePath?: string;
14
15
  previewPrefix?: string;
15
16
  }
17
+ /**
18
+ * `src` asking for the site's header and footer, or `undefined` when it cannot. Only a page under
19
+ * the `previewPrefix` mount is a `createPreviewPage` route, which reads `chrome=1`; any other URL
20
+ * is a page of the site itself, which the editor leaves as it is.
21
+ */
22
+ export declare const siteChromeSrc: (src: string, mount?: PreviewMount) => string | undefined;
16
23
  /**
17
24
  * The previewed page's path on the site, as the preview toolbar shows and copies it: without the
18
- * `previewPrefix` mount, the `branch` query the top bar already names, or a fragment. A relative
25
+ * `previewPrefix` mount, the `branch` query the top bar already names, `chrome=1`, or a fragment. A relative
19
26
  * URL stays relative. An absolute one keeps its origin, since it may be another site, unless an
20
27
  * absolute `previewPrefix` mount is what was taken out.
21
28
  */
@@ -1,9 +1,27 @@
1
1
  import { isAbsoluteUrl, joinUrlPrefix, stripTrailingSlashes } from '../utils/url-prefix.js';
2
+ /**
3
+ * The query part that asks a `createPreviewPage` route to render the site's header and footer
4
+ * (`canopycms-next`'s `layout` option). It changes how the page looks, not which page it is.
5
+ */
6
+ const SITE_CHROME_PART = 'chrome=1';
7
+ /**
8
+ * `search` without the parts `keep` refuses, as `?…`, or '' when none is left. Filtered as text:
9
+ * a URLSearchParams round trip would form-encode the rest (`flag` becomes `flag=`, `%20` `+`).
10
+ */
11
+ const filterQuery = (search, keep) => {
12
+ const query = search
13
+ .slice(1)
14
+ .split('&')
15
+ .filter((part) => part !== '' && keep(part))
16
+ .join('&');
17
+ return query ? `?${query}` : '';
18
+ };
2
19
  /**
3
20
  * Reduces a preview URL to what identifies the page, its path and query, with no origin,
4
- * fragment or trailing slash. The editor names a page by the `src` it built and the framed page
5
- * by its own location. An absolute `src`, or a host redirect that adds or drops a trailing slash,
6
- * spells the same page two ways, and the bridge must still match them.
21
+ * fragment, trailing slash or `chrome=1`. The editor names a page by the `src` it built and the
22
+ * framed page by its own location. An absolute `src`, a host redirect that adds or drops a
23
+ * trailing slash, or the site header and footer toggle, spells the same page two ways, and the
24
+ * bridge must still match them.
7
25
  * @internal Exported for tests.
8
26
  */
9
27
  export const normalizePreviewPath = (url) => {
@@ -14,27 +32,58 @@ export const normalizePreviewPath = (url) => {
14
32
  catch {
15
33
  return url;
16
34
  }
17
- return `${stripTrailingSlashes(parsed.pathname) || '/'}${parsed.search}`;
35
+ const query = filterQuery(parsed.search, (part) => part !== SITE_CHROME_PART);
36
+ return `${stripTrailingSlashes(parsed.pathname) || '/'}${query}`;
18
37
  };
19
38
  /** Whether two preview URLs name the same page; see `normalizePreviewPath`. */
20
39
  export const isSamePreviewPath = (a, b) => normalizePreviewPath(a) === normalizePreviewPath(b);
21
- /** `url` with the `previewPrefix` mount taken out, so `/base/preview/posts/x` reads `/base/posts/x`. */
22
- const stripPreviewPrefix = (url, { basePath, previewPrefix }) => {
40
+ /** The `previewPrefix` mount `url` sits under, and what follows it; `undefined` when not under one. */
41
+ const splitPreviewPrefix = (url, { basePath, previewPrefix }) => {
23
42
  if (!previewPrefix || !stripTrailingSlashes(previewPrefix))
24
- return url;
43
+ return undefined;
25
44
  const mount = stripTrailingSlashes(joinUrlPrefix(basePath, joinUrlPrefix(previewPrefix, '/')));
26
45
  if (!url.startsWith(mount))
27
- return url;
46
+ return undefined;
28
47
  const rest = url.slice(mount.length);
29
- if (rest !== '' && !/^[/?#]/.test(rest))
48
+ return rest === '' || /^[/?#]/.test(rest) ? { mount, rest } : undefined;
49
+ };
50
+ /** `url` with the `previewPrefix` mount taken out, so `/base/preview/posts/x` reads `/base/posts/x`. */
51
+ const stripPreviewPrefix = (url, previewMount) => {
52
+ const split = splitPreviewPrefix(url, previewMount);
53
+ if (!split)
30
54
  return url;
55
+ const { mount, rest } = split;
31
56
  // An absolute prefix skips `basePath`, so the page under it has none either.
32
- const base = isAbsoluteUrl(mount) ? '' : stripTrailingSlashes(joinUrlPrefix(basePath, '/'));
57
+ const base = isAbsoluteUrl(mount)
58
+ ? ''
59
+ : stripTrailingSlashes(joinUrlPrefix(previewMount.basePath, '/'));
33
60
  return rest.startsWith('/') ? `${base}${rest}` : `${base || '/'}${rest}`;
34
61
  };
62
+ /**
63
+ * `src` asking for the site's header and footer, or `undefined` when it cannot. Only a page under
64
+ * the `previewPrefix` mount is a `createPreviewPage` route, which reads `chrome=1`; any other URL
65
+ * is a page of the site itself, which the editor leaves as it is.
66
+ */
67
+ export const siteChromeSrc = (src, mount = {}) => {
68
+ if (!splitPreviewPrefix(src, mount))
69
+ return undefined;
70
+ const hashIndex = src.indexOf('#');
71
+ const beforeHash = hashIndex === -1 ? src : src.slice(0, hashIndex);
72
+ const hash = hashIndex === -1 ? '' : src.slice(hashIndex);
73
+ const queryIndex = beforeHash.indexOf('?');
74
+ if (queryIndex !== -1 &&
75
+ beforeHash
76
+ .slice(queryIndex + 1)
77
+ .split('&')
78
+ .includes(SITE_CHROME_PART)) {
79
+ return src;
80
+ }
81
+ const separator = queryIndex === -1 ? '?' : beforeHash.endsWith('?') ? '' : '&';
82
+ return `${beforeHash}${separator}${SITE_CHROME_PART}${hash}`;
83
+ };
35
84
  /**
36
85
  * The previewed page's path on the site, as the preview toolbar shows and copies it: without the
37
- * `previewPrefix` mount, the `branch` query the top bar already names, or a fragment. A relative
86
+ * `previewPrefix` mount, the `branch` query the top bar already names, `chrome=1`, or a fragment. A relative
38
87
  * URL stays relative. An absolute one keeps its origin, since it may be another site, unless an
39
88
  * absolute `previewPrefix` mount is what was taken out.
40
89
  */
@@ -47,14 +96,8 @@ export const displayPreviewPath = (src, mount = {}) => {
47
96
  catch {
48
97
  return url;
49
98
  }
50
- // Filtered as text: a URLSearchParams round trip would form-encode the query it keeps (`flag`
51
- // becomes `flag=`, `%20` becomes `+`).
52
- const query = parsed.search
53
- .slice(1)
54
- .split('&')
55
- .filter((part) => part !== '' && part.split('=', 1)[0] !== 'branch')
56
- .join('&');
57
- const path = `${parsed.pathname}${query ? `?${query}` : ''}`;
99
+ const query = filterQuery(parsed.search, (part) => part !== SITE_CHROME_PART && part.split('=', 1)[0] !== 'branch');
100
+ const path = `${parsed.pathname}${query}`;
58
101
  if (!isAbsoluteUrl(url))
59
102
  return path;
60
103
  // `origin` is 'null' for a scheme the URL spec does not treat as special.
@@ -1,6 +1,6 @@
1
1
  import React, { type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
2
  export interface ActivityRailProps {
3
- /** Rail buttons from the top: the monogram, then the activities. */
3
+ /** Rail buttons from the top: the activities. */
4
4
  top: ReactNode;
5
5
  /** Rail buttons pinned to the bottom, such as Settings. */
6
6
  bottom?: ReactNode;
@@ -22,8 +22,6 @@ export interface RailButtonProps extends Omit<ComponentPropsWithoutRef<'button'>
22
22
  icon: ReactNode;
23
23
  /** Highlights the button while its panel or drawer is open. */
24
24
  active?: boolean;
25
- /** Tinted at rest, as the site monogram is. */
26
- tinted?: boolean;
27
25
  /** Shown after the label in the tooltip, as the platform writes it ("⌘P"). */
28
26
  shortcut?: string;
29
27
  /** A count badge, hidden at zero; the label should say it too. */
@@ -75,11 +75,11 @@ export function ActivityRail({ top, bottom, account }) {
75
75
  }, children: [top, _jsx("div", { style: { flex: 1 } }), bottom] }), account && _jsx(Stack, { pb: 8, children: account })] }));
76
76
  }
77
77
  /** A rail button: icon only, named by `label`, which its tooltip repeats on hover and focus. */
78
- export const RailButton = forwardRef(function RailButton({ railId, label, icon, active = false, tinted = false, shortcut, count = 0, countTestId, tooltipDisabled, disabledReason, onClick, ...rest }, ref) {
78
+ export const RailButton = forwardRef(function RailButton({ railId, label, icon, active = false, shortcut, count = 0, countTestId, tooltipDisabled, disabledReason, onClick, ...rest }, ref) {
79
79
  const reasonId = useId();
80
80
  const button = (_jsxs(ActionIcon, { ref: ref,
81
- // Filled, not tinted, when active: a tint alone would show the state by colour, under 3:1.
82
- variant: active ? 'filled' : tinted ? 'light' : 'subtle', color: active || tinted ? undefined : 'gray', size: "lg", radius: "md", "aria-label": label, ...rest, "aria-disabled": disabledReason ? true : rest['aria-disabled'], "aria-describedby": disabledReason ? reasonId : rest['aria-describedby'], "data-disabled": disabledReason ? true : undefined, onClick: disabledReason ? undefined : onClick, [RAIL_ITEM_ATTR]: railId, children: [icon, disabledReason && (_jsx(VisuallyHidden, { component: "span", id: reasonId, children: disabledReason }))] }));
81
+ // Filled when active: a tint alone would show the state by colour, under 3:1.
82
+ variant: active ? 'filled' : 'subtle', color: active ? undefined : 'gray', size: "lg", radius: "md", "aria-label": label, ...rest, "aria-disabled": disabledReason ? true : rest['aria-disabled'], "aria-describedby": disabledReason ? reasonId : rest['aria-describedby'], "data-disabled": disabledReason ? true : undefined, onClick: disabledReason ? undefined : onClick, [RAIL_ITEM_ATTR]: railId, children: [icon, disabledReason && (_jsx(VisuallyHidden, { component: "span", id: reasonId, children: disabledReason }))] }));
83
83
  return (_jsx(Tooltip, { label: disabledReason ?? (shortcut ? `${label} (${shortcut})` : label), position: "right", disabled: tooltipDisabled, children: _jsx(Indicator, { label: _jsx("span", { "aria-hidden": true, "data-testid": countTestId, children: count }), size: 16, offset: 4, withBorder: true, disabled: count === 0, styles: {
84
84
  indicator: {
85
85
  pointerEvents: 'none',
@@ -8,6 +8,21 @@ export interface EditorShellProps {
8
8
  /** Drawers, modals and palettes, each portalling itself into `<body>`. */
9
9
  overlays?: ReactNode;
10
10
  }
11
+ /** The line between two shell regions, as under the top bar and a shown banner. */
12
+ export declare const SHELL_REGION_BORDER = "1px solid var(--canopy-input-border)";
13
+ /**
14
+ * Every resizable divider in the shell, the panel's and the panes', shows this many pixels wide.
15
+ * Its pointer target is wider, `DIVIDER_TARGET_PX`. A finger's reaches 6px past each side of the
16
+ * bar: under the panes' separator, the narrow window's overlay panel keeps its header's close
17
+ * button clear, as that header's top padding is 10px.
18
+ */
19
+ export declare const DIVIDER_WIDTH_PX = 4;
20
+ export declare const DIVIDER_TARGET_PX: {
21
+ readonly fine: 12;
22
+ readonly coarse: 16;
23
+ };
24
+ /** A divider's colour at rest; held, hovered, dragged or focused it takes `--canopy-focus-ring`. */
25
+ export declare const DIVIDER_COLOR = "var(--canopy-input-border)";
11
26
  /**
12
27
  * The editor's window: a full-viewport grid of regions (top bar, banner, rail, panel, main,
13
28
  * overlays), portalled into `<body>` above the host page, whose chrome it hides and makes inert