@nyaruka/temba-components 0.169.0 → 0.170.1

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 (40) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/locales/es.js +1 -0
  3. package/dist/locales/es.js.map +1 -1
  4. package/dist/locales/fr.js +1 -0
  5. package/dist/locales/fr.js.map +1 -1
  6. package/dist/locales/pt.js +1 -0
  7. package/dist/locales/pt.js.map +1 -1
  8. package/dist/temba-components.js +501 -173
  9. package/dist/temba-components.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/display/Button.ts +14 -2
  12. package/src/display/Chat.ts +43 -24
  13. package/src/display/Dropdown.ts +11 -7
  14. package/src/display/Label.ts +18 -3
  15. package/src/display/Options.ts +27 -14
  16. package/src/display/TembaDate.ts +9 -2
  17. package/src/form/Compose.ts +37 -28
  18. package/src/form/select/Select.ts +15 -2
  19. package/src/interfaces.ts +13 -3
  20. package/src/list/BroadcastList.ts +20 -7
  21. package/src/list/ContactList.ts +113 -9
  22. package/src/list/ContentList.ts +380 -21
  23. package/src/list/ContentMenu.ts +12 -6
  24. package/src/list/FieldList.ts +8 -1
  25. package/src/list/TembaList.ts +99 -29
  26. package/src/list/TicketList.ts +87 -18
  27. package/src/live/CampaignEvents.ts +31 -11
  28. package/src/live/ContactChat.ts +36 -7
  29. package/src/live/ContactTimeline.ts +46 -24
  30. package/src/locales/es.ts +1 -0
  31. package/src/locales/fr.ts +1 -0
  32. package/src/locales/pt.ts +1 -0
  33. package/src/simulator/Simulator.ts +1 -0
  34. package/src/styles/designTokens.ts +57 -17
  35. package/src/styles/pillVariants.ts +42 -10
  36. package/static/css/design-system.css +47 -12
  37. package/static/css/temba-components.css +31 -9
  38. package/xliff/es.xlf +3 -0
  39. package/xliff/fr.xlf +3 -0
  40. package/xliff/pt.xlf +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyaruka/temba-components",
3
- "version": "0.169.0",
3
+ "version": "0.170.1",
4
4
  "description": "Web components to support rapidpro and related projects",
5
5
  "author": "Nyaruka <code@nyaruka.coim>",
6
6
  "main": "dist/index.js",
@@ -101,9 +101,11 @@ export class Button extends LitElement {
101
101
  background: var(--success, #16a34a);
102
102
  color: #fff;
103
103
  }
104
+ /* Hover darkening: static pre-mixed values first for browsers
105
+ without color-mix(), re-derived from the token below. */
104
106
  .attention-button:hover,
105
107
  .affirmative:hover {
106
- background: color-mix(in srgb, var(--success, #16a34a) 88%, black);
108
+ background: #138f41;
107
109
  }
108
110
 
109
111
  /* DS .btn-danger — flat danger fill (no lift). */
@@ -112,7 +114,17 @@ export class Button extends LitElement {
112
114
  color: #fff;
113
115
  }
114
116
  .destructive-button:hover {
115
- background: color-mix(in srgb, var(--danger, #d03f3f) 88%, black);
117
+ background: #b73737;
118
+ }
119
+
120
+ @supports (color: color-mix(in srgb, red, red)) {
121
+ .attention-button:hover,
122
+ .affirmative:hover {
123
+ background: color-mix(in srgb, var(--success, #16a34a) 88%, black);
124
+ }
125
+ .destructive-button:hover {
126
+ background: color-mix(in srgb, var(--danger, #d03f3f) 88%, black);
127
+ }
116
128
  }
117
129
 
118
130
  /* DS .btn-ghost — text-only, hover fills with sunken */
@@ -2,7 +2,7 @@ import { TemplateResult, html, nothing, PropertyValueMap, css } from 'lit';
2
2
  import { property } from 'lit/decorators.js';
3
3
  import { repeat } from 'lit/directives/repeat.js';
4
4
  import { RapidElement } from '../RapidElement';
5
- import { CustomEventType } from '../interfaces';
5
+ import { CustomEventType, QuickReply } from '../interfaces';
6
6
  import { TicketEvent } from '../events';
7
7
  import { renderEventSummary } from '../events/eventRenderers';
8
8
  import { DEFAULT_AVATAR } from '../webchat/assets';
@@ -81,7 +81,7 @@ interface User extends ObjectReference {
81
81
  export interface Msg {
82
82
  text: string;
83
83
  channel: ObjectReference;
84
- quick_replies: string[];
84
+ quick_replies: (string | QuickReply)[];
85
85
  urn: string;
86
86
  direction: string;
87
87
  type: string;
@@ -518,19 +518,15 @@ export class Chat extends RapidElement {
518
518
  same theme vars the flat fills used — color-mix keeps host
519
519
  re-theming working — with a whisper of shadow for depth and
520
520
  a hairline border mixed just darker than the fill so bubbles
521
- hold their edge against the background. */
521
+ hold their edge against the background. Base rules carry flat
522
+ fills and pre-mixed borders for browsers without color-mix()
523
+ (pre-Chrome 111); the @supports block below layers the
524
+ gradients on top. */
522
525
  .bubble {
523
526
  padding: 10px 14px;
524
- background: linear-gradient(
525
- 180deg,
526
- color-mix(in srgb, var(--color-chat-in, #e5e5ea) 62%, white) 0%,
527
- var(--color-chat-in, #e5e5ea) 100%
528
- );
527
+ background: var(--color-chat-in, #e5e5ea);
529
528
  border-radius: 18px;
530
- border: var(
531
- --chat-border-in,
532
- 1px solid color-mix(in srgb, var(--color-chat-in, #e5e5ea) 93%, black)
533
- );
529
+ border: var(--chat-border-in, 1px solid #d5d5da);
534
530
  box-shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
535
531
  }
536
532
 
@@ -550,20 +546,40 @@ export class Chat extends RapidElement {
550
546
  }
551
547
 
552
548
  .incoming .bubble {
553
- background: linear-gradient(
554
- 180deg,
555
- color-mix(in srgb, var(--color-chat-out, #007aff) 90%, white) 0%,
556
- var(--color-chat-out, #007aff) 58%,
557
- color-mix(in srgb, var(--color-chat-out, #007aff) 95%, black) 100%
558
- );
559
- border: var(
560
- --chat-border-out,
561
- 1px solid
562
- color-mix(in srgb, var(--color-chat-out, #007aff) 90%, black)
563
- );
549
+ background: var(--color-chat-out, #007aff);
550
+ border: var(--chat-border-out, 1px solid #006ee6);
564
551
  color: white;
565
552
  }
566
553
 
554
+ @supports (color: color-mix(in srgb, red, red)) {
555
+ .bubble {
556
+ background: linear-gradient(
557
+ 180deg,
558
+ color-mix(in srgb, var(--color-chat-in, #e5e5ea) 62%, white) 0%,
559
+ var(--color-chat-in, #e5e5ea) 100%
560
+ );
561
+ border: var(
562
+ --chat-border-in,
563
+ 1px solid
564
+ color-mix(in srgb, var(--color-chat-in, #e5e5ea) 93%, black)
565
+ );
566
+ }
567
+
568
+ .incoming .bubble {
569
+ background: linear-gradient(
570
+ 180deg,
571
+ color-mix(in srgb, var(--color-chat-out, #007aff) 90%, white) 0%,
572
+ var(--color-chat-out, #007aff) 58%,
573
+ color-mix(in srgb, var(--color-chat-out, #007aff) 95%, black) 100%
574
+ );
575
+ border: var(
576
+ --chat-border-out,
577
+ 1px solid
578
+ color-mix(in srgb, var(--color-chat-out, #007aff) 90%, black)
579
+ );
580
+ }
581
+ }
582
+
567
583
  .incoming .latest .bubble {
568
584
  border-bottom-right-radius: 4px;
569
585
  }
@@ -573,8 +589,11 @@ export class Chat extends RapidElement {
573
589
  }
574
590
 
575
591
  /* Notes get the same top-lit gradient treatment as message
576
- bubbles, derived from their sticky-note yellow. */
592
+ bubbles, derived from their sticky-note yellow. The flat fill
593
+ first is the fallback for browsers without color-mix() — all
594
+ literals here, so the invalid gradient drops at parse time. */
577
595
  .note .bubble {
596
+ background: #fffac3;
578
597
  background: linear-gradient(
579
598
  180deg,
580
599
  color-mix(in srgb, #fffac3 62%, white) 0%,
@@ -9,9 +9,6 @@ import { styleMap } from 'lit-html/directives/style-map.js';
9
9
  export class Dropdown extends RapidElement {
10
10
  static get styles() {
11
11
  return css`
12
- :host {
13
- }
14
-
15
12
  .wrapper {
16
13
  position: relative;
17
14
  }
@@ -25,9 +22,6 @@ export class Dropdown extends RapidElement {
25
22
  overflow: auto;
26
23
  }
27
24
 
28
- .dropdown:focus {
29
- }
30
-
31
25
  .dropdown.dormant {
32
26
  height: 0;
33
27
  overflow: hidden;
@@ -35,7 +29,16 @@ export class Dropdown extends RapidElement {
35
29
 
36
30
  .dropdown {
37
31
  position: fixed;
38
- z-index: 2;
32
+ /* open popups sit above floating windows (simulator, 5000) and
33
+ other page chrome, but below dialogs and toasts (10000);
34
+ temba-content-menu mirrors this value on its temba-dropdown
35
+ flex item (see ContentMenu.ts) */
36
+ z-index: 9000;
37
+ /* the dormant (height: 0) rule is applied 250ms after close, so
38
+ until then this stays a laid-out, invisible fixed box at
39
+ z-index 9000 over whatever it was covering — ignore pointer
40
+ events unless we're actually open */
41
+ pointer-events: none;
39
42
  padding: 0;
40
43
  opacity: 0;
41
44
  border-radius: calc(var(--curvature) * 1.5);
@@ -67,6 +70,7 @@ export class Dropdown extends RapidElement {
67
70
 
68
71
  .open .dropdown {
69
72
  opacity: 1;
73
+ pointer-events: auto;
70
74
  transform: translateY(0.5em) scale(1);
71
75
  }
72
76
 
@@ -100,7 +100,10 @@ export default class Label extends LitElement {
100
100
  dark variant shade), so flow stays bluish, group stays purplish,
101
101
  etc. — no grey wash. */
102
102
  .label[class*='pill-'].clickable .mask:hover {
103
- background: color-mix(in oklab, currentColor 10%, transparent);
103
+ /* Neutral wash first for browsers without color-mix() the
104
+ currentColor mix can't fall back per-declaration because
105
+ currentColor keeps it valid until computed-value time. */
106
+ background: rgba(0, 0, 0, 0.06);
104
107
  }
105
108
  .label[class*='pill-'] temba-icon {
106
109
  margin-right: 0;
@@ -121,14 +124,26 @@ export default class Label extends LitElement {
121
124
  margin: 0;
122
125
  border: 0;
123
126
  border-radius: 999px;
124
- background: color-mix(in oklab, currentColor 25%, transparent);
127
+ background: rgba(0, 0, 0, 0.12);
125
128
  color: inherit;
126
129
  opacity: 0.8;
127
130
  --icon-color: currentColor;
128
131
  }
129
132
  .label[class*='pill-'] .remove:hover {
130
133
  opacity: 1;
131
- background: color-mix(in oklab, currentColor 45%, transparent);
134
+ background: rgba(0, 0, 0, 0.22);
135
+ }
136
+
137
+ @supports (color: color-mix(in srgb, red, red)) {
138
+ .label[class*='pill-'].clickable .mask:hover {
139
+ background: color-mix(in oklab, currentColor 10%, transparent);
140
+ }
141
+ .label[class*='pill-'] .remove {
142
+ background: color-mix(in oklab, currentColor 25%, transparent);
143
+ }
144
+ .label[class*='pill-'] .remove:hover {
145
+ background: color-mix(in oklab, currentColor 45%, transparent);
146
+ }
132
147
  }
133
148
 
134
149
  /* When a removable X is present, tighten the mask's left padding
@@ -338,6 +338,13 @@ export class Options extends RapidElement {
338
338
  @property({ attribute: false })
339
339
  renderOption: { (option: any, selected: boolean): TemplateResult };
340
340
 
341
+ // renders between rows (and above the first) when the data crosses a
342
+ // grouping boundary - the result sits outside the option rows so it never
343
+ // picks up their hover or selection treatment. Returning null means the
344
+ // rows aren't a boundary and nothing is rendered between them.
345
+ @property({ attribute: false })
346
+ renderDivider: { (prev: any, option: any): TemplateResult | null };
347
+
341
348
  @property({ attribute: false })
342
349
  renderOptionName: { (option: any, selected: boolean): TemplateResult };
343
350
 
@@ -816,20 +823,26 @@ export class Options extends RapidElement {
816
823
  <div class="${classesInner}" style=${styleMap(optionsStyle)}>
817
824
  ${options.length > 0
818
825
  ? options.map((option, index) => {
819
- return html`<div
820
- data-option-index="${index}"
821
- @mousemove=${this.handleMouseMove}
822
- @mousedown=${this.handleOptionClick}
823
- class="option ${index === this.cursorIndex &&
824
- !this.internalFocusDisabled
825
- ? 'focused'
826
- : ''}"
827
- >
828
- ${this.resolvedRenderOption(
829
- option,
830
- index === this.cursorIndex
831
- )}
832
- </div>`;
826
+ return html`${this.renderDivider
827
+ ? this.renderDivider(
828
+ index > 0 ? options[index - 1] : null,
829
+ option
830
+ )
831
+ : null}
832
+ <div
833
+ data-option-index="${index}"
834
+ @mousemove=${this.handleMouseMove}
835
+ @mousedown=${this.handleOptionClick}
836
+ class="option ${index === this.cursorIndex &&
837
+ !this.internalFocusDisabled
838
+ ? 'focused'
839
+ : ''}"
840
+ >
841
+ ${this.resolvedRenderOption(
842
+ option,
843
+ index === this.cursorIndex
844
+ )}
845
+ </div>`;
833
846
  })
834
847
  : this.visible && this.showEmptyMessage
835
848
  ? html`<div
@@ -1,4 +1,4 @@
1
- import { css, html, PropertyValues, TemplateResult } from 'lit';
1
+ import { css, html, nothing, PropertyValues, TemplateResult } from 'lit';
2
2
  import { property } from 'lit/decorators.js';
3
3
  import { RapidElement } from '../RapidElement';
4
4
  import { Store } from '../store/Store';
@@ -47,7 +47,14 @@ export class TembaDate extends RapidElement {
47
47
  }
48
48
  }
49
49
 
50
- public render(): TemplateResult {
50
+ public render(): TemplateResult | typeof nothing {
51
+ // unparseable values and zero-value times (e.g. go's zero time,
52
+ // 0001-01-01T00:00:00Z, for a contact that has never been seen) aren't
53
+ // real dates - render nothing rather than something like "2025 years ago"
54
+ if (this.datetime && !(this.datetime.isValid && this.datetime.year > 1)) {
55
+ return nothing;
56
+ }
57
+
51
58
  if (this.datetime && this.store) {
52
59
  this.datetime.setLocale(this.store.getLocale());
53
60
 
@@ -1,7 +1,13 @@
1
1
  import { TemplateResult, html, css, PropertyValues, nothing } from 'lit';
2
2
  import { FieldElement } from './FieldElement';
3
3
  import { property } from 'lit/decorators.js';
4
- import { Attachment, CustomEventType, Language, Shortcut } from '../interfaces';
4
+ import {
5
+ Attachment,
6
+ CustomEventType,
7
+ Language,
8
+ QuickReply,
9
+ Shortcut
10
+ } from '../interfaces';
5
11
  import { Icon } from '../Icons';
6
12
  import { DEFAULT_MEDIA_ENDPOINT } from '../utils';
7
13
  import { Select } from './select/Select';
@@ -12,7 +18,7 @@ import { setCaretOffset } from '../excellent/caret-utils';
12
18
  export interface ComposeValue {
13
19
  text: string;
14
20
  attachments: { uuid: string }[];
15
- quick_replies: string[];
21
+ quick_replies: (string | QuickReply)[];
16
22
  optin: string;
17
23
  template: string;
18
24
  variables: string[];
@@ -236,7 +242,7 @@ export class Compose extends FieldElement {
236
242
  [lang: string]: {
237
243
  text: string;
238
244
  attachments: Attachment[];
239
- quick_replies: string[];
245
+ quick_replies: (string | QuickReply)[];
240
246
  optin?: { name: string; uuid: string };
241
247
  template?: string;
242
248
  variables?: string[];
@@ -357,11 +363,12 @@ export class Compose extends FieldElement {
357
363
  this.currentText = langValue.text || '';
358
364
  this.initialText = langValue.text || '';
359
365
  this.currentAttachments = langValue.attachments || [];
360
- this.currentQuickReplies = (langValue.quick_replies || []).map(
361
- (value) => {
362
- return { name: value, value };
363
- }
364
- );
366
+ this.currentQuickReplies = (langValue.quick_replies || [])
367
+ .map((reply) =>
368
+ typeof reply === 'string' ? reply : (reply.text ?? null)
369
+ )
370
+ .filter((reply): reply is string => reply !== null)
371
+ .map((value) => ({ name: value, value }));
365
372
  this.currentOptin = langValue['optin'] ? [langValue['optin']] : [];
366
373
  }
367
374
 
@@ -410,25 +417,14 @@ export class Compose extends FieldElement {
410
417
  // own keydown inserts a newline (which would flash before the send
411
418
  // clears it); bubble keeps Enter-to-send working from anywhere else
412
419
  // in the compose after inner widgets have had their shot at it
413
- this.addEventListener(
414
- 'keydown',
415
- this.handleHostKeyDown as EventListener,
416
- true
417
- );
418
- this.addEventListener('keydown', this.handleHostKeyDown as EventListener);
420
+ this.addEventListener('keydown', this.handleHostKeyDownCapture, true);
421
+ this.addEventListener('keydown', this.handleHostKeyDownBubble);
419
422
  }
420
423
 
421
424
  disconnectedCallback() {
422
425
  super.disconnectedCallback();
423
- this.removeEventListener(
424
- 'keydown',
425
- this.handleHostKeyDown as EventListener,
426
- true
427
- );
428
- this.removeEventListener(
429
- 'keydown',
430
- this.handleHostKeyDown as EventListener
431
- );
426
+ this.removeEventListener('keydown', this.handleHostKeyDownCapture, true);
427
+ this.removeEventListener('keydown', this.handleHostKeyDownBubble);
432
428
  }
433
429
 
434
430
  private handleShortcutIconClick() {
@@ -454,7 +450,18 @@ export class Compose extends FieldElement {
454
450
  });
455
451
  }
456
452
 
457
- private handleHostKeyDown = (evt: KeyboardEvent) => {
453
+ // events from inside our shadow tree are retargeted to the host, so
454
+ // both registrations see eventPhase as AT_TARGET — the phase can't
455
+ // tell us which registration is firing, so each binds it explicitly
456
+ private handleHostKeyDownCapture = (evt: KeyboardEvent) => {
457
+ this.handleHostKeyDown(evt, true);
458
+ };
459
+
460
+ private handleHostKeyDownBubble = (evt: KeyboardEvent) => {
461
+ this.handleHostKeyDown(evt, false);
462
+ };
463
+
464
+ private handleHostKeyDown = (evt: KeyboardEvent, capture: boolean) => {
458
465
  if (evt.key === 'Escape' && this.showShortcuts) {
459
466
  evt.preventDefault();
460
467
  evt.stopPropagation();
@@ -474,10 +481,12 @@ export class Compose extends FieldElement {
474
481
  // during capture only editor presses are claimed — anything else
475
482
  // (quick replies, attachments) waits for the bubble so the widget
476
483
  // it targets keeps first claim on the event
477
- if (
478
- evt.eventPhase === Event.CAPTURING_PHASE &&
479
- !(editor && evt.composedPath().includes(editor))
480
- ) {
484
+ if (capture && !(editor && evt.composedPath().includes(editor))) {
485
+ return;
486
+ }
487
+ // by the bubble an inner widget may have claimed the Enter for
488
+ // itself (e.g. the quick reply select adding a tag) — not a send
489
+ if (!capture && evt.defaultPrevented) {
481
490
  return;
482
491
  }
483
492
  if (editor) {
@@ -371,14 +371,27 @@ export class Select<T extends SelectOption> extends FieldElement {
371
371
  margin: 0;
372
372
  border: 0;
373
373
  border-radius: 999px;
374
- background: color-mix(in oklab, currentColor 25%, transparent);
374
+ /* Neutral wash first for browsers without color-mix(); the
375
+ currentColor-tinted version below needs the @supports gate
376
+ because currentColor keeps the declaration valid until
377
+ computed-value time. */
378
+ background: rgba(0, 0, 0, 0.12);
375
379
  color: inherit;
376
380
  opacity: 0.8;
377
381
  --icon-color: currentColor;
378
382
  }
379
383
  .multi .remove-item:hover {
380
384
  opacity: 1;
381
- background: color-mix(in oklab, currentColor 45%, transparent);
385
+ background: rgba(0, 0, 0, 0.22);
386
+ }
387
+
388
+ @supports (color: color-mix(in srgb, red, red)) {
389
+ .multi .remove-item {
390
+ background: color-mix(in oklab, currentColor 25%, transparent);
391
+ }
392
+ .multi .remove-item:hover {
393
+ background: color-mix(in oklab, currentColor 45%, transparent);
394
+ }
382
395
  }
383
396
 
384
397
  input {
package/src/interfaces.ts CHANGED
@@ -47,6 +47,14 @@ export interface Attachment {
47
47
  error: string;
48
48
  }
49
49
 
50
+ /** A quick reply as stored/served by the server (engine shape) —
51
+ * `text` buttons carry text, `location` requests may omit it. */
52
+ export interface QuickReply {
53
+ type?: string;
54
+ text?: string;
55
+ extra?: string;
56
+ }
57
+
50
58
  export enum DateStyle {
51
59
  DayFirst = 'day_first',
52
60
  MonthFirst = 'month_first',
@@ -231,8 +239,9 @@ export interface Broadcast {
231
239
  /** Base-language attachments (`{content_type, url}` objects or
232
240
  * `contentType:url` strings). */
233
241
  attachments?: (string | Attachment)[];
234
- /** Base-language quick replies. */
235
- quick_replies?: string[];
242
+ /** Base-language quick replies — engine `{text}` objects, with
243
+ * plain strings tolerated for older data. */
244
+ quick_replies?: (string | QuickReply)[];
236
245
  /** The opt-in the broadcast requests, when it is one. */
237
246
  optin?: ObjectReference | null;
238
247
  /** The WhatsApp template the broadcast sends, when it uses one. */
@@ -271,7 +280,7 @@ export interface Msg {
271
280
  text: string;
272
281
  status: string;
273
282
  channel: ObjectReference;
274
- quick_replies: string[];
283
+ quick_replies: (string | QuickReply)[];
275
284
  urn: string;
276
285
  /** The message's contact — populated by the messages CRUDL
277
286
  * endpoint. Carries whichever of name/urn the endpoint exposes. */
@@ -479,6 +488,7 @@ export enum CustomEventType {
479
488
  StoreUpdated = 'temba-store-updated',
480
489
  Ready = 'temba-ready',
481
490
  OrderChanged = 'temba-order-changed',
491
+ ColumnOrderChanged = 'temba-column-order-changed',
482
492
  DragStart = 'temba-drag-start',
483
493
  DragStop = 'temba-drag-stop',
484
494
  DragExternal = 'temba-drag-external',
@@ -198,7 +198,8 @@ export class BroadcastList extends ContentList<Broadcast> {
198
198
  }
199
199
  .menu-button.destructive:hover {
200
200
  border-color: #dc2626;
201
- background: color-mix(in srgb, #dc2626 6%, var(--surface, #fff));
201
+ /* pre-mixed fallback for browsers without color-mix() */
202
+ background: #fdf2f2;
202
203
  }
203
204
  .detail-body {
204
205
  display: flex;
@@ -243,15 +244,25 @@ export class BroadcastList extends ContentList<Broadcast> {
243
244
  align-items: flex-start;
244
245
  gap: 0.6em;
245
246
  padding: 0.9em 1em 1em;
246
- background: color-mix(
247
- in srgb,
248
- var(--sunken, #f1f3f5) 45%,
249
- var(--surface, #fff)
250
- );
247
+ /* pre-mixed fallback for browsers without color-mix() */
248
+ background: #f9fafa;
251
249
  border: 1px solid var(--border, #e4e7ec);
252
250
  border-radius: var(--r);
253
251
  line-height: 1.5;
254
252
  }
253
+
254
+ @supports (color: color-mix(in srgb, red, red)) {
255
+ .menu-button.destructive:hover {
256
+ background: color-mix(in srgb, #dc2626 6%, var(--surface, #fff));
257
+ }
258
+ .detail-message {
259
+ background: color-mix(
260
+ in srgb,
261
+ var(--sunken, #f1f3f5) 45%,
262
+ var(--surface, #fff)
263
+ );
264
+ }
265
+ }
255
266
  .detail-message temba-expression-highlight {
256
267
  align-self: stretch;
257
268
  white-space: pre-wrap;
@@ -872,7 +883,9 @@ export class BroadcastList extends ContentList<Broadcast> {
872
883
  ${broadcast.quick_replies.map(
873
884
  (reply) =>
874
885
  html`<temba-label type="neutral"
875
- >${reply}</temba-label
886
+ >${typeof reply === 'string'
887
+ ? reply
888
+ : (reply.text ?? reply.type)}</temba-label
876
889
  >`
877
890
  )}
878
891
  </div>`