@bitforgehq/ui 0.3.1 → 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 (45) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/README.md +9 -8
  3. package/ai/MANIFEST.index.json +18 -0
  4. package/ai/MANIFEST.json +368 -31
  5. package/ai/REFERENCE.index.json +63 -0
  6. package/ai/REFERENCE.json +247 -5
  7. package/ai/UI_GUIDE.md +59 -33
  8. package/ai/components/app-frame.md +328 -0
  9. package/ai/components/card.md +56 -2
  10. package/ai/components/empty-state.md +99 -6
  11. package/ai/components/header.md +5 -1
  12. package/ai/components/sidebar-toggle.md +287 -0
  13. package/ai/components/sidebar.md +188 -10
  14. package/ai/components/stat.md +74 -10
  15. package/ai/components/table.md +134 -5
  16. package/ai/components/tabs.md +5 -1
  17. package/ai/components/toolbar.md +69 -4
  18. package/ai/reference/bf-app-frame-breakpoint.md +27 -0
  19. package/ai/reference/bf-app-frame-content-width.md +29 -0
  20. package/ai/reference/bf-empty-state-heading-level.md +1 -1
  21. package/ai/reference/bf-empty-state-heading.md +33 -0
  22. package/ai/reference/bf-labels.md +6 -0
  23. package/ai/reference/bf-sidebar-state-service.md +21 -0
  24. package/ai/reference/bf-sidebar-toggle-harness.md +32 -0
  25. package/ai/reference/bf-size.md +1 -0
  26. package/ai/reference/bf-stat-appearance.md +33 -0
  27. package/ai/reference/bf-stat-variant.md +1 -1
  28. package/ai/reference/bf-table-harness.md +1 -1
  29. package/ai/reference/bf-table-stack.md +29 -0
  30. package/ai/reference/bf_sidebar_state_storage.md +20 -0
  31. package/ai/reference/provide-bf-labels.md +4 -3
  32. package/ai/reference/provide-bf-sidebar-state.md +20 -0
  33. package/bin/bf-ui.js +433 -3
  34. package/fesm2022/bitforgehq-ui-overlay.mjs +571 -446
  35. package/fesm2022/bitforgehq-ui-overlay.mjs.map +1 -1
  36. package/fesm2022/bitforgehq-ui-testing.mjs +18 -2
  37. package/fesm2022/bitforgehq-ui-testing.mjs.map +1 -1
  38. package/fesm2022/bitforgehq-ui.mjs +759 -127
  39. package/fesm2022/bitforgehq-ui.mjs.map +1 -1
  40. package/llms-full.txt +1560 -43
  41. package/llms.txt +11 -0
  42. package/package.json +1 -1
  43. package/types/bitforgehq-ui-overlay.d.ts +51 -0
  44. package/types/bitforgehq-ui-testing.d.ts +35 -1
  45. package/types/bitforgehq-ui.d.ts +276 -20
@@ -48,6 +48,7 @@ type BfSize = 'sm' | 'md' | 'lg'
48
48
  - [bf-segmented](../components/segmented.md)
49
49
  - [BfSegmentedSize](bf-segmented-size.md)
50
50
  - [bf-select](../components/select.md)
51
+ - [bf-sidebar-toggle](../components/sidebar-toggle.md)
51
52
  - [bf-tab-group](../components/tab-group.md)
52
53
  - [bf-table](../components/table.md)
53
54
  - [bf-textarea](../components/textarea.md)
@@ -0,0 +1,33 @@
1
+ # BfStatAppearance
2
+
3
+ The three stat surfaces, plain, an outlined card or a sunken tile
4
+
5
+ Type from `@bitforgehq/ui` · since 0.3.2
6
+
7
+ The surface of a `bf-stat`, as its `variant` input takes it: a `BfStatVariant` or tile (a
8
+ sunken, borderless tile for a stats row inside a raised card). A new type, so `Record`s over
9
+ the released `BfStatVariant` keep compiling.
10
+
11
+ ```ts
12
+ import { type BfStatAppearance } from '@bitforgehq/ui';
13
+ ```
14
+
15
+ ## Signature
16
+
17
+ ```ts
18
+ type BfStatAppearance = BfStatVariant | 'tile'
19
+ ```
20
+
21
+ ## Values
22
+
23
+ - `plain`
24
+ - `card`
25
+ - `tile`
26
+
27
+ ## Uses
28
+
29
+ - [BfStatVariant](bf-stat-variant.md)
30
+
31
+ ## Used by
32
+
33
+ - [bf-stat](../components/stat.md)
@@ -23,4 +23,4 @@ type BfStatVariant = 'plain' | 'card'
23
23
 
24
24
  ## Used by
25
25
 
26
- - [bf-stat](../components/stat.md)
26
+ - [BfStatAppearance](bf-stat-appearance.md)
@@ -6,7 +6,7 @@ Class from `@bitforgehq/ui/testing` · since 0.3.1
6
6
 
7
7
  Harness for `bf-table`. It works by column header text and row position, so a test never needs
8
8
  the column keys or the row keys (except `getSort()`, which returns the column's `key`, as the
9
- `sort` model holds it).
9
+ `sort` model holds it). It reads and drives a stacked table (`stack`) the same way.
10
10
 
11
11
  ```ts
12
12
  import { BfTableHarness } from '@bitforgehq/ui/testing';
@@ -0,0 +1,29 @@
1
+ # BfTableStack
2
+
3
+ When a table stacks its rows into cards: never, when narrow, or under touch
4
+
5
+ Type from `@bitforgehq/ui` · since 0.3.2
6
+
7
+ When a `bf-table` renders each row as a card of label/value pairs: `never` (the default),
8
+ `narrow` while the table is narrower than 36rem (a container query), or `coarse` under a coarse
9
+ pointer at any width.
10
+
11
+ ```ts
12
+ import { type BfTableStack } from '@bitforgehq/ui';
13
+ ```
14
+
15
+ ## Signature
16
+
17
+ ```ts
18
+ type BfTableStack = 'never' | 'narrow' | 'coarse'
19
+ ```
20
+
21
+ ## Values
22
+
23
+ - `never`
24
+ - `narrow`
25
+ - `coarse`
26
+
27
+ ## Used by
28
+
29
+ - [bf-table](../components/table.md)
@@ -0,0 +1,20 @@
1
+ # BF_SIDEBAR_STATE_STORAGE
2
+
3
+ Injection token for the store that keeps the collapsed sidebar between visits
4
+
5
+ Injection token from `@bitforgehq/ui` · since 0.3.2
6
+
7
+ Where `BfSidebarStateService` keeps whether the sidebar is collapsed (the key
8
+ `bf-sidebar-collapsed`, `'true'` or `'false'`): `localStorage` when it is available, otherwise
9
+ an in-memory map. Provide another store (`sessionStorage`, or an in-memory one in tests) to keep
10
+ it elsewhere.
11
+
12
+ ```ts
13
+ import { BF_SIDEBAR_STATE_STORAGE } from '@bitforgehq/ui';
14
+ ```
15
+
16
+ ## Signature
17
+
18
+ ```ts
19
+ const BF_SIDEBAR_STATE_STORAGE: InjectionToken<Pick<Storage, "getItem" | "setItem">>
20
+ ```
@@ -7,9 +7,10 @@ Function from `@bitforgehq/ui` · since 0.3.0
7
7
  Replaces built-in strings, merged over the parent's (validation merged key by key); a key left
8
8
  out or set to `undefined` keeps the parent's string. In `ApplicationConfig.providers` it covers
9
9
  every string. In a component's `providers` it covers that sub-tree's widgets and form controls
10
- (`loading`, `dismiss`, `tabBar`, `tabBadge`, `back`, `refreshing`, `tableSelectAll`, `theme`,
11
- `themeSystem`, `themeLight`, `themeDark`, `breadcrumb`, `breadcrumbExpand`, `navigation`,
12
- `navBadge`, `pagination`, `previousPage`, `nextPage`, `pageOf`, `goToPage`, `tabGroup`,
10
+ (`loading`, `dismiss`, `tabBar`, `tabBadge`, `back`, `refreshing`, `tableSelectAll`, `tableSortBy`,
11
+ `tableSortAscending`, `tableSortDescending`, `theme`, `themeSystem`, `themeLight`, `themeDark`,
12
+ `breadcrumb`, `breadcrumbExpand`, `navigation`, `navBadge`, `sidebarCollapse`, `sidebarExpand`,
13
+ `skipToContent`, `pagination`, `previousPage`, `nextPage`, `pageOf`, `goToPage`, `tabGroup`,
13
14
  `calendarPreviousMonth`, `calendarNextMonth`, `calendarChooseMonth`, `calendarChooseYear`,
14
15
  `statTrendUp`, `statTrendDown`, `statTrendFlat`, `comboboxEmpty`, `comboboxRemove`,
15
16
  `dateInputDay`, `dateInputMonth`, `dateInputYear`, `dateInputOpenCalendar`, `dateInputInvalid`,
@@ -0,0 +1,20 @@
1
+ # provideBfSidebarState
2
+
3
+ Remembers the collapsed sidebar between visits, for desktop web apps
4
+
5
+ Function from `@bitforgehq/ui` · since 0.3.2
6
+
7
+ Lets `BfSidebarStateService` remember the collapsed sidebar: it stores every change of its
8
+ `collapsed` signal (key `bf-sidebar-collapsed`, `localStorage` in the browser), so the app opens
9
+ with the sidebar as the user left it. Add it to `ApplicationConfig.providers` and bind the
10
+ sidebar's `[(collapsed)]` to `sidebarState.collapsed`.
11
+
12
+ ```ts
13
+ import { provideBfSidebarState } from '@bitforgehq/ui';
14
+ ```
15
+
16
+ ## Signature
17
+
18
+ ```ts
19
+ function provideBfSidebarState(): EnvironmentProviders
20
+ ```
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);