@elytracms/studio 0.0.40 → 0.0.42

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 (44) hide show
  1. package/dist/components/builder/canvas.js +16 -2
  2. package/dist/components/builder/canvas.js.map +1 -1
  3. package/dist/components/cms/detail-editor.js +95 -73
  4. package/dist/components/cms/detail-editor.js.map +1 -1
  5. package/dist/components/cms/details-pane.d.ts +11 -21
  6. package/dist/components/cms/details-pane.js +14 -31
  7. package/dist/components/cms/details-pane.js.map +1 -1
  8. package/dist/components/cms/document-url-path.d.ts +39 -0
  9. package/dist/components/cms/document-url-path.js +60 -0
  10. package/dist/components/cms/document-url-path.js.map +1 -0
  11. package/dist/components/cms/field-control.js +18 -12
  12. package/dist/components/cms/field-control.js.map +1 -1
  13. package/dist/components/cms/rich-text/component-embed.d.ts +7 -2
  14. package/dist/components/cms/rich-text/component-embed.js +60 -5
  15. package/dist/components/cms/rich-text/component-embed.js.map +1 -1
  16. package/dist/components/cms/rich-text/editor.js +24 -5
  17. package/dist/components/cms/rich-text/editor.js.map +1 -1
  18. package/dist/components/cms/rich-text/embed-inspector-context.d.ts +50 -0
  19. package/dist/components/cms/rich-text/embed-inspector-context.js +12 -0
  20. package/dist/components/cms/rich-text/embed-inspector-context.js.map +1 -0
  21. package/dist/components/cms/rich-text/embed-inspector.d.ts +25 -0
  22. package/dist/components/cms/rich-text/embed-inspector.js +48 -0
  23. package/dist/components/cms/rich-text/embed-inspector.js.map +1 -0
  24. package/dist/components/cms/rich-text/embed-picker.d.ts +0 -2
  25. package/dist/components/cms/rich-text/embed-picker.js +0 -4
  26. package/dist/components/cms/rich-text/embed-picker.js.map +1 -1
  27. package/dist/components/cms/rich-text/embed-props.d.ts +7 -0
  28. package/dist/components/cms/rich-text/embed-props.js +9 -0
  29. package/dist/components/cms/rich-text/embed-props.js.map +1 -1
  30. package/dist/components/cms/rich-text/insert.d.ts +8 -2
  31. package/dist/components/cms/rich-text/insert.js +32 -3
  32. package/dist/components/cms/rich-text/insert.js.map +1 -1
  33. package/dist/components/cms/rich-text/slash-command.d.ts +23 -6
  34. package/dist/components/cms/rich-text/slash-command.js +31 -23
  35. package/dist/components/cms/rich-text/slash-command.js.map +1 -1
  36. package/dist/components/cms/version-compare.js +4 -3
  37. package/dist/components/cms/version-compare.js.map +1 -1
  38. package/dist/components/components/component-preview.js +62 -9
  39. package/dist/components/components/component-preview.js.map +1 -1
  40. package/dist/components/inspector/inspector-sidebar.js +20 -3
  41. package/dist/components/inspector/inspector-sidebar.js.map +1 -1
  42. package/dist/lib/i18n/messages.en.js +9 -5
  43. package/dist/lib/i18n/messages.en.js.map +1 -1
  44. package/package.json +2 -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 { Check, ChevronDown, CircleAlert, CircleCheck, ExternalLink, Eye, EyeOff, GitCompareArrows, History, Loader2, Lock, MoreHorizontal, RotateCcw, Save, Tag, Trash2, X, } from 'lucide-react';
3
+ import { Check, ChevronDown, CircleAlert, CircleCheck, 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';
@@ -36,6 +36,11 @@ import { useDocumentVersionsQuery } from './use-document-versions';
36
36
  import { discardDraftStatus } from './discard-draft';
37
37
  import { IssuesPane } from './issues-pane';
38
38
  import { DetailsPane } from './details-pane';
39
+ import { DocumentUrlPath } from './document-url-path';
40
+ // 0.0.41: selection-driven embed inspector. Both modules are deliberately TipTap-free
41
+ // so the editor runtime stays behind rich-text/lazy (EC-322) — see their doc comments.
42
+ import { EmbedInspectorProvider } from './rich-text/embed-inspector-context';
43
+ import { EmbedInspector } from './rich-text/embed-inspector';
39
44
  import { useSetBreadcrumb } from '../breadcrumb-context';
40
45
  import { resolveDeploymentFrontendUrl, resolveDeploymentRoutes } from '@elytracms/studio/lib/workspace';
41
46
  import { usePublishedDocuments } from '@elytracms/studio/lib/cms/published-documents';
@@ -291,6 +296,15 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
291
296
  // `sidebar`-group field can open the Details pane (where it renders). Undefined
292
297
  // = uncontrolled (the sidebar defaults to its first pane).
293
298
  const [inspectorPaneId, setInspectorPaneId] = useState();
299
+ // 0.0.41: the selected rich-text component embed, if any — a `componentEmbed` node
300
+ // selection swaps the inspector sidebar for the embed's prop form. Fed by the
301
+ // rich-text field through the EmbedInspector context; `clearActiveEmbed` keys on the
302
+ // node id so a fast A→B reselect can't let A's deselect wipe B (selection race).
303
+ const [activeEmbed, setActiveEmbed] = useState(null);
304
+ const embedInspectorApi = useMemo(() => ({
305
+ setActiveEmbed: (payload) => setActiveEmbed(payload),
306
+ clearActiveEmbed: (nodeId) => setActiveEmbed((prev) => (prev?.nodeId === nodeId ? null : prev)),
307
+ }), []);
294
308
  // EC-298: the open version comparison. The Compare tab is STICKY — opening it
295
309
  // sets the selection and focuses the tab; tabbing to the real fields keeps it
296
310
  // open (the selection persists) so an editor can flip between diff and fields;
@@ -473,25 +487,23 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
473
487
  // memoized: its render closes over per-render edit state (draft, highlight,
474
488
  // field errors) via `renderFieldRow`, and the inspector re-renders with the rest
475
489
  // of the editor anyway, so a stable pane identity would buy nothing here.
476
- // 0.0.39: whether a deployment route addresses this collection — gates the header's
477
- // "View live" AND the Details pane's document-level URL row (EC-315 hybrid split).
478
- // A cheap synchronous accessor, resolved on every render (NOT frozen in a `useMemo`
479
- // with a static dep — the same first-paint-empty-routes trap the SlugField fix
480
- // removed: routes arrive after the workspace/config loads).
490
+ // 0.0.42 (round 3): whether a deployment route addresses this collection — gates the
491
+ // header's composed-URL row (which carries the live-page affordance, EC-315 hybrid
492
+ // split). A cheap synchronous accessor, resolved on every render (NOT frozen in a
493
+ // `useMemo` with a static dep — the same first-paint-empty-routes trap the SlugField
494
+ // fix removed: routes arrive after the workspace/config loads).
481
495
  const routableCollection = resolveDeploymentRoutes().some((route) => route.document?.collection === collection.id);
482
- const detailsPane = hasAliases(collection) || visibleSidebarFields.length > 0 || routableCollection
496
+ // The composed URL moved OUT of this pane and up to the header (round 3): a lone URL
497
+ // row on an otherwise-empty pane looked odd. So the Details pane exists only for slots
498
+ // and/or sidebar fields — never merely because the collection is routable (that would
499
+ // resurrect the empty-pane problem). A routable page keeps its pane via its slug
500
+ // sidebar field; a routable collection with neither has no pane (URL is in the header).
501
+ const detailsPane = hasAliases(collection) || visibleSidebarFields.length > 0
483
502
  ? {
484
503
  id: 'details',
485
504
  label: translate('cms.editor.detailsPane'),
486
505
  icon: _jsx(Tag, { className: "size-3.5" }),
487
- render: () => (_jsx(DetailsPane, { collection: collection, doc: draft, siblings: source.documents(), readOnly: viewReadOnly, fields: visibleSidebarFields, renderField: renderFieldRow, urlContext: routableCollection
488
- ? {
489
- projectId,
490
- routes: resolveDeploymentRoutes(),
491
- localeConfig: source.localeConfig,
492
- locale,
493
- }
494
- : undefined, onAliasChange: (alias) => setDraft((d) => {
506
+ render: () => (_jsx(DetailsPane, { collection: collection, doc: draft, siblings: source.documents(), readOnly: viewReadOnly, fields: visibleSidebarFields, renderField: renderFieldRow, onAliasChange: (alias) => setDraft((d) => {
495
507
  const next = { ...d };
496
508
  if (alias === undefined)
497
509
  delete next.alias;
@@ -642,10 +654,24 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
642
654
  localeConfig: source.localeConfig,
643
655
  }, collection, published, { siblings: [...publishedDocs.byId.values()], locale });
644
656
  }, [publishedDocs, collection, doc.id, locale, source.localeConfig]);
645
- // EC-246d/EC-315: the DRAFT composed path moved INTO the slug control itself
646
- // (`SlugField`), which derives + decomposes it from the same routes; the editor no
647
- // longer computes a separate `draftUrl`. `liveUrl` (published) still gates the
648
- // header's "View live" button — the sole open-live, so draft never links out.
657
+ // 0.0.42 (round 3): the composed URL row lives in the header under the title; its
658
+ // LEAF segment carries the open-live affordance, mirroring the retired "View live"
659
+ // button's gating EXACTLY. `href` present (published + frontendUrl) → the leaf opens
660
+ // the live page in a new tab; absent → a muted, non-interactive icon with the same
661
+ // reason tooltip (loading / publish-first / set-frontendUrl). The draft-derived path
662
+ // itself still updates as you type — only the OPEN-live action is published-gated, so
663
+ // the draft/live distinction is preserved.
664
+ const headerLive = routableCollection
665
+ ? liveUrl?.href
666
+ ? { href: liveUrl.href, title: translate('cms.editor.openLiveUrl', { path: liveUrl.path }) }
667
+ : {
668
+ title: publishedDocs.status === 'loading'
669
+ ? translate('cms.editor.loadingLiveUrl')
670
+ : !published
671
+ ? translate('cms.editor.publishToViewLive')
672
+ : translate('cms.editor.setFrontendUrlHint'),
673
+ }
674
+ : undefined;
649
675
  // EC-240: hide the tab bar when there is nothing to navigate — a single content
650
676
  // tab with no References/Compare (the users rail). When framework tabs ARE
651
677
  // present we KEEP the content tab(s) so there is always a way back to the form;
@@ -683,60 +709,56 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
683
709
  setCompareBase,
684
710
  setCompareTarget,
685
711
  exitCompare,
686
- }, 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 ? localizedLabel(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
687
- ? translate('cms.editor.saveFailed')
688
- : explicitSave
689
- ? dirty
690
- ? translate('cms.editor.unsavedChanges')
691
- : translate('cms.editor.saved')
692
- : dirty
693
- ? translate('cms.editor.saving')
694
- : translate('cms.editor.saved') }), canPublish ? _jsx(DocStateBadge, { published: published }) : null, routableCollection ? (liveUrl?.href ? (_jsx(Button, { asChild: true, variant: "outline", size: "sm", title: translate('cms.editor.openLiveUrl', { path: liveUrl.path }), children: _jsxs("a", { href: liveUrl.href, target: "_blank", rel: "noreferrer", "data-testid": "view-live", children: [_jsx(ExternalLink, {}), " ", translate('cms.editor.viewLive')] }) })) : (_jsxs(Button, { variant: "outline", size: "sm", disabled: true, "data-testid": "view-live-disabled", title: publishedDocs.status === 'loading'
695
- ? translate('cms.editor.loadingLiveUrl')
696
- : !published
697
- ? translate('cms.editor.publishToViewLive')
698
- : translate('cms.editor.setFrontendUrlHint'), children: [_jsx(ExternalLink, {}), " ", translate('cms.editor.viewLive')] }))) : null, canPublish ? (_jsxs(Row, { gap: "px", align: "center", children: [_jsx(Button, { size: "sm", onClick: handlePublish, disabled: publishDisabled, title: publishTitle, className: canPublishClose ? 'rounded-r-none' : undefined, "data-testid": "doc-publish", children: publishButtonLabel }), canPublishClose ? (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { size: "sm", disabled: publishDisabled, "aria-label": translate('cms.editor.publishActions'), "data-testid": "doc-publish-actions", className: "rounded-l-none border-l border-primary-foreground/25 px-1.5", children: _jsx(ChevronDown, { className: "size-3.5" }) }) }), _jsx(DropdownMenuContent, { align: "end", children: _jsxs(DropdownMenuItem, { onClick: handlePublishAndClose, "data-testid": "doc-publish-and-close", children: [_jsx(Save, {}), " ", translate('cms.editor.publishAndClose')] }) })] })) : null] })) : 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: [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
699
- ? 'bg-accent text-accent-foreground'
700
- : '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'
701
- ? translate('cms.editor.loadingPublishedVersion')
702
- : 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) => {
703
- // EC-306: a conditionally/statically hidden group section is skipped
704
- // entirely (its fields are also exempt from validation).
705
- if (hiddenGroups.has(group.name))
706
- return null;
707
- // EC-273/EC-240: drop a fieldset whose every field is hidden — whether
708
- // content-conditionally or for this principal — so the form never
709
- // shows an empty section header.
710
- const visible = group.fields.filter(isFieldShown);
711
- if (visible.length === 0)
712
- return null;
713
- const section = group.section;
714
- // EC-241: a section `title` is a LocalizedString ({ en, de } or a plain
715
- // string) — resolve it against the active UI locale before rendering.
716
- const sectionTitle = section?.title !== undefined ? localizedLabel(section.title, group.name) : undefined;
717
- return (_jsx(CollapsibleSection, { collapsible: section?.collapsible, defaultOpen: section?.defaultOpen, title: _jsx(Text, { size: "sm", weight: "semibold", className: sectionTitle || group.name === DEFAULT_FIELD_GROUP ? undefined : 'capitalize', children: sectionTitle ??
718
- (group.name === DEFAULT_FIELD_GROUP
719
- ? translate('cms.editor.defaultGroup')
720
- : 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));
721
- }) }) }) }), _jsx(InspectorSidebar, { panes: trailingPanes, activeId: inspectorPaneId, onActiveChange: setInspectorPaneId, className: "w-[320px] shrink-0 border-l border-border" })] })), explicitSave ? (_jsx(UnsavedChangesGuard, { when: dirty, onSave: () => {
722
- save();
723
- return true;
724
- }, description: translate('cms.editor.unsavedChangesGuard') })) : null, confirmAction !== null ? (_jsx(ReferenceConfirmDialog, { projectId: projectId, action: confirmAction, targets: confirmTargets, open: true, onOpenChange: (open) => {
725
- if (!open)
726
- setConfirmAction(null);
727
- }, onConfirm: handleConfirmedAction })) : null, _jsx(Dialog, { open: discardConfirm, onOpenChange: (open) => { if (!open)
728
- 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) {
729
- setPublishConfirm(false);
730
- closeAfterPublishRef.current = false;
731
- } }, 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
732
- ? 'cms.editor.publishAlsoHasIssues.one'
733
- : 'cms.editor.publishAlsoHasIssues.other', { count: schemaIssues.length }) })) : null] }), _jsxs(DialogFooter, { className: "mt-4", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => {
734
- setPublishConfirm(false);
735
- closeAfterPublishRef.current = false;
736
- }, children: translate('common.cancel') }), _jsx(Button, { size: "sm", "data-testid": "publish-anyway", onClick: () => {
737
- setPublishConfirm(false);
738
- void doPublish();
739
- }, children: translate('cms.editor.publishAnyway') })] })] }) })] }) }));
712
+ }, children: _jsx(EmbedInspectorProvider, { value: embedInspectorApi, 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", align: "start", children: [_jsxs(Stack, { gap: "1", className: "min-w-0", children: [_jsxs(Row, { gap: "2", align: "center", className: "min-w-0", children: [_jsx(Heading, { level: 1, size: "h2", className: "truncate", children: singleton ? localizedLabel(collection.form?.label, collection.id) : title || doc.id }), singleton ? null : _jsx(FallbackChip, { fallback: titleFallback, testId: "doc-title-fallback" })] }), routableCollection ? (_jsx(DocumentUrlPath, { projectId: projectId, collection: collection, doc: draft, siblings: source.documents(), routes: resolveDeploymentRoutes(), localeConfig: source.localeConfig, locale: locale, live: headerLive })) : null] }), _jsx(Spacer, {}), _jsx(Text, { size: "xs", tone: "muted", className: "whitespace-nowrap", "data-testid": "autosave-status", children: source.lastError
713
+ ? translate('cms.editor.saveFailed')
714
+ : explicitSave
715
+ ? dirty
716
+ ? translate('cms.editor.unsavedChanges')
717
+ : translate('cms.editor.saved')
718
+ : dirty
719
+ ? translate('cms.editor.saving')
720
+ : translate('cms.editor.saved') }), canPublish ? _jsx(DocStateBadge, { published: published }) : null, canPublish ? (_jsxs(Row, { gap: "px", align: "center", children: [_jsx(Button, { size: "sm", onClick: handlePublish, disabled: publishDisabled, title: publishTitle, className: canPublishClose ? 'rounded-r-none' : undefined, "data-testid": "doc-publish", children: publishButtonLabel }), canPublishClose ? (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { size: "sm", disabled: publishDisabled, "aria-label": translate('cms.editor.publishActions'), "data-testid": "doc-publish-actions", className: "rounded-l-none border-l border-primary-foreground/25 px-1.5", children: _jsx(ChevronDown, { className: "size-3.5" }) }) }), _jsx(DropdownMenuContent, { align: "end", children: _jsxs(DropdownMenuItem, { onClick: handlePublishAndClose, "data-testid": "doc-publish-and-close", children: [_jsx(Save, {}), " ", translate('cms.editor.publishAndClose')] }) })] })) : null] })) : 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: [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
721
+ ? 'bg-accent text-accent-foreground'
722
+ : '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'
723
+ ? translate('cms.editor.loadingPublishedVersion')
724
+ : 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) => {
725
+ // EC-306: a conditionally/statically hidden group section is skipped
726
+ // entirely (its fields are also exempt from validation).
727
+ if (hiddenGroups.has(group.name))
728
+ return null;
729
+ // EC-273/EC-240: drop a fieldset whose every field is hidden — whether
730
+ // content-conditionally or for this principal — so the form never
731
+ // shows an empty section header.
732
+ const visible = group.fields.filter(isFieldShown);
733
+ if (visible.length === 0)
734
+ return null;
735
+ const section = group.section;
736
+ // EC-241: a section `title` is a LocalizedString ({ en, de } or a plain
737
+ // string) — resolve it against the active UI locale before rendering.
738
+ const sectionTitle = section?.title !== undefined ? localizedLabel(section.title, group.name) : undefined;
739
+ return (_jsx(CollapsibleSection, { collapsible: section?.collapsible, defaultOpen: section?.defaultOpen, title: _jsx(Text, { size: "sm", weight: "semibold", className: sectionTitle || group.name === DEFAULT_FIELD_GROUP ? undefined : 'capitalize', children: sectionTitle ??
740
+ (group.name === DEFAULT_FIELD_GROUP
741
+ ? translate('cms.editor.defaultGroup')
742
+ : 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));
743
+ }) }) }) }), activeEmbed ? (_jsx(EmbedInspector, { payload: activeEmbed, className: "w-[320px] shrink-0 border-l border-border" })) : (_jsx(InspectorSidebar, { panes: trailingPanes, activeId: inspectorPaneId, onActiveChange: setInspectorPaneId, className: "w-[320px] shrink-0 border-l border-border" }))] })), explicitSave ? (_jsx(UnsavedChangesGuard, { when: dirty, onSave: () => {
744
+ save();
745
+ return true;
746
+ }, description: translate('cms.editor.unsavedChangesGuard') })) : null, confirmAction !== null ? (_jsx(ReferenceConfirmDialog, { projectId: projectId, action: confirmAction, targets: confirmTargets, open: true, onOpenChange: (open) => {
747
+ if (!open)
748
+ setConfirmAction(null);
749
+ }, onConfirm: handleConfirmedAction })) : null, _jsx(Dialog, { open: discardConfirm, onOpenChange: (open) => { if (!open)
750
+ 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) {
751
+ setPublishConfirm(false);
752
+ closeAfterPublishRef.current = false;
753
+ } }, 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
754
+ ? 'cms.editor.publishAlsoHasIssues.one'
755
+ : 'cms.editor.publishAlsoHasIssues.other', { count: schemaIssues.length }) })) : null] }), _jsxs(DialogFooter, { className: "mt-4", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => {
756
+ setPublishConfirm(false);
757
+ closeAfterPublishRef.current = false;
758
+ }, children: translate('common.cancel') }), _jsx(Button, { size: "sm", "data-testid": "publish-anyway", onClick: () => {
759
+ setPublishConfirm(false);
760
+ void doPublish();
761
+ }, children: translate('cms.editor.publishAnyway') })] })] }) })] }) }) }));
740
762
  }
741
763
  /**
742
764
  * EC-300/AD-16 — errors for composition embeds (hard references) whose target isn't