@gogitcms/design-system 0.15.0-next.3

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 (70) hide show
  1. package/README.md +77 -0
  2. package/css/components.css +312 -0
  3. package/css/tokens.css +212 -0
  4. package/package.json +65 -0
  5. package/src/ThemeProvider.tsx +86 -0
  6. package/src/__tests__/ApplyChangesModal.test.tsx +148 -0
  7. package/src/__tests__/BranchImport.test.tsx +46 -0
  8. package/src/__tests__/Button.test.tsx +45 -0
  9. package/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
  10. package/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
  11. package/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
  12. package/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
  13. package/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
  14. package/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
  15. package/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
  16. package/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
  17. package/src/__tests__/ContentBrowser.media.test.tsx +140 -0
  18. package/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
  19. package/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
  20. package/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
  21. package/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
  22. package/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
  23. package/src/__tests__/ContentBrowser.search.test.tsx +135 -0
  24. package/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
  25. package/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
  26. package/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
  27. package/src/__tests__/MediaBrowser.test.tsx +353 -0
  28. package/src/__tests__/MediaField.test.tsx +185 -0
  29. package/src/__tests__/Notifications.test.tsx +69 -0
  30. package/src/__tests__/Onboarding.test.tsx +287 -0
  31. package/src/__tests__/cssTokens.test.ts +201 -0
  32. package/src/__tests__/fieldComponents.test.ts +43 -0
  33. package/src/__tests__/reorder.test.ts +58 -0
  34. package/src/components/ApplyChangesModal.tsx +348 -0
  35. package/src/components/BranchImport.tsx +157 -0
  36. package/src/components/BranchMenu.tsx +192 -0
  37. package/src/components/Button.tsx +131 -0
  38. package/src/components/ChangeDetail.tsx +472 -0
  39. package/src/components/ChangeRequestSummary.tsx +173 -0
  40. package/src/components/CollabField.tsx +388 -0
  41. package/src/components/ContentBrowser.tsx +5073 -0
  42. package/src/components/Icon.tsx +28 -0
  43. package/src/components/Icon.web.tsx +31 -0
  44. package/src/components/Input.tsx +106 -0
  45. package/src/components/MediaBrowser.tsx +766 -0
  46. package/src/components/MediaField.tsx +670 -0
  47. package/src/components/MediaPreview.tsx +91 -0
  48. package/src/components/MediaPreview.web.tsx +169 -0
  49. package/src/components/NavRow.tsx +105 -0
  50. package/src/components/Notifications.tsx +301 -0
  51. package/src/components/Onboarding.tsx +751 -0
  52. package/src/components/ProjectMenu.tsx +124 -0
  53. package/src/components/Segment.tsx +87 -0
  54. package/src/components/Skeleton.tsx +216 -0
  55. package/src/components/Spinner.tsx +44 -0
  56. package/src/components/Text.tsx +85 -0
  57. package/src/components/documentDrafts.ts +213 -0
  58. package/src/components/layout.tsx +284 -0
  59. package/src/components/primitives.tsx +143 -0
  60. package/src/components/reorder.ts +40 -0
  61. package/src/fieldComponents.ts +63 -0
  62. package/src/icons.ts +102 -0
  63. package/src/index.ts +198 -0
  64. package/src/media.ts +229 -0
  65. package/src/theme.ts +116 -0
  66. package/src/web/Button.tsx +110 -0
  67. package/src/web/Icon.tsx +52 -0
  68. package/src/web/Input.tsx +39 -0
  69. package/src/web/index.ts +43 -0
  70. package/src/web/primitives.tsx +119 -0
@@ -0,0 +1,213 @@
1
+ // Unsaved edits, kept in the browser until the author saves them.
2
+ //
3
+ // The editor used to autosave every keystroke to the API, which meant every
4
+ // half-finished sentence became a row in the database and, through the
5
+ // exporter, a commit on the branch. Editing is now explicit: edits accumulate
6
+ // here, the header says the document has unsaved changes, and Save is what
7
+ // reaches the API and git.
8
+ //
9
+ // That makes this the only copy of work in progress, so it is written to
10
+ // localStorage rather than sessionStorage: a closed tab, a crashed browser or a
11
+ // reboot must not lose an afternoon's writing. The draft is offered back when
12
+ // the document is reopened, and Discard is the way out.
13
+ //
14
+ // Not every host has localStorage — the design system also runs in the mobile
15
+ // app, where `window` has no storage at all — so a missing store degrades to an
16
+ // in-memory map: drafts still survive navigating between documents within the
17
+ // session, which is the case that matters there, and nothing throws.
18
+
19
+ /** One document's unsaved edits. */
20
+ export type DocumentDraft = {
21
+ /** Schema version, so a future format change can ignore old drafts. */
22
+ v: 1;
23
+ /** The editor's value map, exactly as it edits it. */
24
+ values: Record<string, unknown>;
25
+ /** When it was last written, for the "unsaved since" affordance. */
26
+ at: number;
27
+ };
28
+
29
+ const PREFIX = "gitcms.draft.v1:";
30
+
31
+ // The in-memory fallback, also the store under a browser that refuses
32
+ // localStorage (Safari in private mode throws on write, not on access).
33
+ const memory = new Map<string, string>();
34
+
35
+ type Store = {
36
+ get(key: string): string | null;
37
+ set(key: string, value: string): void;
38
+ remove(key: string): void;
39
+ keys(): string[];
40
+ };
41
+
42
+ function store(): Store {
43
+ try {
44
+ const ls = typeof localStorage !== "undefined" ? localStorage : null;
45
+ if (ls) {
46
+ // Probed rather than assumed: private-mode Safari exposes localStorage and
47
+ // throws QuotaExceededError on the first write, and finding that out on
48
+ // the user's first keystroke is worse than finding it out now.
49
+ const probe = PREFIX + "probe";
50
+ ls.setItem(probe, "1");
51
+ ls.removeItem(probe);
52
+ return {
53
+ get: (k) => ls.getItem(k),
54
+ set: (k, v) => ls.setItem(k, v),
55
+ remove: (k) => ls.removeItem(k),
56
+ keys: () => Object.keys(ls),
57
+ };
58
+ }
59
+ } catch {
60
+ /* fall through to memory */
61
+ }
62
+ return {
63
+ get: (k) => memory.get(k) ?? null,
64
+ set: (k, v) => {
65
+ memory.set(k, v);
66
+ },
67
+ remove: (k) => {
68
+ memory.delete(k);
69
+ },
70
+ keys: () => [...memory.keys()],
71
+ };
72
+ }
73
+
74
+ // --- change notification ----------------------------------------------------
75
+ //
76
+ // The list marks documents that have drafts and counts them in its header, so
77
+ // it has to re-render when one is written, saved or discarded — including from
78
+ // another tab, where the same author may be editing the same repository.
79
+ //
80
+ // The cached id set is what makes this usable from a render: `hasDraft` is
81
+ // called once per visible row, and re-reading localStorage on every one of them
82
+ // would put a synchronous storage scan in the middle of scrolling.
83
+
84
+ const listeners = new Set<() => void>();
85
+ let cache: Set<string> | null = null;
86
+ let storageBound = false;
87
+
88
+ function invalidate() {
89
+ cache = null;
90
+ for (const l of listeners) l();
91
+ }
92
+
93
+ function ids(): Set<string> {
94
+ if (!cache) {
95
+ cache = new Set(
96
+ store()
97
+ .keys()
98
+ .filter((k) => k.startsWith(PREFIX))
99
+ .map((k) => k.slice(PREFIX.length)),
100
+ );
101
+ }
102
+ return cache;
103
+ }
104
+
105
+ /**
106
+ * subscribeDrafts registers a callback for any change to the set of documents
107
+ * with unsaved edits. Returns the unsubscribe.
108
+ *
109
+ * Shaped for useSyncExternalStore, which is why the readers below return
110
+ * primitives: a boolean and a number are stable between renders without the
111
+ * caching dance a returned Set or array would need.
112
+ */
113
+ export function subscribeDrafts(onChange: () => void): () => void {
114
+ listeners.add(onChange);
115
+ // A draft written in another tab is a real change to what this list should
116
+ // show. Bound once, lazily, so a host that never renders a list never adds a
117
+ // window listener.
118
+ if (!storageBound && typeof window !== "undefined" && typeof window.addEventListener === "function") {
119
+ storageBound = true;
120
+ window.addEventListener("storage", (e) => {
121
+ if (!e.key || e.key.startsWith(PREFIX)) invalidate();
122
+ });
123
+ }
124
+ return () => {
125
+ listeners.delete(onChange);
126
+ };
127
+ }
128
+
129
+ /**
130
+ * refreshDrafts re-reads the store and notifies subscribers.
131
+ *
132
+ * The cache above assumes every write to these keys goes through this module,
133
+ * which holds within a tab — except when something clears storage wholesale
134
+ * (a host signing the user out, a test between cases). Those callers say so
135
+ * here rather than leaving the list showing badges for drafts that no longer
136
+ * exist. Cross-tab changes need no call: the storage event covers them.
137
+ */
138
+ export function refreshDrafts(): void {
139
+ invalidate();
140
+ }
141
+
142
+ /** hasDraft reports whether a document has unsaved edits. */
143
+ export function hasDraft(documentId: string): boolean {
144
+ return ids().has(documentId);
145
+ }
146
+
147
+ /** draftCount is how many documents have unsaved edits. */
148
+ export function draftCount(): number {
149
+ return ids().size;
150
+ }
151
+
152
+ /**
153
+ * readDraft returns the stored draft for a document, or null.
154
+ *
155
+ * Anything unparseable or from another version is treated as absent and
156
+ * removed: a draft is a convenience, and refusing to open a document because
157
+ * its cached copy is malformed would be the worst possible trade.
158
+ */
159
+ export function readDraft(documentId: string): DocumentDraft | null {
160
+ const s = store();
161
+ const key = PREFIX + documentId;
162
+ const raw = s.get(key);
163
+ if (!raw) return null;
164
+ try {
165
+ const parsed = JSON.parse(raw) as DocumentDraft;
166
+ if (parsed?.v !== 1 || typeof parsed.values !== "object" || parsed.values === null) {
167
+ s.remove(key);
168
+ invalidate();
169
+ return null;
170
+ }
171
+ return parsed;
172
+ } catch {
173
+ s.remove(key);
174
+ invalidate();
175
+ return null;
176
+ }
177
+ }
178
+
179
+ /** writeDraft stores a document's unsaved values. */
180
+ export function writeDraft(documentId: string, values: Record<string, unknown>): void {
181
+ const draft: DocumentDraft = { v: 1, values, at: Date.now() };
182
+ const isNew = !ids().has(documentId);
183
+ try {
184
+ store().set(PREFIX + documentId, JSON.stringify(draft));
185
+ // Only the set of *which* documents have drafts is observable, so a
186
+ // rewrite of one that is already marked notifies nobody — that is every
187
+ // keystroke after the first.
188
+ if (isNew) invalidate();
189
+ } catch {
190
+ // Out of quota, or a value that will not serialize. The edit is still in
191
+ // component state and still savable; only the crash-safety copy is lost, so
192
+ // this must not interrupt typing.
193
+ }
194
+ }
195
+
196
+ /** clearDraft forgets a document's unsaved values — after a save, or a discard. */
197
+ export function clearDraft(documentId: string): void {
198
+ try {
199
+ store().remove(PREFIX + documentId);
200
+ invalidate();
201
+ } catch {
202
+ /* nothing to do: a draft that cannot be removed is still only a cache */
203
+ }
204
+ }
205
+
206
+ /** draftIds lists every document with unsaved edits, for a global indicator. */
207
+ export function draftIds(): string[] {
208
+ try {
209
+ return [...ids()];
210
+ } catch {
211
+ return [];
212
+ }
213
+ }
@@ -0,0 +1,284 @@
1
+ import React from "react";
2
+ import { View, ScrollView, StyleProp, ViewStyle } from "react-native";
3
+ import { useTheme, useThemeMode, ThemeScope } from "../ThemeProvider";
4
+ import { darkTheme } from "../theme";
5
+ import { Text } from "./Text";
6
+ import { IconButton } from "./Button";
7
+
8
+ /** Full-height page background wrapper (used by simple screens like login). */
9
+ export function Screen({
10
+ children,
11
+ center,
12
+ padded = true,
13
+ testID,
14
+ style,
15
+ }: {
16
+ children: React.ReactNode;
17
+ center?: boolean;
18
+ padded?: boolean;
19
+ testID?: string;
20
+ style?: StyleProp<ViewStyle>;
21
+ }) {
22
+ const t = useTheme();
23
+ return (
24
+ <View
25
+ testID={testID}
26
+ style={[
27
+ {
28
+ flex: 1,
29
+ backgroundColor: t.color.surfacePage,
30
+ padding: padded ? t.space(6) : 0,
31
+ justifyContent: center ? "center" : "flex-start",
32
+ gap: t.space(3),
33
+ },
34
+ style,
35
+ ]}
36
+ >
37
+ {children}
38
+ </View>
39
+ );
40
+ }
41
+
42
+ /** Horizontal top bar with left / center / right slots. */
43
+ export function TopBar({
44
+ left,
45
+ center,
46
+ right,
47
+ }: {
48
+ left?: React.ReactNode;
49
+ center?: React.ReactNode;
50
+ right?: React.ReactNode;
51
+ }) {
52
+ const t = useTheme();
53
+ return (
54
+ <View
55
+ style={{
56
+ height: t.layout.topbar,
57
+ flexDirection: "row",
58
+ alignItems: "center",
59
+ paddingHorizontal: t.space(3),
60
+ gap: t.space(3),
61
+ borderBottomWidth: 1,
62
+ // Hairline appropriate to the dark chrome in both modes.
63
+ borderBottomColor: darkTheme.color.borderDefault,
64
+ // Permanent dark bar: near-black in light mode, charcoal in dark.
65
+ backgroundColor: t.color.surfaceTopBar,
66
+ // Establish a stacking context above the content panes so top-bar
67
+ // overlays (e.g. the notification menu) render on top of them.
68
+ position: "relative",
69
+ zIndex: 10,
70
+ }}
71
+ >
72
+ {/* The bar's inline contents render against the dark palette so text and
73
+ icons stay legible on the dark chrome, in both app themes. Overlays
74
+ that open from here (e.g. the notification menu) restore the page
75
+ theme themselves. */}
76
+ <ThemeScope theme={darkTheme}>
77
+ {/* The center slot is positioned against the BAR, not laid out between
78
+ its neighbours. As a flex sibling it would center in whatever space
79
+ the left and right slots left over — and those are never equal
80
+ widths (the workspace tag plus branch name is far wider than the
81
+ icon cluster), so it would sit visibly off-center. Absolute
82
+ positioning makes it centered on screen, which is the only centering
83
+ anyone can actually see.
84
+
85
+ Rendered first so the left/right slots paint above it: menus open
86
+ from those (branch, notifications) and must not fall behind it.
87
+
88
+ The overlay spans the whole bar, so it must not swallow clicks meant
89
+ for the slots underneath — hence `pointerEvents: "none"` here and
90
+ "auto" on the inner wrapper, which is the only combination that
91
+ actually works. React Native's `box-none` would express this in one
92
+ place, but react-native-web emits it verbatim as
93
+ `pointer-events: box-none`, which is not a valid CSS value: browsers
94
+ drop the declaration, the overlay falls back to `auto`, and the
95
+ branch menu, bell and sign-out all go dead. */}
96
+ {center ? (
97
+ <View
98
+ style={{
99
+ position: "absolute",
100
+ left: 0, right: 0, top: 0, bottom: 0,
101
+ flexDirection: "row",
102
+ alignItems: "center",
103
+ justifyContent: "center",
104
+ pointerEvents: "none",
105
+ }}
106
+ >
107
+ <View style={{ pointerEvents: "auto" }}>{center}</View>
108
+ </View>
109
+ ) : null}
110
+ <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>{left}</View>
111
+ {/* Pushes the right slot to the trailing edge now that nothing between
112
+ them is flexing. */}
113
+ <View style={{ flex: 1 }} />
114
+ <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(1) }}>{right}</View>
115
+ </ThemeScope>
116
+ </View>
117
+ );
118
+ }
119
+
120
+ /**
121
+ * A vertical column in the desktop multi-pane layout. Fixed `width` for
122
+ * sidebars/lists, or `flex` for the main content pane.
123
+ */
124
+ export function Pane({
125
+ children,
126
+ width,
127
+ flex,
128
+ header,
129
+ borderRight = true,
130
+ scroll = true,
131
+ testID,
132
+ dataSet,
133
+ }: {
134
+ children: React.ReactNode;
135
+ width?: number;
136
+ flex?: number;
137
+ header?: React.ReactNode;
138
+ borderRight?: boolean;
139
+ scroll?: boolean;
140
+ testID?: string;
141
+ // react-native-web maps this to data-* attributes on the pane's root element.
142
+ dataSet?: Record<string, string | number>;
143
+ }) {
144
+ const t = useTheme();
145
+ // minHeight:0 lets the ScrollView shrink within the pane instead of growing
146
+ // it — so the body scrolls and the header (a sibling above it) stays put.
147
+ const body = scroll ? (
148
+ <ScrollView style={{ flex: 1, minHeight: 0 }} contentContainerStyle={{ paddingVertical: t.space(2) }}>
149
+ {children}
150
+ </ScrollView>
151
+ ) : (
152
+ <View style={{ flex: 1, minHeight: 0 }}>{children}</View>
153
+ );
154
+ return (
155
+ <View
156
+ testID={testID}
157
+ // @ts-expect-error react-native-web accepts dataSet -> data-* attributes
158
+ dataSet={dataSet}
159
+ style={{
160
+ width,
161
+ flex: width ? undefined : flex ?? 1,
162
+ minHeight: 0,
163
+ overflow: "hidden",
164
+ borderRightWidth: borderRight ? 1 : 0,
165
+ borderRightColor: t.color.borderSubtle,
166
+ backgroundColor: t.color.surfacePage,
167
+ }}
168
+ >
169
+ {header ? (
170
+ <View
171
+ style={{
172
+ height: t.layout.topbar,
173
+ paddingHorizontal: t.space(4),
174
+ flexDirection: "row",
175
+ alignItems: "center",
176
+ gap: t.space(2),
177
+ borderBottomWidth: 1,
178
+ borderBottomColor: t.color.borderSubtle,
179
+ }}
180
+ >
181
+ {header}
182
+ </View>
183
+ ) : null}
184
+ {body}
185
+ </View>
186
+ );
187
+ }
188
+
189
+ /** Desktop shell: a top bar over a horizontal row of panes. */
190
+ export function AppShell({
191
+ topBar,
192
+ banner,
193
+ children,
194
+ testID,
195
+ }: {
196
+ topBar?: React.ReactNode;
197
+ // Optional full-width strip below the top bar (e.g. a read-only notice).
198
+ banner?: React.ReactNode;
199
+ children: React.ReactNode;
200
+ testID?: string;
201
+ }) {
202
+ const t = useTheme();
203
+ return (
204
+ <View testID={testID} style={{ flex: 1, backgroundColor: t.color.surfacePage }}>
205
+ {topBar}
206
+ {banner}
207
+ <View style={{ flex: 1, minHeight: 0, flexDirection: "row" }}>{children}</View>
208
+ </View>
209
+ );
210
+ }
211
+
212
+ /**
213
+ * Mobile drill-down screen: a compact header row, an optional large title, and
214
+ * a scrolling body — matching the native mobile layout.
215
+ */
216
+ export function MobileScreen({
217
+ header,
218
+ title,
219
+ banner,
220
+ overlay,
221
+ children,
222
+ testID,
223
+ // Set false when the children own their own scrolling (e.g. a FlatList) so a
224
+ // VirtualizedList isn't nested inside this ScrollView.
225
+ scroll = true,
226
+ }: {
227
+ header?: React.ReactNode;
228
+ title?: string;
229
+ // Full-width strip below the header (e.g. a read-only notice).
230
+ banner?: React.ReactNode;
231
+ // Absolutely-positioned overlay covering the whole screen (e.g. a modal),
232
+ // rendered outside the scroll area so it never scrolls with the body.
233
+ overlay?: React.ReactNode;
234
+ children: React.ReactNode;
235
+ testID?: string;
236
+ scroll?: boolean;
237
+ }) {
238
+ const t = useTheme();
239
+ return (
240
+ <View testID={testID} style={{ flex: 1, backgroundColor: t.color.surfacePage }}>
241
+ {header ? (
242
+ <View
243
+ style={{
244
+ paddingHorizontal: t.space(4),
245
+ paddingTop: t.space(3),
246
+ flexDirection: "row",
247
+ alignItems: "center",
248
+ gap: t.space(3),
249
+ }}
250
+ >
251
+ {header}
252
+ </View>
253
+ ) : null}
254
+ {title ? (
255
+ <View style={{ paddingHorizontal: t.space(4), paddingTop: t.space(3), paddingBottom: t.space(3) }}>
256
+ <Text variant="h1" weight="bold" style={{ fontSize: 32, lineHeight: 38 }}>
257
+ {title}
258
+ </Text>
259
+ </View>
260
+ ) : null}
261
+ {banner}
262
+ {scroll ? (
263
+ <ScrollView style={{ flex: 1 }}>{children}</ScrollView>
264
+ ) : (
265
+ <View style={{ flex: 1, minHeight: 0 }}>{children}</View>
266
+ )}
267
+ {overlay}
268
+ </View>
269
+ );
270
+ }
271
+
272
+ /** Sun/moon button wired to the theme provider. */
273
+ export function ThemeToggle({ size = "md" }: { size?: "sm" | "md" | "lg" }) {
274
+ const { mode, toggleMode } = useThemeMode();
275
+ return (
276
+ <IconButton
277
+ name={mode === "dark" ? "sun" : "moon"}
278
+ onPress={toggleMode}
279
+ size={size}
280
+ label={mode === "dark" ? "Switch to light theme" : "Switch to dark theme"}
281
+ testID="theme-toggle"
282
+ />
283
+ );
284
+ }
@@ -0,0 +1,143 @@
1
+ import React from "react";
2
+ import { View, StyleProp, ViewStyle } from "react-native";
3
+ import { useTheme } from "../ThemeProvider";
4
+ import { Text } from "./Text";
5
+
6
+ /** Bordered surface card — defined by a hairline border, never a shadow. */
7
+ export function Card({
8
+ children,
9
+ padding = 4,
10
+ style,
11
+ testID,
12
+ }: {
13
+ children: React.ReactNode;
14
+ padding?: number;
15
+ style?: StyleProp<ViewStyle>;
16
+ testID?: string;
17
+ }) {
18
+ const t = useTheme();
19
+ return (
20
+ <View
21
+ testID={testID}
22
+ style={[
23
+ {
24
+ backgroundColor: t.color.surfaceRaised,
25
+ borderWidth: 1,
26
+ borderColor: t.color.borderDefault,
27
+ borderRadius: t.radius.md,
28
+ padding: t.space(padding),
29
+ },
30
+ style,
31
+ ]}
32
+ >
33
+ {children}
34
+ </View>
35
+ );
36
+ }
37
+
38
+ export type BadgeTone = "neutral" | "add" | "del" | "strong";
39
+
40
+ /** Small pill for counts and git status letters (M/A/U/R/D). */
41
+ export function Badge({
42
+ label,
43
+ tone = "neutral",
44
+ mono = true,
45
+ testID,
46
+ }: {
47
+ label: string;
48
+ tone?: BadgeTone;
49
+ mono?: boolean;
50
+ testID?: string;
51
+ }) {
52
+ const t = useTheme();
53
+ const tones: Record<BadgeTone, { bg: string; fg: string; border: string }> = {
54
+ neutral: { bg: t.color.surfaceSunken, fg: t.color.textSecondary, border: t.color.borderSubtle },
55
+ add: { bg: t.color.diffAddBg, fg: t.color.diffAddFg, border: t.color.diffAddBg },
56
+ del: { bg: t.color.diffDelBg, fg: t.color.diffDelFg, border: t.color.diffDelBg },
57
+ strong: { bg: t.color.surfaceInverted, fg: t.color.textInverted, border: t.color.surfaceInverted },
58
+ };
59
+ const c = tones[tone];
60
+ return (
61
+ <View
62
+ testID={testID}
63
+ style={{
64
+ paddingHorizontal: t.space(1.5),
65
+ height: 18,
66
+ justifyContent: "center",
67
+ borderRadius: t.radius.sm,
68
+ backgroundColor: c.bg,
69
+ borderWidth: 1,
70
+ borderColor: c.border,
71
+ }}
72
+ >
73
+ <Text variant="monoSm" mono={mono} color={c.fg} weight="medium">
74
+ {label}
75
+ </Text>
76
+ </View>
77
+ );
78
+ }
79
+
80
+ /** Git diff stat: `+N` in add color, `-N` in del color. */
81
+ export function DiffStat({ added, removed }: { added?: number; removed?: number }) {
82
+ const t = useTheme();
83
+ return (
84
+ <View style={{ flexDirection: "row", gap: t.space(1.5) }}>
85
+ {added != null && added > 0 ? (
86
+ <Text variant="monoSm" color={t.color.diffAddFg} weight="medium">{`+${added}`}</Text>
87
+ ) : null}
88
+ {removed != null && removed > 0 ? (
89
+ <Text variant="monoSm" color={t.color.diffDelFg} weight="medium">{`-${removed}`}</Text>
90
+ ) : null}
91
+ </View>
92
+ );
93
+ }
94
+
95
+ /** Square initials avatar. */
96
+ export function Avatar({ initials, size = 28 }: { initials: string; size?: number }) {
97
+ const t = useTheme();
98
+ return (
99
+ <View
100
+ style={{
101
+ width: size,
102
+ height: size,
103
+ borderRadius: t.radius.sm,
104
+ borderWidth: 1,
105
+ borderColor: t.color.borderDefault,
106
+ backgroundColor: t.color.surfaceSunken,
107
+ alignItems: "center",
108
+ justifyContent: "center",
109
+ }}
110
+ >
111
+ <Text variant="monoSm" weight="semibold" color="secondary">
112
+ {initials}
113
+ </Text>
114
+ </View>
115
+ );
116
+ }
117
+
118
+ /** Hairline rule. */
119
+ export function Divider({ vertical, style }: { vertical?: boolean; style?: StyleProp<ViewStyle> }) {
120
+ const t = useTheme();
121
+ return (
122
+ <View
123
+ style={[
124
+ vertical
125
+ ? { width: 1, alignSelf: "stretch", backgroundColor: t.color.borderSubtle }
126
+ : { height: 1, alignSelf: "stretch", backgroundColor: t.color.borderSubtle },
127
+ style,
128
+ ]}
129
+ />
130
+ );
131
+ }
132
+
133
+ /** Uppercase tracked mono micro-label used for nav section headers. */
134
+ export function SectionLabel({ children, style }: { children: React.ReactNode; style?: StyleProp<ViewStyle> }) {
135
+ const t = useTheme();
136
+ return (
137
+ <View style={[{ paddingHorizontal: t.space(4), paddingVertical: t.space(2) }, style]}>
138
+ <Text variant="label" color="tertiary">
139
+ {children}
140
+ </Text>
141
+ </View>
142
+ );
143
+ }
@@ -0,0 +1,40 @@
1
+ // Pure geometry/list helpers for the desktop ContentBrowser's resizable panes
2
+ // and drag-to-reorder columns. Kept side-effect-free so they unit-test without a
3
+ // DOM (the drag/resize gestures themselves are exercised in the browser).
4
+
5
+ /** Clamp `n` into the inclusive [min, max] range. */
6
+ export function clamp(n: number, min: number, max: number): number {
7
+ return Math.max(min, Math.min(max, n));
8
+ }
9
+
10
+ /**
11
+ * Reorder `ids` by moving `dragId` to `slot` — a landing position expressed in
12
+ * the ORIGINAL list's coordinates (0..ids.length). Because removing the dragged
13
+ * id shifts later positions left by one, a slot past the id's own index is
14
+ * decremented. Returns a new array; a no-op (unknown id) returns the input.
15
+ */
16
+ export function reorder(ids: string[], dragId: string, slot: number): string[] {
17
+ const from = ids.indexOf(dragId);
18
+ if (from === -1) return ids;
19
+ const without = ids.filter((x) => x !== dragId);
20
+ const to = clamp(from < slot ? slot - 1 : slot, 0, without.length);
21
+ without.splice(to, 0, dragId);
22
+ return without;
23
+ }
24
+
25
+ /**
26
+ * The insertion slot (0..widths.length) for a pointer at content-x `x` over a
27
+ * horizontal row of columns with the given `widths`, laid out left-to-right. A
28
+ * column is "passed" once the pointer clears its midpoint, so dropping snaps to
29
+ * whichever gap the cursor is nearest.
30
+ */
31
+ export function slotAtX(x: number, widths: number[]): number {
32
+ let left = 0;
33
+ let slot = 0;
34
+ for (const w of widths) {
35
+ if (x <= left + w / 2) break;
36
+ slot++;
37
+ left += w;
38
+ }
39
+ return slot;
40
+ }