@nyaruka/temba-components 0.156.18 → 0.157.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 (56) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/temba-components.js +2119 -1617
  3. package/dist/temba-components.js.map +1 -1
  4. package/package.json +1 -1
  5. package/src/display/Button.ts +102 -121
  6. package/src/display/Chat.ts +74 -9
  7. package/src/display/Dropdown.ts +11 -0
  8. package/src/display/Label.ts +154 -2
  9. package/src/display/LeafletMap.ts +4 -3
  10. package/src/display/Options.ts +71 -16
  11. package/src/display/TembaUser.ts +32 -8
  12. package/src/events/eventRenderers.ts +243 -95
  13. package/src/excellent/caret-utils.ts +0 -1
  14. package/src/flow/AutoTranslate.ts +2 -2
  15. package/src/flow/Editor.ts +4 -4
  16. package/src/flow/NodeEditor.ts +2 -2
  17. package/src/flow/NodeTypeSelector.ts +0 -5
  18. package/src/flow/RevisionsWindow.ts +1 -3
  19. package/src/flow/actions/set_contact_language.ts +5 -4
  20. package/src/flow/nodes/shared.ts +14 -0
  21. package/src/flow/nodes/split_by_llm_categorize.ts +28 -8
  22. package/src/flow/utils.ts +39 -60
  23. package/src/form/ArrayEditor.ts +9 -11
  24. package/src/form/Checkbox.ts +2 -2
  25. package/src/form/ColorPicker.ts +5 -3
  26. package/src/form/Compose.ts +1 -1
  27. package/src/form/FieldElement.ts +8 -8
  28. package/src/form/KeyValueEditor.ts +4 -4
  29. package/src/form/MessageEditor.ts +2 -3
  30. package/src/form/RangePicker.ts +17 -17
  31. package/src/form/TembaSlider.ts +10 -10
  32. package/src/form/TemplateEditor.ts +4 -4
  33. package/src/form/TextInput.ts +19 -1
  34. package/src/form/select/Omnibox.ts +21 -20
  35. package/src/form/select/Select.ts +382 -173
  36. package/src/form/select/WorkspaceSelect.ts +7 -1
  37. package/src/interfaces.ts +1 -0
  38. package/src/languages.ts +56 -0
  39. package/src/layout/Accordion.ts +2 -2
  40. package/src/layout/Dialog.ts +1 -3
  41. package/src/layout/Modax.ts +1 -1
  42. package/src/list/ContentMenu.ts +1 -2
  43. package/src/list/SortableList.ts +156 -0
  44. package/src/list/TembaMenu.ts +159 -113
  45. package/src/live/ContactBadges.ts +2 -1
  46. package/src/live/ContactChat.ts +62 -45
  47. package/src/live/ContactDetails.ts +3 -1
  48. package/src/live/ContactFieldEditor.ts +36 -31
  49. package/src/live/FieldManager.ts +4 -4
  50. package/src/store/AppState.ts +3 -21
  51. package/src/store/Store.ts +0 -29
  52. package/src/styles/designTokens.ts +158 -0
  53. package/src/styles/pillVariants.ts +147 -0
  54. package/static/css/temba-components.css +141 -36
  55. package/web-dev-server.config.mjs +0 -1
  56. package/web-test-runner.config.mjs +98 -1
@@ -7,6 +7,7 @@ import {
7
7
  TemplateResult
8
8
  } from 'lit';
9
9
  import { property } from 'lit/decorators.js';
10
+ import { msg } from '@lit/localize';
10
11
  import {
11
12
  Contact,
12
13
  CustomEventType,
@@ -313,45 +314,51 @@ export class ContactChat extends ContactStoreElement {
313
314
  border-color: #ccc;
314
315
  }
315
316
 
316
- .action-bar {
317
- }
318
-
317
+ /* "Currently in [flow]" treatment.
318
+ Lives in the chat footer to advertise the active run with an
319
+ optional Interrupt action (the chip's X). Sized to its
320
+ contents only (inline-flex) so the chat scrollbar to the
321
+ right remains clickable, and pointer-events:none on the
322
+ wrapping footer means the rest of the row doesn't intercept
323
+ scrollbar drags either. Translucent white bg + backdrop
324
+ blur keeps the chat history legible through the chip. */
319
325
  .in-flow {
320
- border-radius: 0.8em;
321
- align-items: center;
322
- background: #666;
323
- padding: 0.5em 1em;
324
- margin: 1em;
325
- margin-right: 2em;
326
326
  display: inline-flex;
327
- opacity: 0.9;
327
+ align-items: center;
328
+ gap: 8px;
329
+ padding: 0.4em 0.75em;
330
+ margin: 0.5em;
331
+ border-radius: 999px;
332
+ background: rgba(255, 255, 255, 0.75);
333
+ backdrop-filter: blur(8px);
334
+ -webkit-backdrop-filter: blur(8px);
335
+ box-shadow: var(--shadow-1);
328
336
  }
329
337
 
330
338
  .flow-footer {
331
339
  text-align: center;
332
340
  pointer-events: none;
341
+ /* The chat history has a scrollbar on the right edge; the
342
+ footer overlay spans the full container width, so centering
343
+ inside it lands the chip slightly right-of-center relative
344
+ to the visible message area. Reserve the scrollbar width on
345
+ the right so the chip is centered to what the user sees. */
346
+ padding-right: 15px;
333
347
  }
334
348
 
335
349
  .flow-footer .in-flow {
336
350
  pointer-events: auto;
337
351
  }
338
352
 
339
- .in-flow:hover {
340
- opacity: 1;
341
- }
342
-
343
353
  .in-flow .flow-name {
344
- display: flex;
345
- color: #fff;
346
- }
347
-
348
- .in-flow a {
349
- font-weight: bold;
350
- color: #fff;
351
- }
352
-
353
- .in-flow .interrupt-button {
354
- margin-left: 1em;
354
+ display: inline-flex;
355
+ align-items: center;
356
+ gap: 6px;
357
+ /* Match the chat history event text — same hue + size — so
358
+ the "Currently in" line reads as one of the events rather
359
+ than its own UI chrome. */
360
+ font-size: 13.5px;
361
+ color: #8e8e93;
355
362
  }
356
363
 
357
364
  .in-flow .interrupt {
@@ -389,6 +396,18 @@ export class ContactChat extends ContactStoreElement {
389
396
  background: rgba(0, 0, 0, 0.03);
390
397
  }
391
398
 
399
+ /* Keep the assignment + topic controls the same height as the
400
+ Close button so the row reads as one strip. Shrink the user
401
+ avatars (--temba-scale) so they fit in the smaller box. */
402
+ .in-ticket temba-user-select,
403
+ .in-ticket temba-select {
404
+ --temba-select-min-height: 28px;
405
+ }
406
+
407
+ .in-ticket temba-user-select {
408
+ --temba-scale: 0.75;
409
+ }
410
+
392
411
  temba-user {
393
412
  border: 1px solid #ddd;
394
413
  padding: 0.2em 0.5em;
@@ -1003,8 +1022,11 @@ export class ContactChat extends ContactStoreElement {
1003
1022
  if (
1004
1023
  event.type === 'msg_created' ||
1005
1024
  event.type === 'msg_received' ||
1006
- event.type === 'ivr_created'
1025
+ event.type === 'ivr_created' ||
1026
+ event.type === 'ticket_note_added'
1007
1027
  ) {
1028
+ // Notes render as chat-style bubbles (see Chat.ts), so push them
1029
+ // through directly rather than prerendering into an inline event.
1008
1030
  messages.push(event);
1009
1031
  } else {
1010
1032
  this.prerender(event);
@@ -1420,26 +1442,21 @@ export class ContactChat extends ContactStoreElement {
1420
1442
  <div slot="footer" class="flow-footer">
1421
1443
  <div class="in-flow">
1422
1444
  <div class="flow-name">
1423
- <temba-icon name="flow" size="1.2"></temba-icon>
1424
- <div>
1425
- Currently in
1426
- <a
1427
- href="/flow/editor/${this.currentContact.flow
1428
- .uuid}/"
1429
- >${this.currentContact.flow.name}</a
1430
- >
1431
- </div>
1445
+ <span>Currently in</span>
1446
+ <a
1447
+ href="/flow/editor/${this.currentContact.flow
1448
+ .uuid}/"
1449
+ onclick="goto(event, this)"
1450
+ ><temba-label
1451
+ type="flow"
1452
+ clickable
1453
+ ?removable=${this.showInterrupt}
1454
+ removeLabel=${msg('Interrupt flow')}
1455
+ @temba-remove=${this.handleInterrupt}
1456
+ >${this.currentContact.flow.name}</temba-label
1457
+ ></a
1458
+ >
1432
1459
  </div>
1433
- ${this.showInterrupt
1434
- ? html`<temba-button
1435
- class="interrupt-button"
1436
- destructive
1437
- small
1438
- @click=${this.handleInterrupt}
1439
- name="Interrupt"
1440
- >
1441
- </temba-button>`
1442
- : null}
1443
1460
  </div>
1444
1461
  </div>
1445
1462
  `
@@ -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',
@@ -62,7 +63,7 @@ export class ContactDetails extends ContactStoreElement {
62
63
  return;
63
64
  }
64
65
 
65
- const lang = this.store.getLanguageName(this.data.language);
66
+ const lang = getLanguageName(this.data.language);
66
67
 
67
68
  return html`
68
69
  <div class="wrapper">
@@ -75,6 +76,7 @@ export class ContactDetails extends ContactStoreElement {
75
76
  onclick="goto(event)"
76
77
  href="/contact/group/${group.uuid}/"
77
78
  icon=${group.is_dynamic ? Icon.group_smart : Icon.group}
79
+ type="group"
78
80
  clickable
79
81
  >
80
82
  ${group.name}
@@ -101,16 +101,20 @@ export class ContactFieldEditor extends RapidElement {
101
101
  overflow: hidden;
102
102
  text-overflow: ellipsis;
103
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. */
104
108
  position: absolute;
105
- margin-top: -0.6em;
106
- margin-left: 0.5em;
109
+ top: -0.6em;
110
+ left: 0.5em;
107
111
  pointer-events: none;
108
112
  background: #fff;
109
113
  border-radius: var(--curvature);
110
114
  }
111
115
 
112
116
  temba-select .prefix {
113
- margin-top: -1em;
117
+ top: -0.7em;
114
118
  }
115
119
 
116
120
  .wrapper {
@@ -191,19 +195,22 @@ export class ContactFieldEditor extends RapidElement {
191
195
  display: none;
192
196
  }
193
197
 
198
+ /* Keep popper icons within the widget's 34px content area so
199
+ the field height doesn't change when the search/copy buttons
200
+ appear (i.e. when a value is set). Horizontal padding only —
201
+ vertical sizing comes from align-items: stretch on the
202
+ flex .input-container. Inner elements (icons, save button)
203
+ own their own horizontal spacing via margin, so the popper
204
+ itself doesn't add asymmetric padding (which would offset
205
+ its contents and prevent centering). */
194
206
  .popper temba-icon {
195
- padding: 0.5em 0em;
196
- padding-right: 1em;
207
+ padding: 0 0.6em 0 0;
197
208
  }
198
209
 
199
- .popper:first-child {
200
- padding: 0.5em 0em;
201
- padding-right: 0.5em;
202
- padding-left: 1em;
203
- }
204
-
205
- .popper:last-child {
206
- padding-right: 0em;
210
+ /* First icon gets extra left padding so it doesn't hug the
211
+ popper's left edge — visually balances the inter-icon gap. */
212
+ .popper temba-icon:first-of-type {
213
+ padding-left: 0.6em;
207
214
  }
208
215
 
209
216
  .copy.clicked temba-icon {
@@ -223,8 +230,19 @@ export class ContactFieldEditor extends RapidElement {
223
230
  align-items: center;
224
231
  }
225
232
 
226
- .save-button {
227
- padding-right: 1em;
233
+ /* .save-button is the class on the <temba-button> element
234
+ itself. Use tag+class selector and !important to outrank
235
+ :host { align-self: stretch } from inside Button.ts. min/max
236
+ height pin the button size so it can't grow with the parent
237
+ line height (relevant in DatePicker, where the container
238
+ wraps and the line is ~50px tall). */
239
+ temba-button.save-button {
240
+ align-self: center !important;
241
+ height: 22px !important;
242
+ min-height: 22px !important;
243
+ max-height: 22px !important;
244
+ margin: 5px 6px;
245
+ font-size: 12px;
228
246
  }
229
247
 
230
248
  .dirty .copy,
@@ -272,22 +290,6 @@ export class ContactFieldEditor extends RapidElement {
272
290
  padding: 0;
273
291
  }
274
292
 
275
- .dirty temba-datepicker .popper:first-child {
276
- padding-left: 1em;
277
- }
278
-
279
- .success temba-datepicker .popper:first-child {
280
- padding-left: 1em;
281
- }
282
-
283
- .failure temba-datepicker .popper:first-child {
284
- padding-left: 1em;
285
- }
286
-
287
- .saving temba-datepicker .popper:first-child {
288
- padding-left: 1em;
289
- }
290
-
291
293
  temba-datepicker .postfix {
292
294
  margin-left: 0;
293
295
  }
@@ -305,6 +307,9 @@ export class ContactFieldEditor extends RapidElement {
305
307
 
306
308
  temba-select {
307
309
  --color-widget-bg: white;
310
+ /* Let the slotted prefix label escape the widget's top edge
311
+ — same notched-border look as the textinput / datepicker. */
312
+ --temba-select-container-overflow: visible;
308
313
  }
309
314
 
310
315
  temba-option {
@@ -247,14 +247,14 @@ export class FieldManager extends EndpointMonitorElement {
247
247
  <div
248
248
  style="display: flex; min-width: 200px; width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-right: 2em"
249
249
  >
250
- <span
250
+ <temba-label
251
+ type="field"
252
+ clickable
251
253
  @click=${this.handleFieldAction}
252
254
  data-key=${field.key}
253
255
  data-action="update"
254
- style="color: var(--color-link-primary); cursor:pointer;"
256
+ >${field.label}</temba-label
255
257
  >
256
- ${field.label}
257
- </span>
258
258
  ${this.hasUsages(field)
259
259
  ? html`
260
260
  <temba-icon
@@ -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
@@ -0,0 +1,158 @@
1
+ import { css } from 'lit';
2
+
3
+ /**
4
+ * TextIt Design System tokens — single source of truth.
5
+ *
6
+ * Embedded in component shadow DOMs (via FieldElement :host) so the
7
+ * tokens apply regardless of host-page stylesheet. Host pages may still
8
+ * override these by re-declaring the variable on the component element
9
+ * itself (e.g. `temba-select { --accent: ... }`), which beats `:host`.
10
+ *
11
+ * Mirrored in static/css/temba-components.css for hosts that want the
12
+ * tokens at :root scope; keep both in sync if the design system evolves.
13
+ */
14
+ export const designTokens = css`
15
+ :host {
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);
31
+
32
+ /* neutrals */
33
+ --bg: #f6f7f9;
34
+ --surface: #ffffff;
35
+ --sunken: #f1f3f5;
36
+ --border: #e6e8ec;
37
+ --border-strong: #d2d6dc;
38
+ --text-1: #1a1f26;
39
+ --text-2: #4d5664;
40
+ --text-3: #7b8593;
41
+ --text-4: #a2abb8;
42
+
43
+ /* status — full set */
44
+ --success: #16a34a;
45
+ --success-bg: #e8f6ee;
46
+ --success-border: #bfe5cd;
47
+ --info: #2563eb;
48
+ --info-bg: #e8f0fe;
49
+ --info-border: #c7d7f8;
50
+ --warning: #b45309;
51
+ --warning-bg: #fdf3e2;
52
+ --warning-border: #f2d9a9;
53
+ --danger: #d03f3f;
54
+ --danger-bg: #fcebeb;
55
+ --danger-border: #f4c8c8;
56
+ --neutral: #6b7280;
57
+ --neutral-bg: #eef0f3;
58
+ --neutral-border: #d8dce2;
59
+
60
+ /* Pill anchor hues — pillVariants derives bg/fg/border via
61
+ color-mix(in srgb, ...) so host pages can re-theme by
62
+ overriding just the anchor. These are intentionally fixed and
63
+ do NOT track --primary-rgb so that pill identity (group/flow/
64
+ field/channel) stays stable across brand themes. */
65
+ --recipient: #2a6fb5;
66
+ --flow: #16a34a;
67
+ --channel: #6b21a8;
68
+ --topic: #d97706;
69
+ /* Field stays slightly darker than the bright yellow-500 anchor
70
+ used for flow/channel — yellow-500 has too little contrast
71
+ against white to read as a foreground / icon hue on its own.
72
+ Yellow-700 doubles as the pill's icon color via .pill-field. */
73
+ --field: #a16207;
74
+
75
+ /* type */
76
+ --font: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
77
+ --font-mono:
78
+ 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
79
+ --w-regular: 400;
80
+ --w-medium: 500;
81
+ --w-semibold: 600;
82
+ --w-bold: 600;
83
+
84
+ /* shape */
85
+ --r: 8px;
86
+ --r-xs: 2px;
87
+ --r-sm: 4px;
88
+ --r-lg: 12px;
89
+
90
+ /* density */
91
+ --row-h: 36px;
92
+ --input-h: 34px;
93
+ --pad: 10px;
94
+ --gap: 14px;
95
+
96
+ /* shadows */
97
+ --shadow-1:
98
+ 0 1px 1px rgba(15, 22, 36, 0.04), 0 1px 2px rgba(15, 22, 36, 0.04);
99
+ --shadow-2:
100
+ 0 1px 1px rgba(15, 22, 36, 0.04), 0 4px 12px rgba(15, 22, 36, 0.06);
101
+ --shadow-3:
102
+ 0 6px 20px rgba(15, 22, 36, 0.1), 0 2px 6px rgba(15, 22, 36, 0.06);
103
+
104
+ /* legacy aliases — point at the DS tokens above so existing
105
+ components pick up the new design language without code changes */
106
+ --font-family: var(--font);
107
+ --curvature: var(--r-sm);
108
+ --curvature-widget: var(--r-sm);
109
+ /* Focus styling.
110
+ --focus is the single hue, kept separate from --accent so
111
+ changing the focus color doesn't shift chip / recipient hues.
112
+ --focus-muted and --focus-halo are derived once here, so
113
+ everywhere that needs to draw a focus outline or ring can
114
+ consume the same values without re-doing the formula.
115
+ --color-focus / --widget-box-shadow-focused alias the muted
116
+ versions and are what most widgets reference — these get
117
+ overridden to error-red by FieldElement's .has-error rule.
118
+ Surfaces that should stay blue even during a parent field's
119
+ error state (e.g. the dropdown popup) reference --focus-muted
120
+ / --focus-halo directly to skip that override. */
121
+ --focus: rgb(var(--focus-rgb, 91, 156, 229));
122
+ --focus-50: color-mix(in srgb, var(--focus) 12%, white);
123
+ --focus-100: color-mix(in srgb, var(--focus) 24%, white);
124
+ --focus-200: color-mix(in srgb, var(--focus) 40%, white);
125
+ --focus-300: color-mix(in srgb, var(--focus) 60%, white);
126
+ --focus-600: color-mix(in srgb, var(--focus) 60%, black);
127
+ --focus-700: color-mix(in srgb, var(--focus) 45%, black);
128
+ --focus-muted: color-mix(in srgb, var(--focus) 60%, white);
129
+ --focus-halo: 0 0 0 3px color-mix(in srgb, var(--focus) 30%, transparent);
130
+ --color-focus: var(--focus-muted);
131
+ --widget-box-shadow-focused: var(--focus-halo);
132
+
133
+ --color-widget-bg: var(--surface);
134
+ --color-widget-bg-focused: var(--surface);
135
+ --color-widget-border: var(--border-strong);
136
+ --color-options-bg: var(--surface);
137
+ --color-selection: var(--accent-50);
138
+ --color-success: var(--success);
139
+ --widget-box-shadow: none;
140
+ --shadow: var(--shadow-1);
141
+ --shadow-widget: var(--shadow-1);
142
+ --color-text: var(--text-1);
143
+ --color-widget-text: var(--text-1);
144
+ --color-borders: var(--border);
145
+ --color-placeholder: var(--text-3);
146
+ --color-primary-light: var(--sunken);
147
+ --color-label: var(--text-1);
148
+ --color-text-help: var(--text-3);
149
+
150
+ --temba-textinput-padding: 7px var(--pad);
151
+ --temba-textinput-font-size: 13.5px;
152
+ --temba-textinput-min-height: var(--input-h);
153
+ --temba-select-selected-padding: 0 var(--pad);
154
+ --temba-select-selected-line-height: 1.4;
155
+ --temba-select-selected-font-size: 13.5px;
156
+ --temba-select-min-height: var(--input-h);
157
+ }
158
+ `;