@bitforgehq/ui 0.3.0 → 0.3.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.
Files changed (158) hide show
  1. package/CHANGELOG.md +598 -0
  2. package/README.md +18 -11
  3. package/ai/MANIFEST.index.json +306 -0
  4. package/ai/MANIFEST.json +8915 -3788
  5. package/ai/REFERENCE.index.json +476 -0
  6. package/ai/REFERENCE.json +5810 -2905
  7. package/ai/UI_GUIDE.md +212 -58
  8. package/ai/components/app-frame.md +328 -0
  9. package/ai/components/back-button.md +42 -2
  10. package/ai/components/badge.md +17 -7
  11. package/ai/components/bar-chart.md +188 -0
  12. package/ai/components/breadcrumb-item.md +121 -0
  13. package/ai/components/breadcrumb.md +277 -0
  14. package/ai/components/button.md +82 -15
  15. package/ai/components/calendar.md +300 -0
  16. package/ai/components/card.md +119 -4
  17. package/ai/components/chart-card.md +283 -0
  18. package/ai/components/combobox.md +411 -0
  19. package/ai/components/date-input.md +396 -0
  20. package/ai/components/datetime-input.md +215 -0
  21. package/ai/components/description-item.md +111 -0
  22. package/ai/components/description-list.md +160 -0
  23. package/ai/components/donut-chart.md +169 -0
  24. package/ai/components/empty-state.md +99 -6
  25. package/ai/components/header.md +71 -2
  26. package/ai/components/icon-button.md +74 -16
  27. package/ai/components/icon.md +63 -7
  28. package/ai/components/line-chart.md +234 -0
  29. package/ai/components/list.md +70 -6
  30. package/ai/components/menu.md +5 -0
  31. package/ai/components/multi-combobox.md +257 -0
  32. package/ai/components/nav-group.md +178 -0
  33. package/ai/components/nav-item.md +210 -0
  34. package/ai/components/page-header.md +237 -0
  35. package/ai/components/pagination.md +173 -0
  36. package/ai/components/popover.md +250 -0
  37. package/ai/components/side-panel.md +391 -0
  38. package/ai/components/sidebar-toggle.md +287 -0
  39. package/ai/components/sidebar.md +541 -0
  40. package/ai/components/skeleton.md +215 -0
  41. package/ai/components/sparkline.md +188 -0
  42. package/ai/components/stat.md +297 -0
  43. package/ai/components/tab-group.md +226 -0
  44. package/ai/components/tab-panel.md +163 -0
  45. package/ai/components/table.md +697 -0
  46. package/ai/components/tabs.md +46 -2
  47. package/ai/components/theme-menu-button.md +182 -0
  48. package/ai/components/theme-switch.md +170 -0
  49. package/ai/components/time-input.md +240 -0
  50. package/ai/components/toolbar.md +354 -0
  51. package/ai/components/tooltip.md +230 -0
  52. package/ai/components/top-nav.md +159 -0
  53. package/ai/reference/bf-app-frame-breakpoint.md +27 -0
  54. package/ai/reference/bf-app-frame-content-width.md +29 -0
  55. package/ai/reference/bf-badge-appearance.md +36 -0
  56. package/ai/reference/bf-badge-variant.md +1 -1
  57. package/ai/reference/bf-bar-chart-orientation.md +27 -0
  58. package/ai/reference/bf-button-appearance.md +36 -0
  59. package/ai/reference/bf-button-variant.md +1 -1
  60. package/ai/reference/bf-calendar-disabled-date.md +23 -0
  61. package/ai/reference/bf-calendar-harness.md +51 -0
  62. package/ai/reference/bf-calendar-weekday.md +31 -0
  63. package/ai/reference/bf-chart-card-heading-level.md +26 -0
  64. package/ai/reference/bf-chart-curve.md +28 -0
  65. package/ai/reference/bf-chart-harness.md +51 -0
  66. package/ai/reference/bf-chart-legend-position.md +30 -0
  67. package/ai/reference/bf-chart-series.md +23 -0
  68. package/ai/reference/bf-chart-value-format.md +23 -0
  69. package/ai/reference/bf-color-scheme-service.md +30 -0
  70. package/ai/reference/bf-color-scheme.md +31 -0
  71. package/ai/reference/bf-combobox-filter.md +27 -0
  72. package/ai/reference/bf-combobox-harness.md +89 -0
  73. package/ai/reference/bf-combobox-option.md +33 -0
  74. package/ai/reference/bf-compare-with.md +2 -0
  75. package/ai/reference/bf-date-input-harness.md +67 -0
  76. package/ai/reference/bf-date-input-picker.md +27 -0
  77. package/ai/reference/bf-date-time-input-harness.md +76 -0
  78. package/ai/reference/bf-date-time-input-picker.md +27 -0
  79. package/ai/reference/bf-description-list-layout.md +26 -0
  80. package/ai/reference/bf-donut-slice.md +22 -0
  81. package/ai/reference/bf-empty-state-heading-level.md +1 -1
  82. package/ai/reference/bf-empty-state-heading.md +33 -0
  83. package/ai/reference/bf-labels.md +73 -20
  84. package/ai/reference/bf-menu-button-variant.md +1 -0
  85. package/ai/reference/bf-menu-placement.md +1 -0
  86. package/ai/reference/bf-overlay-options.md +28 -27
  87. package/ai/reference/bf-overlay-placement.md +34 -0
  88. package/ai/reference/bf-overlay-position.md +1 -1
  89. package/ai/reference/bf-overlay-service.md +5 -3
  90. package/ai/reference/bf-page-header-heading-level.md +26 -0
  91. package/ai/reference/bf-pagination-harness.md +61 -0
  92. package/ai/reference/bf-platform-service.md +17 -15
  93. package/ai/reference/bf-popover-harness.md +42 -0
  94. package/ai/reference/bf-popover-placement.md +28 -0
  95. package/ai/reference/bf-popover-trigger-directive.md +56 -0
  96. package/ai/reference/bf-side-panel-config.md +35 -0
  97. package/ai/reference/bf-side-panel-harness.md +34 -0
  98. package/ai/reference/bf-side-panel-ref.md +44 -0
  99. package/ai/reference/bf-side-panel-service.md +43 -0
  100. package/ai/reference/bf-side-panel-template-context.md +32 -0
  101. package/ai/reference/bf-side-panel-width.md +28 -0
  102. package/ai/reference/bf-sidebar-state-service.md +21 -0
  103. package/ai/reference/bf-sidebar-toggle-harness.md +32 -0
  104. package/ai/reference/bf-size.md +13 -0
  105. package/ai/reference/bf-skeleton-shape.md +28 -0
  106. package/ai/reference/bf-stat-appearance.md +33 -0
  107. package/ai/reference/bf-stat-trend-intent.md +27 -0
  108. package/ai/reference/bf-stat-trend.md +27 -0
  109. package/ai/reference/bf-stat-variant.md +26 -0
  110. package/ai/reference/bf-tab-group-harness.md +44 -0
  111. package/ai/reference/bf-table-align.md +26 -0
  112. package/ai/reference/bf-table-cell-context.md +24 -0
  113. package/ai/reference/bf-table-cell-directive.md +22 -0
  114. package/ai/reference/bf-table-column.md +28 -0
  115. package/ai/reference/bf-table-harness.md +77 -0
  116. package/ai/reference/bf-table-row-key.md +21 -0
  117. package/ai/reference/bf-table-sort-direction.md +26 -0
  118. package/ai/reference/bf-table-sort.md +27 -0
  119. package/ai/reference/bf-table-stack.md +29 -0
  120. package/ai/reference/bf-theme.md +5 -4
  121. package/ai/reference/bf-time-input-harness.md +67 -0
  122. package/ai/reference/bf-time-input-hour-cycle.md +27 -0
  123. package/ai/reference/bf-time-input-picker.md +27 -0
  124. package/ai/reference/bf-tooltip-directive.md +43 -0
  125. package/ai/reference/bf-tooltip-harness.md +43 -0
  126. package/ai/reference/bf-tooltip-placement.md +28 -0
  127. package/ai/reference/bf_color_scheme_storage.md +20 -0
  128. package/ai/reference/bf_side_panel_data.md +17 -0
  129. package/ai/reference/bf_sidebar_state_storage.md +20 -0
  130. package/ai/reference/bf_theme_storage.md +3 -1
  131. package/ai/reference/provide-bf-color-scheme.md +20 -0
  132. package/ai/reference/provide-bf-labels.md +13 -4
  133. package/ai/reference/provide-bf-sidebar-state.md +20 -0
  134. package/ai/reference/slide-in-from-end.md +24 -0
  135. package/ai/reference/slide-out-to-end.md +24 -0
  136. package/bin/bf-ui.js +433 -3
  137. package/fesm2022/bitforgehq-ui-charts.mjs +3045 -0
  138. package/fesm2022/bitforgehq-ui-charts.mjs.map +1 -0
  139. package/fesm2022/bitforgehq-ui-icons.mjs +107 -3
  140. package/fesm2022/bitforgehq-ui-icons.mjs.map +1 -1
  141. package/fesm2022/bitforgehq-ui-mobile.mjs +14 -59
  142. package/fesm2022/bitforgehq-ui-mobile.mjs.map +1 -1
  143. package/fesm2022/bitforgehq-ui-overlay.mjs +6566 -38
  144. package/fesm2022/bitforgehq-ui-overlay.mjs.map +1 -1
  145. package/fesm2022/bitforgehq-ui-testing.mjs +634 -1
  146. package/fesm2022/bitforgehq-ui-testing.mjs.map +1 -1
  147. package/fesm2022/bitforgehq-ui.mjs +5708 -137
  148. package/fesm2022/bitforgehq-ui.mjs.map +1 -1
  149. package/llms-full.txt +26766 -14470
  150. package/llms.txt +106 -1
  151. package/package.json +7 -3
  152. package/theme.css +101 -0
  153. package/types/bitforgehq-ui-charts.d.ts +967 -0
  154. package/types/bitforgehq-ui-icons.d.ts +13 -1
  155. package/types/bitforgehq-ui-mobile.d.ts +25 -18
  156. package/types/bitforgehq-ui-overlay.d.ts +1895 -7
  157. package/types/bitforgehq-ui-testing.d.ts +782 -1
  158. package/types/bitforgehq-ui.d.ts +2393 -45
package/bin/bf-ui.js CHANGED
@@ -3,16 +3,24 @@
3
3
  * bf-ui CLI, shipped inside @bitforgehq/ui.
4
4
  *
5
5
  * Usage: bf-ui sync-ai [--check]
6
+ * bf-ui theme <hex> [--dark <hex>]
6
7
  *
7
- * Installs (or checks) the agent guide `ai/UI_GUIDE.md`, bundled next to this script, into the
8
- * consuming project as `.ai/UI_GUIDE.md`.
8
+ * `sync-ai` installs (or checks) the agent guide `ai/UI_GUIDE.md`, bundled next to this script,
9
+ * into the consuming project as `.ai/UI_GUIDE.md`.
10
+ *
11
+ * `theme` derives the brand token set (`--color-primary` and the tokens built on it) for light and
12
+ * dark mode from one brand colour, checks every contrast pair the widgets draw with it, and prints
13
+ * a `@theme` block and a `.dark` block to paste after the theme import. It exits 1, naming each
14
+ * failing pair and a shade that passes, when a pair misses its minimum.
9
15
  */
10
16
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
17
  import { dirname, join } from 'node:path';
12
18
  import { fileURLToPath } from 'node:url';
13
19
  import process from 'node:process';
14
20
 
15
- const USAGE = 'Usage: bf-ui sync-ai [--check]';
21
+ const USAGE = ['Usage: bf-ui sync-ai [--check]', ' bf-ui theme <hex> [--dark <hex>]'].join(
22
+ '\n',
23
+ );
16
24
 
17
25
  const CLAUDE_SNIPPET = `## Shared UI library
18
26
  - Before creating any UI primitive, read \`.ai/UI_GUIDE.md\` (from @bitforgehq/ui).
@@ -59,10 +67,432 @@ function syncAi({ check }) {
59
67
  console.log(CLAUDE_SNIPPET);
60
68
  }
61
69
 
70
+ /* ---------- theme: colour maths (OKLCH and WCAG, the same formulas as culori) ---------- */
71
+
72
+ /** WCAG 1.4.3 (text) and 1.4.11 (non-text: state cues, boundaries, focus rings). */
73
+ const TEXT = 4.5;
74
+ const NON_TEXT = 3;
75
+ const WHITE = '#ffffff';
76
+ /** Dark-mode text on `primary` when the deep tint of the brand misses 4.5:1. */
77
+ const DARK_ON_PRIMARY_FALLBACK = '#09090b';
78
+ /** Lightness search bounds and step (OKLCH `l`). */
79
+ const L_FLOOR = 0.05;
80
+ const L_CEILING = 0.95;
81
+ const L_STEP = 0.01;
82
+
83
+ /**
84
+ * The shipped neutrals of `theme.css` the brand tokens are drawn on, per mode. A brand override
85
+ * leaves them alone; `tools/bf-ui-cli.test.ts` keeps them equal to `theme.css`.
86
+ */
87
+ const NEUTRALS = {
88
+ light: {
89
+ bg: '#f7f5ef',
90
+ surface: '#ffffff',
91
+ 'surface-muted': '#efece5',
92
+ 'surface-sunken': '#efece3',
93
+ text: '#1f2a22',
94
+ },
95
+ dark: {
96
+ bg: '#111214',
97
+ surface: '#1e2024',
98
+ 'surface-muted': '#262930',
99
+ 'surface-sunken': '#18191c',
100
+ text: '#ececee',
101
+ },
102
+ };
103
+
104
+ /**
105
+ * Every pair of `tools/theme-contrast.test.ts` that involves a brand token (the rest, status,
106
+ * tooltip and chart colours, does not change with the brand). `primary` on `surface` and `bg` is
107
+ * the checkbox box, radio ring and toggle track: the shipped theme meets 3:1 there, so a brand
108
+ * colour has to as well.
109
+ */
110
+ const PAIRS = [
111
+ { fg: 'on-primary', bg: 'primary', min: TEXT, use: 'primary button, count badge, check mark' },
112
+ { fg: 'on-primary', bg: 'primary-hover', min: TEXT, use: 'primary button hover' },
113
+ { fg: 'on-primary', bg: 'primary-pressed', min: TEXT, use: 'primary button pressed' },
114
+ { fg: 'primary-text', bg: 'bg', min: TEXT, use: 'text button, link, field label' },
115
+ { fg: 'primary-text', bg: 'surface', min: TEXT, use: 'text button, link on a card' },
116
+ { fg: 'primary-strong', bg: 'primary-soft', min: TEXT, use: 'tonal button, chip, badge, tab' },
117
+ { fg: 'primary-strong', bg: 'primary-soft-hover', min: TEXT, use: 'tonal hover' },
118
+ { fg: 'primary-strong', bg: 'primary-soft-pressed', min: TEXT, use: 'tonal, text, tab pressed' },
119
+ { fg: 'primary-strong', bg: 'bg', min: TEXT, use: 'text button hover' },
120
+ { fg: 'primary-strong', bg: 'surface', min: TEXT, use: 'text button hover, radio dot' },
121
+ { fg: 'primary-strong', bg: 'surface-sunken', min: NON_TEXT, use: 'segment cue, progress' },
122
+ { fg: 'primary-strong', bg: 'surface-muted', min: NON_TEXT, use: 'read-only toggle and radio' },
123
+ { fg: 'text', bg: 'primary-soft-pressed', min: TEXT, use: 'outline button pressed' },
124
+ { fg: 'ring', bg: 'bg', min: NON_TEXT, use: 'focus ring' },
125
+ { fg: 'ring', bg: 'surface', min: NON_TEXT, use: 'focus ring on a card' },
126
+ { fg: 'primary', bg: 'surface', min: NON_TEXT, use: 'checkbox box, radio ring' },
127
+ { fg: 'primary', bg: 'bg', min: NON_TEXT, use: 'toggle track on the page' },
128
+ ];
129
+
130
+ /** The brand tokens in the order `theme.css` declares them. */
131
+ const TOKEN_ORDER = [
132
+ 'primary',
133
+ 'primary-hover',
134
+ 'primary-pressed',
135
+ 'primary-soft',
136
+ 'primary-soft-hover',
137
+ 'primary-soft-pressed',
138
+ 'primary-strong',
139
+ 'on-primary',
140
+ 'primary-text',
141
+ 'selected',
142
+ 'ring',
143
+ ];
144
+
145
+ /** `#rgb` or `#rrggbb` (the `#` optional), lower-cased to `#rrggbb`; `undefined` when invalid. */
146
+ function normalizeHex(value) {
147
+ const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value ?? '');
148
+ if (!match) {
149
+ return undefined;
150
+ }
151
+ const digits = match[1].toLowerCase();
152
+ return `#${digits.length === 3 ? [...digits].map((d) => d + d).join('') : digits}`;
153
+ }
154
+
155
+ function hexToRgb(hex) {
156
+ const n = Number.parseInt(hex.slice(1), 16);
157
+ return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255 };
158
+ }
159
+
160
+ function toLinear(c) {
161
+ const abs = Math.abs(c);
162
+ return abs <= 0.04045 ? c / 12.92 : (Math.sign(c) || 1) * Math.pow((abs + 0.055) / 1.055, 2.4);
163
+ }
164
+
165
+ function fromLinear(c) {
166
+ const abs = Math.abs(c);
167
+ return abs > 0.0031308
168
+ ? (Math.sign(c) || 1) * (1.055 * Math.pow(abs, 1 / 2.4) - 0.055)
169
+ : c * 12.92;
170
+ }
171
+
172
+ /** A `#rrggbb` colour as OKLCH (`h` is undefined for a grey). */
173
+ function hexToOklch(hex) {
174
+ const rgb = hexToRgb(hex);
175
+ const r = toLinear(rgb.r);
176
+ const g = toLinear(rgb.g);
177
+ const b = toLinear(rgb.b);
178
+ const L = Math.cbrt(0.412221469470763 * r + 0.5363325372617348 * g + 0.0514459932675022 * b);
179
+ const M = Math.cbrt(0.2119034958178252 * r + 0.6806995506452344 * g + 0.1073969535369406 * b);
180
+ const S = Math.cbrt(0.0883024591900564 * r + 0.2817188391361215 * g + 0.6299787016738222 * b);
181
+ const l = 0.210454268309314 * L + 0.7936177747023054 * M - 0.0040720430116193 * S;
182
+ let a = 1.9779985324311684 * L - 2.4285922420485799 * M + 0.450593709617411 * S;
183
+ let bb = 0.0259040424655478 * L + 0.7827717124575296 * M - 0.8086757549230774 * S;
184
+ if (rgb.r === rgb.g && rgb.g === rgb.b) {
185
+ a = 0;
186
+ bb = 0;
187
+ }
188
+ const c = Math.sqrt(a * a + bb * bb);
189
+ const hue = (Math.atan2(bb, a) * 180) / Math.PI;
190
+ return { l, c, h: c ? ((hue % 360) + 360) % 360 : undefined };
191
+ }
192
+
193
+ /** An OKLCH colour as (unclamped) sRGB channels. */
194
+ function oklchToRgb(l, c, h) {
195
+ const rad = ((h ?? 0) / 180) * Math.PI;
196
+ const a = c ? c * Math.cos(rad) : 0;
197
+ const b = c ? c * Math.sin(rad) : 0;
198
+ const L = Math.pow(l + 0.3963377773761749 * a + 0.2158037573099136 * b, 3);
199
+ const M = Math.pow(l - 0.1055613458156586 * a - 0.0638541728258133 * b, 3);
200
+ const S = Math.pow(l - 0.0894841775298119 * a - 1.2914855480194092 * b, 3);
201
+ return {
202
+ r: fromLinear(4.0767416360759574 * L - 3.3077115392580616 * M + 0.2309699031821044 * S),
203
+ g: fromLinear(-1.2684379732850317 * L + 2.6097573492876887 * M - 0.3413193760026573 * S),
204
+ b: fromLinear(-0.0041960761386756 * L - 0.7034186179359362 * M + 1.7076146940746117 * S),
205
+ };
206
+ }
207
+
208
+ function inGamut({ r, g, b }) {
209
+ return r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1;
210
+ }
211
+
212
+ function rgbToHex({ r, g, b }) {
213
+ const byte = (v) => Math.round(Math.max(0, Math.min(1, v || 0)) * 255);
214
+ return `#${((1 << 24) | (byte(r) << 16) | (byte(g) << 8) | byte(b)).toString(16).slice(1)}`;
215
+ }
216
+
217
+ /**
218
+ * An OKLCH colour as `#rrggbb`, its chroma reduced by bisection to the largest that fits sRGB
219
+ * (culori's `clampChroma(color, 'oklch')`), so a lightness step keeps its hue.
220
+ */
221
+ function toHex(l, c, h) {
222
+ const rgb = oklchToRgb(l, c, h);
223
+ if (inGamut(rgb)) {
224
+ return rgbToHex(rgb);
225
+ }
226
+ if (!inGamut(oklchToRgb(l, 0, h))) {
227
+ return rgbToHex(oklchToRgb(l, 0, h));
228
+ }
229
+ let start = 0;
230
+ let end = c;
231
+ let lastGood = 0;
232
+ let mid = 0;
233
+ while (end - start > 0.4 / 8192) {
234
+ mid = start + (end - start) * 0.5;
235
+ if (inGamut(oklchToRgb(l, mid, h))) {
236
+ lastGood = mid;
237
+ start = mid;
238
+ } else {
239
+ end = mid;
240
+ }
241
+ }
242
+ const last = oklchToRgb(l, mid, h);
243
+ return rgbToHex(inGamut(last) ? last : oklchToRgb(l, lastGood, h));
244
+ }
245
+
246
+ function luminance(hex) {
247
+ const { r, g, b } = hexToRgb(hex);
248
+ return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
249
+ }
250
+
251
+ /** The WCAG 2.x contrast ratio of two `#rrggbb` colours. */
252
+ function contrast(a, b) {
253
+ const la = luminance(a);
254
+ const lb = luminance(b);
255
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
256
+ }
257
+
258
+ /**
259
+ * The first colour of hue `h` and chroma `c`, from lightness `start` stepping 0.01 darker (-1) or
260
+ * lighter (+1), that reaches `min` on every backdrop; it stops at the 0.05 / 0.95 bounds.
261
+ */
262
+ function searchLightness(start, c, h, direction, backdrops, min) {
263
+ let l = start;
264
+ let hex = toHex(l, c, h);
265
+ while (backdrops.some((bd) => contrast(hex, bd) < min) && l > L_FLOOR && l < L_CEILING) {
266
+ l += direction * L_STEP;
267
+ hex = toHex(l, c, h);
268
+ }
269
+ return hex;
270
+ }
271
+
272
+ /* ---------- theme: the derivation (the docs site's theme configurator, white on primary) ---------- */
273
+
274
+ /**
275
+ * Light mode. `primary` is the brand colour itself and `on-primary` stays white (the library's
276
+ * look: white labels on brand buttons, a white check mark); hover is 0.06 and pressed 0.12 darker
277
+ * in OKLCH lightness, walked back towards `primary` while white would miss 4.5:1. The soft tints
278
+ * (tonal fills) sit at lightness 0.94 / 0.90 / 0.86 with a little of the brand's chroma;
279
+ * `primary-strong` (text on the soft tints) starts 0.18 darker and steps darker until 4.5:1 on
280
+ * them and on `bg` and `surface`; `primary-text` steps darker from the brand until 4.5:1 on `bg`
281
+ * and `surface`; the ring starts at lightness 0.62 and steps darker until 3:1.
282
+ */
283
+ function lightTokens(hex) {
284
+ const { l, c, h } = hexToOklch(hex);
285
+ const backdrops = [NEUTRALS.light.surface, NEUTRALS.light.bg];
286
+ const step = (by) => {
287
+ let stepL = l - by;
288
+ let stepHex = toHex(stepL, c, h);
289
+ while (contrast(stepHex, WHITE) < TEXT && stepL < l) {
290
+ stepL += L_STEP;
291
+ stepHex = toHex(stepL, c, h);
292
+ }
293
+ return stepHex;
294
+ };
295
+ const softChroma = Math.min(c * 0.35, 0.06);
296
+ const soft = [0.94, 0.9, 0.86].map((softL) => toHex(softL, softChroma, h));
297
+ return {
298
+ primary: hex,
299
+ 'primary-hover': step(0.06),
300
+ 'primary-pressed': step(0.12),
301
+ 'primary-soft': soft[0],
302
+ 'primary-soft-hover': soft[1],
303
+ 'primary-soft-pressed': soft[2],
304
+ 'primary-strong': searchLightness(
305
+ Math.max(l - 0.18, 0.25),
306
+ c,
307
+ h,
308
+ -1,
309
+ [...soft, ...backdrops],
310
+ TEXT,
311
+ ),
312
+ 'on-primary': WHITE,
313
+ 'primary-text': searchLightness(l, c, h, -1, backdrops, TEXT),
314
+ selected: toHex(0.96, softChroma, h),
315
+ ring: searchLightness(0.62, c * 0.6, h, -1, backdrops, NON_TEXT),
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Dark mode. Without `--dark`, `primary` is the brand lifted to OKLCH lightness 0.76 (chroma
321
+ * x 0.95); with it, the given colour. Hover and pressed are 0.06 and 0.12 lighter; `on-primary` is
322
+ * a deep tint of the brand (lightness 0.16), or near black when that misses 4.5:1. The soft tints
323
+ * sit at lightness 0.30 / 0.35 / 0.40; `primary-strong` and `primary-text` step lighter from
324
+ * `primary` until 4.5:1 on their backdrops; the ring starts at 0.56 and steps lighter until 3:1.
325
+ */
326
+ function darkTokens(lightHex, darkHex) {
327
+ const base = hexToOklch(lightHex);
328
+ const given = darkHex ? hexToOklch(darkHex) : undefined;
329
+ const h = given ? given.h : base.h;
330
+ const c = given ? given.c / 0.95 : base.c;
331
+ const primaryL = given ? given.l : 0.76;
332
+ const primaryC = c * 0.95;
333
+ const primary = darkHex ?? toHex(primaryL, primaryC, h);
334
+ const backdrops = [NEUTRALS.dark.surface, NEUTRALS.dark.bg];
335
+ const soft = [0.3, 0.35, 0.4].map((softL) => toHex(softL, c * 0.4, h));
336
+ const onPrimaryTint = toHex(0.16, c * 0.5, h);
337
+ return {
338
+ primary,
339
+ 'primary-hover': toHex(primaryL + 0.06, primaryC, h),
340
+ 'primary-pressed': toHex(primaryL + 0.12, primaryC, h),
341
+ 'primary-soft': soft[0],
342
+ 'primary-soft-hover': soft[1],
343
+ 'primary-soft-pressed': soft[2],
344
+ 'primary-strong': searchLightness(primaryL, primaryC, h, 1, [...soft, ...backdrops], TEXT),
345
+ 'on-primary':
346
+ contrast(primary, onPrimaryTint) >= TEXT ? onPrimaryTint : DARK_ON_PRIMARY_FALLBACK,
347
+ 'primary-text': searchLightness(primaryL, primaryC, h, 1, backdrops, TEXT),
348
+ selected: toHex(0.24, c * 0.35, h),
349
+ ring: searchLightness(0.56, c * 0.6, h, 1, backdrops, NON_TEXT),
350
+ };
351
+ }
352
+
353
+ /** Each pair's ratio under `tokens` (brand tokens over the mode's neutrals). */
354
+ function checkPairs(tokens, mode) {
355
+ const all = { ...NEUTRALS[mode], ...tokens };
356
+ return PAIRS.map((pair) => {
357
+ const ratio = contrast(all[pair.fg], all[pair.bg]);
358
+ return { ...pair, fgHex: all[pair.fg], bgHex: all[pair.bg], ratio, ok: ratio >= pair.min };
359
+ });
360
+ }
361
+
362
+ /**
363
+ * The nearest shade of `hex` (same hue and chroma, 0.01 lightness steps: darker for light mode,
364
+ * lighter for dark mode) whose whole derived set passes; `undefined` when none does.
365
+ */
366
+ function suggestShade(hex, mode, derive) {
367
+ const { l, c, h } = hexToOklch(hex);
368
+ const direction = mode === 'light' ? -1 : 1;
369
+ for (
370
+ let next = l + direction * L_STEP;
371
+ next > L_FLOOR && next < L_CEILING;
372
+ next += direction * L_STEP
373
+ ) {
374
+ const candidate = toHex(next, c, h);
375
+ if (checkPairs(derive(candidate), mode).every((result) => result.ok)) {
376
+ return candidate;
377
+ }
378
+ }
379
+ return undefined;
380
+ }
381
+
382
+ function cssBlock(selector, tokens, results) {
383
+ const nameWidth = Math.max(...results.map((r) => `${r.fg} on ${r.bg}`.length));
384
+ const lines = [
385
+ `${selector} {`,
386
+ ...TOKEN_ORDER.map((name) => ` --color-${name}: ${tokens[name]};`),
387
+ ' /* Contrast (WCAG 2.x): ratio, minimum, result, where the widgets draw the pair',
388
+ ...results.map(
389
+ (r) =>
390
+ ` ${`${r.fg} on ${r.bg}`.padEnd(nameWidth)} ${r.ratio.toFixed(2).padStart(5)}:1` +
391
+ ` >= ${String(r.min).padEnd(3)} ${r.ok ? 'ok ' : 'FAIL'} ${r.use}`,
392
+ ),
393
+ ' */',
394
+ '}',
395
+ ];
396
+ return lines.join('\n');
397
+ }
398
+
399
+ function theme(args) {
400
+ const [colour, ...rest] = args;
401
+ let darkArg;
402
+ for (let i = 0; i < rest.length; i++) {
403
+ if (rest[i] === '--dark' && darkArg === undefined && i + 1 < rest.length) {
404
+ darkArg = rest[++i];
405
+ } else {
406
+ console.error(USAGE);
407
+ process.exit(1);
408
+ }
409
+ }
410
+ const light = normalizeHex(colour);
411
+ const dark = darkArg === undefined ? undefined : normalizeHex(darkArg);
412
+ if (!light || (darkArg !== undefined && !dark)) {
413
+ const bad = light ? darkArg : colour;
414
+ console.error(
415
+ `bf-ui theme: "${bad ?? ''}" is not a hex colour (#rgb or #rrggbb; quote it, since # starts a comment in some shells).`,
416
+ );
417
+ console.error(USAGE);
418
+ process.exit(1);
419
+ }
420
+
421
+ const lightSet = lightTokens(light);
422
+ const darkSet = darkTokens(light, dark);
423
+ const lightResults = checkPairs(lightSet, 'light');
424
+ const darkResults = checkPairs(darkSet, 'dark');
425
+
426
+ const describe = dark ? `${light} (light) and ${dark} (dark)` : light;
427
+ const neutrals = (mode) =>
428
+ Object.entries(NEUTRALS[mode])
429
+ .map(([name, value]) => `${name} ${value}`)
430
+ .join(', ');
431
+ console.log(
432
+ [
433
+ `/* @bitforgehq/ui brand tokens for ${describe}, from \`bf-ui theme\`.`,
434
+ " Paste into styles.css after @import '@bitforgehq/ui/theme.css'.",
435
+ ` Checked against the shipped light neutrals: ${neutrals('light')};`,
436
+ ` and dark neutrals: ${neutrals('dark')}. */`,
437
+ cssBlock('@theme', lightSet, lightResults),
438
+ '',
439
+ cssBlock('.dark', darkSet, darkResults),
440
+ ].join('\n'),
441
+ );
442
+
443
+ const lightFailed = lightResults.filter((r) => !r.ok);
444
+ const darkFailed = darkResults.filter((r) => !r.ok);
445
+ if (lightFailed.length === 0 && darkFailed.length === 0) {
446
+ return;
447
+ }
448
+ for (const [mode, failed] of [
449
+ ['light', lightFailed],
450
+ ['dark', darkFailed],
451
+ ]) {
452
+ if (failed.length === 0) {
453
+ continue;
454
+ }
455
+ console.error(
456
+ `bf-ui theme: ${failed.length} ${mode} pair${failed.length === 1 ? '' : 's'} below the minimum:`,
457
+ );
458
+ for (const r of failed) {
459
+ console.error(
460
+ ` ${r.fg} ${r.fgHex} on ${r.bg} ${r.bgHex}: ${r.ratio.toFixed(2)}:1 < ${r.min}:1 (${r.use})`,
461
+ );
462
+ }
463
+ }
464
+ // A darker light colour, a lighter dark one: the nearest shades of the same hue that pass.
465
+ const lightShade = lightFailed.length ? suggestShade(light, 'light', lightTokens) : light;
466
+ const darkShade = darkFailed.length
467
+ ? suggestShade(dark ?? darkSet.primary, 'dark', (candidate) =>
468
+ darkTokens(lightShade ?? light, candidate),
469
+ )
470
+ : dark;
471
+ if (lightShade === undefined || (darkFailed.length > 0 && darkShade === undefined)) {
472
+ console.error(' No shade of this hue passes in that mode; pick another colour.');
473
+ } else {
474
+ const changed = [
475
+ lightFailed.length ? 'a darker light' : '',
476
+ darkFailed.length ? 'a lighter dark' : '',
477
+ ]
478
+ .filter(Boolean)
479
+ .join(' and ');
480
+ console.error(
481
+ ` Try ${changed} shade: bf-ui theme '${lightShade}'${darkShade ? ` --dark '${darkShade}'` : ''}`,
482
+ );
483
+ }
484
+ process.exit(1);
485
+ }
486
+
62
487
  function main() {
63
488
  const args = process.argv.slice(2);
64
489
  const [command, ...rest] = args;
65
490
 
491
+ if (command === 'theme') {
492
+ theme(rest);
493
+ return;
494
+ }
495
+
66
496
  if (command !== 'sync-ai') {
67
497
  console.error(USAGE);
68
498
  process.exit(1);