@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
@@ -0,0 +1,147 @@
1
+ import { css } from 'lit';
2
+
3
+ /**
4
+ * TextIt Design System pill color variants — single source of truth.
5
+ *
6
+ * Provides the color triple (background / foreground / border) keyed
7
+ * off `.pill-{type}` plus the JS-side taxonomy (`PILL_TYPES`,
8
+ * `PILL_TYPE_ICONS`, `iconToPillType`) consumed by Select.ts and
9
+ * flow/utils.ts.
10
+ *
11
+ * Variant theming:
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.
15
+ * - .pill-flow, .pill-channel: bg/border derived from --flow /
16
+ * --channel via color-mix; text + icon use the anchor directly.
17
+ * - .pill-field: bg/border are fixed at the Tailwind yellow ramp
18
+ * (yellow-100 / yellow-300) because yellow has too little
19
+ * contrast against white to color-mix into a recognizable swatch.
20
+ * Text uses yellow-900 for readability; the icon uses --field
21
+ * directly, which is the only knob a host page can re-theme.
22
+ * - .pill-neutral / .pill-label / .pill-keyword: greys; not
23
+ * anchor-driven.
24
+ *
25
+ * Shape (height, padding, radius, icon spacing) is the consumer's
26
+ * concern, since pill use-cases differ: Select chips have a remove
27
+ * button on the right, ContactDetails pills are clickable links, etc.
28
+ *
29
+ * To add a new variant: extend `PILL_TYPES`, optionally add an entry
30
+ * to `PILL_TYPE_ICONS` / `ICON_TO_PILL_TYPE`, append a `.pill-{type}`
31
+ * block below, and reference an anchor in `designTokens.ts`.
32
+ */
33
+
34
+ /** Recognized pill variants. Anything outside this set falls back to
35
+ * `pill-neutral` (or is rejected by callers as not a pill at all). */
36
+ export const PILL_TYPES: ReadonlySet<string> = new Set([
37
+ 'neutral',
38
+ 'flow',
39
+ 'group',
40
+ 'contact',
41
+ 'field',
42
+ 'label',
43
+ 'keyword',
44
+ 'channel',
45
+ 'topic'
46
+ ]);
47
+
48
+ /** Default icon name for each pill variant. Used when a consumer
49
+ * specifies `type` but not `icon` — keeps Omnibox-style options and
50
+ * Django-form-rendered options visually consistent without making the
51
+ * data layer set both fields. */
52
+ export const PILL_TYPE_ICONS: Readonly<Record<string, string>> = {
53
+ group: 'group',
54
+ contact: 'contact',
55
+ field: 'fields',
56
+ flow: 'flow',
57
+ label: 'label',
58
+ topic: 'topic'
59
+ };
60
+
61
+ /** Inverse mapping: icon name (alias or resolved SVG id) → pill type.
62
+ * Both forms are valid since flow-action items pass through either. */
63
+ const ICON_TO_PILL_TYPE: Readonly<Record<string, string>> = {
64
+ flow: 'flow',
65
+ group: 'group',
66
+ contact: 'contact',
67
+ contacts: 'contact',
68
+ field: 'field',
69
+ fields: 'field',
70
+ label: 'label',
71
+ // resolved Icon enum SVG ids
72
+ 'users-01': 'group',
73
+ 'atom-01': 'group',
74
+ 'user-01': 'contact',
75
+ 'tag-01': 'label'
76
+ };
77
+
78
+ export const iconToPillType = (icon?: string): string | undefined => {
79
+ if (!icon) return undefined;
80
+ if (ICON_TO_PILL_TYPE[icon]) return ICON_TO_PILL_TYPE[icon];
81
+ // Legacy alias prefix (e.g. 'group_smart' → 'group').
82
+ if (icon.startsWith('group')) return 'group';
83
+ return undefined;
84
+ };
85
+
86
+ export const pillVariants = css`
87
+ .pill-neutral {
88
+ background: var(--sunken);
89
+ color: var(--text-1);
90
+ border-color: var(--border);
91
+ --icon-color: var(--text-2);
92
+ }
93
+ .pill-flow {
94
+ background: color-mix(in srgb, var(--flow) 12%, white);
95
+ color: var(--flow);
96
+ border-color: color-mix(in srgb, var(--flow) 25%, white);
97
+ --icon-color: var(--flow);
98
+ }
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. */
102
+ .pill-contact,
103
+ .pill-group {
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);
108
+ }
109
+ .pill-channel {
110
+ background: color-mix(in srgb, var(--channel) 12%, white);
111
+ color: var(--channel);
112
+ border-color: color-mix(in srgb, var(--channel) 25%, white);
113
+ --icon-color: var(--channel);
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
+ }
121
+ .pill-field {
122
+ /* Yellow has very low contrast against white, so the color-mix
123
+ approach used by other variants washes out at any readable mix
124
+ percentage. We use the Tailwind yellow ramp directly for bg
125
+ (yellow-100) / border (yellow-300) / text (yellow-900). The
126
+ icon hue stays anchored to --field so a host page can still
127
+ re-theme the variant by overriding that one token. */
128
+ background: #fef9c3;
129
+ color: #854d0e;
130
+ border-color: #fde68a;
131
+ --icon-color: var(--field);
132
+ }
133
+ .pill-keyword {
134
+ background: var(--sunken);
135
+ color: var(--text-1);
136
+ border-color: var(--border);
137
+ --icon-color: var(--text-2);
138
+ font-family: var(--font-mono);
139
+ font-size: 11.5px;
140
+ }
141
+ .pill-label {
142
+ background: var(--sunken);
143
+ color: var(--text-2);
144
+ border-color: var(--border);
145
+ --icon-color: var(--text-2);
146
+ }
147
+ `;
@@ -6,12 +6,88 @@
6
6
 
7
7
  html {
8
8
 
9
- --font-family: 'Roboto', Helvetica, Arial, sans-serif;
10
- --primary-rgb: 35, 135, 202;
9
+ /* ─── TextIt Design System tokens ─────────────────────────────────────
10
+ Single source of truth. Cascades through Shadow DOM into every
11
+ component. Legacy tokens below are aliased to these — keep both in
12
+ sync if the design system evolves. */
13
+
14
+ /* accent ramp — the primary color sits at 400 and the ramp is
15
+ derived from it in both directions via sRGB mixing.
16
+ The anchor reads from --primary-rgb so host pages can re-theme
17
+ the entire ramp by setting e.g. --primary-rgb: 112, 0, 132. */
18
+ --accent: rgb(var(--primary-rgb, 98, 147, 201));
19
+ --accent-50: color-mix(in srgb, var(--accent) 6%, white);
20
+ --accent-100: color-mix(in srgb, var(--accent) 16%, white);
21
+ --accent-200: color-mix(in srgb, var(--accent) 32%, white);
22
+ --accent-300: color-mix(in srgb, var(--accent) 60%, white);
23
+ --accent-400: var(--accent);
24
+ --accent-500: color-mix(in srgb, var(--accent) 90%, black);
25
+ --accent-600: color-mix(in srgb, var(--accent) 80%, black);
26
+ --accent-700: color-mix(in srgb, var(--accent) 65%, black);
27
+ --accent-800: color-mix(in srgb, var(--accent) 50%, black);
28
+ --accent-900: color-mix(in srgb, var(--accent) 35%, black);
29
+
30
+ /* neutrals */
31
+ --bg: #F6F7F9;
32
+ --surface: #FFFFFF;
33
+ --sunken: #F1F3F5;
34
+ --border: #E6E8EC;
35
+ --border-strong: #D2D6DC;
36
+ --text-1: #1A1F26;
37
+ --text-2: #4D5664;
38
+ --text-3: #7B8593;
39
+ --text-4: #A2ABB8;
40
+
41
+ /* status — full set */
42
+ --success: #16A34A;
43
+ --success-bg: #E8F6EE;
44
+ --success-border: #BFE5CD;
45
+ --info: #2563EB;
46
+ --info-bg: #E8F0FE;
47
+ --info-border: #C7D7F8;
48
+ --warning: #B45309;
49
+ --warning-bg: #FDF3E2;
50
+ --warning-border: #F2D9A9;
51
+ --danger: #D03F3F;
52
+ --danger-bg: #FCEBEB;
53
+ --danger-border: #F4C8C8;
54
+ --neutral: #6B7280;
55
+ --neutral-bg: #EEF0F3;
56
+ --neutral-border: #D8DCE2;
57
+
58
+ /* type */
59
+ --font: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
60
+ --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
61
+ --w-regular: 400;
62
+ --w-medium: 500;
63
+ --w-semibold: 600;
64
+ --w-bold: 600;
65
+
66
+ /* shape */
67
+ --r: 8px;
68
+ --r-xs: 2px;
69
+ --r-sm: 4px;
70
+ --r-lg: 12px;
71
+
72
+ /* density */
73
+ --row-h: 36px;
74
+ --input-h: 34px;
75
+ --pad: 10px;
76
+ --gap: 14px;
77
+
78
+ /* shadows */
79
+ --shadow-1: 0 1px 1px rgba(15, 22, 36, 0.04), 0 1px 2px rgba(15, 22, 36, 0.04);
80
+ --shadow-2: 0 1px 1px rgba(15, 22, 36, 0.04), 0 4px 12px rgba(15, 22, 36, 0.06);
81
+ --shadow-3: 0 6px 20px rgba(15, 22, 36, 0.10), 0 2px 6px rgba(15, 22, 36, 0.06);
82
+
83
+ /* ─── legacy aliases — point at the DS tokens above ────────────────── */
84
+
85
+ --font-family: var(--font);
86
+ --primary-rgb: 58, 102, 150;
11
87
  --secondary-rgb: 140, 51, 140;
12
88
  --tertiary-rgb: 135, 202, 35;
13
89
 
14
- --focus-rgb: 82, 168, 236;
90
+ --focus-rgb: 91, 156, 229;
15
91
  --error-rgb: 255, 99, 71;
16
92
  --success-rgb: 102, 186, 104;
17
93
 
@@ -23,50 +99,68 @@
23
99
  --disabled-opacity: 0.6;
24
100
  --curvature: 6px;
25
101
  --curvature-widget: 6px;
102
+ --focus: rgb(var(--focus-rgb, 91, 156, 229));
103
+ --focus-50: color-mix(in srgb, var(--focus) 12%, white);
104
+ --focus-100: color-mix(in srgb, var(--focus) 24%, white);
105
+ --focus-200: color-mix(in srgb, var(--focus) 40%, white);
106
+ --focus-300: color-mix(in srgb, var(--focus) 60%, white);
107
+ --focus-600: color-mix(in srgb, var(--focus) 60%, black);
108
+ --focus-700: color-mix(in srgb, var(--focus) 45%, black);
109
+ --focus-muted: color-mix(in srgb, var(--focus) 60%, white);
110
+ --focus-halo: 0 0 0 3px
111
+ color-mix(in srgb, var(--focus) 30%, transparent);
26
112
  --color-focus: #a4cafe;
27
113
  --color-widget-bg: #fff;
28
114
  --color-widget-bg-focused: #fff;
29
115
  --color-widget-border: rgb(225, 225, 225);
30
116
 
31
- --color-options-bg: var(--color-widget-bg);
117
+ --color-options-bg: var(--surface);
32
118
 
33
- /* primary colors, should be dark */
119
+ /* primary colors */
34
120
  --color-selection: #f0f6ff;
35
121
  --color-success: #3ca96a;
36
-
37
- --widget-box-shadow: rgba(-1, -1, 0, .15) 0px 1px 7px 0px, rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
38
- --widget-box-shadow-focused: 0 0 0 3px rgba(164, 202, 254, .45);
122
+ --color-row-hover: rgba(var(--selection-light-rgb), 0.4);
123
+ --color-message: #3c92dd;
124
+ --color-available: #00f100;
125
+
126
+ --widget-box-shadow: rgba(-1, -1, 0, 0.1) 0px 1px 7px 0px,
127
+ rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
128
+ --widget-box-shadow-focused: 0 0 0 3px rgba(164, 202, 254, 0.45),
129
+ rgba(0, 0, 0, 0.05) 0px 3px 7px 0px,
130
+ rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
39
131
  --widget-box-shadow-focused-error: 0 0 0 3px rgba(var(--error-rgb), 0.3);
40
132
 
41
133
  --shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
42
- --shadow-widget: 0 3px 20px 0 rgba(0, 0, 0, 0.04), 0 1px 2px 0 rgba(0, 0, 0, 0.02);
134
+ --shadow-widget: 0 3px 20px 0 rgba(0, 0, 0, 0.04),
135
+ 0 1px 2px 0 rgba(0, 0, 0, 0.02);
43
136
 
44
137
  /* page text, borders, widgets */
45
- --color-text: #333;;
46
- --color-widget-text: #333;
138
+ --color-text: #555;
139
+ --color-widget-text: #555;
47
140
  --color-borders: rgba(0, 0, 0, 0.07);
48
- --color-placeholder: rgb(167, 167, 167);
141
+ --color-placeholder: #ccc;
49
142
 
50
143
  /* light colors, panel backgrounds, selection, etc */
51
144
  --color-primary-light: #eee;
52
- --color-secondary-light: #ccc;
145
+ --color-secondary-light: rgba(var(--secondary-rgb), 0.3);
53
146
 
54
- --color-label: #333;
147
+ --color-label: var(--text-1);
55
148
 
56
- /* dark colors, nav bar, buttons, etc */
57
- --color-primary-dark: rgb(var(--primary-rgb));
149
+ /* dark colors, nav bar, buttons, etc — default to a ramp step
150
+ so they auto-theme when --primary-rgb changes. Hosts can still
151
+ override the legacy variable directly to break out of the ramp. */
152
+ --color-primary-dark: var(--accent-500);
58
153
  --color-secondary-dark: rgb(var(--secondary-rgb));
59
154
 
60
155
  /* light text goes over dark, dark over lights */
61
156
  --color-text-light: rgba(255, 255, 255, 1);
62
- --color-text-dark: rgba(0, 0, 0, 0.8);
157
+ --color-text-dark: #555;
63
158
  --color-text-dark-secondary: rgba(0, 0, 0, 0.25);
64
159
  --color-text-help: rgb(120, 120, 120);
65
160
  --color-tertiary: rgb(var(--tertiary-rgb));
66
161
 
67
162
  --help-text-size: 0.85em;
68
163
  --help-text-margin-left: 0.3em;
69
- --color-text-help: rgb(120, 120, 120);
70
164
 
71
165
  /* solid overlays with text */
72
166
  --color-overlay-dark: rgba(0, 0, 0, 0.2);
@@ -74,12 +168,13 @@
74
168
  --color-overlay-light: rgba(0, 0, 0, 0.05);
75
169
  --color-overlay-light-text: rgba(0, 0, 0, 0.6);
76
170
 
77
- /* links, buttons, and label badges */
78
- --color-link-primary: rgba(var(--primary-rgb), 0.8);
79
- --color-link-primary-hover: rgba(var(--primary-rgb), 0.9);
171
+ /* links, buttons, and label badges — default to ramp steps so
172
+ they auto-theme; overridable via direct variable setting. */
173
+ --color-link-primary: var(--accent-500);
174
+ --color-link-primary-hover: var(--accent-600);
80
175
  --color-link-secondary: rgba(var(--secondary-rgb), 0.8);
81
176
  --color-link-secondary-hover: rgba(var(--secondary-rgb), 0.9);
82
- --color-button-primary: var(--color-primary-dark);
177
+ --color-button-primary: var(--accent-500);
83
178
  --color-button-primary-text: var(--color-text-light);
84
179
  --color-button-light: rgb(246, 248, 250);
85
180
  --color-button-light-text: rgb(36, 41, 47);
@@ -89,10 +184,17 @@
89
184
  --color-button-destructive: rgb(var(--error-rgb));
90
185
  --color-button-destructive-text: var(--color-text-light);
91
186
 
92
- --color-button-attention: #2ecc71;
187
+ --color-button-attention: #3ca96a;
93
188
 
94
189
  --color-connectors: #95cbef;
95
190
 
191
+ --color-label-primary: var(--color-primary-dark);
192
+ --color-label-primary-text: var(--color-text-light);
193
+ --color-label-secondary: var(--color-secondary-dark);
194
+ --color-label-secondary-text: var(--color-text-light);
195
+ --color-label-tertiary: var(--color-tertiary);
196
+ --color-label-tertiary-text: var(--color-text-light);
197
+
96
198
  --color-nav-unselected: #fff;
97
199
  --color-nav-selected-bg: #fff;
98
200
  --color-nav-selected-text: var(--color-primary-dark);
@@ -116,9 +218,10 @@
116
218
 
117
219
  --transition-speed: 250ms;
118
220
  --event-padding: 0.5em 1em;
119
- --temba-select-selected-padding: 9px;
120
- --temba-select-selected-line-height: 16px;
121
- --temba-select-selected-font-size: 13px;
221
+ --temba-select-selected-padding: 0 var(--pad);
222
+ --temba-select-selected-line-height: 1.4;
223
+ --temba-select-selected-font-size: 13.5px;
224
+ --temba-select-min-height: var(--input-h);
122
225
 
123
226
  --font-size: 14px;
124
227
  --button-font-size: 1.125rem;
@@ -126,23 +229,25 @@
126
229
  --header-bg: var(--color-primary-dark);
127
230
  --header-text: var(--color-text-light);
128
231
 
129
- --temba-textinput-padding: 9px 14px;
130
- --temba-textinput-font-size: 14px;
232
+ --temba-textinput-padding: 7px var(--pad);
233
+ --temba-textinput-font-size: 13.5px;
234
+ --temba-textinput-min-height: var(--input-h);
131
235
 
132
- --options-block-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.03);
133
- --options-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
134
- --dropdown-shadow: rgb(0 0 0 / 30%) 0px 0px 60px, rgb(0 0 0 / 12%) 0px 6px 12px;
236
+ --options-block-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1),
237
+ 0 1px 2px 0 rgba(0, 0, 0, 0.03);
238
+ --options-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
239
+ 0 4px 6px -2px rgba(0, 0, 0, 0.05);
240
+ --dropdown-shadow: rgb(0 0 0 / 15%) 0px 0px 30px,
241
+ rgb(0 0 0 / 12%) 0px 2px 6px;
135
242
 
136
243
  --label-size: 14px;
244
+ --control-margin-bottom: 15px;
137
245
 
138
246
  --menu-padding: 1em;
139
247
 
140
248
  font-size: var(--font-size);
141
249
  font-family: var(--font-family);
142
250
 
143
- --button-y: 6px;
144
- --button-x: 14px;
145
-
146
251
  --bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
147
252
 
148
253
  --temba-charcount-counts-margin-top: 4px;
@@ -156,14 +261,14 @@
156
261
  }
157
262
 
158
263
  temba-button {
159
- --button-bg: var(--color-primary-dark);
264
+ --button-bg: var(--accent-500);
160
265
  --button-text: var(--color-text-light);
161
266
  --button-border: none;
162
267
  --button-shadow: var(--widget-box-shadow);
163
268
  }
164
269
 
165
270
  temba-button:hover {
166
- --button-bg-img: linear-gradient(to bottom, rgba(var(--primary-rgb), .1), transparent, transparent);
271
+ --button-bg-img: linear-gradient(to bottom, rgba(255, 255, 255, .1), transparent, transparent);
167
272
  }
168
273
 
169
274
  temba-button.active {
@@ -255,7 +255,6 @@ export default {
255
255
  '/api/v2/contacts.json': 'contacts.json',
256
256
  '/api/v2/optins.json': 'optins.json',
257
257
  '/api/v2/topics.json': 'topics.json',
258
- '/api/v2/languages.json': 'languages.json',
259
258
  '/api/v2/workspace.json': 'workspace.json',
260
259
  '/api/internal/locations.json': 'locations.json',
261
260
  '/api/internal/orgs.json': 'orgs.json',
@@ -2,6 +2,100 @@
2
2
  /* eslint-disable @typescript-eslint/no-unused-vars */
3
3
  process.env.PUPPETEER_DISABLE_HEADLESS_WARNING = '1';
4
4
  import { puppeteerLauncher } from '@web/test-runner-puppeteer';
5
+ import { defaultReporter, summaryReporter } from '@web/test-runner';
6
+
7
+ // Workaround for a wtr OOM under coverage on this branch. With our
8
+ // volume of instrumented code, the merged istanbul-coverage payload
9
+ // across all 121 test sessions overflows V8's max string length
10
+ // (~512MB) when `getTestCoverage` deep-clones it via
11
+ // `JSON.parse(JSON.stringify(coverages))`. That clone exists only to
12
+ // insulate watch mode from istanbul's in-place mutation of the
13
+ // originals — in non-watch coverage mode the clone is dead weight.
14
+ // The throw is caught inside `onSessionFinished`'s try/catch, which
15
+ // calls `runner.stop(error)` and silently exits 1 (the `console.error`
16
+ // it logs is swallowed by wtr's BufferedLogger before the final
17
+ // reportEnd flush ever runs).
18
+ //
19
+ // We can't import `getTestCoverage` directly (it's a transitive dep
20
+ // not re-exported by `@web/test-runner`), so we shim JSON.parse: when
21
+ // it's handed the sentinel produced by our JSON.stringify shim, it
22
+ // returns the original `coverages` array instead of the stringified
23
+ // copy. The shim is intentionally narrow:
24
+ //
25
+ // - Gated on `WTR_COVERAGE_SHIM` (defaults on when --coverage is in
26
+ // argv; can be force-disabled by setting it to "0") so it never
27
+ // runs in plain `pnpm test`, only in the coverage path.
28
+ // - Sentinel is a runtime-random string, so a fixture/cached body
29
+ // containing the literal can't collide.
30
+ // - `__pendingCoverages` is cleared on every parse — matched OR not —
31
+ // so the ~512MB reference never lingers past one call pair, and
32
+ // mismatched call sequences never silently corrupt other parses.
33
+ // - The shape check `__looksLikeCoverageArray` only matches arrays
34
+ // whose first element is an object keyed by absolute file paths
35
+ // pointing at istanbul-shaped entries; it can't be triggered by
36
+ // ordinary JSON the orchestrator stringifies (HTTP responses, etc.)
37
+ //
38
+ // A wtr update that changes how getTestCoverage clones would make the
39
+ // shim a no-op (`__looksLikeCoverageArray` returns false) — we'd then
40
+ // regress to the original OOM rather than silently corrupt data, and
41
+ // the next coverage run would surface it immediately. Long-term, the
42
+ // right fix is patch-package on `@web/test-runner-core`'s
43
+ // `getTestCoverage` to skip the clone in non-watch mode; this shim is
44
+ // scoped to keep that scope-creep out of this PR.
45
+ const __WTR_COVERAGE_SHIM_ON =
46
+ process.env.WTR_COVERAGE_SHIM !== '0' &&
47
+ (process.env.WTR_COVERAGE_SHIM === '1' ||
48
+ process.argv.some((a) => a === '--coverage' || a === '--watch-coverage'));
49
+
50
+ if (__WTR_COVERAGE_SHIM_ON) {
51
+ const __origStringify = JSON.stringify;
52
+ const __origParse = JSON.parse;
53
+ // Random sentinel so the literal string in user content can't
54
+ // accidentally trigger the parse shim.
55
+ const __CLONE_SENTINEL =
56
+ '__WTR_COVERAGE_CLONE__' + Math.random().toString(36).slice(2);
57
+ let __pendingCoverages = null;
58
+
59
+ const __looksLikeCoverageArray = (value) => {
60
+ if (!Array.isArray(value) || value.length === 0) return false;
61
+ const first = value[0];
62
+ if (!first || typeof first !== 'object' || Array.isArray(first)) {
63
+ return false;
64
+ }
65
+ const keys = Object.keys(first);
66
+ if (keys.length === 0) return false;
67
+ if (!keys.every((k) => k.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(k))) {
68
+ return false;
69
+ }
70
+ const entry = first[keys[0]];
71
+ return (
72
+ entry &&
73
+ typeof entry === 'object' &&
74
+ (entry.statementMap || entry.fnMap || entry.b || entry.s)
75
+ );
76
+ };
77
+
78
+ JSON.stringify = function (value, ...rest) {
79
+ if (__looksLikeCoverageArray(value)) {
80
+ // A second matched stringify before a matched parse would
81
+ // otherwise silently drop the first payload — clear first.
82
+ __pendingCoverages = value;
83
+ return __CLONE_SENTINEL;
84
+ }
85
+ return __origStringify(value, ...rest);
86
+ };
87
+ JSON.parse = function (text, ...rest) {
88
+ // Always clear pending on parse (matched or not). Keeps the ~512MB
89
+ // reference from outliving a single call pair even if the matched
90
+ // parse never arrives (e.g. wtr throws between the two calls).
91
+ const pending = __pendingCoverages;
92
+ __pendingCoverages = null;
93
+ if (text === __CLONE_SENTINEL && pending) {
94
+ return pending;
95
+ }
96
+ return __origParse(text, ...rest);
97
+ };
98
+ }
5
99
  import { esbuildPlugin } from '@web/dev-server-esbuild';
6
100
  import fs from 'fs';
7
101
  import * as path from 'path';
@@ -346,6 +440,10 @@ export default {
346
440
  coverageConfig: {
347
441
  include: ['src/**']
348
442
  },
443
+ reporters: [
444
+ defaultReporter({ reportTestResults: true, reportTestProgress: true }),
445
+ summaryReporter({ flatten: true })
446
+ ],
349
447
  testFramework: {
350
448
  config: {
351
449
  timeout: '10000'
@@ -380,7 +478,6 @@ export default {
380
478
  '/api/v2/contacts.json': './static/api/contacts.json',
381
479
  '/api/v2/optins.json': './static/api/optins.json',
382
480
  '/api/v2/topics.json': './static/api/topics.json',
383
- '/api/v2/languages.json': './static/api/languages.json',
384
481
  '/api/v2/workspace.json': './static/api/workspace.json',
385
482
  '/api/internal/locations.json': './static/api/locations.json',
386
483
  '/api/internal/orgs.json': './static/api/orgs.json'