@lotics/ui 18.1.0 → 19.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/MIGRATION.md CHANGED
@@ -4,6 +4,71 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## v19 from 18.x
8
+
9
+ **`TaskFields` is DELETED, with `fieldsWidth`.** A task row was carrying a
10
+ value COLUMN — a width declared on the list and reserved by every row. It aligned, and it read
11
+ badly: a two-word field name sat at the title's origin while its control started a quarter of
12
+ the surface away, and nothing could close the gap because the column was sized for the widest
13
+ value on the page. A task row now carries its TITLE; the fields a user can SET hang under it as
14
+ `TaskSubRow`s, name beside value.
15
+
16
+ ```tsx
17
+ // BEFORE
18
+ <TaskList fieldsWidth={320}>
19
+ <TaskItem>
20
+ <TaskStatus><CheckCircle …/></TaskStatus>
21
+ <TaskTitle>File it on the portal</TaskTitle>
22
+ <TaskFields>
23
+ <View style={{ flexBasis: 208 }}><InlineSelect variant="cell" …/></View>
24
+ <View style={{ width: 104 }}><InlineDatePicker variant="cell" …/></View>
25
+ </TaskFields>
26
+ <TaskActions><ActionMenu …/></TaskActions>
27
+ </TaskItem>
28
+ </TaskList>
29
+
30
+ // AFTER
31
+ <TaskList>
32
+ <TaskItem>
33
+ <TaskStatus><CheckCircle …/></TaskStatus>
34
+ <TaskTitle>File it on the portal</TaskTitle>
35
+ <TaskActions><ActionMenu …/></TaskActions>
36
+ <TaskSubRow label="Hard copy"><InlineSelect …/></TaskSubRow>
37
+ <TaskSubRow label="Completed on"><InlineDatePicker …/></TaskSubRow>
38
+ </TaskItem>
39
+ </TaskList>
40
+ ```
41
+
42
+ Porting a row, cell by cell:
43
+
44
+ - **Every cell becomes one `TaskSubRow`, and it needs a NAME.** The column header the cells
45
+ never had is now the label — write what the value IS ("Due", "Assignee", "Completed on"), not
46
+ what it does.
47
+ - **Drop the wrapper `View`s and the widths.** A sub-row's label column sizes itself to that
48
+ ONE task's labels and its value takes the slack up to a field's width; nothing is passed in.
49
+ - **Drop `variant="cell"`.** A cell variant is for a grid; a sub-row's value is a form-variant
50
+ `Inline*` editor (the default) — the zinc-50 chip that says "editable".
51
+ - **A row that needed SCANNABLE columns — the same four values compared down twenty rows — was
52
+ never a task list.** Move it to `Table`/`DataGrid`.
53
+
54
+ `TaskCaption` (prose about the row) and `TaskDetail` (a free-form block: a chart, a table, a
55
+ form with its own submit) are unchanged.
56
+
57
+ **`TaskList` gains `actionWidth` — the ⋯ is a COLUMN, the mirror of the control gutter.** Both
58
+ pinned controls sit outside the row's flow and the ROW reserves each edge (`controlWidth` on the
59
+ left, `actionWidth` on the right, each plus the row gap); what is left between them is ONE content
60
+ box every line spans exactly — title, caption, sub-row, detail, nested list. Nothing to pass in
61
+ the normal case: it defaults to 28, an `ActionMenu`'s own width, so every task surface reserves
62
+ the same column for free. **Pass `actionWidth={0}` on a list where NO row carries actions**, so it
63
+ does not pay for a column it never uses — that is the only call site this adds.
64
+
65
+ It is not decoration. With the fields hung under the row, a self-sizing menu in the row's FLOW
66
+ made the first line stop where the menu began while every full-width line beneath it ran on to
67
+ the container — and a row without a menu ended somewhere else again, so no two right edges on the
68
+ surface agreed. `TaskTitle` therefore no longer claims a minimum width (nothing shares its line to
69
+ wrap against), and `TaskDetail` now ends on the content edge instead of overhanging it by its own
70
+ inset.
71
+
7
72
  ## v18 from 17.x
8
73
 
9
74
  **`TimePicker` is a segmented field, not `<input type="time">`.** A native time input takes
package/docs/catalog.md CHANGED
@@ -106,10 +106,13 @@ summary is `Ledger`.
106
106
 
107
107
  A task-management PAGE (many tasks, grouping, filters, expandable rows) is COMPOSITION —
108
108
  the `Task` compound owns the row — `TaskStatus` (a `CheckCircle`), `TaskTitle` (a struck
109
- `InlineTextInput`), `TaskFields` (your `InlineDatePicker`/`InlineMemberSelect`/`InlineSelect`
110
- cells, all `variant="cell"`), `TaskActions`. The SAME composition serves a record's 5–8-task
111
- drawer checklist and a grouped desk board, because `TaskFields` reflows instead of being
112
- authored per surface; add `SuggestionChip` commons + `CaptureRow` for the checklist case. See
109
+ `InlineTextInput`), `TaskActions`. **The row carries the TITLE; the fields a user can SET hang
110
+ under it as `TaskSubRow`s** (`InlineDatePicker`/`InlineMemberSelect`/`InlineSelect`, each with
111
+ its NAME beside it), never a `DetailTable` in a `TaskDetail`, which declares a grid of its own.
112
+ The SAME composition serves a record's 5–8-task drawer checklist and a grouped desk board,
113
+ because a sub-row reflows instead of being authored per surface; add `SuggestionChip` commons +
114
+ `CaptureRow` for the checklist case. **A list you scan DOWN columns — the same four values
115
+ compared across twenty rows — is a `Table`, not a `TaskList`.** See
113
116
  [`tpl_record`](../examples/tpl_record.tsx) / [`tpl_item_list`](../examples/tpl_item_list.tsx),
114
117
  and [`tpl_task_board`](../examples/tpl_task_board.tsx) for columns.
115
118
 
@@ -487,7 +490,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
487
490
  they are exclusive at the type level**: WITH it (+ the required `doorLabel`) the whole box is a
488
491
  keyboard door into the record's detail and `facts` values are TEXT (`""` → "—"); WITHOUT it the
489
492
  box is a STATIC card — no door, no tab stop, no pointer, nothing announced as a button — and a
490
- `facts` value may be a NODE (an inline editor; a node prints as authored, no "—"). Reach for the
493
+ `facts` value may be a NODE (an inline editor; a node prints as authored, no "—"). A node fact
494
+ gets the CONTROL BAND on both cells — its label centres on the control instead of printing at
495
+ the band's top — while a string fact keeps its bare text line, so a long value that wraps still
496
+ tops out level with its label. Reach for the
491
497
  static one when the linked record has NO page of its own: nothing to open, so the box is where
492
498
  its values are read and edited, and the only interactive parts are `actions` + the fact nodes.
493
499
  Under a door, `actions` is the ONLY place a control may live (a fact editor there would compete
@@ -635,8 +641,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
635
641
 
636
642
  - **`inline_edit`** — `useInlineEdit` + `InlineEditView` / `InlineEditFrame` +
637
643
  `useInlineEditFocusRestore` (returns focus to the RESTING control when a KEYBOARD close drops
638
- it to `<body>` — what an editor with its own resting shape, e.g. the checklist `note` line,
639
- reaches for instead of the frame) + `INLINE_CONTROL_HEIGHT` (40) +
644
+ it to `<body>` — what a CUSTOM editor that renders its own resting control reaches for instead
645
+ of the frame, which already calls it) + `INLINE_CONTROL_HEIGHT` (40) +
640
646
  `inlineValueTextStyle`: the engine custom inline editors
641
647
  join through — a view ⇄ edit toggle, a draft buffer, async `onSave` with the spinner
642
648
  INSIDE the control and inline error, commit on blur (Enter saves, Escape reverts) or
@@ -703,7 +709,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
703
709
  on a flat-text value row (`InlineStatic`, plain `Text`) tucks the annotation up by the
704
710
  control band's slack so its gap matches a chip row's.
705
711
  `DetailTable`: the compound parent of
706
- a row STACK — `labelWidth` (default 130) / `trailingWidth` / `minHeight` (default 40, the
712
+ a row STACK — `labelWidth` (default `DETAIL_LABEL_WIDTH`, 130 the kit's label column, also
713
+ the floor a `TaskSubRow`'s label sizes from) / `trailingWidth` / `minHeight` (default 40, the
707
714
  inline-control grid) declared ONCE + the 8px row gap; with `trailingWidth` every row
708
715
  reserves the trailing column so value cells share one width and trailing items align at
709
716
  one x, like a table. RESPONSIVE with no prop: it measures its own container (onLayout, not
@@ -781,28 +788,73 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
781
788
  (`onChange(done)`) so the ring means one thing everywhere; `partial` is reached through
782
789
  whatever NAMES it — a status cell, or children ticking off — never by cycling the ring. Keep
783
790
  it monochrome and let colour live in the status cell.
784
- - **`task`** — `TaskList` + `TaskItem` + `TaskStatus` / `TaskTitle` / `TaskFields` /
785
- `TaskActions` / `TaskDetail` — the task COMPOUND, for anything from a 5-item drawer
786
- checklist to a grouped desk board. `TaskList` owns geometry only (the `controlWidth`
787
- column every title aligns on, the indent, and `density`: `comfortable` = a 44px minimum
788
- tap target, `dense` = 32 for a pointer-driven register). Everything else is composed, and
789
- JSX order is screen order nothing inspects child types.
791
+ - **`task`** — `TaskList` + `TaskItem` + `TaskStatus` / `TaskTitle` / `TaskCaption` /
792
+ `TaskActions` / `TaskSubRow` / `TaskDetail` — the task COMPOUND, for anything
793
+ from a 5-item drawer checklist to a grouped desk board. `TaskList` owns geometry only: the two
794
+ GUTTERS — `controlWidth` (the left one, that every title aligns on, and so the INDENT step) and
795
+ `actionWidth` (the right one, that every pins into; `0` on a list whose rows carry no
796
+ actions) plus `density` (`comfortable` = a 44px minimum tap target, `dense` = 32 for a
797
+ pointer-driven register). Everything else is composed, and JSX order is screen order — nothing
798
+ inspects child types.
790
799
 
791
800
  `TaskStatus` takes the `CheckCircle` (omit `onChange` for a read-only ring; a PICKER list
792
- puts a `CheckboxInput` here and sets `controlWidth={24}`). `TaskTitle` takes the struck
793
- transparent `InlineTextInput` or plain `Text`, and claims a readable minimum width — which
794
- is what makes the rest reflow. `TaskFields` is the descriptor cluster (due, assignee,
795
- chips): it sits inline after the title while the row has room and takes its own line when
796
- it does not. That is intrinsic, so the SAME JSX is right on a record page and in a drawer —
797
- never author two variants, and never reach for a viewport query. `TaskActions` carries the
798
- row's `ActionMenu`; Delete lives BEHIND it, danger-styled and last, never a bare ✕.
799
- `TaskDetail` is a block under the row on the title's text edge an inline fill editor, a
800
- drill-down rendered only while open.
801
-
802
- **Subtasks are a nested `TaskList`**, so a step IS a task: give one a due date, a menu or
801
+ puts a `CheckboxInput` here and sets `controlWidth={24}`). `TaskTitle` takes the
802
+ `variant="cell"` `InlineTextInput` or the title TEXT itself as a string child
803
+ (`<TaskTitle struck={done}>{label}</TaskTitle>`), which is the READ-ONLY form: the compound
804
+ applies the cell inset and the row's band, so a plain `<Text>` + a hand-rolled
805
+ `TASK_TEXT_INSET` is never needed and a long title that wraps keeps its first line beside the
806
+ control. It spans the content box between the gutters, like every line hung beneath it.
807
+ `TaskCaption` is the row's state IN WORDS on its own line under the title a
808
+ sentence, never a field. `TaskActions` carries the row's `ActionMenu`, pinned into the RIGHT
809
+ gutter on the first line (Delete lives BEHIND it, danger-styled and last, never a bare ✕).
810
+ `TaskDetail` is a FREE-FORM block under the row on the title's text edge — a chart, a table, a
811
+ form with its own submit rendered only while open. The module also exports
812
+ **`TASK_TEXT_INSET`** (9), the inset a `variant="cell"` control puts on its own text: every
813
+ slot above already applies it, so reach for it ONLY when a custom title NODE (not a cell
814
+ control) has to land on the same text edge.
815
+
816
+ **THE ⋯ IS A COLUMN, THE MIRROR OF THE CONTROL GUTTER.** Both pinned controls sit outside the
817
+ flow and the ROW reserves each edge (`actionWidth` + the row gap), so what is left between them
818
+ is ONE content box every line spans exactly — title, caption, sub-row, detail, nested list. That
819
+ is what keeps a list where only SOME rows carry a menu straight: a menu-less row's content ends
820
+ on the same x as a menu-carrying one's, and so does everything hanging under either. A nested
821
+ list hands the parent's reservation back before its own rows re-take it, so the column does not
822
+ step inward per level. Declare `actionWidth={0}` when NO row in the list carries actions.
823
+
824
+ **THE ROW CARRIES THE TITLE; THE FIELDS THE USER CAN SET ARE SUB-ROWS BENEATH IT.** One task's
825
+ own fields hang under it as **`TaskSubRow`** (`label` · the control · an optional muted
826
+ `description`), indented ONE step — the same step a nested `TaskList` takes, because belonging
827
+ is expressed by indentation and there is only one device for it. Label and value sit ADJACENT
828
+ so the eye pairs them, and the sub-rows of ONE task share a label column sized to THEIR OWN
829
+ labels: nothing is declared on the list and no width is passed in, so a name is never sized for
830
+ a field three tasks further down. The value takes the slack between a readable minimum and a
831
+ field's maximum — on a phone or in a narrow drawer it drops onto its own line under the label
832
+ rather than ellipsizing beside it. Pass a form-variant `Inline*` editor (the default), not
833
+ `variant="cell"`: a cell variant belongs to a grid.
834
+
835
+ **ANYTHING THAT NEEDS SCANNABLE COLUMNS IS A `Table`, NOT A `TaskList`.** The compound spent a
836
+ version carrying a declared value column so cells would line up down the list; it aligned and
837
+ it read worse — each field's name ended up a quarter of the surface from its control, sized for
838
+ the widest value on the page. If the job is comparing the same four values across twenty rows,
839
+ that is a table, and `Table`/`DataGrid` are built for it. A `DetailTable labelWidth={…}` inside
840
+ a `TaskDetail` is the other shape to avoid: it declares a grid whose label and control land at
841
+ two x positions matching nothing above them — a form pasted into a list.
842
+
843
+ **A row's FIRST LINE is a BAND** — 44px `comfortable`, 32 `dense`. Both gutter controls centre
844
+ in it and `TaskTitle` claims it, which is what keeps both columns of
845
+ controls straight as rows grow: a caption, a sub-row, a detail block, a nested list or a
846
+ wrapped title all hang BELOW the band instead of dragging the control down into the gap under
847
+ the title. So every sub-line sits the band's slack (12px comfortable) beneath the title's
848
+ words, the same whether the title is text or an editor. A title node TALLER than the band
849
+ overrides it — an inline editor is 40px, so a `dense` list of editable titles renders 40px rows
850
+ and its ring reads a few px high; `dense` is for text/static rows. A sub-row takes the TALLER
851
+ of the band and the 40px inline-control height, so its label's first line still meets its
852
+ control's centre in a `dense` list.
853
+
854
+ **Subtasks are a nested `TaskList`**, so a step IS a task: give one a field, a menu or
803
855
  children of its own and it works. Collapsing belongs to the app — hold a boolean and render
804
856
  the nested list or don't. There is deliberately **no note slot**: a task's free text is its
805
- title, a descriptor, or detail, and a fourth place to write invited writing it twice.
857
+ title, a caption, or detail, and a fourth place to write invited writing it twice.
806
858
 
807
859
  - **`suggestion_chip`** — `SuggestionChip`: the dismissible SUGGESTION pill — an item the
808
860
  record could have but doesn't yet (a common task, an expected line) as a `Chip` whose
@@ -258,22 +258,58 @@ changing desks — never an inbox, a notification, or a copied task. Two types:
258
258
  handoff is MANAGED AS TASKS — each desk's checklist on the record. The **`Task`** compound owns
259
259
  the list geometry (the control column, the indent, and `density` — 44px targets by default) —
260
260
  compose it, never hand-roll the row. A **`TaskItem`** carries a `TaskStatus` (a `CheckCircle` — a
261
- read-only ring when it has no `onChange`), a `TaskTitle` (a struck transparent `InlineTextInput`,
262
- or plain `Text`), a `TaskFields` cluster (the per-task assignee `InlineMemberSelect`, the due
263
- date) and a `TaskActions` menu never a decorative progress strip. `TaskFields` sits inline
264
- while the row has room and takes its own line when it does not, so the drawer and the record page
265
- run the SAME composition. `CaptureRow` closes the list as its add-affordance.
261
+ read-only ring when it has no `onChange`), a `TaskTitle` (a `variant="cell"` `InlineTextInput`,
262
+ or the title TEXT as a string child `<TaskTitle struck={done}>{label}</TaskTitle>` is the
263
+ read-only form, and the compound owns its inset and band, so never hand-roll a `Text` title) and
264
+ a `TaskActions` menu never a decorative progress strip. The fields the desk SETS (the
265
+ assignee `InlineMemberSelect`, the due date) hang UNDER the row as `TaskSubRow`s, which reflow on
266
+ their own, so the drawer and the record page run the SAME composition. `CaptureRow` closes the
267
+ list as its add-affordance.
268
+
269
+ **A row's first line is a BAND** (44px comfortable, 32 dense): both gutter controls centre in it,
270
+ `TaskTitle` claims it, and everything else — caption, detail, nested list, a title's wrapped
271
+ second line — hangs below it. That is what keeps a column of controls straight down a list of
272
+ mixed-height rows, so a control never drifts into the gap under the title it belongs to.
266
273
 
267
274
  A task's own free text is its TITLE, a **`TaskCaption`** or a **`TaskDetail`** — there is no
268
275
  fourth place to write, because a fourth place gets written in twice. `TaskCaption` is the row's
269
276
  state IN WORDS ("3 of 5 papers received", "waiting on the yard") on its own line under the title;
270
- it is not a `TaskFields` cell, because a field is a VALUE the reader scans down a column and a
271
- caption is a SENTENCE about one row — merging them pushes prose into the value column, where it
272
- aligns with nothing.
273
-
274
- **Subtasks are TASKS**nest a `TaskList` inside the `TaskItem`, and the child carries a due
275
- date, a menu and children of its own. There is no separate subtask shape to outgrow, and no
276
- built-in chevron: collapsing is the app's call (hold a boolean, render the nested list or don't).
277
+ it is not a field, because a field is a NAMED VALUE the reader sets and a caption is a SENTENCE
278
+ about one row — labelling prose makes it read as a field nobody can edit.
279
+
280
+ **THE ROW CARRIES THE TITLE; THE FIELDS THE USER CAN SET ARE SUB-ROWS BENEATH IT.** A field
281
+ belonging to ONE task its due date, its assignee, a portal login, a reference number — hangs
282
+ under it as a **`TaskSubRow`** (`label` · the control · an optional muted `description`), indented
283
+ ONE step, with the NAME beside its VALUE so the eye pairs them. Belonging is expressed by that
284
+ indentation, which is the same step a nested `TaskList` takes — one device, at every depth. The
285
+ sub-rows of ONE task share a label column sized to THEIR OWN labels: nothing is declared on the
286
+ list, no width is passed in, and no field is sized for a name three tasks further down. The value
287
+ takes the slack between a readable minimum and a field's maximum, dropping onto its own line under
288
+ the label on a surface too narrow to seat both. Use the form-variant `Inline*` editor (the
289
+ default) — `variant="cell"` belongs to a grid.
290
+
291
+ **Anything that needs SCANNABLE COLUMNS is a `Table`, not a `TaskList`.** The compound once
292
+ carried a declared value column so cells lined up down the list; it aligned, and it read worse —
293
+ each name ended up a quarter of the surface from its control, sized for the widest value on the
294
+ page. Comparing the same four values across twenty rows is a table's job. A `DetailTable` inside a
295
+ `TaskDetail` is the same mistake in miniature: a grid whose label and control land at two x
296
+ positions matching nothing above them, a form pasted into a list. `TaskDetail` keeps the FREE-FORM
297
+ block (a chart, a table, a form with its own submit) — it has no name to hang on the indent, so a
298
+ rule down its left edge is what ties it to the row.
299
+
300
+ **The ⋯ is a COLUMN — the mirror of the control gutter.** Both pinned controls sit outside the
301
+ row's flow and the ROW reserves each edge (`controlWidth` on the left, `actionWidth` on the
302
+ right, each plus the row gap), leaving ONE content box between them that every line spans exactly:
303
+ the title, a caption, a sub-row, a detail block, a nested list. That is what keeps a list where
304
+ only SOME rows carry a menu straight — a menu-less row's content ends on the same x as a
305
+ menu-carrying one's, and so does everything hanging beneath either. A nested list hands the
306
+ parent's reservation back before its own rows re-take it, so the column does not step inward per
307
+ level. Declare `actionWidth={0}` when NO row in the list carries actions, so it pays nothing for a
308
+ column it never uses.
309
+
310
+ **Subtasks are TASKS** — nest a `TaskList` inside the `TaskItem`, and the child carries fields,
311
+ a menu and children of its own. There is no separate subtask shape to outgrow, and no built-in
312
+ chevron: collapsing is the app's call (hold a boolean, render the nested list or don't).
277
313
 
278
314
  **State flows from the LEAVES.** Where a row's position is computable from what it owns, DERIVE
279
315
  it — a stored status is a second copy of what the fields already say, and two copies drift. Give
@@ -281,13 +317,11 @@ the parent's ring the bulk gesture where its children ARE the fact (tick it, eve
281
317
  where the ring answers a DIFFERENT field the children do not determine — a date, say — coupling
282
318
  them asserts something the operator never said.
283
319
 
284
- **`TaskFields` holds CONTROLS.** Where exactly one field determines a row's status, that cell IS
285
- the field's editor: a read-only copy above an editable control puts one value on the row twice,
286
- under two labels. Everything else derived is prose in `TaskCaption`. A read-only pill parked in
287
- the column sits `TASK_TEXT_INSET` left of its neighbours (a `variant="cell"` control insets its
288
- text by that much; a bare `Badge` by nothing) and reads as pressable when it is not. A `Badge`
289
- INSIDE a cell control is fine — it inherits that cell's inset, and the 12px its label sits in by
290
- is the dot and its gap, not padding.
320
+ **A sub-row holds a CONTROL.** Where exactly one field determines a row's status, the sub-row IS
321
+ that field's editor: a read-only copy of it elsewhere on the row puts one value on screen twice,
322
+ under two names. Everything else derived is prose in `TaskCaption` a read-only pill given a
323
+ field's label reads as pressable when it is not. A `Badge` INSIDE the editor (an
324
+ `InlineSelect renderSelected`) is fine: it inherits the control's own inset.
291
325
 
292
326
  **A status reports what is OUTSTANDING, never what is settled.** A finished row already says so
293
327
  twice — a full ring and its date — so naming the settled condition is a third copy of one fact.
@@ -319,8 +353,8 @@ the record left this register.
319
353
 
320
354
  Tasks PEEK from the register: the done/total column is a pressable compact-`ProgressBar` trigger
321
355
  whose popover holds the same `Task` list — every `TaskItem`'s ring `TaskStatus` · struck
322
- `InlineTextInput` `TaskTitle` (flex) · quick-reassign `InlineMemberSelect` in `TaskFields`, which
323
- takes its own line at popover width — no expandable rows (tags/files depth is
356
+ `InlineTextInput` `TaskTitle` (flex) · a quick-reassign `InlineMemberSelect` in a `TaskSubRow`
357
+ under it — no expandable rows (tags/files depth is
324
358
  the Task list template's lesson, not the peek's); the popover body is `PopoverContent`'s own
325
359
  ScrollView (`disableBodyScroll` is ONLY for children that manage their own scroll, like
326
360
  `OptionList`). The DRAWER carries a real Tasks SECTION in the Record template's shape (heading +
package/docs/templates.md CHANGED
@@ -286,18 +286,17 @@ billing, and quick-capture templates. Top → bottom:
286
286
  - **Tasks** — the full task-list grammar: clearable Group-by `FilterChip` (+ assignee/status
287
287
  chips), the `CaptureRow` on top, `TaskList`/`TaskItem` rows and per-row `TaskActions` menus,
288
288
  suggestions as `SuggestionChip`s; groups divide via the `SubsectionStack` beat with plain
289
- text heads (no dot badges). Each row's `TaskFields` carries **due + assignee as COMPACT grid CELLS** —
290
- the SAME `InlineDatePicker` / `InlineMemberSelect` used in the General section, in **`variant="cell"`**
291
- (transparent rest + wash on hover, no glyph/chevron): the due a urgency-coloured date (`tone`
292
- = `dueTone` — red past due, amber ≤3d, else muted), the assignee an **`avatarOnly`** bare avatar
293
- (dashed add-ghost when unset), sized to the list's `trailingWidth` so the title never overlaps.
294
- (Contrast the General section's dates/assignee, the same editors in the default `variant="form"`
295
- a zinc-50 chip + hover-border + the full member chip.) Two rows carry a **`note`** — the kit's
296
- per-row muted second line (press to edit, save empty to remove); the rest show its add button.
297
- ONE row ("Book the carrier") carries **`subtasks`** four child ticks behind the kit's chevron +
298
- remaining-count expander, open because work is left; every other row passes an EMPTY list, which
299
- renders nothing at all. It is the line to copy: a booking's steps carry only a tick, so they stay
300
- child rows — anything needing its own note/due/assignee would be a task instead.
289
+ text heads (no dot badges). Each row's **due + assignee are `TaskSubRow`s UNDER the title** —
290
+ the SAME `InlineDatePicker` / `InlineMemberSelect` used in the General section, in the default
291
+ form variant, each with its NAME beside it one indent step in: the due an urgency-coloured date
292
+ (`tone` = `dueTone` — red past due, amber ≤3d, else muted), the assignee the full member chip.
293
+ A task row is not a table row — a value column reserved on the list put each name a quarter of
294
+ the record from its own control. ONE row ("Book the carrier") carries **SUBTASKS** — a nested
295
+ `TaskList` of four child ticks, each a `TaskItem` with a STRING `TaskTitle`; every other row
296
+ holds an empty array and renders no list at all. There is no built-in expander: collapsing is
297
+ the app's call, and this template always shows them. It is the line to copy — a booking's steps
298
+ carry only a tick, so they stay child rows; anything needing its own due date or assignee is a
299
+ task instead.
301
300
  - **Documents** — the Agents "Document desk" pattern (this template is its worked example —
302
301
  see the Agents chapter below): the register `Table` (search · Add files) whose selection
303
302
  feeds the `FloatingActionBar` → ONE "Use AI" fork (extract / cross-check / edit-with-AI).
@@ -354,12 +353,13 @@ billing, and quick-capture templates. Top → bottom:
354
353
  arrived. **READINESS**: a form declares the RECORD FIELDS it reads (`needs`); the fields'
355
354
  HOME stays their DATA section (the colocation law) with descriptions naming their
356
355
  consumers ("printed on the delivery note"). The picker rows ride `TaskList`/`TaskItem`
357
- (`controlWidth={24}` for the `CheckboxInput` in `TaskStatus`) the compound owns the row
358
- geometry and the indent; hand-rolling the anatomy is how alignment drifts. Warning and fix are CO-LOCATED on the row. The mark NAMES what's missing — a
359
- **`TaskCaption`**, not a field cell: 13px `circle-alert` + "Needs: …" the actual field labels,
356
+ (`controlWidth={24}` for the `CheckboxInput` in `TaskStatus`, and `actionWidth={0}` because no
357
+ row here carries a ⋯, so the list pays nothing for the action gutter) the compound owns the
358
+ row geometry and both gutters; hand-rolling the anatomy is how alignment drifts. Warning and fix are CO-LOCATED on the row. The mark NAMES what's missing — a
359
+ **`TaskCaption`**, not a field: 13px `circle-alert` + "Needs: …" the actual field labels,
360
360
  muted at rest so gaps show WITHOUT checking, warning once checked because it now blocks. It is
361
- a SENTENCE about the row, and `TaskFields` is the column the reader edits and scans down, so
362
- prose put there lines up with nothing and reads as a control it is not. The FIX sits
361
+ a SENTENCE about the row, and a `TaskSubRow` is a NAMED value the reader sets, so prose given a
362
+ label reads as a field nobody can edit. The FIX sits
363
363
  right under it in the row's `TaskDetail` — an `Inset` (a FORM surface, NEVER a `Callout`:
364
364
  a warning Callout is an ARIA `alert`, wrong around a form) with a `FormTextInput` per gap
365
365
  (label + the consumer hint as `description`) and a "Save fields" (secondary — the section
@@ -21,7 +21,7 @@ import { IconButton } from "@lotics/ui/icon_button";
21
21
  import { ListItem } from "@lotics/ui/list_item";
22
22
  import { PressableHighlight } from "@lotics/ui/pressable_highlight";
23
23
  import { CaptureRow } from "@lotics/ui/capture_row";
24
- import { TaskActions, TaskFields, TaskItem, TaskList, TaskStatus, TaskTitle } from "@lotics/ui/task";
24
+ import { TaskActions, TaskItem, TaskList, TaskStatus, TaskSubRow, TaskTitle } from "@lotics/ui/task";
25
25
  import { SuggestionChip } from "@lotics/ui/suggestion_chip";
26
26
  import { Screen, ScreenRouter, useScreenRouter } from "@lotics/ui/screen_router";
27
27
  import { SummaryLine } from "@lotics/ui/summary_line";
@@ -204,9 +204,10 @@ function TasksChecklist({ tasks, onChange, suggestions, onDismissSuggestion }: {
204
204
  const pending = suggestions.filter((l) => !tasks.some((t) => t.label === l));
205
205
  return (
206
206
  <TaskList>
207
- {/* ONE anatomy for every surface. The assignee and due editors sit inline
208
- after the title when the row has room, and take their own line when it
209
- does not a drawer, a peek, a phone. The template no longer picks. */}
207
+ {/* ONE anatomy for every surface. The row is the title; the assignee and
208
+ the due date hang under it as named fields, and the pair splits onto
209
+ two lines only when the surface is too narrow to seat both a drawer,
210
+ a peek, a phone. The template no longer picks. */}
210
211
  {tasks.map((t) => (
211
212
  <TaskItem key={t.id}>
212
213
  <TaskStatus>
@@ -221,33 +222,32 @@ function TasksChecklist({ tasks, onChange, suggestions, onDismissSuggestion }: {
221
222
  accessibilityLabel="Task title"
222
223
  />
223
224
  </TaskTitle>
224
- <TaskFields>
225
- <View style={{ width: 150 }}>
226
- <InlineMemberSelect
227
- variant="cell"
228
- members={MEMBERS}
229
- value={t.assignee}
230
- onSave={(m) => patch(t.id, { assignee: m })}
231
- placeholder="Assign…"
232
- accessibilityLabel={`Assignee · ${t.label}`}
233
- />
234
- </View>
235
- <View style={{ width: 130 }}>
236
- <InlineDatePicker
237
- variant="cell"
238
- value={t.due}
239
- onSave={(v) => patch(t.id, { due: v })}
240
- placeholder="Set due…"
241
- accessibilityLabel={`Due · ${t.label}`}
242
- />
243
- </View>
244
- </TaskFields>
245
225
  <TaskActions>
246
226
  <ActionMenu
247
227
  items={[{ key: "xoa", label: "Delete task", icon: "trash", danger: true, onPress: () => onChange(tasks.filter((x) => x.id !== t.id)) }]}
248
228
  accessibilityLabel={`Task options: ${t.label}`}
249
229
  />
250
230
  </TaskActions>
231
+ {/* the task's own FIELDS hang UNDER it, name beside value, one indent
232
+ step in — never cells in a shared column, which put each name a
233
+ quarter of the surface from the control it names */}
234
+ <TaskSubRow label="Assignee">
235
+ <InlineMemberSelect
236
+ members={MEMBERS}
237
+ value={t.assignee}
238
+ onSave={(m) => patch(t.id, { assignee: m })}
239
+ placeholder="Assign…"
240
+ accessibilityLabel={`Assignee · ${t.label}`}
241
+ />
242
+ </TaskSubRow>
243
+ <TaskSubRow label="Due">
244
+ <InlineDatePicker
245
+ value={t.due}
246
+ onSave={(v) => patch(t.id, { due: v })}
247
+ placeholder="Set due…"
248
+ accessibilityLabel={`Due · ${t.label}`}
249
+ />
250
+ </TaskSubRow>
251
251
  </TaskItem>
252
252
  ))}
253
253
  {/* SUGGESTIONS are pills, not rows — a chip can't be mistaken for a
@@ -652,7 +652,7 @@ function HoSoWorkspace({ hs, daThu, onPaid, tasks, onTasksChange, suggestions, o
652
652
  </Section>
653
653
  <Divider />
654
654
  {/* TASKS — the Record template's section shape: heading + the compact
655
- meter, the shared columnar checklist below (capture at the end);
655
+ meter, the shared checklist below (capture at the end);
656
656
  the same state feeds the register column + its peek. */}
657
657
  <Section>
658
658
  <SectionHeading>
@@ -32,7 +32,7 @@ import { CheckCircle } from "@lotics/ui/check_circle";
32
32
  import { ProgressBar } from "@lotics/ui/progress_bar";
33
33
  import { FilterChip } from "@lotics/ui/filter_chip";
34
34
  import { CaptureRow } from "@lotics/ui/capture_row";
35
- import { TaskActions, TaskCaption, TaskDetail, TaskFields, TaskItem, TaskList, TaskStatus, TaskTitle, TASK_TEXT_INSET } from "@lotics/ui/task";
35
+ import { TaskActions, TaskCaption, TaskDetail, TaskItem, TaskList, TaskStatus, TaskSubRow, TaskTitle } from "@lotics/ui/task";
36
36
  import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
37
37
  import { SuggestionChip } from "@lotics/ui/suggestion_chip";
38
38
  import { OptionList } from "@lotics/ui/option_list";
@@ -162,8 +162,8 @@ interface StageTask {
162
162
  stage: Desk;
163
163
  done: boolean;
164
164
  assignee: string | null;
165
- /** ISO date (`""` = no due). The row shows it in a `variant="cell"` date field —
166
- * no calendar glyph, coloured by urgency (`tone`), press to edit. */
165
+ /** ISO date (`""` = no due). It hangs under the row as a named `TaskSubRow`
166
+ * field, coloured by urgency (`tone`), press to edit. */
167
167
  due: string;
168
168
  /** The task's CHILD STEPS (`[]` = none). They render as a NESTED `TaskList`,
169
169
  * so a step is a task: give one a due date, a menu or children of its own and
@@ -209,12 +209,10 @@ const newTaskId = () => `t_${(taskSeq += 1)}`;
209
209
  const TASK_SEEDS: StageTask[] = [
210
210
  { id: "t1", label: "Confirm pricing with the customer", stage: "sales", done: true, assignee: "mem_01", due: dueIn(-10), subtasks: [] },
211
211
  { id: "t2", label: "Attach the signed quote", stage: "sales", done: true, assignee: "mem_01", due: "", subtasks: [] },
212
- // two seeded NOTES — the "why it's stuck" a task carries and nothing else
213
- // holds; every other row shows the add affordance instead
214
212
  { id: "t3", label: "Verify the customer's tax ID", stage: "sales", done: false, assignee: null, due: dueIn(-2), subtasks: [] },
215
213
  // the one task with SUBTASKS — a booking is a sequence, and its steps carry
216
214
  // nothing but a tick, so they stay child rows instead of four more tasks
217
- // (the row opens with work left; press the chevron to close it)
215
+ // (collapsing is the app's call this template always renders them)
218
216
  { id: "t4", label: "Book the carrier", stage: "operations", done: false, assignee: "mem_02", due: dueIn(1), subtasks: [
219
217
  { key: "s1", label: "Confirm the pickup window", done: true },
220
218
  { key: "s2", label: "Send the packing list", done: false },
@@ -1851,7 +1849,7 @@ export function TplRecord() {
1851
1849
  ) : null}
1852
1850
  </SubsectionHeading>
1853
1851
  ) : null}
1854
- {/* rows ride the Checklist compound — one geometry, EVERY
1852
+ {/* rows ride the Task compound — one geometry, EVERY
1855
1853
  desk's rows fully editable (the stage gates the handoff
1856
1854
  and Billing, never task editing — planning ahead on a
1857
1855
  later desk is normal work) */}
@@ -1879,19 +1877,22 @@ export function TplRecord() {
1879
1877
  <TaskTitle>
1880
1878
  <InlineTextInput variant="cell" value={t.label} onSave={(v) => renameTask(t.id, v)} struck={t.done} accessibilityLabel="Task title" />
1881
1879
  </TaskTitle>
1882
- {/* Due + assignee as COMPACT grid cells (`variant="cell"`): the due an
1883
- urgency-coloured date (no calendar glyph), the assignee a bare AVATAR
1884
- (`avatarOnly`). They sit inline on a wide record and take their own
1885
- line in a drawer — the SAME JSX, no surface guess. */}
1886
- <TaskFields>
1887
- <View style={{ width: 104 }}>
1888
- <InlineDatePicker variant="cell" tone={dueTone(t.due, t.done)} value={t.due || null} onSave={(v) => dueTask(t.id, v)} onClear={() => dueTask(t.id, "")} placeholder="Due…" locale="en-US" accessibilityLabel={`Due · ${t.label}`} />
1889
- </View>
1890
- <InlineMemberSelect variant="cell" avatarOnly members={TEAM} value={t.assignee} onSave={(m) => assignTask(t.id, m)} accessibilityLabel={`Assignee · ${t.label}`} />
1891
- </TaskFields>
1892
1880
  <TaskActions>
1893
1881
  <ActionMenu items={menuItems} accessibilityLabel={`Task options: ${t.label}`} />
1894
1882
  </TaskActions>
1883
+ {/* Due + assignee are the task's own FIELDS, so they hang UNDER it —
1884
+ each name beside the control it names, indented one step. They were
1885
+ cells in a value column the LIST declared, which is what put a
1886
+ two-word label a quarter of the record away from its own editor.
1887
+ The due keeps its urgency tone; the assignee names the member
1888
+ rather than showing a bare avatar, because a labelled field has the
1889
+ room for it. */}
1890
+ <TaskSubRow label="Due">
1891
+ <InlineDatePicker tone={dueTone(t.due, t.done)} value={t.due || null} onSave={(v) => dueTask(t.id, v)} onClear={() => dueTask(t.id, "")} placeholder="Not set" locale="en-US" accessibilityLabel={`Due · ${t.label}`} />
1892
+ </TaskSubRow>
1893
+ <TaskSubRow label="Assignee">
1894
+ <InlineMemberSelect members={TEAM} value={t.assignee} onSave={(m) => assignTask(t.id, m)} placeholder="Unassigned" accessibilityLabel={`Assignee · ${t.label}`} />
1895
+ </TaskSubRow>
1895
1896
  {t.subtasks.length > 0 ? (
1896
1897
  <TaskList>
1897
1898
  {t.subtasks.map((sub) => (
@@ -1899,12 +1900,11 @@ export function TplRecord() {
1899
1900
  <TaskStatus>
1900
1901
  <CheckCircle state={sub.done ? "done" : "none"} onChange={(on) => toggleSubtask(t.id, sub.key, on)} accessibilityLabel={sub.label} />
1901
1902
  </TaskStatus>
1902
- <TaskTitle>
1903
- {/* The parent's title is an inline editor, which insets its
1904
- own text; a plain `Text` child must add the same or the
1905
- two columns of titles do not line up. */}
1906
- <Text size="sm" color={sub.done ? "muted" : "default"} decoration={sub.done ? "lineThrough" : undefined} style={{ paddingHorizontal: TASK_TEXT_INSET }}>{sub.label}</Text>
1907
- </TaskTitle>
1903
+ {/* A STRING title: the compound insets it like the parent's
1904
+ inline editor and seats it in the row's band, so the two
1905
+ columns of titles line up and the ring stays beside the
1906
+ words even when a long subtask wraps. `struck` for done. */}
1907
+ <TaskTitle struck={sub.done}>{sub.label}</TaskTitle>
1908
1908
  </TaskItem>
1909
1909
  ))}
1910
1910
  </TaskList>
@@ -2243,11 +2243,14 @@ export function TplRecord() {
2243
2243
  <SubsectionHeading>
2244
2244
  <SubsectionHeadingTitle>{g.party(customer?.name ?? null)}</SubsectionHeadingTitle>
2245
2245
  </SubsectionHeading>
2246
- {/* the PICKER rows ride the Checklist primitive — IT owns
2247
- the geometry (row height, control/title alignment, the
2248
- meta/expansion indent); hand-rolling this anatomy is how
2249
- alignment drifts. controlWidth 24 = CheckboxInput. */}
2250
- <TaskList controlWidth={24}>
2246
+ {/* the PICKER rows ride the Task compound — IT owns the
2247
+ geometry (the row band, the two gutters, the indent
2248
+ everything hung under a row takes); hand-rolling this
2249
+ anatomy is how alignment drifts. controlWidth 24 =
2250
+ CheckboxInput; and no row here carries a ⋯, so the list
2251
+ declines the action gutter and its captions run to the
2252
+ surface's edge. */}
2253
+ <TaskList controlWidth={24} actionWidth={0}>
2251
2254
  {shown.map((f) => {
2252
2255
  // READINESS per row, CO-LOCATED: a not-ready row's mark NAMES what's
2253
2256
  // missing (muted at rest — discoverable without checking; warning once
@@ -2263,17 +2266,14 @@ export function TplRecord() {
2263
2266
  <TaskStatus>
2264
2267
  <CheckboxInput accessibilityLabel={f.label} checked={checked} onChange={(on) => toggleForm(f.id, on)} />
2265
2268
  </TaskStatus>
2266
- <TaskTitle>
2267
- {/* A plain-`Text` title carries the cell inset itself, or it sits
2268
- out of line with the editable titles elsewhere on the page and
2269
- with its own caption below. */}
2270
- <Text size="sm" style={{ flexShrink: 1, paddingHorizontal: TASK_TEXT_INSET }}>{f.label}</Text>
2271
- </TaskTitle>
2269
+ {/* A STRING title carries the cell inset and the row's band from the
2270
+ compound, so it sits in line with the editable titles elsewhere on
2271
+ the page and the checkbox stays beside it above its caption. */}
2272
+ <TaskTitle>{f.label}</TaskTitle>
2272
2273
  {/* "Needs: Tax ID, Delivery address" is a SENTENCE about this row, so it
2273
- is a `TaskCaption` — its own line under the title. It was a
2274
- `TaskFields` cell, which is the column the reader EDITS and scans
2275
- down: prose there lines up with nothing, squeezes the cells beside
2276
- it, and reads as a control it is not. */}
2274
+ is a `TaskCaption` — its own line under the title. Not a
2275
+ `TaskSubRow`, which is a NAMED value the reader SETS: prose given a
2276
+ label reads as a field nobody can edit. */}
2277
2277
  {missing.length > 0 ? (
2278
2278
  <TaskCaption>
2279
2279
  <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 5 }}>