canopycms 0.0.68-int.95 → 0.0.68-int.97

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 (42) hide show
  1. package/dist/config/schemas/config.d.ts +4 -4
  2. package/dist/config/schemas/config.js +4 -4
  3. package/dist/editor/FormRenderer.js +15 -14
  4. package/dist/editor/fields/BlockField.d.ts +1 -0
  5. package/dist/editor/fields/BlockField.js +6 -4
  6. package/dist/editor/fields/CodeField.d.ts +1 -0
  7. package/dist/editor/fields/CodeField.js +2 -2
  8. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  9. package/dist/editor/fields/DateTimeField.js +2 -2
  10. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  11. package/dist/editor/fields/FieldDescription.js +11 -0
  12. package/dist/editor/fields/ImageField.d.ts +1 -0
  13. package/dist/editor/fields/ImageField.js +3 -2
  14. package/dist/editor/fields/InlineGroupField.js +4 -1
  15. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  16. package/dist/editor/fields/MarkdownField.js +3 -2
  17. package/dist/editor/fields/NumberField.d.ts +1 -0
  18. package/dist/editor/fields/NumberField.js +2 -2
  19. package/dist/editor/fields/NumberListField.d.ts +1 -0
  20. package/dist/editor/fields/NumberListField.js +2 -2
  21. package/dist/editor/fields/ObjectField.d.ts +1 -0
  22. package/dist/editor/fields/ObjectField.js +5 -2
  23. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  24. package/dist/editor/fields/ReferenceField.js +5 -4
  25. package/dist/editor/fields/SelectField.d.ts +1 -0
  26. package/dist/editor/fields/SelectField.js +2 -2
  27. package/dist/editor/fields/StringListField.d.ts +1 -0
  28. package/dist/editor/fields/StringListField.js +2 -2
  29. package/dist/editor/fields/TextField.d.ts +1 -0
  30. package/dist/editor/fields/TextField.js +2 -2
  31. package/dist/editor/fields/ToggleField.d.ts +1 -0
  32. package/dist/editor/fields/ToggleField.js +7 -4
  33. package/dist/version.d.ts +1 -1
  34. package/dist/version.js +1 -1
  35. package/dist/worker/cms-worker.d.ts +3 -3
  36. package/dist/worker/git-sync.d.ts +4 -4
  37. package/dist/worker/git-sync.js +8 -10
  38. package/dist/worker/history-rewrite.d.ts +1 -1
  39. package/dist/worker/history-rewrite.js +1 -1
  40. package/dist/worker/rebase.js +7 -9
  41. package/dist/worker/worker-context.d.ts +6 -1
  42. package/package.json +1 -1
@@ -237,10 +237,10 @@ export declare const CanopyConfigSchema: z.ZodObject<{
237
237
  /**
238
238
  * Default workspace path for prod mode (used when CANOPYCMS_WORKSPACE_ROOT is not set).
239
239
  *
240
- * WARNING: this fallback assumes a worker-style ROOT mount of EFS at /mnt/efs.
241
- * The CanopyCmsService Lambda mounts EFS THROUGH an access point already rooted
242
- * at /workspace and therefore sets CANOPYCMS_WORKSPACE_ROOT=/mnt/efs explicitly;
243
- * if that env were ever unset on the Lambda this default would resolve to
240
+ * WARNING: this fallback assumes a ROOT mount of EFS at /mnt/efs. CanopyCmsService
241
+ * mounts EFS in both the Lambda and the worker THROUGH an access point already
242
+ * rooted at /workspace and therefore sets CANOPYCMS_WORKSPACE_ROOT=/mnt/efs
243
+ * explicitly; if that env were ever unset this default would resolve to
244
244
  * /mnt/efs/workspace = EFS:/workspace/workspace (a wrong, nested dir). The CDK
245
245
  * always sets the env, so this only bites a hand-rolled misconfiguration.
246
246
  */
@@ -113,10 +113,10 @@ export const CanopyConfigSchema = z
113
113
  /**
114
114
  * Default workspace path for prod mode (used when CANOPYCMS_WORKSPACE_ROOT is not set).
115
115
  *
116
- * WARNING: this fallback assumes a worker-style ROOT mount of EFS at /mnt/efs.
117
- * The CanopyCmsService Lambda mounts EFS THROUGH an access point already rooted
118
- * at /workspace and therefore sets CANOPYCMS_WORKSPACE_ROOT=/mnt/efs explicitly;
119
- * if that env were ever unset on the Lambda this default would resolve to
116
+ * WARNING: this fallback assumes a ROOT mount of EFS at /mnt/efs. CanopyCmsService
117
+ * mounts EFS in both the Lambda and the worker THROUGH an access point already
118
+ * rooted at /workspace and therefore sets CANOPYCMS_WORKSPACE_ROOT=/mnt/efs
119
+ * explicitly; if that env were ever unset this default would resolve to
120
120
  * /mnt/efs/workspace = EFS:/workspace/workspace (a wrong, nested dir). The CDK
121
121
  * always sets the env, so this only bites a hand-rolled misconfiguration.
122
122
  */
@@ -15,6 +15,7 @@ import { ReferenceField } from './fields/ReferenceField.js';
15
15
  import { CodeField } from './fields/CodeField.js';
16
16
  import { ImageField } from './fields/ImageField.js';
17
17
  import { ObjectField } from './fields/ObjectField.js';
18
+ import { FieldDescription, groupDescriptionProps } from './fields/FieldDescription.js';
18
19
  import { InlineGroupField } from './fields/InlineGroupField.js';
19
20
  import { formatCanopyPath, normalizeCanopyPath } from './canopy-path.js';
20
21
  import { FieldWrapper } from './comments/FieldWrapper.js';
@@ -87,28 +88,28 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
87
88
  switch (field.type) {
88
89
  case 'string':
89
90
  if (field.list) {
90
- return wrapWithComments(_jsx(StringListField, { id: fieldId, label: label, value: Array.isArray(currentValue) ? currentValue : [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
91
+ return wrapWithComments(_jsx(StringListField, { id: fieldId, label: label, description: field.description, value: Array.isArray(currentValue) ? currentValue : [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
91
92
  }
92
- return wrapWithComments(_jsx(TextField, { id: fieldId, label: label, value: currentValue ?? '', onChange: update, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
93
+ return wrapWithComments(_jsx(TextField, { id: fieldId, label: label, description: field.description, value: currentValue ?? '', onChange: update, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
93
94
  case 'boolean':
94
- return wrapWithComments(_jsx(ToggleField, { id: fieldId, label: label, value: Boolean(currentValue), onChange: (v) => update(Boolean(v)), dataCanopyField: normalizeCanopyPath(path), testId: `field-toggle-${field.name}` }, fieldKey(path)));
95
+ return wrapWithComments(_jsx(ToggleField, { id: fieldId, label: label, description: field.description, value: Boolean(currentValue), onChange: (v) => update(Boolean(v)), dataCanopyField: normalizeCanopyPath(path), testId: `field-toggle-${field.name}` }, fieldKey(path)));
95
96
  case 'number':
96
97
  if (field.list) {
97
- return wrapWithComments(_jsx(NumberListField, { id: fieldId, label: label, value: Array.isArray(currentValue)
98
+ return wrapWithComments(_jsx(NumberListField, { id: fieldId, label: label, description: field.description, value: Array.isArray(currentValue)
98
99
  ? currentValue.filter((v) => typeof v === 'number')
99
100
  : [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
100
101
  }
101
- return wrapWithComments(_jsx(NumberField, { id: fieldId, label: label, value: typeof currentValue === 'number' ? currentValue : undefined, onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
102
+ return wrapWithComments(_jsx(NumberField, { id: fieldId, label: label, description: field.description, value: typeof currentValue === 'number' ? currentValue : undefined, onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
102
103
  case 'datetime':
103
- return wrapWithComments(_jsx(DateTimeField, { id: fieldId, label: label, value: typeof currentValue === 'string' ? currentValue : '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
104
+ return wrapWithComments(_jsx(DateTimeField, { id: fieldId, label: label, description: field.description, value: typeof currentValue === 'string' ? currentValue : '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
104
105
  case 'markdown':
105
106
  case 'mdx':
106
- return wrapWithComments(_jsx(MarkdownField, { id: fieldId, label: label, value: currentValue ?? '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
107
+ return wrapWithComments(_jsx(MarkdownField, { id: fieldId, label: label, description: field.description, value: currentValue ?? '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
107
108
  case 'select': {
108
109
  const selectField = field;
109
110
  const options = normalizeOptions(selectField.options);
110
111
  const isMulti = Boolean(selectField.list);
111
- return wrapWithComments(_jsx(SelectField, { id: fieldId, label: label, options: options, value: isMulti
112
+ return wrapWithComments(_jsx(SelectField, { id: fieldId, label: label, description: field.description, options: options, value: isMulti
112
113
  ? Array.isArray(currentValue)
113
114
  ? currentValue
114
115
  : []
@@ -120,7 +121,7 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
120
121
  ? normalizeOptions(referenceField.options)
121
122
  : undefined;
122
123
  const isMulti = Boolean(referenceField.list);
123
- return wrapWithComments(_jsx(ReferenceField, { id: fieldId, label: label, options: staticOptions?.map((opt) => ({
124
+ return wrapWithComments(_jsx(ReferenceField, { id: fieldId, label: label, description: field.description, options: staticOptions?.map((opt) => ({
124
125
  label: opt.label,
125
126
  value: opt.value,
126
127
  })), collections: referenceField.collections, entryTypes: referenceField.entryTypes, displayField: referenceField.displayField, branch: branch, value: isMulti
@@ -131,7 +132,7 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
131
132
  }
132
133
  case 'image': {
133
134
  const imageField = field;
134
- return wrapWithComments(_jsx(ImageField, { id: fieldId, label: label, value: currentValue, onChange: (next) => update(next), aspect: imageField.aspect, altOptional: imageField.altOptional, dataCanopyField: canopyPath, errors: {
135
+ return wrapWithComments(_jsx(ImageField, { id: fieldId, label: label, description: field.description, value: currentValue, onChange: (next) => update(next), aspect: imageField.aspect, altOptional: imageField.altOptional, dataCanopyField: canopyPath, errors: {
135
136
  src: fieldErrors?.[`${canopyPath}.src`],
136
137
  alt: fieldErrors?.[`${canopyPath}.alt`],
137
138
  crop: fieldErrors?.[`${canopyPath}.crop`],
@@ -139,7 +140,7 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
139
140
  }
140
141
  case 'block': {
141
142
  const blockField = field;
142
- return wrapWithComments(_jsx(BlockField, { label: label, templates: blockField.templates, value: (Array.isArray(currentValue) ? currentValue : []), onChange: (next) => update(next), renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
143
+ return wrapWithComments(_jsx(BlockField, { label: label, description: field.description, templates: blockField.templates, value: (Array.isArray(currentValue) ? currentValue : []), onChange: (next) => update(next), renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
143
144
  }
144
145
  case 'object': {
145
146
  const objectField = field;
@@ -147,7 +148,7 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
147
148
  const items = Array.isArray(currentValue)
148
149
  ? currentValue
149
150
  : [];
150
- return wrapWithComments(_jsx(Paper, { withBorder: true, radius: "md", p: "md", shadow: "xs", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "sm", fw: 600, children: label }), _jsx(Button, { size: "xs", variant: "light", onClick: () => update([...items, {}]), children: "Add item" })] }), _jsxs(Stack, { gap: "sm", children: [items.map((item, idx) => {
151
+ return wrapWithComments(_jsx(Paper, { withBorder: true, radius: "md", p: "md", shadow: "xs", ...groupDescriptionProps(fieldId, field.description), children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "sm", fw: 600, children: label }), _jsx(Button, { size: "xs", variant: "light", onClick: () => update([...items, {}]), children: "Add item" })] }), _jsx(FieldDescription, { baseId: fieldId, description: field.description }), _jsxs(Stack, { gap: "sm", children: [items.map((item, idx) => {
151
152
  const itemTitle = listItemTitle(item, objectField.itemTitleField, `${label} #${idx + 1}`);
152
153
  return (_jsx(Paper, { role: "group", "aria-label": itemTitle, withBorder: true, radius: "md", p: "sm", shadow: "xs", children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "xs", fw: 700, children: itemTitle }), _jsx(Button, { size: "xs", variant: "subtle", color: "red", onClick: () => update(items.filter((_, i) => i !== idx)), children: "Remove" })] }), _jsx(ObjectField, { fields: objectField.fields, value: item, onChange: (next) => {
153
154
  const nextItems = [...items];
@@ -166,10 +167,10 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
166
167
  // doesn't read as deleting the field from the schema.
167
168
  const hasValue = currentValue !== undefined && currentValue !== null;
168
169
  const canClear = objectField.required !== true && hasValue;
169
- return wrapWithComments(_jsx(ObjectField, { label: label, fields: objectField.fields, value: currentValue, onChange: (next) => update(next), onRemove: canClear ? () => update(undefined) : undefined, renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
170
+ return wrapWithComments(_jsx(ObjectField, { label: label, description: field.description, fields: objectField.fields, value: currentValue, onChange: (next) => update(next), onRemove: canClear ? () => update(undefined) : undefined, renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
170
171
  }
171
172
  case 'code':
172
- return wrapWithComments(_jsx(CodeField, { id: fieldId, label: label, value: typeof currentValue === 'string' ? currentValue : '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
173
+ return wrapWithComments(_jsx(CodeField, { id: fieldId, label: label, description: field.description, value: typeof currentValue === 'string' ? currentValue : '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
173
174
  default:
174
175
  return (_jsxs(Text, { size: "xs", c: "dimmed", children: ["Unsupported field: ", field.type] }, fieldKey(path)));
175
176
  }
@@ -7,6 +7,7 @@ export interface BlockInstance {
7
7
  type RenderField = (field: FieldConfig, value: unknown, onChange: (v: unknown) => void, path: Array<string | number>) => React.ReactNode;
8
8
  export interface BlockFieldProps {
9
9
  label?: string;
10
+ description?: string;
10
11
  templates: BlockConfig[];
11
12
  value: BlockInstance[];
12
13
  onChange: (blocks: BlockInstance[]) => void;
@@ -1,12 +1,13 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import React, { useMemo, useState } from 'react';
3
+ import React, { useId, useMemo, useState } from 'react';
4
4
  let blockKeyCounter = 0;
5
5
  import { ActionIcon, Button, Group, Paper, Select, Stack, Text } from '@mantine/core';
6
6
  import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core';
7
7
  import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable';
8
8
  import { CSS } from '@dnd-kit/utilities';
9
9
  import { formatCanopyPath } from '../canopy-path.js';
10
+ import { FieldDescription, groupDescriptionProps } from './FieldDescription.js';
10
11
  const findTemplate = (templates, name) => templates.find((t) => t.name === name);
11
12
  const SortableBlock = ({ id, children }) => {
12
13
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
@@ -19,7 +20,8 @@ const SortableBlock = ({ id, children }) => {
19
20
  };
20
21
  return (_jsx(Paper, { ref: setNodeRef, withBorder: true, radius: "md", p: "sm", shadow: "xs", style: style, children: _jsxs(Group, { align: "flex-start", gap: "sm", children: [_jsx(ActionIcon, { variant: "subtle", "aria-label": "Drag to reorder", ...attributes, ...listeners, style: { cursor: 'grab' }, children: "\u21C5" }, "drag-handle"), _jsx("div", { style: { flex: 1, minWidth: 0, width: '100%' }, children: children }, "content")] }) }));
21
22
  };
22
- export const BlockField = ({ label, templates, value, onChange, renderField, path, dataCanopyField, }) => {
23
+ export const BlockField = ({ label, description, templates, value, onChange, renderField, path, dataCanopyField, }) => {
24
+ const descriptionBaseId = useId();
23
25
  const [itemKeys, setItemKeys] = useState(() => value.map(() => `block-${blockKeyCounter++}`));
24
26
  const [pendingTemplate, setPendingTemplate] = useState(null);
25
27
  // Keeps itemKeys in sync with value's length; setState runs directly in render, not an effect.
@@ -69,12 +71,12 @@ export const BlockField = ({ label, templates, value, onChange, renderField, pat
69
71
  moveBlock(oldIndex, newIndex);
70
72
  };
71
73
  const selectableTemplates = useMemo(() => templates.map((t) => ({ value: t.name, label: t.label ?? t.name })), [templates]);
72
- return (_jsx(Paper, { withBorder: true, radius: "md", p: "md", bg: "gray.0", "data-canopy-field": dataCanopyField ?? formatCanopyPath(path), shadow: "xs", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "xs", fw: 700, c: "dimmed", children: label ?? 'Blocks' }), _jsx(Select, { "aria-label": "Add block", placeholder: "Add block...", data: selectableTemplates, value: pendingTemplate, onChange: (next) => {
74
+ return (_jsx(Paper, { withBorder: true, radius: "md", p: "md", bg: "gray.0", "data-canopy-field": dataCanopyField ?? formatCanopyPath(path), shadow: "xs", ...groupDescriptionProps(descriptionBaseId, description), children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "xs", fw: 700, c: "dimmed", children: label ?? 'Blocks' }), _jsx(Select, { "aria-label": "Add block", placeholder: "Add block...", data: selectableTemplates, value: pendingTemplate, onChange: (next) => {
73
75
  if (next) {
74
76
  addBlock(next);
75
77
  }
76
78
  setPendingTemplate(null);
77
- }, allowDeselect: true, size: "xs", w: 180 })] }), _jsx(DndContext, { sensors: sensors, onDragEnd: handleDragEnd, children: _jsx(SortableContext, { items: itemKeys, strategy: verticalListSortingStrategy, children: _jsx(Stack, { gap: "sm", children: value.map((block, idx) => {
79
+ }, allowDeselect: true, size: "xs", w: 180 })] }), _jsx(FieldDescription, { baseId: descriptionBaseId, description: description }), _jsx(DndContext, { sensors: sensors, onDragEnd: handleDragEnd, children: _jsx(SortableContext, { items: itemKeys, strategy: verticalListSortingStrategy, children: _jsx(Stack, { gap: "sm", children: value.map((block, idx) => {
78
80
  const template = findTemplate(templates, block.template);
79
81
  const currentPath = [...path, idx];
80
82
  return (_jsx(SortableBlock, { id: itemKeys[idx], children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", align: "flex-start", children: [_jsx(Text, { size: "sm", fw: 600, children: template?.label ?? block.template ?? 'Unknown block' }), _jsxs(Group, { gap: 4, children: [_jsx(ActionIcon, { variant: "light", "aria-label": "Move block up", disabled: idx === 0, onClick: () => moveBlock(idx, idx - 1), children: "\u2191" }), _jsx(ActionIcon, { variant: "light", "aria-label": "Move block down", disabled: idx === value.length - 1, onClick: () => moveBlock(idx, idx + 1), children: "\u2193" }), _jsx(Button, { variant: "subtle", color: "red", size: "xs", onClick: () => removeBlock(idx), children: "Remove" })] })] }), template ? (_jsx(Stack, { gap: "sm", children: template.fields.map((f) => (_jsx(React.Fragment, { children: renderField(f, block.value?.[f.name], (next) => updateBlockValue(idx, {
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  export interface CodeFieldProps {
3
3
  id?: string;
4
4
  label?: string;
5
+ description?: string;
5
6
  value: string;
6
7
  onChange: (value: string) => void;
7
8
  language?: string;
@@ -2,8 +2,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useId } from 'react';
3
3
  import { Textarea } from '@mantine/core';
4
4
  // Placeholder for Monaco integration; host app can provide custom renderer for production.
5
- export const CodeField = ({ id, label, value, onChange, language, dataCanopyField, }) => {
5
+ export const CodeField = ({ id, label, description, value, onChange, language, dataCanopyField, }) => {
6
6
  const generatedId = useId();
7
7
  const inputId = id ?? generatedId;
8
- return (_jsx(Textarea, { id: inputId, label: label, value: value, onChange: (e) => onChange(e.currentTarget.value), placeholder: language ? `Code (${language})` : 'Code', autosize: true, minRows: 6, size: "sm", "data-canopy-field": dataCanopyField, styles: { input: { fontFamily: 'Menlo, Consolas, monospace' } } }));
8
+ return (_jsx(Textarea, { id: inputId, label: label, description: description, value: value, onChange: (e) => onChange(e.currentTarget.value), placeholder: language ? `Code (${language})` : 'Code', autosize: true, minRows: 6, size: "sm", "data-canopy-field": dataCanopyField, styles: { input: { fontFamily: 'Menlo, Consolas, monospace' } } }));
9
9
  };
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  export interface DateTimeFieldProps {
3
3
  id?: string;
4
4
  label?: string;
5
+ description?: string;
5
6
  value: string;
6
7
  onChange: (value: string) => void;
7
8
  dataCanopyField?: string;
@@ -49,8 +49,8 @@ export function datetimeLocalValueToIso(local) {
49
49
  * round-trip; sub-second precision is not preserved (no milliseconds field),
50
50
  * so a value with non-zero milliseconds is zeroed out once resaved.
51
51
  */
52
- export const DateTimeField = ({ id, label, value, onChange, dataCanopyField, }) => {
52
+ export const DateTimeField = ({ id, label, description, value, onChange, dataCanopyField, }) => {
53
53
  const generatedId = useId();
54
54
  const inputId = id ?? generatedId;
55
- return (_jsx(Input.Wrapper, { id: inputId, label: label, size: "sm", children: _jsx(Input, { type: "datetime-local", step: 1, size: "sm", value: isoToDatetimeLocalValue(value), onChange: (e) => onChange(datetimeLocalValueToIso(e.currentTarget.value)), "data-canopy-field": dataCanopyField }) }));
55
+ return (_jsx(Input.Wrapper, { id: inputId, label: label, description: description, size: "sm", children: _jsx(Input, { type: "datetime-local", step: 1, size: "sm", value: isoToDatetimeLocalValue(value), onChange: (e) => onChange(datetimeLocalValueToIso(e.currentTarget.value)), "data-canopy-field": dataCanopyField }) }));
56
56
  };
@@ -0,0 +1,16 @@
1
+ import React from 'react';
2
+ /** The id Mantine gives a native input's description, so custom fields match it. */
3
+ export declare const fieldDescriptionId: (baseId: string) => string;
4
+ /**
5
+ * Guidance text for fields that don't use a Mantine input's native `description` prop.
6
+ * Renders nothing for an absent or empty description.
7
+ */
8
+ export declare const FieldDescription: React.FC<{
9
+ baseId: string;
10
+ description?: string;
11
+ }>;
12
+ /** Props that tie a custom field's container to its `FieldDescription`; empty without one. */
13
+ export declare const groupDescriptionProps: (baseId: string, description: string | undefined) => {
14
+ role?: "group";
15
+ "aria-describedby"?: string;
16
+ };
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Input } from '@mantine/core';
3
+ /** The id Mantine gives a native input's description, so custom fields match it. */
4
+ export const fieldDescriptionId = (baseId) => `${baseId}-description`;
5
+ /**
6
+ * Guidance text for fields that don't use a Mantine input's native `description` prop.
7
+ * Renders nothing for an absent or empty description.
8
+ */
9
+ export const FieldDescription = ({ baseId, description, }) => description ? (_jsx(Input.Description, { id: fieldDescriptionId(baseId), children: description })) : null;
10
+ /** Props that tie a custom field's container to its `FieldDescription`; empty without one. */
11
+ export const groupDescriptionProps = (baseId, description) => description ? { role: 'group', 'aria-describedby': fieldDescriptionId(baseId) } : {};
@@ -8,6 +8,7 @@ interface ImageFieldErrors {
8
8
  export interface ImageFieldProps {
9
9
  id?: string;
10
10
  label?: string;
11
+ description?: string;
11
12
  value: ImageFieldValue | undefined;
12
13
  onChange: (value: ImageFieldValue | undefined) => void;
13
14
  /** "W:H" aspect ratio - when set, picking/uploading a new image opens the crop step before the value commits, and a "Crop" button appears on the filled state. */
@@ -11,8 +11,9 @@ import { parseAspectRatio } from '../media/crop-math.js';
11
11
  import { MediaLibrary } from '../media/MediaLibrary.js';
12
12
  import { useAssetUpload } from '../media/useAssetUpload.js';
13
13
  import { ACCEPTED_IMAGE_MIME_TYPES, MAX_UPLOAD_BYTES } from '../media/upload-constants.js';
14
+ import { FieldDescription, groupDescriptionProps } from './FieldDescription.js';
14
15
  const PREVIEW_WIDTH = 320;
15
- export const ImageField = ({ id, label, value, onChange, aspect, altOptional, dataCanopyField, errors, }) => {
16
+ export const ImageField = ({ id, label, description, value, onChange, aspect, altOptional, dataCanopyField, errors, }) => {
16
17
  const generatedId = useId();
17
18
  const inputId = id ?? generatedId;
18
19
  const { baseUrl } = useAssetContext();
@@ -101,7 +102,7 @@ export const ImageField = ({ id, label, value, onChange, aspect, altOptional, da
101
102
  ? assetUrl({ src: value.src }, { baseUrl })
102
103
  : '';
103
104
  const cropInitial = cropRequest?.kind === 'existing' ? value?.crop : undefined;
104
- return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `image-field-${dataCanopyField}`, children: [label && (_jsx(Text, { size: "sm", fw: 500, children: label })), !hasValue ? (_jsxs(Stack, { gap: "xs", children: [_jsx(Dropzone, { onDrop: (files) => void handleDrop(files), onReject: handleReject, maxSize: MAX_UPLOAD_BYTES, accept: ACCEPTED_IMAGE_MIME_TYPES, loading: upload.uploading, multiple: false, "data-testid": `image-field-dropzone-${dataCanopyField}`, children: _jsxs(Group, { justify: "center", gap: "xs", py: "xs", style: { pointerEvents: 'none' }, children: [_jsx(IconUpload, { size: 18 }), _jsx(Text, { size: "xs", children: "Drop an image here, or click to browse" })] }) }), _jsx(Group, { justify: "center", children: _jsx(Button, { variant: "light", size: "xs", leftSection: _jsx(IconPhoto, { size: 14 }), onClick: () => setPickerOpen(true), "data-testid": `image-field-browse-library-${dataCanopyField}`, children: "Browse library" }) }), (upload.error || dropError) && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", children: upload.error || dropError })), errors?.src && (_jsx(Text, { size: "xs", c: "red", children: errors.src }))] })) : (_jsxs(Stack, { gap: "xs", children: [previewFailed ? (_jsxs("div", { "data-testid": `image-field-preview-fallback-${dataCanopyField}`, style: {
105
+ return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `image-field-${dataCanopyField}`, ...groupDescriptionProps(inputId, description), children: [label && (_jsx(Text, { size: "sm", fw: 500, children: label })), _jsx(FieldDescription, { baseId: inputId, description: description }), !hasValue ? (_jsxs(Stack, { gap: "xs", children: [_jsx(Dropzone, { onDrop: (files) => void handleDrop(files), onReject: handleReject, maxSize: MAX_UPLOAD_BYTES, accept: ACCEPTED_IMAGE_MIME_TYPES, loading: upload.uploading, multiple: false, "data-testid": `image-field-dropzone-${dataCanopyField}`, children: _jsxs(Group, { justify: "center", gap: "xs", py: "xs", style: { pointerEvents: 'none' }, children: [_jsx(IconUpload, { size: 18 }), _jsx(Text, { size: "xs", children: "Drop an image here, or click to browse" })] }) }), _jsx(Group, { justify: "center", children: _jsx(Button, { variant: "light", size: "xs", leftSection: _jsx(IconPhoto, { size: 14 }), onClick: () => setPickerOpen(true), "data-testid": `image-field-browse-library-${dataCanopyField}`, children: "Browse library" }) }), (upload.error || dropError) && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", children: upload.error || dropError })), errors?.src && (_jsx(Text, { size: "xs", c: "red", children: errors.src }))] })) : (_jsxs(Stack, { gap: "xs", children: [previewFailed ? (_jsxs("div", { "data-testid": `image-field-preview-fallback-${dataCanopyField}`, style: {
105
106
  width: PREVIEW_WIDTH,
106
107
  height: 160,
107
108
  display: 'flex',
@@ -1,8 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from 'react';
2
3
  import { Paper, Stack, Text } from '@mantine/core';
3
4
  import { formatCanopyPath } from '../canopy-path.js';
5
+ import { FieldDescription, groupDescriptionProps } from './FieldDescription.js';
4
6
  export const InlineGroupField = ({ label, description, fields, value, onChange, renderField, path, }) => {
5
- return (_jsx(Paper, { withBorder: true, radius: "md", p: "md", bg: "gray.0", shadow: "xs", children: _jsxs(Stack, { gap: "sm", children: [(label || description) && (_jsxs(Stack, { gap: 2, children: [label && (_jsx(Text, { size: "xs", fw: 700, c: "dimmed", children: label })), description && (_jsx(Text, { size: "xs", c: "dimmed", children: description }))] })), _jsx(Stack, { gap: "sm", children: fields.map((field) => {
7
+ const descriptionBaseId = useId();
8
+ return (_jsx(Paper, { withBorder: true, radius: "md", p: "md", bg: "gray.0", shadow: "xs", ...groupDescriptionProps(descriptionBaseId, description), children: _jsxs(Stack, { gap: "sm", children: [(label || description) && (_jsxs(Stack, { gap: 2, children: [label && (_jsx(Text, { size: "xs", fw: 700, c: "dimmed", children: label })), _jsx(FieldDescription, { baseId: descriptionBaseId, description: description })] })), _jsx(Stack, { gap: "sm", children: fields.map((field) => {
6
9
  // Nested inline groups are also transparent — pass the same parent value/onChange
7
10
  if (field.type === 'group') {
8
11
  const childGroup = field;
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  export interface MarkdownFieldProps {
3
3
  id?: string;
4
4
  label?: string;
5
+ description?: string;
5
6
  value: string;
6
7
  onChange: (value: string) => void;
7
8
  dataCanopyField?: string;
@@ -6,6 +6,7 @@ import { InsertEntryLink } from './entry-link/index.js';
6
6
  import { MdxImageDialog } from './MdxImageDialog.js';
7
7
  import { useApiClient } from '../context/index.js';
8
8
  import { uploadAsset } from '../media/upload-asset.js';
9
+ import { FieldDescription, groupDescriptionProps } from './FieldDescription.js';
9
10
  const MDXEditorLazy = React.lazy(async () => {
10
11
  const [{ MDXEditor }, { headingsPlugin, listsPlugin, quotePlugin, thematicBreakPlugin, markdownShortcutPlugin, linkPlugin, linkDialogPlugin, imagePlugin, tablePlugin, toolbarPlugin, codeBlockPlugin, codeMirrorPlugin, BoldItalicUnderlineToggles, BlockTypeSelect, ListsToggle, CreateLink, InsertImage, InsertTable, InsertThematicBreak, CodeToggle, InsertCodeBlock, UndoRedo, Separator, insertMarkdown$, usePublisher, useCellValues, saveImage$, closeImageDialog$, imageDialogState$, },] = await Promise.all([import('@mdxeditor/editor'), import('@mdxeditor/editor')]);
11
12
  const EntryLinkToolbarButton = () => {
@@ -94,7 +95,7 @@ const EditorContentStyles = () => (_jsx("style", { children: `
94
95
  .canopy-mdx-content img { max-width: 100%; height: auto; }
95
96
  ` }));
96
97
  const FallbackTextarea = ({ value, onChange, }) => (_jsx(Textarea, { value: value, onChange: (e) => onChange(e.currentTarget.value), placeholder: "Loading markdown editor...", autosize: true, minRows: 6, size: "sm", readOnly: true }));
97
- export const MarkdownField = ({ id, label, value, onChange, dataCanopyField, }) => {
98
+ export const MarkdownField = ({ id, label, description, value, onChange, dataCanopyField, }) => {
98
99
  const generatedId = useId();
99
100
  const inputId = id ?? generatedId;
100
101
  const editorRef = useRef(null);
@@ -119,5 +120,5 @@ export const MarkdownField = ({ id, label, value, onChange, dataCanopyField, })
119
120
  lastExternalValue.current = newValue;
120
121
  onChange(newValue);
121
122
  }, [onChange]);
122
- return (_jsxs("div", { id: inputId, "data-canopy-field": dataCanopyField, className: "canopy-markdown-field", children: [label && (_jsx(Text, { size: "sm", fw: 500, mb: 4, children: label })), _jsx(EditorContentStyles, {}), _jsx("div", { style: editorWrapperStyle, children: _jsx(Suspense, { fallback: _jsx(FallbackTextarea, { value: value, onChange: onChange }), children: _jsx(MDXEditorLazy, { markdown: value, onChange: handleChange, editorRef: editorRef, imageUploadHandler: imageUploadHandler }) }) })] }));
123
+ return (_jsxs("div", { id: inputId, "data-canopy-field": dataCanopyField, className: "canopy-markdown-field", ...groupDescriptionProps(inputId, description), children: [label && (_jsx(Text, { size: "sm", fw: 500, mb: 4, children: label })), _jsx(FieldDescription, { baseId: inputId, description: description }), _jsx(EditorContentStyles, {}), _jsx("div", { style: editorWrapperStyle, children: _jsx(Suspense, { fallback: _jsx(FallbackTextarea, { value: value, onChange: onChange }), children: _jsx(MDXEditorLazy, { markdown: value, onChange: handleChange, editorRef: editorRef, imageUploadHandler: imageUploadHandler }) }) })] }));
123
124
  };
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  export interface NumberFieldProps {
3
3
  id?: string;
4
4
  label?: string;
5
+ description?: string;
5
6
  value: number | undefined;
6
7
  onChange: (value: number | undefined) => void;
7
8
  dataCanopyField?: string;
@@ -27,14 +27,14 @@ const toNumericValue = (raw) => {
27
27
  * would silently store `0.5` after the second keystroke. See
28
28
  * NumberField.test.tsx's keystroke matrix.
29
29
  */
30
- export const NumberField = ({ id, label, value, onChange, dataCanopyField, }) => {
30
+ export const NumberField = ({ id, label, description, value, onChange, dataCanopyField, }) => {
31
31
  const generatedId = useId();
32
32
  const inputId = id ?? generatedId;
33
33
  const [inputValue, setInputValue] = useState(value ?? '');
34
34
  // Shows the in-progress text only while it still matches the parent's
35
35
  // value; once the parent's value diverges (load, discard, reload), that value wins.
36
36
  const displayValue = toNumericValue(inputValue) === value ? inputValue : (value ?? '');
37
- return (_jsx(NumberInput, { id: inputId, label: label, value: displayValue, size: "sm", onChange: (next) => {
37
+ return (_jsx(NumberInput, { id: inputId, label: label, description: description, value: displayValue, size: "sm", onChange: (next) => {
38
38
  setInputValue(next);
39
39
  onChange(toNumericValue(next));
40
40
  }, "data-canopy-field": dataCanopyField }));
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  export interface NumberListFieldProps {
3
3
  id?: string;
4
4
  label?: string;
5
+ description?: string;
5
6
  value: number[];
6
7
  onChange: (value: number[]) => void;
7
8
  dataCanopyField?: string;
@@ -27,11 +27,11 @@ const parseTag = (tag) => {
27
27
  * on Enter indistinguishably from being accepted, with nothing to tell a
28
28
  * typo from a broken field — so rejected text is named in the error slot.
29
29
  */
30
- export const NumberListField = ({ id, label, value, onChange, dataCanopyField, }) => {
30
+ export const NumberListField = ({ id, label, description, value, onChange, dataCanopyField, }) => {
31
31
  const generatedId = useId();
32
32
  const inputId = id ?? generatedId;
33
33
  const [rejected, setRejected] = useState([]);
34
- return (_jsx(TagsInput, { id: inputId, label: label, value: value.map(String), size: "sm", error: rejected.length > 0
34
+ return (_jsx(TagsInput, { id: inputId, label: label, description: description, value: value.map(String), size: "sm", error: rejected.length > 0
35
35
  ? `Not ${rejected.length === 1 ? 'a number' : 'numbers'}: ${rejected.join(', ')}`
36
36
  : undefined, onChange: (next) => {
37
37
  const parsed = [];
@@ -3,6 +3,7 @@ import type { FieldConfig } from '../../config/index.js';
3
3
  export type RenderField = (field: FieldConfig, value: unknown, onChange: (v: unknown) => void, path: Array<string | number>) => React.ReactNode;
4
4
  export interface ObjectFieldProps {
5
5
  label?: string;
6
+ description?: string;
6
7
  fields: FieldConfig[];
7
8
  value: Record<string, unknown> | undefined;
8
9
  onChange: (value: Record<string, unknown>) => void;
@@ -1,9 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from 'react';
2
3
  import { Button, Group, Paper, Stack, Text } from '@mantine/core';
3
4
  import { formatCanopyPath } from '../canopy-path.js';
4
- export const ObjectField = ({ label, fields, value, onChange, renderField, path, dataCanopyField, onRemove, }) => {
5
+ import { FieldDescription, groupDescriptionProps } from './FieldDescription.js';
6
+ export const ObjectField = ({ label, description, fields, value, onChange, renderField, path, dataCanopyField, onRemove, }) => {
5
7
  const current = value ?? {};
6
- return (_jsx(Paper, { withBorder: true, radius: "md", p: "md", bg: "gray.0", "data-canopy-field": dataCanopyField ?? formatCanopyPath(path), shadow: "xs", children: _jsxs(Stack, { gap: "sm", children: [(label || onRemove) && (_jsxs(Group, { justify: "space-between", children: [label && (_jsx(Text, { size: "xs", fw: 700, c: "neutral.8", children: label })), onRemove && (_jsx(Button, { size: "xs", variant: "subtle", color: "red", onClick: onRemove, children: "Clear" }))] })), _jsx(Stack, { gap: "sm", children: fields.map((field) => {
8
+ const descriptionBaseId = useId();
9
+ return (_jsx(Paper, { withBorder: true, radius: "md", p: "md", bg: "gray.0", "data-canopy-field": dataCanopyField ?? formatCanopyPath(path), shadow: "xs", ...groupDescriptionProps(descriptionBaseId, description), children: _jsxs(Stack, { gap: "sm", children: [(label || onRemove) && (_jsxs(Group, { justify: "space-between", children: [label && (_jsx(Text, { size: "xs", fw: 700, c: "neutral.8", children: label })), onRemove && (_jsx(Button, { size: "xs", variant: "subtle", color: "red", onClick: onRemove, children: "Clear" }))] })), _jsx(FieldDescription, { baseId: descriptionBaseId, description: description }), _jsx(Stack, { gap: "sm", children: fields.map((field) => {
7
10
  const fieldPath = [...path, field.name];
8
11
  return (_jsx("div", { children: renderField(field, current[field.name], (next) => onChange({ ...current, [field.name]: next }), fieldPath) }, formatCanopyPath(fieldPath)));
9
12
  }) })] }) }));
@@ -7,6 +7,7 @@ export interface ReferenceOption {
7
7
  export interface ReferenceFieldProps {
8
8
  id?: string;
9
9
  label?: string;
10
+ description?: string;
10
11
  options?: ReferenceOption[];
11
12
  collections?: string[];
12
13
  entryTypes?: string[];
@@ -6,7 +6,8 @@ import { IconAlertCircle } from '@tabler/icons-react';
6
6
  import { createApiClient } from '../../api/client.js';
7
7
  import { useOptionalApiClient } from '../context/index.js';
8
8
  import { getErrorMessage } from '../../utils/error.js';
9
- export const ReferenceField = ({ id, label, options: staticOptions, collections, entryTypes, displayField = 'title', branch = 'main', value, onChange, multiple, dataCanopyField, }) => {
9
+ import { FieldDescription, groupDescriptionProps } from './FieldDescription.js';
10
+ export const ReferenceField = ({ id, label, description, options: staticOptions, collections, entryTypes, displayField = 'title', branch = 'main', value, onChange, multiple, dataCanopyField, }) => {
10
11
  // Configured with the deployment's basePath when rendered inside an
11
12
  // ApiClientProvider (always true in the real Editor tree); `null` outside one
12
13
  // (e.g. FormRenderer.stories.tsx), so the fetch effect falls back to a
@@ -101,10 +102,10 @@ export const ReferenceField = ({ id, label, options: staticOptions, collections,
101
102
  setRetryCount((count) => count + 1);
102
103
  };
103
104
  if (loading) {
104
- return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `reference-field-${dataCanopyField}`, children: [_jsx(Text, { size: "sm", fw: 500, children: label }), _jsx(Loader, { size: "sm", "data-testid": `reference-loading-${dataCanopyField}` })] }));
105
+ return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `reference-field-${dataCanopyField}`, ...groupDescriptionProps(inputId, description), children: [_jsx(Text, { size: "sm", fw: 500, children: label }), _jsx(FieldDescription, { baseId: inputId, description: description }), _jsx(Loader, { size: "sm", "data-testid": `reference-loading-${dataCanopyField}` })] }));
105
106
  }
106
107
  if (error) {
107
- return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `reference-field-${dataCanopyField}`, children: [_jsx(Text, { size: "sm", fw: 500, children: label }), _jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", title: "Error", "data-testid": `reference-error-${dataCanopyField}`, children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: error }), _jsx(Button, { size: "xs", variant: "light", onClick: handleRetry, "data-testid": `reference-retry-${dataCanopyField}`, children: "Retry" })] }) })] }));
108
+ return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `reference-field-${dataCanopyField}`, ...groupDescriptionProps(inputId, description), children: [_jsx(Text, { size: "sm", fw: 500, children: label }), _jsx(FieldDescription, { baseId: inputId, description: description }), _jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", title: "Error", "data-testid": `reference-error-${dataCanopyField}`, children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: error }), _jsx(Button, { size: "xs", variant: "light", onClick: handleRetry, "data-testid": `reference-retry-${dataCanopyField}`, children: "Retry" })] }) })] }));
108
109
  }
109
- return (_jsx(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `reference-field-${dataCanopyField}`, children: multiple ? (_jsx(MultiSelect, { id: inputId, label: label, data: options, value: normalizedValue, onChange: (next) => onChange(next), searchable: true, placeholder: "Select reference\u2026", size: "sm" })) : (_jsx(Select, { id: inputId, label: label, data: options, value: normalizedValue, onChange: (next) => onChange(next ?? ''), searchable: true, clearable: true, placeholder: "Select reference\u2026", size: "sm" })) }));
110
+ return (_jsx(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `reference-field-${dataCanopyField}`, children: multiple ? (_jsx(MultiSelect, { id: inputId, label: label, description: description, data: options, value: normalizedValue, onChange: (next) => onChange(next), searchable: true, placeholder: "Select reference\u2026", size: "sm" })) : (_jsx(Select, { id: inputId, label: label, description: description, data: options, value: normalizedValue, onChange: (next) => onChange(next ?? ''), searchable: true, clearable: true, placeholder: "Select reference\u2026", size: "sm" })) }));
110
111
  };
@@ -6,6 +6,7 @@ interface SelectOption {
6
6
  export interface SelectFieldProps {
7
7
  id?: string;
8
8
  label?: string;
9
+ description?: string;
9
10
  options: SelectOption[];
10
11
  value: string | string[];
11
12
  onChange: (value: string | string[]) => void;
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useId } from 'react';
3
3
  import { MultiSelect, Select, Stack, Text } from '@mantine/core';
4
- export const SelectField = ({ id, label, options, value, onChange, multiple, placeholder = 'Select…', dataCanopyField, }) => {
4
+ export const SelectField = ({ id, label, description, options, value, onChange, multiple, placeholder = 'Select…', dataCanopyField, }) => {
5
5
  const normalizedValue = multiple
6
6
  ? Array.isArray(value)
7
7
  ? value
@@ -11,5 +11,5 @@ export const SelectField = ({ id, label, options, value, onChange, multiple, pla
11
11
  : '';
12
12
  const generatedId = useId();
13
13
  const inputId = id ?? generatedId;
14
- return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, children: [multiple ? (_jsx(MultiSelect, { id: inputId, label: label, data: options, value: normalizedValue, onChange: (next) => onChange(next), searchable: true, placeholder: placeholder, size: "sm" })) : (_jsx(Select, { id: inputId, label: label, data: options, value: normalizedValue, onChange: (next) => onChange(next ?? ''), searchable: true, clearable: true, placeholder: placeholder, size: "sm" })), multiple && (_jsx(Text, { size: "xs", c: "dimmed", children: "Searchable multi-select; start typing to filter." }))] }));
14
+ return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, children: [multiple ? (_jsx(MultiSelect, { id: inputId, label: label, description: description, data: options, value: normalizedValue, onChange: (next) => onChange(next), searchable: true, placeholder: placeholder, size: "sm" })) : (_jsx(Select, { id: inputId, label: label, description: description, data: options, value: normalizedValue, onChange: (next) => onChange(next ?? ''), searchable: true, clearable: true, placeholder: placeholder, size: "sm" })), multiple && (_jsx(Text, { size: "xs", c: "dimmed", children: "Searchable multi-select; start typing to filter." }))] }));
15
15
  };
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  export interface StringListFieldProps {
3
3
  id?: string;
4
4
  label?: string;
5
+ description?: string;
5
6
  value: string[];
6
7
  onChange: (value: string[]) => void;
7
8
  dataCanopyField?: string;
@@ -7,10 +7,10 @@ import { TagsInput } from '@mantine/core';
7
7
  * Uses Mantine's TagsInput: type + Enter adds an item, each item renders as a
8
8
  * removable pill, and Backspace on an empty input removes the last item.
9
9
  */
10
- export const StringListField = ({ id, label, value, onChange, dataCanopyField, }) => {
10
+ export const StringListField = ({ id, label, description, value, onChange, dataCanopyField, }) => {
11
11
  const generatedId = useId();
12
12
  const inputId = id ?? generatedId;
13
- return (_jsx(TagsInput, { id: inputId, label: label, value: value, size: "sm", onChange: onChange,
13
+ return (_jsx(TagsInput, { id: inputId, label: label, description: description, value: value, size: "sm", onChange: onChange,
14
14
  // Faithful generic-list semantics, not tag ergonomics: no comma-splitting
15
15
  // ("New York, NY" stays one item) and duplicates are legitimate list
16
16
  // data — TagsInput's defaults would break both, so existing file data
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  export interface TextFieldProps {
3
3
  id?: string;
4
4
  label?: string;
5
+ description?: string;
5
6
  value: string;
6
7
  onChange: (value: string) => void;
7
8
  dataCanopyField?: string;
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useId } from 'react';
3
3
  import { TextInput } from '@mantine/core';
4
- export const TextField = ({ id, label, value, onChange, dataCanopyField, }) => {
4
+ export const TextField = ({ id, label, description, value, onChange, dataCanopyField, }) => {
5
5
  const generatedId = useId();
6
6
  const inputId = id ?? generatedId;
7
- return (_jsx(TextInput, { id: inputId, label: label, value: value, size: "sm", onChange: (e) => onChange(e.currentTarget.value), "data-canopy-field": dataCanopyField }));
7
+ return (_jsx(TextInput, { id: inputId, label: label, description: description, value: value, size: "sm", onChange: (e) => onChange(e.currentTarget.value), "data-canopy-field": dataCanopyField }));
8
8
  };
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  export interface ToggleFieldProps {
3
3
  id?: string;
4
4
  label?: string;
5
+ description?: string;
5
6
  value: boolean;
6
7
  onChange: (value: boolean) => void;
7
8
  dataCanopyField?: string;
@@ -1,8 +1,11 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useId } from 'react';
3
- import { Switch } from '@mantine/core';
4
- export const ToggleField = ({ id, label, value, onChange, dataCanopyField, testId, }) => {
3
+ import { Stack, Switch } from '@mantine/core';
4
+ import { FieldDescription, fieldDescriptionId } from './FieldDescription.js';
5
+ export const ToggleField = ({ id, label, description, value, onChange, dataCanopyField, testId, }) => {
5
6
  const generatedId = useId();
6
7
  const inputId = id ?? generatedId;
7
- return (_jsx(Switch, { id: inputId, label: label, checked: value, onChange: (e) => onChange(e.currentTarget.checked), size: "md", "data-canopy-field": dataCanopyField, wrapperProps: testId ? { 'data-testid': testId } : undefined }));
8
+ // A Switch's `description` sits inside its <label> and so joins the accessible name;
9
+ // rendering it as a sibling keeps the name to the label alone.
10
+ return (_jsxs(Stack, { gap: 4, children: [_jsx(Switch, { id: inputId, label: label, "aria-describedby": description ? fieldDescriptionId(inputId) : undefined, checked: value, onChange: (e) => onChange(e.currentTarget.checked), size: "md", "data-canopy-field": dataCanopyField, wrapperProps: testId ? { 'data-testid': testId } : undefined }), _jsx(FieldDescription, { baseId: inputId, description: description })] }));
8
11
  };
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const CANOPYCMS_VERSION = "0.0.68-int.95";
1
+ export declare const CANOPYCMS_VERSION = "0.0.68-int.97";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/bump-version.mjs, its only writer. The package builds with plain
2
2
  // tsc (rootDir: src), so src/ cannot import ../package.json; version.test.ts keeps the two equal.
3
- export const CANOPYCMS_VERSION = '0.0.68-int.95';
3
+ export const CANOPYCMS_VERSION = '0.0.68-int.97';
@@ -15,11 +15,11 @@ export type AuthCacheRefresher = () => Promise<void>;
15
15
  * interface for the two shapes and why an App is optional.
16
16
  */
17
17
  export interface CmsWorkerConfig extends GitHubAuthConfig {
18
- /** Path to workspace root on EFS (e.g., /mnt/efs/workspace) */
18
+ /** Path to workspace root on EFS (e.g., /mnt/efs) */
19
19
  workspacePath: string;
20
- /** GitHub owner (e.g., 'safeinsights') */
20
+ /** GitHub owner (e.g., 'acme') */
21
21
  githubOwner: string;
22
- /** GitHub repo name (e.g., 'docs-site') */
22
+ /** GitHub repo name (e.g., 'site') */
23
23
  githubRepo: string;
24
24
  /** Called periodically to update the auth metadata cache on EFS. */
25
25
  refreshAuthCache?: AuthCacheRefresher;
@@ -15,7 +15,7 @@ import type { WorkerContext } from './worker-context.js';
15
15
  *
16
16
  * One ordering is load-bearing and nothing enforces it: `runRebaseCycle` MUST
17
17
  * follow `reconcileTrackedBranches`. Branch clones fetch the base tip from
18
- * `remote.git` (`origin`), and `reconcileTrackedBranches` is what advances
18
+ * `remote.git`, and `reconcileTrackedBranches` is what advances
19
19
  * `remote.git`'s `refs/heads/*` toward what the fetch above put in the tracking
20
20
  * namespace; reorder them and every branch rebases onto the PREVIOUS cycle's
21
21
  * base tip -- not corrupting, but silently a cycle behind.
@@ -51,7 +51,7 @@ export declare function syncGit(ctx: GitSyncContext): Promise<void>;
51
51
  export declare function cleanupTrashedBranchDirs(ctx: Pick<GitSyncContext, 'contentBranchesPath'>): Promise<number>;
52
52
  /**
53
53
  * Fast-forward the base branch's own working-tree clone
54
- * (content-branches/<baseBranch>) to match origin/<baseBranch>, every sync
54
+ * (content-branches/<baseBranch>) to match remote.git's <baseBranch>, every sync
55
55
  * cycle, so the drift window is bounded by gitSyncInterval.
56
56
  *
57
57
  * A dedicated, explicit and LOUD step rather than a side effect of the rebase
@@ -60,8 +60,8 @@ export declare function cleanupTrashedBranchDirs(ctx: Pick<GitSyncContext, 'cont
60
60
  * new branch "from base" silently gets a stale snapshot. The returned outcome
61
61
  * goes to worker-status.json for the same reason.
62
62
  *
63
- * ff-only on purpose: this clone must stay a linear mirror of
64
- * origin/<baseBranch>, so a merge that isn't a fast-forward (diverged local
63
+ * ff-only on purpose: this clone must stay a linear mirror of remote.git's
64
+ * <baseBranch>, so a merge that isn't a fast-forward (diverged local
65
65
  * history) is left untouched rather than force-resolved.
66
66
  *
67
67
  * Holds the provisioning lock and then, like the rebase loop, the [SYNC-C1]
@@ -409,7 +409,7 @@ export async function cleanupTrashedBranchDirs(ctx) {
409
409
  const warnedTrackedCanopyState = new Set();
410
410
  /**
411
411
  * Fast-forward the base branch's own working-tree clone
412
- * (content-branches/<baseBranch>) to match origin/<baseBranch>, every sync
412
+ * (content-branches/<baseBranch>) to match remote.git's <baseBranch>, every sync
413
413
  * cycle, so the drift window is bounded by gitSyncInterval.
414
414
  *
415
415
  * A dedicated, explicit and LOUD step rather than a side effect of the rebase
@@ -418,8 +418,8 @@ const warnedTrackedCanopyState = new Set();
418
418
  * new branch "from base" silently gets a stale snapshot. The returned outcome
419
419
  * goes to worker-status.json for the same reason.
420
420
  *
421
- * ff-only on purpose: this clone must stay a linear mirror of
422
- * origin/<baseBranch>, so a merge that isn't a fast-forward (diverged local
421
+ * ff-only on purpose: this clone must stay a linear mirror of remote.git's
422
+ * <baseBranch>, so a merge that isn't a fast-forward (diverged local
423
423
  * history) is left untouched rather than force-resolved.
424
424
  *
425
425
  * Holds the provisioning lock and then, like the rebase loop, the [SYNC-C1]
@@ -523,15 +523,13 @@ export async function refreshBaseBranchWorkspace(ctx) {
523
523
  };
524
524
  }
525
525
  // Raw (unsanitized) name from here on: these are git ref operations
526
- // against origin/<baseBranch>, not filesystem paths, so they must use
526
+ // against remote.git's <baseBranch>, not filesystem paths, so they must use
527
527
  // the same name GitHub knows the branch by.
528
- await baseGit.fetch('origin', ctx.baseBranch);
528
+ await baseGit.fetch(ctx.remoteGitPath, ctx.baseBranch);
529
529
  // rev-list, not status.behind, which needs an upstream tracking branch that
530
- // is not guaranteed here. Against the just-fetched tip rather than
531
- // origin/<base>: workspaces are cloned --single-branch (git-manager.ts), so
532
- // for any other base branch origin/<base> never exists and rev-list dies
533
- // with "ambiguous argument". Pin FETCH_HEAD to a SHA immediately -- it is
534
- // one shared mutable file per repo, silently repointed by any other fetch.
530
+ // is not guaranteed here. Against the just-fetched tip: a fetch by path
531
+ // updates no remote-tracking ref. Pin FETCH_HEAD to a SHA immediately -- it
532
+ // is one shared mutable file per repo, silently repointed by any other fetch.
535
533
  const fetchedTip = (await baseGit.revparse(['FETCH_HEAD'])).trim();
536
534
  const behindCount = parseInt((await baseGit.raw(['rev-list', '--count', `HEAD..${fetchedTip}`])).trim(), 10);
537
535
  if (behindCount > 0) {
@@ -72,7 +72,7 @@ export declare function clearHistoryRewrittenMarker(ctx: Pick<HistoryRewriteCont
72
72
  * Returns whether the push landed. A refused lease means a concurrent Lambda
73
73
  * push moved the ref; logged and retried by the self-heal pass, never thrown.
74
74
  */
75
- export declare function forcePublishToLocalRemote(ctx: Pick<HistoryRewriteContext, 'taskTimeoutMs'>, branchPath: string, branchRef: string, expectedSha: string): Promise<boolean>;
75
+ export declare function forcePublishToLocalRemote(ctx: Pick<HistoryRewriteContext, 'remoteGitPath' | 'taskTimeoutMs'>, branchPath: string, branchRef: string, expectedSha: string): Promise<boolean>;
76
76
  /**
77
77
  * Queue the GitHub hop for a branch whose rewritten history now sits in
78
78
  * `remote.git`, so an open PR's head follows the rebase within a cycle instead
@@ -108,7 +108,7 @@ export async function forcePublishToLocalRemote(ctx, branchPath, branchRef, expe
108
108
  // Real flags must precede --end-of-options; everything after it is
109
109
  // positional (see GitManager.push() for the same guard).
110
110
  '--end-of-options',
111
- 'origin',
111
+ ctx.remoteGitPath,
112
112
  `${branchRef}:${branchRef}`,
113
113
  ]);
114
114
  workerLog(` Published rebased ${branchRef} into remote.git`);
@@ -162,8 +162,8 @@ async function runRebaseRounds(ctx, branchGit, branchDir, fetchedBaseTip, isLock
162
162
  break;
163
163
  try {
164
164
  if (nextAction === 'start') {
165
- // The pinned base tip fetched above (single-branch clones have
166
- // no origin/<base> remote-tracking ref for other branches).
165
+ // The pinned base tip fetched above; a fetch by path updates no
166
+ // remote-tracking ref.
167
167
  await branchGit.rebase([fetchedBaseTip]);
168
168
  }
169
169
  else if (nextAction === 'continue') {
@@ -638,7 +638,7 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
638
638
  // the bytes stay untouched, and the wedge is recorded for an operator.
639
639
  const trackedState = trackedCanopyStateChanges(dirtyCheck);
640
640
  if (trackedState.length > 0) {
641
- await branchGit.fetch('origin', ctx.baseBranch);
641
+ await branchGit.fetch(ctx.remoteGitPath, ctx.baseBranch);
642
642
  const baseTip = (await branchGit.revparse(['FETCH_HEAD'])).trim();
643
643
  const { stillTracked } = await splitByUpstreamTracking(branchGit, trackedState, baseTip);
644
644
  const reason = stillTracked.length > 0
@@ -672,14 +672,12 @@ async function rebaseOneBranch(ctx, branchDir, branchPath, isProvisioningLockCom
672
672
  marker: metaFile.branch.historyRewrittenFrom,
673
673
  });
674
674
  }
675
- await branchGit.fetch('origin', ctx.baseBranch);
675
+ await branchGit.fetch(ctx.remoteGitPath, ctx.baseBranch);
676
676
  // rev-list, not status.behind, which needs an upstream tracking branch
677
677
  // that checkoutBranch's fallback paths do not always configure. Against
678
- // the just-fetched tip, not origin/<base>: branch clones are
679
- // --single-branch, so no remote-tracking ref exists for any base branch
680
- // other than the one they were cloned from. Pinned to a SHA immediately
681
- // — FETCH_HEAD is one shared mutable file per repo, repointed by any
682
- // concurrent fetch.
678
+ // the just-fetched tip: a fetch by path updates no remote-tracking ref.
679
+ // Pinned to a SHA immediately — FETCH_HEAD is one shared mutable file per
680
+ // repo, repointed by any concurrent fetch.
683
681
  const fetchedBaseTip = (await branchGit.revparse(['FETCH_HEAD'])).trim();
684
682
  const behindCount = parseInt((await branchGit.raw(['rev-list', '--count', `HEAD..${fetchedBaseTip}`])).trim(), 10);
685
683
  const meta = getBranchMetadataFileManager(branchPath, ctx.contentBranchesPath);
@@ -38,7 +38,12 @@ export interface WorkerContext {
38
38
  readonly sanitizedBaseBranch: SanitizedBranchName;
39
39
  /** `{workspacePath}/.tasks` — the task queue and worker-status.json. */
40
40
  readonly taskDir: string;
41
- /** `{workspacePath}/remote.git` — the shared bare repo. */
41
+ /**
42
+ * `{workspacePath}/remote.git` — the shared bare repo. The worker fetches
43
+ * into and pushes from a workspace clone against this path, never the
44
+ * clone's `origin`: that holds the path the CLONING process saw (usually
45
+ * the Lambda), and the clone's config is Lambda-writable.
46
+ */
42
47
  readonly remoteGitPath: string;
43
48
  /** `{workspacePath}/content-branches` — the branch workspace root. */
44
49
  readonly contentBranchesPath: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@codemirror/language, @lezer/highlight: workaround — @mdxeditor/editor uses cm6-theme-basic-light which peer-requires these but mdxeditor doesn't declare them as dependencies",
3
3
  "name": "canopycms",
4
- "version": "0.0.68-int.95",
4
+ "version": "0.0.68-int.97",
5
5
  "description": "CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.",
6
6
  "license": "MIT",
7
7
  "repository": {