@lotics/ui 46.14.1 → 47.1.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 (49) hide show
  1. package/AGENTS.md +20 -0
  2. package/MIGRATION.md +58 -0
  3. package/docs/catalog.md +72 -13
  4. package/docs/composition.md +94 -6
  5. package/docs/reviewing.md +56 -5
  6. package/docs/templates.md +8 -2
  7. package/examples/tpl_calendar.tsx +22 -29
  8. package/examples/tpl_item_list.tsx +7 -2
  9. package/package.json +1 -1
  10. package/src/accordion.tsx +11 -5
  11. package/src/bar_chart.tsx +3 -1
  12. package/src/board.tsx +1 -2
  13. package/src/breakdown.tsx +3 -1
  14. package/src/calendar/agenda_view.tsx +136 -0
  15. package/src/calendar/calendar_toolbar.tsx +84 -0
  16. package/src/calendar/calendar_view.tsx +196 -103
  17. package/src/calendar/context.ts +47 -0
  18. package/src/calendar/dates.ts +36 -6
  19. package/src/calendar/event_chip.tsx +141 -0
  20. package/src/calendar/index.ts +31 -8
  21. package/src/calendar/layout.ts +113 -11
  22. package/src/calendar/month_view.tsx +194 -150
  23. package/src/calendar/repeat.ts +174 -0
  24. package/src/calendar/time_grid_view.tsx +182 -202
  25. package/src/calendar/types.ts +37 -11
  26. package/src/charge_lines.tsx +24 -5
  27. package/src/control_surface.ts +28 -0
  28. package/src/deadline.ts +10 -0
  29. package/src/file_row.tsx +26 -3
  30. package/src/finding.tsx +1 -1
  31. package/src/form_text_input.tsx +9 -2
  32. package/src/gantt/gantt_view.tsx +212 -119
  33. package/src/gantt/index.ts +2 -2
  34. package/src/gantt/scale.ts +38 -2
  35. package/src/gantt/types.ts +34 -7
  36. package/src/inline_slot.tsx +16 -2
  37. package/src/inline_static.tsx +18 -4
  38. package/src/legend_item.tsx +14 -1
  39. package/src/locale.tsx +30 -0
  40. package/src/matrix.tsx +19 -5
  41. package/src/member_chip.tsx +18 -1
  42. package/src/menu_button.tsx +12 -2
  43. package/src/option_list.tsx +11 -1
  44. package/src/progress_bar.tsx +14 -1
  45. package/src/record_summary.tsx +5 -0
  46. package/src/stacked_bar_chart.tsx +4 -1
  47. package/src/table_fit.ts +18 -1
  48. package/src/thumbnail_stack.tsx +18 -3
  49. package/src/use_option_list.ts +13 -1
package/AGENTS.md CHANGED
@@ -61,6 +61,26 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
61
61
  Worked in `examples/tpl_record.tsx` § Progress.
62
62
  → [catalog.md §"Tasks, to-dos, phased work"](./docs/catalog.md),
63
63
  [catalog.md §"Tasks & checklists"](./docs/catalog.md).
64
+ - **A calendar is for WHEN; a Gantt is for WHO/WHAT.** Days on the axis → `CalendarView`
65
+ (compound: `CalendarToolbar` + `CalendarMonth`/`CalendarWeek`/`CalendarDay`/`CalendarAgenda`,
66
+ controlled via `date`/`view`). Resources on the axis — a channel, an owner, a vehicle →
67
+ `GanttView`, which draws lanes (`group`), milestones and progress. And
68
+ a grid is unreadable in TWO directions: too narrow OR too short, month/week fall back to the
69
+ agenda on their own, so never wrap one in a horizontal scroller to force it.
70
+ → [catalog.md §"Scheduling & time"](./docs/catalog.md).
71
+ - **A repeating event is ONE row with a `repeat`, and there is no `timeZone` prop.** The
72
+ calendar expands a repeat to the view's own range, so a timetable is one event rather than a
73
+ year of them; an occurrence's id resolves back to the source row. And a stored Lotics
74
+ datetime is a naive WALL CLOCK — "14:30" means 14:30 to the business — so there is nothing to
75
+ convert and a calendar that re-projected it would move every event. Only *what day it is*
76
+ depends on the reader's location: that is the `now` prop.
77
+ → [catalog.md §"Scheduling & time"](./docs/catalog.md).
78
+ - **A wash gives a surface its body; a border — or an accent stripe down one side — is that
79
+ same edge asserted twice.** Light ground + dark ink of one family (`Badge`'s tonal pairing),
80
+ never the solid shade under white text; `solid()` is for a DOT, where a few pixels carry
81
+ identity. And a radius is a PROPORTION of the box (`proportionalRadius`), because a component
82
+ sized by data renders as a pill at one height and barely a corner at another.
83
+ → [composition.md §"A body, not an edge"](./docs/composition.md).
64
84
  - **`Badge` = STATUS only; supporting detail is the muted second line.** A type / category /
65
85
  attribute / count is not a status — it belongs under its identity as `size="xs" color="muted"`,
66
86
  never a second chip. →
package/MIGRATION.md CHANGED
@@ -4,6 +4,64 @@ 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
+ ## 47.0.0
8
+
9
+ **`@lotics/ui/calendar` is a compound, and `CalendarEvent.color` is a token.**
10
+
11
+ `CalendarView` still exists and `<CalendarView events={…} />` still renders a working calendar,
12
+ so the drop-in call site is unchanged. Four things did change:
13
+
14
+ - **`MonthView` / `TimeGridView` are gone.** The parts are `CalendarMonth`, `CalendarWeek`,
15
+ `CalendarDay`, `CalendarAgenda`, `CalendarToolbar` and `CalendarBody`, and they read their
16
+ state from the root rather than taking `date`/`events` props. Mount them as children of
17
+ `CalendarView` to compose your own chrome.
18
+ - **`color` is a `ColorName`, not a string.** `color: solid("blue")` becomes `color: "blue"`.
19
+ The views derive a fill, a tint and a border from the token; the old code appended `"1f"` to
20
+ the string for the tint, which produced garbage for anything that was not a 6-digit hex.
21
+ - **`CalendarLabels` is `CalendarViewLabels`, and it is provider-wired.** Strings now resolve
22
+ prop → `LoticsLocale.calendarView` → English, so a localized app can delete its hand-passed
23
+ set. `DEFAULT_CALENDAR_LABELS` is `DEFAULT_CALENDAR_VIEW_LABELS`. (`CalendarLabels` still
24
+ exists and still belongs to `@lotics/ui/date_calendar`, the date PICKER's month grid — the
25
+ two had the same name.) Two labels are new: `agenda`, `noEvents`, `addAt`.
26
+ - **`onEventDrop` is gone.** Dragging an event to another day wrote through no seam an app
27
+ could gate. Press-to-open is the gesture: `onEventPress`, plus `onSlotPress(start, end)` on
28
+ an empty day cell or hour slot.
29
+
30
+ **Two behaviours changed without an API change, and both are worth looking at.** A week or day
31
+ grid now opens on the earliest event IN VIEW rather than an hour before the wall clock (a week
32
+ of 09:00 meetings viewed at 13:00 used to open on empty grid). And month/week fall back to the
33
+ agenda when the surface is too narrow OR too short for a grid to name a single event — if you
34
+ wrapped a calendar in a horizontal `ScrollView` with a `minWidth` to work around the old
35
+ behaviour, delete that: it now fights the fallback.
36
+
37
+ **Additive in the same release:** `CalendarEvent.repeat` expands a series to the view's own
38
+ range (a timetable is one row, not a year of them) and `CalendarView`'s `now` supplies the
39
+ reference instant for the today badge, the now line and the opening scroll — which also
40
+ makes "today" pinnable in a test. There is deliberately **no `timeZone` prop**: Lotics
41
+ stores a datetime as a naive wall clock, so a calendar that re-projected it would move
42
+ every event.
43
+
44
+ ### `GanttView`, in the same major
45
+
46
+ It keeps the promise its type was already making, and `color` becomes a token.
47
+
48
+ - **`group` now renders.** It was declared on `GanttTask`, documented as a lane label, passed by
49
+ callers — and read by nothing; every task rendered in one flat list. Tasks sharing a `group`
50
+ now sit under one heading, in first-seen order, with ungrouped tasks leading. If you were
51
+ passing `group` and relying on the flat rendering, drop the field.
52
+ - **`color` is a `ColorName`, not a string** — `solid("blue")` becomes `"blue"`, matching
53
+ `CalendarEvent`.
54
+ - **`onTaskResize` is gone.** Dragging a bar's edge wrote through no seam an app could gate,
55
+ the same reason the calendar's drag went in 47.0.0. Press is the gesture.
56
+ - **Labels are provider-wired** — prop → `LoticsLocale.gantt` → English, so a localized app can
57
+ delete its hand-passed set. One label is new: `empty`.
58
+
59
+ New, additive: `milestone` draws a diamond, `progress` fills part of a bar. `buildRows` is
60
+ exported for anything that needs the same lane geometry.
61
+
62
+ There are deliberately **no dependency connectors**. Lane order and bar position already carry
63
+ the sequence, and elbow arrows over an already-ruled grid restate it as line noise.
64
+
7
65
  ## 46.3.0
8
66
 
9
67
  **A `Text` with `numberOfLines` now declares `flexShrink: 1` + `minWidth: 0` for you.**
package/docs/catalog.md CHANGED
@@ -86,6 +86,13 @@ inline: a picker option, an assignee, a `select_member` value. Pure: resolve the
86
86
  your directory and pass `name` / `image`; never hand-roll `Avatar` + `Text`. (`MemberSelect`
87
87
  renders these per option.)
88
88
 
89
+ **`showAvatar={false}` where the member is ONE FIELD AMONG COLUMNS** — an owner, an assignee,
90
+ a last-edited-by. Keep the face where the member IS the subject (a roster, a picker option, a
91
+ profile). In a supporting column it fails twice: it competes with the mark the row already
92
+ carries for its own subject, and on a real book a few people own hundreds of rows, so the
93
+ column draws one repeated disc — which `composition.md` §Identity marks calls a mark that
94
+ "carries nothing and is the largest, brightest thing there".
95
+
89
96
  **To answer "who IS this?" — `MemberPeek`**, the same chip made a door onto a
90
97
  `MemberProfileCard`: a 72px avatar, what they sign in as, their role, their teams and when they
91
98
  joined. The trigger takes `Peek`'s `variant="control"` there, so the chip seats in the 40px
@@ -130,7 +137,11 @@ that a whole office shares.
130
137
  `ThumbnailStack` is `AvatarGroup`'s sibling over a different subject: a capped, overlapping set
131
138
  of PICTURES answering "an order of what?", "which parts?", "what arrived?" in one register cell.
132
139
  Pass `{ id, label, image }` per thing; `max` (default 3) caps it and the rest become the `+N`
133
- mark at the end of the stack. Both components read their separating edge from `mark_stack.ts`,
140
+ mark at the end of the stack. Each mark ANNOUNCES its label by default — a standalone stack is
141
+ unreadable otherwise — so pass **`decorative`** where the row already names the thing in text, or
142
+ the pictures read it a second time on every row. (The default is the opposite of
143
+ `Avatar.announce`, and for the same reason: an avatar almost always sits beside its name, a stack
144
+ often does not. The default follows where the component actually sits.) Both components read their separating edge from `mark_stack.ts`,
134
145
  so a row carrying people in one column and goods in the next reads as one device used twice
135
146
  rather than two — but they **tuck by different fractions, and that is deliberate**: a face is
136
147
  edge-anchored and survives being covered two thirds of the way, while a product shot centres its
@@ -279,7 +290,12 @@ by size.)
279
290
  search, you don't group. Worked example: [`tpl_item_list`](../examples/tpl_item_list.tsx).
280
291
  Never an HTML `<table>` or a `.map` of rows. The register adapts to its container on its
281
292
  own — hides columns by `priority`, stacks rows below the two-column floor (see the `table`
282
- inventory entry) — so a narrow screen or panel needs NO branching; compose a hand-designed
293
+ inventory entry) — so a narrow screen or panel needs NO branching. **A flexible column that
294
+ carries fixed chrome inside its cell declares it as `lead`** (a leading mark plus its gap): the
295
+ fit guarantees a READ floor for the text, and without `lead` the mark eats part of that floor,
296
+ so a register reports "fitted" while its subject clips. `composition.md` requires a leading mark
297
+ on every register row and `tpl_item_list` puts it inside the subject cell, so this is the
298
+ ordinary case, not an exotic one; compose a hand-designed
283
299
  card pile (`PressableRow` + your own hierarchy) over `useScreenSize` only when a screen
284
300
  deserves a better mobile shape than the automatic stack.
285
301
  - **Inline-managed grouped table** (MODERATE — hundreds, low-thousands — you MANAGE in view)
@@ -482,7 +498,8 @@ patterns doc indexed in [AGENTS.md](../AGENTS.md)).
482
498
  `ScanField` (scan/verify), `Stepper` (a guided run / progress sequence — done, current,
483
499
  upcoming, horizontal OR vertical), `RemainderMeter` + `AllocationRow` (allocation),
484
500
  `Timeline` (a heterogeneous event LOG — icons + expandable details, not progress),
485
- `Calendar` (the `calendar` module's views), `Gantt`, `comments_thread`.
501
+ `CalendarView` (the `calendar` module — days are the axis), `GanttView` (RESOURCES are the
502
+ axis: a channel, an owner, a vehicle), `comments_thread`.
486
503
 
487
504
  ### AI surfaces
488
505
 
@@ -1350,7 +1367,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1350
1367
  EXACTLY (height, padding, 1px transparent border) so a non-editable field — a computed
1351
1368
  total, a system ID, a synced/locked value — aligns pixel-for-pixel in the same column;
1352
1369
  non-interactive, NOT a disabled input; `muted`/`tabular`/`align="right"` for a number
1353
- column, `weight="medium"` to emphasise a total among plain rows, `multiline` to WRAP rather
1370
+ column, `weight="medium"` to emphasise a total among plain rows, **`actions` for a verb about
1371
+ the value** (look this id up, copy it, request the gated change) — a static value is still a
1372
+ field, and its verb belongs on the field's surface, never on the row's ground where an
1373
+ `InlineButton` has nothing under it; `multiline` to WRAP rather
1354
1374
  than truncate (single-line is the default, because a field column reads as a column only if
1355
1375
  its rows are one height — pass it where the counterpart editor is itself `multiline`, or a
1356
1376
  truncated value beside a wrapping input reintroduces the misalignment on the other axis).
@@ -1375,7 +1395,18 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1375
1395
  - **`inline_button`** — `InlineButton`: a verb that sits INSIDE an inline field's surface
1376
1396
  (the `Open` on a reference, a `Copy` on a value worth copying) rather than in the row's
1377
1397
  trailing column — put it inside when the act is ABOUT THE VALUE, so it travels with what it
1378
- acts on and the trailing column stays free for row-level verbs. FILLED (`Button secondary`'s own
1398
+ acts on and the trailing column stays free for row-level verbs.
1399
+
1400
+ **NEVER use it outside an inline field.** Not in a section header, not in a toolbar, not as a
1401
+ row action, not as a card's own verb — it is an *inner* control of a field, and the whole of its
1402
+ design says so: it is FILLED against the field's white ground (a relationship to a surface it no
1403
+ longer has when you move it), it is 28px against a 40px band, it carries no border because the
1404
+ field already draws one, and **it stops propagation** because the field beneath it is pressable.
1405
+ Outside a field every one of those becomes wrong: it reads as a heavier `Button secondary` that
1406
+ is mysteriously too small, it sits on a ground it was not contrasted against, and its swallowed
1407
+ press is now swallowing nothing. If the verb is about the ROW use the row's trailing action; if
1408
+ it is about the SECTION use its heading row; if it is about the SCREEN it is a `Button`. The
1409
+ test is one question — **is there a field surface under it?** If not, it is the wrong control. FILLED (`Button secondary`'s own
1379
1410
  zinc-100) on the field's white surface — the contrast is a RELATIONSHIP, not a colour: invert it
1380
1411
  if the resting field surface ever changes, or the verb dissolves into the value. 28px, radius 8,
1381
1412
  no border (the field already has one) and no shadow (depth belongs to the primary action alone). **It stops propagation** — that is the contract: the
@@ -2048,14 +2079,42 @@ component rather than showing it at zero.
2048
2079
 
2049
2080
  ### Scheduling & time
2050
2081
 
2051
- - **`calendar`** — the calendar views: `CalendarView` (the composed month/week surface),
2052
- `TimeGridView`, `MonthView`, plus the layout/date helpers (`layoutDayColumns`,
2053
- `packEventLanes`, `addDays`, `startOfWeek`, …) and `CalendarEvent`/`CalendarLabels` types.
2054
- **Limitation:** labels are not provider-wired pass `labels`
2055
- (`DEFAULT_CALENDAR_LABELS` is English).
2056
- - **`gantt`** — `GanttView` + scale helpers (`barGeometry`, `axisRange`, `buildTicks`,
2057
- `pxPerDay`) + `GanttTask`/`GanttLabels` types. **Limitation:** labels are not
2058
- provider-wired pass `labels` (`DEFAULT_GANTT_LABELS` is English).
2082
+ - **`calendar`** — a COMPOUND calendar of events. `CalendarView` is the root and holds the
2083
+ state; `CalendarToolbar`, `CalendarMonth`, `CalendarWeek`, `CalendarDay`, `CalendarAgenda`
2084
+ and `CalendarBody` are the parts. Mount it bare (`<CalendarView events={} />`) for the
2085
+ toolbar plus the current view, or pass children to compose your own chrome — an app screen
2086
+ already has a header band, and a calendar that welds one on stacks two. `date` / `view` with
2087
+ their `on*Change` make it controlled, so a filter chip or a route can drive it. It NEVER
2088
+ writes: `onEventPress` and `onSlotPress` hand you the event or an empty range and your own
2089
+ form does the rest. `renderEvent` replaces a chip's contents while the calendar keeps the
2090
+ press target and the geometry. Also `EventChip`, `useCalendar`, and the pure helpers
2091
+ (`layoutDayColumns`, `packEventLanes`, `compareEvents`, `fitLanes`, `initialScrollMinutes`,
2092
+ `gridWindow`, `weekRowsInMonth`, `addDays`, `startOfWeek`, …).
2093
+ **A repeating event costs one row, not a year of them:** put a `repeat` on the event
2094
+ (`{ every: "day" | "week" | "month", interval?, on?, until?, count? }`) and the calendar
2095
+ expands it to the occurrences in the view's own range. Deliberately NOT RFC 5545 — a
2096
+ timetable, a standup, a monthly close; see `expandRepeats` for why the long tail is a
2097
+ platform field type rather than a wider rule. An occurrence's id encodes its source, so
2098
+ `onEventPress` still hands you the row the series came from (`sourceEventId`).
2099
+ **There is no `timeZone` prop, on purpose:** Lotics stores a datetime as a naive WALL CLOCK,
2100
+ so "14:30" means 14:30 to the business and there is nothing to convert — re-projecting it
2101
+ would move every event. What does depend on where the reader sits is *what day it is*, so
2102
+ that alone is the `now` prop (which also pins "today" in a test).
2103
+ Three things it decides for you: **which shape** — below `compactBelow` (720px) or too short
2104
+ for a month to name one event per row, month/week become the agenda, because seven columns on
2105
+ a phone can only say WHICH days have something; **how many lanes** — measured against the row,
2106
+ not a constant; and **the hour window** — `dayStartHour`/`dayEndHour` are a VIEWPORT, so a
2107
+ 06:00 delivery widens the grid instead of disappearing from it. `color` is a `ColorName`, not
2108
+ a hex. Labels resolve prop → `LoticsLocale.calendarView` → English.
2109
+ - **`gantt`** — `GanttView`: a frozen label column beside a zoomable, horizontally
2110
+ scrollable axis. Reach for it over a calendar when the **rows** are the subject (a phase, an
2111
+ owner, a vehicle, a channel) and dates are the horizontal axis. **Lanes** come from `group`,
2112
+ **milestones** render as diamonds, and `progress` fills part of a bar. No dependency
2113
+ connectors: lane order and bar position already carry the sequence, and elbow arrows over an
2114
+ already-ruled grid restate it as line noise. The bar carries no label, because the frozen
2115
+ column beside it already names the task. Helpers: `buildRows`, `barGeometry`, `axisRange`,
2116
+ `buildTicks`, `pxPerDay`; types `GanttTask`/`GanttRow`/`GanttLabels`. `color` is a
2117
+ `ColorName`; labels resolve prop → `LoticsLocale.gantt` → English.
2059
2118
  - **`timeline`** — `Timeline`: a heterogeneous event LOG — per-row icon + expandable
2060
2119
  details; models the past, NOT progress. Three things it decides for you, because a row
2061
2120
  cannot be trusted to a caller's data: the **label clamps to two lines WHILE COLLAPSED**
@@ -360,6 +360,33 @@ badge. Every period-dependent number MUST follow the selection. Pass `includeTim
360
360
  time-of-day matters: the trigger previews the chosen time (locale-aware) and the `labels` prop
361
361
  translates the presets, footer buttons, placeholder, and the time-segment editors.
362
362
 
363
+ ## A calendar answers WHEN — and only when the position on a timeline is the question
364
+
365
+ A calendar is not a prettier register. Reach for one when the reader's question is *when does
366
+ this happen* and the answer is read off a POSITION — which day, how long, what else is running
367
+ at the same time. When the question is *which of these needs me*, the answer is a register with
368
+ a date column, and a calendar buries it: thirty rows of a table are scannable, thirty rows
369
+ scattered across a month grid are a search.
370
+
371
+ Three rules follow from that, and each of them is about legibility rather than taste.
372
+
373
+ **A grid is unreadable in two directions.** Seven equal columns need roughly 110px each to hold
374
+ a real title; below that every event reads "Cont…" and the grid can only say WHICH days have
375
+ something. Height fails the same way — a month too short to give each week row one event plus
376
+ its overflow chip renders nothing but "+3 more" on all thirty-one days. Both are the same
377
+ finding, and the answer to both is a different SHAPE: `CalendarView` falls back to the agenda on
378
+ its own. Never wrap a month grid in a horizontal scroller to force it — that trades a readable
379
+ list for a wide thing the reader has to drag.
380
+
381
+ **A time window is a viewport, never a filter.** Declaring the working day as 08:00–18:00 sets
382
+ where the grid OPENS. An event outside it widens the grid; a calendar that silently refuses to
383
+ draw a 06:00 delivery is a calendar that lies, and nothing on screen says so.
384
+
385
+ **Rows of days, or rows of resources — pick by what the reader is comparing.** Comparing days
386
+ is a calendar. Comparing a channel against a channel, an owner against an owner, a vehicle
387
+ against a vehicle — with days as the horizontal axis — is `GanttView`. A campaign plan is
388
+ usually the second one, however much it is called a calendar.
389
+
363
390
  ## Keyboard & focus
364
391
 
365
392
  - **Use `tabIndex`, never `focusable`.** React Native Web's `Pressable` silently ignores
@@ -529,9 +556,25 @@ have decided the thing itself (which one they want).
529
556
  The toggle earns its place when the two states differ in more than a number — different rows,
530
557
  different units, a different shape of answer. Swapping `1.700.000` for `2.000.000` is not that.
531
558
 
559
+ **A container's own gap must exceed the largest gap INSIDE its children — not just the gap to its
560
+ siblings.** The comparison authors are told to make is within-group against between-group, which
561
+ reads as "compare the two numbers *you* wrote". The failure that actually ships compares a number
562
+ you wrote against one the KIT wrote inside a component you dropped in: a section body at 12 or 14
563
+ around a field table that spaces its own rows at 16. Both are conventional, neither looks wrong
564
+ alone, and the boundary BETWEEN two objects ends up tighter than the boundary inside one. Measured
565
+ form: per container, collect its own `row-gap` and the **max `row-gap` of its visible children**;
566
+ `own <= child-max` is the finding.
567
+
532
568
  ## Numbers, money, dates
533
569
 
534
570
  - Free-standing numerals: `<Text tabular>` — always.
571
+ - **A kit component that RENDERS a number takes the app's formatter — it must never `String()` a
572
+ decimal.** In a locale that writes `28,9`, the one figure the kit prints for itself becomes the
573
+ one figure on the screen that is wrong, sitting beside numbers the app formatted correctly — and
574
+ in a locale where `.` is the *thousands* separator that is not a cosmetic difference, it is a
575
+ different quantity. `ChargeLines` takes `formatMoney` and `formatQuantity` for this reason, both
576
+ at the BAND so two lines cannot disagree, and threads them to the locked value and the inline
577
+ editor alike. Any new component that displays a caller's number owes the same hook.
535
578
  - **A column states its unit ONCE — which is only possible if it holds ONE unit.** State it in the
536
579
  header band and leave the cells bare. The corollary is the one that bites: a label/value list
537
580
  whose rows are a date range, a sum of money and a COUNT has no single unit to state, so each
@@ -1638,6 +1681,19 @@ information.
1638
1681
  identity a reader actually scans for — a deal takes its account's mark, since "whose deal is
1639
1682
  this" is the question being asked of the list. Only rows with genuinely no identity are exempt:
1640
1683
  a list of amounts, a log of events.
1684
+ - **A member as one FIELD among columns is TEXT, not a face** (`MemberChip showAvatar={false}`).
1685
+ Keep the face where the member IS the subject — a roster, a picker option, a profile. In an
1686
+ owner / assignee / last-edited-by column it fails twice over: it competes with the mark the row
1687
+ already carries for its own subject, and on a real book a handful of people own hundreds of rows,
1688
+ so the column draws the same disc over and over. Shipped example: an owner column rendering four
1689
+ identical discs for two people, both initial "T", both hashed to a purple.
1690
+ - **A mark derived from POSITION is not identity — the list already draws position.** An ordinal
1691
+ disc, a rank badge, an index chip *does* vary per row, so it passes "the mark must not look the
1692
+ same on every row" and every probe built on that test. It fails for a different reason: it
1693
+ restates what top-to-bottom order says, and it spends the one slot that could have carried the
1694
+ subject. Keep the ordinal where order is the thing being EDITED, and then as text in the
1695
+ supporting line — never as the mark. A surface that needs to state position has its own readout
1696
+ (a drawer's `3/8`, a stepper's rail).
1641
1697
  - **The rung follows what the row is ABOUT**: `lg` where the entity is the row's subject, `md`
1642
1698
  where it is one field among columns (see `avatar_size`).
1643
1699
  - **A column of faces must not draw an UNBOUNDED audience as faces.** Where a cell answers "who
@@ -1665,12 +1721,19 @@ Every rule above removes a defect. None of them ADDS anything, so a screen can p
1665
1721
  and still be reported as plain, boring, or unfinished — which is a different complaint with a
1666
1722
  different fix.
1667
1723
 
1668
- - **What is inherently visual in THIS data?** Work software has no photography, so the data itself
1669
- is the imagery: identity marks (above), status colour, file-type badges, sparklines, proportion
1670
- bars, tabular figures aligning down a column. Every one of these is information first and
1671
- decoration never which is why they are the only kind of visual interest that survives contact
1672
- with a real dataset. Reaching instead for a gradient, an illustration or a hero band adds weight
1673
- without adding a single fact.
1724
+ - **What is inherently visual in THIS data?** Mostly the data itself: identity marks (above),
1725
+ status colour, file-type badges, sparklines, proportion bars, tabular figures aligning down a
1726
+ column. Every one of these is information first and decoration never — which is why they are the
1727
+ only kind of visual interest that survives contact with a real dataset. Reaching instead for a
1728
+ gradient, an illustration or a hero band adds weight without adding a single fact.
1729
+ - **But some entities DO have a likeness, and then it is the strongest mark available.** A product,
1730
+ a property, a vehicle, a dish, a person — a reader recognises the thing itself faster than any
1731
+ derived initial, and the picture is doing the identifying rather than decorating. Where the entity
1732
+ has one, an empty image field is a **DATA gap, not a design constraint**: the fix is to put the
1733
+ image on the record, not to restyle the row around its absence. A catalogue of sixty rows opening
1734
+ with text reads as a fixture no treatment pass will rescue. The test is unchanged — it must make
1735
+ the screen clearer AND better-looking — which is exactly what a thumbnail that is also the
1736
+ identifier does, and exactly what a stock photo bolted onto a task list does not.
1674
1737
  - **What does the screen DECIDE?** A register listing every row at equal weight has no point of
1675
1738
  view: it hands the reader the same undifferentiated wall the query returned. A screen with a
1676
1739
  point of view puts what needs attention first (`TableGroup`), spends its one accent on where the
@@ -1827,6 +1890,31 @@ the ring. Collapsing them into one "active" style makes the selected item pixel-
1827
1890
  hovered one, so "which did I pick" is answerable only by moving the mouse away. The tell in code
1828
1891
  is a single boolean OR-ing selection together with hover, press and focus.
1829
1892
 
1893
+ ## A body, not an edge — and a corner proportionate to the box
1894
+
1895
+ Two rules that decide how any small coloured surface is drawn: an event chip, a board card, a
1896
+ timeline bar, a status pill.
1897
+
1898
+ **A wash gives it a body; a border on top is the same edge asserted twice.** A transparent
1899
+ rectangle on a white canvas *is* its border — so the fill is what makes it an object, and once
1900
+ it has one, the outline says nothing new. This matters most where the surface already sits on a
1901
+ ruled ground (a calendar grid, a timeline, a table): every extra hairline competes with the
1902
+ rules that carry the structure. The same goes for an accent stripe down one side — it is a
1903
+ border wearing a colour, and the wash it sits on has already stated where the thing begins.
1904
+
1905
+ **The wash is the family's light ground with its dark ink, never the solid shade with white on
1906
+ it.** A saturated fill per item turns a dense surface into a paint chart, and a screen with a
1907
+ dozen categories on it stops being something anyone can look at. The pairing is the one `Badge`
1908
+ uses for `tonal`. The solid shade keeps exactly one job: a DOT — a few pixels carrying identity
1909
+ rather than area, where the contrast is the point.
1910
+
1911
+ **A radius is a proportion of the box, not a constant.** A component whose height is data —
1912
+ a chip sized to its duration, a bar sized to its span — cannot take one number: at 22px the
1913
+ control rung renders a pill, and the same value on a tall block is barely a corner, so two
1914
+ boxes of one family read as two vocabularies. Scale by height and cap at the container rung
1915
+ (`proportionalRadius`). Keep the ratio under a third, or the corner eats the side and the shape
1916
+ stops being a rounded rectangle.
1917
+
1830
1918
  ## `Badge` is for STATUS only — everything else is text
1831
1919
 
1832
1920
  A `Badge` means STATE that must read at a GLANCE — a lifecycle status, a risk level, a quality
package/docs/reviewing.md CHANGED
@@ -543,15 +543,23 @@ this converges on *distinctive*.
543
543
  largest, brightest thing there.
544
544
  - **What does the screen DECIDE?** A register listing every row at equal weight has no point of
545
545
  view. Usually the real answer when treatment fixes keep not landing.
546
- - **What is inherently visual in THIS data?** Where there is no photography most work software —
547
- the data is the imagery: identity marks, status colour, file-type badges, sparklines, proportion
548
- bars, tabular figures.
546
+ - **What is inherently visual in THIS data?** Usually the data itself: identity marks, status
547
+ colour, file-type badges, sparklines, proportion bars, tabular figures. **But check whether the
548
+ entity has a LIKENESS** — a product, a property, a vehicle, a person. If it does and the rows
549
+ open without one, query the image field's fill rate before writing a treatment finding: an empty
550
+ field makes this a DATA defect, and no amount of restyling fixes it.
549
551
  - **What makes this belong to THIS product?** Default typeface, default greys, default accent,
550
552
  default control shapes is a well-built generic screen, and no polish changes that. The fix is a
551
553
  theme, not another treatment pass.
552
554
 
553
555
  → [composition.md](./composition.md) §"Identity marks", §"Character comes from the DATA", §"Where the accent goes".
554
556
 
557
+ **A driver's `.focus()` does not set `:focus-visible`** — so a focus-ring check driven
558
+ programmatically reports *no ring* on a control whose ring is fine, and the audit spends its time
559
+ on a defect that does not exist. Walk the surface with real `Tab` presses and read the computed
560
+ `box-shadow` at each stop; `.focus()` is only good for asking what CAN take focus, never for how it
561
+ looks when it does.
562
+
555
563
  ### 10. Microcopy
556
564
  - **No punctuation doing a word's job.** ` · ` is banned outright — it claims a relationship while
557
565
  refusing to name it, and it is the loudest templated-metadata tell there is. If a mark cannot be
@@ -637,6 +645,32 @@ variant that walks ELEMENTS rather than text nodes. Those tags hold text nodes t
637
645
  sees, and counting them produces confident nonsense — a page "with eight nodes in Times New Roman"
638
646
  that has none, or a clean tally on a screen with an untracked component still on it.
639
647
 
648
+ ### Identity marks — N identical is a finding, and only a per-COLUMN count sees it
649
+ Probe 9 says "count them. Zero is the finding, and so is N identical" — and every other snippet
650
+ here reads type, rules, gaps, states or reachability, so nothing fired on a column of identical
651
+ discs. Group by the x band (i.e. by column), because that is what separates the row's own subject
652
+ mark from a supporting column that borrowed identity weight.
653
+
654
+ ```js
655
+ // → [{ x, count, distinct, sample }] — flag any band with count > 1 && distinct === 1
656
+ Object.values(
657
+ [...document.querySelectorAll('[data-avatar], [class*="avatar"], img')]
658
+ .filter((n) => n.getBoundingClientRect().width > 0)
659
+ .reduce((acc, n) => {
660
+ const band = Math.round(n.getBoundingClientRect().x / 24) * 24; // column, not pixel
661
+ const cs = getComputedStyle(n);
662
+ const key = [cs.backgroundColor, n.textContent?.trim(), n.getAttribute("src") ?? ""].join("|");
663
+ acc[band] ??= { x: band, keys: new Set(), count: 0 };
664
+ acc[band].keys.add(key);
665
+ acc[band].count++;
666
+ return acc;
667
+ }, {}),
668
+ ).map((b) => ({ x: b.x, count: b.count, distinct: b.keys.size, sample: [...b.keys][0] }));
669
+ ```
670
+
671
+ A band with `count > 1 && distinct === 1` is the reported defect. A band whose `distinct` is well
672
+ under `count` is the softer version — a palette collapsing on real names — and worth reading by eye.
673
+
640
674
  ### Separator beats — is the biggest boundary the widest?
641
675
 
642
676
  **A rule is a painted LINE, and it is drawn two ways.** Selecting "thin and wide" alone gets both
@@ -740,17 +774,34 @@ Snapshot the SAME selector four times — at rest, hovered, focused, and while e
740
774
  const r = e.getBoundingClientRect(), cs = getComputedStyle(e);
741
775
  const inner = e.querySelector('input, textarea, [contenteditable]') || e;
742
776
  const ir = inner.getBoundingClientRect();
777
+ // The PAINTED node, which is often not the one you selected: a press target
778
+ // is frequently transparent with its ground on a child (a chip's body, a
779
+ // bar's fill). Reading the target's own `backgroundColor` returns
780
+ // `rgba(0,0,0,0)` at rest AND hovered, so a component with a perfectly good
781
+ // hover reports as having none — a probe that fails silently in the
782
+ // reassuring direction.
783
+ const opaque = (x) => { const b = getComputedStyle(x).backgroundColor; return b && b !== 'rgba(0, 0, 0, 0)' ? b : null; };
784
+ const painted = opaque(e) || [...e.querySelectorAll('*')].map(opaque).find(Boolean) || 'none';
743
785
  return {
744
786
  box: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)],
745
787
  text: [Math.round(ir.x), Math.round(ir.y)],
746
788
  border: `${cs.borderTopWidth} ${cs.borderTopColor}`,
747
- bg: cs.backgroundColor,
789
+ bg: painted,
748
790
  shadow: cs.boxShadow,
749
791
  };
750
792
  })
751
793
  ```
752
794
 
753
- `border`/`bg`/`shadow` differing is the design; `box`/`text` differing is the defect. The usual
795
+ `border`/`bg`/`shadow` differing is the design; `box`/`text` differing is the defect.
796
+
797
+ **A control that differs in NOTHING is the finding this catches most often**, and it does not
798
+ look like a bug in a screenshot — the thing renders correctly, presses correctly, announces
799
+ correctly, and simply never says it can be pressed. Run it against anything the eye reads as a
800
+ card, chip or bar: identical rest and hover means the affordance was never built. What should
801
+ change is the surface's OWN signature (a chip's fill, a row's wash, a field's border) one rung
802
+ deeper, never a different colour and never the cursor — see composition.md §"The pointer
803
+ cursor". And a surface with no press handler must NOT light up: an affordance on something
804
+ inert advertises an action that does not exist. The usual
754
805
  cause is two components spending the same inset or the same padding — a wrapper AND the input both
755
806
  paying `CONTROL_TEXT_INSET`, a focus ring owned by both a shell and a nested pressable.
756
807
 
package/docs/templates.md CHANGED
@@ -1015,8 +1015,14 @@ when you want them in the context of a whole record surface, and here when money
1015
1015
  One real `CalendarView` week grid over the current week's events, then a "Today" agenda card
1016
1016
  of pressable rows. Every slot is a door: pressing a grid event or an agenda row opens the
1017
1017
  sequenced workspace `Drawer`; agenda rows also carry the `⋯` quick-actions menu. Event colors
1018
- carry meaning (one `ColorName` per event kind). The mock data anchors to the real current
1019
- week so the copied screen is evergreen.
1018
+ carry meaning (one `ColorName` per event kind, stated ONCE the chip's colour and the agenda
1019
+ row's swatch read the same field). The mock data anchors to the real current week so the copied
1020
+ screen is evergreen.
1021
+
1022
+ **Read the header band for the controlled pattern.** The screen owns `date`, so its own "Today"
1023
+ button sets it — the calendar has no second toolbar of its own to fight, and nothing is
1024
+ remounted to force a jump. That is the shape to copy whenever a calendar sits under a header
1025
+ band you already have.
1020
1026
 
1021
1027
  ### `tpl_attendance` — the attendance desk
1022
1028
 
@@ -1,7 +1,7 @@
1
1
  import { useState } from "react";
2
2
  import { ScrollView, View } from "react-native";
3
3
  import { Text } from "@lotics/ui/text";
4
- import { colors, solid, type ColorName } from "@lotics/ui/colors";
4
+ import { colors, type ColorName } from "@lotics/ui/colors";
5
5
  import { ActionMenu } from "@lotics/ui/action_menu";
6
6
  import { Badge } from "@lotics/ui/badge";
7
7
  import { Button } from "@lotics/ui/button";
@@ -52,47 +52,48 @@ function evt(
52
52
  m: number,
53
53
  durMin: number,
54
54
  title: string,
55
- color: string,
56
55
  data: AgendaMeta,
57
56
  ): CalendarEvent<AgendaMeta> {
58
57
  const start = new Date(day.getFullYear(), day.getMonth(), day.getDate(), h, m);
59
- return { id, title, start, end: new Date(start.getTime() + durMin * 60_000), color, data };
58
+ // The chip's colour IS the row's colour `data.mau` is the one statement of
59
+ // it, so the two surfaces cannot drift apart.
60
+ return { id, title, start, end: new Date(start.getTime() + durMin * 60_000), color: data.mau, data };
60
61
  }
61
62
 
62
63
  const EVENTS: CalendarEvent<AgendaMeta>[] = [
63
64
  // Today — the dispatch desk's three slots.
64
- evt("hn-1", TODAY, 8, 0, 90, "Deliver APEX PLASTICS — VTD063518", solid("blue"), {
65
+ evt("hn-1", TODAY, 8, 0, 90, "Deliver APEX PLASTICS — VTD063518", {
65
66
  diaDiem: "Eastport industrial zone",
66
67
  trangThai: "Truck loaded",
67
68
  mau: "blue",
68
69
  }),
69
- evt("hn-2", TODAY, 10, 30, 60, "Reconcile delivery notes — HANDAN", solid("emerald"), {
70
+ evt("hn-2", TODAY, 10, 30, 60, "Reconcile delivery notes — HANDAN", {
70
71
  diaDiem: "Head office",
71
72
  trangThai: "Prepared",
72
73
  mau: "emerald",
73
74
  }),
74
- evt("hn-3", TODAY, 15, 0, 60, "Customer sample review — VITTORIA", solid("amber"), {
75
+ evt("hn-3", TODAY, 15, 0, 60, "Customer sample review — VITTORIA", {
75
76
  diaDiem: "Factory sample room",
76
77
  trangThai: "Awaiting confirmation",
77
78
  mau: "amber",
78
79
  }),
79
80
  // The rest of the week.
80
- evt("t-1", addDays(WEEK_START, SLOTS[0]), 8, 0, 90, "Deliver KOMASPEC — blanks 675×325", solid("blue"), {
81
+ evt("t-1", addDays(WEEK_START, SLOTS[0]), 8, 0, 90, "Deliver KOMASPEC — blanks 675×325", {
81
82
  diaDiem: "Northgate industrial park",
82
83
  trangThai: "Truck loaded",
83
84
  mau: "blue",
84
85
  }),
85
- evt("t-2", addDays(WEEK_START, SLOTS[1]), 14, 0, 60, "Reconcile delivery notes — NEWTECONS", solid("emerald"), {
86
+ evt("t-2", addDays(WEEK_START, SLOTS[1]), 14, 0, 60, "Reconcile delivery notes — NEWTECONS", {
86
87
  diaDiem: "Head office",
87
88
  trangThai: "Prepared",
88
89
  mau: "emerald",
89
90
  }),
90
- evt("t-3", addDays(WEEK_START, SLOTS[2]), 10, 0, 60, "Kick off the VITTORIA order", solid("amber"), {
91
+ evt("t-3", addDays(WEEK_START, SLOTS[2]), 10, 0, 60, "Kick off the VITTORIA order", {
91
92
  diaDiem: "Online meeting",
92
93
  trangThai: "Awaiting confirmation",
93
94
  mau: "amber",
94
95
  }),
95
- evt("t-4", addDays(WEEK_START, SLOTS[3]), 8, 30, 90, "Deliver BRIGHTCELL BATTERIES — TS1250", solid("blue"), {
96
+ evt("t-4", addDays(WEEK_START, SLOTS[3]), 8, 30, 90, "Deliver BRIGHTCELL BATTERIES — TS1250", {
96
97
  diaDiem: "Brightcell plant, Eastport",
97
98
  trangThai: "Truck loaded",
98
99
  mau: "blue",
@@ -104,20 +105,10 @@ const EVENTS: CalendarEvent<AgendaMeta>[] = [
104
105
  start: addDays(WEEK_START, 2),
105
106
  end: addDays(WEEK_START, 4),
106
107
  allDay: true,
107
- color: colors.zinc[500],
108
+ color: "zinc",
108
109
  },
109
110
  ];
110
111
 
111
- const CAL_LABELS = {
112
- today: "Today",
113
- month: "Month",
114
- week: "Week",
115
- day: "Day",
116
- previous: "Previous",
117
- next: "Next",
118
- allDay: "all day",
119
- more: (n: number) => `+${n} more`,
120
- };
121
112
 
122
113
  const p2 = (n: number) => String(n).padStart(2, "0");
123
114
  const fmtTime = (d: Date) => `${p2(d.getHours())}:${p2(d.getMinutes())}`;
@@ -187,9 +178,10 @@ function SlotWorkspace({ e }: { e: CalendarEvent<AgendaMeta> }) {
187
178
  }
188
179
 
189
180
  export function TplCalendar() {
190
- // "Today" remounts the calendar CalendarView owns date+view internally,
191
- // so a fresh mount is the supported way to jump back to the current week.
192
- const [calKey, setCalKey] = useState(0);
181
+ // The screen owns the date, so its own header band can drive the calendar —
182
+ // no remount, no second toolbar. This used to be `key={calKey}`, forcing a
183
+ // fresh mount because the calendar's date was private to it.
184
+ const [date, setDate] = useState(TODAY);
193
185
  const [openId, setOpenId] = useState<string | null>(null);
194
186
 
195
187
  const openEvent = EVENTS.find((e) => e.id === openId) ?? null;
@@ -215,21 +207,22 @@ export function TplCalendar() {
215
207
  <Text size="sm" color="muted">This week — deliveries, reconciliations and customer visits</Text>
216
208
  </View>
217
209
  <Text size="sm" color="muted" tabular>
218
- {viewTitle("week", TODAY, 1, "vi")}
210
+ {viewTitle("week", date, 1, "vi")}
219
211
  </Text>
220
- <Button title="Today" color="secondary" onPress={() => setCalKey((k) => k + 1)} />
212
+ <Button title="Today" color="secondary" onPress={() => setDate(TODAY)} />
221
213
  </View>
222
214
 
223
215
  {/* the calendar — real week grid, internal toolbar + scroll */}
224
216
  <Card style={{ padding: 0, height: 520, overflow: "hidden" }}>
225
217
  <CalendarView<AgendaMeta>
226
- key={calKey}
227
218
  events={EVENTS}
228
219
  defaultView="week"
229
- defaultDate={TODAY}
220
+ date={date}
221
+ onDateChange={setDate}
230
222
  weekStartsOn={1}
231
223
  locale="vi"
232
- labels={CAL_LABELS}
224
+ dayStartHour={7}
225
+ dayEndHour={19}
233
226
  onEventPress={(e) => setOpenId(e.id)}
234
227
  />
235
228
  </Card>
@@ -1244,8 +1244,13 @@ export function TplItemList() {
1244
1244
  <Text size="xxl" weight="semibold">Records</Text>
1245
1245
 
1246
1246
  {/* toolbar — search + consolidated filters LEFT, New case RIGHT, one row.
1247
- A search is present, so status is a dropdown (Select), not pills. */}
1248
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
1247
+ A search is present, so status is a dropdown (Select), not pills.
1248
+
1249
+ `flex-start`, not `center`: the filter block wraps to two or three
1250
+ lines on a phone, and centring floats the CTA against the whole
1251
+ wrapped block — measured landing level with the SECOND row of chips,
1252
+ anchored to nothing. The band's top edge is what it belongs to. */}
1253
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
1249
1254
  <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, alignItems: "center", flex: 1 }}>
1250
1255
  <View style={{ flexGrow: 1, flexBasis: 220, minWidth: 200, maxWidth: 340 }}>
1251
1256
  <SearchInput