@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 +6 -6
- package/MIGRATION.md +18 -0
- package/docs/catalog.md +50 -16
- package/docs/composition.md +114 -0
- package/docs/reviewing.md +8 -0
- package/docs/templates.md +74 -1
- package/package.json +3 -1
- package/src/bar_chart.tsx +20 -2
- package/src/brand_mark.tsx +17 -34
- package/src/chip_group.tsx +5 -3
- package/src/finding.tsx +18 -3
- package/src/line_chart.tsx +5 -14
- package/src/line_chart_labels.ts +32 -0
- package/src/page_header.tsx +13 -1
- package/src/stacked_bar_chart.tsx +197 -0
- package/src/switch_button.tsx +7 -8
- package/src/waterfall_chart.tsx +398 -0
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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`
|
|
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
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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.
|
|
496
|
-
|
|
497
|
-
|
|
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 `
|
|
1083
|
-
|
|
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
|
package/docs/composition.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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,
|
package/src/brand_mark.tsx
CHANGED
|
@@ -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
|
-
|
|
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} />
|
package/src/chip_group.tsx
CHANGED
|
@@ -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,
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
|
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
|
});
|