canopycms 0.1.0 → 0.2.0-int.121

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 (136) hide show
  1. package/CHANGELOG.md +202 -43
  2. package/dist/api/assets.d.ts +27 -7
  3. package/dist/api/assets.js +118 -47
  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/editor-thumbnail-widths.d.ts +11 -0
  10. package/dist/assets/editor-thumbnail-widths.js +7 -0
  11. package/dist/assets/finalize.d.ts +7 -6
  12. package/dist/assets/finalize.js +29 -10
  13. package/dist/assets/keys.d.ts +4 -1
  14. package/dist/assets/keys.js +4 -1
  15. package/dist/assets/materialize.d.ts +17 -2
  16. package/dist/assets/materialize.js +57 -4
  17. package/dist/assets/store-local.d.ts +18 -9
  18. package/dist/assets/store-local.js +58 -26
  19. package/dist/assets/store-s3.d.ts +25 -6
  20. package/dist/assets/store-s3.js +81 -19
  21. package/dist/assets/types.d.ts +33 -4
  22. package/dist/cli/cli.js +151 -56
  23. package/dist/config/types.d.ts +3 -2
  24. package/dist/editor/BranchManager.js +2 -1
  25. package/dist/editor/CommentsPanel.js +67 -29
  26. package/dist/editor/Editor.d.ts +3 -0
  27. package/dist/editor/Editor.js +93 -39
  28. package/dist/editor/EntryNavigator.d.ts +17 -5
  29. package/dist/editor/EntryNavigator.js +66 -31
  30. package/dist/editor/FormHeader.d.ts +2 -1
  31. package/dist/editor/FormHeader.js +9 -5
  32. package/dist/editor/FormRenderer.d.ts +7 -2
  33. package/dist/editor/FormRenderer.js +51 -16
  34. package/dist/editor/PreviewFrame.d.ts +13 -2
  35. package/dist/editor/PreviewFrame.js +62 -5
  36. package/dist/editor/PreviewPane.d.ts +17 -1
  37. package/dist/editor/PreviewPane.js +55 -8
  38. package/dist/editor/PreviewToolbarTools.d.ts +8 -0
  39. package/dist/editor/PreviewToolbarTools.js +9 -1
  40. package/dist/editor/branch-status.d.ts +6 -0
  41. package/dist/editor/branch-status.js +10 -0
  42. package/dist/editor/comments/BranchComments.d.ts +2 -4
  43. package/dist/editor/comments/BranchComments.js +7 -11
  44. package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
  45. package/dist/editor/comments/ThreadCarousel.js +4 -49
  46. package/dist/editor/comments/ThreadReplyControls.js +5 -2
  47. package/dist/editor/components/BranchLockBanner.d.ts +8 -7
  48. package/dist/editor/components/BranchLockBanner.js +30 -3
  49. package/dist/editor/components/BranchPill.d.ts +5 -4
  50. package/dist/editor/components/BranchPill.js +6 -8
  51. package/dist/editor/components/BranchRowLock.d.ts +12 -0
  52. package/dist/editor/components/BranchRowLock.js +19 -0
  53. package/dist/editor/components/BranchesDrawer.js +3 -3
  54. package/dist/editor/components/EditorHeader.d.ts +2 -6
  55. package/dist/editor/components/EditorHeader.js +14 -28
  56. package/dist/editor/components/EditorRail.d.ts +3 -4
  57. package/dist/editor/components/EditorRail.js +3 -4
  58. package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
  59. package/dist/editor/components/EntryBreadcrumb.js +8 -11
  60. package/dist/editor/components/PreviewLinkToast.d.ts +5 -0
  61. package/dist/editor/components/PreviewLinkToast.js +33 -0
  62. package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
  63. package/dist/editor/components/QuickOpenPalette.js +6 -1
  64. package/dist/editor/components/RenameEntryModal.d.ts +6 -1
  65. package/dist/editor/components/RenameEntryModal.js +20 -5
  66. package/dist/editor/components/SaveSplitButton.d.ts +3 -2
  67. package/dist/editor/components/SaveSplitButton.js +25 -12
  68. package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
  69. package/dist/editor/components/StagedChangesDrawer.js +5 -4
  70. package/dist/editor/components/UndoToastMessage.d.ts +6 -5
  71. package/dist/editor/components/UndoToastMessage.js +6 -4
  72. package/dist/editor/components/UnsavedMark.d.ts +9 -0
  73. package/dist/editor/components/UnsavedMark.js +20 -0
  74. package/dist/editor/components/UserBadge.js +13 -11
  75. package/dist/editor/copy.d.ts +4 -0
  76. package/dist/editor/copy.js +4 -0
  77. package/dist/editor/editor-utils.d.ts +49 -9
  78. package/dist/editor/editor-utils.js +117 -16
  79. package/dist/editor/entry-search.d.ts +4 -2
  80. package/dist/editor/entry-search.js +2 -3
  81. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  82. package/dist/editor/fields/FieldLabel.js +1 -1
  83. package/dist/editor/fields/ImageField.d.ts +1 -1
  84. package/dist/editor/fields/ImageField.js +36 -27
  85. package/dist/editor/fields/ObjectField.d.ts +13 -2
  86. package/dist/editor/fields/ObjectField.js +38 -9
  87. package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
  88. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
  89. package/dist/editor/group-manager/index.js +1 -1
  90. package/dist/editor/hooks/useBranchManager.d.ts +2 -0
  91. package/dist/editor/hooks/useBranchManager.js +1 -0
  92. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  93. package/dist/editor/hooks/useEditorLayout.js +5 -0
  94. package/dist/editor/hooks/useEntryManager.d.ts +2 -0
  95. package/dist/editor/hooks/useEntryManager.js +10 -8
  96. package/dist/editor/media/AssetCard.d.ts +6 -3
  97. package/dist/editor/media/AssetCard.js +24 -9
  98. package/dist/editor/media/AssetListRow.d.ts +9 -4
  99. package/dist/editor/media/AssetListRow.js +18 -9
  100. package/dist/editor/media/CropStep.d.ts +8 -10
  101. package/dist/editor/media/CropStep.js +39 -27
  102. package/dist/editor/media/MediaLibrary.js +5 -4
  103. package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
  104. package/dist/editor/media/MediaLibraryBody.js +65 -37
  105. package/dist/editor/media/crop-availability.d.ts +6 -0
  106. package/dist/editor/media/crop-availability.js +19 -0
  107. package/dist/editor/media/crop-math.d.ts +19 -13
  108. package/dist/editor/media/crop-math.js +50 -11
  109. package/dist/editor/media/useAssetLibrary.d.ts +6 -3
  110. package/dist/editor/media/useAssetLibrary.js +17 -10
  111. package/dist/editor/pane-header.d.ts +6 -0
  112. package/dist/editor/pane-header.js +6 -0
  113. package/dist/editor/permission-manager/index.js +1 -1
  114. package/dist/editor/preview-bridge.d.ts +23 -0
  115. package/dist/editor/preview-bridge.js +105 -4
  116. package/dist/editor/preview-path.d.ts +16 -4
  117. package/dist/editor/preview-path.js +80 -19
  118. package/dist/editor/shell/ActivityRail.d.ts +1 -3
  119. package/dist/editor/shell/ActivityRail.js +3 -3
  120. package/dist/editor/shell/EditorShell.d.ts +15 -0
  121. package/dist/editor/shell/EditorShell.js +17 -1
  122. package/dist/editor/shell/OverlayHeader.d.ts +19 -0
  123. package/dist/editor/shell/OverlayHeader.js +15 -0
  124. package/dist/editor/shell/PaneLayout.d.ts +6 -1
  125. package/dist/editor/shell/PaneLayout.js +68 -13
  126. package/dist/editor/shell/SidePanelHost.d.ts +8 -6
  127. package/dist/editor/shell/SidePanelHost.js +70 -33
  128. package/dist/editor/shell/TopBar.js +2 -1
  129. package/dist/editor/theme.d.ts +1 -0
  130. package/dist/editor/theme.js +19 -1
  131. package/dist/editor/undo-removal.d.ts +19 -10
  132. package/dist/editor/undo-removal.js +44 -16
  133. package/dist/server.d.ts +2 -0
  134. package/dist/version.d.ts +1 -1
  135. package/dist/version.js +1 -1
  136. package/package.json +2 -2
@@ -1,12 +1,13 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useLayoutEffect, useRef, useState } from 'react';
4
- import { ActionIcon, Box, Group, Select, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
4
+ import { ActionIcon, Box, Group, Select, Skeleton, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
5
5
  import { useClipboard, useElementSize, useWindowEvent } from '@mantine/hooks';
6
6
  import { IconCheck, IconExternalLink, IconRefresh } from '@tabler/icons-react';
7
7
  import { PreviewFrame } from './PreviewFrame.js';
8
- import { HighlightToggle, MaximizeToggle, UnmappedMarksChip } from './PreviewToolbarTools.js';
9
- import { displayPreviewPath } from './preview-path.js';
8
+ import { HighlightToggle, MaximizeToggle, SiteChromeToggle, UnmappedMarksChip, } from './PreviewToolbarTools.js';
9
+ import { PANE_HEADER_HEIGHT } from './pane-header.js';
10
+ import { displayPreviewPath, siteChromeSrc } from './preview-path.js';
10
11
  import { computePreviewViewport, formatPreviewWidthReadout, PREVIEW_DEVICE_WIDTHS, } from './preview-scale.js';
11
12
  const WIDTH_OPTIONS = [
12
13
  { value: 'fit', label: 'Fit', detail: 'Pane width' },
@@ -27,7 +28,7 @@ const OPEN_SAVED_LABEL = 'Open saved version in a new tab';
27
28
  * fixed width renders the iframe at that CSS width, so the site's own breakpoints apply, scaled
28
29
  * down to fit a narrower pane.
29
30
  */
30
- export const PreviewPane = ({ src, path = src, mount, previewWidth, onPreviewWidthChange, notice, onHighlightToggle, marksNothing, inexactMarks = [], showMarkDiagnostics = false, layout, maximized = false, onMaximizedChange, resizing = false, ...frameProps }) => {
31
+ export const PreviewPane = ({ src, path = src, pending = false, mount, previewWidth, onPreviewWidthChange, notice, onHighlightToggle, marksNothing, inexactMarks = [], showMarkDiagnostics = false, layout, maximized = false, onMaximizedChange, resizing = false, siteChrome = false, onSiteChromeChange, data, isLoading, onMarks, ...frameProps }) => {
31
32
  const { ref: viewportRef, width, height } = useElementSize();
32
33
  const rootRef = useRef(null);
33
34
  const maximizeRef = useRef(null);
@@ -74,13 +75,51 @@ export const PreviewPane = ({ src, path = src, mount, previewWidth, onPreviewWid
74
75
  }
75
76
  hadNotice.current = Boolean(notice);
76
77
  }, [notice]);
78
+ // Pages asked for the header and footer stopped connecting, such as when the site's layout
79
+ // throws: for the rest of the session pages are framed without them, so drafts still show.
80
+ // `trying` until a page without them connects, which confirms the header and footer were the
81
+ // cause; a site that never connects at all is not blamed on them. The toggle and Reload retry.
82
+ const [chromeFallback, setChromeFallback] = useState('off');
77
83
  // Reloading remounts the frame for a fresh handshake. It drops a reported error too: a page
78
84
  // that reports draft errors reports it again once the reloaded page renders the draft.
79
85
  const [reloads, setReloads] = useState(0);
80
86
  const reload = () => {
81
87
  setReloads((count) => count + 1);
88
+ setChromeFallback('off');
82
89
  frameProps.onPreviewError?.(null);
83
90
  };
91
+ // Whether the page framed last said it renders the site's header and footer. Kept across
92
+ // pages, so the toggle stays put while the next page connects.
93
+ const [chromeSupported, setChromeSupported] = useState(false);
94
+ const chromeSrc = siteChromeSrc(src, mount);
95
+ // Also what "Open saved version" opens, so it shows what the frame shows.
96
+ const liveSrc = siteChrome && chromeSrc && chromeFallback === 'off' ? chromeSrc : src;
97
+ // The frame changes page only once the open entry's data is in, so one render hands the new
98
+ // page its own draft: no draft is ever posted to the page of another entry. A reload or the
99
+ // toggle waits for that page too, since the held page has no draft to render.
100
+ const live = { page: src, src: liveSrc, path, key: reloads };
101
+ const [framed, setFramed] = useState(pending ? null : live);
102
+ if (!pending &&
103
+ (framed?.src !== live.src ||
104
+ framed.page !== src ||
105
+ framed.path !== path ||
106
+ framed.key !== reloads)) {
107
+ setFramed(live);
108
+ }
109
+ const frame = pending ? framed : live;
110
+ const onSiteChrome = (support) => {
111
+ setChromeSupported(support === 'supported');
112
+ if (!frame)
113
+ return;
114
+ const withChrome = frame.src !== frame.page;
115
+ if (support === 'unanswered' && withChrome)
116
+ setChromeFallback('trying');
117
+ if (support !== 'unanswered' && !withChrome) {
118
+ setChromeFallback((current) => (current === 'trying' ? 'confirmed' : current));
119
+ }
120
+ };
121
+ // Shown while on even without support, so a page that never connects can still turn it off.
122
+ const showChromeToggle = Boolean(onSiteChromeChange && chromeSrc && (chromeSupported || siteChrome));
84
123
  const shownPath = displayPreviewPath(path, mount);
85
124
  const viewport = computePreviewViewport(previewWidth, width, height);
86
125
  const iframeStyle = previewWidth === 'fit'
@@ -109,8 +148,9 @@ export const PreviewPane = ({ src, path = src, mount, previewWidth, onPreviewWid
109
148
  height: '100%',
110
149
  border: '1px solid var(--mantine-color-default-border)',
111
150
  }, children: [_jsxs(Group, { role: "group", "aria-label": "Preview toolbar", "data-testid": "preview-toolbar", gap: 6, px: 6, style: {
112
- // Wraps to a second row, rather than clipping, in a pane too narrow for one.
113
- minHeight: 32,
151
+ // Wraps to a second row, rather than clipping, in a pane too narrow for one. The pane's
152
+ // own top border makes up the last pixel, so this bar ends level with the form header.
153
+ minHeight: PANE_HEADER_HEIGHT - 1,
114
154
  rowGap: 2,
115
155
  paddingBlock: 2,
116
156
  flex: '0 0 auto',
@@ -119,7 +159,14 @@ export const PreviewPane = ({ src, path = src, mount, previewWidth, onPreviewWid
119
159
  }, children: [_jsx(Tooltip, { label: copyStatus || copyLabel, multiline: true, maw: 360, style: { overflowWrap: 'anywhere' }, children: _jsx(UnstyledButton, { onClick: () => clipboard.copy(shownPath), "aria-label": copyLabel, style: { flex: '1 1 80px', minWidth: 0, borderRadius: 4, padding: '2px 4px' }, children: _jsx(Text, { component: "span", size: "xs", ff: "monospace", truncate: "end", display: "block", children: shownPath }) }) }), _jsx(VisuallyHidden, { role: "status", children: copyStatus }), _jsx(Select, { "aria-label": "Preview width", data: WIDTH_OPTIONS, value: previewWidth, onChange: (value) => {
120
160
  if (isPreviewWidth(value))
121
161
  onPreviewWidthChange(value);
122
- }, allowDeselect: false, size: "xs", w: 96, style: { flexShrink: 0 }, styles: { input: { height: 24, minHeight: 24 } }, comboboxProps: { width: 170, position: 'bottom-end' }, renderOption: ({ option, checked }) => (_jsxs(Group, { gap: 6, wrap: "nowrap", w: "100%", children: [_jsx(IconCheck, { "aria-hidden": true, size: 14, style: { visibility: checked ? 'visible' : 'hidden' } }), _jsx("span", { style: { flex: 1 }, children: option.label }), _jsx(Text, { component: "span", size: "xs", children: WIDTH_OPTIONS.find((o) => o.value === option.value)?.detail })] })) }), width > 0 && (_jsx(Text, { size: "xs", c: "dimmed", "data-testid": "preview-width-readout", style: { flexShrink: 0, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }, children: formatPreviewWidthReadout(viewport) })), (onHighlightToggle || (showMarkDiagnostics && inexactMarks.length > 0)) && (_jsxs(Group, { gap: 6, wrap: "nowrap", children: [onHighlightToggle && (_jsx(HighlightToggle, { enabled: Boolean(frameProps.highlightEnabled), onToggle: onHighlightToggle, marksNothing: marksNothing })), showMarkDiagnostics && _jsx(UnmappedMarksChip, { marks: inexactMarks })] })), _jsxs(Group, { gap: 6, wrap: "nowrap", children: [onMaximizedChange && (_jsx(MaximizeToggle, { maximized: maximized, onChange: onMaximizedChange, maximizeRef: maximizeRef, restoreRef: restoreRef })), _jsx(Tooltip, { label: RELOAD_LABEL, children: _jsx(ActionIcon, { ref: reloadButtonRef, variant: "subtle", size: "md", "aria-label": RELOAD_LABEL, onClick: reload, children: _jsx(IconRefresh, { size: 16 }) }) }), _jsx(Tooltip, { label: OPEN_SAVED_LABEL, children: _jsx(ActionIcon, { component: "a", href: src, target: "_blank", rel: "noopener noreferrer", variant: "subtle", size: "md", "aria-label": OPEN_SAVED_LABEL, children: _jsx(IconExternalLink, { size: 16 }) }) })] })] }), notice, _jsxs(Box, { ref: viewportRef, pos: "relative", style: { flex: '1 1 auto', minHeight: 0 }, children: [_jsx(PreviewFrame, { src: src, path: path, ...frameProps, onReload: () => {
162
+ }, allowDeselect: false, size: "xs", w: 96, style: { flexShrink: 0 }, styles: { input: { height: 24, minHeight: 24 } }, comboboxProps: { width: 170, position: 'bottom-end' }, renderOption: ({ option, checked }) => (_jsxs(Group, { gap: 6, wrap: "nowrap", w: "100%", children: [_jsx(IconCheck, { "aria-hidden": true, size: 14, style: { visibility: checked ? 'visible' : 'hidden' } }), _jsx("span", { style: { flex: 1 }, children: option.label }), _jsx(Text, { component: "span", size: "xs", children: WIDTH_OPTIONS.find((o) => o.value === option.value)?.detail })] })) }), width > 0 && (_jsx(Text, { size: "xs", c: "dimmed", "data-testid": "preview-width-readout", style: { flexShrink: 0, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }, children: formatPreviewWidthReadout(viewport) })), (onHighlightToggle ||
163
+ showChromeToggle ||
164
+ (showMarkDiagnostics && inexactMarks.length > 0)) && (_jsxs(Group, { gap: 6, wrap: "nowrap", children: [showChromeToggle && onSiteChromeChange && (_jsx(SiteChromeToggle, { enabled: siteChrome, failed: chromeFallback === 'confirmed', onToggle: () => {
165
+ setChromeFallback('off');
166
+ onSiteChromeChange(!siteChrome);
167
+ } })), onHighlightToggle && (_jsx(HighlightToggle, { enabled: Boolean(frameProps.highlightEnabled), onToggle: onHighlightToggle, marksNothing: marksNothing })), showMarkDiagnostics && _jsx(UnmappedMarksChip, { marks: inexactMarks })] })), _jsxs(Group, { gap: 6, wrap: "nowrap", children: [onMaximizedChange && (_jsx(MaximizeToggle, { maximized: maximized, onChange: onMaximizedChange, maximizeRef: maximizeRef, restoreRef: restoreRef })), _jsx(Tooltip, { label: RELOAD_LABEL, children: _jsx(ActionIcon, { ref: reloadButtonRef, variant: "subtle", size: "md", "aria-label": RELOAD_LABEL, onClick: reload, children: _jsx(IconRefresh, { size: 16 }) }) }), _jsx(Tooltip, { label: OPEN_SAVED_LABEL, children: _jsx(ActionIcon, { component: "a", href: liveSrc, target: "_blank", rel: "noopener noreferrer", variant: "subtle", size: "md", "aria-label": OPEN_SAVED_LABEL, children: _jsx(IconExternalLink, { size: 16 }) }) })] })] }), notice, _jsxs(Box, { ref: viewportRef, pos: "relative", style: { flex: '1 1 auto', minHeight: 0 }, children: [frame ? (_jsx(PreviewFrame, { src: frame.src, path: frame.path, ...frameProps,
168
+ // A held page gets no draft and its reports go nowhere: it belongs to another entry.
169
+ data: pending ? undefined : data, isLoading: pending ? undefined : isLoading, onMarks: pending ? undefined : onMarks, onPreviewError: pending ? undefined : frameProps.onPreviewError, busy: pending, onSiteChrome: onSiteChrome, onReload: () => {
123
170
  // Retry goes with the frame it sits in; the toolbar's reload offers the same action.
124
171
  reload();
125
172
  reloadButtonRef.current?.focus();
@@ -127,7 +174,7 @@ export const PreviewPane = ({ src, path = src, mount, previewWidth, onPreviewWid
127
174
  position: 'absolute',
128
175
  inset: 0,
129
176
  background: previewWidth === 'fit' ? undefined : 'var(--canopy-surface-muted)',
130
- } }, reloads), resizing && width > 0 && (_jsx(Box, { "aria-hidden": true, "data-testid": "preview-resize-readout", style: {
177
+ } }, frame.key)) : (_jsx(Box, { "aria-busy": "true", pos: "absolute", inset: 0, "data-testid": "preview-loading-skeleton", children: _jsx(Skeleton, { h: "100%", radius: 0 }) })), resizing && width > 0 && (_jsx(Box, { "aria-hidden": true, "data-testid": "preview-resize-readout", style: {
131
178
  position: 'absolute',
132
179
  top: '50%',
133
180
  left: '50%',
@@ -8,6 +8,14 @@ export interface HighlightToggleProps {
8
8
  }
9
9
  /** Turns preview highlights on and off; says so when the page marks nothing to highlight. */
10
10
  export declare function HighlightToggle({ enabled, onToggle, marksNothing }: HighlightToggleProps): import("react/jsx-runtime").JSX.Element;
11
+ export interface SiteChromeToggleProps {
12
+ enabled: boolean;
13
+ onToggle: () => void;
14
+ /** On, but pages with the header and footer don't connect while pages without them do. */
15
+ failed?: boolean;
16
+ }
17
+ /** Shows and hides the site's header and footer; says so when the site couldn't show them. */
18
+ export declare function SiteChromeToggle({ enabled, onToggle, failed }: SiteChromeToggleProps): import("react/jsx-runtime").JSX.Element;
11
19
  /** The admin's count of preview marks that name no field of the entry; see `findInexactMarks`. */
12
20
  export declare function UnmappedMarksChip({ marks }: {
13
21
  marks: readonly InexactMark[];
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useId, useRef, useState } from 'react';
4
4
  import { ActionIcon, Button, Group, Popover, Stack, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
5
- import { IconArrowsMaximize, IconArrowsMinimize, IconMarquee } from '@tabler/icons-react';
5
+ import { IconArrowsMaximize, IconArrowsMinimize, IconLayoutNavbar, IconMarquee, } from '@tabler/icons-react';
6
6
  const HIGHLIGHT_LABEL = 'Highlight fields in the preview';
7
7
  const NOTHING_MARKED_NOTE = "This page marks no editable elements, so nothing can be highlighted or clicked to focus. The site's developer can mark them with fieldProps.";
8
8
  /** Turns preview highlights on and off; says so when the page marks nothing to highlight. */
@@ -11,6 +11,14 @@ export function HighlightToggle({ enabled, onToggle, marksNothing = false }) {
11
11
  const showNote = enabled && marksNothing;
12
12
  return (_jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Tooltip, { label: HIGHLIGHT_LABEL, children: _jsx(ActionIcon, { variant: enabled ? 'filled' : 'subtle', color: enabled ? 'brand' : 'gray', size: "md", "aria-pressed": enabled, "aria-label": HIGHLIGHT_LABEL, "aria-describedby": showNote ? noteId : undefined, "data-testid": "toggle-highlights-button", onClick: onToggle, children: _jsx(IconMarquee, { size: 16 }) }) }), showNote && (_jsx(Tooltip, { label: NOTHING_MARKED_NOTE, multiline: true, w: 260, children: _jsxs(Text, { size: "xs", c: "dimmed", style: { whiteSpace: 'nowrap' }, children: ["Nothing to highlight", _jsx(VisuallyHidden, { id: noteId, children: NOTHING_MARKED_NOTE })] }) }))] }));
13
13
  }
14
+ const SITE_CHROME_LABEL = 'Show site header and footer';
15
+ const SITE_CHROME_FAILED_NOTE = "The site couldn't show its header and footer, so the preview shows pages without them. Turn this off and on, or reload the preview, to try again.";
16
+ /** Shows and hides the site's header and footer; says so when the site couldn't show them. */
17
+ export function SiteChromeToggle({ enabled, onToggle, failed = false }) {
18
+ const noteId = useId();
19
+ const showNote = enabled && failed;
20
+ return (_jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Tooltip, { label: SITE_CHROME_LABEL, children: _jsx(ActionIcon, { variant: enabled ? 'filled' : 'subtle', color: enabled ? 'brand' : 'gray', size: "md", "aria-pressed": enabled, "aria-label": SITE_CHROME_LABEL, "aria-describedby": showNote ? noteId : undefined, "data-testid": "toggle-site-chrome-button", onClick: onToggle, children: _jsx(IconLayoutNavbar, { size: 16 }) }) }), showNote && (_jsx(Tooltip, { label: SITE_CHROME_FAILED_NOTE, multiline: true, w: 260, children: _jsxs(Text, { size: "xs", c: "dimmed", style: { whiteSpace: 'nowrap' }, children: ["Header and footer unavailable", _jsx(VisuallyHidden, { id: noteId, children: SITE_CHROME_FAILED_NOTE })] }) }))] }));
21
+ }
14
22
  const LISTED = 3;
15
23
  // Module-level, so React never re-calls it on a re-render and pulls focus back while closing.
16
24
  const focusOnMount = (node) => node?.focus();
@@ -15,3 +15,9 @@ export declare const BRANCH_STATUS_PRESENTATION: Record<BranchStatus, BranchStat
15
15
  * Returns undefined when there is nothing to show.
16
16
  */
17
17
  export declare function branchStatusPresentation(status: string | undefined, isProtected?: boolean): BranchStatusPresentation | undefined;
18
+ /**
19
+ * The status the top bar shows: only one that tells the user something, so not 'editing', the
20
+ * state every branch starts in. The Branches drawer shows every status through
21
+ * `branchStatusPresentation`.
22
+ */
23
+ export declare function branchBarStatusPresentation(status: string | undefined, isProtected?: boolean): BranchStatusPresentation | undefined;
@@ -27,3 +27,13 @@ export function branchStatusPresentation(status, isProtected = false) {
27
27
  return BRANCH_STATUS_PRESENTATION[status];
28
28
  return { label: status, color: 'gray', variant: 'light' };
29
29
  }
30
+ /**
31
+ * The status the top bar shows: only one that tells the user something, so not 'editing', the
32
+ * state every branch starts in. The Branches drawer shows every status through
33
+ * `branchStatusPresentation`.
34
+ */
35
+ export function branchBarStatusPresentation(status, isProtected = false) {
36
+ if (!isProtected && status === 'editing')
37
+ return undefined;
38
+ return branchStatusPresentation(status, isProtected);
39
+ }
@@ -12,15 +12,13 @@ export interface BranchCommentsProps {
12
12
  onAddComment: (text: string, type: 'field' | 'entry' | 'branch', entryPath?: string, canopyPath?: string, threadId?: string) => Promise<void>;
13
13
  /** Handler to resolve a thread */
14
14
  onResolveThread: (threadId: string) => Promise<void>;
15
- /** Auto-focus and expand */
16
- autoFocus?: boolean;
17
15
  /** Thread ID to highlight and scroll to */
18
16
  highlightThreadId?: string;
19
17
  /** Optional function to fetch user metadata for displaying user badges */
20
18
  onGetUserMetadata?: (userId: string) => Promise<UserSearchResult | null>;
21
19
  }
22
20
  /**
23
- * Branch-level comments section displayed at the top of BranchManager.
24
- * Uses ThreadCarousel for navigation.
21
+ * The branch's existing discussion threads, at the top of BranchManager, in a ThreadCarousel.
22
+ * It renders nothing without one: a new branch comment starts in the Comments drawer.
25
23
  */
26
24
  export declare const BranchComments: React.FC<BranchCommentsProps>;
@@ -1,21 +1,17 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { useMemo, useState } from 'react';
4
- import { Box, Button } from '@mantine/core';
3
+ import { useMemo } from 'react';
5
4
  import { ThreadCarousel } from './ThreadCarousel.js';
6
5
  /**
7
- * Branch-level comments section displayed at the top of BranchManager.
8
- * Uses ThreadCarousel for navigation.
6
+ * The branch's existing discussion threads, at the top of BranchManager, in a ThreadCarousel.
7
+ * It renders nothing without one: a new branch comment starts in the Comments drawer.
9
8
  */
10
- export const BranchComments = ({ comments, currentUserId, canResolve, onAddComment, onResolveThread, autoFocus, highlightThreadId, onGetUserMetadata, }) => {
11
- const [showCarousel, setShowCarousel] = useState(false);
9
+ export const BranchComments = ({ comments, currentUserId, canResolve, onAddComment, onResolveThread, highlightThreadId, onGetUserMetadata, }) => {
12
10
  const branchThreads = useMemo(() => comments.filter((t) => t.type === 'branch'), [comments]);
13
- const shouldShowCarousel = branchThreads.length > 0 || showCarousel || autoFocus;
14
11
  const handleAddComment = async (text, threadId) => {
15
12
  await onAddComment(text, 'branch', undefined, undefined, threadId);
16
13
  };
17
- if (!shouldShowCarousel) {
18
- return (_jsx(Box, { children: _jsx(Button, { variant: "subtle", color: "gray", onClick: () => setShowCarousel(true), style: { fontSize: '0.75rem', height: '1.5rem' }, children: "New branch comment" }) }));
19
- }
20
- return (_jsx(ThreadCarousel, { threads: branchThreads, label: "Branch discussion", contextType: "branch", currentUserId: currentUserId, canResolve: canResolve, onAddComment: handleAddComment, onResolveThread: onResolveThread, autoFocus: autoFocus, autoOpenNewThread: showCarousel && branchThreads.length === 0, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetUserMetadata }));
14
+ if (branchThreads.length === 0)
15
+ return null;
16
+ return (_jsx(ThreadCarousel, { threads: branchThreads, label: "Branch discussion", contextType: "branch", currentUserId: currentUserId, canResolve: canResolve, onAddComment: handleAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetUserMetadata }));
21
17
  };
@@ -35,19 +35,13 @@ export interface ThreadCarouselProps {
35
35
  currentUserId: string;
36
36
  /** Whether the user may resolve any thread; a thread's own author always may resolve it. */
37
37
  canResolve: boolean;
38
- /** Handler to add a new comment (creates new thread or adds to existing) */
39
- onAddComment: (text: string, threadId?: string) => Promise<void>;
38
+ /** Handler to reply to a thread; new threads start in the Comments drawer. */
39
+ onAddComment: (text: string, threadId: string) => Promise<void>;
40
40
  /** Handler to resolve a thread */
41
41
  onResolveThread: (threadId: string) => Promise<void>;
42
- /** Auto-focus and expand (from preview click) */
43
- autoFocus?: boolean;
44
- /** Auto-open new thread box */
45
- autoOpenNewThread?: boolean;
46
42
  /** Thread ID to highlight and scroll to */
47
43
  highlightThreadId?: string;
48
44
  /** Optional function to fetch user metadata for displaying user badges */
49
45
  onGetUserMetadata?: (userId: string) => Promise<UserSearchResult | null>;
50
- /** Called when the user cancels the new-thread box (e.g. so a wrapper can collapse an empty carousel) */
51
- onCancelNewThread?: () => void;
52
46
  }
53
47
  export declare const ThreadCarousel: React.FC<ThreadCarouselProps>;
@@ -1,20 +1,15 @@
1
1
  'use client';
2
2
  import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useState, useMemo, useEffect, useRef } from 'react';
4
- import { ActionIcon, Button, Group, Paper, Stack, Text, Textarea, Tooltip } from '@mantine/core';
4
+ import { ActionIcon, Group, Paper, Stack, Text, Tooltip } from '@mantine/core';
5
5
  import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
6
6
  import { InlineCommentThread } from './InlineCommentThread.js';
7
7
  import { useTrackedWrites } from './use-tracked-writes.js';
8
- import { CommentWriteAlert, toCommentWriteFailure, } from './CommentWriteAlert.js';
9
- export const ThreadCarousel = ({ threads, label = 'Comments', contextType: _, currentUserId, canResolve, onAddComment, onResolveThread, autoFocus, autoOpenNewThread, highlightThreadId, onGetUserMetadata, onCancelNewThread, }) => {
8
+ export const ThreadCarousel = ({ threads, label = 'Comments', contextType: _, currentUserId, canResolve, onAddComment, onResolveThread, highlightThreadId, onGetUserMetadata, }) => {
10
9
  const [currentIndex, setCurrentIndex] = useState(0);
11
- const [showNewThreadBox, setShowNewThreadBox] = useState(false);
12
- const [newThreadText, setNewThreadText] = useState('');
13
- const [isSubmitting, setIsSubmitting] = useState(false);
14
10
  const [carouselHeight, setCarouselHeight] = useState(400);
15
11
  const [isResizing, setIsResizing] = useState(false);
16
12
  const [highlightedThreadId, setHighlightedThreadId] = useState(undefined);
17
- const [error, setError] = useState(null);
18
13
  const scrollContainerRef = useRef(null);
19
14
  const resizeStartY = useRef(0);
20
15
  const resizeStartHeight = useRef(0);
@@ -24,17 +19,6 @@ export const ThreadCarousel = ({ threads, label = 'Comments', contextType: _, cu
24
19
  }
25
20
  return a.resolved ? 1 : -1;
26
21
  }), [threads]);
27
- useEffect(() => {
28
- if (autoFocus && sortedThreads.length > 0) {
29
- const firstUnresolved = sortedThreads.find((t) => !t.resolved);
30
- if (firstUnresolved) {
31
- const index = sortedThreads.findIndex((t) => t.id === firstUnresolved.id);
32
- setCurrentIndex(index);
33
- scrollToIndex(index);
34
- }
35
- }
36
- // eslint-disable-next-line react-hooks/exhaustive-deps -- scrollToIndex is stable
37
- }, [autoFocus, sortedThreads]);
38
22
  useEffect(() => {
39
23
  if (highlightThreadId && sortedThreads.length > 0) {
40
24
  const threadIndex = sortedThreads.findIndex((t) => t.id === highlightThreadId);
@@ -50,11 +34,6 @@ export const ThreadCarousel = ({ threads, label = 'Comments', contextType: _, cu
50
34
  }
51
35
  // eslint-disable-next-line react-hooks/exhaustive-deps -- scrollToIndex is stable
52
36
  }, [highlightThreadId, sortedThreads]);
53
- useEffect(() => {
54
- if (autoOpenNewThread) {
55
- setShowNewThreadBox(true);
56
- }
57
- }, [autoOpenNewThread]);
58
37
  useEffect(() => {
59
38
  const handleMouseMove = (e) => {
60
39
  if (!isResizing)
@@ -119,32 +98,8 @@ export const ThreadCarousel = ({ threads, label = 'Comments', contextType: _, cu
119
98
  const resolveThread = track(onResolveThread);
120
99
  // A resolved thread's controls unmount with the focused button, so focus lands here.
121
100
  const labelRef = useRef(null);
122
- const handleCreateNewThread = async () => {
123
- if (!newThreadText.trim())
124
- return;
125
- setIsSubmitting(true);
126
- setError(null);
127
- try {
128
- await addComment(newThreadText);
129
- setNewThreadText('');
130
- setShowNewThreadBox(false);
131
- }
132
- catch (err) {
133
- setError(toCommentWriteFailure(err));
134
- }
135
- finally {
136
- setIsSubmitting(false);
137
- }
138
- };
139
101
  const unresolvedCount = sortedThreads.filter((t) => !t.resolved).length;
140
- return (_jsx(Paper, { p: "sm", bg: "var(--mantine-color-default-hover)", style: { marginTop: 8 }, children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", align: "center", children: [_jsxs(Group, { gap: "xs", children: [_jsxs(Text, { ref: labelRef, size: "sm", fw: 600, tabIndex: -1, children: [label, sortedThreads.length > 0 && ` (${sortedThreads.length})`] }), unresolvedCount > 0 && (_jsxs(Text, { size: "xs", c: "orange", fw: 600, children: [unresolvedCount, " unresolved"] }))] }), _jsxs(Group, { gap: "xs", children: [sortedThreads.length > 1 && (_jsxs(_Fragment, { children: [_jsx(Tooltip, { label: "Previous thread", children: _jsx(ActionIcon, { variant: "subtle", onClick: handlePrevious, disabled: currentIndex === 0, "aria-label": "Previous thread", children: _jsx(IconChevronLeft, { size: 16, "aria-hidden": true }) }) }), _jsxs(Text, { size: "xs", fw: 500, children: [currentIndex + 1, "/", sortedThreads.length] }), _jsx(Tooltip, { label: "Next thread", children: _jsx(ActionIcon, { variant: "subtle", onClick: handleNext, disabled: currentIndex === sortedThreads.length - 1, "aria-label": "Next thread", children: _jsx(IconChevronRight, { size: 16, "aria-hidden": true }) }) }), _jsx("div", { style: {
141
- width: 1,
142
- height: 16,
143
- background: 'var(--mantine-color-default-border)',
144
- } })] })), _jsx(Button, { variant: "light", onClick: () => setShowNewThreadBox(!showNewThreadBox), children: "+ New" })] })] }), error && _jsx(CommentWriteAlert, { failure: error, onClose: () => setError(null) }), showNewThreadBox && (_jsx(Paper, { withBorder: true, p: "sm", bg: "var(--mantine-color-body)", children: _jsxs(Stack, { gap: "xs", children: [_jsx(Textarea, { placeholder: "Start a new thread...", value: newThreadText, onChange: (e) => setNewThreadText(e.target.value), minRows: 2, disabled: isSubmitting, autoFocus: true, "data-testid": "new-thread-textarea" }), _jsxs(Group, { gap: "xs", children: [_jsx(Button, { onClick: handleCreateNewThread, loading: isSubmitting, disabled: !newThreadText.trim(), "data-testid": "create-thread-button", children: "Create Thread" }), _jsx(Button, { variant: "subtle", onClick: () => {
145
- setShowNewThreadBox(false);
146
- onCancelNewThread?.();
147
- }, children: "Cancel" })] })] }) })), sortedThreads.length > 0 && (_jsxs("div", { style: {
102
+ return (_jsx(Paper, { p: "sm", bg: "var(--mantine-color-default-hover)", style: { marginTop: 8 }, children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", align: "center", children: [_jsxs(Group, { gap: "xs", children: [_jsxs(Text, { ref: labelRef, size: "sm", fw: 600, tabIndex: -1, children: [label, sortedThreads.length > 0 && ` (${sortedThreads.length})`] }), unresolvedCount > 0 && (_jsxs(Text, { size: "xs", c: "orange", fw: 600, children: [unresolvedCount, " unresolved"] }))] }), _jsx(Group, { gap: "xs", children: sortedThreads.length > 1 && (_jsxs(_Fragment, { children: [_jsx(Tooltip, { label: "Previous thread", children: _jsx(ActionIcon, { variant: "subtle", onClick: handlePrevious, disabled: currentIndex === 0, "aria-label": "Previous thread", children: _jsx(IconChevronLeft, { size: 16, "aria-hidden": true }) }) }), _jsxs(Text, { size: "xs", fw: 500, children: [currentIndex + 1, "/", sortedThreads.length] }), _jsx(Tooltip, { label: "Next thread", children: _jsx(ActionIcon, { variant: "subtle", onClick: handleNext, disabled: currentIndex === sortedThreads.length - 1, "aria-label": "Next thread", children: _jsx(IconChevronRight, { size: 16, "aria-hidden": true }) }) })] })) })] }), sortedThreads.length > 0 && (_jsxs("div", { style: {
148
103
  position: 'relative',
149
104
  width: '100%',
150
105
  }, children: [_jsxs("div", { ref: scrollContainerRef, style: {
@@ -206,5 +161,5 @@ export const ThreadCarousel = ({ threads, label = 'Comments', contextType: _, cu
206
161
  height: 3,
207
162
  borderRadius: 2,
208
163
  background: 'var(--mantine-color-dimmed)',
209
- } }) })] })), sortedThreads.length === 0 && !showNewThreadBox && (_jsx(Text, { size: "xs", c: "dimmed", ta: "center", py: "xs", children: "No comments yet. Click \"+ New\" to start a thread." }))] }) }));
164
+ } }) })] }))] }) }));
210
165
  };
@@ -76,8 +76,11 @@ export const ThreadReplyControls = ({ thread, onAddReply, onResolve, focusAfterR
76
76
  setReplyText('');
77
77
  }
78
78
  await onResolve();
79
- if (focusAfterResolve)
80
- moveFocusIfUnmoved(origin, focusAfterResolve());
79
+ // After the commit that settles the write, so focus never lands on a control that still
80
+ // reads as busy.
81
+ if (focusAfterResolve) {
82
+ requestAnimationFrame(() => setTimeout(() => moveFocusIfUnmoved(origin, focusAfterResolve()), 0));
83
+ }
81
84
  }
82
85
  catch (err) {
83
86
  setError(toCommentWriteFailure(err));
@@ -6,14 +6,15 @@ export interface BranchLockInput {
6
6
  branchWriteBlocked?: boolean;
7
7
  }
8
8
  /**
9
- * Which lock applies. `branchWriteBlocked` is the server's own answer (getBranchProtection), so
10
- * nothing offers a write the API would reject; `branchReadOnly` only picks the message, and
11
- * base-branch read-only takes precedence over a workflow status lock.
9
+ * Whether the banner renders; the top bar reads the same answer, so the two cannot drift.
10
+ * @internal Exported for tests.
12
11
  */
13
- export declare function branchLockState({ branchStatus, branchReadOnly, branchWriteBlocked, }: BranchLockInput): {
14
- statusLocked: boolean;
15
- branchDataUnavailable: boolean;
16
- };
12
+ export declare function branchLockBannerShown(lock: BranchLockInput): boolean;
13
+ /**
14
+ * Why the branch takes no writes, with the way forward; undefined when it does take them. The
15
+ * top bar's Save and the navigator's row locks say this; the banner describes the same lock.
16
+ */
17
+ export declare function describeBranchLock(lock: BranchLockInput): string | undefined;
17
18
  export interface BranchLockBannerProps extends BranchLockInput {
18
19
  branchName: string;
19
20
  onBranchManagerOpen: () => void;
@@ -7,7 +7,7 @@ import { EDITOR_ACTIONS } from '../copy.js';
7
7
  * nothing offers a write the API would reject; `branchReadOnly` only picks the message, and
8
8
  * base-branch read-only takes precedence over a workflow status lock.
9
9
  */
10
- export function branchLockState({ branchStatus, branchReadOnly = false, branchWriteBlocked = false, }) {
10
+ function branchLockState({ branchStatus, branchReadOnly = false, branchWriteBlocked = false, }) {
11
11
  const statusLocked = branchWriteBlocked && !branchReadOnly;
12
12
  // `branchStatus` can't describe the lock while branch data loads (undefined) or under version
13
13
  // skew (an old server omits `branchWriteBlocked` but reports 'editing'), so both read as
@@ -15,10 +15,37 @@ export function branchLockState({ branchStatus, branchReadOnly = false, branchWr
15
15
  const branchDataUnavailable = statusLocked && (branchStatus === undefined || branchStatus === 'editing');
16
16
  return { statusLocked, branchDataUnavailable };
17
17
  }
18
+ /**
19
+ * Whether the banner renders; the top bar reads the same answer, so the two cannot drift.
20
+ * @internal Exported for tests.
21
+ */
22
+ export function branchLockBannerShown(lock) {
23
+ return !!lock.branchReadOnly || branchLockState(lock).statusLocked;
24
+ }
25
+ /**
26
+ * Why the branch takes no writes, with the way forward; undefined when it does take them. The
27
+ * top bar's Save and the navigator's row locks say this; the banner describes the same lock.
28
+ */
29
+ export function describeBranchLock(lock) {
30
+ if (!branchLockBannerShown(lock))
31
+ return undefined;
32
+ const { statusLocked, branchDataUnavailable } = branchLockState(lock);
33
+ if (lock.branchReadOnly)
34
+ return 'The base branch is read-only. Create a branch to make changes.';
35
+ if (branchDataUnavailable)
36
+ return 'Branch details could not be loaded. Use "Switch branch…" to retry.';
37
+ if (!statusLocked)
38
+ return undefined;
39
+ if (lock.branchStatus === 'submitted')
40
+ return 'This branch is in review. Withdraw it to make changes.';
41
+ if (lock.branchStatus === 'approved')
42
+ return 'This branch is approved. Withdraw it to make changes.';
43
+ return `This branch is ${lock.branchStatus}. Its content is read-only.`;
44
+ }
18
45
  /** The banner region's explanation of a read-only branch; not dismissible while it applies. */
19
46
  export function BranchLockBanner({ branchName, onBranchManagerOpen, ...lock }) {
20
- const { statusLocked, branchDataUnavailable } = branchLockState(lock);
21
- if (!lock.branchReadOnly && !statusLocked)
47
+ const { branchDataUnavailable } = branchLockState(lock);
48
+ if (!branchLockBannerShown(lock))
22
49
  return null;
23
50
  const [testId, message, action] = lock.branchReadOnly
24
51
  ? [
@@ -4,7 +4,7 @@ export interface BranchPillProps {
4
4
  branchName: string;
5
5
  /** The editor is busy; with no branch name yet, that is the branch list loading. */
6
6
  loading: boolean;
7
- /** The status label ("Editing", "In review", "Protected"), when the mode shows one. */
7
+ /** The status label ("In review", "Protected"), set only for a status worth a place in the bar. */
8
8
  status?: {
9
9
  label: string;
10
10
  testId: string;
@@ -25,8 +25,9 @@ export interface BranchPillProps {
25
25
  pillRef?: Ref<HTMLButtonElement>;
26
26
  }
27
27
  /**
28
- * The branch's name, status and unsaved count. Its menu holds the branch actions and lists the
29
- * unsaved entries, each opening that entry. Below `sm` it is the branch icon and the count, its
30
- * name then in its Tooltip, accessible name and menu label: the row's actions must not clip.
28
+ * The branch's name, status and unsaved count: the unsaved mark and a number. Its menu holds the
29
+ * branch actions and lists the unsaved entries, each opening that entry. Below `sm` it is the
30
+ * branch icon and the count, its name then in its Tooltip, accessible name and menu label: the
31
+ * row's actions must not clip.
31
32
  */
32
33
  export declare function BranchPill({ branchName, loading, status, unsavedCount: unsaved, unsavedEntries, onSwitchBranch, onRefreshBranch, onDiscardAll, onEntrySelect, pillRef, }: BranchPillProps): import("react/jsx-runtime").JSX.Element;
@@ -7,12 +7,14 @@ import { IconChevronDown, IconGitBranch } from '@tabler/icons-react';
7
7
  import { EDITOR_ACTIONS } from '../copy.js';
8
8
  import { DestructiveMenuItem } from './DestructiveMenuItem.js';
9
9
  import { ReasonedMenuItem } from './ReasonedMenuItem.js';
10
+ import { UnsavedMark } from './UnsavedMark.js';
10
11
  import { useMenuItemFocusReturn } from '../utils/focus-return.js';
11
12
  const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
12
13
  /**
13
- * The branch's name, status and unsaved count. Its menu holds the branch actions and lists the
14
- * unsaved entries, each opening that entry. Below `sm` it is the branch icon and the count, its
15
- * name then in its Tooltip, accessible name and menu label: the row's actions must not clip.
14
+ * The branch's name, status and unsaved count: the unsaved mark and a number. Its menu holds the
15
+ * branch actions and lists the unsaved entries, each opening that entry. Below `sm` it is the
16
+ * branch icon and the count, its name then in its Tooltip, accessible name and menu label: the
17
+ * row's actions must not clip.
16
18
  */
17
19
  export function BranchPill({ branchName, loading, status, unsavedCount: unsaved, unsavedEntries, onSwitchBranch, onRefreshBranch, onDiscardAll, onEntrySelect, pillRef, }) {
18
20
  const trigger = useRef(null);
@@ -23,11 +25,7 @@ export function BranchPill({ branchName, loading, status, unsavedCount: unsaved,
23
25
  const name = branchName || (loading ? 'Loading branches…' : 'No branch open');
24
26
  return (_jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-end", ...menuFocus.menu, children: [_jsx(Tooltip, { label: status ? `${name} · ${status.label}` : name, disabled: wide, events: { hover: true, focus: true, touch: false }, children: _jsx(Menu.Target, { children: _jsxs(Button, { ref: triggerRef, variant: "default", size: "sm", miw: 0, px: { base: 8, sm: 14 },
25
27
  // The count is the bar's one branch-wide unsaved signal: the row wraps before it clips.
26
- style: { flexShrink: 0 }, leftSection: _jsx(IconGitBranch, { size: 16, "aria-hidden": "true" }), rightSection: wide ? _jsx(IconChevronDown, { size: 14, "aria-hidden": "true" }) : undefined, loading: !branchName && loading, "data-testid": "branch-dropdown-button", styles: { label: { minWidth: 0, gap: 6 } }, children: [_jsx(Text, { span: true, inherit: true, truncate: "end", maw: 200, visibleFrom: "sm", children: name }), _jsx(VisuallyHidden, { component: "span", hiddenFrom: "sm", children: name }), branchName && status && (_jsxs(Text, { span: true, size: "xs", c: "dimmed", visibleFrom: "md", style: { flexShrink: 0 }, children: [_jsx("span", { "aria-hidden": "true", children: "\u00B7 " }), _jsx("span", { "data-testid": status.testId, "data-status": status.value, children: status.label })] })), unsaved > 0 && (_jsxs(Text, { span: true, size: "xs", fw: 600, px: 6, style: {
27
- flexShrink: 0,
28
- borderRadius: 'var(--mantine-radius-xl)',
29
- background: 'var(--canopy-count-bg)',
30
- }, "data-testid": "branch-unsaved-count", children: [unsaved, _jsx(Box, { component: "span", visibleFrom: "sm", children: ' unsaved' }), _jsx(VisuallyHidden, { component: "span", hiddenFrom: "sm", children: ' unsaved' })] }))] }) }) }), _jsxs(Menu.Dropdown, { "data-testid": "branch-menu", ...menuFocus.dropdown, children: [branchName && status && (_jsx(Menu.Label, { hiddenFrom: "md", children: `${branchName} · ${status.label}` })), _jsx(Menu.Item, { onClick: onSwitchBranch, "data-testid": "manage-branches-menu-item", children: `${EDITOR_ACTIONS.switchBranch}…` }), _jsx(ReasonedMenuItem, { onClick: onRefreshBranch, blockedReason: noBranch, "data-testid": "refresh-branch-menu-item", children: "Refresh branch" }), _jsx(Menu.Divider, {}), _jsx(Menu.Label, { children: "Unsaved changes" }), unsavedEntries.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", px: "sm", py: 6, "data-testid": "edited-entries-empty", children: unsaved === 0 ? 'No unsaved changes on this branch.' : 'Loading their names…' })) : (unsavedEntries.map((entry) => (_jsx(Menu.Item, { onClick: () => onEntrySelect(entry.path), children: entry.label }, entry.path)))), _jsx(Menu.Divider, {}), _jsx(DestructiveMenuItem, { "data-testid": "discard-all-drafts-menu-item", onClick: onDiscardAll, blockedReason: noBranch ?? (unsaved === 0 ? 'Nothing to discard.' : undefined), children: unsaved > 0
28
+ style: { flexShrink: 0 }, leftSection: _jsx(IconGitBranch, { size: 16, "aria-hidden": "true" }), rightSection: wide ? _jsx(IconChevronDown, { size: 14, "aria-hidden": "true" }) : undefined, loading: !branchName && loading, "data-testid": "branch-dropdown-button", styles: { label: { minWidth: 0, gap: 6 } }, children: [_jsx(Text, { span: true, inherit: true, truncate: "end", maw: 200, visibleFrom: "sm", children: name }), _jsx(VisuallyHidden, { component: "span", hiddenFrom: "sm", children: name }), branchName && status && (_jsxs(Text, { span: true, size: "xs", c: "dimmed", visibleFrom: "md", style: { flexShrink: 0 }, children: [_jsx("span", { "aria-hidden": "true", children: "\u00B7 " }), _jsx("span", { "data-testid": status.testId, "data-status": status.value, children: status.label })] })), unsaved > 0 && (_jsxs(Box, { component: "span", display: "inline-flex", style: { alignItems: 'center', gap: 4, flexShrink: 0 }, "data-testid": "branch-unsaved-count", children: [_jsx(UnsavedMark, { "aria-hidden": "true" }), _jsx(Text, { span: true, size: "xs", fw: 600, "aria-hidden": "true", children: unsaved }), _jsx(VisuallyHidden, { component: "span", children: `, ${plural(unsaved, 'entry', 'entries')} with unsaved changes` })] }))] }) }) }), _jsxs(Menu.Dropdown, { "data-testid": "branch-menu", ...menuFocus.dropdown, children: [branchName && status && (_jsx(Menu.Label, { hiddenFrom: "md", children: `${branchName} · ${status.label}` })), _jsx(Menu.Item, { onClick: onSwitchBranch, "data-testid": "manage-branches-menu-item", children: `${EDITOR_ACTIONS.switchBranch}…` }), _jsx(ReasonedMenuItem, { onClick: onRefreshBranch, blockedReason: noBranch, "data-testid": "refresh-branch-menu-item", children: "Refresh branch" }), _jsx(Menu.Divider, {}), _jsx(Menu.Label, { children: "Unsaved changes" }), unsavedEntries.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", px: "sm", py: 6, "data-testid": "edited-entries-empty", children: unsaved === 0 ? 'No unsaved changes on this branch.' : 'Loading their names…' })) : (unsavedEntries.map((entry) => (_jsx(Menu.Item, { onClick: () => onEntrySelect(entry.path), children: entry.label }, entry.path)))), _jsx(Menu.Divider, {}), _jsx(DestructiveMenuItem, { "data-testid": "discard-all-drafts-menu-item", onClick: onDiscardAll, blockedReason: noBranch ?? (unsaved === 0 ? 'Nothing to discard.' : undefined), children: unsaved > 0
31
29
  ? `Discard all changes (${plural(unsaved, 'entry', 'entries')})`
32
30
  : 'Discard all changes' })] })] }));
33
31
  }
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ /**
3
+ * The lock a navigator row shows in the slot its "…" menu would fill, on a branch that takes no
4
+ * writes. It matches the menu button's footprint so rows do not shift between branches, and takes
5
+ * the menu's name; the reason is its Tooltip and its description. It stays `aria-disabled` rather
6
+ * than `disabled`, so the reason opens on keyboard focus and touch too. Space is stopped here: the
7
+ * tree would read it as expand/collapse.
8
+ */
9
+ export declare const BranchRowLock: React.FC<{
10
+ label: string;
11
+ reason: string;
12
+ }>;
@@ -0,0 +1,19 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId } from 'react';
4
+ import { ActionIcon, Tooltip, VisuallyHidden } from '@mantine/core';
5
+ import { IconLock } from '@tabler/icons-react';
6
+ /**
7
+ * The lock a navigator row shows in the slot its "…" menu would fill, on a branch that takes no
8
+ * writes. It matches the menu button's footprint so rows do not shift between branches, and takes
9
+ * the menu's name; the reason is its Tooltip and its description. It stays `aria-disabled` rather
10
+ * than `disabled`, so the reason opens on keyboard focus and touch too. Space is stopped here: the
11
+ * tree would read it as expand/collapse.
12
+ */
13
+ export const BranchRowLock = ({ label, reason }) => {
14
+ const reasonId = useId();
15
+ return (_jsx(Tooltip, { label: reason, withinPortal: true, position: "bottom-end", multiline: true, w: 240, events: { hover: true, focus: true, touch: true }, children: _jsxs(ActionIcon, { size: "xs", variant: "transparent", c: "dimmed", "aria-disabled": true, "aria-label": label, "aria-describedby": reasonId, "data-testid": "branch-lock", style: { cursor: 'default' }, onClick: (event) => event.stopPropagation(), onKeyDown: (event) => {
16
+ if (event.nativeEvent.code === 'Space')
17
+ event.stopPropagation();
18
+ }, children: [_jsx(IconLock, { size: 14, "aria-hidden": true }), _jsx(VisuallyHidden, { component: "span", id: reasonId, children: reason })] }) }));
19
+ };
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Drawer, Text, Title } from '@mantine/core';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { OverlayDrawer } from '../shell/OverlayHeader.js';
4
4
  import { focusReturnProps } from '../utils/focus-return.js';
5
- export const BranchesDrawer = ({ opened, onClose, confirmOpen, returnFocusTo, children, }) => (_jsx(Drawer, { opened: opened, onClose: onClose, closeOnEscape: !confirmOpen, position: "right", title: _jsxs("div", { children: [_jsx(Title, { order: 4, children: "Branches" }), _jsx(Text, { size: "xs", c: "dimmed", children: "Manage access, status, and lifecycle" })] }), padding: "md", size: 420, overlayProps: { blur: 2 }, ...focusReturnProps(returnFocusTo), children: children }));
5
+ export const BranchesDrawer = ({ opened, onClose, confirmOpen, returnFocusTo, children, }) => (_jsx(OverlayDrawer, { opened: opened, onClose: onClose, closeOnEscape: !confirmOpen, position: "right", title: "Branches", size: 420, ...focusReturnProps(returnFocusTo), children: children }));
@@ -20,8 +20,6 @@ export interface EditorHeaderProps {
20
20
  }>;
21
21
  /** Entries with unsaved changes on the branch. */
22
22
  modifiedCount: number;
23
- /** Opens the content panel. */
24
- onNavigatorOpen: () => void;
25
23
  /** Opens the content panel on a collection. */
26
24
  onCollectionOpen: (collectionPath: string) => void;
27
25
  /** The entry menu's trigger. */
@@ -31,12 +29,10 @@ export interface EditorHeaderProps {
31
29
  /** Replaces the open entry with the server's copy. */
32
30
  onFileReload: () => void;
33
31
  onFileDiscardDraft: () => void;
34
- /** Opens the open entry's rename dialog. */
32
+ /** Opens the open entry's "Change URL" dialog. */
35
33
  onEntryRename: () => void;
36
34
  /** Asks to delete the open entry. */
37
35
  onEntryDelete: () => void;
38
- /** The "All content" crumb. */
39
- allContentRef?: Ref<HTMLButtonElement>;
40
36
  onEntrySelect: (path: LogicalPath) => void;
41
37
  /** Re-reads the branch and its entry list, keeping drafts. */
42
38
  onBranchReloadData: () => void;
@@ -98,4 +94,4 @@ export interface EditorHeaderProps {
98
94
  * The top bar's contents: the breadcrumb with the entry menu and "Read-only" chip, then the branch
99
95
  * pill, Submit or Withdraw, and Save with Save all.
100
96
  */
101
- export declare function EditorHeader({ currentEntry, branchName, operatingMode, busy, breadcrumbSegments, editedFiles, modifiedCount, onNavigatorOpen, onCollectionOpen, navigatorToggleRef, branchToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, allContentRef, onEntrySelect, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onSave, onSaveAll, saveAllProgress, saving, savedSignal, savedHere, entryListFailed, onSubmit, onWithdraw, hasUnsavedChanges, entryHasDraft, draftStorageFailed, entryReadOnly, entryUnavailable, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, branchReadOnly, branchWriteBlocked, branchLoading, }: EditorHeaderProps): import("react/jsx-runtime").JSX.Element;
97
+ export declare function EditorHeader({ currentEntry, branchName, operatingMode, busy, breadcrumbSegments, editedFiles, modifiedCount, onCollectionOpen, navigatorToggleRef, branchToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onEntrySelect, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onSave, onSaveAll, saveAllProgress, saving, savedSignal, savedHere, entryListFailed, onSubmit, onWithdraw, hasUnsavedChanges, entryHasDraft, draftStorageFailed, entryReadOnly, entryUnavailable, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, branchReadOnly, branchWriteBlocked, branchLoading, }: EditorHeaderProps): import("react/jsx-runtime").JSX.Element;