@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,32 +1,24 @@
1
- import type { CmsDocument, CollectionDef, FieldDef, LocaleConfig, RouteRecord } from '@elytracms/core/cms-core';
1
+ import type { CmsDocument, CollectionDef, FieldDef } from '@elytracms/core/cms-core';
2
2
  import type { ReactNode } from 'react';
3
- /** 0.0.39 — routing context the Details pane needs to compose the document URL row. */
4
- export interface DetailsUrlContext {
5
- projectId: string;
6
- routes: readonly RouteRecord[];
7
- localeConfig: LocaleConfig;
8
- locale: string;
9
- }
10
3
  /**
11
- * EC-284 + EC-285 (+ 0.0.39 URL row) — document-level metadata pane (the right
12
- * inspector). Up to three optional regions:
4
+ * EC-284 + EC-285 — document-level metadata pane (the right inspector). Up to two
5
+ * optional regions:
13
6
  *
14
- * 1. **URL** (0.0.39): the full composed path as its own row — the EC-315 "hybrid
15
- * split" moves the composed URL out of the cramped slug field and up to the
16
- * document, where a URL is a first-class property. Ancestor-DOCUMENT segments
17
- * link to their editors; static route segments are plain text.
18
- * 2. **Slot** (EC-284): a stable, named handle the site uses to find this document
7
+ * 1. **Slot** (EC-284): a stable, named handle the site uses to find this document
19
8
  * (the main menu, the footer menu…) instead of which exact entry it is — shown
20
9
  * only when the collection declares alias slots.
21
- * 3. **Sidebar fields** (EC-285): the document's `sidebar`-group fields (slug,
10
+ * 2. **Sidebar fields** (EC-285): the document's `sidebar`-group fields (slug,
22
11
  * author, publish date…) — document-level properties an editor sets *about* the
23
12
  * document rather than as body content. Rendered via the detail editor's own
24
13
  * field-row renderer, so they behave identically to a content field.
25
14
  *
26
- * All three are identity/metadata, not content, so they live here — off the content
27
- * tabs. The empty state shows only when there is nothing at all to display.
15
+ * Both are identity/metadata, not content, so they live here — off the content tabs.
16
+ * The composed URL is NOT here: 0.0.42 (round 3) moved it up to the detail-editor
17
+ * HEADER, under the record title, where a URL reads as a first-class property (a lone
18
+ * URL row on an otherwise-empty pane looked odd). The empty state shows only when
19
+ * there is nothing at all to display.
28
20
  */
29
- export declare function DetailsPane({ collection, doc, siblings, readOnly, fields, renderField, onAliasChange, urlContext, }: {
21
+ export declare function DetailsPane({ collection, doc, siblings, readOnly, fields, renderField, onAliasChange, }: {
30
22
  collection: CollectionDef;
31
23
  doc: CmsDocument;
32
24
  /** The other documents in this collection (to show which slots are taken). */
@@ -37,6 +29,4 @@ export declare function DetailsPane({ collection, doc, siblings, readOnly, field
37
29
  /** Render one field row — shared with the content groups so behaviour matches. */
38
30
  renderField: (field: FieldDef) => ReactNode;
39
31
  onAliasChange: (alias: string | undefined) => void;
40
- /** Routing context for the composed URL row (0.0.39); omitted for non-routable rails. */
41
- urlContext?: DetailsUrlContext;
42
32
  }): import("react").JSX.Element;
@@ -1,50 +1,33 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { declaredAliases, resolveUrlSegments } from '@elytracms/core/cms-core';
3
- import { Link, STUDIO_PROJECT_ROUTE, studioPath } from '@elytracms/studio/router';
4
- import { Row, Select, Separator, Stack, Text } from '@elytracms/studio/ui';
5
- import { cn } from '@elytracms/studio/lib/utils';
2
+ import { declaredAliases } from '@elytracms/core/cms-core';
3
+ import { Select, Separator, Stack, Text } from '@elytracms/studio/ui';
6
4
  import { translate } from '@elytracms/studio/lib/i18n';
7
5
  /**
8
- * EC-284 + EC-285 (+ 0.0.39 URL row) — document-level metadata pane (the right
9
- * inspector). Up to three optional regions:
6
+ * EC-284 + EC-285 — document-level metadata pane (the right inspector). Up to two
7
+ * optional regions:
10
8
  *
11
- * 1. **URL** (0.0.39): the full composed path as its own row — the EC-315 "hybrid
12
- * split" moves the composed URL out of the cramped slug field and up to the
13
- * document, where a URL is a first-class property. Ancestor-DOCUMENT segments
14
- * link to their editors; static route segments are plain text.
15
- * 2. **Slot** (EC-284): a stable, named handle the site uses to find this document
9
+ * 1. **Slot** (EC-284): a stable, named handle the site uses to find this document
16
10
  * (the main menu, the footer menu…) instead of which exact entry it is — shown
17
11
  * only when the collection declares alias slots.
18
- * 3. **Sidebar fields** (EC-285): the document's `sidebar`-group fields (slug,
12
+ * 2. **Sidebar fields** (EC-285): the document's `sidebar`-group fields (slug,
19
13
  * author, publish date…) — document-level properties an editor sets *about* the
20
14
  * document rather than as body content. Rendered via the detail editor's own
21
15
  * field-row renderer, so they behave identically to a content field.
22
16
  *
23
- * All three are identity/metadata, not content, so they live here — off the content
24
- * tabs. The empty state shows only when there is nothing at all to display.
17
+ * Both are identity/metadata, not content, so they live here — off the content tabs.
18
+ * The composed URL is NOT here: 0.0.42 (round 3) moved it up to the detail-editor
19
+ * HEADER, under the record title, where a URL reads as a first-class property (a lone
20
+ * URL row on an otherwise-empty pane looked odd). The empty state shows only when
21
+ * there is nothing at all to display.
25
22
  */
26
- export function DetailsPane({ collection, doc, siblings, readOnly, fields, renderField, onAliasChange, urlContext, }) {
23
+ export function DetailsPane({ collection, doc, siblings, readOnly, fields, renderField, onAliasChange, }) {
27
24
  const slots = declaredAliases(collection);
28
25
  const hasSlots = slots.length > 0;
29
26
  const hasFields = fields.length > 0;
30
- const routable = urlContext !== undefined && urlContext.routes.some((r) => r.document?.collection === collection.id);
31
- return (_jsxs(Stack, { gap: "4", className: "p-3", children: [routable && urlContext ? (_jsx(DocumentUrlRow, { collection: collection, doc: doc, siblings: siblings, context: urlContext })) : null, routable && (hasSlots || hasFields) ? _jsx(Separator, {}) : null, hasSlots ? (_jsxs(Stack, { gap: "1", children: [_jsx(Text, { size: "xs", weight: "semibold", children: translate('cms.details.slot') }), _jsxs(Select, { value: doc.alias ?? '', disabled: readOnly, "aria-label": translate('cms.details.slot'), "data-testid": "alias-select", onChange: (e) => onAliasChange(e.target.value === '' ? undefined : e.target.value), children: [_jsx("option", { value: "", children: translate('cms.details.noSlot') }), slots.map((slot) => {
27
+ return (_jsxs(Stack, { gap: "4", className: "p-3", children: [hasSlots ? (_jsxs(Stack, { gap: "1", children: [_jsx(Text, { size: "xs", weight: "semibold", children: translate('cms.details.slot') }), _jsxs(Select, { value: doc.alias ?? '', disabled: readOnly, "aria-label": translate('cms.details.slot'), "data-testid": "alias-select", onChange: (e) => onAliasChange(e.target.value === '' ? undefined : e.target.value), children: [_jsx("option", { value: "", children: translate('cms.details.noSlot') }), slots.map((slot) => {
32
28
  const takenByOther = siblings.some((s) => s.id !== doc.id && s.alias === slot);
33
29
  const blocked = takenByOther && doc.alias !== slot;
34
30
  return (_jsxs("option", { value: slot, disabled: blocked, children: [slot, blocked ? translate('cms.details.slotInUse') : ''] }, slot));
35
- })] }), _jsx(Text, { size: "xs", tone: "muted", children: translate('cms.details.slotHint') })] })) : null, hasSlots && hasFields ? _jsx(Separator, {}) : null, hasFields ? _jsx(Stack, { gap: "4", children: fields.map(renderField) }) : null, !routable && !hasSlots && !hasFields ? (_jsx(Text, { size: "xs", tone: "muted", children: translate('cms.details.empty') })) : null] }));
36
- }
37
- /**
38
- * The document-level URL row (0.0.39 — EC-315 "hybrid split"). The full composed path,
39
- * classified segment by segment: an ancestor-DOCUMENT segment is a link to that
40
- * document's editor (autosave keeps the current edit safe on navigate); a static
41
- * route segment (mount prefix, or `blog` in `/blog/:slug`) is plain text; this
42
- * document's own slug segment is highlighted as the current page. Draft-derived (it
43
- * updates as you type) — the header's published-gated "View live" stays the sole
44
- * open-live action, so the draft/live distinction is preserved here too.
45
- */
46
- function DocumentUrlRow({ collection, doc, siblings, context, }) {
47
- const segments = resolveUrlSegments({ routes: [...context.routes], localeConfig: context.localeConfig }, collection, doc, { siblings, locale: context.locale });
48
- return (_jsxs(Stack, { gap: "1", "data-testid": "details-url", children: [_jsx(Text, { size: "xs", weight: "semibold", children: translate('cms.details.url') }), _jsx(Row, { gap: "none", align: "center", wrap: true, className: "font-mono text-xs text-muted-foreground", children: segments.length === 0 ? (_jsx("span", { "aria-hidden": true, children: "/" })) : (segments.map((seg, i) => (_jsxs("span", { className: "inline-flex items-center", children: [_jsx("span", { "aria-hidden": true, className: "text-muted-foreground/50", children: "/" }), seg.kind === 'ancestor' && seg.documentId ? (_jsx(Link, { to: STUDIO_PROJECT_ROUTE, params: { projectId: context.projectId, _splat: studioPath.cmsDocument(collection.id, seg.documentId) }, title: translate('cms.relation.openTitle', { title: seg.text }), "data-testid": "details-url-ancestor", className: "rounded px-0.5 text-primary hover:bg-primary/10 hover:underline", children: seg.text })) : (_jsx("span", { className: cn('px-0.5', seg.kind === 'current' && 'font-medium text-foreground'), title: seg.kind === 'current' ? translate('cms.details.urlCurrent') : undefined, children: seg.text }))] }, `${seg.text}-${i}`)))) })] }));
31
+ })] }), _jsx(Text, { size: "xs", tone: "muted", children: translate('cms.details.slotHint') })] })) : null, hasSlots && hasFields ? _jsx(Separator, {}) : null, hasFields ? _jsx(Stack, { gap: "4", children: fields.map(renderField) }) : null, !hasSlots && !hasFields ? (_jsx(Text, { size: "xs", tone: "muted", children: translate('cms.details.empty') })) : null] }));
49
32
  }
50
33
  //# sourceMappingURL=details-pane.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"details-pane.js","sourceRoot":"","sources":["../../../src/components/cms/details-pane.tsx"],"names":[],"mappings":"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","sourcesContent":["import { declaredAliases, resolveUrlSegments } from '@elytracms/core/cms-core'\nimport type {\n CmsDocument,\n CollectionDef,\n FieldDef,\n LocaleConfig,\n RouteRecord,\n} from '@elytracms/core/cms-core'\nimport type { ReactNode } from 'react'\nimport { Link, STUDIO_PROJECT_ROUTE, studioPath } from '@elytracms/studio/router'\nimport { Row, Select, Separator, Stack, Text } from '@elytracms/studio/ui'\nimport { cn } from '@elytracms/studio/lib/utils'\nimport { translate } from '@elytracms/studio/lib/i18n'\n\n/** 0.0.39 — routing context the Details pane needs to compose the document URL row. */\nexport interface DetailsUrlContext {\n projectId: string\n routes: readonly RouteRecord[]\n localeConfig: LocaleConfig\n locale: string\n}\n\n/**\n * EC-284 + EC-285 (+ 0.0.39 URL row) — document-level metadata pane (the right\n * inspector). Up to three optional regions:\n *\n * 1. **URL** (0.0.39): the full composed path as its own row — the EC-315 \"hybrid\n * split\" moves the composed URL out of the cramped slug field and up to the\n * document, where a URL is a first-class property. Ancestor-DOCUMENT segments\n * link to their editors; static route segments are plain text.\n * 2. **Slot** (EC-284): a stable, named handle the site uses to find this document\n * (the main menu, the footer menu…) instead of which exact entry it is — shown\n * only when the collection declares alias slots.\n * 3. **Sidebar fields** (EC-285): the document's `sidebar`-group fields (slug,\n * author, publish date…) — document-level properties an editor sets *about* the\n * document rather than as body content. Rendered via the detail editor's own\n * field-row renderer, so they behave identically to a content field.\n *\n * All three are identity/metadata, not content, so they live here — off the content\n * tabs. The empty state shows only when there is nothing at all to display.\n */\nexport function DetailsPane({\n collection,\n doc,\n siblings,\n readOnly,\n fields,\n renderField,\n onAliasChange,\n urlContext,\n}: {\n collection: CollectionDef\n doc: CmsDocument\n /** The other documents in this collection (to show which slots are taken). */\n siblings: readonly CmsDocument[]\n readOnly?: boolean\n /** The collection's `sidebar`-group fields, in order (EC-285). */\n fields: readonly FieldDef[]\n /** Render one field row — shared with the content groups so behaviour matches. */\n renderField: (field: FieldDef) => ReactNode\n onAliasChange: (alias: string | undefined) => void\n /** Routing context for the composed URL row (0.0.39); omitted for non-routable rails. */\n urlContext?: DetailsUrlContext\n}) {\n const slots = declaredAliases(collection)\n const hasSlots = slots.length > 0\n const hasFields = fields.length > 0\n const routable =\n urlContext !== undefined && urlContext.routes.some((r) => r.document?.collection === collection.id)\n\n return (\n <Stack gap=\"4\" className=\"p-3\">\n {routable && urlContext ? (\n <DocumentUrlRow collection={collection} doc={doc} siblings={siblings} context={urlContext} />\n ) : null}\n\n {routable && (hasSlots || hasFields) ? <Separator /> : null}\n\n {hasSlots ? (\n <Stack gap=\"1\">\n <Text size=\"xs\" weight=\"semibold\">\n {translate('cms.details.slot')}\n </Text>\n <Select\n value={doc.alias ?? ''}\n disabled={readOnly}\n aria-label={translate('cms.details.slot')}\n data-testid=\"alias-select\"\n onChange={(e) => onAliasChange(e.target.value === '' ? undefined : e.target.value)}\n >\n <option value=\"\">{translate('cms.details.noSlot')}</option>\n {slots.map((slot) => {\n const takenByOther = siblings.some((s) => s.id !== doc.id && s.alias === slot)\n const blocked = takenByOther && doc.alias !== slot\n return (\n <option key={slot} value={slot} disabled={blocked}>\n {slot}\n {blocked ? translate('cms.details.slotInUse') : ''}\n </option>\n )\n })}\n </Select>\n <Text size=\"xs\" tone=\"muted\">\n {translate('cms.details.slotHint')}\n </Text>\n </Stack>\n ) : null}\n\n {hasSlots && hasFields ? <Separator /> : null}\n\n {hasFields ? <Stack gap=\"4\">{fields.map(renderField)}</Stack> : null}\n\n {!routable && !hasSlots && !hasFields ? (\n <Text size=\"xs\" tone=\"muted\">\n {translate('cms.details.empty')}\n </Text>\n ) : null}\n </Stack>\n )\n}\n\n/**\n * The document-level URL row (0.0.39 — EC-315 \"hybrid split\"). The full composed path,\n * classified segment by segment: an ancestor-DOCUMENT segment is a link to that\n * document's editor (autosave keeps the current edit safe on navigate); a static\n * route segment (mount prefix, or `blog` in `/blog/:slug`) is plain text; this\n * document's own slug segment is highlighted as the current page. Draft-derived (it\n * updates as you type) — the header's published-gated \"View live\" stays the sole\n * open-live action, so the draft/live distinction is preserved here too.\n */\nfunction DocumentUrlRow({\n collection,\n doc,\n siblings,\n context,\n}: {\n collection: CollectionDef\n doc: CmsDocument\n siblings: readonly CmsDocument[]\n context: DetailsUrlContext\n}) {\n const segments = resolveUrlSegments(\n { routes: [...context.routes], localeConfig: context.localeConfig },\n collection,\n doc,\n { siblings, locale: context.locale },\n )\n return (\n <Stack gap=\"1\" data-testid=\"details-url\">\n <Text size=\"xs\" weight=\"semibold\">\n {translate('cms.details.url')}\n </Text>\n <Row gap=\"none\" align=\"center\" wrap className=\"font-mono text-xs text-muted-foreground\">\n {segments.length === 0 ? (\n <span aria-hidden>/</span>\n ) : (\n segments.map((seg, i) => (\n <span key={`${seg.text}-${i}`} className=\"inline-flex items-center\">\n <span aria-hidden className=\"text-muted-foreground/50\">\n /\n </span>\n {seg.kind === 'ancestor' && seg.documentId ? (\n <Link\n to={STUDIO_PROJECT_ROUTE}\n params={{ projectId: context.projectId, _splat: studioPath.cmsDocument(collection.id, seg.documentId) }}\n title={translate('cms.relation.openTitle', { title: seg.text })}\n data-testid=\"details-url-ancestor\"\n className=\"rounded px-0.5 text-primary hover:bg-primary/10 hover:underline\"\n >\n {seg.text}\n </Link>\n ) : (\n <span\n className={cn('px-0.5', seg.kind === 'current' && 'font-medium text-foreground')}\n title={seg.kind === 'current' ? translate('cms.details.urlCurrent') : undefined}\n >\n {seg.text}\n </span>\n )}\n </span>\n ))\n )}\n </Row>\n </Stack>\n )\n}\n"]}
1
+ {"version":3,"file":"details-pane.js","sourceRoot":"","sources":["../../../src/components/cms/details-pane.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAA;AAG1D,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AACrE,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AAEtD;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,WAAW,CAAC,EAC1B,UAAU,EACV,GAAG,EACH,QAAQ,EACR,QAAQ,EACR,MAAM,EACN,WAAW,EACX,aAAa,GAYd;IACC,MAAM,KAAK,GAAG,eAAe,CAAC,UAAU,CAAC,CAAA;IACzC,MAAM,QAAQ,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAA;IACjC,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,GAAG,CAAC,CAAA;IAEnC,OAAO,CACL,MAAC,KAAK,IAAC,GAAG,EAAC,GAAG,EAAC,SAAS,EAAC,KAAK,aAC3B,QAAQ,CAAC,CAAC,CAAC,CACV,MAAC,KAAK,IAAC,GAAG,EAAC,GAAG,aACZ,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,MAAM,EAAC,UAAU,YAC9B,SAAS,CAAC,kBAAkB,CAAC,GACzB,EACP,MAAC,MAAM,IACL,KAAK,EAAE,GAAG,CAAC,KAAK,IAAI,EAAE,EACtB,QAAQ,EAAE,QAAQ,gBACN,SAAS,CAAC,kBAAkB,CAAC,iBAC7B,cAAc,EAC1B,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,aAAa,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,aAElF,iBAAQ,KAAK,EAAC,EAAE,YAAE,SAAS,CAAC,oBAAoB,CAAC,GAAU,EAC1D,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE;gCAClB,MAAM,YAAY,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,GAAG,CAAC,EAAE,IAAI,CAAC,CAAC,KAAK,KAAK,IAAI,CAAC,CAAA;gCAC9E,MAAM,OAAO,GAAG,YAAY,IAAI,GAAG,CAAC,KAAK,KAAK,IAAI,CAAA;gCAClD,OAAO,CACL,kBAAmB,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,aAC9C,IAAI,EACJ,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,uBAAuB,CAAC,CAAC,CAAC,CAAC,EAAE,KAFvC,IAAI,CAGR,CACV,CAAA;4BACH,CAAC,CAAC,IACK,EACT,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,YACzB,SAAS,CAAC,sBAAsB,CAAC,GAC7B,IACD,CACT,CAAC,CAAC,CAAC,IAAI,EAEP,QAAQ,IAAI,SAAS,CAAC,CAAC,CAAC,KAAC,SAAS,KAAG,CAAC,CAAC,CAAC,IAAI,EAE5C,SAAS,CAAC,CAAC,CAAC,KAAC,KAAK,IAAC,GAAG,EAAC,GAAG,YAAE,MAAM,CAAC,GAAG,CAAC,WAAW,CAAC,GAAS,CAAC,CAAC,CAAC,IAAI,EAEnE,CAAC,QAAQ,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CACzB,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,YACzB,SAAS,CAAC,mBAAmB,CAAC,GAC1B,CACR,CAAC,CAAC,CAAC,IAAI,IACF,CACT,CAAA;AACH,CAAC","sourcesContent":["import { declaredAliases } from '@elytracms/core/cms-core'\nimport type { CmsDocument, CollectionDef, FieldDef } from '@elytracms/core/cms-core'\nimport type { ReactNode } from 'react'\nimport { Select, Separator, Stack, Text } from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\n\n/**\n * EC-284 + EC-285 — document-level metadata pane (the right inspector). Up to two\n * optional regions:\n *\n * 1. **Slot** (EC-284): a stable, named handle the site uses to find this document\n * (the main menu, the footer menu…) instead of which exact entry it is — shown\n * only when the collection declares alias slots.\n * 2. **Sidebar fields** (EC-285): the document's `sidebar`-group fields (slug,\n * author, publish date…) — document-level properties an editor sets *about* the\n * document rather than as body content. Rendered via the detail editor's own\n * field-row renderer, so they behave identically to a content field.\n *\n * Both are identity/metadata, not content, so they live here — off the content tabs.\n * The composed URL is NOT here: 0.0.42 (round 3) moved it up to the detail-editor\n * HEADER, under the record title, where a URL reads as a first-class property (a lone\n * URL row on an otherwise-empty pane looked odd). The empty state shows only when\n * there is nothing at all to display.\n */\nexport function DetailsPane({\n collection,\n doc,\n siblings,\n readOnly,\n fields,\n renderField,\n onAliasChange,\n}: {\n collection: CollectionDef\n doc: CmsDocument\n /** The other documents in this collection (to show which slots are taken). */\n siblings: readonly CmsDocument[]\n readOnly?: boolean\n /** The collection's `sidebar`-group fields, in order (EC-285). */\n fields: readonly FieldDef[]\n /** Render one field row — shared with the content groups so behaviour matches. */\n renderField: (field: FieldDef) => ReactNode\n onAliasChange: (alias: string | undefined) => void\n}) {\n const slots = declaredAliases(collection)\n const hasSlots = slots.length > 0\n const hasFields = fields.length > 0\n\n return (\n <Stack gap=\"4\" className=\"p-3\">\n {hasSlots ? (\n <Stack gap=\"1\">\n <Text size=\"xs\" weight=\"semibold\">\n {translate('cms.details.slot')}\n </Text>\n <Select\n value={doc.alias ?? ''}\n disabled={readOnly}\n aria-label={translate('cms.details.slot')}\n data-testid=\"alias-select\"\n onChange={(e) => onAliasChange(e.target.value === '' ? undefined : e.target.value)}\n >\n <option value=\"\">{translate('cms.details.noSlot')}</option>\n {slots.map((slot) => {\n const takenByOther = siblings.some((s) => s.id !== doc.id && s.alias === slot)\n const blocked = takenByOther && doc.alias !== slot\n return (\n <option key={slot} value={slot} disabled={blocked}>\n {slot}\n {blocked ? translate('cms.details.slotInUse') : ''}\n </option>\n )\n })}\n </Select>\n <Text size=\"xs\" tone=\"muted\">\n {translate('cms.details.slotHint')}\n </Text>\n </Stack>\n ) : null}\n\n {hasSlots && hasFields ? <Separator /> : null}\n\n {hasFields ? <Stack gap=\"4\">{fields.map(renderField)}</Stack> : null}\n\n {!hasSlots && !hasFields ? (\n <Text size=\"xs\" tone=\"muted\">\n {translate('cms.details.empty')}\n </Text>\n ) : null}\n </Stack>\n )\n}\n"]}
@@ -0,0 +1,39 @@
1
+ import type { CmsDocument, CollectionDef, LocaleConfig, RouteRecord, UrlSegment } from '@elytracms/core/cms-core';
2
+ /**
3
+ * One classified URL segment. An `ancestor` segment (with a `projectId` to build the
4
+ * link) navigates to that document's editor; a `static`/`current` segment — or an
5
+ * ancestor with no `projectId` (fixtures/no persistence) — is plain text, the current
6
+ * one highlighted. The single source of segment semantics for every surface.
7
+ */
8
+ export declare function DocumentUrlSegment({ segment, projectId, collectionId, }: {
9
+ segment: UrlSegment;
10
+ projectId: string | undefined;
11
+ collectionId: string;
12
+ }): import("react").JSX.Element;
13
+ /**
14
+ * The detail-editor header's composed-URL row (0.0.42): a compact single line under
15
+ * the record title. The ancestor/static prefix ellipsizes gracefully on overflow (a
16
+ * real CSS ellipsis; the full path is the row tooltip) so the row NEVER wraps or
17
+ * pushes the header actions around; the leaf segment + open-live icon never shrink.
18
+ *
19
+ * `live` carries the published live-page affordance (header only), mirroring the
20
+ * retired "View live" button's gating exactly: `href` present → the leaf + external
21
+ * icon open the live page in a new tab; absent → the leaf is plain highlighted text
22
+ * and the icon is a muted, non-interactive hint carrying `title` (publish / set
23
+ * frontendUrl / loading). Omit `live` entirely on non-live surfaces.
24
+ */
25
+ export declare function DocumentUrlPath({ projectId, collection, doc, siblings, routes, localeConfig, locale, live, className, }: {
26
+ projectId: string;
27
+ collection: CollectionDef;
28
+ doc: CmsDocument;
29
+ siblings: readonly CmsDocument[];
30
+ routes: readonly RouteRecord[];
31
+ localeConfig: LocaleConfig;
32
+ locale: string;
33
+ /** Published live-page affordance (header). Omitted → the leaf is plain text. */
34
+ live?: {
35
+ href?: string;
36
+ title: string;
37
+ };
38
+ className?: string;
39
+ }): import("react").JSX.Element;
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ExternalLink } from 'lucide-react';
3
+ import { resolveUrlSegments } from '@elytracms/core/cms-core';
4
+ import { Link, STUDIO_PROJECT_ROUTE, studioPath } from '@elytracms/studio/router';
5
+ import { Row } from '@elytracms/studio/ui';
6
+ import { cn } from '@elytracms/studio/lib/utils';
7
+ import { translate } from '@elytracms/studio/lib/i18n';
8
+ /**
9
+ * 0.0.42 — the shared composed-URL segment surface (EC-315 "hybrid split", round 3).
10
+ *
11
+ * A document's URL is a first-class property, classified segment by segment:
12
+ *
13
+ * - `ancestor` — a hierarchy-parent DOCUMENT's slug: a link to that document's
14
+ * editor (autosave keeps the current edit safe on navigate);
15
+ * - `static` — a mount prefix or route-pattern literal (`blog` in `/blog/:slug`);
16
+ * - `current` — this document's own slug segment (highlighted).
17
+ *
18
+ * ONE renderer feeds every surface — the detail-editor header path row
19
+ * ({@link DocumentUrlPath}) and the slug field's compact parent prefix (which reuses
20
+ * {@link DocumentUrlSegment}) — so a segment reads and behaves identically wherever
21
+ * it shows. Draft-derived (updates as you type); the published-gated live link on the
22
+ * header row is the sole open-live action, preserving the draft/live distinction.
23
+ */
24
+ /** The `/` separator between segments (dimmed so the segments read as the content). */
25
+ function Separator() {
26
+ return (_jsx("span", { "aria-hidden": true, className: "text-muted-foreground/50", children: "/" }));
27
+ }
28
+ /**
29
+ * One classified URL segment. An `ancestor` segment (with a `projectId` to build the
30
+ * link) navigates to that document's editor; a `static`/`current` segment — or an
31
+ * ancestor with no `projectId` (fixtures/no persistence) — is plain text, the current
32
+ * one highlighted. The single source of segment semantics for every surface.
33
+ */
34
+ export function DocumentUrlSegment({ segment, projectId, collectionId, }) {
35
+ if (segment.kind === 'ancestor' && segment.documentId !== undefined && projectId !== undefined) {
36
+ return (_jsx(Link, { to: STUDIO_PROJECT_ROUTE, params: { projectId, _splat: studioPath.cmsDocument(collectionId, segment.documentId) }, title: translate('cms.relation.openTitle', { title: segment.text }), "data-testid": "doc-url-ancestor", className: "rounded px-0.5 text-primary hover:bg-primary/10 hover:underline", children: segment.text }));
37
+ }
38
+ return (_jsx("span", { className: cn('px-0.5', segment.kind === 'current' && 'font-medium text-foreground'), title: segment.kind === 'current' ? translate('cms.details.urlCurrent') : undefined, children: segment.text }));
39
+ }
40
+ /**
41
+ * The detail-editor header's composed-URL row (0.0.42): a compact single line under
42
+ * the record title. The ancestor/static prefix ellipsizes gracefully on overflow (a
43
+ * real CSS ellipsis; the full path is the row tooltip) so the row NEVER wraps or
44
+ * pushes the header actions around; the leaf segment + open-live icon never shrink.
45
+ *
46
+ * `live` carries the published live-page affordance (header only), mirroring the
47
+ * retired "View live" button's gating exactly: `href` present → the leaf + external
48
+ * icon open the live page in a new tab; absent → the leaf is plain highlighted text
49
+ * and the icon is a muted, non-interactive hint carrying `title` (publish / set
50
+ * frontendUrl / loading). Omit `live` entirely on non-live surfaces.
51
+ */
52
+ export function DocumentUrlPath({ projectId, collection, doc, siblings, routes, localeConfig, locale, live, className, }) {
53
+ const segments = resolveUrlSegments({ routes: [...routes], localeConfig }, collection, doc, { siblings, locale });
54
+ const fullPath = segments.length === 0 ? '/' : `/${segments.map((s) => s.text).join('/')}`;
55
+ // The leaf is this page; everything before it is the (truncatable) context prefix.
56
+ const prefix = segments.slice(0, -1);
57
+ const leaf = segments[segments.length - 1];
58
+ return (_jsxs(Row, { gap: "none", align: "center", className: cn('min-w-0 flex-nowrap font-mono text-xs text-muted-foreground', className), title: fullPath, "data-testid": "header-url", children: [_jsx("span", { className: "block min-w-0 shrink overflow-hidden text-ellipsis whitespace-nowrap", children: prefix.map((seg, i) => (_jsxs("span", { children: [_jsx(Separator, {}), _jsx(DocumentUrlSegment, { segment: seg, projectId: projectId, collectionId: collection.id })] }, `${seg.text}-${i}`))) }), _jsxs("span", { className: "inline-flex shrink-0 items-center", children: [_jsx(Separator, {}), live?.href ? (_jsxs("a", { href: live.href, target: "_blank", rel: "noreferrer", title: live.title, "data-testid": "header-live-link", className: "inline-flex items-center gap-0.5 rounded px-0.5 font-medium text-foreground hover:bg-primary/10 hover:text-primary hover:underline", children: [leaf?.text ?? '', _jsx(ExternalLink, { className: "size-3", "aria-hidden": true })] })) : (_jsxs("span", { className: "inline-flex items-center gap-0.5 px-0.5", children: [leaf !== undefined ? (_jsx("span", { className: "font-medium text-foreground", title: translate('cms.details.urlCurrent'), children: leaf.text })) : null, live ? (_jsx("span", { title: live.title, "aria-label": live.title, "data-testid": "header-live-link-disabled", className: "inline-flex text-muted-foreground/40", children: _jsx(ExternalLink, { className: "size-3", "aria-hidden": true }) })) : null] }))] })] }));
59
+ }
60
+ //# sourceMappingURL=document-url-path.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"document-url-path.js","sourceRoot":"","sources":["../../../src/components/cms/document-url-path.tsx"],"names":[],"mappings":"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","sourcesContent":["import { ExternalLink } from 'lucide-react'\nimport { resolveUrlSegments } from '@elytracms/core/cms-core'\nimport type {\n CmsDocument,\n CollectionDef,\n LocaleConfig,\n RouteRecord,\n UrlSegment,\n} from '@elytracms/core/cms-core'\nimport { Link, STUDIO_PROJECT_ROUTE, studioPath } from '@elytracms/studio/router'\nimport { Row } from '@elytracms/studio/ui'\nimport { cn } from '@elytracms/studio/lib/utils'\nimport { translate } from '@elytracms/studio/lib/i18n'\n\n/**\n * 0.0.42 — the shared composed-URL segment surface (EC-315 \"hybrid split\", round 3).\n *\n * A document's URL is a first-class property, classified segment by segment:\n *\n * - `ancestor` — a hierarchy-parent DOCUMENT's slug: a link to that document's\n * editor (autosave keeps the current edit safe on navigate);\n * - `static` — a mount prefix or route-pattern literal (`blog` in `/blog/:slug`);\n * - `current` — this document's own slug segment (highlighted).\n *\n * ONE renderer feeds every surface — the detail-editor header path row\n * ({@link DocumentUrlPath}) and the slug field's compact parent prefix (which reuses\n * {@link DocumentUrlSegment}) — so a segment reads and behaves identically wherever\n * it shows. Draft-derived (updates as you type); the published-gated live link on the\n * header row is the sole open-live action, preserving the draft/live distinction.\n */\n\n/** The `/` separator between segments (dimmed so the segments read as the content). */\nfunction Separator() {\n return (\n <span aria-hidden className=\"text-muted-foreground/50\">\n /\n </span>\n )\n}\n\n/**\n * One classified URL segment. An `ancestor` segment (with a `projectId` to build the\n * link) navigates to that document's editor; a `static`/`current` segment — or an\n * ancestor with no `projectId` (fixtures/no persistence) — is plain text, the current\n * one highlighted. The single source of segment semantics for every surface.\n */\nexport function DocumentUrlSegment({\n segment,\n projectId,\n collectionId,\n}: {\n segment: UrlSegment\n projectId: string | undefined\n collectionId: string\n}) {\n if (segment.kind === 'ancestor' && segment.documentId !== undefined && projectId !== undefined) {\n return (\n <Link\n to={STUDIO_PROJECT_ROUTE}\n params={{ projectId, _splat: studioPath.cmsDocument(collectionId, segment.documentId) }}\n title={translate('cms.relation.openTitle', { title: segment.text })}\n data-testid=\"doc-url-ancestor\"\n className=\"rounded px-0.5 text-primary hover:bg-primary/10 hover:underline\"\n >\n {segment.text}\n </Link>\n )\n }\n return (\n <span\n className={cn('px-0.5', segment.kind === 'current' && 'font-medium text-foreground')}\n title={segment.kind === 'current' ? translate('cms.details.urlCurrent') : undefined}\n >\n {segment.text}\n </span>\n )\n}\n\n/**\n * The detail-editor header's composed-URL row (0.0.42): a compact single line under\n * the record title. The ancestor/static prefix ellipsizes gracefully on overflow (a\n * real CSS ellipsis; the full path is the row tooltip) so the row NEVER wraps or\n * pushes the header actions around; the leaf segment + open-live icon never shrink.\n *\n * `live` carries the published live-page affordance (header only), mirroring the\n * retired \"View live\" button's gating exactly: `href` present → the leaf + external\n * icon open the live page in a new tab; absent → the leaf is plain highlighted text\n * and the icon is a muted, non-interactive hint carrying `title` (publish / set\n * frontendUrl / loading). Omit `live` entirely on non-live surfaces.\n */\nexport function DocumentUrlPath({\n projectId,\n collection,\n doc,\n siblings,\n routes,\n localeConfig,\n locale,\n live,\n className,\n}: {\n projectId: string\n collection: CollectionDef\n doc: CmsDocument\n siblings: readonly CmsDocument[]\n routes: readonly RouteRecord[]\n localeConfig: LocaleConfig\n locale: string\n /** Published live-page affordance (header). Omitted → the leaf is plain text. */\n live?: { href?: string; title: string }\n className?: string\n}) {\n const segments = resolveUrlSegments(\n { routes: [...routes], localeConfig },\n collection,\n doc,\n { siblings, locale },\n )\n const fullPath = segments.length === 0 ? '/' : `/${segments.map((s) => s.text).join('/')}`\n // The leaf is this page; everything before it is the (truncatable) context prefix.\n const prefix = segments.slice(0, -1)\n const leaf = segments[segments.length - 1]\n\n return (\n <Row\n gap=\"none\"\n align=\"center\"\n className={cn('min-w-0 flex-nowrap font-mono text-xs text-muted-foreground', className)}\n title={fullPath}\n data-testid=\"header-url\"\n >\n {/* Prefix — locale/static/ancestor segments before the leaf. A block with inline\n children so `text-overflow: ellipsis` clips the ANCESTORS (not the leaf) when\n the path is too long; `min-w-0` lets it shrink inside the flex row. */}\n <span className=\"block min-w-0 shrink overflow-hidden text-ellipsis whitespace-nowrap\">\n {prefix.map((seg, i) => (\n <span key={`${seg.text}-${i}`}>\n <Separator />\n <DocumentUrlSegment segment={seg} projectId={projectId} collectionId={collection.id} />\n </span>\n ))}\n </span>\n {/* Leaf + live affordance — never shrinks, so the current page and the open-live\n icon stay visible even when the ancestors ellipsize. */}\n <span className=\"inline-flex shrink-0 items-center\">\n <Separator />\n {live?.href ? (\n <a\n href={live.href}\n target=\"_blank\"\n rel=\"noreferrer\"\n title={live.title}\n data-testid=\"header-live-link\"\n className=\"inline-flex items-center gap-0.5 rounded px-0.5 font-medium text-foreground hover:bg-primary/10 hover:text-primary hover:underline\"\n >\n {leaf?.text ?? ''}\n <ExternalLink className=\"size-3\" aria-hidden />\n </a>\n ) : (\n <span className=\"inline-flex items-center gap-0.5 px-0.5\">\n {leaf !== undefined ? (\n <span className=\"font-medium text-foreground\" title={translate('cms.details.urlCurrent')}>\n {leaf.text}\n </span>\n ) : null}\n {live ? (\n <span\n title={live.title}\n aria-label={live.title}\n data-testid=\"header-live-link-disabled\"\n className=\"inline-flex text-muted-foreground/40\"\n >\n <ExternalLink className=\"size-3\" aria-hidden />\n </span>\n ) : null}\n </span>\n )}\n </span>\n </Row>\n )\n}\n"]}
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { AlertTriangle, ChevronDown, ChevronUp, GripVertical, ImagePlus, Link2, MoreHorizontal, Pencil, Plus, RotateCcw, RotateCw, SlidersHorizontal, Trash2, Upload, X } from 'lucide-react';
3
3
  import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { Link, STUDIO_PROJECT_ROUTE, studioPath } from '@elytracms/studio/router';
5
- import { BUILTIN_FIELD_CONTROLS, applySlugify, assetAcceptAttr, assetMatchesAccept, assetNode, assetSubfieldsOf, describeAccept, descendantIds, findSlugCollision, isFieldHidden, parentRelationField, readAssetId, resolveSlugPath, slugSourceValue, } from '@elytracms/core/cms-core';
5
+ import { BUILTIN_FIELD_CONTROLS, applySlugify, assetAcceptAttr, assetMatchesAccept, assetNode, assetSubfieldsOf, describeAccept, descendantIds, findSlugCollision, isFieldHidden, parentRelationField, readAssetId, resolveSlugPath, resolveUrlSegments, slugSourceValue, } from '@elytracms/core/cms-core';
6
6
  import { resolveDeploymentRoutes } from '@elytracms/studio/lib/workspace';
7
7
  import { Button, Card, Code, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Field, Input, Row, RowItem, Select, Spacer, Stack, Text, } from '@elytracms/studio/ui';
8
8
  import { cn } from '@elytracms/studio/lib/utils';
@@ -21,6 +21,7 @@ import { toRefArray } from './cell';
21
21
  import { RelationPicker } from './relation-picker';
22
22
  import { AssetPicker } from './asset-picker';
23
23
  import { RichTextEditor } from './rich-text';
24
+ import { DocumentUrlSegment } from './document-url-path';
24
25
  import { FieldRendererBoundary, FieldRendererIssue, useFieldRenderers, } from './field-renderer';
25
26
  /**
26
27
  * The document-editor field control. Resolves a project's custom renderer by the
@@ -182,6 +183,14 @@ function SlugField({ field, value, onChange, readOnly, id, }) {
182
183
  const parts = draft && routable
183
184
  ? resolveSlugPath({ routes, localeConfig }, source.schema, draft, { siblings, locale })
184
185
  : undefined;
186
+ // The classified context segments (0.0.42, round 3) — the same `resolveUrlSegments`
187
+ // the header URL row uses, so the slug prefix and the header path attribute each
188
+ // segment identically. Everything before this document's own `current` slug is the
189
+ // context prefix; its LAST segment is the immediate parent shown in the compact pill.
190
+ const contextSegments = draft && routable
191
+ ? resolveUrlSegments({ routes, localeConfig }, source.schema, draft, { siblings, locale }).filter((s) => s.kind !== 'current')
192
+ : [];
193
+ const lastContext = contextSegments[contextSegments.length - 1];
185
194
  // Default (sync) uniqueness — the first colliding sibling's title, or null.
186
195
  const defaultCollisionTitle = useMemo(() => {
187
196
  if (slugConfig?.unique || !draft)
@@ -247,17 +256,14 @@ function SlugField({ field, value, onChange, readOnly, id, }) {
247
256
  }
248
257
  const input = (_jsx(Input, { id: id, disabled: readOnly, value: current, placeholder: derived || undefined, onChange: (e) => handleInput(e.target.value), className: cn(parts ? 'h-7 min-w-[10ch] flex-1 bg-background font-mono text-xs' : 'flex-1') }));
249
258
  const regenerateButton = !readOnly ? (_jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", onClick: regenerate, "aria-label": translate('cms.field.slug.regenerate'), title: translate('cms.field.slug.regenerate'), children: _jsx(RotateCw, {}) })) : null;
250
- // The compact, ellipsized read-only prefix (0.0.39 "hybrid split"): only the LAST
251
- // context segment (`…/produkte/`), so a deep path can't overflow the narrow Details
252
- // sidebar. The full composed path is the tooltip AND the document-level URL row in
253
- // the Details pane (which owns the composed URL — a first-class document property).
254
- const prefixSegments = parts
255
- ? [...(parts.localePrefix ? [parts.localePrefix] : []), ...parts.parentSegments]
256
- : [];
257
- const lastSegment = prefixSegments[prefixSegments.length - 1];
258
- const compactPrefix = lastSegment ? `${prefixSegments.length > 1 ? '…/' : '/'}${lastSegment}/` : '/';
259
- const prefixTitle = parts?.path ?? (prefixSegments.length > 0 ? `/${prefixSegments.join('/')}/` : '/');
260
- return (_jsxs(Stack, { gap: "2", children: [parts ? (_jsxs(Row, { gap: "1", align: "center", "data-testid": "slug-composed-path", children: [_jsx("span", { className: "shrink-0 rounded bg-muted/40 px-1.5 py-1 font-mono text-xs text-muted-foreground ring-1 ring-inset ring-border", title: prefixTitle, "data-testid": "slug-prefix", children: compactPrefix }), input, regenerateButton] })) : (_jsxs(Row, { gap: "1", align: "center", children: [input, regenerateButton] })), collisionMessage ? (_jsxs(Row, { gap: "1", align: "center", "data-testid": "slug-collision", children: [_jsx(AlertTriangle, { className: "size-3.5 shrink-0 text-warning", "aria-hidden": true }), _jsx(Text, { size: "xs", tone: "muted", children: collisionMessage })] })) : null] }));
259
+ // The compact, ellipsized read-only prefix (0.0.39 "hybrid split", round-3 clickable):
260
+ // only the LAST context segment (`…/produkte/`) so a deep path can't overflow the
261
+ // narrow Details sidebar — but that shown segment is now CLICKABLE with the same
262
+ // navigation semantics as the header URL row (an ancestor DOCUMENT links to its
263
+ // editor; a static route literal stays plain). The full composed path is the tooltip.
264
+ const prefixTitle = parts?.path ??
265
+ (contextSegments.length > 0 ? `/${contextSegments.map((s) => s.text).join('/')}/` : '/');
266
+ return (_jsxs(Stack, { gap: "2", children: [parts ? (_jsxs(Row, { gap: "1", align: "center", "data-testid": "slug-composed-path", children: [_jsx("span", { className: "inline-flex min-w-0 shrink-0 items-center rounded bg-muted/40 px-1.5 py-1 font-mono text-xs text-muted-foreground ring-1 ring-inset ring-border", title: prefixTitle, "data-testid": "slug-prefix", children: contextSegments.length === 0 ? (_jsx("span", { "aria-hidden": true, className: "text-muted-foreground/50", children: "/" })) : (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, className: "text-muted-foreground/50", children: contextSegments.length > 1 ? '…/' : '/' }), lastContext ? (_jsx(DocumentUrlSegment, { segment: lastContext, projectId: editor?.projectId, collectionId: source.collectionId })) : null, _jsx("span", { "aria-hidden": true, className: "text-muted-foreground/50", children: "/" })] })) }), input, regenerateButton] })) : (_jsxs(Row, { gap: "1", align: "center", children: [input, regenerateButton] })), collisionMessage ? (_jsxs(Row, { gap: "1", align: "center", "data-testid": "slug-collision", children: [_jsx(AlertTriangle, { className: "size-3.5 shrink-0 text-warning", "aria-hidden": true }), _jsx(Text, { size: "xs", tone: "muted", children: collisionMessage })] })) : null] }));
261
267
  }
262
268
  function asRecord(value) {
263
269
  return value !== null && typeof value === 'object' && !Array.isArray(value)