@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
@@ -34,15 +34,23 @@ export enum ContentMenuItemType {
34
34
  export class ContentMenu extends RapidElement {
35
35
  static get styles() {
36
36
  return css`
37
- :host {
38
- tabindex: 0;
39
- z-index: 5000;
40
- }
41
37
  .container {
42
38
  display: flex;
43
39
  align-items: center;
44
40
  }
45
41
 
42
+ temba-dropdown {
43
+ /* as a flex item of .container this z-index takes effect and
44
+ creates a stacking context — keep it in sync with the popup's
45
+ z-index inside temba-dropdown (9000) so the open menu clears
46
+ floating windows like the simulator (5000). This also keeps
47
+ the closed toggle above those windows, intentionally: the
48
+ menu has to stay clickable when a floating window overlaps
49
+ the header. Scoped here rather than on :host so the action
50
+ buttons rendered alongside keep their normal stacking. */
51
+ z-index: 9000;
52
+ }
53
+
46
54
  .button_item,
47
55
  .primary_button_item {
48
56
  margin-left: 1rem;
@@ -68,7 +76,6 @@ export class ContentMenu extends RapidElement {
68
76
  color: rgb(45, 45, 45);
69
77
  z-index: 50;
70
78
  min-width: 200px;
71
- tabindex: 0;
72
79
  }
73
80
 
74
81
  .divider {
@@ -82,7 +89,6 @@ export class ContentMenu extends RapidElement {
82
89
  font-size: 1.1rem;
83
90
  cursor: pointer;
84
91
  font-weight: 400;
85
- tabindex: 0;
86
92
  }
87
93
 
88
94
  .item:hover {
@@ -377,7 +377,14 @@ export class FieldList extends EndpointMonitorElement {
377
377
 
378
378
  .menu-button.destructive:hover {
379
379
  border-color: #dc2626;
380
- background: color-mix(in srgb, #dc2626 6%, var(--surface, #fff));
380
+ /* pre-mixed fallback for browsers without color-mix() */
381
+ background: #fdf2f2;
382
+ }
383
+
384
+ @supports (color: color-mix(in srgb, red, red)) {
385
+ .menu-button.destructive:hover {
386
+ background: color-mix(in srgb, #dc2626 6%, var(--surface, #fff));
387
+ }
381
388
  }
382
389
 
383
390
  .detail-body {
@@ -56,6 +56,9 @@ export class TembaList extends RapidElement {
56
56
  @property({ attribute: false })
57
57
  renderOption: (option: any, selected: boolean) => TemplateResult;
58
58
 
59
+ @property({ attribute: false })
60
+ renderDivider: (prev: any, option: any) => TemplateResult | null;
61
+
59
62
  @property({ attribute: false })
60
63
  renderOptionDetail: (option: any, selected: boolean) => TemplateResult;
61
64
 
@@ -68,6 +71,10 @@ export class TembaList extends RapidElement {
68
71
 
69
72
  reverseRefresh = true;
70
73
 
74
+ // subclasses can enforce a display order when refreshed items are merged in,
75
+ // otherwise new items simply land at the top of the list
76
+ protected compareItems: (a: any, b: any) => number = null;
77
+
71
78
  // subclasses that get realtime updates can opt out of interval polling
72
79
  protected pollingEnabled = true;
73
80
 
@@ -251,6 +258,24 @@ export class TembaList extends RapidElement {
251
258
  return Promise.resolve(results);
252
259
  }
253
260
 
261
+ /**
262
+ * Finds where the item our cursor was pinned to ended up after a merge
263
+ * re-ordered our list. Returns null if the cursor is still on the right item,
264
+ * otherwise the index it moved to (-1 if it is no longer in the list).
265
+ */
266
+ private findRepinnedCursorIndex(prevItem: any, newItems: any[]): number {
267
+ if (!prevItem) {
268
+ return null;
269
+ }
270
+
271
+ const prevValue = this.getValue(prevItem);
272
+ if (prevValue === this.getValue(newItems[this.cursorIndex])) {
273
+ return null;
274
+ }
275
+
276
+ return newItems.findIndex((option) => this.getValue(option) === prevValue);
277
+ }
278
+
254
279
  /**
255
280
  * Refreshes the first page, updating any found items in our list
256
281
  */
@@ -303,7 +328,15 @@ export class TembaList extends RapidElement {
303
328
  }
304
329
  const newItems = [...results, ...items];
305
330
 
331
+ // capture the top item before any display sort - it means "the newest
332
+ // item we just fetched, or the previous top if we fetched nothing" and
333
+ // drives the Refreshed event
306
334
  const topItem = newItems[0];
335
+
336
+ if (this.compareItems) {
337
+ newItems.sort(this.compareItems);
338
+ }
339
+
307
340
  if (
308
341
  !this.mostRecentItem ||
309
342
  JSON.stringify(this.mostRecentItem) !== JSON.stringify(topItem)
@@ -311,27 +344,21 @@ export class TembaList extends RapidElement {
311
344
  this.mostRecentItem = topItem;
312
345
  }
313
346
 
314
- if (prevItem) {
315
- const newItem = newItems[this.cursorIndex];
316
- const prevValue = this.getValue(prevItem);
317
- if (prevValue !== this.getValue(newItem)) {
318
- const newIndex = newItems.findIndex(
319
- (option) => this.getValue(option) === prevValue
320
- );
321
- this.cursorIndex = newIndex;
322
-
323
- // make sure our focused item is visible
324
- window.setTimeout(() => {
325
- const options = this.shadowRoot.querySelector('temba-options');
326
- if (options) {
327
- const option =
328
- options.shadowRoot.querySelector('.option.focused');
329
- if (option) {
330
- option.scrollIntoView({ block: 'end', inline: 'nearest' });
331
- }
347
+ const newIndex = this.findRepinnedCursorIndex(prevItem, newItems);
348
+ if (newIndex !== null && newIndex > -1) {
349
+ this.cursorIndex = newIndex;
350
+
351
+ // make sure our focused item is visible
352
+ window.setTimeout(() => {
353
+ const options = this.shadowRoot.querySelector('temba-options');
354
+ if (options) {
355
+ const option =
356
+ options.shadowRoot.querySelector('.option.focused');
357
+ if (option) {
358
+ option.scrollIntoView({ block: 'end', inline: 'nearest' });
332
359
  }
333
- }, 0);
334
- }
360
+ }
361
+ }, 0);
335
362
  }
336
363
 
337
364
  this.items = newItems;
@@ -457,18 +484,60 @@ export class TembaList extends RapidElement {
457
484
  private handleScrollThreshold() {
458
485
  if (this.nextPage && !this.loading) {
459
486
  this.loading = true;
460
- fetchResultsPage(this.nextPage).then((page: ResultsPage) => {
461
- this.sanitizeResults(page.results).then((sanitizedResults) => {
462
- if (this.sanitizeOption) {
463
- sanitizedResults.forEach(this.sanitizeOption);
464
- }
487
+ fetchResultsPage(this.nextPage)
488
+ .then((page: ResultsPage) => {
489
+ return this.sanitizeResults(page.results).then(
490
+ (sanitizedResults: any[]) => {
491
+ if (this.sanitizeOption) {
492
+ sanitizedResults.forEach(this.sanitizeOption);
493
+ }
494
+
495
+ // the item our cursor is pinned to, sorting can move it
496
+ const prevItem = this.items[this.cursorIndex];
497
+
498
+ // drop anything we already have, a poll can pull an item from the
499
+ // next page up into our loaded window before we fetch it
500
+ const seen = new Set(
501
+ this.items.map((option) => this.getValue(option))
502
+ );
503
+ const appended = (sanitizedResults || []).filter(
504
+ (option: any) => {
505
+ const value = this.getValue(option);
506
+ if (seen.has(value)) {
507
+ return false;
508
+ }
509
+ seen.add(value);
510
+ return true;
511
+ }
512
+ );
465
513
 
466
- this.items = [...this.items, ...sanitizedResults];
467
- this.nextPage = page.next;
468
- this.pages++;
514
+ const items = [...this.items, ...appended];
515
+
516
+ // the server pages in the same total order we display in, so this is
517
+ // normally a no-op, but a poll can re-sort an item (say a ticket that
518
+ // just closed) into the loaded window - without this the appended
519
+ // page would land below it
520
+ if (this.compareItems) {
521
+ items.sort(this.compareItems);
522
+ }
523
+
524
+ // the sort can shift our selection, keep the cursor on it
525
+ const newIndex = this.findRepinnedCursorIndex(prevItem, items);
526
+ if (newIndex !== null && newIndex > -1) {
527
+ this.cursorIndex = newIndex;
528
+ }
529
+
530
+ this.items = items;
531
+ this.nextPage = page.next;
532
+ this.pages++;
533
+ this.loading = false;
534
+ }
535
+ );
536
+ })
537
+ .catch(() => {
538
+ // let the next scroll try again instead of wedging on loading
469
539
  this.loading = false;
470
540
  });
471
- });
472
541
  }
473
542
  }
474
543
 
@@ -512,6 +581,7 @@ export class TembaList extends RapidElement {
512
581
  ?loading=${this.loading}
513
582
  ?internalFocusDisabled=${this.internalFocusDisabled}
514
583
  .renderOption=${this.renderOption}
584
+ .renderDivider=${this.renderDivider}
515
585
  .renderOptionDetail=${this.renderOptionDetail}
516
586
  @temba-scroll-threshold=${this.handleScrollThreshold}
517
587
  @temba-selection=${this.handleSelection.bind(this)}
@@ -1,5 +1,6 @@
1
1
  import { html, TemplateResult } from 'lit';
2
2
  import { property } from 'lit/decorators.js';
3
+ import { msg } from '@lit/localize';
3
4
  import { TembaList } from './TembaList';
4
5
  import { Contact } from '../interfaces';
5
6
  import { Icon } from '../Icons';
@@ -9,15 +10,17 @@ export class TicketList extends TembaList {
9
10
  agent = '';
10
11
 
11
12
  public getRefreshEndpoint() {
12
- if (this.items.length > 0) {
13
- const lastActivity = this.items[0].ticket.last_activity_on;
13
+ // open tickets sort above closed ones, so the newest activity can be
14
+ // anywhere in the list, not just the top. Skip anything unparseable so a
15
+ // single bad timestamp can't poison the cursor with NaN
16
+ const lastActivity = this.items.reduce((newest, item) => {
17
+ const activity = new Date(item.ticket.last_activity_on).getTime();
18
+ return isNaN(activity) ? newest : Math.max(newest, activity);
19
+ }, 0);
20
+
21
+ if (lastActivity > 0) {
14
22
  const separator = this.endpoint.includes('?') ? '&' : '?';
15
- return (
16
- this.endpoint +
17
- separator +
18
- 'after=' +
19
- new Date(lastActivity).getTime() * 1000
20
- );
23
+ return this.endpoint + separator + 'after=' + lastActivity * 1000;
21
24
  }
22
25
  return this.endpoint;
23
26
  }
@@ -35,10 +38,79 @@ export class TicketList extends TembaList {
35
38
  super();
36
39
 
37
40
  this.valueKey = 'ticket.uuid';
38
- this.renderOption = (contact: Contact): TemplateResult => {
41
+ // the refresh feed is served oldest first - the inherited reverseRefresh
42
+ // keeps mostRecentItem meaning the newest fetched item (compareItems
43
+ // re-sorts the merged list immediately anyway)
44
+
45
+ this.compareItems = (a: Contact, b: Contact): number => {
46
+ const aClosed = !!a.ticket.closed_on;
47
+ const bClosed = !!b.ticket.closed_on;
48
+ if (aClosed !== bClosed) {
49
+ return aClosed ? 1 : -1;
50
+ }
51
+ return (
52
+ new Date(b.ticket.last_activity_on).getTime() -
53
+ new Date(a.ticket.last_activity_on).getTime()
54
+ );
55
+ };
56
+ // a quiet label marks where the list crosses from open into closed
57
+ // tickets - the rows themselves then only need gentle muting
58
+ this.renderDivider = (
59
+ prev: Contact,
60
+ contact: Contact
61
+ ): TemplateResult | null => {
62
+ if (!contact.ticket.closed_on || (prev && prev.ticket.closed_on)) {
63
+ return null;
64
+ }
65
+ const closed = msg('Closed');
66
+ return html`
67
+ <div
68
+ role="separator"
69
+ aria-label=${closed}
70
+ style="display:flex; align-items:center; gap:0.6em; padding:0.9em var(--pad) 0.2em var(--pad);"
71
+ >
72
+ <div
73
+ style="font-size:0.7em; font-weight:500; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-4);"
74
+ >
75
+ ${closed}
76
+ </div>
77
+ <div
78
+ style="flex-grow:1; height:1px; background:var(--color-widget-border);"
79
+ ></div>
80
+ </div>
81
+ `;
82
+ };
83
+ this.renderOption = (
84
+ contact: Contact,
85
+ selected: boolean
86
+ ): TemplateResult => {
87
+ // closed tickets are settled history - a compact single line that
88
+ // recedes behind the open work above it
89
+ if (contact.ticket.closed_on) {
90
+ return html`
91
+ <div
92
+ style="display:flex; align-items:baseline; margin-top:0.1em; margin-bottom:0.1em; ${selected
93
+ ? ''
94
+ : 'color:var(--text-3);'}"
95
+ >
96
+ <div
97
+ style="flex:1; min-width:0; font-weight:400; line-height:1.6; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"
98
+ >
99
+ ${contact.name}
100
+ </div>
101
+ <div style="font-size:0.8em; margin-left:0.75em;">
102
+ <temba-date
103
+ value=${contact.ticket.closed_on}
104
+ display="duration"
105
+ ></temba-date>
106
+ </div>
107
+ </div>
108
+ `;
109
+ }
110
+
39
111
  return html`
40
112
  <div
41
- style="align-items:center; margin-top: 0.1em; margin-bottom: 0.1em"
113
+ style="align-items:center; margin-top: 0.1em; margin-bottom: 0.1em;"
42
114
  >
43
115
  <div
44
116
  style="display:flex; align-items: flex-start;border:0px solid red;"
@@ -49,10 +121,8 @@ export class TicketList extends TembaList {
49
121
  >
50
122
  ${contact.name}
51
123
  </div>
52
- ${contact.ticket.closed_on
53
- ? null
54
- : contact.last_msg
55
- ? html`
124
+ ${contact.last_msg
125
+ ? html`
56
126
  <div
57
127
  style="font-size: 0.9em; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;"
58
128
  >
@@ -81,13 +151,13 @@ export class TicketList extends TembaList {
81
151
  }
82
152
  </div></div>
83
153
  `
84
- : null}
154
+ : null}
85
155
  </div>
86
156
  <div
87
157
  style="margin-right: -5px; margin-top: 0px;display:flex;flex-direction:column;align-items:flex-end;max-width:60px;min-width:30px;border:0px solid green;text-align:right"
88
158
  >
89
159
  <div>
90
- ${!contact.ticket.closed_on && contact.ticket.assignee
160
+ ${contact.ticket.assignee
91
161
  ? html`<temba-user
92
162
  name=${contact.ticket.assignee.name}
93
163
  email=${contact.ticket.assignee.email}
@@ -101,8 +171,7 @@ export class TicketList extends TembaList {
101
171
 
102
172
  <div style="font-size:0.8em;text-align:right;border:0px solid red;">
103
173
  <temba-date
104
- value=${contact.ticket.closed_on ||
105
- contact.ticket.last_activity_on}
174
+ value=${contact.ticket.last_activity_on}
106
175
  display="duration"
107
176
  ></temba-date>
108
177
  </div>
@@ -273,11 +273,20 @@ export class CampaignEvents extends EndpointMonitorElement {
273
273
  justify-content: center;
274
274
  /* icons inside the dot take the dot's hue */
275
275
  --icon-color: var(--dot-color);
276
- background: color-mix(
277
- in srgb,
278
- var(--dot-color) 12%,
279
- var(--color-widget-bg, #fff)
280
- );
276
+ /* plain widget bg for browsers without color-mix(); the tinted
277
+ fill below needs the @supports gate since var() keeps an
278
+ invalid declaration alive until computed-value time */
279
+ background: var(--color-widget-bg, #fff);
280
+ }
281
+
282
+ @supports (color: color-mix(in srgb, red, red)) {
283
+ .dot {
284
+ background: color-mix(
285
+ in srgb,
286
+ var(--dot-color) 12%,
287
+ var(--color-widget-bg, #fff)
288
+ );
289
+ }
281
290
  }
282
291
 
283
292
  /* the anchor marks the field's date itself - painted with the widget
@@ -470,7 +479,8 @@ export class CampaignEvents extends EndpointMonitorElement {
470
479
 
471
480
  .menu-button.destructive:hover {
472
481
  border-color: #dc2626;
473
- background: color-mix(in srgb, #dc2626 6%, var(--surface, #fff));
482
+ /* pre-mixed fallback for browsers without color-mix() */
483
+ background: #fdf2f2;
474
484
  }
475
485
 
476
486
  /* the event's action, contained with a leading type label. Children
@@ -481,16 +491,26 @@ export class CampaignEvents extends EndpointMonitorElement {
481
491
  align-items: flex-start;
482
492
  gap: 0.6em;
483
493
  padding: 0.9em 1em 1em;
484
- background: color-mix(
485
- in srgb,
486
- var(--sunken, #f1f3f5) 45%,
487
- var(--surface, #fff)
488
- );
494
+ /* pre-mixed fallback for browsers without color-mix() */
495
+ background: #f9fafa;
489
496
  border: 1px solid var(--border, #e4e7ec);
490
497
  border-radius: var(--r);
491
498
  line-height: 1.5;
492
499
  }
493
500
 
501
+ @supports (color: color-mix(in srgb, red, red)) {
502
+ .menu-button.destructive:hover {
503
+ background: color-mix(in srgb, #dc2626 6%, var(--surface, #fff));
504
+ }
505
+ .detail-action {
506
+ background: color-mix(
507
+ in srgb,
508
+ var(--sunken, #f1f3f5) 45%,
509
+ var(--surface, #fff)
510
+ );
511
+ }
512
+ }
513
+
494
514
  /* the close (✕) in the header's upper right - the square
495
515
  hover-wash button treatment (the pager buttons'), sitting
496
516
  just outboard of the action buttons */
@@ -140,10 +140,18 @@ export class ContactChat extends ContactStoreElement {
140
140
 
141
141
  /* While the contact is in a flow the compose outline carries the
142
142
  flow hue: resting border matches the flow pill border, focus
143
- goes solid flow green */
143
+ goes solid flow green. The pre-mixed static comes first — a
144
+ custom property can't fall back per-declaration, so the
145
+ derived version needs the @supports gate. */
144
146
  .in-flow .compose {
145
- --compose-border: 1px solid
146
- color-mix(in srgb, var(--flow, #16a34a) 25%, white);
147
+ --compose-border: 1px solid #c5e8d2;
148
+ }
149
+
150
+ @supports (color: color-mix(in srgb, red, red)) {
151
+ .in-flow .compose {
152
+ --compose-border: 1px solid
153
+ color-mix(in srgb, var(--flow, #16a34a) 25%, white);
154
+ }
147
155
  }
148
156
 
149
157
  .in-flow .compose temba-compose {
@@ -459,10 +467,18 @@ export class ContactChat extends ContactStoreElement {
459
467
  status-area grey. Fallbacks match the --flow design token for
460
468
  pages without tokens. */
461
469
  .in-flow .contact-status {
462
- background: color-mix(in srgb, var(--flow, #16a34a) 12%, white);
470
+ /* pre-mixed statics first for browsers without color-mix() */
471
+ background: #e3f4e9;
463
472
  color: var(--flow, #16a34a);
464
473
  --icon-color: var(--flow, #16a34a);
465
- border-top-color: color-mix(in srgb, var(--flow, #16a34a) 25%, white);
474
+ border-top-color: #c5e8d2;
475
+ }
476
+
477
+ @supports (color: color-mix(in srgb, red, red)) {
478
+ .in-flow .contact-status {
479
+ background: color-mix(in srgb, var(--flow, #16a34a) 12%, white);
480
+ border-top-color: color-mix(in srgb, var(--flow, #16a34a) 25%, white);
481
+ }
466
482
  }
467
483
 
468
484
  /* compact the interrupt button to the row's caption scale */
@@ -530,7 +546,14 @@ export class ContactChat extends ContactStoreElement {
530
546
  }
531
547
 
532
548
  .in-flow .chat-box {
533
- background: color-mix(in srgb, var(--flow, #16a34a) 12%, white);
549
+ /* pre-mixed static first for browsers without color-mix() */
550
+ background: #e3f4e9;
551
+ }
552
+
553
+ @supports (color: color-mix(in srgb, red, red)) {
554
+ .in-flow .chat-box {
555
+ background: color-mix(in srgb, var(--flow, #16a34a) 12%, white);
556
+ }
534
557
  }
535
558
 
536
559
  .ticket-controls {
@@ -883,9 +906,15 @@ export class ContactChat extends ContactStoreElement {
883
906
  return null;
884
907
  }
885
908
 
909
+ // unparseable values and zero-value times (e.g. go's zero time for a
910
+ // contact that has never been seen) mean there's no last seen to show
911
+ const lastSeen = DateTime.fromISO(lastSeenOn);
912
+ if (!lastSeen.isValid || lastSeen.year <= 1) {
913
+ return null;
914
+ }
915
+
886
916
  // recent activity speaks for itself in the chat - only surface last
887
917
  // seen once the contact has been quiet for at least an hour
888
- const lastSeen = DateTime.fromISO(lastSeenOn);
889
918
  const minutes = DateTime.now().diff(lastSeen, 'minutes').minutes;
890
919
  if (minutes < 60) {
891
920
  return null;
@@ -189,24 +189,36 @@ export class ContactTimeline extends EndpointMonitorElement {
189
189
  padding: 0.05em 0.65em;
190
190
  border-radius: 999px;
191
191
  white-space: nowrap;
192
- background: color-mix(
193
- in srgb,
194
- var(--pill-hue) 12%,
195
- var(--color-widget-bg, #fff)
196
- );
197
- border: 1px solid
198
- color-mix(in srgb, var(--pill-hue) 25%, var(--color-widget-bg, #fff));
192
+ /* plain bg / neutral border for browsers without color-mix();
193
+ the hue-tinted versions live in the @supports block below */
194
+ background: var(--color-widget-bg, #fff);
195
+ border: 1px solid var(--pill-hue);
199
196
  color: var(--pill-hue);
200
197
  cursor: pointer;
201
198
  transition: background 100ms ease-in-out;
202
199
  }
203
200
 
204
- .campaign-pill:hover {
205
- background: color-mix(
206
- in srgb,
207
- var(--pill-hue) 22%,
208
- var(--color-widget-bg, #fff)
209
- );
201
+ @supports (color: color-mix(in srgb, red, red)) {
202
+ .campaign-pill {
203
+ background: color-mix(
204
+ in srgb,
205
+ var(--pill-hue) 12%,
206
+ var(--color-widget-bg, #fff)
207
+ );
208
+ border: 1px solid
209
+ color-mix(
210
+ in srgb,
211
+ var(--pill-hue) 25%,
212
+ var(--color-widget-bg, #fff)
213
+ );
214
+ }
215
+ .campaign-pill:hover {
216
+ background: color-mix(
217
+ in srgb,
218
+ var(--pill-hue) 22%,
219
+ var(--color-widget-bg, #fff)
220
+ );
221
+ }
210
222
  }
211
223
 
212
224
  .campaign-pill:focus-visible {
@@ -289,24 +301,34 @@ export class ContactTimeline extends EndpointMonitorElement {
289
301
  }
290
302
 
291
303
  /* past events are filled with a lighter tint of the same hue and keep
292
- the solid border - they're settled, whether real or projected */
304
+ the solid border - they're settled, whether real or projected.
305
+ Plain widget bg first for browsers without color-mix(). */
293
306
  .dot.past {
294
- background: color-mix(
295
- in srgb,
296
- var(--dot-color) 25%,
297
- var(--color-widget-bg, #fff)
298
- );
307
+ background: var(--color-widget-bg, #fff);
299
308
  }
300
309
 
301
310
  /* future events haven't occurred yet - signal that with a dotted
302
311
  border and the faintest tint of the hue inside */
303
312
  .dot.future {
304
313
  border-style: dotted;
305
- background: color-mix(
306
- in srgb,
307
- var(--dot-color) 8%,
308
- var(--color-widget-bg, #fff)
309
- );
314
+ background: var(--color-widget-bg, #fff);
315
+ }
316
+
317
+ @supports (color: color-mix(in srgb, red, red)) {
318
+ .dot.past {
319
+ background: color-mix(
320
+ in srgb,
321
+ var(--dot-color) 25%,
322
+ var(--color-widget-bg, #fff)
323
+ );
324
+ }
325
+ .dot.future {
326
+ background: color-mix(
327
+ in srgb,
328
+ var(--dot-color) 8%,
329
+ var(--color-widget-bg, #fff)
330
+ );
331
+ }
310
332
  }
311
333
 
312
334
  /* the now dot and label both paint with the SPA content background
package/src/locales/es.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  'sbc913d7dc0f33877': `to add`,
15
15
  's7722a91d3a512442': str`Last seen ${0}`,
16
16
  's22965bb9808befb0': `Interrupt`,
17
+ 's6dbbe2646b239ca5': `Closed`,
17
18
  's122d4de68bcfcdf4': `It's okay to restart`,
18
19
  's3eb2567092b4d7c1': `from the beginning`,
19
20
  's28f37776b3901438': `It's okay to interrupt`,
package/src/locales/fr.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  'sbc913d7dc0f33877': `to add`,
15
15
  's7722a91d3a512442': str`Last seen ${0}`,
16
16
  's22965bb9808befb0': `Interrupt`,
17
+ 's6dbbe2646b239ca5': `Closed`,
17
18
  's122d4de68bcfcdf4': `It's okay to restart`,
18
19
  's3eb2567092b4d7c1': `from the beginning`,
19
20
  's28f37776b3901438': `It's okay to interrupt`,
package/src/locales/pt.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  'sbc913d7dc0f33877': `to add`,
15
15
  's7722a91d3a512442': str`Last seen ${0}`,
16
16
  's22965bb9808befb0': `Interrupt`,
17
+ 's6dbbe2646b239ca5': `Closed`,
17
18
  's122d4de68bcfcdf4': `It's okay to restart`,
18
19
  's3eb2567092b4d7c1': `from the beginning`,
19
20
  's28f37776b3901438': `It's okay to interrupt`,
@@ -630,6 +630,7 @@ export class Simulator extends RapidElement {
630
630
  }
631
631
  .message-input input {
632
632
  flex: 1;
633
+ min-width: 0;
633
634
  border: 1px solid #c6c6c857;
634
635
  border-radius: 20px;
635
636
  padding: 8px 15px;