@explorer02/cfm-survey-sdk 0.3.9 → 0.4.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 (31) hide show
  1. package/dist/cli/index.js +64 -64
  2. package/dist/cli/index.mjs +63 -63
  3. package/package.json +1 -1
  4. package/templates/docs/templates/CsatMatrixScale.tsx +105 -36
  5. package/templates/docs/templates/CustomSliderTrack.tsx +2 -2
  6. package/templates/docs/templates/Footer.tsx +3 -2
  7. package/templates/docs/templates/Header.tsx +14 -14
  8. package/templates/docs/templates/LikertMatrixScale.tsx +2 -2
  9. package/templates/docs/templates/Question.tsx +30 -23
  10. package/templates/docs/templates/RankOrderScale.tsx +10 -2
  11. package/templates/docs/templates/SliderMatrixScale.tsx +1 -1
  12. package/templates/docs/templates/labelStyles.ts +27 -0
  13. package/templates/docs/templates/uiConfig.ts +100 -0
  14. package/templates/preview-harness/preview-bridge.inline.js +55 -17
  15. package/templates/preview-harness/previewPages.js +2 -2
  16. package/templates/preview-harness/previewPages.ts +1 -1
  17. package/templates/preview-harness/vite-app/src/PreviewConfigContext.tsx +60 -7
  18. package/templates/preview-harness/vite-app/src/QuestionPreview.tsx +17 -96
  19. package/templates/preview-harness/vite-app/src/SurveyPagePreview.tsx +32 -8
  20. package/templates/preview-harness/vite-app/src/fixtures/questions.ts +53 -25
  21. package/templates/preview-harness/vite-app/src/globals.css +6 -0
  22. package/templates/preview-harness/vite-app/src/mergePreviewQuestion.ts +233 -0
  23. package/templates/preview-harness/vite-app/src/preview-live-overrides.css +30 -0
  24. package/templates/previewBridge.ts +61 -18
  25. package/templates/survey-theme.css +16 -0
  26. package/templates/wizard-dist/assets/{PreviewMock-CS4WqhDB.js → PreviewMock-MsONyaq9.js} +1 -1
  27. package/templates/wizard-dist/assets/TypePanel-D24BxbHk.js +1 -0
  28. package/templates/wizard-dist/assets/index-C7RSJr09.js +34 -0
  29. package/templates/wizard-dist/index.html +1 -1
  30. package/templates/wizard-dist/assets/TypePanel-BgTW2c74.js +0 -1
  31. package/templates/wizard-dist/assets/index-hjm-fxWO.js +0 -34
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@explorer02/cfm-survey-sdk",
3
- "version": "0.3.9",
3
+ "version": "0.4.1",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -16,14 +16,64 @@ import {
16
16
  mcqSelectedAccentVar,
17
17
  mcqSelectedBgVar,
18
18
  } from '@/lib/surveyUi/selectionStyles';
19
- import { csatColumnLabelStyle, ratingColumnLabelStyle } from '@/lib/surveyUi/labelStyles';
19
+ import { csatColumnLabelStyle, ratingColumnLabelStyle, stackAnchorLabelLines, ratingAnchorLabelPillStyle } from '@/lib/surveyUi/labelStyles';
20
20
 
21
21
  const MATRIX_ROW_LABEL_WIDTH = 'var(--cfm-matrix-row-width, 180px)';
22
22
 
23
23
  type CsatMatrixQuestion = CsatQuestion | RatingMatrixQuestion;
24
24
 
25
25
  function anchorLabelStyle(question: CsatMatrixQuestion) {
26
- return question.type === 'RATING_MATRIX' ? ratingColumnLabelStyle() : csatColumnLabelStyle();
26
+ return question.type === 'RATING_MATRIX' ? ratingAnchorLabelPillStyle() : csatColumnLabelStyle();
27
+ }
28
+
29
+ function anchorLabelPositionPercent(
30
+ index: number,
31
+ labelCount: number,
32
+ columnCount: number,
33
+ isGraphics: boolean,
34
+ question: CsatMatrixQuestion,
35
+ ): number {
36
+ if (labelCount === 1) return 50;
37
+ const useFullSpan = isGraphics || question.type === 'RATING_MATRIX';
38
+ const startPercent = useFullSpan ? 0 : 0.5 / columnCount;
39
+ const rangePercent = useFullSpan ? 1 : (columnCount - 1) / columnCount;
40
+ return (startPercent + (index / (labelCount - 1)) * rangePercent) * 100;
41
+ }
42
+
43
+ function anchorLabelRowMinHeight(question: CsatMatrixQuestion, hasLabels: boolean): string {
44
+ if (question.type === 'RATING_MATRIX' && hasLabels) return '44px';
45
+ return '24px';
46
+ }
47
+
48
+ function renderAnchorLabelContent(label: string, question: CsatMatrixQuestion): React.ReactNode {
49
+ if (question.type !== 'RATING_MATRIX') return label;
50
+ const lines = stackAnchorLabelLines(label);
51
+ if (lines.length === 1) return label;
52
+ return lines.map((line, idx) => (
53
+ <span key={idx} style={{ display: 'block' }}>
54
+ {line}
55
+ </span>
56
+ ));
57
+ }
58
+
59
+ function anchorLabelBoxStyle(question: CsatMatrixQuestion, isGraphics: boolean): React.CSSProperties {
60
+ if (question.type === 'RATING_MATRIX') {
61
+ return {
62
+ textAlign: 'center',
63
+ fontSize: '12px',
64
+ fontWeight: 600,
65
+ ...ratingAnchorLabelPillStyle(),
66
+ };
67
+ }
68
+ return {
69
+ textAlign: 'center',
70
+ fontSize: isGraphics ? '12px' : '13px',
71
+ fontWeight: 600,
72
+ lineHeight: 1.3,
73
+ whiteSpace: isGraphics ? 'normal' : 'nowrap',
74
+ maxWidth: isGraphics ? '72px' : undefined,
75
+ ...anchorLabelStyle(question),
76
+ };
27
77
  }
28
78
 
29
79
  function columnHeaderLabelStyle(question: CsatMatrixQuestion) {
@@ -36,6 +86,15 @@ function graphicsColumnLabelStyle(question: CsatMatrixQuestion) {
36
86
  return { fontSize: '12px', fontWeight: 500, lineHeight: 1.2, wordBreak: 'break-word' as const, ...pillStyle };
37
87
  }
38
88
 
89
+ /** Graphics uses anchor labels (bold top row) — per-column tick badges duplicate them for CSAT + RATING. */
90
+ function hasGraphicsAnchorLabels(labels: string[] | undefined): boolean {
91
+ return Boolean(labels?.some((label) => label.trim().length > 0));
92
+ }
93
+
94
+ function shouldShowGraphicsColumnLabelRow(isGraphics: boolean, labels: string[] | undefined): boolean {
95
+ return isGraphics && !hasGraphicsAnchorLabels(labels);
96
+ }
97
+
39
98
  function GraphicsColumnLabelsRow({
40
99
  scaleColumns,
41
100
  question,
@@ -47,8 +106,8 @@ function GraphicsColumnLabelsRow({
47
106
  const labelStyle = graphicsColumnLabelStyle(question);
48
107
 
49
108
  return (
50
- <div style={{ flex: 1, padding: '0 24px' }}>
51
- <div style={{ position: 'relative', margin: '0 16px', height: '16px' }}>
109
+ <div style={{ flex: 1, minWidth: 0, padding: '0 8px' }}>
110
+ <div style={{ position: 'relative', margin: '0 8px', height: '16px' }}>
52
111
  {scaleColumns.map((col, i) => {
53
112
  const percent = scaleColumns.length === 1 ? 0 : (i / (scaleColumns.length - 1)) * 100;
54
113
  return (
@@ -60,7 +119,7 @@ function GraphicsColumnLabelsRow({
60
119
  transform: 'translateX(-50%)',
61
120
  bottom: 0,
62
121
  textAlign: 'center',
63
- width: '64px',
122
+ maxWidth: '72px',
64
123
  }}
65
124
  >
66
125
  <span style={labelStyle} dangerouslySetInnerHTML={{ __html: col.label }} />
@@ -212,28 +271,31 @@ function CsatMatrixGrid({
212
271
  const n = labels?.length || 0;
213
272
 
214
273
  return (
215
- <div style={{ width: '100%' }}>
274
+ <div style={{ width: '100%', maxWidth: '100%', minWidth: 0, overflowX: 'auto', boxSizing: 'border-box' }}>
216
275
  {/* Labels row */}
217
276
  {showLabels && m > 0 && (
218
- <div style={{ display: 'flex', alignItems: 'flex-end', marginBottom: '12px' }}>
219
- <div style={{ width: MATRIX_ROW_LABEL_WIDTH, flexShrink: 0 }} />
220
- <div style={{ flex: 1, display: 'flex' }}>
221
- <div style={{ flex: 1, position: 'relative', height: '24px', ...(isGraphics ? { margin: '0 40px' } : {}) }}>
277
+ <div style={{ display: 'flex', alignItems: 'flex-end', marginBottom: '12px', minWidth: 0 }}>
278
+ <div style={{ width: MATRIX_ROW_LABEL_WIDTH, flexShrink: 0, minWidth: 0 }} />
279
+ <div style={{ flex: 1, display: 'flex', minWidth: 0 }}>
280
+ <div style={{
281
+ flex: 1,
282
+ position: 'relative',
283
+ minHeight: anchorLabelRowMinHeight(question, Boolean(showLabels)),
284
+ minWidth: 0,
285
+ ...(isGraphics || question.type === 'RATING_MATRIX' ? { margin: '0 8px' } : {}),
286
+ }}>
222
287
  {labels!.map((label, i) => {
223
- const startPercent = isGraphics ? 0 : 0.5 / m;
224
- const rangePercent = isGraphics ? 1 : (m - 1) / m;
225
- const percent = n === 1 ? 50 : (startPercent + (i / (n - 1)) * rangePercent) * 100;
288
+ const percent = anchorLabelPositionPercent(i, n, m, isGraphics, question);
226
289
 
227
290
  return (
228
291
  <div key={i} style={{
229
292
  position: 'absolute',
230
293
  left: `${percent}%`,
294
+ bottom: 0,
231
295
  transform: 'translateX(-50%)',
232
- textAlign: 'center', fontSize: '13px', fontWeight: 600,
233
- lineHeight: 1.3, whiteSpace: 'nowrap',
234
- ...anchorLabelStyle(question),
296
+ ...anchorLabelBoxStyle(question, isGraphics),
235
297
  }}>
236
- {label}
298
+ {renderAnchorLabelContent(label, question)}
237
299
  </div>
238
300
  );
239
301
  })}
@@ -243,9 +305,9 @@ function CsatMatrixGrid({
243
305
  </div>
244
306
  )}
245
307
 
246
- {/* Column tick labels for graphics sliders (shown with or without anchor labels) */}
247
- {isGraphics && m > 0 && (
248
- <div style={{ display: 'flex', alignItems: 'flex-end', marginBottom: showLabels ? '8px' : '16px', minHeight: '24px' }}>
308
+ {/* Column tick labels for graphics only when no anchor label row above */}
309
+ {shouldShowGraphicsColumnLabelRow(isGraphics, labels) && m > 0 && (
310
+ <div style={{ display: 'flex', alignItems: 'flex-end', marginBottom: '16px', minHeight: '24px' }}>
249
311
  <div style={{ width: MATRIX_ROW_LABEL_WIDTH, flexShrink: 0 }} />
250
312
  <div style={{ flex: 1, display: 'flex' }}>
251
313
  <GraphicsColumnLabelsRow scaleColumns={scaleColumns} question={question} />
@@ -285,15 +347,15 @@ function CsatMatrixGrid({
285
347
  {statementRows.map((row, rowIdx) => (
286
348
  <div key={row.id} style={{
287
349
  display: 'flex', alignItems: 'center', borderRadius: '8px',
288
- padding: '12px 0', ...matrixRowBackgroundStyle(rowIdx),
350
+ padding: '12px 0', minWidth: 0, ...matrixRowBackgroundStyle(rowIdx),
289
351
  }}>
290
- <div style={{ width: MATRIX_ROW_LABEL_WIDTH, flexShrink: 0, padding: '0 16px' }}>
352
+ <div style={{ width: MATRIX_ROW_LABEL_WIDTH, flexShrink: 0, minWidth: 0, padding: '0 12px' }}>
291
353
  <span style={{ fontSize: '14px', fontWeight: 500, color: '#111827', lineHeight: 1.4 }}
292
354
  dangerouslySetInnerHTML={{ __html: row.statementText }} />
293
355
  </div>
294
356
 
295
357
  {isGraphics ? (
296
- <div style={{ flex: 1, padding: '0 24px', position: 'relative', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
358
+ <div style={{ flex: 1, minWidth: 0, padding: '0 8px', position: 'relative', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
297
359
  <CustomSliderTrack
298
360
  min={0}
299
361
  max={scaleColumns.length - 1}
@@ -450,40 +512,47 @@ function CsatMatrixCarousel({
450
512
  const showLabels = labels && labels.length > 0;
451
513
 
452
514
  return (
453
- <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', backgroundColor: '#fff', borderRadius: '12px', border: '1px solid #e5e5e5', padding: '32px', boxShadow: '0 4px 6px -1px rgba(0,0,0,0.05)', width: '100%', boxSizing: 'border-box' }}>
515
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', backgroundColor: '#fff', borderRadius: '12px', border: '1px solid #e5e5e5', padding: '24px 16px', boxShadow: '0 4px 6px -1px rgba(0,0,0,0.05)', width: '100%', maxWidth: '100%', minWidth: 0, overflowX: 'auto', boxSizing: 'border-box' }}>
454
516
  <h3 style={{ fontSize: '18px', fontWeight: 500, color: '#111827', marginBottom: '32px', textAlign: 'center' }} dangerouslySetInnerHTML={{ __html: row.statementText }} />
455
517
 
456
518
  {showLabels && scaleColumns.length > 0 && (
457
- <div style={{ width: '100%', maxWidth: '800px', display: 'flex', position: 'relative', height: '24px', marginBottom: '16px' }}>
519
+ <div style={{
520
+ width: '100%',
521
+ maxWidth: '100%',
522
+ minWidth: 0,
523
+ position: 'relative',
524
+ minHeight: anchorLabelRowMinHeight(question, Boolean(showLabels)),
525
+ marginBottom: '16px',
526
+ ...(isGraphics || question.type === 'RATING_MATRIX' ? { padding: '0 8px' } : {}),
527
+ }}>
458
528
  {labels!.map((label, i) => {
459
529
  const m = scaleColumns.length;
460
530
  const n = labels!.length;
461
- const startPercent = isGraphics ? 0 : 0.5 / m;
462
- const rangePercent = isGraphics ? 1 : (m - 1) / m;
463
- const percent = n === 1 ? 50 : (startPercent + (i / (n - 1)) * rangePercent) * 100;
531
+ const percent = anchorLabelPositionPercent(i, n, m, isGraphics, question);
464
532
 
465
533
  return (
466
534
  <div key={i} style={{
467
- position: 'absolute', left: `${percent}%`, transform: 'translateX(-50%)',
468
- textAlign: 'center', fontSize: '13px', fontWeight: 600,
469
- lineHeight: 1.3, whiteSpace: 'nowrap',
470
- ...anchorLabelStyle(question),
535
+ position: 'absolute',
536
+ left: `${percent}%`,
537
+ bottom: 0,
538
+ transform: 'translateX(-50%)',
539
+ ...anchorLabelBoxStyle(question, isGraphics),
471
540
  }}>
472
- {label}
541
+ {renderAnchorLabelContent(label, question)}
473
542
  </div>
474
543
  );
475
544
  })}
476
545
  </div>
477
546
  )}
478
547
 
479
- {isGraphics && scaleColumns.length > 0 && (
480
- <div style={{ width: '100%', maxWidth: '800px', marginBottom: showLabels ? '8px' : '16px' }}>
548
+ {shouldShowGraphicsColumnLabelRow(isGraphics, labels) && scaleColumns.length > 0 && (
549
+ <div style={{ width: '100%', maxWidth: '100%', marginBottom: '16px' }}>
481
550
  <GraphicsColumnLabelsRow scaleColumns={scaleColumns} question={question} />
482
551
  </div>
483
552
  )}
484
553
 
485
554
  {isGraphics ? (
486
- <div style={{ width: '100%', maxWidth: '800px', marginBottom: '40px', padding: '0 24px' }}>
555
+ <div style={{ width: '100%', maxWidth: '100%', minWidth: 0, marginBottom: '40px', padding: '0 8px' }}>
487
556
  <CustomSliderTrack
488
557
  min={0} max={scaleColumns.length - 1} htmlStep={1}
489
558
  value={
@@ -56,7 +56,7 @@ export function CustomSliderTrack({
56
56
 
57
57
  return (
58
58
  <div
59
- style={{ position: 'relative', height: '32px', display: 'flex', alignItems: 'center', margin: '0 16px', opacity: disabled ? 0.5 : 1 }}
59
+ style={{ position: 'relative', height: '32px', display: 'flex', alignItems: 'center', margin: '0 8px', opacity: disabled ? 0.5 : 1 }}
60
60
  onMouseEnter={() => !disabled && setIsHovered(true)}
61
61
  onMouseLeave={() => { setIsHovered(false); setIsDragging(false); }}
62
62
  >
@@ -94,7 +94,7 @@ export function CustomSliderTrack({
94
94
  onTouchStart={() => !disabled && setIsDragging(true)}
95
95
  onTouchEnd={() => setIsDragging(false)}
96
96
  style={{
97
- position: 'absolute', top: '50%', left: '-16px', right: '-16px', width: 'calc(100% + 32px)', height: '100%',
97
+ position: 'absolute', top: '50%', left: '-8px', right: '-8px', width: 'calc(100% + 16px)', height: '100%',
98
98
  transform: 'translateY(-50%)', opacity: 0, cursor: disabled ? 'not-allowed' : 'pointer', zIndex: 10, margin: 0,
99
99
  }}
100
100
  />
@@ -39,10 +39,11 @@ export default function Footer() {
39
39
  data-cfm-logo
40
40
  src={seedLogoSrc!}
41
41
  alt=""
42
- className="object-contain"
42
+ className="object-contain object-left"
43
43
  style={{
44
- width: 'var(--cfm-footer-logo-width, 120px)',
45
44
  height: 'var(--cfm-footer-logo-height, 32px)',
45
+ maxWidth: 'var(--cfm-footer-logo-width, 120px)',
46
+ width: 'auto',
46
47
  }}
47
48
  />
48
49
  </div>
@@ -76,20 +76,20 @@ export default function Header({ embedded = false }: HeaderProps) {
76
76
  {seedCompany}
77
77
  </span>
78
78
 
79
- {seedCompany ? (
80
- <span
81
- data-cfm-company
82
- className="font-semibold"
83
- style={{
84
- display: hasLogoFile && seedShowCompany ? undefined : 'none',
85
- color: 'var(--cfm-header-company-color, var(--cfm-text))',
86
- fontSize: 'var(--cfm-header-company-size, 18px)',
87
- fontWeight: 'var(--cfm-header-company-weight, 600)',
88
- }}
89
- >
90
- {seedCompany}
91
- </span>
92
- ) : null}
79
+ {/* Always in DOM for wizard bridge — visibility toggled via layoutFlags.showCompanyName */}
80
+ <span
81
+ data-cfm-company
82
+ className="font-semibold"
83
+ style={{
84
+ display:
85
+ seedCompany && hasLogoFile && seedShowCompany ? undefined : 'none',
86
+ color: 'var(--cfm-header-company-color, var(--cfm-text))',
87
+ fontSize: 'var(--cfm-header-company-size, 18px)',
88
+ fontWeight: 'var(--cfm-header-company-weight, 600)',
89
+ }}
90
+ >
91
+ {seedCompany}
92
+ </span>
93
93
  </div>
94
94
  </div>
95
95
  </header>
@@ -248,7 +248,7 @@ function LikertMatrixGridLayout({
248
248
  };
249
249
 
250
250
  return (
251
- <div style={{ width: '100%' }}>
251
+ <div style={{ width: '100%', maxWidth: '100%', minWidth: 0, overflowX: 'auto', boxSizing: 'border-box' }}>
252
252
  {!repeatColumnHeaders && renderHeader()}
253
253
 
254
254
  <div style={{ display: 'flex', flexDirection: 'column', gap: repeatColumnHeaders ? '24px' : '0' }}>
@@ -326,7 +326,7 @@ function LikertMatrixGridLayout({
326
326
  </div>
327
327
 
328
328
  {isBipolar && (
329
- <div style={{ flex: '0 0 25%', padding: '0 16px', textAlign: 'right', boxSizing: 'border-box' }}>
329
+ <div style={{ flex: `0 0 ${BIPOLAR_COL_WIDTH}`, padding: '0 16px', textAlign: 'right', boxSizing: 'border-box' }}>
330
330
  <span style={{ fontSize: '14px', fontWeight: 500, color: '#111827', lineHeight: 1.4, wordWrap: 'break-word', display: 'block' }}
331
331
  dangerouslySetInnerHTML={{ __html: transposeTable ? '' : ('oppositeStatementText' in rowItem ? rowItem.oppositeStatementText ?? '' : '') }} />
332
332
  </div>
@@ -19,7 +19,7 @@ import { FileUploadScale } from './FileUploadScale';
19
19
  import { HeatmapScale } from './HeatmapScale';
20
20
  import { RankOrderScale } from './RankOrderScale';
21
21
  import RatingScale from './RatingScale';
22
- import { getLayoutFlags } from '@/lib/uiConfig';
22
+ import { showQuestionNumbersInChrome, showRequiredAsteriskInChrome, resolveNpsButtonStyle, resolveNpsHintMinLabel, resolveNpsHintMaxLabel } from '@/lib/uiConfig';
23
23
  import { hintLabelStyle } from '@/lib/surveyUi/labelStyles';
24
24
  import {
25
25
  mcqCheckboxControlStyle,
@@ -48,7 +48,8 @@ export default function Question({
48
48
  customFieldValues = {},
49
49
  onSelect,
50
50
  }: QuestionProps) {
51
- const { showQuestionNumbers, showRequiredAsterisk } = getLayoutFlags();
51
+ const showNumbers = showQuestionNumbersInChrome();
52
+ const showAsterisk = showRequiredAsteriskInChrome();
52
53
 
53
54
  const questionsById = useMemo(
54
55
  () => new Map(allQuestions.map(q => [q.id, q])),
@@ -125,31 +126,35 @@ export default function Question({
125
126
  borderRadius: 'var(--cfm-border-radius, 12px)',
126
127
  fontFamily: 'var(--cfm-font-family)',
127
128
  marginBottom: 'var(--cfm-card-gap, 32px)',
129
+ maxWidth: '100%',
130
+ overflowX: 'auto',
131
+ boxSizing: 'border-box',
128
132
  }}
129
133
  >
130
134
  <h2
131
135
  className="text-lg font-medium leading-relaxed"
132
136
  style={{ color: 'var(--cfm-text, #111827)' }}
133
137
  >
134
- {question.questionNumber != null && (
135
- <span
136
- data-cfm-question-number
137
- className="mr-1 font-semibold"
138
- style={{ display: showQuestionNumbers ? undefined : 'none' }}
139
- >
140
- {question.questionNumber}.
141
- </span>
142
- )}
138
+ <span
139
+ data-cfm-question-number
140
+ className="mr-1 font-semibold"
141
+ style={{
142
+ display:
143
+ question.questionNumber != null && showNumbers ? undefined : 'none',
144
+ }}
145
+ >
146
+ {question.questionNumber != null ? `${question.questionNumber}.` : ''}
147
+ </span>
143
148
  <span dangerouslySetInnerHTML={{ __html: displayQuestionText }} />
144
- {question.required && (
145
- <span
146
- data-cfm-required
147
- className="ml-1 text-red-500"
148
- style={{ display: showRequiredAsterisk ? undefined : 'none' }}
149
- >
150
- *
151
- </span>
152
- )}
149
+ <span
150
+ data-cfm-required
151
+ className="ml-1 text-red-500"
152
+ style={{
153
+ display: question.required && showAsterisk ? undefined : 'none',
154
+ }}
155
+ >
156
+ *
157
+ </span>
153
158
  </h2>
154
159
  {displayQuestionDescription && (
155
160
  <div
@@ -165,7 +170,8 @@ export default function Question({
165
170
  <span
166
171
  className="absolute left-0 top-0 max-w-[30%] text-left leading-tight"
167
172
  style={{ ...hintLabelStyle(), padding: '2px 6px', borderRadius: '4px' }}
168
- dangerouslySetInnerHTML={{ __html: question.minLabel ?? '' }}
173
+ data-cfm-hint-min
174
+ dangerouslySetInnerHTML={{ __html: resolveNpsHintMinLabel(question.minLabel ?? '') }}
169
175
  />
170
176
  {question.midLabel && (
171
177
  <span
@@ -184,7 +190,8 @@ export default function Question({
184
190
  <span
185
191
  className="absolute right-0 top-0 max-w-[30%] text-right leading-tight"
186
192
  style={{ ...hintLabelStyle(), padding: '2px 6px', borderRadius: '4px' }}
187
- dangerouslySetInnerHTML={{ __html: question.maxLabel ?? '' }}
193
+ data-cfm-hint-max
194
+ dangerouslySetInnerHTML={{ __html: resolveNpsHintMaxLabel(question.maxLabel ?? '') }}
188
195
  />
189
196
  </div>
190
197
  )}
@@ -194,7 +201,7 @@ export default function Question({
194
201
  selectedValue={selectedValue}
195
202
  onSelect={onSelect}
196
203
  variant="nps"
197
- buttonStyle={question.buttonStyle}
204
+ buttonStyle={resolveNpsButtonStyle(question.buttonStyle)}
198
205
  />
199
206
  </div>
200
207
  )}
@@ -182,8 +182,16 @@ function DropdownRankLayout({
182
182
  aria-label={`Rank for ${option.optionLabel || option.id}`}
183
183
  value={currentRank ?? ''}
184
184
  onChange={event => handleRankChange(option.id, event.target.value)}
185
- className="h-10 w-16 shrink-0 rounded border bg-white px-2 text-sm outline-none"
186
- style={{ borderColor: 'var(--cfm-rank-select-border, #d1d5db)' }}
185
+ className="h-10 w-16 shrink-0 rounded border px-2 text-sm font-semibold outline-none"
186
+ style={{
187
+ borderColor: 'var(--cfm-rank-select-border, #d1d5db)',
188
+ backgroundColor: currentRank
189
+ ? 'var(--cfm-rank-badge-bg, var(--cfm-mcq-selected-bg, #fdf2f8))'
190
+ : '#ffffff',
191
+ color: currentRank
192
+ ? 'var(--cfm-rank-badge-text, var(--cfm-mcq-selected-accent, var(--cfm-primary, #e20074)))'
193
+ : '#374151',
194
+ }}
187
195
  onFocus={e => {
188
196
  e.currentTarget.style.borderColor = focusRingVar;
189
197
  e.currentTarget.style.boxShadow = `0 0 0 1px ${focusRingVar}`;
@@ -168,7 +168,7 @@ export function SliderMatrixScale({ question, selectedValue = {}, onSelect }: Sl
168
168
  };
169
169
 
170
170
  return (
171
- <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
171
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', width: '100%', maxWidth: '100%', minWidth: 0, overflowX: 'auto', boxSizing: 'border-box' }}>
172
172
  {renderHeaderRow()}
173
173
 
174
174
  {statementRows.map(row => {
@@ -64,3 +64,30 @@ export function ratingColumnLabelStyle(): CSSProperties {
64
64
  export function sliderTickLabelStyle(): CSSProperties {
65
65
  return columnLabelPillStyle('sliderTick');
66
66
  }
67
+
68
+ /** Split multi-word anchor labels into stacked lines for equal-width matrix headers. */
69
+ export function stackAnchorLabelLines(label: string): string[] {
70
+ const trimmed = label.trim();
71
+ if (!trimmed) return [''];
72
+ const words = trimmed.split(/\s+/);
73
+ if (words.length <= 1) return [trimmed];
74
+ const mid = Math.ceil(words.length / 2);
75
+ return [words.slice(0, mid).join(' '), words.slice(mid).join(' ')].filter(Boolean);
76
+ }
77
+
78
+ /** Rating matrix anchor pills — stacked text, fixed width, same look on every format. */
79
+ export function ratingAnchorLabelPillStyle(): CSSProperties {
80
+ return {
81
+ ...columnLabelPillStyle('rating'),
82
+ display: 'inline-flex',
83
+ flexDirection: 'column',
84
+ alignItems: 'center',
85
+ justifyContent: 'center',
86
+ whiteSpace: 'normal',
87
+ maxWidth: '72px',
88
+ minWidth: '52px',
89
+ padding: '3px 6px',
90
+ wordBreak: 'break-word',
91
+ lineHeight: 1.15,
92
+ };
93
+ }
@@ -82,6 +82,29 @@ export function getLayoutFlags() {
82
82
  };
83
83
  }
84
84
 
85
+ function readLiveChromeFlag(cssVar: string, configFallback: boolean): boolean {
86
+ if (typeof window === 'undefined') return configFallback;
87
+ const root = document.documentElement;
88
+ const live =
89
+ root.style.getPropertyValue(cssVar).trim() ||
90
+ getComputedStyle(root).getPropertyValue(cssVar).trim();
91
+ if (live === '0') return false;
92
+ if (live === '1') return true;
93
+ return configFallback;
94
+ }
95
+
96
+ /** Wizard live preview reads --cfm-show-question-numbers; production uses survey-ui-config.json. */
97
+ export function showQuestionNumbersInChrome(): boolean {
98
+ const flags = getLayoutFlags();
99
+ return readLiveChromeFlag('--cfm-show-question-numbers', flags.showQuestionNumbers);
100
+ }
101
+
102
+ /** Wizard live preview reads --cfm-show-required-asterisk; production uses survey-ui-config.json. */
103
+ export function showRequiredAsteriskInChrome(): boolean {
104
+ const flags = getLayoutFlags();
105
+ return readLiveChromeFlag('--cfm-show-required-asterisk', flags.showRequiredAsterisk);
106
+ }
107
+
85
108
  export function getPlaceholders(): Record<string, string> {
86
109
  return { ...(surveyUiConfig.placeholders ?? {}) };
87
110
  }
@@ -89,3 +112,80 @@ export function getPlaceholders(): Record<string, string> {
89
112
  export function isDebugEnabled(): boolean {
90
113
  return surveyUiConfig.debug?.enabled === true;
91
114
  }
115
+
116
+ export type NpsButtonStyle = 'standard' | 'numbered' | 'emoji' | 'pill';
117
+
118
+ function readLiveNpsButtonStyle(): NpsButtonStyle | null {
119
+ if (typeof window === 'undefined') return null;
120
+ const root = document.documentElement;
121
+ const attr = root.getAttribute('data-cfm-nps-button-style');
122
+ if (attr === 'standard' || attr === 'numbered' || attr === 'emoji' || attr === 'pill') {
123
+ return attr;
124
+ }
125
+ const live =
126
+ root.style.getPropertyValue('--cfm-nps-button-style').trim() ||
127
+ getComputedStyle(root).getPropertyValue('--cfm-nps-button-style').trim();
128
+ if (live === 'standard' || live === 'numbered' || live === 'emoji' || live === 'pill') {
129
+ return live;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ /** Wizard bridge + survey-ui-config.json override API `buttonStyle` for NPS cells. */
135
+ export function resolveNpsButtonStyle(questionStyle?: string): NpsButtonStyle {
136
+ const live = readLiveNpsButtonStyle();
137
+ if (live) return live;
138
+
139
+ if (
140
+ questionStyle === 'standard' ||
141
+ questionStyle === 'numbered' ||
142
+ questionStyle === 'emoji' ||
143
+ questionStyle === 'pill'
144
+ ) {
145
+ return questionStyle;
146
+ }
147
+
148
+ const fromJson = (
149
+ surveyUiConfig.questionTypes as { NPS_SCALE?: { buttonStyle?: string } } | undefined
150
+ )?.NPS_SCALE?.buttonStyle;
151
+ if (
152
+ fromJson === 'standard' ||
153
+ fromJson === 'numbered' ||
154
+ fromJson === 'emoji' ||
155
+ fromJson === 'pill'
156
+ ) {
157
+ return fromJson;
158
+ }
159
+ return 'numbered';
160
+ }
161
+
162
+ function readLiveNpsHintText(kind: 'min' | 'max'): string | null {
163
+ if (typeof window === 'undefined') return null;
164
+ const root = document.documentElement;
165
+ const cssVar = kind === 'min' ? '--cfm-hint-min-text' : '--cfm-hint-max-text';
166
+ const fromCss =
167
+ root.style.getPropertyValue(cssVar).trim() ||
168
+ getComputedStyle(root).getPropertyValue(cssVar).trim();
169
+ return fromCss || null;
170
+ }
171
+
172
+ function resolveNpsHintText(kind: 'min' | 'max', questionFallback: string): string {
173
+ const fromCss = readLiveNpsHintText(kind);
174
+ if (fromCss) return fromCss;
175
+ if (questionFallback.trim()) return questionFallback;
176
+
177
+ const nps = (
178
+ surveyUiConfig.questionTypes as { NPS_SCALE?: { hintMinText?: string; hintMaxText?: string } } | undefined
179
+ )?.NPS_SCALE;
180
+ const fromJson = kind === 'min' ? nps?.hintMinText : nps?.hintMaxText;
181
+ const trimmed = fromJson?.trim();
182
+ return trimmed || questionFallback;
183
+ }
184
+
185
+ export function resolveNpsHintMinLabel(questionFallback = ''): string {
186
+ return resolveNpsHintText('min', questionFallback);
187
+ }
188
+
189
+ export function resolveNpsHintMaxLabel(questionFallback = ''): string {
190
+ return resolveNpsHintText('max', questionFallback);
191
+ }