@lotics/ui 43.4.0 → 43.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/AGENTS.md +3 -2
  2. package/MIGRATION.md +93 -0
  3. package/docs/catalog.md +128 -26
  4. package/docs/composition.md +380 -9
  5. package/docs/data_entry.md +25 -0
  6. package/docs/reviewing.md +477 -0
  7. package/docs/templates.md +9 -6
  8. package/examples/tpl_attendance.tsx +0 -1
  9. package/examples/tpl_item_list.tsx +109 -54
  10. package/examples/tpl_record.tsx +89 -4
  11. package/package.json +7 -3
  12. package/src/avatar.tsx +29 -29
  13. package/src/avatar.web.tsx +32 -31
  14. package/src/avatar_props.ts +66 -0
  15. package/src/avatar_tone.ts +79 -0
  16. package/src/button.tsx +36 -4
  17. package/src/checkbox.tsx +4 -1
  18. package/src/choice_list.tsx +5 -3
  19. package/src/color_tokens.ts +15 -14
  20. package/src/composer.tsx +3 -3
  21. package/src/control_surface.ts +37 -9
  22. package/src/copy_button.tsx +8 -1
  23. package/src/counter.tsx +1 -1
  24. package/src/data_grid.tsx +6 -3
  25. package/src/date_calendar.tsx +4 -2
  26. package/src/date_range_filter_field.tsx +5 -1
  27. package/src/date_segments_field.tsx +2 -2
  28. package/src/file_drop_target.web.tsx +2 -2
  29. package/src/file_dropzone.tsx +12 -6
  30. package/src/file_rows.tsx +22 -2
  31. package/src/file_thumbnail.tsx +19 -3
  32. package/src/font_family.ts +1 -1
  33. package/src/font_family.web.ts +1 -1
  34. package/src/funnel.tsx +1 -1
  35. package/src/icon_button.tsx +5 -2
  36. package/src/index.css +22 -16
  37. package/src/inline_edit.tsx +9 -10
  38. package/src/inline_files.tsx +6 -0
  39. package/src/json_panel.tsx +1 -1
  40. package/src/kpi_card.tsx +1 -1
  41. package/src/locale.tsx +1 -1
  42. package/src/markdown.css +5 -1
  43. package/src/metric.tsx +12 -18
  44. package/src/number_input.tsx +2 -2
  45. package/src/option_picker.tsx +57 -0
  46. package/src/picker.tsx +2 -2
  47. package/src/pressable_row.tsx +15 -5
  48. package/src/progress_bar.tsx +1 -1
  49. package/src/radio_picker.tsx +2 -1
  50. package/src/search_input.tsx +12 -11
  51. package/src/sort_header.tsx +7 -3
  52. package/src/stacked_progress_bar.tsx +1 -1
  53. package/src/step_progress.tsx +5 -2
  54. package/src/switch.tsx +11 -6
  55. package/src/table.tsx +148 -16
  56. package/src/table_fit.ts +12 -0
  57. package/src/tabs.tsx +13 -1
  58. package/src/text.css +50 -18
  59. package/src/text.tsx +37 -40
  60. package/src/text_input_field.tsx +2 -2
  61. package/src/text_utils.ts +48 -6
  62. package/src/theme.ts +13 -0
  63. package/src/theme.web.ts +49 -0
  64. package/src/theme_vars.ts +113 -0
  65. package/src/type_ramp.ts +100 -0
  66. package/src/theme.tsx +0 -24
  67. package/src/theme.web.tsx +0 -79
  68. package/src/theme_context.ts +0 -107
package/AGENTS.md CHANGED
@@ -15,9 +15,10 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
15
15
  | Doc | Read it for |
16
16
  |---|---|
17
17
  | [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
18
- | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW — list + one CTA, `multiple` decides add-vs-replace; the `FilesEditor` COMPOUND for a whole section — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, whether a multiline value keeps its fixed reserve or `autoGrow`s (who decides the length — the field, or whoever is typing), the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
18
+ | [docs/data_entry.md](./docs/data_entry.md) | **a picker over a select field keeps the field's colours** (`optionPicker` — the hand-rolled `map` to `{value,label}` drops `color`, so a value reads coloured in a register and grey in the editor); Which editing pattern for which job — inline edit, fieldset forms, browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW — list + one CTA, `multiple` decides add-vs-replace; the `FilesEditor` COMPOUND for a whole section — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, whether a multiline value keeps its fixed reserve or `autoGrow`s (who decides the length — the field, or whoever is typing), the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
19
19
  | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; **after the run** — a stored record that fills up from several writers (a person, a chat agent, an extraction, an automation) and remembers none of them: an unwritten value must not render like a written one, a machine's prose and a person's must not share a treatment, model markdown goes in `variant="embedded"`, and the read path must project every field the write paths set; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md), the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer, **Stop** while streaming), **stopping** (`cancel` stops the run, `abort` only stops listening — so closing a dialog must `cancel` or it keeps billing); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
20
- | [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other), banded cards, register vs inset rows (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), the button ladder and **what underlined text may mean** (it GOES somewhere or REVEALS something — never mutates; blue leaves the surface, muted stays on it, and the one in-prose disclosure exception is scoped there), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), color discipline, typography, whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
20
+ | [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other), banded cards, register vs inset rows (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), the button ladder and **what underlined text may mean** (it GOES somewhere or REVEALS something — never mutates; blue leaves the surface, muted stays on it, and the one in-prose disclosure exception is scoped there), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), **the register's rhythm** (no hairline between rows — their own height and the hover wash separate them; the register's one line is the band capping the columns, because that is the break whitespace cannot state. Density is `TableRow`'s `minHeight`, never a mode), **a register that TRIAGES** (`TableGroup` — titled bands ordered by what needs the reader now, grouped by what implies a different ACTION rather than by a category already in a column), **the register's own craft** (a column header treated as CHROME a step lighter than row metadata, sentence case in the table header too, and a supporting line one RUNG BELOW the line it supports rather than a fixed size), **identity marks** (a mark that looks the same on every row carries nothing — `Avatar` derives its hue from the name and takes circle/square for person vs organization; the palette is ordered by contrast, not spectrum, and never paints a person red or amber), **where the accent goes** ("which of these" — a selected row, an active filter; never DATA and never the focus ring, while a page-scale position marker like an active TAB takes `primary`, the ink the kit already spends on "this is on"; and selection must not share a wash with hover), **theming is an app-level ESCAPE HATCH, not a product surface** (`applyLoticsTheme({ … })` once at boot, before `mount`, writing the `--lotics-*` roles on the document root; nothing in the platform writes a theme, and `apps.theme.color` is the launcher icon only — the five `var()` roles plus the three font rungs ARE the whole mechanism, and wrapping a component cannot replace them because RN-Web writes `fontFamily` per element and background/border do not inherit), color discipline, **typography** (the ramp is DATA in `type_ramp`, one table both platforms derive from; leading and tracking are baked per rung and a hand-set `letterSpacing` is always a second copy of the curve), whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
21
+ | [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built** — the other docs say what good looks like; this one says how to find out whether you achieved it, because "it looks fine" is the same claim as "it should work" made with the same evidence. Measure, never eyeball: render it, extract computed values, let the table show the defect. Two GATES that outrank every treatment question (**SUBTRACT** — what question does this element answer, and count FACTS not elements; **RIGHT CONTROL** — name the species before treating it), then 10 probes each naming what to COLLECT and the SIGNATURE in the numbers: type inventory (range, singletons, the label/value shrink war), ground+border+radius across siblings, what each RULE separates, gap RATIOS, the state diff (paint may change, geometry may not), alignment drift (incl. a centred child that moves while its container measures identical), the surface walk (open it, press it, read the settled string), the DATA probe (what is really in the table — incl. grepping rendered text for internal ids), the ABSENCE pass that catches "bland", and microcopy. Plus the extraction snippets, what measurement cannot see, and how to act on a finding (fix the CLASS, and never resolve one with "the docs say X"). |
21
22
  | [docs/testing.md](./docs/testing.md) | Driving the kit in a browser — the three anatomies where the a11y tree says one thing and a driver must do another: a `PressDoor` row whose named button always intercepts pointer events (by design), portalled overlays that render at the top of the DOM, and custom pointer drag that `dragTo` cannot move. |
22
23
  | [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget) and the ACTIVITY shape — a communications feed where the row's label is the GIST and the body varies by medium, one anatomy rather than a row type per kind. |
23
24
 
package/MIGRATION.md CHANGED
@@ -4,6 +4,99 @@ 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
+ ## 43.5.0 — registers, type and theming all change appearance
8
+
9
+ No API is removed and nothing throws, but four DEFAULTS moved, so every register and every avatar
10
+ in your app renders differently on upgrade. Each is reversible at the call site if you need the
11
+ old look while you catch up.
12
+
13
+ **Table column headers are sentence case, not all-caps** — and one ink step lighter
14
+ (`zinc-500`). `Table` and `SortHeader` were uppercasing every column label, which the heading ramp
15
+ bans and names the #1 templated tell. The ink step is not cosmetic and cannot be skipped: at
16
+ `muted` the header matched a row's supporting line in size, weight AND colour, so dropping the
17
+ case alone leaves it reading as a stray label rather than as the band naming the columns.
18
+
19
+ **Row separators are lighter than the header rule.** The band capping a table keeps
20
+ `colors.border`; the hairlines between rows are `zinc-100`. Every rule used to be `border`, which
21
+ is what makes a register read as a grid instead of a list with a heading.
22
+
23
+ **`Avatar` grounds its initials in a hue derived from `name`**, not `colors.accent`. A register of
24
+ people used to be N identical discs — the largest, most colourful element in the row carrying no
25
+ information. Pin the old look on one avatar with `style={{ backgroundColor: colors.accent }}`.
26
+ `Avatar` also gained `shape="circle" | "square"`; an organization takes `square`, and `circle`
27
+ stays the default, so existing call sites are unchanged.
28
+
29
+ **`colors.accent_wash` defaults to blue-50, not zinc-100**, so a SELECTED row carries the accent.
30
+ It had defaulted to a grey while `accent` defaulted to blue-600 — one token pair disagreeing with
31
+ itself, which left every unthemed app with a colourless "you are here". Related: selection and
32
+ hover no longer share a wash (hover is `zinc-50`), because the row whose record was open looked
33
+ exactly like the row under the pointer.
34
+
35
+ **`COMPACT_THUMBNAIL_SIZE` is 40, not 32.** The row-riding file tile follows the row height above
36
+ — at 32 it read as a stray glyph with 40px of air around it. It is also the threshold between a
37
+ badge and a card, so a tile at 40 still renders a document as a bare badge rather than printing the
38
+ filename inside itself. Nothing to change unless you passed a literal size to `FileThumbnail` in a
39
+ row; take the constant instead.
40
+
41
+ **`LoticsThemeProvider` is replaced by `applyLoticsTheme`.** Theming is an app-level escape hatch
42
+ — an app declares one identity at boot and never changes it — so it is a function called before
43
+ `mount` rather than a component wrapping the tree:
44
+
45
+ ```diff
46
+ -import { LoticsThemeProvider } from "@lotics/ui/theme";
47
+ +import { applyLoticsTheme } from "@lotics/ui/theme";
48
+ +
49
+ +applyLoticsTheme(MY_THEME);
50
+ mount(
51
+ - <LoticsThemeProvider {...MY_THEME}>
52
+ - <LoticsLocaleProvider locale={vi}><PortalHost><App /></PortalHost></LoticsLocaleProvider>
53
+ - </LoticsThemeProvider>,
54
+ + <LoticsLocaleProvider locale={vi}><PortalHost><App /></PortalHost></LoticsLocaleProvider>,
55
+ );
56
+ ```
57
+
58
+ Same roles, same additive semantics, same variables. It writes them to the document root exactly
59
+ as the provider did, so portalled surfaces (`Drawer`, `Dialog`, `Popover`) keep the theme — and
60
+ called before `mount` the variables are in place for the first paint, where the provider's effect
61
+ ran after one. `useLoticsTheme`, `LoticsThemeContext` and `DEFAULT_ACCENT` are gone with it; they
62
+ had no callers. `THEME_VARS` and the `LoticsTheme` type are unchanged.
63
+
64
+ **A register has no rule between its rows.** `Table` drops the hairline that used to separate them
65
+ — rows are separated by their own height (72px, up from 52) and the hover wash, and the one line
66
+ left is the band capping the columns. Nothing to change and no prop to set; a surface that needs
67
+ density sets `minHeight` on the row. There is deliberately no way to ask for the rules back: a
68
+ table of VALUES rather than of objects is `Matrix` (a numeric grid) or `DataGrid` (a compact one).
69
+
70
+ **Text is airier, and the ramp now reaches native.** The rungs that carry running text moved to
71
+ prose leading — `sm` 14/24 and `md` 16/26, about 4px more line box each — so a note typed into a
72
+ field, the same note rendered as markdown, and a label beside them all sit at one rhythm. Tracking
73
+ is negative at every rung and steepens with size. Nothing to change, but LAYOUT SHIFTS: a
74
+ single line is taller even when it does not wrap, so hand-fixed heights around text may need a
75
+ look. React Native apps see the larger change, because native had been running a stale ramp of its
76
+ own with a flat tracking value — it now derives from the same table as web.
77
+
78
+ **`index.css` no longer defines `--font-size-*`.** The twelve size/line-height variables were a
79
+ third copy of the ramp and disagreed with the real one at every rung (`--font-size-sm-line-height`
80
+ handed out 20px where `Text` renders 24). If you referenced them on a hand-rolled DOM node, set
81
+ `data-text-size="sm"` on the element instead: `text.css` matches that attribute anywhere, and it
82
+ applies size, leading and tracking as one rung. The `--color-*`, `--input-*` and `--font-weight-*`
83
+ variables are unchanged.
84
+
85
+ **Column headers in `DataGrid` are sentence case too.** The 43.5.0 header work reached `Table`
86
+ and `SortHeader` first; `DataGrid` kept `text-transform: uppercase` and `+0.3` tracking, which is
87
+ the last positive tracking in the kit. Both surfaces now label their columns identically.
88
+
89
+ **A file drop target lights up in the ACCENT, not a fixed blue.** `FileDropzone` and
90
+ `FileDropTarget` read `colors.accent` / `colors.accent_wash` for their drag state, so a themed app
91
+ no longer flashes brand blue across the most conspicuous moment on the screen. Unthemed, the wash
92
+ is unchanged and the ring moves one step darker (blue-500 → blue-600).
93
+
94
+ Additive in the same release, nothing to change: `TableGroup` (a titled band of rows inside a
95
+ `Table`), `optionPicker`
96
+ (spread a select field's options into any picker WITH their colours), `FileRows`' `press`
97
+ prop (`"open"` — no gallery), `Avatar`'s `shape` prop, and the `avatar_tone` /
98
+ `avatar_props` / `type_ramp` modules.
99
+
7
100
  ## 43.3.0 — a `bare` inline field sits on the column, and Enter finishes a one-line one
8
101
 
9
102
  Two fixes to `Inline*`, both of them behaviour a caller cannot see in a prop name.
package/docs/catalog.md CHANGED
@@ -425,11 +425,41 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
425
425
  whose text darkens the instant it is focused reads as the text MOVING (at 14px a darkness
426
426
  change re-weights every stroke's antialiasing), which is a bug no box measurement can
427
427
  see. Hand-rolling a raw input means taking this style, not just its font size.
428
+ - **`font_family`** — the three Inter stacks (`fontFamilyRegular` / `fontFamilyMedium` /
429
+ `fontFamilySemiBold`). Weight in this kit is a FAMILY, not a `font-weight` axis: each rung is
430
+ a separate file, which is why `Text`'s `weight` prop swaps the family rather than a number.
431
+ Take these only when styling something `Text` cannot wrap; on web they resolve through
432
+ `--lotics-font-*` so a theme reaches them.
433
+ - **`display_font`** — `fontFamilyDisplay`, the face `<Text family="display">` renders in, and
434
+ the ONLY themeable typeface. There is deliberately no body-face role: body weight is three
435
+ coupled families, `text.css` hand-tunes tracking for Inter's glyphs and for Vietnamese
436
+ diacritics at 12px, and `font-feature-settings: "cv11","ss01","ss03"` are Inter's own
437
+ alternates. A variable for the body face would not fail loudly — it would render every screen
438
+ subtly miscalibrated. Changing the body face is a kit change, done once for everybody.
439
+ - **`type_ramp`** — the type ramp as DATA: `TYPE_RAMP_MOBILE` / `TYPE_RAMP_DESKTOP` (size +
440
+ leading per rung, the 768px breakpoint between them), `TYPE_TRACKING` (em per rung, negative
441
+ throughout and steepening as the type grows), `TypeRung` / `TypeRungName` (`TextSize` IS this
442
+ set), and `trackingPx` for platforms with no em unit. `Text` renders through two mechanisms —
443
+ a `data-text-size` attribute matched by `text.css` on web, a `StyleSheet` on native — and this
444
+ is what both derive from, so the two cannot disagree. Read it to learn what a rung IS; you
445
+ almost never need to import it, because `Text size=` is the way to ask for a rung. Reach for
446
+ it only when styling something `Text` cannot wrap (a raw `TextInput`, a canvas, a chart axis)
447
+ and the result has to sit at a rung exactly.
428
448
 
429
449
  ### Theming, locale & tokens
430
450
 
431
- - **`theme`** — `LoticsThemeProvider` / `useLoticsTheme` + `DEFAULT_ACCENT`: the single
432
- brand accent (OKLCH blue by default) that chart fills, hero CTAs, and focus rings read.
451
+ - **`theme`** — `applyLoticsTheme(theme)` + `THEME_VARS`: THE ESCAPE HATCH by which an app
452
+ carries someone else's brand. Call it ONCE at boot, before `mount`. Six optional roles
453
+ `accent`, `primary`, `background`, `border`, `bodyFont`, `displayFont` — and it is ADDITIVE, so
454
+ setting one leaves every other at the kit's value. It writes the `--lotics-*` custom properties
455
+ on the DOCUMENT ROOT, which is what reaches surfaces react-native-web mounts on `document.body`
456
+ (`Drawer`, `Dialog`, `Popover`); a theme scoped to a React subtree reaches the register and not
457
+ the drawer opened from it. Prefer it over declaring the variables in your own stylesheet: the
458
+ selection wash is DERIVED (the accent at 7%) and one `bodyFont` has to reach all three weight
459
+ rungs, and both fail quietly by hand. No-op on native, where `colors` is a literal table.
460
+ **Theming is not a product surface** — nothing in the platform writes one, and `apps.theme.color`
461
+ is the launcher icon's colour only. → [composition.md](./composition.md)
462
+ §"Theming is an app-level ESCAPE HATCH".
433
463
  - **`locale`** — `LoticsLocaleProvider` / `useLoticsLocale` + the shipped `en` (default) and
434
464
  `vi` packs. Set the language ONCE at the root; wired components resolve **prop → provider
435
465
  locale → English default**. Each pack also carries a **`bcp47`** tag (`en`→`en-US`,
@@ -437,7 +467,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
437
467
  pure core) so the segmented field's part ORDER (dd/MM vs MM/dd), the picker calendar's
438
468
  weekday/month names, and `formatDate` display all follow the provider with no per-instance
439
469
  `locale` prop — an explicit `locale` still overrides. **Limitation:** the calendar/gantt
440
- views and the comment labels are not yet provider-wired — pass their `labels` props directly.
470
+ views and the comment labels do not read the provider — pass their `labels` props directly.
441
471
  - **`colors`** — the palette + `withAlpha`, `solid`, `tint`, `ramp`, `ColorName`,
442
472
  `isColorName`, `asColorName` (coerce a stored option/status token to a `ColorName`,
443
473
  neutral fallback).
@@ -470,8 +500,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
470
500
  - **`fonts.css`** — the Inter sheet (400/500/600, served by absolute URL so it resolves on
471
501
  every origin an app runs from); the app entry imports it ONCE or every `Text` falls back
472
502
  to system fonts.
473
- - **`index.css`** — CSS custom properties (`--font-size-*`, `--input-font-size`, …) for
474
- hand-rolled DOM surfaces; components don't require it.
503
+ - **`index.css`** — CSS custom properties (the `--color-*` palette, `--input-font-size` /
504
+ `--input-line-height`, `--font-weight-*`) for hand-rolled DOM surfaces; components don't
505
+ require it. It carries no type ramp: for SIZE on a raw DOM node, set
506
+ `data-text-size="sm"` — `text.css` matches that attribute on any element and applies the
507
+ rung's size, leading AND tracking together, which loose variables cannot.
475
508
 
476
509
  ### Icons & identity
477
510
 
@@ -486,9 +519,17 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
486
519
  `themeColor` palette token (unknown → neutral zinc) holding any Lucide icon by runtime
487
520
  name via `DynamicIcon`; `size` sm|md. One render wherever an app shows — launcher, list,
488
521
  picker, settings.
489
- - **`avatar`** — `Avatar`: image-or-initial person avatar (`source`/`name`/`size` — a rung,
490
- see above; `announce` when standalone decorative by default because the name text usually
491
- sits beside it).
522
+ - **`avatar`** — `Avatar`: image-or-initial identity mark (`source`/`name`/`size` — a rung,
523
+ see above; `shape` `circle` (a person, default) or `square` (an organization); `announce` when
524
+ standalone — decorative by default because the name text usually sits beside it). The initials
525
+ ground is DERIVED from `name`, not passed — see `avatar_tone`.
526
+ - **`avatar_props`** — `AvatarProps` + `avatarShapeStyle`: the prop contract BOTH `avatar.tsx`
527
+ and `avatar.web.tsx` implement. It lives in a third module because only one of them is ever
528
+ compiled — `tsc` resolves `./avatar` to the native file while every browser loads the web one,
529
+ so a prop declared on one side typechecks everywhere and silently does nothing in the browser.
530
+ - **`avatar_tone`** — `avatarTone(name)`: the initials ground, one stable hue per name, from a
531
+ contrast-ordered palette that excludes `red` and `amber` (a person is not a warning). Pure and
532
+ never stored, so one person is one colour on every screen. See composition.md §Identity marks.
492
533
  - **`avatar_size`** — the shared avatar scale: `AvatarSize` (`sm|md|lg|xl`), `AVATAR_PX`,
493
534
  `AVATAR_TEXT`, `avatarInitials`. Import `AVATAR_PX` when a sibling box must match an
494
535
  avatar's pixels (a stack's overlap, a dashed placeholder) so the two cannot drift.
@@ -505,7 +546,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
505
546
  ### Layout & surfaces
506
547
 
507
548
  - **`card`** — `Card`, `CardHeader`, `CardHeaderTitle` (+ `info` ⓘ popover),
508
- `CardHeaderMeta`, `CardBody`, `CardFooter`.
549
+ `CardHeaderMeta`, `CardBody`, `CardFooter`. A card separates from the canvas by its own border
550
+ and shadow, never by a tinted background, and it is a BAND on a page rather than a box around
551
+ every group — nesting one inside another is how a screen ends up reading as boxes all the way
552
+ down. → [composition.md](./composition.md) §"Cards — banded and composable".
509
553
  - **`inset`** — `Inset`: a tinted, recessed content surface (zinc-50 fill, 10 radius, 14
510
554
  pad, 12 gap) — the "well" INSIDE a Card/Section for a grouped sub-form, an inline fill
511
555
  editor, or a nested block. **NOT a `Callout`:** a Callout is a status BAND (and
@@ -541,7 +585,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
541
585
  - **`stack`** — `Stack`: gap-spaced row/column; `useSeparator` inserts `Separator`s between
542
586
  children.
543
587
  - **`spacer`** — `Spacer` + `SpacerSize` (the fixed size scale).
544
- - **`divider`** — `Divider`: the bare hairline.
588
+ - **`divider`** — `Divider`: the bare hairline. **Ask what it SEPARATES before reaching for
589
+ it.** A line between two like things (row from row, item from item) says what the space
590
+ between them already says, and a register full of them reads as a spreadsheet; a line under a
591
+ column band or above a total separates CHROME from CONTENT, which whitespace cannot say. →
592
+ [composition.md](./composition.md) §"The register's rhythm".
545
593
  - **`separator`** — `Separator`: a `Divider` pre-wrapped in vertical padding (`padding:
546
594
  SpacerSize`, default 8) — the between-groups rule WITH breathing room, what `Stack
547
595
  useSeparator` inserts and what menus/popovers put between option groups.
@@ -565,12 +613,20 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
565
613
  genuinely cannot use it — one with a rail, a side panel, or a scroller it must hold a ref to —
566
614
  still lands on the same edge by importing that token. It OWNS its `ScrollView` and exposes no
567
615
  scroll props, which is the real limit on adoption.
568
- - **`accordion`** — `Accordion` + `AccordionHeader`/`AccordionTitle`/`AccordionMeta`:
616
+ - **`accordion`** — collapsible section rows, and the commonest piece of wrong-ALTITUDE
617
+ furniture: a record's sections are scrolled to, never folded away, so an accordion inside one
618
+ hides content behind a press that the page's own scroll already reaches. It earns its place
619
+ where the folded content is genuinely optional (a long log, an advanced group) and the reader
620
+ can finish their task without ever opening it. → [composition.md](./composition.md)
621
+ §"Canvas & content column". `Accordion` + `AccordionHeader`/`AccordionTitle`/`AccordionMeta`:
569
622
  expandable section rows.
570
623
  - **`tabs`** — `Tabs`: switch between content sections; WAI-ARIA tablist + roving tabindex;
571
624
  each `TabOption` takes an optional `status` `ColorName` → a small attention dot before its
572
625
  label, for a tab whose area needs work.
573
- - **`segmented_control`** — `SegmentedControl`: the exclusive small-set switch.
626
+ - **`segmented_control`** — `SegmentedControl`: the exclusive small-set switch. Picking between
627
+ this, `ChipGroup`, `RadioPicker`, `Select` and `Tabs` is decided by OPTION COUNT and by
628
+ whether the choice filters a view or sets a value — not by taste. →
629
+ [data_entry.md](./data_entry.md) §"Choosing a CHOICE control".
574
630
  - **`danger_zone`** — `DangerZone`: the destructive section — a soft danger-tinted frame
575
631
  (the kit's `tint`/`solid`, never raw hex) + a danger heading + a description + a
576
632
  destructive action slot (children, e.g. a `danger` Button); sits APART at the bottom of a
@@ -729,6 +785,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
729
785
  surface. **NOT for a type / category / attribute / count — that's inline `Text`, never a Badge**
730
786
  (see composition.md "Badge is for status only"). A bare `<Badge>` is `dot`.
731
787
  - **`status_badge`** — `StatusBadge`: an enabled/disabled pulse badge (`enabled` + `label`).
788
+ - **`option_picker`** — `optionPicker(options, { variant? })`: spread into any picker (`InlineSelect`/`Select`/`OptionList`/`Combobox`) to feed a select field's options in WITH their configured colours. Supplies `options` + `renderOptionContent` together — the hand-written `map` to `{ value, label }` drops `color` silently, so an edited value renders greyer than the same value in a register. → data_entry.md.
732
789
  - **`option_badge`** — `OptionBadge`: a select value as its configured colored badge.
733
790
  - **`callout`** — `Callout`, `CalloutTitle`, `CalloutText`, `CalloutActions` (`tone`
734
791
  info|success|warning|error|neutral): inline status band — a MESSAGE (`warning`/`error`
@@ -746,7 +803,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
746
803
  flow (a form that won't save) use `Callout tone="error"`; this is for a region with no content
747
804
  to show, where a tinted strip leaves the area collapsed.
748
805
  - **`completion_state`** — `CompletionState`: the "all done" terminal state.
749
- - **`skeleton`** — `Skeleton`: loading placeholder blocks.
806
+ - **`skeleton`** — `Skeleton`: loading placeholder blocks. **Mirror the layout that is
807
+ arriving** — same row count, same column widths, same heights. A skeleton of a different
808
+ shape makes the content jump when it lands, which is more disruptive than an empty box
809
+ would have been. First of the four region states; the other three are under `error_state`.
750
810
  - **`loading`** — `Loading`: the centered indeterminate loading state (composes
751
811
  `DotsIndicator`).
752
812
  - **`activity_indicator`** — `ActivityIndicator`: the bare spinner.
@@ -797,12 +857,21 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
797
857
  - **`member_select`** — `MemberSelect` + `MEMBER_UNASSIGNED`: the member picker (a `Select`
798
858
  of `MemberChip` options; `unassignedLabel` prepends the unassigned option).
799
859
  - **`radio_picker`** — `RadioPicker`: labelled radio group for a small visible single
800
- choice.
801
- - **`chip_group`** `ChipGroup`: pill single-select over a small visible set.
802
- - **`checkbox`** — `Checkbox`: the bare square check control.
860
+ choice. → `data_entry.md` §"Choosing a CHOICE control" — and check the states really ARE
861
+ exclusive: independent capabilities that happen to be exclusive today bake today's
862
+ combinations into the type.
863
+ - **`chip_group`** — `ChipGroup`: pill single-select over a small visible set. → `data_entry.md`
864
+ §"Choosing a CHOICE control" for which control the count and the job call for.
865
+ - **`checkbox`** — `Checkbox`: the bare square check control. Bare means UNLABELLED — reach for
866
+ `CheckboxInput` unless something else already names it (a table's select-all, a row's
867
+ leading slot). A square means CHOSEN; a ring means DONE, and spending the wrong one teaches a
868
+ reader that a ticked pick-list is work already carried out. → AGENTS.md, Iron rules.
803
869
  - **`checkbox_input`** — `CheckboxInput`: checkbox + label as one labelled control.
804
- - **`switch`** — `Switch`: the bare toggle (a cell/toolbar boolean).
805
- - **`counter`**`Counter`: increment/decrement numeric stepper (filter facet).
870
+ - **`switch`** — `Switch`: the bare toggle (a cell/toolbar boolean). A boolean IS a switch —
871
+ not a two-option select, not a popover holding one checkbox and it commits on flip, so it
872
+ belongs only where there is nothing to save.
873
+ - **`counter`** — `Counter`: increment/decrement numeric stepper (filter facet). For a value a
874
+ person TYPES rather than nudges, `NumberInput`; stepping to 47 is not an input method.
806
875
  - **`slider`** — `Slider` + `rangeSummary`: drag a continuous number along a track. ONE thumb
807
876
  by default (a volume, a threshold); `range` turns on the second and the value becomes
808
877
  `[low, high]` (a price band, a filter facet). `labels={false}` when the caller prints the
@@ -1191,7 +1260,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1191
1260
 
1192
1261
  ### Lists, tables & registers
1193
1262
 
1194
- - **`list`** — `List`: children separated by hairline `Divider`s.
1263
+ - **`list`** — `List`: children separated by hairline `Divider`s. That default is right for a
1264
+ short set of unlike items (a menu, a settings group) and wrong for a long run of like ones —
1265
+ there, the rules are the noise and `Stack` with a gap reads calmer. → `divider` above.
1195
1266
  - **`list_item`** — `ListItem`: the plain list row. A pressable row with a `right` slot
1196
1267
  presses BESIDE it, never around it: the slot holds a control, and a button may not contain
1197
1268
  one — `<button>` inside `<button>` is invalid HTML and buries a second tab stop and name
@@ -1239,6 +1310,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1239
1310
  position (a group band, a totals line) and the gutter stays blank so the cells beside it stay
1240
1311
  in line. A position is NEVER an identity: sort or filter and row 12 is a different record —
1241
1312
  a stable handle is a column, and it leads the cells. Worked screen: `tpl_item_list`.
1313
+ - **`table`** rhythm — rows are separated by their own height and the hover wash, never by a hairline; the register's one line is the band capping the columns. 72px rows, overridable per row with `TableRow`'s `minHeight` when a surface genuinely needs density. There is no `spacing` prop: a table of VALUES rather than of objects is a different component (`Matrix`, `DataGrid`). → [composition.md](./composition.md) §"The register's rhythm".
1314
+ - **`table` › `TableGroup`** — a titled BAND of rows inside a `Table` (`label`, `count?`, `color?` for a single valence dot). Turns a register that presents records into one that triages them: group by what implies a different ACTION, order the bands by what needs the reader now, and let air rather than a rule separate them. → [composition.md](./composition.md) §"A register that TRIAGES".
1242
1315
  - **`sort_header`** — `SortHeader` + `SortState`/`SortDir` + `cycleSort` + `sortBy` +
1243
1316
  `SortHeaderLabels`: the sortable column header and the sort-state helpers `Table`/
1244
1317
  `DataGrid` consumers drive.
@@ -1417,17 +1490,39 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1417
1490
 
1418
1491
  ### Numbers & charts
1419
1492
 
1493
+ **These are where a work screen gets its visual interest, and it is the only kind that survives
1494
+ contact with a real dataset.** Software with no photography has to find its imagery in the data —
1495
+ a figure at display size, a delta's colour, a line's shape, a bar's proportion, digits aligning
1496
+ down a column. Each of these is information first, so none of it is the decoration that gets cut
1497
+ in review. A screen reported as flat or boring is usually a screen with numbers in it rendered as
1498
+ sentences. → [composition.md](./composition.md) §"Character comes from the DATA".
1499
+
1500
+ Two failure modes to hold against them. A figure nobody compares to anything is not a metric — it
1501
+ is a number with a large font, and the size promises a significance it cannot deliver. And a
1502
+ delta, ring or bar with no comparator states a relationship that does not exist; SKIP the
1503
+ component rather than showing it at zero.
1504
+
1420
1505
  - **`kpi_card`** — `KPICard`: one headline metric in a card (trend chip slot, sub-caption,
1421
- ⓘ definition).
1422
- - **`kpi_strip`** `KPIStrip`: the boxed dashboard stat band.
1506
+ ⓘ definition). The card is what makes it a KPI rather than a number: it holds the definition
1507
+ (ⓘ), so the reader can find out what "on-time %" counts without leaving the screen.
1508
+ - **`kpi_strip`** — `KPIStrip`: the boxed dashboard stat band. Every item has to answer a
1509
+ question the surface below it cannot — a strip restating totals the table already carries is
1510
+ the trim test failing, and it costs the reader a pass over the screen to discover that.
1423
1511
  - **`metric`** — `Metric`: a bare headline figure (`format`
1424
- currency|number|percentage|none, `tone`, `size` sm|md|lg|hero).
1425
- - **`trend_chip`** `TrendChip`: the signed ±% delta chip.
1512
+ currency|number|percentage|none, `tone`, `size` sm|md|lg|hero). Bare = no card, no label of its
1513
+ own, so whatever contains it has to name it. Always `tabular` it is set that way here, and it
1514
+ is why a column of figures aligns instead of jittering on proportional digits. It sets no
1515
+ tracking of its own; the rung's tracking comes from `Text`.
1516
+ - **`trend_chip`** — `TrendChip`: the signed ±% delta chip. Reads as a delta only next to the
1517
+ value it moved from, so put it beside the figure, never in its own column.
1426
1518
  - **`trend_footer`** — `TrendFooter`: the "Up X% vs last period" caption under a chart card
1427
1519
  — signed `value` (0 = flat), `periodLabel`, optional `detail`; `goodDirection="down"`
1428
1520
  flips green/red for metrics where down is good; SKIP it when there's no comparator.
1429
1521
  Direction words via the `trendFooter` locale slice; goes in `SectionCard footer`.
1430
- - **`sparkline`** — `Sparkline`: the inline mini trend line.
1522
+ - **`sparkline`** — `Sparkline`: the inline mini trend line. Sized for a ROW or a card corner:
1523
+ it carries SHAPE (rising, spiky, flat) and no readable values, which is exactly what a
1524
+ register wants — a column of them turns "12 numbers per row" into a pattern the eye reads
1525
+ without stopping. Reach for a real chart the moment someone needs to read a value off it.
1431
1526
  - **`bar_chart`** / **`line_chart`** / **`pie_chart`** — `BarChart` / `LineChart` /
1432
1527
  `PieChart`: the canonical SVG chart set (no recharts).
1433
1528
  - **`progress_bar`** — `ProgressBar`: the determinate meter; `compact` = ONE row, track + a
@@ -1624,12 +1719,19 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1624
1719
  the single in-flight upload tile `FileGrid` renders; reach for it only when hand-rolling a
1625
1720
  non-grid upload layout — it is i18n-free by design, so a bare call site owes it `labels`
1626
1721
  (`FileGrid` resolves them from `LoticsLocale.fileUpload` on your behalf).
1722
+ - **`file_thumbnail`** — **TWO RUNGS, and which one you pass decides what the tile IS.**
1723
+ `COMPACT_THUMBNAIL_SIZE` (40) is a tile that RIDES A ROW — the file's identity mark, beside the
1724
+ name — and it renders a document as a bare badge, because the row already names the file and a
1725
+ 40px box has no room to name it again. `THUMBNAIL_SIZE` (96) is a tile that stands on its own in
1726
+ a grid, and it becomes a CARD that prints the filename itself. Pass 96 into a row and you get the
1727
+ same string twice, the second copy truncated. The row rung is the same 40 an `Avatar` takes at
1728
+ `lg`, because both answer "which row is this", and both sit in a 72px register row.
1627
1729
  - **`file_thumbnail`** — **`diff`** puts a `DiffKind` corner mark on a tile (top-LEFT, the one
1628
1730
  corner remove/selection/uploading do not claim) and fades a `removed` one. The mark rides a
1629
1731
  white RING: a tile's ground is an arbitrary photo, and a pale tint over a pale scan is a
1630
1732
  smudge. A `removed` tile fades its BODY only — the mark stays at full strength, since a faded
1631
- mark is the smudge the ring exists to prevent. Ignored below 32px: a 22px disc on a compact
1632
- tile is the tile, and a compact strip lives inside a row that carries the change instead. A grid says MEMBERSHIP well (arriving, leaving) and PAIRING not at
1733
+ mark is the smudge the ring exists to prevent. Ignored at the ROW rung: a corner disc on a row-sized tile is the tile, and such a tile lives
1734
+ inside a row that carries the change instead. A grid says MEMBERSHIP well (arriving, leaving) and PAIRING not at
1633
1735
  all — nowhere on a 96px tile to name what a replacement supersedes — so a set where things
1634
1736
  are being REPLACED wants `FileRow`. Also: `FileThumbnail` + `DisplayFile` + `THUMBNAIL_SIZE` /
1635
1737
  `COMPACT_THUMBNAIL_SIZE` + `getMediaIcon`: the completed tile — the right surface per