@lotics/ui 10.0.0 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @lotics/ui — the UI reference
2
2
 
3
+ > **Upgrading to v11 from ≤10.x — the type scale was re-slotted.** The display sizes gained the
4
+ > missing `##` step: `xl` is now 22/24 (section title), `xxl` is 28/32 (page/record title — the
5
+ > OLD `xl` look), and the new `xxxl` is 32/48 (hero numbers — the OLD `xxl` look). Migrate an
6
+ > app with two renames IN THIS ORDER: first `size="xxl"` → `size="xxxl"`, then `size="xl"` →
7
+ > `size="xxl"` — every existing usage keeps its exact look. Also: `SectionHeadingTitle` is now
8
+ > ALWAYS `##` (22/24), and disabled `Button`s fade their label to the zinc-400 disabled ink on
9
+ > every variant.
10
+
3
11
  A React-Native-Web component kit (renders on web **and** native). This file is the design
4
12
  law + the "reach for what" guide. The **exact API** of any component is its source, which
5
13
  ships with the package: read `node_modules/@lotics/ui/src/<name>.tsx`. **Never guess a prop —
@@ -121,7 +129,12 @@ Pick by capability, not by name. (→ the source file for the API.)
121
129
  row/column/grand totals; press a cell to drill).
122
130
  - **Surfaces & layout** — `Card` (+ `CardHeader`/`CardHeaderTitle`/`CardHeaderMeta`/`CardBody`/
123
131
  `CardFooter`), `Section` (the card-less twin of `Card`: bare gap-spaced region + `SectionHeading`/
124
- `SectionHeadingTitle`/`SectionHeadingMeta`; no body component — children are the body), `SectionCard`,
132
+ `SectionHeadingTitle`/`SectionHeadingMeta`; no body component — children are the body;
133
+ ), `Subsection` (+ `SubsectionHeading`/
134
+ `SubsectionHeadingTitle` — the named group INSIDE a section, `###` lg-semibold title),
135
+ `SectionStack` (the flat page's content column — owns the fixed 56px beat + hairline between
136
+ top-level blocks), `SubsectionStack` (the same law one step tighter — fixed 24px beat +
137
+ hairline between a section's `Subsection` groups), `SectionCard`,
125
138
  `PageHeader` /
126
139
  `PageContent`, `Stack`, `Spacer`, `Divider`, `Accordion`, `Tabs`, `SegmentedControl`, `Stepper`,
127
140
  `DangerZone` (the destructive section — delete/archive — set apart at the bottom of a record/settings surface).
@@ -655,19 +668,32 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
655
668
  - **Canvas**: full-bleed `colors.zinc[50]` ScrollView; content column `maxWidth` 880–1040,
656
669
  centered, `padding: 28`. Cards float on the canvas — never white-on-white.
657
670
  - **Heading vocabulary — ONE construct per altitude, no drift.**
658
- · **Page band**: one per screen — `Text size="xl" weight="semibold"` title + one `sm muted`
671
+ · **Page band**: one per screen — `Text size="xxl" weight="semibold"` title (`#`) + one `sm muted`
659
672
  subtitle that says what the screen decides. Right side: the screen's ONE primary action and/or
660
673
  a period filter — never a summary Badge (those belong to the KPI strip).
661
674
  · **Card header** (a card's own title band, or separate banded cards): `CardHeader` +
662
675
  `CardHeaderTitle` (`sm semibold`; `info` when the title alone doesn't define the numbers).
663
676
  · **Section title** — ONE construct: `Section` > `SectionHeading` > `SectionHeadingTitle`
664
677
  (lg semibold + optional muted `description`/`info`), everything left-aligned at the column
665
- edge. The `Divider` goes BETWEEN sections (page column `gap: 32`, a bare hairline separating
666
- one section from the next) — NEVER directly under the heading, which orphans the title from
667
- its own content. Inside a DRAWER, a full multi-section WORKSPACE uses the real
678
+ edge. The page column is a `SectionStack` it owns the between-section law (`gap: 32` +
679
+ a bare hairline separating one section from the next); the `Divider` NEVER goes directly
680
+ under the heading, which orphans the title from its own content. Inside a DRAWER, a full
681
+ multi-section WORKSPACE uses the real
668
682
  `SectionHeading` (lg) — its meta + CTA slots carry each section's count and action; a
669
683
  `Text size="sm" weight="semibold"` stand-in is only for small sub-groups. Do NOT wrap each section in its own `CardHeader`, do NOT
670
684
  hand-roll sm-semibold lead lines on a flat page. NEVER a bare eyebrow as a section title.
685
+ · **The heading ramp is FIXED — the markdown ladder, no size knobs.** `#` = xxl
686
+ (`RecordSummary` / `PageHeader` title) → `##` = xl (`SectionHeadingTitle`, always) →
687
+ `###` = lg (`SubsectionHeadingTitle`, always) → body sm. One outline on every surface;
688
+ never restyle a heading level per-page.
689
+ · **Subsection title** — the level BELOW a section on a long record surface: `Subsection` >
690
+ `SubsectionHeading` > `SubsectionHeadingTitle` (`###` lg semibold; siblings — a `Badge`, a
691
+ `SectionHeadingMeta`, an action — ride the heading row's right edge). Sibling subsections
692
+ stack in a `SubsectionStack` (24 + hairline between groups — no margins, no hand-rolled
693
+ dividers); a headingless `Subsection` is the section's lead group. Group leads INSIDE a
694
+ subsection's rows are `md medium` at most. Do NOT hand-roll `Text weight="semibold"` group
695
+ leads inside a section, and never promote a subsection to its own section-level heading
696
+ just to separate it.
671
697
  · **Eyebrow / label** (`<Text size="xs" color="muted" weight="medium">` — **sentence case, NEVER
672
698
  `transform="uppercase"`**): a small quiet label above or beside content — an artifact tag
673
699
  ("Proposed", "Question", "Suggested edit"), a field name, a metric caption, a minor one-line
@@ -1017,7 +1043,8 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
1017
1043
  text ·
1018
1044
  markdown (Markdown — the single canonical markdown renderer for chat, apps, and `AgentRun`; rich on web via react-markdown/remark-gfm with copyable tables, plain-text on native; takes a markdown `children` string; import `@lotics/ui/markdown.css` once for styling) ·
1019
1045
  card (Card · CardHeader · CardHeaderTitle · CardHeaderMeta · CardBody · CardFooter) ·
1020
- section_heading (Section · SectionHeading · SectionHeadingTitle · SectionHeadingMeta — the card-less twin of the Card family, compound, owns no margin; spacing via the Section gap, no body component. `SectionHeadingTitle` takes `weight` medium|semibold default medium, `semibold` for dense surfaces (a side panel, a stacked group list) where medium blends into the body, matching `CardHeaderTitle`; + `info` for an provenance popover after the title, same as `CardHeaderTitle.info`) · badge ·
1046
+ section_heading (Section · SectionHeading · SectionHeadingTitle · SectionHeadingMeta · Subsection · SubsectionHeading · SubsectionHeadingTitle — the card-less twin of the Card family, compound, owns no margin; spacing via the Section gap (12, fixed), no body component. `SectionHeadingTitle` is ALWAYS `##` (xl semibold; `weight="medium"` opt-down only) + `info` for an provenance popover after the title, same as `CardHeaderTitle.info`. `SubsectionHeadingTitle` is the `###` lg-semibold level-3 title of a named group inside a section heading-row siblings ride its right edge; the heading ramp is FIXED: # xxl / ## xl / ### lg, no size props) ·
1047
+ section_stack (SectionStack · SubsectionStack — divided stacks that own the between-block law: a fixed beat + hairline Divider BETWEEN blocks, skipping null children (56 for the flat page's Sections, 24 for a section's Subsections); stop hand-rolling gap + `<Divider />` pairs) · badge ·
1021
1048
  option_badge (OptionBadge — a select value as its configured colored badge) ·
1022
1049
  member_chip (MemberChip — avatar + name; the universal person render) ·
1023
1050
  member_select (MemberSelect — a Picker of MemberChip options; the member picker) ·
@@ -67,7 +67,7 @@ export function TplAllocate() {
67
67
  <View style={{ width: "100%", maxWidth: 1000, alignSelf: "center", gap: 16 }}>
68
68
  {/* header */}
69
69
  <View style={{ gap: 2 }}>
70
- <Text size="xl" weight="semibold">Apply payment</Text>
70
+ <Text size="xxl" weight="semibold">Apply payment</Text>
71
71
  <Text size="sm" color="muted">Split one received payment across the customer's open invoices — oldest first, or line by line</Text>
72
72
  </View>
73
73
 
@@ -76,7 +76,7 @@ export function TplAllocate() {
76
76
  <View style={{ padding: 20, flexDirection: "row", alignItems: "flex-start", gap: 24, flexWrap: "wrap" }}>
77
77
  <View style={{ gap: 2, flexGrow: 1, flexBasis: 220 }}>
78
78
  <Text size="xs" color="muted" weight="medium">Payment received</Text>
79
- <Text size="xxl" weight="semibold" tabular>{formatMoney(PAYMENT.amount)}</Text>
79
+ <Text size="xxxl" weight="semibold" tabular>{formatMoney(PAYMENT.amount)}</Text>
80
80
  <Text size="sm" color="muted">{`${PAYMENT.from} · ref ${PAYMENT.ref} · ${PAYMENT.date}`}</Text>
81
81
  </View>
82
82
  <View style={{ gap: 10, flexGrow: 1, flexBasis: 300 }}>
@@ -274,7 +274,7 @@ export function TplAttendance() {
274
274
  {/* header band */}
275
275
  <View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
276
276
  <View style={{ gap: 2, flex: 1 }}>
277
- <Text size="xl" weight="semibold">Attendance</Text>
277
+ <Text size="xxl" weight="semibold">Attendance</Text>
278
278
  <Text size="sm" color="muted">Today, Friday 12/06 — in/out times for 9 employees</Text>
279
279
  </View>
280
280
  <Picker
@@ -209,7 +209,7 @@ export function TplCalendar() {
209
209
  {/* header band */}
210
210
  <View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
211
211
  <View style={{ gap: 2, flex: 1 }}>
212
- <Text size="xl" weight="semibold">Calendar & planning</Text>
212
+ <Text size="xxl" weight="semibold">Calendar & planning</Text>
213
213
  <Text size="sm" color="muted">This week — deliveries, reconciliations and customer visits</Text>
214
214
  </View>
215
215
  <Text size="sm" color="muted" tabular>
@@ -313,7 +313,7 @@ export function TplDashboard() {
313
313
  screen needs it) */}
314
314
  <View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
315
315
  <View style={{ gap: 2, flex: 1 }}>
316
- <Text size="xl" weight="semibold">Executive dashboard</Text>
316
+ <Text size="xxl" weight="semibold">Executive dashboard</Text>
317
317
  <Text size="sm" color="muted">Revenue, the sales pipeline and what needs handling — refreshed every morning</Text>
318
318
  </View>
319
319
  <DateRangeFilterField value={period} onValueChange={setPeriod} locale="en-GB" labels={NHAN_BO_LOC} />
@@ -351,7 +351,7 @@ export function TplDocuments() {
351
351
  <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28, paddingBottom: 120 }}>
352
352
  <View style={{ maxWidth: 720, width: "100%", alignSelf: "center", gap: 24 }}>
353
353
  <View style={{ gap: 2 }}>
354
- <Text size="xl" weight="semibold">PO-2481 — Nordic Furniture AB</Text>
354
+ <Text size="xxl" weight="semibold">PO-2481 — Nordic Furniture AB</Text>
355
355
  <Text size="sm" color="muted">Sea freight · Gothenburg → Ho Chi Minh City · FOB · ETD 18 Jul 2026</Text>
356
356
  </View>
357
357
 
@@ -1212,7 +1212,7 @@ export function TplItemList() {
1212
1212
  <View style={{ width: "100%", maxWidth: 1040, alignSelf: "center", gap: 16 }}>
1213
1213
  {/* header — title only; the New case action lives on the toolbar row */}
1214
1214
  <View style={{ gap: 2 }}>
1215
- <Text size="xl" weight="semibold">Records</Text>
1215
+ <Text size="xxl" weight="semibold">Records</Text>
1216
1216
  <Text size="sm" color="muted">Track each record's status and print the right document for its stage</Text>
1217
1217
  </View>
1218
1218
 
@@ -255,7 +255,7 @@ export function TplLookup() {
255
255
  <Text size="xs" color="muted" weight="medium" style={{ flex: 1 }}>Suggested HS code</Text>
256
256
  <Confidence level={active.confidence} />
257
257
  </View>
258
- <Text size="xxl" weight="semibold" tabular>{active.code}</Text>
258
+ <Text size="xxxl" weight="semibold" tabular>{active.code}</Text>
259
259
  <Text size="sm" color="muted">{active.heading}</Text>
260
260
  </View>
261
261
 
@@ -101,7 +101,7 @@ function CurrentTask({ line, index, total, onConfirm, onShort }: {
101
101
  {/* the bin is the biggest thing on screen — it's where you walk */}
102
102
  <View style={{ gap: 2 }}>
103
103
  <Text size="xs" color="muted" weight="medium">Go to bin</Text>
104
- <Text size="xxl" weight="semibold" tabular>{line.bin}</Text>
104
+ <Text size="xxxl" weight="semibold" tabular>{line.bin}</Text>
105
105
  </View>
106
106
  <View style={{ flexDirection: "row", alignItems: "flex-end", gap: 16, flexWrap: "wrap" }}>
107
107
  <View style={{ gap: 2, flexGrow: 1, flexBasis: 200 }}>
@@ -110,7 +110,7 @@ function CurrentTask({ line, index, total, onConfirm, onShort }: {
110
110
  </View>
111
111
  <View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
112
112
  <Text size="sm" color="muted">Pick</Text>
113
- <Text size="xl" weight="semibold" tabular>{line.qty}</Text>
113
+ <Text size="xxl" weight="semibold" tabular>{line.qty}</Text>
114
114
  <Text size="sm" color="muted">units</Text>
115
115
  </View>
116
116
  </View>
@@ -199,7 +199,7 @@ export function TplPick() {
199
199
  {/* header — the wave + the cart it's filling */}
200
200
  <View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12, flexWrap: "wrap" }}>
201
201
  <View style={{ gap: 2, flex: 1 }}>
202
- <Text size="xl" weight="semibold">Queue W-0142</Text>
202
+ <Text size="xxl" weight="semibold">Queue W-0142</Text>
203
203
  <Text size="sm" color="muted">7 lines for 3 outbound orders · Cart C-07</Text>
204
204
  </View>
205
205
  <Button title="Pause wave" color="muted" onPress={() => {}} />
@@ -128,7 +128,7 @@ export function TplPivot() {
128
128
  <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
129
129
  <View style={{ width: "100%", maxWidth: 960, alignSelf: "center", gap: 16 }}>
130
130
  <View style={{ gap: 2 }}>
131
- <Text size="xl" weight="semibold">Stock distribution</Text>
131
+ <Text size="xxl" weight="semibold">Stock distribution</Text>
132
132
  <Text size="sm" color="muted">Where each category sits across the network — press a cell to list what's behind it</Text>
133
133
  </View>
134
134
 
@@ -12,7 +12,8 @@ import type { PickerOption } from "@lotics/ui/picker";
12
12
  import { Combobox, ComboboxInput, ComboboxContent } from "@lotics/ui/combobox";
13
13
  import { DetailRow, DetailTable } from "@lotics/ui/detail_row";
14
14
  import { Callout, CalloutText } from "@lotics/ui/callout";
15
- import { Section, SectionHeading, SectionHeadingMeta, SectionHeadingTitle } from "@lotics/ui/section_heading";
15
+ import { Section, SectionHeading, SectionHeadingMeta, SectionHeadingTitle, Subsection, SubsectionHeading, SubsectionHeadingTitle } from "@lotics/ui/section_heading";
16
+ import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
16
17
  import { MenuButton } from "@lotics/ui/menu_button";
17
18
  import { Modal, ModalBody, ModalHeader } from "@lotics/ui/modal";
18
19
  import { useSectionNav } from "@lotics/ui/use_section_nav";
@@ -653,7 +654,12 @@ export function TplRecord() {
653
654
  </View>
654
655
  )}
655
656
  <ScrollView ref={nav.scrollRef} onScroll={nav.onScroll} scrollEventThrottle={16} style={{ flex: 1 }} contentContainerStyle={{ padding: 28, paddingBottom: 96 }}>
656
- <View style={{ width: "100%", maxWidth: 720, alignSelf: "center", gap: 32 }}>
657
+ {/* the page column is a SectionStack it owns the 56px beat + the
658
+ hairline BETWEEN top-level blocks (a conditional block that renders
659
+ null never leaves a stray divider) */}
660
+ <SectionStack style={{ width: "100%", maxWidth: 720, alignSelf: "center" }}>
661
+ {/* header band — app bar + summary + key facts read as ONE block */}
662
+ <View style={{ gap: 32 }}>
657
663
  {/* app bar — CREATE is one click; the surface is where everything is refined */}
658
664
  <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", columnGap: 10, rowGap: 8 }}>
659
665
  <Text size="sm" color="muted">Records <Text size="sm" color="muted">/</Text> <Text size="sm" weight="medium" color="default" tabular>{code}</Text></Text>
@@ -691,8 +697,7 @@ export function TplRecord() {
691
697
  <InlineSelect value={terms} options={TERMS} onSave={persist(setTerms)} accessibilityLabel="Payment terms" />
692
698
  </DetailRow>
693
699
  </DetailTable>
694
-
695
- <Divider />
700
+ </View>
696
701
 
697
702
  {/* ONE DetailTable = the record's field grid: label · editor · trailing
698
703
  columns set once on the parent, every row aligned like a table —
@@ -743,8 +748,6 @@ export function TplRecord() {
743
748
  </Section>
744
749
  </View>
745
750
 
746
- <Divider />
747
-
748
751
  {/* CUSTOMER — TWO states, no swap mode. Attached: the read-only-first
749
752
  card (identity + editable fields — Tax ID with the registry Fetch
750
753
  that fills Contact + City, which are inline-editable too); Remove
@@ -857,8 +860,6 @@ export function TplRecord() {
857
860
  </Section>
858
861
  </View>
859
862
 
860
- <Divider />
861
-
862
863
  {/* BILLING — invoice documents on the record, in the inline vocabulary.
863
864
  The issue action lives in each band's header; ONE section-level
864
865
  callout explains what blocks issuing (no per-band totals, no
@@ -884,6 +885,9 @@ export function TplRecord() {
884
885
  </Callout>
885
886
  ) : null}
886
887
 
888
+ {/* the section's GROUPS — bands · collect · deposit — divide via the
889
+ SubsectionStack law (24 + hairline), no hand-rolled dividers */}
890
+ <SubsectionStack>
887
891
  <View style={{ gap: 10 }}>
888
892
  {invoices.map((inv) => (
889
893
  <InvoiceBand
@@ -905,9 +909,8 @@ export function TplRecord() {
905
909
  ))}
906
910
  </View>
907
911
 
908
- {/* collect band */}
909
- <Divider />
910
- <View style={{ gap: 10 }}>
912
+ {/* collect band — a headingless Subsection: the total owns the number */}
913
+ <Subsection>
911
914
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
912
915
  <Text weight="semibold" style={{ flex: 1 }}>Total to collect</Text>
913
916
  <Text weight="semibold" size="lg" tabular>{formatMoney(grandTotal)}</Text>
@@ -923,15 +926,15 @@ export function TplRecord() {
923
926
  <Text size="xs" color="muted" style={{ flex: 1 }}>{issuedCount} of {invoices.length} issued</Text>
924
927
  <Button title="Print receipt" color="primary" disabled={grandTotal <= 0} onPress={printReceipt} />
925
928
  </View>
926
- </View>
929
+ </Subsection>
927
930
 
928
- {/* deposit band — labelled separate, never part of the total */}
929
- <Divider />
930
- <View style={{ gap: 8 }}>
931
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
932
- <Text weight="semibold">Deposit</Text>
931
+ {/* deposit band — a named Subsection: labelled separate, never part
932
+ of the total (the badge rides the heading row's right edge) */}
933
+ <Subsection>
934
+ <SubsectionHeading>
935
+ <SubsectionHeadingTitle>Deposit</SubsectionHeadingTitle>
933
936
  <Badge label="Collected separately" color="zinc" />
934
- </View>
937
+ </SubsectionHeading>
935
938
  <View style={{ flexDirection: "row", alignItems: "center", gap: 12, flexWrap: "wrap", minHeight: 40 }}>
936
939
  <Text size="sm" color="muted" style={{ width: 150 }}>Refundable</Text>
937
940
  <View style={{ flexGrow: 1, flexBasis: 160 }}>
@@ -951,12 +954,11 @@ export function TplRecord() {
951
954
  onPress={() => Alert.alert("Deposit receipt", `Printing deposit receipt for ${formatMoney(deposit)}.`, [{ text: "OK" }])}
952
955
  />
953
956
  </View>
954
- </View>
957
+ </Subsection>
958
+ </SubsectionStack>
955
959
  </Section>
956
960
  </View>
957
961
 
958
- <Divider />
959
-
960
962
  {/* FILES — the record's document CRUD: drop to add, click a thumbnail
961
963
  to preview in the gallery, ✕ to delete. */}
962
964
  <View onLayout={nav.register("files")}>
@@ -996,8 +998,6 @@ export function TplRecord() {
996
998
  </Section>
997
999
  </View>
998
1000
 
999
- <Divider />
1000
-
1001
1001
  {/* TASKS — the handoff checklist, grouped by the desk that owns
1002
1002
  them; every row edits (the stage gates the handoff and Billing,
1003
1003
  never task editing). The CURRENT desk's open tasks warn beside
@@ -1159,8 +1159,6 @@ export function TplRecord() {
1159
1159
  </Section>
1160
1160
  </View>
1161
1161
 
1162
- <Divider />
1163
-
1164
1162
  {/* THE HANDOFF GATE — the atomic boundary between departments, not a
1165
1163
  form and not a message: the record itself changes desks. Each stage
1166
1164
  names what it owes (the why-hint) before its CTA un-gates; the
@@ -1191,8 +1189,6 @@ export function TplRecord() {
1191
1189
  </View>
1192
1190
  )}
1193
1191
 
1194
- <Divider />
1195
-
1196
1192
  {/* the settings shape: title + description left, a live control right,
1197
1193
  applied on change */}
1198
1194
  <View onLayout={nav.register("preferences")}>
@@ -1217,15 +1213,13 @@ export function TplRecord() {
1217
1213
  </Section>
1218
1214
  </View>
1219
1215
 
1220
- <Divider />
1221
-
1222
1216
  <DangerZone
1223
1217
  title="Delete record"
1224
1218
  description="Permanently remove this record and its invoices, charges, and history. This can't be undone."
1225
1219
  >
1226
1220
  <Button title="Delete record" color="danger" onPress={() => {}} />
1227
1221
  </DangerZone>
1228
- </View>
1222
+ </SectionStack>
1229
1223
 
1230
1224
  {/* the narrow-mode section picker — a full-page takeover: pick a section
1231
1225
  to jump there; Escape or the close control dismisses */}
@@ -222,7 +222,7 @@ export function TplReport() {
222
222
  {/* header band — title + the persistent period + the one primary action */}
223
223
  <View style={{ flexDirection: "row", gap: 16, alignItems: "flex-start", flexWrap: "wrap", zIndex: 20 }}>
224
224
  <View style={{ gap: 2, flex: 1, minWidth: 260 }}>
225
- <Text size="xl" weight="semibold">Payments</Text>
225
+ <Text size="xxl" weight="semibold">Payments</Text>
226
226
  <Text size="sm" color="muted">Look up payments by customer, reference, or operator across a period — totals, facets, and the matching lines</Text>
227
227
  </View>
228
228
  <View style={{ flexDirection: "row", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
@@ -147,7 +147,7 @@ export function TplRollup() {
147
147
  <View style={{ width: "100%", maxWidth: 980, alignSelf: "center", gap: 16 }}>
148
148
  {/* header band */}
149
149
  <View style={{ gap: 2 }}>
150
- <Text size="xl" weight="semibold">Network rollup</Text>
150
+ <Text size="xxl" weight="semibold">Network rollup</Text>
151
151
  <Text size="sm" color="muted">This month by region, site and lane — expand the branch that drags the total</Text>
152
152
  </View>
153
153
 
@@ -185,7 +185,7 @@ export function TplShifts() {
185
185
  <View style={{ width: "100%", maxWidth: 1100, alignSelf: "center", gap: 16 }}>
186
186
  {/* header band */}
187
187
  <View style={{ gap: 2 }}>
188
- <Text size="xl" weight="semibold">Shift staffing · week 15–21/06</Text>
188
+ <Text size="xxl" weight="semibold">Shift staffing · week 15–21/06</Text>
189
189
  <Text size="sm" color="muted">Members sign up, you allocate — fill every slot without burning anyone out</Text>
190
190
  </View>
191
191
 
@@ -119,7 +119,7 @@ function StatementPage({ title, meta, children }: { title: string; meta: string;
119
119
  <View style={{ width: "100%", maxWidth: 680, alignSelf: "center", gap: 20 }}>
120
120
  <View style={{ flexDirection: "row", alignItems: "flex-start", flexWrap: "wrap", columnGap: 12, rowGap: 8 }}>
121
121
  <View style={{ flexGrow: 1, flexBasis: 240, gap: 2 }}>
122
- <Text size="xl" weight="semibold">{title}</Text>
122
+ <Text size="xxl" weight="semibold">{title}</Text>
123
123
  <Text size="sm" color="muted">{meta}</Text>
124
124
  </View>
125
125
  <Button title="Export" color="secondary" icon="file-down" onPress={() => {}} />
@@ -193,7 +193,7 @@ export function TplStock() {
193
193
  {/* header band */}
194
194
  <View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
195
195
  <View style={{ gap: 2, flex: 1 }}>
196
- <Text size="xl" weight="semibold">Stock overview</Text>
196
+ <Text size="xxl" weight="semibold">Stock overview</Text>
197
197
  <Text size="sm" color="muted">The whole fleet at a glance — drill into any slice, then walk the units one by one</Text>
198
198
  </View>
199
199
  <Button title="Register units" color="primary" onPress={() => {}} />
@@ -328,7 +328,7 @@ export function TplTaskBoard() {
328
328
  <>
329
329
  <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ paddingVertical: 28, paddingHorizontal: 24, paddingBottom: 96 }}>
330
330
  <View style={{ maxWidth: 1240, width: "100%", alignSelf: "center", gap: 16 }}>
331
- <Text size="xl" weight="semibold">Task board</Text>
331
+ <Text size="xxl" weight="semibold">Task board</Text>
332
332
 
333
333
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
334
334
  <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 8, flex: 1 }}>
@@ -235,7 +235,7 @@ export function TplTasks() {
235
235
  <>
236
236
  <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ paddingVertical: 28, paddingHorizontal: 20, paddingBottom: 96 }}>
237
237
  <View style={{ maxWidth: 680, width: "100%", alignSelf: "center", gap: 14 }}>
238
- <Text size="xl" weight="semibold">Tasks</Text>
238
+ <Text size="xxl" weight="semibold">Tasks</Text>
239
239
 
240
240
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
241
241
  <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 8, flex: 1 }}>
@@ -166,7 +166,7 @@ export function TplTower() {
166
166
  <View style={{ width: "100%", maxWidth: 1100, alignSelf: "center", gap: 16 }}>
167
167
  {/* header band */}
168
168
  <View style={{ gap: 2 }}>
169
- <Text size="xl" weight="semibold">Control tower</Text>
169
+ <Text size="xxl" weight="semibold">Control tower</Text>
170
170
  <Text size="sm" color="muted">Every unit's live state — scan for red, work the exceptions, read the pattern</Text>
171
171
  </View>
172
172
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "10.0.0",
3
+ "version": "11.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -173,6 +173,7 @@
173
173
  "./list_item": "./src/list_item.tsx",
174
174
  "./section": "./src/section.tsx",
175
175
  "./section_heading": "./src/section_heading.tsx",
176
+ "./section_stack": "./src/section_stack.tsx",
176
177
  "./separator": "./src/separator.tsx",
177
178
  "./stack": "./src/stack.tsx",
178
179
  "./status_badge": "./src/status_badge.tsx",
package/src/button.tsx CHANGED
@@ -151,6 +151,10 @@ export function Button(props: ButtonProps) {
151
151
 
152
152
 
153
153
  export function getButtonTextColor(color?: ButtonColor, disabled?: boolean): TextColor {
154
+ // Disabled fades the label to the zinc-400 disabled ink on EVERY variant —
155
+ // the washed background alone is a one-step tint nobody reads as disabled.
156
+ // (The primary keeps inverted ink; its zinc-300 surface carries the wash.)
157
+ if (disabled) return color === "primary" ? "inverted" : "zinc-400";
154
158
  switch (color) {
155
159
  case "primary":
156
160
  return "inverted";
@@ -162,7 +166,7 @@ export function getButtonTextColor(color?: ButtonColor, disabled?: boolean): Tex
162
166
  case "muted":
163
167
  return "muted";
164
168
  default:
165
- return disabled ? "muted" : "default";
169
+ return "default";
166
170
  }
167
171
  }
168
172
 
@@ -6,6 +6,9 @@ import { colors } from "./colors";
6
6
  * same colour. Lives in its own module (not `button.tsx`) so a test that mocks
7
7
  * `@lotics/ui/button` can't leave `IconButton` without it. */
8
8
  export function getButtonIconColor(color?: ButtonColor, disabled?: boolean): string {
9
+ // Disabled mutes the glyph on EVERY variant (mirrors `getButtonTextColor`) —
10
+ // the primary keeps white ink on its washed zinc-300 disabled surface.
11
+ if (disabled) return color === "primary" ? colors.white : colors.zinc["400"];
9
12
  switch (color) {
10
13
  case "primary":
11
14
  return colors.white;
@@ -18,6 +21,6 @@ export function getButtonIconColor(color?: ButtonColor, disabled?: boolean): str
18
21
  case "muted":
19
22
  return colors.zinc["600"];
20
23
  default:
21
- return disabled ? colors.zinc["300"] : colors.zinc["950"];
24
+ return colors.zinc["950"];
22
25
  }
23
26
  }
@@ -84,7 +84,7 @@ export function PageContent(props: PageContentProps) {
84
84
  }}
85
85
  >
86
86
  {!!title && (
87
- <Text size="xl" weight="semibold">
87
+ <Text size="xxl" weight="semibold">
88
88
  {title}
89
89
  </Text>
90
90
  )}
@@ -22,12 +22,12 @@ export function PageHeader(props: PageHeaderProps) {
22
22
  {left}
23
23
  {right}
24
24
  </View>
25
- <Text size="xl" weight="semibold">
25
+ <Text size="xxl" weight="semibold">
26
26
  {title}
27
27
  </Text>
28
28
  </>
29
29
  ) : (
30
- <Text size="xl" weight="semibold">
30
+ <Text size="xxl" weight="semibold">
31
31
  {title}
32
32
  </Text>
33
33
  )}
@@ -47,7 +47,9 @@ export function RecordSummary(props: RecordSummaryProps) {
47
47
  <View style={styles.identityRow}>
48
48
  <View style={styles.identity}>
49
49
  <View style={styles.titleRow}>
50
- <Text size="xl" weight="semibold" tabular level={level}>
50
+ {/* xxl — the `#` of the heading ramp (# xxl → ## xl section
51
+ ### lg subsection); the identity always tops the outline */}
52
+ <Text size="xxl" weight="semibold" tabular level={level}>
51
53
  {title}
52
54
  </Text>
53
55
  {status ?? null}
@@ -53,7 +53,7 @@ export function RingGauge(props: RingGaugeProps) {
53
53
  transform={`rotate(-90 ${center} ${center})`}
54
54
  />
55
55
  </svg>
56
- <Text size="xxl" weight="semibold">
56
+ <Text size="xxxl" weight="semibold">
57
57
  {Math.round(clamped)}%
58
58
  </Text>
59
59
  </View>
@@ -38,13 +38,72 @@ export interface SectionProps {
38
38
  */
39
39
  export function Section(props: SectionProps) {
40
40
  // gap 12 spaces the heading from its body. Space BETWEEN sections belongs to
41
- // the page column: flat record/form pages use `gap: 32` on the content column
42
- // with a bare `Divider` BETWEEN sections (never under the heading the
43
- // heading belongs to its content; the hairline separates it from the LAST
44
- // section).
41
+ // the page column (`SectionStack`, 56 + hairline); a body of `Subsection`
42
+ // groups divides via a nested `SubsectionStack` (24 + hairline)
43
+ // never a hairline directly under the heading (it belongs to its content).
45
44
  return <View style={[{ gap: 12 }, props.style]}>{props.children}</View>;
46
45
  }
47
46
 
47
+ export interface SubsectionProps {
48
+ children: React.ReactNode;
49
+ style?: StyleProp<ViewStyle>;
50
+ }
51
+
52
+ /**
53
+ * One named group INSIDE a `Section` — the level below `Section` on a long
54
+ * record surface (`###` in the heading ramp). Compose a `SubsectionHeading`
55
+ * then the group's rows; sibling subsections stack in a `SubsectionStack`
56
+ * (24 + hairline between groups — no margins, no hand-rolled dividers). A
57
+ * headingless `Subsection` is fine for the section's lead group.
58
+ *
59
+ * <Section>
60
+ * <SectionHeading><SectionHeadingTitle>Delivery</SectionHeadingTitle></SectionHeading>
61
+ * <SubsectionStack>
62
+ * <Subsection>{lead rows}</Subsection>
63
+ * <Subsection>
64
+ * <SubsectionHeading><SubsectionHeadingTitle>Carrier</SubsectionHeadingTitle></SubsectionHeading>
65
+ * {rows}
66
+ * </Subsection>
67
+ * </SubsectionStack>
68
+ * </Section>
69
+ */
70
+ export function Subsection(props: SubsectionProps) {
71
+ return <View style={[{ gap: 12 }, props.style]}>{props.children}</View>;
72
+ }
73
+
74
+ export interface SubsectionHeadingProps {
75
+ children: React.ReactNode;
76
+ style?: StyleProp<ViewStyle>;
77
+ }
78
+
79
+ export function SubsectionHeading(props: SubsectionHeadingProps) {
80
+ return (
81
+ <View style={[{ flexDirection: "row", alignItems: "center", gap: 10 }, props.style]}>
82
+ {props.children}
83
+ </View>
84
+ );
85
+ }
86
+
87
+ export interface SubsectionHeadingTitleProps {
88
+ children: React.ReactNode;
89
+ /** Heading rank. Defaults to 3 — one level under `SectionHeadingTitle`'s 2. */
90
+ level?: HeadingLevel;
91
+ }
92
+
93
+ /** The subsection title — ALWAYS `###` (lg semibold), the fixed step under the
94
+ * xl section title and above the md/sm body. Grows to push siblings (a
95
+ * `SectionHeadingMeta`, a `Badge`, an action) to the right edge. */
96
+ export function SubsectionHeadingTitle(props: SubsectionHeadingTitleProps) {
97
+ const { children, level = 3 } = props;
98
+ return (
99
+ <View style={{ flex: 1 }}>
100
+ <Text level={level} size="lg" weight="semibold">
101
+ {children}
102
+ </Text>
103
+ </View>
104
+ );
105
+ }
106
+
48
107
  export interface SectionHeadingProps {
49
108
  children: React.ReactNode;
50
109
  style?: StyleProp<ViewStyle>;
@@ -73,16 +132,18 @@ export interface SectionHeadingTitleProps {
73
132
  info?: string;
74
133
  }
75
134
 
76
- /** The title block — lg semibold (a section must announce itself; md blends
77
- * into control labels) + an optional muted `description` line. Grows to push
78
- * any sibling actions to the right edge. */
135
+ /** The title block — ALWAYS `##` (xl semibold): the heading ramp is the fixed
136
+ * markdown ladder `#` xxl (`RecordSummary`/`PageHeader`) `##` xl (section) →
137
+ * `###` lg (`SubsectionHeadingTitle`) over the sm body — no per-surface size
138
+ * knobs, one outline everywhere. Optional muted `description` line; grows to
139
+ * push any sibling actions to the right edge. */
79
140
  export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
80
141
  const { children, description, icon, level = 2, weight = "semibold", info } = props;
81
142
  return (
82
143
  <View style={{ flex: 1, gap: 2 }}>
83
144
  <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
84
- {icon ? <Icon name={icon} size={18} /> : null}
85
- <Text level={level} weight={weight} size="lg">
145
+ {icon ? <Icon name={icon} size={20} /> : null}
146
+ <Text level={level} weight={weight} size="xl">
86
147
  {children}
87
148
  </Text>
88
149
  {info ? <InfoPopover text={info} accessibilityLabel={useLoticsLocale().sectionHeading.info} /> : null}
@@ -0,0 +1,67 @@
1
+ import { Children, Fragment } from "react";
2
+ import { View, type StyleProp, type ViewStyle } from "react-native";
3
+ import { Divider } from "./divider";
4
+
5
+ interface StackProps {
6
+ children: React.ReactNode;
7
+ /** Hairline between blocks (default true). Turn off for a short surface
8
+ * where the beat alone is enough. */
9
+ divided?: boolean;
10
+ style?: StyleProp<ViewStyle>;
11
+ }
12
+
13
+ /** Interleave a hairline between the non-null blocks of a gap column. */
14
+ function DividedStack({ children, divided = true, gap, style }: StackProps & { gap: number }) {
15
+ const blocks = Children.toArray(children); // drops null/undefined/false
16
+ return (
17
+ <View style={[{ gap }, style]}>
18
+ {blocks.map((block, i) => (
19
+ // Children.toArray keys are stable for conditional lists; the fragment
20
+ // pairs each block with the hairline that separates it from the last.
21
+ <Fragment key={`sec_${i}`}>
22
+ {divided && i > 0 ? <Divider /> : null}
23
+ {block}
24
+ </Fragment>
25
+ ))}
26
+ </View>
27
+ );
28
+ }
29
+
30
+ export type SectionStackProps = StackProps;
31
+
32
+ /**
33
+ * The content column of a flat record/form page — it OWNS the between-section
34
+ * law: a fixed 56px beat with a hairline `Divider` between top-level blocks
35
+ * (never under a heading; the hairline separates a block from the PREVIOUS
36
+ * one). Null/false children are skipped, so a conditional section never
37
+ * leaves a stray hairline. No knobs — every flat page shares one rhythm.
38
+ *
39
+ * <SectionStack>
40
+ * {headerBand}
41
+ * <Section>…</Section>
42
+ * {canTransfer ? <Section>…</Section> : null}
43
+ * </SectionStack>
44
+ */
45
+ export function SectionStack(props: SectionStackProps) {
46
+ return <DividedStack {...props} gap={56} />;
47
+ }
48
+
49
+ export type SubsectionStackProps = StackProps;
50
+
51
+ /**
52
+ * The divided stack of `Subsection` groups INSIDE a `Section` — the same
53
+ * hairline law one step tighter: a fixed 24px beat. Compose it as the
54
+ * section's body (after the `SectionHeading`); a section's groups divide
55
+ * without hand-rolled gaps or dividers.
56
+ *
57
+ * <Section>
58
+ * <SectionHeading><SectionHeadingTitle>Delivery</SectionHeadingTitle></SectionHeading>
59
+ * <SubsectionStack>
60
+ * <Subsection>{lead rows}</Subsection>
61
+ * {carrier ? <Subsection>…</Subsection> : null}
62
+ * </SubsectionStack>
63
+ * </Section>
64
+ */
65
+ export function SubsectionStack(props: SubsectionStackProps) {
66
+ return <DividedStack {...props} gap={24} />;
67
+ }
package/src/text.css CHANGED
@@ -31,12 +31,19 @@
31
31
  line-height: 24px;
32
32
  letter-spacing: -0.005em;
33
33
  }
34
+ /* Display ramp — the heading ladder: xl = `##` (section), xxl = `#`
35
+ (page/record title), xxxl = hero numbers. */
34
36
  [data-text-size="xl"] {
37
+ font-size: 22px;
38
+ line-height: 28px;
39
+ letter-spacing: -0.01em;
40
+ }
41
+ [data-text-size="xxl"] {
35
42
  font-size: 28px;
36
43
  line-height: 34px;
37
44
  letter-spacing: -0.015em;
38
45
  }
39
- [data-text-size="xxl"] {
46
+ [data-text-size="xxxl"] {
40
47
  font-size: 32px;
41
48
  line-height: 38px;
42
49
  letter-spacing: -0.02em;
@@ -61,10 +68,14 @@
61
68
  line-height: 28px;
62
69
  }
63
70
  [data-text-size="xl"] {
71
+ font-size: 24px;
72
+ line-height: 32px;
73
+ }
74
+ [data-text-size="xxl"] {
64
75
  font-size: 32px;
65
76
  line-height: 44px;
66
77
  }
67
- [data-text-size="xxl"] {
78
+ [data-text-size="xxxl"] {
68
79
  font-size: 48px;
69
80
  line-height: 52px;
70
81
  }
package/src/text.tsx CHANGED
@@ -50,7 +50,7 @@ export interface TextProps {
50
50
 
51
51
  export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
52
52
 
53
- export type TextSize = "xs" | "sm" | "md" | "lg" | "xl" | "xxl";
53
+ export type TextSize = "xs" | "sm" | "md" | "lg" | "xl" | "xxl" | "xxxl";
54
54
  type TextAlign = "left" | "right" | "center";
55
55
  type TextDecorationLine = "underline" | "lineThrough" | "underline lineThrough";
56
56
  type TextWeight = "regular" | "medium" | "semibold";
@@ -146,11 +146,17 @@ const styles = StyleSheet.create({
146
146
  fontSize: 18,
147
147
  lineHeight: 30,
148
148
  },
149
+ // Display ramp — the heading ladder: xl = `##` (section), xxl = `#`
150
+ // (page/record title), xxxl = hero numbers.
149
151
  xl: {
152
+ fontSize: 22,
153
+ lineHeight: 28,
154
+ },
155
+ xxl: {
150
156
  fontSize: 28,
151
157
  lineHeight: 34,
152
158
  },
153
- xxl: {
159
+ xxxl: {
154
160
  fontSize: 32,
155
161
  lineHeight: 38,
156
162
  },
package/src/text_utils.ts CHANGED
@@ -10,7 +10,8 @@ export type TextColor =
10
10
  | "success"
11
11
  | "zinc-900"
12
12
  | "zinc-700"
13
- | "zinc-500";
13
+ | "zinc-500"
14
+ | "zinc-400";
14
15
 
15
16
  export function getTextColor(color?: TextColor): string {
16
17
  switch (color) {
@@ -24,6 +25,10 @@ export function getTextColor(color?: TextColor): string {
24
25
  return colors.zinc["700"];
25
26
  case "zinc-500":
26
27
  return colors.zinc["500"];
28
+ // The disabled ink — light enough that a control reads inert at a glance
29
+ // (muted/600 still reads as body ink on a zinc-50 chip).
30
+ case "zinc-400":
31
+ return colors.zinc["400"];
27
32
  case "inverted":
28
33
  return colors.white;
29
34
  // Valence set — the lightest status weight: a state WORD whose meaning is in