@lotics/ui 47.13.0 → 47.14.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.
@@ -284,7 +284,10 @@ field they would be both a false claim and dead buttons. The **popover** editors
284
284
  (the resting box, wired as the popover trigger) with their OWN `useState` for open/draft/saving/
285
285
  error — a pick, not a keystroke, is the edit, so `useInlineEdit` doesn't fit. `onSave` is async
286
286
  either way: the saving spinner sits INSIDE the control at its right edge (never a sibling — that
287
- reflows); an error shows inline without losing the edit.
287
+ reflows); an error shows inline without losing the edit. Saves overlap — the operator blurs one
288
+ field and edits the next while the first round trip is out — so land each result with the
289
+ updater form (`setForm((f) => ({ ...f, [key]: value }))`), never the form the save started from:
290
+ a snapshot lands the other field back at its pre-edit value while the server holds both.
288
291
 
289
292
  To let a value be UNSET (a diff-write CLEAR — unassign, remove a due date, drop a select), pass
290
293
  **`onClear`** to `InlineSelect` / `InlineMemberSelect` / `InlineDatePicker`. It surfaces through
@@ -332,6 +335,15 @@ in a `DataGrid`), where a cell varying with its data breaks the row rhythm the g
332
335
  on a record surface; reserve in a table. Worked both ways in `dev/pages/inline_edit.tsx`
333
336
  § Multiline — the same value in a reserved field and a grown one, side by side.
334
337
 
338
+ **In a table the WIDTH is reserved too, and by the CELL — the editor never states its own.** An
339
+ inline editor is sized by what it holds: at rest a chip around the formatted value, focused a real
340
+ input at its natural width. Put one in a `TableCell` with nothing bounding it and the two states
341
+ are different boxes — the focused one grows sideways out of its column, over the neighbour, and
342
+ takes the row's height with it, on the one row somebody is working in. So wrap it in a `View` with
343
+ a fixed `width`, one number for the column, and both states land on the same rectangle.
344
+ (`NumberInput` already refuses to state a width for exactly this reason — see its `width: "100%"`
345
+ comment — but "fills its container" only bounds anything once a container has a size.)
346
+
335
347
  ### The row stack — `DetailTable` + `DetailRow`
336
348
 
337
349
  A STACK of rows lives in a `DetailTable` (label, value laid out like a TABLE: `labelWidth` /
@@ -762,12 +774,10 @@ chronological order; each phase owns ITS OWN fee rows + phase total (the invoice
762
774
  level up — money never reads as one pot); each closes with a SEQUENTIAL confirm gate that names
763
775
  its blockers ("Confirm check-in first."). The header status chip carries the phase.
764
776
 
765
- A LONG record surface pairs with a LEFT OUTLINE RAIL — `MenuButton` items + `useSectionNav`
766
- (scroll-spy: jump to a section, the highlight follows the scroll); the active item is
767
- `current={activeKey === key}`, the NAVIGATION state, never `selected`, which is listbox
768
- selection. On narrow containers the rail
769
- becomes a PINNED bar naming the current section that opens a full-page section-picker `Modal`. A
770
- per-phase dot on a rail item carries its confirmed state. A direction that is a TYPE (one record
777
+ A LONG record surface pairs with a LEFT OUTLINE RAIL — `SectionNav` for the chrome,
778
+ `useSectionNav` for the scroll-spy that feeds it `activeKey` and `jumpTo`. On narrow containers
779
+ the same list collapses to a pinned bar naming the current section, which opens it as a picker
780
+ anchored to that bar. A per-phase `issue` dot on an item carries its confirmed state. A direction that is a TYPE (one record
771
781
  per event) is instead a discriminator chosen ONCE at creation — a `SegmentedControl` in the
772
782
  create step, never a toggle on the record.
773
783
 
package/docs/reviewing.md CHANGED
@@ -333,6 +333,13 @@ defect, so finding it on one panel is a reason to sweep the siblings.
333
333
  that drifts across the track means each one was offset by a guess at its own width.
334
334
  - **A row beat belongs to the PAGE** — collect every row primitive's height on one screen before
335
335
  calling any of them right.
336
+ - **A bar's TRACK, not its card.** A chart whose label column and value column are FIXED spends
337
+ those pixels first, so a narrow pane leaves the plot itself with almost nothing — and a bar has
338
+ a minimum width, so every row draws the same short stub and the picture states that all the
339
+ entities are equal. It measures perfectly: the card is there, the rows are there, the figures
340
+ are right. Collect the track's width and each bar's, per row: a set of identical bar widths
341
+ under differing values is the finding, and so is any track under about a third of the card.
342
+ Same collection catches the axis underneath it, whose labels stack rather than crowd.
336
343
  - **Cards laid side by side: collect their BOTTOM edges, not their tops.** Tops align for free —
337
344
  the row sets them — so every horizontal check passes while the row ends on a staircase. One
338
345
  `getBoundingClientRect().bottom` per card in a row: more than one value is the finding, and the
@@ -342,6 +349,15 @@ defect, so finding it on one panel is a reason to sweep the siblings.
342
349
  neighbour, give the short card the fact it is missing, or re-pair the row. Screenshots are how
343
350
  this is noticed and the rects are how it is aimed: a staircase is obvious on sight and invisible
344
351
  to every probe above this one.
352
+ - **A scroller's CONTENT against the scroller — measure both, never just the frame.** A horizontal
353
+ scroll region lays its child out in a ROW, so a grid, a strip or a rail narrower than its frame
354
+ keeps its own content width and leaves the rest of the box empty; a `min-width: 100%` on the
355
+ content container does not carry to the child, which is what makes this look already fixed in
356
+ the source. It never appears as the blank space it is — the child sits at its floor, so what
357
+ shows is its CELLS wrapping their values onto two lines while a third of the card beside them is
358
+ drawn on by nothing. Walk up from one cell to the ancestor whose parent has `overflow-x: auto`,
359
+ comparing widths at each step; a child narrower than that frame is the finding, and the fix is
360
+ to let the child grow, never to widen the cells by hand.
345
361
 
346
362
  → [composition.md](./composition.md) §"Canvas & content column", §"Cards — banded and composable", §"No dead rows", §"The register's own craft".
347
363
 
@@ -583,6 +599,20 @@ explicit one, so a set where "the important ones are annotated" sheds the unanno
583
599
  first — annotating some columns and not others is the failure, not the fix. If the register has
584
600
  more than one column, either all of them carry a rank or none do.
585
601
 
602
+ **Then ask what the FIRST survivor is, because position outranks every rank.** Column 0 is the
603
+ row's identity and never drops, whatever number it carries — so if the survivor list opens on
604
+ something that does not identify the row (a time, a date, a sequence number), the finding is the
605
+ column ORDER and no amount of re-ranking touches it. It hides behind the reading convention:
606
+ anything read as a schedule is ordered time-first, which quietly makes the clock the one column a
607
+ phone cannot lose. Chasing it through the ranks finds nothing, because the rank was never what
608
+ kept it.
609
+
610
+ **And count each survivor as a FACT, not a column.** Two columns holding one fact-pair — declared
611
+ against counted, quoted against invoiced, planned against actual — mean nothing apart, so the fit
612
+ drops whichever it reaches first and the survivor states a number with nothing to compare it to.
613
+ That is worse than dropping both. Put the pair in ONE cell (`38 → 41`) and it survives or sheds
614
+ whole, which also halves what the width has to carry.
615
+
586
616
  ### 8h-bis. A cell that grows a second line lifts its first
587
617
  **Collect** one column's first-line `y` MINUS its row's top, for every row; report the SET.
588
618
 
package/docs/templates.md CHANGED
@@ -805,7 +805,7 @@ quick-capture templates. Top → bottom:
805
805
  so a walk-back is one handoff at a time.
806
806
  - **Danger zone** — with the full section anatomy (heading + description) like every other
807
807
  section; the fenced red `DangerZone` card is the section's BODY.
808
- - A LEFT OUTLINE RAIL (`MenuButton` + `useSectionNav`) — **the rail is the page's COMPLETE
808
+ - A LEFT OUTLINE RAIL (`SectionNav` + `useSectionNav`) — **the rail is the page's COMPLETE
809
809
  map** (every section including Danger zone; subsections never join it) — headed by the
810
810
  bare circular `BackButton`; it scroll-spies the active section (`current={activeKey === key}`
811
811
  on each item — navigation, not selection), and on narrow containers becomes the pinned
@@ -2,7 +2,6 @@ import { useState } from "react";
2
2
  import { ScrollView, View } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
4
  import { colors, solid } from "@lotics/ui/colors";
5
- import { Accordion, AccordionContent, AccordionHeader } from "@lotics/ui/accordion";
6
5
  import { ActionMenu } from "@lotics/ui/action_menu";
7
6
  import { Avatar } from "@lotics/ui/avatar";
8
7
  import { Badge } from "@lotics/ui/badge";
@@ -14,6 +13,7 @@ import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
14
13
  import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
15
14
  import { KPIStrip } from "@lotics/ui/kpi_strip";
16
15
  import { Picker } from "@lotics/ui/picker";
16
+ import { StateMatrix, type StateMatrixState } from "@lotics/ui/state_matrix";
17
17
  import { Tabs } from "@lotics/ui/tabs";
18
18
 
19
19
  // ─────────────────────────────────────────────────────────────────────────────
@@ -24,8 +24,9 @@ import { Tabs } from "@lotics/ui/tabs";
24
24
  // Grammar: zinc-50 canvas, header + month Picker, KPIStrip (four counts),
25
25
  // Tabs Today / This week. "Today" = one banded roster Card — each row
26
26
  // press-opens the employee workspace Drawer (◀ ▶ sequencing) and carries a
27
- // ⋯ ActionMenu with the day's quick fixes. "This week" = per-person Accordion
28
- // rows: five day markers, expanding to the day-by-day detail in place.
27
+ // ⋯ ActionMenu with the day's quick fixes. "This week" = one StateMatrix —
28
+ // a person per row, the weekdays named once across the top, a glyph per day;
29
+ // a cell opens the same workspace Drawer.
29
30
  // ─────────────────────────────────────────────────────────────────────────────
30
31
 
31
32
  type TrangThai = "co_mat" | "di_muon" | "vang" | "nghi_phep";
@@ -66,12 +67,13 @@ const TRANG_THAI: Record<TrangThai, { label: string; color: "emerald" | "amber"
66
67
  nghi_phep: { label: "On leave", color: "blue" },
67
68
  };
68
69
 
69
- const NGAY_CONG: Record<NgayCong, { bg: string; border: string; label: string }> = {
70
- du: { bg: solid("emerald"), border: solid("emerald"), label: "Full day" },
71
- muon: { bg: solid("amber"), border: solid("amber"), label: "Late" },
72
- vang: { bg: colors.white, border: solid("red"), label: "Absent" },
73
- phep: { bg: solid("blue"), border: solid("blue"), label: "On leave" },
74
- };
70
+ const NGAY_CONG: (StateMatrixState & { key: NgayCong })[] = [
71
+ { key: "du", label: "Full day", color: "emerald" },
72
+ { key: "muon", label: "Late", color: "amber" },
73
+ { key: "vang", label: "Absent", color: "red", shape: "hollow" },
74
+ { key: "phep", label: "On leave", color: "blue", shape: "dot" },
75
+ ];
76
+ const ngayCong = (k: NgayCong) => NGAY_CONG.find((n) => n.key === k) ?? NGAY_CONG[0];
75
77
 
76
78
  const THANG = [
77
79
  { label: "April 2026", value: "2026-04" },
@@ -85,12 +87,9 @@ const TAB = [
85
87
  ];
86
88
 
87
89
  const NGAY_TUAN = ["T2", "T3", "T4", "T5", "T6"];
90
+ const NGAY_TUAN_DAY = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"];
88
91
 
89
- const rowPad = { paddingHorizontal: 20, flexDirection: "row" as const, alignItems: "center" as const, gap: 12 };
90
92
 
91
- // Week-view widths (the Accordion grid: day cells + week total + disclosure).
92
- // The Today roster uses COLUMNS below, rendered by Table.
93
- const W = { tong: 72, cell: 22, chevron: 28 };
94
93
 
95
94
  const COLUMNS: TableColumn[] = [
96
95
  { key: "ten", label: "Employee", flex: 1, sortable: true },
@@ -104,12 +103,23 @@ function badgeLabel(nv: NhanVien): string {
104
103
  return nv.trangThai === "di_muon" && nv.muonPhut !== undefined ? `${tt.label} ${nv.muonPhut}ph` : tt.label;
105
104
  }
106
105
 
107
- /** One weekday marker — filled means worked, hollow red ring means absent. */
106
+ /** One weekday marker in the drawer's day list the same glyph the week grid draws. */
108
107
  function DayCell({ ngay }: { ngay: NgayCong }) {
109
- const s = NGAY_CONG[ngay];
108
+ const s = ngayCong(ngay);
109
+ const hollow = s.shape === "hollow";
110
+ const dot = s.shape === "dot";
110
111
  return (
111
- <View style={{ width: W.cell, alignItems: "center" }}>
112
- <View style={{ width: 13, height: 13, borderRadius: 4, backgroundColor: s.bg, borderWidth: 1, borderColor: s.border }} />
112
+ <View style={{ width: 22, alignItems: "center" }}>
113
+ <View
114
+ style={{
115
+ width: dot ? 6 : 12,
116
+ height: dot ? 6 : 12,
117
+ borderRadius: dot ? 999 : 3,
118
+ backgroundColor: hollow ? colors.white : solid(s.color),
119
+ borderWidth: hollow ? 1.5 : 0,
120
+ borderColor: solid(s.color),
121
+ }}
122
+ />
113
123
  </View>
114
124
  );
115
125
  }
@@ -160,38 +170,6 @@ function HomNayRow({ nv, selected, onPress }: { nv: NhanVien; selected: boolean;
160
170
  );
161
171
  }
162
172
 
163
- /** Week row — read-only drill-down: expands in place to the day-by-day detail. */
164
- function TuanRow({ nv }: { nv: NhanVien }) {
165
- return (
166
- <Accordion>
167
- <AccordionHeader accessibilityLabel={`Week detail for ${nv.ten}`}>
168
- <View style={{ flex: 1, gap: 2 }}>
169
- <Text size="sm" weight="medium" leading="tight" numberOfLines={1}>{nv.ten}</Text>
170
- {/* Body size: `medium` above already separates the pair, so the rung
171
- down would be a second demotion on the line that says WHICH
172
- person this is. */}
173
- <Text size="sm" color="muted" leading="tight" numberOfLines={1}>{nv.chucVu}</Text>
174
- </View>
175
- <View style={{ flexDirection: "row", gap: 10 }}>
176
- {nv.tuan.map((ngay, i) => <DayCell key={NGAY_TUAN[i]} ngay={ngay} />)}
177
- </View>
178
- <View style={{ width: W.tong, alignItems: "flex-end" }}>
179
- <Text size="sm" weight="medium" tabular>{nv.tongTuan}</Text>
180
- </View>
181
- </AccordionHeader>
182
- <AccordionContent>
183
- {nv.tuan.map((ngay, i) => (
184
- <View key={NGAY_TUAN[i]} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 32 }}>
185
- <Text size="xs" color="muted" tabular style={{ width: 24 }}>{NGAY_TUAN[i]}</Text>
186
- <DayCell ngay={ngay} />
187
- <Text size="sm">{NGAY_CONG[ngay].label}</Text>
188
- </View>
189
- ))}
190
- </AccordionContent>
191
- </Accordion>
192
- );
193
- }
194
-
195
173
  function KVRow({ label, value }: { label: string; value: string }) {
196
174
  return (
197
175
  <View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 24 }}>
@@ -234,7 +212,7 @@ function NhanVienWorkspace({ nv }: { nv: NhanVien }) {
234
212
  <View key={NGAY_TUAN[i]} style={{ flexDirection: "row", alignItems: "center", gap: 10, minHeight: 24 }}>
235
213
  <Text size="xs" color="muted" tabular style={{ width: 24 }}>{NGAY_TUAN[i]}</Text>
236
214
  <DayCell ngay={ngay} />
237
- <Text size="sm">{NGAY_CONG[ngay].label}</Text>
215
+ <Text size="sm">{ngayCong(ngay).label}</Text>
238
216
  </View>
239
217
  ))}
240
218
  </View>
@@ -258,7 +236,6 @@ export function TplAttendance() {
258
236
  const onSort = (key: string) => setSort(cycleSort(sort, key));
259
237
 
260
238
  const dem = (tt: TrangThai) => NHAN_VIEN.filter((nv) => nv.trangThai === tt).length;
261
- const eyebrow = (label: string) => <Text size="xs" color="muted" weight="medium">{label}</Text>;
262
239
 
263
240
  // The Today roster, ordered by the active sort (Hours sorts numerically).
264
241
  const sorted = sortBy(NHAN_VIEN, sort, (nv, key) =>
@@ -310,35 +287,29 @@ export function TplAttendance() {
310
287
  </Table>
311
288
  </Card>
312
289
  ) : (
313
- <Card style={{ padding: 0 }}>
314
- <View style={[rowPad, { paddingVertical: 10 }]}>
315
- <View style={{ flex: 1 }}>{eyebrow("Employee")}</View>
316
- <View style={{ flexDirection: "row", gap: 10 }}>
317
- {NGAY_TUAN.map((d) => (
318
- <View key={d} style={{ width: W.cell, alignItems: "center" }}>{eyebrow(d)}</View>
319
- ))}
320
- </View>
321
- <View style={{ width: W.tong, alignItems: "flex-end" }}>{eyebrow("Week total")}</View>
322
- <View style={{ width: W.chevron - 12 }} />
323
- </View>
324
- <View style={{ paddingHorizontal: 20 }}>
325
- {NHAN_VIEN.map((nv) => (
326
- <View key={nv.id}>
327
- <Divider />
328
- <TuanRow nv={nv} />
329
- </View>
330
- ))}
331
- </View>
332
- <Divider />
333
- {/* legend band — what each marker means */}
334
- <View style={[rowPad, { paddingVertical: 12, gap: 18 }]}>
335
- {(Object.keys(NGAY_CONG) as NgayCong[]).map((k) => (
336
- <View key={k} style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
337
- <DayCell ngay={k} />
338
- <Text size="xs" color="muted">{NGAY_CONG[k].label}</Text>
339
- </View>
340
- ))}
341
- </View>
290
+ <Card>
291
+ {/* The grid owns its axis, its legend and its scroll; a cell is a door to the
292
+ same workspace the roster opens. Absent is HOLLOW and leave is a DOT so the
293
+ two kinds of "not here" read apart before the legend is consulted. */}
294
+ <StateMatrix
295
+ columns={NGAY_TUAN.map((d, i) => ({ key: d, label: d, current: i === 2 }))}
296
+ states={NGAY_CONG}
297
+ rows={NHAN_VIEN.map((nv) => ({
298
+ key: nv.id,
299
+ leading: <Avatar name={nv.ten} size="sm" />,
300
+ label: nv.ten,
301
+ total: nv.tongTuan,
302
+ cells: nv.tuan.map((ngay, i) => ({
303
+ state: ngay,
304
+ pressable: true,
305
+ name: `${nv.ten}, ${NGAY_TUAN_DAY[i]}: ${ngayCong(ngay).label}`,
306
+ })),
307
+ }))}
308
+ onPressCell={(rowKey) => setOpenId(rowKey)}
309
+ rowLabelWidth={220}
310
+ cellWidth={36}
311
+ labels={{ total: "Week total" }}
312
+ />
342
313
  </Card>
343
314
  )}
344
315
  </View>
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
2
2
  import { Pressable, ScrollView, View } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
4
  import { colors } from "@lotics/ui/colors";
5
+ import { Avatar } from "@lotics/ui/avatar";
5
6
  import { Badge } from "@lotics/ui/badge";
6
7
  import { Card, CardFooter, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
7
8
  import { Divider } from "@lotics/ui/divider";
@@ -149,7 +150,15 @@ export function TplPivot() {
149
150
  </CardHeader>
150
151
  <View style={{ paddingHorizontal: 20, paddingVertical: 16 }}>
151
152
  <Matrix
152
- rows={WAREHOUSES.map((w) => ({ key: w.key, label: w.label }))}
153
+ // A row is a SUBJECT, so it carries the same identity mark a
154
+ // warehouse wears anywhere else in the app — square, because a
155
+ // facility is an organization and not a person. The mark is drawn
156
+ // inside `rowLabelWidth`, so nothing else in the grid moves.
157
+ rows={WAREHOUSES.map((w) => ({
158
+ key: w.key,
159
+ label: w.label,
160
+ leading: <Avatar shape="square" size="sm" name={w.label} />,
161
+ }))}
153
162
  cols={CATEGORIES.map((c) => ({ key: c.key, label: c.label }))}
154
163
  value={value}
155
164
  selected={cell}
@@ -23,14 +23,12 @@ import { Section, SectionHeading, SectionHeadingTitle, SectionHeadingMeta, Subse
23
23
  import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
24
24
  import { Checklist, ChecklistActions, ChecklistGroup, ChecklistItem, ChecklistNote } from "@lotics/ui/checklist";
25
25
  import { DateStamp } from "@lotics/ui/date_stamp";
26
- import { MenuButton } from "@lotics/ui/menu_button";
27
- import { Popover, PopoverContent } from "@lotics/ui/popover";
26
+ import { SectionNav } from "@lotics/ui/section_nav";
28
27
  import { useSectionNav } from "@lotics/ui/use_section_nav";
29
28
  import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle, DialogScrollArea } from "@lotics/ui/dialog";
30
29
  import { formatMoney } from "@lotics/ui/format_money";
31
30
  import { RecordSummary } from "@lotics/ui/record_summary";
32
31
  import { SummaryLine } from "@lotics/ui/summary_line";
33
- import { Badge } from "@lotics/ui/badge";
34
32
  import { daysUntil, deadlineAnnotation } from "@lotics/ui/deadline";
35
33
  import { useLoticsLocale } from "@lotics/ui/locale";
36
34
  import { Skeleton } from "@lotics/ui/skeleton";
@@ -2662,26 +2660,10 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2662
2660
  // since a reference is a field); everything after it is supplementary
2663
2661
  // (discussion, work, documents, money, lifecycle).
2664
2662
  //
2665
- // THE SECTION NAV IS HAND-ROLLED ON PURPOSE, for now. `useSectionNav` is the kit
2666
- // half the scroll-spy, `activeKey`, `jumpTo`, unit-tested RN-free and the
2667
- // CHROME below is this template's. It has exactly one consumer, and the kit's bar
2668
- // is the SECOND hand-roll, so lifting it today would ship an abstraction shaped by
2669
- // a single surface. `ReferenceField` sat here under this same note until a
2670
- // register's drawer needed it, and that is what let the extraction land on the
2671
- // PEEK contract instead of on the layout.
2672
- //
2673
- // When a second surface wants this, the contract worth lifting is: one section
2674
- // list, rendered as a RAIL where the container affords a gutter and as a pinned
2675
- // CURRENT-SECTION bar where it does not, the switch reading the CONTAINER (never
2676
- // the window); the picker anchored to the control that opens it, never a Modal
2677
- // that escapes its container; and the per-section attention dot, which is what
2678
- // makes the rail answer "where is the problem" and not merely "where am I".
2679
- //
2680
- // What is NOT the contract, and would poison it if lifted along: the issue-tone
2681
- // MAP below (its keys are this record's domain), the comments toggle (drawer
2682
- // chrome), the BackButton (page chrome), the gutter arithmetic (28 page / 20
2683
- // drawer, minus the trigger's padding) and the `wide` threshold. One consumer
2684
- // cannot tell those apart from the contract — two can.
2663
+ // `SectionNav` draws both forms; `useSectionNav` spies the scroll. What stays
2664
+ // HERE is what reads this record's domain or this page's layout: the issue map
2665
+ // below (its keys are this record's), the BackButton, the gutter arithmetic
2666
+ // and the `wide` threshold.
2685
2667
  const SECTIONS = [
2686
2668
  // PROGRESS leads. The header already IDENTIFIES the record — it titles the
2687
2669
  // customer and carries the code and city beneath it — so the first section
@@ -2694,7 +2676,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2694
2676
  //
2695
2677
  // The rail lists sections in PAGE order, or the scroll-spy highlights one
2696
2678
  // entry while the reader is looking at another.
2697
- { key: "progress", label: "Progress", icon: "git-branch" },
2679
+ { key: "progress", label: "Progress", icon: "trending-up" },
2698
2680
  { key: "activity", label: "Activity", icon: "message-square" },
2699
2681
  { key: "general", label: "General", icon: "file-text" },
2700
2682
  // Only a SECTION when the page is too narrow to seat the panel — so the wide
@@ -2749,15 +2731,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2749
2731
  transport: scheduleNote !== "on-time" ? "warning" : undefined,
2750
2732
  billing: customer === null ? "warning" : undefined,
2751
2733
  };
2752
- // Narrow: the rail becomes a pinned bar naming the CURRENT section; tapping
2753
- // it opens a full-page section picker (Escape / the close control dismiss).
2754
- const [sectionsOpen, setSectionsOpen] = useState(false);
2755
- // The picker anchors to the BAR BUTTON, so it is scoped to whatever container
2756
- // that button sits in — the page, or a drawer. A Modal escaped the container
2757
- // and took the whole window, which inside a drawer covered the register behind
2758
- // it. `triggerRef` (not `PopoverTrigger`) because the trigger IS a Button and a
2759
- // button must not nest in another pressable.
2760
- const sectionsAnchor = useRef<View>(null);
2761
2734
  const [pageWidth, setPageWidth] = useState<number | null>(null);
2762
2735
  // THREE COLUMNS: the outline rail in the left gutter, the record, and the
2763
2736
  // discussion panel in the right. The right gutter used to be empty — reserved
@@ -2853,63 +2826,21 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2853
2826
  <View style={{ flex: 1, backgroundColor: colors.white, opacity: pageWidth == null ? 0 : 1 }} onLayout={(e) => setPageWidth(e.nativeEvent.layout.width)}>
2854
2827
  {pageWidth == null || wide ? null : (
2855
2828
  // Narrow: a pinned bar names the CURRENT section (the spy keeps it
2856
- // honest while scrolling); tapping opens the full-page section picker —
2857
- // no tab strip the thumb has to scroll.
2858
- // 18, not 12: the bar's gutter is derived from the CONTENT's (28) minus the
2859
- // trigger's own horizontal padding (10), so the section NAME starts on the
2860
- // same left edge as every section title below it. A fill-less button shows
2861
- // no box, so its ink is what the eye lines up 18 + 10 = 28. Set the bar,
2862
- // not a negative margin on the button: the gutter belongs to the bar, and
2863
- // one number here cannot drift out of step with itself.
2864
- <View style={{ borderBottomWidth: 1, borderBottomColor: colors.zinc[200], paddingHorizontal: (isDrawer ? DRAWER_GUTTER : 28) - 10, paddingVertical: 6, flexDirection: "row", alignItems: "center", gap: 8 }}>
2865
- {/* narrow keeps the standard too — the bare back glyph beside the
2866
- section bar (the rail that would carry it is collapsed). A DRAWER
2867
- already has one in its own header; two would be two exits. */}
2868
- {isDrawer ? null : <BackButton onPress={() => {}} />}
2869
- <Popover open={sectionsOpen} onOpenChange={setSectionsOpen} triggerRef={sectionsAnchor} side="bottom" align="start">
2870
- <View ref={sectionsAnchor}>
2871
- {/* MUTED, not `secondary`: this names WHERE YOU ARE, so it is a
2872
- position readout that happens to be pressable, and a filled chip
2873
- competes with the record's own actions for the eye. The usual cost
2874
- of a fill-less button does not apply here — it is chrome in a bar
2875
- with its own gutter, not a verb aligned to a column of text, so
2876
- there is nothing below it for its ink to line up with. The glyph
2877
- carries the affordance: `list-collapse` says WHAT the list is,
2878
- where `Switcher` uses a chevron for the same shape. */}
2879
- <Button
2880
- title={SECTIONS.find((sec) => sec.key === nav.activeKey)?.label ?? "Sections"}
2881
- color="muted"
2882
- onPress={() => setSectionsOpen(true)}
2883
- />
2884
- </View>
2885
- <PopoverContent style={{ width: 248 }} disableBodyScroll>
2886
- <View style={{ gap: 2 }}>
2887
- {SECTIONS.map((sec) => (
2888
- <MenuButton
2889
- key={sec.key}
2890
- icon={sec.icon}
2891
- title={sec.label}
2892
- current={nav.activeKey === sec.key}
2893
- onPress={() => {
2894
- setSectionsOpen(false);
2895
- nav.jumpTo(sec.key);
2896
- }}
2897
- />
2898
- ))}
2899
- </View>
2900
- </PopoverContent>
2901
- </Popover>
2902
- <View style={{ flex: 1 }} />
2903
- {/* WHERE it sits carries the meaning: sections on the left (where you
2904
- ARE), the discussion on the right (what is being SAID) — the same
2905
- two poles the wide layout puts in its two gutters. */}
2906
- {/* A LABEL with its count, not a bare glyph: this is one of the two
2907
- things the body can show, so it has to say which — and how much is
2908
- waiting, which is what decides whether you look now. In drawer chrome
2909
- it toggles the body; on a narrow page it still opens the sheet, which
2910
- is that layout's panel. The pair reads as a switch: whichever side the
2911
- body is showing wears the filled `secondary`. */}
2912
- </View>
2829
+ // honest while scrolling); tapping opens the section picker — no tab
2830
+ // strip the thumb has to scroll. The bar's gutter is the CONTENT's, and
2831
+ // `SectionNav` takes the trigger's own padding off it so the section
2832
+ // NAME starts on the same left edge as every section title below.
2833
+ //
2834
+ // Narrow keeps the back standard too, EXCEPT in a drawer, which already
2835
+ // has one in its own header two would be two exits.
2836
+ <SectionNav
2837
+ items={SECTIONS.map((sec) => ({ ...sec, issue: sectionIssue[sec.key] }))}
2838
+ activeKey={nav.activeKey}
2839
+ onJump={nav.jumpTo}
2840
+ wide={false}
2841
+ barGutter={isDrawer ? DRAWER_GUTTER : 28}
2842
+ back={isDrawer ? undefined : <BackButton onPress={() => {}} />}
2843
+ />
2913
2844
  )}
2914
2845
  <View style={{ flex: 1 }}>
2915
2846
  {/* FULL-WIDTH scroller: the whole page scrolls, wherever the pointer is. */}
@@ -5239,23 +5170,18 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
5239
5170
  scrolls, and out of the scroll row so the page stays wheel-scrollable
5240
5171
  edge to edge. */}
5241
5172
  {pageWidth == null || !wide ? null : (
5242
- <View style={{ position: "absolute", top: 28, left: railLeft, width: RAIL_W, gap: 10 }}>
5243
- {/* THE PANEL STANDARD: it opens with BACK — the circular glyph, the
5244
- collection it returns to named beside it (the register route in a
5245
- real app), visually apart from the outline items. */}
5246
- <BackButton label="Records" onPress={() => {}} />
5247
- <View style={{ gap: 2 }}>
5248
- {SECTIONS.filter((sec) => sec.key !== "comments").map((sec) => (
5249
- <MenuButton
5250
- key={sec.key}
5251
- icon={sec.icon}
5252
- title={sec.label}
5253
- current={activeSection === sec.key}
5254
- right={sectionIssue[sec.key] ? <Badge variant="dot" color={sectionIssue[sec.key] === "danger" ? "red" : "amber"} /> : undefined}
5255
- onPress={() => nav.jumpTo(sec.key)}
5256
- />
5257
- ))}
5258
- </View>
5173
+ <View style={{ position: "absolute", top: 28, left: railLeft }}>
5174
+ <SectionNav
5175
+ /* Comments is a section only while the page is too narrow to seat the
5176
+ panel, so the wide rail drops it rather than offering a jump to
5177
+ something that is not there. */
5178
+ items={SECTIONS.filter((sec) => sec.key !== "comments").map((sec) => ({ ...sec, issue: sectionIssue[sec.key] }))}
5179
+ activeKey={activeSection}
5180
+ onJump={nav.jumpTo}
5181
+ wide
5182
+ railWidth={RAIL_W}
5183
+ back={<BackButton label="Records" onPress={() => {}} />}
5184
+ />
5259
5185
  </View>
5260
5186
  )}
5261
5187
  </View>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "47.13.0",
3
+ "version": "47.14.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./accordion": "./src/accordion.tsx",
@@ -71,6 +71,7 @@
71
71
  "./data_grid": "./src/data_grid.tsx",
72
72
  "./date_calendar": "./src/date_calendar.tsx",
73
73
  "./date_filter": "./src/date_filter.tsx",
74
+ "./date_filter_presets": "./src/date_filter_presets.ts",
74
75
  "./date_picker": "./src/date_picker.tsx",
75
76
  "./date_range_filter_field": "./src/date_range_filter_field.tsx",
76
77
  "./date_stamp": "./src/date_stamp.tsx",
@@ -189,6 +190,8 @@
189
190
  "./markdown_types": "./src/markdown_types.ts",
190
191
  "./remark_gfm_safe": "./src/remark_gfm_safe.ts",
191
192
  "./matrix": "./src/matrix.tsx",
193
+ "./month_math": "./src/month_math.ts",
194
+ "./month_stepper": "./src/month_stepper.tsx",
192
195
  "./matrix_totals": "./src/matrix_totals.ts",
193
196
  "./media_player": {
194
197
  "react-native": "./src/media_player.tsx",
@@ -209,6 +212,7 @@
209
212
  "./option_badge": "./src/option_badge.tsx",
210
213
  "./option_list": "./src/option_list.tsx",
211
214
  "./option_picker": "./src/option_picker.tsx",
215
+ "./organization_name": "./src/organization_name.ts",
212
216
  "./overlay_layer": "./src/overlay_layer.ts",
213
217
  "./overlay_scope": "./src/overlay_scope.ts",
214
218
  "./page_content": "./src/page_content.tsx",
@@ -243,6 +247,7 @@
243
247
  "./search_match": "./src/search_match.ts",
244
248
  "./section_card": "./src/section_card.tsx",
245
249
  "./section_heading": "./src/section_heading.tsx",
250
+ "./section_nav": "./src/section_nav.tsx",
246
251
  "./section_stack": "./src/section_stack.tsx",
247
252
  "./segmented_control": "./src/segmented_control.tsx",
248
253
  "./select": "./src/select.tsx",
@@ -267,6 +272,7 @@
267
272
  "./stacked_bar_chart": "./src/stacked_bar_chart.tsx",
268
273
  "./stacked_progress_bar": "./src/stacked_progress_bar.tsx",
269
274
  "./status_badge": "./src/status_badge.tsx",
275
+ "./state_matrix": "./src/state_matrix.tsx",
270
276
  "./status_grid": "./src/status_grid.tsx",
271
277
  "./step_progress": "./src/step_progress.tsx",
272
278
  "./stepper": "./src/stepper.tsx",
@@ -295,6 +301,8 @@
295
301
  "./time_options": "./src/time_options.ts",
296
302
  "./time_picker": "./src/time_picker.tsx",
297
303
  "./timeline": "./src/timeline.tsx",
304
+ "./timetable": "./src/timetable.tsx",
305
+ "./toggle_strip": "./src/toggle_strip.tsx",
298
306
  "./tokens": "./src/tokens.ts",
299
307
  "./tooltip": "./src/tooltip.tsx",
300
308
  "./trend_chip": "./src/trend_chip.tsx",
@@ -48,14 +48,15 @@ export const AVATAR_PX: Record<AvatarSize, number> = {
48
48
  * beside it. It was 48, which anchored to nothing: the bar's other controls
49
49
  * are 40, and a 48px circle among them reads as the one thing that got out.
50
50
  *
51
- * **In a register, the rung follows what the row IS ABOUT, not the density.**
52
- * Where a person is one FIELD of the row an owner, an assignee beside a
53
- * dozen other columns the figure is a value like any other and takes `md`.
54
- * Where the person is the row's SUBJECT (a league table of reps, a roster,
55
- * anything grouped BY member), the avatar is that row's leading figure and
56
- * takes this rung; at `md` such a register reads as a list of numbers that
57
- * happens to carry faces. Budget for it: this rung is 12px wider than `md`,
58
- * and in a table it takes those 12 from the flexible column the name is in. */
51
+ * **In a register, the rung follows what the reader AIMS AT, not the
52
+ * density.** The row's subject takes this rung and so does an ORGANIZATION
53
+ * in its own column, because a book of movements is scanned by whose they
54
+ * are, and at `md` such a register reads as a list of numbers that happens to
55
+ * carry faces. `md` is for a mark incidental to the value the reader came for
56
+ * a person in an owner or assignee column, where the face is competing with
57
+ * the row's own mark anyway. Budget for it: this rung is 12px wider than
58
+ * `md`, and in a table it takes those 12 from the flexible column the name is
59
+ * in, so a column too tight to seat one takes `md`. */
59
60
  lg: 40,
60
61
  /** A profile's own header. */
61
62
  xl: 72,