@lotics/ui 46.9.0 → 46.13.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
 
package/docs/catalog.md CHANGED
@@ -125,6 +125,28 @@ someone wants `md`. Initials are the first and **last** word of the name — "V
125
125
  Watson" → MW — because a Vietnamese name's first two words are the family and middle names
126
126
  that a whole office shares.
127
127
 
128
+ ### Several THINGS in one cell — `ThumbnailStack`
129
+
130
+ `ThumbnailStack` is `AvatarGroup`'s sibling over a different subject: a capped, overlapping set
131
+ of PICTURES answering "an order of what?", "which parts?", "what arrived?" in one register cell.
132
+ Pass `{ id, label, image }` per thing; `max` (default 3) caps it and the rest become the `+N`
133
+ mark at the end of the stack. Both components read their separating edge from `mark_stack.ts`,
134
+ so a row carrying people in one column and goods in the next reads as one device used twice
135
+ rather than two — but they **tuck by different fractions, and that is deliberate**: a face is
136
+ edge-anchored and survives being covered two thirds of the way, while a product shot centres its
137
+ subject in studio margin, so the facepile's ratio lands the remainder on top of the last
138
+ photograph. Both ratios live in `mark_stack.ts` beside each other, with the reason.
139
+
140
+ What differs from a facepile follows from a thing not being a person: the marks are ROUNDED
141
+ SQUARES (round is the shape the kit spends on a face), and there is **no initials fallback** —
142
+ "CA" is not a stand-in for a bag of coffee, so a thing with no picture renders a neutral
143
+ placeholder and its `label` carries the meaning to a screen reader. Inert, like the facepile:
144
+ its home is a pressable row and it must not hold a second destination.
145
+
146
+ **Reach for it wherever a row states a COUNT of things it could show instead.** A register whose
147
+ rows carry a bare number reads as a ledger of nothing in particular; the pictures are the row's
148
+ own data, and they say what the business is before a word is read.
149
+
128
150
  ### A select-field value
129
151
 
130
152
  `OptionBadge` (a stored `select` value as its CONFIGURED colored badge) — never hand-map
@@ -691,6 +713,19 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
691
713
  - **`group_avatar`** — `GroupAvatar`: a name's initials in a zinc rounded square (`size` — the
692
714
  same rung scale AND the same initials rule as `Avatar`, default `md`); the avatar for
693
715
  image-less entities — groups, organizations. A person → `Avatar`/`MemberChip`.
716
+ - **`thumbnail_stack`** — `ThumbnailStack`: the same capped stack over THINGS rather than
717
+ parties — a set of product, document or unit pictures answering "an order of what?" in one
718
+ register cell. Takes `{ id, label, image? }`, shows `max` (default 3) and makes the rest the
719
+ `+N` mark at the END of the stack. Rounded squares, not discs, and **no initials fallback**: a
720
+ name is a fair stand-in for a face and a useless one for a bag of coffee, so a picture-less
721
+ thing renders a neutral placeholder while its `label` carries the meaning to a screen reader.
722
+ Marks `announce` (the stack shows no text) and it is INERT, for the same reason the facepile
723
+ is. Reach for it wherever a row states a COUNT of things it could show instead.
724
+ - **`mark_stack`** — the constants a capped stack of marks is built from: `MARK_RING` (the
725
+ translucent separating edge, shared, so the two stacks read as one device) plus a tuck ratio
726
+ EACH — `MARK_OVERLAP_RATIO` for faces and the shallower `THUMBNAIL_OVERLAP_RATIO` for things,
727
+ because a face survives being covered two thirds of the way and a centred product shot does
728
+ not. They sit together so the difference reads as a decision rather than a typo.
694
729
  - **`avatar_group`** — `AvatarGroup`: a SET of parties as overlapping marks — "who is on this" in
695
730
  one cell. Takes `{ id, name, kind: "person" | "group", image? }`, shows `max` of them (default
696
731
  3) and makes the rest a `+N` that is the LAST MARK IN THE STACK, not loose text beside it.
@@ -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.13.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./accordion": "./src/accordion.tsx",
@@ -171,6 +171,7 @@
171
171
  "./list_item": "./src/list_item.tsx",
172
172
  "./loading": "./src/loading.tsx",
173
173
  "./locale": "./src/locale.tsx",
174
+ "./mark_stack": "./src/mark_stack.ts",
174
175
  "./markdown": {
175
176
  "react-native": "./src/markdown.tsx",
176
177
  "default": "./src/markdown.web.tsx"
@@ -279,6 +280,7 @@
279
280
  "react-native": "./src/theme.ts",
280
281
  "default": "./src/theme.web.ts"
281
282
  },
283
+ "./thumbnail_stack": "./src/thumbnail_stack.tsx",
282
284
  "./time_columns": "./src/time_columns.tsx",
283
285
  "./time_options": "./src/time_options.ts",
284
286
  "./time_picker": "./src/time_picker.tsx",
@@ -5,6 +5,7 @@ import { Text } from "./text";
5
5
  import { AVATAR_PX, AVATAR_TEXT, type AvatarSize } from "./avatar_size";
6
6
  import { colors } from "./colors";
7
7
  import { useLoticsLocale } from "./locale";
8
+ import { MARK_OVERLAP_RATIO, MARK_RING } from "./mark_stack";
8
9
 
9
10
  /** One party in the set. A PERSON takes the disc; a group or an organization
10
11
  * takes the rounded square, which is `Avatar`'s own shape axis and the one
@@ -25,34 +26,6 @@ export interface AvatarGroupPrincipal {
25
26
  * separable at a glance and a number is the more honest summary. */
26
27
  const DEFAULT_MAX = 3;
27
28
 
28
- /**
29
- * How far each mark tucks under the one before it, as a fraction of the rung.
30
- *
31
- * A FRACTION, not a pixel constant, so the stack is the same object at every
32
- * rung — a fixed inset barely stacks at `lg` and eats the marks at `sm`. Just
33
- * under a third leaves each mark's whole leading edge, which is as much of an
34
- * image or a pair of initials as the reader needs to tell the parties apart.
35
- */
36
- const OVERLAP_RATIO = 0.3;
37
-
38
- /**
39
- * The hairline separating each mark from the one it covers.
40
- *
41
- * TRANSLUCENT INK, never the surface colour — and that is what lets this
42
- * component overlap at all. The conventional facepile rings each mark in the
43
- * page background so the discs read as separate, which binds the ring to a
44
- * colour the row does not keep: a register row washes to `zinc[50]` on hover,
45
- * `zinc[100]` as the open record and `zinc[200]` while pressed, so a white
46
- * ring is a halo in three of the four states — and those are the states a
47
- * reader is in when they are actually looking at the row. A translucent dark
48
- * edge DARKENS whatever is behind it instead of matching it, so it holds on
49
- * every ground. `FileBadge` separates itself from its own surface the same way.
50
- *
51
- * A `boxShadow` rather than a border, because a border grows the box: a stacked
52
- * mark would render 2px wider than the identical mark in the next column.
53
- */
54
- const MARK_RING = "0 0 0 1px rgba(0,0,0,0.12)";
55
-
56
29
  /**
57
30
  * A SET of parties as marks — the answer to "who is on this" in one cell.
58
31
  *
@@ -116,7 +89,7 @@ export function AvatarGroup(props: AvatarGroupProps) {
116
89
  const shown = principals.slice(0, max);
117
90
  const hidden = principals.length - shown.length;
118
91
  const px = AVATAR_PX[size];
119
- const overlap = Math.round(px * OVERLAP_RATIO);
92
+ const overlap = Math.round(px * MARK_OVERLAP_RATIO);
120
93
  // Descending, so the FIRST mark ends up on top. The remainder is one more
121
94
  // thing to lay under, hence its own slot in the depth.
122
95
  const depth = shown.length + (hidden > 0 ? 1 : 0);
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The two constants a capped stack of marks is built from.
3
+ *
4
+ * They live here, side by side, because there are two stacks — a set of PARTIES
5
+ * (`avatar_group`) and a set of THINGS (`thumbnail_stack`) — and the decision
6
+ * each constant encodes is only legible NEXT TO the other one. The edge is
7
+ * shared: a stack that separated itself differently would read as a second
8
+ * device rather than as the same one over a different subject. The tuck is NOT,
9
+ * and the two ratios sitting together is what stops the difference looking like
10
+ * a typo in one of them.
11
+ */
12
+
13
+ /**
14
+ * How far one FACE tucks under the one before it, as a fraction of the rung.
15
+ *
16
+ * A FRACTION, not a pixel constant, so the stack is the same object at every
17
+ * rung — a fixed inset barely stacks at `lg` and eats the marks at `sm`. Just
18
+ * under a third leaves each mark's whole leading edge, which is as much of an
19
+ * image or a pair of initials as the reader needs to tell the marks apart.
20
+ */
21
+ export const MARK_OVERLAP_RATIO = 0.3;
22
+
23
+ /**
24
+ * The same tuck, for a stack of THINGS — and it is deliberately shallower.
25
+ *
26
+ * A face is edge-anchored: hair, a jaw, a colour, all readable from the leading
27
+ * third, which is what lets a facepile overlap so hard. A product shot is the
28
+ * opposite — the subject is CENTRED with studio margin around it, so a third of
29
+ * the tile is exactly the part that carries the object. At the face ratio the
30
+ * remainder lands on top of the last photograph and reads as covering it rather
31
+ * than as following it, which is the one thing the mark must not do: it is a
32
+ * statement ABOUT the set, not a member of it competing for the same glance.
33
+ *
34
+ * Shallow rather than zero, because the marks still have to read as one object.
35
+ */
36
+ export const THUMBNAIL_OVERLAP_RATIO = 0.15;
37
+
38
+ /**
39
+ * The hairline separating each mark from the one it covers.
40
+ *
41
+ * TRANSLUCENT INK, never the surface colour — and that is what lets a stack
42
+ * overlap at all. The conventional facepile rings each mark in the page
43
+ * background so the marks read as separate, which binds the ring to a colour
44
+ * the row does not keep: a register row washes to `zinc[50]` on hover,
45
+ * `zinc[100]` as the open record and `zinc[200]` while pressed, so a white ring
46
+ * is a halo in three of the four states — and those are the states a reader is
47
+ * in when they are actually looking at the row. A translucent dark edge
48
+ * DARKENS whatever is behind it instead of matching it, so it holds on every
49
+ * ground. `FileBadge` separates itself from its own surface the same way.
50
+ *
51
+ * A `boxShadow` rather than a border, because a border grows the box: a stacked
52
+ * mark would render 2px wider than the identical mark in the next column.
53
+ */
54
+ export const MARK_RING = "0 0 0 1px rgba(0,0,0,0.12)";
@@ -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
 
@@ -0,0 +1,151 @@
1
+ import { Image, View, type StyleProp, type ViewStyle } from "react-native";
2
+ import { Icon } from "./icon";
3
+ import { Text } from "./text";
4
+ import { AVATAR_PX, AVATAR_TEXT, type AvatarSize } from "./avatar_size";
5
+ import { colors } from "./colors";
6
+ import { useLoticsLocale } from "./locale";
7
+ import { MARK_RING, THUMBNAIL_OVERLAP_RATIO } from "./mark_stack";
8
+
9
+ /** One THING in the set — a product, a document, a unit. Not a party: a thing
10
+ * has no initials worth reading, so it is its picture or it is a placeholder. */
11
+ export interface ThumbnailStackItem {
12
+ /** Stable identity — the React key. */
13
+ id: string;
14
+ /** What the thing is, for the screen reader. The stack shows no text. */
15
+ label: string;
16
+ /** The thing's own picture. Absent renders the neutral placeholder. */
17
+ image?: string | null;
18
+ }
19
+
20
+ /** Three reads as "a few"; past that the pictures stop being separable at a
21
+ * glance and a number is the more honest summary. Same default as a facepile,
22
+ * for the same reason. */
23
+ const DEFAULT_MAX = 3;
24
+
25
+ /**
26
+ * A SET of THINGS as pictures — the answer to "an order of what?" in one cell.
27
+ *
28
+ * The sibling of `AvatarGroup`, over a different subject. The two share their
29
+ * separating edge (`mark_stack.ts`) so a row carrying both reads as one device
30
+ * used twice. What differs is everything that follows from a thing not being a
31
+ * person — including how hard they tuck:
32
+ *
33
+ * **A shallower overlap.** A face is edge-anchored and survives being covered
34
+ * two thirds of the way; a product shot centres its subject in studio margin,
35
+ * so the face ratio puts the remainder on top of the last photograph. Both
36
+ * ratios live in `mark_stack.ts` beside each other, with the reason.
37
+ *
38
+ * **Square, not round.** Round is the shape the kit spends on a person's face.
39
+ * A rounded square is what `Avatar` already gives an organization, and a crate
40
+ * of goods belongs on that side of the axis.
41
+ *
42
+ * **No initials.** `Avatar` falls back to a person's initials because a name is
43
+ * a reasonable stand-in for a face. "CA" is not a stand-in for a bag of coffee
44
+ * — it is noise wearing the shape of information — so a thing with no picture
45
+ * renders a neutral placeholder that says only "something is here", and the
46
+ * label carries the meaning to anyone listening.
47
+ *
48
+ * **The remainder is the last mark IN the stack**, not text after it: `+9`
49
+ * trailing the pictures as loose type reads as a separate fact about the row
50
+ * instead of as the rest of this one.
51
+ *
52
+ * **It is INERT.** Its home is a register row, and a row that already presses
53
+ * must not hold a second destination four pixels to the left.
54
+ *
55
+ * **It caps what it was GIVEN.** The overflow counts from what will actually
56
+ * render, so `+9` never counts a mark the reader can see is missing. Drop what
57
+ * you cannot show before you pass it in.
58
+ *
59
+ * Pure: pass resolved labels and image urls; this fetches nothing.
60
+ */
61
+ export interface ThumbnailStackProps {
62
+ items: readonly ThumbnailStackItem[];
63
+ /** Pictures shown before the remainder becomes `+N`. Default 3. */
64
+ max?: number;
65
+ /**
66
+ * A rung on the shared avatar scale — the same scale the marks in the
67
+ * neighbouring columns take, so a row of goods and a row of people sit on one
68
+ * beat. Everything inside derives from it; nothing here is a fixed pixel.
69
+ */
70
+ size?: AvatarSize;
71
+ style?: StyleProp<ViewStyle>;
72
+ testID?: string;
73
+ }
74
+
75
+ export function ThumbnailStack(props: ThumbnailStackProps) {
76
+ const { items, max = DEFAULT_MAX, size = "md", style, testID } = props;
77
+ const locale = useLoticsLocale();
78
+
79
+ if (items.length === 0) return null;
80
+
81
+ const shown = items.slice(0, max);
82
+ const hidden = items.length - shown.length;
83
+ const px = AVATAR_PX[size];
84
+ const overlap = Math.round(px * THUMBNAIL_OVERLAP_RATIO);
85
+ const radius = Math.round(px * 0.2);
86
+ // Descending, so the FIRST picture ends up on top and the remainder lies
87
+ // under everything rather than covering one of the things it is counting.
88
+ const depth = shown.length + (hidden > 0 ? 1 : 0);
89
+
90
+ return (
91
+ <View testID={testID} style={[{ flexDirection: "row", alignItems: "center" }, style]}>
92
+ {shown.map((item, index) => (
93
+ <View
94
+ key={item.id}
95
+ accessible
96
+ accessibilityLabel={item.label}
97
+ style={{
98
+ width: px,
99
+ height: px,
100
+ marginLeft: index === 0 ? 0 : -overlap,
101
+ // `zIndex` needs a positioned box to take effect in
102
+ // react-native-web; without it the marks stack in DOM order.
103
+ position: "relative",
104
+ zIndex: depth - index,
105
+ borderRadius: radius,
106
+ overflow: "hidden",
107
+ backgroundColor: colors.zinc["100"],
108
+ alignItems: "center",
109
+ justifyContent: "center",
110
+ boxShadow: MARK_RING,
111
+ }}
112
+ >
113
+ {item.image ? (
114
+ <Image
115
+ source={{ uri: item.image }}
116
+ style={{ width: px, height: px }}
117
+ resizeMode="cover"
118
+ accessibilityIgnoresInvertColors
119
+ />
120
+ ) : (
121
+ <Icon name="package" size={Math.round(px * 0.5)} color={colors.zinc["400"]} />
122
+ )}
123
+ </View>
124
+ ))}
125
+ {hidden > 0 && (
126
+ <View
127
+ accessible
128
+ accessibilityLabel={locale.avatar.more(hidden)}
129
+ style={{
130
+ width: px,
131
+ height: px,
132
+ marginLeft: shown.length === 0 ? 0 : -overlap,
133
+ position: "relative",
134
+ zIndex: 0,
135
+ borderRadius: radius,
136
+ backgroundColor: colors.zinc["100"],
137
+ alignItems: "center",
138
+ justifyContent: "center",
139
+ boxShadow: MARK_RING,
140
+ }}
141
+ >
142
+ {/* The rung's own type — a fixed size here is how a stack ends up
143
+ with a 14px count beside 12px marks. */}
144
+ <Text size={AVATAR_TEXT[size]} weight="medium" color="zinc-700" aria-hidden>
145
+ {`+${hidden}`}
146
+ </Text>
147
+ </View>
148
+ )}
149
+ </View>
150
+ );
151
+ }