@gogitcms/design-system 0.16.0-next.0 → 0.16.0-next.10

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.
@@ -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,18 @@ 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";
33
35
  import { MediaBrowser } from "./MediaBrowser";
34
36
  import { FormsBrowser } from "./FormsBrowser";
35
37
  import { FORMS_NAV_KEY, formsNavKey, isFormsNavKey, parseFormsNavKey, type FormsApi } from "../forms";
38
+ import { type HistoryApi } from "../history";
39
+ import { DocumentHistory } from "./DocumentHistory";
36
40
  import {
37
41
  MEDIA_NAV_KEY,
38
42
  mediaNavKey,
39
43
  isMediaNavKey,
40
44
  parseMediaNavKey,
45
+ type FieldMediaApi,
41
46
  type MediaApi,
42
47
  type MediaSetInfo,
43
48
  type StoreAs,
@@ -111,6 +116,16 @@ export type FieldSlotArgs = {
111
116
  // CRDT and publish presence.
112
117
  collab?: CollabApi;
113
118
  path?: string;
119
+ // Bumped when the host replaces this field's value outright rather than the
120
+ // user editing it — restoring it from a past version. A controlled control
121
+ // needs nothing from this (it already re-renders with the new value), but the
122
+ // body editor does: in a live session it ignores `value` and mirrors the
123
+ // shared fragment, so "the value changed" is not a thing it can observe.
124
+ resetNonce?: number;
125
+ // Media access scoped to this field — open the picker, resolve the paths the
126
+ // document holds — so a host control can embed and display images from the
127
+ // media library. Absent when the host provides no media api.
128
+ media?: FieldMediaApi;
114
129
  };
115
130
  export type RenderField = (args: FieldSlotArgs) => React.ReactNode;
116
131
 
@@ -326,6 +341,15 @@ export type ContentBrowserProps = {
326
341
  // already separated from the frontmatter.
327
342
  onEntryDraft?: (draft: EntrySaveChange, entry: CmsEntry) => void;
328
343
 
344
+ // Which field of an open document has keyboard focus, as the author moves
345
+ // between controls: the field's dotted path (`title`, `seo.description`,
346
+ // `blocks.0.heading`), or null when nothing in that document is focused.
347
+ // Web only — it reads the DOM's focus events. This is what lets a preview
348
+ // ring the part of the page the author is editing (§4.4 of the preview
349
+ // design), and it is separate from live collaboration: a lone author gets
350
+ // it too.
351
+ onFieldFocus?: (entryId: string, path: string | null) => void;
352
+
329
353
  // Optional per-field renderer. When it returns a node for a field, that node
330
354
  // replaces the built-in control (used to inject the markdown body editor).
331
355
  renderField?: RenderField;
@@ -339,6 +363,14 @@ export type ContentBrowserProps = {
339
363
  // Forms data seam, the same split (docs/forms.md §10.2). Omitted → the Forms
340
364
  // surface never renders, which is what a config declaring no forms looks like.
341
365
  forms?: FormsApi;
366
+
367
+ // Document-history data seam, the same split again: the DS owns the version
368
+ // browser and its ephemeral state, the host owns fetching. Provided → an open
369
+ // document's header gains a history button, and pressing it gives that
370
+ // column's body over to the version browser. Omitted → no affordance at all,
371
+ // which is what a deployment with no provider (local mode, desktop) gets: a
372
+ // feature the user never sees beats one that errors when clicked.
373
+ history?: HistoryApi;
342
374
  // The selected submission's id, and the reporter for taps — bound to the URL
343
375
  // by the host exactly as document selection is.
344
376
  selectedSubmissionId?: string | null;
@@ -465,6 +497,9 @@ export type ContentBrowserProps = {
465
497
  canApplyChanges?: boolean;
466
498
  applyOpen?: boolean;
467
499
  applySummary?: string;
500
+ // Why applying opens a change request instead of merging (see
501
+ // ApplyChangesModal.reviewReason). Absent for a direct merge.
502
+ applyReviewReason?: string;
468
503
  applyProgress?: MergeProgress;
469
504
  applyConflictCount?: number;
470
505
  onConfirmApply?: () => void;
@@ -478,6 +513,19 @@ export type ContentBrowserProps = {
478
513
  changeRequestOpen?: boolean;
479
514
  changeRequestUrl?: string;
480
515
  creatingChangeRequest?: boolean;
516
+
517
+ // Protected-branch save prompt. The server refuses a save to a branch the
518
+ // provider protects; the host catches that, sets protectedBranch to the branch
519
+ // name, and this modal offers the one thing that resolves it — a branch to put
520
+ // the edit on. onCreateBranchAndSave receives the name; the host creates the
521
+ // branch and applies the pending edit to it in one server call.
522
+ protectedBranch?: string;
523
+ protectedBranchDocument?: string;
524
+ protectedBranchSuggestedName?: string;
525
+ creatingProtectedBranch?: boolean;
526
+ protectedBranchError?: string | null;
527
+ onCreateBranchAndSave?: (name: string) => void;
528
+ onCancelProtectedSave?: () => void;
481
529
  onCreateChangeRequest?: (explanation: string) => void;
482
530
  onViewChangeRequest?: () => void;
483
531
 
@@ -743,8 +791,45 @@ type FieldControlProps = {
743
791
  // When this number changes, the field programmatically focuses itself (used to
744
792
  // jump to a peer's field from its presence avatar).
745
793
  focusSignal?: number;
794
+ // Bumped when this field's value was replaced by the host rather than typed —
795
+ // restored from a past version. Only the host-supplied body editor needs it
796
+ // (see FieldSlotArgs.resetNonce); every built-in control is controlled and
797
+ // re-renders from `value` on its own.
798
+ resetNonce?: number;
799
+ // The field's full dotted path, when the caller knows better than
800
+ // "parent path + field name" — a list item, whose control is named after
801
+ // the list but sits at `list.<index>`.
802
+ fieldPath?: string;
746
803
  };
747
804
 
805
+ // The dotted path of the field being rendered, for the controls under it:
806
+ // a group's children append their names to it, a list's items append their
807
+ // index. It exists so every control can carry its own path as a DOM
808
+ // attribute (`data-cms-field`) without any of them threading a prop through
809
+ // — the focus reporting in EntryDetail reads that attribute off whichever
810
+ // element the keyboard lands in.
811
+ //
812
+ // Deliberately separate from the collab `path` prop: that one is threaded
813
+ // only where a CRDT binding exists (top-level fields and drilled-in groups),
814
+ // whereas this is present for every field including list items, which the
815
+ // collab layer treats as one register.
816
+ const FieldPathContext = React.createContext<string>("");
817
+
818
+ function FieldControl(props: FieldControlProps) {
819
+ const prefix = React.useContext(FieldPathContext);
820
+ const fullPath = props.fieldPath ?? (prefix ? `${prefix}.${props.field.name}` : props.field.name);
821
+ return (
822
+ <FieldPathContext.Provider value={fullPath}>
823
+ <View
824
+ // @ts-expect-error react-native-web maps dataSet -> data-* attributes
825
+ dataSet={{ cmsField: fullPath }}
826
+ >
827
+ <FieldControlInner {...props} />
828
+ </View>
829
+ </FieldPathContext.Provider>
830
+ );
831
+ }
832
+
748
833
  // The scalar type an array's `of` maps to when rendering item controls.
749
834
  function ofToType(of?: string): string {
750
835
  switch (of) {
@@ -755,8 +840,8 @@ function ofToType(of?: string): string {
755
840
  }
756
841
  }
757
842
 
758
- function FieldControl(props: FieldControlProps) {
759
- const { field, value, onChange, renderField, readOnly = false, error, hideLabel, onOpenGroup, collab, path, focusSignal } = props;
843
+ function FieldControlInner(props: FieldControlProps) {
844
+ const { field, value, onChange, renderField, readOnly = false, error, hideLabel, onOpenGroup, collab, path, focusSignal, resetNonce } = props;
760
845
  const t = useTheme();
761
846
  const label = hideLabel ? "" : field.label || field.name;
762
847
  // A text field participates in live collaboration when the document has a
@@ -767,9 +852,14 @@ function FieldControl(props: FieldControlProps) {
767
852
  ) : null;
768
853
  const labelNode = label ? <Text variant="label" color="tertiary">{label}</Text> : null;
769
854
 
855
+ // Media for a host-supplied control. The picker element is mounted only when
856
+ // the slot actually renders this field, so a form full of built-in controls
857
+ // carries no extra modals.
858
+ const fieldMedia = useFieldMedia({ set: field.media, storeAs: field.storeAs });
859
+
770
860
  // Host-supplied control (e.g. the markdown editor) takes precedence.
771
861
  if (renderField) {
772
- const custom = renderField({ field, value, onChange, readOnly, collab: collabText, path });
862
+ const custom = renderField({ field, value, onChange, readOnly, collab: collabText, path, resetNonce, media: fieldMedia.media });
773
863
  if (custom != null && custom !== false) {
774
864
  return (
775
865
  <View style={{ gap: t.space(2) }}>
@@ -780,6 +870,7 @@ function FieldControl(props: FieldControlProps) {
780
870
  custom
781
871
  )}
782
872
  {errorNode}
873
+ {fieldMedia.picker}
783
874
  </View>
784
875
  );
785
876
  }
@@ -1592,11 +1683,14 @@ function ListControl({
1592
1683
  ...(field.media ? { component: "media", media: field.media, storeAs: field.storeAs } : {}),
1593
1684
  };
1594
1685
 
1686
+ // The list's own path, from the FieldControl wrapping this control; an item
1687
+ // sits at `<list>.<index>`.
1688
+ const listPath = React.useContext(FieldPathContext);
1595
1689
  return (
1596
1690
  <View style={{ gap: t.space(2) }}>
1597
1691
  {items.map((it, i) => (
1598
1692
  <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 />
1693
+ <FieldControl field={itemField} value={it} onChange={(v) => setItem(i, v)} renderField={renderField} readOnly={readOnly} hideLabel fieldPath={listPath ? `${listPath}.${i}` : String(i)} />
1600
1694
  </ItemFrame>
1601
1695
  ))}
1602
1696
  {!readOnly && canAdd ? (
@@ -1636,15 +1730,25 @@ function MixedListControl({
1636
1730
  onChange(next);
1637
1731
  };
1638
1732
  const addItem = () => onChange([...items, { [key]: variants[0]?.name ?? "" }]);
1733
+ const listPath = React.useContext(FieldPathContext);
1639
1734
 
1640
1735
  return (
1641
1736
  <View style={{ gap: t.space(2) }}>
1642
1737
  {items.map((it, i) => {
1643
1738
  const variantName = String(it?.[key] ?? "");
1644
1739
  const variant = variants.find((v) => v.name === variantName);
1740
+ const itemPath = listPath ? `${listPath}.${i}` : String(i);
1645
1741
  return (
1646
1742
  <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) }}>
1743
+ {/* The item's fields compose under `<list>.<index>`; the item
1744
+ itself carries that path so focusing its variant picker
1745
+ reports the item rather than the whole list. */}
1746
+ <FieldPathContext.Provider value={itemPath}>
1747
+ <View
1748
+ style={{ gap: t.space(2) }}
1749
+ // @ts-expect-error react-native-web maps dataSet -> data-* attributes
1750
+ dataSet={{ cmsField: itemPath }}
1751
+ >
1648
1752
  <SelectControl
1649
1753
  value={variantName}
1650
1754
  options={variants.map((v) => v.name)}
@@ -1662,6 +1766,7 @@ function MixedListControl({
1662
1766
  />
1663
1767
  ) : null}
1664
1768
  </View>
1769
+ </FieldPathContext.Provider>
1665
1770
  </ItemFrame>
1666
1771
  );
1667
1772
  })}
@@ -1697,6 +1802,9 @@ function BodyField({
1697
1802
  format: "markdown",
1698
1803
  value,
1699
1804
  };
1805
+ // The schema-less body names no media set; the picker opens on the first
1806
+ // configured one and writes public paths.
1807
+ const fieldMedia = useFieldMedia({});
1700
1808
  // The slot's onChange is unknown-typed (plugin fields may emit any shape);
1701
1809
  // a body control only ever emits markdown strings.
1702
1810
  const custom = renderField?.({
@@ -1704,15 +1812,25 @@ function BodyField({
1704
1812
  value,
1705
1813
  onChange: (v) => onChange(typeof v === "string" ? v : String(v ?? "")),
1706
1814
  readOnly,
1815
+ media: fieldMedia.media,
1707
1816
  });
1708
1817
  return (
1709
- <View style={{ gap: t.space(2), flex: 1 }}>
1818
+ <View
1819
+ style={{ gap: t.space(2), flex: 1 }}
1820
+ // The schema-less body is the document's one field; a preview rings
1821
+ // it under the same name the schema form would give it.
1822
+ // @ts-expect-error react-native-web maps dataSet -> data-* attributes
1823
+ dataSet={{ cmsField: "body" }}
1824
+ >
1710
1825
  <View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
1711
1826
  <Text variant="label" color="tertiary">Body</Text>
1712
1827
  <Text variant="monoSm" color="tertiary">Markdown</Text>
1713
1828
  </View>
1714
1829
  {custom != null && custom !== false ? (
1715
- custom
1830
+ <>
1831
+ {custom}
1832
+ {fieldMedia.picker}
1833
+ </>
1716
1834
  ) : (
1717
1835
  <View
1718
1836
  style={{
@@ -2558,6 +2676,7 @@ function EntryDetail({
2558
2676
  entry,
2559
2677
  documentActions,
2560
2678
  onEntryDraft,
2679
+ onFieldFocus,
2561
2680
  renderField,
2562
2681
  onSaveEntry,
2563
2682
  readOnly = false,
@@ -2567,6 +2686,7 @@ function EntryDetail({
2567
2686
  onMove,
2568
2687
  collectionPath,
2569
2688
  folderOptions,
2689
+ history,
2570
2690
  active,
2571
2691
  onActivate,
2572
2692
  onCollapse,
@@ -2579,6 +2699,8 @@ function EntryDetail({
2579
2699
  documentActions?: (entry: CmsEntry) => React.ReactNode;
2580
2700
  // Debounced in-flight draft (see ContentBrowserProps.onEntryDraft).
2581
2701
  onEntryDraft?: (draft: EntrySaveChange, entry: CmsEntry) => void;
2702
+ // The focused field's path, or null (see ContentBrowserProps.onFieldFocus).
2703
+ onFieldFocus?: (entryId: string, path: string | null) => void;
2582
2704
  renderField?: RenderField;
2583
2705
  onSaveEntry?: SaveEntry;
2584
2706
  readOnly?: boolean;
@@ -2589,6 +2711,10 @@ function EntryDetail({
2589
2711
  collectionPath?: string;
2590
2712
  // Existing folders (relative to the glob base) offered by the move autocomplete.
2591
2713
  folderOptions?: string[];
2714
+ // The document-history seam. Provided → a history button appears in this
2715
+ // document's header and its column can show the version browser. Omitted →
2716
+ // the header renders exactly as it did before history existed.
2717
+ history?: HistoryApi;
2592
2718
  // Desktop columns: `active` marks the focused column (accent + header press
2593
2719
  // activates it via onActivate); onCollapse/onClose add the header rail/close
2594
2720
  // controls. All omitted on mobile → unchanged single-detail behavior.
@@ -2609,6 +2735,23 @@ function EntryDetail({
2609
2735
  const canRename = !readOnly && !!onRename;
2610
2736
  const canMove = !readOnly && !!onMove && glob.hierarchical;
2611
2737
  const [prompt, setPrompt] = useState<"rename" | "move" | null>(null);
2738
+ // Per-field "your value was replaced" counters, bumped by a restore. Only the
2739
+ // host-supplied body editor reads them (see FieldSlotArgs.resetNonce); every
2740
+ // built-in control is controlled and needs no telling.
2741
+ const [resetNonces, setResetNonces] = useState<Record<string, number>>({});
2742
+
2743
+ // Whether this column is showing the version browser instead of the form.
2744
+ // Deliberately per-column and ephemeral: history is something you open, read
2745
+ // and close, so it lives and dies with the column rather than in the URL.
2746
+ const [showHistory, setShowHistory] = useState(false);
2747
+ // A column reused for another document (the "preview tab" behavior) must not
2748
+ // carry the previous document's history view onto it — you asked to see that
2749
+ // document, not this one's past.
2750
+ const historyFor = useRef(entry.id);
2751
+ if (historyFor.current !== entry.id) {
2752
+ historyFor.current = entry.id;
2753
+ if (showHistory) setShowHistory(false);
2754
+ }
2612
2755
 
2613
2756
  // Both branches share one value map. The title+body branch is modeled as a
2614
2757
  // single body-source field so autosave logic is uniform.
@@ -2791,6 +2934,112 @@ function EntryDetail({
2791
2934
  [commit],
2792
2935
  );
2793
2936
 
2937
+ // Publish a value map the form did NOT arrive at by typing — a restore from a
2938
+ // past version, or a discard back to the saved document.
2939
+ //
2940
+ // Both used to write local state and stop there, which is only half the job in
2941
+ // a live session. The shared document is the source of truth there: every
2942
+ // collab control prefers its binding to the value handed to it, so a replaced
2943
+ // value was displayed for one frame and then overwritten by the room's
2944
+ // unchanged one, and no peer ever saw it. Anything that replaces values
2945
+ // wholesale has to come through here.
2946
+ //
2947
+ // `names` are the fields whose values were replaced; unchanged ones are left
2948
+ // alone so a replacement never churns the room (or nudges a peer's cursor)
2949
+ // over a value that did not move.
2950
+ const publishReplacement = useCallback(
2951
+ (previous: Record<string, unknown>, next: Record<string, unknown>, names: string[]) => {
2952
+ const moved = names.filter((name) => !sameValue(previous[name], next[name]));
2953
+ if (moved.length === 0) return;
2954
+
2955
+ if (entry.collab) {
2956
+ for (const name of moved) {
2957
+ // A body is a ProseMirror fragment rather than a field binding, so it
2958
+ // is not written here — its editor is told instead, by the nonce below.
2959
+ if (editFields.find((f) => f.name === name)?.source === "body") continue;
2960
+ writeCollabValue(entry.collab, name, previous[name], next[name]);
2961
+ }
2962
+ }
2963
+
2964
+ // Tell the replaced fields their value came from outside. Keyed per field
2965
+ // rather than one counter for the document: the body editor acts on this,
2966
+ // and replacing some unrelated field must not make it rebuild a body a
2967
+ // peer may be mid-sentence in.
2968
+ setResetNonces((prev) => {
2969
+ const bumped = { ...prev };
2970
+ for (const name of moved) bumped[name] = (bumped[name] ?? 0) + 1;
2971
+ return bumped;
2972
+ });
2973
+ },
2974
+ // editFields is derived from entry, captured by id.
2975
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2976
+ [entry.id, entry.collab],
2977
+ );
2978
+
2979
+ // Values taken from a past version and put back into the form.
2980
+ //
2981
+ // It is an edit like any other: the restored fields land in `values`, the
2982
+ // document goes unsaved, and the author saves it (or discards it) themselves.
2983
+ // Nothing is written to git here — history is a place you read, and rewriting
2984
+ // the document from it should still pass through the same review a typed edit
2985
+ // does.
2986
+ const restoreValues = useCallback(
2987
+ (restored: { fields: Record<string, unknown>; body?: string | null }) => {
2988
+ const previous = valuesRef.current;
2989
+ const next = { ...previous, ...restored.fields };
2990
+ // The body is a field in the form like any other; which one it is comes
2991
+ // from the schema (`source: "body"`), the same mapping buildChange uses in
2992
+ // the other direction. A document whose model has no body field simply
2993
+ // has no body to restore.
2994
+ const bodyField = "body" in restored ? editFields.find((f) => f.source === "body") : undefined;
2995
+ if (bodyField) next[bodyField.name] = restored.body ?? "";
2996
+
2997
+ const replaced = Object.keys(restored.fields);
2998
+ if (bodyField) replaced.push(bodyField.name);
2999
+
3000
+ // Touched under the first restored name so a restore that leaves a
3001
+ // required field empty shows its error immediately, rather than looking
3002
+ // saveable until Save is pressed.
3003
+ //
3004
+ // Committed BEFORE publishing: a collab control observing the room echoes
3005
+ // what it sees back through onChange, and that echo builds its next map by
3006
+ // spreading the current one. Publishing first would have it spread the
3007
+ // pre-restore map.
3008
+ commit(next, replaced[0] ?? SYNTHETIC_BODY);
3009
+ publishReplacement(previous, next, replaced);
3010
+ // Drilled-into groups address the pre-restore shape; a restored group
3011
+ // object can have different children entirely.
3012
+ setGroupPath([]);
3013
+ },
3014
+ // editFields is derived from entry, which commit already captures by id.
3015
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3016
+ [commit, entry.id, publishReplacement],
3017
+ );
3018
+
3019
+ // Throw away everything unsaved and go back to the document as stored.
3020
+ //
3021
+ // In a live session that is a change to the ROOM, not just to this screen: the
3022
+ // unsaved work is the collective state every client is looking at, and a
3023
+ // discard that only reverted the local form would leave the document it just
3024
+ // claimed to restore sitting in the CRDT, ready to come straight back.
3025
+ const discardEdits = useCallback(() => {
3026
+ const previous = valuesRef.current;
3027
+ const restored = seed();
3028
+ valuesRef.current = restored;
3029
+ setValues(restored);
3030
+ setTouched({});
3031
+ // The preview is rendering the draft being thrown away, and nothing else
3032
+ // will tell it otherwise: it is fed from edits, and a discard is the one
3033
+ // change to a document that produces no edit. Without this the preview
3034
+ // keeps showing work the author just deleted, until they type again.
3035
+ emitDraft(buildChange(entry.id, editFields, restored), entry);
3036
+ discard();
3037
+ publishReplacement(previous, restored, editFields.map((f) => f.name));
3038
+ setGroupPath([]);
3039
+ // seed/editFields derive from entry, captured by id.
3040
+ // eslint-disable-next-line react-hooks/exhaustive-deps
3041
+ }, [entry.id, discard, emitDraft, publishReplacement]);
3042
+
2794
3043
  // A field edit within the active group-drill frame: write the value at the
2795
3044
  // frame's nested path, then commit the whole map (buildChange serializes it).
2796
3045
  const changeInFrame = useCallback(
@@ -2805,8 +3054,54 @@ function EntryDetail({
2805
3054
  // The field list + nested value object for the active frame.
2806
3055
  const frame = resolveFrame(editFields, values, groupPath);
2807
3056
 
3057
+ // Which field has the keyboard. One pair of DOM focus listeners on the
3058
+ // column rather than an onFocus on every control: the controls are many
3059
+ // (inputs, selects, the ProseMirror body, plugin fields) and every one of
3060
+ // them renders inside the FieldControl wrapper that carries the path as
3061
+ // `data-cms-field` — so the element the focus landed in is enough.
3062
+ //
3063
+ // focusout fires before the next focusin, so a blur is reported a tick
3064
+ // late and cancelled if focus went straight to another field: moving
3065
+ // between two controls reads as one change, not a flicker through null.
3066
+ const focusRoot = useRef<View>(null);
3067
+ const fieldFocus = useRef(onFieldFocus);
3068
+ fieldFocus.current = onFieldFocus;
3069
+ const entryId = entry.id;
3070
+ useEffect(() => {
3071
+ if (Platform.OS !== "web") return;
3072
+ const node = focusRoot.current as unknown as HTMLElement | null;
3073
+ if (!node || typeof node.addEventListener !== "function") return;
3074
+ let pending: ReturnType<typeof setTimeout> | null = null;
3075
+ let last: string | null = null;
3076
+ const report = (path: string | null) => {
3077
+ if (path === last) return;
3078
+ last = path;
3079
+ fieldFocus.current?.(entryId, path);
3080
+ };
3081
+ const onFocusIn = (ev: Event) => {
3082
+ if (pending) { clearTimeout(pending); pending = null; }
3083
+ const target = ev.target as Element | null;
3084
+ const el = target && typeof target.closest === "function" ? target.closest("[data-cms-field]") : null;
3085
+ report(el?.getAttribute("data-cms-field") || null);
3086
+ };
3087
+ const onFocusOut = () => {
3088
+ if (pending) clearTimeout(pending);
3089
+ pending = setTimeout(() => { pending = null; report(null); }, 0);
3090
+ };
3091
+ node.addEventListener("focusin", onFocusIn);
3092
+ node.addEventListener("focusout", onFocusOut);
3093
+ return () => {
3094
+ node.removeEventListener("focusin", onFocusIn);
3095
+ node.removeEventListener("focusout", onFocusOut);
3096
+ if (pending) clearTimeout(pending);
3097
+ // The column is going away with the field still focused: nothing in
3098
+ // this document has focus any more.
3099
+ if (last !== null) fieldFocus.current?.(entryId, null);
3100
+ };
3101
+ }, [entryId]);
3102
+
2808
3103
  return (
2809
- <View style={{ flex: 1 }}>
3104
+ <View style={{ flex: 1 }} ref={focusRoot}>
2810
3105
  {/* breadcrumb + actions. position/zIndex lift this row (and the "..." menu
2811
3106
  dropdown it hosts) above the content pane so the menu receives clicks. */}
2812
3107
  <View
@@ -2854,19 +3149,7 @@ function EntryDetail({
2854
3149
  {saveHandler && dirty ? (
2855
3150
  <Pressable
2856
3151
  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
- }}
3152
+ onPress={discardEdits}
2870
3153
  style={{ paddingHorizontal: t.space(2), paddingVertical: t.space(1) }}
2871
3154
  >
2872
3155
  <Text variant="monoSm" color="tertiary">Discard</Text>
@@ -2894,6 +3177,20 @@ function EntryDetail({
2894
3177
  document's own actions, while the menu holds the destructive and
2895
3178
  path-changing ones that should stay one level down. */}
2896
3179
  {documentActions?.(entry)}
3180
+ {/* History is a read of this document, not a change to it, so it sits
3181
+ out here with the other non-destructive actions rather than in the
3182
+ menu beside Delete. It toggles: pressing it again returns the column
3183
+ to the form. */}
3184
+ {history ? (
3185
+ <IconButton
3186
+ name="history"
3187
+ size="sm"
3188
+ active={showHistory}
3189
+ label={showHistory ? "Close history" : "Document history"}
3190
+ onPress={() => setShowHistory((v) => !v)}
3191
+ testID="document-history-toggle"
3192
+ />
3193
+ ) : null}
2897
3194
  {canRename || canMove || (canDelete && onDeleteEntry) ? (
2898
3195
  <DetailMenu
2899
3196
  onRename={canRename ? () => setPrompt("rename") : undefined}
@@ -2909,10 +3206,35 @@ function EntryDetail({
2909
3206
  ) : null}
2910
3207
  </View>
2911
3208
 
2912
- {/* Keyed by entry.id so controls (incl. the markdown editor) remount with
3209
+ {/* History takes over the column's body, keeping its header: the header
3210
+ is what says which document you are looking at, and it carries the
3211
+ control that got you here and gets you back. */}
3212
+ {showHistory && history ? (
3213
+ <DocumentHistory
3214
+ documentId={entry.id}
3215
+ documentPath={entry.path}
3216
+ history={history}
3217
+ onClose={() => setShowHistory(false)}
3218
+ // Restoring needs somewhere for the values to go: a document that is
3219
+ // read-only, or that this host never wired a save for, gets the
3220
+ // history with no selection boxes at all rather than a form it can
3221
+ // dirty and never save. (saveHandler is already undefined when
3222
+ // readOnly, so this covers both.)
3223
+ onRestore={
3224
+ !saveHandler
3225
+ ? undefined
3226
+ : (restored) => {
3227
+ restoreValues(restored);
3228
+ // Back to the form, which is where the restored values now
3229
+ // are and where the decision to keep them is made.
3230
+ setShowHistory(false);
3231
+ }
3232
+ }
3233
+ />
3234
+ ) : /* Keyed by entry.id so controls (incl. the markdown editor) remount with
2913
3235
  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 ? (
3236
+ document changes. The header above stays put; only the body scrolls. */
3237
+ hasFields ? (
2916
3238
  // The document's path reaches media fields at any nesting depth through
2917
3239
  // context: a picker inside a group or a list item needs it to interpret
2918
3240
  // relative references, and drilling it through every level would touch
@@ -2935,6 +3257,8 @@ function EntryDetail({
2935
3257
  <Text variant="monoSm" color="tertiary">{frame.labels.join(" / ")}</Text>
2936
3258
  </Pressable>
2937
3259
  ) : null}
3260
+ {/* Drilled into a group, every field's path starts with the group's. */}
3261
+ <FieldPathContext.Provider value={groupPath.join(".")}>
2938
3262
  {frame.fields.map((f) => (
2939
3263
  <FieldControl
2940
3264
  key={f.name}
@@ -2950,11 +3274,16 @@ function EntryDetail({
2950
3274
  collab={entry.collab}
2951
3275
  path={[...groupPath, f.name].join(".")}
2952
3276
  focusSignal={focusTarget && focusTarget.name === f.name ? focusTarget.nonce : undefined}
3277
+ resetNonce={resetNonces[f.name]}
2953
3278
  />
2954
3279
  ))}
3280
+ </FieldPathContext.Provider>
2955
3281
  </ScrollView>
2956
3282
  </DocumentPathProvider>
2957
3283
  ) : (
3284
+ // The body editor resolves the images it embeds relative to this
3285
+ // document, the same way a media field does.
3286
+ <DocumentPathProvider path={entry.path}>
2958
3287
  <View key={entry.id} style={{ padding: t.space(5), gap: t.space(4), flex: 1 }}>
2959
3288
  {saveHandler && error ? <FormAlert message={error} onDismiss={clearError} /> : null}
2960
3289
  <View style={{ gap: t.space(2) }}>
@@ -2968,6 +3297,7 @@ function EntryDetail({
2968
3297
  readOnly={readOnly}
2969
3298
  />
2970
3299
  </View>
3300
+ </DocumentPathProvider>
2971
3301
  )}
2972
3302
 
2973
3303
  {prompt === "rename" && onRename ? (
@@ -3029,27 +3359,6 @@ function useMultiSelect(resetKey: string) {
3029
3359
  return { checked, toggle, clear, checkedIds, checkedFolders, toggleFolder, checkedFolderKeys };
3030
3360
  }
3031
3361
 
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
3362
  // SelectionBar appears above the entry list when rows are checked; it offers a
3054
3363
  // bulk move and/or delete plus a clear action (each shown only when wired).
3055
3364
  function SelectionBar({ count, onMove, onDelete, onClear }: { count: number; onMove?: () => void; onDelete?: () => void; onClear: () => void }) {
@@ -3192,7 +3501,7 @@ function EntriesList({
3192
3501
  <View style={{ flexDirection: "row", alignItems: "center" }}>
3193
3502
  {showChecks ? (
3194
3503
  <View style={{ paddingLeft: t.space(4) }}>
3195
- <CheckBox value={!!checked?.[e.id]} onToggle={() => onToggleEntry?.(e.id)} />
3504
+ <CheckBox value={!!checked?.[e.id]} onToggle={() => onToggleEntry?.(e.id)} testID="entry-checkbox" />
3196
3505
  </View>
3197
3506
  ) : null}
3198
3507
  <View style={{ flex: 1 }}>
@@ -3567,9 +3876,28 @@ function ReadOnlyBanner({ notice, actionLabel, onAction }: { notice?: string; ac
3567
3876
  );
3568
3877
  }
3569
3878
 
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.
3879
+ // ModalOverlays renders the browser's full-screen modals — apply changes /
3880
+ // change request, and the protected-branch save prompt. Shared by both layouts
3881
+ // so every modal is reachable on mobile too.
3882
+ function ModalOverlays(props: ContentBrowserProps) {
3883
+ return (
3884
+ <>
3885
+ <ApplyModalOverlay {...props} />
3886
+ {props.protectedBranch ? (
3887
+ <ProtectedBranchModal
3888
+ branch={props.protectedBranch}
3889
+ documentLabel={props.protectedBranchDocument}
3890
+ suggestedName={props.protectedBranchSuggestedName}
3891
+ busy={props.creatingProtectedBranch}
3892
+ error={props.protectedBranchError}
3893
+ onCreate={props.onCreateBranchAndSave ?? (() => {})}
3894
+ onCancel={props.onCancelProtectedSave ?? (() => {})}
3895
+ />
3896
+ ) : null}
3897
+ </>
3898
+ );
3899
+ }
3900
+
3573
3901
  function ApplyModalOverlay(props: ContentBrowserProps) {
3574
3902
  if (!props.applyOpen) return null;
3575
3903
  return (
@@ -3577,6 +3905,7 @@ function ApplyModalOverlay(props: ContentBrowserProps) {
3577
3905
  sourceBranch={props.workspace.branch}
3578
3906
  targetBranch={props.targetBranch ?? props.defaultBranch ?? ""}
3579
3907
  summary={props.applySummary}
3908
+ reviewReason={props.applyReviewReason}
3580
3909
  progress={props.applyProgress}
3581
3910
  conflictCount={props.applyConflictCount}
3582
3911
  changeRequestOpen={props.changeRequestOpen}
@@ -3937,7 +4266,9 @@ function DesktopBrowser(props: ContentBrowserProps) {
3937
4266
  <EntryDetail
3938
4267
  entry={col.entry}
3939
4268
  documentActions={props.documentActions}
4269
+ history={props.history}
3940
4270
  onEntryDraft={props.onEntryDraft}
4271
+ onFieldFocus={props.onFieldFocus}
3941
4272
  renderField={renderField}
3942
4273
  onSaveEntry={props.onSaveEntry}
3943
4274
  readOnly={col.readOnly}
@@ -4066,7 +4397,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4066
4397
  const readOnlyBanner = (
4067
4398
  <ReadOnlyBanner notice={props.readOnlyNotice} actionLabel={props.readOnlyNoticeActionLabel} onAction={props.onReadOnlyNoticeAction} />
4068
4399
  );
4069
- const applyModal = <ApplyModalOverlay {...props} />;
4400
+ const modalOverlays = <ModalOverlays {...props} />;
4070
4401
 
4071
4402
  // The changes surface reuses the same three-pane model as Edit, so switching
4072
4403
  // between them doesn't relayout the screen — only what each pane contains
@@ -4119,7 +4450,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4119
4450
  conflicts={props.selectedConflicts}
4120
4451
  onResolveConflict={props.onResolveConflict}
4121
4452
  />
4122
- {applyModal}
4453
+ {modalOverlays}
4123
4454
  </AppShell>
4124
4455
  );
4125
4456
  }
@@ -4134,7 +4465,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4134
4465
  <Pane flex={1} testID="pane-plugin" scroll={false}>
4135
4466
  {props.contentSlot}
4136
4467
  </Pane>
4137
- {applyModal}
4468
+ {modalOverlays}
4138
4469
  </AppShell>
4139
4470
  );
4140
4471
  }
@@ -4143,11 +4474,41 @@ function DesktopBrowser(props: ContentBrowserProps) {
4143
4474
  // lists the media sets exactly where a collection's documents would be, and the
4144
4475
  // details pane holds the browser for whichever set is selected. Selecting a set
4145
4476
  // goes through onSelectNav, so it lands in the URL like any other selection.
4146
- // Forms take the whole content area rather than the three-pane model: the
4147
- // surface is already two levels deep (forms → submissions → one submission),
4148
- // and threading that through panes designed for collection → document → editor
4149
- // would mean a pane whose meaning changes with the level.
4477
+ // Forms enter through the same content column every other surface uses: the
4478
+ // list of forms sits exactly where a collection's documents would, at the
4479
+ // list width, with the empty state beside it. A list of half a dozen rows
4480
+ // stretched across everything after the sidebar reads as a different kind of
4481
+ // screen than Edit, Changes and Media, when it is the same kind of screen.
4482
+ //
4483
+ // Opening a form is where forms stop fitting the three-pane model, and so it
4484
+ // is where they leave it: that surface is two more levels deep (submissions →
4485
+ // one submission), and threading those through panes meant for collection →
4486
+ // document → editor would give a pane whose meaning changes with the level.
4487
+ // FormsBrowser owns its own split from there.
4150
4488
  if (formsShowing && props.forms) {
4489
+ if (!activeForm) {
4490
+ return (
4491
+ <AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
4492
+ {navPane}
4493
+ <ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
4494
+
4495
+ <Pane width={listW} testID="pane-forms" scroll={false} header={<PaneTitle title="Forms" />}>
4496
+ <FormsBrowser
4497
+ api={props.forms}
4498
+ form={null}
4499
+ onSelectForm={(name) => onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY)}
4500
+ variant="desktop"
4501
+ />
4502
+ </Pane>
4503
+ <ResizeHandle width={listW} min={260} max={640} onChange={setListW} testID="resize-forms" />
4504
+
4505
+ <View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
4506
+ <Text variant="body" color="tertiary" testID="forms-empty">Select a form</Text>
4507
+ </View>
4508
+ {modalOverlays}
4509
+ </AppShell>
4510
+ );
4511
+ }
4151
4512
  return (
4152
4513
  <AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
4153
4514
  {navPane}
@@ -4162,7 +4523,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4162
4523
  variant="desktop"
4163
4524
  />
4164
4525
  </Pane>
4165
- {applyModal}
4526
+ {modalOverlays}
4166
4527
  </AppShell>
4167
4528
  );
4168
4529
  }
@@ -4189,7 +4550,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4189
4550
  <Text variant="body" color="tertiary" testID="media-sets-empty">Select a media collection</Text>
4190
4551
  </View>
4191
4552
  )}
4192
- {applyModal}
4553
+ {modalOverlays}
4193
4554
  </AppShell>
4194
4555
  );
4195
4556
  }
@@ -4390,7 +4751,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4390
4751
  onClose={() => setMovePrompt(false)}
4391
4752
  />
4392
4753
  ) : null}
4393
- {applyModal}
4754
+ {modalOverlays}
4394
4755
  </AppShell>
4395
4756
  );
4396
4757
  }
@@ -4466,7 +4827,7 @@ function MobileBrowser(props: ContentBrowserProps) {
4466
4827
  const readOnlyBanner = (
4467
4828
  <ReadOnlyBanner notice={props.readOnlyNotice} actionLabel={props.readOnlyNoticeActionLabel} onAction={props.onReadOnlyNoticeAction} />
4468
4829
  );
4469
- const applyModal = <ApplyModalOverlay {...props} />;
4830
+ const modalOverlays = <ModalOverlays {...props} />;
4470
4831
 
4471
4832
  // Media drills the same way a collection does — nav → list → detail — so the
4472
4833
  // back arrow means the same thing at every level. Pressing Media lists the
@@ -4592,7 +4953,7 @@ function MobileBrowser(props: ContentBrowserProps) {
4592
4953
  testID="mobile-shell"
4593
4954
  header={header}
4594
4955
  title={props.surface === "changes" ? "Changes" : "Content"}
4595
- overlay={applyModal}
4956
+ overlay={modalOverlays}
4596
4957
  >
4597
4958
  {/* Edit / Changes surface toggle (fits its content) with the Apply button
4598
4959
  to its right on the changes surface. The read-only notice sits below. */}
@@ -4673,7 +5034,7 @@ function MobileBrowser(props: ContentBrowserProps) {
4673
5034
  testID="mobile-entries"
4674
5035
  scroll={false}
4675
5036
  banner={readOnlyBanner}
4676
- overlay={applyModal}
5037
+ overlay={modalOverlays}
4677
5038
  header={
4678
5039
  search.open ? (
4679
5040
  <SearchHeaderBar search={search} showFilter={facetFields.length > 0} />
@@ -4754,7 +5115,7 @@ function MobileBrowser(props: ContentBrowserProps) {
4754
5115
  <MobileScreen
4755
5116
  testID="mobile-entry"
4756
5117
  banner={readOnlyBanner}
4757
- overlay={applyModal}
5118
+ overlay={modalOverlays}
4758
5119
  header={
4759
5120
  <>
4760
5121
  <IconButton name="chevronLeft" onPress={backToEntries} size="md" label="Back" />
@@ -4782,7 +5143,9 @@ function MobileBrowser(props: ContentBrowserProps) {
4782
5143
  <EntryDetail
4783
5144
  entry={entry}
4784
5145
  documentActions={props.documentActions}
5146
+ history={props.history}
4785
5147
  onEntryDraft={props.onEntryDraft}
5148
+ onFieldFocus={props.onFieldFocus}
4786
5149
  renderField={renderField}
4787
5150
  onSaveEntry={props.onSaveEntry}
4788
5151
  readOnly={readOnly}
@@ -5052,14 +5415,20 @@ function mediaSetLabel(sets: MediaSetInfo[], name: string): string {
5052
5415
  return set ? titleCaseWord(set.name) : titleCaseWord(name);
5053
5416
  }
5054
5417
 
5055
- // PaneTitle is the content pane's heading, matching the entry list's header
5056
- // height so the panes line up when switching between content and media.
5418
+ // PaneTitle is the content pane's heading for the surfaces with no controls
5419
+ // beside it — Changes, Media, Forms.
5420
+ //
5421
+ // Deliberately the same Text the collection header uses (`listHeader`), and
5422
+ // nothing else. Pane's header row already supplies the height, the horizontal
5423
+ // padding and the rule beneath, so the padded wrapper this used to add sat
5424
+ // inside that padding and pushed the title a step right of every collection's —
5425
+ // at `body` rather than `h3`, so it read a size smaller too. Switching between
5426
+ // Posts and Media moved the heading twice over.
5057
5427
  function PaneTitle({ title }: { title: string }) {
5058
- const t = useTheme();
5059
5428
  return (
5060
- <View style={{ paddingHorizontal: t.space(4), paddingVertical: t.space(3) }}>
5061
- <Text variant="body" weight="semibold">{title}</Text>
5062
- </View>
5429
+ <Text variant="h3" weight="semibold" style={{ flex: 1 }}>
5430
+ {title}
5431
+ </Text>
5063
5432
  );
5064
5433
  }
5065
5434