@lotics/ui 28.3.1 → 29.3.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.
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
  },
@@ -21,6 +21,7 @@ import {
21
21
  Linking,
22
22
  } from "react-native";
23
23
  import { isImageMimeType, isVideoMimeType, isAudioMimeType } from "./mime";
24
+ import { DiffMark, type DiffKind } from "./diff_mark";
24
25
 
25
26
  export const THUMBNAIL_SIZE = 96;
26
27
  export const COMPACT_THUMBNAIL_SIZE = 32;
@@ -86,14 +87,67 @@ interface FileThumbnailProps {
86
87
  /** Accessible name for the tile — what pressing it DOES, when the raw filename isn't
87
88
  * the useful name ("Xem chứng từ"). Defaults to `file.filename`. */
88
89
  accessibilityLabel?: string;
90
+ /**
91
+ * WHAT A PROPOSAL DOES TO THIS TILE — a corner mark for a document set under
92
+ * review, rendered as a grid rather than as a list.
93
+ *
94
+ * Top-LEFT because it is the one corner nothing else claims (remove sits
95
+ * top-right, selection bottom-right, the uploading scrim covers everything),
96
+ * and because it puts the mark where the row list already puts it.
97
+ *
98
+ * The disc rides a white RING: a tile's ground is an arbitrary photo, and a
99
+ * pale tint over a pale scan is not a mark, it is a smudge. `removed`
100
+ * additionally fades the tile BODY — the grid's way of saying a document is
101
+ * on its way off the record — while the mark itself stays at full strength,
102
+ * since a faded mark is the exact smudge the ring exists to prevent.
103
+ *
104
+ * Ignored on a COMPACT tile (≤ 32px): a 22px disc on a 32px tile is not a
105
+ * corner mark, it is the tile. A compact strip lives inside a row, and that
106
+ * row is where its change belongs.
107
+ *
108
+ * A grid expresses MEMBERSHIP (this document is arriving, that one is going)
109
+ * and expresses PAIRING badly: a 96px tile has nowhere to say which file a
110
+ * replacement supersedes. Use `FileRow` for a set where things are being
111
+ * replaced rather than added and removed.
112
+ */
113
+ diff?: DiffKind;
89
114
  }
90
115
 
116
+ /**
117
+ * The tile's diff mark — the same disc the rows use, on a white RING.
118
+ *
119
+ * The ring rather than the disc alone because a tile's ground is an arbitrary
120
+ * photo, and a pale tint over a pale scan is a smudge. It is circular rather
121
+ * than a rounded square: two shapes for one mark is exactly the inconsistency
122
+ * the disc exists to end.
123
+ */
124
+ function DiffCorner({ kind }: { kind: DiffKind }) {
125
+ if (kind === "unchanged") return null;
126
+ return (
127
+ <View style={diffCornerStyles.chip} pointerEvents="none">
128
+ <DiffMark kind={kind} />
129
+ </View>
130
+ );
131
+ }
132
+
133
+ const diffCornerStyles = StyleSheet.create({
134
+ chip: {
135
+ position: "absolute",
136
+ top: 4,
137
+ left: 4,
138
+ zIndex: 2,
139
+ backgroundColor: colors.white,
140
+ borderRadius: 999,
141
+ padding: 2,
142
+ },
143
+ });
144
+
91
145
  /**
92
146
  * Displays a file as a square thumbnail.
93
147
  * Works with any file source via DisplayFile interface.
94
148
  */
95
149
  export function FileThumbnail(props: FileThumbnailProps) {
96
- const { file, size, onPress, onLongPress, onRemove, selected, uploading, isTemplate, disablePress } = props;
150
+ const { file, size, onPress, onLongPress, onRemove, selected, uploading, isTemplate, disablePress, diff } = props;
97
151
  const accessibilityLabel = props.accessibilityLabel ?? file.filename;
98
152
  // A tile with no `onPress` still OPENS the file — a useful default for a bare
99
153
  // thumbnail in a list or a gallery. But a caller that said `disablePress`
@@ -106,13 +160,34 @@ export function FileThumbnail(props: FileThumbnailProps) {
106
160
  const rootStyle =
107
161
  size !== undefined ? { width: size, height: size } : { width: "100%" as const, aspectRatio: 1 };
108
162
 
163
+ // A tile ≤ 32px is a badge in a strip, not a card: a 22px mark would swallow
164
+ // it. The row or cell holding the strip carries the change instead.
165
+ const compact = size !== undefined && size <= COMPACT_THUMBNAIL_SIZE;
166
+ const mark = diff === undefined || compact ? null : <DiffCorner kind={diff} />;
167
+
168
+ /**
169
+ * A document on its way OFF the record reads as leaving, and the fade says
170
+ * that across the whole tile — which is what a grid is scanned by.
171
+ *
172
+ * It wraps the BODY rather than the root on purpose. `opacity` cascades to
173
+ * the entire subtree, so on the root it faded the corner mark too, at exactly
174
+ * the moment the mark has the most to say. That is also the dimming
175
+ * `DiffMark`'s ink was darkened for; putting the mark inside it would have
176
+ * spent that margin for nothing.
177
+ */
178
+ const body = (node: React.ReactNode) =>
179
+ diff === "removed" ? <View style={styles.fadedBody}>{node}</View> : node;
180
+
109
181
  if (isImageMimeType(file.mimeType)) {
110
182
  return (
111
183
  <View style={rootStyle}>
112
- <ImageThumbnail file={file} size={size} accessibilityLabel={accessibilityLabel} onPress={press} onLongPress={onLongPress} />
184
+ {body(
185
+ <ImageThumbnail file={file} size={size} accessibilityLabel={accessibilityLabel} onPress={press} onLongPress={onLongPress} />,
186
+ )}
113
187
  {uploading && <UploadingOverlay size={size} />}
114
188
  {onRemove && <RemoveButton onPress={onRemove} />}
115
189
  {selected !== undefined && <SelectionOverlay selected={selected} />}
190
+ {mark}
116
191
  </View>
117
192
  );
118
193
  }
@@ -122,46 +197,52 @@ export function FileThumbnail(props: FileThumbnailProps) {
122
197
  if (size !== undefined && size <= COMPACT_THUMBNAIL_SIZE && mediaIcon === undefined) {
123
198
  return (
124
199
  <View style={rootStyle}>
125
- <DocumentBadge
126
- mimeType={file.mimeType}
127
- size={size}
128
- isTemplate={isTemplate}
129
- accessibilityLabel={accessibilityLabel}
130
- onPress={pressOrOpen}
131
- onLongPress={onLongPress}
132
- />
200
+ {body(
201
+ <DocumentBadge
202
+ mimeType={file.mimeType}
203
+ size={size}
204
+ isTemplate={isTemplate}
205
+ accessibilityLabel={accessibilityLabel}
206
+ onPress={pressOrOpen}
207
+ onLongPress={onLongPress}
208
+ />,
209
+ )}
133
210
  {uploading && <UploadingOverlay size={size} />}
134
211
  {onRemove && <RemoveButton onPress={onRemove} />}
212
+ {mark}
135
213
  </View>
136
214
  );
137
215
  }
138
216
 
139
217
  return (
140
218
  <View style={rootStyle}>
141
- {mediaIcon !== undefined ? (
142
- <MediaCard
143
- mimeType={file.mimeType}
144
- filename={file.filename}
145
- icon={mediaIcon}
146
- size={size}
147
- accessibilityLabel={accessibilityLabel}
148
- onPress={pressOrOpen}
149
- onLongPress={onLongPress}
150
- />
151
- ) : (
152
- <DocumentCard
153
- mimeType={file.mimeType}
154
- filename={file.filename}
155
- size={size}
156
- isTemplate={isTemplate}
157
- accessibilityLabel={accessibilityLabel}
158
- onPress={pressOrOpen}
159
- onLongPress={onLongPress}
160
- />
219
+ {body(
220
+ mediaIcon !== undefined ? (
221
+ <MediaCard
222
+ mimeType={file.mimeType}
223
+ filename={file.filename}
224
+ icon={mediaIcon}
225
+ size={size}
226
+ accessibilityLabel={accessibilityLabel}
227
+ onPress={pressOrOpen}
228
+ onLongPress={onLongPress}
229
+ />
230
+ ) : (
231
+ <DocumentCard
232
+ mimeType={file.mimeType}
233
+ filename={file.filename}
234
+ size={size}
235
+ isTemplate={isTemplate}
236
+ accessibilityLabel={accessibilityLabel}
237
+ onPress={pressOrOpen}
238
+ onLongPress={onLongPress}
239
+ />
240
+ ),
161
241
  )}
162
242
  {uploading && <UploadingOverlay size={size} />}
163
243
  {onRemove && <RemoveButton onPress={onRemove} />}
164
244
  {selected !== undefined && <SelectionOverlay selected={selected} />}
245
+ {mark}
165
246
  </View>
166
247
  );
167
248
  }
@@ -456,6 +537,8 @@ function SelectionOverlay({ selected }: { selected: boolean }) {
456
537
  // =============================================================================
457
538
 
458
539
  const styles = StyleSheet.create({
540
+ // Fills the tile so the fade covers the whole body and nothing reflows.
541
+ fadedBody: { width: "100%", height: "100%", opacity: 0.45 },
459
542
  documentCard: {
460
543
  borderRadius: 10,
461
544
  backgroundColor: colors.white,
@@ -15,6 +15,7 @@ import { View, Pressable, StyleSheet, type ViewStyle } from "react-native";
15
15
  import { Text } from "./text";
16
16
  import { colors } from "./colors";
17
17
  import { FileThumbnail, type DisplayFile, THUMBNAIL_SIZE, COMPACT_THUMBNAIL_SIZE } from "./file_thumbnail";
18
+ import { type DiffKind } from "./diff_mark";
18
19
  import { FOCUS_RING } from "./control_surface";
19
20
  import { useFocusRing } from "./use_focus_ring";
20
21
 
@@ -171,12 +172,22 @@ export interface FileThumbnailGridProps {
171
172
  disablePress?: boolean;
172
173
  /** Selected file IDs — renders a selection overlay on those thumbnails. */
173
174
  selectedIds?: ReadonlySet<string>;
175
+ /**
176
+ * What a proposal does to each file, keyed by id — a corner mark on the tile
177
+ * and a fade on anything being removed. Keyed the same way `selectedIds` is,
178
+ * so a grid under review is the SAME grid with one more map passed in.
179
+ *
180
+ * A grid says MEMBERSHIP well and PAIRING badly: there is nowhere on a 96px
181
+ * tile to name the file a replacement supersedes. A set where documents are
182
+ * being replaced rather than added and removed wants `FileRow`.
183
+ */
184
+ diffs?: ReadonlyMap<string, DiffKind>;
174
185
  onFilePress?: (file: DisplayFile) => void;
175
186
  onRemove?: (id: string) => void;
176
187
  }
177
188
 
178
189
  export function FileThumbnailGrid(props: FileThumbnailGridProps) {
179
- const { files, selectedIds, onFilePress, onRemove, disablePress, ...layout } = props;
190
+ const { files, selectedIds, diffs, onFilePress, onRemove, disablePress, ...layout } = props;
180
191
  return (
181
192
  <ThumbnailGrid
182
193
  {...layout}
@@ -187,6 +198,7 @@ export function FileThumbnailGrid(props: FileThumbnailGridProps) {
187
198
  <FileThumbnail
188
199
  file={file}
189
200
  size={size}
201
+ diff={diffs?.get(file.id)}
190
202
  onPress={disablePress || !onFilePress ? undefined : () => onFilePress(file)}
191
203
  // Withholding `onPress` is NOT enough: a tile with none falls back to
192
204
  // opening the file, so it stays a button and a tab stop. The prop has
package/src/finding.tsx CHANGED
@@ -47,9 +47,8 @@ export interface FindingProps {
47
47
  * observation, a briefing item. Good defaults (severity word, title, detail,
48
48
  * the PROMINENT metric, `Sources` chips) with a `children` slot for any
49
49
  * extra composition. Display-only: a finding informs the verdict the host
50
- * records; it decides nothing itself. Stack several most-severe first
51
- * inside a `ChangeReview` wrap each in a display-only `Change` (the family's
52
- * dividers apply); standalone, separate them yourself.
50
+ * records; it decides nothing itself. Stack several most-severe first,
51
+ * separated by a `Divider`.
53
52
  */
54
53
  export function Finding(props: FindingProps) {
55
54
  const words = useLoticsLocale().finding;
@@ -0,0 +1,58 @@
1
+ import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
2
+ import { type ReactNode } from "react";
3
+ import { CONTROL_RADIUS } from "./control_surface";
4
+ import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
5
+
6
+ export interface InlineSlotProps {
7
+ /** Whatever sits in the value column — a `DiffValue`, a chip, a pair. */
8
+ children: ReactNode;
9
+ style?: StyleProp<ViewStyle>;
10
+ }
11
+
12
+ /**
13
+ * ANY node placed on the INLINE-CONTROL GRID — the box an `Inline*` editor
14
+ * draws, minus the editor.
15
+ *
16
+ * A record's value column mixes editors with things that are not editors: a
17
+ * computed total, a synced value, a proposed change. The editors are 40px tall
18
+ * with 8px of horizontal padding inside a 1px border, and their text therefore
19
+ * sits 9px in from the cell edge and centred in a 40px band. Anything rendered
20
+ * raw beside them starts at the cell edge, 20px tall, at the top — so the value
21
+ * column quietly acquires TWO left edges and two baselines, and `DetailRow`'s
22
+ * label (which pads down to meet a control) agrees with one kind of row and not
23
+ * the other.
24
+ *
25
+ * That is not a cosmetic drift. The column is the only thing telling a reader
26
+ * these values belong to one record, and it stops reading as a column.
27
+ *
28
+ * `InlineStatic` is the STRING case of this and composes it. Reach for the slot
29
+ * directly when the value is a node — which a review surface always is, since a
30
+ * proposed value is a `DiffValue` sitting where its editor would be.
31
+ *
32
+ * NOT for a table cell: a `Table` sets its own row rhythm, and a 40px control
33
+ * band inside one only makes the rows taller.
34
+ */
35
+ export function InlineSlot({ children, style }: InlineSlotProps) {
36
+ return <View style={[styles.box, style]}>{children}</View>;
37
+ }
38
+
39
+ const styles = StyleSheet.create({
40
+ // Matches `inline_edit`'s view box exactly (height, radius, padding, 1px
41
+ // transparent border) so the content's baseline aligns with the editors —
42
+ // minus the FocusRingPressable, so it never reads as an interactive control.
43
+ box: {
44
+ minHeight: INLINE_CONTROL_HEIGHT,
45
+ borderRadius: CONTROL_RADIUS,
46
+ borderWidth: 1,
47
+ borderColor: "transparent",
48
+ paddingHorizontal: 8,
49
+ // The editors' vertical padding too, not just the horizontal. A single line
50
+ // hides its absence — the box is `minHeight` 40 and the text is ~20, so both
51
+ // sides land on 40 whether or not this exists — but an auto-growing input
52
+ // sizes itself to content PLUS its padding and border, so from the SECOND
53
+ // line on, a value without this sits ~14px shorter than the input that
54
+ // replaces it. Matching by coincidence at one line is not matching.
55
+ paddingVertical: 8,
56
+ justifyContent: "center",
57
+ },
58
+ });
@@ -1,8 +1,8 @@
1
- import { StyleSheet, View } from "react-native";
1
+ import { StyleSheet } from "react-native";
2
2
  import { Text } from "./text";
3
3
  import { colors } from "./colors";
4
- import { CONTROL_RADIUS } from "./control_surface";
5
- import { INLINE_CONTROL_HEIGHT, inlineValueTextStyle } from "./inline_edit";
4
+ import { inlineValueTextStyle } from "./inline_edit";
5
+ import { InlineSlot } from "./inline_slot";
6
6
 
7
7
  export interface InlineStaticProps {
8
8
  /** The value to show. Empty → `placeholder` (default "—"). */
@@ -45,7 +45,7 @@ export function InlineStatic(props: InlineStaticProps) {
45
45
  const isEmpty = value.length === 0;
46
46
  const display = isEmpty ? (placeholder ?? "—") : value;
47
47
  return (
48
- <View style={styles.box}>
48
+ <InlineSlot>
49
49
  <Text
50
50
  numberOfLines={multiline ? undefined : 1}
51
51
  tabular={tabular}
@@ -56,28 +56,10 @@ export function InlineStatic(props: InlineStaticProps) {
56
56
  >
57
57
  {display}
58
58
  </Text>
59
- </View>
59
+ </InlineSlot>
60
60
  );
61
61
  }
62
62
 
63
63
  const styles = StyleSheet.create({
64
- // Matches `inline_edit`'s view box exactly (height, radius, padding, 1px
65
- // transparent border) so the value's baseline aligns with the editors — minus
66
- // the FocusRingPressable, so it never reads as an interactive control.
67
- box: {
68
- minHeight: INLINE_CONTROL_HEIGHT,
69
- borderRadius: CONTROL_RADIUS,
70
- borderWidth: 1,
71
- borderColor: "transparent",
72
- paddingHorizontal: 8,
73
- // The editors' vertical padding too, not just the horizontal. A single line
74
- // hides its absence — the box is `minHeight` 40 and the text is ~20, so both
75
- // sides land on 40 whether or not this exists — but an auto-growing input
76
- // sizes itself to content PLUS its padding and border, so from the SECOND
77
- // line on, a value without this sits ~14px shorter than the input that
78
- // replaces it. Matching by coincidence at one line is not matching.
79
- paddingVertical: 8,
80
- justifyContent: "center",
81
- },
82
64
  placeholder: { color: colors.zinc[400] },
83
65
  });
package/src/locale.tsx CHANGED
@@ -94,27 +94,17 @@ export interface LoticsLocale {
94
94
  suggestionChip: { add: (label: string) => string; dismiss: string };
95
95
  /** `Confidence`: the full level phrase ("High confidence" …). */
96
96
  confidence: ConfidenceLabels;
97
- /** `Finding`: the severity badge word ("Critical" …). */
98
97
  /** `RemainderMeter`: the applied / remaining / over / exact captions. */
99
98
  remainderMeter: Required<RemainderMeterLabels>;
100
- /** `ChangeReview` (+ `ChangeReviewActions`): the review-card chrome the
101
- * Keep/Drop verdicts, Undo, the Applied/Discarded tags, the kept-counter,
102
- * the title, and the commit-bar Accept-all / Discard. */
99
+ /** `Finding`: the severity words + the comparison delta caption. */
103
100
  finding: FindingLabels;
104
- changeReview: {
105
- title: string;
106
- accept: string;
107
- reject: string;
108
- undo: string;
109
- keptCount: (kept: number, total: number) => string;
110
- acceptAll: string;
111
- discard: string;
112
- apply: string;
113
- recommended: string;
114
- customValue: string;
115
- opAdd: string;
116
- opEdit: string;
117
- opRemove: string;
101
+ /** `DiffMark`: what a proposal does to a row — the accessible word each mark
102
+ * announces, since the meaning may never rest on colour alone. */
103
+ diff: {
104
+ added: string;
105
+ changed: string;
106
+ removed: string;
107
+ unchanged: string;
118
108
  };
119
109
  /** `DateRangeFilterField` (and the `DateFilter` panel it wraps): presets,
120
110
  * from/to, the footer Clear/Done, the trigger placeholder, and the time-field
@@ -273,20 +263,11 @@ export const en: LoticsLocale = {
273
263
  exact: "Fully applied",
274
264
  },
275
265
  finding: { critical: "Critical", warning: "Warning", info: "Note", positive: "On track", difference: "Difference" },
276
- changeReview: {
277
- title: "Suggested edits",
278
- accept: "Keep",
279
- reject: "Drop",
280
- undo: "Undo",
281
- keptCount: (kept, total) => `${kept} of ${total} kept`,
282
- acceptAll: "Keep all",
283
- discard: "Discard",
284
- apply: "Apply",
285
- recommended: "AI pick",
286
- customValue: "Type another value",
287
- opAdd: "Add",
288
- opEdit: "Edit",
289
- opRemove: "Delete",
266
+ diff: {
267
+ added: "Added",
268
+ changed: "Changed",
269
+ removed: "Removed",
270
+ unchanged: "Unchanged",
290
271
  },
291
272
  dateRange: {
292
273
  year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM",
@@ -437,20 +418,11 @@ export const vi: LoticsLocale = {
437
418
  exact: "Đã phân bổ đủ",
438
419
  },
439
420
  finding: { critical: "Nghiêm trọng", warning: "Cảnh báo", info: "Ghi chú", positive: "Đúng tiến độ", difference: "Chênh lệch" },
440
- changeReview: {
441
- title: "Đề xuất chỉnh sửa",
442
- accept: "Giữ",
443
- reject: "Bỏ",
444
- undo: "Hoàn tác",
445
- keptCount: (kept, total) => `Đã giữ ${kept}/${total}`,
446
- acceptAll: "Giữ tất cả",
447
- discard: "Bỏ hết",
448
- apply: "Áp dụng",
449
- recommended: "AI đề xuất",
450
- customValue: "Nhập giá trị khác",
451
- opAdd: "Thêm",
452
- opEdit: "Sửa",
453
- opRemove: "Xóa",
421
+ diff: {
422
+ added: "Thêm mới",
423
+ changed: "Thay đổi",
424
+ removed: "Xóa",
425
+ unchanged: "Giữ nguyên",
454
426
  },
455
427
  dateRange: {
456
428
  year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH",
package/src/mime.ts CHANGED
@@ -52,3 +52,79 @@ export function isPreviewableMimeType(mimeType: string): boolean {
52
52
  isDocxMimeType(mimeType)
53
53
  );
54
54
  }
55
+
56
+ /** A MIME type without its parameters (`text/csv; charset=utf-8` → `text/csv`). */
57
+ function normalizeMimeType(mimeType: string): string {
58
+ return mimeType.split(";")[0]!.trim().toLowerCase();
59
+ }
60
+
61
+ /**
62
+ * Types that carry no information — a sender that declined to classify the
63
+ * bytes. The empty string is only the most obvious one: an OS clipboard or drag
64
+ * routinely hands a file over as `application/octet-stream` instead, which is
65
+ * the same statement made in a way that LOOKS like an answer.
66
+ */
67
+ const GENERIC_MIME_TYPES: ReadonlySet<string> = new Set([
68
+ "",
69
+ "application/octet-stream",
70
+ "binary/octet-stream",
71
+ "application/binary",
72
+ "application/unknown",
73
+ ]);
74
+
75
+ /**
76
+ * Extension → MIME, for the document and image formats an intake surface names.
77
+ * A `Map`, not an object literal, so a file named `x.constructor` looks up a
78
+ * miss rather than `Object.prototype`'s member.
79
+ */
80
+ const MIME_BY_EXTENSION: ReadonlyMap<string, string> = new Map([
81
+ ["pdf", "application/pdf"],
82
+ ["png", "image/png"],
83
+ ["jpg", "image/jpeg"],
84
+ ["jpeg", "image/jpeg"],
85
+ ["gif", "image/gif"],
86
+ ["webp", "image/webp"],
87
+ ["avif", "image/avif"],
88
+ ["bmp", "image/bmp"],
89
+ ["svg", "image/svg+xml"],
90
+ ["tif", "image/tiff"],
91
+ ["tiff", "image/tiff"],
92
+ ["heic", "image/heic"],
93
+ ["heif", "image/heif"],
94
+ ["csv", "text/csv"],
95
+ ["tsv", "text/tab-separated-values"],
96
+ ["txt", "text/plain"],
97
+ ["xml", "application/xml"],
98
+ ["json", "application/json"],
99
+ ["zip", "application/zip"],
100
+ ["xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
101
+ ["xls", "application/vnd.ms-excel"],
102
+ ["docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
103
+ ["doc", "application/msword"],
104
+ ]);
105
+
106
+ /** What a filename's extension says the file is, or `""` when it says nothing. */
107
+ function mimeTypeFromFileName(name: string): string {
108
+ const dot = name.lastIndexOf(".");
109
+ // `dot <= 0` also rejects a dotfile (`.pdf` is a NAME, not an extension);
110
+ // `length - 1` rejects a trailing dot.
111
+ if (dot <= 0 || dot === name.length - 1) return "";
112
+ return MIME_BY_EXTENSION.get(name.slice(dot + 1).toLowerCase()) ?? "";
113
+ }
114
+
115
+ /**
116
+ * The type to TREAT content as, given what its sender declared and what it is
117
+ * called. An honestly-declared type is never second-guessed — only an
118
+ * uninformative one is resolved, and only from the filename, falling back to
119
+ * the declaration when the extension is unrecognized too.
120
+ *
121
+ * Mirrors `resolveMimeType` in `@lotics/shared/file_type`, which is what the
122
+ * server stores. The kit cannot import it (`primitives_purity.test.ts` keeps
123
+ * `src/` free of `@lotics/shared`), so `mime_parity.test.ts` asserts the two
124
+ * agree instead of trusting that they will.
125
+ */
126
+ export function resolveMimeType(declaredMimeType: string, filename: string): string {
127
+ const declared = normalizeMimeType(declaredMimeType);
128
+ if (!GENERIC_MIME_TYPES.has(declared)) return declared;
129
+ return mimeTypeFromFileName(filename) || declared;
130
+ }
@@ -5,7 +5,7 @@ import { Icon, type IconName } from "./icon";
5
5
  import { Text, type HeadingLevel } from "./text";
6
6
 
7
7
  /**
8
- * Result header — the strip that opens the POST-SAVE RECEIPT. `ChangeReview` gates a proposal
8
+ * Result header — the strip that opens the POST-SAVE RECEIPT. A review surface gates a proposal
9
9
  * BEFORE it applies; this header opens the receipt for what a save-direct run
10
10
  * ALREADY CREATED. The receipt states the outcome and routes — it never edits
11
11
  * (correction happens through the record's ordinary verbs, on the record):