@lotics/ui 7.7.0 → 7.9.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.
Files changed (59) hide show
  1. package/AGENTS.md +75 -33
  2. package/examples/tpl_allocate.tsx +6 -6
  3. package/examples/tpl_assistant.tsx +1 -1
  4. package/examples/tpl_attendance.tsx +1 -1
  5. package/examples/tpl_billing.tsx +2 -2
  6. package/examples/tpl_briefing.tsx +1 -1
  7. package/examples/tpl_calendar.tsx +1 -1
  8. package/examples/tpl_compare.tsx +1 -1
  9. package/examples/tpl_crosscheck.tsx +1 -1
  10. package/examples/tpl_dashboard.tsx +1 -1
  11. package/examples/tpl_detail.tsx +1 -1
  12. package/examples/tpl_dieline.tsx +2 -2
  13. package/examples/tpl_directory.tsx +5 -8
  14. package/examples/tpl_draft.tsx +2 -2
  15. package/examples/tpl_extract.tsx +1 -1
  16. package/examples/tpl_intake.tsx +1 -1
  17. package/examples/{tpl_dossier.tsx → tpl_item_list.tsx} +138 -97
  18. package/examples/tpl_lookup.tsx +2 -2
  19. package/examples/tpl_match.tsx +2 -2
  20. package/examples/tpl_order.tsx +1 -1
  21. package/examples/tpl_pick.tsx +64 -68
  22. package/examples/tpl_pivot.tsx +1 -1
  23. package/examples/tpl_quick.tsx +1 -1
  24. package/examples/tpl_ratedesk.tsx +1 -1
  25. package/examples/tpl_record.tsx +1 -1
  26. package/examples/tpl_record_plain.tsx +1 -1
  27. package/examples/tpl_report.tsx +1 -1
  28. package/examples/tpl_rollup.tsx +1 -1
  29. package/examples/tpl_settings.tsx +1 -1
  30. package/examples/tpl_shifts.tsx +1 -1
  31. package/examples/tpl_stock.tsx +1 -1
  32. package/examples/tpl_task_board.tsx +2 -2
  33. package/examples/tpl_tasks.tsx +1 -1
  34. package/examples/tpl_timeline.tsx +1 -1
  35. package/examples/tpl_tower.tsx +1 -1
  36. package/examples/tpl_triage.tsx +1 -1
  37. package/examples/tpl_wizard.tsx +1 -1
  38. package/package.json +3 -1
  39. package/src/card.tsx +6 -13
  40. package/src/combobox.tsx +28 -11
  41. package/src/data_grid.tsx +1 -1
  42. package/src/kpi_strip.tsx +5 -5
  43. package/src/pressable_row.tsx +22 -10
  44. package/src/sort_header.tsx +9 -4
  45. package/src/summary_line.tsx +76 -0
  46. package/src/table.tsx +27 -11
  47. package/src/use_selection.test.ts +66 -0
  48. package/src/use_selection.ts +75 -0
  49. package/examples/app_orders.tsx +0 -405
  50. package/examples/tpl_approvals.tsx +0 -375
  51. package/examples/tpl_batch.tsx +0 -234
  52. package/examples/tpl_callsheet.tsx +0 -481
  53. package/examples/tpl_convert.tsx +0 -478
  54. package/examples/tpl_crm_desk.tsx +0 -541
  55. package/examples/tpl_dispatch.tsx +0 -289
  56. package/examples/tpl_inventory.tsx +0 -299
  57. package/examples/tpl_pipeline.tsx +0 -400
  58. package/examples/tpl_reconcile.tsx +0 -275
  59. package/examples/tpl_run.tsx +0 -235
package/AGENTS.md CHANGED
@@ -80,7 +80,7 @@ Pick by capability, not by name. (→ the source file for the API.)
80
80
  - **High-volume register** (thousands+ you BROWSE) — `Table` (columns defined once; sortable
81
81
  headers via `SortHeader`; paired with `Pagination`) + read-only rows that open a `Drawer` to edit.
82
82
  It scales by PAGING — renders one page, never the whole set — so you FILTER + search, you don't
83
- group. Worked example: `tpl_dossier`. Never an HTML `<table>` or a `.map` of rows.
83
+ group. Worked example: `tpl_item_list`. Never an HTML `<table>` or a `.map` of rows.
84
84
  - **Inline-managed grouped table** (MODERATE — hundreds, low-thousands — you MANAGE in view) — the
85
85
  `DataGrid` primitive: a grouped, sortable grid whose cells are LIVE inline editors (ANY field — a
86
86
  column is `{ key, label, width?, sortable?, cell: (item) => ReactNode }`, so `InlineMemberSelect` /
@@ -91,8 +91,9 @@ Pick by capability, not by name. (→ the source file for the API.)
91
91
  per-group add row (`renderGroupFooter`, aligned with the exported `gridRowStyle` + the column
92
92
  widths). It renders ALL rows (no virtualization), so it's only for sets small enough to hold in
93
93
  view — at 10k+ it lags, and grouping + pagination/infinite don't compose; use the register instead.
94
- Worked examples: `tpl_task_board` (with a `CheckCircle` leading), `tpl_pipeline` (no leading).
95
- - **Numbers & charts** — `KPIStrip` / `KPICard` / `Metric` (headline figures), `TrendChip`
94
+ Worked example: `tpl_task_board` (a `CheckCircle` leading).
95
+ - **Numbers & charts** — `KPIStrip` (the dashboard stat band) · `SummaryLine` (the light inline
96
+ register/list summary — below the toolbar, from the filtered rows) · `KPICard` / `Metric` (headline figures), `TrendChip`
96
97
  (delta), `Sparkline`, `BarChart` / `LineChart` / `PieChart` (the canonical SVG set — no
97
98
  recharts), `RingGauge`, `ProgressBar` / `StackedProgressBar` / `StepProgress`, `Breakdown`
98
99
  (a stacked bar + ranked share rows, pressable to drill; `maxRows` folds the long tail behind a
@@ -227,7 +228,10 @@ parts render the CHROME, composed as children — never a render-prop pile:
227
228
 
228
229
  It's a SELECT by default (`ComboboxInput` with NO `icon` → a trailing chevron); opt INTO the
229
230
  search-box look with `ComboboxInput icon="search"` only when typing-to-search is primary (a
230
- large/remote set). `allowCustom` appends a "Create …" row (`customOptionLabel`) when the query
231
+ large/remote set). For a select that the user can RESET, pass `clearable` + `onClear` (deselect):
232
+ the chevron shows while the field is empty and a clear ✕ replaces it once there's a value/query;
233
+ pressing ✕ clears the text and reopens the full-browse list, so `onClear` lands back on the whole
234
+ set (on focus an empty field already browses everything — `recentOptions ?? all`). `allowCustom` appends a "Create …" row (`customOptionLabel`) when the query
231
235
  matches no option, emitting the typed text as the value — so a value not in the known set means
232
236
  CREATE. Wire that branch to a create overlay (a modal `Dialog` for 4+ fields, an anchored popover
233
237
  for 1–3) that builds the new record and attaches it; existing matches attach directly. The create
@@ -486,9 +490,9 @@ decisions, or a ranked set, don't cram it into chat prose. Each pairs a template
486
490
  - **Match** (`tpl_match`, body-first `ReviewCard` + `MatchSides`) — the agent proposes PAIRINGS with
487
491
  its reasoning; each is a `ReviewCard` (no title, so body-first) whose body is a two-sided `MatchSides`
488
492
  pairing (source → proposed counterpart), with the confidence + reason beneath, accept / reassign /
489
- reject, high-confidence batch-accept. The AI reconciliation / dedup / correlation queue. (The
490
- deterministic cousin
491
- `tpl_reconcile` has no agent reasoning — that's the tell for which to reach for.)
493
+ reject, high-confidence batch-accept. The AI reconciliation / dedup / correlation queue. (Strip the agent's
494
+ reasoning and it would be a plain deterministic match register — the reasoning is the tell for when
495
+ you need this.)
492
496
  - **Briefing** (`tpl_briefing`, `Finding`) — a generated narrative over the data + a RANKED list of
493
497
  what needs attention, each finding carrying a severity, a metric, its sources, and one action. The
494
498
  leadership digest / anomaly scan — the agent's COMMENTARY, not a raw dashboard.
@@ -613,10 +617,21 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
613
617
  </Card>
614
618
  ```
615
619
  Never nest cards; never hand-compose a title or footer band. `info` is standard, not garnish.
616
- - **The stat band**: `<KPIStrip items={[{label, value, trend?, caption?, tone?, info?}…]}>` is the
617
- ONE way to open a screen with numbers. KPI strips are INFORMATIONAL they never filter or
618
- navigate (that's the tabs/chips' job), and they carry cross-cutting health numbers the tabs
619
- can't show; if there are none, drop the strip. Card stat rails use `KPICard`.
620
+ - **Flat work execution vs carded monitoring.** A `Card` is ALWAYS a bordered, lifted container —
621
+ there is no "flat card". MONITORING / dashboard screens group with cards (and the `KPIStrip`
622
+ band). WORK-EXECUTION screens registers, lists, the worklists an operator acts on — are FLAT:
623
+ the register/list sits DIRECTLY on the white canvas with no Card wrapper; separation comes from
624
+ the row wash, gaps, and `Divider`/`CardHeader`/`CardFooter` bands used BARE. Reach for a Card only
625
+ where a screen holds a DISTINCT region that must be told apart from another; a single register or
626
+ list filling the screen needs none.
627
+ - **Summaries — two altitudes, never mixed.** A DASHBOARD opens with the boxed stat band
628
+ `<KPIStrip items={[{label, value, trend?, caption?, tone?, info?}…]}>` (a `Card` of `KPICard`
629
+ columns). A REGISTER/LIST (work-execution) screen instead carries a LIGHT inline
630
+ `<SummaryLine items={[{label, value, tone?, info?}…]}>` BELOW its toolbar — a small `Metric` + a
631
+ muted label per item, recomputed from the FILTERED rows so it always describes WHAT'S SHOWN. Both
632
+ are INFORMATIONAL — they never filter or navigate (that's the tabs/chips' job); if there are no
633
+ cross-cutting numbers, drop them. Do NOT put a `KPIStrip` on a register page or a `SummaryLine` on
634
+ a dashboard. Card stat rails use `KPICard`.
620
635
  - **Numbers**: free-standing numerals `<Text tabular>`. Money: `formatMoney(n)` from
621
636
  `@lotics/ui/format_money` (`compact` for strip captions). Dates: `formatDate(value, opts)` from
622
637
  `@lotics/ui/format_date` — the date-VALUE formatter. `format` (date STYLE) ∈ `date` (22/05/2026) ·
@@ -639,13 +654,22 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
639
654
  `Drawer` (sequenced); read-only drill-downs expand via `Accordion` or glance via `Peek`; every
640
655
  other row gets an `ActionMenu` (⋯ → MenuButton items: destructive last + **confirmed** via
641
656
  `Alert`). The whole surface is the door: register rows are `PressableRow` (full-bleed wash incl.
642
- nested controls). Two row patterns, never un-separated:
643
- · **Register (default)** — px-20 square rows, wash to the card edges, separated by full-bleed
644
- `Divider`s (`{i > 0 ? <Divider/> : null}`), NO wrapping padding. A COLUMNAR register is
645
- `Table`/`TableRow`/`TableCell` (columns defined once header + widths can't drift); paginate
646
- OUTSIDE (slice + `Pagination` in the `CardFooter`).
647
- · **Inset contained list** grouped/gap-separated lists: `PressableRow variant="inset"` in a
648
- padded container, NO `Divider`s (the gap + rounded hover separate them).
657
+ nested controls), Divider-separated:
658
+ · **Register (default, `variant="register"`)** — the rounded full-width hover/open/`marked` wash
659
+ spans the whole row (incl. nested controls); content sits on the 20px gutter so a `Table` header
660
+ + cells align. The Divider BETWEEN rows is the resting separation. The COLUMNAR register is
661
+ `Table`/`TableRow`/`TableCell` (columns once → header + widths can't drift; ONE hairline under the
662
+ header; the rows below it Divider-separated; a sortable column shows its `SortHeader` sort glyph
663
+ ALWAYS so it reads as sortable). Make it SELECTABLE with the `Table` `leading` gutter + `selectAll`
664
+ slot — a `CheckboxInput` per `TableRow` (`leading`) + a select-all in the header band, the ticked
665
+ rows `marked`, paired with a `FloatingActionBar`; the selection state is the **`useSelection()`**
666
+ hook (`selected`/`count`/`toggle`/`setAll`/`allSelected`/`indeterminate`/`clear` — gating which
667
+ rows CAN be ticked stays with you: pass only the selectable ids to `setAll`/`allSelected`/
668
+ `indeterminate`). `tpl_item_list` is the reference. Paginate OUTSIDE (slice + `Pagination` in the
669
+ `CardFooter`).
670
+ · **`variant="bleed"`** (legacy) — px-20 square, `Divider`-separated; only for an edge-to-edge data
671
+ grid that genuinely wants hard rules.
672
+ · **`variant="inset"`** — grouped lists in a padded container (Accordion drill-downs).
649
673
  Right-hand columns align only if every trailing element is FIXED-width (give each trailing action
650
674
  a fixed `width`, so amount/status columns don't jitter).
651
675
  - **Row actions are ALWAYS-VISIBLE siblings, never hover-revealed, never nested.** A pressable row =
@@ -664,6 +688,15 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
664
688
  step. Facts are `DetailRow`s; the commit bar is a `DrawerFooter`. The open row shows a selected
665
689
  highlight (and `marked` — a resting blue tint — for a bulk-ticked row). `Peek` is ONLY for
666
690
  secondary references, never the primary row press.
691
+ - **Two work shapes, no side panels.** A work screen is ONE of two shapes — never a persistent
692
+ side-by-side split (it dies on a phone, and the "detail" of a list belongs in a `Drawer`/sheet,
693
+ not a second column). (1) **Work each** — a flat register/list; press a row → its workspace
694
+ `Drawer` (above). (2) **Act on many** — a flat list with a leading `CheckboxInput` per row + a
695
+ select-all `CardHeader` band, plus a bottom-pinned `<FloatingActionBar count label onClear>`
696
+ carrying the bulk action(s) while ≥1 row is ticked (`tpl_item_list`); a row
697
+ that can't take the action has NO checkbox. When a region genuinely must sit beside the list (a
698
+ source/remainder summary, a capacity picker) make it a TOP summary header or move it into a
699
+ Popover/sheet at the decision point — not a standing column.
667
700
  - **One control radius** — every interactive control (`Button`, the inputs/selects/pickers,
668
701
  `SearchInput`, `MenuButton`, `Tabs`, the `SegmentedControl` track, `ChipGroup`/`FilterChip`/
669
702
  `Chip`) wears `CONTROL_RADIUS` (10, from `control_surface.ts`) — never a literal. Containers
@@ -697,6 +730,10 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
697
730
  (`icon="map-pin"`, `clearable`) with an `arrow-right` between — flight-search style.
698
731
  · `Tabs` switch between VIEWS/sections (different content/layout) — never a subset of one list. A tab earns a `status` dot (optional `TabOption.status`) only when its area needs attention (a blocker / missing item) — the resting state has none.
699
732
  `SegmentedControl` chooses a MODE/PARAMETER of the SAME view (2–4 peers, no panel swap).
733
+ · **Layout**: search + secondary filters LEFT, the primary CTA RIGHT, in ONE band. A `ChipGroup`
734
+ one-of-N lens is fine ONLY when the band has no `SearchInput`; once a search is present, the
735
+ status filter becomes a `Select`/`FilterChip` dropdown — never a row of pills competing with the
736
+ search.
700
737
  - **Footer actions align RIGHT.** `PopoverFooter`/`DialogFooter`/`DrawerFooter` default
701
738
  `align="end"` — commit at the right edge, secondary to its left. Never left-flow a Save/Submit.
702
739
  - **Empty result**: `<EmptyState message hint? icon? action?>` — never a bare muted Text.
@@ -741,12 +778,19 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
741
778
  paginated register → export) · `tpl_tower` (live `StatusGrid` wallboard) · `tpl_pivot` (the
742
779
  `Matrix` cross-tab desk: one dimension × another, number + heat per cell + row/col/grand totals,
743
780
  press a cell to drill the list behind it) · `tpl_rollup` (hierarchical totals).
744
- - **Work execution** — `app_orders` (stage-gated work queue) · `tpl_crm_desk` (assign & work +
745
- `FloatingActionBar`) · `tpl_callsheet` (call console) · `tpl_convert` (staged conversion) ·
746
- `tpl_inventory` (threshold actions) · `tpl_dossier` (high-volume paginated register) ·
747
- `tpl_approvals` (verdicts: bulk + over-policy `Dialog`) · `tpl_reconcile` (pair matching) ·
748
- `tpl_dispatch` (capacity allocation) · `tpl_batch` (compose from parts) · `tpl_pick` (guided
749
- `ScanField` run) · `tpl_allocate` (`RemainderMeter` split) · `tpl_run` (preview resolve → post) ·
781
+ - **Work execution** — every screen is FLAT (no Card) and ONE of two shapes: a register/list you
782
+ WORK EACH of (press a row → workspace `Drawer`) or a list you ACT ON MANY of (tick rows
783
+ `FloatingActionBar`) never a side-by-side panel. `tpl_item_list` (THE canonical register and the
784
+ consolidated work-list: search + a status `Select` + facet `FilterChip`s LEFT, the New CTA RIGHT in
785
+ one toolbar row, a `SummaryLine` below, then a sortable, Divider-separated `Table` where every row
786
+ carries a leading checkbox; a select-all band, footer `Pagination`, and a `FloatingActionBar` for
787
+ the bulk action over the ticked rows; a per-row PRINT button (the trailing action column) is the
788
+ row's primary action and the ⋯ its overflow; a row that can't take the bulk action gets a DISABLED
789
+ checkbox (here an "Awaiting docs" case — the same gating as a blocked run line) — a row press opens
790
+ the workspace `Drawer`. This ONE template subsumes the old approvals / dispatch / batch / run
791
+ screens: register, per-row action, gated selection, and act-on-many in one) · `tpl_pick` (guided `ScanField` run — a single-focus task column with
792
+ the pick path collapsed into an `Accordion` below) · `tpl_allocate` (`RemainderMeter` split — a
793
+ source/remainder summary header over the invoice list + Apply) ·
750
794
  `tpl_tasks` (the QUICK LIST — Apple-Reminders shape for personal lists: `CheckCircle` rows you tick +
751
795
  expand in place to edit (`Inline*`, stable row — the meta summary never shifts on expand), colour-dot
752
796
  tags, an attachments field (`FileThumbnailGrid` → tap a tile to the gallery for download + confirmed
@@ -759,13 +803,11 @@ recipe for a screen JOB; copy and adapt). Pick by the job:
759
803
  board, a different action per row; the link's pressable fills the cell to match the inline editors),
760
804
  a trailing **`ActionMenu`** (the ⋯ overflow — a `danger` Remove + confirm
761
805
  today, more row actions later), per-group add rows that pre-set the group's field, and the same
762
- Draft-from-notes CTA) · `tpl_pipeline` (the SAME inline-managed grouped-table shape, a second domain — deals grouped by
763
- stage with a money-value cell + owner + close-date edited inline, its own Draft-from-notes CTA drafting
764
- deals; proof the shape generalizes beyond tasks). All three: filters/search left, primary CTA right, one
765
- row; group-by holds only real dimensions, ✕ clears to ungrouped (no "Nothing" option). **`tpl_task_board` + `tpl_pipeline` are the inline-managed
806
+ Draft-from-notes CTA). Both `tpl_tasks` and `tpl_task_board`: filters/search left, primary CTA right, one
807
+ row; group-by holds only real dimensions, clears to ungrouped (no "Nothing" option). **`tpl_task_board` is the inline-managed
766
808
  grouped table — MODERATE data you manage in view (group/sort/filter/edit-in-place, renders all
767
- rows). For thousands+ you BROWSE, that's `tpl_dossier`'s paginated register, not this.** Both build
768
- on the `DataGrid` primitive (the grid shape, extracted once these two uses confirmed it); each owns
809
+ rows). For thousands+ you BROWSE, that's `tpl_item_list`'s paginated register, not this.** It builds
810
+ on the `DataGrid` primitive (the inline-managed grouped-table shape); it owns
769
811
  only its data, toolbar, columns and per-group add. There is NO Task component — a task ROW varies
770
812
  too much to bake into one, so rows compose `CheckCircle` + `InlineTextInput` + inline-editor cells
771
813
  as **sibling** controls in a plain `View`. NEVER wrap the whole row in a `Pressable accessibilityRole="button"`
@@ -819,15 +861,15 @@ list · list_item · menu_button · menu_list_item · detail_row · pressable_ro
819
861
  check_circle (CheckCircle — the completion ring: an empty ring that springs to a filled check when done, distinct from the square checkbox; the task/to-do/checklist toggle. Compose task rows directly, no Task component) · action_menu ·
820
862
  floating_action_bar · filter_chip · column_filter (ColumnFilter — the typed per-column filter pill +
821
863
  columnFilterToConditions; for a register filtering on several columns) · chip_group · search_input ·
822
- sort_header · table · data_grid (DataGrid — the inline-managed grouped table: a grouped, sortable grid of LIVE inline-editor cells (`columns[].cell` → ANY field) + optional per-row `leading` (a CheckCircle) + `renderGroupFooter` (per-group add, align with the exported `gridRowStyle`) + `labels` (localize the sort-header a11y via `SortHeaderLabels`). Owns header/sections/rows; consumer owns data + sort/group/filter/collapse state + toolbar. Renders ALL rows — MODERATE data; 10k+ → the paginated `Table` register. Examples: `tpl_task_board`, `tpl_pipeline`) · pagination · accordion · stepper (Stepper + Step — done/current/upcoming/warning/complete progress on a track (horizontal) or spine (vertical); compound `<Step status>children` OR data `steps[]`+`current`; **navigable** via `Step.onPress` (both orientations — the whole step is the tap target) + `active` to wash the selected one, so it doubles as a section/phase switcher; the guided-run / agent-feed primitive — subsumes the old StepList) ·
864
+ sort_header · table · data_grid (DataGrid — the inline-managed grouped table: a grouped, sortable grid of LIVE inline-editor cells (`columns[].cell` → ANY field) + optional per-row `leading` (a CheckCircle) + `renderGroupFooter` (per-group add, align with the exported `gridRowStyle`) + `labels` (localize the sort-header a11y via `SortHeaderLabels`). Owns header/sections/rows; consumer owns data + sort/group/filter/collapse state + toolbar. Renders ALL rows — MODERATE data; 10k+ → the paginated `Table` register. Example: `tpl_task_board`) · pagination · accordion · stepper (Stepper + Step — done/current/upcoming/warning/complete progress on a track (horizontal) or spine (vertical); compound `<Step status>children` OR data `steps[]`+`current`; **navigable** via `Step.onPress` (both orientations — the whole step is the tap target) + `active` to wash the selected one, so it doubles as a section/phase switcher; the guided-run / agent-feed primitive — subsumes the old StepList) ·
823
865
  step_progress · timeline (heterogeneous event LOG — per-row icon + expandable details, models the past; NOT progress) · drawer (+ DrawerFooter) · dialog · modal (Modal + ModalHeader + ModalBody + ModalFooter — the full-bleed, edge-to-edge takeover: an OPAQUE surface that COVERS THE WHOLE SCREEN, so unlike Dialog (centered card WITH scrim) and Drawer (docked panel WITH scrim) there is nothing behind it to dim — NO scrim, NO backdrop. Lays children as a flex column: a pinned ModalHeader (eyebrow/title + an actions slot + close), a flex:1 scrolling ModalBody, a pinned ModalFooter (the commit bar, same chrome as DialogFooter/DrawerFooter). Reach for it for a focused capture / multi-step wizard / a console the user steps INTO, where surrounding chrome is a distraction; pick Dialog when the surface is a card the user can see context around) · popover · tooltip ·
824
866
  alert · peek · empty_state · completion_state · callout (Callout · CalloutTitle ·
825
- CalloutText · CalloutActions) · kpi_card · kpi_strip · metric · trend_chip · sparkline ·
867
+ CalloutText · CalloutActions) · kpi_card · kpi_strip · summary_line (SummaryLine — the light inline summary of a register/list's FILTERED view, sits below the toolbar; NOT the boxed dashboard `kpi_strip` band) · metric · trend_chip · sparkline ·
826
868
  bar_chart · line_chart · pie_chart · ring_gauge · progress_bar · stacked_progress_bar · breakdown ·
827
869
  funnel (Funnel — conversion funnel: narrowing bars + the step rate as a bold aligned headline row, count below; `orientation` vertical|horizontal, `onSelect`/`selectedKey` press-to-drill; the subset/drop-off sibling of stacked_progress_bar's whole-split) ·
828
870
  status_grid (StatusGrid + StatusLegend) · heatmap (density cross-tab — colour-only, no numbers) · matrix (Matrix — the PIVOT cross-tab: band-compound `Matrix` root + `Matrix.Header` (corner + axis labels) + `Matrix.Grid` (`display` number|heat|both — the cells, pressable, the value IN the cell) + `Matrix.Totals` (row + column + grand) + `Matrix.Legend`; the data layer `matrixTotals`/`MatrixAxisItem`/`MatrixCellRef` import from `@lotics/ui/matrix_totals`. Pick over `Heatmap` when the NUMBER and totals matter, not just where it clusters) · legend_item · remainder_meter · allocation_row ·
829
871
  scan_field · file_dropzone · files_editor (FilesEditor — THE all-in-one attachment field: FileGrid + a toolbar (Upload primary · Select · Download all) that swaps into a batch SELECT mode (Select all · a Menu of Download/Share/Delete · Done; the per-tile ✕ is select-mode-only, never in the default view) + built-in gallery (Download + inline preview; no "open in new tab") + Alert-confirmed remove; host wires `files` + `onAdd`/`onRemove` (+ optional `uploads`, `onShareSelected`, `readOnly`, `labels`, `galleryLabels`, `gridMaxHeight` — cap the grid height so it scrolls and the toolbar pins, for a popover/drawer); mirrors the frontend cell_files_editor. Use FileGrid/FileRows bare only when you own the chrome) · file_grid (FileGrid — the upload-aware grid: completed files + a live upload queue in one surface; FileUpload/PendingUpload types; the add-files default) · uploading_thumbnail (UploadingThumbnail — the single in-flight upload tile FileGrid renders; reach for it only when hand-rolling a non-grid upload layout) · file_thumbnail · file_thumbnail_grid · file_row · file_rows (FileRows — batteries-included file list: row press → built-in gallery + a ⋯ Download/Open-external/Remove menu; composes FileRow + ActionMenu + FileGalleryModal) · file_preview (FilePreview — the universal inline preview: image/PDF/video/audio + Word via `@lotics/docx` + Excel/CSV via `@lotics/xlsx`; the heavy engines are LAZY (dynamic-imported, ~free until a doc of that type is opened) so custom-code APPS can use it too — they just add `@lotics/docx` + `@lotics/xlsx` to their deps. Renders to canvas/DOM, never a nested iframe — works in the sandboxed app iframe) ·
830
- file_gallery_modal · image_gallery · use_selection_mode · share_or_download · rotate_image · avatar · skeleton · activity_indicator · loading · divider ·
872
+ file_gallery_modal · image_gallery · use_selection (useSelection — always-on multi-select state for a register/list: the `selected` Set + `toggle`/`setAll`/`allSelected`/`indeterminate`/`count`/`clear`; selectability gating stays with the caller. The checkbox-always-visible counterpart to `use_selection_mode`) · use_selection_mode · share_or_download · rotate_image · avatar · skeleton · activity_indicator · loading · divider ·
831
873
  spacer · stack · section_card · page_header · page_content · calendar (calendar/index.ts) · gantt ·
832
874
  comments_thread · agent_run (live streaming work feed) · agent_progress (its compact floating
833
875
  expandable form — a composer's working state) · composer (Composer — the adaptive command/chat
@@ -5,7 +5,7 @@ import { colors } from "@lotics/ui/colors";
5
5
  import { AllocationRow } from "@lotics/ui/allocation_row";
6
6
  import { Badge } from "@lotics/ui/badge";
7
7
  import { Button } from "@lotics/ui/button";
8
- import { Card, CardFooter } from "@lotics/ui/card";
8
+ import { CardFooter } from "@lotics/ui/card";
9
9
  import { Divider } from "@lotics/ui/divider";
10
10
  import { RemainderMeter } from "@lotics/ui/remainder_meter";
11
11
  import { formatMoney } from "@lotics/ui/format_money";
@@ -63,7 +63,7 @@ export function TplAllocate() {
63
63
  : "Payment fully applied across the selected invoices";
64
64
 
65
65
  return (
66
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
66
+ <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
67
67
  <View style={{ width: "100%", maxWidth: 1000, alignSelf: "center", gap: 16 }}>
68
68
  {/* header */}
69
69
  <View style={{ gap: 2 }}>
@@ -72,7 +72,7 @@ export function TplAllocate() {
72
72
  </View>
73
73
 
74
74
  {/* the source — the payment, and the remainder as you place it */}
75
- <Card style={{ padding: 0 }}>
75
+ <View>
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" transform="uppercase">Payment received</Text>
@@ -87,10 +87,10 @@ export function TplAllocate() {
87
87
  </View>
88
88
  </View>
89
89
  </View>
90
- </Card>
90
+ </View>
91
91
 
92
92
  {/* the targets — the open invoices, each absorbing part of the payment */}
93
- <Card style={{ padding: 0 }}>
93
+ <View>
94
94
  <View style={{ paddingHorizontal: 20, paddingVertical: 12 }}>
95
95
  <Text size="xs" color="muted" transform="uppercase">{`Open invoices · ${INVOICES.length}`}</Text>
96
96
  </View>
@@ -113,7 +113,7 @@ export function TplAllocate() {
113
113
  <Text size="xs" color={over ? "danger" : "muted"} style={{ flex: 1 }}>{status}</Text>
114
114
  <Button title="Apply payment" color="primary" disabled={over || allocated === 0} onPress={() => {}} />
115
115
  </CardFooter>
116
- </Card>
116
+ </View>
117
117
  </View>
118
118
  </ScrollView>
119
119
  );
@@ -84,7 +84,7 @@ export function TplAssistant() {
84
84
  };
85
85
 
86
86
  return (
87
- <View style={{ flex: 1, backgroundColor: colors.zinc[50] }}>
87
+ <View style={{ flex: 1, backgroundColor: colors.white }}>
88
88
  <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.zinc[200], backgroundColor: colors.background }}>
89
89
  <View style={{ width: 24, height: 24, borderRadius: 7, alignItems: "center", justifyContent: "center", backgroundColor: colors.zinc[100] }}>
90
90
  <View style={{ width: 4, height: 4, borderRadius: 2, backgroundColor: colors.zinc[900] }} />
@@ -269,7 +269,7 @@ export function TplAttendance() {
269
269
  const seqIndex = openNv ? sorted.findIndex((nv) => nv.id === openNv.id) : -1;
270
270
 
271
271
  return (
272
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
272
+ <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
273
273
  <View style={{ width: "100%", maxWidth: 920, alignSelf: "center", gap: 16 }}>
274
274
  {/* header band */}
275
275
  <View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
@@ -233,7 +233,7 @@ export function TplBilling() {
233
233
  };
234
234
 
235
235
  return (
236
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
236
+ <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
237
237
  <View style={{ width: "100%", maxWidth: 640, alignSelf: "center", gap: 16 }}>
238
238
  <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
239
239
  <Text size="xl" weight="semibold">Billing</Text>
@@ -299,7 +299,7 @@ export function TplBilling() {
299
299
 
300
300
  {/* deposit band — tinted + labelled separate, never part of the total */}
301
301
  <Divider />
302
- <CardBody style={{ backgroundColor: colors.zinc[50], gap: 8 }}>
302
+ <CardBody style={{ backgroundColor: colors.white, gap: 8 }}>
303
303
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
304
304
  <Text weight="semibold">Deposit</Text>
305
305
  <Badge label="Collected separately" color="zinc" />
@@ -54,7 +54,7 @@ export function TplBriefing() {
54
54
  const attention = findings.filter((f) => f.severity === "critical" || f.severity === "warning").length;
55
55
 
56
56
  return (
57
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
57
+ <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
58
58
  <View style={{ width: "100%", maxWidth: 860, alignSelf: "center", gap: 16 }}>
59
59
  <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16 }}>
60
60
  <View style={{ flex: 1, gap: 2 }}>
@@ -204,7 +204,7 @@ export function TplCalendar() {
204
204
 
205
205
  return (
206
206
  <View style={{ flex: 1 }}>
207
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
207
+ <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
208
208
  <View style={{ width: "100%", maxWidth: 1040, alignSelf: "center", gap: 16 }}>
209
209
  {/* header band */}
210
210
  <View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>
@@ -90,7 +90,7 @@ export function TplCompare() {
90
90
  const pick = OPTIONS.find((o) => o.id === chosen) ?? null;
91
91
 
92
92
  return (
93
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
93
+ <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
94
94
  <View style={{ width: "100%", maxWidth: 720, alignSelf: "center", gap: 16 }}>
95
95
  <View style={{ gap: 2 }}>
96
96
  <Text size="xl" weight="semibold">Freight options</Text>
@@ -72,7 +72,7 @@ export function TplCrosscheck() {
72
72
  const openCount = SEED.length - resolvedCount;
73
73
 
74
74
  return (
75
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
75
+ <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
76
76
  <View style={{ width: "100%", maxWidth: 760, alignSelf: "center", gap: 16 }}>
77
77
  <View style={{ gap: 2 }}>
78
78
  <Text size="xl" weight="semibold">Document cross-check</Text>
@@ -306,7 +306,7 @@ export function TplDashboard() {
306
306
  const ky = (key ? KY_DU_LIEU[key] : undefined) ?? KY_DU_LIEU[KY_CUOI];
307
307
 
308
308
  return (
309
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
309
+ <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
310
310
  <View style={{ width: "100%", maxWidth: 960, alignSelf: "center", gap: 16 }}>
311
311
  {/* header band — the period filter lives here: presets + custom
312
312
  range in the popover (and time, via includeTime, where a
@@ -191,7 +191,7 @@ export function TplDetail() {
191
191
  const [tab, setTab] = useState<string>("tongquan");
192
192
 
193
193
  return (
194
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
194
+ <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
195
195
  <View style={{ width: "100%", maxWidth: 880, alignSelf: "center", gap: 16 }}>
196
196
  {/* breadcrumb line */}
197
197
  <Text size="xs" color="muted">Orders / SO-2026-0042</Text>
@@ -164,7 +164,7 @@ export function TplDieline() {
164
164
  };
165
165
 
166
166
  return (
167
- <View style={{ flex: 1, backgroundColor: colors.zinc[50] }}>
167
+ <View style={{ flex: 1, backgroundColor: colors.white }}>
168
168
  {/* slim top bar */}
169
169
  <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.zinc[200], backgroundColor: colors.background }}>
170
170
  <Text size="sm" weight="semibold" style={{ flex: 1 }}>Carton design</Text>
@@ -341,7 +341,7 @@ function EmptyFrame() {
341
341
 
342
342
  function BuildingFrame() {
343
343
  return (
344
- <View style={[frame.box, { backgroundColor: colors.zinc[50] }]}>
344
+ <View style={[frame.box, { backgroundColor: colors.white }]}>
345
345
  <DotsIndicator size={9} color={colors.zinc[900]} />
346
346
  <Text size="sm" color="muted">Designing your dieline…</Text>
347
347
  </View>
@@ -170,7 +170,7 @@ export function TplDirectory() {
170
170
  const seqIndex = openPartner ? matches.findIndex((p) => p.id === openPartner.id) : -1;
171
171
 
172
172
  return (
173
- <View style={{ flex: 1, backgroundColor: colors.zinc[50] }}>
173
+ <View style={{ flex: 1, backgroundColor: colors.white }}>
174
174
  <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 28 }}>
175
175
  <View style={{ width: "100%", maxWidth: 880, alignSelf: "center", gap: 16 }}>
176
176
  {/* header band */}
@@ -225,7 +225,7 @@ export function TplDirectory() {
225
225
  </FilterChip>
226
226
  </View>
227
227
 
228
- {/* the register: one card, hairline-separated rows */}
228
+ {/* the register: one card, gap-separated rounded `register` rows (no per-row Divider) */}
229
229
  <Card style={{ padding: 0 }}>
230
230
  {matches.length === 0 ? (
231
231
  <EmptyState
@@ -233,12 +233,9 @@ export function TplDirectory() {
233
233
  hint="Try another keyword or switch the type filter"
234
234
  />
235
235
  ) : (
236
- <View style={{ paddingVertical: 6 }}>
237
- {matches.map((p, i) => (
238
- <View key={p.id}>
239
- {i > 0 ? <Divider /> : null}
240
- <PartnerRow p={p} selected={p.id === openId} onPress={() => setOpenId(p.id)} />
241
- </View>
236
+ <View style={{ paddingVertical: 6, gap: 2 }}>
237
+ {matches.map((p) => (
238
+ <PartnerRow key={p.id} p={p} selected={p.id === openId} onPress={() => setOpenId(p.id)} />
242
239
  ))}
243
240
  </View>
244
241
  )}
@@ -73,7 +73,7 @@ export function TplDraft() {
73
73
  const streamed = phase === "drafting" ? target.split(" ").slice(0, revealed).join(" ") : "";
74
74
 
75
75
  return (
76
- <ScrollView style={{ backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28, paddingBottom: 80 }}>
76
+ <ScrollView style={{ backgroundColor: colors.white }} contentContainerStyle={{ padding: 28, paddingBottom: 80 }}>
77
77
  <View style={{ maxWidth: 720, width: "100%", alignSelf: "center", gap: 16 }}>
78
78
  <View style={{ gap: 2 }}>
79
79
  <Text size="xl" weight="semibold">Reply to inquiry</Text>
@@ -134,7 +134,7 @@ export function TplDraft() {
134
134
  ) : null}
135
135
 
136
136
  {phase === "drafting" ? (
137
- <View style={{ borderWidth: 1, borderColor: colors.zinc[200], borderRadius: 10, padding: 14, gap: 10, backgroundColor: colors.zinc[50] }}>
137
+ <View style={{ borderWidth: 1, borderColor: colors.zinc[200], borderRadius: 10, padding: 14, gap: 10, backgroundColor: colors.white }}>
138
138
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
139
139
  <Text size="xs" color="muted" weight="medium" style={{ flex: 1 }}>Drafting</Text>
140
140
  <DotsIndicator size={5} color={colors.zinc[900]} />
@@ -113,7 +113,7 @@ export function TplExtract() {
113
113
  const confirmedCount = RECORDS.filter((r) => statuses[r.id] === "confirmed").length;
114
114
 
115
115
  return (
116
- <ScrollView style={{ backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28, paddingBottom: 120 }}>
116
+ <ScrollView style={{ backgroundColor: colors.white }} contentContainerStyle={{ padding: 28, paddingBottom: 120 }}>
117
117
  <View style={{ maxWidth: 720, width: "100%", alignSelf: "center", gap: 16 }}>
118
118
  <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16 }}>
119
119
  <View style={{ flex: 1, gap: 2 }}>
@@ -70,7 +70,7 @@ export function TplIntake() {
70
70
  : undefined;
71
71
 
72
72
  return (
73
- <ScrollView style={{ flex: 1, backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28 }}>
73
+ <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
74
74
  <View style={{ width: "100%", maxWidth: 880, alignSelf: "center", gap: 16 }}>
75
75
  {/* header band */}
76
76
  <View style={{ flexDirection: "row", alignItems: "flex-end", gap: 12 }}>