canopycms 0.1.0-int.115 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/CHANGELOG.md +267 -231
  2. package/README.md +0 -1
  3. package/dist/ai/references.js +3 -1
  4. package/dist/api/assets.js +2 -2
  5. package/dist/api/branch-merge.d.ts +1 -1
  6. package/dist/api/branch.js +1 -1
  7. package/dist/api/content.js +1 -1
  8. package/dist/api/entries-constants.d.ts +1 -1
  9. package/dist/api/entries-constants.js +1 -1
  10. package/dist/api/entries.d.ts +2 -2
  11. package/dist/api/permissions.d.ts +2 -2
  12. package/dist/api/types.d.ts +3 -2
  13. package/dist/cli/cli.js +16 -14
  14. package/dist/cli/generate-ai-content.js +7 -5
  15. package/dist/cli/init.js +4 -4
  16. package/dist/client.d.ts +0 -1
  17. package/dist/client.js +0 -1
  18. package/dist/config/schemas/config.d.ts +0 -5
  19. package/dist/config/schemas/config.js +0 -1
  20. package/dist/config/types.d.ts +0 -1
  21. package/dist/content-index-generation.d.ts +1 -1
  22. package/dist/content-index-generation.js +1 -1
  23. package/dist/content-listing.js +1 -1
  24. package/dist/content-reader.js +1 -1
  25. package/dist/content-store.d.ts +1 -1
  26. package/dist/content-store.js +1 -1
  27. package/dist/dev-content-watcher.d.ts +1 -1
  28. package/dist/dev-content-watcher.js +1 -1
  29. package/dist/editor/BranchManager.d.ts +2 -0
  30. package/dist/editor/BranchManager.js +5 -4
  31. package/dist/editor/CanopyEditor.d.ts +1 -1
  32. package/dist/editor/CanopyEditor.js +1 -2
  33. package/dist/editor/CommentsPanel.js +3 -6
  34. package/dist/editor/Editor.d.ts +0 -2
  35. package/dist/editor/Editor.js +329 -181
  36. package/dist/editor/EditorAuthGate.js +8 -3
  37. package/dist/editor/EntryNavigator.d.ts +10 -0
  38. package/dist/editor/EntryNavigator.js +51 -2
  39. package/dist/editor/FormHeader.js +1 -1
  40. package/dist/editor/PreviewFrame.d.ts +5 -1
  41. package/dist/editor/PreviewFrame.js +3 -2
  42. package/dist/editor/PreviewPane.d.ts +47 -0
  43. package/dist/editor/PreviewPane.js +147 -0
  44. package/dist/editor/PreviewToolbarTools.d.ts +22 -0
  45. package/dist/editor/PreviewToolbarTools.js +73 -0
  46. package/dist/editor/comments/form-comments.js +2 -1
  47. package/dist/editor/components/BranchLockBanner.d.ts +22 -0
  48. package/dist/editor/components/BranchLockBanner.js +39 -0
  49. package/dist/editor/components/BranchPill.d.ts +32 -0
  50. package/dist/editor/components/BranchPill.js +33 -0
  51. package/dist/editor/components/ConfirmDeleteModal.d.ts +3 -1
  52. package/dist/editor/components/ConfirmDeleteModal.js +3 -2
  53. package/dist/editor/components/EditorHeader.d.ts +50 -108
  54. package/dist/editor/components/EditorHeader.js +117 -138
  55. package/dist/editor/components/EditorRail.d.ts +49 -0
  56. package/dist/editor/components/EditorRail.js +65 -0
  57. package/dist/editor/components/EntryBreadcrumb.d.ts +33 -0
  58. package/dist/editor/components/EntryBreadcrumb.js +56 -0
  59. package/dist/editor/components/QuickOpenPalette.d.ts +1 -1
  60. package/dist/editor/components/QuickOpenPalette.js +3 -2
  61. package/dist/editor/components/RenameEntryModal.d.ts +3 -1
  62. package/dist/editor/components/RenameEntryModal.js +3 -2
  63. package/dist/editor/components/SaveAllResult.d.ts +25 -0
  64. package/dist/editor/components/SaveAllResult.js +86 -0
  65. package/dist/editor/components/SaveSplitButton.d.ts +30 -0
  66. package/dist/editor/components/SaveSplitButton.js +108 -0
  67. package/dist/editor/components/index.d.ts +2 -2
  68. package/dist/editor/components/index.js +2 -2
  69. package/dist/editor/context/EditorStateContext.d.ts +4 -4
  70. package/dist/editor/context/EditorStateContext.js +4 -4
  71. package/dist/editor/copy.d.ts +3 -0
  72. package/dist/editor/copy.js +3 -0
  73. package/dist/editor/editor-utils.d.ts +11 -6
  74. package/dist/editor/editor-utils.js +8 -17
  75. package/dist/editor/fields/MarkdownField.js +3 -0
  76. package/dist/editor/hooks/save-all.d.ts +68 -0
  77. package/dist/editor/hooks/save-all.js +61 -0
  78. package/dist/editor/hooks/useCommentSystem.d.ts +2 -0
  79. package/dist/editor/hooks/useCommentSystem.js +3 -0
  80. package/dist/editor/hooks/useDraftManager.d.ts +24 -1
  81. package/dist/editor/hooks/useDraftManager.js +297 -16
  82. package/dist/editor/hooks/useEditorLayout.d.ts +15 -11
  83. package/dist/editor/hooks/useEditorLayout.js +13 -25
  84. package/dist/editor/hooks/useEntryManager.d.ts +4 -0
  85. package/dist/editor/hooks/useEntryManager.js +30 -7
  86. package/dist/editor/media/MediaLibrary.d.ts +1 -1
  87. package/dist/editor/preview-path.d.ts +12 -0
  88. package/dist/editor/preview-path.js +45 -1
  89. package/dist/editor/preview-scale.d.ts +27 -0
  90. package/dist/editor/preview-scale.js +34 -0
  91. package/dist/editor/shell/ActivityRail.d.ts +37 -0
  92. package/dist/editor/shell/ActivityRail.js +91 -0
  93. package/dist/editor/shell/EditorShell.d.ts +17 -0
  94. package/dist/editor/shell/EditorShell.js +31 -0
  95. package/dist/editor/shell/PaneLayout.d.ts +28 -0
  96. package/dist/editor/shell/PaneLayout.js +116 -0
  97. package/dist/editor/shell/SidePanelHost.d.ts +35 -0
  98. package/dist/editor/shell/SidePanelHost.js +184 -0
  99. package/dist/editor/shell/TopBar.d.ts +12 -0
  100. package/dist/editor/shell/TopBar.js +14 -0
  101. package/dist/editor/shell/host-chrome.d.ts +16 -0
  102. package/dist/editor/shell/host-chrome.js +69 -0
  103. package/dist/editor/theme.js +18 -3
  104. package/dist/git-manager.d.ts +1 -1
  105. package/dist/git-manager.js +2 -2
  106. package/dist/operating-mode/types.d.ts +1 -1
  107. package/dist/resolve-canopy-user.d.ts +1 -1
  108. package/dist/resolve-canopy-user.js +1 -1
  109. package/dist/schema/resolver.d.ts +1 -1
  110. package/dist/schema/resolver.js +1 -1
  111. package/dist/schema/schema-store.d.ts +1 -1
  112. package/dist/schema/schema-store.js +2 -2
  113. package/dist/url-collision.js +3 -3
  114. package/dist/url-exclusivity-fixtures.d.ts +1 -1
  115. package/dist/url-exclusivity-fixtures.js +1 -1
  116. package/dist/utils/content-write-lock.d.ts +1 -1
  117. package/dist/utils/content-write-lock.js +1 -1
  118. package/dist/utils/debug.d.ts +1 -1
  119. package/dist/utils/debug.js +5 -3
  120. package/dist/utils/git.d.ts +4 -0
  121. package/dist/utils/git.js +27 -5
  122. package/dist/validation/block-structural-keys.d.ts +1 -1
  123. package/dist/validation/block-structural-keys.js +1 -1
  124. package/dist/version.d.ts +1 -1
  125. package/dist/version.js +1 -1
  126. package/dist/worker/cms-worker.d.ts +1 -1
  127. package/dist/worker/cms-worker.js +2 -2
  128. package/dist/worker/github-auth.d.ts +1 -1
  129. package/dist/worker/github-auth.js +1 -1
  130. package/dist/worker/github-gateway-wire.d.ts +2 -1
  131. package/dist/worker/github-gateway-wire.js +7 -3
  132. package/dist/worker/github-gateway.js +1 -1
  133. package/dist/worker/github-mirror.d.ts +2 -2
  134. package/dist/worker/github-mirror.js +2 -2
  135. package/dist/worker/history-rewrite.d.ts +1 -1
  136. package/dist/worker/history-rewrite.js +1 -1
  137. package/dist/worker/self-checks.d.ts +1 -1
  138. package/dist/worker/shared-repo-git.js +1 -1
  139. package/package.json +5 -4
  140. package/dist/editor/EditorPanes.d.ts +0 -20
  141. package/dist/editor/EditorPanes.js +0 -124
  142. package/dist/editor/components/EditorFooter.d.ts +0 -11
  143. package/dist/editor/components/EditorFooter.js +0 -9
  144. package/dist/editor/components/EditorSidebar.d.ts +0 -70
  145. package/dist/editor/components/EditorSidebar.js +0 -50
@@ -0,0 +1,184 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useId, useRef, useState, } from 'react';
4
+ import { CloseButton, Group, Text, Tooltip } from '@mantine/core';
5
+ import { useWindowEvent } from '@mantine/hooks';
6
+ import { focusIfIdle } from '../utils/focus-return.js';
7
+ const KEY_STEP_PX = 16;
8
+ const RESIZING_ATTR = 'data-canopy-panel-resizing';
9
+ const PANEL_STYLES = `
10
+ body[${RESIZING_ATTR}] { cursor: col-resize; user-select: none; }
11
+ body[${RESIZING_ATTR}] iframe { pointer-events: none; }
12
+ .canopy-side-panel-separator {
13
+ position: absolute;
14
+ inset-block: 0;
15
+ inset-inline-end: -3px;
16
+ width: 6px;
17
+ cursor: col-resize;
18
+ touch-action: none;
19
+ z-index: 1;
20
+ }
21
+ .canopy-side-panel-separator:hover,
22
+ .canopy-side-panel-separator[data-dragging] {
23
+ background: var(--canopy-focus-ring);
24
+ }
25
+ .canopy-side-panel-separator:focus-visible {
26
+ outline: 2px solid var(--canopy-focus-ring);
27
+ outline-offset: -2px;
28
+ }
29
+ `;
30
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
31
+ /**
32
+ * A titled side panel, resizable from its inline-end edge. Docked, it is a column of the shell;
33
+ * as an overlay it covers the form pane only, so the preview stays in view, and Escape closes it.
34
+ * A `focusRequest` moves focus to its `data-autofocus` element, else to the panel; closing with
35
+ * focus inside returns focus to `returnFocusTo`.
36
+ */
37
+ export function SidePanelHost({ title, mode, opened, width, minWidth, maxWidth, onWidthChange, onClose, focusRequest = 0, returnFocusTo, actions, children, id, 'data-testid': testId, }) {
38
+ const fallbackId = useId();
39
+ const panelId = id ?? fallbackId;
40
+ const titleId = `${panelId}-title`;
41
+ const panelRef = useRef(null);
42
+ const focusInside = useRef(false);
43
+ const wasOpened = useRef(opened);
44
+ const servedFocusRequest = useRef(focusRequest);
45
+ const drag = useRef(null);
46
+ const [dragWidth, setDragWidth] = useState(null);
47
+ if (!opened && dragWidth !== null)
48
+ setDragWidth(null);
49
+ const shownWidth = clamp(dragWidth ?? width, minWidth, maxWidth);
50
+ useEffect(() => {
51
+ const panel = panelRef.current;
52
+ const asked = opened && focusRequest !== servedFocusRequest.current;
53
+ servedFocusRequest.current = focusRequest;
54
+ if (!asked || !panel)
55
+ return;
56
+ const autofocus = () => panel.querySelector('[data-autofocus]');
57
+ const field = autofocus();
58
+ if (field) {
59
+ field.focus({ preventScroll: true });
60
+ return;
61
+ }
62
+ // The field may render with content still loading: it takes focus when it arrives, unless
63
+ // focus has moved off the panel by then.
64
+ panel.focus({ preventScroll: true });
65
+ const observer = new MutationObserver(() => {
66
+ if (document.activeElement !== panel)
67
+ return observer.disconnect();
68
+ const arrived = autofocus();
69
+ if (!arrived)
70
+ return;
71
+ observer.disconnect();
72
+ arrived.focus({ preventScroll: true });
73
+ });
74
+ observer.observe(panel, { childList: true, subtree: true });
75
+ return () => observer.disconnect();
76
+ }, [opened, focusRequest]);
77
+ useEffect(() => {
78
+ if (!opened && wasOpened.current) {
79
+ // Its separator is gone, and with it the pointer capture that would have ended a drag.
80
+ drag.current = null;
81
+ document.body.removeAttribute(RESIZING_ATTR);
82
+ if (focusInside.current) {
83
+ focusInside.current = false;
84
+ focusIfIdle(returnFocusTo?.() ?? null);
85
+ }
86
+ }
87
+ wasOpened.current = opened;
88
+ }, [opened, returnFocusTo]);
89
+ // On the window, not the panel: a dialog portalled from the panel would bubble its Escape here
90
+ // through React, so only a key from inside the panel's own DOM closes it.
91
+ useWindowEvent('keydown', (event) => {
92
+ if (opened &&
93
+ mode === 'overlay' &&
94
+ event.key === 'Escape' &&
95
+ !event.defaultPrevented &&
96
+ event.target instanceof Node &&
97
+ panelRef.current?.contains(event.target)) {
98
+ event.preventDefault();
99
+ onClose();
100
+ }
101
+ });
102
+ // A drag cut short by unmounting must not leave the page's iframes deaf to the pointer.
103
+ useEffect(() => () => document.body.removeAttribute(RESIZING_ATTR), []);
104
+ if (!opened)
105
+ return null;
106
+ const endDrag = () => {
107
+ const state = drag.current;
108
+ if (!state)
109
+ return;
110
+ drag.current = null;
111
+ document.body.removeAttribute(RESIZING_ATTR);
112
+ setDragWidth(null);
113
+ onWidthChange(state.width);
114
+ };
115
+ const separatorHandlers = {
116
+ onPointerDown: (event) => {
117
+ if (event.button !== 0)
118
+ return;
119
+ event.preventDefault();
120
+ event.currentTarget.setPointerCapture(event.pointerId);
121
+ const rtl = getComputedStyle(event.currentTarget).direction === 'rtl';
122
+ drag.current = {
123
+ startX: event.clientX,
124
+ startWidth: shownWidth,
125
+ width: shownWidth,
126
+ sign: rtl ? -1 : 1,
127
+ };
128
+ document.body.setAttribute(RESIZING_ATTR, '');
129
+ setDragWidth(shownWidth);
130
+ },
131
+ onPointerMove: (event) => {
132
+ const state = drag.current;
133
+ if (!state)
134
+ return;
135
+ state.width = clamp(state.startWidth + state.sign * (event.clientX - state.startX), minWidth, maxWidth);
136
+ setDragWidth(state.width);
137
+ },
138
+ onPointerUp: endDrag,
139
+ onPointerCancel: endDrag,
140
+ onLostPointerCapture: endDrag,
141
+ onKeyDown: (event) => {
142
+ const rtl = getComputedStyle(event.currentTarget).direction === 'rtl';
143
+ const grow = rtl ? 'ArrowLeft' : 'ArrowRight';
144
+ const shrink = rtl ? 'ArrowRight' : 'ArrowLeft';
145
+ const next = event.key === grow
146
+ ? shownWidth + KEY_STEP_PX
147
+ : event.key === shrink
148
+ ? shownWidth - KEY_STEP_PX
149
+ : event.key === 'Home'
150
+ ? minWidth
151
+ : event.key === 'End'
152
+ ? maxWidth
153
+ : undefined;
154
+ if (next === undefined)
155
+ return;
156
+ event.preventDefault();
157
+ onWidthChange(clamp(next, minWidth, maxWidth));
158
+ },
159
+ };
160
+ const overlay = mode === 'overlay';
161
+ const style = {
162
+ position: overlay ? 'absolute' : 'relative',
163
+ insetBlock: overlay ? 0 : undefined,
164
+ insetInlineStart: overlay ? 0 : undefined,
165
+ zIndex: overlay ? 2 : undefined,
166
+ boxShadow: overlay ? 'var(--mantine-shadow-md)' : undefined,
167
+ width: shownWidth,
168
+ maxWidth: '100%',
169
+ height: '100%',
170
+ display: 'flex',
171
+ flexDirection: 'column',
172
+ background: 'var(--mantine-color-body)',
173
+ borderInlineEnd: '1px solid var(--canopy-input-border)',
174
+ };
175
+ return (_jsxs("section", { ref: panelRef, id: panelId, "aria-labelledby": titleId, tabIndex: -1, "data-testid": testId, "data-panel-mode": mode, style: style, onFocus: () => {
176
+ focusInside.current = true;
177
+ }, onBlur: (event) => {
178
+ if (!event.currentTarget.contains(event.relatedTarget))
179
+ focusInside.current = false;
180
+ }, children: [_jsx("style", { children: PANEL_STYLES }), _jsxs(Group, { justify: "space-between", wrap: "nowrap", gap: "xs", px: "md", py: "xs", style: { borderBlockEnd: '1px solid var(--canopy-input-border)' }, children: [_jsx(Text, { id: titleId, component: "h2", fw: 600, size: "md", m: 0, children: title }), _jsxs(Group, { gap: "xs", wrap: "nowrap", children: [actions, _jsx(Tooltip, { label: `Close ${title.toLowerCase()} panel`, children: _jsx(CloseButton, { "aria-label": `Close ${title.toLowerCase()} panel`, onClick: onClose }) })] })] }), _jsx("div", { style: { flex: 1, minHeight: 0, overflow: 'auto' }, children: children }), _jsx("div", { role: "separator", "aria-orientation": "vertical", "aria-label": `Resize ${title.toLowerCase()} panel`, "aria-controls": panelId, "aria-valuenow": Math.round(shownWidth), "aria-valuemin": minWidth, "aria-valuemax": maxWidth,
181
+ // A focusable separator is a window splitter, a widget; jsx-a11y counts every one static.
182
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
183
+ tabIndex: 0, className: "canopy-side-panel-separator", "data-dragging": dragWidth !== null || undefined, ...separatorHandlers })] }));
184
+ }
@@ -0,0 +1,12 @@
1
+ import { type ReactNode } from 'react';
2
+ export interface TopBarProps {
3
+ /** Where you are: the breadcrumb and anything that describes the open entry. */
4
+ leading: ReactNode;
5
+ /** What you can do: the branch and the actions on it. */
6
+ trailing: ReactNode;
7
+ }
8
+ /**
9
+ * The shell's top bar: one 48px row from `md` up. Narrower, `leading` takes a row of its own
10
+ * above `trailing`, which wraps rather than clip a control. Layout only.
11
+ */
12
+ export declare function TopBar({ leading, trailing }: TopBarProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,14 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Box, Group } from '@mantine/core';
4
+ const TOP_BAR_HEIGHT = 48;
5
+ /**
6
+ * The shell's top bar: one 48px row from `md` up. Narrower, `leading` takes a row of its own
7
+ * above `trailing`, which wraps rather than clip a control. Layout only.
8
+ */
9
+ export function TopBar({ leading, trailing }) {
10
+ return (_jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", px: "sm", py: 6, mih: TOP_BAR_HEIGHT, style: {
11
+ borderBottom: '1px solid var(--canopy-input-border)',
12
+ background: 'var(--mantine-color-body)',
13
+ }, children: [_jsx(Box, { flex: { base: '1 0 100%', md: '1 1 0%' }, miw: 0, children: leading }), _jsx(Group, { gap: 6, wrap: "wrap", align: "center", justify: "flex-end", flex: { base: '1 0 100%', md: '0 1 auto' }, miw: 0, children: trailing })] }));
14
+ }
@@ -0,0 +1,16 @@
1
+ import { type RefObject } from 'react';
2
+ /**
3
+ * @internal Exported for tests. The host page's chrome around `anchor`, an element the editor
4
+ * renders in place: the `<body>` child holding it, plus every `<body>` child the same copy of React
5
+ * rendered (a Next.js root layout's header and footer). That copy's fiber key has a random suffix,
6
+ * so portal containers and the roots other libraries create, such as MDXEditor's popup
7
+ * container, never match it.
8
+ */
9
+ export declare function findHostChrome(anchor: Element): HTMLElement[];
10
+ /**
11
+ * Makes the host's chrome inert and hidden while the editor is mounted: Tab never leaves the
12
+ * editor, and chrome with its own z-index never paints over the shell. Applied once, at mount,
13
+ * since an element added later may be a third-party popup that must stay usable; unmount restores
14
+ * exactly what this changed.
15
+ */
16
+ export declare function useHideHostChrome(anchorRef: RefObject<Element | null>): void;
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+ import { useEffect } from 'react';
3
+ const REACT_FIBER_KEY_PREFIX = '__reactFiber$';
4
+ function reactFiberKey(element) {
5
+ return Object.keys(element).find((key) => key.startsWith(REACT_FIBER_KEY_PREFIX));
6
+ }
7
+ /**
8
+ * @internal Exported for tests. The host page's chrome around `anchor`, an element the editor
9
+ * renders in place: the `<body>` child holding it, plus every `<body>` child the same copy of React
10
+ * rendered (a Next.js root layout's header and footer). That copy's fiber key has a random suffix,
11
+ * so portal containers and the roots other libraries create, such as MDXEditor's popup
12
+ * container, never match it.
13
+ */
14
+ export function findHostChrome(anchor) {
15
+ const body = anchor.ownerDocument.body;
16
+ let root = anchor;
17
+ while (root.parentElement && root.parentElement !== body)
18
+ root = root.parentElement;
19
+ if (root.parentElement !== body)
20
+ return [];
21
+ const fiberKey = reactFiberKey(anchor);
22
+ const siblings = fiberKey
23
+ ? Array.from(body.children).filter((child) => child !== root && fiberKey in child)
24
+ : [];
25
+ return [root, ...siblings].filter((element) => element instanceof HTMLElement);
26
+ }
27
+ /** Per element, so a second editor mounted at once never restores what the first still hides. */
28
+ const holds = new WeakMap();
29
+ function hide(element) {
30
+ let hold = holds.get(element);
31
+ if (!hold) {
32
+ const wasInert = element.hasAttribute('inert');
33
+ const { visibility } = element.style;
34
+ element.setAttribute('inert', '');
35
+ element.style.visibility = 'hidden';
36
+ hold = {
37
+ count: 0,
38
+ restore: () => {
39
+ if (!wasInert)
40
+ element.removeAttribute('inert');
41
+ element.style.visibility = visibility;
42
+ },
43
+ };
44
+ holds.set(element, hold);
45
+ }
46
+ hold.count++;
47
+ const held = hold;
48
+ return () => {
49
+ if (--held.count > 0)
50
+ return;
51
+ holds.delete(element);
52
+ held.restore();
53
+ };
54
+ }
55
+ /**
56
+ * Makes the host's chrome inert and hidden while the editor is mounted: Tab never leaves the
57
+ * editor, and chrome with its own z-index never paints over the shell. Applied once, at mount,
58
+ * since an element added later may be a third-party popup that must stay usable; unmount restores
59
+ * exactly what this changed.
60
+ */
61
+ export function useHideHostChrome(anchorRef) {
62
+ useEffect(() => {
63
+ const anchor = anchorRef.current;
64
+ if (!anchor)
65
+ return;
66
+ const releases = findHostChrome(anchor).map(hide);
67
+ return () => releases.forEach((release) => release());
68
+ }, [anchorRef]);
69
+ }
@@ -104,7 +104,18 @@ const canopyComponents = {
104
104
  }
105
105
  : { root: {} },
106
106
  }),
107
- ActionIcon: ActionIcon.extend({ defaultProps: { size: 'sm' } }),
107
+ ActionIcon: ActionIcon.extend({
108
+ defaultProps: { size: 'sm' },
109
+ vars: (theme, props) => (props.variant ?? 'filled') === 'filled' && isPrimary(theme, props.color)
110
+ ? {
111
+ root: {
112
+ '--ai-bg': 'var(--canopy-primary-fill)',
113
+ '--ai-hover': 'var(--canopy-primary-fill-hover)',
114
+ '--ai-color': 'var(--mantine-color-white)',
115
+ },
116
+ }
117
+ : { root: {} },
118
+ }),
108
119
  // Tooltips open on focus and touch too, so an icon-only control's name reaches the keyboard.
109
120
  Tooltip: Tooltip.extend({ defaultProps: { events: { hover: true, focus: true, touch: true } } }),
110
121
  // A default input's border is `--canopy-input-border`; errored and focused ones keep Mantine's.
@@ -151,7 +162,11 @@ const canopyComponents = {
151
162
  }),
152
163
  ModalCloseButton: Modal.CloseButton.extend({ defaultProps: CLOSE_BUTTON_PROPS }),
153
164
  DrawerCloseButton: Drawer.CloseButton.extend({ defaultProps: CLOSE_BUTTON_PROPS }),
154
- Notification: Notification.extend({ defaultProps: { closeButtonProps: CLOSE_BUTTON_PROPS } }),
165
+ // Mantine dims a titled toast's text to gray.6 / dark.2, under 4.5:1 on its surface.
166
+ Notification: Notification.extend({
167
+ defaultProps: { closeButtonProps: CLOSE_BUTTON_PROPS },
168
+ styles: { description: { color: 'var(--mantine-color-text)' } },
169
+ }),
155
170
  // A light yellow Alert's title, icon and close button are yellow on pale yellow, under 2:1, so
156
171
  // they take the text colour; the tint and the icon's shape still mark it a warning.
157
172
  Alert: Alert.extend({
@@ -348,5 +363,5 @@ input.mantine-TimeInput-input::-webkit-datetime-edit-ampm-field:focus {
348
363
  `;
349
364
  export const CanopyCMSProvider = ({ children, colors, colorScheme = 'light', themeOverride, withNotifications = true, }) => {
350
365
  const theme = useMemo(() => createCanopyTheme({ colors, themeOverride }), [colors, themeOverride]);
351
- return (_jsxs(MantineProvider, { theme: theme, defaultColorScheme: colorScheme, cssVariablesResolver: canopyCssVariablesResolver, children: [_jsx("style", { "data-canopy-styles": true, dangerouslySetInnerHTML: { __html: CANOPY_STYLES } }), _jsxs(ModalsProvider, { modalProps: { zIndex: getDefaultZIndex('popover') }, children: [withNotifications ? _jsx(Notifications, { position: "bottom-left" }) : null, children] })] }));
366
+ return (_jsxs(MantineProvider, { theme: theme, defaultColorScheme: colorScheme, cssVariablesResolver: canopyCssVariablesResolver, children: [_jsx("style", { "data-canopy-styles": true, dangerouslySetInnerHTML: { __html: CANOPY_STYLES } }), _jsxs(ModalsProvider, { modalProps: { zIndex: getDefaultZIndex('popover') }, children: [withNotifications ? _jsx(Notifications, { position: "bottom-right" }) : null, children] })] }));
352
367
  };
@@ -54,7 +54,7 @@ export declare function repackBareRemoteIfNeeded(gitDir: string, git?: SimpleGit
54
54
  * so a clone or checkout the OOM killer stopped would pass for complete. Given as `errors` to a
55
55
  * GitManager's own instance, to `cloneRepo`'s, and to the worker's ref probes of an existing
56
56
  * remote.git (worker/cms-worker.ts); its static helpers and the bare-remote functions do not pass
57
- * it yet (.claude/future-tasks/simple-git-signal-exit-reads-as-success.md).
57
+ * it yet (safeinsights/canopycms#845).
58
58
  */
59
59
  export declare function failOnSignalExit(error: Buffer | Error | undefined, result: {
60
60
  exitCode: number;
@@ -181,7 +181,7 @@ export async function repackBareRemoteIfNeeded(gitDir, git = simpleGit().env(git
181
181
  * so a clone or checkout the OOM killer stopped would pass for complete. Given as `errors` to a
182
182
  * GitManager's own instance, to `cloneRepo`'s, and to the worker's ref probes of an existing
183
183
  * remote.git (worker/cms-worker.ts); its static helpers and the bare-remote functions do not pass
184
- * it yet (.claude/future-tasks/simple-git-signal-exit-reads-as-success.md).
184
+ * it yet (safeinsights/canopycms#845).
185
185
  */
186
186
  export function failOnSignalExit(error, result) {
187
187
  if (error)
@@ -399,7 +399,7 @@ export class GitManager {
399
399
  // gitNetworkChildEnv instead. Under the `allowNetworkRemoteInProd` escape
400
400
  // hatch `this.remote` CAN be a network URL, and these calls do hit it with
401
401
  // that restricted env: a known limitation of the escape hatch, tracked in
402
- // .claude/future-tasks/network-escape-hatch-git-env.md.
402
+ // safeinsights/canopycms#754.
403
403
  //
404
404
  // GIT_CEILING_DIRECTORIES stops git traversing above repoPath to a parent
405
405
  // .git: a corrupt or missing workspace .git must fail, never silently
@@ -2,7 +2,7 @@
2
2
  import type { OperatingMode as OM } from './index.js';
3
3
  import type { CanopyConfig } from '../config/index.js';
4
4
  export type OperatingMode = OM;
5
- /** @internal No importer; deletion candidate in knip-no-importer-deletion-candidates.md. */
5
+ /** @internal No importer; deletion candidate in safeinsights/canopycms#725. */
6
6
  export interface ResolveRemoteUrlOptions {
7
7
  mode: OperatingMode;
8
8
  remoteUrl?: string;
@@ -3,7 +3,7 @@ import type { CanopyUser } from './user.js';
3
3
  import type { OperatingMode } from './operating-mode/index.js';
4
4
  /**
5
5
  * Test-only: reset the once-per-process "no admins configured" warning latch.
6
- * @internal No importer; deletion candidate in knip-no-importer-deletion-candidates.md.
6
+ * @internal No importer; deletion candidate in safeinsights/canopycms#725.
7
7
  */
8
8
  export declare function resetResolveCanopyUserWarningForTests(): void;
9
9
  export interface ResolveCanopyUserDeps {
@@ -17,7 +17,7 @@ import { timeRequestPhase } from './utils/request-timing.js';
17
17
  let warnedNoAdmins = false;
18
18
  /**
19
19
  * Test-only: reset the once-per-process "no admins configured" warning latch.
20
- * @internal No importer; deletion candidate in knip-no-importer-deletion-candidates.md.
20
+ * @internal No importer; deletion candidate in safeinsights/canopycms#725.
21
21
  */
22
22
  export function resetResolveCanopyUserWarningForTests() {
23
23
  warnedNoAdmins = false;
@@ -26,7 +26,7 @@ export declare function resolveSchema(contentRoot: string, entrySchemaRegistry:
26
26
  *
27
27
  * @param contentRoot - Path to the content directory
28
28
  * @returns true if at least one .collection.json file exists
29
- * @internal No importer; deletion candidate in knip-no-importer-deletion-candidates.md.
29
+ * @internal No importer; deletion candidate in safeinsights/canopycms#725.
30
30
  */
31
31
  export declare function hasSchemaFiles(contentRoot: string): Promise<boolean>;
32
32
  /**
@@ -43,7 +43,7 @@ export async function resolveSchema(contentRoot, entrySchemaRegistry, options =
43
43
  *
44
44
  * @param contentRoot - Path to the content directory
45
45
  * @returns true if at least one .collection.json file exists
46
- * @internal No importer; deletion candidate in knip-no-importer-deletion-candidates.md.
46
+ * @internal No importer; deletion candidate in safeinsights/canopycms#725.
47
47
  */
48
48
  export async function hasSchemaFiles(contentRoot) {
49
49
  const metaFiles = await loadCollectionMetaFiles(contentRoot);
@@ -317,7 +317,7 @@ export declare class SchemaOps {
317
317
  * root can re-normalise to the ROOT collection on a second call, mutating
318
318
  * the wrong one instead of reporting not-found. Every entry point calls
319
319
  * this exactly once today, so the ambiguity is unreachable; see
320
- * .claude/future-tasks/collection-path-content-root-ambiguity.md. A future
320
+ * safeinsights/canopycms#623. A future
321
321
  * method that accepts a logical collection path must do the same.
322
322
  */
323
323
  private normalizeCollectionPath;
@@ -304,7 +304,7 @@ export class SchemaOps {
304
304
  * root can re-normalise to the ROOT collection on a second call, mutating
305
305
  * the wrong one instead of reporting not-found. Every entry point calls
306
306
  * this exactly once today, so the ambiguity is unreachable; see
307
- * .claude/future-tasks/collection-path-content-root-ambiguity.md. A future
307
+ * safeinsights/canopycms#623. A future
308
308
  * method that accepts a logical collection path must do the same.
309
309
  */
310
310
  normalizeCollectionPath(collectionPath) {
@@ -580,7 +580,7 @@ export class SchemaOps {
580
580
  // already rules out a `{slug}.{id}` directory at the destination — but that check is
581
581
  // case-SENSITIVE and lets an ID-less `docs/`-style directory through, so a case or
582
582
  // ID-less collision can still slip past it for entries deeper than the index; see
583
- // .claude/future-tasks/collection-sibling-name-uniqueness.md.
583
+ // safeinsights/canopycms#624.
584
584
  if (await findIndexEntryIn(physicalPath)) {
585
585
  // Renaming TO "index" collapses this collection onto its OWN new path, not the
586
586
  // parent's — see url-collision.ts's findUrlPathClaimant for the equivalent create-time
@@ -18,7 +18,7 @@ import { parseTypedFilename } from './utils/typed-filename.js';
18
18
  * `doc.index.{id}.json` inside an md-only collection claims no URL yet still blocks a sibling
19
19
  * write. Closing it means threading each collection's configured formats into a deliberately
20
20
  * schema-free module; tracked in
21
- * .claude/future-tasks/url-collision-guard-superset-of-listing.md.
21
+ * safeinsights/canopycms#878.
22
22
  *
23
23
  * A literal rather than derived: deriving it needs the `ContentFormat` union this module has no
24
24
  * reason to import. `url-collision.test.ts` carries the drift tripwire — a compile-time
@@ -65,7 +65,7 @@ async function findChildCollectionDir(dir, name) {
65
65
  *
66
66
  * The claimant set is nonetheless a strict superset of the listing's in three ways, all
67
67
  * over-blocking and all needing the same fix — the schema this module deliberately does not have
68
- * — so they are one task (.claude/future-tasks/url-collision-guard-superset-of-listing.md):
68
+ * — so they are one task (safeinsights/canopycms#878):
69
69
  *
70
70
  * 1. extension — see `CONTENT_EXTENSIONS` above;
71
71
  * 2. entry TYPE — parsing is structural (no `entryTypes` list), so a file whose type is not in
@@ -155,7 +155,7 @@ export async function findUrlPathClaimant(opts) {
155
155
  // KNOWN DEPENDENCY: this resolves ONE child collection by name, and the index direction above
156
156
  // likewise looks only for a parent ENTRY. Neither considers a second collection sharing the same
157
157
  // name, because nothing stops one being created -- `createCollectionInner` does not check
158
- // sibling names (tracked in collection-sibling-name-uniqueness.md). With two `guides.{id}`
158
+ // sibling names (tracked in safeinsights/canopycms#624). With two `guides.{id}`
159
159
  // directories side by side, an index entry can be written into each: both writes pass this
160
160
  // guard, two entries claim one URL, and only the build guard catches it. Fixing sibling-name
161
161
  // uniqueness closes this without changing anything here, so that task is a prerequisite for
@@ -15,7 +15,7 @@
15
15
  * Scope of the invariant it checks: entries written in the `{type}.{slug}.{id}.{ext}` grammar
16
16
  * with a type their collection declares -- exactly the set `listEntries` can see. A legacy
17
17
  * untyped file (`overview.json`) is invisible to enumeration and is deliberately NOT probed here;
18
- * see `.claude/future-tasks/legacy-untyped-files-url-addressable.md`.
18
+ * see safeinsights/canopycms#728.
19
19
  */
20
20
  import { type RootCollectionConfig } from './config/index.js';
21
21
  import type { ListEntriesItem } from './content-listing.js';
@@ -15,7 +15,7 @@
15
15
  * Scope of the invariant it checks: entries written in the `{type}.{slug}.{id}.{ext}` grammar
16
16
  * with a type their collection declares -- exactly the set `listEntries` can see. A legacy
17
17
  * untyped file (`overview.json`) is invisible to enumeration and is deliberately NOT probed here;
18
- * see `.claude/future-tasks/legacy-untyped-files-url-addressable.md`.
18
+ * see safeinsights/canopycms#728.
19
19
  */
20
20
  import { flattenSchema } from './config/index.js';
21
21
  import { findDuplicateUrlPaths } from './static/index.js';
@@ -44,7 +44,7 @@ import { type OnLockCompromised } from './provisioning-lock.js';
44
44
  * `next dev` and build-time provisioning across worker processes. The case to watch is a write
45
45
  * whose in-lock path triggers a full `idIndex()` rescan of a large tree over EFS, which can exceed
46
46
  * this budget and start 409ing unrelated saves. Making it configurable is tracked in
47
- * .claude/future-tasks/content-write-lock-tuning-and-granularity.md.
47
+ * safeinsights/canopycms#640.
48
48
  */
49
49
  export declare const DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS = 2000;
50
50
  /**
@@ -52,7 +52,7 @@ const CONTENT_WRITE_LOCK_NAME = 'content-write.lock';
52
52
  * `next dev` and build-time provisioning across worker processes. The case to watch is a write
53
53
  * whose in-lock path triggers a full `idIndex()` rescan of a large tree over EFS, which can exceed
54
54
  * this budget and start 409ing unrelated saves. Making it configurable is tracked in
55
- * .claude/future-tasks/content-write-lock-tuning-and-granularity.md.
55
+ * safeinsights/canopycms#640.
56
56
  */
57
57
  export const DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS = 2000;
58
58
  /**
@@ -41,6 +41,6 @@ export declare class DebugLogger {
41
41
  export declare function createDebugLogger(options?: DebugOptions): DebugLogger;
42
42
  /**
43
43
  * Default logger for E2E test infrastructure.
44
- * @internal No importer; deletion candidate in knip-no-importer-deletion-candidates.md.
44
+ * @internal No importer; deletion candidate in safeinsights/canopycms#725.
45
45
  */
46
46
  export declare const testLogger: DebugLogger;
@@ -1,3 +1,5 @@
1
+ /** False where no `process` exists: a browser bundle with no shim for it, such as Storybook's. */
2
+ const envFlag = (name) => typeof process !== 'undefined' && process.env[name] === 'true';
1
3
  const LOG_LEVELS = {
2
4
  DEBUG: 0,
3
5
  INFO: 1,
@@ -11,7 +13,7 @@ export class DebugLogger {
11
13
  }
12
14
  shouldLog(level) {
13
15
  // Check enabled at call time to support runtime env var changes
14
- const enabled = this.options.enabled ?? process.env.CANOPYCMS_DEBUG === 'true';
16
+ const enabled = this.options.enabled ?? envFlag('CANOPYCMS_DEBUG');
15
17
  if (!enabled)
16
18
  return false;
17
19
  const minLevel = this.options.minLevel ?? 'DEBUG';
@@ -92,10 +94,10 @@ export function createDebugLogger(options) {
92
94
  }
93
95
  /**
94
96
  * Default logger for E2E test infrastructure.
95
- * @internal No importer; deletion candidate in knip-no-importer-deletion-candidates.md.
97
+ * @internal No importer; deletion candidate in safeinsights/canopycms#725.
96
98
  */
97
99
  export const testLogger = createDebugLogger({
98
- enabled: process.env.E2E_DEBUG === 'true',
100
+ enabled: envFlag('E2E_DEBUG'),
99
101
  prefix: 'E2E',
100
102
  throwOnError: false,
101
103
  });
@@ -47,6 +47,10 @@ export declare function isStaleLeaseRejection(message: string): boolean;
47
47
  * GitHub refuses only content it does not already hold: carrying a base-branch workflow change by
48
48
  * rebase, merge or fast-forward is accepted. Retrying the identical push can never succeed, so the
49
49
  * worker fails it fast. The text is GitHub's own, not git's, so no locale pinning is needed.
50
+ *
51
+ * A scan of each line's backquote-delimited spans, linear in the message, which is git's output of
52
+ * any length: this rule as a regex, with its run between lead and tail, is quadratic on a line
53
+ * that repeats the lead without the tail.
50
54
  */
51
55
  export declare function workflowPushRefusalFile(message: string): string | null;
52
56
  /**
package/dist/utils/git.js CHANGED
@@ -90,10 +90,11 @@ const STALE_LEASE_REASON = 'stale info';
90
90
  export function isStaleLeaseRejection(message) {
91
91
  return message.includes(REJECTED_MARKER) && message.includes(STALE_LEASE_REASON);
92
92
  }
93
- // GitHub's reason text when a push would introduce workflow content the credential may not write.
94
- // It names the credential kind ("a GitHub App", "an OAuth App", ...) and then the file.
95
- // Both classes stop at a newline, so the match stays on the one status line that carries it.
96
- const WORKFLOW_REFUSAL_PATTERN = /refusing to allow an? [^`\n]+? to create or update workflow `([^`\n]+)`/;
93
+ // GitHub's reason text when a push would introduce workflow content the credential may not write:
94
+ // `refusing to allow <credential kind> to create or update workflow `<file>``, where the kind
95
+ // ("a GitHub App", "an OAuth App", ...) holds no backquote and the file is the next quoted span.
96
+ const WORKFLOW_REFUSAL_LEADS = ['refusing to allow a ', 'refusing to allow an '];
97
+ const WORKFLOW_REFUSAL_TAIL = ' to create or update workflow ';
97
98
  /**
98
99
  * The workflow file named by GitHub's refusal of a push that would add workflow content the
99
100
  * credential lacks the workflows permission for, or null if the message is not that refusal.
@@ -101,9 +102,30 @@ const WORKFLOW_REFUSAL_PATTERN = /refusing to allow an? [^`\n]+? to create or up
101
102
  * GitHub refuses only content it does not already hold: carrying a base-branch workflow change by
102
103
  * rebase, merge or fast-forward is accepted. Retrying the identical push can never succeed, so the
103
104
  * worker fails it fast. The text is GitHub's own, not git's, so no locale pinning is needed.
105
+ *
106
+ * A scan of each line's backquote-delimited spans, linear in the message, which is git's output of
107
+ * any length: this rule as a regex, with its run between lead and tail, is quadratic on a line
108
+ * that repeats the lead without the tail.
104
109
  */
105
110
  export function workflowPushRefusalFile(message) {
106
- return WORKFLOW_REFUSAL_PATTERN.exec(message)?.[1] ?? null;
111
+ for (const line of message.split('\n')) {
112
+ const spans = line.split('`');
113
+ // The last span is closed by no backquote, so the file can be at most the one before it.
114
+ for (let i = 0; i + 2 < spans.length; i++) {
115
+ const span = spans[i];
116
+ const file = spans[i + 1];
117
+ if (file === '' || !span.endsWith(WORKFLOW_REFUSAL_TAIL))
118
+ continue;
119
+ const head = span.slice(0, -WORKFLOW_REFUSAL_TAIL.length);
120
+ const named = WORKFLOW_REFUSAL_LEADS.some((lead) => {
121
+ const at = head.indexOf(lead);
122
+ return at !== -1 && at + lead.length < head.length;
123
+ });
124
+ if (named)
125
+ return file;
126
+ }
127
+ }
128
+ return null;
107
129
  }
108
130
  // git's message when `git fetch <remote> <branch>` names a ref the remote does not have. Both
109
131
  // spellings occur: modern git prints the lowercase form, older versions and some transports
@@ -16,7 +16,7 @@
16
16
  * Two other readers still spell the keys out themselves — they read the value
17
17
  * positionally and disagree on precedence (`resolveBlockItem` prefers `template`,
18
18
  * `ai/json-to-markdown.ts` prefers `_type`) — see
19
- * `.claude/future-tasks/block-discriminator-precedence-disagreement.md`.
19
+ * safeinsights/canopycms#584.
20
20
  *
21
21
  * Dependency-free on purpose: both importers reach contexts that must not pull in
22
22
  * schema types or node built-ins.
@@ -16,7 +16,7 @@
16
16
  * Two other readers still spell the keys out themselves — they read the value
17
17
  * positionally and disagree on precedence (`resolveBlockItem` prefers `template`,
18
18
  * `ai/json-to-markdown.ts` prefers `_type`) — see
19
- * `.claude/future-tasks/block-discriminator-precedence-disagreement.md`.
19
+ * safeinsights/canopycms#584.
20
20
  *
21
21
  * Dependency-free on purpose: both importers reach contexts that must not pull in
22
22
  * schema types or node built-ins.