@lotics/ui 44.7.0 → 44.8.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 +11 -1
- package/MIGRATION.md +110 -5
- package/docs/catalog.md +35 -7
- package/docs/composition.md +105 -5
- package/docs/data_entry.md +60 -6
- package/docs/reviewing.md +46 -3
- package/examples/tpl_item_list.tsx +8 -4
- package/package.json +2 -1
- package/src/accordion.tsx +9 -1
- package/src/agent_run_pane.tsx +7 -5
- package/src/card.tsx +7 -1
- package/src/card_select_item.tsx +15 -2
- package/src/control_surface.ts +18 -4
- package/src/data_grid.tsx +9 -1
- package/src/date_range_filter_field.tsx +9 -2
- package/src/date_stamp.tsx +8 -2
- package/src/detail_row.tsx +17 -12
- package/src/empty_state.tsx +9 -2
- package/src/error_state.tsx +9 -2
- package/src/field_annotations.tsx +25 -2
- package/src/finding.tsx +19 -10
- package/src/format_money.ts +16 -2
- package/src/inline_edit.tsx +29 -9
- package/src/inline_select.tsx +8 -1
- package/src/inline_text_input.tsx +6 -7
- package/src/locale.tsx +24 -1
- package/src/number_input.tsx +8 -1
- package/src/reference_field.tsx +9 -2
- package/src/section_heading.tsx +17 -5
- package/src/select.tsx +16 -16
- package/src/sequence.tsx +41 -25
- package/src/step_progress.tsx +6 -4
- package/src/table_fit.ts +18 -8
- package/src/tabs.tsx +19 -0
- package/src/text_input_field.tsx +35 -1
package/docs/reviewing.md
CHANGED
|
@@ -45,10 +45,35 @@ the same direction. The kit measures the CONTAINER, not the screen — see AGENT
|
|
|
45
45
|
|
|
46
46
|
---
|
|
47
47
|
|
|
48
|
-
##
|
|
48
|
+
## Three gates, before any probe
|
|
49
49
|
|
|
50
|
-
A
|
|
51
|
-
|
|
50
|
+
A screen in the wrong FORM measures clean in every direction, a perfectly-treated element that
|
|
51
|
+
should not exist still measures clean, and a wrong control cannot be treated into a right one.
|
|
52
|
+
All three are cheap and all three outrank everything below.
|
|
53
|
+
|
|
54
|
+
### 0. RIGHT FORM
|
|
55
|
+
|
|
56
|
+
**Name what the subject IS before composing anything.** A set of records is a register. A thing
|
|
57
|
+
moving through ordered places is a rail. Work assigned to finite resources over time is a board.
|
|
58
|
+
A sequence of events is a timeline. A population whose point is its deviant minority is a queue
|
|
59
|
+
sorted by deviation. A quantity across two dimensions is a matrix.
|
|
60
|
+
|
|
61
|
+
This gate exists because **every probe below reads a screen that is already a register of fields**
|
|
62
|
+
and measures its TREATMENT. So a screen that should have been a board or a rail passes the type
|
|
63
|
+
inventory, the ground tally, the gaps, the rules and the alignment — all of them — and still reads
|
|
64
|
+
as templated. No amount of treatment fixes it, and nothing else here can see it.
|
|
65
|
+
|
|
66
|
+
*The tell that it was skipped:* every screen in the product is a register with a summary line over
|
|
67
|
+
it. That is what reuse-first produces when nothing asks the question — the catalog's most reachable
|
|
68
|
+
parts are `Table`, `DetailRow`, `SummaryLine` and `Badge`, so a subject whose natural shape is not
|
|
69
|
+
a table gets flattened into one and no probe fires. **Reuse-first is about not duplicating
|
|
70
|
+
PRIMITIVES, not about the shape of a screen**: a one-off composition needs no new component, so
|
|
71
|
+
"the catalog has no journey rail" is an argument for composing one out of `Stepper`, not for
|
|
72
|
+
rendering a status column and moving on.
|
|
73
|
+
|
|
74
|
+
*And check the form against what the data can FILL.* A rail with no stamped stops, a calendar with
|
|
75
|
+
no durations, a map with no coordinates — each renders an empty device that measures perfectly.
|
|
76
|
+
→ probe 8.
|
|
52
77
|
|
|
53
78
|
### 1. SUBTRACT
|
|
54
79
|
|
|
@@ -222,6 +247,16 @@ spacing distinguishes them.
|
|
|
222
247
|
offsets is the signature. Fix by bounding the child that varies — never with a cross-axis knob
|
|
223
248
|
(it moves the content and leaves the chrome), and never by resizing the CONTAINER, which changes
|
|
224
249
|
both terms and leaves the spread.
|
|
250
|
+
- **A VARIABLE-width leading slot destroys the column behind it.** A row's lead is sized for an
|
|
251
|
+
icon; put a chip, a badge or a name in it and every row's title starts at a different x. Collect
|
|
252
|
+
one repeated cell's `left` down the rows — more than one distinct value and the column does not
|
|
253
|
+
exist, however aligned each row looks on its own. The spread is small enough to read as
|
|
254
|
+
sloppiness rather than as a defect, which is why it survives review; the fix is a fixed-width
|
|
255
|
+
lead, or a register, where columns make it impossible.
|
|
256
|
+
- **Every numeric column: is its unit named anywhere, and is it ONE unit?** Collect the leaf values
|
|
257
|
+
sharing a right edge, with their size and weight. Identical treatment across values that are not
|
|
258
|
+
the same KIND of thing — a count beside a currency — is the signature, and the labels beside them
|
|
259
|
+
do not rescue it. → [composition.md](./composition.md) §"Numbers, money, dates".
|
|
225
260
|
- **A slot holding another component's part must be sized by that component's constant** — a
|
|
226
261
|
24px mark in an 18px column sits 3px outside the surface.
|
|
227
262
|
- **A row beat belongs to the PAGE** — collect every row primitive's height on one screen before
|
|
@@ -234,6 +269,14 @@ A resting register is the cheapest thing to screenshot and the least likely to h
|
|
|
234
269
|
|
|
235
270
|
- **Open what the row opens** and measure where the actionable field LANDS. An UNBOUNDED section
|
|
236
271
|
(a feed, a thread) above a bounded one buries it, and the burial deepens with use.
|
|
272
|
+
- **Name the record before you read it.** Collect the y of each fact that IDENTIFIES what is open
|
|
273
|
+
(its number, its counterparty) and compare against the viewport; any of them first answered
|
|
274
|
+
below the fold is the finding. Then measure the other direction — the top of the block answering
|
|
275
|
+
what the reader OPENED this for, and what sits above it. A leading block that pushes that answer
|
|
276
|
+
under the fold is too LONG, not mis-positioned: moving it down trades a buried ledger for a
|
|
277
|
+
reader who cannot tell whose record they are in, and both orders measure clean on their own, so
|
|
278
|
+
nothing that reads a resting screen catches either. Re-measure at 375, where a block that cleared
|
|
279
|
+
the fold at 1280 buries the answer. → [composition.md](./composition.md) §"Master-detail".
|
|
237
280
|
- **Press every control** and look at the state you land in. Commonest miss: a value rendering as
|
|
238
281
|
a coloured chip in a register and as bare grey text in its own EDITOR.
|
|
239
282
|
- **TAB through it, then diff what a keyboard reaches against what LOOKS pressable.** A pointer
|
|
@@ -47,7 +47,6 @@ import { Confidence } from "@lotics/ui/confidence";
|
|
|
47
47
|
import { CardSelectItem } from "@lotics/ui/card_select_item";
|
|
48
48
|
import { FileDropzone } from "@lotics/ui/file_dropzone";
|
|
49
49
|
import { FileDropTarget } from "@lotics/ui/file_drop_target";
|
|
50
|
-
import { Link } from "@lotics/ui/link";
|
|
51
50
|
import { TextInputField } from "@lotics/ui/text_input_field";
|
|
52
51
|
import type { DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
53
52
|
import { InlineFiles } from "@lotics/ui/inline_files";
|
|
@@ -847,10 +846,15 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
|
|
|
847
846
|
shape per card is found without reading the column. */}
|
|
848
847
|
<DiffMark kind={dropped ? "removed" : "added"} />
|
|
849
848
|
<Text size="sm" weight="semibold" style={{ flex: 1 }} numberOfLines={1}>{title}</Text>
|
|
849
|
+
{/* A control, not a Link. These MUTATE the change set;
|
|
850
|
+
they navigate nowhere. Blue underlined text promises a
|
|
851
|
+
destination, and `Link` announces `role="link"` — so a
|
|
852
|
+
template every app copies was teaching the one thing
|
|
853
|
+
composition.md § Buttons bans outright. */}
|
|
850
854
|
{dropped ? (
|
|
851
|
-
<
|
|
855
|
+
<Button color="secondary" title="Keep" onPress={() => kept.undo(d.id)} accessibilityLabel={`Keep ${title}`} />
|
|
852
856
|
) : (
|
|
853
|
-
<
|
|
857
|
+
<Button color="secondary" title="Don’t create" onPress={() => kept.reject(d.id)} accessibilityLabel={`Do not create ${title}`} />
|
|
854
858
|
)}
|
|
855
859
|
</View>
|
|
856
860
|
{/* A dropped card COLLAPSES rather than dimming in place:
|
|
@@ -997,7 +1001,7 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
|
|
|
997
1001
|
{khach}
|
|
998
1002
|
{CUSTOMER_OPTIONS.some((o) => o.label === khach) ? "" : " (new customer)"}
|
|
999
1003
|
</Text>
|
|
1000
|
-
<
|
|
1004
|
+
<Button color="secondary" title="Change" onPress={() => setKhach("")} accessibilityLabel="Change customer" />
|
|
1001
1005
|
</View>
|
|
1002
1006
|
)}
|
|
1003
1007
|
</FormField>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "44.
|
|
3
|
+
"version": "44.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -213,6 +213,7 @@
|
|
|
213
213
|
"./sort_header": "./src/sort_header.tsx",
|
|
214
214
|
"./skeleton": "./src/skeleton.tsx",
|
|
215
215
|
"./table": "./src/table.tsx",
|
|
216
|
+
"./table_fit": "./src/table_fit.ts",
|
|
216
217
|
"./data_grid": "./src/data_grid.tsx",
|
|
217
218
|
"./detail_row": "./src/detail_row.tsx",
|
|
218
219
|
"./record_summary": "./src/record_summary.tsx",
|
package/src/accordion.tsx
CHANGED
|
@@ -150,8 +150,13 @@ export interface AccordionMetaProps {
|
|
|
150
150
|
/** Right-side context for AccordionHeader — an amount, date, or short hint.
|
|
151
151
|
* xs muted, tabular for numerals. */
|
|
152
152
|
export function AccordionMeta(props: AccordionMetaProps) {
|
|
153
|
+
// The meta yields before the title does. The title NAMES the disclosure; the
|
|
154
|
+
// meta only qualifies it — but with the title on `flex: 1` and the meta at its
|
|
155
|
+
// intrinsic width, the squeeze landed entirely on the name (measured at 375:
|
|
156
|
+
// title clipped 24% and ellipsised, meta untouched). Same inverted priority
|
|
157
|
+
// `PageHeader` fixed one rung up; resolved the same way here.
|
|
153
158
|
return (
|
|
154
|
-
<Text size="xs" color="muted" tabular>
|
|
159
|
+
<Text size="xs" color="muted" tabular numberOfLines={1} style={styles.meta}>
|
|
155
160
|
{props.children}
|
|
156
161
|
</Text>
|
|
157
162
|
);
|
|
@@ -196,6 +201,9 @@ const styles = StyleSheet.create({
|
|
|
196
201
|
titleText: {
|
|
197
202
|
flex: 1,
|
|
198
203
|
},
|
|
204
|
+
meta: {
|
|
205
|
+
flexShrink: 1,
|
|
206
|
+
},
|
|
199
207
|
// Flush with the header's left edge — aligns with the title, like a Section
|
|
200
208
|
// body. The chevron + position already say "inside"; no indent needed.
|
|
201
209
|
content: {
|
package/src/agent_run_pane.tsx
CHANGED
|
@@ -48,11 +48,13 @@ export interface AgentRunLike {
|
|
|
48
48
|
* the wrong document, and the only exit was to sit through it.
|
|
49
49
|
*/
|
|
50
50
|
cancel: () => void;
|
|
51
|
-
/** The breaking error that killed the run.
|
|
52
|
-
* `
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
|
|
51
|
+
/** The breaking error that killed the run. OPTIONAL, not merely widened to
|
|
52
|
+
* include `undefined`: an optional property is not assignable to a required
|
|
53
|
+
* one however wide the required one's type, so declaring it required made the
|
|
54
|
+
* three-line usage in `ai_patterns.md` fail to compile against an SDK run —
|
|
55
|
+
* and every app that hit it reached for a `useMemo` reshape or a cast. The
|
|
56
|
+
* pane normalizes absent, `undefined` and `null` alike. */
|
|
57
|
+
error?: string | null | undefined;
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
export function AgentRunScope({ children }: { children: ReactNode }) {
|
package/src/card.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { Divider } from "./divider";
|
|
5
5
|
import { InfoPopover } from "./info_popover";
|
|
6
|
+
import { useLoticsLocale } from "./locale";
|
|
6
7
|
import { Text } from "./text";
|
|
7
8
|
|
|
8
9
|
interface CardProps {
|
|
@@ -84,13 +85,18 @@ export interface CardHeaderTitleProps {
|
|
|
84
85
|
* description line. Grows to push siblings (meta, actions) to the right.
|
|
85
86
|
* Tabular numerals so id-like titles (SR-2026-0081) align across a list. */
|
|
86
87
|
export function CardHeaderTitle(props: CardHeaderTitleProps) {
|
|
88
|
+
// The ⓘ's name comes from the pack, like every other one in the kit
|
|
89
|
+
// (`section_heading` at all three of its levels). This had the Vietnamese
|
|
90
|
+
// value of that very slot pasted in as a literal, which typechecks, renders,
|
|
91
|
+
// and announces Vietnamese to a screen reader in every other tenant's app.
|
|
92
|
+
const words = useLoticsLocale();
|
|
87
93
|
return (
|
|
88
94
|
<View style={styles.headerTitle}>
|
|
89
95
|
<View style={styles.headerTitleRow}>
|
|
90
96
|
<Text size="sm" weight="semibold" tabular>
|
|
91
97
|
{props.children}
|
|
92
98
|
</Text>
|
|
93
|
-
{props.info ? <InfoPopover text={props.info} accessibilityLabel=
|
|
99
|
+
{props.info ? <InfoPopover text={props.info} accessibilityLabel={words.sectionHeading.info} /> : null}
|
|
94
100
|
</View>
|
|
95
101
|
{props.description ? (
|
|
96
102
|
<Text size="xs" color="muted">
|
package/src/card_select_item.tsx
CHANGED
|
@@ -33,8 +33,13 @@ export function CardSelectItem(props: CardSelectItemProps) {
|
|
|
33
33
|
aria-pressed={selected}
|
|
34
34
|
onPress={onPress}
|
|
35
35
|
style={(state: PressableHighlightState) => {
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
// Selection is a GROUND; hover/press/focus are a ring. They used to
|
|
37
|
+
// share one style, which made the card you had CHOSEN pixel-identical to
|
|
38
|
+
// the card under the pointer — the same defect 43.5.0 fixed one release
|
|
39
|
+
// earlier for `Table`'s selected row, and the reason the kit's own
|
|
40
|
+
// `tpl_lookup` routed around this component with a hand-rolled ground.
|
|
41
|
+
const ringed = state.hovered || state.pressed || state.focusVisible;
|
|
42
|
+
return [styles.container, selected && styles.selected, ringed && styles.ring, style];
|
|
38
43
|
}}
|
|
39
44
|
>
|
|
40
45
|
{children}
|
|
@@ -56,4 +61,12 @@ const styles = StyleSheet.create({
|
|
|
56
61
|
ring: {
|
|
57
62
|
...({ boxShadow: FOCUS_RING } as ViewStyle),
|
|
58
63
|
},
|
|
64
|
+
// The accent wash + accent edge is the same "you are here" the selected table
|
|
65
|
+
// row and the active chip carry, so one signal means one thing product-wide.
|
|
66
|
+
// A ground rather than a heavier outline: among white cards a 1px border
|
|
67
|
+
// change is noise, and it vanishes entirely under the hover ring.
|
|
68
|
+
selected: {
|
|
69
|
+
backgroundColor: colors.accent_wash,
|
|
70
|
+
borderColor: colors.accent,
|
|
71
|
+
},
|
|
59
72
|
});
|
package/src/control_surface.ts
CHANGED
|
@@ -38,12 +38,26 @@ export const CONTROL_CONTENT_HEIGHT = 28;
|
|
|
38
38
|
export const CONTROL_PADDING_V = (CONTROL_HEIGHT - CONTROL_CONTENT_HEIGHT) / 2 - 1;
|
|
39
39
|
|
|
40
40
|
/** The narrowest a control may be and still be usable — below it, editors get
|
|
41
|
-
* crushed and option text has nowhere to go. It is the
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* the kit rather than a number re-picked per component. */
|
|
41
|
+
* crushed and option text has nowhere to go. It is the floor under a field
|
|
42
|
+
* popover's width, so "too narrow to use" means one thing across the kit rather
|
|
43
|
+
* than a number re-picked per component. */
|
|
45
44
|
export const MIN_CONTROL_WIDTH = 160;
|
|
46
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The narrowest a record's VALUE column may be before `DetailTable` stops
|
|
48
|
+
* putting label and value side by side and stacks them instead.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately larger than {@link MIN_CONTROL_WIDTH}, because the two answer
|
|
51
|
+
* different questions. A control's floor asks "can this still be operated"; a
|
|
52
|
+
* value column's asks "can this still be READ" — and a value column holds
|
|
53
|
+
* company names, addresses and reference codes, not just controls. Sharing the
|
|
54
|
+
* control's 160 put the stack threshold low enough that a 375px screen kept its
|
|
55
|
+
* two columns and handed the value ~170px: a name needing 221 got 104, a tax
|
|
56
|
+
* code needing 114 got 53. Both halves measured "fine" and the row was
|
|
57
|
+
* unreadable.
|
|
58
|
+
*/
|
|
59
|
+
export const MIN_VALUE_WIDTH = 200;
|
|
60
|
+
|
|
47
61
|
/** The system control radius — every interactive control surface (Button, the
|
|
48
62
|
* inputs/selects/pickers, SearchInput, MenuButton, Tabs, the SegmentedControl
|
|
49
63
|
* track, and ChipGroup/FilterChip/Chip via the surfaces below) wears it,
|
package/src/data_grid.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { FocusRingPressable } from "./focus_ring_pressable";
|
|
|
5
5
|
import { Icon } from "./icon";
|
|
6
6
|
import { Text } from "./text";
|
|
7
7
|
import { type SortState, type SortHeaderLabels } from "./sort_header";
|
|
8
|
+
import { FLEX_MIN_WIDTH } from "./table_fit";
|
|
8
9
|
|
|
9
10
|
export interface DataGridColumn<T> {
|
|
10
11
|
key: string;
|
|
@@ -178,7 +179,14 @@ export const gridRowStyle: ViewStyle = {
|
|
|
178
179
|
|
|
179
180
|
const styles = StyleSheet.create({
|
|
180
181
|
row: gridRowStyle,
|
|
181
|
-
|
|
182
|
+
// A FLOOR, not `minWidth: 0`. This grid sheds no columns — every column is a
|
|
183
|
+
// measure you came to compare, which is what separates it from `Table`'s
|
|
184
|
+
// register of objects — so the fixed columns hold their widths and the
|
|
185
|
+
// flexible identity column absorbed the entire squeeze. At 0 it absorbed it
|
|
186
|
+
// all the way to nothing: a narrow container rendered rows of unlabelled
|
|
187
|
+
// money, silently, with no overflow to notice. Past the floor the grid
|
|
188
|
+
// overflows and the caller scrolls it; see catalog.md § DataGrid.
|
|
189
|
+
flexCol: { flex: 1, minWidth: FLEX_MIN_WIDTH },
|
|
182
190
|
head: {
|
|
183
191
|
minHeight: 30,
|
|
184
192
|
borderBottomWidth: 1,
|
|
@@ -2,7 +2,7 @@ import React, { useMemo, useState } from "react";
|
|
|
2
2
|
import { View } from "react-native";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
|
-
import { CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
|
|
5
|
+
import { CONTROL_HEIGHT, CONTROL_RADIUS, HOVER_BORDER, CONTROL_TRANSITION } from "./control_surface";
|
|
6
6
|
import { Icon } from "./icon";
|
|
7
7
|
import { Button } from "./button";
|
|
8
8
|
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
@@ -129,7 +129,14 @@ export function DateRangeFilterField(props: DateRangeFilterFieldProps) {
|
|
|
129
129
|
flexDirection: "row",
|
|
130
130
|
alignItems: "center",
|
|
131
131
|
gap: 8,
|
|
132
|
-
|
|
132
|
+
// The height comes from the shared constant, never from padding
|
|
133
|
+
// tuned against a particular leading. This was `paddingVertical: 9`,
|
|
134
|
+
// which was exactly 40 against the OLD `sm` 14/20 — then 43.5.0 made
|
|
135
|
+
// running text 14/24 and the same padding silently became 44, one
|
|
136
|
+
// control standing 4px proud of every sibling in its band. A ramp is
|
|
137
|
+
// allowed to move; a control's height is not derived from it.
|
|
138
|
+
height: CONTROL_HEIGHT,
|
|
139
|
+
justifyContent: "center",
|
|
133
140
|
paddingHorizontal: 12,
|
|
134
141
|
borderWidth: 1,
|
|
135
142
|
// `colors.border` like every other control — this rested a step
|
package/src/date_stamp.tsx
CHANGED
|
@@ -15,6 +15,12 @@ export interface DateStampProps {
|
|
|
15
15
|
onChange?: (iso: string) => void;
|
|
16
16
|
/** Locale tag for the rendered date. Defaults to the kit's locale. */
|
|
17
17
|
locale?: string;
|
|
18
|
+
/** Drop the year (`08/09` rather than `08/09/2026`). The stamp sits in
|
|
19
|
+
* columns beside dates a caller formats itself, and a year on one side of a
|
|
20
|
+
* row and not the other is the kind of mismatch nobody reports and everyone
|
|
21
|
+
* notices. Same option `formatDate` takes — the format itself stays there,
|
|
22
|
+
* so there is one place that decides what a date looks like. */
|
|
23
|
+
compact?: boolean;
|
|
18
24
|
accessibilityLabel: string;
|
|
19
25
|
}
|
|
20
26
|
|
|
@@ -44,7 +50,7 @@ export interface DateStampProps {
|
|
|
44
50
|
* value therefore renders nothing at all.
|
|
45
51
|
*/
|
|
46
52
|
export function DateStamp(props: DateStampProps) {
|
|
47
|
-
const { value, onChange, locale, accessibilityLabel } = props;
|
|
53
|
+
const { value, onChange, locale, compact = false, accessibilityLabel } = props;
|
|
48
54
|
const localeTag = useLocaleTag();
|
|
49
55
|
const tag = locale ?? localeTag;
|
|
50
56
|
const [hovered, setHovered] = useState(false);
|
|
@@ -55,7 +61,7 @@ export function DateStamp(props: DateStampProps) {
|
|
|
55
61
|
// replaced. This is what the close path reads instead.
|
|
56
62
|
const picked = useRef<string | null>(value);
|
|
57
63
|
|
|
58
|
-
const shown = value ? formatDate(value, { locale: tag }) : "";
|
|
64
|
+
const shown = value ? formatDate(value, { locale: tag, compact }) : "";
|
|
59
65
|
|
|
60
66
|
// Read-only, or nothing to show and no way to add one: render the text alone
|
|
61
67
|
// so a caller can drop this into a column without a conditional.
|
package/src/detail_row.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createContext, ReactNode, useContext, useState } from "react";
|
|
2
2
|
import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
|
3
|
-
import {
|
|
3
|
+
import { MIN_VALUE_WIDTH } from "./control_surface";
|
|
4
4
|
import { SPACE } from "./spacing";
|
|
5
5
|
import { FieldAnnotationProps, FieldAnnotations, hasFieldAnnotation } from "./field_annotations";
|
|
6
6
|
import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
|
|
@@ -32,9 +32,9 @@ export interface DetailTableProps {
|
|
|
32
32
|
* and custom rows all sit on the inline-control baseline grid. */
|
|
33
33
|
minHeight?: number;
|
|
34
34
|
/** The usable width the value cell must keep before the table STACKS
|
|
35
|
-
* (default
|
|
36
|
-
* amount + method pair) — side-by-side columns that technically
|
|
37
|
-
* still crush a multi-editor cell. */
|
|
35
|
+
* (default {@link MIN_VALUE_WIDTH}). Raise it when a cell holds MORE than one
|
|
36
|
+
* editor (an amount + method pair) — side-by-side columns that technically
|
|
37
|
+
* fit can still crush a multi-editor cell. */
|
|
38
38
|
minValueWidth?: number;
|
|
39
39
|
/** `DetailRow`s (each inherits the table's columns; row props override). */
|
|
40
40
|
children: ReactNode;
|
|
@@ -68,7 +68,7 @@ export interface DetailTableProps {
|
|
|
68
68
|
* above a full-width value. No prop — the switch is automatic.
|
|
69
69
|
*/
|
|
70
70
|
export function DetailTable(props: DetailTableProps) {
|
|
71
|
-
const { labelWidth = DETAIL_LABEL_WIDTH, minHeight = INLINE_CONTROL_HEIGHT, minValueWidth =
|
|
71
|
+
const { labelWidth = DETAIL_LABEL_WIDTH, minHeight = INLINE_CONTROL_HEIGHT, minValueWidth = MIN_VALUE_WIDTH, children, style } = props;
|
|
72
72
|
const [width, setWidth] = useState<number | null>(null);
|
|
73
73
|
const stacked = width != null && width < labelWidth + minValueWidth + 24;
|
|
74
74
|
return (
|
|
@@ -100,12 +100,17 @@ export interface DetailRowProps extends FieldAnnotationProps {
|
|
|
100
100
|
labelSize?: "xs" | "sm";
|
|
101
101
|
/** Min row height. Default 28 (or the `DetailTable`'s `minHeight`). */
|
|
102
102
|
minHeight?: number;
|
|
103
|
-
/** The value
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
103
|
+
/** The value is a RAW node — a plain `Text`, a figure, a pair — sitting off the
|
|
104
|
+
* inline-control grid. NOT `InlineStatic` or `InlineSlot`: those compose the
|
|
105
|
+
* grid's own box, so their words already start at the control text inset and
|
|
106
|
+
* centre in the band, and `flat` there re-introduces both offsets it exists
|
|
107
|
+
* to remove. Annotations then align to the value on BOTH axes,
|
|
108
|
+
* because a control's box is what they were offset from and there is no box:
|
|
109
|
+
* they tuck up by the control band's slack, so the perceived gap under the
|
|
110
|
+
* TEXT equals the gap under a chip rather than leaving the band's invisible
|
|
111
|
+
* bottom half reading as a hole; and they drop the control's text inset, so
|
|
112
|
+
* the description starts on the value's own left edge instead of nine pixels
|
|
113
|
+
* inside it. Row height and label centering are unchanged. */
|
|
109
114
|
flat?: boolean;
|
|
110
115
|
}
|
|
111
116
|
|
|
@@ -160,7 +165,7 @@ export function DetailRow(props: DetailRowProps) {
|
|
|
160
165
|
// text inset, defined once in `field_annotations` and rendered identically by
|
|
161
166
|
// a checklist row's field. A record's field and a checklist's are the same
|
|
162
167
|
// thing on two surfaces; they may not say a fault two different ways.
|
|
163
|
-
const annotations = <FieldAnnotations description={description} warning={warning} error={error} />;
|
|
168
|
+
const annotations = <FieldAnnotations description={description} warning={warning} error={error} flat={flat} />;
|
|
164
169
|
if (table?.stacked) {
|
|
165
170
|
// Stacked mode wears the FORM grammar: the label renders exactly like a
|
|
166
171
|
// `FormField` label (medium, default ink), so a narrow record surface
|
package/src/empty_state.tsx
CHANGED
|
@@ -42,11 +42,11 @@ export function EmptyState(props: EmptyStateProps) {
|
|
|
42
42
|
<Icon name={props.icon} size={28} color={colors.zinc[400]} />
|
|
43
43
|
</View>
|
|
44
44
|
) : null}
|
|
45
|
-
<Text size="sm" color="muted">
|
|
45
|
+
<Text size="sm" color="muted" style={styles.line}>
|
|
46
46
|
{props.message}
|
|
47
47
|
</Text>
|
|
48
48
|
{props.hint ? (
|
|
49
|
-
<Text size="xs" color="muted">
|
|
49
|
+
<Text size="xs" color="muted" style={styles.line}>
|
|
50
50
|
{props.hint}
|
|
51
51
|
</Text>
|
|
52
52
|
) : null}
|
|
@@ -63,4 +63,11 @@ const styles = StyleSheet.create({
|
|
|
63
63
|
icon: {
|
|
64
64
|
marginBottom: 4,
|
|
65
65
|
},
|
|
66
|
+
// The container centres the BOX; without this the text inside it still sets
|
|
67
|
+
// left. Invisible while every line is short enough not to wrap — which is
|
|
68
|
+
// exactly the case a narrow container breaks, leaving a wrapped message
|
|
69
|
+
// reading left-aligned beside a one-line hint reading centred.
|
|
70
|
+
line: {
|
|
71
|
+
textAlign: "center",
|
|
72
|
+
},
|
|
66
73
|
});
|
package/src/error_state.tsx
CHANGED
|
@@ -41,11 +41,11 @@ export function ErrorState(props: ErrorStateProps) {
|
|
|
41
41
|
<View style={styles.icon}>
|
|
42
42
|
<Icon name="triangle-alert" size={28} color={solid("red")} />
|
|
43
43
|
</View>
|
|
44
|
-
<Text size="sm" color="muted">
|
|
44
|
+
<Text size="sm" color="muted" style={styles.line}>
|
|
45
45
|
{props.message}
|
|
46
46
|
</Text>
|
|
47
47
|
{props.detail ? (
|
|
48
|
-
<Text size="xs" color="muted">
|
|
48
|
+
<Text size="xs" color="muted" style={styles.line}>
|
|
49
49
|
{props.detail}
|
|
50
50
|
</Text>
|
|
51
51
|
) : null}
|
|
@@ -66,6 +66,13 @@ const styles = StyleSheet.create({
|
|
|
66
66
|
alignItems: "center",
|
|
67
67
|
gap: 4,
|
|
68
68
|
},
|
|
69
|
+
// Centred like `EmptyState`'s, and for the same reason: the container centres
|
|
70
|
+
// the BOX, not the text inside it. These two swap into one another as a read
|
|
71
|
+
// settles, so a difference here would show up as the copy jumping alignment at
|
|
72
|
+
// the moment a region fails.
|
|
73
|
+
line: {
|
|
74
|
+
textAlign: "center",
|
|
75
|
+
},
|
|
69
76
|
icon: {
|
|
70
77
|
marginBottom: 4,
|
|
71
78
|
},
|
|
@@ -45,10 +45,31 @@ export function hasFieldAnnotation(props: FieldAnnotationProps): boolean {
|
|
|
45
45
|
* they answer different questions ("this is broken" / "this will cost you" / "this is what the
|
|
46
46
|
* field is for"), and hiding the rule that explains the failure is exactly the wrong economy.
|
|
47
47
|
*/
|
|
48
|
-
export
|
|
48
|
+
export interface FieldAnnotationsProps extends FieldAnnotationProps {
|
|
49
|
+
/**
|
|
50
|
+
* The value above is FLAT text, not a painted control — so drop the control's
|
|
51
|
+
* text inset and sit on the value's own left edge.
|
|
52
|
+
*
|
|
53
|
+
* The inset exists to clear a control's border and padding, which is what puts
|
|
54
|
+
* an annotation under the value's WORDS instead of under the editor's
|
|
55
|
+
* invisible box. Under plain text there is no border and no padding to clear,
|
|
56
|
+
* so the same number becomes pure indent: label, value and description end up
|
|
57
|
+
* on THREE left edges where the screen only has two things to say. Nine pixels
|
|
58
|
+
* is small enough to read as sloppiness rather than as structure, and it
|
|
59
|
+
* appears only on the rows whose value happens not to be a control, so a
|
|
60
|
+
* column of fields drifts in and out of alignment down its own length.
|
|
61
|
+
*
|
|
62
|
+
* Hosts already know which kind of value they hold: `DetailRow` passes its
|
|
63
|
+
* `flat`, the same flag that tucks the block up by the control band's slack.
|
|
64
|
+
* One fact about the value, both corrections.
|
|
65
|
+
*/
|
|
66
|
+
flat?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function FieldAnnotations(props: FieldAnnotationsProps) {
|
|
49
70
|
if (!hasFieldAnnotation(props)) return null;
|
|
50
71
|
return (
|
|
51
|
-
<View style={styles.stack}>
|
|
72
|
+
<View style={[styles.stack, props.flat === true && styles.stackFlat]}>
|
|
52
73
|
{props.error != null ? (
|
|
53
74
|
<Text size="xs" color="danger" accessibilityRole="alert" aria-live="polite">
|
|
54
75
|
{props.error}
|
|
@@ -73,4 +94,6 @@ const styles = StyleSheet.create({
|
|
|
73
94
|
// with the editor's invisible box — and the 2px that separates the lines from each other,
|
|
74
95
|
// which is the same rhythm the host puts between the control and this block.
|
|
75
96
|
stack: { gap: 2, paddingLeft: CONTROL_TEXT_INSET },
|
|
97
|
+
// ...and nothing to clear when the value is flat text — see `flat`.
|
|
98
|
+
stackFlat: { paddingLeft: 0 },
|
|
76
99
|
});
|
package/src/finding.tsx
CHANGED
|
@@ -45,8 +45,8 @@ export interface FindingProps {
|
|
|
45
45
|
/** What was found, in one line, in the reader's own nouns. */
|
|
46
46
|
title: string;
|
|
47
47
|
/**
|
|
48
|
-
* The readings that disagree, in source order. Rendered
|
|
49
|
-
* title as `source value
|
|
48
|
+
* The readings that disagree, in source order. Rendered ONE PER LINE beneath
|
|
49
|
+
* the title, each as `source value` — never joined by a separator.
|
|
50
50
|
*
|
|
51
51
|
* NEITHER is marked as the wrong one. A finding reports that two sources
|
|
52
52
|
* disagree; which one is right is the reader's call, and a struck-through or
|
|
@@ -77,8 +77,15 @@ export interface FindingProps {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
|
-
* ONE RANKED
|
|
81
|
-
* observation, a briefing item.
|
|
80
|
+
* ONE RANKED DISCREPANCY from a cross-check — a contradiction between two
|
|
81
|
+
* readings, an audit observation, a briefing item.
|
|
82
|
+
*
|
|
83
|
+
* WHOEVER performed the check. Nothing here is AI-specific — the labels are
|
|
84
|
+
* four severity words and "difference" — but this opened "from an AI check",
|
|
85
|
+
* and a rules-derived contradiction (two papers disagreeing, a carrier moving
|
|
86
|
+
* an ETA) reads as out of scope on that wording. It is not: the next author who
|
|
87
|
+
* believes it hand-rolls a Callout and loses the ranking, the delta and the
|
|
88
|
+
* readings line.
|
|
82
89
|
*
|
|
83
90
|
* The readings sit at `sm`, the same size as the title. They were `xs` — a
|
|
84
91
|
* miniature that said "secondary" by being hard to read, which is the wrong
|
|
@@ -134,11 +141,6 @@ export function Finding(props: FindingProps) {
|
|
|
134
141
|
<View style={styles.readings}>
|
|
135
142
|
{readings.map((reading, index) => (
|
|
136
143
|
<View key={`${reading.source}-${index}`} style={styles.reading}>
|
|
137
|
-
{index > 0 ? (
|
|
138
|
-
<Text size="sm" color="muted">
|
|
139
|
-
·
|
|
140
|
-
</Text>
|
|
141
|
-
) : null}
|
|
142
144
|
<SourceName label={reading.source} onOpen={props.onOpenSource} />
|
|
143
145
|
<Text size="sm" weight="medium" tabular numberOfLines={1}>
|
|
144
146
|
{reading.value}
|
|
@@ -189,6 +191,13 @@ const styles = StyleSheet.create({
|
|
|
189
191
|
// instance — the dot marks the left edge and the figure the right, so a
|
|
190
192
|
// reader scanning a stack gets rank and magnitude in one pass.
|
|
191
193
|
delta: { flexShrink: 0 },
|
|
192
|
-
|
|
194
|
+
// ONE READING PER LINE. They were joined inline by a ` · `, which fails twice:
|
|
195
|
+
// a middot claims a relation while refusing to name it (the templated-metadata
|
|
196
|
+
// tell `docs/reviewing.md` bans outright), and it is the one mark a screen
|
|
197
|
+
// reader drops — so the separator carrying "these two disagree" never survived
|
|
198
|
+
// being read aloud, the same defect `CommentsButton` was fixed for. It also
|
|
199
|
+
// wrapped: at phone width a line could BEGIN with the separator. Stacking pairs
|
|
200
|
+
// each source with its own value at every width and needs no separator at all.
|
|
201
|
+
readings: { flexDirection: "column", alignItems: "flex-start", gap: 4 },
|
|
193
202
|
reading: { flexDirection: "row", alignItems: "baseline", gap: 5, minWidth: 0 },
|
|
194
203
|
});
|
package/src/format_money.ts
CHANGED
|
@@ -5,6 +5,12 @@ export interface FormatMoneyOptions {
|
|
|
5
5
|
* "486 tr ₫". For stat strips/cards where the full figure lives in the
|
|
6
6
|
* table below — never for the table itself. */
|
|
7
7
|
compact?: boolean;
|
|
8
|
+
/** Significant digits below the currency's minor unit. `Intl`'s currency
|
|
9
|
+
* style floors at that unit — 2 for USD — so a genuine per-unit price like
|
|
10
|
+
* an LCL rate of 0,019 US$/kg renders as `0,02`: a 5% error, printed as
|
|
11
|
+
* fact, with nothing on screen saying it was rounded. Set this on a UNIT
|
|
12
|
+
* price; leave it off for a total, where two decimals is the contract. */
|
|
13
|
+
maxFractionDigits?: number;
|
|
8
14
|
}
|
|
9
15
|
|
|
10
16
|
/**
|
|
@@ -29,10 +35,18 @@ export function formatCompactNumber(value: number, locale = "vi-VN"): string {
|
|
|
29
35
|
* or the ₫ suffix.
|
|
30
36
|
*/
|
|
31
37
|
export function formatMoney(value: number, options: FormatMoneyOptions = {}): string {
|
|
32
|
-
const { locale = "vi-VN", currency = "VND", compact = false } = options;
|
|
38
|
+
const { locale = "vi-VN", currency = "VND", compact = false, maxFractionDigits } = options;
|
|
33
39
|
if (compact && Math.abs(value) >= 1_000_000) {
|
|
34
40
|
const suffixed = formatCompactNumber(value, locale);
|
|
35
41
|
return currency === "VND" ? `${suffixed} ₫` : `${suffixed} ${currency}`;
|
|
36
42
|
}
|
|
37
|
-
return value.toLocaleString(locale, {
|
|
43
|
+
return value.toLocaleString(locale, {
|
|
44
|
+
style: "currency",
|
|
45
|
+
currency,
|
|
46
|
+
// Raising the MAXIMUM alone is enough, and is what keeps a total looking
|
|
47
|
+
// like money: Intl holds its own minimum (2 for USD, 0 for VND), so 0,019
|
|
48
|
+
// gains its digits while 2750 still renders 2.750,00. Unset, both stay at
|
|
49
|
+
// the currency's own contract.
|
|
50
|
+
...(maxFractionDigits != null ? { maximumFractionDigits: maxFractionDigits } : {}),
|
|
51
|
+
});
|
|
38
52
|
}
|