@elytracms/studio 0.0.26 → 0.0.28

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 (138) hide show
  1. package/convex/assets.ts +19 -10
  2. package/convex/authority.ts +60 -0
  3. package/convex/cliTokens.ts +4 -4
  4. package/convex/cms.ts +12 -12
  5. package/convex/content.ts +10 -3
  6. package/convex/delivery.ts +22 -4
  7. package/convex/guard.ts +28 -11
  8. package/convex/members.ts +40 -7
  9. package/convex/redirects.ts +67 -0
  10. package/convex/references.ts +11 -11
  11. package/convex/schema.ts +30 -1
  12. package/convex/sync.ts +17 -10
  13. package/convex/users.ts +63 -0
  14. package/dist/components/app-views/default-modules.d.ts +33 -14
  15. package/dist/components/app-views/default-modules.js +59 -20
  16. package/dist/components/app-views/default-modules.js.map +1 -1
  17. package/dist/components/app-views/module.d.ts +32 -0
  18. package/dist/components/app-views/module.js +16 -0
  19. package/dist/components/app-views/module.js.map +1 -1
  20. package/dist/components/app-views/sidebar.d.ts +15 -1
  21. package/dist/components/app-views/sidebar.js +10 -0
  22. package/dist/components/app-views/sidebar.js.map +1 -1
  23. package/dist/components/cms/asset-picker.js +4 -4
  24. package/dist/components/cms/asset-picker.js.map +1 -1
  25. package/dist/components/cms/cell.js +16 -13
  26. package/dist/components/cms/cell.js.map +1 -1
  27. package/dist/components/cms/cms-store.d.ts +11 -26
  28. package/dist/components/cms/cms-store.js +25 -160
  29. package/dist/components/cms/cms-store.js.map +1 -1
  30. package/dist/components/cms/collection-table.d.ts +10 -0
  31. package/dist/components/cms/collection-table.js +72 -72
  32. package/dist/components/cms/collection-table.js.map +1 -1
  33. package/dist/components/cms/detail-editor.d.ts +14 -0
  34. package/dist/components/cms/detail-editor.js +224 -72
  35. package/dist/components/cms/detail-editor.js.map +1 -1
  36. package/dist/components/cms/document-editor-context.d.ts +26 -0
  37. package/dist/components/cms/document-editor-context.js.map +1 -1
  38. package/dist/components/cms/editable-cell.js +12 -7
  39. package/dist/components/cms/editable-cell.js.map +1 -1
  40. package/dist/components/cms/field-control.d.ts +2 -0
  41. package/dist/components/cms/field-control.js +40 -30
  42. package/dist/components/cms/field-control.js.map +1 -1
  43. package/dist/components/cms/filter-builder.d.ts +18 -0
  44. package/dist/components/cms/filter-builder.js +47 -6
  45. package/dist/components/cms/filter-builder.js.map +1 -1
  46. package/dist/components/cms/import-export.js +5 -3
  47. package/dist/components/cms/import-export.js.map +1 -1
  48. package/dist/components/cms/reference-confirm.js +8 -9
  49. package/dist/components/cms/reference-confirm.js.map +1 -1
  50. package/dist/components/cms/relation-picker.js +13 -10
  51. package/dist/components/cms/relation-picker.js.map +1 -1
  52. package/dist/components/cms/use-cell-edit.js +13 -7
  53. package/dist/components/cms/use-cell-edit.js.map +1 -1
  54. package/dist/components/cms/use-document-versions.d.ts +16 -0
  55. package/dist/components/cms/use-document-versions.js +31 -0
  56. package/dist/components/cms/use-document-versions.js.map +1 -0
  57. package/dist/components/cms/version-compare-text.d.ts +30 -0
  58. package/dist/components/cms/version-compare-text.js +113 -0
  59. package/dist/components/cms/version-compare-text.js.map +1 -0
  60. package/dist/components/cms/version-compare.d.ts +12 -0
  61. package/dist/components/cms/version-compare.js +271 -0
  62. package/dist/components/cms/version-compare.js.map +1 -0
  63. package/dist/components/cms/versions-pane.d.ts +3 -0
  64. package/dist/components/cms/versions-pane.js +47 -38
  65. package/dist/components/cms/versions-pane.js.map +1 -1
  66. package/dist/components/cms/view-bar.d.ts +21 -6
  67. package/dist/components/cms/view-bar.js +42 -57
  68. package/dist/components/cms/view-bar.js.map +1 -1
  69. package/dist/components/collection-view/cms-collection-view-source.d.ts +15 -0
  70. package/dist/components/collection-view/cms-collection-view-source.js +158 -0
  71. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -0
  72. package/dist/components/collection-view/collection-view-context.d.ts +10 -0
  73. package/dist/components/collection-view/collection-view-context.js +26 -0
  74. package/dist/components/collection-view/collection-view-context.js.map +1 -0
  75. package/dist/components/current-user-menu.js +8 -1
  76. package/dist/components/current-user-menu.js.map +1 -1
  77. package/dist/components/redirects/redirects-collection-view-source.d.ts +18 -0
  78. package/dist/components/redirects/redirects-collection-view-source.js +114 -0
  79. package/dist/components/redirects/redirects-collection-view-source.js.map +1 -0
  80. package/dist/components/redirects/redirects-module.d.ts +18 -0
  81. package/dist/components/redirects/redirects-module.js +43 -0
  82. package/dist/components/redirects/redirects-module.js.map +1 -0
  83. package/dist/components/redirects/redirects-schema.d.ts +10 -0
  84. package/dist/components/redirects/redirects-schema.js +42 -0
  85. package/dist/components/redirects/redirects-schema.js.map +1 -0
  86. package/dist/components/redirects/redirects-store.d.ts +46 -0
  87. package/dist/components/redirects/redirects-store.js +124 -0
  88. package/dist/components/redirects/redirects-store.js.map +1 -0
  89. package/dist/components/references/references-panel.js +2 -2
  90. package/dist/components/references/references-panel.js.map +1 -1
  91. package/dist/components/section-nav.js +14 -5
  92. package/dist/components/section-nav.js.map +1 -1
  93. package/dist/components/users/user-search.d.ts +8 -0
  94. package/dist/components/users/user-search.js +13 -0
  95. package/dist/components/users/user-search.js.map +1 -0
  96. package/dist/components/users/users-collection-view-source.d.ts +28 -0
  97. package/dist/components/users/users-collection-view-source.js +157 -0
  98. package/dist/components/users/users-collection-view-source.js.map +1 -0
  99. package/dist/components/users/users-module.d.ts +23 -0
  100. package/dist/components/users/users-module.js +61 -0
  101. package/dist/components/users/users-module.js.map +1 -0
  102. package/dist/components/users/users-schema.d.ts +19 -0
  103. package/dist/components/users/users-schema.js +38 -0
  104. package/dist/components/users/users-schema.js.map +1 -0
  105. package/dist/components/users/users-store.d.ts +40 -0
  106. package/dist/components/users/users-store.js +87 -0
  107. package/dist/components/users/users-store.js.map +1 -0
  108. package/dist/lib/cms-views/index.d.ts +4 -5
  109. package/dist/lib/cms-views/index.js +4 -5
  110. package/dist/lib/cms-views/index.js.map +1 -1
  111. package/dist/lib/cms-views/view-model.d.ts +48 -1
  112. package/dist/lib/cms-views/view-model.js +77 -1
  113. package/dist/lib/cms-views/view-model.js.map +1 -1
  114. package/dist/lib/collection-view/collection-view-source.d.ts +251 -0
  115. package/dist/lib/collection-view/collection-view-source.js +10 -0
  116. package/dist/lib/collection-view/collection-view-source.js.map +1 -0
  117. package/dist/lib/operations/session-hydration.d.ts +7 -0
  118. package/dist/lib/operations/session-hydration.js +7 -1
  119. package/dist/lib/operations/session-hydration.js.map +1 -1
  120. package/dist/lib/persistence/convex-adapter.d.ts +5 -1
  121. package/dist/lib/persistence/convex-adapter.js +148 -1
  122. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  123. package/dist/lib/workspace/index.d.ts +1 -8
  124. package/dist/lib/workspace/index.js +3 -9
  125. package/dist/lib/workspace/index.js.map +1 -1
  126. package/dist/router/match.d.ts +4 -0
  127. package/dist/router/match.js +8 -0
  128. package/dist/router/match.js.map +1 -1
  129. package/dist/ui/index.d.ts +2 -0
  130. package/dist/ui/index.js +2 -0
  131. package/dist/ui/index.js.map +1 -1
  132. package/dist/ui/popover.d.ts +13 -0
  133. package/dist/ui/popover.js +17 -0
  134. package/dist/ui/popover.js.map +1 -0
  135. package/dist/ui/row-item.d.ts +16 -0
  136. package/dist/ui/row-item.js +18 -0
  137. package/dist/ui/row-item.js.map +1 -0
  138. package/package.json +7 -2
@@ -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 { CircleAlert, CircleCheck, ExternalLink, Eye, EyeOff, History, Languages, Loader2, Lock, MoreHorizontal, Tag, Trash2, } from 'lucide-react';
3
+ import { Check, CircleAlert, CircleCheck, ExternalLink, Eye, EyeOff, GitCompareArrows, History, Languages, Link2, Loader2, Lock, MoreHorizontal, 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, titleFieldOf, staticFieldHidden, urlForDocument, } from '@elytracms/core/cms-core';
6
6
  import { blockRootsOf } from '@elytracms/core/project-graph';
@@ -13,33 +13,79 @@ import { parseDocumentFieldPath } from '@elytracms/studio/lib/references';
13
13
  import { FallbackChip, fallbackTitle } from './cell';
14
14
  import { FieldControl } from './field-control';
15
15
  import { CompositionFieldEditor } from '../builder/composition-field-editor';
16
- import { fieldLabel, useCms } from './cms-store';
16
+ import { fieldLabel } from './cms-store';
17
+ import { useCmsCollectionViewSource } from '../collection-view/cms-collection-view-source';
18
+ import { CollectionViewProvider, useCollectionView } from '../collection-view/collection-view-context';
19
+ import { resolveFieldAccess } from '@elytracms/studio/lib/collection-view/collection-view-source';
17
20
  import { DocStateBadge } from './doc-state';
21
+ import { UnsavedChangesGuard } from '../guards';
18
22
  import { ReferenceConfirmDialog } from './reference-confirm';
19
23
  import { ReferencesPanel } from '../references/references-panel';
20
24
  import { InspectorSidebar } from '../inspector/inspector-sidebar';
21
25
  import { DocumentEditorProvider } from './document-editor-context';
22
26
  import { VersionsPane } from './versions-pane';
27
+ import { VersionCompare } from './version-compare';
23
28
  import { IssuesPane } from './issues-pane';
24
29
  import { DetailsPane } from './details-pane';
25
30
  import { useSetBreadcrumb } from '../breadcrumb-context';
26
31
  import { resolveDeploymentFrontendUrl, resolveDeploymentRoutes } from '@elytracms/studio/lib/workspace';
27
32
  import { usePublishedDocuments } from '@elytracms/studio/lib/cms/published-documents';
33
+ /** The conventional slug field name (mirrors `url-for-document`'s `SLUG_FIELD`). */
34
+ const SLUG_FIELD = 'slug';
28
35
  export function DetailEditor({ projectId, collectionId, documentId, focusPath, }) {
29
- const cms = useCms();
30
- const collection = cms.getCollection(collectionId);
31
- const doc = cms.getDocument({ collection: collectionId, id: documentId });
36
+ // AD-19: the edit shell is a portable surface over a CollectionViewSource, just
37
+ // like the list. `DetailEditor` is the CMS entry point — it builds the CMS source
38
+ // and hands it to the portable {@link PortableDetailEditor} so `EditorBody` (and
39
+ // the shared field controls / pickers) render over the source, never `useCms()`.
40
+ const source = useCmsCollectionViewSource(collectionId);
41
+ if (!source) {
42
+ return (_jsx(DocumentNotFound, { projectId: projectId, listPath: studioPath.cmsCollection(collectionId), label: collectionId }));
43
+ }
44
+ return (_jsx(PortableDetailEditor, { source: source, projectId: projectId, documentId: documentId, focusPath: focusPath }));
45
+ }
46
+ /**
47
+ * The portable edit shell (EC-240 / AD-19): resolve the record by id from ANY
48
+ * {@link CollectionViewSource}, then render the shared {@link EditorBody} over it.
49
+ * The CMS entry point ({@link DetailEditor}) and the users rail both mount this —
50
+ * routing (the "back to list" target, the not-found link) is rail-owned via
51
+ * `source.listPath`, so the shell never hard-codes the CMS route.
52
+ */
53
+ export function PortableDetailEditor({ source, projectId, documentId, focusPath, }) {
54
+ const doc = source.get({ collection: source.collectionId, id: documentId });
32
55
  // EC-274: singleton opening is owned by `SingletonGate` (it resolves the lone
33
56
  // document and materializes it). The detail editor just renders the document it
34
57
  // is given by id — singletons need no special case here.
35
- if (!collection || !doc) {
36
- return (_jsx("div", { className: "grid h-full place-items-center p-8", children: _jsxs(Stack, { gap: "3", align: "center", className: "text-center", children: [_jsx(Heading, { level: 2, size: "h2", children: "Document not found" }), _jsxs(Link, { to: STUDIO_PROJECT_ROUTE, params: { projectId, _splat: studioPath.cmsCollection(collectionId) }, className: "text-sm text-primary hover:underline", children: ["Back to ", collectionId] })] }) }));
58
+ if (!doc) {
59
+ return (_jsx(DocumentNotFound, { projectId: projectId, listPath: source.listPath, label: source.schema.form?.label ?? source.collectionId }));
37
60
  }
38
- return (_jsx(EditorBody, { projectId: projectId, collection: collection, doc: doc, focusPath: focusPath }, `${collectionId}/${documentId}`));
61
+ return (_jsx(CollectionViewProvider, { source: source, children: _jsx(EditorBody, { projectId: projectId, collection: source.schema, doc: doc, focusPath: focusPath }, `${source.collectionId}/${documentId}`) }));
62
+ }
63
+ /** The record-not-found state — a heading + a rail-owned "back to list" link. */
64
+ function DocumentNotFound({ projectId, listPath, label, }) {
65
+ return (_jsx("div", { className: "grid h-full place-items-center p-8", children: _jsxs(Stack, { gap: "3", align: "center", className: "text-center", children: [_jsx(Heading, { level: 2, size: "h2", children: "Not found" }), listPath !== undefined ? (_jsxs(Link, { to: STUDIO_PROJECT_ROUTE, params: { projectId, _splat: listPath }, className: "text-sm text-primary hover:underline", children: ["Back to ", label] })) : null] }) }));
39
66
  }
40
67
  function EditorBody({ projectId, collection, doc, focusPath, }) {
41
- const cms = useCms();
68
+ const source = useCollectionView();
42
69
  const navigate = useNavigate();
70
+ // AD-19: capability flags gate whole shell regions AND their method groups, so
71
+ // the SAME edit shell serves a non-publishing / non-versioned / single-locale
72
+ // rail (the users rail, EC-240) without a bespoke editor. A false flag hides its
73
+ // region entirely — no dead controls, no reads fired on that region's behalf.
74
+ const canPublish = source.capabilities.publish;
75
+ const canVersions = source.capabilities.versions;
76
+ const canLocales = source.capabilities.locales;
77
+ // References is the CMS content-graph surface (the reference index tracks the
78
+ // publishable document rail). A non-publishing rail (users, future redirects) is
79
+ // not part of that graph, so its References tab would be structurally empty —
80
+ // gate it off. A dedicated `references` capability can split this later if a
81
+ // non-publishing-but-referenceable rail appears (YAGNI today).
82
+ const showReferences = canPublish;
83
+ // Principal-based field access (EC-240, AD-18): `edit|read|hidden`, resolved
84
+ // per field for the current principal. Default-open — a source with no policy
85
+ // derives it from `isFieldReadOnly`. Access depends on the record's identity, not
86
+ // its edited values (content-conditional hiding is the separate `isHidden` axis),
87
+ // so it keys off the stable `doc`, never the live draft.
88
+ const accessOf = useCallback((field) => resolveFieldAccess(source, field, doc), [source, doc]);
43
89
  // EC-217: a singleton collection has no list — there is nowhere to go "back"
44
90
  // to, and its one document is fixed (never deleted). The header drops the
45
91
  // back-crumb/id line and the Delete action accordingly.
@@ -47,7 +93,7 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
47
93
  const [draft, setDraft] = useState(() => structuredClone(doc));
48
94
  // The studio's ambient active locale (EC-176): the form displays and edits
49
95
  // exactly this locale — switched via the shared LocaleSwitcher, never per field.
50
- const locale = cms.activeLocale;
96
+ const locale = source.activeLocale;
51
97
  // Bumped when a version restore replaces the draft out-of-band, to remount the
52
98
  // (uncontrolled) canvas editor from the restored value (EC-224).
53
99
  const [restoreNonce, setRestoreNonce] = useState(0);
@@ -59,7 +105,7 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
59
105
  // EC-226: the draft/published VIEW perspective. Draft = the editable working
60
106
  // draft; Published = the pinned published snapshot, rendered read-only. Only
61
107
  // meaningful when the document is published.
62
- const published = cms.isPublished(ref);
108
+ const published = source.publishing?.isPublished(ref) ?? false;
63
109
  const [perspective, setPerspective] = useState('draft');
64
110
  const [publishedSnapshot, setPublishedSnapshot] = useState({ status: 'loading' });
65
111
  // Snap back to Draft if the document is unpublished while viewing Published.
@@ -70,10 +116,12 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
70
116
  // Load the pinned published snapshot when viewing Published (fresh each switch,
71
117
  // so it reflects the current pin after a re-publish). `loadDocumentVersions` is
72
118
  // a stable callback, so this only re-runs on a perspective/ref change.
73
- const loadVersions = cms.loadDocumentVersions;
119
+ const loadVersions = source.versioning?.load;
74
120
  useEffect(() => {
75
121
  if (perspective !== 'published')
76
122
  return;
123
+ if (!loadVersions)
124
+ return;
77
125
  let cancelled = false;
78
126
  setPublishedSnapshot({ status: 'loading' });
79
127
  void (async () => {
@@ -96,38 +144,48 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
96
144
  const viewDoc = viewReadOnly && publishedSnapshot.status === 'ready' ? publishedSnapshot.doc : draft;
97
145
  const publishedPending = viewReadOnly && publishedSnapshot.status !== 'ready';
98
146
  const dirty = JSON.stringify(draft) !== JSON.stringify(doc);
99
- const schemaIssues = useMemo(() => cms.validate(draft), [cms, draft]);
147
+ const schemaIssues = useMemo(() => source.validate(draft), [source, draft]);
100
148
  // EC-300/AD-16: composition embeds (hard references) whose target isn't published
101
149
  // would render empty on the live site. Surface them as errors in the Issues panel,
102
150
  // but they DON'T gate the Publish button (schema errors do) — publishing is
103
151
  // confirm-through, so the editor can publish the page and the reusable together
104
152
  // knowingly. The publish guard below catches them.
105
- const referenceIssues = useMemo(() => unpublishedEmbedIssues(collection, draft, cms), [collection, draft, cms]);
153
+ const referenceIssues = useMemo(() => unpublishedEmbedIssues(collection, draft, source), [collection, draft, source]);
106
154
  const issues = useMemo(() => [...schemaIssues, ...referenceIssues], [schemaIssues, referenceIssues]);
107
155
  const errorCount = schemaIssues.filter((i) => i.severity === 'error').length;
108
156
  const hasErrors = errorCount > 0;
109
157
  const hasUnpublishedRefs = referenceIssues.length > 0;
110
- // --- Autosave (EC-225) -----------------------------------------------------
111
- // The draft persists automatically 1s after the last edit; there is no manual
112
- // Save button. Refs let the unmount flush read the latest values without
113
- // re-subscribing the effects.
158
+ // --- Save mode (EC-225 autosave · EC-240 explicit) -------------------------
159
+ // A publishing rail AUTOSAVES: the draft persists 1s after the last edit and
160
+ // publish is the deliberate commit. A non-publishing rail (users, EC-240) has no
161
+ // publish step, so silent apply is the wrong default for authority data — it
162
+ // SAVES EXPLICITLY instead: edits stay in the draft until the Save button, and a
163
+ // navigation guard protects unsaved changes. (Role stays in `members`, applied
164
+ // on Save and enforced immediately — never behind a publish pointer.)
165
+ // Refs let the unmount flush read the latest values without re-subscribing.
166
+ const explicitSave = !canPublish;
114
167
  const draftRef = useRef(draft);
115
168
  draftRef.current = draft;
116
169
  const dirtyRef = useRef(dirty);
117
170
  dirtyRef.current = dirty;
118
- const cmsRef = useRef(cms);
119
- cmsRef.current = cms;
171
+ const sourceRef = useRef(source);
172
+ sourceRef.current = source;
173
+ const explicitSaveRef = useRef(explicitSave);
174
+ explicitSaveRef.current = explicitSave;
120
175
  useEffect(() => {
176
+ if (explicitSave)
177
+ return; // explicit rails persist only via the Save button
121
178
  if (!dirty)
122
179
  return;
123
- const timer = window.setTimeout(() => cmsRef.current.saveDocument(draftRef.current), 1000);
180
+ const timer = window.setTimeout(() => sourceRef.current.save(draftRef.current), 1000);
124
181
  return () => window.clearTimeout(timer);
125
- }, [dirty, draft]);
126
- // Flush a still-pending edit on unmount (navigating away inside the debounce
127
- // window) so nothing is lost. Unmount-only — refs carry the latest state.
182
+ }, [dirty, draft, explicitSave]);
183
+ // Flush a still-pending AUTOSAVE edit on unmount (navigating away inside the
184
+ // debounce window) so nothing is lost. Explicit rails never flush silently —
185
+ // their `UnsavedChangesGuard` catches the navigation instead. Unmount-only.
128
186
  useEffect(() => () => {
129
- if (dirtyRef.current)
130
- cmsRef.current.saveDocument(draftRef.current);
187
+ if (!explicitSaveRef.current && dirtyRef.current)
188
+ sourceRef.current.save(draftRef.current);
131
189
  }, []);
132
190
  const errorsByField = useMemo(() => {
133
191
  const map = new Map();
@@ -145,7 +203,9 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
145
203
  // (and loads) only when selected, and unmounts on leave, so normal editing
146
204
  // never triggers reference fetching. Appended after the collection's content tabs.
147
205
  const REFERENCES_TAB = 'references';
148
- const editorTabs = [...tabs, REFERENCES_TAB];
206
+ // EC-298: the version-compare view is a sticky framework tab (kept open while
207
+ // you tab to the real fields; closed only by its ✕).
208
+ const COMPARE_TAB = 'compare';
149
209
  const [activeTab, setActiveTab] = useState(tabs[0]);
150
210
  // EC-285: the reserved `sidebar` group is document-level metadata (slug, author…)
151
211
  // — it renders in the Details inspector pane, not as a content tab, so it is
@@ -168,6 +228,33 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
168
228
  // `sidebar`-group field can open the Details pane (where it renders). Undefined
169
229
  // = uncontrolled (the sidebar defaults to its first pane).
170
230
  const [inspectorPaneId, setInspectorPaneId] = useState();
231
+ // EC-298: the open version comparison. The Compare tab is STICKY — opening it
232
+ // sets the selection and focuses the tab; tabbing to the real fields keeps it
233
+ // open (the selection persists) so an editor can flip between diff and fields;
234
+ // only its ✕ closes it. `activeTab === COMPARE_TAB` is when the diff is visible.
235
+ const [compare, setCompare] = useState(null);
236
+ const startCompare = useCallback((base) => {
237
+ setCompare({ base, target: 'draft' });
238
+ setActiveTab(COMPARE_TAB);
239
+ setInspectorPaneId('versions');
240
+ }, []);
241
+ const setCompareBase = useCallback((base) => {
242
+ setCompare((c) => (c ? { ...c, base } : { base, target: 'draft' }));
243
+ }, []);
244
+ const setCompareTarget = useCallback((target) => {
245
+ setCompare((c) => (c ? { ...c, target } : c));
246
+ }, []);
247
+ const exitCompare = useCallback(() => {
248
+ setCompare(null);
249
+ setActiveTab((prev) => (prev === COMPARE_TAB ? tabs[0] : prev));
250
+ }, [tabs]);
251
+ // A content tab (schema tab or References). Selecting one leaves the Compare
252
+ // view VISIBLE-wise but keeps the comparison open (sticky) — the diff is one
253
+ // click away on its still-present tab chip.
254
+ const renderContentTab = (tab) => {
255
+ const active = tab === activeTab;
256
+ return (_jsx("button", { type: "button", "aria-pressed": active, onClick: () => setActiveTab(tab), className: cn('rounded-md px-2.5 py-1 text-xs font-medium capitalize transition-colors', active ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-muted'), children: tab }, tab));
257
+ };
171
258
  // Focus a field by name: switch to its tab (and locale, when given), scroll the
172
259
  // control into view, and ring-highlight it. Shared by the reference deep-link
173
260
  // (`focusPath`, EC-172) and the Issues pane's jump-to-field (EC-283).
@@ -185,7 +272,7 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
185
272
  const fieldTabs = collection.form?.tabs ?? ['main'];
186
273
  setActiveTab(field.form?.tab ?? fieldTabs[0]);
187
274
  }
188
- if (localeHint !== undefined && cms.localeConfig.locales.includes(localeHint)) {
275
+ if (localeHint !== undefined && source.localeConfig.locales.includes(localeHint)) {
189
276
  setStudioLocale(localeHint);
190
277
  }
191
278
  setHighlightedField(field.name);
@@ -200,7 +287,7 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
200
287
  }, 60);
201
288
  const clearTimer = window.setTimeout(() => setHighlightedField(null), 3200);
202
289
  focusTimersRef.current = [scrollTimer, clearTimer];
203
- }, [collection, cms.localeConfig]);
290
+ }, [collection, source.localeConfig]);
204
291
  useEffect(() => () => focusTimersRef.current.forEach((t) => window.clearTimeout(t)), []);
205
292
  // Reference deep links (EC-172): `?field=<index path>` pinpoints the exact field.
206
293
  useEffect(() => {
@@ -221,7 +308,7 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
221
308
  const explicit = viewDoc.localized?.[locale]?.[field.name];
222
309
  if (explicit !== undefined)
223
310
  return explicit;
224
- return resolveField(collection, viewDoc, field.name, locale, cms.localeConfig).value;
311
+ return resolveField(collection, viewDoc, field.name, locale, source.localeConfig).value;
225
312
  }
226
313
  return viewDoc.values[field.name];
227
314
  }
@@ -249,7 +336,9 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
249
336
  // draft changes, so a field shows/hides live as the editor types.
250
337
  const conditionValues = { ...draft.values, ...(draft.localized?.[locale] ?? {}) };
251
338
  const isHidden = (field) => isFieldHidden(field, { values: conditionValues });
252
- const visibleSidebarFields = sidebarFieldDefs.filter((f) => !isHidden(f));
339
+ // Visible = not content-hidden AND not principal-hidden (EC-240 access === 'hidden').
340
+ const isFieldShown = (field) => !isHidden(field) && accessOf(field) !== 'hidden';
341
+ const visibleSidebarFields = sidebarFieldDefs.filter(isFieldShown);
253
342
  // EC-306: presentational group sections hidden right now (static or predicate) —
254
343
  // skipped entirely, like a hidden field. Recomputed live as the draft changes.
255
344
  const hiddenGroups = hiddenGroupNames(collection, { values: conditionValues });
@@ -261,15 +350,20 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
261
350
  function renderFieldRow(field) {
262
351
  if (isHidden(field))
263
352
  return null;
264
- // Field-level read-only (the Lock badge) vs the view-level read-only
265
- // (Published perspective) — the control honors both.
266
- const fieldLevelReadOnly = cms.isFieldReadOnly(field);
353
+ // EC-240: principal-based access. `hidden` drops the row entirely (this
354
+ // principal can't see the field); `read` renders it read-only. Distinct from
355
+ // the content-conditional `isHidden` above and the view-level read-only
356
+ // (Published perspective) — the control honors all three.
357
+ const access = accessOf(field);
358
+ if (access === 'hidden')
359
+ return null;
360
+ const fieldLevelReadOnly = access === 'read';
267
361
  const fieldReadOnly = viewReadOnly || fieldLevelReadOnly;
268
362
  const localized = isFieldLocalized(collection, field.name);
269
363
  // Fallback visibility (EC-176): the control DISPLAYS the fallback value, and
270
364
  // this marker says so on the exact field.
271
365
  const fallback = localized
272
- ? displayedFieldFallback(collection, viewDoc, field.name, locale, cms.localeConfig)
366
+ ? displayedFieldFallback(collection, viewDoc, field.name, locale, source.localeConfig)
273
367
  : null;
274
368
  // The variant was created in this editing session (the saved document doesn't
275
369
  // have it yet): editing a fallback-shown value is an explicit translation act.
@@ -278,10 +372,10 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
278
372
  localized &&
279
373
  hasLocaleVariant(draft, field.name, locale) &&
280
374
  !hasLocaleVariant(doc, field.name, locale);
281
- 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, fallback ? (_jsxs(Badge, { variant: "warning", title: fallbackTitle(fallback), "data-testid": `field-fallback-${field.name}`, children: ["showing ", localeChipLabel(fallback.sourceLocale)] })) : null, newlyTranslated ? (_jsxs(Badge, { variant: "success", "data-testid": `field-translated-${field.name}`, children: [_jsx(Languages, { className: "size-3" }), " translated to ", localeChipLabel(locale)] })) : null, fieldLevelReadOnly ? (_jsxs(Badge, { variant: "outline", children: [_jsx(Lock, { className: "size-3" }), " read-only"] })) : null, _jsx(Spacer, {})] }), htmlFor: `f-${field.name}`, hint: fallback ? (_jsxs(_Fragment, { children: ["Showing the ", localeChipLabel(fallback.sourceLocale), " (fallback) value \u2014 edit to translate to ", localeChipLabel(locale), ". Clearing the ", localeChipLabel(locale), " text falls back again."] })) : (field.form?.description), 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));
375
+ 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 ? (_jsxs(Badge, { variant: "warning", title: fallbackTitle(fallback), "data-testid": `field-fallback-${field.name}`, children: ["showing ", localeChipLabel(fallback.sourceLocale)] })) : null, newlyTranslated ? (_jsxs(Badge, { variant: "success", "data-testid": `field-translated-${field.name}`, children: [_jsx(Languages, { className: "size-3" }), " translated to ", localeChipLabel(locale)] })) : null, fieldLevelReadOnly ? (_jsxs(Badge, { variant: "outline", children: [_jsx(Lock, { className: "size-3" }), " read-only"] })) : null, _jsx(Spacer, {})] }), htmlFor: `f-${field.name}`, hint: fallback ? (_jsxs(_Fragment, { children: ["Showing the ", localeChipLabel(fallback.sourceLocale), " (fallback) value \u2014 edit to translate to ", localeChipLabel(locale), ". Clearing the ", localeChipLabel(locale), " text falls back again."] })) : (field.form?.description), 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));
282
376
  }
283
377
  function save() {
284
- cms.saveDocument(draft);
378
+ source.save(draft);
285
379
  }
286
380
  // EC-224: a version restore swaps the working draft for an older snapshot; the
287
381
  // open editor adopts it so the form/canvas re-render from the restored content
@@ -326,7 +420,7 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
326
420
  id: 'details',
327
421
  label: 'Details',
328
422
  icon: _jsx(Tag, { className: "size-3.5" }),
329
- render: () => (_jsx(DetailsPane, { collection: collection, doc: draft, siblings: cms.documentsOf(collection.id), readOnly: viewReadOnly, fields: visibleSidebarFields, renderField: renderFieldRow, onAliasChange: (alias) => setDraft((d) => {
423
+ render: () => (_jsx(DetailsPane, { collection: collection, doc: draft, siblings: source.documents(), readOnly: viewReadOnly, fields: visibleSidebarFields, renderField: renderFieldRow, onAliasChange: (alias) => setDraft((d) => {
330
424
  const next = { ...d };
331
425
  if (alias === undefined)
332
426
  delete next.alias;
@@ -336,9 +430,13 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
336
430
  }) })),
337
431
  }
338
432
  : null;
339
- const trailingPanes = detailsPane
340
- ? [detailsPane, versionsPane, issuesPane]
341
- : [versionsPane, issuesPane];
433
+ // Versions is capability-gated (AD-19): a non-versioned rail (users) never mounts
434
+ // the pane, so no version read fires on its records.
435
+ const trailingPanes = [
436
+ ...(detailsPane ? [detailsPane] : []),
437
+ ...(canVersions ? [versionsPane] : []),
438
+ issuesPane,
439
+ ];
342
440
  // EC-268: the Publish button is its own indicator — pending while the publish
343
441
  // round-trips, a brief success, and an error toast if it rejects at transport.
344
442
  const [publishState, setPublishState] = useState('idle');
@@ -367,16 +465,16 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
367
465
  if (cleaned !== draft)
368
466
  setDraft(cleaned);
369
467
  if (cleaned !== draft || dirty)
370
- cms.saveDocument(cleaned);
468
+ source.save(cleaned);
371
469
  // Publish snapshots the draft as a version and pins it (EC-224). The live
372
470
  // row stays a continuous draft — published-ness is the version pointer
373
- // (`cms.isPublished`). Pre-gated by `disabled={hasErrors}`; the optimistic
471
+ // (`source.publishing.isPublished`). Pre-gated by `disabled={hasErrors}`; the optimistic
374
472
  // flip reconciles against the backend (EC-225), so a logical rejection the
375
473
  // gate doesn't cover (schema divergence) self-corrects the badge. A hard
376
474
  // failure (transport) rejects the flush — surface it loudly (EC-268).
377
475
  setPublishState('pending');
378
476
  try {
379
- await cms.publishDocument(ref);
477
+ await source.publishing?.publish(ref);
380
478
  setPublishState('success');
381
479
  setTimeout(() => setPublishState('idle'), 1800);
382
480
  }
@@ -388,26 +486,28 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
388
486
  function handleConfirmedAction() {
389
487
  if (confirmAction === 'unpublish') {
390
488
  setConfirmAction(null);
391
- void cms.unpublishDocument(ref);
489
+ void source.publishing?.unpublish(ref);
392
490
  return;
393
491
  }
394
492
  if (confirmAction === 'delete') {
395
493
  setConfirmAction(null);
396
- cms.deleteDocument(ref);
494
+ source.remove(ref);
495
+ // Return to the rail's list (rail-owned `listPath`), or the project index if
496
+ // the rail has no list surface (AD-19 — never a hard-coded CMS route).
397
497
  void navigate({
398
498
  to: STUDIO_PROJECT_ROUTE,
399
- params: { projectId, _splat: studioPath.cmsCollection(collection.id) },
499
+ params: { projectId, _splat: source.listPath ?? studioPath.index() },
400
500
  });
401
501
  }
402
502
  }
403
503
  // While the published snapshot is pending, don't derive the title from the
404
504
  // draft (it must not read as the published title) — fall back to the id.
405
- const title = publishedPending ? '' : primaryTitle(viewDoc, collection, locale, cms.localeConfig);
505
+ const title = publishedPending ? '' : primaryTitle(viewDoc, collection, locale, source.localeConfig);
406
506
  // EC-242: the untranslated-title chip applies only to a field-backed title; a
407
507
  // function title pattern has no single source field to mark as a fallback.
408
508
  const titleFallback = publishedPending || typeof collection.titleField === 'function'
409
509
  ? null
410
- : displayedFieldFallback(collection, viewDoc, titleFieldOf(collection)?.name ?? 'title', locale, cms.localeConfig);
510
+ : displayedFieldFallback(collection, viewDoc, titleFieldOf(collection)?.name ?? 'title', locale, source.localeConfig);
411
511
  // EC-214: the document is the active record — contribute the collection (a
412
512
  // link back to its list) and the document title to the global header
413
513
  // breadcrumb, replacing the editor's own back-link + collection/id line. A
@@ -415,18 +515,21 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
415
515
  // title tracks the live draft so a rename updates the crumb immediately.
416
516
  const breadcrumbItems = useMemo(() => {
417
517
  const collectionLabel = collection.form?.label ?? collection.id;
418
- if (singleton)
518
+ // A singleton (no list) or a rail with no list surface contributes a single,
519
+ // non-linked crumb; otherwise the collection crumb links back to its list via
520
+ // the rail-owned `listPath` (never a hard-coded CMS route — AD-19).
521
+ if (singleton || source.listPath === undefined)
419
522
  return [{ label: collectionLabel }];
420
- const docTitle = primaryTitle(draft, collection, locale, cms.localeConfig) || doc.id;
523
+ const docTitle = primaryTitle(draft, collection, locale, source.localeConfig) || doc.id;
421
524
  return [
422
525
  {
423
526
  label: collectionLabel,
424
527
  to: STUDIO_PROJECT_ROUTE,
425
- params: { projectId, _splat: studioPath.cmsCollection(collection.id) },
528
+ params: { projectId, _splat: source.listPath },
426
529
  },
427
530
  { label: docTitle },
428
531
  ];
429
- }, [singleton, collection, draft, locale, cms.localeConfig, doc.id, projectId]);
532
+ }, [singleton, collection, draft, locale, source.localeConfig, source.listPath, doc.id, projectId]);
430
533
  useSetBreadcrumb(breadcrumbItems);
431
534
  // EC-205/EC-216: the LIVE URL derives strictly from PUBLISHED data — the
432
535
  // document's pinned published snapshot and its published ancestors — never the
@@ -434,7 +537,7 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
434
537
  // pool is exactly what the live site serves, so a published doc resolves to the
435
538
  // URL visitors actually get; `undefined` until the doc is published (and the
436
539
  // pool has loaded). The collection being routable gates the disabled hint below.
437
- const publishedDocs = usePublishedDocuments(cms.projectId, collection.id);
540
+ const publishedDocs = usePublishedDocuments(source.projectId, collection.id);
438
541
  const routableCollection = useMemo(() => resolveDeploymentRoutes().some((route) => route.document?.collection === collection.id), [collection.id]);
439
542
  const liveUrl = useMemo(() => {
440
543
  const published = publishedDocs.byId.get(doc.id);
@@ -443,9 +546,24 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
443
546
  return urlForDocument({
444
547
  routes: resolveDeploymentRoutes(),
445
548
  frontendUrl: resolveDeploymentFrontendUrl(),
446
- localeConfig: cms.localeConfig,
549
+ localeConfig: source.localeConfig,
447
550
  }, collection, published, { siblings: [...publishedDocs.byId.values()], locale });
448
- }, [publishedDocs, collection, doc.id, locale, cms.localeConfig]);
551
+ }, [publishedDocs, collection, doc.id, locale, source.localeConfig]);
552
+ // EC-246d: the DRAFT path — derived from the current edit (slug + parent chain),
553
+ // not the published pool — so the slug field shows the URL forming live, before
554
+ // the first publish. Read-only: the slug is the single editable source, the path
555
+ // is always derived (never a second stored field). `undefined` until a slug
556
+ // resolves a route (a fresh hierarchy page with no slug has no path yet).
557
+ const draftUrl = useMemo(() => urlForDocument({ routes: resolveDeploymentRoutes(), localeConfig: source.localeConfig }, collection, viewDoc, { siblings: source.documents(), locale }), [collection, viewDoc, locale, source]);
558
+ // EC-240: hide the tab bar when there is nothing to navigate — a single content
559
+ // tab with no References/Compare (the users rail). When framework tabs ARE
560
+ // present we KEEP the content tab(s) so there is always a way back to the form;
561
+ // only a genuinely single-tab surface drops them. And drop the whole lens row
562
+ // when it would be empty (no tabs to show, no locale/perspective controls), so a
563
+ // bare rail (users) shows no orphaned row.
564
+ const hasFrameworkTabs = showReferences || compare !== null;
565
+ const showContentTabs = tabs.length > 1 || hasFrameworkTabs;
566
+ const showLensRow = showContentTabs || canLocales || canPublish;
449
567
  return (_jsx(DocumentEditorProvider, { value: {
450
568
  projectId,
451
569
  collection,
@@ -456,17 +574,37 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
456
574
  viewReadOnly,
457
575
  save,
458
576
  replaceDraft,
459
- }, children: _jsxs("div", { className: cn('flex h-full min-h-0 flex-col', viewReadOnly && 'bg-info/[0.04]'), children: [_jsxs("div", { className: "shrink-0 border-b border-border px-4 py-3 sm:px-6", children: [_jsxs(Row, { gap: "3", children: [_jsxs(Row, { gap: "2", align: "center", className: "min-w-0", children: [_jsx(Heading, { level: 1, size: "h2", className: "truncate", children: singleton ? collection.form?.label ?? collection.id : title || doc.id }), singleton ? null : _jsx(FallbackChip, { fallback: titleFallback, testId: "doc-title-fallback" })] }), _jsx(Spacer, {}), _jsx(Text, { size: "xs", tone: "muted", className: "whitespace-nowrap", "data-testid": "autosave-status", children: cms.persistError ? 'Save failed' : dirty ? 'Saving…' : 'Saved' }), _jsx(DocStateBadge, { published: cms.isPublished(ref) }), routableCollection ? (liveUrl?.href ? (_jsx(Button, { asChild: true, variant: "outline", size: "sm", title: `Open ${liveUrl.path}`, children: _jsxs("a", { href: liveUrl.href, target: "_blank", rel: "noreferrer", "data-testid": "view-live", children: [_jsx(ExternalLink, {}), " View live"] }) })) : (_jsxs(Button, { variant: "outline", size: "sm", disabled: true, "data-testid": "view-live-disabled", title: publishedDocs.status === 'loading'
577
+ compare,
578
+ compareActive: activeTab === COMPARE_TAB,
579
+ startCompare,
580
+ setCompareBase,
581
+ setCompareTarget,
582
+ exitCompare,
583
+ }, children: _jsxs("div", { className: cn('flex h-full min-h-0 flex-col', viewReadOnly && 'bg-info/[0.04]'), children: [_jsxs("div", { className: "shrink-0 border-b border-border px-4 py-3 sm:px-6", children: [_jsxs(Row, { gap: "3", children: [_jsxs(Row, { gap: "2", align: "center", className: "min-w-0", children: [_jsx(Heading, { level: 1, size: "h2", className: "truncate", children: singleton ? collection.form?.label ?? collection.id : title || doc.id }), singleton ? null : _jsx(FallbackChip, { fallback: titleFallback, testId: "doc-title-fallback" })] }), _jsx(Spacer, {}), _jsx(Text, { size: "xs", tone: "muted", className: "whitespace-nowrap", "data-testid": "autosave-status", children: source.lastError
584
+ ? 'Save failed'
585
+ : explicitSave
586
+ ? dirty
587
+ ? 'Unsaved changes'
588
+ : 'Saved'
589
+ : dirty
590
+ ? 'Saving…'
591
+ : 'Saved' }), canPublish ? _jsx(DocStateBadge, { published: published }) : null, routableCollection ? (liveUrl?.href ? (_jsx(Button, { asChild: true, variant: "outline", size: "sm", title: `Open ${liveUrl.path}`, children: _jsxs("a", { href: liveUrl.href, target: "_blank", rel: "noreferrer", "data-testid": "view-live", children: [_jsx(ExternalLink, {}), " View live"] }) })) : (_jsxs(Button, { variant: "outline", size: "sm", disabled: true, "data-testid": "view-live-disabled", title: publishedDocs.status === 'loading'
460
592
  ? 'Loading the live URL…'
461
- : !cms.isPublished(ref)
593
+ : !published
462
594
  ? 'Publish to view the live page'
463
- : 'Set frontendUrl in elytra.config.ts to enable', children: [_jsx(ExternalLink, {}), " View live"] }))) : null, _jsx(Button, { size: "sm", onClick: handlePublish, disabled: hasErrors || viewReadOnly || publishState === 'pending', title: viewReadOnly
595
+ : 'Set frontendUrl in elytra.config.ts to enable', children: [_jsx(ExternalLink, {}), " View live"] }))) : null, canPublish ? (_jsx(Button, { size: "sm", onClick: handlePublish, disabled: hasErrors || viewReadOnly || publishState === 'pending', title: viewReadOnly
464
596
  ? 'Switch to Draft to publish'
465
597
  : hasErrors
466
598
  ? `Fix ${errorCount} ${errorCount === 1 ? 'issue' : 'issues'} to publish`
467
- : undefined, children: publishState === 'pending' ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "animate-spin" }), " Publishing\u2026"] })) : publishState === 'success' ? (_jsxs(_Fragment, { children: [_jsx(CircleCheck, {}), " Published"] })) : (_jsxs(_Fragment, { children: [_jsx(Eye, {}), " Publish"] })) }), cms.isPublished(ref) || !singleton ? (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "More actions", children: _jsx(MoreHorizontal, {}) }) }), _jsxs(DropdownMenuContent, { align: "end", children: [cms.isPublished(ref) ? (_jsxs(DropdownMenuItem, { onClick: () => setConfirmAction('unpublish'), children: [_jsx(EyeOff, {}), " Unpublish"] })) : null, singleton ? null : (_jsxs(DropdownMenuItem, { "data-testid": "doc-delete", className: "text-destructive", onClick: () => setConfirmAction('delete'), children: [_jsx(Trash2, {}), " Delete"] }))] })] })) : null] }), _jsxs(Row, { gap: "3", className: "mt-3", wrap: true, children: [_jsx(LocaleSwitcher, {}), _jsx(Row, { gap: "1", children: editorTabs.map((tab) => (_jsx("button", { type: "button", "aria-pressed": tab === activeTab, onClick: () => setActiveTab(tab), className: cn('rounded-md px-2.5 py-1 text-xs font-medium capitalize transition-colors', tab === activeTab ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-muted'), children: tab }, tab))) }), _jsx(Spacer, {}), _jsxs(Row, { gap: "px", className: "rounded-md bg-muted/60 p-0.5", children: [_jsx(PerspectiveButton, { label: "Draft", active: perspective === 'draft', onClick: () => setPerspective('draft') }), _jsx(PerspectiveButton, { label: "Published", active: perspective === 'published', accent: "info", disabled: !published, title: published ? undefined : 'Publish this document to view its published version', onClick: () => setPerspective('published') })] })] })] }), 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: "Viewing the published version \u2014 read-only." }), _jsx(Spacer, {}), _jsx("button", { type: "button", onClick: () => setPerspective('draft'), className: "text-xs font-medium underline-offset-2 hover:underline", children: "Switch to Draft" })] })) : 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'
599
+ : undefined, children: publishState === 'pending' ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "animate-spin" }), " Publishing\u2026"] })) : publishState === 'success' ? (_jsxs(_Fragment, { children: [_jsx(CircleCheck, {}), " Published"] })) : (_jsxs(_Fragment, { children: [_jsx(Eye, {}), " Publish"] })) })) : null, explicitSave ? (_jsxs(Button, { size: "sm", onClick: save, disabled: !dirty, "data-testid": "doc-save", children: [_jsx(Check, {}), " Save"] })) : null, published || !singleton ? (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "More actions", children: _jsx(MoreHorizontal, {}) }) }), _jsxs(DropdownMenuContent, { align: "end", children: [published ? (_jsxs(DropdownMenuItem, { onClick: () => setConfirmAction('unpublish'), children: [_jsx(EyeOff, {}), " Unpublish"] })) : null, singleton ? null : (_jsxs(DropdownMenuItem, { "data-testid": "doc-delete", className: "text-destructive", onClick: () => setConfirmAction('delete'), children: [_jsx(Trash2, {}), " 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
600
+ ? 'bg-accent text-accent-foreground'
601
+ : '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 }), " Compare"] }), _jsx("button", { type: "button", onClick: exitCompare, "aria-label": "Close compare", 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: "Draft", active: perspective === 'draft', onClick: () => setPerspective('draft') }), _jsx(PerspectiveButton, { label: "Published", active: perspective === 'published', accent: "info", disabled: !published, title: published ? undefined : 'Publish this document to view its published version', 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: "Viewing the published version \u2014 read-only." }), _jsx(Spacer, {}), _jsx("button", { type: "button", onClick: () => setPerspective('draft'), className: "text-xs font-medium underline-offset-2 hover:underline", children: "Switch to Draft" })] })) : 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'
468
602
  ? 'Loading the published version…'
469
- : 'No published version found for this document.' }) })) : 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 || cms.isFieldReadOnly(canvasField), trailingPanes: trailingPanes }, `${canvasField.name}:${restoreNonce}:${perspective}`) })) : activeTab === REFERENCES_TAB ? (
603
+ : 'No published version found for this document.' }) })) : activeTab === COMPARE_TAB && compare ? (
604
+ // EC-298: the version compare view takes over the main area, with the
605
+ // shared inspector (Versions pane) staying on as the navigator — an
606
+ // in-app surface, not a modal, so arbitrary versions can be browsed.
607
+ _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 ? (
470
608
  // EC-269: mounted only while this tab is active → reference data loads on
471
609
  // demand and is torn down on leave (no fetching during normal editing).
472
610
  _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) => {
@@ -474,14 +612,18 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
474
612
  // entirely (its fields are also exempt from validation).
475
613
  if (hiddenGroups.has(group.name))
476
614
  return null;
477
- // EC-273: drop a fieldset whose every field is conditionally hidden,
478
- // so the form never shows an empty section header.
479
- const visible = group.fields.filter((f) => !isHidden(f));
615
+ // EC-273/EC-240: drop a fieldset whose every field is hidden — whether
616
+ // content-conditionally or for this principal — so the form never
617
+ // shows an empty section header.
618
+ const visible = group.fields.filter(isFieldShown);
480
619
  if (visible.length === 0)
481
620
  return null;
482
621
  const section = group.section;
483
622
  return (_jsx(CollapsibleSection, { collapsible: section?.collapsible, defaultOpen: section?.defaultOpen, title: _jsx(Text, { size: "sm", weight: "semibold", className: section?.title ? undefined : 'capitalize', children: section?.title ?? group.name }), children: _jsx("div", { className: "grid grid-cols-1 gap-4 p-4 md:grid-cols-12", children: visible.map((field) => (_jsx("div", { className: cn('col-span-1', fieldWidthColSpan(field.form?.width)), children: renderFieldRow(field) }, field.name))) }) }, group.name));
484
- }) }) }) }), _jsx(InspectorSidebar, { panes: trailingPanes, activeId: inspectorPaneId, onActiveChange: setInspectorPaneId, className: "w-[320px] shrink-0 border-l border-border" })] })), confirmAction !== null ? (_jsx(ReferenceConfirmDialog, { projectId: projectId, action: confirmAction, targets: confirmTargets, open: true, onOpenChange: (open) => {
623
+ }) }) }) }), _jsx(InspectorSidebar, { panes: trailingPanes, activeId: inspectorPaneId, onActiveChange: setInspectorPaneId, className: "w-[320px] shrink-0 border-l border-border" })] })), explicitSave ? (_jsx(UnsavedChangesGuard, { when: dirty, onSave: () => {
624
+ save();
625
+ return true;
626
+ }, description: "You have unsaved changes to this record. Save them before leaving, or discard them." })) : null, confirmAction !== null ? (_jsx(ReferenceConfirmDialog, { projectId: projectId, action: confirmAction, targets: confirmTargets, open: true, onOpenChange: (open) => {
485
627
  if (!open)
486
628
  setConfirmAction(null);
487
629
  }, onConfirm: handleConfirmedAction })) : null, _jsx(Dialog, { open: publishConfirm, onOpenChange: (open) => { if (!open)
@@ -498,8 +640,8 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
498
640
  * confirm-through (not blocked), so these feed the Issues panel + the publish
499
641
  * confirmation, never the button's disabled gate. No reuse capability → none.
500
642
  */
501
- function unpublishedEmbedIssues(collection, draft, cms) {
502
- const capability = resolveReuseCapability(getStudioRegistry().registry.list(), cms.collections);
643
+ function unpublishedEmbedIssues(collection, draft, source) {
644
+ const capability = resolveReuseCapability(getStudioRegistry().registry.list(), source.collections);
503
645
  if (!capability)
504
646
  return [];
505
647
  const issues = [];
@@ -512,21 +654,21 @@ function unpublishedEmbedIssues(collection, draft, cms) {
512
654
  if (seen.has(key))
513
655
  continue;
514
656
  seen.add(key);
515
- const exists = cms.getDocument(ref) !== undefined;
516
- if (exists && cms.isPublished(ref))
657
+ const exists = source.get(ref) !== undefined;
658
+ if (exists && (source.publishing?.isPublished(ref) ?? false))
517
659
  continue;
518
- issues.push(unpublishedEmbedIssue(collection, draft, field.name, ref, exists, cms));
660
+ issues.push(unpublishedEmbedIssue(collection, draft, field.name, ref, exists, source));
519
661
  }
520
662
  }
521
663
  return issues;
522
664
  }
523
665
  /** One unpublished/missing reuse-embed issue (EC-300). */
524
- function unpublishedEmbedIssue(collection, draft, field, ref, exists, cms) {
666
+ function unpublishedEmbedIssue(collection, draft, field, ref, exists, source) {
525
667
  return {
526
668
  code: exists ? 'unpublished-relation-target' : 'unknown-relation-target',
527
669
  severity: 'error',
528
670
  message: exists
529
- ? `Embeds the reusable “${cms.titleOf(ref)}”, which isn’t published — it will be empty on the live site until you publish it.`
671
+ ? `Embeds the reusable “${source.titleOf(ref)}”, which isn’t published — it will be empty on the live site until you publish it.`
530
672
  : `Embeds a reusable that no longer exists (“${ref.id}”) — fix or remove the embed.`,
531
673
  path: [field],
532
674
  collectionId: collection.id,
@@ -546,6 +688,16 @@ function PerspectiveButton({ label, active, accent, disabled, title, onClick, })
546
688
  : 'bg-background text-foreground shadow-sm'
547
689
  : 'text-muted-foreground hover:text-foreground'), children: label }));
548
690
  }
691
+ /**
692
+ * EC-246d: the slug's derived route path, shown read-only directly under the slug
693
+ * input. The slug is the single editable source; the path is composed by the route
694
+ * resolver (`urlForDocument`) from slug + parent chain and never stored. Draft-aware,
695
+ * so it updates as the editor types. Until a slug resolves a route (a fresh
696
+ * hierarchy page), a hint explains where the URL comes from.
697
+ */
698
+ function PathPreview({ url }) {
699
+ 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: "The URL is derived from the slug (and parent) \u2014 set a slug to define it." }))] }));
700
+ }
549
701
  function groupFields(fields, collection) {
550
702
  const order = groupOrder(collection.form);
551
703
  const byGroup = new Map();