@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.
@@ -4,10 +4,11 @@ import { useTheme, useThemeMode, ThemeScope } from "../ThemeProvider";
4
4
  import { lightTheme, darkTheme } from "../theme";
5
5
  import { Text } from "./Text";
6
6
  import { Icon } from "./Icon";
7
+ import type { IconName } from "../icons";
7
8
  import { Spinner } from "./Spinner";
8
9
 
9
10
  // One notification, shaped for display. Transport-agnostic: the frontend/mobile
10
- // apps map GraphQL rows and the desktop maps REST rows into this.
11
+ // apps map GraphQL rows and the desktop and dashboard map REST rows into this.
11
12
  export type NotificationItem = {
12
13
  id: string;
13
14
  title: string;
@@ -16,8 +17,39 @@ export type NotificationItem = {
16
17
  read: boolean;
17
18
  createdAt: string; // ISO 8601
18
19
  author?: string; // display name/email of who caused it, when known
20
+ // What produced it — picks the icon. Unknown kinds fall back to the bell.
21
+ kind?: NotificationKind | string;
22
+ // Where it happened, for a list that spans several repositories/projects.
23
+ // Omit whichever the list is already scoped to.
24
+ repository?: string;
25
+ project?: string;
19
26
  };
20
27
 
28
+ export type NotificationKind = "import" | "export" | "merge" | "change_request" | "member" | "branch" | "project";
29
+
30
+ // The icon for a notification kind. Exported so hosts that draw their own rows
31
+ // (a scope picker's section headers, say) agree with the list.
32
+ export function notificationKindIcon(kind?: string): IconName {
33
+ switch (kind) {
34
+ case "import":
35
+ return "download";
36
+ case "export":
37
+ return "upload";
38
+ case "merge":
39
+ return "gitMerge";
40
+ case "change_request":
41
+ return "gitPullRequest";
42
+ case "member":
43
+ return "user";
44
+ case "branch":
45
+ return "gitBranch";
46
+ case "project":
47
+ return "folder";
48
+ default:
49
+ return "bell";
50
+ }
51
+ }
52
+
21
53
  // relativeTime renders a compact age ("just now", "5m", "3h", "2d").
22
54
  function relativeTime(iso: string): string {
23
55
  const then = Date.parse(iso);
@@ -32,7 +64,65 @@ function relativeTime(iso: string): string {
32
64
  return `${days}d`;
33
65
  }
34
66
 
35
- // A single notification row: an unread dot + level-tinted title, body, and age.
67
+ // InlineCode renders a sentence whose backticked spans (`main`, `#12`'s
68
+ // branch) are set in the mono face, so a title like "Created `zyx` branch"
69
+ // reads as prose around a name rather than as punctuation. An unmatched
70
+ // backtick is left as typed.
71
+ export function InlineCode({
72
+ text,
73
+ variant = "sm",
74
+ weight,
75
+ color,
76
+ numberOfLines,
77
+ testID,
78
+ }: {
79
+ text: string;
80
+ variant?: "sm" | "body" | "monoSm" | "xs";
81
+ weight?: "regular" | "medium" | "semibold";
82
+ color?: string;
83
+ numberOfLines?: number;
84
+ testID?: string;
85
+ }) {
86
+ const t = useTheme();
87
+ const parts = text.split("`");
88
+ // An even number of parts means an odd number of backticks: not a pair, so
89
+ // render the text untouched.
90
+ if (parts.length % 2 === 0) {
91
+ return (
92
+ <Text variant={variant} weight={weight} color={color} numberOfLines={numberOfLines} testID={testID}>
93
+ {text}
94
+ </Text>
95
+ );
96
+ }
97
+ return (
98
+ <Text variant={variant} weight={weight} color={color} numberOfLines={numberOfLines} testID={testID}>
99
+ {parts.map((part, i) =>
100
+ i % 2 === 1 ? (
101
+ <Text
102
+ key={i}
103
+ variant={variant}
104
+ weight={weight}
105
+ color={color}
106
+ mono
107
+ style={{
108
+ backgroundColor: t.color.surfaceSunken,
109
+ borderRadius: t.radius.sm,
110
+ paddingHorizontal: 3,
111
+ }}
112
+ >
113
+ {part}
114
+ </Text>
115
+ ) : (
116
+ part
117
+ ),
118
+ )}
119
+ </Text>
120
+ );
121
+ }
122
+
123
+ // A single notification row: a kind icon, the title (unread rows in bold, error
124
+ // rows tinted), the body, and a context line — where it happened and who did
125
+ // it — with the age on the right.
36
126
  function NotificationRow({
37
127
  item,
38
128
  divided,
@@ -44,12 +134,16 @@ function NotificationRow({
44
134
  }) {
45
135
  const t = useTheme();
46
136
  const titleColor = item.level === "error" ? t.color.diffDelFg : t.color.textPrimary;
137
+ const context = [item.repository, item.project].filter(Boolean).join(" · ");
138
+ const meta = [context, item.author ? `by ${item.author}` : ""].filter(Boolean).join(" · ");
47
139
  return (
48
140
  <Pressable
49
141
  onPress={onPress}
142
+ disabled={!onPress}
143
+ testID={`notification-${item.id}`}
50
144
  style={({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => ({
51
145
  flexDirection: "row",
52
- gap: t.space(2.5),
146
+ gap: t.space(3),
53
147
  paddingHorizontal: t.space(4),
54
148
  paddingVertical: t.space(3),
55
149
  borderBottomWidth: divided ? 1 : 0,
@@ -57,33 +151,57 @@ function NotificationRow({
57
151
  backgroundColor: hovered || pressed ? t.color.surfaceHover : "transparent",
58
152
  })}
59
153
  >
60
- <View
61
- style={{
62
- width: 8,
63
- height: 8,
64
- marginTop: 5,
65
- borderRadius: t.radius.pill,
66
- backgroundColor: item.read ? "transparent" : t.color.diffDelFg,
67
- }}
68
- />
69
- <View style={{ flex: 1, gap: 2 }}>
70
- <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
71
- <Text
72
- variant="sm"
73
- weight={item.read ? "medium" : "semibold"}
74
- color={titleColor}
75
- numberOfLines={2}
76
- style={{ flex: 1 }}
77
- >
78
- {item.title}
79
- </Text>
154
+ <View style={{ width: 28, height: 28, alignItems: "center", justifyContent: "center", position: "relative" }}>
155
+ <View
156
+ style={{
157
+ width: 28,
158
+ height: 28,
159
+ borderRadius: t.radius.pill,
160
+ alignItems: "center",
161
+ justifyContent: "center",
162
+ backgroundColor: item.level === "error" ? t.color.diffDelBg : t.color.surfaceSunken,
163
+ }}
164
+ >
165
+ <Icon
166
+ name={notificationKindIcon(item.kind)}
167
+ size={14}
168
+ color={item.level === "error" ? t.color.diffDelFg : t.color.textSecondary}
169
+ />
170
+ </View>
171
+ {/* Unread dot, riding on the icon's corner. */}
172
+ {item.read ? null : (
173
+ <View
174
+ testID="notification-unread"
175
+ style={{
176
+ position: "absolute",
177
+ top: -1,
178
+ right: -1,
179
+ width: 8,
180
+ height: 8,
181
+ borderRadius: t.radius.pill,
182
+ backgroundColor: t.color.diffDelFg,
183
+ borderWidth: 1,
184
+ borderColor: t.color.surfaceRaised,
185
+ }}
186
+ />
187
+ )}
188
+ </View>
189
+ <View style={{ flex: 1, gap: 2, minWidth: 0 }}>
190
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: t.space(2) }}>
191
+ <View style={{ flex: 1, minWidth: 0 }}>
192
+ <InlineCode
193
+ text={item.title}
194
+ variant="sm"
195
+ weight={item.read ? "medium" : "semibold"}
196
+ color={titleColor}
197
+ numberOfLines={2}
198
+ />
199
+ </View>
80
200
  <Text variant="monoSm" color="tertiary">{relativeTime(item.createdAt)}</Text>
81
201
  </View>
82
- {item.body ? (
83
- <Text variant="monoSm" color="secondary" numberOfLines={2}>{item.body}</Text>
84
- ) : null}
85
- {item.author ? (
86
- <Text variant="monoSm" color="tertiary" numberOfLines={1}>{`by ${item.author}`}</Text>
202
+ {item.body ? <InlineCode text={item.body} variant="monoSm" color={t.color.textSecondary} numberOfLines={2} /> : null}
203
+ {meta ? (
204
+ <Text variant="monoSm" color="tertiary" numberOfLines={1}>{meta}</Text>
87
205
  ) : null}
88
206
  </View>
89
207
  </Pressable>
@@ -0,0 +1,136 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { View, Pressable } from "react-native";
3
+ import { useTheme } from "../ThemeProvider";
4
+ import { Text } from "./Text";
5
+ import { Icon } from "./Icon";
6
+ import { Input } from "./Input";
7
+ import { Button } from "./Button";
8
+
9
+ export type ProtectedBranchModalProps = {
10
+ /** The protected branch the save was refused on. */
11
+ branch: string;
12
+ /** The document that could not be saved, for naming what is at stake. */
13
+ documentLabel?: string;
14
+ /** Prefills the field — the host derives it from the document. */
15
+ suggestedName?: string;
16
+ /** True while the branch is being created and the save applied. */
17
+ busy?: boolean;
18
+ /** The server's message, when the attempt failed. */
19
+ error?: string | null;
20
+ /** Create `name` from `branch` and save the pending edit onto it. */
21
+ onCreate: (name: string) => void;
22
+ /** Dismiss. The edit stays as an unsaved draft on the protected branch. */
23
+ onCancel: () => void;
24
+ };
25
+
26
+ /**
27
+ * Shown when a save is refused because the branch is protected.
28
+ *
29
+ * It is a prompt rather than an error banner because the refusal has exactly one
30
+ * remedy and the editor knows what it is: the work is intact, it just needs
31
+ * somewhere it can land. Presenting that as "here is a message, now go find the
32
+ * branch menu" would make the author reconstruct a plan the product already has.
33
+ *
34
+ * The draft is deliberately NOT discarded on cancel — a protected branch is
35
+ * often noticed mid-thought, and the author may want to keep writing and pick a
36
+ * branch name later. It stays in storage exactly as any other unsaved work does.
37
+ */
38
+ export function ProtectedBranchModal({
39
+ branch, documentLabel, suggestedName, busy, error, onCreate, onCancel,
40
+ }: ProtectedBranchModalProps) {
41
+ const t = useTheme();
42
+ const [name, setName] = useState(suggestedName ?? "");
43
+ // The suggestion is derived from the document, which the host may resolve a
44
+ // render after the modal opens. Adopted only while the field is untouched.
45
+ const [touched, setTouched] = useState(false);
46
+ useEffect(() => {
47
+ if (!touched && suggestedName) setName(suggestedName);
48
+ }, [suggestedName, touched]);
49
+
50
+ const trimmed = name.trim();
51
+ const submit = () => {
52
+ if (!trimmed || busy) return;
53
+ onCreate(trimmed);
54
+ };
55
+
56
+ return (
57
+ <View
58
+ testID="protected-branch-modal"
59
+ style={{
60
+ position: "absolute", top: 0, left: 0, right: 0, bottom: 0, zIndex: 50,
61
+ alignItems: "center", justifyContent: "center", padding: t.space(4),
62
+ backgroundColor: "rgba(0,0,0,0.45)",
63
+ }}
64
+ >
65
+ <View
66
+ style={{
67
+ width: 460, maxWidth: "100%", gap: t.space(4), padding: t.space(5),
68
+ borderRadius: t.radius.lg, borderWidth: 1, borderColor: t.color.borderDefault,
69
+ backgroundColor: t.color.surfaceRaised,
70
+ }}
71
+ >
72
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
73
+ <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
74
+ <Icon name="lock" size={16} color={t.color.textSecondary} />
75
+ <Text variant="h3" weight="semibold">This branch is protected</Text>
76
+ </View>
77
+ {busy ? null : (
78
+ <Pressable
79
+ testID="protected-branch-close"
80
+ accessibilityRole="button"
81
+ accessibilityLabel="Cancel"
82
+ onPress={onCancel}
83
+ style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
84
+ >
85
+ <Icon name="x" size={18} color={t.color.textSecondary} />
86
+ </Pressable>
87
+ )}
88
+ </View>
89
+
90
+ <Text variant="body" color="secondary">
91
+ {documentLabel
92
+ ? `${branch} doesn’t accept changes directly, so “${documentLabel}” can’t be saved to it. Name a branch to save it to instead — your edit lands there.`
93
+ : `${branch} doesn’t accept changes directly. Name a branch to save your edit to instead.`}
94
+ </Text>
95
+
96
+ <Input
97
+ label="New branch"
98
+ value={name}
99
+ onChangeText={(v) => { setTouched(true); setName(v); }}
100
+ onSubmitEditing={submit}
101
+ placeholder="feat/my-change"
102
+ autoCapitalize="none"
103
+ autoFocus
104
+ mono
105
+ editable={!busy}
106
+ error={!!error}
107
+ testID="protected-branch-name"
108
+ />
109
+ <Text variant="monoSm" color="tertiary">{`Branched from ${branch}`}</Text>
110
+
111
+ {error ? (
112
+ <Text testID="protected-branch-error" variant="monoSm" color={t.color.diffDelFg}>{error}</Text>
113
+ ) : null}
114
+
115
+ <View style={{ flexDirection: "row", justifyContent: "flex-end", gap: t.space(2) }}>
116
+ <Button
117
+ title="Cancel"
118
+ variant="ghost"
119
+ size="md"
120
+ disabled={busy}
121
+ onPress={onCancel}
122
+ testID="protected-branch-cancel"
123
+ />
124
+ <Button
125
+ title={busy ? "Creating…" : "Create branch and save"}
126
+ variant="primary"
127
+ size="md"
128
+ disabled={busy || trimmed === ""}
129
+ onPress={submit}
130
+ testID="protected-branch-submit"
131
+ />
132
+ </View>
133
+ </View>
134
+ </View>
135
+ );
136
+ }
@@ -1,7 +1,8 @@
1
1
  import React from "react";
2
- import { View, StyleProp, ViewStyle } from "react-native";
2
+ import { View, Pressable, StyleProp, ViewStyle } from "react-native";
3
3
  import { useTheme } from "../ThemeProvider";
4
4
  import { Text } from "./Text";
5
+ import { Icon } from "./Icon";
5
6
 
6
7
  /** Bordered surface card — defined by a hairline border, never a shadow. */
7
8
  export function Card({
@@ -141,3 +142,47 @@ export function SectionLabel({ children, style }: { children: React.ReactNode; s
141
142
  </View>
142
143
  );
143
144
  }
145
+
146
+ /**
147
+ * A square check box: filled and ticked when on, an empty hairline box when
148
+ * off.
149
+ *
150
+ * Lives here rather than beside its first caller because two surfaces select
151
+ * things now — entry rows in the browser, and the fields of a past version in
152
+ * the history diff — and they have to look like the same control. The press
153
+ * stops propagating: every current caller puts this inside a larger Pressable
154
+ * (a list row, a field block), and a tick that also opened the row would be
155
+ * unusable.
156
+ */
157
+ export function CheckBox({
158
+ value,
159
+ onToggle,
160
+ testID = "check-box",
161
+ }: {
162
+ value: boolean;
163
+ onToggle: () => void;
164
+ testID?: string;
165
+ }) {
166
+ const t = useTheme();
167
+ return (
168
+ <Pressable
169
+ onPress={(e?: { stopPropagation?: () => void }) => {
170
+ e?.stopPropagation?.();
171
+ onToggle();
172
+ }}
173
+ testID={testID}
174
+ style={{
175
+ width: 18,
176
+ height: 18,
177
+ borderRadius: t.radius.sm,
178
+ borderWidth: 1,
179
+ borderColor: value ? t.color.borderStrong : t.color.borderDefault,
180
+ backgroundColor: value ? t.color.surfaceInverted : t.color.surfaceRaised,
181
+ alignItems: "center",
182
+ justifyContent: "center",
183
+ }}
184
+ >
185
+ {value ? <Icon name="check" size={12} color={t.color.textInverted} /> : null}
186
+ </Pressable>
187
+ );
188
+ }
package/src/history.ts ADDED
@@ -0,0 +1,82 @@
1
+ import type { DocumentChange } from "./components/ChangeDetail";
2
+
3
+ /**
4
+ * One commit in a document's history — a row in the version list.
5
+ *
6
+ * This is the provider's history for the document's CURRENT path. GitHub's
7
+ * commits API has no equivalent of `git log --follow`, so a renamed document's
8
+ * history stops at the rename; the list says so at its end rather than letting
9
+ * the absence read as "this is where the document began".
10
+ */
11
+ export type DocumentVersion = {
12
+ sha: string;
13
+ /** First 7 characters — an identity without the noise. */
14
+ shortSha: string;
15
+ /** The full commit message; the list shows its first line. */
16
+ message: string;
17
+ authorName: string;
18
+ authorEmail?: string | null;
19
+ /** ISO 8601, in UTC. */
20
+ authoredAt: string;
21
+ /** The commit's page on the provider. */
22
+ url: string;
23
+ /** How many files the commit touched — this document is one of them. */
24
+ filesChanged: number;
25
+ };
26
+
27
+ /**
28
+ * HistoryApi is the data seam between the design system and its host, the same
29
+ * split MediaApi and FormsApi use: the DS owns the version browser's UI and its
30
+ * (ephemeral) open/selected state, the host owns fetching. Omitting it from
31
+ * ContentBrowser hides the history affordance entirely, which is what a
32
+ * deployment with no provider — local mode, the desktop app — should look like.
33
+ */
34
+ export interface HistoryApi {
35
+ /**
36
+ * One page of a document's versions, newest first. The DS pages as the list
37
+ * scrolls; a short page (fewer than `limit`) ends it.
38
+ */
39
+ list: (params: { documentId: string; limit: number; offset: number }) => Promise<DocumentVersion[]>;
40
+ /**
41
+ * The document as it was at one commit, diffed against what it is now:
42
+ * `before` on each field is the value at that version, `after` its current
43
+ * value. Unchanged fields are included, which is what makes the result a
44
+ * readable preview of the whole document rather than a list of differences.
45
+ *
46
+ * Null when the version can't be read (a commit that has gone away under a
47
+ * force push, say) — the pane says so instead of showing an empty document.
48
+ */
49
+ get: (params: { documentId: string; sha: string }) => Promise<DocumentChange | null>;
50
+ }
51
+
52
+ /**
53
+ * firstLine is the summary a version row shows. A commit message's first line is
54
+ * its subject by convention, and the body below it is prose that would push
55
+ * every other row off the screen.
56
+ */
57
+ export function firstLine(message: string): string {
58
+ const line = message.split("\n", 1)[0].trim();
59
+ return line || "(no commit message)";
60
+ }
61
+
62
+ /**
63
+ * relativeTime renders a commit's age the way a history reads — "3d ago" —
64
+ * falling back to the date once "ago" stops being informative.
65
+ *
66
+ * `now` is injectable so a test can assert a fixed string rather than race the
67
+ * clock.
68
+ */
69
+ export function relativeTime(iso: string, now: Date = new Date()): string {
70
+ const then = new Date(iso);
71
+ const seconds = Math.round((now.getTime() - then.getTime()) / 1000);
72
+ if (!Number.isFinite(seconds)) return "";
73
+ if (seconds < 60) return "just now";
74
+ const minutes = Math.round(seconds / 60);
75
+ if (minutes < 60) return `${minutes}m ago`;
76
+ const hours = Math.round(minutes / 60);
77
+ if (hours < 24) return `${hours}h ago`;
78
+ const days = Math.round(hours / 24);
79
+ if (days < 30) return `${days}d ago`;
80
+ // Past a month, "5 weeks ago" is harder to place than the date itself.
81
+ return then.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
82
+ }
package/src/icons.ts CHANGED
@@ -28,6 +28,7 @@ export const icons = {
28
28
  chevronDown: ["m6 9 6 6 6-6"],
29
29
  chevronUp: ["m18 15-6-6-6 6"],
30
30
  folder: ["M3 6a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"],
31
+ user: ["M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2", "M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z"],
31
32
  building: ["M3 21h18", "M6 21V7l7-4v18", "M18 21V11l-5-3"],
32
33
  menu: ["M3 6h18", "M3 12h18", "M3 18h18"],
33
34
  plus: ["M5 12h14", "M12 5v14"],
package/src/index.ts CHANGED
@@ -55,6 +55,10 @@ export type {
55
55
  } from "./components/ApplyChangesModal";
56
56
 
57
57
  // Change-request summary surface (developer escalation)
58
+ export { ProtectedBranchModal } from "./components/ProtectedBranchModal";
59
+ export type { ProtectedBranchModalProps } from "./components/ProtectedBranchModal";
60
+ export { BranchDeletedModal } from "./components/BranchDeletedModal";
61
+ export type { BranchDeletedModalProps } from "./components/BranchDeletedModal";
58
62
  export { ChangeRequestSummary } from "./components/ChangeRequestSummary";
59
63
  export type {
60
64
  ChangeRequestSummaryProps,
@@ -63,7 +67,11 @@ export type {
63
67
  } from "./components/ChangeRequestSummary";
64
68
 
65
69
  // Changes surface: one document's diff between a branch and its base
66
- export { ChangeDetail } from "./components/ChangeDetail";
70
+ export { ChangeDetail, BODY_FIELD } from "./components/ChangeDetail";
71
+ export { DocumentHistory } from "./components/DocumentHistory";
72
+ export type { DocumentHistoryProps } from "./components/DocumentHistory";
73
+ export { firstLine, relativeTime } from "./history";
74
+ export type { DocumentVersion, HistoryApi } from "./history";
67
75
  export type {
68
76
  ChangeDetailProps,
69
77
  DocumentChange,
@@ -109,9 +117,10 @@ export { Spinner } from "./components/Spinner";
109
117
  export type { SpinnerProps } from "./components/Spinner";
110
118
 
111
119
  // Notifications
112
- export { NotificationBell, NotificationList } from "./components/Notifications";
120
+ export { NotificationBell, NotificationList, InlineCode, notificationKindIcon } from "./components/Notifications";
113
121
  export type {
114
122
  NotificationItem,
123
+ NotificationKind,
115
124
  NotificationBellProps,
116
125
  NotificationListProps,
117
126
  } from "./components/Notifications";
@@ -127,7 +136,7 @@ export {
127
136
  } from "./components/layout";
128
137
 
129
138
  // Media: the picker control, its previews, and the host data seam
130
- export { MediaField, MediaPicker, MediaProvider, DocumentPathProvider, useMediaApi, pathForStoreAs } from "./components/MediaField";
139
+ export { MediaField, MediaPicker, MediaProvider, DocumentPathProvider, useMediaApi, useFieldMedia, pathForStoreAs } from "./components/MediaField";
131
140
  export type { MediaFieldProps, MediaPickerProps } from "./components/MediaField";
132
141
  export { MediaPreview, MediaChip } from "./components/MediaPreview";
133
142
  export type { MediaPreviewProps } from "./components/MediaPreview";
@@ -155,6 +164,7 @@ export type {
155
164
  MediaResolution,
156
165
  MediaSetInfo,
157
166
  MediaApi,
167
+ FieldMediaApi,
158
168
  MediaCondition,
159
169
  MediaFilterField,
160
170
  MediaFilterOp,
package/src/media.ts CHANGED
@@ -166,6 +166,21 @@ export interface MediaApi {
166
166
  }) => Promise<MediaAsset>;
167
167
  }
168
168
 
169
+ // FieldMediaApi is the slice of media access a host-supplied field control
170
+ // (the markdown body editor, a plugin field) gets through the renderField slot.
171
+ // It is scoped to one field: the picker opens on that field's set and writes
172
+ // paths in its store_as form, and resolution is relative to the open document.
173
+ // Absent from the slot args when the host has no media wiring.
174
+ export interface FieldMediaApi {
175
+ // Opens the picker. Resolves with the chosen asset plus the string to write
176
+ // for it (in the field's store_as form), or null when the picker is dismissed.
177
+ pick: (opts?: { kinds?: MediaKind[] }) => Promise<{ asset: MediaAsset; path: string } | null>;
178
+ // Resolves reference strings as they appear in the document (an image's src,
179
+ // a frontmatter path) to assets, so a control can show the real file behind a
180
+ // path the CMS origin does not serve.
181
+ resolve: (paths: string[]) => Promise<MediaResolution[]>;
182
+ }
183
+
169
184
  // MediaKind is the render class a preview switches on. It is derived server-side
170
185
  // on import and delivered on every Media object, so clients never re-derive it
171
186
  // from a filename (and never disagree with the server about it).