@lotics/ui 7.19.2 → 8.0.0

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.
@@ -1,259 +1,732 @@
1
- import { type ReactNode } from "react";
2
- import { StyleSheet, View } from "react-native";
1
+ import {
2
+ Children,
3
+ createContext,
4
+ isValidElement,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ type ReactNode,
12
+ } from "react";
13
+ import { StyleSheet, TextInput as RNTextInput, View } from "react-native";
3
14
  import { colors } from "./colors";
4
- import { reviewCardStyle, reviewResolvedStyle } from "./control_surface";
5
15
  import { Text } from "./text";
6
16
  import { Icon } from "./icon";
7
17
  import { Button } from "./button";
18
+ import { TextInputField } from "./text_input_field";
19
+ import { FocusRingPressable } from "./focus_ring_pressable";
20
+ import { CardSelectItem } from "./card_select_item";
8
21
  import { useLoticsLocale } from "./locale";
22
+ import { fontFamilyMedium } from "./text_utils";
9
23
 
10
- export type ChangeReviewItemStatus = "pending" | "accepted" | "rejected";
11
-
12
- export interface ChangeReviewProps<T> {
13
- /** The proposed changes to review. Each renders however the host wants via
14
- * `renderItem` a field diff (`ChangeDiff`), a record card, an editable
15
- * data-entry form. The component owns the review MECHANICS, not the look. */
16
- items: T[];
17
- /** Stable identity per item. */
18
- getKey: (item: T, index: number) => string;
19
- /** The PENDING proposal body — render it however fits. The host owns any edit
20
- * state inside it (e.g. an editable price), so a "kept" item carries the
21
- * host's edits when applied. */
22
- renderItem: (item: T, index: number) => ReactNode;
23
- /** The collapsed one-line preview shown AFTER a decision (1-by-1 mode). The
24
- * component frames it with the Kept/Dropped tag + Undo; this fills the row.
25
- * Omit to fall back to `getTitle` (struck when dropped). */
26
- renderSummary?: (item: T, accepted: boolean, index: number) => ReactNode;
27
- /** Short title for the default collapsed row when `renderSummary` is omitted. */
28
- getTitle?: (item: T, index: number) => string;
29
- /** Per-item decision, controlled by the host (default "pending"). Drives the
30
- * collapse + the "N of M kept" counter in 1-by-1 mode. */
31
- statusOf?: (item: T, index: number) => ChangeReviewItemStatus;
32
- /** The instruction that produced the changes ("make it 5 mm taller"). */
33
- summary?: string;
34
- /** Card heading. Default "Suggested edits". */
35
- title?: string;
36
- /** Providing either flips into 1-BY-1 mode: each proposal is its own card with
37
- * Keep / Drop; deciding COLLAPSES it to a preview the host can `onUndoItem`.
38
- * Omit both for whole-set (the list, reviewed together). The commit bar
39
- * (Accept all / Apply / Discard) is NOT here — it's `ChangeReviewActions`,
40
- * which the host places in a `DialogFooter` / `DrawerFooter` / inline. */
41
- onAcceptItem?: (index: number) => void;
42
- onRejectItem?: (index: number) => void;
43
- /** Revert a decided item back to pending (the Undo on a collapsed card). */
44
- onUndoItem?: (index: number) => void;
45
- /** 1-by-1 per-item action labels. Default "Keep" / "Drop". */
24
+ export type ChangeStatus = "pending" | "accepted" | "rejected";
25
+
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+ // ChangeReview THE review-before-apply surface, as a COMPOUND family. The
28
+ // host composes exactly what each change shows; the family owns the review
29
+ // mechanics (decide collapse → undo, the kept counter, the commit bar).
30
+ //
31
+ // <ChangeReview>
32
+ // <ChangeReviewHeader /> ← title + auto "N/M kept"
33
+ // <Change id="vessel" status={…} onAccept={…} onReject={…} onUndo={…}>
34
+ // <ChangeLabel>Vessel</ChangeLabel>
35
+ // …any body ChangeFields, a finding, prose…
36
+ // <ChangeSummary>Vessel MAERSK SALINA</ChangeSummary> ← decided row
37
+ // </Change>
38
+ // …
39
+ // <ChangeReviewActions onApply={…} onDiscard={…} />
40
+ // </ChangeReview>
41
+ //
42
+ // State is the HOST's (controlled `status` per Change / ChangeRecord) the
43
+ // context only mirrors it so the header counter, Keep-all, and the
44
+ // apply-disabled logic derive without the host re-wiring them. An add, an
45
+ // update, a removal and a source conflict are compositions of ONE grammar:
46
+ // `ChangeFields` + `ChangeField` (the − band · the + value · candidate rows)
47
+ // — not separate components; `ChangeRecord` wraps the same grammar in the
48
+ // per-item card for record SETS.
49
+ // ─────────────────────────────────────────────────────────────────────────────
50
+
51
+ interface ChangeEntry {
52
+ status: ChangeStatus;
53
+ accept?: () => void;
54
+ }
55
+
56
+ interface ChangeReviewCtx {
57
+ register: (id: string, entry: ChangeEntry) => void;
58
+ unregister: (id: string) => void;
59
+ entries: ReadonlyMap<string, ChangeEntry>;
60
+ }
61
+
62
+ const Ctx = createContext<ChangeReviewCtx | null>(null);
63
+
64
+ const useReviewCtx = (who: string): ChangeReviewCtx => {
65
+ const ctx = useContext(Ctx);
66
+ if (!ctx) throw new Error(`${who} must render inside <ChangeReview>`);
67
+ return ctx;
68
+ };
69
+
70
+ /** Mirrors a decidable entry's controlled state into the review context — the
71
+ * registration `Change` and `ChangeRecord` share (the header counter, Keep-all
72
+ * and the apply gate derive from the registry). */
73
+ function useRegisterEntry(who: string, id: string, status: ChangeStatus, onAccept?: () => void) {
74
+ const { register, unregister } = useReviewCtx(who);
75
+ const acceptRef = useRef(onAccept);
76
+ acceptRef.current = onAccept;
77
+ const stableAccept = useCallback(() => acceptRef.current?.(), []);
78
+ useEffect(() => {
79
+ register(id, { status, accept: onAccept ? stableAccept : undefined });
80
+ }, [register, id, status, onAccept, stableAccept]);
81
+ useEffect(() => () => unregister(id), [unregister, id]);
82
+ }
83
+
84
+ export function ChangeReview({ children }: { children: ReactNode }) {
85
+ const [entries, setEntries] = useState<Map<string, ChangeEntry>>(new Map());
86
+ const register = useCallback((id: string, entry: ChangeEntry) => {
87
+ setEntries((m) => {
88
+ const cur = m.get(id);
89
+ if (cur && cur.status === entry.status && cur.accept === entry.accept) return m;
90
+ const n = new Map(m);
91
+ n.set(id, entry);
92
+ return n;
93
+ });
94
+ }, []);
95
+ const unregister = useCallback((id: string) => {
96
+ setEntries((m) => {
97
+ if (!m.has(id)) return m;
98
+ const n = new Map(m);
99
+ n.delete(id);
100
+ return n;
101
+ });
102
+ }, []);
103
+ const value = useMemo(() => ({ register, unregister, entries }), [register, unregister, entries]);
104
+ // Containerless: changes are OPEN sections separated by hairline dividers —
105
+ // spacing and rules, never card boxes (controls keep their own affordances).
106
+ const kids = Children.toArray(children);
107
+ const withDividers: ReactNode[] = [];
108
+ kids.forEach((k, i) => {
109
+ const prev = kids[i - 1];
110
+ if (i > 0 && isValidElement(k) && k.type === Change && isValidElement(prev) && prev.type === Change) {
111
+ withDividers.push(<View key={`div-${i}`} style={styles.divider} />);
112
+ }
113
+ withDividers.push(k);
114
+ });
115
+ return (
116
+ <Ctx.Provider value={value}>
117
+ <View style={styles.stack}>{withDividers}</View>
118
+ </Ctx.Provider>
119
+ );
120
+ }
121
+
122
+ /** Title line + the automatic "N of M kept" counter (derived from the
123
+ * registered `Change`s / `ChangeRecord`s). `children` slots extra header
124
+ * content on the right. */
125
+ export function ChangeReviewHeader({ title, children }: { title?: string; children?: ReactNode }) {
126
+ const loc = useLoticsLocale().changeReview;
127
+ return (
128
+ <View style={styles.header}>
129
+ <Text size="md" weight="semibold" style={{ flex: 1 }}>
130
+ {title ?? loc.title}
131
+ </Text>
132
+ {children}
133
+ </View>
134
+ );
135
+ }
136
+
137
+ export interface ChangeProps {
138
+ /** Stable identity — the counter/commit derive from registered ids. */
139
+ id: string;
140
+ /** The HOST owns the decision. */
141
+ status?: ChangeStatus;
142
+ onAccept?: () => void;
143
+ onReject?: () => void;
144
+ /** Revert a decided change back to pending (the Undo on the collapsed row). */
145
+ onUndo?: () => void;
146
+ /** Labels for the per-change actions. Defaults localize ("Keep"/"Drop"). */
46
147
  acceptLabel?: string;
47
148
  rejectLabel?: string;
48
- /** Once resolved the list settles to a quiet outcome line. */
49
- status?: "open" | "applied" | "discarded";
50
- /** Localize the chrome the LIST emits itself (Keep/Drop take their own props;
51
- * the commit-bar labels live on `ChangeReviewActions`). */
52
- labels?: {
53
- undo?: string;
54
- applied?: string;
55
- discarded?: string;
56
- /** The "N of M kept" counter — default `${kept} of ${total} kept`. */
57
- keptCount?: (kept: number, total: number) => string;
58
- };
149
+ /** Disable Accept until a precondition holds (an unresolved conflict). */
150
+ acceptDisabled?: boolean;
151
+ children: ReactNode;
59
152
  }
60
153
 
61
- export interface ChangeReviewActionsProps<T> {
62
- /** The same items + per-item status the list reads, so Accept-all knows which
63
- * are pending and Apply knows the kept count. */
64
- items: T[];
65
- statusOf?: (item: T, index: number) => ChangeReviewItemStatus;
66
- /** Presence enables 1-by-1: shows Accept-all and disables Apply at 0 kept. Omit
67
- * for whole-set (no Accept-all; Apply commits the lot). */
68
- onAcceptItem?: (index: number) => void;
69
- onApply?: () => void;
70
- onDiscard?: () => void;
71
- applyLabel?: string;
72
- acceptAllLabel?: string;
73
- discardLabel?: string;
74
- /** Self-hides unless open — the host can always render it. */
75
- status?: "open" | "applied" | "discarded";
76
- }
154
+ /**
155
+ * ONE proposed change. Pending renders the composed body + a labeled action
156
+ * row (only the callbacks you pass render buttons — omit both for a display-
157
+ * only entry). Decided → collapses to the `ChangeSummary` child (falls back to
158
+ * the body) behind a status mark + Undo. The body is ANY composition —
159
+ * `ChangeLabel` + `ChangeFields` for a record's fields, a finding, an
160
+ * editable form.
161
+ */
162
+ export function Change(props: ChangeProps) {
163
+ const loc = useLoticsLocale().changeReview;
164
+ const status = props.status ?? "pending";
165
+ useRegisterEntry("Change", props.id, status, props.onAccept);
166
+
167
+ const kids = Children.toArray(props.children);
168
+ const summary = kids.filter((k) => isValidElement(k) && k.type === ChangeSummary);
169
+ const header = kids.filter((k) => isValidElement(k) && k.type === ChangeLabel);
170
+ const body = kids.filter((k) => !(isValidElement(k) && (k.type === ChangeSummary || k.type === ChangeLabel)));
171
+
172
+ if (status !== "pending") {
173
+ const kept = status === "accepted";
174
+ return (
175
+ <View style={styles.decidedRow}>
176
+ <Icon name={kept ? "check" : "x"} size={15} color={kept ? colors.emerald[600] : colors.zinc[400]} />
177
+ <View style={{ flex: 1, opacity: kept ? 1 : 0.6 }}>
178
+ {summary.length > 0 ? summary : <View style={styles.summaryFallback}>{body}</View>}
179
+ </View>
180
+ {props.onUndo ? <Button title={loc.undo} color="muted" onPress={props.onUndo} /> : null}
181
+ </View>
182
+ );
183
+ }
77
184
 
78
- /** A git-style diff body: the old value on a red `−` line (struck), the new
79
- * value on a green `+` line. An addition (no `before`) is just the `+` line.
80
- * The canonical `renderItem` for a single-field change — compose it under your
81
- * own field label. */
82
- export function ChangeDiff({ before, after }: { before?: string; after: string }) {
185
+ const hasActions = props.onAccept != null || props.onReject != null;
83
186
  return (
84
- <View style={styles.diff}>
85
- {before != null ? (
86
- <View style={styles.diffLine}>
87
- <Text size="sm" weight="medium" tabular style={styles.marker(colors.red[600])}>
88
-
89
- </Text>
90
- <Text size="sm" tabular style={[styles.strike, styles.value(colors.red[600])]}>
91
- {before}
92
- </Text>
187
+ <View style={styles.changeSection}>
188
+ {header.length > 0 ? header : null}
189
+ <View style={styles.changeBody}>{body}</View>
190
+ {hasActions ? (
191
+ <View style={styles.changeActions}>
192
+ {props.onReject ? <Button title={props.rejectLabel ?? loc.reject} color="muted" onPress={props.onReject} /> : null}
193
+ {props.onAccept ? (
194
+ <Button title={props.acceptLabel ?? loc.accept} color="secondary" disabled={props.acceptDisabled} onPress={props.onAccept} />
195
+ ) : null}
93
196
  </View>
94
197
  ) : null}
95
- <View style={styles.diffLine}>
96
- <Text size="sm" weight="medium" tabular style={styles.marker(colors.emerald[700])}>
97
- +
198
+ </View>
199
+ );
200
+ }
201
+
202
+ /** The change's SUBJECT — the section heading; a stack of changes skims by
203
+ * subject. Provenance goes at the section's BOTTOM (`<Sources>` before the
204
+ * actions), never crowded into the heading. */
205
+ export function ChangeLabel({ children }: { children: ReactNode }) {
206
+ return (
207
+ <View style={styles.labelBand}>
208
+ <Text size="sm" weight="semibold" style={{ flexShrink: 1 }} numberOfLines={1}>
209
+ {children}
210
+ </Text>
211
+ </View>
212
+ );
213
+ }
214
+
215
+ /** The agent's optional reasoning — for a change that is not
216
+ * self-explanatory. A quiet hairline-quoted aside: present when it earns its
217
+ * place, never competing with the value decision. */
218
+ export function ChangeReasoning({ children }: { children: ReactNode }) {
219
+ return (
220
+ <View style={styles.reasoning}>
221
+ <Text size="sm" color="muted">
222
+ {children}
223
+ </Text>
224
+ </View>
225
+ );
226
+ }
227
+
228
+ /** The stacked FIELD container — the open form of the record grammar: use it
229
+ * directly when the section IS the record under review (no card chrome);
230
+ * `ChangeRecord` wraps the same container in the item card. */
231
+ export function ChangeFields({ children }: { children: ReactNode }) {
232
+ const kids = Children.toArray(children);
233
+ const withRules: ReactNode[] = [];
234
+ kids.forEach((k, i) => {
235
+ if (i > 0) withRules.push(<View key={`rule-${i}`} style={styles.fieldRule} />);
236
+ withRules.push(k);
237
+ });
238
+ return <View style={styles.fields}>{withRules}</View>;
239
+ }
240
+
241
+ export interface ChangeRecordProps {
242
+ /** Stable identity — registers in the review context like a `Change`. */
243
+ id: string;
244
+ /** "add" (+, green) · "remove" (−, red) · "edit" (±, neutral — only the
245
+ * changed fields, each with its own mini-diff via `before`). */
246
+ tone: "add" | "remove" | "edit";
247
+ /** The card header title (the item's name). */
248
+ title: string;
249
+ /** The record's fields — `ChangeField`s (or any inline content). */
250
+ children: ReactNode;
251
+ status?: ChangeStatus;
252
+ onAccept?: () => void;
253
+ onReject?: () => void;
254
+ onUndo?: () => void;
255
+ /** The collapsed one-liner after a decision. */
256
+ summary: string;
257
+ }
258
+
259
+ /**
260
+ * A whole RECORD as ONE bordered card — the right altitude for line items:
261
+ * the tinted header carries the +/±/− op for the entire record, the body is
262
+ * the same stacked field grammar (`ChangeFields`), and the card's ONE
263
+ * Keep/Drop pair sits bottom-right. The verb level follows the decision
264
+ * level: add/remove cards carry the card verbs and their fields none; an
265
+ * edit card carries NO card verbs — its fields decide. Decided → the same
266
+ * compact bordered row every decided change collapses to.
267
+ */
268
+ export function ChangeRecord(props: ChangeRecordProps) {
269
+ const loc = useLoticsLocale().changeReview;
270
+ const status = props.status ?? "pending";
271
+ useRegisterEntry("ChangeRecord", props.id, status, props.onAccept);
272
+
273
+ if (status !== "pending") {
274
+ const kept = status === "accepted";
275
+ return (
276
+ <View style={styles.fieldCollapsed}>
277
+ <Icon name={kept ? "check" : "x"} size={15} color={kept ? colors.emerald[600] : colors.zinc[400]} />
278
+ <Text size="sm" numberOfLines={1} style={[{ flex: 1 }, kept ? null : styles.strike]}>
279
+ {props.summary}
98
280
  </Text>
99
- <Text size="sm" weight="medium" tabular style={styles.value(colors.emerald[700])}>
100
- {after}
281
+ {props.onUndo ? <Button title={loc.undo} color="muted" onPress={props.onUndo} /> : null}
282
+ </View>
283
+ );
284
+ }
285
+ const mark =
286
+ props.tone === "add"
287
+ ? { glyph: "+", word: loc.opAdd, color: colors.emerald[700], bg: colors.emerald[50], header: colors.emerald[100], border: colors.emerald[200] }
288
+ : props.tone === "remove"
289
+ ? { glyph: "−", word: loc.opRemove, color: colors.red[600], bg: colors.red[50], header: colors.red[100], border: colors.red[200] }
290
+ : { glyph: "±", word: loc.opEdit, color: colors.zinc[600], bg: colors.white, header: colors.zinc[50], border: colors.border };
291
+ return (
292
+ <View style={[styles.recordCard, { backgroundColor: mark.bg, borderColor: mark.border }]}>
293
+ {/* The op reads on the WHOLE card — a quiet tone wash (50 body ·
294
+ 100 header · 200 border); an edit stays white, its diffs carry it. */}
295
+ <View style={[styles.recordCardHeader, { backgroundColor: mark.header }]}>
296
+ <Text size="sm" weight="semibold" tabular style={{ color: mark.color, width: 14 }}>
297
+ {mark.glyph}
101
298
  </Text>
299
+ <Text size="sm" weight="semibold" style={{ color: mark.color }}>
300
+ {mark.word}
301
+ </Text>
302
+ <Text size="sm" color="muted">·</Text>
303
+ <Text size="sm" weight="semibold" numberOfLines={1} style={{ flexShrink: 1 }}>
304
+ {props.title}
305
+ </Text>
306
+ </View>
307
+ <View style={styles.recordCardBody}>
308
+ {/* Plain 6px stack — the hairline rhythm belongs to the OPEN form;
309
+ inside a card the border already scopes the record. */}
310
+ <View style={styles.recordFieldStack}>{props.children}</View>
311
+ {props.onAccept || props.onReject ? (
312
+ <View style={styles.verbRow}>
313
+ {props.onReject ? <Button title={loc.reject} color="muted" onPress={props.onReject} /> : null}
314
+ {props.onAccept ? <Button title={loc.accept} color="secondary" onPress={props.onAccept} /> : null}
315
+ </View>
316
+ ) : null}
102
317
  </View>
103
318
  </View>
104
319
  );
105
320
  }
106
321
 
322
+ export interface ChangeFieldCandidate {
323
+ value: string;
324
+ /** Where the candidate came from (a document name) — quiet meta on the row;
325
+ * deciding between values requires knowing which source said what. */
326
+ source?: string;
327
+ /** A one-line qualifier (a score, a reason) for ranked picks. */
328
+ description?: string;
329
+ /** The row the current value matches — the host computes it. */
330
+ selected?: boolean;
331
+ }
332
+
333
+ export interface ChangeFieldProps {
334
+ label: string;
335
+ /** The proposed value. Omit for a pure REMOVAL (`before` alone — the − band
336
+ * is the whole change). */
337
+ value?: string;
338
+ /** Present ⇒ the value is press-to-edit in place (an input swaps in). */
339
+ onChangeText?: (value: string) => void;
340
+ /** The record's CURRENT value — the − band above the value (the field's
341
+ * mini-diff). Omit for a field the record doesn't hold yet (an add). */
342
+ before?: string;
343
+ /** Per-field decision — the same tri-state as a `Change` (dropping a field
344
+ * narrows the update diff). */
345
+ status?: "pending" | "kept" | "dropped";
346
+ onKeep?: () => void;
347
+ onDrop?: () => void;
348
+ onUndo?: () => void;
349
+ /** Gate Keep until a precondition holds (an unresolved conflict). */
350
+ keepDisabled?: boolean;
351
+ /** Conflict candidates — full-width decision rows under the value; the
352
+ * value band is the READ-ONLY outcome (`valueReadOnly`). */
353
+ candidates?: ChangeFieldCandidate[];
354
+ onPickCandidate?: (candidate: ChangeFieldCandidate) => void;
355
+ /** Offer "type another value" as an explicit third option under the
356
+ * candidates; selecting it reveals an input. The host stores the text and
357
+ * treats it like any picked value. */
358
+ customValue?: string;
359
+ customSelected?: boolean;
360
+ onCustomValue?: (value: string) => void;
361
+ onCustomSelect?: () => void;
362
+ /** The field's quiet why — the hairline aside under the value. */
363
+ reasoning?: string;
364
+ /** Render the value as the read-only outcome band (a conflict): the + band
365
+ * shows the value — or the muted `placeholder` while it's still empty. */
366
+ valueReadOnly?: boolean;
367
+ placeholder?: string;
368
+ /** The one-line resolved value on the collapsed row after a decision.
369
+ * Falls back to `value`. */
370
+ summary?: string;
371
+ /** Replace the built-in value surface — `ChangeValueInput` (the diff-at-rest
372
+ * editor) or any input. */
373
+ children?: ReactNode;
374
+ accessibilityLabel?: string;
375
+ }
376
+
107
377
  /**
108
- * An agent-proposed set of changes, reviewed before it lands never
109
- * auto-applied. This is the LIST half: it owns the review MECHANICS (per-item
110
- * decide + collapse, the kept counter), `renderItem` owns how each proposal
111
- * looks. The COMMIT BAR is `ChangeReviewActions` a separate piece the host
112
- * places where commit bars belong (a `DialogFooter`, a `DrawerFooter`, or
113
- * inline under the list), so the commit never scrolls away inside a dialog.
114
- * Two modes. Whole-set: the list, reviewed together. 1-BY-1 (when the host
115
- * passes `onAcceptItem`/`onRejectItem`): each proposal is its own card with
116
- * Keep / Drop; deciding COLLAPSES it to a preview with Undo. Where a
117
- * `ReviewCard` reviews a SINGLE proposal, this is the BATCH engine.
378
+ * THE field of the record grammar one proposed value inside `ChangeFields`.
379
+ * A CHANGED field reads like the field diff: the label line, the band
380
+ * (when replacing/removing), the + value (editable, read-only on a conflict,
381
+ * or absent on a pure removal), conflict candidates + the type-another-value
382
+ * third option, the quiet reasoning deciding for ITSELF via Keep/Drop.
383
+ * A plain field (no diff, no decision) is the inline label · value row,
384
+ * press-to-edit when `onChangeText` is given. Decided ONE quiet collapsed
385
+ * row: mark · label value · Undo.
118
386
  */
119
- export function ChangeReview<T>(props: ChangeReviewProps<T>) {
387
+ export function ChangeField(props: ChangeFieldProps) {
120
388
  const loc = useLoticsLocale().changeReview;
121
- const status = props.status ?? "open";
122
- const perItem = props.onAcceptItem != null || props.onRejectItem != null;
123
- const total = props.items.length;
124
- const statusFor = (item: T, i: number) => props.statusOf?.(item, i) ?? "pending";
125
- const keptCount = props.items.filter((it, i) => statusFor(it, i) === "accepted").length;
126
- const title = props.title ?? loc.title;
127
- const L = {
128
- undo: loc.undo,
129
- applied: loc.applied,
130
- discarded: loc.discarded,
131
- keptCount: loc.keptCount,
132
- ...props.labels,
133
- };
134
-
135
- const summaryNote = props.summary ? (
136
- <View style={styles.note}>
137
- <Text size="sm" color="muted">
138
- {props.summary}
139
- </Text>
140
- </View>
141
- ) : null;
142
-
143
- const resolvedLine =
144
- status !== "open" ? (
145
- <Text size="xs" color="muted" weight="medium">
146
- {status === "applied" ? L.applied : L.discarded}
147
- </Text>
148
- ) : null;
389
+ const [editing, setEditing] = useState(false);
390
+ const status = props.status ?? "pending";
149
391
 
150
- // ── 1-BY-1: each proposal a bordered card; deciding collapses it to a quiet,
151
- // lighter row visual weight tracks whether it still needs you ──────────
152
- if (perItem) {
392
+ if (status !== "pending") {
393
+ const kept = status === "kept";
394
+ const resolved = props.summary ?? props.value;
153
395
  return (
154
- <View style={status !== "open" ? styles.resolvedCard : styles.stack}>
155
- <View style={styles.head}>
156
- <Text size="xs" color="muted" weight="medium" style={{ flex: 1 }}>
157
- {title}
396
+ <View style={styles.fieldCollapsed}>
397
+ <Icon name={kept ? "check" : "x"} size={15} color={kept ? colors.emerald[600] : colors.zinc[400]} />
398
+ <View style={{ flex: 1, flexDirection: "row", alignItems: "baseline", gap: 6, flexWrap: "wrap" }}>
399
+ <Text size="sm" weight="medium" numberOfLines={1} style={kept ? null : styles.strike}>
400
+ {props.label}
158
401
  </Text>
159
- {status === "open" ? (
160
- <Text size="xs" color="muted" tabular>
161
- {L.keptCount(keptCount, total)}
402
+ {kept && resolved ? (
403
+ <Text size="sm" color="muted" numberOfLines={1} style={{ flexShrink: 1 }}>
404
+ {resolved}
162
405
  </Text>
163
406
  ) : null}
164
407
  </View>
165
- {summaryNote}
408
+ {props.onUndo ? <Button title={loc.undo} color="muted" onPress={props.onUndo} /> : null}
409
+ </View>
410
+ );
411
+ }
166
412
 
167
- {status !== "open" ? (
168
- resolvedLine
169
- ) : (
170
- <View style={styles.list}>
171
- {props.items.map((item, i) => {
172
- const st = statusFor(item, i);
173
- if (st === "pending") {
174
- return (
175
- <View key={props.getKey(item, i)} style={styles.itemCard}>
176
- {props.renderItem(item, i)}
177
- <View style={styles.itemActions}>
178
- <Button title={props.rejectLabel ?? loc.reject} color="muted" onPress={() => props.onRejectItem?.(i)} />
179
- <Button title={props.acceptLabel ?? loc.accept} color="secondary" onPress={() => props.onAcceptItem?.(i)} />
180
- </View>
181
- </View>
182
- );
183
- }
184
- const kept = st === "accepted";
185
- return (
186
- <View key={props.getKey(item, i)} style={[styles.decidedRow, kept ? null : styles.decidedDrop]}>
187
- <Icon name={kept ? "check" : "x"} size={15} color={kept ? colors.emerald[600] : colors.zinc[400]} />
188
- <View style={{ flex: 1 }}>
189
- {props.renderSummary ? (
190
- props.renderSummary(item, kept, i)
191
- ) : (
192
- <Text size="sm" color={kept ? "default" : "muted"} numberOfLines={1} style={kept ? null : styles.strike}>
193
- {props.getTitle?.(item, i) ?? ""}
194
- </Text>
195
- )}
196
- </View>
197
- {props.onUndoItem ? (
198
- <Button title={L.undo} color="muted" onPress={() => props.onUndoItem?.(i)} />
199
- ) : null}
200
- </View>
201
- );
202
- })}
413
+ const isRemoval = props.before != null && !props.value && !props.onChangeText && !props.candidates && props.children == null;
414
+ if (props.before != null || props.candidates || props.reasoning || props.onKeep || props.onDrop || props.children != null || props.valueReadOnly) {
415
+ return (
416
+ <View style={styles.fieldDiff}>
417
+ <Text size="sm" weight="medium" numberOfLines={1}>
418
+ {props.label}
419
+ </Text>
420
+ {/* The agent's why sits under the label — consistent with the
421
+ record-level ChangeReasoning under its heading. */}
422
+ {props.reasoning ? (
423
+ <View style={styles.reasoning}>
424
+ <Text size="sm" color="muted">{props.reasoning}</Text>
203
425
  </View>
204
- )}
426
+ ) : null}
427
+ {props.before != null ? <ChangeBand tone="remove">{props.before}</ChangeBand> : null}
428
+ {!isRemoval
429
+ ? (props.children ??
430
+ (props.valueReadOnly || !props.onChangeText ? (
431
+ <ChangeBand tone="add">
432
+ {props.value ? (
433
+ props.value
434
+ ) : (
435
+ <Text size="sm" color="muted" style={{ flexShrink: 1 }}>
436
+ {props.placeholder ?? ""}
437
+ </Text>
438
+ )}
439
+ </ChangeBand>
440
+ ) : (
441
+ <ChangeValueInput
442
+ value={props.value ?? ""}
443
+ onChangeText={props.onChangeText}
444
+ placeholder={props.placeholder}
445
+ accessibilityLabel={props.accessibilityLabel ?? props.label}
446
+ />
447
+ )))
448
+ : null}
449
+ {props.candidates && props.candidates.length > 0 ? (
450
+ <View style={styles.candidateList}>
451
+ {props.candidates.map((c, i) => (
452
+ <CardSelectItem
453
+ key={`${c.value}-${i}`}
454
+ onPress={() => props.onPickCandidate?.(c)}
455
+ selected={c.selected}
456
+ accessibilityLabel={`${props.label}: ${c.value}`}
457
+ style={styles.candidateItem}
458
+ >
459
+ <Text size="sm" weight="medium" tabular numberOfLines={2} style={{ flexShrink: 1 }}>
460
+ {c.value}
461
+ </Text>
462
+ </CardSelectItem>
463
+ ))}
464
+ {props.onCustomValue ? (
465
+ props.customSelected ? (
466
+ <TextInputField
467
+ value={props.customValue ?? ""}
468
+ onChangeText={props.onCustomValue}
469
+ placeholder={loc.customValue}
470
+ accessibilityLabel={`${props.label}: ${loc.customValue}`}
471
+ autoFocus
472
+ />
473
+ ) : (
474
+ <CardSelectItem
475
+ onPress={() => props.onCustomSelect?.()}
476
+ accessibilityLabel={`${props.label}: ${loc.customValue}`}
477
+ style={styles.candidateItem}
478
+ >
479
+ <Text size="sm" color="muted">{loc.customValue}</Text>
480
+ </CardSelectItem>
481
+ )
482
+ ) : null}
483
+ </View>
484
+ ) : null}
485
+ {props.onKeep || props.onDrop ? (
486
+ <View style={styles.verbRow}>
487
+ {props.onDrop ? <Button title={loc.reject} color="muted" onPress={props.onDrop} /> : null}
488
+ {props.onKeep ? <Button title={loc.accept} color="secondary" disabled={props.keepDisabled} onPress={props.onKeep} /> : null}
489
+ </View>
490
+ ) : null}
205
491
  </View>
206
492
  );
207
493
  }
208
494
 
209
- // ── WHOLE-SET: one bordered card, review the lot together ──────────────────
495
+ // A plain field: the inline-record row DetailRow metrics (the record
496
+ // template's labelWidth 130 / minHeight 40); the value is press-to-edit
497
+ // when `onChangeText` is given.
498
+ // Rest and edit share ONE constant box (the full value column at 40px):
499
+ // editing swaps the Text for a borderless input with identical metrics.
500
+ const valueBlock =
501
+ editing && props.onChangeText ? (
502
+ <View style={[styles.fieldPress, styles.fieldPressHovered]}>
503
+ <RNTextInput
504
+ value={props.value}
505
+ onChangeText={props.onChangeText}
506
+ accessibilityLabel={props.accessibilityLabel ?? props.label}
507
+ autoFocus
508
+ onBlur={() => setEditing(false)}
509
+ onSubmitEditing={() => setEditing(false)}
510
+ style={styles.valueInput}
511
+ />
512
+ </View>
513
+ ) : (
514
+ <Text size="sm" weight="medium" tabular numberOfLines={1}>
515
+ {props.value}
516
+ </Text>
517
+ );
518
+ // The label names the SUBJECT of a decision — full color, never muted
519
+ // (DetailRow's muted label is the passive record-page convention).
210
520
  return (
211
- <View style={[reviewCardStyle, status !== "open" ? reviewResolvedStyle : null]}>
212
- <Text size="xs" color="muted" weight="medium">
213
- {title}
521
+ <View style={styles.fieldPlainRow}>
522
+ <Text size="sm" weight="medium" style={styles.fieldPlainLabel} numberOfLines={1}>
523
+ {props.label}
214
524
  </Text>
215
- {summaryNote}
216
- <View>
217
- {props.items.map((item, i) => (
218
- <View key={props.getKey(item, i)} style={i > 0 ? styles.changeSpacer : undefined}>
219
- {props.renderItem(item, i)}
220
- </View>
221
- ))}
525
+ <View style={{ flex: 1 }}>
526
+ {!editing && props.onChangeText ? (
527
+ <FocusRingPressable
528
+ onPress={() => setEditing(true)}
529
+ accessibilityRole="button"
530
+ accessibilityLabel={props.accessibilityLabel ?? props.label}
531
+ style={(state: { hovered: boolean }) => [styles.fieldPress, state.hovered ? styles.fieldPressHovered : null]}
532
+ >
533
+ {valueBlock}
534
+ </FocusRingPressable>
535
+ ) : (
536
+ valueBlock
537
+ )}
222
538
  </View>
223
- {resolvedLine}
224
539
  </View>
225
540
  );
226
541
  }
227
542
 
543
+ export interface ChangeBandProps {
544
+ /** "add" = the green + band (incoming); "remove" = the red − band. */
545
+ tone: "add" | "remove";
546
+ /** Clamp string content to N lines. Default UNCLAMPED — a review must show
547
+ * the whole value being decided (prose edits included). */
548
+ numberOfLines?: number;
549
+ children: ReactNode;
550
+ }
551
+
552
+ /** The raw diff BAND — a light tinted row with the aligned +/− marker. The
553
+ * pieces `ChangeField`/`ChangeValueInput` are built from it; reach for it
554
+ * directly for record-scale strokes (a removed record's one-line summary)
555
+ * or any custom body. */
556
+ export function ChangeBand(props: ChangeBandProps) {
557
+ const add = props.tone === "add";
558
+ return (
559
+ <View style={styles.diffBlock(add ? colors.emerald[50] : colors.red[50])}>
560
+ <Text size="sm" weight="medium" tabular style={styles.marker(add ? colors.emerald[700] : colors.red[600])}>
561
+ {add ? "+" : "−"}
562
+ </Text>
563
+ {typeof props.children === "string" ? (
564
+ <Text size="sm" tabular numberOfLines={props.numberOfLines} style={{ flexShrink: 1 }}>
565
+ {props.children}
566
+ </Text>
567
+ ) : (
568
+ props.children
569
+ )}
570
+ </View>
571
+ );
572
+ }
573
+
574
+ export interface ChangeValueInputProps {
575
+ value: string;
576
+ onChangeText?: (value: string) => void;
577
+ /** Shown (muted) at rest while the value is empty — an undecided conflict. */
578
+ placeholder?: string;
579
+ /** A fixed suffix (a unit — "pcs", "EUR"): renders after the editable core
580
+ * and never enters the input. Type the number, not the unit. */
581
+ unit?: string;
582
+ accessibilityLabel: string;
583
+ }
584
+
228
585
  /**
229
- * The commit bar for a `ChangeReview`Accept all (1-by-1) · Discard · Apply.
230
- * SEPARATE from the list so the host pins it where commit bars belong: drop it
231
- * into a `DialogFooter` / `DrawerFooter`, or render it inline under the list.
232
- * It reads the same `items` + `statusOf` the list does, so it owns Accept-all
233
- * and the apply-disabled-at-0-kept logic — the host never re-derives them.
234
- * Renders a right-aligned button row (no divider — the footer container or an
235
- * inline wrapper provides the chrome); self-hides once `status` isn't open.
586
+ * The proposed value as a PROPER DIFF at rest the green `+` line that
587
+ * becomes a real input on press. The review scans like a diff; editing is one
588
+ * click away. Rest and edit states share one height (no layout shift).
236
589
  */
237
- export function ChangeReviewActions<T>(props: ChangeReviewActionsProps<T>) {
590
+ export function ChangeValueInput(props: ChangeValueInputProps) {
591
+ const [editing, setEditing] = useState(false);
592
+ const editable = props.onChangeText != null;
593
+
594
+ const marker = (
595
+ <Text size="sm" weight="medium" tabular style={styles.marker(colors.emerald[700])}>
596
+ +
597
+ </Text>
598
+ );
599
+ const unit = props.unit ? (
600
+ <Text size="sm" color="muted" tabular>
601
+ {props.unit}
602
+ </Text>
603
+ ) : null;
604
+
605
+ // Editing happens INSIDE the band — the container never changes (no width /
606
+ // font / height shift): the value Text swaps for a borderless input with
607
+ // IDENTICAL type metrics; the unit stays fixed outside the editable core.
608
+ if (editing && editable) {
609
+ return (
610
+ <View style={[styles.valueRest, styles.valueRestHovered]}>
611
+ {marker}
612
+ <RNTextInput
613
+ value={props.value}
614
+ onChangeText={props.onChangeText}
615
+ placeholder={props.placeholder}
616
+ accessibilityLabel={props.accessibilityLabel}
617
+ autoFocus
618
+ onBlur={() => setEditing(false)}
619
+ onSubmitEditing={() => setEditing(false)}
620
+ style={styles.valueInput}
621
+ />
622
+ {unit}
623
+ </View>
624
+ );
625
+ }
626
+
627
+ const content = (
628
+ <>
629
+ {marker}
630
+ {props.value ? (
631
+ <Text size="sm" weight="medium" tabular numberOfLines={2} style={{ flexShrink: 1 }}>
632
+ {props.value}
633
+ </Text>
634
+ ) : (
635
+ <Text size="sm" color="muted" style={{ flexShrink: 1 }}>
636
+ {props.placeholder ?? ""}
637
+ </Text>
638
+ )}
639
+ {unit}
640
+ {props.value && !props.unit ? null : <View style={{ flex: 1 }} />}
641
+ </>
642
+ );
643
+ if (!editable) {
644
+ return <View style={styles.valueRest}>{content}</View>;
645
+ }
646
+ return (
647
+ <FocusRingPressable
648
+ onPress={() => setEditing(true)}
649
+ accessibilityRole="button"
650
+ accessibilityLabel={props.accessibilityLabel}
651
+ style={(state: { hovered: boolean }) => [styles.valueRest, state.hovered ? styles.valueRestHovered : null]}
652
+ >
653
+ {content}
654
+ </FocusRingPressable>
655
+ );
656
+ }
657
+
658
+ /** The collapsed one-line content a decided `Change` shows. Compose text or
659
+ * anything row-sized; the frame (status mark + Undo) is the Change's. */
660
+ export function ChangeSummary({ children }: { children: ReactNode }) {
661
+ return typeof children === "string" ? (
662
+ <Text size="sm" numberOfLines={1}>
663
+ {children}
664
+ </Text>
665
+ ) : (
666
+ <>{children}</>
667
+ );
668
+ }
669
+
670
+ export interface ChangeReviewActionsProps {
671
+ onApply?: () => void;
672
+ onDiscard?: () => void;
673
+ /** Keep-all presses every pending entry's own `onAccept`. Rendered only
674
+ * when at least one registered entry is acceptable (or `onAcceptAll`). */
675
+ showAcceptAll?: boolean;
676
+ /** Host-owned Keep-all — replaces the context-derived handler ENTIRELY
677
+ * (for decisions the registry can't see: field-level state, unresolved
678
+ * conflicts). With it, the button renders even when no registered entry
679
+ * is acceptable. */
680
+ onAcceptAll?: () => void;
681
+ applyLabel?: string;
682
+ discardLabel?: string;
683
+ acceptAllLabel?: string;
684
+ /** Disable Apply below this many kept (default 1 when any entry carries
685
+ * accept/reject; pass 0 for a whole-set review that always applies). */
686
+ minKept?: number;
687
+ /** Host-level gate — for state the registry can't see (field inclusion, an
688
+ * unresolved conflict still on its placeholder). ORs with the kept gate. */
689
+ applyDisabled?: boolean;
690
+ /** The host's committing state — a real apply is an async write. */
691
+ applyLoading?: boolean;
692
+ }
693
+
694
+ /**
695
+ * The commit bar — place it where commit bars belong (a `DialogFooter`, a
696
+ * `DrawerFooter`, inline under the list). Reads the registered entries, so
697
+ * Keep-all and the apply-disabled-at-0-kept logic never live in the host.
698
+ */
699
+ export function ChangeReviewActions(props: ChangeReviewActionsProps) {
238
700
  const loc = useLoticsLocale().changeReview;
239
- if ((props.status ?? "open") !== "open") return null;
240
- const perItem = props.onAcceptItem != null;
241
- const statusFor = (item: T, i: number) => props.statusOf?.(item, i) ?? "pending";
242
- const keptCount = props.items.filter((it, i) => statusFor(it, i) === "accepted").length;
243
- const acceptAll = () =>
244
- props.items.forEach((it, i) => {
245
- if (statusFor(it, i) === "pending") props.onAcceptItem?.(i);
246
- });
701
+ const { entries } = useReviewCtx("ChangeReviewActions");
702
+ const all = [...entries.values()];
703
+ const acceptable = all.filter((e) => e.accept != null);
704
+ const kept = all.filter((e) => e.status === "accepted").length;
705
+ const pendingAccepts = acceptable.filter((e) => e.status === "pending");
706
+ const minKept = props.minKept ?? (acceptable.length > 0 ? 1 : 0);
247
707
  return (
248
708
  <View style={styles.actionsRow}>
249
- {perItem ? <Button title={props.acceptAllLabel ?? loc.acceptAll} color="muted" onPress={acceptAll} /> : null}
709
+ {props.showAcceptAll !== false && (props.onAcceptAll != null || acceptable.length > 0) ? (
710
+ <Button
711
+ title={props.acceptAllLabel ?? loc.acceptAll}
712
+ color="secondary"
713
+ disabled={props.onAcceptAll == null && pendingAccepts.length === 0}
714
+ onPress={props.onAcceptAll ?? (() => pendingAccepts.forEach((e) => e.accept?.()))}
715
+ />
716
+ ) : null}
717
+ {acceptable.length > 0 ? (
718
+ <Text size="xs" color="muted" tabular>
719
+ {loc.keptCount(kept, acceptable.length)}
720
+ </Text>
721
+ ) : null}
250
722
  <View style={{ flex: 1 }} />
251
723
  {props.onDiscard ? <Button title={props.discardLabel ?? loc.discard} color="muted" onPress={props.onDiscard} /> : null}
252
724
  {props.onApply ? (
253
725
  <Button
254
- title={props.applyLabel ?? (perItem ? loc.applyKept : loc.apply)}
726
+ title={props.applyLabel ?? loc.apply}
255
727
  color="primary"
256
- disabled={perItem && keptCount === 0}
728
+ disabled={props.applyDisabled === true || kept < minKept}
729
+ loading={props.applyLoading}
257
730
  onPress={props.onApply}
258
731
  />
259
732
  ) : null}
@@ -263,41 +736,64 @@ export function ChangeReviewActions<T>(props: ChangeReviewActionsProps<T>) {
263
736
 
264
737
  const styles = {
265
738
  ...StyleSheet.create({
266
- // 1-by-1 outer: no chrome — the parent (dialog / message column) is the
267
- // boundary. A nested card-in-card with two fills reads muddy. (Whole-set
268
- // wears the shared `reviewCardStyle`; the resolved summary just stacks.)
269
- stack: { gap: 14 },
270
- resolvedCard: { gap: 14 },
271
- head: { flexDirection: "row", alignItems: "center", gap: 8 },
272
- note: { borderLeftWidth: 2, borderLeftColor: colors.zinc[200], paddingLeft: 12 },
273
- list: { gap: 8 },
274
- // A PENDING proposal: a hairline-bordered card, no fill — it needs a decision.
275
- itemCard: { borderWidth: 1, borderColor: colors.border, borderRadius: 12, padding: 16, gap: 14, backgroundColor: colors.white },
276
- // Actions sit in a divided footer so they read intentional, not floating.
277
- itemActions: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8, borderTopWidth: 1, borderTopColor: colors.zinc[100], paddingTop: 12 },
278
- // A DECIDED proposal: a lighter, quieter row — resolved, out of the way.
279
- decidedRow: {
280
- flexDirection: "row",
281
- alignItems: "center",
282
- gap: 10,
283
- borderWidth: 1,
284
- borderColor: colors.zinc[100],
285
- borderRadius: 10,
286
- paddingLeft: 12,
287
- paddingRight: 8,
288
- paddingVertical: 6,
289
- minHeight: 44,
290
- },
291
- decidedDrop: { backgroundColor: colors.zinc[50] },
292
- changeSpacer: { marginTop: 12, borderTopWidth: 1, borderTopColor: colors.zinc[100], paddingTop: 12 },
293
- diff: { gap: 3 },
294
- diffLine: { flexDirection: "row", alignItems: "baseline", gap: 8 },
739
+ stack: { gap: 10 },
740
+ header: { flexDirection: "row", alignItems: "center", gap: 8, paddingBottom: 2 },
741
+ // An OPEN section no card box; the divider between changes is the only rule.
742
+ changeSection: { gap: 12, paddingVertical: 4 },
743
+ divider: { height: 1, backgroundColor: colors.zinc[100] },
744
+ // The heading line: subject left at full weight, meta right. No border —
745
+ // the subject's weight carries the hierarchy.
746
+ labelBand: { flexDirection: "row", alignItems: "center", gap: 8, minHeight: 24 },
747
+ changeBody: { gap: 8 },
748
+ changeActions: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 },
749
+ // A DECIDED change: the same open row, settled and quiet.
750
+ decidedRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 10, minHeight: 44 },
751
+ summaryFallback: { opacity: 0.7 },
752
+ reasoning: { borderLeftWidth: 2, borderLeftColor: colors.zinc[200], paddingLeft: 10 },
753
+ fields: { gap: 12 },
754
+ recordFieldStack: { gap: 6 },
755
+ fieldPlainRow: { flexDirection: "row", alignItems: "center", gap: 12, minHeight: 40 },
756
+ fieldPlainLabel: { width: 130 },
757
+ fieldRule: { height: 1, backgroundColor: colors.zinc[100] },
758
+ fieldDiff: { gap: 6, paddingVertical: 4 },
759
+ recordCard: { borderWidth: 1, borderColor: colors.border, borderRadius: 10, backgroundColor: colors.white, overflow: "hidden" },
760
+ recordCardHeader: { flexDirection: "row", alignItems: "center", gap: 4, paddingHorizontal: 12, paddingVertical: 9, minHeight: 38 },
761
+ recordCardBody: { padding: 12, gap: 10 },
762
+ verbRow: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 },
763
+ fieldPress: { borderRadius: 8, borderWidth: 1, borderColor: "transparent", paddingHorizontal: 8, marginHorizontal: -8, minHeight: 40, justifyContent: "center", alignSelf: "stretch" },
764
+ fieldPressHovered: { borderColor: colors.border },
765
+ fieldCollapsed: { flexDirection: "row", alignItems: "center", gap: 10, minHeight: 44, borderWidth: 1, borderColor: colors.border, borderRadius: 10, paddingHorizontal: 12, paddingVertical: 6, backgroundColor: colors.white },
766
+ candidateList: { gap: 8 },
767
+ candidateItem: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 10, paddingHorizontal: 12 },
295
768
  strike: { textDecorationLine: "line-through" },
296
- // The commit bar: a plain right-aligned row. The container (DialogFooter / an
297
- // inline wrapper) owns any divider — the bar itself stays chrome-less so it
298
- // nests cleanly in a footer without double borders. flex:1 fills the row.
299
769
  actionsRow: { flexDirection: "row", alignItems: "center", gap: 8, flex: 1 },
770
+ // Reserves the input's height so rest ↔ edit never shifts the layout. The
771
+ // GitHub-diff idiom: a light GREEN band with dark text carries "incoming";
772
+ // an editable value hovers via its BORDER (the transparent edge reveals).
773
+ valueRest: { flexDirection: "row", alignItems: "center", gap: 8, minHeight: 40, borderRadius: 8, borderWidth: 1, borderColor: "transparent", paddingHorizontal: 10, backgroundColor: colors.emerald[50], alignSelf: "stretch" },
774
+ // The in-band editor: the SAME type metrics as the resting value Text —
775
+ // editing never shifts the band.
776
+ valueInput: {
777
+ flex: 1,
778
+ padding: 0,
779
+ borderWidth: 0,
780
+ backgroundColor: "transparent",
781
+ fontFamily: fontFamilyMedium,
782
+ fontSize: 14,
783
+ lineHeight: 20,
784
+ color: colors.zinc[900],
785
+ fontVariant: ["tabular-nums"],
786
+ },
787
+ valueRestHovered: { borderColor: colors.border },
300
788
  }),
301
789
  marker: (color: string) => ({ color, width: 12 }),
302
- value: (color: string) => ({ color }),
790
+ diffBlock: (bg: string) => ({
791
+ flexDirection: "row" as const,
792
+ alignItems: "center" as const,
793
+ gap: 8,
794
+ minHeight: 40,
795
+ borderRadius: 8,
796
+ paddingHorizontal: 10,
797
+ backgroundColor: bg,
798
+ }),
303
799
  };