@nyaruka/temba-components 0.171.0 → 0.173.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 (48) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/dist/static/svg/index.svg +1 -1
  3. package/dist/temba-components.js +1292 -758
  4. package/dist/temba-components.js.map +1 -1
  5. package/package.json +1 -1
  6. package/src/Icons.ts +4 -1
  7. package/src/RapidElement.ts +83 -41
  8. package/src/display/Button.ts +14 -2
  9. package/src/display/Chat.ts +41 -22
  10. package/src/display/Label.ts +18 -3
  11. package/src/events/eventRenderers.ts +4 -0
  12. package/src/flow/FlowSearch.ts +12 -392
  13. package/src/flow/actions/send_msg.ts +2 -1
  14. package/src/flow/actions/set_contact_field.ts +1 -0
  15. package/src/flow/actions/set_contact_name.ts +1 -0
  16. package/src/flow/nodes/split_by_ticket.ts +1 -0
  17. package/src/flow/nodes/wait_for_dial.ts +1 -0
  18. package/src/form/RangePicker.ts +10 -2
  19. package/src/form/select/Select.ts +15 -2
  20. package/src/interfaces.ts +2 -0
  21. package/src/layout/HeaderBar.ts +28 -2
  22. package/src/layout/PageHeader.ts +14 -7
  23. package/src/layout/SearchModal.ts +564 -0
  24. package/src/list/BroadcastList.ts +17 -6
  25. package/src/list/ContentList.ts +29 -13
  26. package/src/list/FieldList.ts +8 -1
  27. package/src/list/ShortcutContentList.ts +347 -0
  28. package/src/list/TembaMenu.ts +71 -13
  29. package/src/live/CampaignEvents.ts +31 -11
  30. package/src/live/ContactChat.ts +276 -76
  31. package/src/live/ContactTimeline.ts +46 -24
  32. package/src/live/ContactWatch.ts +166 -63
  33. package/src/live/Realtime.ts +137 -8
  34. package/src/live/SocketService.ts +115 -10
  35. package/src/live/TicketSearch.ts +290 -0
  36. package/src/live/Watchers.ts +93 -0
  37. package/src/simulator/Simulator.ts +59 -36
  38. package/src/store/Store.ts +17 -34
  39. package/src/styles/designTokens.ts +68 -17
  40. package/src/styles/pillVariants.ts +42 -10
  41. package/static/css/design-system.css +47 -12
  42. package/static/css/temba-components.css +31 -9
  43. package/static/svg/index.svg +1 -1
  44. package/static/svg/work/traced/book-open-01.svg +1 -0
  45. package/static/svg/work/traced/layout-alt-03.svg +1 -0
  46. package/static/svg/work/used/book-open-01.svg +3 -0
  47. package/static/svg/work/used/layout-alt-03.svg +3 -0
  48. package/temba-modules.ts +4 -0
@@ -25,8 +25,10 @@ import { RapidElement } from '../RapidElement';
25
25
  import {
26
26
  RealtimeSubscription,
27
27
  setRealtimeContext,
28
- subscribeToOrganization
28
+ subscribeToOrganization,
29
+ OrganizationEvent
29
30
  } from '../live/Realtime';
31
+ import { Watchers } from '../live/Watchers';
30
32
  import { lru } from 'tiny-lru';
31
33
  import { DateTime } from 'luxon';
32
34
  import { css, html } from 'lit';
@@ -229,7 +231,7 @@ export class Store extends RapidElement {
229
231
  private shortcuts: Shortcut[] = [];
230
232
  private workspace: Workspace;
231
233
  private featuredFields: ContactField[] = [];
232
- private assetWatchers: AssetWatcher[] = [];
234
+ private assetWatchers = new Watchers<AssetWatcher>('store asset watcher');
233
235
  // canonical names, the identities we've already asked about (including ones
234
236
  // the endpoint had no asset for) and their last-write versions, all bounded
235
237
  // so a long-lived page doesn't keep every asset it has ever rendered
@@ -729,19 +731,12 @@ export class Store extends RapidElement {
729
731
  })),
730
732
  onEvent
731
733
  };
732
- this.assetWatchers.push(watcher);
733
- Promise.resolve().then(() => {
734
- if (this.assetWatchers.includes(watcher)) {
735
- this.deliverAssetEvent(watcher, null);
736
- }
737
- });
734
+ this.assetWatchers.add(watcher);
735
+ this.assetWatchers.prime(watcher, () => watcher.onEvent(null));
738
736
 
739
737
  return {
740
738
  unsubscribe: () => {
741
- const index = this.assetWatchers.indexOf(watcher);
742
- if (index >= 0) {
743
- this.assetWatchers.splice(index, 1);
744
- }
739
+ this.assetWatchers.remove(watcher);
745
740
  }
746
741
  };
747
742
  }
@@ -754,7 +749,7 @@ export class Store extends RapidElement {
754
749
  */
755
750
  private async refreshAssetCache(): Promise<void> {
756
751
  const interests = new Map<string, StoreAssetReference>();
757
- for (const watcher of this.assetWatchers) {
752
+ for (const watcher of this.assetWatchers.all()) {
758
753
  for (const interest of watcher.interests) {
759
754
  const identity = assetIdentity(interest);
760
755
  if (identity) {
@@ -765,9 +760,7 @@ export class Store extends RapidElement {
765
760
  if (interests.size > 0) {
766
761
  await this.resolveAssets([...interests.values()], true);
767
762
  }
768
- for (const watcher of this.assetWatchers) {
769
- this.deliverAssetEvent(watcher, null);
770
- }
763
+ this.assetWatchers.each((watcher) => watcher.onEvent(null));
771
764
  }
772
765
 
773
766
  private async fetchAssetBatch(
@@ -863,8 +856,8 @@ export class Store extends RapidElement {
863
856
  this.assetVersions.set(identity, ++this.assetVersion);
864
857
  }
865
858
 
866
- private handleOrganizationEvent(event: any): void {
867
- const asset = event?.asset as StoreAsset;
859
+ private handleOrganizationEvent(event: OrganizationEvent): void {
860
+ const asset = event?.asset;
868
861
  const identity = assetIdentity(asset);
869
862
  if (
870
863
  event?.type !== 'asset_changed' ||
@@ -875,7 +868,7 @@ export class Store extends RapidElement {
875
868
  }
876
869
 
877
870
  const changed = { ...asset };
878
- const interested = this.assetWatchers.filter((watcher) =>
871
+ const interested = this.assetWatchers.some((watcher) =>
879
872
  watchesAsset(watcher, changed)
880
873
  );
881
874
  const previouslyResolved = this.resolvedAssetIdentities.has(identity);
@@ -892,7 +885,7 @@ export class Store extends RapidElement {
892
885
  name: changed.name
893
886
  };
894
887
  }
895
- if (!previouslyResolved && !pending && interested.length === 0) {
888
+ if (!previouslyResolved && !pending && !interested) {
896
889
  return;
897
890
  }
898
891
 
@@ -904,20 +897,10 @@ export class Store extends RapidElement {
904
897
  type: 'asset_changed',
905
898
  asset: changed
906
899
  };
907
- for (const watcher of interested) {
908
- this.deliverAssetEvent(watcher, publication);
909
- }
910
- }
911
-
912
- private deliverAssetEvent(
913
- watcher: AssetWatcher,
914
- event: StoreAssetChangedEvent | null
915
- ): void {
916
- try {
917
- watcher.onEvent(event);
918
- } catch (error) {
919
- console.error('store asset watcher failed', error);
920
- }
900
+ this.assetWatchers.each(
901
+ (watcher) => watcher.onEvent(publication),
902
+ (watcher) => watchesAsset(watcher, changed)
903
+ );
921
904
  }
922
905
 
923
906
  public isDynamicGroup(uuid: string): boolean {
@@ -20,16 +20,21 @@ export const designTokens = css`
20
20
  Fallback matches the design-system.css default so component-
21
21
  only test harnesses pick up the same brand colour. */
22
22
  --accent: rgb(var(--primary-rgb, 73, 127, 206));
23
- --accent-50: color-mix(in srgb, var(--accent) 6%, white);
24
- --accent-100: color-mix(in srgb, var(--accent) 16%, white);
25
- --accent-200: color-mix(in srgb, var(--accent) 32%, white);
26
- --accent-300: color-mix(in srgb, var(--accent) 60%, white);
23
+ /* Static ramp values are pre-mixed from the default anchor for
24
+ browsers without color-mix() (pre-Chrome 111, e.g. the last
25
+ Chrome available on Windows 7/8.1). The @supports block below
26
+ re-derives them from --accent so host re-theming still works
27
+ where color-mix() is available. */
28
+ --accent-50: #f4f7fc;
29
+ --accent-100: #e2ebf7;
30
+ --accent-200: #c5d6ef;
31
+ --accent-300: #92b2e2;
27
32
  --accent-400: var(--accent);
28
- --accent-500: color-mix(in srgb, var(--accent) 90%, black);
29
- --accent-600: color-mix(in srgb, var(--accent) 80%, black);
30
- --accent-700: color-mix(in srgb, var(--accent) 65%, black);
31
- --accent-800: color-mix(in srgb, var(--accent) 50%, black);
32
- --accent-900: color-mix(in srgb, var(--accent) 35%, black);
33
+ --accent-500: #4272b9;
34
+ --accent-600: #3a66a5;
35
+ --accent-700: #2f5386;
36
+ --accent-800: #244067;
37
+ --accent-900: #1a2c48;
33
38
 
34
39
  /* neutrals */
35
40
  --bg: #f6f7f9;
@@ -98,6 +103,17 @@ export const designTokens = css`
98
103
  --pad: 10px;
99
104
  --gap: 14px;
100
105
 
106
+ /* Page header strip. --header-h is the height of a header bar over a
107
+ single-line title (the tickets / contact chat strip). --title-line is
108
+ one line of that title, declared here rather than inside
109
+ temba-page-header because temba-header-bar derives its balanced inset
110
+ from it — keeping both on one definition means restyling the title
111
+ can't silently unbalance the strip. */
112
+ --header-h: 53px;
113
+ --title-size: 15.5px;
114
+ --title-leading: 1.25;
115
+ --title-line: calc(var(--title-size) * var(--title-leading));
116
+
101
117
  /* shadows */
102
118
  --shadow-1:
103
119
  0 1px 1px rgba(15, 22, 36, 0.04), 0 1px 2px rgba(15, 22, 36, 0.04);
@@ -124,14 +140,19 @@ export const designTokens = css`
124
140
  error state (e.g. the dropdown popup) reference --focus-muted
125
141
  / --focus-halo directly to skip that override. */
126
142
  --focus: rgb(var(--focus-rgb, 91, 156, 229));
127
- --focus-50: color-mix(in srgb, var(--focus) 12%, white);
128
- --focus-100: color-mix(in srgb, var(--focus) 24%, white);
129
- --focus-200: color-mix(in srgb, var(--focus) 40%, white);
130
- --focus-300: color-mix(in srgb, var(--focus) 60%, white);
131
- --focus-600: color-mix(in srgb, var(--focus) 60%, black);
132
- --focus-700: color-mix(in srgb, var(--focus) 45%, black);
133
- --focus-muted: color-mix(in srgb, var(--focus) 60%, white);
134
- --focus-halo: 0 0 0 3px color-mix(in srgb, var(--focus) 30%, transparent);
143
+ /* Static values pre-mixed from the default focus hue for browsers
144
+ without color-mix(); re-derived in the @supports block below. */
145
+ --focus-50: #ebf3fc;
146
+ --focus-100: #d8e7f9;
147
+ --focus-200: #bdd7f5;
148
+ --focus-300: #9dc4ef;
149
+ --focus-600: #375e89;
150
+ --focus-700: #294667;
151
+ --focus-muted: #9dc4ef;
152
+ /* The halo is a translucent wash, so unlike the opaque ramp steps
153
+ it can keep tracking the theme without color-mix() — rgba(var())
154
+ substitution works wherever custom properties do. */
155
+ --focus-halo: 0 0 0 3px rgba(var(--focus-rgb, 91, 156, 229), 0.3);
135
156
  --color-focus: var(--focus-muted);
136
157
  --widget-box-shadow-focused: var(--focus-halo);
137
158
 
@@ -160,4 +181,34 @@ export const designTokens = css`
160
181
  --temba-select-selected-font-size: 13.5px;
161
182
  --temba-select-min-height: var(--input-h);
162
183
  }
184
+
185
+ /* Where color-mix() is supported, derive the accent / focus ramps
186
+ from their anchors so host pages can re-theme by overriding just
187
+ --primary-rgb / --focus-rgb. Browsers without color-mix() keep the
188
+ static pre-mixed values above. Note the static values can't act as
189
+ per-declaration fallbacks — a custom property is never invalid at
190
+ parse time, so the last declaration always wins — hence the
191
+ @supports gate. */
192
+ @supports (color: color-mix(in srgb, red, red)) {
193
+ :host {
194
+ --accent-50: color-mix(in srgb, var(--accent) 6%, white);
195
+ --accent-100: color-mix(in srgb, var(--accent) 16%, white);
196
+ --accent-200: color-mix(in srgb, var(--accent) 32%, white);
197
+ --accent-300: color-mix(in srgb, var(--accent) 60%, white);
198
+ --accent-500: color-mix(in srgb, var(--accent) 90%, black);
199
+ --accent-600: color-mix(in srgb, var(--accent) 80%, black);
200
+ --accent-700: color-mix(in srgb, var(--accent) 65%, black);
201
+ --accent-800: color-mix(in srgb, var(--accent) 50%, black);
202
+ --accent-900: color-mix(in srgb, var(--accent) 35%, black);
203
+
204
+ --focus-50: color-mix(in srgb, var(--focus) 12%, white);
205
+ --focus-100: color-mix(in srgb, var(--focus) 24%, white);
206
+ --focus-200: color-mix(in srgb, var(--focus) 40%, white);
207
+ --focus-300: color-mix(in srgb, var(--focus) 60%, white);
208
+ --focus-600: color-mix(in srgb, var(--focus) 60%, black);
209
+ --focus-700: color-mix(in srgb, var(--focus) 45%, black);
210
+ --focus-muted: color-mix(in srgb, var(--focus) 60%, white);
211
+ --focus-halo: 0 0 0 3px color-mix(in srgb, var(--focus) 30%, transparent);
212
+ }
213
+ }
163
214
  `;
@@ -100,10 +100,14 @@ export const pillVariants = css`
100
100
  border-color: var(--pill-border, var(--border));
101
101
  --icon-color: var(--text-2);
102
102
  }
103
+ /* Anchor-derived variants carry static pre-mixed bg/border values
104
+ for browsers without color-mix() (pre-Chrome 111); the @supports
105
+ block at the bottom re-derives them from the anchors so host
106
+ re-theming still works where color-mix() is available. */
103
107
  .pill-flow {
104
- background: color-mix(in srgb, var(--flow) 12%, white);
108
+ background: #e3f4e9;
105
109
  color: var(--flow);
106
- border-color: color-mix(in srgb, var(--flow) 25%, white);
110
+ border-color: #c5e8d2;
107
111
  --icon-color: var(--flow);
108
112
  }
109
113
  /* Recipient color — shared by contacts and groups. Anchored to
@@ -111,27 +115,27 @@ export const pillVariants = css`
111
115
  identity even when the host page re-themes via --primary-rgb. */
112
116
  .pill-contact,
113
117
  .pill-group {
114
- background: color-mix(in srgb, var(--recipient) 12%, white);
118
+ background: #e5eef6;
115
119
  color: var(--recipient);
116
- border-color: color-mix(in srgb, var(--recipient) 25%, white);
120
+ border-color: #cadbec;
117
121
  --icon-color: var(--recipient);
118
122
  }
119
123
  .pill-channel {
120
- background: color-mix(in srgb, var(--channel) 12%, white);
124
+ background: #ede4f5;
121
125
  color: var(--channel);
122
- border-color: color-mix(in srgb, var(--channel) 25%, white);
126
+ border-color: #dac8e9;
123
127
  --icon-color: var(--channel);
124
128
  }
125
129
  .pill-topic {
126
- background: color-mix(in srgb, var(--topic) 12%, white);
130
+ background: #faefe1;
127
131
  color: var(--topic);
128
- border-color: color-mix(in srgb, var(--topic) 25%, white);
132
+ border-color: #f6ddc1;
129
133
  --icon-color: var(--topic);
130
134
  }
131
135
  .pill-campaign {
132
- background: color-mix(in srgb, var(--campaign) 12%, white);
136
+ background: #e1f2f6;
133
137
  color: var(--campaign);
134
- border-color: color-mix(in srgb, var(--campaign) 25%, white);
138
+ border-color: #c1e4ec;
135
139
  --icon-color: var(--campaign);
136
140
  }
137
141
  .pill-field {
@@ -171,4 +175,32 @@ export const pillVariants = css`
171
175
  border-color: var(--pill-border, var(--border));
172
176
  --icon-color: var(--text-2);
173
177
  }
178
+
179
+ /* Anchor-derived bg/border for browsers with color-mix(). These
180
+ can't be simple same-rule fallbacks: a declaration containing
181
+ var() isn't dropped at parse time, it goes invalid at
182
+ computed-value time and takes the earlier declaration with it. */
183
+ @supports (color: color-mix(in srgb, red, red)) {
184
+ .pill-flow {
185
+ background: color-mix(in srgb, var(--flow) 12%, white);
186
+ border-color: color-mix(in srgb, var(--flow) 25%, white);
187
+ }
188
+ .pill-contact,
189
+ .pill-group {
190
+ background: color-mix(in srgb, var(--recipient) 12%, white);
191
+ border-color: color-mix(in srgb, var(--recipient) 25%, white);
192
+ }
193
+ .pill-channel {
194
+ background: color-mix(in srgb, var(--channel) 12%, white);
195
+ border-color: color-mix(in srgb, var(--channel) 25%, white);
196
+ }
197
+ .pill-topic {
198
+ background: color-mix(in srgb, var(--topic) 12%, white);
199
+ border-color: color-mix(in srgb, var(--topic) 25%, white);
200
+ }
201
+ .pill-campaign {
202
+ background: color-mix(in srgb, var(--campaign) 12%, white);
203
+ border-color: color-mix(in srgb, var(--campaign) 25%, white);
204
+ }
205
+ }
174
206
  `;
@@ -20,18 +20,22 @@
20
20
  --primary-rgb: 73, 127, 206;
21
21
 
22
22
  /* accent ramp — derived from --primary-rgb via sRGB mixing.
23
- 400 == primary; the ramp computes outward in both directions. */
23
+ 400 == primary; the ramp computes outward in both directions.
24
+ Static values pre-mixed from the default anchor come first for
25
+ browsers without color-mix() (pre-Chrome 111); the @supports
26
+ block below re-derives them from --accent so re-theming via
27
+ --primary-rgb still works where color-mix() is available. */
24
28
  --accent: rgb(var(--primary-rgb));
25
- --accent-50: color-mix(in srgb, var(--accent) 6%, white);
26
- --accent-100: color-mix(in srgb, var(--accent) 16%, white);
27
- --accent-200: color-mix(in srgb, var(--accent) 32%, white);
28
- --accent-300: color-mix(in srgb, var(--accent) 60%, white);
29
+ --accent-50: #f4f7fc;
30
+ --accent-100: #e2ebf7;
31
+ --accent-200: #c5d6ef;
32
+ --accent-300: #92b2e2;
29
33
  --accent-400: var(--accent);
30
- --accent-500: color-mix(in srgb, var(--accent) 90%, black);
31
- --accent-600: color-mix(in srgb, var(--accent) 80%, black);
32
- --accent-700: color-mix(in srgb, var(--accent) 65%, black);
33
- --accent-800: color-mix(in srgb, var(--accent) 50%, black);
34
- --accent-900: color-mix(in srgb, var(--accent) 35%, black);
34
+ --accent-500: #4272b9;
35
+ --accent-600: #3a66a5;
36
+ --accent-700: #2f5386;
37
+ --accent-800: #244067;
38
+ --accent-900: #1a2c48;
35
39
 
36
40
  /* neutrals */
37
41
  --bg: #F6F7F9;
@@ -94,6 +98,24 @@
94
98
  --section-w-collapsed: 56px;
95
99
  }
96
100
 
101
+ /* Where color-mix() is supported, derive the accent ramp from --accent
102
+ so re-theming via --primary-rgb works without design-system.js.
103
+ Custom properties can't fall back per-declaration (they're never
104
+ invalid at parse time), hence the @supports gate. */
105
+ @supports (color: color-mix(in srgb, red, red)) {
106
+ :root {
107
+ --accent-50: color-mix(in srgb, var(--accent) 6%, white);
108
+ --accent-100: color-mix(in srgb, var(--accent) 16%, white);
109
+ --accent-200: color-mix(in srgb, var(--accent) 32%, white);
110
+ --accent-300: color-mix(in srgb, var(--accent) 60%, white);
111
+ --accent-500: color-mix(in srgb, var(--accent) 90%, black);
112
+ --accent-600: color-mix(in srgb, var(--accent) 80%, black);
113
+ --accent-700: color-mix(in srgb, var(--accent) 65%, black);
114
+ --accent-800: color-mix(in srgb, var(--accent) 50%, black);
115
+ --accent-900: color-mix(in srgb, var(--accent) 35%, black);
116
+ }
117
+ }
118
+
97
119
  /* ─── base ───────────────────────────────────────────────────────────── */
98
120
  .ds * { box-sizing: border-box; }
99
121
  .ds {
@@ -364,7 +386,13 @@
364
386
  color: white;
365
387
  }
366
388
  .ds .btn-danger:hover {
367
- background: color-mix(in srgb, var(--danger) 88%, black);
389
+ /* pre-mixed fallback for browsers without color-mix() */
390
+ background: #b73737;
391
+ }
392
+ @supports (color: color-mix(in srgb, red, red)) {
393
+ .ds .btn-danger:hover {
394
+ background: color-mix(in srgb, var(--danger) 88%, black);
395
+ }
368
396
  }
369
397
  .ds .btn.is-disabled,
370
398
  .ds .btn:disabled { opacity: 0.45; cursor: not-allowed; }
@@ -533,7 +561,14 @@
533
561
  .ds .input:focus-within {
534
562
  outline: 0;
535
563
  border-color: var(--accent-300);
536
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
564
+ /* translucent-ring fallback for browsers without color-mix() */
565
+ box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.25);
566
+ }
567
+ @supports (color: color-mix(in srgb, red, red)) {
568
+ .ds .input:focus,
569
+ .ds .input:focus-within {
570
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
571
+ }
537
572
  }
538
573
 
539
574
  .ds .input-tokens {
@@ -41,15 +41,19 @@
41
41
  --curvature: 6px;
42
42
  --curvature-widget: 6px;
43
43
  --focus: rgb(var(--focus-rgb, 91, 156, 229));
44
- --focus-50: color-mix(in srgb, var(--focus) 12%, white);
45
- --focus-100: color-mix(in srgb, var(--focus) 24%, white);
46
- --focus-200: color-mix(in srgb, var(--focus) 40%, white);
47
- --focus-300: color-mix(in srgb, var(--focus) 60%, white);
48
- --focus-600: color-mix(in srgb, var(--focus) 60%, black);
49
- --focus-700: color-mix(in srgb, var(--focus) 45%, black);
50
- --focus-muted: color-mix(in srgb, var(--focus) 60%, white);
51
- --focus-halo: 0 0 0 3px
52
- color-mix(in srgb, var(--focus) 30%, transparent);
44
+ /* Static values pre-mixed from the default focus hue for browsers
45
+ without color-mix(); re-derived in the @supports block below. */
46
+ --focus-50: #ebf3fc;
47
+ --focus-100: #d8e7f9;
48
+ --focus-200: #bdd7f5;
49
+ --focus-300: #9dc4ef;
50
+ --focus-600: #375e89;
51
+ --focus-700: #294667;
52
+ --focus-muted: #9dc4ef;
53
+ /* The halo is a translucent wash, so unlike the opaque ramp steps
54
+ it can keep tracking the theme without color-mix() — rgba(var())
55
+ substitution works wherever custom properties do. */
56
+ --focus-halo: 0 0 0 3px rgba(var(--focus-rgb, 91, 156, 229), 0.3);
53
57
  --color-focus: #a4cafe;
54
58
  --color-widget-bg: #fff;
55
59
  --color-widget-bg-focused: #fff;
@@ -201,6 +205,24 @@
201
205
 
202
206
  }
203
207
 
208
+ /* Where color-mix() is supported, derive the focus ramp from --focus
209
+ so re-theming via --focus-rgb works. Custom properties can't fall
210
+ back per-declaration (they're never invalid at parse time), hence
211
+ the @supports gate. */
212
+ @supports (color: color-mix(in srgb, red, red)) {
213
+ html {
214
+ --focus-50: color-mix(in srgb, var(--focus) 12%, white);
215
+ --focus-100: color-mix(in srgb, var(--focus) 24%, white);
216
+ --focus-200: color-mix(in srgb, var(--focus) 40%, white);
217
+ --focus-300: color-mix(in srgb, var(--focus) 60%, white);
218
+ --focus-600: color-mix(in srgb, var(--focus) 60%, black);
219
+ --focus-700: color-mix(in srgb, var(--focus) 45%, black);
220
+ --focus-muted: color-mix(in srgb, var(--focus) 60%, white);
221
+ --focus-halo: 0 0 0 3px
222
+ color-mix(in srgb, var(--focus) 30%, transparent);
223
+ }
224
+ }
225
+
204
226
  temba-button {
205
227
  --button-bg: var(--accent-500);
206
228
  --button-text: var(--color-text-light);