@explorer02/cfm-survey-sdk 0.3.0 → 0.3.2

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 (40) hide show
  1. package/dist/cli/index.js +94 -83
  2. package/dist/cli/index.mjs +94 -83
  3. package/package.json +1 -1
  4. package/templates/docs/00-integration/client-lib-folder.md +1 -0
  5. package/templates/docs/00-integration/wizard-chrome-contract.md +2 -1
  6. package/templates/docs/00-integration/wizard-preview-build-guide.md +17 -0
  7. package/templates/docs/01-components/03-rating-scale.md +8 -3
  8. package/templates/docs/02-reference/value-derivation.md +2 -0
  9. package/templates/docs/03-ui-specs/01-rating.md +15 -1
  10. package/templates/docs/templates/CsatMatrixScale.tsx +18 -24
  11. package/templates/docs/templates/CustomSliderTrack.tsx +5 -3
  12. package/templates/docs/templates/FileUploadScale.tsx +143 -18
  13. package/templates/docs/templates/Header.tsx +1 -0
  14. package/templates/docs/templates/HeatmapScale.tsx +11 -2
  15. package/templates/docs/templates/LanguageSelector.tsx +42 -19
  16. package/templates/docs/templates/LikertMatrixScale.tsx +43 -33
  17. package/templates/docs/templates/MatrixDropdown.tsx +12 -11
  18. package/templates/docs/templates/Question.tsx +109 -44
  19. package/templates/docs/templates/RankOrderScale.tsx +22 -5
  20. package/templates/docs/templates/RatingScale.tsx +77 -39
  21. package/templates/docs/templates/SliderMatrixScale.tsx +44 -16
  22. package/templates/docs/templates/selectionStyles.ts +90 -2
  23. package/templates/docs/templates/surveyUiIcons.tsx +2 -2
  24. package/templates/docs/templates/surveyUiScaleUtils.ts +52 -5
  25. package/templates/docs/templates/uiConfig.ts +56 -0
  26. package/templates/preview-harness/preview-bridge.inline.js +44 -0
  27. package/templates/preview-harness/previewPages.js +76 -51
  28. package/templates/preview-harness/previewPages.ts +7 -34
  29. package/templates/preview-harness/vite-app/src/PreviewConfigContext.tsx +7 -0
  30. package/templates/preview-harness/vite-app/src/QuestionPreview.tsx +59 -30
  31. package/templates/preview-harness/vite-app/src/SurveyPagePreview.tsx +36 -8
  32. package/templates/preview-harness/vite-app/src/fixtures/questions.ts +182 -82
  33. package/templates/previewBridge.ts +33 -0
  34. package/templates/survey-theme.css +13 -4
  35. package/templates/wizard-dist/assets/{PreviewMock-Bax7oRAL.js → PreviewMock-DbbLpHdF.js} +1 -1
  36. package/templates/wizard-dist/assets/TypePanel-DQbV2iCf.js +1 -0
  37. package/templates/wizard-dist/assets/index-CY7WMJ93.js +34 -0
  38. package/templates/wizard-dist/index.html +1 -1
  39. package/templates/wizard-dist/assets/TypePanel-D2t4FPSd.js +0 -1
  40. package/templates/wizard-dist/assets/index-c5lka74l.js +0 -34
@@ -6,7 +6,12 @@ import {
6
6
  scaleRadioDotStyle,
7
7
  scaleCellSelectedStyle,
8
8
  } from '@/lib/surveyUi/selectionStyles';
9
- import { getAccentColor, npsValueFromOption, starValueFromOption } from './surveyUiScaleUtils';
9
+ import {
10
+ getAccentColor,
11
+ isScaleAnswerSelected,
12
+ npsValueFromOption,
13
+ starValueFromOption,
14
+ } from './surveyUiScaleUtils';
10
15
  import { getEmojiForIndex } from './surveyUiIcons';
11
16
 
12
17
  type RatingScaleProps = {
@@ -15,22 +20,22 @@ type RatingScaleProps = {
15
20
  selectedValue?: AnswerValue;
16
21
  onSelect: (value: AnswerValue) => void;
17
22
  variant?: 'nps' | 'star';
18
- buttonStyle?: 'standard' | 'numbered' | 'emoji';
23
+ buttonStyle?: 'standard' | 'numbered' | 'emoji' | 'pill';
19
24
  };
20
25
 
21
26
  function resolveBadgeColor(index: number, value: number): string {
22
- const mode = getComputedStyle(document.documentElement)
23
- .getPropertyValue('--cfm-number-label-mode')
24
- .trim();
27
+ if (typeof document === 'undefined') return getAccentColor(value);
28
+
29
+ const root = document.documentElement;
30
+ const mode = getComputedStyle(root).getPropertyValue('--cfm-number-label-mode').trim();
25
31
  if (mode === 'monochrome') {
26
32
  return (
27
- getComputedStyle(document.documentElement).getPropertyValue('--cfm-number-mono-color').trim() ||
28
- 'var(--cfm-primary)'
33
+ getComputedStyle(root).getPropertyValue('--cfm-number-mono-color').trim() || getAccentColor(value)
29
34
  );
30
35
  }
31
36
  if (mode === 'individual') {
32
37
  return (
33
- getComputedStyle(document.documentElement).getPropertyValue(`--cfm-number-color-${index}`).trim() ||
38
+ getComputedStyle(root).getPropertyValue(`--cfm-number-color-${index}`).trim() ||
34
39
  getAccentColor(value)
35
40
  );
36
41
  }
@@ -52,7 +57,7 @@ export default function RatingScale({
52
57
  : npsValueFromOption(option as NpsScalePoint, index);
53
58
  const numericValue = typeof value === 'number' ? value : index;
54
59
  const color =
55
- variant === 'nps' ? resolveBadgeColor(index, numericValue) : 'var(--cfm-primary)';
60
+ variant === 'nps' ? resolveBadgeColor(index, numericValue) : 'var(--cfm-primary, #e20074)';
56
61
  return { id: option.id, label: option.optionLabel, value, color };
57
62
  });
58
63
 
@@ -62,19 +67,34 @@ export default function RatingScale({
62
67
  gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
63
68
  gap: 'var(--cfm-nps-cell-gap, 2px)',
64
69
  };
70
+ const showNumberBadges = buttonStyle !== 'standard';
71
+ const usePillCells = buttonStyle === 'pill';
65
72
 
66
73
  if (buttonStyle === 'emoji') {
67
74
  return (
68
75
  <div role="radiogroup" className="w-full overflow-x-auto" data-cfm-nps-area>
69
76
  <div className="min-w-0" style={gridStyle}>
70
77
  {resolvedOptions.map((option, index) => {
71
- const isSelected = selectedValue === option.value;
78
+ const isSelected = isScaleAnswerSelected(selectedValue, option.value);
72
79
  return (
73
- <label key={`${questionId}-${option.value}`} title={option.label} className="flex cursor-pointer justify-center">
74
- <input type="radio" name={questionId} checked={isSelected} onChange={() => onSelect(option.value)} className="sr-only" />
80
+ <label
81
+ key={`${questionId}-${option.value}`}
82
+ title={option.label}
83
+ className="flex cursor-pointer justify-center"
84
+ >
85
+ <input
86
+ type="radio"
87
+ name={questionId}
88
+ checked={isSelected}
89
+ onChange={() => onSelect(option.value)}
90
+ className="sr-only"
91
+ />
75
92
  <div
76
93
  style={{
77
- ...scaleRadioRingStyle(isSelected, { pill: false, size: 'var(--cfm-csat-emoji-size, 48px)' }),
94
+ ...scaleRadioRingStyle(isSelected, {
95
+ pill: false,
96
+ size: 'var(--cfm-csat-emoji-size, 40px)',
97
+ }),
78
98
  fontSize: 'var(--cfm-csat-emoji-size, 24px)',
79
99
  }}
80
100
  >
@@ -90,28 +110,30 @@ export default function RatingScale({
90
110
 
91
111
  return (
92
112
  <div role="radiogroup" className="w-full overflow-x-auto" data-cfm-nps-area>
93
- <div className="mb-3 min-w-0" style={gridStyle}>
94
- {resolvedOptions.map((option, index) => {
95
- const isSelected = selectedValue === option.value;
96
- return (
97
- <div key={`${questionId}-badge-${option.value}`} className="flex justify-center">
98
- <div
99
- className="flex items-center justify-center rounded-[3px] text-[10px] font-bold text-white shadow-sm sm:text-xs"
100
- style={{
101
- width: 'var(--cfm-nps-cell-size, 24px)',
102
- height: 'var(--cfm-nps-cell-size, 24px)',
103
- ...(isSelected
104
- ? scaleCellSelectedStyle(true)
105
- : { backgroundColor: option.color }),
106
- }}
107
- title={option.label}
108
- >
109
- {option.label}
113
+ {showNumberBadges && (
114
+ <div className="mb-3 min-w-0" style={gridStyle}>
115
+ {resolvedOptions.map((option) => {
116
+ const isSelected = isScaleAnswerSelected(selectedValue, option.value);
117
+ return (
118
+ <div key={`${questionId}-badge-${option.value}`} className="flex justify-center">
119
+ <div
120
+ className="flex items-center justify-center rounded-[3px] text-[10px] font-bold text-white shadow-sm sm:text-xs"
121
+ style={{
122
+ width: 'var(--cfm-nps-cell-size, 24px)',
123
+ height: 'var(--cfm-nps-cell-size, 24px)',
124
+ ...(isSelected
125
+ ? scaleCellSelectedStyle(true)
126
+ : { backgroundColor: option.color }),
127
+ }}
128
+ title={option.label}
129
+ >
130
+ {option.label}
131
+ </div>
110
132
  </div>
111
- </div>
112
- );
113
- })}
114
- </div>
133
+ );
134
+ })}
135
+ </div>
136
+ )}
115
137
 
116
138
  <div
117
139
  className="relative w-full rounded-md px-1 py-2"
@@ -119,12 +141,28 @@ export default function RatingScale({
119
141
  >
120
142
  <div className="min-w-0" style={gridStyle}>
121
143
  {resolvedOptions.map((option) => {
122
- const isSelected = selectedValue === option.value;
144
+ const isSelected = isScaleAnswerSelected(selectedValue, option.value);
123
145
  return (
124
- <label key={`${questionId}-${option.value}`} title={option.label} className="flex cursor-pointer justify-center">
125
- <input type="radio" name={questionId} checked={isSelected} onChange={() => onSelect(option.value)} className="sr-only" />
126
- <div style={scaleRadioRingStyle(isSelected, { size: 'var(--cfm-nps-cell-size, 40px)' })}>
127
- <div style={scaleRadioDotStyle(isSelected)} />
146
+ <label
147
+ key={`${questionId}-${option.value}`}
148
+ title={option.label}
149
+ className="flex cursor-pointer justify-center"
150
+ >
151
+ <input
152
+ type="radio"
153
+ name={questionId}
154
+ checked={isSelected}
155
+ onChange={() => onSelect(option.value)}
156
+ className="sr-only"
157
+ />
158
+ <div
159
+ className="flex items-center justify-center rounded-full border-2 transition-all"
160
+ style={scaleRadioRingStyle(isSelected, {
161
+ size: 'var(--cfm-nps-cell-size, 40px)',
162
+ pill: usePillCells,
163
+ })}
164
+ >
165
+ <div style={scaleRadioDotStyle(isSelected, { pill: usePillCells })} />
128
166
  </div>
129
167
  </label>
130
168
  );
@@ -9,6 +9,10 @@ import React from 'react';
9
9
  import type { SliderMatrixQuestion, MatrixRowAnswers } from '@explorer02/cfm-survey-sdk';
10
10
  import { tickColorFromIndex } from './surveyUiScaleUtils';
11
11
  import { CustomSliderTrack } from './CustomSliderTrack';
12
+ import { hintLabelStyle, sliderTickLabelStyle } from '@/lib/surveyUi/labelStyles';
13
+
14
+ const MATRIX_ROW_LABEL_WIDTH = 'var(--cfm-matrix-row-width, 180px)';
15
+ const SLIDER_ROW_BAND = 'var(--cfm-slider-row-band, var(--cfm-row-band, #f3f4f6))';
12
16
 
13
17
  type SliderMatrixScaleProps = {
14
18
  question: SliderMatrixQuestion;
@@ -67,14 +71,22 @@ export function SliderMatrixScale({ question, selectedValue = {}, onSelect }: Sl
67
71
  display: 'flex', width: '100%', marginBottom: '4px', alignItems: 'flex-end',
68
72
  padding: '0 16px', boxSizing: 'border-box',
69
73
  }}>
70
- <div style={{ flex: '0 0 25%', paddingRight: '16px' }} />
74
+ <div style={{ flex: `0 0 ${MATRIX_ROW_LABEL_WIDTH}`, paddingRight: '16px' }} />
71
75
 
72
76
  <div style={{ flex: '1', position: 'relative' }}>
73
77
  {labels && labels.length > 0 && (
74
- <div style={{
75
- position: 'relative', display: 'flex', justifyContent: 'space-between',
76
- margin: '0 16px', marginBottom: '16px', color: '#111827', fontSize: '14px', fontWeight: 500,
77
- }}>
78
+ <div
79
+ style={{
80
+ position: 'relative',
81
+ display: 'flex',
82
+ justifyContent: 'space-between',
83
+ margin: '0 16px',
84
+ marginBottom: '16px',
85
+ fontSize: '14px',
86
+ fontWeight: 500,
87
+ ...hintLabelStyle(),
88
+ }}
89
+ >
78
90
  {labels.map((lbl, idx) => (
79
91
  <div key={idx} style={{ width: 0, display: 'flex', justifyContent: 'center' }}>
80
92
  <span style={{ whiteSpace: 'nowrap' }}>{lbl}</span>
@@ -83,10 +95,17 @@ export function SliderMatrixScale({ question, selectedValue = {}, onSelect }: Sl
83
95
  </div>
84
96
  )}
85
97
 
86
- <div style={{
87
- position: 'relative', margin: '0 16px', marginBottom: '8px',
88
- color: '#4b5563', fontSize: '14px', fontWeight: 400, height: '28px',
89
- }}>
98
+ <div
99
+ style={{
100
+ position: 'relative',
101
+ margin: '0 16px',
102
+ marginBottom: '8px',
103
+ fontSize: '14px',
104
+ fontWeight: 400,
105
+ height: '28px',
106
+ ...sliderTickLabelStyle(),
107
+ }}
108
+ >
90
109
  {tickValues.length > 0 ? (
91
110
  tickValues.map((tv, idx) => {
92
111
  const percentage = max !== min ? ((tv.value - min) / (max - min)) * 100 : 0;
@@ -101,11 +120,20 @@ export function SliderMatrixScale({ question, selectedValue = {}, onSelect }: Sl
101
120
  {tv.label}
102
121
  </div>
103
122
  ) : (
104
- <div style={{
105
- backgroundColor: tv.color, color: 'white', padding: '4px 8px', borderRadius: '4px',
106
- fontWeight: 600, wordBreak: 'break-word', textAlign: 'center', lineHeight: '1.2',
107
- boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
108
- }}>
123
+ <div
124
+ style={{
125
+ backgroundColor: tv.color,
126
+ color: 'white',
127
+ padding: '4px 8px',
128
+ borderRadius: '4px',
129
+ fontWeight: 600,
130
+ wordBreak: 'break-word',
131
+ textAlign: 'center',
132
+ lineHeight: '1.2',
133
+ boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
134
+ minWidth: 'var(--cfm-slider-tick-size, 28px)',
135
+ }}
136
+ >
109
137
  {tv.label}
110
138
  </div>
111
139
  )}
@@ -151,9 +179,9 @@ export function SliderMatrixScale({ question, selectedValue = {}, onSelect }: Sl
151
179
  return (
152
180
  <div key={row.id} style={{
153
181
  display: 'flex', width: '100%', alignItems: 'center',
154
- backgroundColor: '#f3f4f6', borderRadius: '12px', padding: '16px', boxSizing: 'border-box',
182
+ backgroundColor: SLIDER_ROW_BAND, borderRadius: '12px', padding: '16px', boxSizing: 'border-box',
155
183
  }}>
156
- <div style={{ flex: '0 0 25%', paddingRight: '16px', fontSize: '15px', color: '#111827', wordWrap: 'break-word', display: 'flex', alignItems: 'center' }}>
184
+ <div style={{ flex: `0 0 ${MATRIX_ROW_LABEL_WIDTH}`, paddingRight: '16px', fontSize: '15px', color: '#111827', wordWrap: 'break-word', display: 'flex', alignItems: 'center' }}>
157
185
  {row.statementText !== 'Question' && row.statementText !== '' ? (
158
186
  <span dangerouslySetInnerHTML={{ __html: row.statementText }} />
159
187
  ) : null}
@@ -8,8 +8,96 @@
8
8
  */
9
9
  import type { CSSProperties } from 'react';
10
10
 
11
- export const cellSelectedVar = 'var(--cfm-matrix-selected, var(--cfm-primary))';
12
- export const focusRingVar = 'var(--cfm-input-focus-ring, var(--cfm-primary))';
11
+ /** Terminal hex fallbacks match apps/client — visible before survey-theme.css loads. */
12
+ export const cellSelectedVar = 'var(--cfm-matrix-selected, var(--cfm-primary, #fbe8f3))';
13
+ export const focusRingVar = 'var(--cfm-input-focus-ring, var(--cfm-primary, #e20074))';
14
+ export const mcqSelectedAccentVar = focusRingVar;
15
+ export const mcqSelectedBgVar = 'var(--cfm-mcq-selected-bg, var(--cfm-secondary, #fdf2f8))';
16
+ export const matrixCarouselDotVar = 'var(--cfm-matrix-carousel-dot, #2563eb)';
17
+
18
+ const unselectedBorder = 'var(--cfm-card-border-color, #e5e5e5)';
19
+
20
+ function readMcqOptionStyle(): string {
21
+ if (typeof document === 'undefined') return 'outlined';
22
+ const root = document.documentElement;
23
+ return (
24
+ root.getAttribute('data-cfm-mcq-style') ||
25
+ getComputedStyle(root).getPropertyValue('--cfm-mcq-option-style').trim() ||
26
+ 'outlined'
27
+ );
28
+ }
29
+
30
+ /** MCQ option card — border, background, padding (wizard: --cfm-mcq-*). */
31
+ export function mcqOptionCardStyle(isSelected: boolean): CSSProperties {
32
+ const optionStyle = readMcqOptionStyle();
33
+ const base: CSSProperties = {
34
+ borderRadius: 'var(--cfm-mcq-border-radius, 8px)',
35
+ border: `2px solid ${isSelected ? focusRingVar : unselectedBorder}`,
36
+ background: isSelected ? mcqSelectedBgVar : 'var(--cfm-card-bg, #fff)',
37
+ padding: 'var(--cfm-mcq-card-padding, 16px 20px)',
38
+ marginBottom: 'var(--cfm-mcq-option-gap, 8px)',
39
+ };
40
+
41
+ if (optionStyle === 'filled') {
42
+ return {
43
+ ...base,
44
+ background: isSelected ? mcqSelectedBgVar : 'var(--cfm-secondary, #f9fafb)',
45
+ };
46
+ }
47
+
48
+ if (optionStyle === 'minimal') {
49
+ return {
50
+ ...base,
51
+ border: isSelected ? `2px solid ${focusRingVar}` : '2px solid transparent',
52
+ boxShadow: 'none',
53
+ background: isSelected ? mcqSelectedBgVar : 'transparent',
54
+ };
55
+ }
56
+
57
+ return base;
58
+ }
59
+
60
+ /** MCQ checkbox control indicator. */
61
+ export function mcqCheckboxControlStyle(isSelected: boolean): CSSProperties {
62
+ return {
63
+ display: 'flex',
64
+ height: '20px',
65
+ width: '20px',
66
+ flexShrink: 0,
67
+ alignItems: 'center',
68
+ justifyContent: 'center',
69
+ borderRadius: '4px',
70
+ border: `1px solid ${isSelected ? focusRingVar : '#9ca3af'}`,
71
+ backgroundColor: isSelected ? focusRingVar : '#fff',
72
+ transition: 'all 0.15s',
73
+ };
74
+ }
75
+
76
+ /** MCQ radio outer ring. */
77
+ export function mcqRadioOuterStyle(isSelected: boolean): CSSProperties {
78
+ return {
79
+ display: 'flex',
80
+ height: '20px',
81
+ width: '20px',
82
+ flexShrink: 0,
83
+ alignItems: 'center',
84
+ justifyContent: 'center',
85
+ borderRadius: '50%',
86
+ border: isSelected ? `2px solid ${focusRingVar}` : '1px solid #9ca3af',
87
+ backgroundColor: isSelected ? cellSelectedVar : '#fff',
88
+ transition: 'all 0.15s',
89
+ };
90
+ }
91
+
92
+ /** MCQ radio inner dot — focus ring color. */
93
+ export function mcqRadioInnerStyle(): CSSProperties {
94
+ return {
95
+ height: '10px',
96
+ width: '10px',
97
+ borderRadius: '50%',
98
+ backgroundColor: focusRingVar,
99
+ };
100
+ }
13
101
 
14
102
  const unselectedRing = '#d1d5db';
15
103
  const unselectedDot = '#d1d5db';
@@ -55,9 +55,9 @@ export function getEmojiForIndex(index: number, total: number): React.ReactNode
55
55
 
56
56
  export const CsatStarIcons: { filled: React.ReactNode; empty: React.ReactNode } = {
57
57
  filled: React.createElement(FaStar as React.ElementType, {
58
- style: { color: '#e20074', fontSize: '28px' },
58
+ style: { color: 'var(--cfm-csat-accent, var(--cfm-primary, #e20074))', fontSize: 'var(--cfm-csat-emoji-size, 28px)' },
59
59
  }),
60
60
  empty: React.createElement(FaRegStar as React.ElementType, {
61
- style: { color: '#d1d5db', fontSize: '28px', stroke: '#d1d5db', strokeWidth: '16px' },
61
+ style: { color: '#d1d5db', fontSize: 'var(--cfm-csat-emoji-size, 28px)', stroke: '#d1d5db', strokeWidth: '16px' },
62
62
  }),
63
63
  };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Portable scale utilities — copy to src/lib/surveyUiScaleUtils.ts or co-locate with components.
3
3
  */
4
- import type { NpsScalePoint, ScaleColumn } from '@explorer02/cfm-survey-sdk';
4
+ import type { AnswerValue, NpsScalePoint, ScaleColumn } from '@explorer02/cfm-survey-sdk';
5
5
 
6
6
  export const SCALE_COLORS: readonly string[] = [
7
7
  '#e2001a', '#e4251b', '#ec610a', '#f18b00', '#f7b200', '#fcd900',
@@ -13,23 +13,70 @@ export function getAccentColor(value: number): string {
13
13
  }
14
14
 
15
15
  export function tickColorFromIndex(index: number, total: number): string {
16
+ if (typeof document !== 'undefined') {
17
+ const resolved = resolveNumberLabelColor(index);
18
+ if (resolved) return resolved;
19
+ }
16
20
  if (total <= 1) return '#e20074';
17
21
  const hue = Math.round((index / (total - 1)) * 120);
18
22
  return `hsl(${hue}, 90%, 40%)`;
19
23
  }
20
24
 
25
+ /** Reads wizard number-label CSS vars for traffic / monochrome / individual modes. */
26
+ export function resolveNumberLabelColor(index: number, fallback = ''): string {
27
+ if (typeof document === 'undefined') return fallback || getAccentColor(index);
28
+
29
+ const root = document.documentElement;
30
+ const mode = getComputedStyle(root).getPropertyValue('--cfm-number-label-mode').trim();
31
+ if (mode === 'monochrome') {
32
+ return (
33
+ getComputedStyle(root).getPropertyValue('--cfm-number-mono-color').trim() ||
34
+ fallback ||
35
+ '#9CA3AF'
36
+ );
37
+ }
38
+ if (mode === 'individual') {
39
+ return (
40
+ getComputedStyle(root).getPropertyValue(`--cfm-number-color-${index}`).trim() ||
41
+ fallback ||
42
+ '#9CA3AF'
43
+ );
44
+ }
45
+ return fallback || getAccentColor(index);
46
+ }
47
+
21
48
  export function nonNaScaleColumns(columns: ScaleColumn[]): ScaleColumn[] {
22
49
  return columns.filter(col => col.id !== 'na');
23
50
  }
24
51
 
52
+ function stripHtml(label: string): string {
53
+ return label.replace(/<[^>]*>/g, '').trim();
54
+ }
55
+
25
56
  export function starValueFromOption(option: NpsScalePoint, index: number): number {
26
- const numeric = Number(option.optionLabel);
27
- return Number.isFinite(numeric) && option.optionLabel.trim() !== '' ? numeric : index;
57
+ const label = stripHtml(option.optionLabel);
58
+ const numeric = Number(label);
59
+ return Number.isFinite(numeric) && label !== '' ? numeric : index;
28
60
  }
29
61
 
30
62
  export function npsValueFromOption(option: NpsScalePoint, index: number): number {
31
- const numeric = Number(option.optionLabel);
32
- return Number.isFinite(numeric) && option.optionLabel.trim() !== '' ? numeric : index;
63
+ const label = stripHtml(option.optionLabel);
64
+ const numeric = Number(label);
65
+ return Number.isFinite(numeric) && label !== '' ? numeric : index;
66
+ }
67
+
68
+ /** Loose match for NPS/star stored answers (number) vs derived option values. */
69
+ export function isScaleAnswerSelected(
70
+ selectedValue: AnswerValue | undefined,
71
+ optionValue: number,
72
+ ): boolean {
73
+ if (selectedValue === undefined || selectedValue === null) return false;
74
+ if (typeof selectedValue === 'number') return selectedValue === optionValue;
75
+ if (typeof selectedValue === 'string') {
76
+ const parsed = Number(stripHtml(selectedValue));
77
+ return Number.isFinite(parsed) && parsed === optionValue;
78
+ }
79
+ return false;
33
80
  }
34
81
 
35
82
  /** Matches SDK matrixColumnStoredValue — scale-matrix answers store column ids. */
@@ -31,3 +31,59 @@ export function getLogoSrc(options?: { staticExport?: boolean }): string | null
31
31
  export function shouldShowCompanyName(): boolean {
32
32
  return surveyUiConfig.global?.header?.showCompanyName !== false;
33
33
  }
34
+
35
+ export function getSurveyTitle(): string {
36
+ return surveyUiConfig.global?.surveyTitle?.trim() ?? '';
37
+ }
38
+
39
+ export function getThankYouMessage(): string {
40
+ return surveyUiConfig.global?.thankYouMessage?.trim() ?? '';
41
+ }
42
+
43
+ export function getBrowserTabTitle(): string {
44
+ return surveyUiConfig.global?.browserTabTitle?.trim() ?? getSurveyTitle();
45
+ }
46
+
47
+ export function getButtonLabels(): { next: string; back: string; submit: string } {
48
+ const buttons = surveyUiConfig.global?.buttons;
49
+ return {
50
+ next: buttons?.next?.trim() || 'Next',
51
+ back: buttons?.back?.trim() || 'Back',
52
+ submit: buttons?.submit?.trim() || 'Submit',
53
+ };
54
+ }
55
+
56
+ export function getFooterCopyright(): string {
57
+ return surveyUiConfig.global?.footer?.copyrightText?.trim() ?? '';
58
+ }
59
+
60
+ export function getFooterLinks(): { label: string; url: string }[] {
61
+ const links = surveyUiConfig.global?.footer?.links;
62
+ return Array.isArray(links) ? links : [];
63
+ }
64
+
65
+ export function getLayoutFlags() {
66
+ const layout = surveyUiConfig.global?.layout ?? {};
67
+ const header = surveyUiConfig.global?.header ?? {};
68
+ const footer = surveyUiConfig.global?.footer ?? {};
69
+ return {
70
+ showHeader: layout.showHeader !== false,
71
+ showFooter: layout.showFooter !== false,
72
+ showProgressBar: layout.showProgressBar !== false,
73
+ showLanguageSwitcher: layout.showLanguageSwitcher === true,
74
+ showNextButton: layout.showNextButton !== false,
75
+ showBackButton: layout.showBackButton !== false,
76
+ showQuestionNumbers: layout.showQuestionNumbers !== false,
77
+ showRequiredAsterisk: layout.showRequiredAsterisk !== false,
78
+ showCompanyName: header.showCompanyName !== false,
79
+ showFooterLogo: footer.showLogo !== false,
80
+ };
81
+ }
82
+
83
+ export function getPlaceholders(): Record<string, string> {
84
+ return { ...(surveyUiConfig.placeholders ?? {}) };
85
+ }
86
+
87
+ export function isDebugEnabled(): boolean {
88
+ return surveyUiConfig.debug?.enabled === true;
89
+ }
@@ -17,6 +17,7 @@
17
17
  delete pending[k];
18
18
  }
19
19
  }
20
+ applyThemeSideEffects();
20
21
  }
21
22
 
22
23
  function queue(vars) {
@@ -33,6 +34,7 @@
33
34
  root.style.setProperty(k, vars[k]);
34
35
  }
35
36
  }
37
+ applyThemeSideEffects();
36
38
  }
37
39
 
38
40
  function setDisplay(selector, visible) {
@@ -41,6 +43,44 @@
41
43
  });
42
44
  }
43
45
 
46
+ var TRAFFIC = ['#ef4444', '#f97316', '#eab308', '#84cc16', '#22c55e', '#14b8a6', '#06b6d4', '#3b82f6', '#6366f1', '#8b5cf6', '#a855f7'];
47
+
48
+ function readVar(name, fallback) {
49
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
50
+ }
51
+
52
+ function applyNumberBadgeColors() {
53
+ var mode = readVar('--cfm-number-label-mode', 'traffic');
54
+ var mono = readVar('--cfm-number-mono-color', '#9ca3af');
55
+ document.querySelectorAll('[data-cfm-number-badge]').forEach(function (el) {
56
+ if (el.classList.contains('selected')) return;
57
+ var i = parseInt(el.getAttribute('data-cfm-number-badge'), 10);
58
+ if (isNaN(i)) return;
59
+ var bg = TRAFFIC[i] || '#9ca3af';
60
+ if (mode === 'monochrome') bg = mono;
61
+ if (mode === 'individual') bg = readVar('--cfm-number-color-' + i, '#9ca3af');
62
+ el.style.backgroundColor = bg;
63
+ });
64
+ }
65
+
66
+ function applyMultiStatement(show) {
67
+ document.querySelectorAll('[data-cfm-matrix-row-extra]').forEach(function (el) {
68
+ el.style.display = show ? '' : 'none';
69
+ });
70
+ }
71
+
72
+ function applyDropzoneStyle() {
73
+ var style = readVar('--cfm-upload-border-style', 'dashed');
74
+ document.querySelectorAll('[data-cfm-dropzone], .cfm-dropzone').forEach(function (el) {
75
+ el.style.borderStyle = style;
76
+ });
77
+ }
78
+
79
+ function applyThemeSideEffects() {
80
+ applyNumberBadgeColors();
81
+ applyDropzoneStyle();
82
+ }
83
+
44
84
  function applyContent(patch) {
45
85
  if (!patch) return;
46
86
  if (patch.logoUrl !== undefined) {
@@ -188,7 +228,11 @@
188
228
  el.textContent = patch.previewState.hintMaxText || '';
189
229
  });
190
230
  }
231
+ if (patch.previewState.multiStatement !== undefined) {
232
+ applyMultiStatement(!!patch.previewState.multiStatement);
233
+ }
191
234
  }
235
+ applyThemeSideEffects();
192
236
  }
193
237
 
194
238
  function handleMessage(event) {