@lotics/ui 45.8.1 → 45.10.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
@@ -104,12 +104,12 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
104
104
  `error={overdue ? … }` is the tell: it makes a date due tomorrow read like one due in three
105
105
  months. → [catalog.md §Dates & times](./docs/catalog.md).
106
106
  - **A third-party channel takes `BrandMark`, our own glyphs take `Icon`.** `@lotics/ui/brand_mark`
107
- draws Facebook / Zalo / LinkedIn / X / Google Meet / Gmail / Outlook / Google Drive as solid
108
- single-colour silhouettes — a reader scanning a feed recognises the logo before the word. The
109
- names are an allowlist, so a channel with no mark (a phone call, an event, a website) fails to
110
- typecheck and falls back to `Icon` instead of borrowing another company's logo. `SwitchButton`
111
- takes one directly as `brand`, for a row naming an outside service.
112
- → [catalog.md §Text & formatting](./docs/catalog.md).
107
+ draws Facebook / Zalo / LinkedIn / X / Google Meet as solid single-colour silhouettes a reader
108
+ scanning a feed recognises the logo before the word. The names are an allowlist, so a channel with
109
+ no mark (a phone call, an event, a website) fails to typecheck and falls back to `Icon` instead of
110
+ borrowing another company's logo. A product's official full-colour logo is NOT one of these: the
111
+ kit ships no vendor artwork, so a row that names a product passes its own image into
112
+ `SwitchButton`'s `leading` slot. → [catalog.md §Text & formatting](./docs/catalog.md).
113
113
  - **The kit's fonts/colors/icons ARE the design system** — never a custom font, icon set, or
114
114
  hand-picked palette shade; color is `solid`/`tint`/`ramp` with ONE accent per screen.
115
115
  - **Hand-typed type is off-system — and it always lands too small.** Every run of language is
package/MIGRATION.md CHANGED
@@ -4,6 +4,24 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## 45.9.0
8
+
9
+ **`BrandMark` no longer carries product logos, and `SwitchButton` takes `leading` instead of
10
+ `brand`.** `BRAND_NAMES` drops `gmail`, `outlook` and `google-drive`; the kit's marks are the
11
+ single-colour CC0 silhouettes that NAME a channel — `facebook`, `zalo`, `linkedin`, `x`,
12
+ `google-meet` — and a monochrome stand-in for a product logo reads as a different thing.
13
+
14
+ Replace `<BrandMark name="gmail" />` with your own image asset. A row that named a product with
15
+ `<SwitchButton brand="gmail" …>` passes it through the new `leading` slot instead, which takes any
16
+ node:
17
+
18
+ ```tsx
19
+ <SwitchButton leading={<Image source={{ uri: logo }} style={{ width: 20, height: 20 }} />} … />
20
+ ```
21
+
22
+ A retired name no longer typechecks, and a bundle compiled against an older kit renders nothing
23
+ where the mark was rather than throwing.
24
+
7
25
  ## 45.5.0
8
26
 
9
27
  **`InlineSelect` and `InlineMemberSelect` are a `combobox`, not a `button`.** They rest as a
package/docs/catalog.md CHANGED
@@ -285,7 +285,11 @@ its max it reads `2,100 / 2,000 (105%)`, because a meter that says "100%" when y
285
285
  tells the reader they are exactly at the limit. Numbers format in the reader's locale, so
286
286
  never hand-format the value you pass in — when display precision differs from the true value
287
287
  (whole credits off a fractional balance), `formatValue` reshapes the caption text and leaves
288
- the fill and the percentage exact) / `StackedProgressBar` / `StepProgress`, `Breakdown` (a
288
+ the fill and the percentage exact) / `StackedProgressBar` (ONE whole split across segments,
289
+ filling its own track whatever the total) / `StackedBarChart` (SEVERAL wholes compared — rows
290
+ of stacked bars on one shared scale, so length says how big and the segments say what of) /
291
+ `StepProgress`, `WaterfallChart` (the BRIDGE — an opening level, the signed steps that move
292
+ it, the level they close at; revenue walked down to net), `Breakdown` (a
289
293
  stacked bar + ranked share rows, pressable to drill; `maxRows` folds the long tail behind a
290
294
  "Show N more" toggle — `labels` to localize — so several facet cards align to one height in
291
295
  a row), `Funnel` (a CONVERSION funnel — ordered stages as bars that NARROW; the step
@@ -484,17 +488,16 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
484
488
  `SubsectionHeading` instead. Pick between this and `Eyebrow` by what is LABELLED — a block or a
485
489
  value; composition.md § Eyebrow vs group lead has the table.
486
490
  - **`brand_mark`** — `BrandMark`: an OUTSIDE brand's mark, naming the channel a touchpoint
487
- happened on or the service a connection reaches — `facebook`, `zalo`, `linkedin`, `x`,
488
- `google-meet`, `gmail`, `outlook`, `google-drive`. Solid single-colour silhouettes in
489
- each brand's own hex, not the full-colour logos: a byline draws these at 14–20px and a
490
- five-colour logo turns to mud there. `size` (default 16) and `muted` (draw in the text colour).
491
- The name list is an ALLOWLIST — `BRAND_NAMES` is the array and `BrandName` derives from it, so a
492
- channel with no mark will not typecheck and the call site falls back to a generic `Icon` rather
493
- than rendering the wrong company's logo. Not for the app's
491
+ happened on — `facebook`, `zalo`, `linkedin`, `x`, `google-meet`. Solid single-colour silhouettes
492
+ in each brand's own hex, drawn from CC0 geometry. `size` (default 16) and `muted` (draw in the
493
+ text colour). The name list is an ALLOWLIST `BRAND_NAMES` is the array and `BrandName` derives
494
+ from it, so a channel with no mark will not typecheck and the call site falls back to a generic
495
+ `Icon` rather than rendering the wrong company's logo. Not for the app's
494
496
  own glyphs; that is `icon`. `x` is the one mark whose brand colour is BLACK, so it is the one
495
- that needs `muted` on a dark ground; the rest carry their own hue either way. Where a brand's
496
- official mark is a composite (Outlook's envelope-and-O), the entry carries the half that stays
497
- legible small the identifying monogram because a shape nobody can read is not the mark.
497
+ that needs `muted` on a dark ground; the rest carry their own hue either way. **Not a product
498
+ logo**: a monochrome stand-in for one reads as a different thing (a red "M" is not Gmail), the kit
499
+ ships no vendor artwork, and official artwork is not uniformly inlinable anyway pass your own
500
+ image to `SwitchButton`'s `leading` slot instead.
498
501
  - **`markdown`** — `Markdown`: the single canonical markdown renderer for chat, apps, and
499
502
  `AgentRun`; rich GFM markdown on web with copyable tables, plain-text on
500
503
  native; takes a markdown `children` string. **`variant`** decides whose type
@@ -1079,9 +1082,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1079
1082
  (optional leading mark + medium title left, `Switch` pinned right) where the whole row IS the
1080
1083
  switch (`accessibilityRole="switch"`, the inner Switch read-only). The settings-panel/menu
1081
1084
  row toggle. The leading slot takes EITHER `icon` (an `IconName`, the app's own glyph language)
1082
- or `brand` (a `BrandName`, an outside service's mark); `icon` wins if both are passed. A row
1083
- naming a third-party service takes `brand` the two sets are deliberately separate, so a
1084
- connector row cannot reach for a Lotics glyph to stand in for a company's logo.
1085
+ or `leading` (any node); `icon` wins if both are passed. A row naming an outside product passes
1086
+ its logo through `leading`, because the kit ships no vendor artwork of its own.
1085
1087
  - **`use_form`** — `useForm`: THE batch draft-form state hook — `values` = `initialValues` +
1086
1088
  an edits overlay (a revalidation refreshes untouched fields, no sync effect), `validate`
1087
1089
  (sync/async, gates submit, editing clears the field's error), `onSubmit(values, helpers)`
@@ -1782,7 +1784,11 @@ component rather than showing it at zero.
1782
1784
  register wants — a column of them turns "12 numbers per row" into a pattern the eye reads
1783
1785
  without stopping. Reach for a real chart the moment someone needs to read a value off it.
1784
1786
  - **`bar_chart`** / **`line_chart`** / **`pie_chart`** — `BarChart` / `LineChart` /
1785
- `PieChart`: the canonical SVG chart set (no recharts).
1787
+ `PieChart`: the canonical SVG chart set (no recharts). `LineChart` prints as many x labels
1788
+ as the track fits and thins the rest, anchored on the LAST point — the newest reading is the
1789
+ one a reader looks up, and anchoring there is what keeps the spacing uniform. The first
1790
+ point is labelled only when it clears the same distance, so a series whose length does not
1791
+ divide evenly drops its opening label rather than crowding the one beside it.
1786
1792
  - **`progress_bar`** — `ProgressBar`: the determinate meter; `compact` = ONE row, track + a
1787
1793
  plain sm tabular count beside it.
1788
1794
  - **`progress_ring`** — `ProgressRing`: the same meter in a circle, and the ONLY circular one. Same API shape as the bar — a real `value`/`max` rather than a
@@ -1794,7 +1800,35 @@ component rather than showing it at zero.
1794
1800
  bare ring renders no text, so it announces nothing — whatever owns it must say what it
1795
1801
  measures.
1796
1802
  - **`stacked_progress_bar`** — `StackedProgressBar`: one whole split across segments (a
1797
- status mix on one bar).
1803
+ status mix on one bar). Its segments size against EACH OTHER, not against `total` — the
1804
+ track is always full — because one whole is the whole of itself. That is the right model for
1805
+ one bar and the wrong one for a column of them: ten unequal totals draw ten equal bars, which
1806
+ reads as a chart and states nothing. Several wholes → `stacked_bar_chart`.
1807
+ - **`stacked_bar_chart`** — `StackedBarChart`: rows of stacked bars on ONE SHARED SCALE
1808
+ (`max` defaults to the largest row total). Bar LENGTH answers "how big is this one", the
1809
+ segment run answers "what is it made of", in one pass — spend against return per campaign,
1810
+ cost make-up per product line. `series` (key/label/colour, one hue family per dimension)
1811
+ drives a legend that is on by default; each `StackedBarRow` takes `label` + optional `meta`,
1812
+ a pre-formatted headline `value` with its `valueTone`, and a `caption` sentence, so colour is
1813
+ never the only channel. Between `stacked_progress_bar` (one whole, own track) and `breakdown`
1814
+ (one whole + ranked share rows beneath it): reach here the moment there are SEVERAL wholes to
1815
+ compare.
1816
+ - **`waterfall_chart`** — `WaterfallChart`: the BRIDGE — an opening level, the signed steps
1817
+ that move it, and the level they close at. Revenue walked down to net, budget to actual,
1818
+ opening balance to closing. `items` carry a `kind`: `basis` and `total` are LEVELS standing on
1819
+ zero, `delta` (the default) is a signed STEP floating between the level before it and the
1820
+ level after; colour derives from that — neutral basis, emerald up, red down, and a close in
1821
+ the ink of its own sign — so a caller passes no colours. `orientation` vertical columns |
1822
+ horizontal rows (take rows for a narrow container or long step names; a 70px column and a
1823
+ rotated label is not an answer). The span always includes zero, so a bridge that closes at a
1824
+ loss hangs below the baseline instead of floating on a scale that exaggerates every step.
1825
+ `waterfallLayout` is the pure geometry, exported and tested on its own: a bridge is only worth
1826
+ drawing if the floating bars land where the arithmetic says, and no screenshot confirms that.
1827
+ Pick it over `ledger` when the reader is scanning for SHAPE (a dashboard), and `ledger` when
1828
+ the figures must be read exactly and reconciled line by line (a record). Over `bar_chart`
1829
+ whenever the bars are steps in ONE arithmetic — five columns all starting at zero state a
1830
+ comparison nobody meant. The steps must be all of the arithmetic and must close on the total
1831
+ drawn; derive them from one computation, never two that can drift.
1798
1832
  - **`step_progress`** — `StepProgress`: N-of-M dots/segments progress. A real `progressbar`
1799
1833
  (valuenow/min/max) because its segments are decoration over ONE quantity — the opposite
1800
1834
  case from `stepper`, whose steps carry content a `progressbar` would hide. Pass the stage
@@ -10,6 +10,40 @@ them produces off-system UI.
10
10
 
11
11
  ## The form comes before the treatment
12
12
 
13
+ **If the domain already has a standard shape, that is the answer — build it.** A parcel's journey
14
+ has looked the same in every carrier's app for twenty years: an ordered set of stages, where it is
15
+ now, when each was stamped, and where it is stuck. A ledger has looked like a ledger since before
16
+ software. A calendar, a seating chart, a pipeline, a boarding pass — each is a form the reader has
17
+ already learned somewhere else, and matching it means they arrive knowing how to read the screen.
18
+ Go and look at how the industry does it before designing something new; a shape that is unfamiliar
19
+ in a domain that has a familiar one is a cost paid by every user, forever, to no end.
20
+
21
+ That is a licence, not a leash. **The rules in this document constrain treatment, not invention.**
22
+ Where the job needs a component the kit does not have, build it — the catalog is where solved
23
+ problems live, not a ceiling on what a screen may be, and a screen assembled only from the parts
24
+ nearest to hand is how everything ends up a register with a header. The bar for a new composition
25
+ is that it is the RIGHT shape for the subject and that it obeys the language below: the kit's type
26
+ ramp, colours, spacing and states. Reach for the shape the work deserves, then dress it in the
27
+ kit's clothes.
28
+
29
+ **Which rules bind, and which advise.** Read every rule here for what it ENCODES, because the two
30
+ kinds are answered differently:
31
+
32
+ - **A rule that prevents a known HARM binds.** A page that scrolls sideways on a phone, an act that
33
+ cannot be undone, a figure that is typed where it should be computed, a machine's guess rendered
34
+ like a person's decision, a control a keyboard cannot reach — these are settled, they were paid
35
+ for once, and re-opening them just re-buys the defect.
36
+ - **A rule that picks among valid FORMS advises.** Which shape fits this subject, how a set is cut,
37
+ what the reader sees first — the space of jobs is unbounded and the best answer is regularly one
38
+ no document here has named. Treat these as the accumulated experience they are, then decide.
39
+
40
+ The distinction is not hard-versus-soft, it is harm-versus-choice — and the second kind is where
41
+ the good work happens. **Most of what reads as inventive is this document's FIRST rule being
42
+ followed well, not escaped**: a set whose tabs are the stages its records pass through is not a
43
+ departure from "name what the subject IS", it is that rule landing. Naming the subject honestly is
44
+ what produces a shape nobody has seen; reaching for the nearest template is what produces the
45
+ fiftieth register with a header.
46
+
13
47
  **Name what the subject IS, then reach for parts.** A set of records is a register; a thing moving
14
48
  through ordered places is a rail; work assigned to finite resources over time is a board; a
15
49
  sequence of events is a timeline; a population whose point is its deviant minority is a queue
@@ -563,6 +597,14 @@ read-only drill-downs expand via `Accordion` or glance via `Peek`; every other r
563
597
  surface is the door: register rows are `PressableRow` (full-bleed wash including nested controls),
564
598
  Divider-separated.
565
599
 
600
+ **How MUCH the row reveals decides expand-vs-drawer.** An expanding row is for a few facts the
601
+ reader takes in without losing their place — the rows around it stay visible, which is the whole
602
+ point. Once the reveal is a screenful, it pushes the rest of the register off the fold and the
603
+ reader is now reading a record through a slot cut in a list: they cannot see the set they came for,
604
+ and they have to remember to collapse it. That is a `Drawer`. The threshold is not a row count, it
605
+ is whether the neighbouring rows survive the expansion; if opening one row means the list is gone,
606
+ open it properly.
607
+
566
608
  | `PressableRow` variant | Shape | Use |
567
609
  |---|---|---|
568
610
  | `register` (default) | rounded (radius 10) full-width hover/open/`marked` wash | THE record list |
@@ -687,6 +729,15 @@ The tokens are one step darker for exactly that reason, and they are what `IconB
687
729
  hover language. Bleed the wash outward (`marginHorizontal: -8` against its own padding) so the
688
730
  cell's content stays on the column's edge — see `ROW_WASH_BLEED`.
689
731
 
732
+ **A pressable cell's target is the whole CELL, not the text inside it.** Bind the press and the
733
+ hover to the cell box, so the reader aims at a column rather than at a word. Wrapping only the name
734
+ leaves a target whose shape nobody can predict — it changes with the length of the value, it is a
735
+ sliver on a short one, and the wash it lights stops short of the cell's edges so the highlight
736
+ appears to bleed into the neighbouring thumbnail or supporting line rather than belonging to
737
+ anything. If a cell holds an image and a name and pressing it opens the same thing, the image is
738
+ part of the target too; a cell where only some of the content responds is telling the reader there
739
+ are two things there when there is one.
740
+
690
741
  **Size a flexible column by what it CARRIES, measured.** A `flex` share is a claim about content;
691
742
  when the content shrinks — a value moves to the record, a stacked pair becomes a count — the
692
743
  share left behind is white space, and the columns beside it pay for it. Measure the widest row's
@@ -1330,6 +1381,27 @@ A flat register sorted by a column hands the reader every row at equal weight an
1330
1381
  deciding to them: "which of these needs me today" is answered by reading all of them. `TableGroup`
1331
1382
  moves that decision into the screen — a titled band of rows with a count.
1332
1383
 
1384
+ **Flat is the default; grouping is something the reader turns ON.** A register opens as one
1385
+ continuous list, and the control that splits it into bands lives in the filter band beside the
1386
+ other view controls — an explicit "group by" the reader operates, defaulting to none. Authored
1387
+ bands fail three ways at once: the value they group on is unsortable, it is unfilterable, and it
1388
+ vanishes the moment the reader wants a different cut — so the screen holds an opinion the reader
1389
+ cannot inspect, disagree with, or put down. Worse, it decides on their behalf every time they open
1390
+ the screen, including the many times the triage it encodes is not the question they came with.
1391
+
1392
+ **The status COLUMN is the floor.** Whatever the register can group on also exists as a column the
1393
+ reader can see, sort and filter — the column is the fact, the grouping is one way of looking at it.
1394
+ Ship the column first, offer grouping as a control, and let `TableGroup` render whatever the reader
1395
+ picked. Everything below still governs how the bands look and behave once they are on; it just no
1396
+ longer describes the state the screen starts in.
1397
+
1398
+ **A value's filter renders the value the same way its cell does.** If the status cell is a
1399
+ `Badge variant="dot"`, the status filter's options are dot badges too — same vocabulary, same
1400
+ colours, same shapes. The reader is matching what they just saw in the rows against what the
1401
+ control offers; re-rendering it as plain text makes them translate between two presentations of
1402
+ one field, and the two drift the first time either side gains a value. This holds for every
1403
+ custom-rendered value, not just status.
1404
+
1333
1405
  **Group by what implies a different ACTION, never by a category the reader can already see.**
1334
1406
  Bands like *Waiting on you* / *Gone quiet* / *Open* / *Closed* each name a different response, so
1335
1407
  the grouping tells the reader something no column does. Grouping by a value that is already a
@@ -1596,6 +1668,37 @@ Type SCALE is the fourth, and it belongs with the others: range = largest ÷ sma
1596
1668
  screen, and under about 2× reads flat *at any amount of colour*. Scale is the one hierarchy device
1597
1669
  colour cannot substitute for. A register with no page band tops out near 1.33×.
1598
1670
 
1671
+ **The test every visual has to pass: does it make the screen CLEARER *and* better-looking?** Both,
1672
+ not either. A visual that only decorates is clutter and goes; a visual that only informs but fights
1673
+ the composition is a chart nobody reads. The ones that pay are the ones where the two are the same
1674
+ act — a status colour that is also the scan path, a product thumbnail that is also the identifier, a
1675
+ trend line that is also the answer. That framing is what keeps "make it less bland" from becoming
1676
+ "add more things", which is the failure this section is most often misread into.
1677
+
1678
+ **And the same test cuts the other way, which the rest of this document does not say often enough.**
1679
+ Nearly every rule here removes something — SUBTRACT is a gate, the trim test hunts restatement, the
1680
+ absence pass looks for what should not be there. Follow only that gradient and you converge on a
1681
+ screen that is clean, defensible, and *does not tell the reader enough to act*. **Minimal is not the
1682
+ same as clear.** A number with no comparison, a status with no age, a total with no composition, a
1683
+ row that forces a click to answer the question it exists to answer — each is a screen that passed
1684
+ every subtraction and failed the reader. Withholding is a defect exactly as much as clutter is; it
1685
+ is simply the one that survives review, because nothing on the screen looks wrong. Ask what the
1686
+ reader must DECIDE, then check that the screen holds every fact that decision needs.
1687
+
1688
+ **Where the data has a history, show the trajectory, not just the latest value.** A figure states
1689
+ where things stand; a line, a bar series or a `Sparkline` states where they are GOING, and the
1690
+ direction is usually the decision — a balance that is falling, a return rate that is climbing, a
1691
+ week that broke the pattern. This is the most common honest source of visual interest in work
1692
+ software, because the shape *is* information. It is also the one most often left out, since the
1693
+ current value is what the query returns first.
1694
+
1695
+ **A register whose entity has ordered stages should render the stage, not just name it.** A word in
1696
+ a cell makes the reader know the vocabulary and its order; a small coloured progress indicator makes
1697
+ position legible at a glance and lets rows be compared down the column — which is the question a
1698
+ register is asked. Colour it by what the stage MEANS (moving / stalled / done / failed), so the
1699
+ column reads as a state of play rather than a palette. Do not spend this on a two-state field: a
1700
+ progress rendering of "open / closed" is a badge wearing a costume.
1701
+
1599
1702
  ## Where the accent goes — "where you are", and nothing else
1600
1703
 
1601
1704
  `colors.accent` paints an active FILTER — the one place a brand hue answers "where the reader is"
@@ -1856,6 +1959,17 @@ Anything pressable is ≥ 40px tall (8px minimum gap between pressables). 16px b
1856
1959
  canvas padding, 16–20 inside bands, 10–12 between content lines. Density comes from alignment +
1857
1960
  hierarchy, not cramming.
1858
1961
 
1962
+ **Space states what belongs together, so the gap INSIDE a unit is always smaller than the gap
1963
+ BETWEEN units.** A line item's name, its code and its quantity are one thing and sit tight; the
1964
+ next line item starts after a clearly larger gap. Get the ratio backwards — or make both gaps
1965
+ equal — and the reader has to reconstruct the grouping from the content, because the layout is
1966
+ telling them the parts are peers of the wholes. The tell is a stack that reads as an undifferentiated
1967
+ list when you unfocus your eyes, or a supporting line drifting so far from the value it supports
1968
+ that it looks like the next unit's first line. This is the same law the gap-ratio probe in
1969
+ [reviewing.md](./reviewing.md) measures (~2:1 between over inside); it is stated here because it is
1970
+ a composition decision, not a treatment one — no amount of type or colour work fixes a stack whose
1971
+ spacing groups the wrong things.
1972
+
1859
1973
  **A control's height comes from `CONTROL_HEIGHT`, never from padding tuned against a leading.**
1860
1974
  Padding that happens to sum to the band height is a second owner of that number, and it drifts the
1861
1975
  moment the type ramp moves — which the ramp is allowed to do. The signature is one control
package/docs/reviewing.md CHANGED
@@ -269,6 +269,14 @@ spacing distinguishes them.
269
269
  do not rescue it. → [composition.md](./composition.md) §"Numbers, money, dates".
270
270
  - **A slot holding another component's part must be sized by that component's constant** — a
271
271
  24px mark in an 18px column sits 3px outside the surface.
272
+ - **Labels placed along a track: collect each one's HEIGHT and compare across the set.** Anything
273
+ positioned by a fraction of its container — an axis tick, a marker on a timeline, a legend on a
274
+ meter — is sized by the space left to the container's EDGE, so the one at 100% shrink-wraps to a
275
+ few pixels and breaks its own text across two lines while every sibling measures fine. Height,
276
+ not width, is the signature: a wrapped label is *taller* than its neighbours, which no
277
+ alignment check and no screenshot at the width where it happens to fit will show. The same
278
+ collection catches a fixed pixel nudge standing in for centring — spacing between label CENTRES
279
+ that drifts across the track means each one was offset by a guess at its own width.
272
280
  - **A row beat belongs to the PAGE** — collect every row primitive's height on one screen before
273
281
  calling any of them right.
274
282
 
package/docs/templates.md CHANGED
@@ -146,6 +146,36 @@ name `Peek`s the dossier). Also exercises the wider chart family (`LineChart`, `
146
146
  filter actually filters the KPI datasets. Start here for any "state of the operation"
147
147
  landing screen.
148
148
 
149
+ **A dashboard is VISUAL first.** Its reader is scanning for shape — is this up or down, where is
150
+ the outlier, which slice dominates — and shape is what a chart states and a number does not. A
151
+ dashboard built as rows of figures is a report wearing a dashboard's name: correct, and it makes
152
+ the reader do the comparing that the screen was supposed to do for them. Lead with the charts, let
153
+ the figures support them. `KPIStrip` earns its place as the headline row above the charts, never as
154
+ the whole screen.
155
+
156
+ The chart family is `BarChart`, `LineChart`, `PieChart`, `Funnel`, `Heatmap`, `Sparkline`,
157
+ `ProgressRing`, `ProgressBar`, `StackedProgressBar`, `StackedBarChart`, `WaterfallChart`,
158
+ `StepProgress` — and **that list is a starting inventory, not the boundary.** When the truth needs
159
+ a shape the kit has no component for — a dual-axis pairing spend against return, a bullet against
160
+ target — **build it into `@lotics/ui`** rather than hand-rolling it inside one app. A chart
161
+ assembled from `View`s in an app screen is invisible to every other app, carries its own spacing
162
+ and colour decisions, and is the exact duplication `src/` exists to prevent; the same component in
163
+ the kit is measured, themed and reusable the day after. Graduating it is the intended path, not an
164
+ imposition on the kit (→ AGENTS.md, "components graduate into `src/` + an `examples/tpl_*` + a
165
+ catalog entry").
166
+
167
+ Read the `dataviz` guidance before choosing a chart type or a palette — picking the form is a
168
+ separate decision from drawing it well.
169
+
170
+ **No AI run belongs on a dashboard.** A dashboard's entire contract is that the answer is already
171
+ there — the reader opens it and knows. A panel that has to be asked, that streams, that costs
172
+ credits and can fail, breaks exactly the property the surface exists for, and it lands the reader
173
+ on a spinner in the one place they came for an instant read. If a figure is worth showing, compute
174
+ it and show it; if the question is open-ended enough to need a model, it belongs in chat, where
175
+ interrogating the data is the point and waiting is understood. The same test disqualifies anything
176
+ else that makes a dashboard *ask before it answers*: a required mode chooser, an empty state
177
+ awaiting a selection, a panel that loads only on click.
178
+
149
179
  ### `tpl_stock` — the drill-down overview (large populations)
150
180
 
151
181
  Monitoring a population too large to browse (10,000+ units). The funnel: `KPIStrip`
@@ -194,7 +224,31 @@ master-detail panel.
194
224
  ### `tpl_item_list` — THE canonical register
195
225
 
196
226
  The one work-execution list shape; it subsumes approvals, dispatch, batch-building, and run
197
- screens — register, per-row action, gated selection, and act-on-many in one. The page:
227
+ screens — register, per-row action, gated selection, and act-on-many in one.
228
+
229
+ **A register is a WORK surface, and that fixes its altitude.** The reader came to narrow a set
230
+ down and act on what is left — filter, scan, check, do. Everything on the screen serves that or it
231
+ is in the way. So the aggregate belongs elsewhere: **no `KPIStrip`, no chart panel, no report band
232
+ above the rows.** The register's whole summary is the one `SummaryLine`, and what it states is
233
+ *this filtered view* — money at stake, how many need a decision, the worst case in view — never a
234
+ count of rows the reader can already see. Aggregates are a DASHBOARD's job, and the two surfaces
235
+ answer different questions: a dashboard says how the operation is doing, a register is where the
236
+ work gets done. Putting a KPI band on a register pushes the rows below the fold to say something
237
+ the reader did not come for. (A per-row micro-visual — a `Sparkline`, a bar in a cell — is row
238
+ DATA, not a panel, and is fine.)
239
+
240
+ The corollaries, each of which a register is routinely missing:
241
+
242
+ - **Filter on the axis its reader actually thinks in**, and make anything transactional filterable
243
+ by DATE. A register you can only scroll is a report.
244
+ - **Sortable columns wherever the reader ranks** — dates and amounts above all. "Which is biggest,
245
+ which is oldest" is the most common question a register is asked.
246
+ - **Put intake ON the register.** The set is added to from the surface that lists it, and one CTA
247
+ per tab covers the tab's own shape. Where a file is the real-world input, the CTA pair is manual
248
+ entry beside a file-drop that proposes rows for review.
249
+ - **If a row can carry a document, the register states whether it has one.** Absence is the
250
+ information — a badge that shows nothing for a row missing its invoice is exactly the column
251
+ that earns its width.
198
252
 
199
253
  - **One toolbar row** — search + a status `Select` + facet `FilterChip`s LEFT, the New CTA
200
254
  RIGHT; then a light `SummaryLine` of the filtered view BELOW it, above the rows — the counts are what the toolbar just produced, so they read after it, not before.
@@ -697,6 +751,25 @@ billing, and quick-capture templates. Top → bottom:
697
751
  below that it collapses to the pinned bar. Sizing the column is the SHELL's job — inner
698
752
  blocks are plain `width: "100%"`, never a second `maxWidth` + `alignSelf: "center"`.
699
753
 
754
+ ### Anything that ADVANCES a record must be able to walk it back
755
+
756
+ This holds wherever the progression is rendered — a stage CTA on a register row, a tick in a
757
+ drawer's checklist, a bulk action over a selection, the handoff compound below. **Every control
758
+ that moves a record forward ships its inverse, in reach of the person who pressed it.**
759
+
760
+ A one-way control makes a misclick permanent, and stage CTAs invite the misclick: they sit on the
761
+ row, they are the fastest thing on the screen, and the row leaves the tab the moment it is pressed
762
+ — so the feedback for "that was wrong" is the record vanishing, with no obvious way back and often
763
+ no obvious place it went. The reader is then hunting through tabs for a record they did not mean
764
+ to move, which is a worse outcome than the one the CTA saved them.
765
+
766
+ The undo belongs **beside the forward act, as its secondary** — the pair reads as one decision with
767
+ two directions, the same pairing the kit already uses for a commit and the control that undoes it.
768
+ Do not bury it in an `ActionMenu`: the person who needs it needs it in the second after the press,
769
+ which is exactly when they will not go looking. Where a stage is genuinely irreversible (money has
770
+ moved, a document has been filed, something left the building), the honest answer is not a missing
771
+ undo — it is a confirmation *before* the act, and copy that says why it cannot be taken back.
772
+
700
773
  ### The handoff shape — a CONNECTED checklist, and where it lives
701
774
 
702
775
  One record walking N ordered positions, where **the position decides what you can see and do**:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "45.8.1",
3
+ "version": "45.10.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -83,6 +83,8 @@
83
83
  "./gantt": "./src/gantt/index.ts",
84
84
  "./progress_ring": "./src/progress_ring.tsx",
85
85
  "./stacked_progress_bar": "./src/stacked_progress_bar.tsx",
86
+ "./stacked_bar_chart": "./src/stacked_bar_chart.tsx",
87
+ "./waterfall_chart": "./src/waterfall_chart.tsx",
86
88
  "./legend_item": "./src/legend_item.tsx",
87
89
  "./breakdown": "./src/breakdown.tsx",
88
90
  "./funnel": "./src/funnel.tsx",
package/src/bar_chart.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { View, StyleSheet } from "react-native";
1
+ import { View, StyleSheet, type ViewStyle } from "react-native";
2
2
  import { useMemo } from "react";
3
3
  import { Text } from "./text";
4
4
  import { colors } from "./colors";
@@ -293,7 +293,25 @@ const styles = StyleSheet.create({
293
293
  horizontalAxisTick: {
294
294
  position: "absolute",
295
295
  alignItems: "center",
296
- transform: [{ translateX: -12 }],
296
+ /**
297
+ * `max-content` + a half-width shift, so the label sizes to its own text and
298
+ * then centres on its tick at ANY label length.
299
+ *
300
+ * Both halves are load-bearing. An absolutely-positioned box with only
301
+ * `left` set is sized by the space remaining to the container's RIGHT EDGE,
302
+ * so the tick at 100% shrink-wraps to a few pixels and breaks its text
303
+ * across two lines — visible only at the widths where that label happens
304
+ * not to fit, which is why it survives a desktop-only pass. And a fixed
305
+ * pixel nudge cannot centre anything: it assumes one label width, so every
306
+ * tick is off by however far its own label differs from the guess.
307
+ */
308
+ transform: "translateX(-50%)",
309
+ // The one style here RN cannot type: `DimensionValue` covers numbers and
310
+ // percentages, and `max-content` is a real CSS width that react-native-web
311
+ // passes straight through. Nothing typed does this job — a number floor
312
+ // only moves the breaking point to the next longer label, and dropping to
313
+ // the track's own width is what breaks it in the first place.
314
+ ...({ width: "max-content" } as unknown as ViewStyle),
297
315
  },
298
316
  tickMark: {
299
317
  width: 1,
@@ -7,16 +7,7 @@ import { Svg, Path } from "react-native-svg";
7
7
  * email that is not tied to one provider. Inventing a mark for those is how a
8
8
  * byline ends up claiming a channel the record does not carry.
9
9
  */
10
- export const BRAND_NAMES = [
11
- "facebook",
12
- "zalo",
13
- "linkedin",
14
- "x",
15
- "google-meet",
16
- "gmail",
17
- "outlook",
18
- "google-drive",
19
- ] as const;
10
+ export const BRAND_NAMES = ["facebook", "zalo", "linkedin", "x", "google-meet"] as const;
20
11
 
21
12
  export type BrandName = (typeof BRAND_NAMES)[number];
22
13
 
@@ -37,6 +28,15 @@ interface Mark {
37
28
  * Path data is Simple Icons (CC0), so the geometry carries no licence of its
38
29
  * own; the marks themselves stay the trademarks of their owners and are used
39
30
  * here only to NAME the channel a touchpoint happened on.
31
+ *
32
+ * A product's OFFICIAL full-colour logo does not belong here, and the reason is
33
+ * not size — at 20px the real Gmail and Drive artwork reads perfectly well. It
34
+ * is provenance and shape: official artwork is the vendor's asset rather than
35
+ * CC0 geometry, and it is not uniformly inlinable — Gmail and Drive are flat
36
+ * multi-path files, Outlook is fourteen gradients across fifteen paths. A kit
37
+ * that carried two of them and gave up on the third would be the worst of both.
38
+ * Those live in the app's own image assets, and a row that wants one passes it
39
+ * in (`SwitchButton`'s `leading` slot).
40
40
  */
41
41
  const MARKS: Record<BrandName, Mark> = {
42
42
  facebook: {
@@ -63,29 +63,6 @@ const MARKS: Record<BrandName, Mark> = {
63
63
  color: "#00897B",
64
64
  path: "M5.53 2.13 0 7.75h5.53zm.398 0v5.62h7.608v3.65l5.47-4.45c-.014-1.22.031-2.25-.025-3.46-.148-1.09-1.287-1.47-2.236-1.36zM23.1 4.32c-.802.295-1.358.995-2.047 1.49-2.506 2.05-4.982 4.12-7.468 6.19 3.025 2.59 6.04 5.18 9.065 7.76 1.218.671 1.428-.814 1.328-1.64v-13a.828.828 0 0 0-.877-.825zM.038 8.15v7.7h5.53v-7.7zm13.577 8.1H6.008v5.62c3.864-.006 7.737.011 11.58-.009 1.02-.07 1.618-1.12 1.468-2.07v-2.51l-5.47-4.68v3.65zm-13.577 0c.02 1.44-.041 2.88.033 4.31.162.948 1.158 1.43 2.047 1.31h3.464v-5.62z",
65
65
  },
66
- // The envelope silhouette, in Gmail's red. The full mark is five colours
67
- // meeting along the flap's diagonals — at 16px those seams alias into a
68
- // brown smear, which is precisely the case the note above is about.
69
- gmail: {
70
- color: "#EA4335",
71
- path: "M24 5.457v13.909c0 .904-.732 1.636-1.636 1.636h-3.819V11.73L12 16.64l-6.545-4.91v9.273H1.636A1.636 1.636 0 0 1 0 19.366V5.457c0-2.023 2.309-3.178 3.927-1.964L5.455 4.64 12 9.548l6.545-4.909 1.528-1.146C21.69 2.28 24 3.434 24 5.457z",
72
- },
73
- // The bare "O" monogram — an oval ring, drawn as two subpaths of OPPOSITE
74
- // winding so the nonzero fill leaves the counter open. Simple Icons' Outlook
75
- // path is the envelope-and-O composite, and it was tried here first: at 20px
76
- // it collapses into a blue rectangle with no readable feature, and it is not
77
- // much better at 64. The O alone is what identifies the brand at a glance and
78
- // it is the half that survives the size.
79
- outlook: {
80
- color: "#0078D4",
81
- path: "M12 1.2a8.7 10.8 0 1 0 0 21.6 8.7 10.8 0 1 0 0-21.6ZM12 6.6a4.1 5.4 0 1 1 0 10.8 4.1 5.4 0 1 1 0-10.8Z",
82
- },
83
- // Drive's triangle, drawn as one silhouette. The three-colour original is the
84
- // same aliasing problem as Gmail's, and worse — its colours meet at a point.
85
- "google-drive": {
86
- color: "#1FA463",
87
- path: "M12.01 1.485c-2.082 0-3.754.02-3.743.047.011.024 1.708 3.001 3.774 6.62l3.76 6.574h7.46c.001-.02-1.696-2.998-3.762-6.618l-3.76-6.623zm-4.28.939a5.482 5.482 0 0 0-.309.443L3.71 9.17 0 15.647l1.863 3.209a338.99 338.99 0 0 0 1.914 3.28c.04.045 7.55-13.116 7.55-13.234 0-.045-1.487-2.652-3.305-5.793zM9.97 15.463c-.639 1.099-1.943 3.361-2.9 5.03l-1.737 3.021h15.09c0-.019.848-1.484 1.884-3.259l1.882-3.222H9.97z",
88
- },
89
66
  };
90
67
 
91
68
  export interface BrandMarkProps {
@@ -112,7 +89,13 @@ export interface BrandMarkProps {
112
89
  */
113
90
  export function BrandMark(props: BrandMarkProps) {
114
91
  const { name, size = 16, muted, testID } = props;
115
- const mark = MARKS[name];
92
+ // A name outside the allowlist does not typecheck, so reaching here means a
93
+ // deployed app compiled against an older kit that still had the name. Render
94
+ // nothing rather than dereferencing undefined: a mark is decoration beside a
95
+ // label that already names the channel, and throwing takes the whole render
96
+ // tree of an app that is otherwise fine.
97
+ const mark = MARKS[name] as Mark | undefined;
98
+ if (!mark) return null;
116
99
  return (
117
100
  <Svg width={size} height={size} viewBox="0 0 24 24" testID={testID}>
118
101
  <Path d={mark.path} fill={muted ? "currentColor" : mark.color} />
@@ -4,9 +4,11 @@ import { PressableHighlight } from "./pressable_highlight";
4
4
  import { chipSurfaceStyle } from "./control_surface";
5
5
 
6
6
  // One-of-N chips: every option visible, one tap to switch, the row wraps on
7
- // narrow widths. Quiet zinc styling — bordered white at rest, dark fill when
8
- // active (color stays reserved for status + primary actions). Two jobs, one
9
- // control:
7
+ // narrow widths. Quiet zinc styling — bordered white at rest, and the active one
8
+ // keeps that same ground and takes a doubled zinc-900 EDGE instead, so a
9
+ // selection never spends the brand and never speaks the row's language (the
10
+ // reasoning is on `chipSurfaceStyle`). Colour stays reserved for status +
11
+ // primary actions. Two jobs, one control:
10
12
  // - a SMALL, HOT FILTER the user flips between constantly ("narrow this
11
13
  // view") — model the unfiltered state as an explicit option (e.g. "All").
12
14
  // - a small REQUIRED single-select in a form / composer (a call outcome, a
package/src/finding.tsx CHANGED
@@ -142,7 +142,7 @@ export function Finding(props: FindingProps) {
142
142
  {readings.map((reading, index) => (
143
143
  <View key={`${reading.source}-${index}`} style={styles.reading}>
144
144
  <SourceName label={reading.source} onOpen={props.onOpenSource} />
145
- <Text size="sm" weight="medium" tabular numberOfLines={1}>
145
+ <Text size="sm" weight="medium" tabular numberOfLines={1} style={styles.readingValue}>
146
146
  {reading.value}
147
147
  </Text>
148
148
  </View>
@@ -181,13 +181,18 @@ export function Finding(props: FindingProps) {
181
181
  function SourceName({ label, onOpen }: { label: string; onOpen?: (source: string) => void }) {
182
182
  if (!onOpen) {
183
183
  return (
184
- <Text size="sm" color="muted" numberOfLines={1}>
184
+ <Text size="sm" color="muted" numberOfLines={1} style={styles.sourceName}>
185
185
  {label}
186
186
  </Text>
187
187
  );
188
188
  }
189
189
  return (
190
- <PressableHighlight onPress={() => onOpen(label)} accessibilityRole="link" accessibilityLabel={label}>
190
+ <PressableHighlight
191
+ onPress={() => onOpen(label)}
192
+ accessibilityRole="link"
193
+ accessibilityLabel={label}
194
+ style={styles.sourceName}
195
+ >
191
196
  <Text size="sm" weight="medium" decoration="underline" numberOfLines={1}>
192
197
  {label}
193
198
  </Text>
@@ -217,4 +222,14 @@ const styles = StyleSheet.create({
217
222
  // each source with its own value at every width and needs no separator at all.
218
223
  readings: { flexDirection: "column", alignItems: "flex-start", gap: 4 },
219
224
  reading: { flexDirection: "row", alignItems: "baseline", gap: 5, minWidth: 0 },
225
+ // The same split as `title`/`delta` one row up, for the same reason: the SOURCE
226
+ // is a label and gives way, the VALUE is the figure and never does. Both halves
227
+ // are required on the label — a React Native `Text` is `flexShrink: 0` unlike
228
+ // the web, and `flexShrink` alone then floors it at its longest word. Without
229
+ // this pair the row laid both children out at intrinsic width and simply ran
230
+ // off the card: measured at x=790 in a 390px frame, clipped, with no page
231
+ // scroll to reach it — so on a phone the evidence a Finding exists to show was
232
+ // the part you could not read.
233
+ sourceName: { flexShrink: 1, minWidth: 0 },
234
+ readingValue: { flexShrink: 0 },
220
235
  });