@gogitcms/design-system 0.16.0-next.1 → 0.16.0-next.11

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 (31) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/ApplyChangesModal.test.tsx +48 -0
  3. package/src/__tests__/BranchDeletedModal.test.tsx +42 -0
  4. package/src/__tests__/ContentBrowser.fieldfocus.test.tsx +109 -0
  5. package/src/__tests__/ContentBrowser.forms.test.tsx +47 -1
  6. package/src/__tests__/ContentBrowser.history.test.tsx +385 -0
  7. package/src/__tests__/ContentBrowser.historycollab.test.tsx +433 -0
  8. package/src/__tests__/ContentBrowser.references.test.tsx +179 -0
  9. package/src/__tests__/ContentBrowser.slotmedia.test.tsx +150 -0
  10. package/src/__tests__/MediaField.test.tsx +18 -0
  11. package/src/__tests__/ProtectedBranchModal.test.tsx +68 -0
  12. package/src/components/ApplyChangesModal.tsx +70 -21
  13. package/src/components/BranchDeletedModal.tsx +111 -0
  14. package/src/components/ChangeDetail.tsx +94 -11
  15. package/src/components/CollabField.tsx +71 -0
  16. package/src/components/ContentBrowser.tsx +494 -77
  17. package/src/components/DocumentDetails.tsx +0 -0
  18. package/src/components/DocumentHistory.tsx +408 -0
  19. package/src/components/MediaField.tsx +62 -0
  20. package/src/components/MediaPreview.tsx +14 -1
  21. package/src/components/MediaPreview.web.tsx +18 -3
  22. package/src/components/Notifications.tsx +146 -28
  23. package/src/components/ProtectedBranchModal.tsx +136 -0
  24. package/src/components/ReferenceField.tsx +0 -0
  25. package/src/components/primitives.tsx +46 -1
  26. package/src/fieldComponents.ts +2 -2
  27. package/src/history.ts +82 -0
  28. package/src/icons.ts +1 -0
  29. package/src/index.ts +28 -3
  30. package/src/media.ts +15 -0
  31. package/src/references.ts +97 -0
@@ -6,7 +6,7 @@ import { Text } from "./Text";
6
6
  import { Icon } from "./Icon";
7
7
  import { Button, IconButton } from "./Button";
8
8
  import { Input } from "./Input";
9
- import { Avatar, Badge, DiffStat, Divider, SectionLabel } from "./primitives";
9
+ import { Avatar, Badge, CheckBox, DiffStat, Divider, SectionLabel } from "./primitives";
10
10
  import { NavRow } from "./NavRow";
11
11
  import { AppShell, MobileScreen, Pane, TopBar, ThemeToggle } from "./layout";
12
12
  import { Segment, type SegmentItem } from "./Segment";
@@ -15,6 +15,7 @@ import { BranchMenu, type BranchRef } from "./BranchMenu";
15
15
  import { ProjectMenu, type ProjectRef } from "./ProjectMenu";
16
16
  import { ChangeDetail, type DocumentChange, type FieldConflict, type ConflictChoice } from "./ChangeDetail";
17
17
  import { ApplyChangesModal, type MergeProgress } from "./ApplyChangesModal";
18
+ import { ProtectedBranchModal } from "./ProtectedBranchModal";
18
19
  import { NotificationBell, type NotificationItem } from "./Notifications";
19
20
  import {
20
21
  CollabInput,
@@ -22,6 +23,7 @@ import {
22
23
  PresenceField,
23
24
  PresenceAvatars,
24
25
  useCollabRegister,
26
+ writeCollabValue,
25
27
  useCollabMirror,
26
28
  useCollabSynced,
27
29
  useCollabValue,
@@ -29,15 +31,21 @@ import {
29
31
  useCollabPeersUnder,
30
32
  } from "./CollabField";
31
33
  import { clamp, reorder, slotAtX } from "./reorder";
32
- import { MediaField, MediaProvider, DocumentPathProvider } from "./MediaField";
34
+ import { MediaField, MediaProvider, DocumentPathProvider, useFieldMedia } from "./MediaField";
35
+ import { ReferenceField, ReferenceProvider } from "./ReferenceField";
36
+ import { DocumentDetails } from "./DocumentDetails";
37
+ import type { ReferenceApi, ReferenceDef } from "../references";
33
38
  import { MediaBrowser } from "./MediaBrowser";
34
39
  import { FormsBrowser } from "./FormsBrowser";
35
40
  import { FORMS_NAV_KEY, formsNavKey, isFormsNavKey, parseFormsNavKey, type FormsApi } from "../forms";
41
+ import { type HistoryApi } from "../history";
42
+ import { DocumentHistory } from "./DocumentHistory";
36
43
  import {
37
44
  MEDIA_NAV_KEY,
38
45
  mediaNavKey,
39
46
  isMediaNavKey,
40
47
  parseMediaNavKey,
48
+ type FieldMediaApi,
41
49
  type MediaApi,
42
50
  type MediaSetInfo,
43
51
  type StoreAs,
@@ -71,6 +79,12 @@ export type EntryField = {
71
79
  // field these describe its *items*, making each one a media picker.
72
80
  media?: string;
73
81
  storeAs?: string;
82
+ // For a document picker (`component: "reference"`, or an array whose items
83
+ // are references): what the field points at and stores. An object-shaped
84
+ // reference's `fields` are the derived children — the key child and the
85
+ // embedded copies — which the server recomputes on every save and the
86
+ // editor shows read-only (docs/references.md).
87
+ reference?: ReferenceDef;
74
88
  value: unknown;
75
89
  // Item type for an array field (string | number | boolean | object).
76
90
  of?: string;
@@ -111,6 +125,16 @@ export type FieldSlotArgs = {
111
125
  // CRDT and publish presence.
112
126
  collab?: CollabApi;
113
127
  path?: string;
128
+ // Bumped when the host replaces this field's value outright rather than the
129
+ // user editing it — restoring it from a past version. A controlled control
130
+ // needs nothing from this (it already re-renders with the new value), but the
131
+ // body editor does: in a live session it ignores `value` and mirrors the
132
+ // shared fragment, so "the value changed" is not a thing it can observe.
133
+ resetNonce?: number;
134
+ // Media access scoped to this field — open the picker, resolve the paths the
135
+ // document holds — so a host control can embed and display images from the
136
+ // media library. Absent when the host provides no media api.
137
+ media?: FieldMediaApi;
114
138
  };
115
139
  export type RenderField = (args: FieldSlotArgs) => React.ReactNode;
116
140
 
@@ -326,6 +350,15 @@ export type ContentBrowserProps = {
326
350
  // already separated from the frontmatter.
327
351
  onEntryDraft?: (draft: EntrySaveChange, entry: CmsEntry) => void;
328
352
 
353
+ // Which field of an open document has keyboard focus, as the author moves
354
+ // between controls: the field's dotted path (`title`, `seo.description`,
355
+ // `blocks.0.heading`), or null when nothing in that document is focused.
356
+ // Web only — it reads the DOM's focus events. This is what lets a preview
357
+ // ring the part of the page the author is editing (§4.4 of the preview
358
+ // design), and it is separate from live collaboration: a lone author gets
359
+ // it too.
360
+ onFieldFocus?: (entryId: string, path: string | null) => void;
361
+
329
362
  // Optional per-field renderer. When it returns a node for a field, that node
330
363
  // replaces the built-in control (used to inject the markdown body editor).
331
364
  renderField?: RenderField;
@@ -336,9 +369,22 @@ export type ContentBrowserProps = {
336
369
  // store gets.
337
370
  media?: MediaApi;
338
371
 
372
+ // References data seam, the same split (docs/references.md §6): the DS owns
373
+ // the document picker and the "Referenced by" list, the host owns fetching.
374
+ // Omitted → reference fields render read-only.
375
+ references?: ReferenceApi;
376
+
339
377
  // Forms data seam, the same split (docs/forms.md §10.2). Omitted → the Forms
340
378
  // surface never renders, which is what a config declaring no forms looks like.
341
379
  forms?: FormsApi;
380
+
381
+ // Document-history data seam, the same split again: the DS owns the version
382
+ // browser and its ephemeral state, the host owns fetching. Provided → an open
383
+ // document's header gains a history button, and pressing it gives that
384
+ // column's body over to the version browser. Omitted → no affordance at all,
385
+ // which is what a deployment with no provider (local mode, desktop) gets: a
386
+ // feature the user never sees beats one that errors when clicked.
387
+ history?: HistoryApi;
342
388
  // The selected submission's id, and the reporter for taps — bound to the URL
343
389
  // by the host exactly as document selection is.
344
390
  selectedSubmissionId?: string | null;
@@ -465,6 +511,9 @@ export type ContentBrowserProps = {
465
511
  canApplyChanges?: boolean;
466
512
  applyOpen?: boolean;
467
513
  applySummary?: string;
514
+ // Why applying opens a change request instead of merging (see
515
+ // ApplyChangesModal.reviewReason). Absent for a direct merge.
516
+ applyReviewReason?: string;
468
517
  applyProgress?: MergeProgress;
469
518
  applyConflictCount?: number;
470
519
  onConfirmApply?: () => void;
@@ -478,6 +527,19 @@ export type ContentBrowserProps = {
478
527
  changeRequestOpen?: boolean;
479
528
  changeRequestUrl?: string;
480
529
  creatingChangeRequest?: boolean;
530
+
531
+ // Protected-branch save prompt. The server refuses a save to a branch the
532
+ // provider protects; the host catches that, sets protectedBranch to the branch
533
+ // name, and this modal offers the one thing that resolves it — a branch to put
534
+ // the edit on. onCreateBranchAndSave receives the name; the host creates the
535
+ // branch and applies the pending edit to it in one server call.
536
+ protectedBranch?: string;
537
+ protectedBranchDocument?: string;
538
+ protectedBranchSuggestedName?: string;
539
+ creatingProtectedBranch?: boolean;
540
+ protectedBranchError?: string | null;
541
+ onCreateBranchAndSave?: (name: string) => void;
542
+ onCancelProtectedSave?: () => void;
481
543
  onCreateChangeRequest?: (explanation: string) => void;
482
544
  onViewChangeRequest?: () => void;
483
545
 
@@ -743,8 +805,45 @@ type FieldControlProps = {
743
805
  // When this number changes, the field programmatically focuses itself (used to
744
806
  // jump to a peer's field from its presence avatar).
745
807
  focusSignal?: number;
808
+ // Bumped when this field's value was replaced by the host rather than typed —
809
+ // restored from a past version. Only the host-supplied body editor needs it
810
+ // (see FieldSlotArgs.resetNonce); every built-in control is controlled and
811
+ // re-renders from `value` on its own.
812
+ resetNonce?: number;
813
+ // The field's full dotted path, when the caller knows better than
814
+ // "parent path + field name" — a list item, whose control is named after
815
+ // the list but sits at `list.<index>`.
816
+ fieldPath?: string;
746
817
  };
747
818
 
819
+ // The dotted path of the field being rendered, for the controls under it:
820
+ // a group's children append their names to it, a list's items append their
821
+ // index. It exists so every control can carry its own path as a DOM
822
+ // attribute (`data-cms-field`) without any of them threading a prop through
823
+ // — the focus reporting in EntryDetail reads that attribute off whichever
824
+ // element the keyboard lands in.
825
+ //
826
+ // Deliberately separate from the collab `path` prop: that one is threaded
827
+ // only where a CRDT binding exists (top-level fields and drilled-in groups),
828
+ // whereas this is present for every field including list items, which the
829
+ // collab layer treats as one register.
830
+ const FieldPathContext = React.createContext<string>("");
831
+
832
+ function FieldControl(props: FieldControlProps) {
833
+ const prefix = React.useContext(FieldPathContext);
834
+ const fullPath = props.fieldPath ?? (prefix ? `${prefix}.${props.field.name}` : props.field.name);
835
+ return (
836
+ <FieldPathContext.Provider value={fullPath}>
837
+ <View
838
+ // @ts-expect-error react-native-web maps dataSet -> data-* attributes
839
+ dataSet={{ cmsField: fullPath }}
840
+ >
841
+ <FieldControlInner {...props} />
842
+ </View>
843
+ </FieldPathContext.Provider>
844
+ );
845
+ }
846
+
748
847
  // The scalar type an array's `of` maps to when rendering item controls.
749
848
  function ofToType(of?: string): string {
750
849
  switch (of) {
@@ -755,8 +854,8 @@ function ofToType(of?: string): string {
755
854
  }
756
855
  }
757
856
 
758
- function FieldControl(props: FieldControlProps) {
759
- const { field, value, onChange, renderField, readOnly = false, error, hideLabel, onOpenGroup, collab, path, focusSignal } = props;
857
+ function FieldControlInner(props: FieldControlProps) {
858
+ const { field, value, onChange, renderField, readOnly = false, error, hideLabel, onOpenGroup, collab, path, focusSignal, resetNonce } = props;
760
859
  const t = useTheme();
761
860
  const label = hideLabel ? "" : field.label || field.name;
762
861
  // A text field participates in live collaboration when the document has a
@@ -767,9 +866,14 @@ function FieldControl(props: FieldControlProps) {
767
866
  ) : null;
768
867
  const labelNode = label ? <Text variant="label" color="tertiary">{label}</Text> : null;
769
868
 
869
+ // Media for a host-supplied control. The picker element is mounted only when
870
+ // the slot actually renders this field, so a form full of built-in controls
871
+ // carries no extra modals.
872
+ const fieldMedia = useFieldMedia({ set: field.media, storeAs: field.storeAs });
873
+
770
874
  // Host-supplied control (e.g. the markdown editor) takes precedence.
771
875
  if (renderField) {
772
- const custom = renderField({ field, value, onChange, readOnly, collab: collabText, path });
876
+ const custom = renderField({ field, value, onChange, readOnly, collab: collabText, path, resetNonce, media: fieldMedia.media });
773
877
  if (custom != null && custom !== false) {
774
878
  return (
775
879
  <View style={{ gap: t.space(2) }}>
@@ -780,6 +884,7 @@ function FieldControl(props: FieldControlProps) {
780
884
  custom
781
885
  )}
782
886
  {errorNode}
887
+ {fieldMedia.picker}
783
888
  </View>
784
889
  );
785
890
  }
@@ -787,6 +892,28 @@ function FieldControl(props: FieldControlProps) {
787
892
 
788
893
  const comp = resolveComponent(field);
789
894
 
895
+ // ---- references ----------------------------------------------------------
896
+ // Like media, one component for two field types: a string stores the key, an
897
+ // object stores the key plus the server-computed copies. Checked before the
898
+ // type branches for the same reason media is.
899
+ if (comp === "reference" && field.reference) {
900
+ return (
901
+ <View style={{ gap: t.space(2) }}>
902
+ {labelNode}
903
+ <ReferenceField
904
+ value={value}
905
+ onChange={onChange}
906
+ shape={field.type === "object" ? "object" : "string"}
907
+ def={field.reference}
908
+ embedFields={field.fields}
909
+ readOnly={readOnly}
910
+ error={!!error}
911
+ />
912
+ {errorNode}
913
+ </View>
914
+ );
915
+ }
916
+
790
917
  // ---- media ------------------------------------------------------------
791
918
  // Checked before the type-specific branches because `media` is the one
792
919
  // component valid for two different field types: a string field stores a path,
@@ -1590,13 +1717,19 @@ function ListControl({
1590
1717
  fields: field.fields,
1591
1718
  value: null,
1592
1719
  ...(field.media ? { component: "media", media: field.media, storeAs: field.storeAs } : {}),
1720
+ // An array that declares a reference makes each item a document picker, the
1721
+ // same way an array naming a media set makes each item a media picker.
1722
+ ...(field.reference ? { component: "reference", reference: field.reference } : {}),
1593
1723
  };
1594
1724
 
1725
+ // The list's own path, from the FieldControl wrapping this control; an item
1726
+ // sits at `<list>.<index>`.
1727
+ const listPath = React.useContext(FieldPathContext);
1595
1728
  return (
1596
1729
  <View style={{ gap: t.space(2) }}>
1597
1730
  {items.map((it, i) => (
1598
1731
  <ItemFrame key={i} index={i} count={items.length} readOnly={readOnly || !canRemove} onRemove={() => removeItem(i)} onMove={(d) => moveItem(i, d)}>
1599
- <FieldControl field={itemField} value={it} onChange={(v) => setItem(i, v)} renderField={renderField} readOnly={readOnly} hideLabel />
1732
+ <FieldControl field={itemField} value={it} onChange={(v) => setItem(i, v)} renderField={renderField} readOnly={readOnly} hideLabel fieldPath={listPath ? `${listPath}.${i}` : String(i)} />
1600
1733
  </ItemFrame>
1601
1734
  ))}
1602
1735
  {!readOnly && canAdd ? (
@@ -1636,15 +1769,25 @@ function MixedListControl({
1636
1769
  onChange(next);
1637
1770
  };
1638
1771
  const addItem = () => onChange([...items, { [key]: variants[0]?.name ?? "" }]);
1772
+ const listPath = React.useContext(FieldPathContext);
1639
1773
 
1640
1774
  return (
1641
1775
  <View style={{ gap: t.space(2) }}>
1642
1776
  {items.map((it, i) => {
1643
1777
  const variantName = String(it?.[key] ?? "");
1644
1778
  const variant = variants.find((v) => v.name === variantName);
1779
+ const itemPath = listPath ? `${listPath}.${i}` : String(i);
1645
1780
  return (
1646
1781
  <ItemFrame key={i} index={i} count={items.length} readOnly={readOnly || !canRemove} onRemove={() => removeItem(i)} onMove={(d) => moveItem(i, d)}>
1647
- <View style={{ gap: t.space(2) }}>
1782
+ {/* The item's fields compose under `<list>.<index>`; the item
1783
+ itself carries that path so focusing its variant picker
1784
+ reports the item rather than the whole list. */}
1785
+ <FieldPathContext.Provider value={itemPath}>
1786
+ <View
1787
+ style={{ gap: t.space(2) }}
1788
+ // @ts-expect-error react-native-web maps dataSet -> data-* attributes
1789
+ dataSet={{ cmsField: itemPath }}
1790
+ >
1648
1791
  <SelectControl
1649
1792
  value={variantName}
1650
1793
  options={variants.map((v) => v.name)}
@@ -1662,6 +1805,7 @@ function MixedListControl({
1662
1805
  />
1663
1806
  ) : null}
1664
1807
  </View>
1808
+ </FieldPathContext.Provider>
1665
1809
  </ItemFrame>
1666
1810
  );
1667
1811
  })}
@@ -1697,6 +1841,9 @@ function BodyField({
1697
1841
  format: "markdown",
1698
1842
  value,
1699
1843
  };
1844
+ // The schema-less body names no media set; the picker opens on the first
1845
+ // configured one and writes public paths.
1846
+ const fieldMedia = useFieldMedia({});
1700
1847
  // The slot's onChange is unknown-typed (plugin fields may emit any shape);
1701
1848
  // a body control only ever emits markdown strings.
1702
1849
  const custom = renderField?.({
@@ -1704,15 +1851,25 @@ function BodyField({
1704
1851
  value,
1705
1852
  onChange: (v) => onChange(typeof v === "string" ? v : String(v ?? "")),
1706
1853
  readOnly,
1854
+ media: fieldMedia.media,
1707
1855
  });
1708
1856
  return (
1709
- <View style={{ gap: t.space(2), flex: 1 }}>
1857
+ <View
1858
+ style={{ gap: t.space(2), flex: 1 }}
1859
+ // The schema-less body is the document's one field; a preview rings
1860
+ // it under the same name the schema form would give it.
1861
+ // @ts-expect-error react-native-web maps dataSet -> data-* attributes
1862
+ dataSet={{ cmsField: "body" }}
1863
+ >
1710
1864
  <View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
1711
1865
  <Text variant="label" color="tertiary">Body</Text>
1712
1866
  <Text variant="monoSm" color="tertiary">Markdown</Text>
1713
1867
  </View>
1714
1868
  {custom != null && custom !== false ? (
1715
- custom
1869
+ <>
1870
+ {custom}
1871
+ {fieldMedia.picker}
1872
+ </>
1716
1873
  ) : (
1717
1874
  <View
1718
1875
  style={{
@@ -2385,14 +2542,18 @@ function PromptDialog({
2385
2542
  );
2386
2543
  }
2387
2544
 
2388
- // DetailMenu is the "..." menu in the detail header: Rename / Move to folder /
2389
- // Delete, each shown only when its handler is provided. Delete has an inline
2390
- // confirm step; Rename/Move open a PromptDialog owned by EntryDetail.
2545
+ // DetailMenu is the "..." menu in the detail header: Details / Rename / Move to
2546
+ // folder / Delete, each shown only when its handler is provided. Details is a
2547
+ // read (metadata and references, see DocumentDetails) and so is offered on
2548
+ // read-only documents too; Delete has an inline confirm step; Rename/Move open
2549
+ // a PromptDialog owned by EntryDetail.
2391
2550
  function DetailMenu({
2551
+ onDetails,
2392
2552
  onRename,
2393
2553
  onMove,
2394
2554
  onDelete,
2395
2555
  }: {
2556
+ onDetails?: () => void;
2396
2557
  onRename?: () => void;
2397
2558
  onMove?: () => void;
2398
2559
  onDelete?: () => void;
@@ -2423,6 +2584,12 @@ function DetailMenu({
2423
2584
  borderRadius: t.radius.md, paddingVertical: t.space(1),
2424
2585
  }}
2425
2586
  >
2587
+ {onDetails ? (
2588
+ <Pressable testID="detail-details" onPress={() => { close(); onDetails(); }} style={rowStyle}>
2589
+ <Icon name="fileText" size={15} color={t.color.textSecondary} />
2590
+ <Text variant="sm">Details</Text>
2591
+ </Pressable>
2592
+ ) : null}
2426
2593
  {onRename ? (
2427
2594
  <Pressable testID="detail-rename" onPress={() => { close(); onRename(); }} style={rowStyle}>
2428
2595
  <Icon name="file" size={15} color={t.color.textSecondary} />
@@ -2558,6 +2725,7 @@ function EntryDetail({
2558
2725
  entry,
2559
2726
  documentActions,
2560
2727
  onEntryDraft,
2728
+ onFieldFocus,
2561
2729
  renderField,
2562
2730
  onSaveEntry,
2563
2731
  readOnly = false,
@@ -2565,8 +2733,10 @@ function EntryDetail({
2565
2733
  onDeleteEntry,
2566
2734
  onRename,
2567
2735
  onMove,
2736
+ collection,
2568
2737
  collectionPath,
2569
2738
  folderOptions,
2739
+ history,
2570
2740
  active,
2571
2741
  onActivate,
2572
2742
  onCollapse,
@@ -2579,6 +2749,8 @@ function EntryDetail({
2579
2749
  documentActions?: (entry: CmsEntry) => React.ReactNode;
2580
2750
  // Debounced in-flight draft (see ContentBrowserProps.onEntryDraft).
2581
2751
  onEntryDraft?: (draft: EntrySaveChange, entry: CmsEntry) => void;
2752
+ // The focused field's path, or null (see ContentBrowserProps.onFieldFocus).
2753
+ onFieldFocus?: (entryId: string, path: string | null) => void;
2582
2754
  renderField?: RenderField;
2583
2755
  onSaveEntry?: SaveEntry;
2584
2756
  readOnly?: boolean;
@@ -2586,9 +2758,15 @@ function EntryDetail({
2586
2758
  onDeleteEntry?: (entry: CmsEntry) => Promise<void> | void;
2587
2759
  onRename?: (entry: CmsEntry, filename: string) => Promise<void> | void;
2588
2760
  onMove?: (entry: CmsEntry, folder: string) => Promise<void> | void;
2761
+ // The collection the document belongs to, shown in its Details.
2762
+ collection?: string;
2589
2763
  collectionPath?: string;
2590
2764
  // Existing folders (relative to the glob base) offered by the move autocomplete.
2591
2765
  folderOptions?: string[];
2766
+ // The document-history seam. Provided → a history button appears in this
2767
+ // document's header and its column can show the version browser. Omitted →
2768
+ // the header renders exactly as it did before history existed.
2769
+ history?: HistoryApi;
2592
2770
  // Desktop columns: `active` marks the focused column (accent + header press
2593
2771
  // activates it via onActivate); onCollapse/onClose add the header rail/close
2594
2772
  // controls. All omitted on mobile → unchanged single-detail behavior.
@@ -2609,6 +2787,36 @@ function EntryDetail({
2609
2787
  const canRename = !readOnly && !!onRename;
2610
2788
  const canMove = !readOnly && !!onMove && glob.hierarchical;
2611
2789
  const [prompt, setPrompt] = useState<"rename" | "move" | null>(null);
2790
+ // The Details modal: metadata and references, opened from the menu.
2791
+ const [showDetails, setShowDetails] = useState(false);
2792
+ // A header action that failed — a delete refused because other documents
2793
+ // reference this one — reported inline, where the Delete was pressed.
2794
+ const [actionError, setActionError] = useState<string | null>(null);
2795
+ const deleteEntry = onDeleteEntry
2796
+ ? () => {
2797
+ setActionError(null);
2798
+ Promise.resolve(onDeleteEntry(entry)).catch((e: unknown) => {
2799
+ setActionError(e instanceof Error ? e.message : "Couldn’t delete the document.");
2800
+ });
2801
+ }
2802
+ : undefined;
2803
+ // Per-field "your value was replaced" counters, bumped by a restore. Only the
2804
+ // host-supplied body editor reads them (see FieldSlotArgs.resetNonce); every
2805
+ // built-in control is controlled and needs no telling.
2806
+ const [resetNonces, setResetNonces] = useState<Record<string, number>>({});
2807
+
2808
+ // Whether this column is showing the version browser instead of the form.
2809
+ // Deliberately per-column and ephemeral: history is something you open, read
2810
+ // and close, so it lives and dies with the column rather than in the URL.
2811
+ const [showHistory, setShowHistory] = useState(false);
2812
+ // A column reused for another document (the "preview tab" behavior) must not
2813
+ // carry the previous document's history view onto it — you asked to see that
2814
+ // document, not this one's past.
2815
+ const historyFor = useRef(entry.id);
2816
+ if (historyFor.current !== entry.id) {
2817
+ historyFor.current = entry.id;
2818
+ if (showHistory) setShowHistory(false);
2819
+ }
2612
2820
 
2613
2821
  // Both branches share one value map. The title+body branch is modeled as a
2614
2822
  // single body-source field so autosave logic is uniform.
@@ -2791,6 +2999,112 @@ function EntryDetail({
2791
2999
  [commit],
2792
3000
  );
2793
3001
 
3002
+ // Publish a value map the form did NOT arrive at by typing — a restore from a
3003
+ // past version, or a discard back to the saved document.
3004
+ //
3005
+ // Both used to write local state and stop there, which is only half the job in
3006
+ // a live session. The shared document is the source of truth there: every
3007
+ // collab control prefers its binding to the value handed to it, so a replaced
3008
+ // value was displayed for one frame and then overwritten by the room's
3009
+ // unchanged one, and no peer ever saw it. Anything that replaces values
3010
+ // wholesale has to come through here.
3011
+ //
3012
+ // `names` are the fields whose values were replaced; unchanged ones are left
3013
+ // alone so a replacement never churns the room (or nudges a peer's cursor)
3014
+ // over a value that did not move.
3015
+ const publishReplacement = useCallback(
3016
+ (previous: Record<string, unknown>, next: Record<string, unknown>, names: string[]) => {
3017
+ const moved = names.filter((name) => !sameValue(previous[name], next[name]));
3018
+ if (moved.length === 0) return;
3019
+
3020
+ if (entry.collab) {
3021
+ for (const name of moved) {
3022
+ // A body is a ProseMirror fragment rather than a field binding, so it
3023
+ // is not written here — its editor is told instead, by the nonce below.
3024
+ if (editFields.find((f) => f.name === name)?.source === "body") continue;
3025
+ writeCollabValue(entry.collab, name, previous[name], next[name]);
3026
+ }
3027
+ }
3028
+
3029
+ // Tell the replaced fields their value came from outside. Keyed per field
3030
+ // rather than one counter for the document: the body editor acts on this,
3031
+ // and replacing some unrelated field must not make it rebuild a body a
3032
+ // peer may be mid-sentence in.
3033
+ setResetNonces((prev) => {
3034
+ const bumped = { ...prev };
3035
+ for (const name of moved) bumped[name] = (bumped[name] ?? 0) + 1;
3036
+ return bumped;
3037
+ });
3038
+ },
3039
+ // editFields is derived from entry, captured by id.
3040
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3041
+ [entry.id, entry.collab],
3042
+ );
3043
+
3044
+ // Values taken from a past version and put back into the form.
3045
+ //
3046
+ // It is an edit like any other: the restored fields land in `values`, the
3047
+ // document goes unsaved, and the author saves it (or discards it) themselves.
3048
+ // Nothing is written to git here — history is a place you read, and rewriting
3049
+ // the document from it should still pass through the same review a typed edit
3050
+ // does.
3051
+ const restoreValues = useCallback(
3052
+ (restored: { fields: Record<string, unknown>; body?: string | null }) => {
3053
+ const previous = valuesRef.current;
3054
+ const next = { ...previous, ...restored.fields };
3055
+ // The body is a field in the form like any other; which one it is comes
3056
+ // from the schema (`source: "body"`), the same mapping buildChange uses in
3057
+ // the other direction. A document whose model has no body field simply
3058
+ // has no body to restore.
3059
+ const bodyField = "body" in restored ? editFields.find((f) => f.source === "body") : undefined;
3060
+ if (bodyField) next[bodyField.name] = restored.body ?? "";
3061
+
3062
+ const replaced = Object.keys(restored.fields);
3063
+ if (bodyField) replaced.push(bodyField.name);
3064
+
3065
+ // Touched under the first restored name so a restore that leaves a
3066
+ // required field empty shows its error immediately, rather than looking
3067
+ // saveable until Save is pressed.
3068
+ //
3069
+ // Committed BEFORE publishing: a collab control observing the room echoes
3070
+ // what it sees back through onChange, and that echo builds its next map by
3071
+ // spreading the current one. Publishing first would have it spread the
3072
+ // pre-restore map.
3073
+ commit(next, replaced[0] ?? SYNTHETIC_BODY);
3074
+ publishReplacement(previous, next, replaced);
3075
+ // Drilled-into groups address the pre-restore shape; a restored group
3076
+ // object can have different children entirely.
3077
+ setGroupPath([]);
3078
+ },
3079
+ // editFields is derived from entry, which commit already captures by id.
3080
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3081
+ [commit, entry.id, publishReplacement],
3082
+ );
3083
+
3084
+ // Throw away everything unsaved and go back to the document as stored.
3085
+ //
3086
+ // In a live session that is a change to the ROOM, not just to this screen: the
3087
+ // unsaved work is the collective state every client is looking at, and a
3088
+ // discard that only reverted the local form would leave the document it just
3089
+ // claimed to restore sitting in the CRDT, ready to come straight back.
3090
+ const discardEdits = useCallback(() => {
3091
+ const previous = valuesRef.current;
3092
+ const restored = seed();
3093
+ valuesRef.current = restored;
3094
+ setValues(restored);
3095
+ setTouched({});
3096
+ // The preview is rendering the draft being thrown away, and nothing else
3097
+ // will tell it otherwise: it is fed from edits, and a discard is the one
3098
+ // change to a document that produces no edit. Without this the preview
3099
+ // keeps showing work the author just deleted, until they type again.
3100
+ emitDraft(buildChange(entry.id, editFields, restored), entry);
3101
+ discard();
3102
+ publishReplacement(previous, restored, editFields.map((f) => f.name));
3103
+ setGroupPath([]);
3104
+ // seed/editFields derive from entry, captured by id.
3105
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3106
+ }, [entry.id, discard, emitDraft, publishReplacement]);
3107
+
2794
3108
  // A field edit within the active group-drill frame: write the value at the
2795
3109
  // frame's nested path, then commit the whole map (buildChange serializes it).
2796
3110
  const changeInFrame = useCallback(
@@ -2805,8 +3119,54 @@ function EntryDetail({
2805
3119
  // The field list + nested value object for the active frame.
2806
3120
  const frame = resolveFrame(editFields, values, groupPath);
2807
3121
 
3122
+ // Which field has the keyboard. One pair of DOM focus listeners on the
3123
+ // column rather than an onFocus on every control: the controls are many
3124
+ // (inputs, selects, the ProseMirror body, plugin fields) and every one of
3125
+ // them renders inside the FieldControl wrapper that carries the path as
3126
+ // `data-cms-field` — so the element the focus landed in is enough.
3127
+ //
3128
+ // focusout fires before the next focusin, so a blur is reported a tick
3129
+ // late and cancelled if focus went straight to another field: moving
3130
+ // between two controls reads as one change, not a flicker through null.
3131
+ const focusRoot = useRef<View>(null);
3132
+ const fieldFocus = useRef(onFieldFocus);
3133
+ fieldFocus.current = onFieldFocus;
3134
+ const entryId = entry.id;
3135
+ useEffect(() => {
3136
+ if (Platform.OS !== "web") return;
3137
+ const node = focusRoot.current as unknown as HTMLElement | null;
3138
+ if (!node || typeof node.addEventListener !== "function") return;
3139
+ let pending: ReturnType<typeof setTimeout> | null = null;
3140
+ let last: string | null = null;
3141
+ const report = (path: string | null) => {
3142
+ if (path === last) return;
3143
+ last = path;
3144
+ fieldFocus.current?.(entryId, path);
3145
+ };
3146
+ const onFocusIn = (ev: Event) => {
3147
+ if (pending) { clearTimeout(pending); pending = null; }
3148
+ const target = ev.target as Element | null;
3149
+ const el = target && typeof target.closest === "function" ? target.closest("[data-cms-field]") : null;
3150
+ report(el?.getAttribute("data-cms-field") || null);
3151
+ };
3152
+ const onFocusOut = () => {
3153
+ if (pending) clearTimeout(pending);
3154
+ pending = setTimeout(() => { pending = null; report(null); }, 0);
3155
+ };
3156
+ node.addEventListener("focusin", onFocusIn);
3157
+ node.addEventListener("focusout", onFocusOut);
3158
+ return () => {
3159
+ node.removeEventListener("focusin", onFocusIn);
3160
+ node.removeEventListener("focusout", onFocusOut);
3161
+ if (pending) clearTimeout(pending);
3162
+ // The column is going away with the field still focused: nothing in
3163
+ // this document has focus any more.
3164
+ if (last !== null) fieldFocus.current?.(entryId, null);
3165
+ };
3166
+ }, [entryId]);
3167
+
2808
3168
  return (
2809
- <View style={{ flex: 1 }}>
3169
+ <View style={{ flex: 1 }} ref={focusRoot}>
2810
3170
  {/* breadcrumb + actions. position/zIndex lift this row (and the "..." menu
2811
3171
  dropdown it hosts) above the content pane so the menu receives clicks. */}
2812
3172
  <View
@@ -2854,19 +3214,7 @@ function EntryDetail({
2854
3214
  {saveHandler && dirty ? (
2855
3215
  <Pressable
2856
3216
  testID="discard-changes"
2857
- onPress={() => {
2858
- const restored = seed();
2859
- valuesRef.current = restored;
2860
- setValues(restored);
2861
- setTouched({});
2862
- // The preview is rendering the draft that is being thrown away,
2863
- // and nothing else will tell it otherwise: it is fed from edits,
2864
- // and a discard is the one change to a document that produces no
2865
- // edit. Without this the preview keeps showing work the author
2866
- // just deleted, until they type again.
2867
- emitDraft(buildChange(entry.id, editFields, restored), entry);
2868
- discard();
2869
- }}
3217
+ onPress={discardEdits}
2870
3218
  style={{ paddingHorizontal: t.space(2), paddingVertical: t.space(1) }}
2871
3219
  >
2872
3220
  <Text variant="monoSm" color="tertiary">Discard</Text>
@@ -2894,13 +3242,26 @@ function EntryDetail({
2894
3242
  document's own actions, while the menu holds the destructive and
2895
3243
  path-changing ones that should stay one level down. */}
2896
3244
  {documentActions?.(entry)}
2897
- {canRename || canMove || (canDelete && onDeleteEntry) ? (
2898
- <DetailMenu
2899
- onRename={canRename ? () => setPrompt("rename") : undefined}
2900
- onMove={canMove ? () => setPrompt("move") : undefined}
2901
- onDelete={canDelete && onDeleteEntry ? () => onDeleteEntry(entry) : undefined}
3245
+ {/* History is a read of this document, not a change to it, so it sits
3246
+ out here with the other non-destructive actions rather than in the
3247
+ menu beside Delete. It toggles: pressing it again returns the column
3248
+ to the form. */}
3249
+ {history ? (
3250
+ <IconButton
3251
+ name="history"
3252
+ size="sm"
3253
+ active={showHistory}
3254
+ label={showHistory ? "Close history" : "Document history"}
3255
+ onPress={() => setShowHistory((v) => !v)}
3256
+ testID="document-history-toggle"
2902
3257
  />
2903
3258
  ) : null}
3259
+ <DetailMenu
3260
+ onDetails={() => setShowDetails(true)}
3261
+ onRename={canRename ? () => setPrompt("rename") : undefined}
3262
+ onMove={canMove ? () => setPrompt("move") : undefined}
3263
+ onDelete={canDelete && deleteEntry ? deleteEntry : undefined}
3264
+ />
2904
3265
  {onCollapse ? (
2905
3266
  <IconButton name="panelLeft" size="sm" label="Collapse column" onPress={onCollapse} testID="column-collapse" />
2906
3267
  ) : null}
@@ -2909,10 +3270,35 @@ function EntryDetail({
2909
3270
  ) : null}
2910
3271
  </View>
2911
3272
 
2912
- {/* Keyed by entry.id so controls (incl. the markdown editor) remount with
3273
+ {/* History takes over the column's body, keeping its header: the header
3274
+ is what says which document you are looking at, and it carries the
3275
+ control that got you here and gets you back. */}
3276
+ {showHistory && history ? (
3277
+ <DocumentHistory
3278
+ documentId={entry.id}
3279
+ documentPath={entry.path}
3280
+ history={history}
3281
+ onClose={() => setShowHistory(false)}
3282
+ // Restoring needs somewhere for the values to go: a document that is
3283
+ // read-only, or that this host never wired a save for, gets the
3284
+ // history with no selection boxes at all rather than a form it can
3285
+ // dirty and never save. (saveHandler is already undefined when
3286
+ // readOnly, so this covers both.)
3287
+ onRestore={
3288
+ !saveHandler
3289
+ ? undefined
3290
+ : (restored) => {
3291
+ restoreValues(restored);
3292
+ // Back to the form, which is where the restored values now
3293
+ // are and where the decision to keep them is made.
3294
+ setShowHistory(false);
3295
+ }
3296
+ }
3297
+ />
3298
+ ) : /* Keyed by entry.id so controls (incl. the markdown editor) remount with
2913
3299
  fresh initial values (and the scroll resets to top) when the selected
2914
- document changes. The header above stays put; only the body scrolls. */}
2915
- {hasFields ? (
3300
+ document changes. The header above stays put; only the body scrolls. */
3301
+ hasFields ? (
2916
3302
  // The document's path reaches media fields at any nesting depth through
2917
3303
  // context: a picker inside a group or a list item needs it to interpret
2918
3304
  // relative references, and drilling it through every level would touch
@@ -2925,6 +3311,7 @@ function EntryDetail({
2925
3311
  contentContainerStyle={{ padding: t.space(5), gap: t.space(4) }}
2926
3312
  >
2927
3313
  {saveHandler && error ? <FormAlert message={error} onDismiss={clearError} /> : null}
3314
+ {actionError ? <FormAlert message={actionError} onDismiss={() => setActionError(null)} /> : null}
2928
3315
  {groupPath.length > 0 ? (
2929
3316
  <Pressable
2930
3317
  testID="group-back"
@@ -2935,6 +3322,8 @@ function EntryDetail({
2935
3322
  <Text variant="monoSm" color="tertiary">{frame.labels.join(" / ")}</Text>
2936
3323
  </Pressable>
2937
3324
  ) : null}
3325
+ {/* Drilled into a group, every field's path starts with the group's. */}
3326
+ <FieldPathContext.Provider value={groupPath.join(".")}>
2938
3327
  {frame.fields.map((f) => (
2939
3328
  <FieldControl
2940
3329
  key={f.name}
@@ -2950,13 +3339,19 @@ function EntryDetail({
2950
3339
  collab={entry.collab}
2951
3340
  path={[...groupPath, f.name].join(".")}
2952
3341
  focusSignal={focusTarget && focusTarget.name === f.name ? focusTarget.nonce : undefined}
3342
+ resetNonce={resetNonces[f.name]}
2953
3343
  />
2954
3344
  ))}
3345
+ </FieldPathContext.Provider>
2955
3346
  </ScrollView>
2956
3347
  </DocumentPathProvider>
2957
3348
  ) : (
3349
+ // The body editor resolves the images it embeds relative to this
3350
+ // document, the same way a media field does.
3351
+ <DocumentPathProvider path={entry.path}>
2958
3352
  <View key={entry.id} style={{ padding: t.space(5), gap: t.space(4), flex: 1 }}>
2959
3353
  {saveHandler && error ? <FormAlert message={error} onDismiss={clearError} /> : null}
3354
+ {actionError ? <FormAlert message={actionError} onDismiss={() => setActionError(null)} /> : null}
2960
3355
  <View style={{ gap: t.space(2) }}>
2961
3356
  <Text variant="label" color="tertiary">Title</Text>
2962
3357
  <Text variant="h2">{entry.title}</Text>
@@ -2968,8 +3363,12 @@ function EntryDetail({
2968
3363
  readOnly={readOnly}
2969
3364
  />
2970
3365
  </View>
3366
+ </DocumentPathProvider>
2971
3367
  )}
2972
3368
 
3369
+ {showDetails ? (
3370
+ <DocumentDetails entry={entry} collection={collection} onClose={() => setShowDetails(false)} />
3371
+ ) : null}
2973
3372
  {prompt === "rename" && onRename ? (
2974
3373
  <PromptDialog
2975
3374
  title="Rename file"
@@ -3029,27 +3428,6 @@ function useMultiSelect(resetKey: string) {
3029
3428
  return { checked, toggle, clear, checkedIds, checkedFolders, toggleFolder, checkedFolderKeys };
3030
3429
  }
3031
3430
 
3032
- // CheckBox is the row-selection control for multi-select.
3033
- function CheckBox({ value, onToggle }: { value: boolean; onToggle: () => void }) {
3034
- const t = useTheme();
3035
- return (
3036
- <Pressable
3037
- // Stop the press from bubbling to the row's open handler (the checkbox
3038
- // sits inside the row Pressable) so toggling never also opens the entry.
3039
- onPress={(e?: { stopPropagation?: () => void }) => { e?.stopPropagation?.(); onToggle(); }}
3040
- testID="entry-checkbox"
3041
- style={{
3042
- width: 18, height: 18, borderRadius: t.radius.sm, borderWidth: 1,
3043
- borderColor: value ? t.color.borderStrong : t.color.borderDefault,
3044
- backgroundColor: value ? t.color.surfaceInverted : t.color.surfaceRaised,
3045
- alignItems: "center", justifyContent: "center",
3046
- }}
3047
- >
3048
- {value ? <Icon name="check" size={12} color={t.color.textInverted} /> : null}
3049
- </Pressable>
3050
- );
3051
- }
3052
-
3053
3431
  // SelectionBar appears above the entry list when rows are checked; it offers a
3054
3432
  // bulk move and/or delete plus a clear action (each shown only when wired).
3055
3433
  function SelectionBar({ count, onMove, onDelete, onClear }: { count: number; onMove?: () => void; onDelete?: () => void; onClear: () => void }) {
@@ -3093,6 +3471,8 @@ type ColumnModel = {
3093
3471
  canDelete?: boolean;
3094
3472
  canRename: boolean;
3095
3473
  canMove: boolean;
3474
+ // The tab's collection key, for the document's Details.
3475
+ collection?: string;
3096
3476
  collectionPath?: string;
3097
3477
  /** Set for a custom column (see OpenTab.content); `entry` is then unused. */
3098
3478
  content?: React.ReactNode;
@@ -3192,7 +3572,7 @@ function EntriesList({
3192
3572
  <View style={{ flexDirection: "row", alignItems: "center" }}>
3193
3573
  {showChecks ? (
3194
3574
  <View style={{ paddingLeft: t.space(4) }}>
3195
- <CheckBox value={!!checked?.[e.id]} onToggle={() => onToggleEntry?.(e.id)} />
3575
+ <CheckBox value={!!checked?.[e.id]} onToggle={() => onToggleEntry?.(e.id)} testID="entry-checkbox" />
3196
3576
  </View>
3197
3577
  ) : null}
3198
3578
  <View style={{ flex: 1 }}>
@@ -3567,9 +3947,28 @@ function ReadOnlyBanner({ notice, actionLabel, onAction }: { notice?: string; ac
3567
3947
  );
3568
3948
  }
3569
3949
 
3570
- // ApplyModalOverlay renders the apply-changes / change-request modal as a
3571
- // full-screen overlay. Shared by both layouts so the modal (and its "learn more"
3572
- // change-request link) is reachable on mobile too.
3950
+ // ModalOverlays renders the browser's full-screen modals — apply changes /
3951
+ // change request, and the protected-branch save prompt. Shared by both layouts
3952
+ // so every modal is reachable on mobile too.
3953
+ function ModalOverlays(props: ContentBrowserProps) {
3954
+ return (
3955
+ <>
3956
+ <ApplyModalOverlay {...props} />
3957
+ {props.protectedBranch ? (
3958
+ <ProtectedBranchModal
3959
+ branch={props.protectedBranch}
3960
+ documentLabel={props.protectedBranchDocument}
3961
+ suggestedName={props.protectedBranchSuggestedName}
3962
+ busy={props.creatingProtectedBranch}
3963
+ error={props.protectedBranchError}
3964
+ onCreate={props.onCreateBranchAndSave ?? (() => {})}
3965
+ onCancel={props.onCancelProtectedSave ?? (() => {})}
3966
+ />
3967
+ ) : null}
3968
+ </>
3969
+ );
3970
+ }
3971
+
3573
3972
  function ApplyModalOverlay(props: ContentBrowserProps) {
3574
3973
  if (!props.applyOpen) return null;
3575
3974
  return (
@@ -3577,6 +3976,7 @@ function ApplyModalOverlay(props: ContentBrowserProps) {
3577
3976
  sourceBranch={props.workspace.branch}
3578
3977
  targetBranch={props.targetBranch ?? props.defaultBranch ?? ""}
3579
3978
  summary={props.applySummary}
3979
+ reviewReason={props.applyReviewReason}
3580
3980
  progress={props.applyProgress}
3581
3981
  conflictCount={props.applyConflictCount}
3582
3982
  changeRequestOpen={props.changeRequestOpen}
@@ -3756,6 +4156,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
3756
4156
  canDelete: tab.canDelete,
3757
4157
  canRename: !!tab.canRename,
3758
4158
  canMove: !!tab.canMove,
4159
+ collection: tab.collection,
3759
4160
  collectionPath: tab.collectionPath,
3760
4161
  content: tab.content,
3761
4162
  title: tab.title,
@@ -3937,7 +4338,9 @@ function DesktopBrowser(props: ContentBrowserProps) {
3937
4338
  <EntryDetail
3938
4339
  entry={col.entry}
3939
4340
  documentActions={props.documentActions}
4341
+ history={props.history}
3940
4342
  onEntryDraft={props.onEntryDraft}
4343
+ onFieldFocus={props.onFieldFocus}
3941
4344
  renderField={renderField}
3942
4345
  onSaveEntry={props.onSaveEntry}
3943
4346
  readOnly={col.readOnly}
@@ -3945,6 +4348,9 @@ function DesktopBrowser(props: ContentBrowserProps) {
3945
4348
  onDeleteEntry={onDeleteEntry}
3946
4349
  onRename={col.canRename ? onRename : undefined}
3947
4350
  onMove={col.canMove ? onMove : undefined}
4351
+ // A host-managed tab names its collection; a column the browser opened
4352
+ // itself (uncontrolled selection) belongs to the active one.
4353
+ collection={navLabelFor(props.sections, col.collection || props.activeNavKey) ?? (col.collection || props.activeNavKey)}
3948
4354
  collectionPath={col.collectionPath}
3949
4355
  folderOptions={moveFolders}
3950
4356
  active={!showCreate && col.active}
@@ -4066,7 +4472,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4066
4472
  const readOnlyBanner = (
4067
4473
  <ReadOnlyBanner notice={props.readOnlyNotice} actionLabel={props.readOnlyNoticeActionLabel} onAction={props.onReadOnlyNoticeAction} />
4068
4474
  );
4069
- const applyModal = <ApplyModalOverlay {...props} />;
4475
+ const modalOverlays = <ModalOverlays {...props} />;
4070
4476
 
4071
4477
  // The changes surface reuses the same three-pane model as Edit, so switching
4072
4478
  // between them doesn't relayout the screen — only what each pane contains
@@ -4119,7 +4525,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4119
4525
  conflicts={props.selectedConflicts}
4120
4526
  onResolveConflict={props.onResolveConflict}
4121
4527
  />
4122
- {applyModal}
4528
+ {modalOverlays}
4123
4529
  </AppShell>
4124
4530
  );
4125
4531
  }
@@ -4134,7 +4540,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4134
4540
  <Pane flex={1} testID="pane-plugin" scroll={false}>
4135
4541
  {props.contentSlot}
4136
4542
  </Pane>
4137
- {applyModal}
4543
+ {modalOverlays}
4138
4544
  </AppShell>
4139
4545
  );
4140
4546
  }
@@ -4174,7 +4580,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4174
4580
  <View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
4175
4581
  <Text variant="body" color="tertiary" testID="forms-empty">Select a form</Text>
4176
4582
  </View>
4177
- {applyModal}
4583
+ {modalOverlays}
4178
4584
  </AppShell>
4179
4585
  );
4180
4586
  }
@@ -4192,7 +4598,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4192
4598
  variant="desktop"
4193
4599
  />
4194
4600
  </Pane>
4195
- {applyModal}
4601
+ {modalOverlays}
4196
4602
  </AppShell>
4197
4603
  );
4198
4604
  }
@@ -4219,7 +4625,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4219
4625
  <Text variant="body" color="tertiary" testID="media-sets-empty">Select a media collection</Text>
4220
4626
  </View>
4221
4627
  )}
4222
- {applyModal}
4628
+ {modalOverlays}
4223
4629
  </AppShell>
4224
4630
  );
4225
4631
  }
@@ -4420,7 +4826,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4420
4826
  onClose={() => setMovePrompt(false)}
4421
4827
  />
4422
4828
  ) : null}
4423
- {applyModal}
4829
+ {modalOverlays}
4424
4830
  </AppShell>
4425
4831
  );
4426
4832
  }
@@ -4496,7 +4902,7 @@ function MobileBrowser(props: ContentBrowserProps) {
4496
4902
  const readOnlyBanner = (
4497
4903
  <ReadOnlyBanner notice={props.readOnlyNotice} actionLabel={props.readOnlyNoticeActionLabel} onAction={props.onReadOnlyNoticeAction} />
4498
4904
  );
4499
- const applyModal = <ApplyModalOverlay {...props} />;
4905
+ const modalOverlays = <ModalOverlays {...props} />;
4500
4906
 
4501
4907
  // Media drills the same way a collection does — nav → list → detail — so the
4502
4908
  // back arrow means the same thing at every level. Pressing Media lists the
@@ -4622,7 +5028,7 @@ function MobileBrowser(props: ContentBrowserProps) {
4622
5028
  testID="mobile-shell"
4623
5029
  header={header}
4624
5030
  title={props.surface === "changes" ? "Changes" : "Content"}
4625
- overlay={applyModal}
5031
+ overlay={modalOverlays}
4626
5032
  >
4627
5033
  {/* Edit / Changes surface toggle (fits its content) with the Apply button
4628
5034
  to its right on the changes surface. The read-only notice sits below. */}
@@ -4703,7 +5109,7 @@ function MobileBrowser(props: ContentBrowserProps) {
4703
5109
  testID="mobile-entries"
4704
5110
  scroll={false}
4705
5111
  banner={readOnlyBanner}
4706
- overlay={applyModal}
5112
+ overlay={modalOverlays}
4707
5113
  header={
4708
5114
  search.open ? (
4709
5115
  <SearchHeaderBar search={search} showFilter={facetFields.length > 0} />
@@ -4784,7 +5190,7 @@ function MobileBrowser(props: ContentBrowserProps) {
4784
5190
  <MobileScreen
4785
5191
  testID="mobile-entry"
4786
5192
  banner={readOnlyBanner}
4787
- overlay={applyModal}
5193
+ overlay={modalOverlays}
4788
5194
  header={
4789
5195
  <>
4790
5196
  <IconButton name="chevronLeft" onPress={backToEntries} size="md" label="Back" />
@@ -4812,7 +5218,9 @@ function MobileBrowser(props: ContentBrowserProps) {
4812
5218
  <EntryDetail
4813
5219
  entry={entry}
4814
5220
  documentActions={props.documentActions}
5221
+ history={props.history}
4815
5222
  onEntryDraft={props.onEntryDraft}
5223
+ onFieldFocus={props.onFieldFocus}
4816
5224
  renderField={renderField}
4817
5225
  onSaveEntry={props.onSaveEntry}
4818
5226
  readOnly={readOnly}
@@ -4820,6 +5228,7 @@ function MobileBrowser(props: ContentBrowserProps) {
4820
5228
  onDeleteEntry={onDeleteEntry}
4821
5229
  onRename={onRename}
4822
5230
  onMove={onMove}
5231
+ collection={navLabelFor(props.sections, props.activeNavKey) ?? props.activeNavKey}
4823
5232
  collectionPath={collectionPath}
4824
5233
  folderOptions={moveFolders}
4825
5234
  />
@@ -5071,7 +5480,9 @@ export function ContentBrowser(props: ContentBrowserProps) {
5071
5480
  // object store) renders media fields read-only.
5072
5481
  return (
5073
5482
  <MediaProvider api={props.media}>
5074
- {isDesktop ? <DesktopBrowser {...withSections} /> : <MobileBrowser {...withSections} />}
5483
+ <ReferenceProvider api={props.references}>
5484
+ {isDesktop ? <DesktopBrowser {...withSections} /> : <MobileBrowser {...withSections} />}
5485
+ </ReferenceProvider>
5075
5486
  </MediaProvider>
5076
5487
  );
5077
5488
  }
@@ -5082,14 +5493,20 @@ function mediaSetLabel(sets: MediaSetInfo[], name: string): string {
5082
5493
  return set ? titleCaseWord(set.name) : titleCaseWord(name);
5083
5494
  }
5084
5495
 
5085
- // PaneTitle is the content pane's heading, matching the entry list's header
5086
- // height so the panes line up when switching between content and media.
5496
+ // PaneTitle is the content pane's heading for the surfaces with no controls
5497
+ // beside it — Changes, Media, Forms.
5498
+ //
5499
+ // Deliberately the same Text the collection header uses (`listHeader`), and
5500
+ // nothing else. Pane's header row already supplies the height, the horizontal
5501
+ // padding and the rule beneath, so the padded wrapper this used to add sat
5502
+ // inside that padding and pushed the title a step right of every collection's —
5503
+ // at `body` rather than `h3`, so it read a size smaller too. Switching between
5504
+ // Posts and Media moved the heading twice over.
5087
5505
  function PaneTitle({ title }: { title: string }) {
5088
- const t = useTheme();
5089
5506
  return (
5090
- <View style={{ paddingHorizontal: t.space(4), paddingVertical: t.space(3) }}>
5091
- <Text variant="body" weight="semibold">{title}</Text>
5092
- </View>
5507
+ <Text variant="h3" weight="semibold" style={{ flex: 1 }}>
5508
+ {title}
5509
+ </Text>
5093
5510
  );
5094
5511
  }
5095
5512