@lotics/ui 43.0.0 → 43.2.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.
@@ -1033,8 +1033,30 @@ When it IS a status, match weight to prominence — lightest → heaviest:
1033
1033
  a surface (a drawer/detail header). Opt IN explicitly and sparingly; one per view, never
1034
1034
  sprinkled through rows.
1035
1035
 
1036
- A register's dense rows read lighter — the row's primary status is `dot`, its drawer twin may go
1037
- tonal. A `Badge` is never a metric value.
1036
+ **`tonal` IS allowed on a register's ONE primary status column, and that is the rule this
1037
+ paragraph used to get wrong.** It read "a register's dense rows read lighter, the row's primary
1038
+ status is `dot`" while `badge.tsx` named "a register's primary Status column" as a legitimate
1039
+ tonal use — two docs, opposite answers, so the choice fell to whoever read which. What "one per
1040
+ view" governs is the number of badge KINDS on a surface, not the number of rows: badging the
1041
+ stage AND the type AND the city is the clutter the rule exists to stop; one status column that
1042
+ happens to render 133 times is one badge.
1043
+
1044
+ Prefer `dot` when the column is secondary or the row already carries a coloured mark. Reach for
1045
+ `tonal` when the status column is what the register is SCANNED by — the tints are the palette's
1046
+ 50-step against its 900-step ink (measured 8.7:1 and 9.5:1, so roughly double the AA floor), and
1047
+ a column of them is the difference between a register that reads as organised and one that reads
1048
+ as a grey spreadsheet. That difference is the commonest "it looks bland" report, and it has a
1049
+ real answer that costs no meaning: make the colour the screen ALREADY earns more present, rather
1050
+ than adding colour somewhere that has to invent a meaning to justify it.
1051
+
1052
+ **A `Badge` in a table cell needs `alignSelf`.** A cell stretches its child on the cross axis, and
1053
+ a `dot` has no ground so nothing shows — swap the variant to `tonal` and the pill becomes a filled
1054
+ block the full width of the column, which reads as a cell state rather than a chip. The kit cannot
1055
+ fix this on the badge: `alignSelf: "flex-start"` means LEFT inside a column-direction cell and TOP
1056
+ inside a row-direction one, so setting it globally would break every badge sitting inline beside
1057
+ text. Pass it at the call site.
1058
+
1059
+ A `Badge` is never a metric value.
1038
1060
 
1039
1061
  ## Typography
1040
1062
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "43.0.0",
3
+ "version": "43.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -277,7 +277,15 @@
277
277
  "./avatar_size": "./src/avatar_size.ts",
278
278
  "./diff_value": "./src/diff_value.tsx",
279
279
  "./diff_mark": "./src/diff_mark.tsx",
280
- "./use_change_set": "./src/use_change_set.ts"
280
+ "./use_change_set": "./src/use_change_set.ts",
281
+ "./display_font": {
282
+ "react-native": "./src/display_font.ts",
283
+ "default": "./src/display_font.web.ts"
284
+ },
285
+ "./font_family": {
286
+ "react-native": "./src/font_family.ts",
287
+ "default": "./src/font_family.web.ts"
288
+ }
281
289
  },
282
290
  "files": [
283
291
  "src",
package/src/badge.tsx CHANGED
@@ -15,8 +15,16 @@ interface BadgeProps {
15
15
  * Light enough to sit anywhere without shouting. Reach for this by default.
16
16
  * - "tonal": a filled pill — HEAVY, and easy to overuse into clutter. Reserve
17
17
  * it for the ONE prominent status of a surface (a drawer header, a register's
18
- * primary Status column). One per view, a deliberate choice not sprinkled
19
- * through rows or option lists. If in doubt, use `dot`.
18
+ * primary Status column). "One per view" counts badge KINDS, not rows: one
19
+ * status column rendering on every row is ONE badge, while the stage plus the
20
+ * type plus the city is the clutter this warns about. Not for option lists.
21
+ * If in doubt, use `dot`.
22
+ *
23
+ * In a TABLE CELL, pass `style={{ alignSelf: "flex-start" }}`: a cell
24
+ * stretches its child, so a tonal pill fills the whole column otherwise. It
25
+ * cannot be defaulted here — `flex-start` means left in a column-direction
26
+ * parent and TOP in a row-direction one, which would misalign every badge
27
+ * sitting inline beside text.
20
28
  * (StatusGrid/StatusLegend keep their own dot — raw hex coupled to the grid
21
29
  * cells' tint, a separate data-viz concern.)
22
30
  */
package/src/button.tsx CHANGED
@@ -27,6 +27,35 @@ interface ButtonPropsBase {
27
27
  icon?: IconName;
28
28
  alignSelf?: "flex-start" | "flex-end" | "center" | "stretch" | "auto";
29
29
  color?: ButtonColor;
30
+ /**
31
+ * An action whose DESTINATION carries a brand of its own — an export that
32
+ * produces a spreadsheet, a sign-in with a named provider, a share into a
33
+ * service. Pass that brand's colour and the button wears it: a wash of it as
34
+ * the ground, the colour itself on the label and the mark.
35
+ *
36
+ * **Why this exists rather than a `style` escape hatch.** `ButtonColor` is a
37
+ * closed set on purpose — primary / secondary / danger are the kit's OWN
38
+ * vocabulary, and keeping it small is what lets a reader learn the ladder once.
39
+ * An outside brand genuinely is not on that ladder: it is not a status, not a
40
+ * valence, and not a palette family, so no named variant could ever cover it.
41
+ * The answer is a named CATEGORY with a colour slot, never a hole through
42
+ * which any call site can restyle any button — that is how a design system
43
+ * stops being one.
44
+ *
45
+ * It renders SOLID, with white ink — which is what a brand action looks like
46
+ * everywhere it appears, and what makes the colour recognisable at a glance
47
+ * rather than merely present. A tonal wash was tried first, on the reasoning
48
+ * that a second filled control competes with the surface's one primary; it
49
+ * reads as washed-out rather than restrained, and the hierarchy survives
50
+ * anyway because the primary keeps the last position and the affirmative verb.
51
+ *
52
+ * The cost is real and worth naming: a screen with several of these is a screen
53
+ * with no primary. Reach for it where a destination genuinely has a brand, not
54
+ * to make a button louder.
55
+ *
56
+ * Ignored on `primary` and `danger`, whose own colours are load-bearing.
57
+ */
58
+ brandColor?: string;
30
59
  loading?: boolean;
31
60
  disabled?: boolean;
32
61
  tooltip?: string | UseTooltipOptions;
@@ -59,11 +88,15 @@ export function Button(props: ButtonProps) {
59
88
  onPress,
60
89
  testID,
61
90
  accessibilityLabel,
91
+ brandColor,
62
92
  } = props;
63
93
 
64
94
  // The press waits out an inline commit the same gesture started (pending_commits.ts)
65
95
  // and reports busy while it does — so `busy`, not the `loading` prop alone, drives the
66
96
  // spinner, aria-busy, and the double-press block.
97
+ // Ignored on the variants whose colour MEANS something: a primary that is not
98
+ // the surface's primary colour, or a danger that is not red, would be lying.
99
+ const brand = color === "primary" || color === "danger" || color === "danger-secondary" ? undefined : brandColor;
67
100
  const { handlePress, waiting } = useGatedPress<NativeSyntheticEvent<any>>(onPress);
68
101
  const busy = loading || waiting;
69
102
  const disabledOrLoading = disabled || busy;
@@ -77,14 +110,16 @@ export function Button(props: ButtonProps) {
77
110
  ) : (
78
111
  <>
79
112
  {!!icon && (
80
- <Icon size={20} name={icon} color={getButtonIconColor(color, disabledOrLoading)} />
113
+ <Icon size={20} name={icon} color={brand && !disabledOrLoading ? colors.white : getButtonIconColor(color, disabledOrLoading)} />
81
114
  )}
82
115
  {!!title && (
83
116
  <Text
84
117
  numberOfLines={1}
85
118
  size="sm"
86
119
  weight="medium"
87
- color={getButtonTextColor(color, disabledOrLoading)}
120
+ // `inverted` — the kit's own white-on-dark ink, the same token the primary
121
+ // uses, so a brand button and the primary read as one family.
122
+ color={brand && !disabledOrLoading ? "inverted" : getButtonTextColor(color, disabledOrLoading)}
88
123
  userSelect="none"
89
124
  >
90
125
  {title}
@@ -116,11 +151,16 @@ export function Button(props: ButtonProps) {
116
151
  borderRadius: CONTROL_RADIUS,
117
152
  backgroundColor: disabled
118
153
  ? getButtonDisabledBackgroundColor(color)
119
- : pressed
120
- ? getButtonPressedColor(color)
121
- : hovered
122
- ? getButtonHoverColor(color)
123
- : getButtonBackgroundColor(color),
154
+ : brand
155
+ ? // Solid, darkening under the pointer — the same depth ladder the
156
+ // primary walks, so a brand button behaves like a button rather
157
+ // than like a coloured label.
158
+ shade(brand, pressed ? 0.78 : hovered ? 0.88 : 1)
159
+ : pressed
160
+ ? getButtonPressedColor(color)
161
+ : hovered
162
+ ? getButtonHoverColor(color)
163
+ : getButtonBackgroundColor(color),
124
164
  },
125
165
  // Subtle depth on the primary: a soft drop shadow lifts it off the
126
166
  // surface and a whisper of top highlight + a gentle vertical shade (the
@@ -169,10 +209,31 @@ export function getButtonTextColor(color?: ButtonColor, disabled?: boolean): Tex
169
209
  }
170
210
  }
171
211
 
212
+ /**
213
+ * A brand colour at `factor` of its own lightness — the hover/press steps for a
214
+ * solid brand button.
215
+ *
216
+ * Multiplying the channels rather than blending toward black keeps the hue: a
217
+ * dark green stays green as it darkens, where mixing with black walks it toward
218
+ * grey and the recognition goes with it. Returns the input unchanged for a value
219
+ * it cannot parse, so an unusual colour loses its pointer feedback rather than
220
+ * rendering as something else entirely.
221
+ */
222
+ function shade(hex: string, factor: number): string {
223
+ const m = /^#([0-9a-f]{6})$/i.exec(hex.trim());
224
+ if (!m || factor === 1) return hex;
225
+ const n = parseInt(m[1], 16);
226
+ const ch = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => Math.round(v * factor));
227
+ return `rgb(${ch[0]}, ${ch[1]}, ${ch[2]})`;
228
+ }
229
+
172
230
  function getButtonBackgroundColor(color?: ButtonColor) {
173
231
  switch (color) {
174
232
  case "primary":
175
- return colors.zinc["900"];
233
+ // The themeable role, whose DEFAULT is this exact near-black — so an app
234
+ // that sets no theme renders identical pixels, and one that sets `primary`
235
+ // puts its brand on the surface's single filled button.
236
+ return colors.primary;
176
237
  case "secondary":
177
238
  return colors.zinc["100"];
178
239
  case "danger":
@@ -292,8 +292,8 @@ const palette = {
292
292
  };
293
293
 
294
294
  /**
295
- * THE THREE THEMEABLE ROLES are `background`, `border` and `accent` — and
296
- * deliberately nothing else. See `colors.web.ts`, which redefines exactly
295
+ * THE FOUR THEMEABLE ROLES are `background`, `border`, `accent` and `primary` —
296
+ * and deliberately nothing else. See `colors.web.ts`, which redefines exactly
297
297
  * these as CSS variables so an app can carry its own identity.
298
298
  *
299
299
  * The line is drawn at MEANING, not at convenience. A palette family
@@ -327,6 +327,30 @@ export const colors = {
327
327
  * a palette family, so an accent can never overwrite a meaning.
328
328
  */
329
329
  accent: palette.blue["600"],
330
+ /**
331
+ * The PRIMARY action's fill — the one filled button on a surface.
332
+ *
333
+ * Separate from `accent` because their defaults differ and so do their jobs: a
334
+ * brand can own the disc a person is drawn as without owning the shape of a
335
+ * commit, and near-black is a deliberate neutral that works under any brand.
336
+ * Folding them would force an app that themes its avatars to also repaint every
337
+ * CTA, and would turn this near-black into blue for every app that themes
338
+ * nothing.
339
+ */
340
+ primary: palette.zinc["900"],
341
+ /**
342
+ * The brand's TINT — a row's hover, an active filter's ground, a header band.
343
+ *
344
+ * It exists as its own token because it cannot be derived where it is used:
345
+ * `withAlpha`/`tint` do string surgery on an `rgba()`, and on web `accent` is a
346
+ * `var()`, so asking for "accent at 7%" at the call site produces garbage no
347
+ * type would catch. `LoticsThemeProvider` computes it instead, from the literal
348
+ * hex the app handed it — derivation happens where the literal still exists.
349
+ *
350
+ * The default is the neutral wash these surfaces already wore, so an app that
351
+ * themes nothing is pixel-identical.
352
+ */
353
+ accent_wash: palette.zinc["100"],
330
354
  shadow: `0px 0px 6px 1px ${palette.zinc["300"]}`,
331
355
  };
332
356
 
@@ -357,7 +381,7 @@ export function withAlpha(color: string, alpha: number): string {
357
381
  */
358
382
  export type ColorName = Exclude<
359
383
  keyof typeof colors,
360
- "border" | "border_shadow" | "background" | "accent" | "shadow" | "black" | "white"
384
+ "border" | "border_shadow" | "background" | "accent" | "accent_wash" | "primary" | "shadow" | "black" | "white"
361
385
  >;
362
386
 
363
387
  /**
package/src/colors.web.ts CHANGED
@@ -4,7 +4,7 @@ export { withAlpha, solid, tint, ramp, isColorName, asColorName } from "./color_
4
4
  export type { ColorName } from "./color_tokens";
5
5
 
6
6
  /**
7
- * The token contract, with its FOUR themeable roles indirected through CSS
7
+ * The token contract, with its four themeable roles indirected through CSS
8
8
  * variables so an app can carry its own visual identity.
9
9
  *
10
10
  * Why this file exists at all, rather than a React context: 136 of the kit's 320
@@ -28,7 +28,7 @@ export type { ColorName } from "./color_tokens";
28
28
  * the strictest sense — an app that sets nothing has no `--lotics-*` defined,
29
29
  * every `var()` falls back, and the pixels are identical to before this file.
30
30
  *
31
- * ONLY these three. The palette families stay literal, and must: `withAlpha`,
31
+ * ONLY these five. The palette families stay literal, and must: `withAlpha`,
32
32
  * `tint` and `ramp` do string surgery on an `rgba()` to derive a wash, and a
33
33
  * `var()` handed to them would produce garbage no type would catch. Keeping the
34
34
  * themeable set to chrome keeps those functions total — the maintainability
@@ -40,4 +40,6 @@ export const colors = {
40
40
  background: `var(--lotics-background, ${contract.background})`,
41
41
  border: `var(--lotics-border, ${contract.border})`,
42
42
  accent: `var(--lotics-accent, ${contract.accent})`,
43
+ primary: `var(--lotics-primary, ${contract.primary})`,
44
+ accent_wash: `var(--lotics-accent-wash, ${contract.accent_wash})`,
43
45
  };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * THE DISPLAY FACE — the family `<Text family="display">` renders in.
3
+ *
4
+ * A themeable typeface exists for the DISPLAY role and deliberately not for the
5
+ * body, and the asymmetry is the whole design. Swapping the face a headline
6
+ * wears is one value with no dependencies: it appears a handful of times, large,
7
+ * in one weight. Swapping the BODY face is four coupled things —
8
+ *
9
+ * 1. weight is a FAMILY here, not a `font-weight` (`Inter_400Regular` /
10
+ * `Inter_500Medium` / `Inter_600SemiBold`), so one variable cannot say it;
11
+ * 2. `text.css` hand-tunes letter-spacing per size FOR Inter's glyphs and for
12
+ * Vietnamese diacritics at 12px;
13
+ * 3. `font-feature-settings: "cv11","ss01","ss03"` are INTER's stylistic
14
+ * alternates and mean nothing — or something else — on another face;
15
+ * 4. the files are self-hosted, so a family name nobody `@font-face`d falls
16
+ * silently through the stack to system sans.
17
+ *
18
+ * — which is why there is no `bodyFont` role. A variable for it would not fail,
19
+ * it would render every screen in the product subtly miscalibrated, globally and
20
+ * invisibly to typecheck. Changing the body face is a KIT change: add the
21
+ * @font-face trio, re-tune the curve, re-check the features. Rare, deliberate,
22
+ * and done once for everybody.
23
+ */
24
+ import { fontFamilySemiBold } from "./font_family";
25
+
26
+ /** Native: a literal, because `var()` means nothing off the web. */
27
+ export const fontFamilyDisplay = fontFamilySemiBold;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * THE DISPLAY FACE — the family `<Text family="display">` renders in.
3
+ *
4
+ * A themeable typeface exists for the DISPLAY role and deliberately not for the
5
+ * body, and the asymmetry is the whole design. Swapping the face a headline
6
+ * wears is one value with no dependencies: it appears a handful of times, large,
7
+ * in one weight. Swapping the BODY face is four coupled things —
8
+ *
9
+ * 1. weight is a FAMILY here, not a `font-weight` (`Inter_400Regular` /
10
+ * `Inter_500Medium` / `Inter_600SemiBold`), so one variable cannot say it;
11
+ * 2. `text.css` hand-tunes letter-spacing per size FOR Inter's glyphs and for
12
+ * Vietnamese diacritics at 12px;
13
+ * 3. `font-feature-settings: "cv11","ss01","ss03"` are INTER's stylistic
14
+ * alternates and mean nothing — or something else — on another face;
15
+ * 4. the files are self-hosted, so a family name nobody `@font-face`d falls
16
+ * silently through the stack to system sans.
17
+ *
18
+ * — which is why there is no `bodyFont` role. A variable for it would not fail,
19
+ * it would render every screen in the product subtly miscalibrated, globally and
20
+ * invisibly to typecheck. Changing the body face is a KIT change: add the
21
+ * @font-face trio, re-tune the curve, re-check the features. Rare, deliberate,
22
+ * and done once for everybody.
23
+ */
24
+ import { fontFamilySemiBold } from "./font_family";
25
+
26
+ /**
27
+ * The default IS the kit's own semibold sans, so `family="display"` changes
28
+ * nothing until an app sets `displayFont` — the same opt-in contract the colour
29
+ * roles keep.
30
+ */
31
+ export const fontFamilyDisplay = `var(--lotics-display-font, ${fontFamilySemiBold})`;
@@ -113,7 +113,10 @@ export function FilterChip(props: FilterChipProps) {
113
113
  <Chip onDismiss={showClear ? onClear : undefined} dismissTooltip={clearLabel}>
114
114
  {active && typeof summary !== "string" ? (
115
115
  <View style={styles.summaryRow}>
116
- <Text userSelect="none" size="sm" weight="medium" color="zinc-700">{`${label}:`}</Text>
116
+ {/* This branch only renders when ACTIVE, so it takes the accent
117
+ unconditionally — the two branches are one state seen twice, and
118
+ they were drifting the moment only one of them was themed. */}
119
+ <Text userSelect="none" size="sm" weight="medium" style={{ color: colors.accent }}>{`${label}:`}</Text>
117
120
  {summary}
118
121
  </View>
119
122
  ) : (
@@ -121,7 +124,14 @@ export function FilterChip(props: FilterChipProps) {
121
124
  userSelect="none"
122
125
  size="sm"
123
126
  weight="medium"
124
- color={active ? "zinc-900" : "zinc-700"}
127
+ // An APPLIED filter wears the accent; an empty one stays neutral.
128
+ // The pill already changed its words and grew a ×, but both are
129
+ // read only after you look AT it — the whole job of a filter bar is
130
+ // to answer "is anything on?" from across the screen, and ink is the
131
+ // only channel that carries at that distance. It is a state, not
132
+ // decoration, which is what makes it a legitimate place for brand.
133
+ color={active ? undefined : "zinc-700"}
134
+ style={active ? { color: colors.accent } : undefined}
125
135
  numberOfLines={1}
126
136
  >
127
137
  {active ? `${label}: ${summary}` : label}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * THE BODY FACE, as three weight rungs.
3
+ *
4
+ * Weight is a FAMILY in this kit, not a `font-weight`: Inter ships as three
5
+ * separate `@font-face` families (`Inter_400Regular` / `Inter_500Medium` /
6
+ * `Inter_600SemiBold`), which is the React Native convention and the reason a
7
+ * single family token cannot express the ladder on its own.
8
+ *
9
+ * So the tokens are per-rung, and `LoticsThemeProvider` FANS ONE VALUE OUT
10
+ * across all three when an app sets `bodyFont`. That is what keeps the app-facing
11
+ * surface at one parameter while the kit keeps a rung it can default correctly:
12
+ * unthemed, each rung resolves to its own Inter face; themed, all three resolve
13
+ * to the app's family and the numeric `font-weight` already on every style makes
14
+ * the weights. The fan-out lives in the provider, where it is explained once,
15
+ * rather than at 11 call sites that would each have to remember it.
16
+ */
17
+ /** Native: literals. `var()` means nothing off the web. */
18
+ export const fontFamilyRegular = `Inter_400Regular, "apple-system", "BlinkMacSystemFont",
19
+ "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
20
+ "Droid Sans", "Helvetica Neue", sans-serif`;
21
+ export const fontFamilyMedium = `Inter_500Medium, "apple-system", "BlinkMacSystemFont",
22
+ "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
23
+ "Droid Sans", "Helvetica Neue", sans-serif`;
24
+ export const fontFamilySemiBold = `Inter_600SemiBold, "apple-system", "BlinkMacSystemFont",
25
+ "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
26
+ "Droid Sans", "Helvetica Neue", sans-serif`;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * THE BODY FACE, as three weight rungs.
3
+ *
4
+ * Weight is a FAMILY in this kit, not a `font-weight`: Inter ships as three
5
+ * separate `@font-face` families (`Inter_400Regular` / `Inter_500Medium` /
6
+ * `Inter_600SemiBold`), which is the React Native convention and the reason a
7
+ * single family token cannot express the ladder on its own.
8
+ *
9
+ * So the tokens are per-rung, and `LoticsThemeProvider` FANS ONE VALUE OUT
10
+ * across all three when an app sets `bodyFont`. That is what keeps the app-facing
11
+ * surface at one parameter while the kit keeps a rung it can default correctly:
12
+ * unthemed, each rung resolves to its own Inter face; themed, all three resolve
13
+ * to the app's family and the numeric `font-weight` already on every style makes
14
+ * the weights. The fan-out lives in the provider, where it is explained once,
15
+ * rather than at 11 call sites that would each have to remember it.
16
+ */
17
+ /**
18
+ * Each rung carries its own Inter face as the inline fallback, so an app that
19
+ * themes nothing renders byte-identically to before these variables existed.
20
+ */
21
+ export const fontFamilyRegular = `var(--lotics-font-regular, Inter_400Regular, "apple-system", "BlinkMacSystemFont",
22
+ "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
23
+ "Droid Sans", "Helvetica Neue", sans-serif)`;
24
+ export const fontFamilyMedium = `var(--lotics-font-medium, Inter_500Medium, "apple-system", "BlinkMacSystemFont",
25
+ "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
26
+ "Droid Sans", "Helvetica Neue", sans-serif)`;
27
+ export const fontFamilySemiBold = `var(--lotics-font-semibold, Inter_600SemiBold, "apple-system", "BlinkMacSystemFont",
28
+ "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
29
+ "Droid Sans", "Helvetica Neue", sans-serif)`;
package/src/index.css CHANGED
@@ -1,6 +1,4 @@
1
1
  :root {
2
- --font-family: "Inter", sans-serif;
3
-
4
2
  --font-size-xs: 12px;
5
3
  --font-size-xs-line-height: 16px;
6
4
  --font-size-sm: 14px;
@@ -86,7 +86,11 @@ export function PressableRow(props: PressableRowProps) {
86
86
  styles.row,
87
87
  variant === "inset" ? styles.inset : variant === "bleed" ? styles.bleed : styles.register,
88
88
  {
89
- backgroundColor: pressed ? colors.zinc[200] : selected ? colors.zinc[100] : hovered ? colors.zinc[100] : marked ? colors.blue[50] : undefined,
89
+ // Hover and selection wear the brand's WASH, whose default IS the zinc-100
90
+ // these states already used — so an unthemed app is unchanged and a themed
91
+ // one warms every row it touches. `pressed` stays neutral and one step
92
+ // darker: it is a momentary depth cue, not an identity.
93
+ backgroundColor: pressed ? colors.zinc[200] : selected ? colors.accent_wash : hovered ? colors.accent_wash : marked ? colors.blue[50] : undefined,
90
94
  },
91
95
  style,
92
96
  ]}
package/src/summary.tsx CHANGED
@@ -28,6 +28,16 @@ export interface SummaryBucket {
28
28
  color: string;
29
29
  /** What this bucket means — the ⓘ beside its count. */
30
30
  info?: string;
31
+ /**
32
+ * Valence on the bucket's COUNT, for a bucket that represents work someone has
33
+ * to do. Reserve it: a screen where every bucket is toned has said nothing, and
34
+ * the bucket holding the largest number is usually the one that needs it least
35
+ * (work that has left the building is not a queue).
36
+ *
37
+ * It colours the figure only. The segment keeps the bucket's own `color`, so
38
+ * the bar goes on reading as a distribution rather than as a heat map.
39
+ */
40
+ tone?: MetricTone;
31
41
  }
32
42
 
33
43
  interface SummaryContext {
@@ -99,13 +109,26 @@ function Header(props: { children: ReactNode }) {
99
109
  /**
100
110
  * The set's SIZE, at display scale — the band's headline.
101
111
  *
102
- * The number is the sum of the buckets and is never passed in. A page that
103
- * restates its own total eventually restates it wrongly: a bucket gets added,
112
+ * By DEFAULT it is the sum of the buckets, and that default is the point: a page
113
+ * that restates its own total eventually restates it wrongly a bucket is added,
104
114
  * the constant does not move, and the headline quietly disagrees with the bar
105
115
  * directly beneath it.
116
+ *
117
+ * `value` overrides it, for the one case that is not a restatement: a total with
118
+ * its OWN authority, counted by the same source that counted the buckets. A
119
+ * server-paginated register is the example — the buckets are per-stage `COUNT`s
120
+ * over the filtered view and the size is a fourth count over that same view, so
121
+ * summing the three client-side would replace an authoritative number with a
122
+ * derived one that can differ (a bucket query filtered a shade differently, or
123
+ * landed a moment later) and would be believed because it is the biggest figure
124
+ * on the screen.
125
+ *
126
+ * The line, then: pass `value` when something ELSE counted it, never to write a
127
+ * literal. A number typed into this prop is the defect the default prevents.
106
128
  */
107
- function Total(props: { label: string; formatValue?: (n: number) => string }) {
108
- const { total } = useSummary("Total");
129
+ function Total(props: { label: string; value?: number; formatValue?: (n: number) => string }) {
130
+ const { total: summed } = useSummary("Total");
131
+ const total = props.value ?? summed;
109
132
  const tag = useLocaleTag();
110
133
  // Grouped by the reader's locale by default — a set of 12,345 was rendering
111
134
  // "12345" in the one place on the screen sized to be read first, because the
@@ -114,7 +137,13 @@ function Total(props: { label: string; formatValue?: (n: number) => string }) {
114
137
  const shown = props.formatValue ? props.formatValue(total) : total.toLocaleString(tag);
115
138
  return (
116
139
  <View style={styles.pair}>
117
- <Text size="xxxl" weight="semibold" tabular>
140
+ {/* DISPLAY type by definition — this figure is what the page exists to
141
+ show, which is the whole meaning of the role. Without it a themed app
142
+ gets a serif headline where it hand-rolls one and a sans headline where
143
+ it uses this compound, and two sibling screens disagree about their own
144
+ masthead. Costs nothing unthemed: `family="display"` falls back to the
145
+ body face until an app sets `displayFont`. */}
146
+ <Text size="xxxl" weight="semibold" tabular family="display">
118
147
  {shown}
119
148
  </Text>
120
149
  <Text size="md" color="muted">
@@ -187,6 +216,7 @@ function Facts(props: { extra?: readonly SummaryLineItem[] }) {
187
216
  value: b.value,
188
217
  color: b.color,
189
218
  info: b.info,
219
+ tone: b.tone,
190
220
  }));
191
221
  return <SummaryLine items={[...items, ...(props.extra ?? [])]} />;
192
222
  }
package/src/table.tsx CHANGED
@@ -17,6 +17,7 @@ import { Divider } from "./divider";
17
17
  import { DetailRow } from "./detail_row";
18
18
  import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
19
19
  import { COLUMN_GAP, ROW_GUTTER, computeTableFit, type TableFit, type TableFitColumn } from "./table_fit";
20
+ import { ROW_WASH_BLEED } from "./control_surface";
20
21
 
21
22
  /**
22
23
  * One column of a register — its width/flex/align/label/sortability defined ONCE,
@@ -120,8 +121,36 @@ export interface TableProps {
120
121
  * Positions are yours for the same reason — see `TableRowProps.ordinal`. This
121
122
  * prop only says "this register is counted", which is what reserves the gutter
122
123
  * on the header and on every row at once, so they cannot disagree.
124
+ *
125
+ * Pass {@link TableProps.counted} INSTEAD when the page already states the
126
+ * total somewhere the reader will see first.
123
127
  */
124
128
  count?: number;
129
+ /**
130
+ * Number the rows WITHOUT heading the gutter with a total.
131
+ *
132
+ * The total earns its place in the header by answering "how many" without
133
+ * scrolling to the last row — so it stops earning it the moment the page
134
+ * answers that louder. A register whose band opens with the count in display
135
+ * type was printing the same figure twice within a hundred pixels, at the same
136
+ * left edge, one huge and one muted, which reads as a mistake rather than as
137
+ * two deliberate statements.
138
+ *
139
+ * Ignored when `count` is given; the gutter is reserved either way, so header
140
+ * and rows can never disagree about its width.
141
+ */
142
+ counted?: boolean;
143
+ /**
144
+ * Paint the column-header band with the brand's wash.
145
+ *
146
+ * OPT-IN, and deliberately not the default: a filled header is a real restyle
147
+ * of every table in every app, and the kit's language is a white canvas where
148
+ * a hairline does the separating. It earns its place on a register that is the
149
+ * page's whole content, where the band gives the columns a lid and the table
150
+ * stops floating; it is noise on a small table inside a card that already has
151
+ * edges of its own.
152
+ */
153
+ headerFill?: boolean;
125
154
  /** Reserve a trailing gutter (px) for rows that render a `trailing` slot (a ⋯ / button). */
126
155
  trailing?: number;
127
156
  /** The `TableRow`s. */
@@ -143,8 +172,11 @@ export interface TableProps {
143
172
  * keeps its current order). See `computeTableFit`.
144
173
  */
145
174
  export function Table(props: TableProps) {
146
- const { columns, sort, onSort, sortLabels, selectAll, leading = 0, count, trailing = 0, children } = props;
147
- const ordinal = count != null ? ORDINAL_W : 0;
175
+ const { columns, sort, onSort, sortLabels, selectAll, leading = 0, count, counted, headerFill, trailing = 0, children } = props;
176
+ // Either prop reserves the gutter; only `count` puts a number in its header.
177
+ // `LeadGutter` already renders an empty box when it has no number — the same
178
+ // path a group band takes — so nothing new is needed to draw it.
179
+ const ordinal = count != null || counted ? ORDINAL_W : 0;
148
180
  const rows = Children.toArray(children).filter(isValidElement);
149
181
 
150
182
  // Measure-then-REVEAL (the `DetailTable` contract): the unmeasured first
@@ -174,12 +206,12 @@ export function Table(props: TableProps) {
174
206
  // select-all checkbox keeps its band: it's the bulk-select entry point,
175
207
  // aligned over the rows' leading checkboxes.
176
208
  (selectAll != null && leading > 0) || ordinal > 0 ? (
177
- <View style={styles.headerBand}>
209
+ <View style={[styles.headerBand, headerFill ? styles.headerBandFilled : null]}>
178
210
  <LeadGutter ordinal={count}>{selectAll}</LeadGutter>
179
211
  </View>
180
212
  ) : null
181
213
  ) : (
182
- <View style={styles.headerBand}>
214
+ <View style={[styles.headerBand, headerFill ? styles.headerBandFilled : null]}>
183
215
  {/* The TOTAL heads the column of positions — the one number that
184
216
  answers "how many" without the reader scrolling to the last row. */}
185
217
  <LeadGutter ordinal={count}>{selectAll}</LeadGutter>
@@ -458,6 +490,16 @@ const styles = StyleSheet.create({
458
490
  },
459
491
  // A hairline under the column header anchors the columns; the rows below it are
460
492
  // Divider-separated.
493
+ headerBandFilled: {
494
+ backgroundColor: colors.accent_wash,
495
+ // The band bleeds to the row wash's width so its edges line up with a hovered
496
+ // row beneath it; without this the lid is narrower than the rows it caps.
497
+ marginHorizontal: -ROW_WASH_BLEED,
498
+ paddingHorizontal: ROW_GUTTER + ROW_WASH_BLEED,
499
+ paddingTop: 8,
500
+ borderTopLeftRadius: 8,
501
+ borderTopRightRadius: 8,
502
+ },
461
503
  headerBand: {
462
504
  paddingHorizontal: ROW_GUTTER,
463
505
  // NO top padding (GAP-85): a Table is borderless, so its container ALWAYS owns
package/src/text.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import "./text.css";
2
2
  import React from "react";
3
3
  import { Platform, Text as RNText, TextProps as RNTextProps, StyleSheet } from "react-native";
4
+ import { fontFamilyDisplay } from "./display_font";
4
5
  import {
5
6
  fontFamilyMedium,
6
7
  fontFamilyRegular,
@@ -14,6 +15,16 @@ export interface TextProps {
14
15
  testID?: string;
15
16
  size?: TextSize;
16
17
  color?: TextColor;
18
+ /**
19
+ * `display` renders in the app's display face — a headline, a wordmark, a
20
+ * figure the page exists to show. Everything else stays the body face, which
21
+ * is not themeable (see `display_font.ts`).
22
+ *
23
+ * A ROLE, not a font name: the call site says what the text IS, and the theme
24
+ * decides what that looks like. Threading family strings through call sites is
25
+ * how one screen ends up in a different face from the next.
26
+ */
27
+ family?: "display";
17
28
  align?: TextAlign;
18
29
  weight?: TextWeight;
19
30
  numberOfLines?: number;
@@ -69,6 +80,7 @@ export function Text(props: TextProps) {
69
80
  color = "default",
70
81
  size = "sm",
71
82
  weight = "regular",
83
+ family,
72
84
  numberOfLines,
73
85
  decoration,
74
86
  tabular,
@@ -107,6 +119,10 @@ export function Text(props: TextProps) {
107
119
  styles.text,
108
120
  Platform.OS !== "web" && styles[size],
109
121
  styles[weight],
122
+ // AFTER the weight, because the weight ladder sets a family too — each
123
+ // rung is a distinct Inter family, not a `font-weight` — so the display
124
+ // face has to win the family while leaving the numeric weight alone.
125
+ family === "display" && { fontFamily: fontFamilyDisplay },
110
126
  // Only when ASKED for. Left to inherit, a label inside a button is
111
127
  // unselectable and a paragraph is selectable, each from its container —
112
128
  // which is what a container setting `userSelect` is trying to say.
package/src/text_utils.ts CHANGED
@@ -47,17 +47,9 @@ export function getTextColor(color?: TextColor): string {
47
47
  }
48
48
  }
49
49
 
50
- export const fontFamilyRegular = `Inter_400Regular, "apple-system", "BlinkMacSystemFont",
51
- "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
52
- "Droid Sans", "Helvetica Neue", sans-serif`;
53
-
54
- export const fontFamilyMedium = `Inter_500Medium, "apple-system", "BlinkMacSystemFont",
55
- "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
56
- "Droid Sans", "Helvetica Neue", sans-serif`;
57
-
58
- export const fontFamilySemiBold = `Inter_600SemiBold, "apple-system", "BlinkMacSystemFont",
59
- "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
60
- "Droid Sans", "Helvetica Neue", sans-serif`;
50
+ // The three rungs live in their own platform-split module — see `font_family.ts`.
51
+ // Re-exported here so every existing consumer keeps one import.
52
+ export { fontFamilyRegular, fontFamilyMedium, fontFamilySemiBold } from "./font_family";
61
53
 
62
54
  /** Line height for mobile web (prevents Safari iOS auto-zoom) */
63
55
  export const INPUT_LINE_HEIGHT_MOBILE = 24;
package/src/theme.web.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useMemo, type CSSProperties, type ReactNode } from "react";
2
- import { LoticsThemeContext, THEME_VARS, type LoticsTheme } from "./theme_context";
2
+ import { ACCENT_WASH_VAR, deriveAccentWash, FONT_MEDIUM_VAR, FONT_SEMIBOLD_VAR, LoticsThemeContext, THEME_VARS, type LoticsTheme } from "./theme_context";
3
3
 
4
4
  export { DEFAULT_ACCENT, useLoticsTheme, THEME_VARS, type LoticsTheme } from "./theme_context";
5
5
 
@@ -46,6 +46,21 @@ export function LoticsThemeProvider(props: LoticsThemeProviderProps) {
46
46
  const value = theme[role as keyof LoticsTheme];
47
47
  if (value !== undefined) vars[name] = value;
48
48
  }
49
+ // The wash is DERIVED here, where the accent is still a literal — see
50
+ // `deriveAccentWash`. An accent the parser cannot read leaves it undefined,
51
+ // so those surfaces keep the neutral default instead of breaking.
52
+ if (theme.accent !== undefined) {
53
+ const wash = deriveAccentWash(theme.accent);
54
+ if (wash !== undefined) vars[ACCENT_WASH_VAR] = wash;
55
+ }
56
+ // ONE font value, THREE rungs. `THEME_VARS` already mapped `bodyFont` to the
57
+ // regular rung; medium and semibold follow it here, because a family set on
58
+ // only one rung would leave every medium and semibold run in Inter — the
59
+ // screen would come out in two typefaces and look like a loading bug.
60
+ if (theme.bodyFont !== undefined) {
61
+ vars[FONT_MEDIUM_VAR] = theme.bodyFont;
62
+ vars[FONT_SEMIBOLD_VAR] = theme.bodyFont;
63
+ }
49
64
  // `display: contents` — the provider generates NO box. Custom properties
50
65
  // inherit down the DOM tree rather than the box tree, so the variables still
51
66
  // reach every descendant while the element itself adds no layout at all.
@@ -19,8 +19,40 @@ export interface LoticsTheme {
19
19
  * on almost every app; set it only for a deliberately toned surface. */
20
20
  background?: string;
21
21
  /** Hairlines — 48 sites in the kit, and a register is mostly these lines, so a
22
- * small change here is felt across a whole screen. */
22
+ * small change here is felt against a whole screen. */
23
23
  border?: string;
24
+ /**
25
+ * THE TYPEFACE, for everything. One value — a CSS font stack — and the whole
26
+ * app follows it.
27
+ *
28
+ * It fans out to the three weight rungs (`font_family.ts`), because weight is
29
+ * a FAMILY in this kit rather than a `font-weight`. Themed, all three resolve
30
+ * to this family and the numeric weight already on every style makes the
31
+ * ladder; unthemed, each keeps its own Inter face.
32
+ *
33
+ * Pass a stack you know RESOLVES — a face nobody `@font-face`d falls silently
34
+ * through to system sans, which is the one failure here that looks like
35
+ * nothing happening. And check Vietnamese: a face without the diacritics turns
36
+ * "Thẩm định" into tofu.
37
+ *
38
+ * The kit's letter-spacing curve is tuned for Inter's glyphs, so another face
39
+ * inherits tracking chosen for a different drawing. It is a refinement, not a
40
+ * defect — worth an eye, not a blocker.
41
+ */
42
+ bodyFont?: string;
43
+ /**
44
+ * The typeface for DISPLAY text only — what `<Text family="display">` renders
45
+ * in. A headline, a wordmark, a figure the page exists to show.
46
+ *
47
+ * Defaults to `bodyFont` when that is set, so ONE parameter changes the whole
48
+ * app and a SECOND one is only needed to make display type differ from body —
49
+ * a serif masthead over a sans register, say.
50
+ */
51
+ displayFont?: string;
52
+ /** The PRIMARY action's fill. Defaults to the kit's near-black, so an app that
53
+ * themes nothing is unchanged; set it to put the brand on the one filled
54
+ * button a surface gets. Distinct from `accent`, which marks identity. */
55
+ primary?: string;
24
56
  }
25
57
 
26
58
  /**
@@ -31,8 +63,38 @@ export const THEME_VARS: Record<keyof LoticsTheme, string> = {
31
63
  accent: "--lotics-accent",
32
64
  background: "--lotics-background",
33
65
  border: "--lotics-border",
66
+ primary: "--lotics-primary",
67
+ bodyFont: "--lotics-font-regular",
68
+ displayFont: "--lotics-display-font",
34
69
  };
35
70
 
71
+ /** The extra variable the provider DERIVES — not a role an app sets, because it
72
+ * is the accent seen through alpha and two sources for one colour drift. */
73
+ export const ACCENT_WASH_VAR = "--lotics-accent-wash";
74
+
75
+ /** The two rungs `bodyFont` fans out to beyond its own `THEME_VARS` entry. */
76
+ export const FONT_MEDIUM_VAR = "--lotics-font-medium";
77
+ export const FONT_SEMIBOLD_VAR = "--lotics-font-semibold";
78
+
79
+ /**
80
+ * The accent at wash strength.
81
+ *
82
+ * Computed from the literal hex the app passed, because it CANNOT be computed
83
+ * downstream: on web the token is a `var()`, and the kit's alpha helpers parse
84
+ * `rgba()` strings. Returns undefined for a value it cannot parse (a named
85
+ * colour, an `oklch()`), so an unparseable accent falls back to the neutral wash
86
+ * rather than painting rows in garbage.
87
+ */
88
+ export function deriveAccentWash(accent: string): string | undefined {
89
+ const hex = accent.trim();
90
+ const m = /^#([0-9a-f]{6})$/i.exec(hex);
91
+ if (!m) return undefined;
92
+ const n = parseInt(m[1], 16);
93
+ // 0.07 — heavy enough to read as a tint on white, light enough that body text
94
+ // on top keeps its contrast; the neutral it replaces (zinc-100) sits about here.
95
+ return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, 0.07)`;
96
+ }
97
+
36
98
  export const LoticsThemeContext = createContext<LoticsTheme>({});
37
99
 
38
100
  /**