@lotics/ui 44.10.0 → 44.10.2
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 +6 -2
- package/MIGRATION.md +83 -4
- package/docs/composition.md +22 -0
- package/docs/reviewing.md +47 -11
- package/package.json +1 -1
- package/src/file_row.tsx +10 -1
- package/src/ledger.tsx +37 -5
- package/src/matrix.tsx +14 -3
- package/src/option_list.tsx +15 -1
package/AGENTS.md
CHANGED
|
@@ -17,7 +17,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
17
17
|
| [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
|
|
18
18
|
| [docs/data_entry.md](./docs/data_entry.md) | **a picker over a select field keeps the field's colours** (`optionPicker` — the hand-rolled `map` to `{value,label}` drops `color`, so a value reads coloured in a register and grey in the editor); Which editing pattern for which job — inline edit, fieldset forms, browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW — list + one CTA, `multiple` decides add-vs-replace; the `FilesEditor` COMPOUND for a whole section — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, whether a multiline value keeps its fixed reserve or `autoGrow`s (who decides the length — the field, or whoever is typing), the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
|
|
19
19
|
| [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; **after the run** — a stored record that fills up from several writers (a person, a chat agent, an extraction, an automation) and remembers none of them: an unwritten value must not render like a written one, a machine's prose and a person's must not share a treatment, model markdown goes in `variant="embedded"`, and the read path must project every field the write paths set; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md), the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer, **Stop** while streaming), **stopping** (`cancel` stops the run, `abort` only stops listening — so closing a dialog must `cancel` or it keeps billing); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface, its kind derived from before/after rather than a boolean "did this change" — an empty `before` is `added`, never `changed`), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
|
|
20
|
-
| [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS — register, rail, board, timeline, deviation queue — before reaching for parts; a subject flattened into rows is well-typed and says nothing, and reuse-first is about not duplicating primitives, not about a screen's shape), canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other, and a section heading's own row never wraps so a control label that repeats the title can squeeze the title+description into a narrow ribbon at phone width), banded cards, register vs inset rows (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), the button ladder and **what underlined text may mean** (it GOES somewhere or REVEALS something — never mutates; blue leaves the surface, muted stays on it, and the one in-prose disclosure exception is scoped there), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), **the register's rhythm** (no hairline between rows — their own height and the hover wash separate them; the register's one line is the band capping the columns, because that is the break whitespace cannot state. Density is `TableRow`'s `minHeight`, never a mode), **a register that TRIAGES** (`TableGroup` — titled bands ordered by what needs the reader now, grouped by what implies a different ACTION rather than by a category already in a column), **the register's own craft** (a column header treated as CHROME a step lighter than row metadata, sentence case in the table header too, and a supporting line one RUNG BELOW the line it supports rather than a fixed size — plus the half that rule was missing: every SUBJECT on one row starts at the same rung, the subject leads by WEIGHT and its identity mark rather than by size, and whether the supporting line drops a rung is decided by what else separates the pair), **identity marks** (a mark that looks the same on every row carries nothing — `Avatar` derives its hue from the name and takes circle/square for person vs organization; the palette is ordered by contrast, not spectrum, and never paints a person red or amber), **where the accent goes** ("which of these" — a selected row, an active filter; never DATA and never the focus ring, while a page-scale position marker like an active TAB takes `primary`, the ink the kit already spends on "this is on"; and selection must not share a wash with hover), **theming is an app-level ESCAPE HATCH, not a product surface** (`applyLoticsTheme({ … })` once at boot, before `mount`, writing the `--lotics-*` roles on the document root; nothing in the platform writes a theme, and `apps.theme.color` is the launcher icon only — the five `var()` roles plus the three font rungs ARE the whole mechanism, and wrapping a component cannot replace them because RN-Web writes `fontFamily` per element and background/border do not inherit), color discipline, **typography** (the ramp is DATA in `type_ramp`, one table both platforms derive from; leading and tracking are baked per rung and a hand-set `letterSpacing` is always a second copy of the curve), whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
|
|
20
|
+
| [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS — register, rail, board, timeline, deviation queue — before reaching for parts; a subject flattened into rows is well-typed and says nothing, and reuse-first is about not duplicating primitives, not about a screen's shape), canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other, and a section heading's own row never wraps so a control label that repeats the title can squeeze the title+description into a narrow ribbon at phone width), banded cards, register vs inset rows (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), the button ladder and **what underlined text may mean** (it GOES somewhere or REVEALS something — never mutates; blue leaves the surface, muted stays on it, and the one in-prose disclosure exception is scoped there), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), **the register's rhythm** (no hairline between rows — their own height and the hover wash separate them; the register's one line is the band capping the columns, because that is the break whitespace cannot state. Density is `TableRow`'s `minHeight`, never a mode), **a register that TRIAGES** (`TableGroup` — titled bands ordered by what needs the reader now, grouped by what implies a different ACTION rather than by a category already in a column, and what the screen can CLASSIFY kept as a separate list from what it RENDERS, so a value omitted on purpose does not resurface under *Unclassified* and one added later does not vanish), **the register's own craft** (a column header treated as CHROME a step lighter than row metadata, sentence case in the table header too, and a supporting line one RUNG BELOW the line it supports rather than a fixed size — plus the half that rule was missing: every SUBJECT on one row starts at the same rung, the subject leads by WEIGHT and its identity mark rather than by size, and whether the supporting line drops a rung is decided by what else separates the pair; plus the two ways a `columns` array and a rendered row disagree — a partial `priority` annotation, and a conditional column whose cell was not hidden with it), **identity marks** (a mark that looks the same on every row carries nothing — `Avatar` derives its hue from the name and takes circle/square for person vs organization; the palette is ordered by contrast, not spectrum, and never paints a person red or amber), **where the accent goes** ("which of these" — a selected row, an active filter; never DATA and never the focus ring, while a page-scale position marker like an active TAB takes `primary`, the ink the kit already spends on "this is on"; and selection must not share a wash with hover), **theming is an app-level ESCAPE HATCH, not a product surface** (`applyLoticsTheme({ … })` once at boot, before `mount`, writing the `--lotics-*` roles on the document root; nothing in the platform writes a theme, and `apps.theme.color` is the launcher icon only — the five `var()` roles plus the three font rungs ARE the whole mechanism, and wrapping a component cannot replace them because RN-Web writes `fontFamily` per element and background/border do not inherit), color discipline, **typography** (the ramp is DATA in `type_ramp`, one table both platforms derive from; leading and tracking are baked per rung and a hand-set `letterSpacing` is always a second copy of the curve), whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
|
|
21
21
|
| [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built** — the other docs say what good looks like; this one says how to find out whether you achieved it, because "it looks fine" is the same claim as "it should work" made with the same evidence. Measure, never eyeball: render it, extract computed values, let the table show the defect. Two GATES that outrank every treatment question (**SUBTRACT** — what question does this element answer, and count FACTS not elements; **RIGHT CONTROL** — name the species before treating it), then 10 probes each naming what to COLLECT and the SIGNATURE in the numbers: type inventory (range, singletons, the label/value shrink war), ground+border+radius across siblings, what each RULE separates, gap RATIOS, the state diff (paint may change, geometry may not), alignment drift (incl. a centred child that moves while its container measures identical), the surface walk (open it, press it, read the settled string), the DATA probe (what is really in the table — incl. grepping rendered text for internal ids), the ABSENCE pass that catches "bland", and microcopy. Plus the extraction snippets, what measurement cannot see, and how to act on a finding (fix the CLASS, and never resolve one with "the docs say X"). |
|
|
22
22
|
| [docs/testing.md](./docs/testing.md) | Driving the kit in a browser — the three anatomies where the a11y tree says one thing and a driver must do another: a `PressDoor` row whose named button always intercepts pointer events (by design), portalled overlays that render at the top of the DOM, and custom pointer drag that `dragTo` cannot move. |
|
|
23
23
|
| [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget) and the ACTIVITY shape — a communications feed where the row's label is the GIST and the body varies by medium, one anatomy rather than a row type per kind. |
|
|
@@ -51,7 +51,11 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
51
51
|
- **Annotate every droppable table column, or none.** A partial `priority` is the trap: an
|
|
52
52
|
unannotated column sorts above the whole hand-written range, so one annotation makes every
|
|
53
53
|
other column drop first, and one unannotated column among annotated ones goes first however
|
|
54
|
-
important it is.
|
|
54
|
+
important it is. **A conditionally hidden column is the same family**: the `columns` entry and
|
|
55
|
+
the row's `TableCell` are two edits, dropping only the first leaves the body one cell longer
|
|
56
|
+
than the header and shifts every column after it under its neighbour's title — derive both from
|
|
57
|
+
one value so they cannot be edited apart. →
|
|
58
|
+
[composition.md §The register's own craft](./docs/composition.md).
|
|
55
59
|
- **A section's ADD rides its heading row, right edge** — Add files, Add fee, New line: a
|
|
56
60
|
`primary` `Button` beside `SectionHeadingTitle`, rendered empty or full. Under the rows it
|
|
57
61
|
extends, the verb MOVES with the row count and vanishes off-screen on a long list; a heading is
|
package/MIGRATION.md
CHANGED
|
@@ -4,6 +4,79 @@ Breaking changes, newest first — normally per major, plus the rare minor that
|
|
|
4
4
|
anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
|
|
5
5
|
this file exists only to move an app from one release to the next.
|
|
6
6
|
|
|
7
|
+
**`FileRow`'s `leading` slot is a SIBLING of the press door, not inside it.** No API change;
|
|
8
|
+
a row with `onPress` now has a slightly smaller press target, excluding the leading mark.
|
|
9
|
+
|
|
10
|
+
`trailing`'s own docstring already stated the rule — *"an independently-interactive SIBLING:
|
|
11
|
+
never swallowed by the row press (a button never nests in the door button)"* — while `leading`
|
|
12
|
+
recommended *"a selection checkbox"* and rendered inside the `Pressable`. So the documented
|
|
13
|
+
usage produced an interactive control nested in a `button`: invalid ARIA, and one press with two
|
|
14
|
+
owners, since ticking the box also opened the file. A slot the docs tell you to put a control in
|
|
15
|
+
cannot be inside the control.
|
|
16
|
+
|
|
17
|
+
## 44.9.3 — an option's description stops being erased, and a pivot's headers rejoin the ramp
|
|
18
|
+
|
|
19
|
+
**`OptionList` no longer suppresses `getOptionDescription` when `renderOptionContent` is set.**
|
|
20
|
+
The two are independent facts about an option — how its value renders, and a subtitle under it —
|
|
21
|
+
and one was erasing the other. A caller who wanted both had one slot for two things, so they put
|
|
22
|
+
the subtitle inside the custom render; `Select` and `InlineSelect` then reuse that render as the
|
|
23
|
+
RESTING trigger display, which turned a gloss meant for the open menu into a two-line control on
|
|
24
|
+
every read (measured at 55px against 40px for every other editor in the same table). Pass no
|
|
25
|
+
`getOptionDescription` if you want no subtitle. The documented `renderSelected` →
|
|
26
|
+
`renderOptionContent` → `label` fallback is unchanged; it was never the defect.
|
|
27
|
+
|
|
28
|
+
**`Matrix`'s column headers are `sm` / `muted` / `medium`, matching `SortHeader`.** They were
|
|
29
|
+
`xs` / regular — which composition.md already names as the failure: *"a column header is a LABEL
|
|
30
|
+
at the body size… dropped to `xs` and lightened past `muted` it stops being legible and starts
|
|
31
|
+
reading as a caption that lost its value."* A screen carrying both a `Table` and a `Matrix`
|
|
32
|
+
rendered the same structural role two ways.
|
|
33
|
+
|
|
34
|
+
## 44.9.3 — the rules probe could not see a rule
|
|
35
|
+
|
|
36
|
+
**`docs/reviewing.md`'s probe 3 collector missed border-drawn rules and counted transparent
|
|
37
|
+
spacers.** Docs only, no code change — but this is the method app authors run, so it shipped a
|
|
38
|
+
blind spot: it selected on "thin and wide" with no paint check, which counts a zero-height flex
|
|
39
|
+
spacer as a rule and misses a band drawn as a `borderBottom` on a full-height header row. A
|
|
40
|
+
surface with a visible rule under its column headers therefore reported **zero**, and zero reads
|
|
41
|
+
as a clean result rather than a blind probe.
|
|
42
|
+
|
|
43
|
+
The snippet now requires actual paint, accepts a single-sided border on a box of any height,
|
|
44
|
+
excludes four-sided boxes (a control's own edge is not a rule), reads the line's y from the border
|
|
45
|
+
edge rather than the box origin, and reports what each rule SEPARATES — which is what probe 3
|
|
46
|
+
actually asks.
|
|
47
|
+
|
|
48
|
+
## 44.9.2 — a pivot's figures actually align, and a ledger row keeps its own name
|
|
49
|
+
|
|
50
|
+
**`Matrix.Grid`'s cell stretches its figure instead of centring it, so 44.9.1's right-alignment
|
|
51
|
+
actually applies.** A `Text` centred by its parent shrink-wraps, so the `align="right"` added in
|
|
52
|
+
44.9.1 had nothing to align against and the value cells never moved — while the headers and totals,
|
|
53
|
+
which are not centred, did. Measured on a real pivot as shipped in 44.9.1, figures in one column
|
|
54
|
+
ended across a **47px** spread; with the cell stretched, **0**. `Matrix.Header`'s column cell also
|
|
55
|
+
gains a 2px right padding, matching the inset the cell's own border imposes, so a header and the
|
|
56
|
+
numbers beneath it end on one x rather than two pixels apart.
|
|
57
|
+
|
|
58
|
+
**`Ledger` measures itself and `LedgerRow` stops rendering `meta` in a row too tight for it.**
|
|
59
|
+
No API change; a narrow ledger simply shows fewer captions.
|
|
60
|
+
|
|
61
|
+
44.9.1 made the caption yield to the label first, which was the right ORDER and not a floor:
|
|
62
|
+
shrinking indefinitely still starves the identity. Measured at 375, a 30-character caption
|
|
63
|
+
wanted 184px of a ~250px text budget and clipped anyway, ellipsising a 74px label at 71px. The
|
|
64
|
+
row now drops the caption entirely below the width where a label, a caption and a figure can
|
|
65
|
+
all be seated (`FLEX_MIN_WIDTH` for the identity, ~100 for the money column, ~80 before a
|
|
66
|
+
caption stops being worth rendering).
|
|
67
|
+
|
|
68
|
+
Measured across the three states in a 180px row, the label kept 31px, then 55px, then 74px of
|
|
69
|
+
the 77 it needs.
|
|
70
|
+
|
|
71
|
+
`Ledger` measures its own box via `onLayout`, the way `Table` / `DetailTable` / `Breakdown`
|
|
72
|
+
already do — not the window, and once for the whole ledger rather than per row. A ledger in a
|
|
73
|
+
narrow column on a wide screen is exactly the case a window-sized answer gets wrong. Two
|
|
74
|
+
separate app authors had already worked around this by dropping `meta` at small widths in their
|
|
75
|
+
own code; that is the component's job.
|
|
76
|
+
|
|
77
|
+
`LedgerBasis` is unaffected — it stacks its caption UNDER the label, so the two never compete
|
|
78
|
+
for one line.
|
|
79
|
+
|
|
7
80
|
## 44.9.1 — a pivot's figures align, and a ledger row stops losing its own name
|
|
8
81
|
|
|
9
82
|
No API is removed and nothing fails to compile. Three renders change.
|
|
@@ -11,10 +84,16 @@ No API is removed and nothing fails to compile. Three renders change.
|
|
|
11
84
|
**`Matrix` right-aligns its figures instead of centring them.** Every value, column
|
|
12
85
|
header and total was `align="center"` while the cells also set `tabular` — and tabular
|
|
13
86
|
figures exist so digits line up in a column, which centring throws away. Measured on a
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
centred
|
|
87
|
+
**This was only half the fix — see 44.9.2.** `Matrix.Grid`'s cell sets `alignItems: "center"`,
|
|
88
|
+
which makes the figure inside SHRINK-WRAP, and `align="right"` on a box exactly as wide as its
|
|
89
|
+
own glyphs does nothing. So this release right-aligned the headers and totals (which carry no
|
|
90
|
+
such centring) and left the value cells centred — worse than either end state, since a figure
|
|
91
|
+
could then sit up to 45px left of its own column header's right edge. The measurement quoted
|
|
92
|
+
below counted distinct right edges across ALL figures rather than the spread WITHIN a column,
|
|
93
|
+
which is why it read as an improvement. Measured on a real pivot: centred, 24 figures painted
|
|
94
|
+
across 14 distinct right edges; right-aligned, 12. No `align` prop came with this: a matrix is
|
|
95
|
+
one measure across two dimensions, so its cells cannot want different alignments, and a prop for
|
|
96
|
+
a centred case nobody has is surface without a consumer.
|
|
18
97
|
|
|
19
98
|
**`Matrix` row labels carry default ink, not `muted`.** A pivot whose rows are entities
|
|
20
99
|
rendered every entity name a step quieter than the numbers about it, inverting how the
|
package/docs/composition.md
CHANGED
|
@@ -1284,6 +1284,18 @@ column is a sort wearing chrome: it spends a band per value to repeat the cell b
|
|
|
1284
1284
|
- **Watch the ratio of headings to rows.** Four bands over ten rows is a lot of chrome for a
|
|
1285
1285
|
little content, and the shape only pays off as the register grows. A register that will never
|
|
1286
1286
|
hold more than a screenful is usually better flat.
|
|
1287
|
+
- **What the screen KNOWS and what it SHOWS are two lists, never one.** A register that bands on
|
|
1288
|
+
an enumerated field usually also wants to omit some of that field's values — the settled ones,
|
|
1289
|
+
the archived ones — and the tempting shortcut is to let the band list double as the vocabulary,
|
|
1290
|
+
treating "matches no band" as "unrecognised". It breaks in both directions at once. The values
|
|
1291
|
+
you deliberately left out now match nothing, so they resurface under whatever heading catches
|
|
1292
|
+
the remainder — *Unclassified*, *Other* — which is louder than the bands they were removed from
|
|
1293
|
+
and states something false about them. Fix that by treating "known" as "shown" instead, and a
|
|
1294
|
+
value added to the field later silently disappears from the one screen that would have shown it.
|
|
1295
|
+
Keep a set of values the screen can classify and a list of bands it renders: a value in the
|
|
1296
|
+
first but not the second is omitted on purpose, and a value in neither gets a visible band,
|
|
1297
|
+
because a register that quietly files what it does not understand is how a stale row stays
|
|
1298
|
+
invisible. Both lists are ALLOWLISTS — `else` is where the second one goes wrong.
|
|
1287
1299
|
|
|
1288
1300
|
Banding also changes what the COLUMNS have to carry, and the check is worth running afterwards: a
|
|
1289
1301
|
column that exists to signal presence ("is there a next step?") is now stated by the band, so it
|
|
@@ -1325,6 +1337,16 @@ question to answer per register is "what is the last column standing", and the a
|
|
|
1325
1337
|
always the figure the register exists to compare — so give that one the lowest number and work
|
|
1326
1338
|
outwards.
|
|
1327
1339
|
|
|
1340
|
+
**A conditional column and its cell are ONE decision.** A register may hide a column whose data
|
|
1341
|
+
no row carries yet — that is data-driven shape, and it is allowed. What breaks is that the
|
|
1342
|
+
`columns` entry and the row's `TableCell` are two edits in two files, so it is natural to make
|
|
1343
|
+
only the first: the header band loses a heading, the row still renders the cell, and from there
|
|
1344
|
+
every column right of the gap sits under its neighbour's title. Nothing is null, nothing throws,
|
|
1345
|
+
and both files read as correct on their own — the defect exists only in the pair. Derive both from
|
|
1346
|
+
one value (`const showX = …`, used for the `columns` filter AND passed to the row) so they cannot
|
|
1347
|
+
be edited apart. The signature when reviewing a render: the header has one fewer cell than the
|
|
1348
|
+
body, and the columns after the hidden one are shifted by exactly one slot.
|
|
1349
|
+
|
|
1328
1350
|
**A row's SUBJECT is `medium`, never semibold.** Semibold is the heading ladder's weight — `#`,
|
|
1329
1351
|
`##`, `###` are all semibold — so a register whose row subjects are semibold renders its content
|
|
1330
1352
|
at heading weight once per row, in the same font CUT as the page title (`Inter_600SemiBold` is a
|
package/docs/reviewing.md
CHANGED
|
@@ -187,7 +187,9 @@ rule behind it lives in the area doc named beside it; this file never restates o
|
|
|
187
187
|
→ [composition.md](./composition.md) §"Color discipline", §"Buttons & action labels", §"Every number is a door", §"Hover grammar".
|
|
188
188
|
|
|
189
189
|
### 3. Rules — ask what each one SEPARATES
|
|
190
|
-
**Collect** every border/divider with weight, colour and width
|
|
190
|
+
**Collect** every border/divider with weight, colour and width — including the ones drawn as a
|
|
191
|
+
border on a box of ordinary height, which is how a column band is usually painted. A collector that
|
|
192
|
+
matches only thin boxes reports zero rules on a surface that has one, and zero reads as clean.
|
|
191
193
|
|
|
192
194
|
The test is not how big the break is. A rule between two rows divides **like from like** —
|
|
193
195
|
whitespace says that just as well, which is why a register can drop every internal rule and read
|
|
@@ -441,25 +443,59 @@ that has none, or a clean tally on a screen with an untracked component still on
|
|
|
441
443
|
|
|
442
444
|
### Separator beats — is the biggest boundary the widest?
|
|
443
445
|
|
|
446
|
+
**A rule is a painted LINE, and it is drawn two ways.** Selecting "thin and wide" alone gets both
|
|
447
|
+
halves wrong, and both wrongs are silent: a transparent zero-height flex spacer matches it and is
|
|
448
|
+
counted, while a border on a box of ordinary height does not and is missed. The second is the one
|
|
449
|
+
that bites — a component that draws its column band as a `borderBottom` on a full-height header row
|
|
450
|
+
then reports **zero rules on a surface that visibly has one**, and a count of `0` reads as a clean
|
|
451
|
+
result rather than a blind probe.
|
|
452
|
+
|
|
444
453
|
```js
|
|
445
454
|
() => {
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
455
|
+
const CLEAR = v => !v || v === 'rgba(0, 0, 0, 0)' || v === 'transparent';
|
|
456
|
+
const lineOf = e => {
|
|
457
|
+
const r = e.getBoundingClientRect(), cs = getComputedStyle(e);
|
|
458
|
+
if (!(r.width > 200 && e.checkVisibility?.())) return null;
|
|
459
|
+
if (r.height <= 1.5) { // a hairline BOX
|
|
460
|
+
if (!CLEAR(cs.backgroundColor)) return { y: r.y, paint: cs.backgroundColor };
|
|
461
|
+
for (const s of ['Top', 'Bottom']) // …or a hairline drawn as a border
|
|
462
|
+
if (parseFloat(cs[`border${s}Width`]) > 0 && !CLEAR(cs[`border${s}Color`]))
|
|
463
|
+
return { y: r.y, paint: cs[`border${s}Color`] };
|
|
464
|
+
return null; // painted nothing: a spacer, not a rule
|
|
465
|
+
}
|
|
466
|
+
const sides = ['Top', 'Bottom'].filter(s =>
|
|
467
|
+
parseFloat(cs[`border${s}Width`]) > 0 && !CLEAR(cs[`border${s}Color`]));
|
|
468
|
+
const boxed = ['Left', 'Right'].every(s =>
|
|
469
|
+
parseFloat(cs[`border${s}Width`]) > 0 && !CLEAR(cs[`border${s}Color`])) && sides.length === 2;
|
|
470
|
+
if (boxed || sides.length !== 1) return null; // a control's own edge is not a rule
|
|
471
|
+
// The LINE's y is the border's edge, never the box's origin.
|
|
472
|
+
return { y: sides[0] === 'Top' ? r.y : r.bottom, paint: cs[`border${sides[0]}Color`] };
|
|
473
|
+
};
|
|
474
|
+
const rules = [...document.querySelectorAll('div,hr')]
|
|
475
|
+
.map(lineOf).filter(Boolean)
|
|
476
|
+
.map(l => ({ y: Math.round(l.y), paint: l.paint }))
|
|
477
|
+
.sort((a, b) => a.y - b.y);
|
|
449
478
|
const blocks = [...document.querySelectorAll('*')]
|
|
450
479
|
.filter(e => e.children.length === 0 && e.textContent.trim())
|
|
451
|
-
.map(e => { const r = e.getBoundingClientRect(); return { y: Math.round(r.y), b: Math.round(r.bottom) }; });
|
|
452
|
-
return rules.map(y => ({
|
|
453
|
-
rule: y,
|
|
454
|
-
above: y - Math.max(...blocks.filter(o => o.b <= y).map(o => o.b), -Infinity),
|
|
455
|
-
below: Math.min(...blocks.filter(o => o.y >= y).map(o => o.y), Infinity) - y,
|
|
480
|
+
.map(e => { const r = e.getBoundingClientRect(); return { y: Math.round(r.y), b: Math.round(r.bottom), t: e.textContent.trim().slice(0, 24) }; });
|
|
481
|
+
return rules.map(({ y, paint }) => ({
|
|
482
|
+
rule: y, paint,
|
|
483
|
+
above: y - Math.max(...blocks.filter(o => o.b <= y + 1).map(o => o.b), -Infinity),
|
|
484
|
+
below: Math.min(...blocks.filter(o => o.y >= y - 1).map(o => o.y), Infinity) - y,
|
|
485
|
+
separates: [blocks.filter(o => o.b <= y + 1).sort((a, c) => c.b - a.b)[0]?.t,
|
|
486
|
+
blocks.filter(o => o.y >= y - 1).sort((a, c) => a.y - c.y)[0]?.t].join(' | '),
|
|
456
487
|
}));
|
|
457
488
|
}
|
|
458
489
|
```
|
|
459
490
|
|
|
491
|
+
Read `separates` first — probe 3 asks what each rule DIVIDES, and a rule whose two neighbours are
|
|
492
|
+
the same kind of thing is the one to delete however important the break feels.
|
|
493
|
+
|
|
460
494
|
Unequal `above`/`below` on one rule, or a rule with a visibly tighter beat than its neighbours, is
|
|
461
|
-
the finding. A hand-rolled `Divider` sitting in a parent's uniform `gap` next to a
|
|
462
|
-
is the usual cause.
|
|
495
|
+
the other finding. A hand-rolled `Divider` sitting in a parent's uniform `gap` next to a
|
|
496
|
+
`SectionStack` is the usual cause. Asymmetry is not automatically wrong: a rule that BELONGS to
|
|
497
|
+
the element above it sits tight under it and loose before the next, and that gap is what says
|
|
498
|
+
which side owns it — the finding is asymmetry nobody chose.
|
|
463
499
|
|
|
464
500
|
### Clipping, overflow, collision
|
|
465
501
|
|
package/package.json
CHANGED
package/src/file_row.tsx
CHANGED
|
@@ -40,6 +40,14 @@ export interface FileRowProps {
|
|
|
40
40
|
* Leading slot, BEFORE the badge — a `DiffMark` saying what happened to this
|
|
41
41
|
* document, a selection checkbox, a status dot.
|
|
42
42
|
*
|
|
43
|
+
* A SIBLING of the row's press door, like `trailing` — not inside it. It used
|
|
44
|
+
* to render within the `Pressable`, which made the documented checkbox an
|
|
45
|
+
* interactive control nested in a `button`: invalid ARIA, and two owners for
|
|
46
|
+
* one press, since ticking the box also opened the file. The cost of the fix
|
|
47
|
+
* is that the mark itself is no longer part of the door; that is the correct
|
|
48
|
+
* trade, because a slot the docs tell you to put a control in cannot be
|
|
49
|
+
* inside the control.
|
|
50
|
+
*
|
|
43
51
|
* It sits outside the badge rather than on it because it must line up DOWN
|
|
44
52
|
* the list: a mark drawn over a 30px badge moves with the badge's size, and
|
|
45
53
|
* `sm` and `md` rows would stop agreeing. Give every row the same slot,
|
|
@@ -91,7 +99,6 @@ export function FileRow({
|
|
|
91
99
|
|
|
92
100
|
const content = (
|
|
93
101
|
<>
|
|
94
|
-
{leading}
|
|
95
102
|
<FileBadge size={md ? 38 : 30} mimeType={mimeType} placeholder={placeholder} isTemplate={isTemplate} />
|
|
96
103
|
<View style={styles.text}>
|
|
97
104
|
<Text size="sm" weight="medium" numberOfLines={1}>
|
|
@@ -114,6 +121,7 @@ export function FileRow({
|
|
|
114
121
|
if (!onPress) {
|
|
115
122
|
return (
|
|
116
123
|
<View style={[styles.row, md && styles.rowMd]}>
|
|
124
|
+
{leading}
|
|
117
125
|
{content}
|
|
118
126
|
{trailing}
|
|
119
127
|
</View>
|
|
@@ -130,6 +138,7 @@ export function FileRow({
|
|
|
130
138
|
]}
|
|
131
139
|
{...mouseProps}
|
|
132
140
|
>
|
|
141
|
+
{leading}
|
|
133
142
|
<Pressable
|
|
134
143
|
onPress={onPress}
|
|
135
144
|
{...focusProps}
|
package/src/ledger.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createContext, useContext, type ReactNode } from "react";
|
|
1
|
+
import { createContext, useContext, useState, type ReactNode } from "react";
|
|
2
2
|
import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
4
|
import { Divider } from "./divider";
|
|
@@ -31,8 +31,17 @@ import { Text } from "./text";
|
|
|
31
31
|
// <LedgerTotal label="Outstanding" value={due} tone="danger" zeroLabel="Paid in full" />
|
|
32
32
|
// </Ledger>
|
|
33
33
|
|
|
34
|
+
/** Below this the row cannot seat a label, a caption and a figure at once.
|
|
35
|
+
* Derived, not chosen: the identity needs a flex column's usable floor
|
|
36
|
+
* (`FLEX_MIN_WIDTH`, 120), a money column runs ~100, and a caption is not worth
|
|
37
|
+
* rendering under ~80 — plus the row's own gaps. Under that the caption is
|
|
38
|
+
* taking room from the one string the reader needs. */
|
|
39
|
+
const META_MIN_ROW_WIDTH = 320;
|
|
40
|
+
|
|
34
41
|
interface LedgerContextValue {
|
|
35
42
|
format: (n: number) => string;
|
|
43
|
+
/** The row is too tight to carry a caption as well — see `META_MIN_ROW_WIDTH`. */
|
|
44
|
+
dropMeta: boolean;
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
const LedgerContext = createContext<LedgerContextValue | null>(null);
|
|
@@ -61,9 +70,25 @@ export interface LedgerProps {
|
|
|
61
70
|
|
|
62
71
|
export function Ledger(props: LedgerProps) {
|
|
63
72
|
const { formatValue, children, style } = props;
|
|
73
|
+
// MEASURES ITSELF, like `Table`/`DetailTable`/`Breakdown` — not the window,
|
|
74
|
+
// and not per row. A ledger in a narrow column on a wide screen is the case a
|
|
75
|
+
// window-sized answer gets wrong, and one `onLayout` on the container serves
|
|
76
|
+
// every row in it.
|
|
77
|
+
//
|
|
78
|
+
// 0 until first layout, which reads as "not yet too narrow" — a caption that
|
|
79
|
+
// appears on the second frame is a far smaller wrong than one that flashes
|
|
80
|
+
// away, and a ledger wide enough to matter answers on that frame anyway.
|
|
81
|
+
const [width, setWidth] = useState(0);
|
|
64
82
|
return (
|
|
65
|
-
<LedgerContext.Provider
|
|
66
|
-
|
|
83
|
+
<LedgerContext.Provider
|
|
84
|
+
value={{ format: formatValue, dropMeta: width > 0 && width < META_MIN_ROW_WIDTH }}
|
|
85
|
+
>
|
|
86
|
+
<View
|
|
87
|
+
style={[styles.ledger, style]}
|
|
88
|
+
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
|
|
89
|
+
>
|
|
90
|
+
{children}
|
|
91
|
+
</View>
|
|
67
92
|
</LedgerContext.Provider>
|
|
68
93
|
);
|
|
69
94
|
}
|
|
@@ -230,13 +255,20 @@ export interface LedgerRowProps {
|
|
|
230
255
|
export function LedgerRow(props: LedgerRowProps) {
|
|
231
256
|
const { label, meta, value, tone = "default", peek, peekWidth = 300, reference, accessibilityLabel } = props;
|
|
232
257
|
const rowDetails = useLoticsLocale().ledger.rowDetails;
|
|
233
|
-
const { format } = useLedger();
|
|
258
|
+
const { format, dropMeta } = useLedger();
|
|
259
|
+
// The caption goes entirely rather than shrinking to nothing. Yielding first
|
|
260
|
+
// (below) is the right ORDER but not a floor: measured at 375, a 30-character
|
|
261
|
+
// caption still wanted 184px of a ~250px text budget and clipped — and took
|
|
262
|
+
// the label down with it, ellipsising a 74px identity at 71px. Two separate
|
|
263
|
+
// authors had already worked around this by dropping `meta` at small widths
|
|
264
|
+
// in their own apps, which is the component's job.
|
|
265
|
+
const showMeta = meta != null && meta !== "" && !dropMeta;
|
|
234
266
|
const content = (
|
|
235
267
|
<>
|
|
236
268
|
<Text size="sm" numberOfLines={1} style={styles.shrink}>
|
|
237
269
|
{label}
|
|
238
270
|
</Text>
|
|
239
|
-
{
|
|
271
|
+
{showMeta ? (
|
|
240
272
|
<Text size="xs" color="muted" numberOfLines={1} style={styles.yield}>
|
|
241
273
|
{meta}
|
|
242
274
|
</Text>
|
package/src/matrix.tsx
CHANGED
|
@@ -143,12 +143,12 @@ function MatrixHeader({ corner, totalLabel }: MatrixHeaderProps) {
|
|
|
143
143
|
)}
|
|
144
144
|
</View>
|
|
145
145
|
{cols.map((c) => (
|
|
146
|
-
<Text key={c.key} size="
|
|
146
|
+
<Text key={c.key} size="sm" color="muted" weight="medium" align="right" numberOfLines={1} style={styles.colCell}>
|
|
147
147
|
{c.label}
|
|
148
148
|
</Text>
|
|
149
149
|
))}
|
|
150
150
|
{hasTotals ? (
|
|
151
|
-
<Text size="
|
|
151
|
+
<Text size="sm" color="muted" weight="medium" align="right" numberOfLines={1} style={[styles.totalCol, { width: totalColWidth }]}>
|
|
152
152
|
{resolvedTotalLabel}
|
|
153
153
|
</Text>
|
|
154
154
|
) : null}
|
|
@@ -325,12 +325,23 @@ const styles = StyleSheet.create({
|
|
|
325
325
|
height: 36,
|
|
326
326
|
borderRadius: 4,
|
|
327
327
|
borderWidth: 2,
|
|
328
|
-
|
|
328
|
+
// STRETCH, not center. The figure inside sets `align="right"`, and a Text
|
|
329
|
+
// centred by its parent SHRINK-WRAPS — so it is exactly as wide as its own
|
|
330
|
+
// glyphs and has nothing to align against. Right-aligning the text while
|
|
331
|
+
// this said `center` therefore changed the headers and totals (which carry
|
|
332
|
+
// no such centring) and left the cells where they were, so a figure sat up
|
|
333
|
+
// to 45px left of its own column header's right edge. `justifyContent`
|
|
334
|
+
// still centres vertically: in a column-direction box that is the main
|
|
335
|
+
// axis, and only `alignItems` decides the horizontal.
|
|
336
|
+
alignItems: "stretch",
|
|
329
337
|
justifyContent: "center",
|
|
330
338
|
},
|
|
331
339
|
colCell: {
|
|
332
340
|
flex: 1,
|
|
333
341
|
minWidth: 52,
|
|
342
|
+
// Matches the 2px the cell's own border insets its figure by, so a header
|
|
343
|
+
// and the numbers under it end on ONE x rather than two pixels apart.
|
|
344
|
+
paddingRight: 2,
|
|
334
345
|
},
|
|
335
346
|
totalCol: {
|
|
336
347
|
paddingLeft: 4,
|
package/src/option_list.tsx
CHANGED
|
@@ -157,7 +157,21 @@ export function OptionList<T extends string, MULTI extends boolean = false, D =
|
|
|
157
157
|
// description — is `MenuListItem`: the listbox row that GROWS. `MenuButton` is
|
|
158
158
|
// the fixed-height single-line menu row, and stays that for plain options.
|
|
159
159
|
return content || desc ? (
|
|
160
|
-
<MenuListItem
|
|
160
|
+
<MenuListItem
|
|
161
|
+
key={`${row.kind}-${opt.value}`}
|
|
162
|
+
{...shared}
|
|
163
|
+
title={content ?? label}
|
|
164
|
+
// A custom render and a description are INDEPENDENT facts about
|
|
165
|
+
// an option — how its value looks, and a subtitle under it — so
|
|
166
|
+
// one does not erase the other. Suppressing `desc` whenever
|
|
167
|
+
// `renderOptionContent` was set left a caller who wanted both
|
|
168
|
+
// with one slot for two things: they crammed the subtitle into
|
|
169
|
+
// the custom render, which `Select`/`InlineSelect` then reuse
|
|
170
|
+
// as the RESTING trigger display, so a gloss meant for the menu
|
|
171
|
+
// became a two-line control on every read. A caller who wants no
|
|
172
|
+
// subtitle passes no `getOptionDescription`.
|
|
173
|
+
description={desc}
|
|
174
|
+
/>
|
|
161
175
|
) : (
|
|
162
176
|
<MenuButton
|
|
163
177
|
key={`${row.kind}-${opt.value}`}
|