@lotics/ui 37.1.0 → 38.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.
package/AGENTS.md CHANGED
@@ -118,9 +118,10 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
118
118
  anyway. Use `FileRow` / `FileRows`, where the full name is the row and a second line can
119
119
  carry a date, a size, a status, a diff. Photos, scans and drawings are identified by their
120
120
  CONTENT — the filename is `IMG_4471.jpg` and tells nobody anything — so use
121
- `FileThumbnailGrid`, where the picture is the identity and many fit on one screen. A mixed
122
- set follows its majority; an app whose attachments are overwhelmingly paperwork should not
123
- pay a grid's wasted space for the occasional photo.
121
+ `FileThumbnailGrid`, where the picture is the identity and many fit on one screen. **A mixed
122
+ set follows its MAJORITY, and that one surface holds the whole set — never split a pile by
123
+ file type** the cost lands on the minority by design, not on the reader, who would otherwise
124
+ get two places to look for one thing.
124
125
  → [catalog.md](./docs/catalog.md).
125
126
  - **A reference to another record is a FIELD** — it wears the inline editor's own resting
126
127
  surface and PEEKS its facts on press; EVERY verb lives in the peek's footer as a `Button`,
package/MIGRATION.md CHANGED
@@ -4,6 +4,34 @@ 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
+ ## 38.0.0 — the `lg` avatar is a control (40), not a size (48)
8
+
9
+ `AVATAR_PX.lg` was 48 and `AVATAR_TEXT.lg` was `lg` type. They are now **40** and `md`
10
+ type. Every `size="lg"` avatar — `Avatar`, `MemberChip`, `GroupAvatar` — renders 8px
11
+ smaller. Nothing to pass; there is no way to ask for 48.
12
+
13
+ **Why.** The scale's whole argument is that a rung quotes a number the system already
14
+ owns, which is why `md` is `CONTROL_CONTENT_HEIGHT` (28). `lg` quoted nothing. It is
15
+ the rung for an avatar that stands in a band **on its own** — an account menu beside a
16
+ row of icon buttons — and those are `CONTROL_HEIGHT`, so at 48 the avatar was the one
17
+ item in the row that visibly got out. 40 is that number.
18
+
19
+ **If a surface genuinely needs a bigger person figure**, it is not in a control band,
20
+ and `xl` (72) is the rung for it. If neither fits, the surface is setting its own row
21
+ height and should say so rather than reaching for an avatar rung to do it.
22
+
23
+ ### `PageHeader` gained `leading`, and its title now wraps instead of overflowing
24
+
25
+ `leading` is additive — a control rendered immediately BEFORE the title, for something
26
+ that changes what is AROUND the content (a side-panel toggle, a back arrow), as opposed
27
+ to `actions`, which act on the content itself.
28
+
29
+ The behaviour change to check: a title too long for the row used to keep its intrinsic
30
+ width and push `actions` clean off the row — measured at 417px outside a 480px header.
31
+ It now wraps, and `actions` hold their place. **If you worked around this** by shortening
32
+ a title, hiding the CTA at narrow widths, or capping the header's width, that workaround
33
+ is now what is distorting the layout — drop it.
34
+
7
35
  ## 37.0.0 — `PageContent`'s side gutter is a token, and responsive
8
36
 
9
37
  `PageContent` padded `16` at every width. It now uses `pagePad` from
package/docs/catalog.md CHANGED
@@ -76,20 +76,23 @@ inline: a picker option, an assignee, a `select_member` value. Pure: resolve the
76
76
  your directory and pass `name` / `image`; never hand-roll `Avatar` + `Text`. (`MemberSelect`
77
77
  renders these per option.)
78
78
 
79
- **Size is a RUNG, never a pixel count** — `sm | md | lg | xl` = 24 / 28 / 48 / 72
79
+ **Size is a RUNG, never a pixel count** — `sm | md | lg | xl` = 24 / 28 / 40 / 72
80
80
  (`@lotics/ui/avatar_size`, shared by `Avatar`, `MemberChip` and `GroupAvatar`). Default `md`.
81
81
  The number was removed because the same concept had drifted to eleven different values across
82
82
  the product and its apps, so one person looked different on every screen. Pick by density:
83
83
  `sm` for a dense row or inline cell, `md` for a control band, register or list (the default),
84
84
  `lg` for a prominent row or the account menu, `xl` for a profile's own header.
85
85
 
86
- **`md` is `CONTROL_CONTENT_HEIGHT`** (28) the largest rung a 40px control band seats without
87
- growing, and the height `InlineButton` and a `md` `IconButton` already used to sit inside a
88
- field. An avatar is the tallest thing in whatever row holds it, so a rung that overshoots
89
- doesn't look slightly big, it RESIZES the control: at the old 36 the same member chip measured
90
- 46 in a `Select` trigger, 50 in an inline editor and 54 in an `InlineSlot`, because each
91
- surface had picked its own inset with no number to pick it from. Put nothing taller than `md`
92
- in a control a bigger avatar belongs in a row that sets its own height.
86
+ **Both middle rungs quote `control_surface`, and which one you want depends on whether the
87
+ avatar is IN a control or IS one.** `md` is `CONTROL_CONTENT_HEIGHT` (28) the largest rung a
88
+ 40px control band seats without growing, and the height `InlineButton` and a `md` `IconButton`
89
+ already used to sit inside a field. An avatar is the tallest thing in whatever row holds it, so
90
+ a rung that overshoots doesn't look slightly big, it RESIZES the control: at the old 36 the same
91
+ member chip measured 46 in a `Select` trigger, 50 in an inline editor and 54 in an `InlineSlot`,
92
+ because each surface had picked its own inset with no number to pick it from. Put nothing taller
93
+ than `md` in a control. `lg` is `CONTROL_HEIGHT` (40) — the avatar that stands in a band on its
94
+ own, an account menu beside a row of icon buttons, so it measures what they do. Anything bigger
95
+ belongs in a row that sets its own height.
93
96
 
94
97
  **`sm` shows ONE initial; every larger rung shows two** — two letters are not legible at 24px.
95
98
  That is the practical reason not to reach for `sm` by reflex: on a real roster a single letter
@@ -283,6 +286,21 @@ says the read succeeded and found nothing, a failed read knows neither.
283
286
 
284
287
  ### Files
285
288
 
289
+ **Pick the surface FIRST, and pick exactly one per set.** Documents get a LIST (`FileRows`),
290
+ images get a GRID (`FileThumbnailGrid`) — the discriminator is what identifies the file to the
291
+ person hunting for it: a PDF's thumbnail is a grey page identical to every other grey page, so
292
+ its NAME is the identity; a photo's filename is `IMG_4471.jpg` and tells nobody anything, so its
293
+ CONTENT is. **A mixed pile follows its majority and stays whole.** Sorting one pile by mime type
294
+ into a list *and* a grid is the mistake — it gives the reader two places to look for the same
295
+ thing. The minority is not stranded by this: a non-image tile in a grid renders a `DocumentCard`
296
+ printing its filename, and a photo in a list keeps its full name plus a meta line and is ONE tap
297
+ from the picture, because a `FileRows` row press opens the built-in gallery. (The badge is
298
+ MIME-driven, so that row shows a generic image mark, not the shot itself — which is the cost the
299
+ majority already decided to pay.) A screen showing BOTH surfaces (`tpl_record`) does
300
+ so because it holds two distinct SETS the reader asks different questions of — the paperwork
301
+ that arrived on the record, and the hand-over photos taken against it — not because one set was
302
+ split by type.
303
+
286
304
  `FilesEditor` (THE attachment surface: an upload-aware grid whose bar you COMPOSE — the root
287
305
  owns selection + gallery + confirmed remove, the bar pieces and any HOST verb go below it via
288
306
  `useFilesEditorSelection`),
@@ -517,7 +535,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
517
535
  useSeparator` inserts and what menus/popovers put between option groups.
518
536
  - **`container`** — `Container`: centers content at a max width (`ContainerSize` sm|md|lg,
519
537
  `CONTAINER_SIZES`).
520
- - **`page_header`** — `PageHeader`: the page's title band; `actions` puts page-level CTAs on the title row (right-aligned), while `left`/`right` form a separate nav row above.
538
+ - **`page_header`** — `PageHeader`: the page's title band. `actions` puts page-level CTAs on
539
+ the title row (right-aligned) and `leading` puts a control immediately BEFORE the title;
540
+ `left`/`right` form a separate nav row above. Split `leading` from `actions` by what the
541
+ control acts on: `actions` do something to the page's CONTENT (create, sort, export),
542
+ `leading` changes what is AROUND it (a side-panel toggle, a back arrow). A panel toggle
543
+ filed under `actions` reads as a peer of "create one of these". Under a title too long
544
+ for the row the TITLE gives way and wraps while `actions` keep their width — a wrapped
545
+ title is merely taller, a CTA pushed off the row is unreachable.
521
546
  - **`page_content`** — `PageContent` + `PAGE_SIZES`: the page's padded, width-capped content
522
547
  region — a centred column with optional `title`/`titleRight`/`description`, `header`/`footer`
523
548
  slots and `fullscreen`. **Reach for it before hand-rolling a screen shell**: a scroller, a
@@ -2683,7 +2683,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2683
2683
  </Section>
2684
2684
  </View>
2685
2685
 
2686
- {/* PHOTOS — the same collection, the other SURFACE.
2686
+ {/* PHOTOS — a second SET, and therefore the other SURFACE.
2687
2687
 
2688
2688
  Files above is a row LIST because a shipping document is identified by
2689
2689
  its NAME: a PDF thumbnail is a grey page identical to every other grey
@@ -2693,6 +2693,17 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2693
2693
  same verbs, different surface, decided by what the reader recognises.
2694
2694
  (AGENTS.md: documents get a LIST, images get a GRID.)
2695
2695
 
2696
+ READ THIS BEFORE COPYING THE SHAPE. Two sections here does NOT mean
2697
+ "sort one pile by mime type into a list and a grid". This record holds
2698
+ two distinct SETS — the paperwork that arrived on it, and the hand-over
2699
+ photos taken against it — which the reader asks different questions of.
2700
+ A SINGLE pile picks ONE surface by its majority and keeps it: the odd
2701
+ .docx among forty phone photos stays in the grid, where it renders a
2702
+ `DocumentCard` printing its filename, and the odd photo among forty
2703
+ PDFs stays in the list, where its row carries the full name and a press
2704
+ opens the gallery. Splitting one set by type would give the reader two
2705
+ places to look for the same thing.
2706
+
2696
2707
  The ADD rides the heading row, right edge, exactly as it does on Files
2697
2708
  and Fees — a section's add sits in the same spot whether the section
2698
2709
  holds nought or forty. Note it is a plain `Button` + `pickFiles`, not
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "37.1.0",
3
+ "version": "38.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -28,6 +28,13 @@ export type AvatarSize = "sm" | "md" | "lg" | "xl";
28
28
  * trigger, 50 in an inline editor and 54 in an `InlineSlot` — three heights for
29
29
  * one control band, because each surface had picked its own inset and none of
30
30
  * them had a number to pick it FROM.
31
+ *
32
+ * The same argument fixes the rung ABOVE it. An avatar that stands on its own in
33
+ * a band — an account menu next to a row of icon buttons — is not a child of a
34
+ * control, it IS one, so its number is `CONTROL_HEIGHT` (40), not a size picked
35
+ * for looking prominent. Both live rungs therefore quote `control_surface`; only
36
+ * `sm` (too small for two letters) and `xl` (a profile header, in no band at all)
37
+ * are free.
31
38
  */
32
39
  export const AVATAR_PX: Record<AvatarSize, number> = {
33
40
  /** Dense rows and inline chips — one initial, because two do not fit. */
@@ -35,8 +42,12 @@ export const AVATAR_PX: Record<AvatarSize, number> = {
35
42
  /** The default: a 40px control band, a register, a list — anywhere a person is
36
43
  * a row. Equals `CONTROL_CONTENT_HEIGHT` (`control_surface`). */
37
44
  md: 28,
38
- /** Prominent rows, the account menu. */
39
- lg: 48,
45
+ /** An avatar that IS a control rather than one sitting inside a control — the
46
+ * account-menu trigger in a top bar, a prominent row's leading figure. Equals
47
+ * `CONTROL_HEIGHT` (`control_surface`), so it lines up with the icon buttons
48
+ * beside it. It was 48, which anchored to nothing: the bar's other controls
49
+ * are 40, and a 48px circle among them reads as the one thing that got out. */
50
+ lg: 40,
40
51
  /** A profile's own header. */
41
52
  xl: 72,
42
53
  };
@@ -51,13 +62,17 @@ export const AVATAR_PX: Record<AvatarSize, number> = {
51
62
  *
52
63
  * sm 12 on 24 = 50%
53
64
  * md 12 on 28 = 43%
54
- * lg 20 on 48 = 42%
65
+ * lg 16 on 40 = 40%
55
66
  * xl 32 on 72 = 44%
56
67
  *
57
- * The two-letter rungs sit within 2 points of each other, so an avatar reads the
58
- * same at every size rather than growing emptier as it grows — `lg` was on `md`
59
- * type (16 on 48 = 33%) and was visibly the thin one. `sm` runs richer by design:
60
- * one letter in a small circle needs the weight.
68
+ * The two-letter rungs sit within 4 points of each other, so an avatar reads the
69
+ * same at every size rather than growing emptier as it grows — `lg` at 48 was on
70
+ * `md` type (16 on 48 = 33%) and was visibly the thin one. `sm` runs richer by
71
+ * design: one letter in a small circle needs the weight.
72
+ *
73
+ * `lg` keeps `md` type now that the circle is 40, and the next rung up would be
74
+ * worse in the other direction: `lg` type is 20, which on 40 is 50% — the fattest
75
+ * of the two-letter rungs, and the same defect this table was written to fix.
61
76
  *
62
77
  * `md` shares `sm`'s rung and is NOT the odd one out for it: the same 12px reads
63
78
  * as 50% of a 24px circle holding one letter and 43% of a 28px circle holding
@@ -68,7 +83,7 @@ export const AVATAR_PX: Record<AvatarSize, number> = {
68
83
  export const AVATAR_TEXT: Record<AvatarSize, TextSize> = {
69
84
  sm: "xs", // 12 on 24 — one letter, so it carries a larger share of the circle
70
85
  md: "xs", // 12 on 28
71
- lg: "lg", // 20 on 48
86
+ lg: "md", // 16 on 40
72
87
  xl: "xxl", // 32 on 72
73
88
  };
74
89
 
@@ -5,29 +5,70 @@ import { ReactNode } from "react";
5
5
  interface PageHeaderProps {
6
6
  title: string;
7
7
  description?: string | null;
8
+ /** The nav row ABOVE the title — breadcrumbs, a back control, row-level status. */
8
9
  left?: ReactNode;
10
+ /** The nav row ABOVE the title, right-aligned. */
9
11
  right?: ReactNode;
12
+ /**
13
+ * A control that belongs to the TITLE, rendered immediately before it on the
14
+ * title row — a panel toggle, a back arrow into the page you came from.
15
+ *
16
+ * The distinction from `actions` is what the control acts ON, not where it
17
+ * looks best. `actions` are things you do to the page's CONTENT (create, sort,
18
+ * export) and collect at the right, where a scanning eye reaches them last;
19
+ * `leading` changes what is AROUND the content, and belongs against the title
20
+ * it frames. Putting a panel toggle in `actions` files it with the CTAs and
21
+ * makes the reader parse "open a side panel" as a peer of "create one of
22
+ * these".
23
+ */
24
+ leading?: ReactNode;
10
25
  /** Page-level actions (CTAs), rendered on the title row, right-aligned —
11
26
  * distinct from `left`/`right`, which form a separate nav row above. */
12
27
  actions?: ReactNode;
13
28
  }
14
29
 
15
30
  export function PageHeader(props: PageHeaderProps) {
16
- const { title, description, left, right, actions } = props;
31
+ const { title, description, left, right, leading, actions } = props;
17
32
 
18
33
  const hasNav = !!left || !!right;
19
34
 
20
- const titleRow = actions ? (
21
- <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
22
- <Text size="xxl" weight="semibold">
23
- {title}
24
- </Text>
35
+ // ONE shape, whichever slots are filled. Branching the row on `leading` gave
36
+ // the title two different behaviours under a long string — shrinking when a
37
+ // panel toggle happened to be present and overflowing when it was not — which
38
+ // is a layout law decided by an unrelated prop.
39
+ //
40
+ // The title is what gives way; `actions` keep their width, because a CTA
41
+ // pushed off the row is unreachable while a wrapped title is merely taller.
42
+ // Both halves of the pair are required: a React Native `View`/`Text` does NOT
43
+ // shrink by default (`flexShrink: 0`, unlike the web), and `flexShrink` alone
44
+ // then floors the item at its MIN-CONTENT width — the longest word — so
45
+ // `minWidth: 0` is what lets it actually wrap. `DetailRow`'s spread value is
46
+ // the same pair for the same reason.
47
+ const titleRow = (
48
+ <View
49
+ style={{
50
+ flexDirection: "row",
51
+ alignItems: "center",
52
+ justifyContent: "space-between",
53
+ gap: 12,
54
+ }}
55
+ >
56
+ <View
57
+ style={{
58
+ flexDirection: "row",
59
+ alignItems: "center",
60
+ gap: 8,
61
+ flexShrink: 1,
62
+ minWidth: 0,
63
+ }}
64
+ >
65
+ {leading}
66
+ <Text size="xxl" weight="semibold" style={{ flexShrink: 1, minWidth: 0 }}>
67
+ {title}
68
+ </Text>
69
+ </View>
25
70
  {actions}
26
71
  </View>
27
- ) : (
28
- <Text size="xxl" weight="semibold">
29
- {title}
30
- </Text>
31
72
  );
32
73
 
33
74
  return (