@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/AGENTS.md +12 -1
- package/MIGRATION.md +26 -0
- package/docs/ai_patterns.md +158 -111
- package/docs/catalog.md +100 -25
- package/docs/data_entry.md +13 -0
- 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 +7 -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_intake.ts +17 -2
- 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/mime.ts +76 -0
- package/src/result_header.tsx +1 -1
- package/src/use_change_set.ts +115 -0
- package/src/change_review.tsx +0 -800
|
@@ -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 {
|
|
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([]);
|
|
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
|
|
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,
|
|
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 →
|
|
386
|
-
|
|
387
|
-
and the commit bar
|
|
388
|
-
|
|
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
|
-
|
|
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 =
|
|
405
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
417
|
-
<
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
<
|
|
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": "
|
|
3
|
+
"version": "29.3.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",
|
|
@@ -321,6 +324,7 @@
|
|
|
321
324
|
"test": "vitest run"
|
|
322
325
|
},
|
|
323
326
|
"devDependencies": {
|
|
327
|
+
"@lotics/shared": "*",
|
|
324
328
|
"@types/react-dom": "~19.2.2",
|
|
325
329
|
"@vitejs/plugin-react": "^4.3.4",
|
|
326
330
|
"lucide-react": "^0.562.0",
|
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` + `
|
|
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;
|
package/src/clarify_wizard.tsx
CHANGED
|
@@ -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
|
|
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
|
+
});
|
package/src/file_badge.tsx
CHANGED
|
@@ -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_intake.ts
CHANGED
|
@@ -13,11 +13,13 @@
|
|
|
13
13
|
|
|
14
14
|
import type { ReactNode } from "react";
|
|
15
15
|
import type { StyleProp, ViewStyle } from "react-native";
|
|
16
|
+
import { resolveMimeType } from "./mime";
|
|
16
17
|
|
|
17
18
|
/** The filter every intake path applies to what arrived. */
|
|
18
19
|
export interface FileIntakeFilter {
|
|
19
20
|
/** Native `accept` filter (e.g. `"application/pdf,image/*"`) — matched against
|
|
20
|
-
* the file's MIME type
|
|
21
|
+
* the file's MIME type (falling back to what its extension says when the file
|
|
22
|
+
* declares none), or against its filename for a `.ext` pattern. */
|
|
21
23
|
accept?: string;
|
|
22
24
|
/** Keep more than one file. Default true. */
|
|
23
25
|
multiple?: boolean;
|
|
@@ -79,15 +81,28 @@ export interface FileDropTargetProps extends FileIntakeFilter {
|
|
|
79
81
|
* Does a file pass a native `accept` list? Each comma-separated pattern is an
|
|
80
82
|
* extension (`.pdf`), a wildcard type (`image/*`) or an exact MIME
|
|
81
83
|
* (`application/pdf`); an empty/absent list accepts everything.
|
|
84
|
+
*
|
|
85
|
+
* A file whose declared type says NOTHING is judged by its extension instead.
|
|
86
|
+
* The OS clipboard is the case that forces this: copy a file in
|
|
87
|
+
* Finder/Explorer and paste it, and the browser hands over real bytes and a
|
|
88
|
+
* real filename while declaring `""` — or, just as often,
|
|
89
|
+
* `application/octet-stream`, the same non-answer wearing the shape of one.
|
|
90
|
+
* Reading either as a MIME mismatch asserts "this is not a PDF" from data that
|
|
91
|
+
* never said so, and the file is dropped; because a paste carrying nothing
|
|
92
|
+
* acceptable is deliberately left alone, that drop is invisible.
|
|
93
|
+
*
|
|
94
|
+
* `resolveMimeType` owns which declarations count as uninformative, so this
|
|
95
|
+
* matches on the same type the SERVER will store for the file.
|
|
82
96
|
*/
|
|
83
97
|
export function matchesAccept(file: File, accept: string | undefined): boolean {
|
|
84
98
|
if (!accept) return true;
|
|
85
99
|
const patterns = accept.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean);
|
|
86
100
|
if (patterns.length === 0) return true;
|
|
87
|
-
const mime = file.type.toLowerCase();
|
|
88
101
|
const name = file.name.toLowerCase();
|
|
102
|
+
const mime = resolveMimeType(file.type, name);
|
|
89
103
|
return patterns.some((p) => {
|
|
90
104
|
if (p.startsWith(".")) return name.endsWith(p);
|
|
105
|
+
if (!mime) return false;
|
|
91
106
|
if (p.endsWith("/*")) return mime.startsWith(p.slice(0, -1));
|
|
92
107
|
return mime === p;
|
|
93
108
|
});
|