@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 CHANGED
@@ -4,6 +4,40 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## 42.0.0 — `DateFilter`'s presets are a listbox, and "Custom" is not one of them
8
+
9
+ Three defects with one root: the preset row used EMPHASIS to mean "current state", inside a
10
+ panel where emphasis means "action".
11
+
12
+ **The active preset was painted like a primary button.** `zinc.800` with inverted text — the
13
+ loudest treatment the system has — spent on a row that reports state rather than offering one.
14
+ It outshouted the panel's own Done button. It now uses `MenuButton`'s `selected`, the kit's
15
+ listbox row: a `zinc.100` resting highlight. That prop also carries `aria-selected`, which the
16
+ hand-rolled fill never did — until now the active preset announced **nothing**, so which period
17
+ was selected was information available only to someone who could see the fill.
18
+
19
+ **The panel rendered its phone layout on desktop.** It sized itself with `useContainerSize()`,
20
+ which inside a `Popover` measures the popover BODY — and the body's width is decided by the
21
+ branch that reading picks. Two calendars come to ~617px, always under the 768 phone threshold,
22
+ so the panel resolved "small" on a 1440px viewport and its sidebar branch was unreachable: the
23
+ presets collapsed into a horizontal strip of bare text where the only item with any chrome was
24
+ the active one. It now reads `useScreenSize()` — the same instrument, and for the same reason,
25
+ that `Popover` already uses to choose sheet vs anchored.
26
+
27
+ **`"custom"` is gone from `PresetId`, `PRESET_IDS`, and `DateFilterLabels`.** It was the one
28
+ entry that set no range: it CLEARED the value. So it lit up whenever the range matched no
29
+ preset — the normal state for any hand-picked range, and the opening state of any app whose
30
+ default period is not a preset — and clicking the lit row, the obvious move for "let me pick my
31
+ own dates", threw the range away. The calendar is the custom picker and the footer's Clear is
32
+ the clear.
33
+
34
+ `getPresetValue` is now total: it returns `DateFilterValue`, never `| null`.
35
+
36
+ **To migrate:** delete `custom` from any `DateFilterLabels` / `DateRangeFilterFieldLabels`
37
+ object you pass — it is an excess property now and the compiler will point at it. If you called
38
+ `getPresetValue` and branched on `null`, drop the branch. Nothing else changes: a range that
39
+ matches no preset simply leaves every row unselected, which is what it always meant.
40
+
7
41
  ## 41.4.0 — embedded markdown is demoted in the OUTLINE, not only on the type ladder
8
42
 
9
43
  `<Markdown variant="embedded">` sized its headings down and left them as the tags the author
package/docs/catalog.md CHANGED
@@ -857,6 +857,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
857
857
  for it bare only when the calendar lives permanently on the surface, not behind a field.
858
858
  - **`date_filter`** — `DateFilter`: the date+time period filter panel — presets
859
859
  (`PresetId`), calendar, optional time segments; the body `DateRangeFilterField` opens.
860
+ **Every preset SETS a range** — the list holds no mode and no null case, so a hand-picked
861
+ range selects nothing rather than lighting a "custom" row whose click cleared the value. The
862
+ presets are a `listbox` of `MenuButton` options: selection is the kit's `zinc.100` row
863
+ highlight plus `aria-selected`, never a filled primary treatment, because the row reports
864
+ state rather than offering an action. Sidebar at viewports of 768 and up, strip below —
865
+ measured on the VIEWPORT, since a popover's own width is decided by that very branch.
860
866
  **One selection rule, no modes**: a click OPENS a range, the next CLOSES it (in either
861
867
  order — bounds come back sorted), and a single day is the same day clicked twice
862
868
  (`start`/`end` equal — which is how a consumer detects "one day" and how the trigger
@@ -1219,6 +1225,21 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1219
1225
  a record's identity band reporting that record's own state**: derived verdicts on one record
1220
1226
  ("documents incomplete", "2 fields need checking") are the checklist a record must not carry
1221
1227
  (§`checklist`) and restate at a distance what the sections below state in place.
1228
+ - **`summary`** — `Summary`: the register summary band, for a set with a SHAPE worth showing —
1229
+ a headline figure, a distribution bar, and the buckets named. A COMPOUND:
1230
+ `Summary.Header` / `Summary.Total` / `Summary.Metric` / `Summary.Distribution` /
1231
+ `Summary.Facts`, composed as children. **Declare the buckets ONCE on the root and every part
1232
+ derives from them** — the headline is their sum (there is no `total` prop to go stale), the
1233
+ segments and the legend swatches are the buckets' own colours. That correspondence is the
1234
+ reason it exists: a `segments` array passed beside an `items` array beside a `total` drifts
1235
+ three ways, invisibly. A `SummaryBucket` must PARTITION the set; an aggregate over the same
1236
+ rows that does not (a condition like "overdue" beside "draft / sent / paid", counted again
1237
+ from a different question) goes to `Summary.Facts` as `extra`, where it renders with NO swatch
1238
+ — the visual difference says it is not a slice of the bar. Ordering is the caller's and is
1239
+ never re-ranked, because for a pipeline the sequence is the process. Pick between the four
1240
+ summaries by what you have: only aggregates → `summary_line`; aggregates + a shape →
1241
+ `summary`; a boxed dashboard stat band → `kpi_strip`; a drill-down facet browser over a large
1242
+ population → `breakdown`.
1222
1243
  - **`use_selection`** — `useSelection`: always-on multi-select state for a register/list —
1223
1244
  the `selected` Set + `toggle`/`setAll`/`allSelected`/`indeterminate`/`count`/`clear`;
1224
1245
  selectability gating stays with the caller. The checkbox-always-visible counterpart to
@@ -264,9 +264,13 @@ objects — hairlines between them blur into one run-on list.
264
264
  | Altitude | Component | Where | Shape |
265
265
  |---|---|---|---|
266
266
  | Dashboard | `<KPIStrip items={[{label, value, trend?, caption?, tone?, compact?, info?}…]}>` | TOP of a dashboard | a boxed stat band — a `Card` of `KPICard` columns |
267
- | Register / list | `<SummaryLine items={[{label, value, tone?, compact?, trend?, info?}…]}>` | BELOW the toolbar | a light inline line — a small `Metric` + muted label per item |
267
+ | Register / list | `<SummaryLine items={[{label, value, tone?, compact?, trend?, info?, color?}…]}>` | BELOW the toolbar | a light inline line — a small `Metric` + muted label per item |
268
+ | Register / list, with a SHAPE | `<Summary buckets={…}><Summary.Header>…</Summary.Header><Summary.Distribution/><Summary.Facts/></Summary>` | BELOW the toolbar | headline figure + distribution bar + the buckets named, all derived from one bucket list |
268
269
 
269
- A `SummaryLine` is recomputed from the FILTERED rows so it always describes WHAT'S SHOWN. Both are
270
+ `SummaryLine` is `Summary`'s `Facts` row on its own reach for the compound when the set has a
271
+ distribution worth drawing, and for the bare line when it is only a few aggregates. A
272
+ `SummaryLine`'s `color` exists so it can double as the bar's legend without giving up its `info`
273
+ popovers. Both are recomputed from the FILTERED rows so they always describe WHAT'S SHOWN. Both are
270
274
  INFORMATIONAL — they never filter or navigate (that's the tabs/chips' job); if there are no
271
275
  cross-cutting numbers, drop them. Do NOT put a `KPIStrip` on a register page or a `SummaryLine` on
272
276
  a dashboard. Card stat rails use `KPICard`.
@@ -474,6 +478,23 @@ content per column and set `flex` from that. A column whose cell holds a PROPORT
474
478
  bar) is the exception in one direction only: cap it, because a gauge stops reading as an
475
479
  instrument once its track is long enough to be a rule across the row.
476
480
 
481
+ **The FIXED columns' widths are the flexible one's budget — so audit them by what they CARRY,
482
+ not by what they are called.** With one `flex` column among N fixed, every pixel a fixed column
483
+ takes comes out of the flexible one, silently and with no measurement pointing at the culprit:
484
+ the symptom appears somewhere else entirely, as truncated content in the column that was
485
+ supposed to have room. The usual offender is a column sized by its HEADER over single-character
486
+ data — a long label above a column of one-digit counts reserves width for a word nobody reads
487
+ twice, and the register's identity column pays for it. Measure the header row's cells and the
488
+ widest CELL content per column; where a column's width is set by its label rather than its
489
+ values, that difference is the identity column's, not its own.
490
+
491
+ **Space by the RATIO of within-group to between-group, never by absolutes.** Collect the gap
492
+ inside a composed band and the gap between that band and its neighbours, and divide. Near 1 is
493
+ the defect however sensible each number is alone — at 14 inside against 16 between, a headline,
494
+ a chart and a register read as one undifferentiated stack, because nothing on the screen is
495
+ grouped. Aim for roughly 2 or more, and change the BETWEEN gap first: tightening the inside
496
+ crowds the group to buy separation the page should have provided.
497
+
477
498
  Make a register SELECTABLE with the `Table` `leading` gutter + `selectAll` slot — a
478
499
  `CheckboxInput` per `TableRow` (its `leading` slot) + a select-all in the header band, the ticked
479
500
  rows `marked`, paired with a `FloatingActionBar` (its "Clear" escape is locale-resolved — pass
@@ -98,7 +98,7 @@ const KY_CUOI = "2026-06";
98
98
  const NHAN_BO_LOC: Partial<DateRangeFilterFieldLabels> = {
99
99
  year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM",
100
100
  today: "Today", yesterday: "Yesterday", tomorrow: "Tomorrow", thisWeek: "This week",
101
- thisMonth: "This month", lastMonth: "Last month", custom: "Custom", from: "From", to: "To",
101
+ thisMonth: "This month", lastMonth: "Last month", from: "From", to: "To",
102
102
  selectDateRange: "Select date range", selectDate: "Select date",
103
103
  clear: "Clear", done: "Done", placeholder: "All time",
104
104
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "41.4.1",
3
+ "version": "42.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -8,7 +8,10 @@
8
8
  "default": "./src/vite.mjs"
9
9
  },
10
10
  "./tokens": "./src/tokens.ts",
11
- "./colors": "./src/colors.ts",
11
+ "./colors": {
12
+ "react-native": "./src/colors.ts",
13
+ "default": "./src/colors.web.ts"
14
+ },
12
15
  "./deadline": "./src/deadline.ts",
13
16
  "./option_badge": "./src/option_badge.tsx",
14
17
  "./member_chip": "./src/member_chip.tsx",
@@ -67,6 +70,7 @@
67
70
  "./kpi_card": "./src/kpi_card.tsx",
68
71
  "./kpi_strip": "./src/kpi_strip.tsx",
69
72
  "./summary_line": "./src/summary_line.tsx",
73
+ "./summary": "./src/summary.tsx",
70
74
  "./empty_state": "./src/empty_state.tsx",
71
75
  "./error_state": "./src/error_state.tsx",
72
76
  "./format_date": "./src/format_date.ts",
@@ -85,7 +89,10 @@
85
89
  "./trend_footer": "./src/trend_footer.tsx",
86
90
  "./locale": "./src/locale.tsx",
87
91
  "./spacing": "./src/spacing.ts",
88
- "./theme": "./src/theme.tsx",
92
+ "./theme": {
93
+ "react-native": "./src/theme.tsx",
94
+ "default": "./src/theme.web.tsx"
95
+ },
89
96
  "./progress_bar": "./src/progress_bar.tsx",
90
97
  "./form_date_picker": "./src/form_date_picker.tsx",
91
98
  "./form_picker": "./src/form_picker.tsx",
package/src/avatar.tsx CHANGED
@@ -39,7 +39,12 @@ export function Avatar(props: AvatarProps) {
39
39
  aria-hidden={decorative || undefined}
40
40
  style={[
41
41
  styles.base,
42
- { backgroundColor: colors.blue["600"], width: px, height: px },
42
+ // The initials disc is the one place a member LOOKS like the product,
43
+ // so it carries the brand accent rather than a fixed blue. NOT a
44
+ // restyle: `colors.accent` DEFAULTS to this exact blue-600, so an app
45
+ // that themes nothing renders identical pixels, while one that sets an
46
+ // accent gets its own hue without this file knowing.
47
+ { backgroundColor: colors.accent, width: px, height: px },
43
48
  style,
44
49
  ]}
45
50
  >
@@ -46,7 +46,12 @@ export function Avatar(props: AvatarProps) {
46
46
  aria-hidden={decorative || undefined}
47
47
  style={[
48
48
  styles.base,
49
- { backgroundColor: colors.blue["600"], width: px, height: px },
49
+ // The initials disc is the one place a member LOOKS like the product,
50
+ // so it carries the brand accent rather than a fixed blue. NOT a
51
+ // restyle: `colors.accent` DEFAULTS to this exact blue-600, so an app
52
+ // that themes nothing renders identical pixels, while one that sets an
53
+ // accent gets its own hue without this file knowing.
54
+ { backgroundColor: colors.accent, width: px, height: px },
50
55
  style,
51
56
  ]}
52
57
  >
@@ -46,7 +46,16 @@ export const AVATAR_PX: Record<AvatarSize, number> = {
46
46
  * account-menu trigger in a top bar, a prominent row's leading figure. Equals
47
47
  * `CONTROL_HEIGHT` (`control_surface`), so it lines up with the icon buttons
48
48
  * beside it. It was 48, which anchored to nothing: the bar's other controls
49
- * are 40, and a 48px circle among them reads as the one thing that got out. */
49
+ * are 40, and a 48px circle among them reads as the one thing that got out.
50
+ *
51
+ * **In a register, the rung follows what the row IS ABOUT, not the density.**
52
+ * Where a person is one FIELD of the row — an owner, an assignee beside a
53
+ * dozen other columns — the figure is a value like any other and takes `md`.
54
+ * Where the person is the row's SUBJECT (a league table of reps, a roster,
55
+ * anything grouped BY member), the avatar is that row's leading figure and
56
+ * takes this rung; at `md` such a register reads as a list of numbers that
57
+ * happens to carry faces. Budget for it: this rung is 12px wider than `md`,
58
+ * and in a table it takes those 12 from the flexible column the name is in. */
50
59
  lg: 40,
51
60
  /** A profile's own header. */
52
61
  xl: 72,
@@ -0,0 +1,422 @@
1
+ const palette = {
2
+ black: "rgba(0,0,0,1)",
3
+ white: "rgba(255,255,255,1)",
4
+
5
+ // We use RGBA so that we can use React Native Animations with colors
6
+ red: {
7
+ 50: "rgba(254, 242, 242, 1)",
8
+ 100: "rgba(254, 226, 226, 1)",
9
+ 200: "rgba(254, 202, 202, 1)",
10
+ 300: "rgba(252, 165, 165, 1)",
11
+ 400: "rgba(248, 113, 113, 1)",
12
+ 500: "rgba(239, 68, 68, 1)",
13
+ 600: "rgba(220, 38, 38, 1)",
14
+ 700: "rgba(185, 28, 28, 1)",
15
+ 800: "rgba(153, 27, 27, 1)",
16
+ 900: "rgba(127, 29, 29, 1)",
17
+ 950: "rgba(69, 10, 10, 1)",
18
+ },
19
+ orange: {
20
+ 50: "rgba(255, 247, 237, 1)",
21
+ 100: "rgba(255, 237, 213, 1)",
22
+ 200: "rgba(254, 215, 170, 1)",
23
+ 300: "rgba(253, 186, 116, 1)",
24
+ 400: "rgba(251, 146, 60, 1)",
25
+ 500: "rgba(249, 115, 22, 1)",
26
+ 600: "rgba(234, 88, 12, 1)",
27
+ 700: "rgba(194, 65, 12, 1)",
28
+ 800: "rgba(154, 52, 18, 1)",
29
+ 900: "rgba(124, 45, 18, 1)",
30
+ 950: "rgba(67, 20, 7, 1)",
31
+ },
32
+ amber: {
33
+ 50: "rgba(255, 251, 235, 1)",
34
+ 100: "rgba(254, 243, 199, 1)",
35
+ 200: "rgba(253, 230, 138, 1)",
36
+ 300: "rgba(252, 211, 77, 1)",
37
+ 400: "rgba(251, 191, 36, 1)",
38
+ 500: "rgba(245, 158, 11, 1)",
39
+ 600: "rgba(217, 119, 6, 1)",
40
+ 700: "rgba(180, 83, 9, 1)",
41
+ 800: "rgba(146, 64, 14, 1)",
42
+ 900: "rgba(120, 53, 15, 1)",
43
+ 950: "rgba(69, 26, 3, 1)",
44
+ },
45
+ yellow: {
46
+ 50: "rgba(254, 252, 232, 1)",
47
+ 100: "rgba(254, 249, 195, 1)",
48
+ 200: "rgba(254, 240, 138, 1)",
49
+ 300: "rgba(253, 224, 71, 1)",
50
+ 400: "rgba(250, 204, 21, 1)",
51
+ 500: "rgba(234, 179, 8, 1)",
52
+ 600: "rgba(202, 138, 4, 1)",
53
+ 700: "rgba(161, 98, 7, 1)",
54
+ 800: "rgba(133, 77, 14, 1)",
55
+ 900: "rgba(113, 63, 18, 1)",
56
+ 950: "rgba(66, 32, 6, 1)",
57
+ },
58
+ lime: {
59
+ 50: "rgba(247, 254, 231, 1)",
60
+ 100: "rgba(236, 252, 203, 1)",
61
+ 200: "rgba(217, 249, 157, 1)",
62
+ 300: "rgba(190, 242, 100, 1)",
63
+ 400: "rgba(163, 230, 53, 1)",
64
+ 500: "rgba(132, 204, 22, 1)",
65
+ 600: "rgba(101, 163, 13, 1)",
66
+ 700: "rgba(77, 124, 15, 1)",
67
+ 800: "rgba(63, 98, 18, 1)",
68
+ 900: "rgba(54, 83, 20, 1)",
69
+ 950: "rgba(26, 46, 5, 1)",
70
+ },
71
+ green: {
72
+ 50: "rgba(240, 253, 244, 1)",
73
+ 100: "rgba(220, 252, 231, 1)",
74
+ 200: "rgba(187, 247, 208, 1)",
75
+ 300: "rgba(134, 239, 172, 1)",
76
+ 400: "rgba(74, 222, 128, 1)",
77
+ 500: "rgba(34, 197, 94, 1)",
78
+ 600: "rgba(22, 163, 74, 1)",
79
+ 700: "rgba(21, 128, 61, 1)",
80
+ 800: "rgba(22, 101, 52, 1)",
81
+ 900: "rgba(20, 83, 45, 1)",
82
+ 950: "rgba(5, 46, 22, 1)",
83
+ },
84
+ emerald: {
85
+ 50: "rgba(236, 253, 245, 1)",
86
+ 100: "rgba(209, 250, 229, 1)",
87
+ 200: "rgba(167, 243, 208, 1)",
88
+ 300: "rgba(110, 231, 183, 1)",
89
+ 400: "rgba(52, 211, 153, 1)",
90
+ 500: "rgba(16, 185, 129, 1)",
91
+ 600: "rgba(5, 150, 105, 1)",
92
+ 700: "rgba(4, 120, 87, 1)",
93
+ 800: "rgba(6, 95, 70, 1)",
94
+ 900: "rgba(6, 78, 59, 1)",
95
+ 950: "rgba(2, 44, 34, 1)",
96
+ },
97
+ teal: {
98
+ 50: "rgba(240, 253, 250, 1)",
99
+ 100: "rgba(204, 251, 241, 1)",
100
+ 200: "rgba(153, 246, 228, 1)",
101
+ 300: "rgba(94, 234, 212, 1)",
102
+ 400: "rgba(45, 212, 191, 1)",
103
+ 500: "rgba(20, 184, 166, 1)",
104
+ 600: "rgba(13, 148, 136, 1)",
105
+ 700: "rgba(15, 118, 110, 1)",
106
+ 800: "rgba(17, 94, 89, 1)",
107
+ 900: "rgba(19, 78, 74, 1)",
108
+ 950: "rgba(4, 47, 46, 1)",
109
+ },
110
+ cyan: {
111
+ 50: "rgba(236, 254, 255, 1)",
112
+ 100: "rgba(207, 250, 254, 1)",
113
+ 200: "rgba(165, 243, 252, 1)",
114
+ 300: "rgba(103, 232, 249, 1)",
115
+ 400: "rgba(34, 211, 238, 1)",
116
+ 500: "rgba(6, 182, 212, 1)",
117
+ 600: "rgba(8, 145, 178, 1)",
118
+ 700: "rgba(14, 116, 144, 1)",
119
+ 800: "rgba(21, 94, 117, 1)",
120
+ 900: "rgba(22, 78, 99, 1)",
121
+ 950: "rgba(8, 51, 68, 1)",
122
+ },
123
+ sky: {
124
+ 50: "rgba(240, 249, 255, 1)",
125
+ 100: "rgba(224, 242, 254, 1)",
126
+ 200: "rgba(186, 230, 253, 1)",
127
+ 300: "rgba(125, 211, 252, 1)",
128
+ 400: "rgba(56, 189, 248, 1)",
129
+ 500: "rgba(14, 165, 233, 1)",
130
+ 600: "rgba(2, 132, 199, 1)",
131
+ 700: "rgba(3, 105, 161, 1)",
132
+ 800: "rgba(7, 89, 133, 1)",
133
+ 900: "rgba(12, 74, 110, 1)",
134
+ 950: "rgba(8, 47, 73, 1)",
135
+ },
136
+ blue: {
137
+ 50: "rgba(239, 246, 255, 1)",
138
+ 100: "rgba(219, 234, 254, 1)",
139
+ 200: "rgba(191, 219, 254, 1)",
140
+ 300: "rgba(147, 197, 253, 1)",
141
+ 400: "rgba(96, 165, 250, 1)",
142
+ 500: "rgba(59, 130, 246, 1)",
143
+ 600: "rgba(37, 99, 235, 1)",
144
+ 700: "rgba(29, 78, 216, 1)",
145
+ 800: "rgba(30, 64, 175, 1)",
146
+ 900: "rgba(30, 58, 138, 1)",
147
+ 950: "rgba(23, 37, 84, 1)",
148
+ },
149
+ indigo: {
150
+ 50: "rgba(238, 242, 255, 1)",
151
+ 100: "rgba(224, 231, 255, 1)",
152
+ 200: "rgba(199, 210, 254, 1)",
153
+ 300: "rgba(165, 180, 252, 1)",
154
+ 400: "rgba(129, 140, 248, 1)",
155
+ 500: "rgba(99, 102, 241, 1)",
156
+ 600: "rgba(79, 70, 229, 1)",
157
+ 700: "rgba(67, 56, 202, 1)",
158
+ 800: "rgba(55, 48, 163, 1)",
159
+ 900: "rgba(49, 46, 129, 1)",
160
+ 950: "rgba(30, 27, 75, 1)",
161
+ },
162
+ violet: {
163
+ 50: "rgba(245, 243, 255, 1)",
164
+ 100: "rgba(237, 233, 254, 1)",
165
+ 200: "rgba(221, 214, 254, 1)",
166
+ 300: "rgba(196, 181, 253, 1)",
167
+ 400: "rgba(167, 139, 250, 1)",
168
+ 500: "rgba(139, 92, 246, 1)",
169
+ 600: "rgba(124, 58, 237, 1)",
170
+ 700: "rgba(109, 40, 217, 1)",
171
+ 800: "rgba(91, 33, 182, 1)",
172
+ 900: "rgba(76, 29, 149, 1)",
173
+ 950: "rgba(46, 16, 101, 1)",
174
+ },
175
+ purple: {
176
+ 50: "rgba(250, 245, 255, 1)",
177
+ 100: "rgba(243, 232, 255, 1)",
178
+ 200: "rgba(233, 213, 255, 1)",
179
+ 300: "rgba(216, 180, 254, 1)",
180
+ 400: "rgba(192, 132, 252, 1)",
181
+ 500: "rgba(168, 85, 247, 1)",
182
+ 600: "rgba(147, 51, 234, 1)",
183
+ 700: "rgba(126, 34, 206, 1)",
184
+ 800: "rgba(107, 33, 168, 1)",
185
+ 900: "rgba(88, 28, 135, 1)",
186
+ 950: "rgba(59, 7, 100, 1)",
187
+ },
188
+ fuchsia: {
189
+ 50: "rgba(253, 244, 255, 1)",
190
+ 100: "rgba(250, 232, 255, 1)",
191
+ 200: "rgba(245, 208, 254, 1)",
192
+ 300: "rgba(240, 171, 252, 1)",
193
+ 400: "rgba(232, 121, 249, 1)",
194
+ 500: "rgba(217, 70, 239, 1)",
195
+ 600: "rgba(192, 38, 211, 1)",
196
+ 700: "rgba(162, 28, 175, 1)",
197
+ 800: "rgba(134, 25, 143, 1)",
198
+ 900: "rgba(112, 26, 117, 1)",
199
+ 950: "rgba(74, 4, 78, 1)",
200
+ },
201
+ pink: {
202
+ 50: "rgba(253, 242, 248, 1)",
203
+ 100: "rgba(252, 231, 243, 1)",
204
+ 200: "rgba(251, 207, 232, 1)",
205
+ 300: "rgba(249, 168, 212, 1)",
206
+ 400: "rgba(244, 114, 182, 1)",
207
+ 500: "rgba(236, 72, 153, 1)",
208
+ 600: "rgba(219, 39, 119, 1)",
209
+ 700: "rgba(190, 24, 93, 1)",
210
+ 800: "rgba(157, 23, 77, 1)",
211
+ 900: "rgba(131, 24, 67, 1)",
212
+ 950: "rgba(80, 7, 36, 1)",
213
+ },
214
+ rose: {
215
+ 50: "rgba(255, 241, 242, 1)",
216
+ 100: "rgba(255, 228, 230, 1)",
217
+ 200: "rgba(254, 205, 211, 1)",
218
+ 300: "rgba(253, 164, 175, 1)",
219
+ 400: "rgba(251, 113, 133, 1)",
220
+ 500: "rgba(244, 63, 94, 1)",
221
+ 600: "rgba(225, 29, 72, 1)",
222
+ 700: "rgba(190, 18, 60, 1)",
223
+ 800: "rgba(159, 18, 57, 1)",
224
+ 900: "rgba(136, 19, 55, 1)",
225
+ 950: "rgba(76, 5, 25, 1)",
226
+ },
227
+ slate: {
228
+ 50: "rgba(248, 250, 252, 1)",
229
+ 100: "rgba(241, 245, 249, 1)",
230
+ 200: "rgba(226, 232, 240, 1)",
231
+ 300: "rgba(203, 213, 225, 1)",
232
+ 400: "rgba(148, 163, 184, 1)",
233
+ 500: "rgba(100, 116, 139, 1)",
234
+ 600: "rgba(71, 85, 105, 1)",
235
+ 700: "rgba(51, 65, 85, 1)",
236
+ 800: "rgba(30, 41, 59, 1)",
237
+ 900: "rgba(15, 23, 42, 1)",
238
+ 950: "rgba(2, 6, 23, 1)",
239
+ },
240
+ gray: {
241
+ 50: "rgba(249, 250, 251, 1)",
242
+ 100: "rgba(243, 244, 246, 1)",
243
+ 200: "rgba(229, 231, 235, 1)",
244
+ 300: "rgba(209, 213, 219, 1)",
245
+ 400: "rgba(156, 163, 175, 1)",
246
+ 500: "rgba(107, 114, 128, 1)",
247
+ 600: "rgba(75, 85, 99, 1)",
248
+ 700: "rgba(55, 65, 81, 1)",
249
+ 800: "rgba(31, 41, 55, 1)",
250
+ 900: "rgba(17, 24, 39, 1)",
251
+ 950: "rgba(3, 7, 18, 1)",
252
+ },
253
+ zinc: {
254
+ 50: "rgba(250, 250, 250, 1)",
255
+ 100: "rgba(244, 244, 245, 1)",
256
+ 200: "rgba(228, 228, 231, 1)",
257
+ 300: "rgba(212, 212, 216, 1)",
258
+ 400: "rgba(161, 161, 170, 1)",
259
+ 500: "rgba(113, 113, 122, 1)",
260
+ 600: "rgba(82, 82, 91, 1)",
261
+ 700: "rgba(63, 63, 70, 1)",
262
+ 800: "rgba(39, 39, 42, 1)",
263
+ 900: "rgba(24, 24, 27, 1)",
264
+ 950: "rgba(9, 9, 11, 1)",
265
+ },
266
+ neutral: {
267
+ 50: "rgba(250, 250, 250, 1)",
268
+ 100: "rgba(245, 245, 245, 1)",
269
+ 200: "rgba(229, 229, 229, 1)",
270
+ 300: "rgba(212, 212, 212, 1)",
271
+ 400: "rgba(163, 163, 163, 1)",
272
+ 500: "rgba(115, 115, 115, 1)",
273
+ 600: "rgba(82, 82, 82, 1)",
274
+ 700: "rgba(64, 64, 64, 1)",
275
+ 800: "rgba(38, 38, 38, 1)",
276
+ 900: "rgba(23, 23, 23, 1)",
277
+ 950: "rgba(10, 10, 10, 1)",
278
+ },
279
+ stone: {
280
+ 50: "rgba(250, 250, 249, 1)",
281
+ 100: "rgba(245, 245, 244, 1)",
282
+ 200: "rgba(231, 229, 228, 1)",
283
+ 300: "rgba(214, 211, 209, 1)",
284
+ 400: "rgba(168, 162, 158, 1)",
285
+ 500: "rgba(120, 113, 108, 1)",
286
+ 600: "rgba(87, 83, 78, 1)",
287
+ 700: "rgba(68, 64, 60, 1)",
288
+ 800: "rgba(41, 37, 36, 1)",
289
+ 900: "rgba(28, 25, 23, 1)",
290
+ 950: "rgba(12, 10, 9, 1)",
291
+ },
292
+ };
293
+
294
+ /**
295
+ * THE THREE THEMEABLE ROLES are `background`, `border` and `accent` — and
296
+ * deliberately nothing else. See `colors.web.ts`, which redefines exactly
297
+ * these as CSS variables so an app can carry its own identity.
298
+ *
299
+ * The line is drawn at MEANING, not at convenience. A palette family
300
+ * (`red`, `amber`, `emerald`, `zinc`…) is how this system says what something
301
+ * IS — a status, a valence, a series — so letting an app repaint those would
302
+ * not restyle the product, it would make it lie. What is left over is chrome:
303
+ * the paper, the paper's edge, and the one hue that is the customer's rather
304
+ * than ours. An app can own all of that and change no claim the UI makes.
305
+ *
306
+ * This file is the CONTRACT and stays literal: TypeScript resolves `./colors`
307
+ * here (never the `.web` variant), so both files must carry the same keys or an
308
+ * app reading `colors.accent` would fail to typecheck against the platform it
309
+ * actually runs on. It is also what native renders, where `var()` means nothing.
310
+ */
311
+ export const colors = {
312
+ ...palette,
313
+ border: palette.zinc["200"],
314
+ border_shadow: `0px 1px 2px 0px ${palette.zinc["200"]}`,
315
+ /** A RAISED surface — Card, Drawer, Modal, the things that sit on the page. */
316
+ background: palette.white,
317
+ /**
318
+ * The one brand hue, and its reach is narrower than the word suggests: it
319
+ * paints IDENTITY marks — today the avatar's initials disc, the one place a
320
+ * person is rendered as a coloured shape, on every register in every app.
321
+ *
322
+ * It deliberately does NOT paint interaction chrome. The focus ring is
323
+ * `zinc[900]` and the primary action is near-black because those are the KIT's
324
+ * semantics, shared across every app so a reader learns them once; repainting
325
+ * them per tenant would make "what does a focus ring look like" a per-app
326
+ * question. And never for DATA: a series, a status and a valence all come from
327
+ * a palette family, so an accent can never overwrite a meaning.
328
+ */
329
+ accent: palette.blue["600"],
330
+ shadow: `0px 0px 6px 1px ${palette.zinc["300"]}`,
331
+ };
332
+
333
+ /**
334
+ * A low-alpha wash of a palette color (hover tints, dimmed chart segments,
335
+ * icon discs). Handles the palette's rgba() strings plus rgb()/#RRGGBB.
336
+ */
337
+ export function withAlpha(color: string, alpha: number): string {
338
+ if (color.startsWith("rgba(")) return color.replace(/,\s*[\d.]+\s*\)\s*$/, `, ${alpha})`);
339
+ if (color.startsWith("rgb(")) return color.replace("rgb(", "rgba(").replace(/\)\s*$/, `, ${alpha})`);
340
+ if (color.startsWith("#") && color.length === 7) {
341
+ return `${color}${Math.round(alpha * 255)
342
+ .toString(16)
343
+ .padStart(2, "0")}`;
344
+ }
345
+ return color;
346
+ }
347
+
348
+ /**
349
+ * A palette FAMILY name — the single, semantic way to reference a color across
350
+ * the system (Badge, status indicators, chart series, breakdown segments).
351
+ * Reference colors by NAME and let the component resolve the shade it needs;
352
+ * never thread a raw hex through props — it drifts (one call site picks 500,
353
+ * another 600, and the same "status" renders two greens). Excludes every
354
+ * non-scale role key — the four themeable roles included, since `solid("accent")`
355
+ * would otherwise typecheck and hand back `undefined` from a family that has no
356
+ * shades.
357
+ */
358
+ export type ColorName = Exclude<
359
+ keyof typeof colors,
360
+ "border" | "border_shadow" | "background" | "accent" | "shadow" | "black" | "white"
361
+ >;
362
+
363
+ /**
364
+ * The canonical SOLID shade of a family (500) — status dots, status-grid
365
+ * cells, breakdown segments, chart series. Defined ONCE so every indicator of
366
+ * the same color agrees on the shade.
367
+ */
368
+ export function solid(name: ColorName): string {
369
+ return colors[name][500];
370
+ }
371
+
372
+ /** A low-alpha wash of a family's solid shade — cell/segment tints, dimmed
373
+ * states. `tint("emerald", 0.2)` === `withAlpha(solid("emerald"), 0.2)`. */
374
+ export function tint(name: ColorName, alpha: number): string {
375
+ return withAlpha(solid(name), alpha);
376
+ }
377
+
378
+ /** Dark → light stops a `ramp` spans (the usable mid range of a scale). */
379
+ const RAMP_STOPS = [700, 600, 500, 400, 300, 200] as const;
380
+
381
+ /**
382
+ * `count` distinct shades of ONE family, strong → light — the monochrome
383
+ * "one hue family per dimension" data ramp (a Breakdown's segments, an ordered
384
+ * funnel, any multi-category breakdown of a single dimension). Use this for a
385
+ * COHERENT dimension instead of hand-picking shades or scattering hues; the
386
+ * segment LABEL carries identity, the shade only orders. (For semantic
387
+ * categories whose color carries MEANING — status — give each its own
388
+ * `ColorName` and `solid()` it, don't ramp.)
389
+ */
390
+ export function ramp(name: ColorName, count: number): string[] {
391
+ const scale = colors[name];
392
+ if (count <= 1) return [scale[600]];
393
+ return Array.from({ length: count }, (_, i) => {
394
+ const idx = Math.round((i / (count - 1)) * (RAMP_STOPS.length - 1));
395
+ return scale[RAMP_STOPS[idx]];
396
+ });
397
+ }
398
+
399
+ /**
400
+ * Is `value` a usable {@link ColorName} — a palette FAMILY, not a role key
401
+ * (`border`/`background`/…) and not `black`/`white`? A family resolves to a
402
+ * shade object; the role/scalar keys resolve to a string, so "value is an
403
+ * object" is the test (no name list to keep in sync).
404
+ */
405
+ export function isColorName(value: unknown): value is ColorName {
406
+ return (
407
+ typeof value === "string" &&
408
+ value in colors &&
409
+ typeof (colors as Record<string, unknown>)[value] === "object"
410
+ );
411
+ }
412
+
413
+ /**
414
+ * Coerce an arbitrary color token to a {@link ColorName}, falling back to a
415
+ * neutral. The single graceful-degradation point for stored option/status
416
+ * colors: a select option's `color` may be a token a newer table config
417
+ * introduced that this UI build predates, or absent entirely — either way a
418
+ * component renders a neutral badge instead of breaking. Used by `OptionBadge`.
419
+ */
420
+ export function asColorName(value: unknown, fallback: ColorName = "zinc"): ColorName {
421
+ return isColorName(value) ? value : fallback;
422
+ }