@lotics/ui 46.9.0 → 46.12.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 +2 -2
- package/docs/composition.md +43 -0
- package/docs/data_entry.md +1 -1
- package/docs/reviewing.md +88 -1
- package/package.json +1 -1
- package/src/summary_line.tsx +9 -0
- package/src/table_fit.ts +36 -9
package/AGENTS.md
CHANGED
|
@@ -17,8 +17,8 @@ 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) | Which editing pattern for which job — inline edit, fieldset forms, choosing a CHOICE control by option count, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW, the `FilesEditor` COMPOUND for a whole section, plus the three-way file INTAKE), stage gates, and the commit-on-blur vs action-press ordering law. |
|
|
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; findings, provenance, confidence; **after the run** — a stored record that fills up from several writers; the whole run in a dialog, **stopping**; **review surfaces compose from atoms** — `DiffValue`, `DiffMark`, `useChangeSet`. |
|
|
20
|
-
| [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS before reaching for parts), canvas + content column, heading altitude, register vs inset rows, the button ladder, master-detail `Drawer`, the register's rhythm, craft and TRIAGE bands, where the accent goes, theming as an app-level ESCAPE HATCH, color discipline, typography, **one fact one surface** (a chooser over the thing it chooses is a SELECTOR, not a second card grid; a control that only swaps one number for another is replaced by showing both), and where a commit sits. |
|
|
21
|
-
| [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built — and the answer when one "looks bad"** — the other docs say what good looks like; this one says how to find out whether you achieved it. Run it BEFORE the deploy: a typecheck, a lint, a test and `app check` are all blind to how a screen reads. GATES that outrank every treatment question, then
|
|
20
|
+
| [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS before reaching for parts), canvas + content column, heading altitude, register vs inset rows, the button ladder, master-detail `Drawer`, the register's rhythm, craft and TRIAGE bands, where the accent goes, theming as an app-level ESCAPE HATCH, color discipline, typography, **one fact one surface** (a chooser over the thing it chooses is a SELECTOR, not a second card grid; a control that only swaps one number for another is replaced by showing both), **a width floor answers can this be READ and never can this be operated** (the operating question passes a row whose every string is already cut), and where a commit sits. |
|
|
21
|
+
| [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built — and the answer when one "looks bad"** — the other docs say what good looks like; this one says how to find out whether you achieved it. Run it BEFORE the deploy: a typecheck, a lint, a test and `app check` are all blind to how a screen reads. GATES that outrank every treatment question, then the probes, each naming what to COLLECT and the SIGNATURE in the numbers — including the truncation sweep, the only one that catches a row whose halves both measure fine and whose text is cut. Plus the extraction snippets, what measurement cannot see, and how to act on a finding. |
|
|
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. |
|
|
24
24
|
|
package/docs/composition.md
CHANGED
|
@@ -992,6 +992,31 @@ by what KIND of thing they are.
|
|
|
992
992
|
list. A tab earns a `status` dot (the optional `TabOption.status`, a `ColorName`) only when its
|
|
993
993
|
area needs attention (a blocker / missing item) — the resting state has none.
|
|
994
994
|
**`SegmentedControl`** chooses a MODE/PARAMETER of the SAME view (2–4 peers, no panel swap).
|
|
995
|
+
- **ONE tab strip per surface. A `Tabs` never opens onto another `Tabs`.** The second strip is not
|
|
996
|
+
navigation any more — it is a filter wearing navigation's clothes, and it charges the reader a
|
|
997
|
+
second click plus a second thing to keep in their head to reach a list that was always a sibling
|
|
998
|
+
of the first. Below a tab strip the switch is a `ChipGroup` (a lens on what is already on
|
|
999
|
+
screen) or a `SegmentedControl` (a mode of it); if neither fits because the two panels really
|
|
1000
|
+
are different content, that tab was **two destinations wearing one name** and the fix is to
|
|
1001
|
+
promote both into the strip above.
|
|
1002
|
+
**The label confesses it before the layout does: a tab named "X and Y" is two tabs.** Reach for
|
|
1003
|
+
the domain's own list of things — the entities its people already name one at a time — and give
|
|
1004
|
+
each one a tab; a pair invented to keep the strip short buys nothing, because the pair has to be
|
|
1005
|
+
taken apart again one level down. A flat strip of six beats five that hide two.
|
|
1006
|
+
- **A tab is a DESTINATION, and it has to earn one. Measure what it holds for a SCOPED reader,
|
|
1007
|
+
never for yourself.** The account building the thing usually sees everything; the person using it
|
|
1008
|
+
sees one team's, one site's, one region's slice. A tab that looks like a list while you develop
|
|
1009
|
+
can be a single row in the field — and a top-level slot spent on one row is a slot the reader
|
|
1010
|
+
pays for on every visit. Open the app as someone whose scope actually bites before deciding the
|
|
1011
|
+
strip is right.
|
|
1012
|
+
- **The second question is who OWNS what the tab edits.** A screen belongs in the app where its
|
|
1013
|
+
owner works, not wherever it was convenient to build. When a tab lets one role edit another
|
|
1014
|
+
role's master data — the org's catalogue reachable from a site-level tool — the fix is not a
|
|
1015
|
+
permission check bolted on top; it is that the screen is in the wrong app. Move it to the one its
|
|
1016
|
+
owner already opens, and put it BESIDE the thing it is about rather than in a tab of its own: a
|
|
1017
|
+
record and the people attached to it are one subject and belong on one screen. Moving it to a new
|
|
1018
|
+
tab in the new app just relocates the mistake.
|
|
1019
|
+
|
|
995
1020
|
- **Layout**: search + secondary filters LEFT, the primary CTA RIGHT, in ONE band. A `ChipGroup`
|
|
996
1021
|
one-of-N lens is fine ONLY when the band has no `SearchInput`; once a search is present, the
|
|
997
1022
|
status filter becomes a `Select`/`FilterChip` dropdown — never a row of pills competing with the
|
|
@@ -1466,6 +1491,24 @@ question to answer per register is "what is the last column standing", and the a
|
|
|
1466
1491
|
always the figure the register exists to compare — so give that one the lowest number and work
|
|
1467
1492
|
outwards.
|
|
1468
1493
|
|
|
1494
|
+
**A width floor answers "can this be READ", never "can this be operated" — and the second
|
|
1495
|
+
question passes screens the first one fails.** The two numbers look interchangeable and are not: a
|
|
1496
|
+
control's floor is what it takes to still click and type in something, a value's is what it takes
|
|
1497
|
+
to still read the sentence inside it. Ask the operating question about a column and it reports a
|
|
1498
|
+
fit at a width where the content is already ellipsis: a name needing 221 gets 104, a status phrase
|
|
1499
|
+
needing 165 gets 117, and BOTH HALVES MEASURE FINE while the row is unreadable — there is no
|
|
1500
|
+
overflow, no clipped container, nothing a layout probe sees. What gets cut is the tail of the
|
|
1501
|
+
phrase, which in a status column is the part that carries the meaning: the reason, not the noun.
|
|
1502
|
+
|
|
1503
|
+
Two corollaries, because this is a family of bugs rather than one:
|
|
1504
|
+
|
|
1505
|
+
- **Shed a neighbour before crushing the flexible column.** A column is flex BECAUSE its content
|
|
1506
|
+
is variable-length — it is the one that asked for the slack, so it is the last that should be
|
|
1507
|
+
squeezed to buy room for a fixed neighbour the author already marked droppable.
|
|
1508
|
+
- **Below the width where the last pair can both be read, the row is not a narrow row — it is a
|
|
1509
|
+
stack.** Vertical space is free; horizontal space is not. Fewer, taller rows that say everything
|
|
1510
|
+
beat more rows that say half.
|
|
1511
|
+
|
|
1469
1512
|
**A conditional column and its cell are ONE decision.** A register may hide a column whose data
|
|
1470
1513
|
no row carries yet — that is data-driven shape, and it is allowed. What breaks is that the
|
|
1471
1514
|
`columns` entry and the row's `TableCell` are two edits in two files, so it is natural to make
|
package/docs/data_entry.md
CHANGED
|
@@ -471,7 +471,7 @@ first earns a chooser. For the second, render the path most readers take and off
|
|
|
471
471
|
secondary action beside that form's submit, where it is visible without being in the way.
|
|
472
472
|
|
|
473
473
|
The tell is that people press the chooser far more than once each. The
|
|
474
|
-
measurement is in [reviewing.md](./reviewing.md) probe
|
|
474
|
+
measurement is in [reviewing.md](./reviewing.md) probe 8c.
|
|
475
475
|
|
|
476
476
|
And when you do offer the alternative beside the submit, **give it its own treatment**: an
|
|
477
477
|
alternative WAY IN and a recovery link ("forgot your password") are different roles, and two
|
package/docs/reviewing.md
CHANGED
|
@@ -374,7 +374,52 @@ A resting register is the cheapest thing to screenshot and the least likely to h
|
|
|
374
374
|
- **Ask of every value: could this exist in the data?** Name the column. No answer means the block
|
|
375
375
|
goes.
|
|
376
376
|
|
|
377
|
-
###
|
|
377
|
+
### 8a. Navigation depth — count the tab strips on one screen
|
|
378
|
+
|
|
379
|
+
**Measure.** `document.querySelectorAll('[role="tablist"]').length` on every top-level
|
|
380
|
+
destination, not just the landing one. More than **one** on any screen is a finding: the second
|
|
381
|
+
strip is a filter wearing navigation's clothes, and it costs a click and a place to remember on
|
|
382
|
+
the way to a list that was a sibling of the first all along.
|
|
383
|
+
|
|
384
|
+
```js
|
|
385
|
+
[...document.querySelectorAll('[role="tablist"]')].map(t => ({
|
|
386
|
+
labels: [...t.querySelectorAll('[role="tab"]')].map(x => x.textContent.trim()).join(' | '),
|
|
387
|
+
top: Math.round(t.getBoundingClientRect().top),
|
|
388
|
+
}))
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
**Walk every tab before concluding.** The nesting usually hides behind one or two of them, so a
|
|
392
|
+
landing screen measuring `1` proves nothing — the app that prompted this rule was flat on four of
|
|
393
|
+
its five destinations and nested on the other two.
|
|
394
|
+
|
|
395
|
+
**The cheaper signature, before you open a browser at all: grep the tab LABELS for "and" / "và".**
|
|
396
|
+
A destination named for two things is two destinations, and the second strip is where the pair
|
|
397
|
+
gets taken apart again. The same grep over the source (`<Tabs` per file) finds it without a
|
|
398
|
+
running app: more than one file rendering `Tabs` in one app is the same defect at rest.
|
|
399
|
+
|
|
400
|
+
→ [composition.md](./composition.md) §"Choice, filter & navigation controls" (one strip per surface).
|
|
401
|
+
|
|
402
|
+
### 8b. Does each tab earn its slot — count its rows AS A SCOPED READER
|
|
403
|
+
|
|
404
|
+
**Measure.** For every top-level destination, count what it holds for a reader whose scope actually
|
|
405
|
+
bites — not for the build account, which usually sees everything. A tab holding **one row** for a
|
|
406
|
+
real user is not a destination; it is a record, and it belongs beside the thing it is about.
|
|
407
|
+
|
|
408
|
+
Where a surface is scoped by membership, the build account is the worst possible reader to judge
|
|
409
|
+
from: it is typically on every team/site, so a one-row tab renders as a full list and looks fine.
|
|
410
|
+
Drive it as a scoped member (an app-level view-as flag if the platform has one, or read the query's
|
|
411
|
+
own filter and count what a single-membership reader would match).
|
|
412
|
+
|
|
413
|
+
**Then ask who owns the data.** A tab that edits master data belonging to a DIFFERENT role is in
|
|
414
|
+
the wrong app — a site-level tool editing the organisation's catalogue. That is not fixed by a
|
|
415
|
+
permission check; the screen moves to the app its owner already opens.
|
|
416
|
+
|
|
417
|
+
**Source-side signature:** a screen whose list query filters by the viewer's membership, over a
|
|
418
|
+
table the domain describes as organisation-wide.
|
|
419
|
+
|
|
420
|
+
→ [composition.md](./composition.md) §"Choice, filter & navigation controls".
|
|
421
|
+
|
|
422
|
+
### 8c. First paint — count the controls the task actually needs
|
|
378
423
|
- **On any screen whose job is ENTRY, count `input`/`select`/`textarea` on first paint.** Zero is
|
|
379
424
|
the finding: something is gating the task behind a press, and every probe above reads whatever
|
|
380
425
|
that gate rendered instead — a two-item menu measures perfectly as a two-item menu.
|
|
@@ -389,6 +434,48 @@ A resting register is the cheapest thing to screenshot and the least likely to h
|
|
|
389
434
|
- **The same count catches the inverse**: a screen that opens on twelve inputs where the task
|
|
390
435
|
needs three. Count what the FIRST decision requires, not what the record can hold.
|
|
391
436
|
|
|
437
|
+
### 8d. Dead counts — every summary number that names a set you cannot reach
|
|
438
|
+
|
|
439
|
+
**Measure.** Read the summary strip, then the filter band above it, and pair them off. Every
|
|
440
|
+
`warning`/`danger`-toned item, and every item phrased as "N missing / N overdue / N unassigned",
|
|
441
|
+
must have a filter that isolates exactly that set. An item with no partner is a dead count.
|
|
442
|
+
|
|
443
|
+
**Then press the filter and count rows, because a rendered chip proves nothing.** Filtering to a
|
|
444
|
+
set the data says is empty must EMPTY the list; filtering to one that matches everything must
|
|
445
|
+
change nothing. Both readings are needed — a chip that binds its label but never narrows looks
|
|
446
|
+
identical to a working one. (Found exactly that way: a chip whose summary read back correctly and
|
|
447
|
+
whose list stayed at nine of nine, because the `useMemo` computing the list never listed the
|
|
448
|
+
filter in its dependency array. Typecheck, lint and a screenshot all passed it.)
|
|
449
|
+
|
|
450
|
+
**The cheap source-side signature:** a `SummaryLine` item carrying `tone` in a file whose filter
|
|
451
|
+
band has only a `SearchInput`.
|
|
452
|
+
|
|
453
|
+
→ [composition.md](./composition.md) §"Choice, filter & navigation controls"; the rule itself is in
|
|
454
|
+
`src/summary_line.tsx`'s contract.
|
|
455
|
+
|
|
456
|
+
### 8e. Truncation — every string the layout cut, at the width a phone actually is
|
|
457
|
+
|
|
458
|
+
**Measure.** At 375 (and again at 320), walk every leaf node and compare `scrollWidth` to
|
|
459
|
+
`clientWidth`. Anything wider is being cut, and the page reports no overflow while it happens:
|
|
460
|
+
|
|
461
|
+
```js
|
|
462
|
+
[...document.querySelectorAll("*")]
|
|
463
|
+
.filter((el) => !el.children.length && el.scrollWidth > el.clientWidth + 1 && el.textContent.trim())
|
|
464
|
+
.map((el) => ({ txt: el.textContent.trim(), needs: el.scrollWidth, got: el.clientWidth }))
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
An empty array is the pass. This is the ONE probe that catches a width floor answering the wrong
|
|
468
|
+
question — see composition.md, "A width floor answers can this be READ" — because every other
|
|
469
|
+
signal is green: no horizontal scroll, no console error, the container fits, the screenshot at a
|
|
470
|
+
glance looks like a table. Only the string is short.
|
|
471
|
+
|
|
472
|
+
**Read the result by WHAT was cut, not how much.** A trimmed heading is cosmetic. A trimmed
|
|
473
|
+
*status*, *reason*, or *error* is a functional defect: those phrases front-load the category and
|
|
474
|
+
back-load the meaning, so the half that survives is the half the reader already knew.
|
|
475
|
+
|
|
476
|
+
**Source-side signature:** a fit or stack decision that reaches for a control/usability constant
|
|
477
|
+
(`MIN_CONTROL_WIDTH`, a flex minimum) where the thing being measured holds prose.
|
|
478
|
+
|
|
392
479
|
### 9. The absence pass — what catches "bland"
|
|
393
480
|
Every probe above reads an element that EXISTS. None fires on a screen where nothing is wrong and
|
|
394
481
|
nothing is right, which is the screen reported as bland. Defect-removal converges on *inoffensive*;
|
package/package.json
CHANGED
package/src/summary_line.tsx
CHANGED
|
@@ -55,6 +55,15 @@ export interface SummaryLineProps {
|
|
|
55
55
|
* that gathers children (a consol of house shipments, an order of lines) may carry one
|
|
56
56
|
* above THAT list.
|
|
57
57
|
*
|
|
58
|
+
* **A count here is a PROMISE that the subset is reachable.** An item the reader
|
|
59
|
+
* would act on — anything tinted `warning`/`danger`, and any "N missing / N
|
|
60
|
+
* overdue / N unassigned" — names a set, and the strip renders it as text with no
|
|
61
|
+
* press target. So the band ABOVE must carry the filter that isolates it: the
|
|
62
|
+
* same split, the same words. Without one the screen says *something here needs
|
|
63
|
+
* attention* and then hides the rows, which is worse than staying silent — the
|
|
64
|
+
* reader now knows there is a problem and has only the scrollbar to find it.
|
|
65
|
+
* A neutral total ("12 lines", "45 seats") promises nothing and needs no filter.
|
|
66
|
+
*
|
|
58
67
|
* **Never on a record's identity band, reporting that record's own state.** Derived
|
|
59
68
|
* verdicts about one record — "documents incomplete", "2 fields need checking", "no
|
|
60
69
|
* next milestone" — are a checklist, and a record does not carry a checklist
|
package/src/table_fit.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
+
import { MIN_VALUE_WIDTH } from "./control_surface";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* The fit layer for `Table` — the pure decision of which columns fit a measured
|
|
3
|
-
* container width, kept free of any React-Native import so it
|
|
4
|
-
* directly (the register in `table.tsx` renders this result; see
|
|
5
|
-
* for the same split).
|
|
5
|
+
* container width, kept free of any React-Native import at RUNTIME so it
|
|
6
|
+
* unit-tests directly (the register in `table.tsx` renders this result; see
|
|
7
|
+
* `matrix_totals` for the same split). That is now a property of a CHAIN rather
|
|
8
|
+
* than of this file: `control_surface` is imported for {@link MIN_VALUE_WIDTH},
|
|
9
|
+
* and it stays importable here only while its own React-Native import remains
|
|
10
|
+
* type-only. Give it a runtime one and this module stops being pure with
|
|
11
|
+
* nothing here to say so.
|
|
6
12
|
*
|
|
7
13
|
* The register degrades in two deterministic tiers instead of overflowing.
|
|
8
14
|
* Tier 1: everything fits → the columnar register, unchanged. Tier 2: drop
|
|
@@ -77,12 +83,33 @@ export const ROW_HEIGHT = 72;
|
|
|
77
83
|
*/
|
|
78
84
|
export const ROW_GUTTER = 0;
|
|
79
85
|
const ROW_H_PADDING = ROW_GUTTER * 2;
|
|
80
|
-
/** Width a flexible column needs to stay
|
|
81
|
-
* ellipsis soup. `
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
86
|
+
/** Width a flexible column needs to stay OPERABLE — below this it is crushed to
|
|
87
|
+
* ellipsis soup. `DataGrid`, which sheds nothing, uses it as the column's hard
|
|
88
|
+
* floor so the squeeze overflows the grid instead of erasing its identity
|
|
89
|
+
* column. `Table` can shed, so it asks the harder question instead — see
|
|
90
|
+
* {@link FLEX_READ_WIDTH}. */
|
|
85
91
|
export const FLEX_MIN_WIDTH = 120;
|
|
92
|
+
/**
|
|
93
|
+
* What `Table` counts a flex column as needing — what it takes to READ one, not
|
|
94
|
+
* what it takes to operate one.
|
|
95
|
+
*
|
|
96
|
+
* The two questions are different, and the kit has answered this one before:
|
|
97
|
+
* `DetailRow` asked a CONTROL's floor whether a value column was wide enough,
|
|
98
|
+
* and "both halves measured fine and the row was unreadable" — which is why
|
|
99
|
+
* {@link MIN_VALUE_WIDTH} exists. `Table` is the surface that fix missed. Asking
|
|
100
|
+
* {@link FLEX_MIN_WIDTH} let a register declare itself fitted while handing its
|
|
101
|
+
* one flexible column 120px: a 375 phone kept two columns and gave the second
|
|
102
|
+
* 117, so a status phrase needing 165 came out "Quầy chưa ch…" and the reason a
|
|
103
|
+
* vehicle could not leave was the half that got cut.
|
|
104
|
+
*
|
|
105
|
+
* A flex column is flex BECAUSE its content is variable-length — it is the one
|
|
106
|
+
* column that asked for the slack, so it is the last one that should be crushed
|
|
107
|
+
* to buy room for a fixed neighbour the author already marked droppable. Shed
|
|
108
|
+
* one more column first; stack only when even the two-column floor cannot seat a
|
|
109
|
+
* readable one. `DataGrid` keeps {@link FLEX_MIN_WIDTH} because it sheds
|
|
110
|
+
* nothing, so for it the honest question really is "can this still be operated".
|
|
111
|
+
*/
|
|
112
|
+
const FLEX_READ_WIDTH = MIN_VALUE_WIDTH;
|
|
86
113
|
/** Fewer side-by-side columns than this stops being a register — stack instead. */
|
|
87
114
|
const MIN_VISIBLE_COLUMNS = 2;
|
|
88
115
|
|
|
@@ -106,7 +133,7 @@ export function computeTableFit(
|
|
|
106
133
|
const requiredWidth = (cols: TableFitColumn[]): number => {
|
|
107
134
|
const slots = cols.length + (leading > 0 ? 1 : 0) + (trailing > 0 ? 1 : 0);
|
|
108
135
|
const gaps = Math.max(0, slots - 1) * COLUMN_GAP;
|
|
109
|
-
const colsWidth = cols.reduce((sum, c) => sum + (c.width ??
|
|
136
|
+
const colsWidth = cols.reduce((sum, c) => sum + (c.width ?? FLEX_READ_WIDTH), 0);
|
|
110
137
|
return ROW_H_PADDING + (leading > 0 ? leading : 0) + (trailing > 0 ? trailing : 0) + colsWidth + gaps;
|
|
111
138
|
};
|
|
112
139
|
|