@lotics/ui 47.6.0 → 47.6.1

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.
Files changed (63) hide show
  1. package/AGENTS.md +22 -3
  2. package/MIGRATION.md +61 -62
  3. package/docs/ai_patterns.md +9 -2
  4. package/docs/catalog.md +37 -3
  5. package/docs/composition.md +148 -10
  6. package/docs/data_entry.md +8 -0
  7. package/docs/reviewing.md +51 -2
  8. package/examples/tpl_attendance.tsx +1 -1
  9. package/package.json +3 -1
  10. package/src/alert.tsx +1 -1
  11. package/src/avatar.tsx +1 -1
  12. package/src/avatar.web.tsx +1 -1
  13. package/src/avatar_group.tsx +1 -1
  14. package/src/bar_chart.tsx +10 -1
  15. package/src/board.tsx +1 -1
  16. package/src/button.tsx +4 -4
  17. package/src/cell_stack.tsx +140 -0
  18. package/src/column_filter.tsx +1 -1
  19. package/src/combobox.tsx +2 -2
  20. package/src/comments_thread.tsx +2 -2
  21. package/src/count.tsx +1 -1
  22. package/src/date_calendar.tsx +2 -2
  23. package/src/date_filter.tsx +1 -1
  24. package/src/diff_value.tsx +2 -2
  25. package/src/eyebrow.tsx +1 -1
  26. package/src/file_thumbnail.tsx +2 -2
  27. package/src/file_thumbnail_grid.tsx +1 -1
  28. package/src/filter_chip.tsx +5 -2
  29. package/src/finding.tsx +19 -2
  30. package/src/form_field.tsx +1 -1
  31. package/src/inline_date_picker.tsx +1 -1
  32. package/src/inline_edit.tsx +2 -1
  33. package/src/line_chart.tsx +3 -1
  34. package/src/list_item.tsx +1 -1
  35. package/src/loading.tsx +1 -1
  36. package/src/matrix.tsx +1 -1
  37. package/src/member_chip.tsx +1 -1
  38. package/src/member_profile_card.tsx +1 -1
  39. package/src/menu_list_item.tsx +2 -2
  40. package/src/metric.tsx +1 -1
  41. package/src/option_list.tsx +1 -1
  42. package/src/page_header.tsx +26 -25
  43. package/src/pagination.tsx +1 -1
  44. package/src/pie_chart.tsx +4 -2
  45. package/src/radio_picker.tsx +3 -3
  46. package/src/section_heading.tsx +74 -5
  47. package/src/segmented_control.tsx +1 -1
  48. package/src/select.tsx +1 -1
  49. package/src/shortcut_badge.tsx +1 -1
  50. package/src/stacked_bar_chart.tsx +1 -1
  51. package/src/switcher.tsx +1 -1
  52. package/src/table.tsx +46 -8
  53. package/src/tabs.tsx +2 -2
  54. package/src/text.tsx +2 -7
  55. package/src/text_ink.ts +96 -0
  56. package/src/text_link.tsx +1 -1
  57. package/src/text_utils.ts +1 -46
  58. package/src/thumbnail_stack.tsx +1 -1
  59. package/src/time_picker.tsx +1 -1
  60. package/src/timeline.tsx +1 -1
  61. package/src/tokens.ts +28 -4
  62. package/src/tooltip.tsx +1 -1
  63. package/src/uploading_thumbnail.tsx +1 -1
package/docs/catalog.md CHANGED
@@ -443,6 +443,11 @@ controls is `PressableRow` + **`PressDoor`** — never `PressableHighlight`, whi
443
443
  button and wraps its children (a button must not contain interactive descendants). `Table`/
444
444
  `TableRow` does exactly this internally, so a columnar register needs no assembly.
445
445
 
446
+ A subject with ONE qualifying line under it — in a table cell, a row, or beside a mark — is
447
+ **`CellStack`** (`title` + `caption`), never two `Text`s in a `View`. It owns the three-part
448
+ stacked-pair rule that composition.md §Typography states: which rung the caption takes, which
449
+ lines are tightened, and the zero gap. See §"Lists, tables & registers".
450
+
446
451
  ### Filters & view controls
447
452
 
448
453
  `SearchInput`, `ChipGroup`, `FilterChip` (+ `Slider range`, `Counter`), `Chip`,
@@ -463,6 +468,16 @@ passes through — `Skeleton` / `Loading` (in flight) → `ErrorState` (failed)
463
468
  (succeeded, nothing) → `CompletionState` (done). Pick by what the region can ASSERT: an empty
464
469
  says the read succeeded and found nothing, a failed read knows neither.
465
470
 
471
+ **An EXCEPTION a check produced is `Finding`, not a `Callout` — and it does not matter who ran
472
+ the check.** The discriminator is the SHAPE of what was found, never its author: two readings
473
+ that disagree, with a magnitude between them, is a `Finding` (severity ranks it, `readings` name
474
+ what each side says, `delta` says how far apart) — a rules engine comparing two stored figures
475
+ qualifies exactly as an extraction comparing two documents does. A `Callout` is for a STATE with
476
+ no second reading to contradict: a record past its validity, a row that will not bill. Reaching
477
+ for a Callout on a genuine contradiction throws away the ranking, the magnitude and the
478
+ provenance, and there is no way to get them back at the call site.
479
+ → `src/finding.tsx`, whose docstring owns this rule.
480
+
466
481
  ### Files
467
482
 
468
483
  **Pick the surface FIRST, and pick exactly one per set.** Documents get a LIST (`FileRows`),
@@ -526,8 +541,9 @@ layout). An add is `after` alone, a removal is `before` alone, an update is both
526
541
  is a `DiffValue` over `CardSelectItem` candidates — and the ROW around any of them belongs to
527
542
  your screen. See the AI-patterns doc indexed in [AGENTS.md](../AGENTS.md) for the laws; `Clarify` (the agent asks back — selectable
528
543
  `ChoiceList` options), `Sources` (provenance chips for AI output — at review scale,
529
- `label={null}` slots the chips at a section's bottom), `Finding` (one ranked insight from an
530
- AI check, laid out HORIZONTALLY: a severity dot, the title, and the `delta` right-aligned on
544
+ `label={null}` slots the chips at a section's bottom), `Finding` (one ranked discrepancy from a
545
+ cross-check AI or a rules engine, the component does not care; see §"Status / feedback" for
546
+ the Finding-vs-Callout call — laid out HORIZONTALLY: a severity dot, the title, and the `delta` right-aligned on
531
547
  that same line, over the `readings` stacked ONE PER LINE — each `source value`, no separator
532
548
  joining them (a middot claims a relation it will not name, and is the one mark a screen reader
533
549
  drops), both kept on
@@ -1660,6 +1676,23 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1660
1676
  A `description` passed as a NODE keeps its own leading — the component only sets it on the
1661
1677
  string branch, because forcing a line box on somebody else's subtree re-spaces content that
1662
1678
  may not be a line of text at all.
1679
+ - **`cell_stack`** — `CellStack`: a SUBJECT over one qualifying line, as a value primitive —
1680
+ the commonest cell in any register, and the shape most often hand-rolled wrong. `title` +
1681
+ `caption`, plus `weight` / `marked` / `size` / `color` / `tabular` / `align` /
1682
+ `numberOfLines`.
1683
+ It OWNS the three-part stacked-pair rule that [composition.md](./composition.md) §Typography
1684
+ states, so a call site never re-derives it: the caption's rung follows **what else already
1685
+ separates the pair** (a `medium` subject or a leading identity mark ⇒ the caption keeps body
1686
+ size, because that line is often the disambiguator; both lines regular ⇒ it drops to `xs` or
1687
+ the two read as peers); both body-rung lines take `leading="tight"`, an `xs` caption being
1688
+ tight already; and the stack's `gap` is **0**, because each tight box already contributes
1689
+ 3px a side.
1690
+ A LONE `title` is deliberately NOT tightened — tight leading closes a PAIR up, and there is
1691
+ no second line to close up to. A `caption` passed as a NODE keeps its own leading and ink,
1692
+ for the reason `ListItem`'s `description` does.
1693
+ Reach for it over two `Text`s in a `View`: the excess height in a hand-rolled pair comes
1694
+ from the LINE BOX, so no margin and no gap appears anywhere to explain it and no spacing
1695
+ probe can see it — it is reported as "too far apart", never as its cause.
1663
1696
  - **`table`** — `Table` + `TableRow` + `TableCell`: the paginated high-volume register
1664
1697
  (columns defined once, `sortLabels` localizable; rows are `PressableRow`-based).
1665
1698
  CONTAINER-RESPONSIVE with no prop (measures itself, like `Breakdown`/`DetailTable`): when
@@ -2589,7 +2622,8 @@ component rather than showing it at zero.
2589
2622
  - **`sources`** — `Sources` + `SourceRef`/`SourceKind` (record | document | table | web |
2590
2623
  knowledge): provenance chips, per-kind glyphs.
2591
2624
  - **`finding`** — `Finding` + `FindingReading` + `FindingSeverity`/`FindingLabels`: one ranked
2592
- AI-check insight on two lines — severity dot, title, right-aligned `delta`, then the
2625
+ discrepancy on two lines, from a cross-check by WHOEVER ran it an AI extraction, a rules
2626
+ engine, an audit — severity dot, title, right-aligned `delta`, then the
2593
2627
  `readings` with the source each came from; optional `detail` consequence, children slot, and
2594
2628
  the `finding` locale slice.
2595
2629
  - **`result_header`** — `ResultHeader` (+ `ResultTone`): the save-direct RECEIPT's outcome
@@ -318,6 +318,14 @@ verb is off-screen, and on an empty list there is no last row to sit under. The
318
318
  section's control line — it already carries the meta and the view controls — so the add belongs on
319
319
  it, in the same spot whether the section holds nought or forty.
320
320
 
321
+ **The heading's FIRST child is the title — a mark for the subject goes in
322
+ `SectionHeadingTitle`'s `leading` slot, never beside it.** `SectionHeading` reads child
323
+ one as the title and groups everything after it, so an `Avatar` passed as a sibling
324
+ becomes the title and the real title joins the trailing group — which is content-sized,
325
+ so the row stops pushing the action to its right edge. Nothing clips and nothing errors;
326
+ the action simply sits mid-row. `icon` is for a CATEGORY (an `IconName`); `leading` is for
327
+ an IDENTITY — a company's avatar, a person's, a logo.
328
+
321
329
  **When the row runs out of width, the VERB wraps — the title never gives way.** The title names
322
330
  the section; the verb is the thing that can move. Getting this backwards is easy to ship because
323
331
  RN-Web resolves `flex: 1` to `flex-basis: 0%` with `min-width: 0`, so a title beside a button that
@@ -674,6 +682,23 @@ by — its key, code, or id — because that is what they are scanning against a
674
682
  or someone on the phone. No other column can take its place: a name repeats, a status is shared by
675
683
  dozens.
676
684
 
685
+ **A set of records the reader COMPARES on a value is a register, and a `ListItem` list cannot be
686
+ one.** `ListItem` gives a title, one description string and a right slot, so every field after the
687
+ first is prose joined by separators — and a value inside prose cannot be scanned down a column,
688
+ which is the whole act the reader came to perform. The tell is a **superlative badge**: a "cheapest",
689
+ "best" or "fastest" mark appears exactly when there is no column to read the answer off, so it is a
690
+ workaround for the missing form rather than a feature, and it disappears the moment the value gets
691
+ a column. The other tell is a per-row badge repeating a value the rows are already SORTED by — that
692
+ is a section heading printed once, not a pill printed on every row.
693
+
694
+ Reach for `ListItem` where the rows are heterogeneous and the reader picks one by NAME — settings,
695
+ a menu of destinations, a detail row. Reach for a register wherever they share a field set.
696
+
697
+ **Sibling surfaces must be the same form.** Two tabs of one dialog, or two panes of one screen,
698
+ showing the same KIND of subject in different forms reads as an accident: a reader parses shape
699
+ before meaning, and the difference tells them the two are unrelated when they are the same thing
700
+ from two sources.
701
+
677
702
  Every listed record is actionable. PRIMARY entity rows press-open the workspace `Drawer`
678
703
  (sequenced — see [master-detail](#master-detail--list--workspace-drawer-with-sequencing));
679
704
  read-only drill-downs expand via `Accordion` or glance via `Peek`; every other row gets an
@@ -692,6 +717,29 @@ not a row count, it is whether the neighbouring rows survive the expansion.
692
717
  | `bleed` (legacy) | square wash, `Divider`-separated | only an edge-to-edge data grid that genuinely wants hard rules |
693
718
  | `inset` | rounded (radius 8) | grouped lists in a padded container (Accordion drill-downs) |
694
719
 
720
+ **A leading MARK in a column reserves its slot on every row, including the empty ones.** An
721
+ avatar, a thumbnail or a status dot that only some rows carry gives that column two left edges —
722
+ the marked rows indent by the mark's width, the placeholder sits on the column's own edge — and a
723
+ column is scannable precisely because its values start in the same place. Hold the gutter open
724
+ with an empty box of the mark's width. The header stays on the column edge, which is what a
725
+ column with a leading mark looks like everywhere.
726
+
727
+ **A register centres its cells, and that is right until a TRAILING CONTROL has to line up
728
+ with one of them.** With a control in the row's gutter, centring puts it wherever the tallest
729
+ cell leaves it — 10px below the field on one row and 27px on the next, so the controls stop
730
+ forming a column; that register passes `align="top"`. Without one, keep the default: centring
731
+ lets each cell's block sit in the middle of the row, so a one-line value beside a two-line
732
+ neighbour reads as balanced rather than stranded at the top. **Removing the control is a reason
733
+ to drop `top` again** — nothing breaks when you forget, it just looks slightly wrong forever.
734
+
735
+ **The mechanism, for the `top` case.** Centring resolves per cell: the moment one cell is two lines its
736
+ single-line siblings sit at a different height, and the trailing control — centred
737
+ against the tallest cell — drops below the field it acts on, by an amount that varies
738
+ with the data, so the controls stop forming a column at all. A caller cannot fix this
739
+ (`alignSelf` on the control reaches the slot's own centring, one level below the row's),
740
+ which is why it is a `Table` prop. A one-line row is pixel-identical either way, so
741
+ passing it costs nothing and omitting it is invisible until a value grows a qualifier.
742
+
695
743
  **A washed row BLEEDS its wash; it never insets its content.** All three variants
696
744
  carry `marginHorizontal: -ROW_WASH_BLEED` absorbed by an equal `paddingHorizontal`
697
745
  — net zero for layout — so a row's content sits on its container's own edge,
@@ -947,6 +995,17 @@ action goes to the referenced record's own page; a local `Drawer` that re-shows
947
995
  just showed makes "Open" mean *read that again, wider*, and leaves the real record unreachable
948
996
  from the only control that promised it.
949
997
 
998
+ **A detail surface never contains a register of its own SIBLINGS.** The list it was opened from
999
+ is still on screen behind it, and `onPrev`/`onNext` already walk that list — so a "related records"
1000
+ table inside the panel is the third rendering of one set, and the reader has to work out whether
1001
+ the three agree. The trap is that it can be RIGHT when it is built and rot into this later: a
1002
+ register that folds many children into one row (a lane, an order, a person) genuinely hides them,
1003
+ and a table in the detail is then the only way to see them. Flatten that register to one row per
1004
+ child — usually a good change on its own — and the table silently becomes a copy of the rows it is
1005
+ covering, with nothing failing. **When a list changes what a row stands for, re-read every detail
1006
+ panel it opens.** Keep the VERBS such a section carried (a bulk action over the group is real work
1007
+ the register may not offer) and move them to the section they act on.
1008
+
950
1009
  **A record surface answers *which record is this* first — and the block that does it is SHORT.**
951
1010
  Identity is the handful of facts someone would say aloud to name this record (its number and its
952
1011
  counterparty, usually two); everything else is an ATTRIBUTE and belongs below the answer the
@@ -968,6 +1027,14 @@ ONE at a time, because two open details push the next row off screen. The bounda
968
1027
  weight, not its field count: read or light-edit (independent inline commits) expands, a real form
969
1028
  with a submit and cross-field validation still earns its own surface.
970
1029
 
1030
+ **A column of always-open inputs makes a list look like a form.** An inline editor is a framed
1031
+ box, so a register that puts one in every row of its NAME column renders eight boxes down the left
1032
+ edge and reads as eight fields waiting to be filled rather than eight records to scan. It also
1033
+ gives the column two left edges — the frame's and the text's — which no amount of aligning the
1034
+ rest of the row recovers. Put the editors on the values a reader actually revises in place (a
1035
+ quantity, a price) and leave the SUBJECT as text; it edits in the expansion, which is where
1036
+ editing lives anyway. The tell is a screenshot: count the frames before you count the rows.
1037
+
971
1038
  The expansion carries **EVERY** field, including the ones already in columns. Column cells are
972
1039
  read-only, and `table_fit` tier 2 drops droppable columns on the explicit promise that a door
973
1040
  reaches the dropped values — the expansion IS that door, so anything it omits is unreachable.
@@ -1159,6 +1226,13 @@ by what KIND of thing they are.
1159
1226
  rides its section's HEADING row at the right edge — see § The add-placement law for why, and
1160
1227
  for the one-verb-one-weight rule that keeps it `primary` wherever it renders. (The
1161
1228
  icon-only `+` `IconButton` — a composer's attach — is a different thing and keeps its glyph.)
1229
+ - **Edit is never an icon.** A pencil is not a word: it does not say WHAT it edits, it reads as
1230
+ decoration beside real controls, and in a row it competes with the values it sits next to. Use
1231
+ a `secondary` `Button` with the verb. `IconButton` is for a verb whose glyph IS the universal
1232
+ name of the act *and* whose surface has no room for a word — a close ✕, an overflow ⋯, a
1233
+ composer's attach. Reserve the row's action gutter wide enough for the label rather than
1234
+ shrinking the verb to fit the gutter; a control that toggles a disclosure names both states
1235
+ (`Sửa` → `Xong`), because a label that stays put while its effect reverses is a lie.
1162
1236
 
1163
1237
  ## The action-gating law — disabled + the reason as a co-located Callout
1164
1238
 
@@ -1656,6 +1730,13 @@ tight, so only the subject carries the prop. Then the stack's own `gap` goes to
1656
1730
  line box already contributes 3px above and below, giving 6px between the two, and a `gap` on top
1657
1731
  is a third source of space between lines that are meant to read as one.
1658
1732
 
1733
+ **All three pieces above are one component: reach for `CellStack` (`title` + `caption`) rather
1734
+ than assembling a pair by hand.** They have to agree with each other, and a hand-rolled stack gets
1735
+ one of them wrong silently — the excess height comes from the LINE BOX, so there is no margin and
1736
+ no gap anywhere to explain it, and every spacing probe passes. It surfaces as "too far apart" and
1737
+ gets fixed by tightening a gap, which is a fourth wrong number. Two `Text`s in a `View` is the
1738
+ tell. → [catalog.md §"Lists, tables & registers"](./catalog.md).
1739
+
1659
1740
  Running text keeps prose leading. The default is for paragraphs, and `.ui-markdown` renders at it.
1660
1741
 
1661
1742
  **A CONTROL riding a text line must not resize that line.** A control is taller than the text it
@@ -1687,6 +1768,16 @@ row that must not look the same on every row. A register of twenty people in one
1687
1768
  twenty identical circles, with the largest and most colourful thing in the row carrying no
1688
1769
  information.
1689
1770
 
1771
+ - **The GLYPH is derived from the DISTINGUISHING part of the name, which is not always the
1772
+ first word.** `avatarInitials` takes a name's first (and last) letters, which is right for a
1773
+ person and wrong for an ORGANIZATION: an organization's name opens with its legal form, so a
1774
+ register of companies marks the same letter on every row — and it is the same failure the derived
1775
+ hue was written to fix, arriving through the glyph instead of the ground. Strip the form from
1776
+ both ends before the name reaches the mark, and use the stripped name for the row's TITLE too:
1777
+ whatever eats the first letters of a mark is also eating the first 60px of the column beside it,
1778
+ where the words that tell two customers apart fall off the end. The legal name belongs on the
1779
+ record the row opens, not in the list. **A hue tally cannot see this** — the ground is seeded
1780
+ from the whole name, so a screen of identical letters still measures as N distinct colours.
1690
1781
  - **The hue is derived, never passed.** A per-app or per-screen map is a second copy of something
1691
1782
  the name already determines, and it drifts. `avatar_tone` is pure, so one person is one colour
1692
1783
  on every screen and in every session — the value is never stored.
@@ -1876,7 +1967,22 @@ selection washes).
1876
1967
 
1877
1968
  **Count the chromatic grounds on a finished screen — the tally is the check.** One or two hues used
1878
1969
  many times reads calm and deliberate; ZERO reads like a spreadsheet; ten used once each reads
1879
- cheap. A hue with a SINGLE member, from a family used nowhere else on the screen, is a library
1970
+ cheap.
1971
+
1972
+ **A tonal column cannot encode more than about two categories, and the limit is measurable rather
1973
+ than a matter of taste.** The tints are the 50-step, and 50-steps of NEIGHBOURING families are
1974
+ nearly the same colour: sky/blue/indigo measure 1.9–4.5 ΔE apart, against a just-noticeable
1975
+ difference of roughly 2.3. So a hue per value across five or six values ships a column that LOOKS
1976
+ coded and is not — the reader still reads every label, and the palette has spent itself saying
1977
+ nothing. The families far enough apart to actually separate are the warm ones, which already mean
1978
+ danger and waiting, so reaching for them to distinguish a category makes every red on the screen
1979
+ ambiguous.
1980
+
1981
+ Give colour the ONE split in the set that a reader must not get wrong — the one where two values
1982
+ are not interchangeable — and let the label carry the rest. Two hues used many times is the
1983
+ disciplined tally above; it is also the most the medium can carry. Where the values are ORDERED
1984
+ rather than categorical, `ramp` is the tool: one family, shaded, where the eye reads sequence
1985
+ without having to resolve hue at all. A hue with a SINGLE member, from a family used nowhere else on the screen, is a library
1880
1986
  default nobody chose — the giveaway is that the call site mentions no colour at all, so the shade
1881
1987
  arrived from a component's fallback rather than from a decision. Either give it siblings or take it
1882
1988
  back to a neutral.
@@ -1889,12 +1995,43 @@ colours on one screen: collect every element's `color` grouped by what it MEANS,
1889
1995
  holding more than one value — or any value serving more than one meaning — is the finding, whatever
1890
1996
  the sizes are.
1891
1997
 
1892
- **Supporting text is `muted`, and a raw neutral in its place is the usual cause of the above.**
1893
- `color="muted"` is the token that means "this supports something else"; a hand-written
1894
- `color="zinc-500"` is a *different, lighter* neutral that typechecks, renders plausibly, and is
1895
- already spoken for — components mute a DEPARTED or disabled subject to it. Reach for the raw shade
1896
- and on a departed row the mute and the metadata come out identical, so the state stops signalling
1897
- anything. Grep a component for a raw neutral on `Text` before trusting that it reads muted.
1998
+ **Supporting text is `muted`, and there is no raw neutral to reach for instead.** `Text`'s `color`
1999
+ is a set of ROLES `default`, `muted`, `inactive`, `inverted`, `danger`, `warning`, `success` —
2000
+ and carries no palette names at all. That is not tidiness; a shade name in this position is wrong
2001
+ three ways.
2002
+
2003
+ *It cannot know its ground.* A call site names a colour without knowing what will be painted under
2004
+ it, and the kit paints several things under text: white, a `zinc-50` hover, a `zinc-100` selected
2005
+ row. The retired `zinc-500` cleared AA on white by 0.33 (4.83:1) and **failed on the selected row**
2006
+ at 4.40:1, for text at `sm`/`xs` where the bar is 4.5. A role is resolved once, centrally, against
2007
+ the darkest ground the kit permits — which is why `muted` is `zinc-600` (7.03:1 at worst) and not
2008
+ the lighter step that looks tempting in isolation.
2009
+
2010
+ *It splits one role across two inks.* Supporting lines under a VALUE were `muted` while a heading's
2011
+ `description` was a raw shade — one structural role, "the line that supports the thing above it",
2012
+ rendering in two colours decided by what happened to sit above it.
2013
+
2014
+ *And it cannot be re-pointed.* An inverted ground, a denser surface, a dark theme — a role name
2015
+ moves, a name that states its own value becomes a lie. `inverted` is the proof the kit already
2016
+ leans on: if shade names were right it would be called `white`.
2017
+
2018
+ **The vocabulary is TWO LAYERS, and only one is a decision.** `colors.zinc[600]` is a primitive —
2019
+ a value, owned by nobody, the same ramp anyone could paste. `TEXT_INK` (`@lotics/ui/text_ink`) is
2020
+ the semantic layer that says `muted` → `zinc.600`, and it is where the rule lives. Components
2021
+ address ink only through the second.
2022
+
2023
+ Keep them separate even when the design tool wants shade names. A designer picking `zinc-500` and
2024
+ an author typing `zinc-500` look like one source of truth, but what they agree on is a THIRD-PARTY
2025
+ RAMP — the decision itself is written in neither, so it gets re-made at every call site, which is
2026
+ precisely how one role came to render in two inks. `textInkTokens()` emits both layers in the W3C
2027
+ Design Tokens format with the alias intact (`{color.zinc.600}`, never a flattened hex), which is
2028
+ what a design tool imports as an ALIAS variable. One file, both tools, and re-pointing a role moves
2029
+ them together.
2030
+
2031
+ `inactive` is the one ink below the AA floor, deliberately — WCAG 1.4.3 exempts text in an inactive
2032
+ component, and a disabled label or an absent value is not content. Everything else is asserted by
2033
+ `text_ink_contrast.test.ts`, which iterates the vocabulary the `TextColor` type is DERIVED from, so
2034
+ a newly added ink is checked automatically rather than when someone remembers to add a case.
1898
2035
 
1899
2036
  **TEXT never wears `solid()`** — the 500 is a FILL shade (dots, series). A status word/number uses
1900
2037
  `Text`'s valence tokens: `color="danger"` (red-900) / `"warning"` (amber-700) / `"success"`
@@ -2061,9 +2198,10 @@ file and wrong on the screen. If something cannot be wrapped in `Text` (a raw `T
2061
2198
  axis), take the rung from `type_ramp` rather than picking a number.
2062
2199
 
2063
2200
  Weights: `regular` / `medium` /
2064
- `semibold`. Colors: `default` (zinc-900), `muted` (zinc-600), `inverted`, the valence set
2065
- (`danger`/`warning`/`success`), and the explicit `zinc-900/700/500/400` steps (`zinc-400` is the
2066
- disabled ink).
2201
+ `semibold`. Colors are ROLES and there are seven: `default`, `muted` (all supporting text),
2202
+ `inactive` (a disabled control's label, and a value that is not there yet), `onInverse` (requires a
2203
+ dark ground — illegal on the canvas), and the valence set (`danger`/`warning`/`success`). No
2204
+ palette names — see §"Supporting text is `muted`" for why a shade in this position is wrong.
2067
2205
 
2068
2206
  ## Touch & whitespace
2069
2207
 
@@ -385,6 +385,14 @@ where it must stay strictly inside its box — that is `framed`. A DISABLED `fra
385
385
  box: it drops the border and fill so it cannot promise a press, but it stays in line with the enabled
386
386
  fields beside it.
387
387
 
388
+ **A `framed` field in a RIGHT-ALIGNED column must be told to fill its cell.** It sizes to its
389
+ content, and the column's alignment then pins each box's RIGHT edge while its left edge lands
390
+ wherever that row's value happened to end — so a column of amounts reads as a ragged stack of
391
+ boxes, worse the more the values differ in length. Nothing measures wrong: every box is correct
392
+ on its own, no text clips, and the values ARE right-aligned. Give the cell's editor a full-width
393
+ wrapper so one box width serves the column. (`bare` has the mirror constraint above and cannot be
394
+ right-aligned at all.)
395
+
388
396
  **`bare` puts its whole affordance in the hover edge, so nothing may outrank that edge.** Pass the
389
397
  variant as a PROP; never express it as a resting `borderColor: "transparent"` in `style`, which is
390
398
  applied after the hover rule and erases the edge one line after it is computed. The failure is
package/docs/reviewing.md CHANGED
@@ -553,6 +553,50 @@ back-load the meaning, so the half that survives is the half the reader already
553
553
  **Source-side signature:** a fit or stack decision that reaches for a control/usability constant
554
554
  (`MIN_CONTROL_WIDTH`, a flex minimum) where the thing being measured holds prose.
555
555
 
556
+ ### 8h. Which columns SURVIVE the narrow width
557
+ **Collect** the header labels a register still renders at 375, and diff them against 1280.
558
+
559
+ A register sheds columns by rank rather than overflowing, so the question is never whether it
560
+ fits — it always does — but WHICH columns it kept. The signature of a wrong rank is a survivor
561
+ list missing the column the register exists for: an amount register that kept a name and a status
562
+ and dropped the amount. It is invisible at desk width, which is where it gets reviewed, and no
563
+ probe that measures a wide screen can see it.
564
+
565
+ Read the survivor list, not the ranks in source: an unannotated column takes a rank ABOVE every
566
+ explicit one, so a set where "the important ones are annotated" sheds the unannotated column
567
+ first — annotating some columns and not others is the failure, not the fix. If the register has
568
+ more than one column, either all of them carry a rank or none do.
569
+
570
+ ### 8h-bis. A cell that grows a second line lifts its first
571
+ **Collect** one column's first-line `y` MINUS its row's top, for every row; report the SET.
572
+
573
+ A register centres its cells, so a cell's content straddles the row's middle: one line sits ON the
574
+ centre, two lines straddle it, and the two states put the first line at different heights. A column
575
+ whose cell is sometimes one line and sometimes two therefore does not form a line down the page —
576
+ its figures drift by half a line on exactly the rows that grew a caption, which are the rows that
577
+ had something worth saying. A constant non-zero offset is fine (a one-line column centred in a
578
+ two-line row is correct); a SET with more than one value is the finding.
579
+
580
+ Fixing the cell that grew only MOVES it — the neighbour that did not grow is now the odd one. Fix
581
+ it by making the varying cell's height constant: render the optional line always, empty when there
582
+ is nothing to say. It costs no row height wherever some other cell is already two lines tall.
583
+
584
+ *Two traps in the fix.* A lone `" "` collapses in the web renderer and the reserved line silently
585
+ loses its height — use a non-breaking space. And a caption WIDER than its column does not clip: it
586
+ overflows into the neighbour, so two adjacent captions close up and read as one sentence. A column
587
+ holding a sentence is sized by the sentence, never by the figure above it.
588
+
589
+ ### 8i. Row controls against a multi-line cell
590
+ **Collect** each row's trailing control top and the top of the field it acts on; report
591
+ the SET of deltas, not the average.
592
+
593
+ A register's rows centre their cells, so a cell that grew a second line leaves its
594
+ siblings and the trailing control at a different height. The signature is a delta set
595
+ with more than one value — 10px on one row and 27px on another means the offset tracks
596
+ the data, which is worse than a constant one because the controls no longer form a
597
+ column. Non-zero-but-constant is the same defect with luckier data.
598
+
599
+
556
600
  ### 9. The absence pass — what catches "bland"
557
601
  Every probe above reads an element that EXISTS. None fires on a screen where nothing is wrong and
558
602
  nothing is right, which is the screen reported as bland. Defect-removal converges on *inoffensive*;
@@ -563,7 +607,10 @@ this converges on *distinctive*.
563
607
  ten used once each reads cheap.
564
608
  - **Identity marks** — on a screen whose subject is an entity, count them. Zero is the finding,
565
609
  and so is N identical: a mark that looks the same on every row carries nothing, and it is the
566
- largest, brightest thing there.
610
+ largest, brightest thing there. **Tally the GLYPH and the GROUND separately**, and report the
611
+ size of the largest GROUP rather than the count of distinct values — the two halves are derived
612
+ by different rules and fail independently, so a set of marks can be five distinct colours and
613
+ five copies of one letter, which "5 distinct marks" reports as healthy.
567
614
  - **What does the screen DECIDE?** A register listing every row at equal weight has no point of
568
615
  view. Usually the real answer when treatment fixes keep not landing.
569
616
  - **What is inherently visual in THIS data?** Usually the data itself: identity marks, status
@@ -684,7 +731,9 @@ instead of one blended string.
684
731
  ```js
685
732
  () => {
686
733
  const SZ = { '12px':'xs','14px':'sm','16px':'md','18px':'lg','20px':'lg','22px':'xl','24px':'xl','28px':'xxl','32px':'xxl/xxxl','48px':'xxxl' };
687
- const INK = { 'rgb(24, 24, 27)':'default','rgb(82, 82, 91)':'muted','rgb(113, 113, 122)':'zinc-500','rgb(161, 161, 170)':'zinc-400','rgb(255, 255, 255)':'inverted' };
734
+ // An ink the kit no longer has a NAME for is a finding, not a label: the
735
+ // vocabulary is default / muted / inactive / inverted + the valence set.
736
+ const INK = { 'rgb(24, 24, 27)':'default','rgb(82, 82, 91)':'muted','rgb(161, 161, 170)':'inactive','rgb(255, 255, 255)':'onInverse','rgb(113, 113, 122)':'OFF-SYSTEM zinc-500 (retired)','rgb(63, 63, 70)':'OFF-SYSTEM zinc-700 (retired)' };
688
737
  const out = [];
689
738
  const walk = (n) => {
690
739
  if (n.nodeType === 3 && n.textContent.trim()) {
@@ -143,7 +143,7 @@ function HomNayRow({ nv, selected, onPress }: { nv: NhanVien; selected: boolean;
143
143
  <Avatar name={nv.ten} size="md" />
144
144
  <View style={{ flex: 1, gap: 2, minWidth: 0 }}>
145
145
  <Text weight="medium" numberOfLines={1}>{nv.ten}</Text>
146
- <Text size="sm" color="zinc-500" numberOfLines={1}>{nv.chucVu}</Text>
146
+ <Text size="sm" color="muted" numberOfLines={1}>{nv.chucVu}</Text>
147
147
  </View>
148
148
  </View>
149
149
  </TableCell>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "47.6.0",
3
+ "version": "47.6.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./accordion": "./src/accordion.tsx",
@@ -35,6 +35,7 @@
35
35
  "./capture_row": "./src/capture_row.tsx",
36
36
  "./card": "./src/card.tsx",
37
37
  "./card_select_item": "./src/card_select_item.tsx",
38
+ "./cell_stack": "./src/cell_stack.tsx",
38
39
  "./charge_lines": "./src/charge_lines.tsx",
39
40
  "./check_circle": "./src/check_circle.tsx",
40
41
  "./checkbox": "./src/checkbox.tsx",
@@ -275,6 +276,7 @@
275
276
  "./tabs": "./src/tabs.tsx",
276
277
  "./text": "./src/text.tsx",
277
278
  "./text_disclosure": "./src/text_disclosure.tsx",
279
+ "./text_ink": "./src/text_ink.ts",
278
280
  "./text_input_field": "./src/text_input_field.tsx",
279
281
  "./text_link": "./src/text_link.tsx",
280
282
  "./text_utils": "./src/text_utils.ts",
package/src/alert.tsx CHANGED
@@ -180,7 +180,7 @@ class Alert {
180
180
  </Text>
181
181
  )}
182
182
  {message && (
183
- <Text nativeID={messageId} size="sm" color="zinc-500">
183
+ <Text nativeID={messageId} size="sm" color="muted">
184
184
  {message}
185
185
  </Text>
186
186
  )}
package/src/avatar.tsx CHANGED
@@ -45,7 +45,7 @@ export function Avatar(props: AvatarProps) {
45
45
  userSelect="none"
46
46
  size={AVATAR_TEXT[size]}
47
47
  weight="medium"
48
- color="inverted"
48
+ color="onInverse"
49
49
  accessibilityElementsHidden
50
50
  importantForAccessibility="no-hide-descendants"
51
51
  aria-hidden
@@ -53,7 +53,7 @@ export function Avatar(props: AvatarProps) {
53
53
  userSelect="none"
54
54
  size={AVATAR_TEXT[size]}
55
55
  weight="medium"
56
- color="inverted"
56
+ color="onInverse"
57
57
  accessibilityElementsHidden
58
58
  importantForAccessibility="no-hide-descendants"
59
59
  aria-hidden
@@ -146,7 +146,7 @@ export function AvatarGroup(props: AvatarGroupProps) {
146
146
  >
147
147
  {/* The rung's own type — the same one initials take. A fixed size here
148
148
  is how a stack ends up with a 14px count beside 12px initials. */}
149
- <Text size={AVATAR_TEXT[size]} weight="medium" color="zinc-700" aria-hidden>
149
+ <Text size={AVATAR_TEXT[size]} weight="medium" color="muted" aria-hidden>
150
150
  {`+${hidden}`}
151
151
  </Text>
152
152
  </View>
package/src/bar_chart.tsx CHANGED
@@ -314,8 +314,17 @@ export function BarChart(props: BarChartProps) {
314
314
  }
315
315
 
316
316
  const styles = StyleSheet.create({
317
+ // CONTENT-SIZED; the HOST decides whether a chart stretches.
318
+ //
319
+ // This was `flex: 1`, which RN-Web resolves to a zero basis on the parent's
320
+ // MAIN axis. A card body is a vertical stack, so the chart's height resolved to
321
+ // nothing and it painted its fixed-height content centred on that — outside the
322
+ // card. Every consumer then had to neutralise it by wrapping the chart in a row,
323
+ // and getting that wrapper wrong is silent at one width and broken at another.
324
+ // `Funnel` and `Breakdown` were content-sized all along, so the family did not
325
+ // even agree with itself. A host that genuinely wants a chart to fill a box
326
+ // still says so, on the box.
317
327
  chartContainer: {
318
- flex: 1,
319
328
  justifyContent: "center",
320
329
  },
321
330
  verticalChartWrapper: {
package/src/board.tsx CHANGED
@@ -290,7 +290,7 @@ export function BoardColumn(props: BoardColumnProps) {
290
290
  <View ref={ref} style={[styles.column, over && styles.columnOver]}>
291
291
  <View style={styles.columnHeading}>
292
292
  <View style={styles.columnIdentity}>{heading}</View>
293
- <Text size="sm" color="zinc-500" tabular>
293
+ <Text size="sm" color="muted" tabular>
294
294
  {count}
295
295
  </Text>
296
296
  <View style={styles.columnAction}>{action}</View>
package/src/button.tsx CHANGED
@@ -13,7 +13,7 @@ import { colors } from "./colors";
13
13
  import { CONTROL_RADIUS, FOCUS_RING } from "./control_surface";
14
14
  import { Icon, IconName } from "./icon";
15
15
  import { Ref } from "react";
16
- import { TextColor } from "./text_utils";
16
+ import { TextColor } from "./text_ink";
17
17
  import { useTooltip, UseTooltipOptions } from "./tooltip";
18
18
  import { useFocusRing, composeHandler } from "./use_focus_ring";
19
19
  import { getButtonIconColor } from "./button_colors";
@@ -119,7 +119,7 @@ export function Button(props: ButtonProps) {
119
119
  weight="medium"
120
120
  // `inverted` — the kit's own white-on-dark ink, the same token the primary
121
121
  // uses, so a brand button and the primary read as one family.
122
- color={brand && !disabledOrLoading ? "inverted" : getButtonTextColor(color, disabledOrLoading)}
122
+ color={brand && !disabledOrLoading ? "onInverse" : getButtonTextColor(color, disabledOrLoading)}
123
123
  userSelect="none"
124
124
  >
125
125
  {title}
@@ -206,10 +206,10 @@ export function getButtonTextColor(color?: ButtonColor, disabled?: boolean): Tex
206
206
  // Disabled fades the label to the zinc-400 disabled ink on EVERY variant —
207
207
  // the washed background alone is a one-step tint nobody reads as disabled.
208
208
  // (The primary keeps inverted ink; its zinc-300 surface carries the wash.)
209
- if (disabled) return color === "primary" ? "inverted" : "zinc-400";
209
+ if (disabled) return color === "primary" ? "onInverse" : "inactive";
210
210
  switch (color) {
211
211
  case "primary":
212
- return "inverted";
212
+ return "onInverse";
213
213
  case "secondary":
214
214
  return "default";
215
215
  case "danger":