@lotics/ui 44.7.1 → 44.9.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 +15 -3
- package/MIGRATION.md +110 -5
- package/docs/ai_patterns.md +20 -0
- package/docs/catalog.md +100 -8
- package/docs/composition.md +100 -6
- package/docs/data_entry.md +60 -6
- package/docs/reviewing.md +44 -3
- package/examples/tpl_item_list.tsx +8 -4
- package/package.json +4 -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/checklist.tsx +23 -3
- 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/ledger.tsx +5 -2
- package/src/locale.tsx +35 -1
- package/src/number_input.tsx +8 -1
- package/src/reference_field.tsx +9 -2
- package/src/related_record_row.tsx +80 -0
- package/src/running_ledger.tsx +196 -0
- 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/stepper.tsx +26 -2
- package/src/table_fit.ts +18 -8
- package/src/tabs.tsx +19 -0
- package/src/text_input_field.tsx +35 -1
package/docs/composition.md
CHANGED
|
@@ -8,6 +8,23 @@ full worked-example screens ship as source in `examples/tpl_*.tsx` (picked by jo
|
|
|
8
8
|
[templates](./templates.md)). Every rule here is enforced by the primitives' defaults; fighting
|
|
9
9
|
them produces off-system UI.
|
|
10
10
|
|
|
11
|
+
## The form comes before the treatment
|
|
12
|
+
|
|
13
|
+
**Name what the subject IS, then reach for parts.** A set of records is a register; a thing moving
|
|
14
|
+
through ordered places is a rail; work assigned to finite resources over time is a board; a
|
|
15
|
+
sequence of events is a timeline; a population whose point is its deviant minority is a queue
|
|
16
|
+
sorted by deviation. Getting this wrong cannot be recovered later — a register of objects that
|
|
17
|
+
should have been a rail is well-typed, well-spaced, correctly aligned and still says nothing about
|
|
18
|
+
order or progress, because a status column reports one word and leaves the reader to know which
|
|
19
|
+
word comes after which.
|
|
20
|
+
|
|
21
|
+
The pull toward a register is structural, not a lapse in taste: `Table`, `DetailRow`,
|
|
22
|
+
`SummaryLine` and `Badge` are the most reachable things here, and they compose into something
|
|
23
|
+
defensible for almost any data. That is exactly why the question has to be asked out loud. **Reuse
|
|
24
|
+
first is about not duplicating primitives** — a one-off composition needs no new component, so the
|
|
25
|
+
kit lacking a device is a reason to compose one from what it has, not a reason to flatten the
|
|
26
|
+
subject into rows.
|
|
27
|
+
|
|
11
28
|
## Canvas & content column
|
|
12
29
|
|
|
13
30
|
- **Canvas**: a full-bleed `colors.white` ScrollView with `padding: 28`. Cards separate from the
|
|
@@ -98,7 +115,15 @@ restyle a heading level per-page.
|
|
|
98
115
|
(`##` — xl semibold, optional muted `description` and `info` popover), everything left-aligned
|
|
99
116
|
at the column edge; `SectionHeadingMeta`, any VIEW control, and the section's own ADD ride the
|
|
100
117
|
heading row's right edge (the title's `flex: 1` pushes them) — see the add-placement law
|
|
101
|
-
below.
|
|
118
|
+
below. **The row is `flexDirection: "row"` and never wraps**, so a long control label doesn't
|
|
119
|
+
drop to its own line at phone width — it holds its own width and the title's `flex: 1` gives
|
|
120
|
+
up whatever is left, taking the `description` down with it. A label that repeats the heading
|
|
121
|
+
verbatim is the usual way this gets long enough to bite: measured on a real section, an
|
|
122
|
+
11-character title-plus-button pair left the title a comfortable column, and the SAME text
|
|
123
|
+
duplicated onto the button squeezed both title and description into a 107px ribbon wrapping
|
|
124
|
+
one or two words per line for eleven lines total. The button-label law already asks for
|
|
125
|
+
2–4 words; a heading-row control is the case where skipping it has a measurable width cost,
|
|
126
|
+
not just a wordier button. A COUNT earns that slot only when what it counts is not fully on screen — collapsed,
|
|
102
127
|
paged, or long enough that the reader would have to scan to know. Over a list or thread
|
|
103
128
|
rendered whole directly beneath its own heading it restates what is already in view, and the
|
|
104
129
|
heading reads busier for it. The page column is a **`SectionStack`** — it owns the
|
|
@@ -183,6 +208,14 @@ without scanning. The heading row is the section's control line — it already c
|
|
|
183
208
|
and the view controls — so the add belongs on it, in the same spot whether the section holds
|
|
184
209
|
nought or forty.
|
|
185
210
|
|
|
211
|
+
**When the row runs out of width, the VERB wraps — the title never gives way.** The title names
|
|
212
|
+
the section; the verb is the thing that can move. Getting this backwards is easy to ship because
|
|
213
|
+
RN-Web resolves `flex: 1` to `flex-basis: 0%` with `min-width: 0`, so a title beside a button that
|
|
214
|
+
holds its intrinsic width is the half that collapses — measured at 375 as a heading broken one
|
|
215
|
+
word per line beside an untouched button. `SectionHeading` and `SubsectionHeading` wrap and floor
|
|
216
|
+
their title column for this; do not add a second `flex: 1` spacer beside a title that already has
|
|
217
|
+
one, which halves its share again and is how a badge came to land on top of a section title.
|
|
218
|
+
|
|
186
219
|
**Where there IS no heading row, the add has nowhere else to be, so it rides the surface's own
|
|
187
220
|
bar** — an attachment popover, a dialog body, any self-contained widget. That is not an exception
|
|
188
221
|
to the law so much as the same reasoning: the rule exists so the verb sits in ONE place a reader
|
|
@@ -217,6 +250,18 @@ no path actually closes.
|
|
|
217
250
|
- **Only the ADD.** A verb that acts on a SELECTION (delete, export, run) belongs to the
|
|
218
251
|
selection — `FloatingActionBar` — and a verb about ONE row stays on the row.
|
|
219
252
|
|
|
253
|
+
## A partitioning control states its band's size as a PROP
|
|
254
|
+
|
|
255
|
+
`TableGroup` takes `count`; `Tabs` takes it per option. Neither takes a label with the number
|
|
256
|
+
formatted into it, and that is the whole rule: `"Trên tàu (4)"` is a second copy of a figure the
|
|
257
|
+
screen already computes, and it is the copy that goes stale — the band re-counts on every filter
|
|
258
|
+
and the string does not.
|
|
259
|
+
|
|
260
|
+
It also decides where the count may live. A register whose bands already state their sizes must
|
|
261
|
+
not carry a strip of chips above it repeating the same three numbers; that is one fact twice, and
|
|
262
|
+
the reader learns to skip the strip. And a count is a reason to PRESS — a control that states how
|
|
263
|
+
many rows are behind it and does not filter to them is stating a fact it refuses to act on.
|
|
264
|
+
|
|
220
265
|
## Period filters for time-constrained data
|
|
221
266
|
|
|
222
267
|
Time-constrained data gets a **`DateRangeFilterField`** in the header band — never a static period
|
|
@@ -312,7 +357,11 @@ compete instead of reading as different things. Selection changes what the box I
|
|
|
312
357
|
hover changes how intensely it says it.
|
|
313
358
|
|
|
314
359
|
For fields: layer the hover-border AFTER `style` so it wins over the caller's resting edge, put the
|
|
315
|
-
open ring (`FOCUS_RING`) on the open state, and **a field NEVER greys its content**.
|
|
360
|
+
open ring (`FOCUS_RING`) on the open state, and **a field NEVER greys its content**. That ordering
|
|
361
|
+
is load-bearing rather than tidy: a caller's resting `borderColor` is exactly how a frameless
|
|
362
|
+
variant gets expressed, and layered the other way it erases the hover edge that is such a field's
|
|
363
|
+
ONLY affordance — a control that then draws nothing in either state and is simply never pressed.
|
|
364
|
+
Give the field a variant PROP for its resting frame instead of taking one through `style`. The bug this
|
|
316
365
|
grammar prevents: a FIELD built on `PressableHighlight` inherits the wash AND its own border — it
|
|
317
366
|
greys *and* animates its edge while its siblings only border-hover. Reach for `PressableHighlight`
|
|
318
367
|
on a bordered/input control and you've made it.
|
|
@@ -416,6 +465,12 @@ a dashboard. Card stat rails use `KPICard`.
|
|
|
416
465
|
form round-trip never expands them to a fabricated `"2026-05-01"`. Comparisons and sorting treat
|
|
417
466
|
a partial as its period start (`"2026" < "2026-05" < "2026-05-01"`).
|
|
418
467
|
|
|
468
|
+
**A per-unit price is not a total, and the currency's minor unit is the wrong floor for it.**
|
|
469
|
+
`Intl`'s currency style rounds to two decimals for USD, so a rate of 0,019 per kg prints as
|
|
470
|
+
`0,02` — a 5% error rendered as fact, with nothing on the screen saying it was rounded. Pass
|
|
471
|
+
`maxFractionDigits` on a UNIT price; leave it off for a total, where two decimals is the contract.
|
|
472
|
+
Never hand-roll the grouping or the symbol to get the digits back.
|
|
473
|
+
|
|
419
474
|
## Every number is a door
|
|
420
475
|
|
|
421
476
|
Except the KPI strip: a component that summarizes records leads to the records behind it when
|
|
@@ -712,6 +767,21 @@ action goes to the referenced record's own page; a local `Drawer` that re-shows
|
|
|
712
767
|
just showed makes "Open" mean *read that again, wider*, and leaves the real record unreachable
|
|
713
768
|
from the only control that promised it.
|
|
714
769
|
|
|
770
|
+
**A record surface answers *which record is this* first — and the block that does it is SHORT.**
|
|
771
|
+
Identity is the handful of facts someone would say aloud to name this record (its number and its
|
|
772
|
+
counterparty, usually two); everything else is an ATTRIBUTE and belongs below the answer the
|
|
773
|
+
reader opened the record for. Get the size wrong and the position becomes unwinnable: eight
|
|
774
|
+
labelled header fields push the ledger under the fold, and moving the block to the bottom to fix
|
|
775
|
+
that leaves a reader who cannot tell whose record they are in. Both orders measure perfectly, so
|
|
776
|
+
neither is caught by any check that reads the rendered screen. **When a leading block buries the
|
|
777
|
+
answer, shrink it — do not move it**; frequency of EDIT is not the axis, because a rarely-edited
|
|
778
|
+
fact can still be the one that names the thing.
|
|
779
|
+
|
|
780
|
+
"The chrome already carries identity, so the body need not lead with it" is a real argument and a
|
|
781
|
+
falsifiable one — verify it FACT BY FACT before relying on it. A summary strip showing the record
|
|
782
|
+
number and its totals carries half an identity; the counterparty is missing, and half is what
|
|
783
|
+
makes the claim feel true while the reader still cannot name what they are looking at.
|
|
784
|
+
|
|
715
785
|
**All of the above is the LIST SCREEN.** A CHILD COLLECTION inside a record page — fees, lines,
|
|
716
786
|
stops, containers — does the opposite: the row **EXPANDS** (`TableRow` `detail` + `expanded`),
|
|
717
787
|
never docks a drawer. A drawer's whole job is to supply a context the reader is missing, and on a
|
|
@@ -928,10 +998,13 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
928
998
|
alignment law: the row top-aligns and label, control, trailing each center within the
|
|
929
999
|
first control line — a tall value block never drags the label, and a LABEL too long for its
|
|
930
1000
|
column WRAPS (never clipped, never abbreviated to fit) with its first line still level with
|
|
931
|
-
the value. A
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
the
|
|
1001
|
+
the value. A value that sits OFF the
|
|
1002
|
+
inline-control grid — a raw node, not an `InlineStatic` / `InlineSlot` — sets **`DetailRow
|
|
1003
|
+
flat`**, which aligns its annotations on both axes: they drop the control's text inset so the
|
|
1004
|
+
description starts on the value's own left edge, and tuck up by the band's slack so the gap
|
|
1005
|
+
under the text matches the gap under a chip. Miss it and the row has three left edges and a
|
|
1006
|
+
hole; pass it on a value that IS on the grid and you create both. Which one a static value
|
|
1007
|
+
should be is decided per table — see data_entry.md §The editability affordance.
|
|
935
1008
|
- **A verb about a VALUE goes ON the value — an `InlineButton` in the field's `actions`.** Copy
|
|
936
1009
|
this reference, call this number, open this record: the act names the value, so it travels
|
|
937
1010
|
with what it acts on, and a reader never has to pair a control sitting at the page's right
|
|
@@ -1243,6 +1316,15 @@ which is the loudest possible version of the thing the rule exists to stop. Remo
|
|
|
1243
1316
|
without replacing the separation is what left the header looking adrift — the ink step above is
|
|
1244
1317
|
the replacement, and the two changes only work together.
|
|
1245
1318
|
|
|
1319
|
+
**Annotate every droppable column, or none.** `priority` decides what a narrow container sheds
|
|
1320
|
+
(lowest number is last to go, and column 0 — the identity — never drops). A PARTIAL annotation is
|
|
1321
|
+
the trap: an unannotated column defaults above the whole hand-written range, so one annotated
|
|
1322
|
+
column silently makes every other column drop before it, and one unannotated column among
|
|
1323
|
+
annotated ones is the first to go however important it is. Both read as correct in review. The
|
|
1324
|
+
question to answer per register is "what is the last column standing", and the answer is almost
|
|
1325
|
+
always the figure the register exists to compare — so give that one the lowest number and work
|
|
1326
|
+
outwards.
|
|
1327
|
+
|
|
1246
1328
|
**A row's SUBJECT is `medium`, never semibold.** Semibold is the heading ladder's weight — `#`,
|
|
1247
1329
|
`##`, `###` are all semibold — so a register whose row subjects are semibold renders its content
|
|
1248
1330
|
at heading weight once per row, in the same font CUT as the page title (`Inter_600SemiBold` is a
|
|
@@ -1495,6 +1577,12 @@ disclosure exception). `TextLink` takes the blue only when it has an `href`; as
|
|
|
1495
1577
|
a value, or as a muted in-place disclosure, it stays neutral — because a press that opens a PEEK,
|
|
1496
1578
|
or unfolds a transcript, is not a trip.
|
|
1497
1579
|
|
|
1580
|
+
**Selection is a GROUND, and hover is a ring — at every altitude.** A row, a card, a chip, a
|
|
1581
|
+
tile: the thing you have CHOSEN carries the accent wash, and the thing under the pointer carries
|
|
1582
|
+
the ring. Collapsing them into one "active" style makes the selected item pixel-identical to the
|
|
1583
|
+
hovered one, so "which did I pick" is answerable only by moving the mouse away. The tell in code
|
|
1584
|
+
is a single boolean OR-ing selection together with hover, press and focus.
|
|
1585
|
+
|
|
1498
1586
|
## `Badge` is for STATUS only — everything else is text
|
|
1499
1587
|
|
|
1500
1588
|
A `Badge` means STATE that must read at a GLANCE — a lifecycle status, a risk level, a quality
|
|
@@ -1610,6 +1698,12 @@ Anything pressable is ≥ 40px tall (8px minimum gap between pressables). 16px b
|
|
|
1610
1698
|
canvas padding, 16–20 inside bands, 10–12 between content lines. Density comes from alignment +
|
|
1611
1699
|
hierarchy, not cramming.
|
|
1612
1700
|
|
|
1701
|
+
**A control's height comes from `CONTROL_HEIGHT`, never from padding tuned against a leading.**
|
|
1702
|
+
Padding that happens to sum to the band height is a second owner of that number, and it drifts the
|
|
1703
|
+
moment the type ramp moves — which the ramp is allowed to do. The signature is one control
|
|
1704
|
+
standing a few pixels proud of every sibling in a filter bar, correct on the day it was written
|
|
1705
|
+
and wrong after a release that touched no control at all.
|
|
1706
|
+
|
|
1613
1707
|
**The trim test — does this element RESTATE something already on the screen?** Run it over every
|
|
1614
1708
|
screen before shipping: a per-row proportion bar when the percentage is already on the row; the
|
|
1615
1709
|
percentage itself when the column holds four values and a total; a state line that only says
|
package/docs/data_entry.md
CHANGED
|
@@ -66,6 +66,13 @@ Controlled inputs make derived state track what is on screen, and read shorter t
|
|
|
66
66
|
they replace — four `DetailRow` + `Inline*` pairs whose `onSave` exists only to `setState` collapse
|
|
67
67
|
to four fields. Worked example: `tpl_record`'s New-customer dialog.
|
|
68
68
|
|
|
69
|
+
**The discriminator, since the kit's own `tpl_item_list` fills preview cards with `Inline*`
|
|
70
|
+
editors:** what decides is whether anything on the surface READS the draft before the commit. A
|
|
71
|
+
dialog whose Save is gated on the values being typed, or whose visible totals derive from them,
|
|
72
|
+
takes controlled `FormField`s — that is this rule. A REVIEW surface whose gate is decided by
|
|
73
|
+
something else (which cards are kept, which candidate is picked) may keep inline editors, because
|
|
74
|
+
nothing is arguing with the box under the cursor. Neither shape is a create form's stand-in.
|
|
75
|
+
|
|
69
76
|
**But do not then derive the ERROR per keystroke** — the mistake controlled inputs invite, and the
|
|
70
77
|
opposite failure to the one above. Validity and the error MESSAGE are different questions. Validity
|
|
71
78
|
gates the commit and is checked continuously; the message claims the reader did something wrong, so
|
|
@@ -313,19 +320,66 @@ where it must stay strictly inside its box — that is `framed`. A DISABLED `fra
|
|
|
313
320
|
box: it drops the border and fill so it cannot promise a press, but it stays in line with the enabled
|
|
314
321
|
fields beside it.
|
|
315
322
|
|
|
323
|
+
**`bare` puts its whole affordance in the hover edge, so nothing may outrank that edge.** Pass the
|
|
324
|
+
variant as a PROP; never express it as a resting `borderColor: "transparent"` in `style`, which is
|
|
325
|
+
applied after the hover rule and erases the edge one line after it is computed. The failure is
|
|
326
|
+
silent in every direction that usually catches things — the field keeps its role, its label, its
|
|
327
|
+
focus ring, its metrics and its save — and what breaks is only that nobody presses it, because a
|
|
328
|
+
control drawing nothing at rest and nothing on hover is text. The kit's own `TextInputField` had
|
|
329
|
+
it, so a `bare` inline editor in a register column went un-pressed rather than mis-styled.
|
|
330
|
+
|
|
331
|
+
**The cursor is the other half of that promise, and it belongs to the CONTROL, not to the page.**
|
|
332
|
+
An inline editor takes the pointer like every other control. It once forced the arrow back, on the
|
|
333
|
+
reasoning that an editor is an input rather than a button — right that it is not a button, wrong
|
|
334
|
+
about the replacement: the arrow is what inert prose gets, so the one value on the row that was
|
|
335
|
+
the reader's to change was the one that looked least touchable. The I-beam is not the alternative
|
|
336
|
+
either; a resting editor sets `userSelect: "none"` so a drag edits rather than selects, and an
|
|
337
|
+
I-beam would promise a caret that is not there. A field is disabled is the only case that reverts
|
|
338
|
+
to the arrow, for the same reason it rests bare: an inert value must promise nothing.
|
|
339
|
+
|
|
316
340
|
A READ-ONLY value in the same column — a computed total, a system ID, a synced/locked field — is
|
|
317
341
|
**`InlineStatic`**: it copies the editor box metrics exactly (height, padding, 1px transparent
|
|
318
342
|
border) but stays FLAT and non-interactive, so editable (chip) vs read-only (flat) is legible at a
|
|
319
343
|
glance and the static value never reads as a disabled input.
|
|
320
344
|
|
|
321
|
-
**
|
|
322
|
-
`INLINE_CONTROL_HEIGHT` — worth paying to hold a column straight
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
345
|
+
**In a grid that MIXES the two, or one that ANNOTATES.** "Copies the editor box metrics" means it
|
|
346
|
+
hard-sets `INLINE_CONTROL_HEIGHT` — worth paying to hold a column straight, waste where there is
|
|
347
|
+
no column to hold. Two different things can make the column, and missing the second is what put
|
|
348
|
+
three left edges on a review dialog:
|
|
349
|
+
|
|
350
|
+
- **An editor beside it.** The obvious case: a static value rendered raw beside a real editor
|
|
351
|
+
starts at the cell edge, 20px tall, at the top, while the editor's text starts 9px in and
|
|
352
|
+
centres in a 40px band.
|
|
353
|
+
- **A value column's floor is not a control's floor.** `minValueWidth` decides when the table
|
|
354
|
+
gives up two columns and stacks, and it defaults to `MIN_VALUE_WIDTH` (200) rather than a
|
|
355
|
+
control's `MIN_CONTROL_WIDTH` (160), because the column holds company names and addresses, not
|
|
356
|
+
only editors. The narrow-width failure is silent in both halves: the table technically fits, so
|
|
357
|
+
it does not stack, and every value is then truncated past half while the label beside it measures
|
|
358
|
+
perfectly. Raise it further where a cell holds MORE than one editor; lower it only where the
|
|
359
|
+
values genuinely are short.
|
|
360
|
+
|
|
361
|
+
**A `description` / `warning` / `error` under it.** `FieldAnnotations` insets its stack by
|
|
362
|
+
`CONTROL_TEXT_INSET` so it lines up with a CONTROL's words — so an annotated row whose value is
|
|
363
|
+
raw puts the value at the cell edge and its own description 9px inside it. Nothing else on the
|
|
364
|
+
screen is wrong, the drift is small enough to read as sloppiness rather than structure, and it
|
|
365
|
+
appears only on the rows that happen to carry a description, so a column slides in and out of
|
|
366
|
+
alignment down its own length.
|
|
367
|
+
|
|
368
|
+
Decide it per TABLE, never per row: one annotated static row puts every static value in that table
|
|
369
|
+
on the grid, or the table keeps two row heights for no reason a reader can see.
|
|
370
|
+
|
|
371
|
+
In an all-static, un-annotated grid (a peek's facts, a summary popover) the grid buys nothing and
|
|
372
|
+
costs 40px of band for a 20px value — and the row is the MAX of that box and the table's own band,
|
|
373
|
+
so neither fix works alone: use plain `Text` for the value AND pass `DetailTable` a `minHeight`
|
|
374
|
+
(28, `DetailRow`'s own default). Same test as `labelWidth` — a `DetailTable`'s defaults are the
|
|
327
375
|
inline-control grid's; a grid that isn't one overrides both.
|
|
328
376
|
|
|
377
|
+
`DetailRow flat` is the ESCAPE from the grid, not a synonym for read-only: it says "this value is
|
|
378
|
+
a raw node sitting off the control band", and it corrects the annotations on both axes for it —
|
|
379
|
+
dropping the text inset and tucking the block up by the band's slack. Never pass it with
|
|
380
|
+
`InlineStatic` or `InlineSlot`, which are ON the grid: there it re-introduces the 9px drift it
|
|
381
|
+
exists to remove and pulls the description 4px too tight.
|
|
382
|
+
|
|
329
383
|
### Choosing a CHOICE control — by option count, not by taste
|
|
330
384
|
|
|
331
385
|
A field whose value is one-of-N has three answers, and the wrong one is what makes a record
|
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
|
|
|
@@ -244,6 +269,14 @@ A resting register is the cheapest thing to screenshot and the least likely to h
|
|
|
244
269
|
|
|
245
270
|
- **Open what the row opens** and measure where the actionable field LANDS. An UNBOUNDED section
|
|
246
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".
|
|
247
280
|
- **Press every control** and look at the state you land in. Commonest miss: a value rendering as
|
|
248
281
|
a coloured chip in a register and as bare grey text in its own EDITOR.
|
|
249
282
|
- **TAB through it, then diff what a keyboard reaches against what LOOKS pressable.** A pointer
|
|
@@ -275,6 +308,14 @@ A resting register is the cheapest thing to screenshot and the least likely to h
|
|
|
275
308
|
no option colours). The fix is often config or a write path, not CSS.
|
|
276
309
|
- **The obvious column may be the empty one.** Reading only the field the composer writes ships a
|
|
277
310
|
feature empty on every row and correct in code review.
|
|
311
|
+
- **List every field a write path SETS and check each one has a render on the record's own read
|
|
312
|
+
surface.** A screen with no visual defect can still be hiding real stored data: a workflow or
|
|
313
|
+
form commits a field, the record page never grew a row for it, and nothing about the page
|
|
314
|
+
LOOKS wrong — there is no empty box, no broken layout, just an absence with nothing marking
|
|
315
|
+
it. Pull the field list from the write path (a workflow's `set`, a mutation's payload), not
|
|
316
|
+
from the screen, then confirm each one against the record's own DetailRow/Text tree; where one
|
|
317
|
+
is missing, query the record directly to prove the value is really sitting there unseen before
|
|
318
|
+
calling it a fix. A screenshot cannot catch this — it only shows what already renders.
|
|
278
319
|
- **A fixture can fail in the generous direction too**: an image tile pointed at a dead URL renders
|
|
279
320
|
nothing, so the chip designed to survive a photo was never tested against one.
|
|
280
321
|
- **Grep rendered text for internal identifiers — first probe, every audit** —
|
|
@@ -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.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"./use_selection": "./src/use_selection.ts",
|
|
58
58
|
"./use_section_nav": "./src/use_section_nav.ts",
|
|
59
59
|
"./ledger": "./src/ledger.tsx",
|
|
60
|
+
"./running_ledger": "./src/running_ledger.tsx",
|
|
60
61
|
"./use_selection_mode": "./src/use_selection_mode.ts",
|
|
61
62
|
"./file_thumbnail_grid": "./src/file_thumbnail_grid.tsx",
|
|
62
63
|
"./uploading_thumbnail": "./src/uploading_thumbnail.tsx",
|
|
@@ -208,11 +209,13 @@
|
|
|
208
209
|
"./counter": "./src/counter.tsx",
|
|
209
210
|
"./link": "./src/link.tsx",
|
|
210
211
|
"./reference_field": "./src/reference_field.tsx",
|
|
212
|
+
"./related_record_row": "./src/related_record_row.tsx",
|
|
211
213
|
"./text_disclosure": "./src/text_disclosure.tsx",
|
|
212
214
|
"./text_link": "./src/text_link.tsx",
|
|
213
215
|
"./sort_header": "./src/sort_header.tsx",
|
|
214
216
|
"./skeleton": "./src/skeleton.tsx",
|
|
215
217
|
"./table": "./src/table.tsx",
|
|
218
|
+
"./table_fit": "./src/table_fit.ts",
|
|
216
219
|
"./data_grid": "./src/data_grid.tsx",
|
|
217
220
|
"./detail_row": "./src/detail_row.tsx",
|
|
218
221
|
"./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/checklist.tsx
CHANGED
|
@@ -209,6 +209,15 @@ export interface ChecklistItemProps extends StepPositional {
|
|
|
209
209
|
accessibilityLabel?: string;
|
|
210
210
|
/** Ticked. */
|
|
211
211
|
done?: boolean;
|
|
212
|
+
/**
|
|
213
|
+
* A DECIDED outcome, not a completion tick — a QC pass/fail, a match that
|
|
214
|
+
* held or didn't. Takes over the row's mark from `done`/`current` (a
|
|
215
|
+
* verdict is reached by definition) and renders `fail` in red with an X, so
|
|
216
|
+
* a scan of the run tells "settled" from "settled, and the answer is no" —
|
|
217
|
+
* ticking every row and separately noting a rejection in prose is the
|
|
218
|
+
* failure this exists to replace.
|
|
219
|
+
*/
|
|
220
|
+
verdict?: "pass" | "fail";
|
|
212
221
|
/** The row the reader is waiting on — medium weight, so a run full of ticks
|
|
213
222
|
* still says which one is owed. */
|
|
214
223
|
current?: boolean;
|
|
@@ -241,6 +250,7 @@ export function ChecklistItem({
|
|
|
241
250
|
title,
|
|
242
251
|
accessibilityLabel,
|
|
243
252
|
done,
|
|
253
|
+
verdict,
|
|
244
254
|
current,
|
|
245
255
|
onToggle,
|
|
246
256
|
trailing,
|
|
@@ -249,10 +259,18 @@ export function ChecklistItem({
|
|
|
249
259
|
children,
|
|
250
260
|
...positional
|
|
251
261
|
}: ChecklistItemProps) {
|
|
262
|
+
const settled = done || verdict != null;
|
|
252
263
|
return (
|
|
253
264
|
<Step
|
|
254
|
-
status={done ? "done" : current ? "current" : "upcoming"}
|
|
255
|
-
|
|
265
|
+
status={verdict === "fail" ? "fail" : verdict === "pass" ? "done" : done ? "done" : current ? "current" : "upcoming"}
|
|
266
|
+
// A verdict is DECIDED elsewhere (an inspection, an AI check) and
|
|
267
|
+
// reported here, not toggled — passing `onToggle` through would put a
|
|
268
|
+
// completion checkbox on a row whose mark already means something else,
|
|
269
|
+
// and neither `CheckCircle` nor `CheckboxInput` (the interactive marks)
|
|
270
|
+
// know a `fail` state exists, so it would silently render as "not done"
|
|
271
|
+
// instead. The non-interactive `Marker` does, so this keeps a verdict
|
|
272
|
+
// row on that path.
|
|
273
|
+
onToggle={verdict != null ? undefined : onToggle}
|
|
256
274
|
accessibilityLabel={accessibilityLabel ?? title}
|
|
257
275
|
{...positional}
|
|
258
276
|
>
|
|
@@ -262,7 +280,9 @@ export function ChecklistItem({
|
|
|
262
280
|
WRAPS, so a long title drops its stamp to the next line rather than
|
|
263
281
|
crushing it. */}
|
|
264
282
|
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 10, flexWrap: "wrap" }}>
|
|
265
|
-
|
|
283
|
+
{/* A fail stays in default ink, never muted — muting reads as
|
|
284
|
+
"settled, move on" and a rejection is the opposite of that. */}
|
|
285
|
+
<Text size="sm" weight={current ? "medium" : "regular"} color={verdict === "fail" ? "danger" : settled ? "muted" : "default"}>
|
|
266
286
|
{title}
|
|
267
287
|
</Text>
|
|
268
288
|
{trailing}
|
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,
|