@lotics/ui 47.0.0 → 47.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/catalog.md CHANGED
@@ -86,6 +86,13 @@ inline: a picker option, an assignee, a `select_member` value. Pure: resolve the
86
86
  your directory and pass `name` / `image`; never hand-roll `Avatar` + `Text`. (`MemberSelect`
87
87
  renders these per option.)
88
88
 
89
+ **`showAvatar={false}` where the member is ONE FIELD AMONG COLUMNS** — an owner, an assignee,
90
+ a last-edited-by. Keep the face where the member IS the subject (a roster, a picker option, a
91
+ profile). In a supporting column it fails twice: it competes with the mark the row already
92
+ carries for its own subject, and on a real book a few people own hundreds of rows, so the
93
+ column draws one repeated disc — which `composition.md` §Identity marks calls a mark that
94
+ "carries nothing and is the largest, brightest thing there".
95
+
89
96
  **To answer "who IS this?" — `MemberPeek`**, the same chip made a door onto a
90
97
  `MemberProfileCard`: a 72px avatar, what they sign in as, their role, their teams and when they
91
98
  joined. The trigger takes `Peek`'s `variant="control"` there, so the chip seats in the 40px
@@ -130,7 +137,11 @@ that a whole office shares.
130
137
  `ThumbnailStack` is `AvatarGroup`'s sibling over a different subject: a capped, overlapping set
131
138
  of PICTURES answering "an order of what?", "which parts?", "what arrived?" in one register cell.
132
139
  Pass `{ id, label, image }` per thing; `max` (default 3) caps it and the rest become the `+N`
133
- mark at the end of the stack. Both components read their separating edge from `mark_stack.ts`,
140
+ mark at the end of the stack. Each mark ANNOUNCES its label by default — a standalone stack is
141
+ unreadable otherwise — so pass **`decorative`** where the row already names the thing in text, or
142
+ the pictures read it a second time on every row. (The default is the opposite of
143
+ `Avatar.announce`, and for the same reason: an avatar almost always sits beside its name, a stack
144
+ often does not. The default follows where the component actually sits.) Both components read their separating edge from `mark_stack.ts`,
134
145
  so a row carrying people in one column and goods in the next reads as one device used twice
135
146
  rather than two — but they **tuck by different fractions, and that is deliberate**: a face is
136
147
  edge-anchored and survives being covered two thirds of the way, while a product shot centres its
@@ -279,7 +290,12 @@ by size.)
279
290
  search, you don't group. Worked example: [`tpl_item_list`](../examples/tpl_item_list.tsx).
280
291
  Never an HTML `<table>` or a `.map` of rows. The register adapts to its container on its
281
292
  own — hides columns by `priority`, stacks rows below the two-column floor (see the `table`
282
- inventory entry) — so a narrow screen or panel needs NO branching; compose a hand-designed
293
+ inventory entry) — so a narrow screen or panel needs NO branching. **A flexible column that
294
+ carries fixed chrome inside its cell declares it as `lead`** (a leading mark plus its gap): the
295
+ fit guarantees a READ floor for the text, and without `lead` the mark eats part of that floor,
296
+ so a register reports "fitted" while its subject clips. `composition.md` requires a leading mark
297
+ on every register row and `tpl_item_list` puts it inside the subject cell, so this is the
298
+ ordinary case, not an exotic one; compose a hand-designed
283
299
  card pile (`PressableRow` + your own hierarchy) over `useScreenSize` only when a screen
284
300
  deserves a better mobile shape than the automatic stack.
285
301
  - **Inline-managed grouped table** (MODERATE — hundreds, low-thousands — you MANAGE in view)
@@ -1351,7 +1367,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1351
1367
  EXACTLY (height, padding, 1px transparent border) so a non-editable field — a computed
1352
1368
  total, a system ID, a synced/locked value — aligns pixel-for-pixel in the same column;
1353
1369
  non-interactive, NOT a disabled input; `muted`/`tabular`/`align="right"` for a number
1354
- column, `weight="medium"` to emphasise a total among plain rows, `multiline` to WRAP rather
1370
+ column, `weight="medium"` to emphasise a total among plain rows, **`actions` for a verb about
1371
+ the value** (look this id up, copy it, request the gated change) — a static value is still a
1372
+ field, and its verb belongs on the field's surface, never on the row's ground where an
1373
+ `InlineButton` has nothing under it; `multiline` to WRAP rather
1355
1374
  than truncate (single-line is the default, because a field column reads as a column only if
1356
1375
  its rows are one height — pass it where the counterpart editor is itself `multiline`, or a
1357
1376
  truncated value beside a wrapping input reintroduces the misalignment on the other axis).
@@ -1376,7 +1395,18 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1376
1395
  - **`inline_button`** — `InlineButton`: a verb that sits INSIDE an inline field's surface
1377
1396
  (the `Open` on a reference, a `Copy` on a value worth copying) rather than in the row's
1378
1397
  trailing column — put it inside when the act is ABOUT THE VALUE, so it travels with what it
1379
- acts on and the trailing column stays free for row-level verbs. FILLED (`Button secondary`'s own
1398
+ acts on and the trailing column stays free for row-level verbs.
1399
+
1400
+ **NEVER use it outside an inline field.** Not in a section header, not in a toolbar, not as a
1401
+ row action, not as a card's own verb — it is an *inner* control of a field, and the whole of its
1402
+ design says so: it is FILLED against the field's white ground (a relationship to a surface it no
1403
+ longer has when you move it), it is 28px against a 40px band, it carries no border because the
1404
+ field already draws one, and **it stops propagation** because the field beneath it is pressable.
1405
+ Outside a field every one of those becomes wrong: it reads as a heavier `Button secondary` that
1406
+ is mysteriously too small, it sits on a ground it was not contrasted against, and its swallowed
1407
+ press is now swallowing nothing. If the verb is about the ROW use the row's trailing action; if
1408
+ it is about the SECTION use its heading row; if it is about the SCREEN it is a `Button`. The
1409
+ test is one question — **is there a field surface under it?** If not, it is the wrong control. FILLED (`Button secondary`'s own
1380
1410
  zinc-100) on the field's white surface — the contrast is a RELATIONSHIP, not a colour: invert it
1381
1411
  if the resting field surface ever changes, or the verb dissolves into the value. 28px, radius 8,
1382
1412
  no border (the field already has one) and no shadow (depth belongs to the primary action alone). **It stops propagation** — that is the contract: the
@@ -556,9 +556,25 @@ have decided the thing itself (which one they want).
556
556
  The toggle earns its place when the two states differ in more than a number — different rows,
557
557
  different units, a different shape of answer. Swapping `1.700.000` for `2.000.000` is not that.
558
558
 
559
+ **A container's own gap must exceed the largest gap INSIDE its children — not just the gap to its
560
+ siblings.** The comparison authors are told to make is within-group against between-group, which
561
+ reads as "compare the two numbers *you* wrote". The failure that actually ships compares a number
562
+ you wrote against one the KIT wrote inside a component you dropped in: a section body at 12 or 14
563
+ around a field table that spaces its own rows at 16. Both are conventional, neither looks wrong
564
+ alone, and the boundary BETWEEN two objects ends up tighter than the boundary inside one. Measured
565
+ form: per container, collect its own `row-gap` and the **max `row-gap` of its visible children**;
566
+ `own <= child-max` is the finding.
567
+
559
568
  ## Numbers, money, dates
560
569
 
561
570
  - Free-standing numerals: `<Text tabular>` — always.
571
+ - **A kit component that RENDERS a number takes the app's formatter — it must never `String()` a
572
+ decimal.** In a locale that writes `28,9`, the one figure the kit prints for itself becomes the
573
+ one figure on the screen that is wrong, sitting beside numbers the app formatted correctly — and
574
+ in a locale where `.` is the *thousands* separator that is not a cosmetic difference, it is a
575
+ different quantity. `ChargeLines` takes `formatMoney` and `formatQuantity` for this reason, both
576
+ at the BAND so two lines cannot disagree, and threads them to the locked value and the inline
577
+ editor alike. Any new component that displays a caller's number owes the same hook.
562
578
  - **A column states its unit ONCE — which is only possible if it holds ONE unit.** State it in the
563
579
  header band and leave the cells bare. The corollary is the one that bites: a label/value list
564
580
  whose rows are a date range, a sum of money and a COUNT has no single unit to state, so each
@@ -1665,6 +1681,19 @@ information.
1665
1681
  identity a reader actually scans for — a deal takes its account's mark, since "whose deal is
1666
1682
  this" is the question being asked of the list. Only rows with genuinely no identity are exempt:
1667
1683
  a list of amounts, a log of events.
1684
+ - **A member as one FIELD among columns is TEXT, not a face** (`MemberChip showAvatar={false}`).
1685
+ Keep the face where the member IS the subject — a roster, a picker option, a profile. In an
1686
+ owner / assignee / last-edited-by column it fails twice over: it competes with the mark the row
1687
+ already carries for its own subject, and on a real book a handful of people own hundreds of rows,
1688
+ so the column draws the same disc over and over. Shipped example: an owner column rendering four
1689
+ identical discs for two people, both initial "T", both hashed to a purple.
1690
+ - **A mark derived from POSITION is not identity — the list already draws position.** An ordinal
1691
+ disc, a rank badge, an index chip *does* vary per row, so it passes "the mark must not look the
1692
+ same on every row" and every probe built on that test. It fails for a different reason: it
1693
+ restates what top-to-bottom order says, and it spends the one slot that could have carried the
1694
+ subject. Keep the ordinal where order is the thing being EDITED, and then as text in the
1695
+ supporting line — never as the mark. A surface that needs to state position has its own readout
1696
+ (a drawer's `3/8`, a stepper's rail).
1668
1697
  - **The rung follows what the row is ABOUT**: `lg` where the entity is the row's subject, `md`
1669
1698
  where it is one field among columns (see `avatar_size`).
1670
1699
  - **A column of faces must not draw an UNBOUNDED audience as faces.** Where a cell answers "who
@@ -1692,12 +1721,19 @@ Every rule above removes a defect. None of them ADDS anything, so a screen can p
1692
1721
  and still be reported as plain, boring, or unfinished — which is a different complaint with a
1693
1722
  different fix.
1694
1723
 
1695
- - **What is inherently visual in THIS data?** Work software has no photography, so the data itself
1696
- is the imagery: identity marks (above), status colour, file-type badges, sparklines, proportion
1697
- bars, tabular figures aligning down a column. Every one of these is information first and
1698
- decoration never which is why they are the only kind of visual interest that survives contact
1699
- with a real dataset. Reaching instead for a gradient, an illustration or a hero band adds weight
1700
- without adding a single fact.
1724
+ - **What is inherently visual in THIS data?** Mostly the data itself: identity marks (above),
1725
+ status colour, file-type badges, sparklines, proportion bars, tabular figures aligning down a
1726
+ column. Every one of these is information first and decoration never — which is why they are the
1727
+ only kind of visual interest that survives contact with a real dataset. Reaching instead for a
1728
+ gradient, an illustration or a hero band adds weight without adding a single fact.
1729
+ - **But some entities DO have a likeness, and then it is the strongest mark available.** A product,
1730
+ a property, a vehicle, a dish, a person — a reader recognises the thing itself faster than any
1731
+ derived initial, and the picture is doing the identifying rather than decorating. Where the entity
1732
+ has one, an empty image field is a **DATA gap, not a design constraint**: the fix is to put the
1733
+ image on the record, not to restyle the row around its absence. A catalogue of sixty rows opening
1734
+ with text reads as a fixture no treatment pass will rescue. The test is unchanged — it must make
1735
+ the screen clearer AND better-looking — which is exactly what a thumbnail that is also the
1736
+ identifier does, and exactly what a stock photo bolted onto a task list does not.
1701
1737
  - **What does the screen DECIDE?** A register listing every row at equal weight has no point of
1702
1738
  view: it hands the reader the same undifferentiated wall the query returned. A screen with a
1703
1739
  point of view puts what needs attention first (`TableGroup`), spends its one accent on where the
package/docs/reviewing.md CHANGED
@@ -543,15 +543,23 @@ this converges on *distinctive*.
543
543
  largest, brightest thing there.
544
544
  - **What does the screen DECIDE?** A register listing every row at equal weight has no point of
545
545
  view. Usually the real answer when treatment fixes keep not landing.
546
- - **What is inherently visual in THIS data?** Where there is no photography most work software —
547
- the data is the imagery: identity marks, status colour, file-type badges, sparklines, proportion
548
- bars, tabular figures.
546
+ - **What is inherently visual in THIS data?** Usually the data itself: identity marks, status
547
+ colour, file-type badges, sparklines, proportion bars, tabular figures. **But check whether the
548
+ entity has a LIKENESS** — a product, a property, a vehicle, a person. If it does and the rows
549
+ open without one, query the image field's fill rate before writing a treatment finding: an empty
550
+ field makes this a DATA defect, and no amount of restyling fixes it.
549
551
  - **What makes this belong to THIS product?** Default typeface, default greys, default accent,
550
552
  default control shapes is a well-built generic screen, and no polish changes that. The fix is a
551
553
  theme, not another treatment pass.
552
554
 
553
555
  → [composition.md](./composition.md) §"Identity marks", §"Character comes from the DATA", §"Where the accent goes".
554
556
 
557
+ **A driver's `.focus()` does not set `:focus-visible`** — so a focus-ring check driven
558
+ programmatically reports *no ring* on a control whose ring is fine, and the audit spends its time
559
+ on a defect that does not exist. Walk the surface with real `Tab` presses and read the computed
560
+ `box-shadow` at each stop; `.focus()` is only good for asking what CAN take focus, never for how it
561
+ looks when it does.
562
+
555
563
  ### 10. Microcopy
556
564
  - **No punctuation doing a word's job.** ` · ` is banned outright — it claims a relationship while
557
565
  refusing to name it, and it is the loudest templated-metadata tell there is. If a mark cannot be
@@ -637,6 +645,32 @@ variant that walks ELEMENTS rather than text nodes. Those tags hold text nodes t
637
645
  sees, and counting them produces confident nonsense — a page "with eight nodes in Times New Roman"
638
646
  that has none, or a clean tally on a screen with an untracked component still on it.
639
647
 
648
+ ### Identity marks — N identical is a finding, and only a per-COLUMN count sees it
649
+ Probe 9 says "count them. Zero is the finding, and so is N identical" — and every other snippet
650
+ here reads type, rules, gaps, states or reachability, so nothing fired on a column of identical
651
+ discs. Group by the x band (i.e. by column), because that is what separates the row's own subject
652
+ mark from a supporting column that borrowed identity weight.
653
+
654
+ ```js
655
+ // → [{ x, count, distinct, sample }] — flag any band with count > 1 && distinct === 1
656
+ Object.values(
657
+ [...document.querySelectorAll('[data-avatar], [class*="avatar"], img')]
658
+ .filter((n) => n.getBoundingClientRect().width > 0)
659
+ .reduce((acc, n) => {
660
+ const band = Math.round(n.getBoundingClientRect().x / 24) * 24; // column, not pixel
661
+ const cs = getComputedStyle(n);
662
+ const key = [cs.backgroundColor, n.textContent?.trim(), n.getAttribute("src") ?? ""].join("|");
663
+ acc[band] ??= { x: band, keys: new Set(), count: 0 };
664
+ acc[band].keys.add(key);
665
+ acc[band].count++;
666
+ return acc;
667
+ }, {}),
668
+ ).map((b) => ({ x: b.x, count: b.count, distinct: b.keys.size, sample: [...b.keys][0] }));
669
+ ```
670
+
671
+ A band with `count > 1 && distinct === 1` is the reported defect. A band whose `distinct` is well
672
+ under `count` is the softer version — a palette collapsing on real names — and worth reading by eye.
673
+
640
674
  ### Separator beats — is the biggest boundary the widest?
641
675
 
642
676
  **A rule is a painted LINE, and it is drawn two ways.** Selecting "thin and wide" alone gets both
@@ -1244,8 +1244,13 @@ export function TplItemList() {
1244
1244
  <Text size="xxl" weight="semibold">Records</Text>
1245
1245
 
1246
1246
  {/* toolbar — search + consolidated filters LEFT, New case RIGHT, one row.
1247
- A search is present, so status is a dropdown (Select), not pills. */}
1248
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
1247
+ A search is present, so status is a dropdown (Select), not pills.
1248
+
1249
+ `flex-start`, not `center`: the filter block wraps to two or three
1250
+ lines on a phone, and centring floats the CTA against the whole
1251
+ wrapped block — measured landing level with the SECOND row of chips,
1252
+ anchored to nothing. The band's top edge is what it belongs to. */}
1253
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
1249
1254
  <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, alignItems: "center", flex: 1 }}>
1250
1255
  <View style={{ flexGrow: 1, flexBasis: 220, minWidth: 200, maxWidth: 340 }}>
1251
1256
  <SearchInput
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "47.0.0",
3
+ "version": "47.1.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./accordion": "./src/accordion.tsx",
package/src/accordion.tsx CHANGED
@@ -175,9 +175,14 @@ export interface AccordionContentProps {
175
175
  }
176
176
 
177
177
  /** The collapsible body, revealed in place under the header. Plain by
178
- * design — flush with the header's left edge (aligns with the title), no
179
- * tinted well; the chevron + position already say "inside". Compose
180
- * rows/dividers/actions directly. */
178
+ * design — flush with the header BOX's left edge, no tinted well; the chevron
179
+ * + position already say "inside". Compose rows/dividers/actions directly.
180
+ *
181
+ * It does NOT align with the title text: `AccordionTitle` always reserves a
182
+ * 16px icon slot plus a 12px gap, so the title starts 28px in while the content
183
+ * starts at 0. That is deliberate — the body belongs to the section, not to the
184
+ * heading — but it means a header with no icon shows the offset with nothing
185
+ * explaining it, so pass an icon when the gap reads as a mistake. */
181
186
  export function AccordionContent(props: AccordionContentProps) {
182
187
  const { expanded } = useAccordionContext();
183
188
  if (!expanded) return null;
@@ -210,8 +215,9 @@ const styles = StyleSheet.create({
210
215
  meta: {
211
216
  flexShrink: 1,
212
217
  },
213
- // Flush with the header's left edge aligns with the title, like a Section
214
- // body. The chevron + position already say "inside"; no indent needed.
218
+ // Flush with the header BOX's left edge, like a Section body NOT with the
219
+ // title text, which the icon slot pushes 28px in. The chevron + position
220
+ // already say "inside"; no indent needed.
215
221
  content: {
216
222
  paddingBottom: 10,
217
223
  },
package/src/bar_chart.tsx CHANGED
@@ -300,7 +300,9 @@ export function BarChart(props: BarChartProps) {
300
300
  <Text numberOfLines={1} weight="medium">
301
301
  {item.label}
302
302
  </Text>
303
- <Text color="muted">{formatNumber(item.value)}</Text>
303
+ <Text numberOfLines={1} color="muted">
304
+ {formatNumber(item.value)}
305
+ </Text>
304
306
  </View>
305
307
  ) : null}
306
308
  </View>
package/src/breakdown.tsx CHANGED
@@ -85,7 +85,9 @@ export function Breakdown(props: BreakdownProps) {
85
85
  <Text size="sm" color={dimmed ? "muted" : "default"} numberOfLines={1} style={styles.label}>
86
86
  {item.label}
87
87
  </Text>
88
- <Text size="sm" weight={selected ? "semibold" : "regular"} color={dimmed ? "muted" : "default"} tabular>
88
+ {/* medium, matching `StackedBarChart`'s figure the value is what the
89
+ row is read for. `semibold` stays reserved for selection. */}
90
+ <Text size="sm" weight={selected ? "semibold" : "medium"} color={dimmed ? "muted" : "default"} tabular>
89
91
  {formatValue(item.value)}
90
92
  </Text>
91
93
  <Text size="xs" color="muted" tabular align="right" style={styles.share}>
@@ -120,6 +120,11 @@ export interface ChargeLinesProps {
120
120
  * not the sum of something visible is the one figure a reader cannot check. */
121
121
  total: number;
122
122
  formatMoney: (n: number) => string;
123
+ /** Format a QUANTITY. Band-level for the same reason `formatMoney` is: a
124
+ * locale that writes `28,9` must not have the one figure the kit renders
125
+ * itself print `28.9` while every number the app formats beside it reads
126
+ * correctly. Defaults to the platform's own `String`. */
127
+ formatQuantity?: (n: number) => string;
123
128
  /** Sits in the closing row beside the total — the issue/collect action. */
124
129
  action?: ReactNode;
125
130
  /** Shown in place of the lines when there are none. */
@@ -129,7 +134,12 @@ export interface ChargeLinesProps {
129
134
  const QTY_W = 76;
130
135
  const OP_W = 14;
131
136
  const PRICE_W = 132;
132
- const QTY_W_NARROW = 48;
137
+ // 48 fitted an integer and clipped `28,9` — 33px of text in the 30 left after the
138
+ // inset and border, on 4 of 7 lines of a real record. A quantity with one decimal
139
+ // is the ordinary case wherever goods are sold by weight, and `formatQuantity`
140
+ // now makes the separator a comma in the locales that use one, which is no wider
141
+ // but no narrower either. 60 seats four glyphs and a unit's worth of slack.
142
+ const QTY_W_NARROW = 60;
133
143
  const PRICE_W_NARROW = 92;
134
144
  const AMOUNT_W = 108;
135
145
  const ACTION_W = 32;
@@ -138,7 +148,7 @@ const ACTION_W = 32;
138
148
  const FORK_AT = 520;
139
149
 
140
150
  export function ChargeLines(props: ChargeLinesProps) {
141
- const { children, totalLabel, total, formatMoney, action, empty } = props;
151
+ const { children, totalLabel, total, formatMoney, formatQuantity, action, empty } = props;
142
152
  const [width, setWidth] = useState(0);
143
153
  const narrow = width > 0 && width < FORK_AT;
144
154
  const onLayout = useCallback((e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width), []);
@@ -146,7 +156,7 @@ export function ChargeLines(props: ChargeLinesProps) {
146
156
  const isEmpty = Array.isArray(rows) ? rows.length === 0 : !rows;
147
157
 
148
158
  return (
149
- <ChargeLinesContext.Provider value={{ narrow, formatMoney }}>
159
+ <ChargeLinesContext.Provider value={{ narrow, formatMoney, formatQuantity }}>
150
160
  <View onLayout={onLayout} style={styles.band}>
151
161
  {isEmpty ? (
152
162
  <View style={styles.empty}>{empty}</View>
@@ -188,6 +198,10 @@ export function ChargeLine(props: ChargeLineProps) {
188
198
  const band = useChargeLines();
189
199
  const words = useLoticsLocale().chargeLines;
190
200
  const money = props.formatMoney ?? band.formatMoney;
201
+ const qty = band.formatQuantity ?? ((n: number) => String(n));
202
+ const fmtQty = band.formatQuantity
203
+ ? (v: number | null) => (v == null ? "" : qty(v))
204
+ : undefined;
191
205
  const priced = quantity !== undefined || unitPrice !== undefined;
192
206
  const amount = priced ? (quantity ?? 0) * (unitPrice ?? 0) : (props.amount ?? 0);
193
207
  /** The row's money verb — the unit price's on a priced line, the amount's on a
@@ -212,12 +226,13 @@ export function ChargeLine(props: ChargeLineProps) {
212
226
  <View style={{ width: band.narrow ? QTY_W_NARROW : QTY_W }}>
213
227
  {locked || !onQuantityChange ? (
214
228
  <Text size="sm" tabular style={styles.inset}>
215
- {quantity ?? "—"}
229
+ {quantity == null ? "—" : qty(quantity)}
216
230
  </Text>
217
231
  ) : (
218
232
  <InlineNumberInput
219
233
  value={quantity ?? null}
220
234
  onSave={onQuantityChange}
235
+ format={fmtQty}
221
236
  min={0}
222
237
  placeholder="1"
223
238
  align="right"
@@ -340,7 +355,11 @@ export function ChargeLine(props: ChargeLineProps) {
340
355
  );
341
356
  }
342
357
 
343
- const ChargeLinesContext = createContext<{ narrow: boolean; formatMoney: (n: number) => string }>({
358
+ const ChargeLinesContext = createContext<{
359
+ narrow: boolean;
360
+ formatMoney: (n: number) => string;
361
+ formatQuantity?: (n: number) => string;
362
+ }>({
344
363
  narrow: false,
345
364
  formatMoney: (n) => String(n),
346
365
  });
package/src/finding.tsx CHANGED
@@ -220,7 +220,7 @@ const styles = StyleSheet.create({
220
220
  // being read aloud, the same defect `CommentsButton` was fixed for. It also
221
221
  // wrapped: at phone width a line could BEGIN with the separator. Stacking pairs
222
222
  // each source with its own value at every width and needs no separator at all.
223
- readings: { flexDirection: "column", alignItems: "flex-start", gap: 4 },
223
+ readings: { flexDirection: "column", alignItems: "stretch", gap: 4 },
224
224
  reading: { flexDirection: "row", alignItems: "baseline", gap: 5, minWidth: 0 },
225
225
  // The same split as `title`/`delta` one row up, for the same reason: the SOURCE
226
226
  // is a label and gives way, the VALUE is the figure and never does. Both halves
@@ -7,10 +7,17 @@ export interface FormTextInputProps extends Omit<FormFieldProps, "style">, TextI
7
7
  /** `FormField` (label / description / warning / error / optional) wrapping a `TextInputField` — one
8
8
  * labeled text field. For a 2-col grid cell, prefer a bare `FormField style={half}` around the input. */
9
9
  export function FormTextInput(props: FormTextInputProps) {
10
- const { label, description, warning, optional, error, ...textInputProps } = props;
10
+ const { label, description, warning, optional, optionalLabel, error, ...textInputProps } = props;
11
11
 
12
12
  return (
13
- <FormField label={label} description={description} warning={warning} error={error} optional={optional}>
13
+ <FormField
14
+ label={label}
15
+ description={description}
16
+ warning={warning}
17
+ error={error}
18
+ optional={optional}
19
+ optionalLabel={optionalLabel}
20
+ >
14
21
  <TextInputField {...textInputProps} />
15
22
  </FormField>
16
23
  );
@@ -823,7 +823,11 @@ const styles = StyleSheet.create({
823
823
  // the row's own menu. With `marginLeft` alone the box grows leftward, its right
824
824
  // edge stays on the container, and `paddingRight` still holds the text off the
825
825
  // border.
826
- viewBareBleed: { marginLeft: -8 },
826
+ // Stretches for the same reason `editRow` does — it is the outermost element
827
+ // when there are no verbs, so a right-aligned cell would size IT to content
828
+ // instead. The negative margin is the bare variant's deliberate 8px bleed, so
829
+ // the field reads 8px wider than its column by design.
830
+ viewBareBleed: { marginLeft: -8, alignSelf: "stretch", minWidth: 0 },
827
831
  // The shell's own inset: the right edge tightens to 4 so an `InlineButton` sits
828
832
  // on the surface's edge with the same air a `Chip`'s dismiss gets.
829
833
  shell: { paddingRight: 4 },
@@ -864,7 +868,13 @@ const styles = StyleSheet.create({
864
868
  // showed it — `Text` sets its own alignment — so the centring only surfaced
865
869
  // once `display` accepted a node and rendered prose that inherited it.
866
870
  viewNode: { flex: 1, minWidth: 0, textAlign: "left" },
867
- editRow: { flexDirection: "row", alignItems: "flex-start", gap: 6 },
871
+ // `alignSelf: stretch` because an inline edit takes its width from the COLUMN
872
+ // it sits in, never from what it happens to contain. Without it a right-aligned
873
+ // cell (`alignItems: flex-end`) sizes this row to its content, and in edit mode
874
+ // the content is a native `<input>`, whose default `size=20` is ~236px it will
875
+ // not shrink below. In a 104px quantity column that overflowed 132px LEFTWARD —
876
+ // pinned to the cell's right edge, spilling across the column beside it.
877
+ editRow: { flexDirection: "row", alignItems: "flex-start", gap: 6, alignSelf: "stretch", minWidth: 0 },
868
878
  editControl: { flex: 1, position: "relative" },
869
879
  // The input brings its own 40px height, so the surface must not add its resting
870
880
  // vertical padding on top — that was a 12px jump the moment the field focused.
@@ -6,6 +6,10 @@ import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
6
6
  export interface InlineSlotProps {
7
7
  /** Whatever sits in the value column — a `DiffValue`, a chip, a pair. */
8
8
  children: ReactNode;
9
+ /** Verbs about the value, pinned right ON the slot's surface — the same place
10
+ * an editable `Inline*` puts its `actions`, so a static row and an editable
11
+ * one carry their verbs on one edge. */
12
+ actions?: ReactNode;
9
13
  style?: StyleProp<ViewStyle>;
10
14
  }
11
15
 
@@ -32,11 +36,21 @@ export interface InlineSlotProps {
32
36
  * NOT for a table cell: a `Table` sets its own row rhythm, and a 40px control
33
37
  * band inside one only makes the rows taller.
34
38
  */
35
- export function InlineSlot({ children, style }: InlineSlotProps) {
36
- return <View style={[styles.box, style]}>{children}</View>;
39
+ export function InlineSlot({ children, actions, style }: InlineSlotProps) {
40
+ if (!actions) return <View style={[styles.box, style]}>{children}</View>;
41
+ return (
42
+ <View style={[styles.box, styles.withActions, style]}>
43
+ <View style={styles.grow}>{children}</View>
44
+ {actions}
45
+ </View>
46
+ );
37
47
  }
38
48
 
39
49
  const styles = StyleSheet.create({
50
+ // The value takes the slack and the verbs pin right, so a column of rows —
51
+ // some with a verb, some without — keeps one right edge.
52
+ withActions: { flexDirection: "row", alignItems: "center", gap: 8 },
53
+ grow: { flex: 1, minWidth: 0 },
40
54
  // Matches `inline_edit`'s view box exactly (height, radius, padding, 1px
41
55
  // transparent border) so the content's baseline aligns with the editors —
42
56
  // minus the FocusRingPressable, so it never reads as an interactive control.
@@ -1,3 +1,4 @@
1
+ import type { ReactNode } from "react";
1
2
  import { StyleSheet } from "react-native";
2
3
  import { Text } from "./text";
3
4
  import { colors } from "./colors";
@@ -28,6 +29,18 @@ export interface InlineStaticProps {
28
29
  * misalignment this component exists to prevent, just on the other axis.
29
30
  */
30
31
  multiline?: boolean;
32
+ /**
33
+ * Verbs about THIS value, on the value's own surface — an `InlineButton`, the
34
+ * same slot every editable `Inline*` exposes.
35
+ *
36
+ * A read-only value often still has an act attached to it: look this id up,
37
+ * copy it, request the change that is gated behind an approval. Without this
38
+ * slot that verb has nowhere to sit but the row's ground, and an `InlineButton`
39
+ * outside a field surface is the wrong control (see `catalog.md`
40
+ * §`inline_button`). A static value is still a field; it just is not editable
41
+ * in place.
42
+ */
43
+ actions?: ReactNode;
31
44
  }
32
45
 
33
46
  /**
@@ -37,15 +50,16 @@ export interface InlineStaticProps {
37
50
  * locked value — sits flush in the same column as the editable rows. It is
38
51
  * NON-interactive (no hover, no pointer, no focus ring): it reads as a plain
39
52
  * value, NOT a disabled input. Pair with `DetailRow` exactly like the `Inline*`
40
- * editors; wrap it in a `flex:1` View when the row also carries a `trailing`
41
- * slot, so the value takes the slack and the trailing pins right.
53
+ * editors. A verb about the value goes in `actions`, on the value's own surface
54
+ * `DetailRow` has no trailing slot, and a verb on the row's ground is an
55
+ * `InlineButton` without a field under it.
42
56
  */
43
57
  export function InlineStatic(props: InlineStaticProps) {
44
- const { value, placeholder, muted, tabular, align = "left", weight, multiline } = props;
58
+ const { value, placeholder, muted, tabular, align = "left", weight, multiline, actions } = props;
45
59
  const isEmpty = value.length === 0;
46
60
  const display = isEmpty ? (placeholder ?? "—") : value;
47
61
  return (
48
- <InlineSlot>
62
+ <InlineSlot actions={actions}>
49
63
  <Text
50
64
  numberOfLines={multiline ? undefined : 1}
51
65
  tabular={tabular}
@@ -29,6 +29,20 @@ interface MemberChipProps {
29
29
  /** A rung on the shared avatar scale. Default `md`, which is sized to seat in a
30
30
  * 40px control band; drop to `sm` for a dense cell. See `AVATAR_PX`. */
31
31
  size?: AvatarSize;
32
+ /**
33
+ * Drop the face and render the person as TEXT.
34
+ *
35
+ * Pass it wherever the member is ONE FIELD AMONG COLUMNS rather than the row's
36
+ * subject — an owner column, an assignee cell, a "last edited by". Two reasons,
37
+ * both from `composition.md` §Identity marks: a second mark competes with the
38
+ * mark the row already carries for its own subject, and a handful of people
39
+ * owning hundreds of rows draws the same disc over and over, which "carries
40
+ * nothing and is the largest, brightest thing there".
41
+ *
42
+ * Keep the face where the member IS the subject — a roster, a picker option, a
43
+ * profile, a directory row.
44
+ */
45
+ showAvatar?: boolean;
32
46
  /** This person is no longer active — they left, their account was closed.
33
47
  * Mutes the name so a record that still names them reads as history rather
34
48
  * than as a current assignment. The chip keeps its height, because its usual
@@ -58,6 +72,7 @@ export function MemberChip({
58
72
  image,
59
73
  secondary,
60
74
  size = "md",
75
+ showAvatar = true,
61
76
  inactive,
62
77
  style,
63
78
  }: MemberChipProps) {
@@ -101,7 +116,9 @@ export function MemberChip({
101
116
  secondary && AVATAR_PX[size] < AVATAR_PX.lg ? "lg" : size;
102
117
  return (
103
118
  <View style={[styles.row, style]}>
104
- <Avatar size={markSize} name={displayName} source={image ? { uri: image } : undefined} />
119
+ {showAvatar ? (
120
+ <Avatar size={markSize} name={displayName} source={image ? { uri: image } : undefined} />
121
+ ) : null}
105
122
  <View style={styles.text}>
106
123
  <Text
107
124
  userSelect="none"
@@ -91,7 +91,11 @@ export function MenuButton(props: MenuButtonProps) {
91
91
 
92
92
  const resolvedIcon =
93
93
  typeof icon === "string" ? (
94
- <Icon size={20} name={icon as IconName} color={danger ? colors.red["900"] : undefined} />
94
+ <Icon
95
+ size={20}
96
+ name={icon as IconName}
97
+ color={disabled ? colors.zinc["400"] : danger ? colors.red["900"] : undefined}
98
+ />
95
99
  ) : (
96
100
  icon
97
101
  );
@@ -102,7 +106,7 @@ export function MenuButton(props: MenuButtonProps) {
102
106
  weight="medium"
103
107
  numberOfLines={1}
104
108
  userSelect="none"
105
- color={danger ? "danger" : undefined}
109
+ color={disabled ? "muted" : danger ? "danger" : undefined}
106
110
  >
107
111
  {title}
108
112
  </Text>
@@ -125,6 +129,7 @@ export function MenuButton(props: MenuButtonProps) {
125
129
  styles.container,
126
130
  highlighted && styles.highlighted,
127
131
  focused && !highlighted && styles.focused,
132
+ disabled && styles.disabled,
128
133
  style,
129
134
  ];
130
135
 
@@ -182,6 +187,11 @@ const styles = StyleSheet.create({
182
187
  highlighted: {
183
188
  backgroundColor: colors.zinc["100"],
184
189
  },
190
+ // A blocked row keeps its geometry and loses its weight — the label and icon
191
+ // go muted above, and the row itself stops inviting the press.
192
+ disabled: {
193
+ opacity: 0.6,
194
+ },
185
195
  focused: {
186
196
  backgroundColor: colors.zinc["50"],
187
197
  },
@@ -73,6 +73,13 @@ export function NumberInput(props: NumberInputProps) {
73
73
  autoFocus={autoFocus}
74
74
  style={{
75
75
  height: 40,
76
+ // A native input carries `size=20` — about 236px of intrinsic width it
77
+ // refuses to shrink below, because a flex item's `min-width` defaults to
78
+ // `auto`. In any column narrower than that the field overflowed rather
79
+ // than fitting. Width comes from the container; the input never states
80
+ // its own.
81
+ width: "100%",
82
+ minWidth: 0,
76
83
  paddingLeft: seamless ? 0 : 8,
77
84
  paddingRight: seamless ? 0 : 8,
78
85
  borderRadius: CONTROL_RADIUS,
@@ -92,8 +92,15 @@ export function ProgressBar(props: ProgressBarProps) {
92
92
  <View style={[styles.track, styles.compactTrack]}>
93
93
  <View style={[styles.fill, { width: `${percentage}%`, backgroundColor: isComplete ? completeColor : color }]} />
94
94
  </View>
95
+ {/* The caption is MUTED at every ratio, matching the non-compact anatomy.
96
+ A meter can mean "done" (a task, an upload) or "used up" (a credit
97
+ limit, a quota), and the same 100% is good in one and bad in the
98
+ other — so the component cannot colour the number without asserting
99
+ something it does not know. It used to draw a green caption beside a
100
+ red bar on every cap meter. The verdict stays where the caller states
101
+ it: the FILL, via `completeColor`. */}
95
102
  {format === "none" ? null : (
96
- <Text size="sm" tabular color={isComplete ? "success" : "muted"}>
103
+ <Text size="sm" tabular color="muted">
97
104
  {label}
98
105
  </Text>
99
106
  )}
@@ -151,6 +158,12 @@ const styles = StyleSheet.create({
151
158
  },
152
159
  compactTrack: {
153
160
  flex: 1,
161
+ // A shrink-to-fit parent — a stacked `Table` cell, a `DetailRow` with no
162
+ // `DetailTable` above it — gives `flex: 1` nothing to divide, so the track
163
+ // measures 0 and the meter renders as its caption alone. It passes every
164
+ // treatment probe because there is nothing left to measure. The floor is
165
+ // what makes it a bar rather than a number.
166
+ minWidth: 64,
154
167
  },
155
168
  header: {
156
169
  flexDirection: "row",
@@ -102,6 +102,11 @@ const styles = StyleSheet.create({
102
102
  flexDirection: "row",
103
103
  alignItems: "flex-start",
104
104
  gap: 16,
105
+ // The metric sizes to its content while `identity` is `flex: 1`, so without
106
+ // this the title is the only thing that can give — and it gave down to three
107
+ // wrapped lines in a 358px column while the metric's note kept its full width.
108
+ // A record's identity is the last thing on the surface that should yield.
109
+ flexWrap: "wrap",
105
110
  },
106
111
  identity: {
107
112
  flex: 1,
@@ -129,7 +129,10 @@ export function StackedBarChart(props: StackedBarChartProps) {
129
129
  <View style={styles.leading}>{row.leading}</View>
130
130
  ) : null}
131
131
  <View style={styles.headText}>
132
- <Text size="sm" weight="medium" numberOfLines={2} leading="tight">
132
+ {/* regular, matching `Breakdown`'s label: the two components draw
133
+ the same row and a card may sit them side by side. The figure
134
+ carries the weight; the series name does not. */}
135
+ <Text size="sm" numberOfLines={2} leading="tight">
133
136
  {row.label}
134
137
  </Text>
135
138
  {row.meta ? (
package/src/table_fit.ts CHANGED
@@ -26,6 +26,23 @@ export interface TableFitColumn {
26
26
  key: string;
27
27
  /** Fixed width in px; omit for a flexible column. */
28
28
  width?: number;
29
+ /**
30
+ * Fixed CHROME inside this flexible cell, in px — a leading mark, a badge, a
31
+ * swatch, plus its gap. Added to the read floor so the cell keeps
32
+ * {@link FLEX_READ_WIDTH} for the TEXT rather than spending part of it on
33
+ * furniture.
34
+ *
35
+ * `composition.md` requires every register row to carry a leading mark and
36
+ * `tpl_item_list` puts it INSIDE the subject cell (the `leading` slot holds
37
+ * the checkbox), so obeying the kit's own rule silently adds ~50px the fit
38
+ * could not see: a register reports "fitted" while its subject clips. Measured
39
+ * in the wild — a subject handed 154px for strings needing 283, with every
40
+ * other signal green.
41
+ *
42
+ * Ignored on a fixed-`width` column, which already states its total. Default
43
+ * 0, so a caller that passes nothing gets exactly the previous behaviour.
44
+ */
45
+ lead?: number;
29
46
  /** This column's claim on space, **`1` = highest**, the way P1/P2/P3 rank a
30
47
  * bug: the column you least want to lose gets the SMALLEST number. Said the
31
48
  * other way round it is the same rule — the larger the number, the sooner the
@@ -133,7 +150,7 @@ export function computeTableFit(
133
150
  const requiredWidth = (cols: TableFitColumn[]): number => {
134
151
  const slots = cols.length + (leading > 0 ? 1 : 0) + (trailing > 0 ? 1 : 0);
135
152
  const gaps = Math.max(0, slots - 1) * COLUMN_GAP;
136
- const colsWidth = cols.reduce((sum, c) => sum + (c.width ?? FLEX_READ_WIDTH), 0);
153
+ const colsWidth = cols.reduce((sum, c) => sum + (c.width ?? FLEX_READ_WIDTH + (c.lead ?? 0)), 0);
137
154
  return ROW_H_PADDING + (leading > 0 ? leading : 0) + (trailing > 0 ? trailing : 0) + colsWidth + gaps;
138
155
  };
139
156
 
@@ -291,6 +291,12 @@ const styles = StyleSheet.create({
291
291
  // overrides via `style` (it lands after this in the array).
292
292
  backgroundColor: colors.white,
293
293
  height: 40,
294
+ // Same reason `NumberInput` states it: a native input carries `size=20`,
295
+ // about 236px of intrinsic width, and a flex item's `min-width` defaults to
296
+ // `auto` — so in any column narrower than that the field overflows instead
297
+ // of fitting. Width comes from the container; the input never states its own.
298
+ width: "100%",
299
+ minWidth: 0,
294
300
  paddingVertical: INPUT_PADDING_Y,
295
301
  paddingHorizontal: 8,
296
302
  fontFamily: fontFamilyRegular,
@@ -62,6 +62,19 @@ export interface ThumbnailStackProps {
62
62
  items: readonly ThumbnailStackItem[];
63
63
  /** Pictures shown before the remainder becomes `+N`. Default 3. */
64
64
  max?: number;
65
+ /**
66
+ * Silence the marks for assistive tech — they become decoration.
67
+ *
68
+ * The DEFAULT is to announce each `label`, and that is deliberate: a stack is
69
+ * often the only statement of what is there, and a run of unlabelled pictures
70
+ * is unreadable. Pass this where the row ALREADY names the thing in text, so
71
+ * the pictures do not read it a second time on every row.
72
+ *
73
+ * Note the default is the opposite of `Avatar.announce`, and for the same
74
+ * reason: an avatar almost always sits beside its name, a stack often does
75
+ * not. The default follows where the component actually sits.
76
+ */
77
+ decorative?: boolean;
65
78
  /**
66
79
  * A rung on the shared avatar scale — the same scale the marks in the
67
80
  * neighbouring columns take, so a row of goods and a row of people sit on one
@@ -73,7 +86,7 @@ export interface ThumbnailStackProps {
73
86
  }
74
87
 
75
88
  export function ThumbnailStack(props: ThumbnailStackProps) {
76
- const { items, max = DEFAULT_MAX, size = "md", style, testID } = props;
89
+ const { items, max = DEFAULT_MAX, size = "md", decorative, style, testID } = props;
77
90
  const locale = useLoticsLocale();
78
91
 
79
92
  if (items.length === 0) return null;
@@ -92,8 +105,10 @@ export function ThumbnailStack(props: ThumbnailStackProps) {
92
105
  {shown.map((item, index) => (
93
106
  <View
94
107
  key={item.id}
95
- accessible
96
- accessibilityLabel={item.label}
108
+ // Announced by default — a standalone stack is unreadable otherwise.
109
+ // `decorative` silences it where the row already names the thing.
110
+ accessible={decorative ? undefined : true}
111
+ accessibilityLabel={decorative ? undefined : item.label}
97
112
  style={{
98
113
  width: px,
99
114
  height: px,