@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 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 11 probes each naming what to COLLECT and the SIGNATURE in the numbers. Plus the extraction snippets, what measurement cannot see, and how to act on a finding. |
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
 
@@ -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
@@ -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 8b.
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
- ### 8b. First paint — count the controls the task actually needs
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "46.9.0",
3
+ "version": "46.12.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./accordion": "./src/accordion.tsx",
@@ -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 unit-tests
4
- * directly (the register in `table.tsx` renders this result; see `matrix_totals`
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 usable — below this it is crushed to
81
- * ellipsis soup. `Table` counts a flex column as this wide when deciding what
82
- * fits; `DataGrid`, which sheds nothing, uses it as the column's hard floor so
83
- * the squeeze overflows the grid instead of erasing its identity column.
84
- * Exported so the two cannot drift. */
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 ?? FLEX_MIN_WIDTH), 0);
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