@lotics/ui 7.19.1 → 7.19.2

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 CHANGED
@@ -655,13 +655,16 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
655
655
  cross-cutting numbers, drop them. Do NOT put a `KPIStrip` on a register page or a `SummaryLine` on
656
656
  a dashboard. Card stat rails use `KPICard`.
657
657
  - **Numbers**: free-standing numerals `<Text tabular>`. Money: `formatMoney(n)` from
658
- `@lotics/ui/format_money` (`compact` for strip captions). Dates: `formatDate(value, opts)` from
658
+ `@lotics/ui/format_money` (`compact` for strip captions; it emits a non-breaking space before `₫` —
659
+ normalize `\s` in test asserts). Dates: `formatDate(value, opts)` from
659
660
  `@lotics/ui/format_date` — the date-VALUE formatter. `format` (date STYLE) ∈ `date` (22/05/2026) ·
660
661
  `medium` (22 thg 5, 2026) · `long` (22 tháng 5, 2026) · `dayMonth` (22 thg 5) · `monthYear`
661
662
  (Tháng 5 2026); **`time: true` is orthogonal** — prepends the 24h time to ANY style
662
663
  (`14:30 22/05/2026`, `14:30 22 tháng 5, 2026`); `compact` drops the year on `date`. It's `Intl` +
663
664
  the product's conventions (stable `/`, naive-ISO parse, 24h time-first, `emptyLabel`) — never
664
- hand-roll a date via `Intl`/`padStart` for value display. (Exempt: a component's own internal
665
+ hand-roll a date via `Intl`/`padStart` for value display. An order the presets lack (e.g.
666
+ time-first `HH:mm dd/MM/yyyy` in a doc builder): `parseDate` + assemble the missing order —
667
+ never a regex re-implementation. (Exempt: a component's own internal
665
668
  chrome — a calendar's header/weekday/a11y labels, a gantt axis — renders its own set.)
666
669
  - **Every number is a door** (except the KPI strip). A component that summarizes records leads to
667
670
  the records behind it when pressed — switch to the filtered list, expand in place, or navigate.
@@ -782,6 +785,10 @@ was only ever about the review surfaces' sparkles/severity glyphs, not functiona
782
785
  A status is ONE family NAME (`{ color: "emerald" }`) and every weight derives from it. One accent
783
786
  per screen purpose (blue=pipeline, emerald=money, red=danger, amber=waiting). Direct `colors.*` is
784
787
  reserved for NEUTRALS (`zinc`, `border`, `white`, the `*[50]` selection washes).
788
+ **TEXT never wears `solid()`** — the 500 is a FILL shade (dots, series). A status word/number uses
789
+ `Text`'s valence tokens: `color="danger"` (red-900) / `"warning"` (amber-700) / `"success"`
790
+ (emerald-700) — AA on white. Map a dynamic `ColorName` to a token at the call site; hue nuance
791
+ (rose vs orange) stays on `Badge`s. Links use `Link`/`TextLink` (blue-600), never `solid("blue")`.
785
792
  - **Status indicators have a WEIGHT — match to prominence, never to a metric.** colored `Text` (a
786
793
  verdict WORD in a header) < `Badge variant="dot"` (a categorical STATE in a scannable column /
787
794
  legend) < `Badge` tonal pill (a record's PROMINENT status, a drawer/detail header). A register's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "7.19.1",
3
+ "version": "7.19.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
package/src/timeline.tsx CHANGED
@@ -46,20 +46,17 @@ export function Timeline(props: TimelineProps) {
46
46
  {items.map((item, index) => {
47
47
  const expanded = expandedIds[item.id] ?? false;
48
48
  const hasDetails = !!item.details;
49
+ const hasBelow = !!item.description || !!item.error || (expanded && !!item.details);
50
+ const isFirst = index === 0;
51
+ const isLast = index === items.length - 1;
49
52
 
53
+ // The label row: label + right + chevron only. Secondary lines
54
+ // (description/error) render BELOW in the spine band, so the disc can
55
+ // center against this row whatever its height (a plain 20px label or a
56
+ // 40px inline control in `right` both align label-on-disc).
50
57
  const row = (
51
- <View style={[styles.row, item.description ? styles.rowTop : null]}>
52
- <View style={{ flex: 1, gap: 2 }}>
53
- <Text size="sm">{item.label}</Text>
54
- {item.description ? (
55
- <Text size="xs" color="muted">{item.description}</Text>
56
- ) : null}
57
- {item.error ? (
58
- <Text size="xs" color="danger" numberOfLines={1}>
59
- {item.error}
60
- </Text>
61
- ) : null}
62
- </View>
58
+ <View style={styles.row}>
59
+ <Text size="sm" style={{ flex: 1 }}>{item.label}</Text>
63
60
  {item.right}
64
61
  {hasDetails ? (
65
62
  <Icon name={expanded ? "chevron-up" : "chevron-down"} size={14} color={colors.zinc[400]} />
@@ -68,30 +65,47 @@ export function Timeline(props: TimelineProps) {
68
65
  );
69
66
 
70
67
  return (
71
- <View key={item.id} style={styles.itemContainer}>
72
- <View style={styles.spineColumn}>
73
- <AnimationFadeIn key={`${item.id}-${item.icon}`}>
74
- {/* Tinted disc: a low-alpha wash of the accent behind a full-color icon. */}
75
- <View style={[styles.node, { backgroundColor: withAlpha(item.iconColor, 0.1) }]}>
76
- {item.isLoading ? (
77
- <ActivityIndicator size={13} color={item.iconColor} />
78
- ) : (
79
- <Icon name={item.icon} size={13} color={item.iconColor} />
80
- )}
81
- </View>
82
- </AnimationFadeIn>
83
- {index < items.length - 1 && <View style={styles.spine} />}
68
+ <View key={item.id}>
69
+ {/* Band 1 — disc beside the label row, vertically CENTERED on it.
70
+ The half-spines above/below the disc keep the line continuous
71
+ through tall rows. */}
72
+ <View style={styles.labelBand}>
73
+ <View style={styles.discColumn}>
74
+ <View style={styles.halfSpineSlot}>{!isFirst ? <View style={styles.spineFill} /> : null}</View>
75
+ <AnimationFadeIn key={`${item.id}-${item.icon}`}>
76
+ {/* Tinted disc: a low-alpha wash of the accent behind a full-color icon. */}
77
+ <View style={[styles.node, { backgroundColor: withAlpha(item.iconColor, 0.1) }]}>
78
+ {item.isLoading ? (
79
+ <ActivityIndicator size={13} color={item.iconColor} />
80
+ ) : (
81
+ <Icon name={item.icon} size={13} color={item.iconColor} />
82
+ )}
83
+ </View>
84
+ </AnimationFadeIn>
85
+ <View style={styles.halfSpineSlot}>{!isLast ? <View style={styles.spineFill} /> : null}</View>
86
+ </View>
87
+ <View style={styles.rowColumn}>
88
+ {hasDetails ? (
89
+ <PressableHighlight focusRing onPress={() => toggleItem(item.id)} style={styles.pressableRow}>
90
+ {row}
91
+ </PressableHighlight>
92
+ ) : (
93
+ <View style={styles.plainRow}>{row}</View>
94
+ )}
95
+ </View>
84
96
  </View>
85
97
 
86
- <View style={styles.contentColumn}>
87
- {hasDetails ? (
88
- <PressableHighlight focusRing onPress={() => toggleItem(item.id)} style={styles.pressableRow}>
89
- {row}
90
- </PressableHighlight>
91
- ) : (
92
- <View style={styles.plainRow}>{row}</View>
93
- )}
94
- {expanded && item.details ? <View style={styles.detailsContainer}>{item.details}</View> : null}
98
+ {/* Band 2 — description/error/details under the label, beside the
99
+ continuing spine; also carries the inter-item gap. */}
100
+ <View style={styles.belowBand}>
101
+ <View style={styles.spineColumn}>{!isLast ? <View style={styles.spineFill} /> : null}</View>
102
+ <View style={[styles.belowColumn, isLast && !hasBelow ? styles.belowColumnLast : null]}>
103
+ {item.description ? <Text size="xs" color="muted">{item.description}</Text> : null}
104
+ {item.error ? (
105
+ <Text size="xs" color="danger" numberOfLines={1}>{item.error}</Text>
106
+ ) : null}
107
+ {expanded && item.details ? <View style={styles.detailsContainer}>{item.details}</View> : null}
108
+ </View>
95
109
  </View>
96
110
  </View>
97
111
  );
@@ -101,16 +115,27 @@ export function Timeline(props: TimelineProps) {
101
115
  }
102
116
 
103
117
  const styles = StyleSheet.create({
104
- itemContainer: {
118
+ labelBand: {
105
119
  flexDirection: "row",
106
120
  gap: 12,
107
121
  },
108
- spineColumn: {
122
+ // Stretches to the label row's height; the disc centers on it, half-spines
123
+ // fill the remainder so the line never breaks on a tall row.
124
+ discColumn: {
109
125
  width: 24,
126
+ alignSelf: "stretch",
110
127
  alignItems: "center",
111
- // Align the node with the FIRST line of content (not the centre of a
112
- // multi-line block), so the spine reads as connected.
113
- paddingTop: 2,
128
+ },
129
+ halfSpineSlot: {
130
+ flex: 1,
131
+ alignItems: "center",
132
+ justifyContent: "center",
133
+ },
134
+ spineFill: {
135
+ width: 1.5,
136
+ flex: 1,
137
+ borderRadius: 1,
138
+ backgroundColor: colors.zinc[200],
114
139
  },
115
140
  node: {
116
141
  width: 24,
@@ -118,33 +143,36 @@ const styles = StyleSheet.create({
118
143
  borderRadius: 12,
119
144
  justifyContent: "center",
120
145
  alignItems: "center",
146
+ marginVertical: 3,
121
147
  },
122
- spine: {
123
- width: 1.5,
148
+ rowColumn: {
124
149
  flex: 1,
125
- minHeight: 12,
126
- borderRadius: 1,
127
- backgroundColor: colors.zinc[200],
128
- marginVertical: 3,
129
150
  },
130
- contentColumn: {
151
+ belowBand: {
152
+ flexDirection: "row",
153
+ gap: 12,
154
+ },
155
+ spineColumn: {
156
+ width: 24,
157
+ alignItems: "center",
158
+ alignSelf: "stretch",
159
+ },
160
+ belowColumn: {
131
161
  flex: 1,
162
+ gap: 2,
132
163
  paddingBottom: 14,
133
164
  },
165
+ belowColumnLast: {
166
+ paddingBottom: 0,
167
+ },
134
168
  row: {
135
169
  flexDirection: "row",
136
170
  alignItems: "center",
137
171
  gap: 10,
138
172
  flex: 1,
139
173
  },
140
- // With a description the row is taller than one line — top-align so the
141
- // timestamp (`right`) sits beside the label instead of centred on the block.
142
- rowTop: {
143
- alignItems: "flex-start",
144
- },
145
174
  // The press target for expandable rows; pressable rows bleed the hover wash
146
- // past the text column. paddingVertical pairs with the spineColumn paddingTop
147
- // so the node lines up with the first line.
175
+ // past the text column.
148
176
  pressableRow: {
149
177
  borderRadius: 8,
150
178
  paddingHorizontal: 8,
@@ -166,3 +194,4 @@ const styles = StyleSheet.create({
166
194
  paddingLeft: 2,
167
195
  },
168
196
  });
197
+