@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.
- package/AGENTS.md +12 -1
- package/MIGRATION.md +26 -0
- package/docs/ai_patterns.md +158 -111
- package/docs/catalog.md +93 -24
- package/docs/templates.md +31 -12
- package/examples/tpl_item_list.tsx +201 -28
- package/examples/tpl_record.tsx +390 -123
- package/examples/tpl_task_board.tsx +45 -29
- package/package.json +6 -3
- package/src/agent_run.tsx +1 -1
- package/src/clarify_wizard.tsx +1 -1
- package/src/diff_mark.tsx +110 -0
- package/src/diff_value.tsx +232 -0
- package/src/file_badge.tsx +2 -0
- package/src/file_row.tsx +58 -6
- package/src/file_thumbnail.tsx +113 -30
- package/src/file_thumbnail_grid.tsx +13 -1
- package/src/finding.tsx +2 -3
- package/src/inline_slot.tsx +58 -0
- package/src/inline_static.tsx +5 -23
- package/src/locale.tsx +18 -46
- package/src/result_header.tsx +1 -1
- package/src/use_change_set.ts +115 -0
- package/src/change_review.tsx +0 -800
package/src/file_thumbnail.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
{
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
*
|
|
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
|
+
});
|
package/src/inline_static.tsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { StyleSheet
|
|
1
|
+
import { StyleSheet } from "react-native";
|
|
2
2
|
import { Text } from "./text";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
/** `
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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/result_header.tsx
CHANGED
|
@@ -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.
|
|
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):
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export type ChangeDecision = "pending" | "accepted" | "rejected";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* THE REVIEW BOOKKEEPING, WITHOUT A SURFACE.
|
|
7
|
+
*
|
|
8
|
+
* Keeping which proposals are in and which are out is genuinely fiddly — the
|
|
9
|
+
* counter, keep-all, undo, the commit gate — and genuinely the same every time.
|
|
10
|
+
* What is NOT the same every time is where any of it renders. So this is a hook:
|
|
11
|
+
* the host gets the state machine and draws whatever its screen needs, instead
|
|
12
|
+
* of adopting a container to get the arithmetic.
|
|
13
|
+
*
|
|
14
|
+
* Decisions are stored as OVERRIDES against a default, never seeded from `ids`
|
|
15
|
+
* into state. Seeding would need an effect to keep in step, and `ids` is
|
|
16
|
+
* typically a `useMemo` over query rows — a new array every render, so the
|
|
17
|
+
* effect fires every render and sets state every time. (That exact loop has bit
|
|
18
|
+
* this codebase before: `Maximum update depth exceeded` in a settle drawer that
|
|
19
|
+
* seeded "everything ticked" from its rows.) Storing the exceptions makes the
|
|
20
|
+
* selection derived: nothing to seed, nothing to re-sync, and ids that come and
|
|
21
|
+
* go simply pick up the default.
|
|
22
|
+
*/
|
|
23
|
+
export interface ChangeSet<Id extends string = string> {
|
|
24
|
+
status: (id: Id) => ChangeDecision;
|
|
25
|
+
accept: (id: Id) => void;
|
|
26
|
+
reject: (id: Id) => void;
|
|
27
|
+
/** Back to the default — the Undo on a decided row. */
|
|
28
|
+
undo: (id: Id) => void;
|
|
29
|
+
acceptAll: () => void;
|
|
30
|
+
rejectAll: () => void;
|
|
31
|
+
/** Every id back to the default. */
|
|
32
|
+
reset: () => void;
|
|
33
|
+
accepted: readonly Id[];
|
|
34
|
+
rejected: readonly Id[];
|
|
35
|
+
pending: readonly Id[];
|
|
36
|
+
keptCount: number;
|
|
37
|
+
total: number;
|
|
38
|
+
/** Nothing is left undecided — the usual gate on a commit button. */
|
|
39
|
+
settled: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface UseChangeSetOptions {
|
|
43
|
+
/**
|
|
44
|
+
* What an untouched proposal counts as. Default `accepted`: the operator
|
|
45
|
+
* drops the exceptions rather than approving each of eight identical lines,
|
|
46
|
+
* which is the difference between a review and a second round of data entry.
|
|
47
|
+
* Use `pending` when each change genuinely deserves its own verdict — and
|
|
48
|
+
* gate the commit on `settled`.
|
|
49
|
+
*/
|
|
50
|
+
initial?: ChangeDecision;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function useChangeSet<Id extends string = string>(
|
|
54
|
+
ids: readonly Id[],
|
|
55
|
+
options?: UseChangeSetOptions,
|
|
56
|
+
): ChangeSet<Id> {
|
|
57
|
+
const initial = options?.initial ?? "accepted";
|
|
58
|
+
const [overrides, setOverrides] = useState<ReadonlyMap<Id, ChangeDecision>>(new Map());
|
|
59
|
+
|
|
60
|
+
const set = useCallback((id: Id, decision: ChangeDecision) => {
|
|
61
|
+
setOverrides((prev) => {
|
|
62
|
+
const next = new Map(prev);
|
|
63
|
+
next.set(id, decision);
|
|
64
|
+
return next;
|
|
65
|
+
});
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
const undo = useCallback((id: Id) => {
|
|
69
|
+
setOverrides((prev) => {
|
|
70
|
+
if (!prev.has(id)) return prev;
|
|
71
|
+
const next = new Map(prev);
|
|
72
|
+
next.delete(id);
|
|
73
|
+
return next;
|
|
74
|
+
});
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
const all = useCallback(
|
|
78
|
+
(decision: ChangeDecision) => setOverrides(new Map(ids.map((id) => [id, decision]))),
|
|
79
|
+
[ids],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const status = useCallback((id: Id) => overrides.get(id) ?? initial, [overrides, initial]);
|
|
83
|
+
|
|
84
|
+
const groups = useMemo(() => {
|
|
85
|
+
const accepted: Id[] = [];
|
|
86
|
+
const rejected: Id[] = [];
|
|
87
|
+
const pending: Id[] = [];
|
|
88
|
+
for (const id of ids) {
|
|
89
|
+
const s = overrides.get(id) ?? initial;
|
|
90
|
+
if (s === "accepted") accepted.push(id);
|
|
91
|
+
else if (s === "rejected") rejected.push(id);
|
|
92
|
+
else pending.push(id);
|
|
93
|
+
}
|
|
94
|
+
return { accepted, rejected, pending };
|
|
95
|
+
}, [ids, overrides, initial]);
|
|
96
|
+
|
|
97
|
+
return useMemo(
|
|
98
|
+
() => ({
|
|
99
|
+
status,
|
|
100
|
+
accept: (id: Id) => set(id, "accepted"),
|
|
101
|
+
reject: (id: Id) => set(id, "rejected"),
|
|
102
|
+
undo,
|
|
103
|
+
acceptAll: () => all("accepted"),
|
|
104
|
+
rejectAll: () => all("rejected"),
|
|
105
|
+
reset: () => setOverrides(new Map()),
|
|
106
|
+
accepted: groups.accepted,
|
|
107
|
+
rejected: groups.rejected,
|
|
108
|
+
pending: groups.pending,
|
|
109
|
+
keptCount: groups.accepted.length,
|
|
110
|
+
total: ids.length,
|
|
111
|
+
settled: groups.pending.length === 0,
|
|
112
|
+
}),
|
|
113
|
+
[status, set, undo, all, groups, ids.length],
|
|
114
|
+
);
|
|
115
|
+
}
|