@oxy.so/bloom 4.27.0 → 4.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/docs/carousel.mdx +2 -0
  2. package/docs/design-tokens.mdx +15 -4
  3. package/docs/glass.mdx +1 -1
  4. package/lib/commonjs/carousel/Carousel.js +41 -25
  5. package/lib/commonjs/carousel/Carousel.js.map +1 -1
  6. package/lib/commonjs/design-tokens/tokens.json +50 -48
  7. package/lib/commonjs/theme/color-presets.js +64 -4
  8. package/lib/commonjs/theme/color-presets.js.map +1 -1
  9. package/lib/commonjs/theme/preset-vars.js +19 -1
  10. package/lib/commonjs/theme/preset-vars.js.map +1 -1
  11. package/lib/module/carousel/Carousel.js +42 -26
  12. package/lib/module/carousel/Carousel.js.map +1 -1
  13. package/lib/module/design-tokens/tokens.json +50 -48
  14. package/lib/module/theme/color-presets.js +64 -4
  15. package/lib/module/theme/color-presets.js.map +1 -1
  16. package/lib/module/theme/preset-vars.js +20 -2
  17. package/lib/module/theme/preset-vars.js.map +1 -1
  18. package/lib/typescript/commonjs/carousel/Carousel.d.ts +1 -1
  19. package/lib/typescript/commonjs/carousel/Carousel.d.ts.map +1 -1
  20. package/lib/typescript/commonjs/carousel/types.d.ts +13 -0
  21. package/lib/typescript/commonjs/carousel/types.d.ts.map +1 -1
  22. package/lib/typescript/commonjs/theme/color-presets.d.ts +57 -2
  23. package/lib/typescript/commonjs/theme/color-presets.d.ts.map +1 -1
  24. package/lib/typescript/commonjs/theme/preset-vars.d.ts.map +1 -1
  25. package/lib/typescript/module/carousel/Carousel.d.ts +1 -1
  26. package/lib/typescript/module/carousel/Carousel.d.ts.map +1 -1
  27. package/lib/typescript/module/carousel/types.d.ts +13 -0
  28. package/lib/typescript/module/carousel/types.d.ts.map +1 -1
  29. package/lib/typescript/module/theme/color-presets.d.ts +57 -2
  30. package/lib/typescript/module/theme/color-presets.d.ts.map +1 -1
  31. package/lib/typescript/module/theme/preset-vars.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/carousel/Carousel.tsx +19 -8
  34. package/src/carousel/types.ts +13 -0
  35. package/src/design-tokens/tokens.json +50 -48
  36. package/src/theme/color-presets.ts +67 -2
  37. package/src/theme/preset-vars.ts +22 -1
  38. package/src/__tests__/support/adoption-matrix.ts +0 -703
  39. package/src/__tests__/support/collision-fixture-barrel.ts +0 -20
  40. package/src/__tests__/support/commerce-harness.tsx +0 -97
  41. package/src/__tests__/support/composite.ts +0 -68
  42. package/src/__tests__/support/constructed-style-sheets.ts +0 -68
  43. package/src/__tests__/support/press-host.ts +0 -30
  44. package/src/__tests__/support/rendered-style.ts +0 -99
  45. package/src/__tests__/support/unread-hook-fixture.ts +0 -33
  46. package/src/__tests__/support/worklet-capture-fixture.tsx +0 -28
  47. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +0 -9346
  48. package/src/theme/__tests__/__fixtures__/tonal-glass-primary-failures.json +0 -69
  49. package/src/theme/__tests__/fixtures/color-engine-golden.json +0 -1
@@ -40,6 +40,7 @@ import type { CarouselItemProps, CarouselProps } from './types';
40
40
  * prev/next buttons above and a position indicator below.
41
41
  *
42
42
  * column gap 16
43
+ * header optional leading content of the arrows row (a title)
43
44
  * arrows right-aligned row, gap 8, small secondary icon buttons
44
45
  * (Bloom's pill `Button`, sized to a 32px square icon button)
45
46
  * track native horizontal scrolling that SNAPS to each slide — CSS
@@ -105,6 +106,7 @@ const DOT = '[data-bloom-carousel-dot]';
105
106
  const BLOOM_CAROUSEL_CSS = `
106
107
  ${TRACK} {
107
108
  scroll-snap-type: x mandatory;
109
+ scroll-padding-inline: var(--bloom-carousel-inset, 0px);
108
110
  overscroll-behavior-x: contain;
109
111
  scrollbar-width: none;
110
112
  outline: none;
@@ -266,10 +268,12 @@ interface SlideOffset {
266
268
  const CarouselComponent = function Carousel({
267
269
  children,
268
270
  accessibilityLabel,
271
+ header,
269
272
  showArrows = true,
270
273
  showDots = true,
271
274
  align = 'start',
272
275
  gap = 16,
276
+ inset = 0,
273
277
  onIndexChange,
274
278
  previousLabel = 'Previous slide',
275
279
  nextLabel = 'Next slide',
@@ -337,11 +341,11 @@ const CarouselComponent = function Carousel({
337
341
 
338
342
  const targetFor = useCallback(
339
343
  (item: SlideOffset) => {
340
- const raw = align === 'center' ? item.x - (trackWidth - item.width) / 2 : item.x;
344
+ const raw = align === 'center' ? item.x - (trackWidth - item.width) / 2 : item.x - inset;
341
345
  const max = Math.max(0, scroll.current.contentWidth - trackWidth);
342
346
  return Math.min(Math.max(0, raw), max);
343
347
  },
344
- [align, trackWidth],
348
+ [align, inset, trackWidth],
345
349
  );
346
350
 
347
351
  const recomputeSnaps = useCallback(() => {
@@ -392,6 +396,8 @@ const CarouselComponent = function Carousel({
392
396
  width: '100%',
393
397
  // The `:focus-visible` ring colour, read by the adopted sheet.
394
398
  '--bloom-carousel-ring': paint.ring,
399
+ // Where a start-aligned slide snaps to on web, matching `inset` on native.
400
+ '--bloom-carousel-inset': `${inset}px`,
395
401
  };
396
402
 
397
403
  return (
@@ -402,10 +408,15 @@ const CarouselComponent = function Carousel({
402
408
  style={[{ width: '100%', flexDirection: 'column', gap: 16 }, style]}
403
409
  testID={testID}
404
410
  >
405
- {showArrows && count > 0 ? (
406
- <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
407
- <Button size="sm" icon={RiArrowLeftSLine} accessibilityLabel={previousLabel} disabled={atStart} onPress={() => scrollToIndex(active - 1)} appearance="subtle" tone="neutral" />
408
- <Button size="sm" icon={RiArrowRightSLine} accessibilityLabel={nextLabel} disabled={atEnd} onPress={() => scrollToIndex(active + 1)} appearance="subtle" tone="neutral" />
411
+ {header != null || (showArrows && count > 0) ? (
412
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: inset }}>
413
+ <View style={{ flex: 1, minWidth: 0 }}>{header}</View>
414
+ {showArrows && count > 0 ? (
415
+ <>
416
+ <Button size="sm" icon={RiArrowLeftSLine} accessibilityLabel={previousLabel} disabled={atStart} onPress={() => scrollToIndex(active - 1)} appearance="subtle" tone="neutral" />
417
+ <Button size="sm" icon={RiArrowRightSLine} accessibilityLabel={nextLabel} disabled={atEnd} onPress={() => scrollToIndex(active + 1)} appearance="subtle" tone="neutral" />
418
+ </>
419
+ ) : null}
409
420
  </View>
410
421
  ) : null}
411
422
 
@@ -426,7 +437,7 @@ const CarouselComponent = function Carousel({
426
437
  decelerationRate={IS_WEB ? undefined : 'fast'}
427
438
  disableIntervalMomentum
428
439
  style={trackStyle}
429
- contentContainerStyle={{ gap }}
440
+ contentContainerStyle={{ gap, paddingHorizontal: inset }}
430
441
  >
431
442
  {slides.map((child, index) => (
432
443
  <CarouselItemIndexContext.Provider key={child.key ?? index} value={index}>
@@ -437,7 +448,7 @@ const CarouselComponent = function Carousel({
437
448
  </CarouselContext.Provider>
438
449
 
439
450
  {showDots && count > 1 ? (
440
- <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
451
+ <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, paddingHorizontal: inset }}>
441
452
  {Array.from({ length: count }, (_, index) => (
442
453
  <CarouselDot
443
454
  key={index}
@@ -9,6 +9,12 @@ export interface CarouselProps {
9
9
  * `aria-label`). The region announces itself as a "carousel".
10
10
  */
11
11
  accessibilityLabel: string;
12
+ /**
13
+ * Leading content of the row above the track — typically the section title
14
+ * and a "See all" link. It shares the row with the arrows (header at the
15
+ * start, arrows at the end), so a titled carousel costs one row, not two.
16
+ */
17
+ header?: ReactNode;
12
18
  /** Previous / next buttons above the track. Defaults to `true`. */
13
19
  showArrows?: boolean;
14
20
  /** Position indicator below the track. Defaults to `true`. */
@@ -17,6 +23,13 @@ export interface CarouselProps {
17
23
  align?: 'start' | 'center';
18
24
  /** Gap between slides, in px. Defaults to `16`. */
19
25
  gap?: number;
26
+ /**
27
+ * Horizontal inset, in px, of the header row, the dots and the first and
28
+ * last slide. Slides still scroll under it to the carousel's edges (it is
29
+ * padding INSIDE the track, not a margin around it), and a snapped slide
30
+ * rests at the inset, not flush. Defaults to `0`.
31
+ */
32
+ inset?: number;
20
33
  /** Called when the slide in view changes. */
21
34
  onIndexChange?: (index: number) => void;
22
35
  /** Accessible name of the previous button. Defaults to `'Previous slide'`. */
@@ -4445,64 +4445,66 @@
4445
4445
  }
4446
4446
  },
4447
4447
  "oxy": {
4448
- "$description": "Oxy purple, reserved for the Oxy account identity.",
4448
+ "$description": "Oxy orchid with a magenta support and a terracotta action, reserved for the Oxy account identity.",
4449
4449
  "$extensions": {
4450
4450
  "so.oxy.bloom": {
4451
4451
  "seed": "#c46ede",
4452
4452
  "variant": "vivid",
4453
4453
  "displayName": "Oxy",
4454
4454
  "family": "brand",
4455
- "description": "Oxy purple, reserved for the Oxy account identity.",
4456
- "pairing": "derived",
4455
+ "description": "Oxy orchid with a magenta support and a terracotta action, reserved for the Oxy account identity.",
4456
+ "pairing": "curated",
4457
4457
  "featured": false,
4458
+ "secondarySeed": "#8f3987",
4459
+ "tertiarySeed": "#a0392b",
4458
4460
  "gate": "handle"
4459
4461
  }
4460
4462
  },
4461
4463
  "light": {
4462
4464
  "background": {
4463
- "$value": "#fee7ff"
4465
+ "$value": "#f7ebf0"
4464
4466
  },
4465
4467
  "foreground": {
4466
4468
  "$value": "#2a1132"
4467
4469
  },
4468
4470
  "surface": {
4469
- "$value": "#fbd7ff"
4471
+ "$value": "#f1dde9"
4470
4472
  },
4471
4473
  "surface-foreground": {
4472
4474
  "$value": "#2a1132"
4473
4475
  },
4474
4476
  "popover": {
4475
- "$value": "#f1cbf7"
4477
+ "$value": "#edcee4"
4476
4478
  },
4477
4479
  "popover-foreground": {
4478
4480
  "$value": "#2a1132"
4479
4481
  },
4480
4482
  "primary": {
4481
- "$value": "#903daa"
4483
+ "$value": "#9422ae"
4482
4484
  },
4483
4485
  "primary-foreground": {
4484
- "$value": "#ffffff"
4486
+ "$value": "#ffeefc"
4485
4487
  },
4486
4488
  "secondary": {
4487
- "$value": "#00785e"
4489
+ "$value": "#8f3987"
4488
4490
  },
4489
4491
  "secondary-foreground": {
4490
- "$value": "#ffffff"
4492
+ "$value": "#ffeef8"
4491
4493
  },
4492
4494
  "tertiary": {
4493
- "$value": "#307900"
4495
+ "$value": "#a0392b"
4494
4496
  },
4495
4497
  "tertiary-foreground": {
4496
- "$value": "#ffffff"
4498
+ "$value": "#ffefed"
4497
4499
  },
4498
4500
  "muted": {
4499
- "$value": "#e6c0eb"
4501
+ "$value": "#e7c1dd"
4500
4502
  },
4501
4503
  "muted-foreground": {
4502
4504
  "$value": "#593c60"
4503
4505
  },
4504
4506
  "accent": {
4505
- "$value": "#e6c0eb"
4507
+ "$value": "#e7c1dd"
4506
4508
  },
4507
4509
  "accent-foreground": {
4508
4510
  "$value": "#593c60"
@@ -4517,13 +4519,13 @@
4517
4519
  "$value": "#c9a5cf"
4518
4520
  },
4519
4521
  "ring": {
4520
- "$value": "#9c00c8"
4522
+ "$value": "#9422ae"
4521
4523
  },
4522
4524
  "sidebar": {
4523
- "$value": "#fbd7ff"
4525
+ "$value": "#f1dde9"
4524
4526
  },
4525
4527
  "card": {
4526
- "$value": "#fff7fb"
4528
+ "$value": "#fff7f9"
4527
4529
  },
4528
4530
  "card-foreground": {
4529
4531
  "$value": "#2a1132"
@@ -4550,22 +4552,22 @@
4550
4552
  "$value": "#2a1132"
4551
4553
  },
4552
4554
  "sidebar-primary": {
4553
- "$value": "#9c00c8"
4555
+ "$value": "#9422ae"
4554
4556
  },
4555
4557
  "sidebar-primary-foreground": {
4556
- "$value": "#ffffff"
4558
+ "$value": "#ffeefc"
4557
4559
  },
4558
4560
  "sidebar-accent": {
4559
- "$value": "#e6c0eb"
4561
+ "$value": "#ffbdf1"
4560
4562
  },
4561
4563
  "sidebar-accent-foreground": {
4562
- "$value": "#593c60"
4564
+ "$value": "#772371"
4563
4565
  },
4564
4566
  "sidebar-border": {
4565
4567
  "$value": "#c9a5cf"
4566
4568
  },
4567
4569
  "sidebar-ring": {
4568
- "$value": "#9c00c8"
4570
+ "$value": "#9422ae"
4569
4571
  },
4570
4572
  "primary-text": {
4571
4573
  "$value": "#9c00c8"
@@ -4574,16 +4576,16 @@
4574
4576
  "$value": "#d556ff21"
4575
4577
  },
4576
4578
  "secondary-text": {
4577
- "$value": "#006b54"
4579
+ "$value": "#96388e"
4578
4580
  },
4579
4581
  "secondary-subtle": {
4580
- "$value": "#00a48121"
4582
+ "$value": "#ff05f821"
4581
4583
  },
4582
4584
  "tertiary-text": {
4583
- "$value": "#2a6c00"
4585
+ "$value": "#a7392a"
4584
4586
  },
4585
4587
  "tertiary-subtle": {
4586
- "$value": "#44a50021"
4588
+ "$value": "#ff554021"
4587
4589
  },
4588
4590
  "chart-1-active": {
4589
4591
  "$value": "#893ca2"
@@ -4675,49 +4677,49 @@
4675
4677
  },
4676
4678
  "dark": {
4677
4679
  "background": {
4678
- "$value": "#1c0324"
4680
+ "$value": "#1f031d"
4679
4681
  },
4680
4682
  "foreground": {
4681
4683
  "$value": "#fee7ff"
4682
4684
  },
4683
4685
  "surface": {
4684
- "$value": "#2a1132"
4686
+ "$value": "#2f0f2c"
4685
4687
  },
4686
4688
  "surface-foreground": {
4687
4689
  "$value": "#fee7ff"
4688
4690
  },
4689
4691
  "popover": {
4690
- "$value": "#33193b"
4692
+ "$value": "#381835"
4691
4693
  },
4692
4694
  "popover-foreground": {
4693
4695
  "$value": "#fee7ff"
4694
4696
  },
4695
4697
  "primary": {
4696
- "$value": "#be69d8"
4698
+ "$value": "#ec86ff"
4697
4699
  },
4698
4700
  "primary-foreground": {
4699
4701
  "$value": "#000000"
4700
4702
  },
4701
4703
  "secondary": {
4702
- "$value": "#00fdc8"
4704
+ "$value": "#fd99ee"
4703
4705
  },
4704
4706
  "secondary-foreground": {
4705
- "$value": "#000000"
4707
+ "$value": "#651161"
4706
4708
  },
4707
4709
  "tertiary": {
4708
- "$value": "#8ef956"
4710
+ "$value": "#ff9381"
4709
4711
  },
4710
4712
  "tertiary-foreground": {
4711
- "$value": "#000000"
4713
+ "$value": "#640d05"
4712
4714
  },
4713
4715
  "muted": {
4714
- "$value": "#3c2244"
4716
+ "$value": "#42203d"
4715
4717
  },
4716
4718
  "muted-foreground": {
4717
4719
  "$value": "#e0bae6"
4718
4720
  },
4719
4721
  "accent": {
4720
- "$value": "#3c2244"
4722
+ "$value": "#42203d"
4721
4723
  },
4722
4724
  "accent-foreground": {
4723
4725
  "$value": "#e0bae6"
@@ -4732,13 +4734,13 @@
4732
4734
  "$value": "#66486d"
4733
4735
  },
4734
4736
  "ring": {
4735
- "$value": "#f0b0ff"
4737
+ "$value": "#ec86ff"
4736
4738
  },
4737
4739
  "sidebar": {
4738
- "$value": "#2a1132"
4740
+ "$value": "#2f0f2c"
4739
4741
  },
4740
4742
  "card": {
4741
- "$value": "#462a4d"
4743
+ "$value": "#4b2947"
4742
4744
  },
4743
4745
  "card-foreground": {
4744
4746
  "$value": "#fee7ff"
@@ -4765,22 +4767,22 @@
4765
4767
  "$value": "#fee7ff"
4766
4768
  },
4767
4769
  "sidebar-primary": {
4768
- "$value": "#f0b0ff"
4770
+ "$value": "#ec86ff"
4769
4771
  },
4770
4772
  "sidebar-primary-foreground": {
4771
- "$value": "#54006d"
4773
+ "$value": "#530065"
4772
4774
  },
4773
4775
  "sidebar-accent": {
4774
- "$value": "#3c2244"
4776
+ "$value": "#762371"
4775
4777
  },
4776
4778
  "sidebar-accent-foreground": {
4777
- "$value": "#e0bae6"
4779
+ "$value": "#ffbbf1"
4778
4780
  },
4779
4781
  "sidebar-border": {
4780
4782
  "$value": "#66486d"
4781
4783
  },
4782
4784
  "sidebar-ring": {
4783
- "$value": "#f0b0ff"
4785
+ "$value": "#ec86ff"
4784
4786
  },
4785
4787
  "primary-text": {
4786
4788
  "$value": "#f0b0ff"
@@ -4789,16 +4791,16 @@
4789
4791
  "$value": "#d556ff3d"
4790
4792
  },
4791
4793
  "secondary-text": {
4792
- "$value": "#00e0b1"
4794
+ "$value": "#ffabf0"
4793
4795
  },
4794
4796
  "secondary-subtle": {
4795
- "$value": "#00a4813d"
4797
+ "$value": "#ff05f83d"
4796
4798
  },
4797
4799
  "tertiary-text": {
4798
- "$value": "#90d968"
4800
+ "$value": "#ffb4a8"
4799
4801
  },
4800
4802
  "tertiary-subtle": {
4801
- "$value": "#44a5003d"
4803
+ "$value": "#ff55403d"
4802
4804
  },
4803
4805
  "chart-1-active": {
4804
4806
  "$value": "#f0b0ff"
@@ -51,6 +51,7 @@ interface ColorPresetSource {
51
51
  /** Force a white primary label even when preserving the seed would choose black. */
52
52
  label?: 'white';
53
53
  gate?: ColorPresetGate;
54
+ tokens?: PresetTokenOverrides;
54
55
  }
55
56
 
56
57
  /**
@@ -157,11 +158,63 @@ const COLOR_PRESET_SOURCES = [
157
158
  name: 'oxy',
158
159
  displayName: 'Oxy',
159
160
  family: 'brand',
160
- description: 'Oxy purple, reserved for the Oxy account identity.',
161
+ description: 'Oxy orchid with a magenta support and a terracotta action, reserved for the Oxy account identity.',
161
162
  hex: '#c46ede',
163
+ secondaryHex: '#8f3987',
164
+ tertiaryHex: '#a0392b',
162
165
  variant: 'vivid',
163
- pairing: 'derived',
166
+ pairing: 'curated',
164
167
  gate: 'handle',
168
+ // Hand-authored from the Oxy logo's own Material 3 scheme (plum page, pink
169
+ // surfaces, orchid/magenta/terracotta trio). The accent fills and their
170
+ // labels are that scheme's roles verbatim. The surface ladder keeps each
171
+ // role's hue and chroma (capped at Bloom's 28) but sits on Bloom's
172
+ // four-tone ramp, since the scheme's own two-tone steps fall below what the
173
+ // eye separates (`SURFACE_RAMP`, color-policy.ts). Text, borders and status
174
+ // stay derived: the policy is what holds them at AA on that deeper ladder.
175
+ tokens: {
176
+ light: {
177
+ '--background': '#f7ebf0',
178
+ '--surface': '#f1dde9',
179
+ '--popover': '#edcee4',
180
+ '--card': '#fff7f9',
181
+ '--muted': '#e7c1dd',
182
+ '--accent': '#e7c1dd',
183
+ '--primary': '#9422ae',
184
+ '--primary-foreground': '#ffeefc',
185
+ '--ring': '#9422ae',
186
+ '--secondary': '#8f3987',
187
+ '--secondary-foreground': '#ffeef8',
188
+ '--tertiary': '#a0392b',
189
+ '--tertiary-foreground': '#ffefed',
190
+ '--sidebar': '#f1dde9',
191
+ '--sidebar-primary': '#9422ae',
192
+ '--sidebar-primary-foreground': '#ffeefc',
193
+ '--sidebar-accent': '#ffbdf1',
194
+ '--sidebar-accent-foreground': '#772371',
195
+ '--sidebar-ring': '#9422ae',
196
+ },
197
+ dark: {
198
+ '--background': '#1f031d',
199
+ '--surface': '#2f0f2c',
200
+ '--popover': '#381835',
201
+ '--card': '#4b2947',
202
+ '--muted': '#42203d',
203
+ '--accent': '#42203d',
204
+ '--primary': '#ec86ff',
205
+ '--ring': '#ec86ff',
206
+ '--secondary': '#fd99ee',
207
+ '--secondary-foreground': '#651161',
208
+ '--tertiary': '#ff9381',
209
+ '--tertiary-foreground': '#640d05',
210
+ '--sidebar': '#2f0f2c',
211
+ '--sidebar-primary': '#ec86ff',
212
+ '--sidebar-primary-foreground': '#530065',
213
+ '--sidebar-accent': '#762371',
214
+ '--sidebar-accent-foreground': '#ffbbf1',
215
+ '--sidebar-ring': '#ec86ff',
216
+ },
217
+ },
165
218
  },
166
219
  {
167
220
  name: 'faircoin',
@@ -782,6 +835,8 @@ export interface AppColorPreset {
782
835
  tertiaryHex?: string;
783
836
  label?: 'white';
784
837
  gate?: ColorPresetGate;
838
+ /** Authored per-mode token values that win over the engine's derivation. */
839
+ tokens?: PresetTokenOverrides;
785
840
  }
786
841
 
787
842
  /** Enriched registry entry for a picker or colour playground. */
@@ -815,6 +870,7 @@ export const APP_COLOR_PRESETS: Record<AppColorName, AppColorPreset> =
815
870
  ...(preset.tertiaryHex !== undefined ? { tertiaryHex: preset.tertiaryHex } : {}),
816
871
  ...(preset.label !== undefined ? { label: preset.label } : {}),
817
872
  ...(preset.gate !== undefined ? { gate: preset.gate } : {}),
873
+ ...(preset.tokens !== undefined ? { tokens: preset.tokens } : {}),
818
874
  };
819
875
  return presets;
820
876
  }, {} as Record<AppColorName, AppColorPreset>);
@@ -857,6 +913,15 @@ export const COLOR_PRESET_GROUPS: Readonly<Record<ColorPresetFamily, ColorPreset
857
913
  */
858
914
  export type PresetTokens = Record<string, string>;
859
915
 
916
+ /**
917
+ * A hand-authored brand scheme, per mode, as `#rrggbb` keyed by canonical token.
918
+ * Only for a brand that owns a drawn palette; every other preset stays derived.
919
+ */
920
+ export interface PresetTokenOverrides {
921
+ light?: PresetTokens;
922
+ dark?: PresetTokens;
923
+ }
924
+
860
925
  const namesGatedBy = (gate: ColorPresetGate | undefined): readonly AppColorName[] =>
861
926
  COLOR_PRESET_REGISTRY.filter((preset) => preset.gate === gate).map(({ name }) => name);
862
927
 
@@ -1,5 +1,5 @@
1
1
  import { APP_COLOR_PRESETS, type AppColorName, type PresetTokens } from './color-presets';
2
- import { generateRoleColors, type RoleColors } from './color-engine';
2
+ import { argbFromHex, generateRoleColors, type RoleColors } from './color-engine';
3
3
  import { buildPolicyTokens } from './color-policy';
4
4
 
5
5
  /**
@@ -112,9 +112,30 @@ export function getPresetVars(
112
112
  secondary: accents?.secondaryHex ?? preset.secondaryHex,
113
113
  tertiary: accents?.tertiaryHex ?? preset.tertiaryHex,
114
114
  }),
115
+ ...authoredTokens(preset.tokens?.[mode], accents),
115
116
  };
116
117
  }
117
118
 
119
+ /**
120
+ * A preset's hand-authored values, as the same `rgb(r g b)` form the engine
121
+ * emits. An app-wide accent override still wins over the authored accent it
122
+ * replaces, so the `secondary*`/`tertiary*` family is dropped when pinned.
123
+ */
124
+ function authoredTokens(
125
+ authored: PresetTokens | undefined,
126
+ accents: ExplicitAccents | undefined,
127
+ ): PresetTokens {
128
+ if (authored === undefined) return {};
129
+ const out: PresetTokens = {};
130
+ for (const [token, hex] of Object.entries(authored)) {
131
+ if (accents?.secondaryHex !== undefined && token.startsWith('--secondary')) continue;
132
+ if (accents?.tertiaryHex !== undefined && token.startsWith('--tertiary')) continue;
133
+ const argb = argbFromHex(hex);
134
+ out[token] = `rgb(${(argb >> 16) & 255} ${(argb >> 8) & 255} ${argb & 255})`;
135
+ }
136
+ return out;
137
+ }
138
+
118
139
  /**
119
140
  * Apply a preset's extended vars to `document.documentElement`. Web-only no-op
120
141
  * on native. `BloomThemeProvider` already writes the base preset vars on web;