@elytracms/studio 0.0.38 → 0.0.39

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 (179) hide show
  1. package/convex/cms.ts +8 -2
  2. package/dist/components/app-views/default-modules.d.ts +60 -5
  3. package/dist/components/app-views/default-modules.js +53 -15
  4. package/dist/components/app-views/default-modules.js.map +1 -1
  5. package/dist/components/app-views/module.d.ts +2 -0
  6. package/dist/components/app-views/module.js +8 -0
  7. package/dist/components/app-views/module.js.map +1 -1
  8. package/dist/components/assets/asset-detail.d.ts +24 -0
  9. package/dist/components/assets/asset-detail.js +163 -0
  10. package/dist/components/assets/asset-detail.js.map +1 -0
  11. package/dist/components/assets/asset-grid.d.ts +28 -0
  12. package/dist/components/assets/asset-grid.js +68 -0
  13. package/dist/components/assets/asset-grid.js.map +1 -0
  14. package/dist/components/assets/asset-metadata.d.ts +19 -0
  15. package/dist/components/assets/asset-metadata.js +44 -0
  16. package/dist/components/assets/asset-metadata.js.map +1 -1
  17. package/dist/components/assets/assets-collection-view-source.d.ts +54 -0
  18. package/dist/components/assets/assets-collection-view-source.js +186 -0
  19. package/dist/components/assets/assets-collection-view-source.js.map +1 -0
  20. package/dist/components/assets/assets-module.d.ts +18 -0
  21. package/dist/components/assets/assets-module.js +94 -0
  22. package/dist/components/assets/assets-module.js.map +1 -0
  23. package/dist/components/assets/assets-store.js +2 -7
  24. package/dist/components/assets/assets-store.js.map +1 -1
  25. package/dist/components/assets/assets-view-preference.d.ts +15 -0
  26. package/dist/components/assets/assets-view-preference.js +22 -0
  27. package/dist/components/assets/assets-view-preference.js.map +1 -0
  28. package/dist/components/builder/builder-store.d.ts +12 -0
  29. package/dist/components/builder/builder-store.js +18 -16
  30. package/dist/components/builder/builder-store.js.map +1 -1
  31. package/dist/components/builder/clipboard-actions.js +5 -14
  32. package/dist/components/builder/clipboard-actions.js.map +1 -1
  33. package/dist/components/builder/composition-field-editor-lazy.d.ts +19 -0
  34. package/dist/components/builder/composition-field-editor-lazy.js +29 -0
  35. package/dist/components/builder/composition-field-editor-lazy.js.map +1 -0
  36. package/dist/components/builder/composition-field-editor.js +1 -4
  37. package/dist/components/builder/composition-field-editor.js.map +1 -1
  38. package/dist/components/builder/insert-context.d.ts +8 -0
  39. package/dist/components/builder/insert-context.js +1 -3
  40. package/dist/components/builder/insert-context.js.map +1 -1
  41. package/dist/components/builder/page-tree.js +1 -3
  42. package/dist/components/builder/page-tree.js.map +1 -1
  43. package/dist/components/builder/property-inspector.js +7 -134
  44. package/dist/components/builder/property-inspector.js.map +1 -1
  45. package/dist/components/cms/cms-store.d.ts +15 -4
  46. package/dist/components/cms/cms-store.js +42 -24
  47. package/dist/components/cms/cms-store.js.map +1 -1
  48. package/dist/components/cms/collection-table.d.ts +8 -1
  49. package/dist/components/cms/collection-table.js +62 -10
  50. package/dist/components/cms/collection-table.js.map +1 -1
  51. package/dist/components/cms/column-width.d.ts +25 -0
  52. package/dist/components/cms/column-width.js +47 -0
  53. package/dist/components/cms/column-width.js.map +1 -0
  54. package/dist/components/cms/detail-editor.js +101 -68
  55. package/dist/components/cms/detail-editor.js.map +1 -1
  56. package/dist/components/cms/discard-draft.d.ts +32 -0
  57. package/dist/components/cms/discard-draft.js +13 -0
  58. package/dist/components/cms/discard-draft.js.map +1 -0
  59. package/dist/components/cms/document-staging.d.ts +7 -0
  60. package/dist/components/cms/document-staging.js +44 -0
  61. package/dist/components/cms/document-staging.js.map +1 -0
  62. package/dist/components/cms/field-connection.d.ts +22 -0
  63. package/dist/components/cms/field-connection.js +37 -0
  64. package/dist/components/cms/field-connection.js.map +1 -0
  65. package/dist/components/cms/field-control.js +158 -34
  66. package/dist/components/cms/field-control.js.map +1 -1
  67. package/dist/components/cms/prop-fields.d.ts +43 -0
  68. package/dist/components/cms/prop-fields.js +130 -0
  69. package/dist/components/cms/prop-fields.js.map +1 -0
  70. package/dist/components/cms/rich-text/component-embed.d.ts +17 -3
  71. package/dist/components/cms/rich-text/component-embed.js +29 -7
  72. package/dist/components/cms/rich-text/component-embed.js.map +1 -1
  73. package/dist/components/cms/rich-text/embed-props.d.ts +49 -0
  74. package/dist/components/cms/rich-text/embed-props.js +106 -0
  75. package/dist/components/cms/rich-text/embed-props.js.map +1 -0
  76. package/dist/components/cms/rich-text/index.d.ts +6 -1
  77. package/dist/components/cms/rich-text/index.js +6 -1
  78. package/dist/components/cms/rich-text/index.js.map +1 -1
  79. package/dist/components/cms/rich-text/lazy.d.ts +19 -0
  80. package/dist/components/cms/rich-text/lazy.js +26 -0
  81. package/dist/components/cms/rich-text/lazy.js.map +1 -0
  82. package/dist/components/cms/row-actions.d.ts +28 -0
  83. package/dist/components/cms/row-actions.js +20 -0
  84. package/dist/components/cms/row-actions.js.map +1 -0
  85. package/dist/components/cms/use-document-versions.d.ts +8 -1
  86. package/dist/components/cms/use-document-versions.js +31 -4
  87. package/dist/components/cms/use-document-versions.js.map +1 -1
  88. package/dist/components/cms/version-compare-lazy.d.ts +1 -0
  89. package/dist/components/cms/version-compare-lazy.js +23 -0
  90. package/dist/components/cms/version-compare-lazy.js.map +1 -0
  91. package/dist/components/cms/version-compare-text.d.ts +46 -0
  92. package/dist/components/cms/version-compare-text.js +78 -0
  93. package/dist/components/cms/version-compare-text.js.map +1 -1
  94. package/dist/components/cms/version-compare.d.ts +9 -5
  95. package/dist/components/cms/version-compare.js +184 -59
  96. package/dist/components/cms/version-compare.js.map +1 -1
  97. package/dist/components/cms/versions-pane.js +10 -4
  98. package/dist/components/cms/versions-pane.js.map +1 -1
  99. package/dist/components/components/component-detail.d.ts +5 -3
  100. package/dist/components/components/component-detail.js +41 -8
  101. package/dist/components/components/component-detail.js.map +1 -1
  102. package/dist/components/components/component-preview.d.ts +61 -0
  103. package/dist/components/components/component-preview.js +103 -0
  104. package/dist/components/components/component-preview.js.map +1 -0
  105. package/dist/components/components/components-index.d.ts +5 -3
  106. package/dist/components/components/components-index.js +19 -24
  107. package/dist/components/components/components-index.js.map +1 -1
  108. package/dist/components/components/registry-browser.d.ts +7 -0
  109. package/dist/components/components/registry-browser.js +20 -36
  110. package/dist/components/components/registry-browser.js.map +1 -1
  111. package/dist/components/components/taxonomy.d.ts +51 -0
  112. package/dist/components/components/taxonomy.js +73 -0
  113. package/dist/components/components/taxonomy.js.map +1 -0
  114. package/dist/components/components/transfer-dialog.d.ts +20 -0
  115. package/dist/components/components/transfer-dialog.js +64 -0
  116. package/dist/components/components/transfer-dialog.js.map +1 -0
  117. package/dist/components/components/transfer.d.ts +43 -0
  118. package/dist/components/components/transfer.js +31 -0
  119. package/dist/components/components/transfer.js.map +1 -0
  120. package/dist/components/project-list.js +3 -4
  121. package/dist/components/project-list.js.map +1 -1
  122. package/dist/components/project-switcher.js +3 -2
  123. package/dist/components/project-switcher.js.map +1 -1
  124. package/dist/components/rail-store-status.d.ts +21 -0
  125. package/dist/components/rail-store-status.js +21 -0
  126. package/dist/components/rail-store-status.js.map +1 -0
  127. package/dist/components/redirects/redirects-module.js +4 -8
  128. package/dist/components/redirects/redirects-module.js.map +1 -1
  129. package/dist/components/redirects/redirects-store.js +2 -7
  130. package/dist/components/redirects/redirects-store.js.map +1 -1
  131. package/dist/components/sign-in-needed-badge.d.ts +6 -0
  132. package/dist/components/sign-in-needed-badge.js +13 -0
  133. package/dist/components/sign-in-needed-badge.js.map +1 -0
  134. package/dist/components/studio-project-screen.js +1 -4
  135. package/dist/components/studio-project-screen.js.map +1 -1
  136. package/dist/components/studio-shell.js +1 -4
  137. package/dist/components/studio-shell.js.map +1 -1
  138. package/dist/components/users/users-module.js +5 -7
  139. package/dist/components/users/users-module.js.map +1 -1
  140. package/dist/components/users/users-store.js +2 -7
  141. package/dist/components/users/users-store.js.map +1 -1
  142. package/dist/lib/auth/index.js +1 -1
  143. package/dist/lib/auth/index.js.map +1 -1
  144. package/dist/lib/clipboard/paste-target.js +1 -3
  145. package/dist/lib/clipboard/paste-target.js.map +1 -1
  146. package/dist/lib/cms-views/view-model.d.ts +0 -2
  147. package/dist/lib/cms-views/view-model.js +0 -7
  148. package/dist/lib/cms-views/view-model.js.map +1 -1
  149. package/dist/lib/diff-view-preference.d.ts +7 -0
  150. package/dist/lib/diff-view-preference.js +21 -0
  151. package/dist/lib/diff-view-preference.js.map +1 -0
  152. package/dist/lib/i18n/messages.en.js +79 -30
  153. package/dist/lib/i18n/messages.en.js.map +1 -1
  154. package/dist/lib/local-preference.d.ts +11 -0
  155. package/dist/lib/local-preference.js +26 -0
  156. package/dist/lib/local-preference.js.map +1 -0
  157. package/dist/lib/locale/fallback.d.ts +45 -0
  158. package/dist/lib/locale/fallback.js +85 -0
  159. package/dist/lib/locale/fallback.js.map +1 -1
  160. package/dist/lib/persona/labels.d.ts +6 -0
  161. package/dist/lib/persona/labels.js +8 -0
  162. package/dist/lib/persona/labels.js.map +1 -1
  163. package/dist/lib/query/index.d.ts +1 -1
  164. package/dist/lib/query/index.js +1 -1
  165. package/dist/lib/query/index.js.map +1 -1
  166. package/dist/lib/query/operations-query.d.ts +14 -2
  167. package/dist/lib/query/operations-query.js +11 -6
  168. package/dist/lib/query/operations-query.js.map +1 -1
  169. package/dist/lib/rail.d.ts +0 -6
  170. package/dist/lib/rail.js +6 -25
  171. package/dist/lib/rail.js.map +1 -1
  172. package/dist/lib/theme.d.ts +0 -6
  173. package/dist/lib/theme.js +10 -13
  174. package/dist/lib/theme.js.map +1 -1
  175. package/dist/router/match.d.ts +1 -0
  176. package/dist/router/match.js +4 -0
  177. package/dist/router/match.js.map +1 -1
  178. package/dist/ui/button.d.ts +1 -1
  179. package/package.json +22 -25
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Link, STUDIO_PROJECT_ROUTE, studioPath, useNavigate } from '@elytracms/studio/router';
3
- import { Check, CircleAlert, CircleCheck, ExternalLink, Eye, EyeOff, GitCompareArrows, History, Languages, Link2, Loader2, Lock, MoreHorizontal, Tag, Trash2, X, } from 'lucide-react';
3
+ import { Check, CircleAlert, CircleCheck, ExternalLink, Eye, EyeOff, GitCompareArrows, History, Loader2, Lock, MoreHorizontal, RotateCcw, Save, Tag, Trash2, X, } from 'lucide-react';
4
4
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5
5
  import { FIELD_WIDTH_SPANS, SIDEBAR_FIELD_GROUP, clearHiddenValues, documentKey, groupOrder, groupSectionOf, hasAliases, hiddenGroupNames, isBlocksField, isFieldHidden, isFieldLocalized, isSingleton, resolveDocumentForLocale, resolveDocumentTitle, resolveField, sidebarFields, tabDescriptorOf, tabOrder, titleFieldOf, staticFieldHidden, urlForDocument, } from '@elytracms/core/cms-core';
6
6
  import { blockRootsOf } from '@elytracms/core/project-graph';
@@ -8,12 +8,16 @@ import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogFooter,
8
8
  import { getStudioRegistry } from '@elytracms/studio/lib/registry';
9
9
  import { collectEmbedRefs, resolveReuseCapability } from '../builder/reuse';
10
10
  import { cn } from '@elytracms/studio/lib/utils';
11
+ import { useOperationCache } from '@elytracms/studio/lib/query';
11
12
  import { localizedLabel, translate } from '@elytracms/studio/lib/i18n';
12
- import { LocaleSwitcher, displayedFieldFallback, hasLocaleVariant, localeChipLabel, setStudioLocale, } from '@elytracms/studio/lib/locale';
13
+ import { LocaleSwitcher, activeLocaleValues, displayedFieldFallback, fieldConnection, fillFieldFromBase, resetFieldToBase, setFieldValue, setStudioLocale, } from '@elytracms/studio/lib/locale';
13
14
  import { parseDocumentFieldPath } from '@elytracms/studio/lib/references';
14
- import { FallbackChip, fallbackTitle } from './cell';
15
+ import { FallbackChip } from './cell';
16
+ import { FieldConnectionControl } from './field-connection';
15
17
  import { FieldControl } from './field-control';
16
- import { CompositionFieldEditor } from '../builder/composition-field-editor';
18
+ // EC-322: the lazy-wrapped page builder — the heavy canvas subtree code-splits out
19
+ // of the detail-view critical path and downloads only when a `blocks` field opens.
20
+ import { CompositionFieldEditor } from '../builder/composition-field-editor-lazy';
17
21
  import { fieldLabel } from './cms-store';
18
22
  import { useCmsCollectionViewSource } from '../collection-view/cms-collection-view-source';
19
23
  import { CollectionViewProvider, useCollectionView } from '../collection-view/collection-view-context';
@@ -25,14 +29,16 @@ import { ReferencesPanel } from '../references/references-panel';
25
29
  import { InspectorSidebar } from '../inspector/inspector-sidebar';
26
30
  import { DocumentEditorProvider } from './document-editor-context';
27
31
  import { VersionsPane } from './versions-pane';
28
- import { VersionCompare } from './version-compare';
32
+ // EC-322: the lazy-wrapped compare view — its diff UI code-splits off the detail-view
33
+ // critical path and loads only when the Compare tab opens.
34
+ import { VersionCompare } from './version-compare-lazy';
35
+ import { useDocumentVersionsQuery } from './use-document-versions';
36
+ import { discardDraftStatus } from './discard-draft';
29
37
  import { IssuesPane } from './issues-pane';
30
38
  import { DetailsPane } from './details-pane';
31
39
  import { useSetBreadcrumb } from '../breadcrumb-context';
32
40
  import { resolveDeploymentFrontendUrl, resolveDeploymentRoutes } from '@elytracms/studio/lib/workspace';
33
41
  import { usePublishedDocuments } from '@elytracms/studio/lib/cms/published-documents';
34
- /** The conventional slug field name (mirrors `url-for-document`'s `SLUG_FIELD`). */
35
- const SLUG_FIELD = 'slug';
36
42
  export function DetailEditor({ projectId, collectionId, documentId, focusPath, }) {
37
43
  // AD-19: the edit shell is a portable surface over a CollectionViewSource, just
38
44
  // like the list. `DetailEditor` is the CMS entry point — it builds the CMS source
@@ -144,7 +150,59 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
144
150
  const viewReadOnly = perspective === 'published';
145
151
  const viewDoc = viewReadOnly && publishedSnapshot.status === 'ready' ? publishedSnapshot.doc : draft;
146
152
  const publishedPending = viewReadOnly && publishedSnapshot.status !== 'ready';
147
- const dirty = JSON.stringify(draft) !== JSON.stringify(doc);
153
+ // EC-316 — "Discard changes": revert the draft to exactly the published
154
+ // content. Only meaningful for a publishing + versioned rail AND a published
155
+ // document, so the history query is gated off otherwise (never a wasted
156
+ // fetch on e.g. the users rail or a never-published draft).
157
+ const canDiscard = canPublish && canVersions;
158
+ const { versionsQuery: discardVersionsQuery, versionKey: discardVersionKey } = useDocumentVersionsQuery(ref, {
159
+ enabled: canDiscard && published,
160
+ });
161
+ const discardCache = useOperationCache();
162
+ const [discardConfirm, setDiscardConfirm] = useState(false);
163
+ const [discardBusy, setDiscardBusy] = useState(false);
164
+ const discardView = discardVersionsQuery.data ?? null;
165
+ const publishedVersionNumber = discardView?.publishedVersion;
166
+ const publishedSnapshotForDiscard = publishedVersionNumber !== undefined
167
+ ? discardView?.versions.find((v) => v.version === publishedVersionNumber)?.snapshot
168
+ : undefined;
169
+ // One pure decision (tested in discard-draft.test.ts): hidden / disabled-
170
+ // with-reason / ready. Its semantic equality is the SAME comparator the
171
+ // Versions pane's publish gate uses, so the two never contradict (EC-298).
172
+ const discardStatus = discardDraftStatus({
173
+ canDiscard,
174
+ published,
175
+ viewReadOnly,
176
+ publishedSnapshot: publishedSnapshotForDiscard,
177
+ collection,
178
+ draft,
179
+ });
180
+ const discardDisabledReason = discardStatus === 'view-read-only'
181
+ ? translate('cms.editor.switchToDraftToDiscard')
182
+ : discardStatus === 'up-to-date'
183
+ ? translate('cms.editor.discardDraftUpToDateHint')
184
+ : discardStatus === 'loading'
185
+ ? translate('common.loading')
186
+ : undefined;
187
+ // Reuses the EC-298 restore machinery (the same op the Compare view's
188
+ // "Restore version" button calls) — never a parallel write path: reverting
189
+ // the draft IS restoring the published version's snapshot, whole-document.
190
+ async function handleDiscardDraft() {
191
+ if (publishedVersionNumber === undefined)
192
+ return;
193
+ setDiscardBusy(true);
194
+ try {
195
+ const restored = await source.versioning?.restore(ref, publishedVersionNumber, { kind: 'all' });
196
+ if (restored)
197
+ replaceDraft(restored);
198
+ await discardCache.invalidate(discardVersionKey);
199
+ }
200
+ finally {
201
+ setDiscardBusy(false);
202
+ setDiscardConfirm(false);
203
+ }
204
+ }
205
+ const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(doc), [draft, doc]);
148
206
  const schemaIssues = useMemo(() => source.validate(draft), [source, draft]);
149
207
  // EC-300/AD-16: composition embeds (hard references) whose target isn't published
150
208
  // would render empty on the live site. Surface them as errors in the Issues panel,
@@ -327,29 +385,18 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
327
385
  }
328
386
  return viewDoc.values[field.name];
329
387
  }
388
+ // EC-176/EC-318: the present/absent-key write is single-sourced in `setFieldValue`
389
+ // — a localized field writes `localized[locale]`, clearing (value === undefined)
390
+ // DELETES the key (reconnecting to base). `fill from base` / `reset to base` below
391
+ // reuse the same foundation, so typing, filling, and resetting can never diverge.
330
392
  function setValue(field, value) {
331
- setDraft((d) => {
332
- if (isFieldLocalized(collection, field.name)) {
333
- const perLocale = { ...(d.localized?.[locale] ?? {}) };
334
- if (value === undefined)
335
- delete perLocale[field.name];
336
- else
337
- perLocale[field.name] = value;
338
- return { ...d, localized: { ...d.localized, [locale]: perLocale } };
339
- }
340
- const values = { ...d.values };
341
- if (value === undefined)
342
- delete values[field.name];
343
- else
344
- values[field.name] = value;
345
- return { ...d, values };
346
- });
393
+ setDraft((d) => setFieldValue(collection, d, field.name, locale, value));
347
394
  }
348
395
  // EC-273: conditional-visibility context — the draft's values for the active
349
396
  // locale, against which a field's `hidden(values)` predicate is evaluated. A
350
397
  // hidden field's row is omitted (and it is not validated). Recomputed as the
351
398
  // draft changes, so a field shows/hides live as the editor types.
352
- const conditionValues = { ...draft.values, ...(draft.localized?.[locale] ?? {}) };
399
+ const conditionValues = activeLocaleValues(draft, locale);
353
400
  const isHidden = (field) => isFieldHidden(field, { values: conditionValues });
354
401
  // Visible = not content-hidden AND not principal-hidden (EC-240 access === 'hidden').
355
402
  const isFieldShown = (field) => !isHidden(field) && accessOf(field) !== 'hidden';
@@ -375,28 +422,32 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
375
422
  const fieldLevelReadOnly = access === 'read';
376
423
  const fieldReadOnly = viewReadOnly || fieldLevelReadOnly;
377
424
  const localized = isFieldLocalized(collection, field.name);
378
- // Fallback visibility (EC-176): the control DISPLAYS the fallback value, and
379
- // this marker says so on the exact field.
380
- const fallback = localized
381
- ? displayedFieldFallback(collection, viewDoc, field.name, locale, source.localeConfig)
425
+ // EC-318 — connected fields: the per-locale connection state of a localized
426
+ // field (connected/diverged + fill-from-base), DERIVED from the draft, never
427
+ // stored. Null for a non-localized field or the base-locale view. Suppressed
428
+ // while the row is read-only (Published view or a field-level `read` grant) —
429
+ // there is nothing to connect or diverge there.
430
+ const connection = localized
431
+ ? fieldConnection(collection, draft, field.name, locale, source.localeConfig)
382
432
  : null;
383
- // The variant was created in this editing session (the saved document doesn't
384
- // have it yet): editing a fallback-shown value is an explicit translation act.
385
- // Moot (and hidden) while viewing the read-only published snapshot.
386
- const newlyTranslated = !viewReadOnly &&
387
- localized &&
388
- hasLocaleVariant(draft, field.name, locale) &&
389
- !hasLocaleVariant(doc, field.name, locale);
390
- return (_jsxs(Field, { className: cn('rounded-md transition-shadow', highlightedField === field.name && 'bg-primary/5 p-2 ring-2 ring-primary/70'), label: _jsxs(Row, { gap: "2", children: [_jsx("span", { children: fieldLabel(field) }), field.validation?.required ? _jsx("span", { className: "text-destructive", children: "*" }) : null, localized ? _jsx(Badge, { variant: "outline", children: locale }) : null, fallback ? (_jsx(Badge, { variant: "warning", title: fallbackTitle(fallback), "data-testid": `field-fallback-${field.name}`, children: translate('cms.editor.fieldShowingFallback', {
391
- locale: localeChipLabel(fallback.sourceLocale),
392
- }) })) : null, newlyTranslated ? (_jsxs(Badge, { variant: "success", "data-testid": `field-translated-${field.name}`, children: [_jsx(Languages, { className: "size-3" }), ' ', translate('cms.editor.fieldTranslatedTo', { locale: localeChipLabel(locale) })] })) : null, fieldLevelReadOnly ? (_jsxs(Badge, { variant: "outline", children: [_jsx(Lock, { className: "size-3" }), " ", translate('cms.editor.readOnlyBadge')] })) : null, _jsx(Spacer, {})] }), htmlFor: `f-${field.name}`, hint: fallback ? (translate('cms.editor.fieldFallbackHint', {
393
- source: localeChipLabel(fallback.sourceLocale),
394
- locale: localeChipLabel(locale),
395
- })) : (localizedLabel(field.form?.description) || undefined), error: fieldReadOnly ? undefined : errorsByField.get(field.name), children: [_jsx(FieldControl, { id: `f-${field.name}`, field: field, value: getValue(field), onChange: (v) => setValue(field, v), readOnly: fieldReadOnly }), field.name === SLUG_FIELD && routableCollection ? _jsx(PathPreview, { url: draftUrl }) : null] }, field.name));
433
+ return (_jsx(Field, { className: cn('rounded-md transition-shadow', highlightedField === field.name && 'bg-primary/5 p-2 ring-2 ring-primary/70'), label: _jsxs(Row, { gap: "2", children: [_jsx("span", { children: fieldLabel(field) }), field.validation?.required ? _jsx("span", { className: "text-destructive", children: "*" }) : null, localized ? _jsx(Badge, { variant: "outline", children: locale }) : null, fieldLevelReadOnly ? (_jsxs(Badge, { variant: "outline", children: [_jsx(Lock, { className: "size-3" }), " ", translate('cms.editor.readOnlyBadge')] })) : null, _jsx(Spacer, {}), connection && !fieldReadOnly ? (_jsx(FieldConnectionControl, { connection: connection, fieldName: field.name, locale: locale, onFillFromBase: () => setDraft((d) => fillFieldFromBase(collection, d, field.name, locale, source.localeConfig)), onResetToBase: () => setDraft((d) => resetFieldToBase(collection, d, field.name, locale)) })) : null] }), htmlFor: `f-${field.name}`, hint: localizedLabel(field.form?.description) || undefined, error: fieldReadOnly ? undefined : errorsByField.get(field.name), children: _jsx(FieldControl, { id: `f-${field.name}`, field: field, value: getValue(field), onChange: (v) => setValue(field, v), readOnly: fieldReadOnly }) }, field.name));
396
434
  }
397
435
  function save() {
398
436
  source.save(draft);
399
437
  }
438
+ // EC-316: "Save & close" — the SAME save path as everywhere else in this
439
+ // header (autosave's debounce, the explicit-save rail's Save button, and the
440
+ // unmount flush all call this exact `save()`), immediately, then navigate
441
+ // back to the rail's own list route. Rail-owned (`listPath`, AD-19) so this
442
+ // never hard-codes the CMS route; falls back to the project index for the
443
+ // rare rail with no list surface at all (mirrors the delete flow below).
444
+ function saveAndClose() {
445
+ save();
446
+ void navigate({
447
+ to: STUDIO_PROJECT_ROUTE,
448
+ params: { projectId, _splat: source.listPath ?? studioPath.index() },
449
+ });
450
+ }
400
451
  // EC-224: a version restore swaps the working draft for an older snapshot; the
401
452
  // open editor adopts it so the form/canvas re-render from the restored content
402
453
  // (the operation already persisted it, so it lands clean/non-dirty).
@@ -571,12 +622,10 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
571
622
  localeConfig: source.localeConfig,
572
623
  }, collection, published, { siblings: [...publishedDocs.byId.values()], locale });
573
624
  }, [publishedDocs, collection, doc.id, locale, source.localeConfig]);
574
- // EC-246d: the DRAFT path — derived from the current edit (slug + parent chain),
575
- // not the published pool — so the slug field shows the URL forming live, before
576
- // the first publish. Read-only: the slug is the single editable source, the path
577
- // is always derived (never a second stored field). `undefined` until a slug
578
- // resolves a route (a fresh hierarchy page with no slug has no path yet).
579
- const draftUrl = useMemo(() => urlForDocument({ routes: resolveDeploymentRoutes(), localeConfig: source.localeConfig }, collection, viewDoc, { siblings: source.documents(), locale }), [collection, viewDoc, locale, source]);
625
+ // EC-246d/EC-315: the DRAFT composed path moved INTO the slug control itself
626
+ // (`SlugField`), which derives + decomposes it from the same routes; the editor no
627
+ // longer computes a separate `draftUrl`. `liveUrl` (published) still gates the
628
+ // header's "View live" button — the sole open-live, so draft never links out.
580
629
  // EC-240: hide the tab bar when there is nothing to navigate — a single content
581
630
  // tab with no References/Compare (the users rail). When framework tabs ARE
582
631
  // present we KEEP the content tab(s) so there is always a way back to the form;
@@ -620,18 +669,11 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
620
669
  ? translate(errorCount === 1
621
670
  ? 'cms.editor.fixIssuesToPublish.one'
622
671
  : 'cms.editor.fixIssuesToPublish.other', { count: errorCount })
623
- : undefined, children: publishState === 'pending' ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "animate-spin" }), " ", translate('cms.editor.publishing')] })) : publishState === 'success' ? (_jsxs(_Fragment, { children: [_jsx(CircleCheck, {}), " ", translate('cms.editor.publishedState')] })) : (_jsxs(_Fragment, { children: [_jsx(Eye, {}), " ", translate('cms.editor.publish')] })) })) : null, explicitSave ? (_jsxs(Button, { size: "sm", onClick: save, disabled: !dirty, "data-testid": "doc-save", children: [_jsx(Check, {}), " ", translate('common.save')] })) : null, published || !singleton ? (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": translate('cms.editor.moreActions'), children: _jsx(MoreHorizontal, {}) }) }), _jsxs(DropdownMenuContent, { align: "end", children: [published ? (_jsxs(DropdownMenuItem, { onClick: () => setConfirmAction('unpublish'), children: [_jsx(EyeOff, {}), " ", translate('cms.editor.unpublish')] })) : null, singleton ? null : (_jsxs(DropdownMenuItem, { "data-testid": "doc-delete", className: "text-destructive", onClick: () => setConfirmAction('delete'), children: [_jsx(Trash2, {}), " ", translate('common.delete')] }))] })] })) : null] }), showLensRow ? (_jsxs(Row, { gap: "3", className: "mt-3", wrap: true, children: [_jsxs(Row, { gap: "1", align: "center", children: [showContentTabs ? tabs.map((tab) => renderContentTab(tab)) : null, showReferences ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "mx-1 h-4 w-px bg-border", "aria-hidden": true }), renderContentTab(REFERENCES_TAB)] })) : null, compare ? (_jsxs("span", { className: cn('inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium transition-colors', activeTab === COMPARE_TAB
672
+ : undefined, children: publishState === 'pending' ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "animate-spin" }), " ", translate('cms.editor.publishing')] })) : publishState === 'success' ? (_jsxs(_Fragment, { children: [_jsx(CircleCheck, {}), " ", translate('cms.editor.publishedState')] })) : (_jsxs(_Fragment, { children: [_jsx(Eye, {}), " ", translate('cms.editor.publish')] })) })) : null, explicitSave ? (_jsxs(Button, { size: "sm", onClick: save, disabled: !dirty, "data-testid": "doc-save", children: [_jsx(Check, {}), " ", translate('common.save')] })) : null, !singleton && source.listPath !== undefined ? (_jsxs(Button, { variant: "outline", size: "sm", onClick: saveAndClose, "data-testid": "doc-save-and-close", children: [_jsx(Save, {}), " ", translate('cms.editor.saveAndClose')] })) : null, published || !singleton ? (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": translate('cms.editor.moreActions'), children: _jsx(MoreHorizontal, {}) }) }), _jsxs(DropdownMenuContent, { align: "end", children: [discardStatus !== 'hidden' ? (_jsxs(DropdownMenuItem, { "data-testid": "doc-discard-draft", disabled: discardStatus !== 'ready', title: discardDisabledReason, onClick: () => setDiscardConfirm(true), children: [_jsx(RotateCcw, {}), " ", translate('cms.editor.discardDraft')] })) : null, published ? (_jsxs(DropdownMenuItem, { onClick: () => setConfirmAction('unpublish'), children: [_jsx(EyeOff, {}), " ", translate('cms.editor.unpublish')] })) : null, singleton ? null : (_jsxs(DropdownMenuItem, { "data-testid": "doc-delete", className: "text-destructive", onClick: () => setConfirmAction('delete'), children: [_jsx(Trash2, {}), " ", translate('common.delete')] }))] })] })) : null] }), showLensRow ? (_jsxs(Row, { gap: "3", className: "mt-3", wrap: true, children: [_jsxs(Row, { gap: "1", align: "center", children: [showContentTabs ? tabs.map((tab) => renderContentTab(tab)) : null, showReferences ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "mx-1 h-4 w-px bg-border", "aria-hidden": true }), renderContentTab(REFERENCES_TAB)] })) : null, compare ? (_jsxs("span", { className: cn('inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium transition-colors', activeTab === COMPARE_TAB
624
673
  ? 'bg-accent text-accent-foreground'
625
674
  : 'text-muted-foreground hover:bg-muted'), children: [_jsxs("button", { type: "button", onClick: () => setActiveTab(COMPARE_TAB), "aria-pressed": activeTab === COMPARE_TAB, className: "inline-flex items-center gap-1", children: [_jsx(GitCompareArrows, { className: "size-3", "aria-hidden": true }), " ", translate('cms.version.compare')] }), _jsx("button", { type: "button", onClick: exitCompare, "aria-label": translate('cms.version.closeCompare'), className: "ml-0.5 rounded p-0.5 hover:bg-background/40", children: _jsx(X, { className: "size-3" }) })] })) : null] }), _jsx(Spacer, {}), canLocales ? _jsx(LocaleSwitcher, {}) : null, canPublish ? (_jsxs(Row, { gap: "px", className: "rounded-md bg-muted/60 p-0.5", children: [_jsx(PerspectiveButton, { label: translate('cms.editor.draftState'), active: perspective === 'draft', onClick: () => setPerspective('draft') }), _jsx(PerspectiveButton, { label: translate('cms.editor.publishedState'), active: perspective === 'published', accent: "info", disabled: !published, title: published ? undefined : translate('cms.editor.publishToViewPublished'), onClick: () => setPerspective('published') })] })) : null] })) : null] }), viewReadOnly ? (_jsxs(Row, { gap: "2", className: "shrink-0 border-b border-info/30 bg-info/10 px-4 py-1.5 text-info sm:px-6", "data-testid": "published-view-banner", children: [_jsx(Lock, { className: "size-3.5 shrink-0", "aria-hidden": true }), _jsx(Text, { size: "xs", className: "text-info", children: translate('cms.editor.publishedReadOnlyBanner') }), _jsx(Spacer, {}), _jsx("button", { type: "button", onClick: () => setPerspective('draft'), className: "text-xs font-medium underline-offset-2 hover:underline", children: translate('cms.editor.switchToDraft') })] })) : null, publishedPending ? (_jsx("div", { className: "grid min-h-0 flex-1 place-items-center p-8", children: _jsx(Text, { size: "sm", tone: "muted", children: publishedSnapshot.status === 'loading'
626
675
  ? translate('cms.editor.loadingPublishedVersion')
627
- : translate('cms.editor.noPublishedVersion') }) })) : activeTab === COMPARE_TAB && compare ? (
628
- // EC-298: the version compare view takes over the main area, with the
629
- // shared inspector (Versions pane) staying on as the navigator — an
630
- // in-app surface, not a modal, so arbitrary versions can be browsed.
631
- _jsxs("div", { className: "flex min-h-0 flex-1 overflow-hidden", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-auto", children: _jsx(VersionCompare, {}) }), _jsx(InspectorSidebar, { panes: trailingPanes, activeId: inspectorPaneId, onActiveChange: setInspectorPaneId, className: "w-[320px] shrink-0 border-l border-border" })] })) : canvasField ? (_jsx("div", { className: "min-h-0 flex-1 overflow-hidden", children: _jsx(CompositionFieldEditor, { field: canvasField, value: getValue(canvasField), onChange: (v) => setValue(canvasField, v), readOnly: viewReadOnly || source.isFieldReadOnly(canvasField), trailingPanes: trailingPanes }, `${canvasField.name}:${restoreNonce}:${perspective}`) })) : activeTab === REFERENCES_TAB ? (
632
- // EC-269: mounted only while this tab is active → reference data loads on
633
- // demand and is torn down on leave (no fetching during normal editing).
634
- _jsx("div", { className: "flex min-h-0 flex-1 overflow-hidden", children: _jsx("div", { className: "min-h-0 flex-1 overflow-auto p-4 sm:p-6", children: _jsx("div", { className: "mx-auto w-full max-w-2xl", children: _jsx(ReferencesPanel, { projectId: projectId, source: { type: 'document', id: documentKey(ref) }, target: { type: 'document', id: documentKey(ref) } }) }) }) })) : (_jsxs("div", { className: "flex min-h-0 flex-1 overflow-hidden", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-auto p-4 sm:p-6", children: _jsx("div", { className: "mx-auto w-full max-w-2xl", children: _jsx(Stack, { gap: "6", children: groups.map((group) => {
676
+ : translate('cms.editor.noPublishedVersion') }) })) : activeTab === COMPARE_TAB && compare ? (_jsxs("div", { className: "flex min-h-0 flex-1 overflow-hidden", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-auto", children: _jsx(VersionCompare, {}) }), _jsx(InspectorSidebar, { panes: trailingPanes, activeId: inspectorPaneId, onActiveChange: setInspectorPaneId, className: "w-[320px] shrink-0 border-l border-border" })] })) : canvasField ? (_jsx("div", { className: "min-h-0 flex-1 overflow-hidden", children: _jsx(CompositionFieldEditor, { field: canvasField, value: getValue(canvasField), onChange: (v) => setValue(canvasField, v), readOnly: viewReadOnly || source.isFieldReadOnly(canvasField), trailingPanes: trailingPanes }, `${canvasField.name}:${restoreNonce}:${perspective}`) })) : activeTab === REFERENCES_TAB ? (_jsx("div", { className: "flex min-h-0 flex-1 overflow-hidden", children: _jsx("div", { className: "min-h-0 flex-1 overflow-auto p-4 sm:p-6", children: _jsx("div", { className: "mx-auto w-full max-w-2xl", children: _jsx(ReferencesPanel, { projectId: projectId, source: { type: 'document', id: documentKey(ref) }, target: { type: 'document', id: documentKey(ref) } }) }) }) })) : (_jsxs("div", { className: "flex min-h-0 flex-1 overflow-hidden", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-auto p-4 sm:p-6", children: _jsx("div", { className: "mx-auto w-full max-w-2xl", children: _jsx(Stack, { gap: "6", children: groups.map((group) => {
635
677
  // EC-306: a conditionally/statically hidden group section is skipped
636
678
  // entirely (its fields are also exempt from validation).
637
679
  if (hiddenGroups.has(group.name))
@@ -656,7 +698,8 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
656
698
  }, description: translate('cms.editor.unsavedChangesGuard') })) : null, confirmAction !== null ? (_jsx(ReferenceConfirmDialog, { projectId: projectId, action: confirmAction, targets: confirmTargets, open: true, onOpenChange: (open) => {
657
699
  if (!open)
658
700
  setConfirmAction(null);
659
- }, onConfirm: handleConfirmedAction })) : null, _jsx(Dialog, { open: publishConfirm, onOpenChange: (open) => { if (!open)
701
+ }, onConfirm: handleConfirmedAction })) : null, _jsx(Dialog, { open: discardConfirm, onOpenChange: (open) => { if (!open)
702
+ setDiscardConfirm(false); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('cms.editor.discardDraftTitle') }), _jsx(DialogDescription, { children: translate('cms.editor.discardDraftDescription') })] }), _jsxs(DialogFooter, { className: "mt-4", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => setDiscardConfirm(false), disabled: discardBusy, children: translate('common.cancel') }), _jsx(Button, { variant: "destructive", size: "sm", "data-testid": "discard-draft-confirm", disabled: discardBusy, onClick: () => void handleDiscardDraft(), children: discardBusy ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "animate-spin" }), " ", translate('cms.editor.discardingDraft')] })) : (_jsxs(_Fragment, { children: [_jsx(RotateCcw, {}), " ", translate('cms.editor.discardDraftConfirm')] })) })] })] }) }), _jsx(Dialog, { open: publishConfirm, onOpenChange: (open) => { if (!open)
660
703
  setPublishConfirm(false); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('cms.editor.publishUnpublishedRefsTitle') }), _jsx(DialogDescription, { children: translate('cms.editor.publishUnpublishedRefsDescription') })] }), _jsxs(Stack, { gap: "1", children: [referenceIssues.map((issue, i) => (_jsxs(Text, { size: "xs", tone: "muted", children: ["\u2022 ", issue.message] }, `${issue.meta?.id ?? i}`))), schemaIssues.length > 0 ? (_jsx(Text, { size: "xs", tone: "muted", className: "mt-1", children: translate(schemaIssues.length === 1
661
704
  ? 'cms.editor.publishAlsoHasIssues.one'
662
705
  : 'cms.editor.publishAlsoHasIssues.other', { count: schemaIssues.length }) })) : null] }), _jsxs(DialogFooter, { className: "mt-4", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => setPublishConfirm(false), children: translate('common.cancel') }), _jsx(Button, { size: "sm", "data-testid": "publish-anyway", onClick: () => {
@@ -720,16 +763,6 @@ function PerspectiveButton({ label, active, accent, disabled, title, onClick, })
720
763
  : 'bg-background text-foreground shadow-sm'
721
764
  : 'text-muted-foreground hover:text-foreground'), children: label }));
722
765
  }
723
- /**
724
- * EC-246d: the slug's derived route path, shown read-only directly under the slug
725
- * input. The slug is the single editable source; the path is composed by the route
726
- * resolver (`urlForDocument`) from slug + parent chain and never stored. Draft-aware,
727
- * so it updates as the editor types. Until a slug resolves a route (a fresh
728
- * hierarchy page), a hint explains where the URL comes from.
729
- */
730
- function PathPreview({ url }) {
731
- return (_jsxs(Row, { gap: "1", className: "text-xs text-muted-foreground", "data-testid": "slug-path-preview", children: [_jsx(Link2, { className: "size-3 shrink-0", "aria-hidden": true }), url ? (_jsxs(Text, { as: "span", size: "xs", tone: "muted", className: "min-w-0 truncate", title: url.path, children: ["Served at ", _jsx("span", { className: "font-medium text-foreground/80", children: url.path })] })) : (_jsx(Text, { as: "span", size: "xs", tone: "muted", children: translate('cms.editor.slugUrlHint') }))] }));
732
- }
733
766
  /** The implicit fieldset that holds fields with no declared `form.group` (localized via `cms.editor.defaultGroup`). */
734
767
  const DEFAULT_FIELD_GROUP = 'fields';
735
768
  function groupFields(fields, collection) {