@lotics/ui 28.3.1 → 29.2.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.
@@ -28,7 +28,8 @@ import { INLINE_CONTROL_HEIGHT } from "@lotics/ui/inline_edit";
28
28
  import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
29
29
  import { Composer } from "@lotics/ui/composer";
30
30
  import { AgentRun } from "@lotics/ui/agent_run";
31
- import { Change, ChangeReview, ChangeReviewActions, ChangeReviewHeader, ChangeSummary, type ChangeStatus } from "@lotics/ui/change_review";
31
+ import { DiffMark } from "@lotics/ui/diff_mark";
32
+ import { useChangeSet } from "@lotics/ui/use_change_set";
32
33
  import { Dialog, DialogHeader, DialogHeaderTitle, DialogScrollArea, DialogFooter } from "@lotics/ui/dialog";
33
34
  import { CompletionState } from "@lotics/ui/completion_state";
34
35
  import { type ConfidenceLevel } from "@lotics/ui/confidence";
@@ -228,23 +229,26 @@ export function TplTaskBoard() {
228
229
  const [phase, setPhase] = useState<Phase>("idle");
229
230
  const [revealed, setRevealed] = useState(0);
230
231
  const [proposals, setProposals] = useState<Proposal[]>([]);
231
- const [decisions, setDecisions] = useState<Record<string, ChangeStatus>>({});
232
232
  const [addedCount, setAddedCount] = useState(0);
233
233
 
234
+ // Each drafted task deserves its OWN verdict, so this set starts `pending` and
235
+ // the commit counts what was kept. An intake where the whole drop is one
236
+ // decision leaves `initial` at its default and never renders per-row buttons.
237
+ const proposalIds = useMemo(() => proposals.map((p) => p.id), [proposals]);
238
+ const review = useChangeSet(proposalIds, { initial: "pending" });
239
+
234
240
  const startDraft = useCallback(() => { setRevealed(0); setPhase("reading"); }, []);
235
- const resetDraft = useCallback(() => { setProposals([]); setDecisions({}); setRevealed(0); setPhase("idle"); }, []);
241
+ const resetDraft = useCallback(() => { setProposals([]); review.reset(); setRevealed(0); setPhase("idle"); }, [review]);
236
242
  const closeDraft = useCallback(() => { setDraftOpen(false); resetDraft(); }, [resetDraft]);
237
243
  const editProposal = useCallback((id: string, p: Partial<Proposal>) =>
238
244
  setProposals((ps) => ps.map((x) => (x.id === id ? { ...x, ...p } : x))), []);
239
- const decide = useCallback((id: string, status: ChangeStatus | null) =>
240
- setDecisions((dec) => { const n = { ...dec }; if (status) n[id] = status; else delete n[id]; return n; }), []);
241
- const acceptedCount = proposals.filter((p) => decisions[p.id] === "accepted").length;
242
245
  const addAccepted = useCallback(() => {
243
- const accepted = proposals.filter((p) => decisions[p.id] === "accepted");
246
+ const kept = new Set(review.accepted);
247
+ const accepted = proposals.filter((p) => kept.has(p.id));
244
248
  setTasks((ts) => [...ts, ...accepted.map((p, i) => ({ id: `d-${p.id}-${i}`, title: p.title, ownerId: null, due: p.due, status: "todo" as Status, tags: [], files: [], action: null }))]);
245
249
  setAddedCount(accepted.length);
246
250
  setPhase("done");
247
- }, [proposals, decisions]);
251
+ }, [proposals, review.accepted]);
248
252
 
249
253
  useEffect(() => {
250
254
  if (phase !== "reading") return;
@@ -382,10 +386,10 @@ export function TplTaskBoard() {
382
386
  </View>
383
387
  </ScrollView>
384
388
 
385
- {/* Draft from notes — Composer → AgentRun → ChangeReview → commit (adds tasks).
386
- The review provider wraps the WHOLE dialog so the `Change`s (scroll area)
387
- and the commit bar (`DialogFooter`) share one review context. */}
388
- <ChangeReview>
389
+ {/* Draft from notes — Composer → AgentRun → review → commit (adds tasks).
390
+ NO provider wraps the dialog: `useChangeSet` holds the decisions, so the
391
+ counter in the scroll area and the commit bar in the footer read one
392
+ object without a context spanning both. */}
389
393
  <Dialog open={draftOpen} onOpenChange={(o) => { if (!o) closeDraft(); }} maxWidth={620}>
390
394
  <DialogHeader><DialogHeaderTitle>Draft from notes</DialogHeaderTitle></DialogHeader>
391
395
  <DialogScrollArea>
@@ -398,25 +402,37 @@ export function TplTaskBoard() {
398
402
  {phase === "reading" ? <AgentRun parts={runItems} state={revealed >= DRAFT_STEPS.length ? "done" : "streaming"} /> : null}
399
403
  {phase === "review" ? (
400
404
  <View style={{ gap: 8 }}>
401
- <ChangeReviewHeader title="Drafted tasks" />
405
+ {/* Heading + counter are ordinary components — the count is one
406
+ expression off `review`, so it can sit here, in the footer, or
407
+ nowhere, without a context spanning the screen to supply it. */}
408
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
409
+ <Text size="md" weight="semibold" style={{ flex: 1 }}>Drafted tasks</Text>
410
+ <Text size="xs" color="muted">{`${review.keptCount} of ${review.total} kept`}</Text>
411
+ </View>
402
412
  <Text size="sm" color="muted">{`${proposals.length} from your notes — keep, edit, or drop each. A “Low” mark is worth a glance.`}</Text>
403
413
  {proposals.map((p) => {
404
- const status = decisions[p.id] ?? "pending";
405
- const kept = status === "accepted";
414
+ const status = review.status(p.id);
415
+ if (status !== "pending") {
416
+ // The decided row: a mark, the title, an Undo. The template says
417
+ // how a settled proposal reads instead of inheriting one
418
+ // container's answer.
419
+ const kept = status === "accepted";
420
+ return (
421
+ <View key={p.id} style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingVertical: 6 }}>
422
+ <DiffMark kind={kept ? "added" : "removed"} />
423
+ <Text size="sm" numberOfLines={1} style={{ flex: 1 }} color={kept ? "default" : "muted"} decoration={kept ? undefined : "lineThrough"}>{p.title}</Text>
424
+ <Button title="Undo" color="muted" onPress={() => review.undo(p.id)} />
425
+ </View>
426
+ );
427
+ }
406
428
  return (
407
- <Change
408
- key={p.id}
409
- id={p.id}
410
- status={status}
411
- onAccept={() => decide(p.id, "accepted")}
412
- onReject={() => decide(p.id, "rejected")}
413
- onUndo={() => decide(p.id, null)}
414
- >
429
+ <View key={p.id} style={{ gap: 8, paddingVertical: 6 }}>
415
430
  <TaskProposal proposal={p} onEdit={editProposal} />
416
- <ChangeSummary>
417
- <Text size="sm" numberOfLines={1} color={kept ? "default" : "muted"} style={kept ? undefined : { textDecorationLine: "line-through" }}>{p.title}</Text>
418
- </ChangeSummary>
419
- </Change>
431
+ <View style={{ flexDirection: "row", justifyContent: "flex-end", gap: 8 }}>
432
+ <Button title="Drop" color="muted" onPress={() => review.reject(p.id)} />
433
+ <Button title="Keep" color="secondary" onPress={() => review.accept(p.id)} />
434
+ </View>
435
+ </View>
420
436
  );
421
437
  })}
422
438
  </View>
@@ -427,7 +443,8 @@ export function TplTaskBoard() {
427
443
  </DialogScrollArea>
428
444
  {phase === "review" ? (
429
445
  <DialogFooter>
430
- <ChangeReviewActions onApply={addAccepted} onDiscard={resetDraft} applyLabel={`Add ${acceptedCount} ${acceptedCount === 1 ? "task" : "tasks"}`} />
446
+ <Button title="Discard" color="secondary" onPress={resetDraft} />
447
+ <Button title={`Add ${review.keptCount} ${review.keptCount === 1 ? "task" : "tasks"}`} color="primary" disabled={review.keptCount === 0} onPress={addAccepted} />
431
448
  </DialogFooter>
432
449
  ) : phase === "done" ? (
433
450
  <DialogFooter>
@@ -436,7 +453,6 @@ export function TplTaskBoard() {
436
453
  </DialogFooter>
437
454
  ) : null}
438
455
  </Dialog>
439
- </ChangeReview>
440
456
  </>
441
457
  );
442
458
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "28.3.1",
3
+ "version": "29.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -106,7 +106,6 @@
106
106
  },
107
107
  "./markdown.css": "./src/markdown.css",
108
108
  "./confidence": "./src/confidence.tsx",
109
- "./change_review": "./src/change_review.tsx",
110
109
  "./finding": "./src/finding.tsx",
111
110
  "./clarify": "./src/clarify.tsx",
112
111
  "./clarify_wizard": "./src/clarify_wizard.tsx",
@@ -147,6 +146,7 @@
147
146
  "./inset": "./src/inset.tsx",
148
147
  "./inline_button": "./src/inline_button.tsx",
149
148
  "./inline_edit": "./src/inline_edit.tsx",
149
+ "./inline_slot": "./src/inline_slot.tsx",
150
150
  "./inline_static": "./src/inline_static.tsx",
151
151
  "./inline_text_input": "./src/inline_text_input.tsx",
152
152
  "./inline_number_input": "./src/inline_number_input.tsx",
@@ -256,7 +256,10 @@
256
256
  "./skip_link": "./src/skip_link.tsx",
257
257
  "./text_utils": "./src/text_utils.ts",
258
258
  "./column_filter": "./src/column_filter.tsx",
259
- "./chip_group": "./src/chip_group.tsx"
259
+ "./chip_group": "./src/chip_group.tsx",
260
+ "./diff_value": "./src/diff_value.tsx",
261
+ "./diff_mark": "./src/diff_mark.tsx",
262
+ "./use_change_set": "./src/use_change_set.ts"
260
263
  },
261
264
  "files": [
262
265
  "src",
package/src/agent_run.tsx CHANGED
@@ -142,7 +142,7 @@ const INK = colors.zinc[700];
142
142
  * fires (not a growing stack of dots); once prose resumes the group settles into
143
143
  * one row — "{final action} ({n} steps)" — that EXPANDS on press to the steps in
144
144
  * between. Text segments render as prose. The run always ends on the agent's text.
145
- * Pair with `Composer` + `ChangeReview`.
145
+ * Pair with `Composer` + a review surface (`DiffValue` / `useChangeSet`).
146
146
  */
147
147
  export function AgentRun(props: AgentRunProps) {
148
148
  const { parts, labelForCall, renderToolOutput, onRetry, stepsLabel, accessibilityLabel } = props;
@@ -49,7 +49,7 @@ const ScopeContext = createContext<ClarifyWizardScopeValue | null>(null);
49
49
  /**
50
50
  * The compound frame for a wizard whose actions live in the dialog's footer —
51
51
  * the dialog grammar's home for action bars. Wrap the `Dialog` from OUTSIDE
52
- * (the `ChangeReview` position), put the `ClarifyWizard` in the content pane
52
+ * (where the review would sit), put the `ClarifyWizard` in the content pane
53
53
  * and `ClarifyWizardActions` in the `DialogFooter`; the wizard then suppresses
54
54
  * its inline action row and drives the bar through this scope. One wizard per
55
55
  * scope. Standalone `ClarifyWizard` (no scope — e.g. riding a parked run's
@@ -0,0 +1,110 @@
1
+ import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
2
+ import { Text } from "./text";
3
+ import { Icon, type IconName } from "./icon";
4
+ import { colors } from "./colors";
5
+ import { useLoticsLocale } from "./locale";
6
+
7
+ /**
8
+ * WHAT HAPPENED TO THIS ROW — the second atom, and the other half of a review.
9
+ *
10
+ * `DiffValue` says how a value moved; this says what happened to the thing
11
+ * holding it, for the cases where no single value carries the change: a row that
12
+ * is entirely new, one that will be removed, one that a document merely touched.
13
+ *
14
+ * Deliberately tiny and layout-free so it can lead a table row, sit in a card
15
+ * corner, precede a field label, or annotate a list item — the host decides
16
+ * where. It renders a glyph plus an accessible word, never colour alone: "added"
17
+ * and "removed" differing only by hue is invisible to a reader who cannot
18
+ * separate them, and this is exactly the distinction a review turns on.
19
+ *
20
+ * ONE treatment, everywhere: a filled DISC. It briefly had two — a bare glyph
21
+ * for dense surfaces, a filled shape for sparse ones — and a form marked with
22
+ * bare glyphs sitting beside a table marked with filled shapes read as two
23
+ * different systems saying the same thing. A reader parses shape before meaning,
24
+ * so an unexplained difference reads as an accident however principled the
25
+ * reason was. The disc also gives the glyph its own ground, which is what lets
26
+ * one mark sit on a white row, a tinted cell, or a photograph.
27
+ */
28
+ export type DiffKind = "added" | "changed" | "removed" | "unchanged";
29
+
30
+ export interface DiffMarkProps {
31
+ kind: DiffKind;
32
+ /** Print the word beside the disc. Off by default — in a table the column
33
+ * already says what the mark means, and repeating it on every row is noise. */
34
+ showLabel?: boolean;
35
+ /** Override the word (the row is not "added", it is "new claim line"). */
36
+ label?: string;
37
+ style?: StyleProp<ViewStyle>;
38
+ }
39
+
40
+ /**
41
+ * Sized to be READ, not to be tasteful. The glyph began at 13px against 14px
42
+ * label text — proportionate, and too small to identify without looking
43
+ * straight at it, which is the one thing a scan mark must never require.
44
+ *
45
+ * The ink is 700 on a 100 ground: at 600-on-50 the amber measured ~3.4:1, over
46
+ * the 3:1 floor for a graphical object by so little that any dimming — a decided
47
+ * row at half opacity, which this renders inside routinely — put it under.
48
+ */
49
+ export const DIFF_MARK_SIZE = 22;
50
+ const GLYPH_SIZE = 14;
51
+
52
+ const GLYPH: Record<DiffKind, { icon: IconName | null; color: string; ground: string }> = {
53
+ added: { icon: "plus", color: colors.emerald[700], ground: colors.emerald[100] },
54
+ changed: { icon: "pencil", color: colors.amber[700], ground: colors.amber[100] },
55
+ removed: { icon: "minus", color: colors.red[700], ground: colors.red[100] },
56
+ unchanged: { icon: null, color: colors.zinc[300], ground: "transparent" },
57
+ };
58
+
59
+ export function DiffMark({ kind, showLabel, label, style }: DiffMarkProps) {
60
+ const loc = useLoticsLocale().diff;
61
+ const g = GLYPH[kind];
62
+ const word = label ?? loc[kind];
63
+
64
+ // UNTOUCHED IS SILENCE — an empty disc-sized hole, not a mark.
65
+ //
66
+ // Most rows in a real review are untouched, and a glyph on every one of them
67
+ // is the noise the mark existed to cut through: the reader ends up reading
68
+ // the column to find the rows that moved. The hole keeps the column's width,
69
+ // so the marks still form a line down the page and no untouched row shifts
70
+ // left. It announces nothing either — "Unchanged" eleven times is the same
71
+ // noise to a screen reader that a dot is to an eye.
72
+ if (kind === "unchanged") {
73
+ return <View style={[styles.disc, style]} />;
74
+ }
75
+
76
+ const disc = (extra?: StyleProp<ViewStyle>) => (
77
+ <View
78
+ style={[styles.disc, { backgroundColor: g.ground }, extra]}
79
+ accessibilityLabel={showLabel ? undefined : word}
80
+ >
81
+ <Icon name={g.icon as IconName} size={GLYPH_SIZE} color={g.color} />
82
+ </View>
83
+ );
84
+
85
+ // The word sits OUTSIDE the disc — text crammed inside stops it being a
86
+ // circle, and the shape is what makes the mark findable without reading.
87
+ // Without a word the disc IS the root: a wrapper around it would take the
88
+ // caller's style and leave the disc unstyled, which is a box with no size.
89
+ if (!showLabel) return disc(style);
90
+
91
+ return (
92
+ <View style={[styles.row, style]}>
93
+ {disc()}
94
+ <Text size="xs" color="muted">
95
+ {word}
96
+ </Text>
97
+ </View>
98
+ );
99
+ }
100
+
101
+ const styles = StyleSheet.create({
102
+ row: { flexDirection: "row", alignItems: "center", gap: 6 },
103
+ disc: {
104
+ width: DIFF_MARK_SIZE,
105
+ height: DIFF_MARK_SIZE,
106
+ borderRadius: DIFF_MARK_SIZE / 2,
107
+ alignItems: "center",
108
+ justifyContent: "center",
109
+ },
110
+ });
@@ -0,0 +1,232 @@
1
+ import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
2
+ import { type ReactNode } from "react";
3
+ import { Text } from "./text";
4
+ import { colors } from "./colors";
5
+ import { type TextColor } from "./text_utils";
6
+
7
+ /**
8
+ * A VALUE THAT CHANGED — the atom of every review surface.
9
+ *
10
+ * It owns no layout beyond its own two lines: no container, no context, no
11
+ * section, no decision. That is the whole point. A review is not a place you go;
12
+ * it is a thing that happens to values already on screen, so the diff has to
13
+ * render wherever the value renders — a table cell, a `DetailRow`, a total, a
14
+ * chip, a sentence — and inherit that container's width, alignment and rhythm.
15
+ *
16
+ * The predecessor got this backwards: it was a compound family whose root was a
17
+ * required context provider emitting its own divided stack, so a change could
18
+ * only ever look like a stack of labelled field diffs. Every surface that needed
19
+ * a diff somewhere else — inside a ledger row, beside a total — had to rebuild
20
+ * it, and did.
21
+ *
22
+ * BOTH values stay on screen. The old one is the only evidence a reader has that
23
+ * the correction is the right SIZE; a bare new value asks them to trust it.
24
+ */
25
+ export interface DiffValueProps {
26
+ /**
27
+ * The value being replaced. Omit for an ADDITION — nothing was there.
28
+ *
29
+ * Usually a string or a number. It may also be a NODE — a `FileBadge` for a
30
+ * scan being superseded, a chip for a link being repointed — and that case is
31
+ * struck by a drawn rule rather than by text decoration, because
32
+ * `line-through` set on a `Text` does not cross a `View` child: the old value
33
+ * would render at full strength beside its replacement with nothing saying it
34
+ * is the one going away.
35
+ */
36
+ before?: ReactNode;
37
+ /** The proposed value. Omit for a REMOVAL — the struck `before` is the change. */
38
+ after?: ReactNode;
39
+ /**
40
+ * `stacked` (default) puts `before` above `after` — the shape for a column of
41
+ * figures, where the eye compares down the column and the row height is free.
42
+ * `inline` puts them on one line with an arrow, for prose and dense rows where
43
+ * vertical space is the scarce thing.
44
+ */
45
+ layout?: "stacked" | "inline";
46
+ size?: "xs" | "sm" | "md";
47
+ /** Right for figures (with `tabular`), left for text. Inherited container
48
+ * alignment is not enough: the two lines must agree with EACH OTHER. */
49
+ align?: "left" | "right";
50
+ /** Tabular figures — always on for money and counts, so the digits line up
51
+ * between the struck value and its replacement. */
52
+ tabular?: boolean;
53
+ /** The tone of the AFTER value. Default `success` (a proposal, not yet a
54
+ * fact); `danger` when the change itself is the bad news. */
55
+ tone?: "success" | "danger" | "default";
56
+ /**
57
+ * Shown in place of a missing `after` on a removal, and a missing `before` on
58
+ * an addition, when the absence needs a word rather than a blank.
59
+ *
60
+ * YOUR string, and yours to translate. This component renders no word of its
61
+ * own — the only fallbacks are `—` and empty — so nothing here reaches the
62
+ * locale packs. `DiffMark` beside it does ship localized words, and the two
63
+ * are different sentences even where English collides them: the mark says
64
+ * what happened to the ROW ("Removed"), this says what is in the FIELD now,
65
+ * which in Vietnamese is "đã xóa" or "bỏ trống" rather than the bare verb.
66
+ * The same prop also carries "Pick a candidate below".
67
+ */
68
+ placeholder?: string;
69
+ /**
70
+ * HOW FAR the value moved, pre-formatted by the host — "+250 pcs",
71
+ * "−118.519 ₫ VAT", "3 days later".
72
+ *
73
+ * `1.600.000 → 1.481.481` makes the reader do subtraction to find out whether
74
+ * a correction is trivial or alarming; the delta is the thing they were going
75
+ * to work out anyway. A string, not a number, because only the host knows the
76
+ * unit, the sign convention and the rounding — the same contract
77
+ * `FindingComparison.delta` already uses.
78
+ */
79
+ delta?: string;
80
+ style?: StyleProp<ViewStyle>;
81
+ accessibilityLabel?: string;
82
+ }
83
+
84
+ const TONE: Record<NonNullable<DiffValueProps["tone"]>, TextColor> = {
85
+ success: "success",
86
+ danger: "danger",
87
+ default: "default",
88
+ };
89
+
90
+ export function DiffValue(props: DiffValueProps) {
91
+ const {
92
+ before,
93
+ after,
94
+ layout = "stacked",
95
+ size = "sm",
96
+ align = "left",
97
+ tabular,
98
+ tone = "success",
99
+ placeholder,
100
+ delta,
101
+ style,
102
+ accessibilityLabel,
103
+ } = props;
104
+
105
+ const hasBefore = before !== undefined && before !== null && before !== "";
106
+ const hasAfter = after !== undefined && after !== null && after !== "";
107
+
108
+ /**
109
+ * AGREEMENT IS NOT A CHANGE — and it is information worth showing.
110
+ *
111
+ * A document that confirms the value already on the record is the good case:
112
+ * a supplier invoice matching the amount that was claimed, a second source
113
+ * agreeing with the first. Rendered as a diff it came out as the value struck
114
+ * through above an identical copy in the proposal colour, which asserts a
115
+ * change that did not happen and puts the reader through a comparison whose
116
+ * answer is "nothing". One value, plain, is the honest rendering.
117
+ *
118
+ * Compared only for primitives: two ReactNodes cannot be equality-checked, and
119
+ * guessing would silently collapse two genuinely different renders.
120
+ */
121
+ const comparable = (v: ReactNode) => typeof v === "string" || typeof v === "number";
122
+ const unchanged = hasBefore && hasAfter && comparable(before) && comparable(after) && before === after;
123
+
124
+ if (unchanged) {
125
+ return (
126
+ <View style={[align === "right" ? styles.stackedRight : null, style]} accessibilityLabel={accessibilityLabel}>
127
+ <Text size={size} tabular={tabular} numberOfLines={1} style={{ textAlign: align }}>
128
+ {after}
129
+ </Text>
130
+ </View>
131
+ );
132
+ }
133
+
134
+ // A DiffValue with neither side is not a diff — render the placeholder rather
135
+ // than an empty box that reads as a loading state.
136
+ if (!hasBefore && !hasAfter) {
137
+ return (
138
+ <Text size={size} color="muted" tabular={tabular} style={{ textAlign: align }}>
139
+ {placeholder ?? "—"}
140
+ </Text>
141
+ );
142
+ }
143
+
144
+ const beforeText = comparable(before) ? (
145
+ <Text
146
+ size={layout === "stacked" ? "xs" : size}
147
+ color="muted"
148
+ tabular={tabular}
149
+ decoration="lineThrough"
150
+ numberOfLines={1}
151
+ style={{ textAlign: align }}
152
+ >
153
+ {before}
154
+ </Text>
155
+ ) : (
156
+ // A NODE being replaced — a file badge, a chip, an avatar. Text
157
+ // decoration cannot reach it, so the rule is drawn across it and the
158
+ // whole thing is dimmed. Both are needed: the dimming alone reads as
159
+ // "secondary", the rule alone reads as a divider.
160
+ <View style={styles.struckNode}>
161
+ {before}
162
+ <View style={styles.struckRule} pointerEvents="none" />
163
+ </View>
164
+ );
165
+
166
+ const afterText = (
167
+ <Text
168
+ size={size}
169
+ // A removal has no replacement to emphasise, so the struck value carries
170
+ // the whole change and nothing below it should compete. The placeholder
171
+ // goes LIGHTER than muted deliberately: at `muted` it rendered 14px/400/
172
+ // zinc-600 — pixel-identical to the field LABEL beside it, so the one word
173
+ // saying a value is being deleted read as chrome. It describes an absence,
174
+ // so it sits below the label rather than level with it.
175
+ color={hasAfter ? TONE[tone] : "zinc-400"}
176
+ weight={hasAfter ? "medium" : "regular"}
177
+ tabular={tabular}
178
+ numberOfLines={1}
179
+ style={{ textAlign: align }}
180
+ >
181
+ {hasAfter ? after : (placeholder ?? "")}
182
+ </Text>
183
+ );
184
+
185
+ const deltaText =
186
+ delta === undefined || delta === "" ? null : (
187
+ <Text size="xs" color="muted" tabular={tabular} numberOfLines={1} style={{ textAlign: align }}>
188
+ {delta}
189
+ </Text>
190
+ );
191
+
192
+ if (layout === "inline") {
193
+ return (
194
+ <View
195
+ style={[styles.inline, align === "right" ? styles.inlineRight : null, style]}
196
+ accessibilityLabel={accessibilityLabel}
197
+ >
198
+ {hasBefore ? beforeText : null}
199
+ {hasBefore && (hasAfter || placeholder !== undefined) ? (
200
+ // Reads aloud as "becomes". The one piece of punctuation this file
201
+ // allows itself: a diff arrow names its relation, which is the test
202
+ // the composition grammar sets for any glue mark.
203
+ <Text size={size} color="muted" aria-hidden>
204
+
205
+ </Text>
206
+ ) : null}
207
+ {hasAfter || placeholder !== undefined ? afterText : null}
208
+ {deltaText}
209
+ </View>
210
+ );
211
+ }
212
+
213
+ return (
214
+ <View
215
+ style={[styles.stacked, align === "right" ? styles.stackedRight : null, style]}
216
+ accessibilityLabel={accessibilityLabel}
217
+ >
218
+ {hasBefore ? beforeText : null}
219
+ {hasAfter || placeholder !== undefined ? afterText : null}
220
+ {deltaText}
221
+ </View>
222
+ );
223
+ }
224
+
225
+ const styles = StyleSheet.create({
226
+ struckNode: { position: "relative", alignSelf: "flex-start", opacity: 0.55 },
227
+ struckRule: { position: "absolute", left: 0, right: 0, top: "50%", height: 1, backgroundColor: colors.zinc[500] },
228
+ stacked: { gap: 1 },
229
+ stackedRight: { alignItems: "flex-end" },
230
+ inline: { flexDirection: "row", alignItems: "baseline", gap: 6, flexWrap: "wrap" },
231
+ inlineRight: { justifyContent: "flex-end" },
232
+ });
@@ -12,6 +12,8 @@ const MIME_MAP: Record<string, { label: string; color: string }> = {
12
12
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { label: "XLSX", color: "#16a34a" },
13
13
  "application/vnd.ms-excel": { label: "XLS", color: "#16a34a" },
14
14
  "text/csv": { label: "CSV", color: "#16a34a" },
15
+ "application/zip": { label: "ZIP", color: "#a16207" },
16
+ "application/x-zip-compressed": { label: "ZIP", color: "#a16207" },
15
17
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document": { label: "DOCX", color: "#2563eb" },
16
18
  "application/msword": { label: "DOC", color: "#2563eb" },
17
19
  "image/png": { label: "PNG", color: "#7c3aed" },
package/src/file_row.tsx CHANGED
@@ -9,8 +9,18 @@ import { useFocusRing } from "./use_focus_ring";
9
9
  export interface FileRowProps {
10
10
  /** The file / document name — the primary line. */
11
11
  name: string;
12
- /** Secondary line: size, status, timestamp, etc. */
13
- meta?: string;
12
+ /**
13
+ * Secondary line: size, status, timestamp — or a NODE, when the second line
14
+ * is itself carrying a change.
15
+ *
16
+ * A document set under review needs it: a file being RECLASSIFIED is the same
17
+ * bytes with a different filing ("Other" becomes "Certificate of origin"),
18
+ * and a file being SUPERSEDED wants the scan it displaces struck beneath it.
19
+ * Both are `DiffValue`s, and a string could only have described them in prose.
20
+ * A node renders OUTSIDE the muted single-line `Text` — that wrapper would
21
+ * clip it and its colour would fight the diff's own.
22
+ */
23
+ meta?: React.ReactNode;
14
24
  /** File type → the FileBadge glyph + color. Omit (with `placeholder`) for an
15
25
  * expected-but-not-yet-provided document. */
16
26
  mimeType?: string;
@@ -25,6 +35,16 @@ export interface FileRowProps {
25
35
  * control. An independently-interactive SIBLING: never swallowed by the row
26
36
  * press (a button never nests in the door button). */
27
37
  trailing?: React.ReactNode;
38
+ /**
39
+ * Leading slot, BEFORE the badge — a `DiffMark` saying what happened to this
40
+ * document, a selection checkbox, a status dot.
41
+ *
42
+ * It sits outside the badge rather than on it because it must line up DOWN
43
+ * the list: a mark drawn over a 30px badge moves with the badge's size, and
44
+ * `sm` and `md` rows would stop agreeing. Give every row the same slot,
45
+ * filled or empty, or the names stop forming a column.
46
+ */
47
+ leading?: React.ReactNode;
28
48
  /**
29
49
  * Row weight. `"sm"` (default) is the compact attachment line — many files
30
50
  * scanned as a list. `"md"` is the DOCUMENT-DESK row: a taller badge and a
@@ -53,6 +73,7 @@ export function FileRow({
53
73
  isTemplate,
54
74
  onPress,
55
75
  trailing,
76
+ leading,
56
77
  size = "sm",
57
78
  }: FileRowProps) {
58
79
  const md = size === "md";
@@ -68,16 +89,22 @@ export function FileRow({
68
89
 
69
90
  const content = (
70
91
  <>
92
+ {leading}
71
93
  <FileBadge size={md ? 38 : 30} mimeType={mimeType} placeholder={placeholder} isTemplate={isTemplate} />
72
94
  <View style={styles.text}>
73
95
  <Text size="sm" weight="medium" numberOfLines={1}>
74
96
  {name}
75
97
  </Text>
76
- {meta ? (
98
+ {/* A STRING keeps the muted single-line treatment; a node renders as
99
+ itself. Wrapping a node here would clip it at one line and tint it
100
+ muted, which is exactly wrong for a diff that has its own colour. */}
101
+ {meta == null || meta === "" ? null : typeof meta === "string" ? (
77
102
  <Text size="xs" color="muted" numberOfLines={1}>
78
103
  {meta}
79
104
  </Text>
80
- ) : null}
105
+ ) : (
106
+ meta
107
+ )}
81
108
  </View>
82
109
  </>
83
110
  );
@@ -117,19 +144,44 @@ export function FileRow({
117
144
  );
118
145
  }
119
146
 
147
+ /**
148
+ * THE ROW BEAT, owned in one place.
149
+ *
150
+ * `sm` used to have no minimum and no vertical padding at all, so a static row
151
+ * was as tall as its own contents — 37px measured — while the same component
152
+ * with an `onPress` came out at 49, because only the pressable variant carried
153
+ * padding for its hover wash. Whether a row was a DOOR decided how tall it was,
154
+ * which is two owners for one property that happened never to be compared.
155
+ *
156
+ * 37 was also under every other row beat in the kit — `DetailRow` 40, the
157
+ * `TableRow` 52, this component's own `md` 56 — which is what "squeezed"
158
+ * literally was: a file line packed tighter than any list it sits beside.
159
+ */
160
+ const ROW_MIN_HEIGHT = 48;
161
+ const ROW_PAD_Y = 6;
162
+
120
163
  const styles = StyleSheet.create({
121
- row: { flexDirection: "row", alignItems: "center", gap: 12 },
164
+ row: {
165
+ flexDirection: "row",
166
+ alignItems: "center",
167
+ gap: 12,
168
+ minHeight: ROW_MIN_HEIGHT,
169
+ // The pressable variant's padding too, so the badge sits at the same height
170
+ // in both and a list mixing them keeps one baseline.
171
+ paddingVertical: ROW_PAD_Y,
172
+ },
122
173
  pressableRow: {
123
174
  flexDirection: "row",
124
175
  alignItems: "center",
125
176
  gap: 12,
177
+ minHeight: ROW_MIN_HEIGHT,
126
178
  // Negative horizontal margin cancels the padding for the CONTENT's position
127
179
  // (badge stays aligned with static rows / the container edge) while the hover
128
180
  // wash bleeds 8px outward into the parent's padding — a hit area with no
129
181
  // layout shift. The parent is expected to have ≥8px horizontal padding.
130
182
  marginHorizontal: -8,
131
183
  paddingHorizontal: 8,
132
- paddingVertical: 6,
184
+ paddingVertical: ROW_PAD_Y,
133
185
  borderRadius: 8,
134
186
  ...({ transitionDuration: "0.1s", transitionProperty: "background-color" } as object),
135
187
  },