@nyaruka/temba-components 0.156.17 → 0.157.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 (39) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/temba-components.js +1189 -767
  3. package/dist/temba-components.js.map +1 -1
  4. package/package.json +1 -1
  5. package/src/display/Chat.ts +14 -0
  6. package/src/display/Label.ts +156 -2
  7. package/src/display/Options.ts +71 -16
  8. package/src/display/TembaUser.ts +23 -5
  9. package/src/events/eventRenderers.ts +104 -41
  10. package/src/excellent/caret-utils.ts +0 -1
  11. package/src/flow/RevisionsWindow.ts +53 -9
  12. package/src/flow/nodes/shared.ts +14 -0
  13. package/src/flow/nodes/split_by_llm_categorize.ts +33 -8
  14. package/src/flow/revision-summary.ts +25 -0
  15. package/src/flow/utils.ts +38 -40
  16. package/src/form/ArrayEditor.ts +9 -11
  17. package/src/form/Checkbox.ts +2 -2
  18. package/src/form/Compose.ts +1 -1
  19. package/src/form/FieldElement.ts +8 -8
  20. package/src/form/KeyValueEditor.ts +4 -4
  21. package/src/form/MessageEditor.ts +2 -3
  22. package/src/form/RangePicker.ts +17 -17
  23. package/src/form/TembaSlider.ts +10 -10
  24. package/src/form/TemplateEditor.ts +4 -4
  25. package/src/form/TextInput.ts +19 -1
  26. package/src/form/select/Omnibox.ts +22 -19
  27. package/src/form/select/Select.ts +379 -171
  28. package/src/form/select/WorkspaceSelect.ts +7 -1
  29. package/src/layout/Accordion.ts +2 -2
  30. package/src/layout/Modax.ts +1 -1
  31. package/src/list/SortableList.ts +159 -0
  32. package/src/live/ContactChat.ts +46 -44
  33. package/src/live/ContactDetails.ts +1 -0
  34. package/src/live/ContactFieldEditor.ts +38 -31
  35. package/src/live/FieldManager.ts +4 -4
  36. package/src/styles/designTokens.ts +145 -0
  37. package/src/styles/pillVariants.ts +136 -0
  38. package/static/css/temba-components.css +106 -28
  39. package/web-test-runner.config.mjs +98 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyaruka/temba-components",
3
- "version": "0.156.17",
3
+ "version": "0.157.0",
4
4
  "description": "Web components to support rapidpro and related projects",
5
5
  "author": "Nyaruka <code@nyaruka.coim>",
6
6
  "main": "dist/index.js",
@@ -67,6 +67,8 @@ export interface ObjectReference {
67
67
  interface User extends ObjectReference {
68
68
  avatar?: string;
69
69
  email: string;
70
+ first_name?: string;
71
+ last_name?: string;
70
72
  }
71
73
 
72
74
  export interface Msg {
@@ -153,6 +155,11 @@ export class Chat extends RapidElement {
153
155
  left: 0;
154
156
  right: 0;
155
157
  display: block;
158
+ /* The slot overlays the bottom of the chat history, so clicks
159
+ on the chat scrollbar or messages behind it must pass
160
+ through. Slotted footer content can opt back in with
161
+ pointer-events: auto on its interactive bits. */
162
+ pointer-events: none;
156
163
  }
157
164
 
158
165
  .block {
@@ -447,6 +454,9 @@ export class Chat extends RapidElement {
447
454
  display: none;
448
455
  }
449
456
 
457
+ /* Top/bottom scroll-shadow indicators. Decorative only — they
458
+ must not intercept clicks (would otherwise block the chat
459
+ scrollbar and the bottom edge of the messages area). */
450
460
  .messages:before {
451
461
  content: '';
452
462
  background: radial-gradient(
@@ -461,6 +471,7 @@ export class Chat extends RapidElement {
461
471
  width: 100%;
462
472
  transition: opacity var(--toggle-speed, 200ms) ease-out;
463
473
  z-index: 1;
474
+ pointer-events: none;
464
475
  }
465
476
 
466
477
  .messages:after {
@@ -480,6 +491,7 @@ export class Chat extends RapidElement {
480
491
  margin-right: 5em;
481
492
  transition: opacity var(--toggle-speed, 200ms) ease-out;
482
493
  z-index: 1;
494
+ pointer-events: none;
483
495
  }
484
496
 
485
497
  .bubble-wrap {
@@ -1262,6 +1274,8 @@ export class Chat extends RapidElement {
1262
1274
  <temba-user
1263
1275
  uuid=${currentMsg._user?.uuid}
1264
1276
  name=${name}
1277
+ first_name=${currentMsg._user?.first_name}
1278
+ last_name=${currentMsg._user?.last_name}
1265
1279
  avatar=${currentMsg._user?.avatar}
1266
1280
  ?system=${isSystem}
1267
1281
  >
@@ -1,13 +1,27 @@
1
1
  import { LitElement, TemplateResult, html, css } from 'lit';
2
2
  import { property } from 'lit/decorators.js';
3
+ import { msg } from '@lit/localize';
3
4
  import { getClasses } from '../utils';
4
5
  import { styleMap } from 'lit-html/directives/style-map.js';
6
+ import { designTokens } from '../styles/designTokens';
7
+ import {
8
+ pillVariants,
9
+ PILL_TYPES,
10
+ PILL_TYPE_ICONS
11
+ } from '../styles/pillVariants';
5
12
 
6
13
  export default class Label extends LitElement {
7
14
  static get styles() {
8
15
  return css`
16
+ ${designTokens}
17
+
9
18
  :host {
10
19
  display: inline-block;
20
+ /* Cap at parent width so a pill sitting in a constrained
21
+ container (e.g. a flow canvas node body) shrinks to fit
22
+ rather than overflowing. The slot/mask below have the
23
+ min-width:0 needed to let the ellipsis engage. */
24
+ max-width: 100%;
11
25
  }
12
26
 
13
27
  slot {
@@ -15,12 +29,19 @@ export default class Label extends LitElement {
15
29
  overflow-x: hidden;
16
30
  text-overflow: ellipsis;
17
31
  display: block;
32
+ /* Without min-width:0 the slot — as a flex item inside .mask —
33
+ refuses to shrink below its content size, defeating the
34
+ overflow/ellipsis. */
35
+ min-width: 0;
18
36
  }
19
37
 
20
38
  .mask {
21
39
  padding: 3px 8px;
22
40
  border-radius: 12px;
23
41
  display: flex;
42
+ /* Same reason as slot — let the mask shrink below its content
43
+ size so the inner slot can ellipsize. */
44
+ min-width: 0;
24
45
  }
25
46
 
26
47
  temba-icon {
@@ -43,6 +64,72 @@ export default class Label extends LitElement {
43
64
  text-shadow: none;
44
65
  }
45
66
 
67
+ /* DS pill mode — engaged when the consumer sets [type]. Overrides
68
+ the legacy chip chrome (shadow) and shape (12px radius) with
69
+ the design-system pill (flat, type-colored via .pill-{type},
70
+ 1px border, 999px radius). Background/foreground/icon-color
71
+ are owned by pillVariants — we only set non-color chrome here
72
+ so we don't outrank the variant. */
73
+ .label[class*='pill-'] {
74
+ font-size: 11.5px;
75
+ font-weight: var(--w-regular);
76
+ /* border color is owned by .pill-{type} in pillVariants — only
77
+ set width/style here so we don't outrank the variant. */
78
+ border-width: 1px;
79
+ border-style: solid;
80
+ border-radius: 999px;
81
+ box-shadow: none;
82
+ }
83
+ .label[class*='pill-'] .mask {
84
+ padding: 0 7px;
85
+ height: 20px;
86
+ align-items: center;
87
+ gap: 4px;
88
+ border-radius: 999px;
89
+ }
90
+ /* Hover tint pulled from the pill's own foreground (which is the
91
+ dark variant shade), so flow stays bluish, group stays purplish,
92
+ etc. — no grey wash. */
93
+ .label[class*='pill-'].clickable .mask:hover {
94
+ background: color-mix(in oklab, currentColor 10%, transparent);
95
+ }
96
+ .label[class*='pill-'] temba-icon {
97
+ margin-right: 0;
98
+ padding-bottom: 0;
99
+ }
100
+
101
+ /* Chip-style X button — matches the multi-select chip's
102
+ .remove-item. currentColor-tinted bg so it picks up the
103
+ pill variant's hue. Sits on the left, ahead of the icon. */
104
+ .label[class*='pill-'] .remove {
105
+ cursor: pointer;
106
+ display: inline-flex;
107
+ align-items: center;
108
+ justify-content: center;
109
+ width: 16px;
110
+ height: 16px;
111
+ padding: 0;
112
+ margin: 0;
113
+ border: 0;
114
+ border-radius: 999px;
115
+ background: color-mix(in oklab, currentColor 25%, transparent);
116
+ color: inherit;
117
+ opacity: 0.8;
118
+ --icon-color: currentColor;
119
+ }
120
+ .label[class*='pill-'] .remove:hover {
121
+ opacity: 1;
122
+ background: color-mix(in oklab, currentColor 45%, transparent);
123
+ }
124
+
125
+ /* When a removable X is present, tighten the mask's left padding
126
+ so the X sits snug against the pill edge (matches the
127
+ multi-select chip's 4px left padding). Right padding stays so
128
+ the trailing icon/name keep their breathing room. */
129
+ .label[class*='pill-']:has(.remove) .mask {
130
+ padding-left: 4px;
131
+ }
132
+
46
133
  .danger {
47
134
  background: tomato;
48
135
  color: #fff;
@@ -79,6 +166,11 @@ export default class Label extends LitElement {
79
166
  .shadow {
80
167
  box-shadow: 1px 1px 2px 1px rgba(0, 0, 0, 0.1);
81
168
  }
169
+
170
+ /* DS pill variants come last so .pill-{type} wins on source
171
+ order against equal-specificity legacy rules above (.label,
172
+ .danger, .primary, etc.). */
173
+ ${pillVariants}
82
174
  `;
83
175
  }
84
176
 
@@ -106,12 +198,47 @@ export default class Label extends LitElement {
106
198
  @property({ type: String })
107
199
  icon: string;
108
200
 
201
+ /**
202
+ * Design-system pill variant — `flow`, `group`, `contact`, `field`,
203
+ * `keyword`, `label`, or `neutral`. When set, switches the chrome to
204
+ * a flat DS pill (rounded 999px, type-colored). Stays the legacy
205
+ * shadowed label when unset, so existing consumers are unaffected.
206
+ */
207
+ @property({ type: String })
208
+ type: string;
209
+
210
+ /**
211
+ * Render a chip-style X button on the left of the pill. Clicking it
212
+ * fires a `temba-remove` event; the rest of the pill stays clickable
213
+ * for navigation as usual.
214
+ */
215
+ @property({ type: Boolean })
216
+ removable: boolean;
217
+
218
+ /**
219
+ * Accessible label for the remove button. Defaults to a localized
220
+ * "Remove", but consumers whose action verb differs (e.g.
221
+ * "Interrupt flow") should pass their own — the X button is the
222
+ * affordance for whatever action `temba-remove` triggers, so the
223
+ * accessible name should match.
224
+ */
225
+ @property({ type: String })
226
+ removeLabel: string;
227
+
109
228
  @property()
110
229
  backgroundColor: string;
111
230
 
112
231
  @property()
113
232
  textColor: string;
114
233
 
234
+ private handleRemove(e: MouseEvent) {
235
+ e.stopPropagation();
236
+ e.preventDefault();
237
+ this.dispatchEvent(
238
+ new CustomEvent('temba-remove', { bubbles: true, composed: true })
239
+ );
240
+ }
241
+
115
242
  public render(): TemplateResult {
116
243
  const labelStyle = {};
117
244
 
@@ -124,6 +251,22 @@ export default class Label extends LitElement {
124
251
  labelStyle['--icon-color'] = this.textColor;
125
252
  }
126
253
 
254
+ // Only emit `pill-${this.type}` if it's a recognized variant.
255
+ // An unknown value (or one containing whitespace, e.g.
256
+ // `"flow danger"` from a malformed template) would otherwise split
257
+ // into multiple classes and collide with internal modifiers
258
+ // (`.danger`, `.shadow`, `.clickable`, etc.) defined on `.label`.
259
+ const validType = this.type && PILL_TYPES.has(this.type);
260
+ const variantClass = validType ? `pill-${this.type}` : '';
261
+ // When the consumer sets a recognized `type` (group / flow / etc.)
262
+ // but doesn't supply an explicit `icon`, fall back to the type's
263
+ // default icon from PILL_TYPE_ICONS. Call sites then only need
264
+ // `type="group"` instead of `type="group" icon="group"`.
265
+ const resolvedIcon =
266
+ this.icon || (validType ? PILL_TYPE_ICONS[this.type] : undefined);
267
+
268
+ const removeAriaLabel = this.removeLabel || msg('Remove');
269
+
127
270
  return html`
128
271
  <div
129
272
  class="label ${getClasses({
@@ -134,11 +277,22 @@ export default class Label extends LitElement {
134
277
  shadow: this.shadow,
135
278
  danger: this.danger,
136
279
  dark: this.dark
137
- })}"
280
+ })} ${variantClass}"
138
281
  style=${styleMap(labelStyle)}
139
282
  >
140
283
  <div class="mask">
141
- ${this.icon ? html`<temba-icon name=${this.icon} />` : null}
284
+ ${this.removable
285
+ ? html`<button
286
+ class="remove"
287
+ @click=${this.handleRemove}
288
+ aria-label=${removeAriaLabel}
289
+ >
290
+ <temba-icon name="x" size="0.85"></temba-icon>
291
+ </button>`
292
+ : null}
293
+ ${resolvedIcon
294
+ ? html`<temba-icon name=${resolvedIcon} />`
295
+ : null}
142
296
  <slot></slot>
143
297
  </div>
144
298
  </div>
@@ -5,10 +5,13 @@ import { RapidElement, EventHandler } from '../RapidElement';
5
5
  import { styleMap } from 'lit-html/directives/style-map.js';
6
6
  import { getClasses, getScrollParent, throttle } from '../utils';
7
7
  import { msg } from '@lit/localize';
8
+ import { designTokens } from '../styles/designTokens';
8
9
 
9
10
  export class Options extends RapidElement {
10
11
  static get styles() {
11
12
  return css`
13
+ ${designTokens}
14
+
12
15
  :host {
13
16
  --transition-speed: 0;
14
17
  }
@@ -41,6 +44,10 @@ export class Options extends RapidElement {
41
44
  flex-grow: 1;
42
45
  height: 100%;
43
46
  border: none;
47
+ /* Block-mode is inline (ticket sidebar, etc.) — drop the
48
+ z-index so floating popups (selects, dropdowns) opened over
49
+ the block list always stack above its options. */
50
+ z-index: auto;
44
51
  }
45
52
 
46
53
  :host([block]) .options-scroll {
@@ -96,25 +103,75 @@ export class Options extends RapidElement {
96
103
  border: none;
97
104
  }
98
105
 
106
+ /* When shown, the popup is opaque and clickable. Keep the
107
+ high z-index from .options-container so the popup stacks
108
+ above neighboring widgets — e.g. embedded prefix labels of
109
+ later form fields, which sit absolutely positioned at the
110
+ top edge of their host element. */
99
111
  .show {
100
- border: 1px solid var(--color-widget-border);
101
112
  opacity: 1;
102
- z-index: 1;
103
113
  pointer-events: auto;
104
114
  margin-top: var(--options-margin-top);
105
115
  }
106
116
 
117
+ /* Floating popup border uses --focus-muted (not --color-focus)
118
+ so a parent field's error state — which overrides
119
+ --color-focus to red via .has-error — doesn't turn the
120
+ popup red. The popup stays blue regardless. Single source
121
+ of truth: --focus in designTokens. No halo here — the
122
+ dropdown is an attached panel, not its own focus indicator
123
+ (the parent select keeps its own halo). Block-mode renders
124
+ inline and skips this rule entirely. */
125
+ :host(:not([block])) .show {
126
+ border: 1px solid var(--temba-options-focus-border, var(--focus-muted));
127
+ }
128
+
129
+ /* Each option is a DS-style list row: flat, fixed height,
130
+ tight padding, no inter-item margin. Full-bleed background on
131
+ hover/focus → no border-radius (rounded corners would leave
132
+ visible gaps at the dropdown edges). */
107
133
  .option {
108
- font-size: var(--temba-options-font-size);
109
- padding: var(--temba-options-option-padding, 5px 10px);
110
- border-radius: var(--temba-options-option-radius, 4px);
111
- margin: var(--temba-options-option-margin, 0.3em);
134
+ font-size: var(--temba-options-font-size, 13.5px);
135
+ padding: var(--temba-options-option-padding, 0 var(--pad));
136
+ border-radius: var(--temba-options-option-radius, 0);
137
+ margin: var(--temba-options-option-margin, 0);
138
+ min-height: var(--temba-options-option-min-height, 32px);
139
+ display: flex;
140
+ align-items: center;
112
141
  cursor: pointer;
113
- color: var(--color-text-dark);
142
+ color: var(--text-1);
114
143
  scroll-margin: 5px 0px;
115
144
  text-align: left;
116
145
  }
117
146
 
147
+ /* A single wrapping renderOption child stretches to fill the row
148
+ so custom templates (e.g. ones using justify-content:
149
+ space-between to right-align trailing badges) get the full
150
+ width to lay out in. Scoped to :only-child so a renderOption
151
+ that emits multiple top-level siblings keeps its natural
152
+ layout instead of getting an equal-width flex partition. */
153
+ .option > :only-child {
154
+ flex: 1;
155
+ min-width: 0;
156
+ }
157
+
158
+ /* Block-mode (inline, always-visible lists like the ticket
159
+ sidebar): inset every option uniformly from the container
160
+ and from each other (padding + flex gap), and re-add the
161
+ radius so the focused/active wash reads as a rounded pill
162
+ instead of a stripe. Rich custom renderOption content
163
+ (multi-line ticket cards, etc.) also wants more vertical
164
+ padding than the dropdown's compact rows. */
165
+ :host([block]) .options-scroll {
166
+ padding: 4px;
167
+ gap: 4px;
168
+ }
169
+ :host([block]) .option {
170
+ margin: 0;
171
+ padding: 8px var(--pad);
172
+ border-radius: var(--r-sm);
173
+ }
174
+
118
175
  .option * {
119
176
  user-select: none;
120
177
  -webkit-user-select: none;
@@ -169,20 +226,18 @@ export class Options extends RapidElement {
169
226
  max-height: 1.1em;
170
227
  }
171
228
 
172
- .option:hover {
173
- background: var(
174
- --temba-options-option-hover-bg,
175
- var(--option-hover-bg)
176
- );
177
- color: var(--temba-options-option-hover-text, var(--option-hover-text));
178
- }
179
-
229
+ /* The component syncs cursorIndex to pointer position on
230
+ mousemove, so .focused already follows the mouse. A
231
+ separate :hover rule would just create a second highlight
232
+ that flickers between rows on transition — keep only the
233
+ single focused/active state. */
180
234
  .option.focused {
181
235
  background: var(
182
236
  --temba-options-option-focus-bg,
183
237
  var(--color-selection)
184
238
  );
185
- color: var(--temba-options-option-focus-text, var(--color-text-dark));
239
+ color: var(--temba-options-option-focus-text, var(--accent-700));
240
+ --icon-color: var(--accent-700);
186
241
  }
187
242
 
188
243
  .option.no-options {
@@ -11,7 +11,10 @@ export const getFullName = (user: {
11
11
  first_name?: string;
12
12
  last_name?: string;
13
13
  }) => {
14
- return user.name || [user.first_name, user.last_name].join(' ');
14
+ if (user.first_name || user.last_name) {
15
+ return [user.first_name, user.last_name].filter(Boolean).join(' ');
16
+ }
17
+ return user.name || '';
15
18
  };
16
19
 
17
20
  export class TembaUser extends RapidElement {
@@ -59,6 +62,12 @@ export class TembaUser extends RapidElement {
59
62
  @property({ type: String })
60
63
  name: string;
61
64
 
65
+ @property({ type: String })
66
+ first_name: string;
67
+
68
+ @property({ type: String })
69
+ last_name: string;
70
+
62
71
  @property({ type: String })
63
72
  email: string;
64
73
 
@@ -75,10 +84,19 @@ export class TembaUser extends RapidElement {
75
84
  this.bgimage = `url('${DEFAULT_AVATAR}') center / contain no-repeat`;
76
85
  }
77
86
 
78
- if (changed.has('name')) {
79
- if (this.name) {
80
- this.bgcolor = colorHash.hex(this.name);
81
- this.initials = extractInitials(this.name);
87
+ if (
88
+ changed.has('name') ||
89
+ changed.has('first_name') ||
90
+ changed.has('last_name')
91
+ ) {
92
+ const fullName = getFullName({
93
+ name: this.name,
94
+ first_name: this.first_name,
95
+ last_name: this.last_name
96
+ });
97
+ if (fullName) {
98
+ this.bgcolor = colorHash.hex(fullName);
99
+ this.initials = extractInitials(fullName);
82
100
  } else {
83
101
  this.bgcolor = '#e6e6e6';
84
102
  this.initials = '';
@@ -56,28 +56,100 @@ export enum Events {
56
56
  WEBHOOK_CALLED = 'webhook_called'
57
57
  }
58
58
 
59
+ /**
60
+ * Renders a single DS pill of the given type. temba-label auto-resolves
61
+ * the icon from `type` (via PILL_TYPE_ICONS), so we don't pass `icon`
62
+ * unless the consumer explicitly overrides it. When `href` is provided
63
+ * the pill is wrapped in a navigation anchor (SPA goto); otherwise it's
64
+ * rendered inline as a plain pill. Single source of truth for the
65
+ * "entity pill in chat history" look — inline margin keeps wrapping
66
+ * airy, and inline style works regardless of host-page Tailwind reach.
67
+ */
68
+ const renderEntityPill = (
69
+ pillType: string,
70
+ name: string,
71
+ opts: { href?: string; icon?: string } = {}
72
+ ): TemplateResult => {
73
+ const pill = opts.icon
74
+ ? html`<temba-label
75
+ icon=${opts.icon}
76
+ type=${pillType}
77
+ ?clickable=${!!opts.href}
78
+ style="margin: 1px 2px; vertical-align: middle;"
79
+ >${name}</temba-label
80
+ >`
81
+ : html`<temba-label
82
+ type=${pillType}
83
+ ?clickable=${!!opts.href}
84
+ style="margin: 1px 2px; vertical-align: middle;"
85
+ >${name}</temba-label
86
+ >`;
87
+ return opts.href
88
+ ? html`<a
89
+ href=${opts.href}
90
+ onclick="goto(event, this)"
91
+ style="vertical-align: middle;"
92
+ >${pill}</a
93
+ >`
94
+ : pill;
95
+ };
96
+
97
+ const groupPill = (item: any) =>
98
+ renderEntityPill('group', item.name, {
99
+ href: `/contact/group/${item.uuid}/`
100
+ });
101
+
102
+ const flowPill = (flow: any) =>
103
+ renderEntityPill('flow', flow.name, {
104
+ href: `/flow/editor/${flow.uuid}/`
105
+ });
106
+
107
+ const fieldPill = (field: any) => renderEntityPill('field', field.name);
108
+
109
+ /**
110
+ * Renders a generic value as a neutral pill (white bg, gray border).
111
+ * Used for "after" values in update/change events — visually paired
112
+ * with the type pill on the left side of the line, without claiming
113
+ * a domain hue.
114
+ */
115
+ const valuePill = (value: string | number) =>
116
+ html`<span
117
+ style="display: inline-flex; align-items: center; height: 20px; padding: 0 8px; margin: 1px 2px; border-radius: 999px; border: 1px solid var(--border-strong, #d2d6dc); background: #fff; color: var(--text-1, #1a1f26); font-size: 11.5px; font-weight: 400; line-height: 1; vertical-align: middle;"
118
+ >${value}</span
119
+ >`;
120
+
121
+ /**
122
+ * Inline-flex wrapper style that text + pills share. Without it,
123
+ * plain text sits on its own baseline while vertical-align: middle
124
+ * pills sit slightly above and the text appears to "float". flex
125
+ * centering keeps the row of words and pills on one cross-axis.
126
+ */
127
+ const eventLineStyle =
128
+ 'display: inline-flex; align-items: center; flex-wrap: wrap; justify-content: center; gap: 2px 4px;';
129
+
59
130
  const renderInfoList = (
60
131
  singular: string,
61
132
  plural: string,
62
133
  items: any[]
63
134
  ): TemplateResult => {
64
135
  if (items.length === 1) {
65
- return html`<div>${singular} <strong>${items[0].name}</strong></div>`;
66
- } else {
67
- const list = items.map((item) => item.name);
68
- if (list.length === 2) {
69
- return html`<div>
70
- ${plural} <strong>${list[0]}</strong> and <strong>${list[1]}</strong>
71
- </div>`;
72
- } else {
73
- const last = list.pop();
74
- const middle = list.map(
75
- (name, index) =>
76
- html`<strong>${name}</strong>${index < list.length - 1 ? ', ' : ''}`
77
- );
78
- return html`<div>${plural} ${middle}, and <strong>${last}</strong></div>`;
79
- }
136
+ return html`<div style=${eventLineStyle}>
137
+ ${singular} ${groupPill(items[0])}
138
+ </div>`;
80
139
  }
140
+ if (items.length === 2) {
141
+ return html`<div style=${eventLineStyle}>
142
+ ${plural} ${groupPill(items[0])} and ${groupPill(items[1])}
143
+ </div>`;
144
+ }
145
+ // No commas between pills — the flex `gap` on eventLineStyle
146
+ // already provides visual separation, and a pill list reads as a
147
+ // single "set" rather than a sentence.
148
+ const middle = items.slice(0, -1).map((item) => groupPill(item));
149
+ const last = items[items.length - 1];
150
+ return html`<div style=${eventLineStyle}>
151
+ ${plural} ${middle} and ${groupPill(last)}
152
+ </div>`;
81
153
  };
82
154
 
83
155
  export const renderRunEvent = (event: RunEvent): TemplateResult => {
@@ -92,11 +164,8 @@ export const renderRunEvent = (event: RunEvent): TemplateResult => {
92
164
  }
93
165
  }
94
166
 
95
- return html`<div>
96
- ${verb}
97
- <a href="/flow/editor/${event.flow.uuid}/"
98
- ><strong>${event.flow.name}</strong></a
99
- >
167
+ return html`<div style=${eventLineStyle}>
168
+ ${verb} ${flowPill(event.flow)}
100
169
  </div>`;
101
170
  };
102
171
 
@@ -112,27 +181,27 @@ export const renderChatStartedEvent = (
112
181
 
113
182
  export const renderUpdateEvent = (event: UpdateFieldEvent): TemplateResult => {
114
183
  return event.value
115
- ? html`<div>
116
- Updated <strong>${event.field.name}</strong> to
117
- <strong>${event.value.text}</strong>
184
+ ? html`<div style=${eventLineStyle}>
185
+ Updated ${fieldPill(event.field)} to ${valuePill(event.value.text)}
118
186
  </div>`
119
- : html`<div>Cleared <strong>${event.field.name}</strong></div>`;
187
+ : html`<div style=${eventLineStyle}>
188
+ Cleared ${fieldPill(event.field)}
189
+ </div>`;
120
190
  };
121
191
 
122
192
  export const renderNameChanged = (event: NameChangedEvent): TemplateResult => {
123
- return html`<div>
124
- Updated <strong>name</strong> to <strong>${event.name}</strong>
193
+ return html`<div style=${eventLineStyle}>
194
+ Updated name to ${valuePill(event.name)}
125
195
  </div>`;
126
196
  };
127
197
 
128
198
  export const renderContactURNsChanged = (
129
199
  event: URNsChangedEvent
130
200
  ): TemplateResult => {
131
- return html`<div>
132
- Updated <strong>URNs</strong> to
133
- ${oxfordFn(
134
- event.urns,
135
- (urn: string) => html`<strong>${urn.split(':')[1].split('?')[0]}</strong>`
201
+ return html`<div style=${eventLineStyle}>
202
+ Updated URNs to
203
+ ${oxfordFn(event.urns, (urn: string) =>
204
+ valuePill(urn.split(':')[1].split('?')[0])
136
205
  )}
137
206
  </div>`;
138
207
  };
@@ -214,15 +283,11 @@ export const renderContactGroupsEvent = (
214
283
  ): TemplateResult => {
215
284
  const groupsEvent = event as ContactGroupsEvent;
216
285
  if (groupsEvent.groups_added) {
217
- return renderInfoList(
218
- 'Added to group',
219
- 'Added to groups',
220
- groupsEvent.groups_added
221
- );
286
+ return renderInfoList('Added to', 'Added to', groupsEvent.groups_added);
222
287
  } else if (groupsEvent.groups_removed) {
223
288
  return renderInfoList(
224
- 'Removed from group',
225
- 'Removed from groups',
289
+ 'Removed from',
290
+ 'Removed from',
226
291
  groupsEvent.groups_removed
227
292
  );
228
293
  }
@@ -375,9 +440,7 @@ export const renderBroadcastCreated = (event: any): TemplateResult | null => {
375
440
  export const renderSessionTriggered = (event: any): TemplateResult | null => {
376
441
  const flow = event.flow;
377
442
  if (flow) {
378
- return html`<div>
379
- Started somebody else in <strong>${flow.name}</strong>
380
- </div>`;
443
+ return html`<div>Started somebody else in ${flowPill(flow)}</div>`;
381
444
  }
382
445
  return null;
383
446
  };
@@ -294,4 +294,3 @@ export function setCaretRange(
294
294
  selection.removeAllRanges();
295
295
  selection.addRange(range);
296
296
  }
297
-