@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
@@ -5,6 +5,7 @@ import { Text } from "@lotics/ui/text";
5
5
  import { colors, solid, type ColorName } from "@lotics/ui/colors";
6
6
  import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
7
7
  import { Alert } from "@lotics/ui/alert";
8
+ import { Avatar } from "@lotics/ui/avatar";
8
9
  import { Badge } from "@lotics/ui/badge";
9
10
  import { Button } from "@lotics/ui/button";
10
11
  import { CheckboxInput } from "@lotics/ui/checkbox_input";
@@ -60,7 +61,8 @@ import { useChangeSet } from "@lotics/ui/use_change_set";
60
61
  // Template, Register — THE canonical register, and the ONE work-execution list
61
62
  // shape. A flat, paginated, SELECTABLE list of records: one toolbar row (search +
62
63
  // consolidated filters LEFT, New CTA RIGHT), a light SummaryLine of the filtered
63
- // view, a sortable, Divider-separated register where every row has a leading
64
+ // view, a sortable register whose rows are separated by their own height and the
65
+ // hover wash rather than by a rule, where every row has a leading
64
66
  // checkbox (a row that can't take the bulk action — here an "Awaiting docs" case —
65
67
  // gets a DISABLED one), a per-row PRINT action button, and a ⋯ overflow (press the
66
68
  // row = the sequenced workspace Drawer), a select-all band, footer totals +
@@ -215,59 +217,98 @@ function HoSoRow({ hs, ordinal, daThu, selected, marked, selectable, onToggle, o
215
217
  ordinal={ordinal}
216
218
  selected={selected}
217
219
  marked={marked}
218
- minHeight={56}
219
220
  accessibilityLabel={`Open record ${hs.ma} — ${hs.khach}`}
220
221
  leading={<CheckboxInput accessibilityLabel={selectable ? `Select ${hs.ma}` : `${hs.ma} — awaiting docs, not ready to export`} checked={marked} disabled={!selectable} onChange={onToggle} />}
221
222
  action={<Button title="Print" icon={docFor(hs, daThu).icon} color="secondary" onPress={() => {}} />}
222
223
  trailing={<ActionMenu items={menuFor(hs, daThu)} accessibilityLabel={`Actions for ${hs.ma}`} />}
223
224
  >
224
225
  <TableCell>
225
- <View style={{ gap: 2, alignSelf: "stretch" }}>
226
- {/* The name takes the row, the thread pins right. A comment count is
227
- the one thing on this row that is about PEOPLE talking rather than
228
- the record's own state, so it sits apart from the values below it
229
- instead of joining them on the supporting line. */}
230
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
231
- <Text size="sm" weight="medium" numberOfLines={1} style={{ flexShrink: 1 }}>{hs.khach}</Text>
232
- {/* Rendered only where a thread EXISTS — a zero on every quiet row is a
233
- column of noise that trains the eye to skip where the signal appears. */}
234
- {hs.binhLuan > 0 ? <CommentsButton count={hs.binhLuan} subject={hs.khach} onPress={onOpenComments} /> : null}
235
- </View>
236
- {/* Every value on this row is selectable that is what a register owes
237
- its reader, and it costs no chrome. The phone gets a CONTROL on top
238
- because it is the one value copied often enough to earn one (a desk
239
- pastes it into a message, a rep dials it). The verb rides the value
240
- inside its own cell: the trailing gutter belongs to the ⋯, and with
241
- two values in this cell it would have nothing to name.
242
-
243
- UNCONDITIONAL, and that is the load-bearing part of this shape. A
244
- row centres its cells, so a supporting line that appears only when
245
- its field is set makes this cell two heights, and the NAME above it
246
- then sits at two different offsets down the column while every row
247
- still measures the same. That is why the values here are ones every
248
- record carries — a key, a contact rather than whichever field is
249
- most interesting: the line has to be honest on every row to be
250
- reservable at all. */}
251
- <View style={{ flexDirection: "row", alignItems: "center", gap: 2 }}>
252
- {/* The KEY, muted, on the supporting line at most this, never a
253
- column and never the row's name.
254
-
255
- It does NOT shrink. A key is quoted whole or it is not a key: a
256
- phone number clipped to "(555) 384-7…" is still recognisably a
257
- number the reader can widen the column to finish, while
258
- "RC-2026-0…" is unusableit is the exact string a desk reads
259
- out. So the fixed-length identifier holds its width and the phone
260
- gives way, which is also the right order of loss: the phone has a
261
- Copy control beside it that hands over the full value regardless. */}
262
- <Text size="xs" color="muted" tabular numberOfLines={1} style={{ flexShrink: 0 }}>{hs.ma}</Text>
263
- {/* A GAP, not a middot. Two facts of different kinds sat either side
264
- of a ` · ` here, which is the separator this kit bans outright —
265
- punctuation claiming a relation it refuses to name, and dropped
266
- entirely by a screen reader. Spacing separates them without
267
- asserting anything, and the two are already told apart by shape:
268
- one is a key, one is a number with a Copy control on it. */}
269
- <Text size="xs" color="muted" tabular numberOfLines={1} style={{ flexShrink: 1, marginLeft: 8 }}>{hs.dienThoai}</Text>
270
- <CopyButton value={hs.dienThoai} label="Copy phone number" />
226
+ {/* THE IDENTITY MARK leads the cell that names the row. A register of
227
+ entities without one is N rows of grey text a reader scans linearly;
228
+ with one, the eye lands on the row it wants by colour and letter
229
+ before it reads a word. `Avatar` derives its hue from the name, so
230
+ the marks differ across rows without anyone configuring anything —
231
+ the failure mode to avoid is N identical marks, which is the largest
232
+ and brightest thing on the row carrying no information at all.
233
+
234
+ It sits in the SUBJECT'S CELL, not the `leading` slot: `leading`
235
+ already holds the selection checkbox, and those two are different
236
+ jobs — one is what the row IS, the other is something you are doing
237
+ to it. Stacking them in one slot reads as a single compound control.
238
+
239
+ `lg` because the customer is what this row is ABOUT. A person who is
240
+ one FIELD among columns (the owner, below) takes `md`; see
241
+ `avatar_size.ts`, which owns that rule.
242
+
243
+ ONE SHAPE for the whole register. `Avatar` takes `square` for an
244
+ organization, but that distinction earns its place only where a
245
+ surface mixes entity TYPES and the reader acts on which is which.
246
+ Here every row is a record, and whether the customer is a company is
247
+ not something the reader does anything with so a column of mixed
248
+ circles and squares reads as an inconsistency rather than as
249
+ information, which is exactly how it gets reported. */}
250
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10, alignSelf: "stretch" }}>
251
+ <Avatar name={hs.khach} size="lg" />
252
+ <View style={{ gap: 2, flex: 1, minWidth: 0 }}>
253
+ {/* The name takes the row, the thread pins right. A comment count is
254
+ the one thing on this row that is about PEOPLE talking rather than
255
+ the record's own state, so it sits apart from the values below it
256
+ instead of joining them on the supporting line. */}
257
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
258
+ <Text size="sm" weight="medium" numberOfLines={1} style={{ flexShrink: 1 }}>{hs.khach}</Text>
259
+ {/* Rendered only where a thread EXISTS a zero on every quiet row is a
260
+ column of noise that trains the eye to skip where the signal appears. */}
261
+ {hs.binhLuan > 0 ? <CommentsButton count={hs.binhLuan} subject={hs.khach} onPress={onOpenComments} /> : null}
262
+ </View>
263
+ {/* Every value on this row is selectable — that is what a register owes
264
+ its reader, and it costs no chrome. The phone gets a CONTROL on top
265
+ because it is the one value copied often enough to earn one (a desk
266
+ pastes it into a message, a rep dials it). The verb rides the value
267
+ inside its own cell: the trailing gutter belongs to the ⋯, and with
268
+ two values in this cell it would have nothing to name.
269
+
270
+ UNCONDITIONAL, and that is the load-bearing part of this shape. A
271
+ row centres its cells, so a supporting line that appears only when
272
+ its field is set makes this cell two heights, and the NAME above it
273
+ then sits at two different offsets down the column while every row
274
+ still measures the same. That is why the values here are ones every
275
+ record carries — a key, a contact — rather than whichever field is
276
+ most interesting: the line has to be honest on every row to be
277
+ reservable at all. */}
278
+ {/* HEIGHT PINNED TO THE xs LINE BOX (18), because this line carries a
279
+ CONTROL. `CopyButton` at `sm` is 24 — sized to sit beside `sm`
280
+ text, per its own doc — so on an `xs` line it made the row 24 and
281
+ `alignItems: "center"` spent 3 of those px above the text. The
282
+ supporting line then started 5px under the name while the Fee
283
+ column's identical primary-over-supporting pair, which carries no
284
+ control, started 2px under its own. Same shape, two treatments,
285
+ and the reason (one of them has a button) is invisible to a
286
+ reader — which is the whole test.
287
+
288
+ Pinning the row to the text's line box lets the 24px hit area
289
+ overflow it without moving the text. Any verb riding a supporting
290
+ line needs this; a control must not resize the line it sits on. */}
291
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 2, height: 18 }}>
292
+ {/* The KEY, muted, on the supporting line — at most this, never a
293
+ column and never the row's name.
294
+
295
+ It does NOT shrink. A key is quoted whole or it is not a key: a
296
+ phone number clipped to "(555) 384-7…" is still recognisably a
297
+ number the reader can widen the column to finish, while
298
+ "RC-2026-0…" is unusable — it is the exact string a desk reads
299
+ out. So the fixed-length identifier holds its width and the phone
300
+ gives way, which is also the right order of loss: the phone has a
301
+ Copy control beside it that hands over the full value regardless. */}
302
+ <Text size="xs" color="muted" tabular numberOfLines={1} style={{ flexShrink: 0 }}>{hs.ma}</Text>
303
+ {/* A GAP, not a middot. Two facts of different kinds sat either side
304
+ of a ` · ` here, which is the separator this kit bans outright —
305
+ punctuation claiming a relation it refuses to name, and dropped
306
+ entirely by a screen reader. Spacing separates them without
307
+ asserting anything, and the two are already told apart by shape:
308
+ one is a key, one is a number with a Copy control on it. */}
309
+ <Text size="xs" color="muted" tabular numberOfLines={1} style={{ flexShrink: 1, marginLeft: 8 }}>{hs.dienThoai}</Text>
310
+ <CopyButton value={hs.dienThoai} label="Copy phone number" />
311
+ </View>
271
312
  </View>
272
313
  </View>
273
314
  </TableCell>
@@ -1199,11 +1240,15 @@ export function TplItemList() {
1199
1240
  <FileDropTarget onFiles={openIntakeSeeded} paste style={{ flex: 1 }}>
1200
1241
  <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
1201
1242
  <View style={{ width: "100%", maxWidth: 1040, alignSelf: "center", gap: 16 }}>
1202
- {/* header title only; the New case action lives on the toolbar row */}
1203
- <View style={{ gap: 2 }}>
1204
- <Text size="xxl" weight="semibold">Records</Text>
1205
- <Text size="sm" color="muted">Track each record's status and print the right document for its stage</Text>
1206
- </View>
1243
+ {/* The page band the screen's TYPE ANCHOR before it is a label. Without
1244
+ it this page's largest text is 16px against a 12px floor, and a 1.33x
1245
+ range reads flat however much colour is on it. The title repeating the
1246
+ nav is a real cost and the right trade: one line buys the whole range.
1247
+ What is NOT here is a gloss on the widget ("track each record's status
1248
+ and print the right document for its stage"), which described the
1249
+ control rather than the domain — and what is not here EITHER is the
1250
+ summary, which belongs under the filters that change it. */}
1251
+ <Text size="xxl" weight="semibold">Records</Text>
1207
1252
 
1208
1253
  {/* toolbar — search + consolidated filters LEFT, New case RIGHT, one row.
1209
1254
  A search is present, so status is a dropdown (Select), not pills. */}
@@ -1273,7 +1318,17 @@ export function TplItemList() {
1273
1318
  <Button title="Enter data" color="primary" onPress={openIntake} />
1274
1319
  </View>
1275
1320
 
1276
- {/* light summary of the filtered register (search/assignee/fee applied; status counts stay visible across tabs) */}
1321
+ {/* THE SUMMARY READS THE FILTERED SET, so it sits BELOW the controls that
1322
+ produce it. These figures move every time the reader touches a chip:
1323
+ put them above, and the screen answers before it has been asked — the
1324
+ reader changes a filter, the numbers change behind their eye, and the
1325
+ causal order the layout states is backwards. Under the toolbar it
1326
+ reads as the result of what was just set, and it is the last thing
1327
+ before the rows it counts.
1328
+
1329
+ It is not a page-band subtitle for the same reason. A subtitle
1330
+ describes the screen; these describe the QUERY, and the query is what
1331
+ the toolbar above just wrote. */}
1277
1332
  <SummaryLine
1278
1333
  items={[
1279
1334
  { label: "processing", value: dem("dangxuly") },
@@ -52,7 +52,7 @@ import { MemberSelect, type MemberSelectMember } from "@lotics/ui/member_select"
52
52
  import { EmptyState } from "@lotics/ui/empty_state";
53
53
  import { FormField } from "@lotics/ui/form_field";
54
54
  import { TextInputField } from "@lotics/ui/text_input_field";
55
- import { FileThumbnail, THUMBNAIL_SIZE, type DisplayFile } from "@lotics/ui/file_thumbnail";
55
+ import { FileThumbnail, THUMBNAIL_SIZE, COMPACT_THUMBNAIL_SIZE, type DisplayFile } from "@lotics/ui/file_thumbnail";
56
56
  import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
57
57
  import { Inset } from "@lotics/ui/inset";
58
58
  import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
@@ -122,6 +122,87 @@ type Part = UIMessagePart<UIDataTypes, UITools>;
122
122
  // table ONCE (creates + links the sibling; Recall undoes).
123
123
  // - Danger zone — destructive lifecycle. Always last.
124
124
  //
125
+ //
126
+ // ─── THE RULES THIS TEMPLATE ENCODES ─────────────────────────────────────────
127
+ // Read these before adapting it. Each one shipped WRONG on a real app first,
128
+ // was reported by the person using it, and cost a rebuild. They are not style
129
+ // preferences; they are the difference between a screen that works and a screen
130
+ // that gets called bland, cluttered or inconsistent.
131
+ //
132
+ // 1. A BADGE MEANS STATUS. Nothing else.
133
+ // A lifecycle, a stage, a risk level — something that reads at a glance and
134
+ // changes what you DO. A type, a category, an attribute or a count is NOT a
135
+ // status: an industry, a source, a department, a headcount band, a CITY.
136
+ // Those are plain `Text`. A coloured pill only means "state" for as long as
137
+ // it stays scarce, so one spent on where a company happens to be devalues
138
+ // the one column that IS a state. A field carrying a configured `color` is
139
+ // not consent to paint it — someone set that colour for the one surface that
140
+ // badges, and every other surface still has to decide.
141
+ // Feed pickers with `optionPicker(options)` — text — and add
142
+ // `{ badge: "dot" }` only on a status. Helper defaults must be the SAFE
143
+ // answer, because a badging default reaches every call site in one sweep.
144
+ //
145
+ // 2. ONE VARIANT PER CONCEPT, product-wide. `dot` or `tonal`, chosen once. The
146
+ // same field rendering as a filled pill on one screen and a dot on another is
147
+ // the inconsistency people actually notice. Where a register already carries
148
+ // identity marks, prefer `dot`: two colour systems in one row compete and
149
+ // neither reads as the subject.
150
+ //
151
+ // 3. THE PAGE BAND IS THE TYPE ANCHOR before it is a label. Without it a record
152
+ // or register tops out near 16px over a 12px floor — a 1.33x range that reads
153
+ // flat at ANY amount of colour, because scale is the one hierarchy device
154
+ // colour cannot replace. Accept that the title repeats the nav. Its second
155
+ // line carries LIVE STATE (counts that move as you filter), never a gloss on
156
+ // the widget.
157
+ //
158
+ // 4. A SUPPORTING LINE IS ONE RUNG BELOW ITS PRIMARY, never a fixed size. `md`
159
+ // over `xs` drops 1.33x while the same shape elsewhere drops 1.17x, and the
160
+ // wider pair reads as though its second line SHRANK rather than stepped.
161
+ // Compare the RATIOS across the surface, not the sizes.
162
+ //
163
+ // 5. ACTIONABLE FIRST. Identity and the one editable status go above the fold.
164
+ // An UNBOUNDED section (an activity feed, a comment thread) stacked above a
165
+ // bounded field set does not order them — it BURIES the second, and the
166
+ // burial deepens every time the record is used. Measured on a real record,
167
+ // the fields the reader came to edit began 2,500px down. That is the case
168
+ // where tabs beat one page, against the record-extent rule.
169
+ // Promoting a field means MOVING it: a copy left behind gives one field two
170
+ // editors on one surface, and either could be the one the reader changed.
171
+ //
172
+ // 6. PICK THE CONTROL BY SPECIES, from what already exists. Read
173
+ // `data_entry.md`'s ladder and the `tpl_*` that covers the shape BEFORE
174
+ // building. A binary filter is a `ChipGroup` (every option visible, one
175
+ // press), never a popover holding one checkbox. Grouping picks a DIMENSION
176
+ // via the same control species as the filters beside it, never an icon
177
+ // toggling on/off. A SELECTED control needs a GROUND, not a heavier outline —
178
+ // among white pills a 1px border change is invisible.
179
+ //
180
+ // 7. CLUSTER A TOOLBAR BY QUESTION, and check the gap RATIO. Filters answer
181
+ // *which rows*; view controls answer *how are they arranged*; the CTA answers
182
+ // neither. Uniform gaps make eight controls read as one undifferentiated
183
+ // band; 8px within a cluster and 24px between reads as groups with no lines
184
+ // or boxes added. Long labels are a toolbar defect — the band is a set of
185
+ // handles, not sentences.
186
+ //
187
+ // 8. PICK THE FILE SURFACE BY WHAT IDENTIFIES THE FILE, after counting the real
188
+ // data. Images are identified by CONTENT (grid); documents by NAME (list) —
189
+ // a grid of PDFs is a wall of one grey tile. Press should OPEN a document
190
+ // (`press="open"`); a preview lightbox is a dead end for anything you sign,
191
+ // edit or send. A files field is `InlineFiles` in a `DetailRow`, the same
192
+ // grammar as every other field beside it — never a bespoke block below the
193
+ // table. A surface that can SHOW a file and cannot RECEIVE one is unfinished.
194
+ //
195
+ // 9. AN IDENTITY MARK MUST NOT LOOK THE SAME ON EVERY ROW — that is its entire
196
+ // job. `Avatar` derives its hue from the name; circle for a person, square
197
+ // for an organization. A screen whose subject is an entity and whose marks
198
+ // are all one colour has the largest, brightest element in each row carrying
199
+ // no information.
200
+ //
201
+ // 10. NEVER FORK THE KIT. Hitting a limit means fixing the kit — a component
202
+ // built app-local beside one that nearly fits is a fork that drifts, and the
203
+ // next author inherits both. If a prop is missing, add the prop.
204
+ // ─────────────────────────────────────────────────────────────────────────────
205
+ //
125
206
  // The lifecycle is the HANDOFF CHAIN, and it reads in TWO places by design:
126
207
  // `Progress` carries the position and the act that changes it; `Handoff` near
127
208
  // the end carries the RESULT — what each handoff created, and when. There is no
@@ -3609,9 +3690,13 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
3609
3690
  >
3610
3691
  <TableCell>
3611
3692
  <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
3612
- {/* ONE square 32px slot for every type: an image fills it as a
3613
- real thumbnail, a document centers its badge in it */}
3614
- <FileThumbnail file={toDisplay(f)} size={32} onPress={() => openPreview(visibleFiles, fi)} />
3693
+ {/* ONE square slot for every type: an image fills it as a real
3694
+ thumbnail, a document centers its badge in it. The rung is
3695
+ the kit's, not a number picked here — it tracks the row
3696
+ height, and a file row's mark is the same size as a
3697
+ person's in a register, because both answer "which row is
3698
+ this". */}
3699
+ <FileThumbnail file={toDisplay(f)} size={COMPACT_THUMBNAIL_SIZE} onPress={() => openPreview(visibleFiles, fi)} />
3615
3700
  <Text size="sm" weight="medium" numberOfLines={1} style={{ flexShrink: 1 }}>{f.name}</Text>
3616
3701
  </View>
3617
3702
  </TableCell>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "43.4.0",
3
+ "version": "43.5.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -13,6 +13,7 @@
13
13
  "default": "./src/colors.web.ts"
14
14
  },
15
15
  "./deadline": "./src/deadline.ts",
16
+ "./option_picker": "./src/option_picker.tsx",
16
17
  "./option_badge": "./src/option_badge.tsx",
17
18
  "./member_chip": "./src/member_chip.tsx",
18
19
  "./member_select": "./src/member_select.tsx",
@@ -90,8 +91,8 @@
90
91
  "./locale": "./src/locale.tsx",
91
92
  "./spacing": "./src/spacing.ts",
92
93
  "./theme": {
93
- "react-native": "./src/theme.tsx",
94
- "default": "./src/theme.web.tsx"
94
+ "react-native": "./src/theme.ts",
95
+ "default": "./src/theme.web.ts"
95
96
  },
96
97
  "./progress_bar": "./src/progress_bar.tsx",
97
98
  "./form_date_picker": "./src/form_date_picker.tsx",
@@ -272,9 +273,12 @@
272
273
  "./landmark": "./src/landmark.tsx",
273
274
  "./skip_link": "./src/skip_link.tsx",
274
275
  "./text_utils": "./src/text_utils.ts",
276
+ "./type_ramp": "./src/type_ramp.ts",
275
277
  "./column_filter": "./src/column_filter.tsx",
276
278
  "./chip_group": "./src/chip_group.tsx",
277
279
  "./avatar_size": "./src/avatar_size.ts",
280
+ "./avatar_tone": "./src/avatar_tone.ts",
281
+ "./avatar_props": "./src/avatar_props.ts",
278
282
  "./diff_value": "./src/diff_value.tsx",
279
283
  "./diff_mark": "./src/diff_mark.tsx",
280
284
  "./use_change_set": "./src/use_change_set.ts",
package/src/avatar.tsx CHANGED
@@ -1,33 +1,22 @@
1
- import { Image, ImageContentFit, ImageSource } from "expo-image";
1
+ import { Image } from "expo-image";
2
2
  import React from "react";
3
- import { View, StyleSheet, StyleProp, ViewStyle, ImageStyle } from "react-native";
3
+ import { View, StyleSheet, ImageStyle } from "react-native";
4
4
  import { Text } from "./text";
5
- import { colors } from "./colors";
6
5
  import { useLoticsLocale } from "./locale";
7
- import { AVATAR_PX, AVATAR_TEXT, avatarInitials, type AvatarSize } from "./avatar_size";
8
-
9
- interface AvatarProps {
10
- /** A rung on the shared avatar scale — never a pixel count. See
11
- * [`avatar_size`](./avatar_size.ts) for why the number was removed. */
12
- size?: AvatarSize;
13
- source?: ImageSource;
14
- name?: string;
15
- style?: StyleProp<ViewStyle | ImageStyle>;
16
- contentFit?: ImageContentFit;
17
- /**
18
- * When true, the avatar announces its `name` to assistive tech. Default
19
- * false because avatars almost always appear adjacent to the name text —
20
- * announcing the image as well would double-read. Pass `announce` when the
21
- * avatar is standalone (with no visible name nearby).
22
- */
23
- announce?: boolean;
24
- }
6
+ import { avatarTone } from "./avatar_tone";
7
+ import { AVATAR_PX, AVATAR_TEXT, avatarInitials } from "./avatar_size";
8
+ import { avatarShapeStyle, type AvatarProps } from "./avatar_props";
25
9
 
10
+ /**
11
+ * A person or an organization, as an identity MARK — an image when there is
12
+ * one, otherwise initials on the brand accent. Prop contract: `avatar_props`.
13
+ */
26
14
  export function Avatar(props: AvatarProps) {
27
15
  const locale = useLoticsLocale();
28
- const { source, size = "md", name = locale.avatar.unknown, style, contentFit, announce } = props;
16
+ const { source, size = "md", shape = "circle", name = locale.avatar.unknown, style, contentFit, announce } = props;
29
17
  const decorative = !announce;
30
18
  const px = AVATAR_PX[size];
19
+ const shapeStyle = avatarShapeStyle(shape);
31
20
 
32
21
  if (!source || !source.uri) {
33
22
  return (
@@ -39,12 +28,14 @@ export function Avatar(props: AvatarProps) {
39
28
  aria-hidden={decorative || undefined}
40
29
  style={[
41
30
  styles.base,
42
- // The initials disc is the one place a member LOOKS like the product,
43
- // so it carries the brand accent rather than a fixed blue. NOT a
44
- // restyle: `colors.accent` DEFAULTS to this exact blue-600, so an app
45
- // that themes nothing renders identical pixels, while one that sets an
46
- // accent gets its own hue without this file knowing.
47
- { backgroundColor: colors.accent, width: px, height: px },
31
+ // The disc's ground is DERIVED FROM THE NAME see `avatar_tone`. It
32
+ // used to be `colors.accent`, which made every person in a register the
33
+ // same colour and left the row's biggest, brightest element carrying no
34
+ // information. The brand did not lose a home; it moved to the chrome
35
+ // (active tab, selection wash, primary action), where it is shared
36
+ // across apps and learned once.
37
+ { backgroundColor: avatarTone(name), width: px, height: px },
38
+ shapeStyle,
48
39
  style,
49
40
  ]}
50
41
  >
@@ -70,7 +61,7 @@ export function Avatar(props: AvatarProps) {
70
61
  alt={decorative ? "" : name}
71
62
  accessibilityElementsHidden={decorative}
72
63
  importantForAccessibility={decorative ? "no-hide-descendants" : undefined}
73
- style={[styles.base, { width: px, height: px }, style as ImageStyle]}
64
+ style={[styles.base, { width: px, height: px }, shapeStyle, style as ImageStyle]}
74
65
  source={source}
75
66
  contentFit={contentFit}
76
67
  cachePolicy="memory-disk"
@@ -85,5 +76,14 @@ const styles = StyleSheet.create({
85
76
  justifyContent: "center",
86
77
  alignItems: "center",
87
78
  userSelect: "none",
79
+ // An INSET hairline, not a border and not a shadow. A flat disc of saturated
80
+ // colour on white is a sticker: nothing relates it to the surface, and at
81
+ // register scale a column of them reads as printed-on rather than sitting
82
+ // there. `inset` keeps the ring INSIDE the box, so the avatar's measured size
83
+ // is unchanged and every layout that budgeted `AVATAR_PX` still fits — an
84
+ // outset border would have grown every disc by 2px and pushed the name beside
85
+ // it. Black at 8% darkens whatever hue is underneath rather than tinting it,
86
+ // so it works across the whole identity palette without a per-hue value.
87
+ ...({ boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.08)" } as object),
88
88
  },
89
89
  });
@@ -1,40 +1,30 @@
1
- import type { ImageContentFit, ImageSource } from "expo-image";
2
- import { Image, View, StyleSheet, StyleProp, ViewStyle, ImageStyle } from "react-native";
1
+ import { Image, View, StyleSheet, ImageStyle } from "react-native";
3
2
  import { Text } from "./text";
4
- import { colors } from "./colors";
5
3
  import { useLoticsLocale } from "./locale";
6
- import { AVATAR_PX, AVATAR_TEXT, avatarInitials, type AvatarSize } from "./avatar_size";
7
-
8
- interface AvatarProps {
9
- /** A rung on the shared avatar scale — never a pixel count. */
10
- size?: AvatarSize;
11
- source?: ImageSource;
12
- name?: string;
13
- style?: StyleProp<ViewStyle | ImageStyle>;
14
- contentFit?: ImageContentFit;
15
- /**
16
- * When true, the avatar announces its `name` to assistive tech. Default
17
- * false because avatars almost always appear adjacent to the name text —
18
- * announcing the image as well would double-read. Pass `announce` when the
19
- * avatar is standalone (with no visible name nearby).
20
- */
21
- announce?: boolean;
22
- }
4
+ import { avatarTone } from "./avatar_tone";
5
+ import { AVATAR_PX, AVATAR_TEXT, avatarInitials } from "./avatar_size";
6
+ import { avatarShapeStyle, type AvatarProps } from "./avatar_props";
23
7
 
24
8
  /**
25
9
  * Web Avatar. The native `avatar.tsx` renders through `expo-image`, a native
26
10
  * module that pulls expo's runtime into the graph and fails to load under
27
11
  * pure-web bundlers (Vite dev throws on its CJS interop; vitest can't resolve
28
12
  * expo's winter runtime). On web, `react-native`'s `Image` (→ react-native-web
29
- * → `<img>`) renders the same circular avatar with none of that cost. The prop
30
- * surface is identical — `ImageSource`/`ImageContentFit` are kept as erased
31
- * type-only imports so no expo-image module is ever loaded.
13
+ * → `<img>`) renders the same avatar with none of that cost.
14
+ *
15
+ * Its prop surface is NOT declared here see `avatar_props`, which both
16
+ * variants implement and where expo-image's `ImageSource`/`ImageContentFit`
17
+ * survive as erased type-only imports, so no expo-image module is ever loaded on
18
+ * this path. The interface used to be declared twice, and `tsc` resolves
19
+ * `./avatar` to the NATIVE sibling: a prop added there compiled clean
20
+ * everywhere, and did nothing in the browser.
32
21
  */
33
22
  export function Avatar(props: AvatarProps) {
34
23
  const locale = useLoticsLocale();
35
- const { source, size = "md", name = locale.avatar.unknown, style, contentFit, announce } = props;
24
+ const { source, size = "md", shape = "circle", name = locale.avatar.unknown, style, contentFit, announce } = props;
36
25
  const decorative = !announce;
37
26
  const px = AVATAR_PX[size];
27
+ const shapeStyle = avatarShapeStyle(shape);
38
28
 
39
29
  if (!source || !source.uri) {
40
30
  return (
@@ -46,12 +36,14 @@ export function Avatar(props: AvatarProps) {
46
36
  aria-hidden={decorative || undefined}
47
37
  style={[
48
38
  styles.base,
49
- // The initials disc is the one place a member LOOKS like the product,
50
- // so it carries the brand accent rather than a fixed blue. NOT a
51
- // restyle: `colors.accent` DEFAULTS to this exact blue-600, so an app
52
- // that themes nothing renders identical pixels, while one that sets an
53
- // accent gets its own hue without this file knowing.
54
- { backgroundColor: colors.accent, width: px, height: px },
39
+ // The disc's ground is DERIVED FROM THE NAME see `avatar_tone`. It
40
+ // used to be `colors.accent`, which made every person in a register the
41
+ // same colour and left the row's biggest, brightest element carrying no
42
+ // information. The brand did not lose a home; it moved to the chrome
43
+ // (active tab, selection wash, primary action), where it is shared
44
+ // across apps and learned once.
45
+ { backgroundColor: avatarTone(name), width: px, height: px },
46
+ shapeStyle,
55
47
  style,
56
48
  ]}
57
49
  >
@@ -77,7 +69,7 @@ export function Avatar(props: AvatarProps) {
77
69
  accessibilityLabel={decorative ? undefined : name}
78
70
  accessibilityElementsHidden={decorative}
79
71
  importantForAccessibility={decorative ? "no-hide-descendants" : undefined}
80
- style={[styles.base, { width: px, height: px }, style as ImageStyle]}
72
+ style={[styles.base, { width: px, height: px }, shapeStyle, style as ImageStyle]}
81
73
  source={{ uri: source.uri }}
82
74
  resizeMode={contentFit === "contain" ? "contain" : "cover"}
83
75
  />
@@ -91,5 +83,14 @@ const styles = StyleSheet.create({
91
83
  justifyContent: "center",
92
84
  alignItems: "center",
93
85
  userSelect: "none",
86
+ // An INSET hairline, not a border and not a shadow. A flat disc of saturated
87
+ // colour on white is a sticker: nothing relates it to the surface, and at
88
+ // register scale a column of them reads as printed-on rather than sitting
89
+ // there. `inset` keeps the ring INSIDE the box, so the avatar's measured size
90
+ // is unchanged and every layout that budgeted `AVATAR_PX` still fits — an
91
+ // outset border would have grown every disc by 2px and pushed the name beside
92
+ // it. Black at 8% darkens whatever hue is underneath rather than tinting it,
93
+ // so it works across the whole identity palette without a per-hue value.
94
+ ...({ boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.08)" } as object),
94
95
  },
95
96
  });