@lotics/ui 2.4.1 → 2.6.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.
Files changed (69) hide show
  1. package/package.json +28 -8
  2. package/src/accordion.tsx +146 -63
  3. package/src/action_menu.tsx +72 -0
  4. package/src/allocation_row.tsx +54 -0
  5. package/src/badge.tsx +40 -9
  6. package/src/breakdown.tsx +121 -0
  7. package/src/card.tsx +150 -0
  8. package/src/cell_select.tsx +3 -2
  9. package/src/chip_group.tsx +65 -0
  10. package/src/colors.ts +61 -0
  11. package/src/column_filter.tsx +9 -24
  12. package/src/completion_state.tsx +43 -0
  13. package/src/control_surface.ts +32 -0
  14. package/src/counter.tsx +58 -0
  15. package/src/date_range_filter_field.tsx +44 -12
  16. package/src/detail_row.tsx +45 -0
  17. package/src/dialog.tsx +0 -24
  18. package/src/download.ts +2 -1
  19. package/src/drawer.tsx +94 -2
  20. package/src/empty_state.tsx +37 -0
  21. package/src/file_badge.tsx +27 -4
  22. package/src/file_dropzone.tsx +188 -0
  23. package/src/file_picker.ts +45 -0
  24. package/src/filter_pill.tsx +106 -0
  25. package/src/floating_action_bar.tsx +57 -0
  26. package/src/fonts.css +10 -13
  27. package/src/format_money.ts +38 -0
  28. package/src/heatmap.tsx +153 -0
  29. package/src/icon.tsx +2 -0
  30. package/src/icon_button.tsx +16 -2
  31. package/src/index.css +4 -3
  32. package/src/info_popover.tsx +4 -6
  33. package/src/kpi_card.tsx +19 -6
  34. package/src/kpi_strip.tsx +89 -0
  35. package/src/line_chart.tsx +61 -34
  36. package/src/link_button.tsx +50 -0
  37. package/src/metric.tsx +21 -12
  38. package/src/pagination.tsx +5 -9
  39. package/src/peek.tsx +68 -0
  40. package/src/picker.tsx +13 -1
  41. package/src/picker_menu.tsx +8 -16
  42. package/src/pie_chart.tsx +29 -8
  43. package/src/pill_button.tsx +10 -8
  44. package/src/popover.tsx +14 -4
  45. package/src/pressable_highlight.tsx +10 -1
  46. package/src/pressable_row.tsx +91 -0
  47. package/src/progress_bar.tsx +47 -17
  48. package/src/radio_picker.tsx +20 -9
  49. package/src/range_slider.tsx +185 -0
  50. package/src/remainder_meter.tsx +48 -0
  51. package/src/ring_gauge.tsx +5 -5
  52. package/src/scan_field.tsx +58 -0
  53. package/src/search_input.tsx +12 -0
  54. package/src/skeleton.tsx +47 -0
  55. package/src/sort_header.tsx +102 -0
  56. package/src/stacked_progress_bar.tsx +51 -16
  57. package/src/status_grid.tsx +187 -0
  58. package/src/step_list.tsx +128 -0
  59. package/src/step_progress.tsx +145 -0
  60. package/src/stepper.tsx +9 -4
  61. package/src/table.tsx +168 -112
  62. package/src/text.tsx +15 -0
  63. package/src/text_utils.ts +10 -0
  64. package/src/timeline.tsx +90 -57
  65. package/src/trend_footer.tsx +2 -2
  66. package/src/alert_row.tsx +0 -81
  67. package/src/table.web.tsx +0 -235
  68. package/src/table_picker.tsx +0 -305
  69. package/src/table_types.ts +0 -47
package/src/fonts.css CHANGED
@@ -1,23 +1,20 @@
1
1
  /*
2
2
  * Inter @font-face for custom-code apps.
3
3
  *
4
- * The woff2 files are served by the backend at /iframe/fonts/<name>.woff2 —
5
- * same-origin to a deployed app's sandboxed iframe, sent as font/woff2 with
6
- * immutable cache headers, so the browser caches them across every app and
7
- * every render.
4
+ * Served from static.lotics.ai (public, CORS-enabled R2) by ABSOLUTE URL, so the
5
+ * same stylesheet resolves on every origin an app is served from `lotics app
6
+ * dev` on localhost, the API same-origin, and a per-app deploy origin — with no
7
+ * per-origin font routing (no vite /iframe proxy, no app-host worker proxy).
8
8
  *
9
- * Kept tiny on purpose: base64-inlining the fonts would put ~440 KB of font
10
- * binary into this render-blocking stylesheet and delay first paint. As
11
- * separate files with `font-display: swap`, nothing blocks the first paint.
12
- *
13
- * `lotics app dev` serves the app from localhost, where `/iframe/...` would
14
- * 404 — the starter vite.config proxies /iframe to the API for development.
9
+ * NOT base64-inlined: ~440 KB of inlined font binary would make this stylesheet
10
+ * render-blocking and delay first paint. Separate cached files with
11
+ * `font-display: swap` block nothing.
15
12
  */
16
13
  @font-face {
17
14
  font-family: Inter_400Regular;
18
15
  font-style: normal;
19
16
  font-weight: 400;
20
- src: url("/iframe/fonts/Inter_400Regular.woff2") format("woff2");
17
+ src: url("https://static.lotics.ai/fonts/Inter_400Regular.woff2") format("woff2");
21
18
  font-display: swap;
22
19
  }
23
20
 
@@ -25,7 +22,7 @@
25
22
  font-family: Inter_500Medium;
26
23
  font-style: normal;
27
24
  font-weight: 500;
28
- src: url("/iframe/fonts/Inter_500Medium.woff2") format("woff2");
25
+ src: url("https://static.lotics.ai/fonts/Inter_500Medium.woff2") format("woff2");
29
26
  font-display: swap;
30
27
  }
31
28
 
@@ -33,6 +30,6 @@
33
30
  font-family: Inter_600SemiBold;
34
31
  font-style: normal;
35
32
  font-weight: 600;
36
- src: url("/iframe/fonts/Inter_600SemiBold.woff2") format("woff2");
33
+ src: url("https://static.lotics.ai/fonts/Inter_600SemiBold.woff2") format("woff2");
37
34
  font-display: swap;
38
35
  }
@@ -0,0 +1,38 @@
1
+ export interface FormatMoneyOptions {
2
+ locale?: string;
3
+ currency?: string;
4
+ /** Abbreviate for display density: ≥1 tỷ → "1,28 tỷ ₫", ≥1 triệu →
5
+ * "486 tr ₫". For stat strips/cards where the full figure lives in the
6
+ * table below — never for the table itself. */
7
+ compact?: boolean;
8
+ }
9
+
10
+ /**
11
+ * Compact vi-style magnitude suffixes: ≥1 tỷ → "1,28 tỷ", ≥1 triệu →
12
+ * "486 tr"; smaller values render in full. Bare numbers only — for money,
13
+ * use `formatMoney` with `compact`.
14
+ */
15
+ export function formatCompactNumber(value: number, locale = "vi-VN"): string {
16
+ if (Math.abs(value) >= 1_000_000_000) {
17
+ return `${(value / 1_000_000_000).toLocaleString(locale, { maximumFractionDigits: 2 })} tỷ`;
18
+ }
19
+ if (Math.abs(value) >= 1_000_000) {
20
+ return `${Math.round(value / 1_000_000).toLocaleString(locale)} tr`;
21
+ }
22
+ return value.toLocaleString(locale);
23
+ }
24
+
25
+ /**
26
+ * THE money formatter — `Metric` (and through it `KPICard`/`KPIStrip`)
27
+ * delegates here; table cells and captions call it directly. Defaults to
28
+ * the product's home market: `1.234.567 ₫`. Never hand-roll digit grouping
29
+ * or the ₫ suffix.
30
+ */
31
+ export function formatMoney(value: number, options: FormatMoneyOptions = {}): string {
32
+ const { locale = "vi-VN", currency = "VND", compact = false } = options;
33
+ if (compact && Math.abs(value) >= 1_000_000) {
34
+ const suffixed = formatCompactNumber(value, locale);
35
+ return currency === "VND" ? `${suffixed} ₫` : `${suffixed} ${currency}`;
36
+ }
37
+ return value.toLocaleString(locale, { style: "currency", currency });
38
+ }
@@ -0,0 +1,153 @@
1
+ import { useState } from "react";
2
+ import { Pressable, StyleSheet, View } from "react-native";
3
+ import { colors, withAlpha } from "./colors";
4
+ import { Text } from "./text";
5
+
6
+ export interface HeatmapAxisItem {
7
+ key: string;
8
+ label: string;
9
+ }
10
+
11
+ export interface HeatmapCell {
12
+ row: string;
13
+ col: string;
14
+ }
15
+
16
+ export interface HeatmapProps {
17
+ rows: HeatmapAxisItem[];
18
+ cols: HeatmapAxisItem[];
19
+ /** values[rowIndex][colIndex] — intensity scales to the matrix max. */
20
+ values: number[][];
21
+ /** The hue. Intensity is alpha within it; zero cells go neutral. */
22
+ color?: string;
23
+ /** The inspected cell — host renders its detail underneath. */
24
+ selected?: HeatmapCell | null;
25
+ /** Press a cell to inspect (press the same cell again to clear). Omit
26
+ * for an informational heatmap. */
27
+ onSelectCell?: (cell: HeatmapCell | null) => void;
28
+ formatValue?: (n: number) => string;
29
+ }
30
+
31
+ const defaultFormat = (n: number): string => n.toLocaleString("en-US");
32
+
33
+ /**
34
+ * Density over two dimensions — "when/where does it cluster?" (alarms by
35
+ * site × day, errors by hour, demand by lane × week). One hue; intensity
36
+ * carries the value; zero stays neutral so gaps read as quiet, not as
37
+ * data. Pressing a cell selects it for the host to drill (detail band,
38
+ * filtered list). Pair with a row dimension that matches the screen's
39
+ * other bands so a hot cell points somewhere actionable.
40
+ */
41
+ export function Heatmap(props: HeatmapProps) {
42
+ const { rows, cols, values, color = colors.blue[600], selected = null, onSelectCell, formatValue = defaultFormat } = props;
43
+ const max = Math.max(1, ...values.flat());
44
+
45
+ return (
46
+ <View style={styles.container}>
47
+ {rows.map((row, r) => (
48
+ <View key={row.key} style={styles.row}>
49
+ <Text size="xs" color="muted" numberOfLines={1} style={styles.rowLabel}>
50
+ {row.label}
51
+ </Text>
52
+ {cols.map((col, c) => {
53
+ const value = values[r]?.[c] ?? 0;
54
+ const isSelected = selected?.row === row.key && selected?.col === col.key;
55
+ return (
56
+ <Cell
57
+ key={col.key}
58
+ label={`${row.label} · ${col.label}: ${formatValue(value)}`}
59
+ background={value === 0 ? colors.zinc[100] : withAlpha(color, 0.15 + 0.85 * (value / max))}
60
+ selected={isSelected}
61
+ onPress={
62
+ onSelectCell
63
+ ? () => onSelectCell(isSelected ? null : { row: row.key, col: col.key })
64
+ : undefined
65
+ }
66
+ />
67
+ );
68
+ })}
69
+ </View>
70
+ ))}
71
+ <View style={styles.row}>
72
+ <View style={styles.rowLabel} />
73
+ {cols.map((col) => (
74
+ <Text key={col.key} size="xs" color="muted" tabular align="center" numberOfLines={1} style={styles.colLabel}>
75
+ {col.label}
76
+ </Text>
77
+ ))}
78
+ </View>
79
+ <View style={styles.scale}>
80
+ <Text size="xs" color="muted">Less</Text>
81
+ {[0.15, 0.36, 0.57, 0.78, 1].map((alpha) => (
82
+ <View key={alpha} style={[styles.scaleSwatch, { backgroundColor: withAlpha(color, alpha) }]} />
83
+ ))}
84
+ <Text size="xs" color="muted">More</Text>
85
+ </View>
86
+ </View>
87
+ );
88
+ }
89
+
90
+ interface CellProps {
91
+ label: string;
92
+ background: string;
93
+ selected: boolean;
94
+ onPress?: () => void;
95
+ }
96
+
97
+ function Cell(props: CellProps) {
98
+ const { label, background, selected, onPress } = props;
99
+ const [hovered, setHovered] = useState(false);
100
+ const base = {
101
+ backgroundColor: background,
102
+ borderColor: selected ? colors.zinc[900] : hovered && onPress ? colors.zinc[400] : "transparent",
103
+ };
104
+ if (!onPress) return <View style={[styles.cell, base]} />;
105
+ return (
106
+ <Pressable
107
+ accessibilityRole="button"
108
+ accessibilityState={{ selected }}
109
+ accessibilityLabel={label}
110
+ onPress={onPress}
111
+ onHoverIn={() => setHovered(true)}
112
+ onHoverOut={() => setHovered(false)}
113
+ style={[styles.cell, base]}
114
+ />
115
+ );
116
+ }
117
+
118
+ const styles = StyleSheet.create({
119
+ container: {
120
+ gap: 3,
121
+ },
122
+ row: {
123
+ flexDirection: "row",
124
+ alignItems: "center",
125
+ gap: 3,
126
+ },
127
+ rowLabel: {
128
+ width: 88,
129
+ paddingRight: 8,
130
+ },
131
+ cell: {
132
+ flex: 1,
133
+ height: 26,
134
+ borderRadius: 4,
135
+ borderWidth: 2,
136
+ },
137
+ colLabel: {
138
+ flex: 1,
139
+ paddingTop: 2,
140
+ },
141
+ scale: {
142
+ flexDirection: "row",
143
+ alignItems: "center",
144
+ justifyContent: "flex-end",
145
+ gap: 4,
146
+ paddingTop: 8,
147
+ },
148
+ scaleSwatch: {
149
+ width: 14,
150
+ height: 10,
151
+ borderRadius: 3,
152
+ },
153
+ });
package/src/icon.tsx CHANGED
@@ -92,6 +92,7 @@ import History from "lucide-react-native/dist/esm/icons/history";
92
92
  import House from "lucide-react-native/dist/esm/icons/house";
93
93
  import Image from "lucide-react-native/dist/esm/icons/image";
94
94
  import Inbox from "lucide-react-native/dist/esm/icons/inbox";
95
+ import Info from "lucide-react-native/dist/esm/icons/info";
95
96
  import Italic from "lucide-react-native/dist/esm/icons/italic";
96
97
  import Keyboard from "lucide-react-native/dist/esm/icons/keyboard";
97
98
  import Languages from "lucide-react-native/dist/esm/icons/languages";
@@ -283,6 +284,7 @@ const iconComponents = {
283
284
  house: House,
284
285
  image: Image,
285
286
  inbox: Inbox,
287
+ info: Info,
286
288
  italic: Italic,
287
289
  keyboard: Keyboard,
288
290
  languages: Languages,
@@ -10,6 +10,10 @@ interface IconButtonBase {
10
10
  testID?: string;
11
11
  icon: IconName;
12
12
  color?: "none" | "secondary" | "white";
13
+ /** `md` (28px, 18px glyph) for toolbars; `sm` (24px, 14px glyph) for
14
+ * inline affordances sitting next to body/sm text — e.g. the ⓘ in a
15
+ * CardHeader. Both keep a 40px touch target via hitSlop. */
16
+ size?: "md" | "sm";
13
17
  iconColor?: string;
14
18
  tooltipSide?: TooltipSide;
15
19
  onPress?: (event: GestureResponderEvent) => void;
@@ -34,6 +38,7 @@ export function IconButton(props: IconButtonProps) {
34
38
  iconColor,
35
39
  onPress,
36
40
  color = "none",
41
+ size = "md",
37
42
  tooltip,
38
43
  tooltipSide,
39
44
  accessibilityLabel,
@@ -56,12 +61,15 @@ export function IconButton(props: IconButtonProps) {
56
61
  tooltipSide={tooltipSide}
57
62
  accessibilityRole="button"
58
63
  accessibilityLabel={accessibilityLabel ?? tooltip}
59
- style={[styles.button, styles[color], disabled && styles.disabled, style]}
64
+ style={[styles.button, styles[size], styles[color], disabled && styles.disabled, style]}
60
65
  onPress={handlePress}
61
66
  disabled={disabled}
67
+ // 40px touch target regardless of visual size (28 + 2×6 / 24 + 2×8)
68
+ // without shifting surrounding layouts.
69
+ hitSlop={size === "sm" ? 8 : 6}
62
70
  >
63
71
  <Icon
64
- size={18}
72
+ size={size === "sm" ? 14 : 18}
65
73
  name={icon}
66
74
  color={iconColor || (color === "white" ? colors.white : colors.zinc[700])}
67
75
  />
@@ -76,9 +84,15 @@ const styles = StyleSheet.create({
76
84
  alignItems: "center",
77
85
  justifyContent: "center",
78
86
  borderRadius: 999,
87
+ },
88
+ md: {
79
89
  width: 28,
80
90
  height: 28,
81
91
  },
92
+ sm: {
93
+ width: 24,
94
+ height: 24,
95
+ },
82
96
  disabled: {
83
97
  opacity: 0.3,
84
98
  },
package/src/index.css CHANGED
@@ -353,6 +353,7 @@ html {
353
353
  - Frontend: /fonts/Inter_*.woff2 (served from public/)
354
354
  - Extension: fonts/Inter_*.woff2 (relative, in dist/fonts/)
355
355
  See frontend/public/fonts/ and browser_extension/public/fonts.css.
356
- Contexts without a stable font-file path (custom-code apps served under the
357
- /v1/apps/{id}/asset/ proxy) can `import "@lotics/ui/fonts.css"` — a
358
- path-independent base64 @font-face bundle. See src/fonts.css. */
356
+ Contexts without a stable font-file path (custom-code apps served from a
357
+ per-app deploy origin) can `import "@lotics/ui/fonts.css"` — it loads Inter
358
+ from static.lotics.ai by absolute, CORS-enabled URL, so it works from any
359
+ origin. See src/fonts.css. */
@@ -17,21 +17,19 @@ export interface InfoPopoverProps {
17
17
  }
18
18
 
19
19
  /**
20
- * A "?" icon button that opens a popover explaining something — a metric, a
20
+ * The button that opens a popover explaining something — a metric, a
21
21
  * field, a setting. The middle ground between Tooltip (a short hover label)
22
22
  * and composing Popover directly (rich, interactive content): a labeled,
23
23
  * keyboard-accessible help affordance for a sentence or two of text.
24
+ * Sized to sit inline next to sm/body text (a CardHeaderTitle, a form
25
+ * label) without inflating the line.
24
26
  */
25
27
  export function InfoPopover(props: InfoPopoverProps) {
26
28
  const { text, accessibilityLabel = "More information", side = "bottom", align = "end" } = props;
27
29
  return (
28
30
  <Popover side={side} align={align}>
29
31
  <PopoverTrigger>
30
- <IconButton
31
- icon="message-circle-question-mark"
32
- iconColor={colors.zinc[500]}
33
- accessibilityLabel={accessibilityLabel}
34
- />
32
+ <IconButton icon="info" size="sm" iconColor={colors.zinc[500]} accessibilityLabel={accessibilityLabel} />
35
33
  </PopoverTrigger>
36
34
  <PopoverContent style={styles.content} disableBodyScroll>
37
35
  <Text size="sm" color="muted">
package/src/kpi_card.tsx CHANGED
@@ -2,6 +2,7 @@ import { View, StyleSheet, type ViewStyle, type StyleProp } from "react-native";
2
2
  import { Text } from "./text";
3
3
  import { Metric, type MetricFormat, type MetricSize, type MetricTone } from "./metric";
4
4
  import { TrendChip } from "./trend_chip";
5
+ import { InfoPopover } from "./info_popover";
5
6
  import { SPACE } from "./spacing";
6
7
 
7
8
  interface KPICardProps {
@@ -13,6 +14,8 @@ interface KPICardProps {
13
14
  currency?: string;
14
15
  locale?: string;
15
16
  emptyLabel?: string;
17
+ /** Abbreviate large values (≥1 triệu) — see Metric.compact. */
18
+ compact?: boolean;
16
19
  /** Numeric size hint. Default `lg`; pass `hero` for the dominant
17
20
  * metric in a card; `md` for supporting metrics in a horizontal strip. */
18
21
  size?: MetricSize;
@@ -23,10 +26,12 @@ interface KPICardProps {
23
26
  trend?: number | null;
24
27
  /**
25
28
  * 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").
29
+ * ("Last month: 50,000,000 "), unit hint, or short caveat.
28
30
  */
29
31
  caption?: string;
32
+ /** What this metric means / how it's computed — renders the ⓘ next to
33
+ * the label, opening the explanation in a popover. */
34
+ info?: string;
30
35
  style?: StyleProp<ViewStyle>;
31
36
  }
32
37
 
@@ -44,12 +49,15 @@ interface KPICardProps {
44
49
  * mediocre dashboards.
45
50
  */
46
51
  export function KPICard(props: KPICardProps) {
47
- const { label, value, format, currency, locale, emptyLabel, size = "lg", tone, trend, caption, style } = props;
52
+ const { label, value, format, currency, locale, emptyLabel, compact, size = "lg", tone, trend, caption, info, style } = props;
48
53
  return (
49
54
  <View style={[styles.container, style]}>
50
- <Text size="xs" color="muted" transform="uppercase">
51
- {label}
52
- </Text>
55
+ <View style={styles.labelRow}>
56
+ <Text size="xs" color="muted" transform="uppercase">
57
+ {label}
58
+ </Text>
59
+ {info ? <InfoPopover text={info} accessibilityLabel={`About ${label}`} /> : null}
60
+ </View>
53
61
  <View style={styles.valueRow}>
54
62
  <Metric
55
63
  value={value}
@@ -57,6 +65,7 @@ export function KPICard(props: KPICardProps) {
57
65
  currency={currency}
58
66
  locale={locale}
59
67
  emptyLabel={emptyLabel}
68
+ compact={compact}
60
69
  size={size}
61
70
  tone={tone}
62
71
  />
@@ -73,5 +82,9 @@ export function KPICard(props: KPICardProps) {
73
82
 
74
83
  const styles = StyleSheet.create({
75
84
  container: { gap: SPACE.xs },
85
+ // Reserve the inline ⓘ button's height (sm IconButton = 24) on EVERY label row
86
+ // so columns with and without `info` center their labels — and thus their value
87
+ // rows — on the same baseline. Without this the no-info columns sit 4px higher.
88
+ labelRow: { flexDirection: "row", alignItems: "center", gap: 2, minHeight: 24 },
76
89
  valueRow: { flexDirection: "row", alignItems: "baseline", gap: SPACE.sm, flexWrap: "wrap" },
77
90
  });
@@ -0,0 +1,89 @@
1
+ import { StyleSheet, View } from "react-native";
2
+ import { Card } from "./card";
3
+ import { colors } from "./colors";
4
+ import { KPICard } from "./kpi_card";
5
+ import type { MetricFormat, MetricSize, MetricTone } from "./metric";
6
+
7
+ export interface KPIStripItem {
8
+ /** Short uppercase label above the value. */
9
+ label: string;
10
+ value: number | string | null | undefined;
11
+ format?: MetricFormat;
12
+ currency?: string;
13
+ locale?: string;
14
+ emptyLabel?: string;
15
+ tone?: MetricTone;
16
+ /** Abbreviate large values (≥1 triệu → "486 tr") — strip columns are
17
+ * narrow; the full figure belongs in the content below. */
18
+ compact?: boolean;
19
+ /** Optional ±% trend chip — the "is this good?" indicator. */
20
+ trend?: number | null;
21
+ /** Optional comparator/unit line below the value. */
22
+ caption?: string;
23
+ /** What this metric means / how it's computed — the ⓘ next to the label
24
+ * opens it in a popover. KPI strips are INFORMATIONAL: filtering belongs
25
+ * to the tabs/chips below, never to the strip. */
26
+ info?: string;
27
+ }
28
+
29
+ export interface KPIStripProps {
30
+ items: KPIStripItem[];
31
+ /** Value scale, uniform across every column by design. Default `lg` —
32
+ * the big-number dashboard register. */
33
+ size?: MetricSize;
34
+ }
35
+
36
+ /**
37
+ * THE stat band at the top of a screen — one Card, hairline-divided columns
38
+ * of KPICard with a uniform value scale. Screens must not hand-compose stat
39
+ * strips: the same four-numbers-in-a-row should look identical on a
40
+ * dashboard, an inventory register, or a payroll page. Wraps on narrow
41
+ * screens (columns keep a 180px floor).
42
+ */
43
+ export function KPIStrip(props: KPIStripProps) {
44
+ const { items, size = "lg" } = props;
45
+ return (
46
+ <Card style={styles.card}>
47
+ <View style={styles.row}>
48
+ {items.map((item, i) => (
49
+ <View key={item.label} style={[styles.cell, i > 0 && styles.divided]}>
50
+ <KPICard
51
+ label={item.label}
52
+ value={item.value}
53
+ format={item.format}
54
+ currency={item.currency}
55
+ locale={item.locale}
56
+ emptyLabel={item.emptyLabel}
57
+ compact={item.compact}
58
+ tone={item.tone}
59
+ trend={item.trend}
60
+ caption={item.caption}
61
+ info={item.info}
62
+ size={size}
63
+ />
64
+ </View>
65
+ ))}
66
+ </View>
67
+ </Card>
68
+ );
69
+ }
70
+
71
+ const styles = StyleSheet.create({
72
+ card: {
73
+ padding: 0,
74
+ },
75
+ row: {
76
+ flexDirection: "row",
77
+ flexWrap: "wrap",
78
+ },
79
+ cell: {
80
+ flexGrow: 1,
81
+ flexBasis: 180,
82
+ paddingVertical: 16,
83
+ paddingHorizontal: 20,
84
+ },
85
+ divided: {
86
+ borderLeftWidth: 1,
87
+ borderLeftColor: colors.zinc[100],
88
+ },
89
+ });
@@ -2,7 +2,7 @@ import { View, StyleSheet, LayoutChangeEvent } from "react-native";
2
2
  import { Text } from "./text";
3
3
  import { colors } from "./colors";
4
4
  import { useMemo, useState, useCallback } from "react";
5
- import Svg, { Polyline, Circle, Line } from "react-native-svg";
5
+ import Svg, { Circle, Defs, Line, LinearGradient, Polygon, Polyline, Stop } from "react-native-svg";
6
6
 
7
7
  export interface LineChartPoint {
8
8
  x: string | number;
@@ -41,44 +41,49 @@ export function LineChart(props: LineChartProps) {
41
41
 
42
42
  const paddingTop = 8;
43
43
  const paddingBottom = 8;
44
- const paddingRight = 8;
44
+ const paddingLeft = 6;
45
+ const paddingRight = 10;
45
46
 
47
+ // Nice domain AROUND the data — a 16–18% series must not be drawn against
48
+ // a 0–20 axis (it flattens into a sliver at the top). The baseline snaps
49
+ // to 0 only when the data actually lives near 0.
46
50
  const yAxisTicks = useMemo(() => {
47
- const rawMax = Math.max(...data.map((d) => d.y), 1);
48
- const magnitude = Math.pow(10, Math.floor(Math.log10(rawMax)));
49
- const normalized = rawMax / magnitude;
50
- let niceMax: number;
51
- if (normalized <= 1) niceMax = magnitude;
52
- else if (normalized <= 2) niceMax = 2 * magnitude;
53
- else if (normalized <= 5) niceMax = 5 * magnitude;
54
- else niceMax = 10 * magnitude;
55
-
56
- const tickCount = 4;
57
- const step = niceMax / tickCount;
51
+ const ys = data.map((d) => d.y);
52
+ const lo = Math.min(...ys, 0 === ys.length ? 0 : Infinity);
53
+ const hi = Math.max(...ys, lo);
54
+ const span = hi - lo || Math.abs(hi) || 1;
55
+ let min = lo - span * 0.2;
56
+ let max = hi + span * 0.2;
57
+ if (lo >= 0 && lo <= span) min = Math.max(0, min === lo ? 0 : min);
58
+ if (lo >= 0 && min < 0) min = 0;
59
+ const rawStep = (max - min) / 4 || 1;
60
+ const mag = Math.pow(10, Math.floor(Math.log10(rawStep)));
61
+ const norm = rawStep / mag;
62
+ const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10) * mag;
63
+ min = Math.floor(min / step) * step;
64
+ max = Math.ceil(max / step) * step;
58
65
  const ticks: number[] = [];
59
- for (let i = 0; i <= tickCount; i++) {
60
- ticks.push(i * step);
61
- }
62
- return { ticks, max: niceMax };
66
+ for (let t = min; t <= max + step / 1e6; t += step) ticks.push(Number(t.toFixed(10)));
67
+ return { ticks, min, max };
63
68
  }, [data]);
64
69
 
65
- const maxValue = yAxisTicks.max;
70
+ const { min: minValue, max: maxValue } = yAxisTicks;
66
71
 
67
72
  const drawnPoints = useMemo(() => {
68
73
  if (data.length === 0 || chartWidth === 0) return [];
69
74
 
70
- const availableWidth = chartWidth - paddingRight;
75
+ const availableWidth = chartWidth - paddingLeft - paddingRight;
71
76
  const availableHeight = chartHeight - paddingTop - paddingBottom;
77
+ const range = maxValue - minValue || 1;
72
78
 
73
79
  return data.map((point, index) => {
74
80
  const x =
75
- data.length > 1
76
- ? (index / (data.length - 1)) * availableWidth
77
- : availableWidth / 2;
78
- const y = paddingTop + availableHeight - (point.y / maxValue) * availableHeight;
81
+ paddingLeft +
82
+ (data.length > 1 ? (index / (data.length - 1)) * availableWidth : availableWidth / 2);
83
+ const y = paddingTop + availableHeight - ((point.y - minValue) / range) * availableHeight;
79
84
  return { x, y, raw: point };
80
85
  });
81
- }, [data, chartWidth, maxValue, chartHeight]);
86
+ }, [data, chartWidth, minValue, maxValue, chartHeight]);
82
87
 
83
88
  const polylinePoints = drawnPoints.map((p) => `${p.x},${p.y}`).join(" ");
84
89
 
@@ -126,34 +131,55 @@ export function LineChart(props: LineChartProps) {
126
131
  <View style={[styles.svgContainer, { height: chartHeight }]} onLayout={handleLayout}>
127
132
  {chartWidth > 0 && (
128
133
  <Svg width={chartWidth} height={chartHeight}>
134
+ <Defs>
135
+ {/* soft area wash under the line — depth without decoration */}
136
+ <LinearGradient id="lineChartArea" x1="0" y1="0" x2="0" y2="1">
137
+ <Stop offset="0" stopColor={lineColor} stopOpacity={0.16} />
138
+ <Stop offset="1" stopColor={lineColor} stopOpacity={0.01} />
139
+ </LinearGradient>
140
+ </Defs>
129
141
  {yAxisTicks.ticks.map((tick, i) => {
130
142
  const availableHeight = chartHeight - paddingTop - paddingBottom;
131
- const y = paddingTop + availableHeight - (tick / maxValue) * availableHeight;
143
+ const range = maxValue - minValue || 1;
144
+ const y = paddingTop + availableHeight - ((tick - minValue) / range) * availableHeight;
132
145
  return (
133
146
  <Line
134
147
  key={i}
135
- x1={0}
148
+ x1={paddingLeft}
136
149
  y1={y}
137
150
  x2={chartWidth - paddingRight}
138
151
  y2={y}
139
- stroke={colors.zinc[200]}
152
+ stroke={i === 0 ? colors.zinc[200] : colors.zinc[100]}
140
153
  strokeWidth={1}
141
154
  />
142
155
  );
143
156
  })}
157
+ {drawnPoints.length > 1 && (
158
+ <Polygon
159
+ points={`${polylinePoints} ${drawnPoints[drawnPoints.length - 1].x},${chartHeight - paddingBottom} ${drawnPoints[0].x},${chartHeight - paddingBottom}`}
160
+ fill="url(#lineChartArea)"
161
+ stroke="none"
162
+ />
163
+ )}
144
164
  {drawnPoints.length > 1 && (
145
165
  <Polyline
146
166
  points={polylinePoints}
147
167
  fill="none"
148
168
  stroke={lineColor}
149
- strokeWidth={2}
169
+ strokeWidth={2.5}
150
170
  strokeLinejoin="round"
151
171
  strokeLinecap="round"
152
172
  />
153
173
  )}
154
- {drawnPoints.map((point, index) => (
155
- <Circle key={index} cx={point.x} cy={point.y} r={3} fill={lineColor} />
156
- ))}
174
+ {drawnPoints.map((point, index) => {
175
+ const isLast = index === drawnPoints.length - 1;
176
+ return isLast ? (
177
+ // the latest value is the story — emphasize its point
178
+ <Circle key={index} cx={point.x} cy={point.y} r={4.5} fill={lineColor} stroke={colors.white} strokeWidth={2} />
179
+ ) : (
180
+ <Circle key={index} cx={point.x} cy={point.y} r={3} fill={colors.white} stroke={lineColor} strokeWidth={1.5} />
181
+ );
182
+ })}
157
183
  </Svg>
158
184
  )}
159
185
  </View>
@@ -198,13 +224,14 @@ const styles = StyleSheet.create({
198
224
  gap: 4,
199
225
  },
200
226
  yAxis: {
201
- width: 56,
227
+ width: 48,
202
228
  justifyContent: "space-between",
203
- alignItems: "flex-start",
229
+ alignItems: "flex-end",
204
230
  paddingVertical: 4,
231
+ paddingRight: 4,
205
232
  },
206
233
  yAxisLabel: {
207
- textAlign: "left",
234
+ textAlign: "right",
208
235
  },
209
236
  svgContainer: {
210
237
  flex: 1,