@lotics/ui 43.3.2 → 43.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -2
- package/MIGRATION.md +93 -0
- package/docs/catalog.md +128 -26
- package/docs/composition.md +380 -9
- package/docs/data_entry.md +25 -0
- package/docs/reviewing.md +477 -0
- package/docs/templates.md +9 -6
- package/examples/tpl_attendance.tsx +0 -1
- package/examples/tpl_item_list.tsx +109 -54
- package/examples/tpl_record.tsx +89 -4
- package/package.json +7 -3
- package/src/avatar.tsx +29 -29
- package/src/avatar.web.tsx +32 -31
- package/src/avatar_props.ts +66 -0
- package/src/avatar_tone.ts +79 -0
- package/src/button.tsx +36 -4
- package/src/checkbox.tsx +4 -1
- package/src/choice_list.tsx +5 -3
- package/src/color_tokens.ts +15 -14
- package/src/composer.tsx +3 -3
- package/src/control_surface.ts +37 -9
- package/src/copy_button.tsx +8 -1
- package/src/counter.tsx +1 -1
- package/src/data_grid.tsx +6 -3
- package/src/date_calendar.tsx +4 -2
- package/src/date_range_filter_field.tsx +5 -1
- package/src/date_segments_field.tsx +2 -2
- package/src/file_drop_target.web.tsx +2 -2
- package/src/file_dropzone.tsx +12 -6
- package/src/file_rows.tsx +22 -2
- package/src/file_thumbnail.tsx +19 -3
- package/src/font_family.ts +1 -1
- package/src/font_family.web.ts +1 -1
- package/src/funnel.tsx +1 -1
- package/src/icon_button.tsx +5 -2
- package/src/index.css +22 -16
- package/src/inline_edit.tsx +9 -10
- package/src/inline_files.tsx +6 -0
- package/src/json_panel.tsx +1 -1
- package/src/kpi_card.tsx +1 -1
- package/src/locale.tsx +1 -1
- package/src/markdown.css +5 -1
- package/src/member_chip.tsx +19 -2
- package/src/metric.tsx +12 -18
- package/src/number_input.tsx +2 -2
- package/src/option_picker.tsx +57 -0
- package/src/picker.tsx +2 -2
- package/src/pressable_row.tsx +15 -5
- package/src/progress_bar.tsx +1 -1
- package/src/radio_picker.tsx +2 -1
- package/src/search_input.tsx +12 -11
- package/src/sort_header.tsx +7 -3
- package/src/stacked_progress_bar.tsx +1 -1
- package/src/step_progress.tsx +5 -2
- package/src/switch.tsx +11 -6
- package/src/table.tsx +148 -16
- package/src/table_fit.ts +12 -0
- package/src/tabs.tsx +13 -1
- package/src/text.css +50 -18
- package/src/text.tsx +37 -40
- package/src/text_input_field.tsx +2 -2
- package/src/text_utils.ts +48 -6
- package/src/theme.ts +13 -0
- package/src/theme.web.ts +49 -0
- package/src/theme_vars.ts +113 -0
- package/src/type_ramp.ts +100 -0
- package/src/theme.tsx +0 -24
- package/src/theme.web.tsx +0 -79
- package/src/theme_context.ts +0 -107
package/src/theme.web.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { themeDeclarations, type LoticsTheme } from "./theme_vars";
|
|
2
|
+
|
|
3
|
+
export { THEME_VARS, deriveAccentWash, type LoticsTheme } from "./theme_vars";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Give this app its own identity — call ONCE at boot, before `mount`.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* applyLoticsTheme({ primary: "#0F766E", bodyFont: "'Be Vietnam Pro', sans-serif" });
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* THE ESCAPE HATCH, and deliberately shaped like one. Theming is not a product
|
|
13
|
+
* surface: nothing in the platform writes a theme, there is no settings screen
|
|
14
|
+
* for it, and `apps.theme.color` is the launcher icon's colour and nothing else.
|
|
15
|
+
* This exists because an app occasionally needs its own typeface or brand, and
|
|
16
|
+
* the alternative — passing `style` at every call site — is a fork rather than a
|
|
17
|
+
* theme.
|
|
18
|
+
*
|
|
19
|
+
* Every kit surface follows, because `colors.web.ts` reads each role through a
|
|
20
|
+
* CSS variable with the kit's own value as the inline fallback. Setting one role
|
|
21
|
+
* overrides that role and nothing else.
|
|
22
|
+
*
|
|
23
|
+
* THE DOCUMENT ROOT, which is the point rather than an implementation detail. A
|
|
24
|
+
* custom property inherits down the DOM tree, and `Modal` (react-native-web)
|
|
25
|
+
* mounts its content into `document.body` — so a theme scoped to a React subtree
|
|
26
|
+
* reaches the register and not the `Drawer` opened from it. Nothing errors; the
|
|
27
|
+
* two halves of the app simply disagree, and the seam is visible only with both
|
|
28
|
+
* on screen at once.
|
|
29
|
+
*
|
|
30
|
+
* Called BEFORE `mount`, the variables are in place for the first paint, so no
|
|
31
|
+
* frame renders in the kit's defaults first.
|
|
32
|
+
*
|
|
33
|
+
* Idempotent — call it again with different values and the app re-themes, which
|
|
34
|
+
* is what a tenant switch or a live preview needs. There is nothing to unwind,
|
|
35
|
+
* which is why this is a function rather than a component: the provider it
|
|
36
|
+
* replaces spent a memo, a wrapper element, a layout effect and a
|
|
37
|
+
* restore-on-unmount serving a React lifecycle that an app setting one identity
|
|
38
|
+
* at boot never had.
|
|
39
|
+
*
|
|
40
|
+
* A no-op where there is no document (SSR, a test without jsdom), so a call at
|
|
41
|
+
* module scope is safe.
|
|
42
|
+
*/
|
|
43
|
+
export function applyLoticsTheme(theme: LoticsTheme): void {
|
|
44
|
+
if (typeof document === "undefined") return;
|
|
45
|
+
const root = document.documentElement;
|
|
46
|
+
for (const [name, value] of Object.entries(themeDeclarations(theme))) {
|
|
47
|
+
root.style.setProperty(name, value);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE THEME CONTRACT — the roles an app may own, and the CSS custom properties
|
|
3
|
+
* they resolve through.
|
|
4
|
+
*
|
|
5
|
+
* This is data, not machinery. `colors.web.ts` reads each variable with an
|
|
6
|
+
* inline fallback, so a role nobody sets answers with the kit's own value —
|
|
7
|
+
* which is what makes a theme ADDITIVE: setting one role overrides that role and
|
|
8
|
+
* nothing else.
|
|
9
|
+
*
|
|
10
|
+
* The variables are part of the published contract, so an app CAN declare them
|
|
11
|
+
* in its own stylesheet. Prefer `applyLoticsTheme`, which gets the two derived
|
|
12
|
+
* values right — see `deriveAccentWash` and the font fan-out below.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The roles an app may own. See `color_tokens.ts` for why exactly these. */
|
|
16
|
+
export interface LoticsTheme {
|
|
17
|
+
/** The one brand hue. It marks WHICH OF THESE — a selected row, an active
|
|
18
|
+
* filter — never a status, a series or a valence, which come from a palette
|
|
19
|
+
* family and carry meaning. */
|
|
20
|
+
accent?: string;
|
|
21
|
+
/** The surface Card, Drawer and Modal paint. Left white on almost every app;
|
|
22
|
+
* set it only for a deliberately toned surface. */
|
|
23
|
+
background?: string;
|
|
24
|
+
/** Hairlines. A register is mostly these lines, so a small change here is felt
|
|
25
|
+
* against a whole screen. */
|
|
26
|
+
border?: string;
|
|
27
|
+
/**
|
|
28
|
+
* THE TYPEFACE, for everything. One value — a CSS font stack — and the whole
|
|
29
|
+
* app follows it, because it fans out to the three weight rungs
|
|
30
|
+
* (`font_family.ts`): weight is a FAMILY in this kit, not a `font-weight`.
|
|
31
|
+
*
|
|
32
|
+
* Pass a stack you know RESOLVES — a face nobody `@font-face`d falls silently
|
|
33
|
+
* through to system sans, which is the one failure here that looks like
|
|
34
|
+
* nothing happening. And check Vietnamese: a face without the diacritics turns
|
|
35
|
+
* "Thẩm định" into tofu. The kit's letter-spacing curve is tuned for Inter's
|
|
36
|
+
* glyphs, so another face inherits tracking chosen for a different drawing —
|
|
37
|
+
* a refinement to check, not a blocker.
|
|
38
|
+
*/
|
|
39
|
+
bodyFont?: string;
|
|
40
|
+
/**
|
|
41
|
+
* The typeface for DISPLAY text only — what `<Text family="display">` renders
|
|
42
|
+
* in. Defaults to `bodyFont` when that is set, so ONE parameter changes the
|
|
43
|
+
* whole app and a second is needed only to make display type differ from body.
|
|
44
|
+
*/
|
|
45
|
+
displayFont?: string;
|
|
46
|
+
/** The ink that ACTS — the primary button's fill, a checkbox's fill, a
|
|
47
|
+
* switch's track, an active tab's rail. Defaults to the kit's near-black. */
|
|
48
|
+
primary?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Role → the CSS custom property `colors.web.ts` reads for it. */
|
|
52
|
+
export const THEME_VARS: Record<keyof LoticsTheme, string> = {
|
|
53
|
+
accent: "--lotics-accent",
|
|
54
|
+
background: "--lotics-background",
|
|
55
|
+
border: "--lotics-border",
|
|
56
|
+
primary: "--lotics-primary",
|
|
57
|
+
bodyFont: "--lotics-font-regular",
|
|
58
|
+
displayFont: "--lotics-display-font",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** The extra variable that is DERIVED — not a role an app sets, because it is
|
|
62
|
+
* the accent seen through alpha and two sources for one colour drift. */
|
|
63
|
+
export const ACCENT_WASH_VAR = "--lotics-accent-wash";
|
|
64
|
+
|
|
65
|
+
/** The two rungs `bodyFont` fans out to beyond its own `THEME_VARS` entry. */
|
|
66
|
+
export const FONT_MEDIUM_VAR = "--lotics-font-medium";
|
|
67
|
+
export const FONT_SEMIBOLD_VAR = "--lotics-font-semibold";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The accent at wash strength.
|
|
71
|
+
*
|
|
72
|
+
* Computed from the literal hex the app passed, because it CANNOT be computed
|
|
73
|
+
* downstream: on web the token is a `var()`, and the kit's alpha helpers parse
|
|
74
|
+
* `rgba()` strings. Returns undefined for a value it cannot parse (a named
|
|
75
|
+
* colour, an `oklch()`), so an unparseable accent falls back to the neutral wash
|
|
76
|
+
* rather than painting rows in garbage.
|
|
77
|
+
*/
|
|
78
|
+
export function deriveAccentWash(accent: string): string | undefined {
|
|
79
|
+
const m = /^#([0-9a-f]{6})$/i.exec(accent.trim());
|
|
80
|
+
if (!m) return undefined;
|
|
81
|
+
const n = parseInt(m[1], 16);
|
|
82
|
+
// 0.07 — heavy enough to read as a tint on white, light enough that body text
|
|
83
|
+
// on top keeps its contrast; the neutral it replaces (zinc-100) sits about here.
|
|
84
|
+
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, 0.07)`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The theme as CSS declarations — the role variables plus the two things an app
|
|
89
|
+
* writing raw CSS has to remember and usually will not.
|
|
90
|
+
*
|
|
91
|
+
* Shared by both platforms' `applyLoticsTheme` so the mapping has one
|
|
92
|
+
* definition; only a role actually PASSED appears.
|
|
93
|
+
*/
|
|
94
|
+
export function themeDeclarations(theme: LoticsTheme): Record<string, string> {
|
|
95
|
+
const vars: Record<string, string> = {};
|
|
96
|
+
for (const [role, name] of Object.entries(THEME_VARS)) {
|
|
97
|
+
const value = theme[role as keyof LoticsTheme];
|
|
98
|
+
if (value !== undefined) vars[name] = value;
|
|
99
|
+
}
|
|
100
|
+
if (theme.accent !== undefined) {
|
|
101
|
+
const wash = deriveAccentWash(theme.accent);
|
|
102
|
+
if (wash !== undefined) vars[ACCENT_WASH_VAR] = wash;
|
|
103
|
+
}
|
|
104
|
+
// ONE font value, THREE rungs. `THEME_VARS` maps `bodyFont` to the regular
|
|
105
|
+
// rung; medium and semibold follow it here, because a family set on only one
|
|
106
|
+
// rung leaves every medium and semibold run in Inter — the screen comes out in
|
|
107
|
+
// two typefaces and reads as a font that half-loaded.
|
|
108
|
+
if (theme.bodyFont !== undefined) {
|
|
109
|
+
vars[FONT_MEDIUM_VAR] = theme.bodyFont;
|
|
110
|
+
vars[FONT_SEMIBOLD_VAR] = theme.bodyFont;
|
|
111
|
+
}
|
|
112
|
+
return vars;
|
|
113
|
+
}
|
package/src/type_ramp.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TYPE RAMP — the one place the two curves are written down.
|
|
3
|
+
*
|
|
4
|
+
* `Text` renders through two entirely different mechanisms: on web the size is a
|
|
5
|
+
* `data-text-size` attribute matched by `text.css`, on native it is a
|
|
6
|
+
* `StyleSheet` entry. Those were two hand-maintained tables, and they drifted
|
|
7
|
+
* exactly as far as you would expect — by the time this file was written native
|
|
8
|
+
* disagreed with web on the leading of three rungs and on the tracking of all
|
|
9
|
+
* seven, because every typographic change since had been made on the web side
|
|
10
|
+
* only. The mobile app was running a ramp nobody had looked at.
|
|
11
|
+
*
|
|
12
|
+
* So the ramp is DATA here, and both consumers derive from it. `text.tsx` builds
|
|
13
|
+
* its native sheet from these numbers, so no second native table exists to drift.
|
|
14
|
+
* `text.css` cannot read TypeScript and stays hand-written — it needs the media
|
|
15
|
+
* query and it carries the reasoning a reader needs — so `type_ramp.test.ts`
|
|
16
|
+
* parses it and fails when a value disagrees with this table. Eliminate the
|
|
17
|
+
* duplication where you can; test it where you cannot.
|
|
18
|
+
*
|
|
19
|
+
* LEADING: the body rungs carry PROSE leading — 14/24, ratio 1.71, exactly what
|
|
20
|
+
* `.ui-markdown` uses. The ladder tightens as it rises, which is the convention:
|
|
21
|
+
* large type needs proportionally less leading, so the display rungs sit near
|
|
22
|
+
* 1.2 and only the rungs carrying running text are generous. The cost is real
|
|
23
|
+
* and deliberate — line-height sets the BOX height of even a single line, so
|
|
24
|
+
* every label, value and control gained about 4px of line box. Density was
|
|
25
|
+
* traded for rhythm.
|
|
26
|
+
*
|
|
27
|
+
* TRACKING: negative throughout, steepening as the type grows. The body rung is
|
|
28
|
+
* ANCHORED on -0.028em — the -0.4px this product already set on `.ui-markdown`
|
|
29
|
+
* and on its inputs, expressed as an em so it scales. That value is the
|
|
30
|
+
* product's own, arrived at by reading it in Vietnamese every day, and the rest
|
|
31
|
+
* of the ramp is derived FROM it rather than imported from another design
|
|
32
|
+
* system. Inter is drawn generously at default tracking, so untracked body reads
|
|
33
|
+
* loose beside it, and what looks right at 14px looks slack at 32px.
|
|
34
|
+
*
|
|
35
|
+
* `xs` is the one rung held back (-0.02em rather than the full body value). 12px
|
|
36
|
+
* is where Vietnamese stacked diacritics (ự, ộ, ế, ệ) and Inter's `1`/`l`/`i`
|
|
37
|
+
* come closest to colliding, and tightening is the direction that makes both
|
|
38
|
+
* worse.
|
|
39
|
+
*
|
|
40
|
+
* Tracking is stated ONCE, in em, and is not repeated per breakpoint: an em
|
|
41
|
+
* scales with the font size, so the same value is correct at both. Only the
|
|
42
|
+
* sizes and their leading differ across the breakpoint.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** A rung's box, in px. */
|
|
46
|
+
export interface TypeRung {
|
|
47
|
+
size: number;
|
|
48
|
+
leading: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The ramp below 768px — and the ramp NATIVE uses, because a phone is a phone.
|
|
53
|
+
* The display rungs are the ones that grow on a desktop; body sits still.
|
|
54
|
+
*/
|
|
55
|
+
export const TYPE_RAMP_MOBILE = {
|
|
56
|
+
xs: { size: 12, leading: 18 },
|
|
57
|
+
sm: { size: 14, leading: 24 },
|
|
58
|
+
md: { size: 16, leading: 26 },
|
|
59
|
+
lg: { size: 18, leading: 28 },
|
|
60
|
+
// Display ramp — the heading ladder: xl = `##` (section), xxl = `#`
|
|
61
|
+
// (page/record title), xxxl = hero numbers.
|
|
62
|
+
xl: { size: 22, leading: 28 },
|
|
63
|
+
xxl: { size: 28, leading: 34 },
|
|
64
|
+
xxxl: { size: 32, leading: 38 },
|
|
65
|
+
} as const satisfies Record<string, TypeRung>;
|
|
66
|
+
|
|
67
|
+
/** The ramp at 768px and above. Breakpoint per `use_screen_size`. */
|
|
68
|
+
export const TYPE_RAMP_DESKTOP = {
|
|
69
|
+
xs: { size: 12, leading: 18 },
|
|
70
|
+
sm: { size: 14, leading: 24 },
|
|
71
|
+
md: { size: 16, leading: 26 },
|
|
72
|
+
lg: { size: 20, leading: 30 },
|
|
73
|
+
xl: { size: 24, leading: 32 },
|
|
74
|
+
xxl: { size: 32, leading: 44 },
|
|
75
|
+
xxxl: { size: 48, leading: 52 },
|
|
76
|
+
} as const satisfies Record<keyof typeof TYPE_RAMP_MOBILE, TypeRung>;
|
|
77
|
+
|
|
78
|
+
/** Tracking per rung, in em. Negative at every rung — see the header. */
|
|
79
|
+
export const TYPE_TRACKING = {
|
|
80
|
+
xs: -0.02,
|
|
81
|
+
sm: -0.028,
|
|
82
|
+
md: -0.03,
|
|
83
|
+
lg: -0.032,
|
|
84
|
+
xl: -0.035,
|
|
85
|
+
xxl: -0.038,
|
|
86
|
+
xxxl: -0.04,
|
|
87
|
+
} as const satisfies Record<keyof typeof TYPE_RAMP_MOBILE, number>;
|
|
88
|
+
|
|
89
|
+
/** The rungs, in ladder order. `TextSize` in `text.tsx` is this set. */
|
|
90
|
+
export type TypeRungName = keyof typeof TYPE_RAMP_MOBILE;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The em tracking of a rung as px at a given size — what React Native needs,
|
|
94
|
+
* since `letterSpacing` there has no em unit and is a flat point value.
|
|
95
|
+
* Rounded to a thousandth so the number stays readable in a snapshot; the
|
|
96
|
+
* difference that discards is four orders of magnitude below a pixel.
|
|
97
|
+
*/
|
|
98
|
+
export function trackingPx(rung: TypeRungName, size: number): number {
|
|
99
|
+
return Math.round(size * TYPE_TRACKING[rung] * 1000) / 1000;
|
|
100
|
+
}
|
package/src/theme.tsx
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { type ReactNode } from "react";
|
|
2
|
-
import { LoticsThemeContext, type LoticsTheme } from "./theme_context";
|
|
3
|
-
|
|
4
|
-
export { DEFAULT_ACCENT, useLoticsTheme, THEME_VARS, type LoticsTheme } from "./theme_context";
|
|
5
|
-
|
|
6
|
-
interface LoticsThemeProviderProps extends LoticsTheme {
|
|
7
|
-
children: ReactNode;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* App-root provider — the NATIVE half.
|
|
12
|
-
*
|
|
13
|
-
* On native there are no CSS variables, and `colors` is a literal table, so
|
|
14
|
-
* there is nothing to declare: the provider publishes the values for anything
|
|
15
|
-
* that reads them through `useLoticsTheme()` and renders its children
|
|
16
|
-
* untouched. The web half (`theme.web.tsx`) is where a theme actually paints.
|
|
17
|
-
*
|
|
18
|
-
* Deliberately NOT a `View`: a wrapper here would insert a layout box into every
|
|
19
|
-
* themed app's tree on the one platform that gains nothing from it.
|
|
20
|
-
*/
|
|
21
|
-
export function LoticsThemeProvider(props: LoticsThemeProviderProps) {
|
|
22
|
-
const { children, ...theme } = props;
|
|
23
|
-
return <LoticsThemeContext.Provider value={theme}>{children}</LoticsThemeContext.Provider>;
|
|
24
|
-
}
|
package/src/theme.web.tsx
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import { useMemo, type CSSProperties, type ReactNode } from "react";
|
|
2
|
-
import { ACCENT_WASH_VAR, deriveAccentWash, FONT_MEDIUM_VAR, FONT_SEMIBOLD_VAR, LoticsThemeContext, THEME_VARS, type LoticsTheme } from "./theme_context";
|
|
3
|
-
|
|
4
|
-
export { DEFAULT_ACCENT, useLoticsTheme, THEME_VARS, type LoticsTheme } from "./theme_context";
|
|
5
|
-
|
|
6
|
-
interface LoticsThemeProviderProps extends LoticsTheme {
|
|
7
|
-
children: ReactNode;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* App-root provider that gives `@lotics/ui` primitives this app's identity:
|
|
12
|
-
*
|
|
13
|
-
* // src/main.tsx
|
|
14
|
-
* <LoticsThemeProvider accent="#0F766E" border="#E3E8E6">
|
|
15
|
-
* <App />
|
|
16
|
-
* </LoticsThemeProvider>
|
|
17
|
-
*
|
|
18
|
-
* It declares the CSS custom properties `colors.web.ts` reads, so every kit
|
|
19
|
-
* component below re-paints without knowing the provider exists. That
|
|
20
|
-
* indirection is the point: the alternative — a context each primitive
|
|
21
|
-
* subscribes to — would have to be threaded through 136 modules and would turn
|
|
22
|
-
* their static stylesheets into per-render inline styles.
|
|
23
|
-
*
|
|
24
|
-
* **It renders a plain `div`, not a `View`, and that is load-bearing.**
|
|
25
|
-
* react-native-web's style compiler only emits properties it knows; a custom
|
|
26
|
-
* property handed to a `View` is silently DROPPED, so the variables never reach
|
|
27
|
-
* the DOM and a themed app renders in the platform defaults with no error
|
|
28
|
-
* anywhere. React DOM, by contrast, writes `--*` inline style keys through
|
|
29
|
-
* verbatim. The failure is invisible rather than loud, which is exactly why the
|
|
30
|
-
* two platforms get two files instead of one clever component.
|
|
31
|
-
*
|
|
32
|
-
* Only a role actually PASSED is declared, so an omitted one leaves its variable
|
|
33
|
-
* undefined and `colors.web.ts`'s inline fallback answers. That is what makes
|
|
34
|
-
* theming additive: a partial theme overrides a part, never resetting the rest
|
|
35
|
-
* to some second set of defaults.
|
|
36
|
-
*/
|
|
37
|
-
export function LoticsThemeProvider(props: LoticsThemeProviderProps) {
|
|
38
|
-
const { children, ...theme } = props;
|
|
39
|
-
|
|
40
|
-
// Keyed on the four values, not the object: the call site is normally an
|
|
41
|
-
// inline literal, which would otherwise rebuild this style — and re-render
|
|
42
|
-
// every kit surface under it — on each parent render.
|
|
43
|
-
const style = useMemo(() => {
|
|
44
|
-
const vars: Record<string, string> = {};
|
|
45
|
-
for (const [role, name] of Object.entries(THEME_VARS)) {
|
|
46
|
-
const value = theme[role as keyof LoticsTheme];
|
|
47
|
-
if (value !== undefined) vars[name] = value;
|
|
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
|
-
}
|
|
64
|
-
// `display: contents` — the provider generates NO box. Custom properties
|
|
65
|
-
// inherit down the DOM tree rather than the box tree, so the variables still
|
|
66
|
-
// reach every descendant while the element itself adds no layout at all.
|
|
67
|
-
// The first version was a flex column with `flex: 1`, which silently imposed
|
|
68
|
-
// a layout contract on every app that wrapped its root in this: a provider
|
|
69
|
-
// whose entire job is declaring three strings has no business deciding how its
|
|
70
|
-
// children stack.
|
|
71
|
-
return { display: "contents", ...vars } as CSSProperties;
|
|
72
|
-
}, [theme.accent, theme.background, theme.border]);
|
|
73
|
-
|
|
74
|
-
return (
|
|
75
|
-
<div style={style}>
|
|
76
|
-
<LoticsThemeContext.Provider value={theme}>{children}</LoticsThemeContext.Provider>
|
|
77
|
-
</div>
|
|
78
|
-
);
|
|
79
|
-
}
|
package/src/theme_context.ts
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import { createContext, useContext } from "react";
|
|
2
|
-
import { colors } from "./color_tokens";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The platform's own accent — what every kit surface wears unthemed. Same value
|
|
6
|
-
* `colors.accent` falls back to; named so an app can reference the default
|
|
7
|
-
* explicitly instead of re-typing a literal.
|
|
8
|
-
*/
|
|
9
|
-
export const DEFAULT_ACCENT = colors.accent;
|
|
10
|
-
|
|
11
|
-
/** The roles an app may own. See `color_tokens.ts` for why exactly these. */
|
|
12
|
-
export interface LoticsTheme {
|
|
13
|
-
/** The one brand hue. It paints IDENTITY marks — today the avatar's initials
|
|
14
|
-
* disc — and deliberately NOT the focus ring or the primary action, which stay
|
|
15
|
-
* neutral so those read the same in every app. Never a status, a series or a
|
|
16
|
-
* valence: those come from a palette family and carry meaning. */
|
|
17
|
-
accent?: string;
|
|
18
|
-
/** The surface Card, Drawer and Modal paint — 21 sites in the kit. Left white
|
|
19
|
-
* on almost every app; set it only for a deliberately toned surface. */
|
|
20
|
-
background?: string;
|
|
21
|
-
/** Hairlines — 48 sites in the kit, and a register is mostly these lines, so a
|
|
22
|
-
* small change here is felt against a whole screen. */
|
|
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;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* The CSS custom properties `colors.web.ts` reads. Part of the published
|
|
60
|
-
* contract: an app may set them from its own stylesheet instead of the provider.
|
|
61
|
-
*/
|
|
62
|
-
export const THEME_VARS: Record<keyof LoticsTheme, string> = {
|
|
63
|
-
accent: "--lotics-accent",
|
|
64
|
-
background: "--lotics-background",
|
|
65
|
-
border: "--lotics-border",
|
|
66
|
-
primary: "--lotics-primary",
|
|
67
|
-
bodyFont: "--lotics-font-regular",
|
|
68
|
-
displayFont: "--lotics-display-font",
|
|
69
|
-
};
|
|
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
|
-
|
|
98
|
-
export const LoticsThemeContext = createContext<LoticsTheme>({});
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Read the current theme. An app SETS values through the provider's props; this
|
|
102
|
-
* is for a surface that must branch on one (a chart picking a fill). A role the
|
|
103
|
-
* app did not set reads `undefined` — ask `colors.<role>` for what will paint.
|
|
104
|
-
*/
|
|
105
|
-
export function useLoticsTheme(): LoticsTheme {
|
|
106
|
-
return useContext(LoticsThemeContext);
|
|
107
|
-
}
|