@lotics/ui 19.0.1 → 20.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,41 @@ 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
+ ## v20 from 19.x
8
+
9
+ **`@lotics/ui/section` is DELETED.** Two modules exported a `Section` — the layout grammar's
10
+ card-less region (`section_heading`) and a standalone titled block with its own `title` string,
11
+ `collapsible` flag and imperative `SectionHandlers.expand/collapse`. The second one predated both
12
+ the fixed heading ramp and the `Accordion` family, so a collapsible section had a heading that
13
+ could not take the ramp's rungs and a disclosure that could not be driven from state.
14
+
15
+ A collapsible section is now the `Accordion` family with a heading rung composed inside it — one
16
+ heading family and one disclosure across static and collapsible. Replace the imperative handle
17
+ with the controlled `expanded`/`onToggle` pair (or `defaultExpanded` when nothing outside needs to
18
+ drive it).
19
+
20
+ ```tsx
21
+ // BEFORE
22
+ const ref = useRef<SectionHandlers>(null);
23
+ <Section ref={ref} icon="eye-off" title="Hidden fields" collapsible initialCollapsed>
24
+ {body}
25
+ </Section>
26
+ // …elsewhere: ref.current?.expand()
27
+
28
+ // AFTER
29
+ <Accordion expanded={open} onToggle={setOpen}>
30
+ <AccordionHeader>
31
+ <DialogSectionHeadingTitle icon="eye-off">Hidden fields</DialogSectionHeadingTitle>
32
+ </AccordionHeader>
33
+ <AccordionContent>{body}</AccordionContent>
34
+ </Accordion>
35
+ ```
36
+
37
+ Pick the heading rung by where the surface sits, exactly as for a static section:
38
+ `SectionHeadingTitle` (`##`), `SubsectionHeadingTitle` (`###`), `DialogSectionHeadingTitle`
39
+ (`####`). The last one gained `icon` and `description` in this release, so every rung of the ramp
40
+ now carries the same slots.
41
+
7
42
  ## v19 from 18.x
8
43
 
9
44
  **`TaskFields` is DELETED, with `fieldsWidth`.** A task row was carrying a
@@ -44,8 +79,9 @@ Porting a row, cell by cell:
44
79
  - **Every cell becomes one `TaskSubRow`, and it needs a NAME.** The column header the cells
45
80
  never had is now the label — write what the value IS ("Due", "Assignee", "Completed on"), not
46
81
  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.
82
+ - **Drop the wrapper `View`s and the per-cell widths.** The label column is the LIST's
83
+ (`TaskList labelWidth`, default `DETAIL_LABEL_WIDTH`) and the value takes the slack, so a row
84
+ passes no width at all — set `labelWidth` once on the list if the vocabulary needs more.
49
85
  - **Drop `variant="cell"`.** A cell variant is for a grid; a sub-row's value is a form-variant
50
86
  `Inline*` editor (the default) — the zinc-50 chip that says "editable".
51
87
  - **A row that needed SCANNABLE columns — the same four values compared down twenty rows — was
package/docs/catalog.md CHANGED
@@ -150,7 +150,10 @@ Two columnar shapes, and the choice is about data size:
150
150
  `TrendChip` (delta), `Sparkline`, `BarChart` / `LineChart` / `PieChart` (the canonical SVG
151
151
  set — no recharts), `RingGauge`, `ProgressBar` (its `compact` prop = ONE row, track + a
152
152
  plain sm tabular count beside it — the cell/heading/peek-trigger meter; a caption floating
153
- above a tiny bar reads misaligned) / `StackedProgressBar` / `StepProgress`, `Breakdown` (a
153
+ above a tiny bar reads misaligned. **The track clamps at 100%, the caption does not** — over
154
+ its max it reads `2,100 / 2,000 · 105%`, because a meter that says "100%" when you are over
155
+ tells the reader they are exactly at the limit. Numbers format in the reader's locale, so
156
+ never hand-format the value you pass in) / `StackedProgressBar` / `StepProgress`, `Breakdown` (a
154
157
  stacked bar + ranked share rows, pressable to drill; `maxRows` folds the long tail behind a
155
158
  "Show N more" toggle — `labels` to localize — so several facet cards align to one height in
156
159
  a row), `Funnel` (a CONVERSION funnel — ordered stages as bars that NARROW; the step
@@ -172,6 +175,8 @@ to drill).
172
175
  gap-spaced region + `SectionHeading`/`SectionHeadingTitle`/`SectionHeadingMeta`; no body
173
176
  component — children are the body), `Subsection` (+ `SubsectionHeading`/
174
177
  `SubsectionHeadingTitle` — the named group INSIDE a section, `###` lg-semibold title),
178
+ `DialogSectionHeadingTitle` (`####` md-semibold — the rung for a surface inside a DIALOG, whose
179
+ `DialogHeaderTitle` already occupies lg; composes with the Subsection layout),
175
180
  `SectionStack` (the flat page's content column — owns the fixed 56px beat + hairline between
176
181
  top-level blocks), `SubsectionStack` (a section's `Subsection` groups — fixed 32px beat,
177
182
  space-only while the groups are SHORT, `divided` once they run long), `SectionCard`, `PageHeader` /
@@ -334,9 +339,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
334
339
  hand-rolled plain DOM/CSS (nothing injects them automatically; `@lotics/ui` components
335
340
  don't need them).
336
341
  - **`spacing`** — the `SPACE` scale + `SpaceToken`.
337
- - **`control_surface`** — `CONTROL_HEIGHT` (40) · `CONTROL_RADIUS` (10) · `FOCUS_RING` ·
338
- `HOVER_BORDER` · `CONTROL_TRANSITION` · `chipSurfaceStyle` the shared control-surface
339
- tokens.
342
+ - **`control_surface`** — `CONTROL_HEIGHT` (40) · `CONTROL_RADIUS` (10) · `CONTROL_TEXT_INSET`
343
+ (9 how far a control insets its OWN text: 1px border + 8px padding; anything that must line
344
+ up with a control's WORDS rather than its box carries it, and `TASK_TEXT_INSET` IS it) ·
345
+ `FOCUS_RING` · `HOVER_BORDER` · `CONTROL_TRANSITION` · `chipSurfaceStyle` — the shared
346
+ control-surface tokens.
340
347
  - **`fonts.css`** — the Inter sheet (400/500/600, served by absolute URL so it resolves on
341
348
  every origin an app runs from); the app entry imports it ONCE or every `Text` falls back
342
349
  to system fonts.
@@ -376,19 +383,17 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
376
383
  `warning`/`error` render as an ARIA `alert`) — wrapping form fields in an alert is wrong.
377
384
  The law: **content + fields → `Inset`; a message → `Callout`.**
378
385
  - **`section_heading`** — `Section` · `SectionHeading` · `SectionHeadingTitle` ·
379
- `SectionHeadingMeta` · `Subsection` · `SubsectionHeading` · `SubsectionHeadingTitle` — the
386
+ `SectionHeadingMeta` · `Subsection` · `SubsectionHeading` · `SubsectionHeadingTitle` ·
387
+ `DialogSectionHeadingTitle` — the
380
388
  card-less twin of the Card family, compound, owns no margin; spacing via the Section gap
381
389
  (16, fixed), no body component. `SectionHeadingTitle` is ALWAYS `##` (xl semibold;
382
390
  `weight="medium"` opt-down only) + `info` for an ⓘ provenance popover after the title,
383
391
  same as `CardHeaderTitle.info`. `SubsectionHeadingTitle` is the `###` lg-semibold level-3
384
392
  title of a named group inside a section (same `info` ⓘ affordance as the section title) —
385
- heading-row siblings ride its right edge; the
386
- heading ramp is FIXED: `#` xxl / `##` xl / `###` lg, no size props.
387
- - **`section`** a SECOND, standalone `Section`: a self-contained titled block
388
- (`title`/`description`/`collapsible`/`icon`/`titleRight`, imperative
389
- `SectionHandlers.expand/collapse`). **Warning:** two modules export a `Section` — the
390
- layout grammar's card-less region is the one in `section_heading`; import from
391
- `@lotics/ui/section` only when you specifically want this collapsible titled block.
393
+ heading-row siblings ride its right edge. `DialogSectionHeadingTitle` is the `####`
394
+ md-semibold rung with the SAME `icon`/`description`/`info` slots as the section title, so a
395
+ dialog surface loses only the type size, never an affordance. The heading ramp is FIXED:
396
+ `#` xxl / `##` xl / `###` lg / `####` md, no size props.
392
397
  - **`section_stack`** — `SectionStack` · `SubsectionStack` — stacks that own the
393
398
  between-block law, skipping null children: `SectionStack` = a fixed 56px beat + a hairline
394
399
  `Divider` between top-level blocks; `SubsectionStack` = a fixed 32px beat, space-only while
@@ -708,9 +713,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
708
713
  wire both); invalid/cross-field STATE = a co-located `Callout`; **`flat`**
709
714
  on a flat-text value row (`InlineStatic`, plain `Text`) tucks the annotation up by the
710
715
  control band's slack so its gap matches a chip row's.
716
+ The three annotations are ONE anatomy shared with a task's `TaskSubRow` — same names, same
717
+ order, same alert semantics — because a record's field and a task's field are the same thing
718
+ on two surfaces.
711
719
  `DetailTable`: the compound parent of
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
720
+ a row STACK — `labelWidth` (default `DETAIL_LABEL_WIDTH`, 130 — THE kit's label column, and
721
+ a `TaskList`'s default too) / `trailingWidth` / `minHeight` (default 40, the
714
722
  inline-control grid) declared ONCE + the 8px row gap; with `trailingWidth` every row
715
723
  reserves the trailing column so value cells share one width and trailing items align at
716
724
  one x, like a table. RESPONSIVE with no prop: it measures its own container (onLayout, not
@@ -793,9 +801,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
793
801
  from a 5-item drawer checklist to a grouped desk board. `TaskList` owns geometry only: the two
794
802
  GUTTERS — `controlWidth` (the left one, that every title aligns on, and so the INDENT step) and
795
803
  `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.
804
+ actions) — plus `labelWidth` (the column every `TaskSubRow` renders its NAME in; default
805
+ `DETAIL_LABEL_WIDTH`, 130) and `density` (`comfortable` = a 44px minimum tap target, `dense` =
806
+ 32 for a pointer-driven register). All four are inherited by nested lists. Everything else is
807
+ composed, and JSX order is screen order — nothing inspects child types.
799
808
 
800
809
  `TaskStatus` takes the `CheckCircle` (omit `onChange` for a read-only ring; a PICKER list
801
810
  puts a `CheckboxInput` here and sets `controlWidth={24}`). `TaskTitle` takes the
@@ -805,7 +814,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
805
814
  `TASK_TEXT_INSET` is never needed and a long title that wraps keeps its first line beside the
806
815
  control. It spans the content box between the gutters, like every line hung beneath it.
807
816
  `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
817
+ sentence about the ROW, never a field, and never a sentence about one FIELD (that is the
818
+ sub-row's own `description` / `warning` / `error`).
819
+ `TaskActions` carries the row's ⋯ `ActionMenu`, pinned into the RIGHT
809
820
  gutter on the first line (Delete lives BEHIND it, danger-styled and last, never a bare ✕).
810
821
  `TaskDetail` is a FREE-FORM block under the row on the title's text edge — a chart, a table, a
811
822
  form with its own submit — rendered only while open. The module also exports
@@ -822,23 +833,35 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
822
833
  step inward per level. Declare `actionWidth={0}` when NO row in the list carries actions.
823
834
 
824
835
  **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.
836
+ own fields hang under it as **`TaskSubRow`** (`label` · the control · `description` /
837
+ `warning` / `error`), indented ONE step — the same step a nested `TaskList` takes, because
838
+ belonging is expressed by indentation and there is only one device for it. Label and value sit
839
+ ADJACENT so the eye pairs them. **The label column is the LIST's, not the row's** one
840
+ `labelWidth` for every sub-row at every depth, exactly as `DetailTable labelWidth` is the
841
+ record's so every value on the surface starts on ONE x. A name longer than the column WRAPS
842
+ inside it and its value does not move; a column each row sized to its own words is not a
843
+ column (it started a short label's value at one x and a long one's at another, and any
844
+ vocabulary with longer field names staggered nearly every row). The value takes the slack from
845
+ a readable minimum — on a phone or in a narrow drawer it drops onto its own line under the
846
+ label rather than ellipsizing beside it. Pass a form-variant `Inline*` editor (the default),
847
+ not `variant="cell"`: a cell variant belongs to a grid.
848
+
849
+ **A TASK'S FIELD ANNOTATES EXACTLY LIKE A RECORD'S FIELD.** `description` (persistent
850
+ guidance), `warning` (a consequence to weigh — amber, announced) and `error` (field-level
851
+ failure — danger, alert semantics) are the same three props with the same order and a11y as
852
+ `DetailRow`'s, from ONE shared implementation. A fault in a single field belongs ON that
853
+ field; `TaskCaption` is a sentence about the whole ROW, so demoting a field fault into one
854
+ says something different.
834
855
 
835
856
  **ANYTHING THAT NEEDS SCANNABLE COLUMNS IS A `Table`, NOT A `TaskList`.** The compound spent a
836
857
  version carrying a declared value column so cells would line up down the list; it aligned and
837
858
  it read worse — each field's name ended up a quarter of the surface from its control, sized for
838
859
  the widest value on the page. If the job is comparing the same four values across twenty rows,
839
860
  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.
861
+ a `TaskDetail` is the other shape to avoid: the list ALREADY has a label column, so a second
862
+ one lands its label and its value at two more x positions matching nothing above them — a form
863
+ pasted into a list. Fields belonging to the task are `TaskSubRow`s, which annotate the same way
864
+ a `DetailRow` does, so there is nothing left that a nested `DetailTable` can say.
842
865
 
843
866
  **A row's FIRST LINE is a BAND** — 44px `comfortable`, 32 `dense`. Both gutter controls centre
844
867
  in it and `TaskTitle` claims it, which is what keeps both columns of
@@ -53,12 +53,14 @@ restyle a heading level per-page.
53
53
  slots carry each section's count and CTA); a `Text size="sm" weight="semibold"` stand-in is only
54
54
  for small sub-groups. Do NOT wrap each section in its own `CardHeader`, do NOT hand-roll
55
55
  sm-semibold lead lines on a flat page, and NEVER use a bare eyebrow as a section title.
56
-
57
- **Warning:** two different `Section` components ship. The compound one described here comes from
58
- `@lotics/ui/section_heading` (alongside `SectionHeading`/`SectionHeadingTitle`/
59
- `SectionHeadingMeta`/`Subsection`/…). `@lotics/ui/section` exports a LEGACY collapsible
60
- `Section` with a `title` string prop a different component. Import from `section_heading` for
61
- the grammar described here.
56
+ - **Dialog section title** — the FOURTH rung, for a surface hosted in a dialog:
57
+ `DialogSectionHeadingTitle` (`####` md semibold, with the same `icon`/`description`/`info`
58
+ slots as `SectionHeadingTitle`, so only the type size steps down). It exists because
59
+ `DialogHeaderTitle` is **lg** — the rung `SubsectionHeadingTitle` occupies — so a dialog holding
60
+ a multi-section surface otherwise ties with, or is outranked by, its own content. Compose it
61
+ with the SUBSECTION layout (`Subsection` › `SubsectionHeading` › this, in a `SubsectionStack`):
62
+ only the type size steps down, the 12px body gap and 32px beat are already right. The ladder
63
+ stays ABSOLUTE — this is one more fixed size, never a `size`/`compact` knob on a rung above.
62
64
  - **Subsection title** — the level BELOW a section on a long record surface: `Subsection` ›
63
65
  `SubsectionHeading` › `SubsectionHeadingTitle` (`###` — lg semibold, with the same `info` ⓘ
64
66
  affordance as `SectionHeadingTitle`; siblings — a `Badge`, a
@@ -233,10 +235,14 @@ below the pressed element — the composable `Accordion` family (`AccordionHeade
233
235
  / `AccordionMeta` / `AccordionContent`); a header-only accordion renders a plain row of identical
234
236
  rhythm, so lists mix expandable + static rows. The body is **flush** with the header's left edge
235
237
  (aligns with the title — like a `Section` body, no indent, no tinted well). `AccordionHeader` is a
236
- layout slot: `AccordionTitle`/`AccordionMeta` is the compact list-row heading, but for a
237
- **collapsible Section** (configurable font/level/description + count) compose the SAME
238
- `SectionHeadingTitle`/`SectionHeadingMeta` inside it one heading family across static +
239
- collapsible.
238
+ layout slot: `AccordionTitle`/`AccordionMeta` is the compact list-row heading, but a **collapsible
239
+ SECTION** is this same family with the heading rung composed inside it — `SectionHeadingTitle` /
240
+ `SubsectionHeadingTitle` / `DialogSectionHeadingTitle` (+ `SectionHeadingMeta`) in the
241
+ `AccordionHeader`, the body in `AccordionContent`. There is no separate collapsible-section
242
+ component: one heading family and one disclosure across static + collapsible. The disclosure
243
+ header IS the section heading — name it for what it hides, never a generic bucket ("Advanced",
244
+ "More", "Options") wrapped around one named section: that stacks two titles of the same rank and
245
+ leaves the reader no reason to open the chevron.
240
246
 
241
247
  ## No dead rows — registers & tables
242
248
 
@@ -272,30 +272,46 @@ second line — hangs below it. That is what keeps a column of controls straight
272
272
  mixed-height rows, so a control never drifts into the gap under the title it belongs to.
273
273
 
274
274
  A task's own free text is its TITLE, a **`TaskCaption`** or a **`TaskDetail`** — there is no
275
- fourth place to write, because a fourth place gets written in twice. `TaskCaption` is the row's
276
- state IN WORDS ("3 of 5 papers received", "waiting on the yard") on its own line under the title;
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.
275
+ fourth place to write about the TASK, because a fourth place gets written in twice. `TaskCaption`
276
+ is the row's state IN WORDS ("3 of 5 papers received", "waiting on the yard") on its own line
277
+ under the title; it is not a field, because a field is a NAMED VALUE the reader sets and a caption
278
+ is a SENTENCE about one row — labelling prose makes it read as a field nobody can edit. A sentence
279
+ about one FIELD is not a caption either: it is that sub-row's own `description` / `warning` /
280
+ `error`, which is why the sub-row carries all three.
279
281
 
280
282
  **THE ROW CARRIES THE TITLE; THE FIELDS THE USER CAN SET ARE SUB-ROWS BENEATH IT.** A field
281
283
  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.
284
+ under it as a **`TaskSubRow`** (`label` · the control · `description` / `warning` / `error`),
285
+ indented ONE step, with the NAME beside its VALUE so the eye pairs them. Belonging is expressed by
286
+ that indentation, which is the same step a nested `TaskList` takes — one device, at every depth.
287
+
288
+ **The label column belongs to the LIST, not to the row** `TaskList labelWidth`, defaulting to
289
+ `DETAIL_LABEL_WIDTH` (130), inherited to every depth, exactly as `DetailTable labelWidth` is the
290
+ record's. Every sub-row on the surface therefore starts its value on ONE x, and a name longer than
291
+ the column WRAPS inside it instead of pushing its own value right. A column each row sized to its
292
+ own words is not a column: it lined up the labels of a task whose names happened to be short and
293
+ staggered every other one, and a vocabulary with longer field names than English staggered nearly
294
+ all of them. Raise it once, on the list, for a vocabulary that genuinely needs more. The value
295
+ takes the slack from a readable minimum, dropping onto its own line under the label on a surface
296
+ too narrow to seat both. Use the form-variant `Inline*` editor (the default) — `variant="cell"`
297
+ belongs to a grid.
298
+
299
+ **A TASK'S FIELD ANNOTATES EXACTLY LIKE A RECORD'S FIELD.** `TaskSubRow` carries the same
300
+ `description` / `warning` / `error` as `DetailRow`, from one shared implementation — same tones,
301
+ same severity order (error nearest the control, then warning, then the guidance), same alert
302
+ semantics. A fault in ONE field says so on that field. Demoting it into a `TaskCaption` — a
303
+ sentence about the whole ROW — says something different, and that asymmetry is what forced apps
304
+ to do it.
290
305
 
291
306
  **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 —
307
+ carried a declared VALUE column so cells lined up down the list; it aligned, and it read worse —
293
308
  each name ended up a quarter of the surface from its control, sized for the widest value on the
294
309
  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.
310
+ `TaskDetail` is the same mistake in miniature: the list already HAS a label column, so a second
311
+ one lands a label and a control at two more x positions matching nothing above them — a form
312
+ pasted into a list, and with sub-row annotations there is nothing left for it to say.
313
+ `TaskDetail` keeps the FREE-FORM block (a chart, a table, a form with its own submit) — it has no
314
+ name to hang on the indent, so a rule down its left edge is what ties it to the row.
299
315
 
300
316
  **The ⋯ is a COLUMN — the mirror of the control gutter.** Both pinned controls sit outside the
301
317
  row's flow and the ROW reserves each edge (`controlWidth` on the left, `actionWidth` on the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "19.0.1",
3
+ "version": "20.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -195,7 +195,6 @@
195
195
  "./dots_indicator": "./src/dots_indicator.tsx",
196
196
  "./list": "./src/list.tsx",
197
197
  "./list_item": "./src/list_item.tsx",
198
- "./section": "./src/section.tsx",
199
198
  "./section_heading": "./src/section_heading.tsx",
200
199
  "./section_stack": "./src/section_stack.tsx",
201
200
  "./separator": "./src/separator.tsx",
package/src/accordion.tsx CHANGED
@@ -103,7 +103,10 @@ export function AccordionHeader(props: AccordionHeaderProps) {
103
103
  <PressableHighlight
104
104
  focusRing onPress={toggle}
105
105
  accessibilityRole="button"
106
- accessibilityState={{ expanded }}
106
+ // The W3C prop, NOT `accessibilityState` this react-native-web build drops the
107
+ // latter silently, so a disclosure shipped a `role="button"` that never announced
108
+ // whether its content was showing.
109
+ aria-expanded={expanded}
107
110
  accessibilityLabel={accessibilityLabel}
108
111
  style={[styles.headerRow, styles.pressable, style]}
109
112
  >
package/src/breakdown.tsx CHANGED
@@ -113,7 +113,8 @@ export function Breakdown(props: BreakdownProps) {
113
113
  {collapsible ? (
114
114
  <PressableHighlight
115
115
  focusRing accessibilityRole="button"
116
- accessibilityState={{ expanded }}
116
+ // The W3C prop — `accessibilityState` is dropped by this react-native-web build.
117
+ aria-expanded={expanded}
117
118
  accessibilityLabel={expanded ? labels.less : labels.more(hidden)}
118
119
  onPress={() => setExpanded((e) => !e)}
119
120
  style={[styles.row, styles.pressable, styles.toggle]}
@@ -25,6 +25,16 @@ export const CONTROL_RADIUS = 10;
25
25
  * one look. Apps building their own controls reuse it. */
26
26
  export const FOCUS_RING = `0 0 0 2px ${colors.zinc[900]}`;
27
27
 
28
+ /**
29
+ * How far a control surface insets its OWN text: 1px of border (transparent on an
30
+ * inline editor at rest) plus 8px of padding. Anything that has to line up with a
31
+ * control's WORDS rather than with its invisible box — a field's annotation under
32
+ * its value, a caption hung beneath a title — carries this same number. It is 9,
33
+ * not 8: reading the padding alone and forgetting the border is what put a whole
34
+ * family of hanging lines 1px left of the text they belonged to.
35
+ */
36
+ export const CONTROL_TEXT_INSET = 9;
37
+
28
38
  /** A bordered control's pointer-hover edge: the resting `border` darkens to this on
29
39
  * hover — the premium "this is interactive" affordance that complements the focus
30
40
  * ring (focus = the ring; hover = a darker border). Drive it with `useHover` on a
package/src/data_grid.tsx CHANGED
@@ -75,7 +75,8 @@ export function DataGrid<T>(props: DataGridProps<T>) {
75
75
  <FocusRingPressable
76
76
  onPress={() => onToggleCollapse?.(g.key)}
77
77
  accessibilityRole="button"
78
- accessibilityState={{ expanded: !isCollapsed }}
78
+ // The W3C prop — `accessibilityState` is dropped by this react-native-web build.
79
+ aria-expanded={!isCollapsed}
79
80
  disabled={!onToggleCollapse}
80
81
  style={({ hovered }: { hovered?: boolean }) => [styles.section, hovered && onToggleCollapse ? styles.sectionHover : null]}
81
82
  >
@@ -1,5 +1,6 @@
1
1
  import { createContext, ReactNode, useContext, useState } from "react";
2
2
  import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
3
+ import { FieldAnnotationProps, FieldAnnotations, hasFieldAnnotation } from "./field_annotations";
3
4
  import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
4
5
  import { getInputLineHeight } from "./text_utils";
5
6
  import { Text } from "./text";
@@ -20,9 +21,11 @@ const DetailTableContext = createContext<DetailTableColumns | null>(null);
20
21
  const MIN_VALUE_WIDTH = 160;
21
22
 
22
23
  /** THE kit's label column — ~18 characters, where real field names live. It is
23
- * this table's default AND the floor a `TaskSubRow`'s label sizes from, so a
24
- * record's fields and a task's read as one language on a surface carrying
25
- * both; that only holds while the two are ONE number. */
24
+ * this table's default AND a `TaskList`'s, so a record's fields and a task's
25
+ * read as one language on a surface carrying both; that only holds while the
26
+ * two are ONE number. A name longer than the column WRAPS inside it: the
27
+ * column belongs to the SURFACE, so no one field's name may push its own value
28
+ * out of line with the field above it. */
26
29
  export const DETAIL_LABEL_WIDTH = 130;
27
30
 
28
31
  export interface DetailTableProps {
@@ -89,7 +92,7 @@ export function DetailTable(props: DetailTableProps) {
89
92
  );
90
93
  }
91
94
 
92
- export interface DetailRowProps {
95
+ export interface DetailRowProps extends FieldAnnotationProps {
93
96
  /** The field label — rendered `sm muted`. */
94
97
  label: ReactNode;
95
98
  /** The value — any node (a `Text`, a `Badge`, a stack). */
@@ -110,23 +113,6 @@ export interface DetailRowProps {
110
113
  labelSize?: "xs" | "sm";
111
114
  /** Min row height. Default 28 (or the `DetailTable`'s `minHeight`). */
112
115
  minHeight?: number;
113
- /** PERSISTENT guidance — what to enter, or the rule that applies ("Printed
114
- * on every document"). Renders under the VALUE (the label column has no
115
- * room for prose); stacked mode mirrors `FormField` (label · description ·
116
- * control). Always visible — field annotations are EXPLICIT, never a
117
- * hidden ⓘ gloss. Invalid STATE → `error` or a co-located `Callout`. Same
118
- * name, same meaning as `FormField.description`. */
119
- description?: string;
120
- /** A consequence the user should WEIGH before acting — renders in the same
121
- * annotation slot in amber (`Text color="warning"`), one tone between the
122
- * muted `description` and the danger `error`, announced on appearance. Same
123
- * name, same meaning as `FormField.warning`. */
124
- warning?: string;
125
- /** Field-level failure, under the value in danger ink with `FormField`'s
126
- * alert semantics (announced on appearance). For CONSUMER-validated state —
127
- * the `Inline*` editors already render their own transient save errors;
128
- * don't wire both to the same failure. */
129
- error?: string;
130
116
  /** The value renders as FLAT text (an `InlineStatic`, a plain `Text`) rather
131
117
  * than a control-height chip. Annotations then tuck up by the control
132
118
  * band's slack, so the perceived gap under the TEXT equals the gap under a
@@ -153,7 +139,7 @@ export function DetailRow(props: DetailRowProps) {
153
139
  const form = labelWidth != null;
154
140
  // Annotations (description / warning / error) live UNDER THE VALUE — the row
155
141
  // grows, so the label + trailing center against the CONTROL LINE, not the block.
156
- const annotated = description != null || warning != null || error != null;
142
+ const annotated = hasFieldAnnotation(props);
157
143
  // A FLAT value (no painted chip) centers its text in the control band,
158
144
  // leaving invisible slack below it — tuck the annotations up by that slack
159
145
  // so the perceived gap matches a chip row's. Horizontal mode only (stacked
@@ -170,29 +156,11 @@ export function DetailRow(props: DetailRowProps) {
170
156
  </Text>
171
157
  </View>
172
158
  );
173
- // Severity descending from the control: error first (adjacent to the control
174
- // that failed, FormField's alert semantics), then warning, then the guidance.
175
- // Indented to the inline chip's OWN text inset (paddingHorizontal 8), so
176
- // annotations align with the value's text.
177
- const annotations = annotated ? (
178
- <>
179
- {error != null ? (
180
- <Text size="xs" color="danger" accessibilityRole="alert" aria-live="polite" style={styles.annotation}>
181
- {error}
182
- </Text>
183
- ) : null}
184
- {warning != null ? (
185
- <Text size="xs" color="warning" accessibilityRole="alert" aria-live="polite" style={styles.annotation}>
186
- {warning}
187
- </Text>
188
- ) : null}
189
- {description != null ? (
190
- <Text size="xs" color="muted" style={styles.annotation}>
191
- {description}
192
- </Text>
193
- ) : null}
194
- </>
195
- ) : null;
159
+ // THE field-annotation anatomy severity, tones, a11y and the control's own
160
+ // text inset, defined once in `field_annotations` and rendered identically by
161
+ // a task's `TaskSubRow`. A record's field and a task's field are the same
162
+ // thing on two surfaces; they may not say a fault two different ways.
163
+ const annotations = <FieldAnnotations description={description} warning={warning} error={error} />;
196
164
  if (table?.stacked) {
197
165
  // Stacked mode wears the FORM grammar: the label renders exactly like a
198
166
  // `FormField` label (medium, default ink), so a narrow record surface
@@ -234,12 +202,12 @@ export function DetailRow(props: DetailRowProps) {
234
202
  {form ? (
235
203
  <View style={[styles.value, annotated && styles.valueStack]}>
236
204
  <View style={[styles.controlLine, { minHeight }]}>{children}</View>
237
- {flatTuck !== 0 && annotations != null ? <View style={{ marginTop: flatTuck }}>{annotations}</View> : annotations}
205
+ {flatTuck !== 0 ? <View style={{ marginTop: flatTuck }}>{annotations}</View> : annotations}
238
206
  </View>
239
207
  ) : (
240
208
  <View style={annotated ? styles.valueStack : undefined}>
241
209
  <View style={[styles.controlLine, { minHeight }]}>{children}</View>
242
- {flatTuck !== 0 && annotations != null ? <View style={{ marginTop: flatTuck }}>{annotations}</View> : annotations}
210
+ {flatTuck !== 0 ? <View style={{ marginTop: flatTuck }}>{annotations}</View> : annotations}
243
211
  </View>
244
212
  )}
245
213
  {table?.trailingWidth != null ? (
@@ -279,9 +247,9 @@ const styles = StyleSheet.create({
279
247
  // The first line of the value cell — the control centers in it exactly like
280
248
  // the label and trailing cells do.
281
249
  controlLine: { justifyContent: "center" },
250
+ // The 2px between the control line and the annotation block beneath it — the
251
+ // same rhythm `FieldAnnotations` puts between its own lines.
282
252
  valueStack: { gap: 2 },
283
- // Aligns annotation text with the inline chip's text (its 8px inset).
284
- annotation: { paddingLeft: 8 },
285
253
  // Form mode (labelWidth set): the value column FILLS the row so every inline
286
254
  // editor spans the SAME width — and none jumps wider when it swaps to the
287
255
  // (flex:1) edit control. A stretch column, so an InlineEditView / InlineStatic
@@ -0,0 +1,76 @@
1
+ import { StyleSheet, View } from "react-native";
2
+ import { CONTROL_TEXT_INSET } from "./control_surface";
3
+ import { Text } from "./text";
4
+
5
+ /**
6
+ * What a FIELD says about itself under its own value — ONE anatomy, shared by every field
7
+ * surface in the kit (`DetailRow` on a record, `TaskSubRow` on a task). A record's field and a
8
+ * task's field carry the same three tones, in the same order, with the same a11y, because they
9
+ * ARE the same thing on two surfaces: a name, a value the reader sets, and whatever has to be
10
+ * said about it. Where the two disagreed, an app with a field-level fault had to demote it into
11
+ * prose about the whole ROW — which says something different.
12
+ *
13
+ * Always VISIBLE. A field annotation is never a hidden ⓘ gloss: guidance nobody opens is
14
+ * guidance nobody reads, and a fault behind a disclosure is a fault the reader ships.
15
+ */
16
+ export interface FieldAnnotationProps {
17
+ /** PERSISTENT guidance — what to enter, or the rule that applies ("Printed on every
18
+ * document"). Muted, never announced: it is true before the reader touches the field and
19
+ * stays true after. An invalid STATE is `error`, not a description written in the past
20
+ * tense. Same name, same meaning as `FormField.description`. */
21
+ description?: string;
22
+ /** A consequence the reader should WEIGH before acting — amber, one tone between the muted
23
+ * `description` and the danger `error`, announced on appearance. It does not block: the
24
+ * value is accepted and something downstream is worse for it. Same name, same meaning as
25
+ * `FormField.warning`. */
26
+ warning?: string;
27
+ /** Field-level FAILURE — danger ink, `FormField`'s alert semantics, announced on appearance.
28
+ * For CONSUMER-validated state: the `Inline*` editors already render their own transient
29
+ * save errors, so don't wire both to one failure. */
30
+ error?: string;
31
+ }
32
+
33
+ /** Does this field have anything to say? Hosts ask because an annotated field is a TALLER row,
34
+ * and the label beside it still has to centre on the CONTROL line rather than on the block. */
35
+ export function hasFieldAnnotation(props: FieldAnnotationProps): boolean {
36
+ return props.description != null || props.warning != null || props.error != null;
37
+ }
38
+
39
+ /**
40
+ * The stack itself — rendered directly beneath the value, `null` when the field has nothing to
41
+ * say.
42
+ *
43
+ * SEVERITY DESCENDS FROM THE CONTROL: error first (adjacent to the value that failed), then
44
+ * warning, then the guidance. All three can be present at once and none suppresses another —
45
+ * they answer different questions ("this is broken" / "this will cost you" / "this is what the
46
+ * field is for"), and hiding the rule that explains the failure is exactly the wrong economy.
47
+ */
48
+ export function FieldAnnotations(props: FieldAnnotationProps) {
49
+ if (!hasFieldAnnotation(props)) return null;
50
+ return (
51
+ <View style={styles.stack}>
52
+ {props.error != null ? (
53
+ <Text size="xs" color="danger" accessibilityRole="alert" aria-live="polite">
54
+ {props.error}
55
+ </Text>
56
+ ) : null}
57
+ {props.warning != null ? (
58
+ <Text size="xs" color="warning" accessibilityRole="alert" aria-live="polite">
59
+ {props.warning}
60
+ </Text>
61
+ ) : null}
62
+ {props.description != null ? (
63
+ <Text size="xs" color="muted">
64
+ {props.description}
65
+ </Text>
66
+ ) : null}
67
+ </View>
68
+ );
69
+ }
70
+
71
+ const styles = StyleSheet.create({
72
+ // The control's own text inset, so an annotation lines up with the value's WORDS rather than
73
+ // with the editor's invisible box — and the 2px that separates the lines from each other,
74
+ // which is the same rhythm the host puts between the control and this block.
75
+ stack: { gap: 2, paddingLeft: CONTROL_TEXT_INSET },
76
+ });
@@ -1,6 +1,7 @@
1
1
  import { View, StyleSheet, type ViewStyle } from "react-native";
2
2
  import { Text } from "./text";
3
3
  import { colors } from "./colors";
4
+ import { useLocaleTag } from "./locale";
4
5
 
5
6
  export type ProgressBarFormat = "percentage" | "fraction" | "none";
6
7
 
@@ -12,7 +13,8 @@ export interface ProgressBarProps {
12
13
  * it. */
13
14
  title?: string;
14
15
  /** Caption above-right of the bar: `percentage` → "50%", `fraction` →
15
- * "1.250 / 2.500 · 50%". */
16
+ * "1,250 / 2,500 · 50%" (separators follow the reader's locale). Reports the
17
+ * TRUE ratio — over `max` it reads "105%" while the track stays clamped. */
16
18
  format?: ProgressBarFormat;
17
19
  color?: string;
18
20
  completeColor?: string;
@@ -40,12 +42,23 @@ export function ProgressBar(props: ProgressBarProps) {
40
42
  compact = false,
41
43
  } = props;
42
44
 
43
- const percentage = max > 0 ? Math.min(100, Math.max(0, (value / max) * 100)) : 0;
44
- const isComplete = percentage >= 100;
45
+ // The pack is locale-aware (see `RemainderMeter`); a hardcoded tag renders 2.100 for an
46
+ // English reader, which reads as 2.1.
47
+ const localeTag = useLocaleTag();
48
+ /**
49
+ * The TRACK clamps — a fill wider than its own track is meaningless. The CAPTION does not: a
50
+ * meter reading `2.100 / 2.000 · 100%` asserts a number that is false, and a reader scanning
51
+ * percentages sees "exactly at the limit" when they are over it. Progress-toward-completion
52
+ * never exceeds its max, so this only diverges for the over-allowance case, where the true
53
+ * figure is the whole point.
54
+ */
55
+ const ratio = max > 0 ? Math.max(0, (value / max) * 100) : 0;
56
+ const percentage = Math.min(100, ratio);
57
+ const isComplete = ratio >= 100;
45
58
 
46
59
  if (compact) {
47
60
  const label =
48
- format === "percentage" ? `${Math.round(percentage)}%` : `${value.toLocaleString("vi-VN")}/${max.toLocaleString("vi-VN")}`;
61
+ format === "percentage" ? `${Math.round(ratio)}%` : `${value.toLocaleString(localeTag)}/${max.toLocaleString(localeTag)}`;
49
62
  return (
50
63
  <View style={styles.compactRow}>
51
64
  <View style={[styles.track, styles.compactTrack]}>
@@ -62,9 +75,9 @@ export function ProgressBar(props: ProgressBarProps) {
62
75
 
63
76
  const caption =
64
77
  format === "fraction"
65
- ? `${value.toLocaleString("vi-VN")} / ${max.toLocaleString("vi-VN")} · ${Math.round(percentage)}%`
78
+ ? `${value.toLocaleString(localeTag)} / ${max.toLocaleString(localeTag)} · ${Math.round(ratio)}%`
66
79
  : format === "percentage"
67
- ? `${Math.round(percentage)}%`
80
+ ? `${Math.round(ratio)}%`
68
81
  : null;
69
82
 
70
83
  return (
@@ -110,6 +110,64 @@ export function SubsectionHeadingTitle(props: SubsectionHeadingTitleProps) {
110
110
  );
111
111
  }
112
112
 
113
+ export interface DialogSectionHeadingTitleProps {
114
+ children: React.ReactNode;
115
+ /** Optional muted line under the title, as on `SectionHeadingTitle`. */
116
+ description?: string;
117
+ /** Leading glyph, as on `SectionHeadingTitle`. */
118
+ icon?: IconName;
119
+ /** Heading rank. Defaults to 4 — one level under `SubsectionHeadingTitle`'s 3. */
120
+ level?: HeadingLevel;
121
+ /** An ⓘ popover after the title, as on the two rungs above. */
122
+ info?: string;
123
+ }
124
+
125
+ /**
126
+ * The section title for a surface hosted in a DIALOG — ALWAYS `####` (md semibold), the fourth
127
+ * rung of the same fixed ladder.
128
+ *
129
+ * It exists because `DialogHeaderTitle` is `lg`, which is the rung `SubsectionHeadingTitle`
130
+ * occupies: a dialog holding a multi-section surface has its own title tying with, or being
131
+ * outranked by, its content. The ladder stays ABSOLUTE — this is one more fixed size, never a
132
+ * knob on an existing rung. Do not reach for a `size`/`compact` prop on the titles above; the same
133
+ * rank rendering at two sizes is exactly what the fixed ramp prevents.
134
+ *
135
+ * Compose it with the SUBSECTION layout — `Subsection` › `SubsectionHeading` › this, inside a
136
+ * `SubsectionStack`. Only the type size steps down; the 12px body gap and the 32px between-group
137
+ * beat are already right for a dialog, so duplicating them under a `Dialog*` name would add three
138
+ * exports that differ from their siblings in nothing.
139
+ *
140
+ * <SubsectionStack divided>
141
+ * <Subsection>
142
+ * <SubsectionHeading>
143
+ * <DialogSectionHeadingTitle info="…">Usage</DialogSectionHeadingTitle>
144
+ * <SectionHeadingMeta>Aug 2 – Sep 2</SectionHeadingMeta>
145
+ * </SubsectionHeading>
146
+ * {body}
147
+ * </Subsection>
148
+ * </SubsectionStack>
149
+ */
150
+ export function DialogSectionHeadingTitle(props: DialogSectionHeadingTitleProps) {
151
+ const { children, description, icon, level = 4, info } = props;
152
+ const words = useLoticsLocale();
153
+ return (
154
+ <View style={{ flex: 1, gap: 2 }}>
155
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
156
+ {icon ? <Icon name={icon} size={16} /> : null}
157
+ <Text level={level} size="md" weight="semibold">
158
+ {children}
159
+ </Text>
160
+ {info ? <InfoPopover text={info} accessibilityLabel={words.sectionHeading.info} /> : null}
161
+ </View>
162
+ {description ? (
163
+ <Text color="zinc-500" size="sm">
164
+ {description}
165
+ </Text>
166
+ ) : null}
167
+ </View>
168
+ );
169
+ }
170
+
113
171
  export interface SectionHeadingProps {
114
172
  children: React.ReactNode;
115
173
  style?: StyleProp<ViewStyle>;
package/src/select.tsx CHANGED
@@ -228,11 +228,13 @@ function SelectTrigger<T extends string>({
228
228
  testID={testID}
229
229
  // Without a role this Pressable renders as an unfocusable <div> on web — the
230
230
  // trigger drops out of the tab order and Enter/Space can't open it. `button`
231
- // makes it tab-focusable and maps keyboard activation to onPress; `expanded`
232
- // announces open/closed to assistive tech.
231
+ // makes it tab-focusable and maps keyboard activation to onPress; `aria-expanded`
232
+ // announces open/closed to assistive tech. The W3C props, NOT `accessibilityState` —
233
+ // this react-native-web build drops the latter silently.
233
234
  accessibilityRole="button"
234
235
  accessibilityLabel={accessibilityLabel}
235
- accessibilityState={{ expanded: open, disabled }}
236
+ aria-expanded={open}
237
+ aria-disabled={disabled}
236
238
  style={(state) => [styles.pressable, CONTROL_TRANSITION, open && styles.opened, disabled && styles.disabled, style, state.hovered && !disabled && { borderColor: HOVER_BORDER }]}
237
239
  onPress={!disabled ? onPress : undefined}
238
240
  disabled={disabled}
@@ -9,7 +9,9 @@ function SheetTab(props: { selected: boolean; onPress: () => void; name: string
9
9
  return (
10
10
  <FocusRingPressable
11
11
  accessibilityRole="tab"
12
- accessibilityState={{ selected: props.selected }}
12
+ // The W3C prop — `accessibilityState` is dropped by this react-native-web build, and a
13
+ // tab that never announces `aria-selected` leaves the tablist with no current item.
14
+ aria-selected={props.selected}
13
15
  onPress={props.onPress}
14
16
  style={[styles.tab, props.selected ? styles.tabActive : null]}
15
17
  >
package/src/task.tsx CHANGED
@@ -1,7 +1,9 @@
1
1
  import { createContext, useContext, type ReactNode } from "react";
2
2
  import { StyleSheet, View } from "react-native";
3
3
  import { colors } from "./colors";
4
+ import { CONTROL_TEXT_INSET } from "./control_surface";
4
5
  import { DETAIL_LABEL_WIDTH } from "./detail_row";
6
+ import { FieldAnnotationProps, FieldAnnotations } from "./field_annotations";
5
7
  import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
6
8
  import { Text } from "./text";
7
9
  import { TASK_ROW_BAND, TASK_ROW_GAP, taskGutter, taskTitleSlack } from "./task_metrics";
@@ -51,6 +53,14 @@ import { TASK_ROW_BAND, TASK_ROW_GAP, taskGutter, taskTitleSlack } from "./task_
51
53
  * `TaskDetail` is the exception that proves the indent — a free-form block has no label to hang
52
54
  * on it, so it keeps a rule instead.
53
55
  *
56
+ * **The sub-row LABEL column is the list's** (`labelWidth`, defaulting to the record surface's
57
+ * `DETAIL_LABEL_WIDTH`), inherited to every depth like the gutters. A name longer than it wraps
58
+ * INSIDE it and its value stays put. The column each sub-row sized to its OWN words looked
59
+ * shared on a list of two-word English names and was not one: a short label started its value at
60
+ * one x, a long one at another, and a vocabulary with longer words than English staggered nearly
61
+ * every row. A column that is not shared is not a column — and this is the label's, never the
62
+ * value's, which is the one this family deleted.
63
+ *
54
64
  * **A task list is not a table.** Anything the reader scans DOWN a column — six rows compared on
55
65
  * the same four values — is a `Table` or a `DataGrid`, which are built for it. A `TaskList` sized
56
66
  * for that is a table with a worse header.
@@ -70,10 +80,14 @@ import { TASK_ROW_BAND, TASK_ROW_GAP, taskGutter, taskTitleSlack } from "./task_
70
80
  *
71
81
  * **A value and a sentence are different things.** A `TaskSubRow` holds a NAMED value the reader
72
82
  * sets; `TaskCaption` holds a sentence ABOUT the row ("Missing 2 of 6") on its own line under the
73
- * title. Prose given a label reads as a field nobody can edit.
83
+ * title. Prose given a label reads as a field nobody can edit. Which is why a sub-row annotates
84
+ * like a record's field does — `description` · `warning` · `error`, the one anatomy in
85
+ * `field_annotations` — so a fault in ONE field says so on that field instead of being demoted
86
+ * into a sentence about the whole row.
74
87
  *
75
88
  * There is deliberately no `note` slot. A task's free text is its title, a caption, or detail;
76
- * a fourth place to write invited writing it twice.
89
+ * a fourth place to write about the TASK invited writing it twice. A sentence about one FIELD is
90
+ * that sub-row's own annotation, not a caption.
77
91
  */
78
92
 
79
93
  interface TaskListContextValue {
@@ -81,6 +95,8 @@ interface TaskListContextValue {
81
95
  controlWidth: number;
82
96
  /** The RIGHT gutter's control — the row's ⋯. `0` on a list whose rows carry no actions. */
83
97
  actionWidth: number;
98
+ /** The label column every `TaskSubRow` on the list renders its name in, at every depth. */
99
+ labelWidth: number;
84
100
  /** 0 at the root. Any value above 0 means "I am rendered inside a task". */
85
101
  depth: number;
86
102
  density: TaskDensity;
@@ -101,29 +117,11 @@ const TASK_ACTION_WIDTH = 28;
101
117
  const TaskListContext = createContext<TaskListContextValue>({
102
118
  controlWidth: 20,
103
119
  actionWidth: TASK_ACTION_WIDTH,
120
+ labelWidth: DETAIL_LABEL_WIDTH,
104
121
  depth: 0,
105
122
  density: "comfortable",
106
123
  });
107
124
 
108
- /**
109
- * A sub-row's label column. It is sized to its OWN content — no measurement, no width from the
110
- * list — between a floor and a ceiling, which is what makes the labels of ONE task line up with
111
- * each other: a field name is two or three words, every one of them lands under the floor, so
112
- * every value in that task starts on the same x. The floor IS the kit's label column
113
- * (`DETAIL_LABEL_WIDTH`, `DetailTable`'s default) — ~18 characters, where real field names live
114
- * ("Completed on", "Portal account", "Courier reference") — taken from that one constant rather
115
- * than matched to it, so a task's fields and a record's cannot drift apart on a surface carrying
116
- * both.
117
- *
118
- * The ceiling is the honest half of the deal. A label longer than the floor takes the width it
119
- * needs and its value sits outboard of its siblings' — until the ceiling, where it wraps instead,
120
- * so one verbose name cannot push the whole column off the surface. A TRUE shared column (every
121
- * label at the widest sibling's width, whatever that is) cannot be had in flexbox without
122
- * measuring text, and a measured column costs a layout pass, a re-render and a rule this file
123
- * could not state — for a case that mostly does not arise.
124
- */
125
- const SUB_LABEL_MIN = DETAIL_LABEL_WIDTH;
126
- const SUB_LABEL_MAX = 168;
127
125
  /**
128
126
  * The width a value claims before it would rather WRAP. It is set by what a real value needs —
129
127
  * an email address, a member's name, a three-word status all run to about 185px — so below it,
@@ -133,17 +131,14 @@ const SUB_LABEL_MAX = 168;
133
131
  */
134
132
  const SUB_VALUE_MIN = 200;
135
133
  /**
136
- * How far a `variant="cell"` control insets its own text: 1px of transparent border plus 8px
137
- * of padding. Everything that hangs beneath a row the caption, a sub-row's label, the detail
138
- * blockadds the same, so it lines up with the WORDS rather than with the editor's invisible
139
- * box. A STRING title gets it from `TaskTitle`; only a custom title NODE that is not a
140
- * `variant="cell"` control has to add it, or it sits out of line with the editable titles
141
- * around it.
142
- *
143
- * It was 8 — the padding alone, forgetting the border — which put every caption, detail block
144
- * and nested list 1px left of the title it belonged to.
134
+ * How far a `variant="cell"` control insets its own text the kit's `CONTROL_TEXT_INSET` under
135
+ * this family's name, because a task surface reaches for it constantly. Everything that hangs
136
+ * beneath a row — the caption, a sub-row's label, the detail block adds the same, so it lines
137
+ * up with the WORDS rather than with the editor's invisible box. A STRING title gets it from
138
+ * `TaskTitle`; only a custom title NODE that is not a `variant="cell"` control has to add it, or
139
+ * it sits out of line with the editable titles around it.
145
140
  */
146
- export const TASK_TEXT_INSET = 9;
141
+ export const TASK_TEXT_INSET = CONTROL_TEXT_INSET;
147
142
 
148
143
  export interface TaskListProps {
149
144
  children: ReactNode;
@@ -163,6 +158,20 @@ export interface TaskListProps {
163
158
  * nested list inherits it.
164
159
  */
165
160
  actionWidth?: number;
161
+ /**
162
+ * The width of the label column EVERY `TaskSubRow` on this list renders its name in, at every
163
+ * depth — the list's, never the row's, exactly as `DetailTable labelWidth` is the record's.
164
+ * Defaults to `DETAIL_LABEL_WIDTH` (130, ~18 characters), taken from that one constant rather
165
+ * than matched to it, so a task's fields and a record's cannot drift apart on a surface
166
+ * carrying both.
167
+ *
168
+ * A name longer than the column WRAPS inside it and its value stays put. That is the whole
169
+ * point: a column each row sized to its OWN words is not a column — it started a short label's
170
+ * value at one x and a long one's at another, on the same list, and a surface whose field
171
+ * names run long (any language with longer words than English) had a different value x on
172
+ * nearly every row. Raise it for a vocabulary that genuinely needs more, on the LIST, once.
173
+ */
174
+ labelWidth?: number;
166
175
  /**
167
176
  * `comfortable` (the default) gives every row a 44px first-line BAND — the tap target a
168
177
  * finger needs, and what the leading control centres in; `dense` drops to 32px for a
@@ -183,9 +192,10 @@ export function TaskList(props: TaskListProps) {
183
192
  const isNested = parent.depth > 0;
184
193
  const controlWidth = props.controlWidth ?? parent.controlWidth;
185
194
  const actionWidth = props.actionWidth ?? parent.actionWidth;
195
+ const labelWidth = props.labelWidth ?? parent.labelWidth;
186
196
  const density = props.density ?? parent.density;
187
197
  return (
188
- <TaskListContext.Provider value={{ controlWidth, actionWidth, depth: parent.depth + 1, density }}>
198
+ <TaskListContext.Provider value={{ controlWidth, actionWidth, labelWidth, depth: parent.depth + 1, density }}>
189
199
  <View
190
200
  style={[
191
201
  styles.list,
@@ -344,32 +354,40 @@ export function TaskActions(props: { children: ReactNode }) {
344
354
  );
345
355
  }
346
356
 
357
+ interface TaskSubRowProps extends FieldAnnotationProps {
358
+ /** The field's NAME, rendered muted in the LIST's label column. Longer than the column and it
359
+ * WRAPS inside it — it is never clipped, and it never moves its own value. */
360
+ label: ReactNode;
361
+ /** The value's control — a form-variant `Inline*` editor, a `Text`. It FILLS the cell. */
362
+ children: ReactNode;
363
+ }
364
+
347
365
  /**
348
366
  * ONE of the task's own FIELDS, hung under the row: its NAME, then its VALUE, adjacent, indented
349
367
  * one step so it reads as the task's.
350
368
  *
351
- * The sub-rows of one task share a label column sized to THEIR content local to this task,
352
- * never a width the list declares and never one the caller passes. A label and its value sit
353
- * next to each other because that is how the eye pairs them; a column shared with every other
354
- * row on the surface put a two-word name a quarter of a screen from its input, and no amount of
355
- * alignment made that legible.
369
+ * **The label column belongs to the LIST** (`TaskList labelWidth`, defaulting to the kit's
370
+ * `DETAIL_LABEL_WIDTH`), so every sub-row on the surface starts its value on ONE x, at every
371
+ * depth the same contract `DetailTable labelWidth` gives a record's fields, because a task's
372
+ * field and a record's field are the same thing on two surfaces. A label longer than the column
373
+ * wraps inside it and its value does not move. The column each row sized to its OWN words was
374
+ * the bug: it aligned the labels of a task whose names happened to be short and staggered every
375
+ * other one, and a surface whose field names run long had a different value x on nearly every
376
+ * row. A column that is not shared is not a column.
356
377
  *
357
378
  * The value takes the row's slack from a readable minimum: on a narrow surface it drops onto its
358
379
  * own line under the label rather than compressing to a few characters, and on a wide one it
359
380
  * FILLS the content box, ending on the same x as the title above it and the ⋯ gutter beside it.
360
381
  *
382
+ * It annotates exactly like a `DetailRow` — `description` · `warning` · `error`, one shared
383
+ * anatomy (`field_annotations`). A field-level fault says so ON THE FIELD; demoting it into a
384
+ * `TaskCaption`, which is a sentence about the ROW, says something different.
385
+ *
361
386
  * **Use `TaskDetail` instead when the block is not a label/value field** — a chart, a whole
362
387
  * table, a form with its own submit. (Subtasks are neither: they are a nested `TaskList`.)
363
388
  */
364
- export function TaskSubRow(props: {
365
- /** The field's NAME, rendered muted beside its value. */
366
- label: ReactNode;
367
- /** The value's control — a form-variant `Inline*` editor, a `Text`. It FILLS the cell. */
368
- children: ReactNode;
369
- /** Persistent guidance under the value ("The password is never stored"), on the control's
370
- * own text inset. Not a state: a fault or a warning about the ROW is a `TaskCaption`. */
371
- description?: string;
372
- }) {
389
+ export function TaskSubRow(props: TaskSubRowProps) {
390
+ const { labelWidth } = useContext(TaskListContext);
373
391
  // The sub-row's own first-line band. The row's band, unless the density's band cannot contain
374
392
  // a form control (a `dense` 32 row against a 40px inline editor) — the label's slack and the
375
393
  // control's centre are then BOTH taken from 40, so they still meet on one y.
@@ -379,18 +397,17 @@ export function TaskSubRow(props: {
379
397
  <View
380
398
  style={[
381
399
  styles.subLabel,
382
- // Symmetric slack, not centring — a field name long enough to WRAP keeps its first
383
- // line on the control beside it, the same law `TaskTitle` follows against the ring.
384
- { paddingVertical: taskTitleSlack(band) },
400
+ // The LIST's column, and symmetric slack rather than centring — a field name long
401
+ // enough to WRAP keeps its first line on the control beside it, the same law
402
+ // `TaskTitle` follows against the ring.
403
+ { width: labelWidth, paddingVertical: taskTitleSlack(band) },
385
404
  ]}
386
405
  >
387
406
  <Text size="sm" color="muted">{props.label}</Text>
388
407
  </View>
389
408
  <View style={styles.subValue}>
390
409
  <View style={[styles.subControl, { minHeight: band }]}>{props.children}</View>
391
- {props.description !== undefined ? (
392
- <Text size="xs" color="muted" style={styles.subDescription}>{props.description}</Text>
393
- ) : null}
410
+ <FieldAnnotations description={props.description} warning={props.warning} error={props.error} />
394
411
  </View>
395
412
  </View>
396
413
  );
@@ -460,16 +477,18 @@ const styles = StyleSheet.create({
460
477
  // rhythm the row uses — and the same wrap: too narrow to seat both and the value drops to its
461
478
  // own line under the label.
462
479
  subRow: { flexBasis: "100%", flexDirection: "row", flexWrap: "wrap", columnGap: TASK_ROW_GAP },
463
- // `flexBasis: auto` the label is sized by its OWN words, clamped to the shared band the
464
- // floor and ceiling define. `flexShrink` lets it give way before it overflows a container
465
- // narrower than the floor. The inset puts its WORDS on the TITLE's x, not one step in: the
466
- // control gutter already says which rows are tasks and which are their fields, so an indent
467
- // on top of it only breaks the single left edge every line on the surface otherwise shares.
480
+ // A FIXED column, arriving as the list's `labelWidth` — never sized by this row's own words,
481
+ // which is what puts every value on one x. `flexShrink` is the safety valve alone: a
482
+ // container narrower than the whole column has no line to shrink against, so the label gives
483
+ // way rather than overflowing and it gives way by the same amount on every row of that
484
+ // list. The inset puts its WORDS on the TITLE's x, not one step in: the control gutter
485
+ // already says which rows are tasks and which are their fields, so an indent on top of it
486
+ // only breaks the single left edge every line on the surface otherwise shares. It is INSIDE
487
+ // the column, so the value starts the same distance from the row's edge as a `DetailRow`'s
488
+ // does on a record.
468
489
  subLabel: {
469
490
  flexGrow: 0,
470
491
  flexShrink: 1,
471
- minWidth: SUB_LABEL_MIN,
472
- maxWidth: SUB_LABEL_MAX,
473
492
  paddingLeft: TASK_TEXT_INSET,
474
493
  },
475
494
  // FILLS the rest of the row, exactly as `DetailRow`'s value column does — a sub-row is the
@@ -477,13 +496,12 @@ const styles = StyleSheet.create({
477
496
  // left the row reading as a fragment floating under a full-width one. A control too wide for
478
497
  // its content is that CONTROL's business (and `DetailRow` would have the same complaint), not
479
498
  // a reason for this row to end early.
480
- subValue: { flexGrow: 1, flexShrink: 1, flexBasis: SUB_VALUE_MIN, minWidth: 0 },
499
+ // The 2px `gap` is the air between the control line and any annotation block beneath it — the
500
+ // same rhythm `DetailRow` uses, and inert on the rows that say nothing.
501
+ subValue: { flexGrow: 1, flexShrink: 1, flexBasis: SUB_VALUE_MIN, minWidth: 0, gap: 2 },
481
502
  // The control CENTRES in the sub-row's band — the label's first line is pinned to the same
482
503
  // centre — and stretches to the cell's full width.
483
504
  subControl: { justifyContent: "center" },
484
- // The control's own text inset, so the guidance lines up with the value it describes rather
485
- // than with the editor's invisible box.
486
- subDescription: { paddingLeft: TASK_TEXT_INSET, marginTop: 2 },
487
505
  // A full line like the rest, except that its inset arrives as a MARGIN (the rule has to sit on
488
506
  // the title's text edge, so it cannot be padding). A 100% basis plus that margin is 9px wider
489
507
  // than the line, so `flexShrink` gives the 9 back and the block ends on the content edge with
package/src/section.tsx DELETED
@@ -1,101 +0,0 @@
1
- import { Pressable, View } from "react-native";
2
- import { Text } from "./text";
3
- import { useState, useImperativeHandle } from "react";
4
- import { Icon, IconName } from "./icon";
5
- import { Spacer } from "./spacer";
6
- import { FOCUS_RING } from "./control_surface";
7
- import { useFocusRing } from "./use_focus_ring";
8
-
9
- export interface SectionHandlers {
10
- expand: () => void;
11
- collapse: () => void;
12
- }
13
-
14
- interface SectionProps {
15
- children: React.ReactNode;
16
- title: string;
17
- description?: string;
18
- collapsible?: boolean;
19
- icon?: IconName;
20
- titleRight?: React.ReactNode;
21
- initialCollapsed?: boolean;
22
- ref?: React.Ref<SectionHandlers>;
23
- }
24
-
25
- export function Section(props: SectionProps) {
26
- const {
27
- children,
28
- title,
29
- icon,
30
- description,
31
- collapsible,
32
- titleRight,
33
- initialCollapsed = false,
34
- ref,
35
- } = props;
36
- const [collapsed, setCollapsed] = useState(collapsible && initialCollapsed);
37
-
38
- useImperativeHandle(ref, () => ({
39
- expand: () => {
40
- if (collapsible) {
41
- setCollapsed(false);
42
- }
43
- },
44
- collapse: () => {
45
- if (collapsible) {
46
- setCollapsed(true);
47
- }
48
- },
49
- }));
50
-
51
- const { focusVisible, focusProps } = useFocusRing();
52
-
53
- const titleComponent = collapsible ? (
54
- <Pressable
55
- {...focusProps}
56
- style={[{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }, focusVisible && { boxShadow: FOCUS_RING, borderRadius: 4 }]}
57
- onPress={() => setCollapsed(!collapsed)}
58
- >
59
- <Icon name={collapsed ? "chevron-right" : "chevron-down"} size={20} />
60
- {icon && <Icon name={icon} size={20} />}
61
- <Text size="md" weight="medium" userSelect="none">
62
- {title}
63
- </Text>
64
- </Pressable>
65
- ) : (
66
- <View style={{ flex: 1 }}>
67
- <Text size="md" weight="medium">
68
- {title}
69
- </Text>
70
- </View>
71
- );
72
-
73
- return (
74
- <View>
75
- <View
76
- style={{
77
- flexDirection: "row",
78
- alignItems: "center",
79
- height: 40,
80
- paddingBottom: 8,
81
- }}
82
- >
83
- {titleComponent}
84
- {titleRight}
85
- </View>
86
- {!collapsed && (
87
- <>
88
- {!!description && (
89
- <>
90
- <Text size="sm" color="zinc-500">
91
- {description}
92
- </Text>
93
- <Spacer size={16} />
94
- </>
95
- )}
96
- {children}
97
- </>
98
- )}
99
- </View>
100
- );
101
- }