@lotics/ui 43.0.0 → 43.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/docs/composition.md +24 -2
- package/package.json +10 -2
- package/src/badge.tsx +10 -2
- package/src/button.tsx +69 -8
- package/src/color_tokens.ts +27 -3
- package/src/colors.web.ts +4 -2
- package/src/display_font.ts +27 -0
- package/src/display_font.web.ts +31 -0
- package/src/filter_chip.tsx +12 -2
- package/src/font_family.ts +26 -0
- package/src/font_family.web.ts +29 -0
- package/src/index.css +0 -2
- package/src/pressable_row.tsx +5 -1
- package/src/summary.tsx +28 -4
- package/src/table.tsx +46 -4
- package/src/text.tsx +16 -0
- package/src/text_utils.ts +3 -11
- package/src/theme.web.tsx +16 -1
- package/src/theme_context.ts +63 -1
package/docs/composition.md
CHANGED
|
@@ -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
|
-
|
|
1037
|
-
|
|
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.
|
|
3
|
+
"version": "43.1.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
|
|
19
|
-
*
|
|
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
|
-
|
|
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
|
-
:
|
|
120
|
-
?
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
:
|
|
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
|
-
|
|
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":
|
package/src/color_tokens.ts
CHANGED
|
@@ -292,8 +292,8 @@ const palette = {
|
|
|
292
292
|
};
|
|
293
293
|
|
|
294
294
|
/**
|
|
295
|
-
* THE
|
|
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
|
|
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
|
|
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})`;
|
package/src/filter_chip.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
package/src/pressable_row.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
103
|
-
* restates its own total eventually restates it wrongly
|
|
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
|
|
@@ -187,6 +210,7 @@ function Facts(props: { extra?: readonly SummaryLineItem[] }) {
|
|
|
187
210
|
value: b.value,
|
|
188
211
|
color: b.color,
|
|
189
212
|
info: b.info,
|
|
213
|
+
tone: b.tone,
|
|
190
214
|
}));
|
|
191
215
|
return <SummaryLine items={[...items, ...(props.extra ?? [])]} />;
|
|
192
216
|
}
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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.
|
package/src/theme_context.ts
CHANGED
|
@@ -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
|
|
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
|
/**
|