@lotics/ui 47.13.1 → 47.15.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
@@ -17,8 +17,8 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
17
17
  | [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — incl. `just_arrived` (`useJustArrived` + `TableRow`'s `justArrived`: the one-shot wash a row wears when it LANDS, so a realtime push is visible rather than one row of two hundred silently changing) and `SplitRow`/`SplitPane` (the responsive pane row — a weight that survives into a stacked column divides HEIGHT and clips a pane) — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
18
18
  | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, choosing a CHOICE control by option count, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW, the `FilesEditor` COMPOUND for a whole section, plus the three-way file INTAKE), stage gates, and the commit-on-blur vs action-press ordering law. |
19
19
  | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values; findings, provenance, confidence (`Finding` is filed here because AI produces most of them, NOT because it is AI-only — a rules-derived contradiction is as in scope as an extraction's); **after the run** — a stored record that fills up from several writers; the whole run in a dialog, **stopping**; **review surfaces compose from atoms** — `DiffValue`, `DiffMark`, `useChangeSet`. |
20
- | [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS before reaching for parts), canvas + content column, heading altitude, register vs inset rows, the button ladder, master-detail `Drawer`, the register's rhythm, craft and TRIAGE bands, where the accent goes, theming as an app-level ESCAPE HATCH, color discipline, typography, **one fact one surface** (a chooser over the thing it chooses is a SELECTOR, not a second card grid; a control that only swaps one number for another is replaced by showing both), **a width floor answers can this be READ and never can this be operated** (the operating question passes a row whose every string is already cut), **a status column earns its slot by carrying a REASON rather than a state** (the floor asks for the fact to be visible, not for a badge), **an identity mark's GLYPH is derived from the name's distinguishing part** (an organization's first word is its legal form, so untreated every row marks one letter), **a row subject's supporting line takes a REFERENCE and never a sentence** (an explanation truncates to the clause the reader already knew), **a list stays fast by keeping IDENTITY rather than memoizing** (key by the record not the index; `memo` is the last resort and does nothing against a freshly-built prop), and where a commit sits. |
21
- | [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built — and the answer when one "looks bad"** — the other docs say what good looks like; this one says how to find out whether you achieved it. Run it BEFORE the deploy: a typecheck, a lint, a test and `app check` are all blind to how a screen reads. GATES that outrank every treatment question, then the probes, each naming what to COLLECT and the SIGNATURE in the numbers — including the truncation sweep (a row whose halves both measure fine and whose text is cut) the summary-vs-facet check (a strip that keeps describing the unfiltered set) and the derivable-column test (a status badge its neighbour already answers) the first-line drift a column shows when its cell is sometimes one line and sometimes two, the cross-FRAME check (what the screen destroys when a row arrives — the one defect that exists in no single frame), and the semantic-section check (a danger or empty section drawn as an ordinary one, and the primitive nobody reached for). Plus the extraction snippets, what measurement cannot see, and how to act on a finding. |
20
+ | [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS before reaching for parts), canvas + content column, heading altitude, register vs inset rows, the button ladder, master-detail `Drawer`, the register's rhythm, craft and TRIAGE bands, where the accent goes, theming as an app-level ESCAPE HATCH, color discipline, typography, **one fact one surface** (a chooser over the thing it chooses is a SELECTOR, not a second card grid; a control that only swaps one number for another is replaced by showing both), **a width floor answers can this be READ and never can this be operated** (the operating question passes a row whose every string is already cut), **a status column earns its slot by carrying a REASON rather than a state** (the floor asks for the fact to be visible, not for a badge), **an identity mark's GLYPH is derived from the name's distinguishing part** (an organization's first word is its legal form, so untreated every row marks one letter), **a row subject's supporting line takes a REFERENCE and never a sentence** (an explanation truncates to the clause the reader already knew), **a list stays fast by keeping IDENTITY rather than memoizing** (key by the record not the index; `memo` is the last resort and does nothing against a freshly-built prop), **a surface keyed by a whole MONTH takes a `MonthStepper`, never a range field** (its `max` bound is the contract), **a subject-by-date sheet of STATES is a `StateMatrix`** (a glyph per cell, the legend derived from the same list; not a `Matrix`, where a blank means zero), **a report's comparator is DERIVED from the selected range with `previousPeriod`, never a second control** (show it on the figure — `KPIStripItem.trend`/`caption`, `Metric previousValue`, a second `LineChart` series; a grain `SegmentedControl` beside the date field re-adds the mode the field removed), and where a commit sits. |
21
+ | [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built — and the answer when one "looks bad"** — the other docs say what good looks like; this one says how to find out whether you achieved it. Run it BEFORE the deploy: a typecheck, a lint, a test and `app check` are all blind to how a screen reads. GATES that outrank every treatment question, then the probes, each naming what to COLLECT and the SIGNATURE in the numbers — including the truncation sweep (a row whose halves both measure fine and whose text is cut) the summary-vs-facet check (a strip that keeps describing the unfiltered set) and the derivable-column test (a status badge its neighbour already answers) the opinions-the-reader-cannot-operate pair (bands nobody asked for, pills crowding a search) the first-line drift a column shows when its cell is sometimes one line and sometimes two, the cross-FRAME check (what the screen destroys when a row arrives — the one defect that exists in no single frame), and the semantic-section check (a danger or empty section drawn as an ordinary one, and the primitive nobody reached for). Plus the extraction snippets, what measurement cannot see, and how to act on a finding. |
22
22
  | [docs/testing.md](./docs/testing.md) | Driving the kit in a browser — the three anatomies where the a11y tree says one thing and a driver must do another: a `PressDoor` row whose named button always intercepts pointer events (by design), portalled overlays that render at the top of the DOM, and custom pointer drag that `dragTo` cannot move. |
23
23
  | [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget) and the ACTIVITY shape — a communications feed where the row's label is the GIST and the body varies by medium, one anatomy rather than a row type per kind. |
24
24
 
@@ -87,7 +87,9 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
87
87
  identity. And a radius is a PROPORTION of the box (`proportionalRadius`), because a component
88
88
  sized by data renders as a pill at one height and barely a corner at another.
89
89
  → [composition.md §"A body, not an edge"](./docs/composition.md).
90
- - **A subject over one qualifying line is `CellStack`, never two `Text`s in a `View`.** It owns
90
+ - **A subject over one qualifying line is `CellStack`, never two `Text`s in a `View`.** Pass a
91
+ whitespace `caption` to RESERVE the line on a row with nothing to say — it is normalised to a
92
+ non-breaking space, so the column keeps one first-line height. It owns
91
93
  the whole stacked-pair rule — which rung the caption takes (from what ELSE separates the pair),
92
94
  which lines are tightened, and the zero gap. A hand-rolled pair gets one of the three wrong
93
95
  silently: the excess comes from the LINE BOX, so no margin and no gap exists to explain it and
@@ -107,6 +109,11 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
107
109
  - **A countdown ANNOTATES its date field — never a trailing badge, and never hand-rolled.**
108
110
  Reach for `@lotics/ui/deadline` and spread `deadlineAnnotation` onto the
109
111
  `DetailRow`. → [catalog.md §"Dates & times"](./docs/catalog.md).
112
+ - **A record surface's outline is `SectionNav` + `useSectionNav`, never hand-rolled rows.** The
113
+ hook spies the scroll, the component draws the rail AND the narrow bar that replaces it, and
114
+ the item's `issue` dot is what makes the outline say where the problem is. A hand-rolled rail
115
+ loses the live re-measure, the last-section rule and the narrow form.
116
+ → [catalog.md §"Overlays & navigation"](./docs/catalog.md).
110
117
  - **A third-party channel takes `BrandMark`, our own glyphs take `Icon`.** The names are an
111
118
  allowlist, so a channel with no mark (a phone call, an event, a website) fails to typecheck
112
119
  and falls back to `Icon` instead of borrowing another company's logo.
@@ -146,6 +153,13 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
146
153
  gets `<GroupLead>` (`sm` medium, page ink). Neither takes `size`/`weight`/`transform` — the
147
154
  omitted props are the rule. →
148
155
  [composition.md §"The heading ramp"](./docs/composition.md).
156
+ - **N booleans across FIXED positions are a `ToggleStrip`, never a row of hand-rolled cells.**
157
+ A week's running days, a gate's open hours, a notice's channels: a compact strip at
158
+ `CONTROL_CONTENT_HEIGHT`, each cell a `checkbox`, `minOne` where the empty set is a different
159
+ reading rather than a neutral one, and `toggleStripWidth(n)` for the column that seats it.
160
+ Selected wears `chipSurfaceStyle`'s doubled dark edge — a brand ground on a selection is the
161
+ decision the kit already reversed once.
162
+ → [catalog.md §"Pickers & selection controls"](./docs/catalog.md).
149
163
  - **Pure primitives only** — no i18n, analytics, or domain types in `src/` (pass `labels`,
150
164
  callbacks); Lotics-coupled UI belongs in `@lotics/ui-internal`.
151
165
  - **A button never contains a control** — a row that presses open AND carries its own controls
package/docs/catalog.md CHANGED
@@ -387,7 +387,9 @@ Worked example: [`tpl_board`](../examples/tpl_board.tsx).
387
387
  `KPIStrip` (the dashboard stat band), `SummaryLine` (the light inline register/list summary
388
388
  — below the toolbar, from the filtered rows), `KPICard` / `Metric` (headline figures),
389
389
  `TrendChip` (delta), `Sparkline`, `BarChart` / `LineChart` / `PieChart` (the canonical SVG
390
- set — no recharts), `ProgressRing`, `ProgressBar` (its `compact` prop = ONE row, track + a
390
+ set — no recharts; `LineChart` takes `points` for one line or `series` for several on one
391
+ shared scale, which is where a "this period vs last" comparison goes), `ProgressRing`,
392
+ `ProgressBar` (its `compact` prop = ONE row, track + a
391
393
  plain sm tabular count beside it — the cell/heading/peek-trigger meter. **The track clamps at
392
394
  100%, the caption does not** — over its max it reads `2,100 / 2,000 (105%)`. **`max: null` is an
393
395
  unbounded meter** — the caption states the value and the caller's `unboundedLabel`
@@ -413,7 +415,9 @@ stays solid, others dim — the caller renders the records). The subset/drop-off
413
415
  split across stages), `StatusGrid` + `StatusLegend`, `Heatmap`
414
416
  (density: colour-only, "where does it cluster"), `Matrix` (the PIVOT cross-tab: the NUMBER
415
417
  in each cell — optionally a heat wash behind it — plus row/column/grand totals; press a cell
416
- to drill).
418
+ to drill), `StateMatrix` (the CATEGORICAL cross-tab: subjects down the side, dated positions
419
+ across the top, a STATE glyph in every cell — an attendance sheet, a fleet's month; a blank
420
+ cell means "nothing was due", never zero).
417
421
 
418
422
  ### Surfaces & layout
419
423
 
@@ -533,7 +537,8 @@ patterns doc indexed in [AGENTS.md](../AGENTS.md)).
533
537
  upcoming, horizontal OR vertical), `RemainderMeter` + `AllocationRow` (allocation),
534
538
  `Timeline` (a heterogeneous event LOG — icons + expandable details, not progress),
535
539
  `CalendarView` (the `calendar` module — days are the axis), `GanttView` (RESOURCES are the
536
- axis: a channel, an owner, a vehicle), `comments_thread`, `SocialPostPreview` (a post as ONE
540
+ axis: a channel, an owner, a vehicle), `Timetable` (a repeating PATTERN with no dates — time ×
541
+ weekday, a slot per row, today a tinted column), `comments_thread`, `SocialPostPreview` (a post as ONE
537
542
  network's feed shows it, with the count against that network's limit and every rule its API
538
543
  would refuse — the publish gate reads the same `socialPostIssues` the preview draws).
539
544
 
@@ -641,7 +646,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
641
646
  `react-markdown` instance the kit's renderer cannot serve. Never `remark-gfm`: it ships a
642
647
  regex-lookbehind literal, which WebKit older than Safari 16.4 rejects while parsing the bundle.
643
648
  - **`markdown.css`** — import once for the web markdown styling.
644
- - **`format_date`** — `formatDate` / `parseDate` / `toISODate` + `DateFormatStyle`.
649
+ - **`format_date`** — `formatDate` / `parseDate` / `toISODate` + `DateFormatStyle` (incl.
650
+ `monthYear` and `quarterYear`, the two period labels).
645
651
  - **`format_money`** — `formatMoney` / `formatCompactNumber`.
646
652
  - **`text_utils`** — text/typography plumbing: `getTextColor` (the TextColor→hex map incl.
647
653
  the AA-cleared valence set), the Inter `fontFamily*` stacks, and `getInputTextStyle` /
@@ -690,8 +696,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
690
696
  `vi`→`vi-VN`); date/time controls read it via **`useLocaleTag`** (`resolveLocaleTag` is its
691
697
  pure core) so the segmented field's part ORDER (dd/MM vs MM/dd), the picker calendar's
692
698
  weekday/month names, and `formatDate` display all follow the provider with no per-instance
693
- `locale` prop — an explicit `locale` still overrides. **Limitation:** the calendar/gantt
694
- views and the comment labels do not read the provider pass their `labels` props directly.
699
+ `locale` prop — an explicit `locale` still overrides. `CalendarView` and `GanttView` are
700
+ wired the same way, chrome and tick names both. **Limitation:** `CommentList` carries its
701
+ own English defaults — pass its `labels` prop directly.
695
702
  - **`colors`** — the palette + `withAlpha`, `solid`, `tint`, `ramp`, `ColorName`,
696
703
  `isColorName`, `asColorName` (coerce a stored option/status token to a `ColorName`,
697
704
  neutral fallback).
@@ -770,6 +777,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
770
777
  - **`group_avatar`** — `GroupAvatar`: a name's initials in a zinc rounded square (`size` — the
771
778
  same rung scale AND the same initials rule as `Avatar`, default `md`); the avatar for
772
779
  image-less entities — groups, organizations. A person → `Avatar`/`MemberChip`.
780
+ - **`organization_name`** — `stripLegalForm(name)`: an organization's name without the legal
781
+ form it opens or closes with ("Công ty TNHH", "HTX", "Hộ kinh doanh", "Ltd.", "LLC"…). The
782
+ name a mark derives its initials from AND the title a register row shows — composition.md
783
+ §Identity marks says why: the form puts the same first letters on every row and spends the
784
+ first 60px of the column on words that tell no two rows apart. The legal name belongs on the
785
+ record the row opens. A name that is nothing but a form comes back unchanged.
773
786
  - **`thumbnail_stack`** — `ThumbnailStack`: the same capped stack over THINGS rather than
774
787
  parties — a set of product, document or unit pictures answering "an order of what?" in one
775
788
  register cell. Takes `{ id, label, image? }`, shows `max` (default 3) and makes the rest the
@@ -952,6 +965,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
952
965
  destructive action slot (children, e.g. a `danger` Button); sits APART at the bottom of a
953
966
  record/settings surface. The heading defaults to the locale's `dangerZone.title`
954
967
  ("Danger zone" / "Vùng nguy hiểm") — pass `title` only to override.
968
+ ONE LEFT EDGE, and it is `Callout`'s anatomy that gets it: the hazard mark sits in an outer
969
+ ROW beside a column holding title, consequence and actions, so all three derive from the same
970
+ edge. Put the mark INSIDE the heading row instead — the obvious way to write it, and how this
971
+ shipped — and only the TITLE moves right, so the loudest line in the block sits 22px past the
972
+ sentence explaining it and the button under both starts somewhere else again. Three edges
973
+ inside one 16px-padded box, on the primitive that exists so a hazard is not hand-rolled. Any
974
+ block with this anatomy (mark, title, body, actions) is laid out the same way.
955
975
  - **`landmark`** — `Landmark`: the semantic region wrapper — `kind`
956
976
  banner|navigation|main|complementary|contentinfo|region maps to the matching HTML element
957
977
  on web for screen-reader landmark navigation, `accessibilityRole` on native;
@@ -1208,7 +1228,13 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1208
1228
  choice. → `data_entry.md` §"Choosing a CHOICE control" — and check the states really ARE
1209
1229
  exclusive: independent capabilities that happen to be exclusive today bake today's
1210
1230
  combinations into the type.
1211
- - **`chip_group`** — `ChipGroup`: pill single-select over a small visible set. The row wraps by
1231
+ - **`chip_group`** — `ChipGroup`: pill single-select over a small visible set. The ACTIVE chip
1232
+ steps up in INK, never into a heavier weight: both states are `medium`, and what says "this
1233
+ one is on" is the doubled zinc-900 edge plus default ink. Semibold is a separate, genuinely
1234
+ heavier font FILE and the heading ramp is semibold at every rung, so an active chip in
1235
+ semibold rendered a filter value at heading weight — and made itself a singleton treatment on
1236
+ every screen carrying one, while `Tabs`, which answers the same question, steps
1237
+ regular → medium. The row wraps by
1212
1238
  default; `overflow="scroll"` keeps it to ONE row for a register FILTER, where six options
1213
1239
  carrying counts wrap to three rows — 136px on a phone, more than the search band and the view
1214
1240
  switcher combined. Deliberately NOT a screen-width branch: width is not the question, the job
@@ -1216,6 +1242,35 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1216
1242
  chips are worth more than a `Picker` there only because every option is visible, and a swipe
1217
1243
  is exactly what takes that away. → `data_entry.md` §"Choosing a CHOICE control" for which
1218
1244
  control the count and the job call for.
1245
+ - **`toggle_strip`** — `ToggleStrip` (+ `toggleStripWidth`): N INDEPENDENT booleans across FIXED
1246
+ positions, drawn as one compact strip — which days a departure runs, which hours a gate is
1247
+ open, which channels a notice goes out on. Sits inside a register row or a field at
1248
+ `CONTROL_CONTENT_HEIGHT`, not on a control band.
1249
+ Not `ChipGroup` (one choice, and chips WRAP — which destroys the reading this exists for:
1250
+ a column of strips lets the eye run down position 3 and see the shape of the week), and not
1251
+ `SegmentedControl` (an exclusive choice, a radiogroup — each cell here is a `checkbox`).
1252
+ `minOne` refuses to turn the LAST cell off, for a set where empty is a DIFFERENT reading
1253
+ rather than a neutral one. Each option carries a `label` (the one or two characters shown)
1254
+ AND a `name` (what a screen reader says) — a two-letter cell announces as nonsense, and seven
1255
+ of them in a register are told apart only by voice. Budget the table column with
1256
+ `toggleStripWidth(n)`, never a guess: a guessed column is one the fit sheds for the wrong
1257
+ reason. Selected wears `chipSurfaceStyle`'s doubled dark EDGE, never a brand ground — a ground
1258
+ is the row's language and this is a control, and seven filled cells per row made the screen's
1259
+ one primary button the twenty-third copy of its own ground.
1260
+ **`variant="mark"`** drops the letters for a bare square (filled = on) — for a COLUMN of strips
1261
+ whose positions a header names once, which is what `Timetable` does; `current` tints the cell
1262
+ of the position the reader stands in (today). `TOGGLE_STRIP_CELL_WIDTH` / `_GAP` are the
1263
+ geometry a header lays its labels on.
1264
+ - **`timetable`** — `Timetable`: slots down the side, a FIXED repeating axis across the top, a
1265
+ mark wherever a slot is on — a departure board (time × weekday), a shift roster, a lane's
1266
+ opening hours. `slots` are the positions (`label` shown once in the header, `name` for the
1267
+ cells' announcement), `current` is today's column (tinted, `aria-current="date"`), `rows` carry
1268
+ a `time`, the `active` positions, a `subject` node (put the identity mark here), and optionally
1269
+ `status`, `trailing` (an `ActionMenu`), and `detail` + `expanded` + `onPress` — the row's time and
1270
+ subject are TEXT and edit in the expansion, since a timetable is read daily and changed a few
1271
+ times a month; the pattern is the one thing edited in place. Not `CalendarView`: nothing has a
1272
+ DATE, so there is no week to page to and no duration to draw. Not a `Table` of `ToggleStrip`s
1273
+ with letters in every cell — that names the axis on every row.
1219
1274
  - **`checkbox`** — `Checkbox`: the bare square check control. Bare means UNLABELLED — reach for
1220
1275
  `CheckboxInput` unless something else already names it (a table's select-all, a row's
1221
1276
  leading slot). A square means CHOSEN; a ring means DONE, and spending the wrong one teaches a
@@ -1309,6 +1364,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1309
1364
  the next click produces; hovering backwards from the start previews just as well.
1310
1365
  - **`date_filter`** — `DateFilter`: the date+time period filter panel — presets
1311
1366
  (`PresetId`), calendar, optional time segments; the body `DateRangeFilterField` opens.
1367
+ The presets run day → week → month → quarter → year (`today`, `yesterday`, `tomorrow`,
1368
+ `this_week`, `this_month`, `last_month`, `this_quarter`, `last_quarter`, `this_year`,
1369
+ `last_year`), shortest grain first, so a reader scans past the days to the grain they mean.
1312
1370
  **Every preset SETS a range** — the list holds no mode and no null case, so a hand-picked
1313
1371
  range selects nothing. The
1314
1372
  presets are a `listbox` of `MenuButton` options: selection is the kit's `zinc.100` row
@@ -1321,7 +1379,38 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1321
1379
  renders it as a single date). Both bounds always show, so a half-picked range names the
1322
1380
  end that is still open.
1323
1381
  - **`date_range_filter_field`** — `DateRangeFilterField`: the register's period filter field
1324
- (presets + footer + time-segment a11y; localized via the `dateRange` locale slice).
1382
+ (presets + footer + time-segment a11y; localized via the `dateRange` locale slice). **A range
1383
+ that IS a whole period reads as that period** on the trigger — a whole month "Tháng 9 năm
1384
+ 2026", a whole quarter "Quý 3 năm 2026", a whole year "2026", one day one date; only a range
1385
+ that is no period falls back to a date pair. That is what lets one field serve a dashboard
1386
+ that lives in period rhythm, so never put a grain `SegmentedControl` beside it. For a
1387
+ surface keyed by a whole MONTH — a plan, a roster — reach for `month_stepper` instead: a
1388
+ month-keyed record cannot take "the 3rd to the 19th", so a range field offers moves that do
1389
+ not exist.
1390
+ - **`date_filter_presets`** — `PRESET_IDS` / `getPresetValue(id, now)` / **`previousPeriod(value)`** /
1391
+ **`periodToDate(value, now)`**;
1392
+ the panel's range math, React-free and computed off an INJECTED `now` so a host can resolve or
1393
+ compare a period without rendering anything. `previousPeriod` is the comparator a report means
1394
+ by "so với kỳ trước": a whole calendar month, quarter or year steps back one WHOLE period
1395
+ (February against March is 28 days against 31 — sliding a 31-day window back would compare
1396
+ March against three days of January), any other complete range steps back by its own length
1397
+ ending the day before it starts, and an open range returns `null` because it names no period.
1398
+ Bounds come back day-aligned and untimed, like a preset's. Feed the result to the same query
1399
+ the selected range feeds and show the delta ON the figure — never as a second control beside
1400
+ the field. `periodToDate` is what a report keyed by a period actually reads: while the period
1401
+ is still RUNNING it cuts both sides to the days elapsed — the selection ending today, the
1402
+ comparator cut to the same count from its own first day and clamped to that period's end — and
1403
+ reports `running` / `elapsedDays` / `totalDays` for the caption. Reach for it over a bare
1404
+ `previousPeriod` on any figure the reader may open mid-period, or six days of this month get
1405
+ compared against a whole month and the headline states a collapse that never happened.
1406
+ → [composition.md §"Period filters for time-constrained data"](./composition.md).
1407
+ - **`month_stepper`** — `MonthStepper`: ‹ Tháng 9 năm 2026 › — `value` (any day of the month, as
1408
+ `yyyy-MM-dd`), `onChange` (always the month's first day), `max` / `min` (the last / first
1409
+ month that may be stepped INTO — pass this month on a surface about what has happened, or
1410
+ the reader walks into a month nobody has planned), `labels` (the two arrows' names; the
1411
+ `monthStepper` locale slice). The month name comes from `formatDate`'s `monthYear`.
1412
+ - **`month_math`** — `monthStart` / `shiftMonth`: the stepper's arithmetic on `yyyy-MM-dd`
1413
+ strings, React-free, for a host that keys its own state by month.
1325
1414
  - **`time_picker`** — `TimePicker`: the time-of-day FIELD. The value on a pressable
1326
1415
  control (the bordered surface a `Select` uses, so it reads as pickable by the same
1327
1416
  shape as every other field that opens something), and `TimeColumns` behind it.
@@ -1513,8 +1602,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1513
1602
  control line while a
1514
1603
  one-line label still centers on it (both modes, no prop). The FIELD-ANNOTATION vocabulary (same
1515
1604
  names + meanings as `FormField`), always EXPLICIT — a row never hides guidance behind an ⓘ:
1516
- **`description`** = a fact / persistent guidance (muted), under the VALUE (stacked mode
1517
- mirrors the form order label, description, control); **`warning`** = a consequence to weigh
1605
+ **`description`** = a fact / persistent guidance (muted), under the VALUE at BOTH widths —
1606
+ stacked mode wears the form LOOK (a `FormField`-weight label) but keeps the RECORD's reading
1607
+ order, label then value then annotation. Not the form ORDER: a form's description is guidance
1608
+ you need BEFORE typing, a record's qualifies a value that already exists, so ahead of it the
1609
+ reader gets "còn 116 ngày" before any date. Both modes render the shared `FieldAnnotations`
1610
+ anatomy, so a fault says itself one way at both widths; **`warning`** = a consequence to weigh
1518
1611
  before acting (amber, announced); **`error`** = field-level failure under the value, danger +
1519
1612
  alert semantics (the `Inline*` editors already render their own transient save errors — don't
1520
1613
  wire both); invalid/cross-field STATE = a co-located `Callout`; **`flat`**
@@ -1582,7 +1675,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1582
1675
  rows), `LedgerTotal` = the divider-set emphasized close with `zeroLabel` for settled. **`meta` is a
1583
1676
  neutral QUALIFIER** (a date, a method) at caption weight — never a problem or a state: a
1584
1677
  caption is the wrong weight for something wanting action. A problem goes on the row
1585
- that can FIX it.
1678
+ that can FIX it. **The label outranks the caption when the row runs out of width**: the
1679
+ caption is sized off what the label and the figure leave, so it clips and is down to
1680
+ nothing before the label gives up a word — and under a 320px ledger the row drops it
1681
+ outright, since a caption clipped to two syllables is worth less than the space.
1586
1682
  **Pick the shape by what the statement IS**, because there are two and they read differently:
1587
1683
  - **`charges → total`** — `LedgerGroup`s, each **closed by its own sum** ("Charges" vs
1588
1684
  "Received"), giving three ascending rungs: row `sm/regular`, subtotal `sm/medium`, total
@@ -1732,10 +1828,28 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1732
1828
  A `description` passed as a NODE keeps its own leading — the component only sets it on the
1733
1829
  string branch, because forcing a line box on somebody else's subtree re-spaces content that
1734
1830
  may not be a line of text at all.
1831
+ **`selected` outranks hover, and the two never paint the same grey.** The ladder is
1832
+ `PressableRow`'s, verbatim — zinc-50 hovered, zinc-100 selected, zinc-200 pressed, one
1833
+ neutral step apart with HOVER LIGHTER — so pointing at a neighbour cannot erase which row is
1834
+ open. The two used to share `zinc-100`, which is invisible to every probe that reads a
1835
+ RESTING screen and fails hardest on a navigation pane, where the selection is the only thing
1836
+ tying a chooser to the detail it opened.
1735
1837
  - **`cell_stack`** — `CellStack`: a SUBJECT over one qualifying line, as a value primitive —
1736
1838
  the commonest cell in any register, and the shape most often hand-rolled wrong. `title` +
1737
1839
  `caption`, plus `weight` / `marked` / `size` / `color` / `tabular` / `align` /
1738
- `numberOfLines`.
1840
+ `numberOfLines`. `leading` draws the identity mark (an `Avatar`, a `FileBadge`) before the
1841
+ pair and owns the mark-to-text gap — the subject cell of a register is `CellStack leading`,
1842
+ never a hand-rolled row of mark + stack; the column still budgets the mark's width. That gap
1843
+ is exported as `LEADING_GAP`, the ONE number for mark-to-label anywhere the kit draws the pair
1844
+ (`Matrix` and `StateMatrix` rows read it too) — a caller drawing its own pair reads it rather
1845
+ than picking a number.
1846
+ To RESERVE the caption line on a row that has nothing to say — which is how a column whose
1847
+ cells are sometimes two lines keeps ONE first-line height down the page — pass a whitespace
1848
+ string. `" "` is what everybody writes and the web renderer collapses it, so `CellStack`
1849
+ normalises whitespace to a non-breaking space itself: without that the cell drops to one line
1850
+ and its first line rides half a line above the rows that DID grow a caption, while the ROW
1851
+ height, set by whichever sibling is two lines tall, never moves — so no spacing probe sees it.
1852
+ (One app wrote the bare space twelve times.)
1739
1853
  It OWNS the three-part stacked-pair rule that [composition.md](./composition.md) §Typography
1740
1854
  states, so a call site never re-derives it: the caption's rung follows **what else already
1741
1855
  separates the pair** (a `medium` subject or a leading identity mark ⇒ the caption keeps body
@@ -1750,15 +1864,22 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1750
1864
  from the LINE BOX, so no margin and no gap appears anywhere to explain it and no spacing
1751
1865
  probe can see it — it is reported as "too far apart", never as its cause.
1752
1866
  - **`table`** — `Table` + `TableRow` + `TableCell`: the paginated high-volume register
1753
- (columns defined once, `sortLabels` localizable; rows are `PressableRow`-based).
1867
+ (columns defined once, `sortLabels` localizable; rows are `PressableRow`-based). A column
1868
+ whose header is a set of POSITIONS rather than a word passes `header` (a node) beside its
1869
+ `label` — `Timetable`'s seven weekdays — never as a way to restyle a label.
1754
1870
  CONTAINER-RESPONSIVE with no prop (measures itself, like `Breakdown`/`DetailTable`): when
1755
1871
  the width can't fit every column it hides columns by `TableColumn.priority` (**`1` = highest,
1756
1872
  as in P1/P2/P3** — the smallest number is the last to go; default = column order, rightmost
1757
1873
  first; column 0 — the identity — never drops), and
1758
1874
  below the two-column floor every row STACKS: the identity cell (column 0) HEADS the pile
1759
1875
  unlabelled — its column name would only restate the value — and each REMAINING cell renders
1760
- as a `DetailRow`-spread line (muted label left, value at the right edge one vocabulary with
1761
- the drawer's detail rows), the lines sitting on the identity column's text edge, never under the checkbox;
1876
+ as a `DetailRow` inside a `DetailTable`, so the field lines wear the FORM grammar: one fixed
1877
+ label column, every value on ONE left edge, one vocabulary with the drawer's detail rows. The
1878
+ lines sit on the identity column's text edge, never under the checkbox.
1879
+ **A SPREAD row is right for one detail value and wrong for a register of them** — that is
1880
+ what the narrow fork used to render, and repeated down twenty-one rows the same label string
1881
+ measured seven distinct widths while its value started at a different x on every row, so the
1882
+ fork had no value column at all. Invisible at desk width, where the fork never runs;
1762
1883
  the header band gives way, so sorting is a wide-container affordance. Rows carry TWO right-side slots: `action`
1763
1884
  (the primary CTA `Button` — register: in the trailing gutter; stacked: closes the content,
1764
1885
  right-aligned) and `trailing` (the ⋯ overflow — stays beside the identity on the top line).
@@ -1965,6 +2086,13 @@ a child of the app root, which every overlay's body-level box follows.
1965
2086
  and a PINNED one (a heading or summary above a scrolling list), which puts the exported
1966
2087
  `DRAWER_GUTTER` on the pinned part and on the scroller's `contentContainerStyle` rather than
1967
2088
  copying `20`.
2089
+ **`position` + `onPrev`/`onNext` make it a RECORD navigator, and below the split width that
2090
+ navigation drops to its own line** so the title keeps the full bar. The title names the record
2091
+ — whose file is open — and it is the only element on that bar that CAN give way, since the
2092
+ pager and the three icon buttons are all fixed widths; sharing one line at 375 cut a record's
2093
+ own name at 206 of the 261px it needed while the chrome beside it sat whole. Pass a NODE title
2094
+ (name over a qualifying line) and give its first line `lg` — that is the rung a string title
2095
+ already takes, and at `md` a record reads as a peer of the section headings inside it.
1968
2096
  - **`modal`** — `Modal` + `ModalHeader` + `ModalBody` + `ModalFooter` — the full-bleed,
1969
2097
  edge-to-edge takeover: an OPAQUE surface that COVERS THE WHOLE SCREEN, so unlike Dialog
1970
2098
  (centered card WITH scrim) and Drawer (docked panel WITH scrim) there is nothing behind it
@@ -2053,10 +2181,19 @@ a child of the app root, which every overlay's body-level box follows.
2053
2181
  jump both re-measure the live DOM on web, so content loading in above a section never
2054
2182
  strands them. Reaching the END of the scroll activates the LAST key regardless of offsets —
2055
2183
  a final section shorter than the viewport never scrolls its top past the trigger line.
2056
- On NARROW containers
2057
- the rail becomes a PINNED bar naming the CURRENT section that opens a full-page
2058
- section-picker `Modal` — never a horizontal tab strip. Worked example:
2184
+ Pair it with `SectionNav`, which draws both forms. Worked example:
2059
2185
  [`tpl_record`](../examples/tpl_record.tsx).
2186
+ - **`section_nav`** — `SectionNav`: the CHROME half of the outline, in its two forms — a RAIL
2187
+ in a gutter, or a pinned bar naming the CURRENT section that opens the same list as a picker,
2188
+ never a horizontal tab strip. `wide` picks the form and the CALLER measures its own container
2189
+ for it (a window read gives a drawer the page's answer); `items` carry an `issue` dot, which is
2190
+ what makes the outline answer "where is the problem" and not merely "where am I" and which is
2191
+ ANNOUNCED as well as coloured, since it is the only thing marking the section; `back` leads
2192
+ both forms, omitted where the container already has an exit. The active entry is `current`
2193
+ (→ `aria-current`), never `selected` — a rail item is navigation, not listbox selection. The
2194
+ picker anchors to the bar's own button, so it stays inside whatever container that bar sits in.
2195
+ It owns neither the gutter the rail floats in, nor the width threshold, nor which sections are
2196
+ in trouble.
2060
2197
  - **`scroll_to_bottom`** — `ScrollToBottom`: the floating jump-to-latest circle button for a
2061
2198
  chat/feed; ONLY the affordance — the caller owns positioning, visibility, and the actual
2062
2199
  scroll.
@@ -2095,7 +2232,24 @@ component rather than showing it at zero.
2095
2232
  it carries SHAPE (rising, spiky, flat) and no readable values, which is exactly what a
2096
2233
  register wants. Reach for a real chart the moment someone needs to read a value off it.
2097
2234
  - **`bar_chart`** / **`line_chart`** / **`pie_chart`** — `BarChart` / `LineChart` /
2098
- `PieChart`: the canonical SVG chart set (no recharts). **A categorical axis prints as many
2235
+ `PieChart`: the canonical SVG chart set (no recharts). `LineChart` takes EITHER `points`
2236
+ (one line, its own `lineColor`) OR **`series: { key, label, color?, points }[]`** — never
2237
+ both; the union makes passing two, or neither, a compile error. **Several series share ONE
2238
+ y-scale**, which is the whole reason they are one chart: per-series scales would draw two
2239
+ different quantities at the same height and the picture would state nothing. So a
2240
+ COMPARISON — this period against last, this depot against that one — is two series on one
2241
+ chart, never two charts side by side and never a control that swaps one for the other. The
2242
+ x axis is shared too: position `i` is position `i` for every series, and the label comes
2243
+ from whichever series reaches it, so a shorter series just ends early. **`legend` is on by
2244
+ default once there is more than one series** (several unnamed lines are a picture nobody can
2245
+ read) and off for a single one, which the card around it already names; the anatomy is the
2246
+ same `LegendItem` every other chart annotates itself with. A series that names no `color`
2247
+ takes the next one off the chart's own order, so four series are four distinguishable lines
2248
+ with nothing to pass. Each named series is a `role="img"` carrying its label — a line
2249
+ announces as nothing otherwise. The area wash and the per-reading markers are SINGLE-line
2250
+ devices: stacked washes muddy into a colour belonging to no series, and four lines' markers
2251
+ read as a field of dots, so several series draw bare with only their latest reading marked.
2252
+ **A categorical axis prints as many
2099
2253
  labels as the track fits and thins the rest** — `LineChart` and `BarChart`'s vertical
2100
2254
  orientation both, because it is the axis's rule and not one chart's. It is anchored on the LAST
2101
2255
  position: the newest reading is the one a reader looks up, and anchoring there is what keeps the
@@ -2112,7 +2266,18 @@ component rather than showing it at zero.
2112
2266
  `Table` or the horizontal orientation, which gives each entity its own row and its own value
2113
2267
  column. `BarChart`'s horizontal orientation is one row per entity: **`labelWidth`**
2114
2268
  sizes the fixed label column (default 80, which fits a date and not a company name), and each
2115
- item's **`leading`** slot carries that entity's own mark ahead of its name.
2269
+ item's **`leading`** slot carries that entity's own mark ahead of its name. **The value axis
2270
+ reads the VALUES, not just their maximum, so an axis over whole things never prints a half** —
2271
+ a series of counts gets whole-number gridlines however small its maximum, and only genuinely
2272
+ fractional data gets fractional ones. Nothing to pass; it follows the data. **That axis thins
2273
+ its labels too**, by the same rule the categorical one runs: its ticks are positioned by
2274
+ fraction, so a narrow track does not crowd them, it STACKS them into an unreadable smudge, and
2275
+ the tick marks stay because a 1px line collides with nothing. And **`labelWidth` is a REQUEST,
2276
+ not a reservation**: the label and value columns are fixed, so in a narrow pane they would take
2277
+ the whole width and leave every bar on its 2px floor — a row of identical stubs claiming every
2278
+ entity is equal — so the label column gives way first and the plot keeps a readable minimum. A
2279
+ trimmed name beside a real bar says more than a full name beside a stub; ask for the width the
2280
+ names want and let the chart settle it.
2116
2281
  - **`progress_bar`** — `ProgressBar`: the determinate meter; `compact` = ONE row, track + a
2117
2282
  plain sm tabular count beside it; `max: null` + `unboundedLabel` = the same meter with no bound.
2118
2283
  - **`progress_ring`** — `ProgressRing`: the same meter in a circle, and the ONLY circular one. Same API shape as the bar — a real `value`/`max` rather than a
@@ -2168,26 +2333,56 @@ component rather than showing it at zero.
2168
2333
  `onSelect`/`selectedKey` press-to-drill; the subset/drop-off sibling of
2169
2334
  `stacked_progress_bar`'s whole-split.
2170
2335
  - **`status_grid`** — `StatusGrid` + `StatusLegend`: the per-slot state grid (occupancy,
2171
- availability) and its legend.
2336
+ availability) and its legend. A PILE of units with no axes — when the reader compares a
2337
+ subject against a date, that is `state_matrix`.
2338
+ - **`state_matrix`** — `StateMatrix`: the CATEGORICAL cross-tab — `rows` (a subject each:
2339
+ `label`, an identity `leading`, a pinned `total`, one cell per column), `columns` (dated
2340
+ positions named ONCE in the header, `caption` for a second line, `current` for the one the
2341
+ reader stands in — tinted down the grid, `aria-current`), `states` (key, label, palette
2342
+ `color`, `shape` filled | hollow | dot | none — the legend derives from this list, so it can
2343
+ never name a glyph the cells do not draw). A cell carries a `name` (a glyph announces as
2344
+ nothing) and is a `button` only when `pressable` — `onPressCell(rowKey, colKey)` opens what
2345
+ it has to say. The label block is PINNED outside the grid's own horizontal scroll. Not
2346
+ `Matrix` (nothing here is a number; blank is "not due", never zero), not `Timetable` (a
2347
+ timetable's positions are a dateless pattern and every mark is its own editor). Worked
2348
+ example: `examples/tpl_attendance.tsx` §This week.
2172
2349
  - **`heatmap`** — `Heatmap`: density cross-tab — colour-only, no numbers; "where does it
2173
2350
  cluster".
2174
2351
  - **`matrix`** — `Matrix`: the PIVOT cross-tab — band-compound `Matrix` root +
2175
2352
  `Matrix.Header` (corner + axis labels) + `Matrix.Grid` (`display` number|heat|both — the
2176
2353
  cells, pressable, the value IN the cell) + `Matrix.Totals` (row + column + grand) +
2177
- `Matrix.Legend`. Pick over `Heatmap` when the NUMBER and totals matter. Figures are
2354
+ `Matrix.Legend`. Pick over `Heatmap` when the NUMBER and totals matter. **The heat is a RANK
2355
+ scale behind numbers** (`heatScale`, default `rank` for `display="both"`, `linear` for a pure
2356
+ heatmap): a linear map of value ÷ largest-cell spends the whole band on the one outlier every
2357
+ real sheet has and leaves the rest inside a step the eye cannot resolve — a white grid with one
2358
+ dark square. Pass `heatScale="linear"` only where the wash is the sole statement of MAGNITUDE.
2359
+ `Matrix.Legend` samples the swatches out of that same scale rather than listing its own alphas,
2360
+ so it cannot advertise a shade no cell can wear, and it renders nothing when no cell is painted.
2361
+ **An empty interior cell says ZERO, not "no data"** — the grid has no third state, so a column
2362
+ the source has not reached yet is the caller's to leave out. Figures are
2178
2363
  RIGHT-aligned — they are `tabular`, and centring them defeats the aligned column — and row
2179
- labels carry default ink, because a row's label is its identity, not chrome. **The totals column
2364
+ labels carry default ink, because a row's label is its identity, not chrome. **A ROW may carry
2365
+ a `leading` identity mark** (an `Avatar`, a status dot), drawn inside `rowLabelWidth` so it eats
2366
+ the label's width rather than widening the column — which is how a matrix shows the same mark
2367
+ the chart beside it shows. Rows only: a column is a period or a category and has no identity, so
2368
+ `Matrix.Header` ignores `leading`, and the totals band carries no mark either. **The totals column
2180
2369
  takes one flex share like a data column**, because a total is the longest figure in its row: on a
2181
2370
  fixed width it is the FIRST thing to clip, and a clamped figure is whole in the DOM, so nothing
2182
- but a rendered screen sees it. `MATRIX_COL_GAP` is
2183
- exported for a caller budgeting
2184
- its own column widths. **A money matrix states its unit once** in the card's
2371
+ but a rendered screen sees it. **The grid owns its own horizontal scroll**: past the width where
2372
+ every cell still meets its floor it scrolls rather than compressing, so a matrix with many columns
2373
+ keeps its row labels and its totals column instead of squeezing the label to one glyph and pushing
2374
+ the totals off the edge. That is the component's answer to a wide column axis — never the page
2375
+ body, and never a caller's own wrapper. A date axis is still the caller's to BOUND: a sheet that
2376
+ reaches past the last day with data buys empty columns that the ones with numbers then pay for.
2377
+ `MATRIX_COL_GAP` is exported for a caller budgeting its own column widths. **A money matrix states its unit once** — in the card's
2185
2378
  `CardHeaderMeta`, cells bare (`formatCompactNumber`, not `formatMoney`), per
2186
2379
  [composition.md](./composition.md) §"A column states its unit ONCE": one currency glyph per cell
2187
2380
  is the same word fifty times and it is what pushes the totals column past its share.
2188
2381
  - **`matrix_totals`** — the data layer for `Matrix`: `matrixTotals` (the cross-tab
2189
- aggregation) + `MatrixAxisItem` / `MatrixCellRef` / `MatrixTotalsResult`; React-free, so a
2190
- KPI can be driven off the same numbers the grid shows.
2382
+ aggregation) + `matrixHeatScale` (value alpha, and the legend sampled from that same
2383
+ function) + `MatrixAxisItem` (`key`, `label`, and a rows-only `leading` mark — a TYPE-only
2384
+ node, so nothing here runs) / `MatrixCellRef` / `MatrixTotalsResult` / `MatrixHeatScale`;
2385
+ React-free, so a KPI can be driven off the same numbers the grid shows.
2191
2386
  - **`highlight_segments`** — `splitHighlightSegments`: cuts a string into plain and
2192
2387
  highlighted runs for `Composer`'s `highlightRanges`. React-free: it feeds a mirror sitting
2193
2388
  behind a real text field, so a boundary off by one paints the tint adrift while everything
@@ -2764,7 +2959,11 @@ component rather than showing it at zero.
2764
2959
  `cafe` finds `Café` and `da giac` finds `Đa Giác`. ONE definition, and every list that filters
2765
2960
  uses it — two rules is not a style problem: a reader who found a row by typing unaccented text
2766
2961
  in one list and finds nothing in the next reads the second as missing data. `OptionList`,
2767
- `Combobox` and `Switcher` all match through it.
2962
+ `Combobox` and `Switcher` all match through it. `codeKey(s)` / `codeMatches(query, ...fields)`:
2963
+ the same fold for a CODE — a plate, a container number, a voucher number — dropping every
2964
+ separator on both sides, so `14b031` and `031.02` both find `14B-031.02`; still a substring
2965
+ test, never a subsequence one. A register searched by an identifier matches through this,
2966
+ never through its own regex.
2768
2967
  - **`use_async_fn`** — `useAsyncFn`: wrap an async function into a manual-trigger mutation —
2769
2968
  `[run, {loading, data, error}]`, unmount-safe, the error lands in state AND rethrows; the
2770
2969
  pending-state engine for a submit/download/upload action.