@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
@@ -0,0 +1,136 @@
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: full ramp derived from --accent
13
+ * via `color-mix(in oklab, …)`. Override --accent to re-theme.
14
+ * - .pill-flow, .pill-channel: bg/border derived from --flow /
15
+ * --channel via color-mix; text + icon use the anchor directly.
16
+ * - .pill-field: bg/border are fixed at the Tailwind yellow ramp
17
+ * (yellow-100 / yellow-300) because yellow has too little
18
+ * contrast against white to color-mix into a recognizable swatch.
19
+ * Text uses yellow-900 for readability; the icon uses --field
20
+ * directly, which is the only knob a host page can re-theme.
21
+ * - .pill-neutral / .pill-label / .pill-keyword: greys; not
22
+ * anchor-driven.
23
+ *
24
+ * Shape (height, padding, radius, icon spacing) is the consumer's
25
+ * concern, since pill use-cases differ: Select chips have a remove
26
+ * button on the right, ContactDetails pills are clickable links, etc.
27
+ *
28
+ * To add a new variant: extend `PILL_TYPES`, optionally add an entry
29
+ * to `PILL_TYPE_ICONS` / `ICON_TO_PILL_TYPE`, append a `.pill-{type}`
30
+ * block below, and reference an anchor in `designTokens.ts`.
31
+ */
32
+
33
+ /** Recognized pill variants. Anything outside this set falls back to
34
+ * `pill-neutral` (or is rejected by callers as not a pill at all). */
35
+ export const PILL_TYPES: ReadonlySet<string> = new Set([
36
+ 'neutral',
37
+ 'flow',
38
+ 'group',
39
+ 'contact',
40
+ 'field',
41
+ 'label',
42
+ 'keyword',
43
+ 'channel'
44
+ ]);
45
+
46
+ /** Default icon name for each pill variant. Used when a consumer
47
+ * specifies `type` but not `icon` — keeps Omnibox-style options and
48
+ * Django-form-rendered options visually consistent without making the
49
+ * data layer set both fields. */
50
+ export const PILL_TYPE_ICONS: Readonly<Record<string, string>> = {
51
+ group: 'group',
52
+ contact: 'contact',
53
+ field: 'fields',
54
+ flow: 'flow',
55
+ label: 'label'
56
+ };
57
+
58
+ /** Inverse mapping: icon name (alias or resolved SVG id) → pill type.
59
+ * Both forms are valid since flow-action items pass through either. */
60
+ const ICON_TO_PILL_TYPE: Readonly<Record<string, string>> = {
61
+ flow: 'flow',
62
+ group: 'group',
63
+ contact: 'contact',
64
+ contacts: 'contact',
65
+ field: 'field',
66
+ fields: 'field',
67
+ label: 'label',
68
+ // resolved Icon enum SVG ids
69
+ 'users-01': 'group',
70
+ 'atom-01': 'group',
71
+ 'user-01': 'contact',
72
+ 'tag-01': 'label'
73
+ };
74
+
75
+ export const iconToPillType = (icon?: string): string | undefined => {
76
+ if (!icon) return undefined;
77
+ if (ICON_TO_PILL_TYPE[icon]) return ICON_TO_PILL_TYPE[icon];
78
+ // Legacy alias prefix (e.g. 'group_smart' → 'group').
79
+ if (icon.startsWith('group')) return 'group';
80
+ return undefined;
81
+ };
82
+
83
+ export const pillVariants = css`
84
+ .pill-neutral {
85
+ background: var(--sunken);
86
+ color: var(--text-1);
87
+ border-color: var(--border);
88
+ --icon-color: var(--text-2);
89
+ }
90
+ .pill-flow {
91
+ background: color-mix(in oklab, var(--flow) 12%, white);
92
+ color: var(--flow);
93
+ border-color: color-mix(in oklab, var(--flow) 25%, white);
94
+ --icon-color: var(--flow);
95
+ }
96
+ /* Recipient color — shared by contacts and groups. */
97
+ .pill-contact,
98
+ .pill-group {
99
+ background: var(--accent-100);
100
+ color: var(--accent-700);
101
+ border-color: var(--accent-200);
102
+ --icon-color: var(--accent-700);
103
+ }
104
+ .pill-channel {
105
+ background: color-mix(in oklab, var(--channel) 12%, white);
106
+ color: var(--channel);
107
+ border-color: color-mix(in oklab, var(--channel) 25%, white);
108
+ --icon-color: var(--channel);
109
+ }
110
+ .pill-field {
111
+ /* Yellow has very low contrast against white, so the color-mix
112
+ approach used by other variants washes out at any readable mix
113
+ percentage. We use the Tailwind yellow ramp directly for bg
114
+ (yellow-100) / border (yellow-300) / text (yellow-900). The
115
+ icon hue stays anchored to --field so a host page can still
116
+ re-theme the variant by overriding that one token. */
117
+ background: #fef9c3;
118
+ color: #854d0e;
119
+ border-color: #fde68a;
120
+ --icon-color: var(--field);
121
+ }
122
+ .pill-keyword {
123
+ background: var(--sunken);
124
+ color: var(--text-1);
125
+ border-color: var(--border);
126
+ --icon-color: var(--text-2);
127
+ font-family: var(--font-mono);
128
+ font-size: 11.5px;
129
+ }
130
+ .pill-label {
131
+ background: var(--sunken);
132
+ color: var(--text-2);
133
+ border-color: var(--border);
134
+ --icon-color: var(--text-2);
135
+ }
136
+ `;
@@ -6,7 +6,80 @@
6
6
 
7
7
  html {
8
8
 
9
- --font-family: 'Roboto', Helvetica, Arial, sans-serif;
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 — derived from a single anchor via OKLab mixing */
15
+ --accent: #2A6FB5;
16
+ --accent-50: color-mix(in oklab, var(--accent) 6%, white);
17
+ --accent-100: color-mix(in oklab, var(--accent) 12%, white);
18
+ --accent-200: color-mix(in oklab, var(--accent) 25%, white);
19
+ --accent-300: color-mix(in oklab, var(--accent) 45%, white);
20
+ --accent-400: color-mix(in oklab, var(--accent) 75%, white);
21
+ --accent-500: var(--accent);
22
+ --accent-600: color-mix(in oklab, var(--accent) 88%, black);
23
+ --accent-700: color-mix(in oklab, var(--accent) 75%, black);
24
+ --accent-800: color-mix(in oklab, var(--accent) 60%, black);
25
+ --accent-900: color-mix(in oklab, var(--accent) 45%, black);
26
+
27
+ /* neutrals */
28
+ --bg: #F6F7F9;
29
+ --surface: #FFFFFF;
30
+ --sunken: #F1F3F5;
31
+ --border: #E6E8EC;
32
+ --border-strong: #D2D6DC;
33
+ --text-1: #1A1F26;
34
+ --text-2: #4D5664;
35
+ --text-3: #7B8593;
36
+ --text-4: #A2ABB8;
37
+
38
+ /* status — full set */
39
+ --success: #16A34A;
40
+ --success-bg: #E8F6EE;
41
+ --success-border: #BFE5CD;
42
+ --info: #2563EB;
43
+ --info-bg: #E8F0FE;
44
+ --info-border: #C7D7F8;
45
+ --warning: #B45309;
46
+ --warning-bg: #FDF3E2;
47
+ --warning-border: #F2D9A9;
48
+ --danger: #D03F3F;
49
+ --danger-bg: #FCEBEB;
50
+ --danger-border: #F4C8C8;
51
+ --neutral: #6B7280;
52
+ --neutral-bg: #EEF0F3;
53
+ --neutral-border: #D8DCE2;
54
+
55
+ /* type */
56
+ --font: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
57
+ --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
58
+ --w-regular: 400;
59
+ --w-medium: 500;
60
+ --w-semibold: 600;
61
+ --w-bold: 700;
62
+
63
+ /* shape */
64
+ --r: 8px;
65
+ --r-xs: 2px;
66
+ --r-sm: 4px;
67
+ --r-lg: 12px;
68
+
69
+ /* density */
70
+ --row-h: 36px;
71
+ --input-h: 34px;
72
+ --pad: 10px;
73
+ --gap: 14px;
74
+
75
+ /* shadows */
76
+ --shadow-1: 0 1px 1px rgba(15, 22, 36, 0.04), 0 1px 2px rgba(15, 22, 36, 0.04);
77
+ --shadow-2: 0 1px 1px rgba(15, 22, 36, 0.04), 0 4px 12px rgba(15, 22, 36, 0.06);
78
+ --shadow-3: 0 6px 20px rgba(15, 22, 36, 0.10), 0 2px 6px rgba(15, 22, 36, 0.06);
79
+
80
+ /* ─── legacy aliases — point at the DS tokens above ────────────────── */
81
+
82
+ --font-family: var(--font);
10
83
  --primary-rgb: 35, 135, 202;
11
84
  --secondary-rgb: 140, 51, 140;
12
85
  --tertiary-rgb: 135, 202, 35;
@@ -21,37 +94,41 @@
21
94
  --select-input-height: inherit;
22
95
 
23
96
  --disabled-opacity: 0.6;
24
- --curvature: 6px;
25
- --curvature-widget: 6px;
26
- --color-focus: #a4cafe;
27
- --color-widget-bg: #fff;
28
- --color-widget-bg-focused: #fff;
29
- --color-widget-border: rgb(225, 225, 225);
30
-
31
- --color-options-bg: var(--color-widget-bg);
97
+ --curvature: var(--r-sm);
98
+ --curvature-widget: var(--r-sm);
99
+ --focus: #5b9ce5;
100
+ --focus-muted: color-mix(in oklab, var(--focus) 60%, white);
101
+ --focus-halo: 0 0 0 3px
102
+ color-mix(in oklab, var(--focus) 30%, transparent);
103
+ --color-focus: var(--focus-muted);
104
+ --color-widget-bg: var(--surface);
105
+ --color-widget-bg-focused: var(--surface);
106
+ --color-widget-border: var(--border-strong);
107
+
108
+ --color-options-bg: var(--surface);
32
109
 
33
110
  /* primary colors, should be dark */
34
- --color-selection: #f0f6ff;
35
- --color-success: #3ca96a;
111
+ --color-selection: var(--accent-50);
112
+ --color-success: var(--success);
36
113
 
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);
114
+ --widget-box-shadow: none;
115
+ --widget-box-shadow-focused: var(--focus-halo);
39
116
  --widget-box-shadow-focused-error: 0 0 0 3px rgba(var(--error-rgb), 0.3);
40
117
 
41
- --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);
118
+ --shadow: var(--shadow-1);
119
+ --shadow-widget: var(--shadow-1);
43
120
 
44
121
  /* page text, borders, widgets */
45
- --color-text: #333;;
46
- --color-widget-text: #333;
47
- --color-borders: rgba(0, 0, 0, 0.07);
48
- --color-placeholder: rgb(167, 167, 167);
122
+ --color-text: var(--text-1);
123
+ --color-widget-text: var(--text-1);
124
+ --color-borders: var(--border);
125
+ --color-placeholder: var(--text-3);
49
126
 
50
127
  /* light colors, panel backgrounds, selection, etc */
51
- --color-primary-light: #eee;
128
+ --color-primary-light: var(--sunken);
52
129
  --color-secondary-light: #ccc;
53
130
 
54
- --color-label: #333;
131
+ --color-label: var(--text-1);
55
132
 
56
133
  /* dark colors, nav bar, buttons, etc */
57
134
  --color-primary-dark: rgb(var(--primary-rgb));
@@ -61,12 +138,11 @@
61
138
  --color-text-light: rgba(255, 255, 255, 1);
62
139
  --color-text-dark: rgba(0, 0, 0, 0.8);
63
140
  --color-text-dark-secondary: rgba(0, 0, 0, 0.25);
64
- --color-text-help: rgb(120, 120, 120);
141
+ --color-text-help: var(--text-3);
65
142
  --color-tertiary: rgb(var(--tertiary-rgb));
66
143
 
67
144
  --help-text-size: 0.85em;
68
145
  --help-text-margin-left: 0.3em;
69
- --color-text-help: rgb(120, 120, 120);
70
146
 
71
147
  /* solid overlays with text */
72
148
  --color-overlay-dark: rgba(0, 0, 0, 0.2);
@@ -116,9 +192,10 @@
116
192
 
117
193
  --transition-speed: 250ms;
118
194
  --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;
195
+ --temba-select-selected-padding: 0 var(--pad);
196
+ --temba-select-selected-line-height: 1.4;
197
+ --temba-select-selected-font-size: 13.5px;
198
+ --temba-select-min-height: var(--input-h);
122
199
 
123
200
  --font-size: 14px;
124
201
  --button-font-size: 1.125rem;
@@ -126,8 +203,9 @@
126
203
  --header-bg: var(--color-primary-dark);
127
204
  --header-text: var(--color-text-light);
128
205
 
129
- --temba-textinput-padding: 9px 14px;
130
- --temba-textinput-font-size: 14px;
206
+ --temba-textinput-padding: 7px var(--pad);
207
+ --temba-textinput-font-size: 13.5px;
208
+ --temba-textinput-min-height: var(--input-h);
131
209
 
132
210
  --options-block-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.03);
133
211
  --options-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
@@ -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'