@nyaruka/temba-components 0.157.0 → 0.158.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 +16 -0
  2. package/dist/temba-components.js +1617 -1590
  3. package/dist/temba-components.js.map +1 -1
  4. package/orca/setup.sh +81 -0
  5. package/orca.yaml +3 -0
  6. package/package.json +1 -1
  7. package/src/display/Button.ts +102 -121
  8. package/src/display/Chat.ts +60 -9
  9. package/src/display/Dropdown.ts +11 -0
  10. package/src/display/Label.ts +1 -3
  11. package/src/display/LeafletMap.ts +4 -3
  12. package/src/display/TembaUser.ts +9 -3
  13. package/src/events/eventRenderers.ts +151 -71
  14. package/src/flow/AutoTranslate.ts +2 -2
  15. package/src/flow/CanvasNode.ts +14 -6
  16. package/src/flow/DragManager.ts +4 -2
  17. package/src/flow/Editor.ts +4 -4
  18. package/src/flow/NodeEditor.ts +2 -2
  19. package/src/flow/NodeTypeSelector.ts +0 -5
  20. package/src/flow/actions/set_contact_language.ts +5 -4
  21. package/src/flow/nodes/split_by_llm_categorize.ts +1 -6
  22. package/src/flow/utils.ts +2 -20
  23. package/src/form/ColorPicker.ts +5 -3
  24. package/src/form/DatePicker.ts +2 -1
  25. package/src/form/select/Omnibox.ts +1 -3
  26. package/src/form/select/Select.ts +5 -4
  27. package/src/interfaces.ts +1 -0
  28. package/src/languages.ts +56 -0
  29. package/src/layout/Dialog.ts +1 -3
  30. package/src/layout/Tab.ts +0 -15
  31. package/src/layout/TabPane.ts +73 -163
  32. package/src/list/ContentMenu.ts +1 -2
  33. package/src/list/SortableList.ts +1 -4
  34. package/src/list/TembaMenu.ts +159 -113
  35. package/src/live/ContactBadges.ts +2 -1
  36. package/src/live/ContactChat.ts +22 -3
  37. package/src/live/ContactDetails.ts +42 -36
  38. package/src/live/ContactFieldEditor.ts +35 -57
  39. package/src/live/ContactFields.ts +1 -2
  40. package/src/live/ContactNotepad.ts +9 -1
  41. package/src/live/ContactPending.ts +1 -0
  42. package/src/store/AppState.ts +3 -21
  43. package/src/store/Store.ts +0 -29
  44. package/src/styles/designTokens.ts +33 -18
  45. package/src/styles/pillVariants.ts +24 -13
  46. package/static/css/temba-components.css +84 -55
  47. package/web-dev-server.config.mjs +0 -1
  48. package/web-test-runner.config.mjs +0 -1
@@ -2,6 +2,7 @@ import { css, html, TemplateResult } from 'lit';
2
2
  import { ContactStoreElement } from './ContactStoreElement';
3
3
  import { Icon } from '../Icons';
4
4
  import { capitalize } from '../utils';
5
+ import { getLanguageName } from '../languages';
5
6
 
6
7
  const STATUS = {
7
8
  active: 'Active',
@@ -20,36 +21,39 @@ const SCHEMES = {
20
21
  export class ContactDetails extends ContactStoreElement {
21
22
  static get styles() {
22
23
  return css`
23
- .urn {
24
- display: flex;
25
- padding: 0.4em 1em 0.8em 1em;
26
- border-bottom: 1px solid #e6e6e6;
27
- margin-bottom: 0.5em;
24
+ .wrapper {
25
+ padding-top: 0em;
28
26
  }
29
27
 
30
- .urn .path {
31
- margin-left: 0.2em;
28
+ /* Mirrors the disabled <temba-contact-field> row so the Groups
29
+ entry reads as just another field — same label color/size,
30
+ same horizontal inset, same bottom separator. Margin matches
31
+ the combined .wrapper + :host margins of contact-field
32
+ (0.5em + 1em) so spacing between rows stays uniform. */
33
+ .row {
34
+ padding-bottom: 0.6em;
35
+ border-bottom: 1px solid #ececec;
36
+ margin-bottom: 1.5em;
32
37
  }
33
38
 
34
- .wrapper {
35
- padding-top: 0em;
39
+ .row .label {
40
+ color: var(--text-2);
41
+ font-size: 12px;
42
+ font-weight: var(--w-medium);
43
+ margin-top: 0.25em;
44
+ margin-left: 0.25em;
36
45
  }
37
46
 
38
- .groups {
39
- padding: 0.4em 0.5em 0.6em 0.5em;
40
- border-bottom: 1px solid #e6e6e6;
41
- margin-bottom: 0.4em;
42
- }
43
- .group {
44
- margin-right: 0.7em;
45
- margin-bottom: 0.7em;
47
+ .row .value {
48
+ margin-left: 0.25em;
49
+ margin-top: 0.1em;
50
+ min-height: 1.75em;
51
+ display: flex;
52
+ flex-wrap: wrap;
53
+ gap: 0.4em;
46
54
  }
47
55
 
48
- .label {
49
- font-size: 0.8em;
50
- color: rgb(136, 136, 136);
51
- margin-left: 0.5em;
52
- margin-bottom: 0.4em;
56
+ .group {
53
57
  }
54
58
  `;
55
59
  }
@@ -62,25 +66,27 @@ export class ContactDetails extends ContactStoreElement {
62
66
  return;
63
67
  }
64
68
 
65
- const lang = this.store.getLanguageName(this.data.language);
69
+ const lang = getLanguageName(this.data.language);
66
70
 
67
71
  return html`
68
72
  <div class="wrapper">
69
73
  ${this.data.groups.length > 0
70
- ? html` <div class="groups">
74
+ ? html` <div class="row">
71
75
  <div class="label">Groups</div>
72
- ${this.data.groups.map((group) => {
73
- return html`<temba-label
74
- class="group"
75
- onclick="goto(event)"
76
- href="/contact/group/${group.uuid}/"
77
- icon=${group.is_dynamic ? Icon.group_smart : Icon.group}
78
- type="group"
79
- clickable
80
- >
81
- ${group.name}
82
- </temba-label>`;
83
- })}
76
+ <div class="value">
77
+ ${this.data.groups.map((group) => {
78
+ return html`<temba-label
79
+ class="group"
80
+ onclick="goto(event)"
81
+ href="/contact/group/${group.uuid}/"
82
+ icon=${group.is_dynamic ? Icon.group_smart : Icon.group}
83
+ type="group"
84
+ clickable
85
+ >
86
+ ${group.name}
87
+ </temba-label>`;
88
+ })}
89
+ </div>
84
90
  </div>`
85
91
  : null}
86
92
  ${this.data.urns.map((urn) => {
@@ -93,51 +93,34 @@ export class ContactFieldEditor extends RapidElement {
93
93
  --color-widget-border: rgb(235, 235, 235);
94
94
  }
95
95
 
96
- .prefix {
97
- border-top-left-radius: var(--curvature-widget);
98
- border-bottom-left-radius: var(--curvature-widget);
99
- cursor: pointer !important;
100
- white-space: nowrap;
101
- overflow: hidden;
102
- text-overflow: ellipsis;
103
- display: flex;
104
- /* Pin to the top-left of the host (temba-select :host is
105
- position: relative). Using top rather than margin-top keeps
106
- the absolute element out of the flex flow of .left-side so
107
- it doesn't push the selected value down. */
108
- position: absolute;
109
- top: -0.6em;
110
- left: 0.5em;
111
- pointer-events: none;
112
- background: #fff;
113
- border-radius: var(--curvature);
114
- }
115
-
116
- temba-select .prefix {
117
- top: -0.7em;
118
- }
119
-
120
96
  .wrapper {
121
97
  margin-bottom: 0.5em;
122
98
  }
123
99
 
124
- .prefix .name,
125
- .label .name {
126
- padding: 0em 0.4em;
127
- color: rgba(100, 100, 100, 0.7);
100
+ .field-label {
101
+ display: block;
102
+ font-size: 12px;
103
+ font-weight: var(--w-medium);
104
+ color: var(--text-2);
105
+ margin: 0 0 4px 2px;
128
106
  white-space: nowrap;
129
107
  overflow: hidden;
130
108
  text-overflow: ellipsis;
131
- font-size: 0.8em;
109
+ }
110
+
111
+ .label .name {
112
+ color: var(--text-2);
113
+ font-size: 12px;
114
+ font-weight: var(--w-medium);
132
115
  }
133
116
 
134
117
  .disabled .name {
135
- margin-top: 1em;
136
- margin-left: 0.75em;
118
+ margin-top: 0.25em;
119
+ margin-left: 0.25em;
137
120
  }
138
121
 
139
122
  .disabled .value {
140
- margin-left: 0.9em;
123
+ margin-left: 0.25em;
141
124
  margin-top: 0.1em;
142
125
  min-height: 1.75em;
143
126
  }
@@ -242,8 +225,6 @@ export class ContactFieldEditor extends RapidElement {
242
225
  min-height: 22px !important;
243
226
  max-height: 22px !important;
244
227
  margin: 5px 6px;
245
- --button-y: 0;
246
- --button-x: 10px;
247
228
  font-size: 12px;
248
229
  }
249
230
 
@@ -428,27 +409,30 @@ export class ContactFieldEditor extends RapidElement {
428
409
  }
429
410
 
430
411
  private renderDateField(state: TemplateResult) {
431
- return html` <temba-datepicker
432
- timezone=${this.timezone}
433
- value="${this.value ? this.value : ''}"
434
- @change=${this.handleDateChange}
435
- ?disabled=${this.disabled}
436
- time
437
- >
438
- <div class="prefix" slot="prefix">
439
- <div class="name">${this.name}</div>
440
- </div>
441
- <div class="postfix" slot="postfix">
442
- <div class="popper ${this.status} ${this.dirty ? 'dirty' : ''}">
443
- ${state}
412
+ return html`
413
+ <label id="field-label" class="field-label">${this.name}</label>
414
+ <temba-datepicker
415
+ aria-labelledby="field-label"
416
+ timezone=${this.timezone}
417
+ value="${this.value ? this.value : ''}"
418
+ @change=${this.handleDateChange}
419
+ ?disabled=${this.disabled}
420
+ time
421
+ >
422
+ <div class="postfix" slot="postfix">
423
+ <div class="popper ${this.status} ${this.dirty ? 'dirty' : ''}">
424
+ ${state}
425
+ </div>
444
426
  </div>
445
- </div>
446
- </temba-datepicker>`;
427
+ </temba-datepicker>
428
+ `;
447
429
  }
448
430
 
449
431
  private renderTextField(state: TemplateResult) {
450
432
  return html`
433
+ <label id="field-label" class="field-label">${this.name}</label>
451
434
  <temba-textinput
435
+ aria-labelledby="field-label"
452
436
  class="${this.status} ${this.dirty ? 'dirty' : ''}"
453
437
  value="${this.value ? this.value : ''}"
454
438
  @keyup=${this.handleInput}
@@ -456,10 +440,6 @@ export class ContactFieldEditor extends RapidElement {
456
440
  type=${this.getInputType(this.type)}
457
441
  ?disabled=${this.disabled}
458
442
  >
459
- <div class="prefix" slot="prefix">
460
- <div class="name">${this.name}</div>
461
- </div>
462
-
463
443
  <div class="postfix">
464
444
  <div
465
445
  class="popper ${this.iconClass} ${this.status} ${this.dirty
@@ -508,7 +488,9 @@ export class ContactFieldEditor extends RapidElement {
508
488
 
509
489
  public renderLocationField(level: string = 'state') {
510
490
  return html`
491
+ <label id="field-label" class="field-label">${this.name}</label>
511
492
  <temba-select
493
+ aria-labelledby="field-label"
512
494
  endpoint="/api/internal/locations.json?level=${level}"
513
495
  nameKey="path"
514
496
  valueKey="path"
@@ -518,14 +500,10 @@ export class ContactFieldEditor extends RapidElement {
518
500
  queryParam="query"
519
501
  searchable
520
502
  clearable
521
- inpsutStyle=${JSON.stringify({ 'margin-top': '1.1em !important;' })}
522
503
  values=${this.value
523
504
  ? JSON.stringify([{ path: this.value, osm_id: this.value }])
524
505
  : '[]'}
525
506
  >
526
- <div class="prefix" slot="prefix">
527
- <div class="name">${this.name}</div>
528
- </div>
529
507
  </temba-select>
530
508
  `;
531
509
  }
@@ -66,9 +66,8 @@ export class ContactFields extends ContactStoreElement {
66
66
 
67
67
  .toggle {
68
68
  display: flex;
69
- background: #fff;
70
69
  align-items: center;
71
- margin-bottom: 0.5em;
70
+ margin-top: 0.5em;
72
71
  }
73
72
 
74
73
  .disabled .toggle {
@@ -3,6 +3,7 @@ import { property } from 'lit/decorators.js';
3
3
  import { ContactStoreElement } from './ContactStoreElement';
4
4
  import { getDisplayName } from './ContactChat';
5
5
  import { ContactNote, CustomEventType } from '../interfaces';
6
+ import { designTokens } from '../styles/designTokens';
6
7
 
7
8
  export class ContactNotepad extends ContactStoreElement {
8
9
  @property({ type: Object, attribute: false })
@@ -14,9 +15,12 @@ export class ContactNotepad extends ContactStoreElement {
14
15
 
15
16
  static get styles() {
16
17
  return css`
18
+ ${designTokens}
19
+
17
20
  :host {
18
21
  height: 100%;
19
22
  display: flex;
23
+ margin-top: var(--gap);
20
24
  }
21
25
 
22
26
  .wrapper {
@@ -24,9 +28,13 @@ export class ContactNotepad extends ContactStoreElement {
24
28
  --color-widget-bg: transparent;
25
29
  --color-widget-bg-focused: transparent;
26
30
  outline: none;
27
- border-radius: var(--curvature);
28
31
  display: flex;
29
32
  flex-direction: column;
33
+ background: var(--surface-note);
34
+ border: 1px solid var(--border-note);
35
+ border-radius: var(--r-sm);
36
+ box-shadow: var(--shadow-2);
37
+ overflow: hidden;
30
38
  }
31
39
 
32
40
  .notepad {
@@ -92,6 +92,7 @@ export class ContactPending extends EndpointMonitorElement {
92
92
  display: flex;
93
93
  flex-direction: row;
94
94
  align-items: center;
95
+ background: var(--surface);
95
96
  box-shadow:
96
97
  0 0 8px 1px rgba(0, 0, 0, 0.055),
97
98
  0 0 0px 1px rgba(0, 0, 0, 0.02);
@@ -1,5 +1,6 @@
1
1
  import { createStore, StoreApi } from 'zustand/vanilla';
2
- import { fetchResults, generateUUID } from '../utils';
2
+ import { generateUUID } from '../utils';
3
+ import { getLanguageName } from '../languages';
3
4
  import {
4
5
  Action,
5
6
  Exit,
@@ -193,7 +194,6 @@ export interface AppState {
193
194
  issuesByAction: Map<string, FlowIssue[]>;
194
195
 
195
196
  languageCode: string;
196
- languageNames: { [code: string]: string };
197
197
  workspace: Workspace;
198
198
  isTranslating: boolean;
199
199
  viewingRevision: boolean;
@@ -209,7 +209,6 @@ export interface AppState {
209
209
  getCurrentActivity: () => Activity | null;
210
210
  fetchRevision: (endpoint: string, id?: string) => Promise<void>;
211
211
  fetchWorkspace: (endpoint: string) => Promise<void>;
212
- fetchAllLanguages: (endpoint: string) => Promise<void>;
213
212
  fetchActivity: (endpoint: string) => Promise<void>;
214
213
  setActivityEndpoint: (endpoint: string) => void;
215
214
  updateActivity: (activity: Activity) => void;
@@ -267,7 +266,6 @@ export const zustand = createStore<AppState>()(
267
266
  immer((set, get) => ({
268
267
  features: [] as string[],
269
268
  brand: '',
270
- languageNames: {},
271
269
  canvasSize: { width: 0, height: 0 },
272
270
  languageCode: '',
273
271
  workspace: null,
@@ -331,21 +329,6 @@ export const zustand = createStore<AppState>()(
331
329
  set({ workspace: data });
332
330
  },
333
331
 
334
- fetchAllLanguages: async (endpoint) => {
335
- const results = await fetchResults(endpoint);
336
-
337
- // convert array to map for easier lookup
338
- const allLanguages = results.reduce(function (
339
- languages: any,
340
- result: any
341
- ) {
342
- languages[result.value] = result.name;
343
- return languages;
344
- }, {});
345
-
346
- set({ languageNames: allLanguages });
347
- },
348
-
349
332
  setActivityEndpoint: (endpoint: string) => {
350
333
  set({ activityEndpoint: endpoint });
351
334
  },
@@ -394,8 +377,7 @@ export const zustand = createStore<AppState>()(
394
377
  getLanguage: () => {
395
378
  const state = get();
396
379
  const languageCode = state.languageCode;
397
- const languageNames = state.languageNames;
398
- return { name: languageNames[languageCode], code: languageCode };
380
+ return { name: getLanguageName(languageCode), code: languageCode };
399
381
  },
400
382
 
401
383
  setFeatures: (features: string[]) => {
@@ -30,7 +30,6 @@ import { sourceLocale, targetLocales } from '../locales/locale-codes';
30
30
  import { getFullName } from '../display/TembaUser';
31
31
  import { AppState, zustand } from './AppState';
32
32
  import { StoreApi } from 'zustand/vanilla';
33
- import { getLanguageDisplayName } from '../flow/utils';
34
33
 
35
34
  const { setLocale } = configureLocalization({
36
35
  sourceLocale,
@@ -84,9 +83,6 @@ export class Store extends RapidElement {
84
83
  @property({ type: String, attribute: 'globals' })
85
84
  globalsEndpoint: string;
86
85
 
87
- @property({ type: String, attribute: 'languages' })
88
- languagesEndpoint: string;
89
-
90
86
  @property({ type: String, attribute: 'workspace' })
91
87
  workspaceEndpoint: string;
92
88
 
@@ -110,7 +106,6 @@ export class Store extends RapidElement {
110
106
  private fields: { [key: string]: ContactField } = {};
111
107
  private groups: { [uuid: string]: ContactGroup } = {};
112
108
  private shortcuts: Shortcut[] = [];
113
- private languages: any = {};
114
109
  private workspace: Workspace;
115
110
  private featuredFields: ContactField[] = [];
116
111
 
@@ -196,22 +191,6 @@ export class Store extends RapidElement {
196
191
  );
197
192
  }
198
193
 
199
- if (this.languagesEndpoint) {
200
- appState.fetchAllLanguages(this.languagesEndpoint);
201
- fetches.push(
202
- getAssets(this.languagesEndpoint).then((results: any[]) => {
203
- // convert array of objects to lookup
204
- this.languages = results.reduce(function (
205
- languages: any,
206
- result: any
207
- ) {
208
- languages[result.value] = result.name;
209
- return languages;
210
- }, {});
211
- })
212
- );
213
- }
214
-
215
194
  if (this.groupsEndpoint) {
216
195
  fetches.push(
217
196
  getAssets(this.groupsEndpoint).then((groups: any[]) => {
@@ -383,14 +362,6 @@ export class Store extends RapidElement {
383
362
  return this.featuredFields;
384
363
  }
385
364
 
386
- public getLanguageName(iso: string) {
387
- const name = this.languages[iso];
388
- if (!name || name === 'und' || iso === 'und') {
389
- return getLanguageDisplayName(iso);
390
- }
391
- return name;
392
- }
393
-
394
365
  public isDynamicGroup(uuid: string): boolean {
395
366
  const group = this.groups[uuid];
396
367
  // we treat missing groups as dynamic since the
@@ -13,25 +13,30 @@ import { css } from 'lit';
13
13
  */
14
14
  export const designTokens = css`
15
15
  :host {
16
- /* accent ramp — derived from a single anchor via OKLab mixing */
17
- --accent: #2a6fb5;
18
- --accent-50: color-mix(in oklab, var(--accent) 6%, white);
19
- --accent-100: color-mix(in oklab, var(--accent) 12%, white);
20
- --accent-200: color-mix(in oklab, var(--accent) 25%, white);
21
- --accent-300: color-mix(in oklab, var(--accent) 45%, white);
22
- --accent-400: color-mix(in oklab, var(--accent) 75%, white);
23
- --accent-500: var(--accent);
24
- --accent-600: color-mix(in oklab, var(--accent) 88%, black);
25
- --accent-700: color-mix(in oklab, var(--accent) 75%, black);
26
- --accent-800: color-mix(in oklab, var(--accent) 60%, black);
27
- --accent-900: color-mix(in oklab, var(--accent) 45%, black);
16
+ /* accent ramp — the primary color sits at 400 and the ramp is
17
+ derived from it in both directions via sRGB mixing.
18
+ The anchor reads from --primary-rgb so host pages can re-theme
19
+ the entire ramp by setting e.g. --primary-rgb: 112, 0, 132. */
20
+ --accent: rgb(var(--primary-rgb, 98, 147, 201));
21
+ --accent-50: color-mix(in srgb, var(--accent) 6%, white);
22
+ --accent-100: color-mix(in srgb, var(--accent) 16%, white);
23
+ --accent-200: color-mix(in srgb, var(--accent) 32%, white);
24
+ --accent-300: color-mix(in srgb, var(--accent) 60%, white);
25
+ --accent-400: var(--accent);
26
+ --accent-500: color-mix(in srgb, var(--accent) 90%, black);
27
+ --accent-600: color-mix(in srgb, var(--accent) 80%, black);
28
+ --accent-700: color-mix(in srgb, var(--accent) 65%, black);
29
+ --accent-800: color-mix(in srgb, var(--accent) 50%, black);
30
+ --accent-900: color-mix(in srgb, var(--accent) 35%, black);
28
31
 
29
32
  /* neutrals */
30
33
  --bg: #f6f7f9;
31
34
  --surface: #ffffff;
35
+ --surface-note: #fff9c2;
32
36
  --sunken: #f1f3f5;
33
37
  --border: #e6e8ec;
34
38
  --border-strong: #d2d6dc;
39
+ --border-note: #ebdf6f;
35
40
  --text-1: #1a1f26;
36
41
  --text-2: #4d5664;
37
42
  --text-3: #7b8593;
@@ -55,10 +60,14 @@ export const designTokens = css`
55
60
  --neutral-border: #d8dce2;
56
61
 
57
62
  /* Pill anchor hues — pillVariants derives bg/fg/border via
58
- color-mix(in oklab, ...) so host pages can re-theme by
59
- overriding just the anchor. (Recipient pills reuse --accent.) */
63
+ color-mix(in srgb, ...) so host pages can re-theme by
64
+ overriding just the anchor. These are intentionally fixed and
65
+ do NOT track --primary-rgb so that pill identity (group/flow/
66
+ field/channel) stays stable across brand themes. */
67
+ --recipient: #2a6fb5;
60
68
  --flow: #16a34a;
61
69
  --channel: #6b21a8;
70
+ --topic: #d97706;
62
71
  /* Field stays slightly darker than the bright yellow-500 anchor
63
72
  used for flow/channel — yellow-500 has too little contrast
64
73
  against white to read as a foreground / icon hue on its own.
@@ -72,7 +81,7 @@ export const designTokens = css`
72
81
  --w-regular: 400;
73
82
  --w-medium: 500;
74
83
  --w-semibold: 600;
75
- --w-bold: 700;
84
+ --w-bold: 600;
76
85
 
77
86
  /* shape */
78
87
  --r: 8px;
@@ -111,9 +120,15 @@ export const designTokens = css`
111
120
  Surfaces that should stay blue even during a parent field's
112
121
  error state (e.g. the dropdown popup) reference --focus-muted
113
122
  / --focus-halo directly to skip that override. */
114
- --focus: #5b9ce5;
115
- --focus-muted: color-mix(in oklab, var(--focus) 60%, white);
116
- --focus-halo: 0 0 0 3px color-mix(in oklab, var(--focus) 30%, transparent);
123
+ --focus: rgb(var(--focus-rgb, 91, 156, 229));
124
+ --focus-50: color-mix(in srgb, var(--focus) 12%, white);
125
+ --focus-100: color-mix(in srgb, var(--focus) 24%, white);
126
+ --focus-200: color-mix(in srgb, var(--focus) 40%, white);
127
+ --focus-300: color-mix(in srgb, var(--focus) 60%, white);
128
+ --focus-600: color-mix(in srgb, var(--focus) 60%, black);
129
+ --focus-700: color-mix(in srgb, var(--focus) 45%, black);
130
+ --focus-muted: color-mix(in srgb, var(--focus) 60%, white);
131
+ --focus-halo: 0 0 0 3px color-mix(in srgb, var(--focus) 30%, transparent);
117
132
  --color-focus: var(--focus-muted);
118
133
  --widget-box-shadow-focused: var(--focus-halo);
119
134
 
@@ -9,8 +9,9 @@ import { css } from 'lit';
9
9
  * flow/utils.ts.
10
10
  *
11
11
  * Variant theming:
12
- * - .pill-contact / .pill-group: full ramp derived from --accent
13
- * via `color-mix(in oklab, …)`. Override --accent to re-theme.
12
+ * - .pill-contact / .pill-group: derived from --recipient (a fixed
13
+ * blue), NOT the accent ramp. This keeps recipient pills stable
14
+ * across brand re-theming via --primary-rgb.
14
15
  * - .pill-flow, .pill-channel: bg/border derived from --flow /
15
16
  * --channel via color-mix; text + icon use the anchor directly.
16
17
  * - .pill-field: bg/border are fixed at the Tailwind yellow ramp
@@ -40,7 +41,8 @@ export const PILL_TYPES: ReadonlySet<string> = new Set([
40
41
  'field',
41
42
  'label',
42
43
  'keyword',
43
- 'channel'
44
+ 'channel',
45
+ 'topic'
44
46
  ]);
45
47
 
46
48
  /** Default icon name for each pill variant. Used when a consumer
@@ -52,7 +54,8 @@ export const PILL_TYPE_ICONS: Readonly<Record<string, string>> = {
52
54
  contact: 'contact',
53
55
  field: 'fields',
54
56
  flow: 'flow',
55
- label: 'label'
57
+ label: 'label',
58
+ topic: 'topic'
56
59
  };
57
60
 
58
61
  /** Inverse mapping: icon name (alias or resolved SVG id) → pill type.
@@ -88,25 +91,33 @@ export const pillVariants = css`
88
91
  --icon-color: var(--text-2);
89
92
  }
90
93
  .pill-flow {
91
- background: color-mix(in oklab, var(--flow) 12%, white);
94
+ background: color-mix(in srgb, var(--flow) 12%, white);
92
95
  color: var(--flow);
93
- border-color: color-mix(in oklab, var(--flow) 25%, white);
96
+ border-color: color-mix(in srgb, var(--flow) 25%, white);
94
97
  --icon-color: var(--flow);
95
98
  }
96
- /* Recipient color — shared by contacts and groups. */
99
+ /* Recipient color — shared by contacts and groups. Anchored to
100
+ --recipient (fixed blue), NOT the accent ramp, so pills keep their
101
+ identity even when the host page re-themes via --primary-rgb. */
97
102
  .pill-contact,
98
103
  .pill-group {
99
- background: var(--accent-100);
100
- color: var(--accent-700);
101
- border-color: var(--accent-200);
102
- --icon-color: var(--accent-700);
104
+ background: color-mix(in srgb, var(--recipient) 12%, white);
105
+ color: var(--recipient);
106
+ border-color: color-mix(in srgb, var(--recipient) 25%, white);
107
+ --icon-color: var(--recipient);
103
108
  }
104
109
  .pill-channel {
105
- background: color-mix(in oklab, var(--channel) 12%, white);
110
+ background: color-mix(in srgb, var(--channel) 12%, white);
106
111
  color: var(--channel);
107
- border-color: color-mix(in oklab, var(--channel) 25%, white);
112
+ border-color: color-mix(in srgb, var(--channel) 25%, white);
108
113
  --icon-color: var(--channel);
109
114
  }
115
+ .pill-topic {
116
+ background: color-mix(in srgb, var(--topic) 12%, white);
117
+ color: var(--topic);
118
+ border-color: color-mix(in srgb, var(--topic) 25%, white);
119
+ --icon-color: var(--topic);
120
+ }
110
121
  .pill-field {
111
122
  /* Yellow has very low contrast against white, so the color-mix
112
123
  approach used by other variants washes out at any readable mix