@lotics/ui 41.4.1 → 42.3.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/MIGRATION.md +34 -0
- package/docs/catalog.md +21 -0
- package/docs/composition.md +23 -2
- package/examples/tpl_dashboard.tsx +1 -1
- package/package.json +10 -3
- package/src/avatar.tsx +6 -1
- package/src/avatar.web.tsx +6 -1
- package/src/avatar_size.ts +10 -1
- package/src/color_tokens.ts +422 -0
- package/src/colors.ts +14 -383
- package/src/colors.web.ts +43 -0
- package/src/date_filter.tsx +54 -34
- package/src/date_filter_presets.ts +15 -10
- package/src/locale.tsx +2 -2
- package/src/summary.tsx +217 -0
- package/src/summary_line.tsx +23 -0
- package/src/theme.tsx +13 -50
- package/src/theme.web.tsx +64 -0
- package/src/theme_context.ts +45 -0
package/src/locale.tsx
CHANGED
|
@@ -308,7 +308,7 @@ export const en: LoticsLocale = {
|
|
|
308
308
|
year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM",
|
|
309
309
|
today: "Today", yesterday: "Yesterday", tomorrow: "Tomorrow",
|
|
310
310
|
thisWeek: "This week", thisMonth: "This month", lastMonth: "Last month",
|
|
311
|
-
|
|
311
|
+
from: "From", to: "To",
|
|
312
312
|
selectDateRange: "Select date range", selectDate: "Select date",
|
|
313
313
|
clear: "Clear", done: "Done", placeholder: "All time",
|
|
314
314
|
},
|
|
@@ -479,7 +479,7 @@ export const vi: LoticsLocale = {
|
|
|
479
479
|
year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH",
|
|
480
480
|
today: "Hôm nay", yesterday: "Hôm qua", tomorrow: "Ngày mai",
|
|
481
481
|
thisWeek: "Tuần này", thisMonth: "Tháng này", lastMonth: "Tháng trước",
|
|
482
|
-
|
|
482
|
+
from: "Từ", to: "Đến",
|
|
483
483
|
selectDateRange: "Chọn khoảng ngày", selectDate: "Chọn ngày",
|
|
484
484
|
clear: "Xóa", done: "Xong", placeholder: "Tất cả thời gian",
|
|
485
485
|
},
|
package/src/summary.tsx
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
|
+
import { Metric, type MetricFormat, type MetricTone } from "./metric";
|
|
4
|
+
import { StackedProgressBar } from "./stacked_progress_bar";
|
|
5
|
+
import { SummaryLine, type SummaryLineItem } from "./summary_line";
|
|
6
|
+
import { useLocaleTag } from "./locale";
|
|
7
|
+
import { Text } from "./text";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One bucket of the set being summarized — a slice of the whole, with the colour
|
|
11
|
+
* it wears wherever it appears.
|
|
12
|
+
*
|
|
13
|
+
* The buckets PARTITION the set: their values sum to the total, which is why the
|
|
14
|
+
* total is derived rather than passed. A count that belongs to the same rows but
|
|
15
|
+
* does not partition them — a condition rather than a position, "overdue" beside
|
|
16
|
+
* "draft / sent / paid" — is not a bucket and must not be one, or the bar starts
|
|
17
|
+
* claiming a share for something that is already inside another segment. Pass
|
|
18
|
+
* those to `Summary.Facts` as `extra`.
|
|
19
|
+
*/
|
|
20
|
+
export interface SummaryBucket {
|
|
21
|
+
key: string;
|
|
22
|
+
/** Reads AFTER the value, like every other label in this family. */
|
|
23
|
+
label: string;
|
|
24
|
+
value: number;
|
|
25
|
+
/** The segment's colour, and the swatch's. Use the same value this bucket
|
|
26
|
+
* wears in the rest of the product — a bar that disagrees with the badge
|
|
27
|
+
* beside it is worse than no bar. */
|
|
28
|
+
color: string;
|
|
29
|
+
/** What this bucket means — the ⓘ beside its count. */
|
|
30
|
+
info?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface SummaryContext {
|
|
34
|
+
buckets: readonly SummaryBucket[];
|
|
35
|
+
total: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const Ctx = createContext<SummaryContext | null>(null);
|
|
39
|
+
|
|
40
|
+
function useSummary(part: string): SummaryContext {
|
|
41
|
+
const ctx = useContext(Ctx);
|
|
42
|
+
if (!ctx) throw new Error(`<Summary.${part}> must be rendered inside <Summary>.`);
|
|
43
|
+
return ctx;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface SummaryProps {
|
|
47
|
+
buckets: readonly SummaryBucket[];
|
|
48
|
+
children: ReactNode;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The register's summary band — a headline figure, the set's DISTRIBUTION, and
|
|
53
|
+
* the buckets named — composed from parts rather than configured by props.
|
|
54
|
+
*
|
|
55
|
+
* **Why a compound and not one component with more props.** The parts encode a
|
|
56
|
+
* correspondence that is easy to break and impossible to see broken: the bar,
|
|
57
|
+
* the legend and the headline all describe ONE set. Passing a `segments` array
|
|
58
|
+
* beside an `items` array beside a `total` invites exactly three drifts —
|
|
59
|
+
* segments reordered against their labels, a legend swatch picking a different
|
|
60
|
+
* shade, and a hardcoded total that stops summing after a bucket is added. So
|
|
61
|
+
* the buckets are declared ONCE on the root and every part derives from them.
|
|
62
|
+
* The total is a sum. The swatches are the segments' own colours. Nothing is
|
|
63
|
+
* restated, so nothing can disagree.
|
|
64
|
+
*
|
|
65
|
+
* That correspondence is the reason this is a primitive at all — this kit does
|
|
66
|
+
* not extract a composition for saving lines of layout.
|
|
67
|
+
*
|
|
68
|
+
* Reach for it when the set has a SHAPE worth showing. A summary that is only a
|
|
69
|
+
* few aggregates stays `SummaryLine`, which is this band's `Facts` row on its
|
|
70
|
+
* own; a boxed dashboard stat band is `KPIStrip`; a drill-down facet browser
|
|
71
|
+
* over a large population is `Breakdown`.
|
|
72
|
+
*
|
|
73
|
+
* <Summary buckets={STAGES}>
|
|
74
|
+
* <Summary.Header>
|
|
75
|
+
* <Summary.Total label="khách hàng" />
|
|
76
|
+
* <Summary.Metric value={660_000_000} label="hoa hồng" format="currency" compact />
|
|
77
|
+
* </Summary.Header>
|
|
78
|
+
* <Summary.Distribution />
|
|
79
|
+
* <Summary.Facts extra={[{ label: "đang tắc", value: 3, tone: "warning" }]} />
|
|
80
|
+
* </Summary>
|
|
81
|
+
*/
|
|
82
|
+
export function Summary(props: SummaryProps) {
|
|
83
|
+
const { buckets, children } = props;
|
|
84
|
+
const total = buckets.reduce((sum, b) => sum + b.value, 0);
|
|
85
|
+
return (
|
|
86
|
+
<Ctx.Provider value={{ buckets, total }}>
|
|
87
|
+
<View style={styles.root}>{children}</View>
|
|
88
|
+
</Ctx.Provider>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The band's top line: a `Total` at the start, anything else pushed to the end.
|
|
93
|
+
* Baseline-aligned, because figures of different sizes sitting on one line is
|
|
94
|
+
* the whole reason this row exists. */
|
|
95
|
+
function Header(props: { children: ReactNode }) {
|
|
96
|
+
return <View style={styles.header}>{props.children}</View>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The set's SIZE, at display scale — the band's headline.
|
|
101
|
+
*
|
|
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,
|
|
104
|
+
* the constant does not move, and the headline quietly disagrees with the bar
|
|
105
|
+
* directly beneath it.
|
|
106
|
+
*/
|
|
107
|
+
function Total(props: { label: string; formatValue?: (n: number) => string }) {
|
|
108
|
+
const { total } = useSummary("Total");
|
|
109
|
+
const tag = useLocaleTag();
|
|
110
|
+
// Grouped by the reader's locale by default — a set of 12,345 was rendering
|
|
111
|
+
// "12345" in the one place on the screen sized to be read first, because the
|
|
112
|
+
// first app to use this counted in the hundreds. `formatValue` is the escape
|
|
113
|
+
// hatch for a total whose unit is not a plain count.
|
|
114
|
+
const shown = props.formatValue ? props.formatValue(total) : total.toLocaleString(tag);
|
|
115
|
+
return (
|
|
116
|
+
<View style={styles.pair}>
|
|
117
|
+
<Text size="xxxl" weight="semibold" tabular>
|
|
118
|
+
{shown}
|
|
119
|
+
</Text>
|
|
120
|
+
<Text size="md" color="muted">
|
|
121
|
+
{props.label}
|
|
122
|
+
</Text>
|
|
123
|
+
</View>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A second headline figure that is NOT part of the distribution — a sum of money
|
|
129
|
+
* over the same rows, an average, a rate.
|
|
130
|
+
*
|
|
131
|
+
* Deliberately not a bucket: it does not partition the set, so the bar must not
|
|
132
|
+
* carry it. It sits on the header row because a reader opening a register asks
|
|
133
|
+
* two questions ("how much of this is there" and "what is it worth"), and the
|
|
134
|
+
* answers belong side by side.
|
|
135
|
+
*/
|
|
136
|
+
function SummaryMetric(props: {
|
|
137
|
+
value: number | string | null | undefined;
|
|
138
|
+
label: string;
|
|
139
|
+
format?: MetricFormat;
|
|
140
|
+
currency?: string;
|
|
141
|
+
compact?: boolean;
|
|
142
|
+
emptyLabel?: string;
|
|
143
|
+
tone?: MetricTone;
|
|
144
|
+
}) {
|
|
145
|
+
const { value, label, ...rest } = props;
|
|
146
|
+
return (
|
|
147
|
+
<View style={styles.pair}>
|
|
148
|
+
<Metric value={value} size="lg" {...rest} />
|
|
149
|
+
<Text size="sm" color="muted">
|
|
150
|
+
{label}
|
|
151
|
+
</Text>
|
|
152
|
+
</View>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The set's shape — one segment per bucket, in declaration order.
|
|
158
|
+
*
|
|
159
|
+
* Order is the CALLER's, untouched, because for an ordered set (a pipeline, a
|
|
160
|
+
* ladder, an age bracket) the sequence carries meaning that sorting by size
|
|
161
|
+
* would destroy. Re-rank the array before passing it if size is what matters.
|
|
162
|
+
*/
|
|
163
|
+
function Distribution(props: { height?: number }) {
|
|
164
|
+
const { buckets, total } = useSummary("Distribution");
|
|
165
|
+
return (
|
|
166
|
+
<StackedProgressBar
|
|
167
|
+
height={props.height ?? 20}
|
|
168
|
+
total={total}
|
|
169
|
+
segments={buckets.map((b) => ({ key: b.key, value: b.value, color: b.color }))}
|
|
170
|
+
/>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The buckets named, with their counts — the band's legend, and on its own the
|
|
176
|
+
* whole of `SummaryLine`.
|
|
177
|
+
*
|
|
178
|
+
* Each item takes its swatch from its own bucket, so the legend cannot drift
|
|
179
|
+
* from the bar above it. `extra` appends aggregates that are NOT segments and
|
|
180
|
+
* therefore get no swatch — the visual difference is the point: a reader can see
|
|
181
|
+
* that the unswatched figure is not a slice of the bar.
|
|
182
|
+
*/
|
|
183
|
+
function Facts(props: { extra?: readonly SummaryLineItem[] }) {
|
|
184
|
+
const { buckets } = useSummary("Facts");
|
|
185
|
+
const items: SummaryLineItem[] = buckets.map((b) => ({
|
|
186
|
+
label: b.label,
|
|
187
|
+
value: b.value,
|
|
188
|
+
color: b.color,
|
|
189
|
+
info: b.info,
|
|
190
|
+
}));
|
|
191
|
+
return <SummaryLine items={[...items, ...(props.extra ?? [])]} />;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
Summary.Header = Header;
|
|
195
|
+
Summary.Total = Total;
|
|
196
|
+
Summary.Metric = SummaryMetric;
|
|
197
|
+
Summary.Distribution = Distribution;
|
|
198
|
+
Summary.Facts = Facts;
|
|
199
|
+
|
|
200
|
+
const styles = StyleSheet.create({
|
|
201
|
+
// 12, and it is half of what a page must put BETWEEN this band and its
|
|
202
|
+
// neighbours. The band's parts are one group — a figure, the shape of what it
|
|
203
|
+
// counts, and that shape's key — so they have to sit closer to each other than
|
|
204
|
+
// the band sits to the register below it. Measured at 14 against a 16px page
|
|
205
|
+
// gap, the ratio was 1.14: every block on the screen an equal peer, nothing
|
|
206
|
+
// grouped, which is what "no rhythm" is when you measure it.
|
|
207
|
+
root: { gap: 12 },
|
|
208
|
+
header: {
|
|
209
|
+
flexDirection: "row",
|
|
210
|
+
alignItems: "baseline",
|
|
211
|
+
flexWrap: "wrap",
|
|
212
|
+
justifyContent: "space-between",
|
|
213
|
+
columnGap: 20,
|
|
214
|
+
rowGap: 8,
|
|
215
|
+
},
|
|
216
|
+
pair: { flexDirection: "row", alignItems: "baseline", gap: 8 },
|
|
217
|
+
});
|
package/src/summary_line.tsx
CHANGED
|
@@ -20,6 +20,21 @@ export interface SummaryLineItem {
|
|
|
20
20
|
trend?: number | null;
|
|
21
21
|
/** What this metric means — the ⓘ next to the value opens it in a popover. */
|
|
22
22
|
info?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Swatch colour, when this line is ALSO the legend for a chart above it —
|
|
25
|
+
* typically a `StackedProgressBar` whose segments are these same aggregates.
|
|
26
|
+
*
|
|
27
|
+
* It exists so a bar can be labelled without giving up what this component
|
|
28
|
+
* already carries. `LegendItem` annotates a chart but holds no `info`, so
|
|
29
|
+
* reaching for it costs the popovers that explain what a bucket includes and
|
|
30
|
+
* what it must not be added to — and those explanations are the reason several
|
|
31
|
+
* of these lines are trustworthy. One swatch here keeps identity off colour
|
|
32
|
+
* alone, which is what a legend is for.
|
|
33
|
+
*
|
|
34
|
+
* Pass the SAME value the segment uses. Omit it and the item renders exactly
|
|
35
|
+
* as it always has.
|
|
36
|
+
*/
|
|
37
|
+
color?: string;
|
|
23
38
|
}
|
|
24
39
|
|
|
25
40
|
export interface SummaryLineProps {
|
|
@@ -55,6 +70,7 @@ export function SummaryLine(props: SummaryLineProps) {
|
|
|
55
70
|
<View style={styles.row}>
|
|
56
71
|
{items.map((item) => (
|
|
57
72
|
<View key={item.label} style={styles.item}>
|
|
73
|
+
{item.color ? <View style={[styles.swatch, { backgroundColor: item.color }]} /> : null}
|
|
58
74
|
<Metric
|
|
59
75
|
value={item.value}
|
|
60
76
|
format={item.format}
|
|
@@ -89,4 +105,11 @@ const styles = StyleSheet.create({
|
|
|
89
105
|
alignItems: "center",
|
|
90
106
|
gap: 6,
|
|
91
107
|
},
|
|
108
|
+
// Matches `LegendItem`'s swatch, because the two annotate the same charts and
|
|
109
|
+
// a reader should not be able to tell which component drew the legend.
|
|
110
|
+
swatch: {
|
|
111
|
+
width: 8,
|
|
112
|
+
height: 8,
|
|
113
|
+
borderRadius: 2,
|
|
114
|
+
},
|
|
92
115
|
});
|
package/src/theme.tsx
CHANGED
|
@@ -1,61 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { LoticsThemeContext, type LoticsTheme } from "./theme_context";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
* Default platform accent — refined OKLCH blue. Used by chart fills,
|
|
5
|
-
* focus rings, and any primitive that asks "what's the brand color".
|
|
6
|
-
* Apps that need a different accent wrap their root in `LoticsThemeProvider`.
|
|
7
|
-
*
|
|
8
|
-
* Why OKLCH instead of hex? Perceptual uniformity — `oklch(0.6 0.118 250)`
|
|
9
|
-
* sits at the same perceptual lightness/saturation as the `oklch(0.6 0.118
|
|
10
|
-
* 184.704)` (teal) chị's workspace uses, just shifted in hue. Hex shifts
|
|
11
|
-
* lightness as hue rotates and the eye picks it up as inconsistency.
|
|
12
|
-
*/
|
|
13
|
-
export const DEFAULT_ACCENT = "oklch(0.6 0.118 250)";
|
|
14
|
-
|
|
15
|
-
interface LoticsTheme {
|
|
16
|
-
/** Single brand accent. Chart fills, hero CTAs, focus rings. */
|
|
17
|
-
accent: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const LoticsThemeContext = createContext<LoticsTheme>({ accent: DEFAULT_ACCENT });
|
|
4
|
+
export { DEFAULT_ACCENT, useLoticsTheme, THEME_VARS, type LoticsTheme } from "./theme_context";
|
|
21
5
|
|
|
22
|
-
interface LoticsThemeProviderProps {
|
|
23
|
-
/** Brand accent. Overrides the platform default. Accepts any CSS color
|
|
24
|
-
* value (OKLCH recommended, hex / hsl also fine). */
|
|
25
|
-
accent?: string;
|
|
6
|
+
interface LoticsThemeProviderProps extends LoticsTheme {
|
|
26
7
|
children: ReactNode;
|
|
27
8
|
}
|
|
28
9
|
|
|
29
10
|
/**
|
|
30
|
-
* App-root provider
|
|
31
|
-
* Wrap your top-level app element to override the platform defaults:
|
|
11
|
+
* App-root provider — the NATIVE half.
|
|
32
12
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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.
|
|
37
17
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* customization without needing a different provider.
|
|
41
|
-
*
|
|
42
|
-
* Scope is intentionally narrow — accent only. Semantic colors (success,
|
|
43
|
-
* danger) already work via existing `colors.green[600]` etc. Adding more
|
|
44
|
-
* theme tokens requires a real product reason.
|
|
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.
|
|
45
20
|
*/
|
|
46
21
|
export function LoticsThemeProvider(props: LoticsThemeProviderProps) {
|
|
47
|
-
const
|
|
48
|
-
return
|
|
49
|
-
<LoticsThemeContext.Provider value={{ accent }}>
|
|
50
|
-
{props.children}
|
|
51
|
-
</LoticsThemeContext.Provider>
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Read the current theme. Primitives that need the accent color call this
|
|
57
|
-
* hook; apps don't need it directly (use the provider's prop instead).
|
|
58
|
-
*/
|
|
59
|
-
export function useLoticsTheme(): LoticsTheme {
|
|
60
|
-
return useContext(LoticsThemeContext);
|
|
22
|
+
const { children, ...theme } = props;
|
|
23
|
+
return <LoticsThemeContext.Provider value={theme}>{children}</LoticsThemeContext.Provider>;
|
|
61
24
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useMemo, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { 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
|
+
// `display: contents` — the provider generates NO box. Custom properties
|
|
50
|
+
// inherit down the DOM tree rather than the box tree, so the variables still
|
|
51
|
+
// reach every descendant while the element itself adds no layout at all.
|
|
52
|
+
// The first version was a flex column with `flex: 1`, which silently imposed
|
|
53
|
+
// a layout contract on every app that wrapped its root in this: a provider
|
|
54
|
+
// whose entire job is declaring three strings has no business deciding how its
|
|
55
|
+
// children stack.
|
|
56
|
+
return { display: "contents", ...vars } as CSSProperties;
|
|
57
|
+
}, [theme.accent, theme.background, theme.border]);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div style={style}>
|
|
61
|
+
<LoticsThemeContext.Provider value={theme}>{children}</LoticsThemeContext.Provider>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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 across a whole screen. */
|
|
23
|
+
border?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The CSS custom properties `colors.web.ts` reads. Part of the published
|
|
28
|
+
* contract: an app may set them from its own stylesheet instead of the provider.
|
|
29
|
+
*/
|
|
30
|
+
export const THEME_VARS: Record<keyof LoticsTheme, string> = {
|
|
31
|
+
accent: "--lotics-accent",
|
|
32
|
+
background: "--lotics-background",
|
|
33
|
+
border: "--lotics-border",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const LoticsThemeContext = createContext<LoticsTheme>({});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read the current theme. An app SETS values through the provider's props; this
|
|
40
|
+
* is for a surface that must branch on one (a chart picking a fill). A role the
|
|
41
|
+
* app did not set reads `undefined` — ask `colors.<role>` for what will paint.
|
|
42
|
+
*/
|
|
43
|
+
export function useLoticsTheme(): LoticsTheme {
|
|
44
|
+
return useContext(LoticsThemeContext);
|
|
45
|
+
}
|