@lotics/ui 1.6.0 → 1.8.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.
@@ -0,0 +1,77 @@
1
+ import { View, StyleSheet, type ViewStyle, type StyleProp } from "react-native";
2
+ import { Text } from "./text";
3
+ import { Metric, type MetricFormat, type MetricSize, type MetricTone } from "./metric";
4
+ import { TrendChip } from "./trend_chip";
5
+ import { SPACE } from "./spacing";
6
+
7
+ interface KPICardProps {
8
+ /** Short uppercase label above the value. Identifies the metric. */
9
+ label: string;
10
+ /** The number to display. `null` renders as `emptyLabel`. */
11
+ value: number | string | null | undefined;
12
+ format?: MetricFormat;
13
+ currency?: string;
14
+ locale?: string;
15
+ emptyLabel?: string;
16
+ /** Numeric size hint. Default `lg`; pass `hero` for the dominant
17
+ * metric in a card; `md` for supporting metrics in a horizontal strip. */
18
+ size?: MetricSize;
19
+ tone?: MetricTone;
20
+ /** Optional ±% trend chip next to the value. Pass `null` (not omitted)
21
+ * to explicitly hide — useful when the comparator base is 0 and the
22
+ * percentage would be meaningless. */
23
+ trend?: number | null;
24
+ /**
25
+ * Goes below the value. Free text. Typical uses: comparator detail
26
+ * ("Tháng trước: 50.000.000 đ"), unit hint ("Hồ sơ"), or short caveat
27
+ * ("Tính theo ngày tạo").
28
+ */
29
+ caption?: string;
30
+ style?: StyleProp<ViewStyle>;
31
+ }
32
+
33
+ /**
34
+ * One labeled metric block — the building block of a KPI strip or hero
35
+ * row. Forces the recipe so authors can't drift:
36
+ * - Uppercase muted label (eyeline anchor)
37
+ * - Metric value with tabular nums + tighter tracking at display sizes
38
+ * - Optional inline trend chip (the "is this good?" indicator)
39
+ * - Optional caption below for comparator detail / unit / caveat
40
+ *
41
+ * The recipe matches Mercury's checking dashboard, Stripe's revenue cards,
42
+ * Linear's project metrics. Without enforcing it, dashboards drift into
43
+ * "every metric looks slightly different" — the #1 readability tell on
44
+ * mediocre dashboards.
45
+ */
46
+ export function KPICard(props: KPICardProps) {
47
+ const { label, value, format, currency, locale, emptyLabel, size = "lg", tone, trend, caption, style } = props;
48
+ return (
49
+ <View style={[styles.container, style]}>
50
+ <Text size="xs" color="muted" transform="uppercase">
51
+ {label}
52
+ </Text>
53
+ <View style={styles.valueRow}>
54
+ <Metric
55
+ value={value}
56
+ format={format}
57
+ currency={currency}
58
+ locale={locale}
59
+ emptyLabel={emptyLabel}
60
+ size={size}
61
+ tone={tone}
62
+ />
63
+ {trend != null && <TrendChip value={trend} />}
64
+ </View>
65
+ {caption && (
66
+ <Text size="xs" color="muted">
67
+ {caption}
68
+ </Text>
69
+ )}
70
+ </View>
71
+ );
72
+ }
73
+
74
+ const styles = StyleSheet.create({
75
+ container: { gap: SPACE.xs },
76
+ valueRow: { flexDirection: "row", alignItems: "baseline", gap: SPACE.sm, flexWrap: "wrap" },
77
+ });
@@ -0,0 +1,47 @@
1
+ import { View, StyleSheet } from "react-native";
2
+ import { Text } from "./text";
3
+ import { Metric } from "./metric";
4
+ import { SPACE } from "./spacing";
5
+
6
+ interface LegendItemProps {
7
+ /** Square swatch color — should match the segment in the chart this
8
+ * legend annotates. */
9
+ color: string;
10
+ label: string;
11
+ /** Optional count to render after the label. `null` for loading state. */
12
+ value?: number | string | null;
13
+ }
14
+
15
+ /**
16
+ * Color swatch + label + optional count. Used as the legend row below a
17
+ * `StackedProgressBar`, a `ChartBar` with categorical colors, or any
18
+ * other chart where consumers need to map color → meaning.
19
+ *
20
+ * Tabular nums on the value (via Metric) keep counts aligned when several
21
+ * legend items sit in a row.
22
+ */
23
+ export function LegendItem(props: LegendItemProps) {
24
+ return (
25
+ <View style={styles.row}>
26
+ <View style={[styles.swatch, { backgroundColor: props.color }]} />
27
+ <Text size="sm" color="muted">
28
+ {props.label}
29
+ </Text>
30
+ {props.value !== undefined && <Metric value={props.value} size="sm" />}
31
+ </View>
32
+ );
33
+ }
34
+
35
+ const styles = StyleSheet.create({
36
+ row: {
37
+ flexDirection: "row",
38
+ alignItems: "center",
39
+ gap: SPACE.sm,
40
+ minWidth: 130,
41
+ },
42
+ swatch: {
43
+ width: 8,
44
+ height: 8,
45
+ borderRadius: 2,
46
+ },
47
+ });
package/src/metric.tsx CHANGED
@@ -1,10 +1,22 @@
1
- import { View, StyleSheet } from "react-native";
1
+ import { View, StyleSheet, type TextStyle } from "react-native";
2
2
  import { Text } from "./text";
3
3
  import { colors } from "./colors";
4
4
  import { useMemo } from "react";
5
5
 
6
6
  export type MetricFormat = "currency" | "number" | "percentage" | "none";
7
7
 
8
+ /** Semantic colour of the metric value. `default` inherits the text colour. */
9
+ export type MetricTone = "default" | "warning" | "danger";
10
+
11
+ /**
12
+ * Visual scale. `md` is the historical default — matches body display size.
13
+ * `hero` is for the single most important number in a section ("Doanh thu
14
+ * trong tháng: 10.000.000 đ" at the top of a card with smaller supporting
15
+ * metrics below). Mercury / Stripe / Linear all use this hero-supporting
16
+ * hierarchy to give dashboards a reading order.
17
+ */
18
+ export type MetricSize = "sm" | "md" | "lg" | "hero";
19
+
8
20
  export interface MetricProps {
9
21
  value: number | string | null | undefined;
10
22
  previousValue?: number | string | null | undefined;
@@ -12,12 +24,36 @@ export interface MetricProps {
12
24
  currency?: string;
13
25
  locale?: string;
14
26
  emptyLabel?: string;
27
+ tone?: MetricTone;
28
+ size?: MetricSize;
15
29
  }
16
30
 
31
+ const SIZE_TO_TEXT_SIZE: Record<MetricSize, "md" | "lg" | "xl" | "xxl"> = {
32
+ sm: "md",
33
+ md: "lg",
34
+ lg: "xl",
35
+ hero: "xxl",
36
+ };
37
+
38
+ // Letter-spacing tightens as font-size grows — "designed" not "inflated".
39
+ // Hero gets the tightest tracking (-1.2) to match the 48px display size's
40
+ // expected condensation.
41
+ const SIZE_TO_LETTER_SPACING: Record<MetricSize, number> = {
42
+ sm: 0,
43
+ md: -0.25,
44
+ lg: -0.5,
45
+ hero: -1.2,
46
+ };
47
+
17
48
  const TREND_UP = "↑";
18
49
  const TREND_DOWN = "↓";
19
50
  const TREND_FLAT = "→";
20
51
 
52
+ const TONE_COLOR: Record<Exclude<MetricTone, "default">, string> = {
53
+ warning: colors.amber[600],
54
+ danger: colors.red[600],
55
+ };
56
+
21
57
  export function Metric(props: MetricProps) {
22
58
  const {
23
59
  value,
@@ -26,6 +62,8 @@ export function Metric(props: MetricProps) {
26
62
  currency = "USD",
27
63
  locale,
28
64
  emptyLabel = "-",
65
+ tone = "default",
66
+ size = "md",
29
67
  } = props;
30
68
 
31
69
  const displayValue = useMemo(() => {
@@ -55,7 +93,22 @@ export function Metric(props: MetricProps) {
55
93
 
56
94
  return (
57
95
  <View style={styles.container}>
58
- <Text size="xl" weight="semibold">
96
+ <Text
97
+ size={SIZE_TO_TEXT_SIZE[size]}
98
+ weight="medium"
99
+ style={[
100
+ // Tabular nums prevent the comma/decimal jitter when 1,234 sits
101
+ // next to 7,890 across cards — proportional glyphs misalign every
102
+ // column. Tighter tracking at display sizes reads as "designed",
103
+ // not "inflated body text" (Linear / Stripe / Geist all do this
104
+ // at hero metrics).
105
+ {
106
+ fontVariantNumeric: "tabular-nums",
107
+ letterSpacing: SIZE_TO_LETTER_SPACING[size],
108
+ } as TextStyle,
109
+ tone === "default" ? undefined : { color: TONE_COLOR[tone] },
110
+ ]}
111
+ >
59
112
  {displayValue}
60
113
  </Text>
61
114
  {trend && (
@@ -0,0 +1,44 @@
1
+ import { useEffect } from "react";
2
+ import { Platform } from "react-native";
3
+
4
+ /**
5
+ * Tracks how many modal/overlay surfaces (dialogs, popovers) are currently open.
6
+ * A counter — not a boolean — so nested overlays compose correctly.
7
+ */
8
+ let activeCount = 0;
9
+
10
+ /**
11
+ * True while at least one modal dialog or popover is open. Read synchronously
12
+ * by the keyboard shortcut registry to suppress lower-layer page shortcuts so
13
+ * an open overlay does not hijack keystrokes (e.g. native Ctrl+F find).
14
+ */
15
+ export function isOverlayScopeActive(): boolean {
16
+ return activeCount > 0;
17
+ }
18
+
19
+ /**
20
+ * Imperatively marks an overlay surface as open. Returns a release function
21
+ * that must be called exactly once when the overlay closes. Prefer the
22
+ * `useOverlayScope` hook in React components — this is the testable core.
23
+ */
24
+ export function pushOverlayScope(): () => void {
25
+ activeCount++;
26
+ let released = false;
27
+ return () => {
28
+ if (released) return;
29
+ released = true;
30
+ activeCount--;
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Marks an overlay surface as open while `active` is true. Web-only — no-op on
36
+ * native. Call this from overlay primitives (Dialog, Popover, etc.), not from
37
+ * individual call sites.
38
+ */
39
+ export function useOverlayScope(active: boolean): void {
40
+ useEffect(() => {
41
+ if (Platform.OS !== "web" || !active) return;
42
+ return pushOverlayScope();
43
+ }, [active]);
44
+ }
package/src/popover.tsx CHANGED
@@ -11,6 +11,7 @@ import { colors } from "./colors";
11
11
  import { IconButton } from "./icon_button";
12
12
  import { Portal } from "./portal";
13
13
  import { Divider } from "./divider";
14
+ import { useOverlayScope } from "./overlay_scope";
14
15
  import { PopoverNavContext, type PopoverNavContextValue } from "./popover_nav";
15
16
 
16
17
  export type PopoverSide = "top" | "right" | "bottom" | "left";
@@ -76,6 +77,8 @@ export function Popover(props: PopoverProps) {
76
77
  const isControlled = controlledOpen !== undefined;
77
78
  const open = isControlled ? controlledOpen : uncontrolledOpen;
78
79
 
80
+ useOverlayScope(open);
81
+
79
82
  const onOpenChange = useCallback(
80
83
  (newOpen: boolean) => {
81
84
  if (!isControlled) {
@@ -0,0 +1,68 @@
1
+ import { type ReactNode } from "react";
2
+ import { View, StyleSheet } from "react-native";
3
+ import { Card } from "./card";
4
+ import { Text } from "./text";
5
+ import { Divider } from "./divider";
6
+ import { SPACE } from "./spacing";
7
+
8
+ interface SectionCardProps {
9
+ title: string;
10
+ /**
11
+ * One-line context under the title. Mercury, Stripe, Linear all show this
12
+ * — a bare title says "what this is" but the description says "why this
13
+ * is here and what it answers". Skip only when the title is unambiguous
14
+ * on its own.
15
+ */
16
+ description?: string;
17
+ /** Body content. Author owns the internal layout. */
18
+ children: ReactNode;
19
+ /**
20
+ * Optional footer separated by a hairline. The right place for trend
21
+ * captions ("Tăng 12% so với tháng trước"), source notes, or
22
+ * cross-references. Skip if the body already self-explains.
23
+ */
24
+ footer?: ReactNode;
25
+ }
26
+
27
+ /**
28
+ * Section-shaped card with semantic title + description + body + optional
29
+ * footer slots. The shape every well-designed dashboard section takes:
30
+ * Linear's checklist sections, Stripe's metric cards, Mercury's account
31
+ * panels. Mirrors the shadcn `Card` + `CardHeader` + `CardContent` +
32
+ * `CardFooter` composition pattern.
33
+ *
34
+ * Uses Card under the hood — picks up the border + soft shadow defaults.
35
+ * Adds generous 32px internal padding so the description + body + footer
36
+ * breathe; bare Card's 20px feels cramped once you have structured content.
37
+ */
38
+ export function SectionCard(props: SectionCardProps) {
39
+ return (
40
+ <Card style={styles.card}>
41
+ <View style={styles.container}>
42
+ <View style={styles.header}>
43
+ <Text size="lg" weight="semibold">
44
+ {props.title}
45
+ </Text>
46
+ {props.description && (
47
+ <Text size="sm" color="muted">
48
+ {props.description}
49
+ </Text>
50
+ )}
51
+ </View>
52
+ {props.children}
53
+ {props.footer && (
54
+ <>
55
+ <Divider />
56
+ {props.footer}
57
+ </>
58
+ )}
59
+ </View>
60
+ </Card>
61
+ );
62
+ }
63
+
64
+ const styles = StyleSheet.create({
65
+ card: { padding: SPACE.xl }, // 32px — Stripe / Mercury internal padding
66
+ container: { gap: SPACE.lg },
67
+ header: { gap: SPACE.xs },
68
+ });
package/src/spacing.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 8-grid spacing tokens. Every gap, padding, and margin in a dashboard
3
+ * should come from this scale — multiples of 8 read as "designed",
4
+ * multiples of 4 read as "developer-tuned-on-the-fly", arbitrary numbers
5
+ * read as broken.
6
+ *
7
+ * Aliases (xs/sm/md/lg/xl/xxl) match the Text size scale so authors can
8
+ * reason about spacing and typography on the same vocabulary.
9
+ *
10
+ * Why not extend further (3xl, 4xl)? Lotics surfaces fit comfortably in
11
+ * the 4-48px range. A "between-section gap" of 64px+ usually means the
12
+ * sections should be on separate routes, not the same page.
13
+ */
14
+ export const SPACE = {
15
+ xs: 4,
16
+ sm: 8,
17
+ md: 16,
18
+ lg: 24,
19
+ xl: 32,
20
+ xxl: 48,
21
+ } as const;
22
+
23
+ export type SpaceToken = keyof typeof SPACE;
@@ -0,0 +1,85 @@
1
+ import { useMemo } from "react";
2
+ import { View } from "react-native";
3
+ import { colors } from "./colors";
4
+
5
+ export interface SparklineProps {
6
+ /**
7
+ * Series of values in chronological order. Two or more required to draw
8
+ * a line; one value or empty renders an empty box of the requested
9
+ * height (preserves layout while data loads).
10
+ */
11
+ data: number[];
12
+ /** Pixel height of the chart. Default 32. */
13
+ height?: number;
14
+ /** Pixel width. Default 100. */
15
+ width?: number;
16
+ /** Stroke color. Default `colors.zinc[700]`. */
17
+ color?: string;
18
+ /** Whether to fill the area under the line. Default false. */
19
+ filled?: boolean;
20
+ }
21
+
22
+ /**
23
+ * Compact line trend, ~40px tall. Sits next to a metric to answer "is
24
+ * this good?" — static numbers answer "what" but not "trending up or
25
+ * down". Used in dashboards (Stripe, Mercury, Linear all ship this
26
+ * pattern).
27
+ *
28
+ * Implementation: native HTML `<svg>` rather than `react-native-svg`.
29
+ * Custom-code apps are web-only and Vite resolves react-native-svg's
30
+ * Fabric native paths incorrectly (Metro handles the platform variants
31
+ * but Vite doesn't). Wrapping in RN's `<View>` preserves the layout
32
+ * surface; SVG inside renders cleanly on the DOM.
33
+ */
34
+ export function Sparkline(props: SparklineProps) {
35
+ const { data, height = 32, width = 100, color = colors.zinc[700], filled = false } = props;
36
+
37
+ const { path, fillPath } = useMemo(() => {
38
+ if (data.length < 2) return { path: null, fillPath: null };
39
+ const min = Math.min(...data);
40
+ const max = Math.max(...data);
41
+ // Flat data → horizontal line at vertical center. Avoid /0 division.
42
+ const range = max - min || 1;
43
+ // Stroke width budget — inset by 1.5px so the line doesn't clip the
44
+ // SVG viewport on either side.
45
+ const inset = 1.5;
46
+ const w = width - inset * 2;
47
+ const h = height - inset * 2;
48
+ const stepX = w / (data.length - 1);
49
+ let p = "";
50
+ data.forEach((v, i) => {
51
+ const x = inset + i * stepX;
52
+ // Y axis inverted in SVG (higher value = smaller y). Normalize to
53
+ // 0-1 then map into the inset area.
54
+ const y = inset + h - ((v - min) / range) * h;
55
+ p += `${i === 0 ? "M" : "L"} ${x.toFixed(2)} ${y.toFixed(2)} `;
56
+ });
57
+ const trimmed = p.trim();
58
+ return {
59
+ path: trimmed,
60
+ fillPath: filled
61
+ ? `${trimmed} L ${width - inset} ${height - inset} L ${inset} ${height - inset} Z`
62
+ : null,
63
+ };
64
+ }, [data, width, height, filled]);
65
+
66
+ if (!path) {
67
+ return <View style={{ width, height }} />;
68
+ }
69
+
70
+ return (
71
+ <View style={{ width, height }}>
72
+ <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
73
+ {fillPath && <path d={fillPath} fill={color} fillOpacity={0.1} />}
74
+ <path
75
+ d={path}
76
+ stroke={color}
77
+ strokeWidth={1.5}
78
+ fill="none"
79
+ strokeLinejoin="round"
80
+ strokeLinecap="round"
81
+ />
82
+ </svg>
83
+ </View>
84
+ );
85
+ }
@@ -0,0 +1,65 @@
1
+ import { View, StyleSheet } from "react-native";
2
+ import { colors } from "./colors";
3
+
4
+ interface Segment {
5
+ /** Identifier for the segment — used as a React key. */
6
+ key: string;
7
+ /** Numeric weight. Each segment's width is `value / total`. */
8
+ value: number;
9
+ /** CSS color. */
10
+ color: string;
11
+ }
12
+
13
+ interface StackedProgressBarProps {
14
+ segments: Segment[];
15
+ /** Total value across all segments. Pass explicitly so loading + empty
16
+ * states render consistently (an empty array would otherwise look like
17
+ * "data loaded with no values"). */
18
+ total: number;
19
+ /** Bar pixel height. Default 14 — the dashboard hero-progress size.
20
+ * Drop to 6-8 for inline status bars in tight rows; bump to 20-24
21
+ * when the bar IS the section's main visualization. */
22
+ height?: number;
23
+ loading?: boolean;
24
+ }
25
+
26
+ /**
27
+ * Horizontal segmented bar for funnels / status breakdowns / category
28
+ * distributions. The Mercury / Linear stage-breakdown pattern — handles
29
+ * sparse data gracefully because zero-value segments collapse to zero
30
+ * width, and a single dominant value renders as one long segment rather
31
+ * than a "broken" bar chart with five empty stages.
32
+ *
33
+ * Pair with `<LegendItem />` rows below to name the colored segments.
34
+ * Without a legend, the colored bar is decorative — viewers can't map
35
+ * colors back to meaning.
36
+ */
37
+ export function StackedProgressBar(props: StackedProgressBarProps) {
38
+ const { segments, total, height = 14, loading } = props;
39
+ if (loading || total === 0) {
40
+ return <View style={[styles.bar, { height, backgroundColor: colors.zinc[100] }]} />;
41
+ }
42
+ return (
43
+ <View style={[styles.bar, styles.barFilled, { height }]}>
44
+ {segments
45
+ .filter((s) => s.value > 0)
46
+ .map((seg) => (
47
+ <View
48
+ key={seg.key}
49
+ style={{ flex: seg.value, backgroundColor: seg.color }}
50
+ />
51
+ ))}
52
+ </View>
53
+ );
54
+ }
55
+
56
+ const styles = StyleSheet.create({
57
+ bar: {
58
+ borderRadius: 999,
59
+ overflow: "hidden",
60
+ },
61
+ barFilled: {
62
+ backgroundColor: colors.zinc[100],
63
+ flexDirection: "row",
64
+ },
65
+ });
package/src/text.css CHANGED
@@ -1,10 +1,26 @@
1
+ /*
2
+ * Letter-spacing curve: positive at small sizes (improves legibility for
3
+ * Vietnamese diacritics + Inter's `1`/`l`/`i` at 12px), neutral at body,
4
+ * negative at display. The single biggest "designed" tell on web type —
5
+ * Radix, Linear, Geist all use this curve.
6
+ *
7
+ * Inter stylistic alternates: `cv11` (single-storey `a`), `ss01` (alternate
8
+ * `1`), `ss03` (alternate `g`) — disambiguates similar glyphs without
9
+ * shifting metrics. No layout impact, premium-character signal.
10
+ */
11
+ * {
12
+ font-feature-settings: "cv11", "ss01", "ss03";
13
+ }
14
+
1
15
  [data-text-size="xs"] {
2
16
  font-size: 12px;
3
17
  line-height: 16px;
18
+ letter-spacing: 0.01em;
4
19
  }
5
20
  [data-text-size="sm"] {
6
21
  font-size: 14px;
7
22
  line-height: 20px;
23
+ letter-spacing: 0.0025em;
8
24
  }
9
25
  [data-text-size="md"] {
10
26
  font-size: 16px;
@@ -13,14 +29,17 @@
13
29
  [data-text-size="lg"] {
14
30
  font-size: 18px;
15
31
  line-height: 24px;
32
+ letter-spacing: -0.005em;
16
33
  }
17
34
  [data-text-size="xl"] {
18
- font-size: 28;
35
+ font-size: 28px;
19
36
  line-height: 34px;
37
+ letter-spacing: -0.015em;
20
38
  }
21
39
  [data-text-size="xxl"] {
22
40
  font-size: 32px;
23
41
  line-height: 38px;
42
+ letter-spacing: -0.02em;
24
43
  }
25
44
 
26
45
  /* Refer to `use_screen_size` for breakpoints */
package/src/theme.tsx ADDED
@@ -0,0 +1,61 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+
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 });
21
+
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;
26
+ children: ReactNode;
27
+ }
28
+
29
+ /**
30
+ * App-root provider that supplies brand tokens to @lotics/ui primitives.
31
+ * Wrap your top-level app element to override the platform defaults:
32
+ *
33
+ * // src/main.tsx
34
+ * <LoticsThemeProvider accent="oklch(0.6 0.118 184.704)">
35
+ * <App />
36
+ * </LoticsThemeProvider>
37
+ *
38
+ * Components that consume theme tokens use `useLoticsTheme()`. Each
39
+ * primitive also accepts a per-instance `color` prop for one-off
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.
45
+ */
46
+ export function LoticsThemeProvider(props: LoticsThemeProviderProps) {
47
+ const accent = props.accent ?? DEFAULT_ACCENT;
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);
61
+ }