@lotics/ui 12.1.2 → 13.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # @lotics/ui — the UI reference index
2
2
 
3
+ > **Upgrading to v13 from 12.x.** `RadioPicker`'s **`direction`** prop was removed — the
4
+ > picker is always the stacked column of full-width choices. A former `direction="row"`
5
+ > usage just drops the prop. Additively, `DetailRow` and `FormField` gained a **`warning`**
6
+ > field annotation (amber, the third tone between `description` and `error`).
7
+
3
8
  > **Upgrading to v12 from 11.x.** Two exports were removed: **`CommentComposer`** (compose
4
9
  > comments with THE kit `Composer` — attach via `actionsButton`/`files`; `CommentList` is
5
10
  > unchanged) and **`DetailRow.info`** (field annotations are explicit — use the new
package/docs/catalog.md CHANGED
@@ -95,8 +95,8 @@ Batch draft-form state → `useForm`.
95
95
 
96
96
  ### Edit a record's fields in place
97
97
 
98
- The `Inline*` family: `InlineTextInput` · `InlineNumberInput` · `InlineSelect` ·
99
- `InlineMemberSelect` · `InlineTagSelect` · `InlineDatePicker` · `InlineTimePicker`; a
98
+ The `Inline*` family: `InlineTextInput` · `InlineNumberInput` · `InlineSelect` (single or
99
+ `multi`) · `InlineMemberSelect` · `InlineDatePicker` · `InlineTimePicker`; a
100
100
  READ-ONLY field in that same column uses `InlineStatic` (matches the editor box exactly, no
101
101
  input chrome, so it aligns pixel-for-pixel). A stack of labelled field rows lives in
102
102
  `DetailTable` + `DetailRow`; the record's identity band is `RecordSummary`; its money
@@ -105,10 +105,9 @@ summary is `Ledger`.
105
105
  ### Tasks / to-dos — pick by ALTITUDE
106
106
 
107
107
  A task-management PAGE (many tasks, grouping, filters, expandable rows) is COMPOSITION —
108
- there is NO Task component (a Reminders row and a Linear column-grid share almost nothing):
109
- `CheckCircle` + a struck `InlineTextInput` + your meta cells; see
110
- [`tpl_tasks`](../examples/tpl_tasks.tsx) (quick list) and
111
- [`tpl_task_board`](../examples/tpl_task_board.tsx) (columns). A record's own small CHECKLIST
108
+ there is NO Task component: `CheckCircle` + a struck `InlineTextInput` + your meta cells
109
+ (`InlineDatePicker`/`InlineMemberSelect`/`InlineSelect`, all `variant="cell"`); see `tpl_record`'s
110
+ Tasks section (the canonical task pattern) and [`tpl_task_board`](../examples/tpl_task_board.tsx) (columns). A record's own small CHECKLIST
112
111
  (the 5–8 tasks living in a drawer/section) is the `Checklist`/`ChecklistRow` compound +
113
112
  `SuggestionChip` commons + `CaptureRow` — see [`tpl_record`](../examples/tpl_record.tsx) /
114
113
  [`tpl_item_list`](../examples/tpl_item_list.tsx).
@@ -342,10 +341,15 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
342
341
 
343
342
  - **`card`** — `Card` · `CardHeader` · `CardHeaderTitle` (+ `info` ⓘ popover) ·
344
343
  `CardHeaderMeta` · `CardBody` · `CardFooter`.
344
+ - **`inset`** — `Inset`: a tinted, recessed content surface (zinc-50 fill · 10 radius · 14
345
+ pad · 12 gap) — the "well" INSIDE a Card/Section for a grouped sub-form, an inline fill
346
+ editor, or a nested block. **NOT a `Callout`:** a Callout is a status BAND (and
347
+ `warning`/`error` render as an ARIA `alert`) — wrapping form fields in an alert is wrong.
348
+ The law: **content + fields → `Inset`; a message → `Callout`.**
345
349
  - **`section_heading`** — `Section` · `SectionHeading` · `SectionHeadingTitle` ·
346
350
  `SectionHeadingMeta` · `Subsection` · `SubsectionHeading` · `SubsectionHeadingTitle` — the
347
351
  card-less twin of the Card family, compound, owns no margin; spacing via the Section gap
348
- (12, fixed), no body component. `SectionHeadingTitle` is ALWAYS `##` (xl semibold;
352
+ (16, fixed), no body component. `SectionHeadingTitle` is ALWAYS `##` (xl semibold;
349
353
  `weight="medium"` opt-down only) + `info` for an ⓘ provenance popover after the title,
350
354
  same as `CardHeaderTitle.info`. `SubsectionHeadingTitle` is the `###` lg-semibold level-3
351
355
  title of a named group inside a section (same `info` ⓘ affordance as the section title) —
@@ -356,10 +360,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
356
360
  `SectionHandlers.expand/collapse`). **Warning:** two modules export a `Section` — the
357
361
  layout grammar's card-less region is the one in `section_heading`; import from
358
362
  `@lotics/ui/section` only when you specifically want this collapsible titled block.
359
- - **`section_stack`** — `SectionStack` · `SubsectionStack` — divided stacks that own the
360
- between-block law: a fixed beat + hairline `Divider` BETWEEN blocks, skipping null
361
- children (56 for the flat page's Sections, 24 for a section's Subsections); stop
362
- hand-rolling gap + `<Divider />` pairs.
363
+ - **`section_stack`** — `SectionStack` · `SubsectionStack` — stacks that own the
364
+ between-block law, skipping null children: `SectionStack` = a fixed 56px beat + a hairline
365
+ `Divider` between top-level blocks; `SubsectionStack` = a fixed 32px beat, space-only (no
366
+ hairline — titles carry the grouping, `divided` an opt-in for a rare headingless stack);
367
+ stop hand-rolling gap + `<Divider />` pairs.
363
368
  - **`section_card`** — `SectionCard`: a titled card with a one-line description and an
364
369
  optional hairline-set `footer` (where `TrendFooter` and source notes go).
365
370
  - **`stack`** — `Stack`: gap-spaced row/column; `useSeparator` inserts `Separator`s between
@@ -434,6 +439,15 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
434
439
  - **`pressable_row`** — `PressableRow`: THE register row — full-width hover/open wash,
435
440
  `selected` (the open record) vs `marked` (ticked in bulk-select), forwards `ref` for
436
441
  popover anchoring.
442
+ - **`linked_record_box`** — `LinkedRecordBox`: a bordered box scoping ANOTHER record's data
443
+ (`icon` · `name` · `subtitle` · vertical `facts`), the WHOLE box a keyboard door into its
444
+ detail (`onOpen`/`doorLabel`); verbs ride an `actions` slot in a hairline-fenced footer at the
445
+ bottom (destructive LEFT, go-to RIGHT — the divider draws itself when `actions` is present, so
446
+ don't hand-add one). Use it wherever a record points at another (a shipment's customer, an invoice's
447
+ party, a case's sibling). It **enforces the a11y contract** copy-pasting got wrong: a
448
+ container with interactive descendants is never `role="button"` (invalid HTML) — an internal
449
+ empty door sibling carries the tab stop / name / focus ring, verbs lift above via `zIndex`.
450
+ The reassign picker / empty state that swaps in for the box is the consumer's.
437
451
  - **`pressable_highlight`** — `PressableHighlight`: the hover-wash + keyboard-focus-ring
438
452
  `Pressable` under `MenuButton`/`Switcher`/custom pressable surfaces; its style-fn/children
439
453
  receive `hovered` + `focusVisible`.
@@ -453,12 +467,16 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
453
467
 
454
468
  ### Badges, status & feedback
455
469
 
456
- - **`badge`** — `Badge`: the tonal pill; `variant="dot"` = pill-less colored dot + label,
457
- the LIGHT state indicator.
470
+ - **`badge`** — `Badge`: a STATUS indicator (lifecycle state / risk / quality cue), the ONLY
471
+ thing it's for. **Default `variant="dot"`** (pill-less colored dot + label, the light indicator);
472
+ `variant="tonal"` is the heavy filled pill — opt in sparingly for the ONE prominent status of a
473
+ surface. **NOT for a type / category / attribute / count — that's inline `Text`, never a Badge**
474
+ (see composition.md "Badge is for status only"). A bare `<Badge>` is `dot`.
458
475
  - **`status_badge`** — `StatusBadge`: an enabled/disabled pulse badge (`enabled` + `label`).
459
476
  - **`option_badge`** — `OptionBadge`: a select value as its configured colored badge.
460
477
  - **`callout`** — `Callout` · `CalloutTitle` · `CalloutText` · `CalloutActions` (`tone`
461
- info|success|warning|error|neutral): inline status band.
478
+ info|success|warning|error|neutral): inline status band — a MESSAGE (`warning`/`error`
479
+ are ARIA `alert`s). For a form / nested content surface use **`Inset`**, never a Callout.
462
480
  - **`empty_state`** — `EmptyState`: centered placeholder for an empty list/filter result —
463
481
  `message` + `hint`, optional `icon` anchor and `action` CTA.
464
482
  - **`completion_state`** — `CompletionState`: the "all done" terminal state.
@@ -506,13 +524,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
506
524
  the tint; override via `style` only when a surface genuinely wants otherwise.
507
525
  - **`number_input`** — `NumberInput`: numeric input; `format` for currency/units.
508
526
  - **`search_input`** — `SearchInput`: the search box for toolbars/filters.
509
- - **`form_field`** — `FormField` + `useFormField`: label / description / error / `optional`
510
- marker wrapper; the fieldset grid cell (`half`/`full` widths see the data-entry patterns
511
- doc indexed in [AGENTS.md](../AGENTS.md)).
527
+ - **`form_field`** — `FormField` + `useFormField`: label / description / warning / error /
528
+ `optional` marker wrapper (the three field-annotation tones muted / amber / danger — all
529
+ wired into the input's describedby); the fieldset grid cell (`half`/`full` widths — see the
530
+ data-entry patterns doc indexed in [AGENTS.md](../AGENTS.md)).
512
531
  - **`form_text_input`** — `FormTextInput`: `FormField` wrapping a `TextInputField`; the
513
532
  one-line labeled text field. Controls without a Form\* twin just wrap in `FormField`.
514
533
  - **`form_switch`** — `FormSwitch`: the FormField-labeled twin of `Switch` — toggle +
515
- clickable `label` (pressing it flips the value) + optional `description`/`error`; the
534
+ clickable `label` (pressing it flips the value) + optional `description`/`warning`/`error`; the
516
535
  settings-form boolean row. A bare toggle in a cell/toolbar → `Switch`; a full-row menu
517
536
  toggle → `SwitchButton`.
518
537
  - **`form_picker`** — `FormPicker`: `FormField` wrapping a `Picker` — one labeled native
@@ -558,20 +577,41 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
558
577
  INSIDE the control and inline error, commit on blur (Enter saves, Escape reverts) or
559
578
  `controls="buttons"`; KEYBOARD focus on the closed view opens edit mode with the input
560
579
  focused (type → Tab → type — see the data-entry keyboard contract), pointer focus never
561
- does; `background="tint" | "transparent"` (the zinc-50 editability chip vs
562
- flat for dense uniformly-editable surfaces).
580
+ does; **`variant: "form" | "cell"`** is THE axis that separates a form field from a data-grid
581
+ cell (see below).
563
582
  - **`inline_text_input`**, **`inline_number_input`** (`format` for currency/units),
564
583
  **`inline_select`**, **`inline_member_select`**, **`inline_date_picker`**
565
584
  (`format="datetime"`, `optionalTime`; keyboard focus opens the TYPED segmented `DateField`
566
585
  — locale field order, separator advances, Alt+ArrowDown floats the calendar; click still
567
- opens the calendar popover), **`inline_time_picker`** — the Inline\* per-field
568
- editors; `InlineSelect`/`InlineMemberSelect` render the resting value like its option
586
+ opens the calendar popover), **`inline_time_picker`** — the inline field editors (a form field by
587
+ default, a grid cell with `variant="cell"` see the split below);
588
+ `InlineSelect`/`InlineMemberSelect` render the resting value like its option —
569
589
  `renderOptionContent` by default, `renderSelected` to override — a chip/badge at rest, not
570
- just text.
571
- - **`inline_tag_select`** `InlineTagSelect`: the MULTI inline editor a tag SET in the
572
- inline vocabulary: selected tags render as badges inside the standard chip (`renderTag`),
573
- clicking floats a multi `OptionList` (checkbox rows), CLOSING commits the new set in one
574
- `onSave` never a borderless `Select` posing as an inline field.
590
+ just text. **`InlineSelect` is single OR multi** — pass `multi` for a tag SET (`value: T[]`,
591
+ commits the new set on popover-CLOSE; selected tags render as badges via `renderSelected`),
592
+ mirroring `Select`'s `multi` axis; there is NO separate tag component. Both modes take
593
+ `allowCustom` (a create-a-tag/option row) + `searchable`. **`InlineMemberSelect` takes `avatarOnly`**
594
+ the resting display is the bare AVATAR (no name/chevron, a dashed "add" ghost when unset) for a
595
+ DENSE row where the name won't fit (a task-row assignee); the dropdown rows still show avatar + name.
596
+ `InlineDatePicker` takes a **`tone`**
597
+ (`TextColor`) that colours the resting date by
598
+ semantic state — overdue red, soon-due amber (the caller owns the rule, e.g. a task's `dueTone`);
599
+ the value text IS the signal, no separate dot.
600
+ **The form/cell split — one `variant`, not a second family.** Every `Inline*` editor is BOTH a
601
+ form field and a data-grid cell; `variant` picks which:
602
+ - **`variant="form"`** (default) — a zinc-50 chip at rest + a hover-BORDER. THE editability
603
+ affordance on a surface that MIXES editable and static values (a `DetailTable` / record field).
604
+ - **`variant="cell"`** — transparent at rest + a background-tint WASH on hover (`zinc-100` /
605
+ `zinc-200` press — the same `PressableHighlight` language as the row/cell beside it), and it reads
606
+ VALUE-FIRST: the date drops its resting calendar glyph, a select drops its chevron (the column
607
+ header + the uniformly-editable grid are the affordance; the saving spinner still shows). For a
608
+ DENSE, uniformly-editable surface — a register/`DataGrid` column, a task row (worked example:
609
+ `tpl_task_board`'s columns, `tpl_record`'s Tasks section).
610
+
611
+ There is NO separate `*Cell` component family — a data-grid cell is `<InlineSelect variant="cell" …/>`,
612
+ not a `SelectCell`. (Naming a component for a USE, and duplicating the picker stack to flip two
613
+ style properties, are both things the kit forbids.) A custom pressable cell you hand-roll should
614
+ match the `variant="cell"` language: the `PressableHighlight` wash, never a border.
575
615
  - **`inline_static`** — `InlineStatic`: a READ-ONLY value matching the Inline\* box metrics
576
616
  EXACTLY (height, padding, 1px transparent border) so a non-editable field — a computed
577
617
  total, a system ID, a synced/locked value — aligns pixel-for-pixel in the same column;
@@ -583,15 +623,16 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
583
623
  edit; optional `trailing` slot renders a right-side action/badge after the value (units
584
624
  belong IN the value via `InlineNumberInput format`). The FIELD-ANNOTATION vocabulary (same
585
625
  names + meanings as `FormField`), always EXPLICIT — a row never hides guidance behind an ⓘ:
586
- **`description`** = persistent guidance, under the VALUE (stacked mode mirrors the form
587
- order label · description · control); **`error`** = field-level failure under the value,
588
- danger + alert semantics (the `Inline*` editors already render their own transient save
589
- errors don't wire both); invalid/cross-field STATE = a co-located `Callout`; **`flat`**
626
+ **`description`** = a fact / persistent guidance (muted), under the VALUE (stacked mode
627
+ mirrors the form order label · description · control); **`warning`** = a consequence to weigh
628
+ before acting (amber, announced); **`error`** = field-level failure under the value, danger +
629
+ alert semantics (the `Inline*` editors already render their own transient save errors — don't
630
+ wire both); invalid/cross-field STATE = a co-located `Callout`; **`flat`**
590
631
  on a flat-text value row (`InlineStatic`, plain `Text`) tucks the annotation up by the
591
632
  control band's slack so its gap matches a chip row's.
592
633
  `DetailTable`: the compound parent of
593
634
  a row STACK — `labelWidth` (default 130) / `trailingWidth` / `minHeight` (default 40, the
594
- inline-control grid) declared ONCE + the 6px row gap; with `trailingWidth` every row
635
+ inline-control grid) declared ONCE + the 8px row gap; with `trailingWidth` every row
595
636
  reserves the trailing column so value cells share one width and trailing items align at
596
637
  one x, like a table. RESPONSIVE with no prop: it measures its own container (onLayout, not
597
638
  the viewport — works inside a Drawer; the unmeasured first frame renders opacity-0 so the
@@ -370,6 +370,11 @@ All view controls are 40px tall (`CONTROL_HEIGHT`), `sm` labels, in ONE wrapping
370
370
  - **Inline status**: `<Callout tone="info|success|warning|error|neutral">` — compound (like
371
371
  Card): compose `CalloutTitle`/`CalloutText`/`CalloutActions` inside. `Callout` is INLINE;
372
372
  `Alert` is the blocking modal; `Badge` is a one-word pill.
373
+ - **Nested content / a sub-form goes in an `Inset`, NEVER a `Callout`.** A Callout is a
374
+ MESSAGE, and `warning`/`error` render as an ARIA `alert` — wrapping form fields in an alert
375
+ makes a screen reader announce the whole form as one time-sensitive alert. `<Inset>` is the
376
+ tinted "well" for a grouped sub-form / inline fill editor / nested block: **fields + content
377
+ → `Inset`; a message → `Callout`.**
373
378
  - **One primary action per surface.** Everything else secondary/muted; destructive = `danger`
374
379
  styling + an explicit label. (A grouped BUILDER section — line items — MAY give its own Add
375
380
  action `primary`; it sits at a different altitude than the form's one terminal commit.)
@@ -407,29 +412,50 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
407
412
 
408
413
  ## Field annotations & linked records
409
414
 
410
- - **The field-annotation vocabulary** (same names + meanings on `FormField` and `DetailRow`,
411
- always EXPLICIT never a hidden ⓘ on rows): **`description`** = persistent guidance (under
412
- the VALUE on a horizontal row — the label column has no room for prose; stacked mode wears
413
- the form order label · description · control); **`error`** = field-level failure under the
414
- value, danger + alert semantics (the `Inline*` editors already render their own transient
415
- save errors); cross-field / record-level STATE = a co-located `Callout`. Rows share ONE
415
+ - **The field-annotation vocabulary** — THREE tones, same names + meanings on `FormField` and
416
+ `DetailRow`, always EXPLICIT (never a hidden ⓘ on rows): **`description`** (info, muted) = a
417
+ content/domain fact or persistent guidance, under the VALUE on a horizontal row — the label
418
+ column has no room for prose; stacked mode wears the form order label · description · control;
419
+ **`warning`** (amber) = a consequence the user should weigh before acting, announced on
420
+ appearance; **`error`** (danger + alert semantics) = an invalid state under the value (the
421
+ `Inline*` editors already render their own transient save errors); cross-field / record-level
422
+ STATE = a co-located `Callout`. Every tone is USEFUL AND TERSE — a short clause stating the
423
+ fact or consequence, never widget mechanics, never a second sentence; `warning` marks a
424
+ consequence, not decoration. Rows share ONE
416
425
  alignment law: the row top-aligns and label · control · trailing each center within the
417
426
  first control line — a tall value block never drags the label. A FLAT value row
418
427
  (`InlineStatic`, plain `Text`) sets **`DetailRow flat`**: the annotation tucks up by the
419
428
  control band's slack so the perceived gap under the text equals the gap under a chip —
420
429
  the band's invisible bottom half must never read as a hole above the description.
421
430
  - **A LINKED record never renders as field rows** — another record's data must not read as
422
- fields on THIS one. The reference shape (`tpl_record`'s Customer/Handoff, inline no
423
- wrapper component): a BORDERED, fully pressable box (glyph anchor · identity · facts
424
- stacked vertically) whose press opens the record's detail drawer (read-only rows + the
425
- navigate verb); the action row sits INSIDE the box — destructive LEFT in danger, go-to
426
- RIGHT. Editing the linked record happens on ITS page, never here. The press ANATOMY:
427
- the box is a `PressableRow` (role-less, non-focusable surface hover wash spans the
428
- nested verbs) with the keyboard DOOR an EMPTY absolutely-positioned `role="button"`
429
- sibling carrying the tab stop, the accessible name and the focus ring — beneath the
430
- content, and the interior verbs lifted above it via `zIndex: 1`. NEVER `role="button"`
431
- on a container with interactive descendants: a button must not contain a button
432
- (invalid HTML, a hydration error, and AT double-announcement).
431
+ fields on THIS one. Use **`LinkedRecordBox`** (`tpl_record`'s Customer/sibling are the worked
432
+ examples): a BORDERED, fully pressable box (glyph anchor · identity · facts stacked
433
+ vertically) whose press opens the record's detail drawer; the verbs ride its `actions` slot
434
+ in a hairline-fenced footer INSIDE the box — destructive LEFT in danger, go-to RIGHT. One verb
435
+ set across every box of a group (a party wall's five links all read "Gỡ", not a mix of
436
+ "Đổi"/"Gỡ"): with a clearable link, changing = remove the empty-state picker re-prompts, so
437
+ the destructive verb covers both required anchors and optional adds. Editing the linked record happens on
438
+ ITS page, never here.
439
+ **The empty / attach state is a find-or-create `Combobox`, never a bare picker** (`tpl_record`'s
440
+ Customer is the worked example): rows are `renderOptionContent`-rich, NOT a wall of bare names
441
+ the premium shape is a **glyph tile (28·radius 7·zinc-100) · the name over a muted metadata line**
442
+ that carries the type + identifying fact as TEXT (`type · code · city`), joined by "·" and free to
443
+ wrap the row taller. A type/attribute is **NOT a status, so it is text, never a `Badge`** (see
444
+ Badge discipline below). The row should read like the `LinkedRecordBox` it becomes — reuse the same
445
+ kind-glyph. (The kit suppresses `renderOptionContent` on the `allowCustom` "Create …" row so it keeps its plain + look.)
446
+ Plus `allowCustom` + `customOptionPlacement:"top"` + `customOptionLabel={q => 'Create "…"'}`. In
447
+ `onValueChange`, an existing pick attaches; the custom row opens a create **`Dialog` prefilled with
448
+ the typed query** (mint the record + link it) — so a party absent from the registry is created
449
+ WITHOUT leaving the record. `customOptionPlacement:"top"` keeps the create row visible while the
450
+ keyboard highlight stays on the first MATCH, so Enter on a partial never makes a duplicate. A
451
+ linked record seeded by a workflow (not user-attached) skips the picker — show the box or an
452
+ `EmptyState`, no create affordance. The component OWNS the press ANATOMY (the error-prone part hand-rolling
453
+ got wrong): the box is a `PressableRow` (role-less, non-focusable surface — hover wash spans
454
+ the nested verbs) with the keyboard DOOR — an EMPTY absolutely-positioned `role="button"`
455
+ sibling carrying the tab stop, the accessible name and the focus ring — beneath the content,
456
+ the interior verbs lifted above it via `zIndex: 1`. NEVER `role="button"` on a container with
457
+ interactive descendants: a button must not contain a button (invalid HTML, a hydration error,
458
+ and AT double-announcement).
433
459
 
434
460
  ## Color discipline — solid / tint / ramp + ONE accent
435
461
 
@@ -452,16 +478,27 @@ selection washes).
452
478
  (emerald-700) — all AA on white. Map a dynamic `ColorName` to a token at the call site; hue nuance
453
479
  (rose vs orange) stays on `Badge`s. Links use `Link`/`TextLink` (blue-600), never `solid("blue")`.
454
480
 
455
- ## Status indicators have a WEIGHTmatch to prominence, never to a metric
481
+ ## `Badge` is for STATUS onlyeverything else is text
456
482
 
457
- Lightest heaviest:
483
+ A `Badge` means STATE that must read at a GLANCE — a lifecycle status, a risk level, a quality
484
+ cue. That is the ONLY thing it is for; keep the vocabulary scarce so a colored chip always MEANS
485
+ status. **A type / category / attribute / count is NOT a status — render it as inline `Text` (a
486
+ muted metadata line, free to wrap the row taller), NEVER a Badge.** A party's type, a document
487
+ kind, a city, "3 files" → text. If you're reaching for a Badge in a row or an option list "to
488
+ show more info," STOP — that's text. (The bug that set this rule: tonal type-pills sprinkled
489
+ through combobox option rows.)
458
490
 
459
- 1. **Colored `Text`** a verdict WORD in a header (the valence tokens above).
460
- 2. **`Badge variant="dot"`** — a categorical STATE in a scannable column / legend.
461
- 3. **`Badge`** (tonal pill, the default) — a record's PROMINENT status: a drawer/detail header.
491
+ When it IS a status, match weight to prominence lightest heaviest:
462
492
 
463
- A register's dense rows read lighter the row's primary status is `variant="dot"`, its drawer
464
- twin tonal. A `Badge` is never a metric value.
493
+ 1. **Colored `Text`** a verdict WORD in a header (the valence tokens above).
494
+ 2. **`Badge variant="dot"` (the DEFAULT)** a colored dot + label; a categorical STATE in a
495
+ scannable column / legend / inline. Reach for this by default; a bare `<Badge>` is `dot`.
496
+ 3. **`Badge variant="tonal"`** — a filled pill, the HEAVY exception: the ONE prominent status of
497
+ a surface (a drawer/detail header). Opt IN explicitly and sparingly; one per view, never
498
+ sprinkled through rows.
499
+
500
+ A register's dense rows read lighter — the row's primary status is `dot`, its drawer twin may go
501
+ tonal. A `Badge` is never a metric value.
465
502
 
466
503
  ## Typography
467
504
 
@@ -13,13 +13,14 @@ in [the templates](./templates.md) (`examples/tpl_*.tsx`).
13
13
  | You're capturing… | Reach for | Why |
14
14
  |---|---|---|
15
15
  | an EXISTING record's fields | [**Inline edit**](#inline-edit--the-preferred-way-to-edit-an-existing-record) (`Inline*`) | edit in place, no form mode |
16
+ | a value in a DENSE ROW / register `DataGrid` cell (a task's due · assignee · status) | the SAME `Inline*` editor in **`variant="cell"`** | transparent at rest + a background-tint wash on hover (not the form's chip + border), value-first (the date drops its calendar glyph) — one editor per value type, `variant` picks form vs grid; there is no separate `*Cell` family |
16
17
  | a brand-NEW record | **create-then-refine** (`tpl_record`: "New" is ONE CLICK → a fresh Draft; everything refines in place on the record surface) | nobody fills 5 sections in one sitting; the surface is the editor |
17
18
  | a RELATED record (pick or make) | [**find-or-create**](#find-or-create--the-combobox-family-is-the-control) (`Combobox allowCustom`) | one control covers both |
18
19
  | REPEATING rows you build & revise | [**line items**](#line-items--create--preview--edit-a-composition-not-a-primitive) (create→preview→edit) | add / edit / remove, live totals |
19
20
  | CHARGES that bill onto documents | [**billing**](#billing--the-invoice-document-is-the-unit) (`tpl_record` Billing section) | the invoice document is the unit |
20
21
  | a record's FEE/charge SUMMARY | [**`Ledger`**](#fee-summary--ledger) (worked example: `tpl_item_list` drawer) | grouped money lines, one emphasized total — no bars/charts |
21
22
  | a multi-value TAG field | [**`Select multi`**](#tag--multi-value-field--select-multi) (`renderSelected` → `Chip`) | chips composed, not a separate control |
22
- | ONE choice from a small visible set | **`ChipGroup` pills** (or `RadioPicker` — column when options carry descriptions, row when short) | required single-select, one tap, every option visible |
23
+ | ONE choice from a small visible set | **`ChipGroup` pills** (or `RadioPicker` — a stacked column of full-width choices, right when options carry descriptions) | required single-select, one tap, every option visible |
23
24
  | ONE pick from a BIG registry (ports, projects…) | on a RECORD surface: **`InlineSelect searchable`** (search lives in the popover); on a form/attach surface: **`Combobox`** with `value` | an inline row stays an inline editor — a persistent search input breaks the surface's grammar |
24
25
  | a YES/NO on a record | **`CheckboxInput`** as the `DetailRow` value | a boolean needs no editor mode |
25
26
  | a STATUS with terminal outcomes | [**disposition**](#disposition--lifecycle-status-is-asymmetric-by-phase) (open → resolve → revise) | guides the decision |
@@ -71,7 +72,7 @@ calendar popover stays the pointer path (click the resting value, as ever) — i
71
72
  the only path. A PARTIAL entry never commits and never clears the stored value: the field
72
73
  stays in edit mode showing the `datePicker.invalidDate` inline error until fixed or Escaped.
73
74
 
74
- The POPOVER editors (`InlineSelect` / `InlineMemberSelect` / `InlineTagSelect`) deliberately
75
+ The POPOVER editors (`InlineSelect` / `InlineMemberSelect`) deliberately
75
76
  do NOT auto-open on focus — a popup opening on Tab arrival traps traversal; per the WAI-ARIA
76
77
  combobox contract they open on Enter/Space from the keyboard.
77
78
 
@@ -94,16 +95,16 @@ One per type:
94
95
  ADD/REMOVE a time — the value's own shape, date vs datetime, is the source of truth
95
96
  (`optionalTime` is ignored when `format="datetime"`).
96
97
  - **`InlineTimePicker`** — a time-only value.
97
- - **`InlineTagSelect`** — the MULTI member a tag SET in the inline vocabulary: selected tags
98
- render as badges inside the standard chip, clicking floats a multi `OptionList` (checkbox rows),
99
- CLOSING commits the new set in one `onSave` — never a borderless `Select` posing as an inline
98
+ - **`InlineSelect multi`** — the MULTI mode of `InlineSelect` (there's no separate tag component):
99
+ a tag SET (`value: T[]`), selected tags render as badges inside the standard chip, clicking floats
100
+ a multi `OptionList` (checkbox rows), CLOSING commits the new set in one `onSave` — never a borderless `Select` posing as an inline
100
101
  field.
101
102
 
102
103
  They split across two shared shells. The same-height **input-swap** editors (`InlineTextInput`,
103
104
  `InlineNumberInput`, `InlineTimePicker`) render **`InlineEditFrame`** driven by the
104
105
  **`useInlineEdit`** hook (the view⇄edit toggle, draft buffer, and async save) — swap a custom
105
106
  input into that pair to join the family. The **popover** editors (`InlineSelect`,
106
- `InlineMemberSelect`, `InlineDatePicker`, `InlineTagSelect`) instead compose **`InlineEditView`**
107
+ `InlineMemberSelect`, `InlineDatePicker`) instead compose **`InlineEditView`**
107
108
  (the resting box, wired as the popover trigger) with their OWN `useState` for open/draft/saving/
108
109
  error — a pick, not a keystroke, is the edit, so `useInlineEdit` doesn't fit. `onSave` is async
109
110
  either way: the saving spinner sits INSIDE the control at its right edge (never a sibling — that
@@ -118,7 +119,7 @@ list's active-highlight and doubles the footer hairline), and never a persistent
118
119
  cell (noise on a dense board, and a pressable nested in the trigger is invalid DOM). `onSave`'s
119
120
  `next` stays non-null — a caller opts in per field; the clear fires `onClear`, which writes `null`
120
121
  (the app workflow must ACCEPT null on that input — a `select`/`date`/`member` field clears on
121
- null). `InlineTagSelect` (multi) needs no `onClear` — an empty set is already a valid `onSave`.
122
+ null). `InlineSelect multi` needs no `onClear` — an empty set is already a valid `onSave`.
122
123
 
123
124
  ### The row stack — `DetailTable` + `DetailRow`
124
125
 
@@ -127,19 +128,20 @@ A STACK of rows lives in a `DetailTable` (label · value · trailing laid out li
127
128
  zinc-50 chips need) holding `DetailRow`s — set `trailingWidth` when ANY row carries a trailing
128
129
  action/badge, so EVERY row reserves the column and one row's `Copy` button never makes its editor
129
130
  narrower than its neighbours'. Rows share ONE alignment law: the row top-aligns and label ·
130
- control · trailing each center within the first control line — annotations (`description` under
131
- the value, `error` in danger with alert semantics; both indented to the chip's 8px text inset)
132
- grow the row downward without dragging the label. The full annotation vocabulary lives in
133
- composition.md §Field annotations.
131
+ control · trailing each center within the first control line — annotations (the three tones
132
+ under the value: `description` muted · `warning` amber · `error` danger, each indented to the
133
+ chip's 8px text inset) grow the row downward without dragging the label. The full annotation
134
+ vocabulary lives in composition.md §Field annotations.
134
135
 
135
136
  ### The editability affordance
136
137
 
137
- An editor AT REST sits on a zinc-50 chip — THE editability affordance: users see what's editable
138
- without hovering. `background="transparent"` opts a field out of the chip for DENSE,
139
- uniformly-editable surfaces (a task list/board where EVERY cell edits: the chip repeated
140
- everywhere is noise and distinguishes nothing; hover/focus still reveal the input). Keep the chip
141
- wherever editable and static values MIX. A `disabled` editor rests FLAT automatically — the chip
142
- is the editability promise, and an inert field must not make it.
138
+ A `variant="form"` editor (the default) AT REST sits on a zinc-50 chip — THE editability affordance:
139
+ users see what's editable without hovering, and it hovers via a BORDER (an input's edge). Keep it
140
+ wherever editable and static values MIX (a `DetailTable` / record field). `variant="cell"` opts out
141
+ of the chip transparent at rest + a background-tint WASH on hover — for DENSE, uniformly-editable
142
+ surfaces (a register/`DataGrid` column, a task row where EVERY cell edits: the chip repeated
143
+ everywhere is noise, and the wash matches the row/cell it sits in). A `disabled` editor rests FLAT
144
+ automatically — the chip is the editability promise, and an inert field must not make it.
143
145
 
144
146
  A READ-ONLY value in the same column — a computed total, a system ID, a synced/locked field — is
145
147
  **`InlineStatic`**: it copies the editor box metrics exactly (height, padding, 1px transparent