@explorer02/cfm-survey-sdk 0.4.0 → 0.4.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 (27) hide show
  1. package/dist/cli/index.js +69 -65
  2. package/dist/cli/index.mjs +69 -65
  3. package/package.json +1 -1
  4. package/templates/docs/templates/CsatMatrixScale.tsx +102 -29
  5. package/templates/docs/templates/Footer.tsx +3 -2
  6. package/templates/docs/templates/RatingScale.tsx +4 -20
  7. package/templates/docs/templates/labelStyles.ts +44 -0
  8. package/templates/docs/templates/surveyUiIcons.tsx +6 -1
  9. package/templates/docs/templates/uiConfig.ts +23 -8
  10. package/templates/preview-harness/preview-bridge.inline.js +12 -0
  11. package/templates/preview-harness/preview-google-fonts.html +6 -0
  12. package/templates/preview-harness/previewPages.js +3 -2
  13. package/templates/preview-harness/vite-app/src/PreviewConfigContext.tsx +82 -18
  14. package/templates/preview-harness/vite-app/src/QuestionPreview.tsx +1 -1
  15. package/templates/preview-harness/vite-app/src/SurveyPagePreview.tsx +3 -3
  16. package/templates/preview-harness/vite-app/src/fixtures/questions.ts +6 -9
  17. package/templates/preview-harness/vite-app/src/globals.css +4 -3
  18. package/templates/preview-harness/vite-app/src/mergePreviewQuestion.ts +67 -7
  19. package/templates/preview-harness/vite-app/src/preview-live-overrides.css +9 -0
  20. package/templates/previewBridge.ts +13 -0
  21. package/templates/survey-theme.css +13 -0
  22. package/templates/wizard-dist/assets/{PreviewMock-BlOAvUCN.js → PreviewMock-B2SDZ408.js} +1 -1
  23. package/templates/wizard-dist/assets/TypePanel-COi_YFKb.js +1 -0
  24. package/templates/wizard-dist/assets/index-BbqXvrx7.js +34 -0
  25. package/templates/wizard-dist/index.html +1 -1
  26. package/templates/wizard-dist/assets/TypePanel-DcbMaw1b.js +0 -1
  27. package/templates/wizard-dist/assets/index-2Bi_7Fdh.js +0 -34
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@explorer02/cfm-survey-sdk",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -1,7 +1,7 @@
1
1
  import React, { useState } from 'react';
2
2
  import type { CsatQuestion, RatingMatrixQuestion, MatrixRowAnswers, ScaleColumn } from '@explorer02/cfm-survey-sdk';
3
3
  import { getEmojiForIndex, CsatStarIcons } from './surveyUiIcons';
4
- import { columnSubmitValue, nonNaScaleColumns } from './surveyUiScaleUtils';
4
+ import { columnSubmitValue, nonNaScaleColumns, resolveNumberLabelColor } from './surveyUiScaleUtils';
5
5
  import { CustomSliderTrack } from './CustomSliderTrack';
6
6
  import MatrixDropdown from './MatrixDropdown';
7
7
  import {
@@ -16,14 +16,78 @@ 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, csatAnchorLabelPillStyle } 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 =
38
+ isGraphics || question.type === 'RATING_MATRIX' || question.type === 'CSAT_MATRIX';
39
+ const startPercent = useFullSpan ? 0 : 0.5 / columnCount;
40
+ const rangePercent = useFullSpan ? 1 : (columnCount - 1) / columnCount;
41
+ return (startPercent + (index / (labelCount - 1)) * rangePercent) * 100;
42
+ }
43
+
44
+ function anchorLabelRowMinHeight(question: CsatMatrixQuestion, hasLabels: boolean): string {
45
+ if (
46
+ hasLabels &&
47
+ (question.type === 'RATING_MATRIX' || question.type === 'CSAT_MATRIX')
48
+ ) {
49
+ return '44px';
50
+ }
51
+ return '24px';
52
+ }
53
+
54
+ function renderAnchorLabelContent(label: string, question: CsatMatrixQuestion): React.ReactNode {
55
+ if (question.type !== 'RATING_MATRIX' && question.type !== 'CSAT_MATRIX') return label;
56
+ const lines = stackAnchorLabelLines(label);
57
+ if (lines.length === 1) return label;
58
+ return lines.map((line, idx) => (
59
+ <span key={idx} style={{ display: 'block' }}>
60
+ {line}
61
+ </span>
62
+ ));
63
+ }
64
+
65
+ function anchorLabelBoxStyle(question: CsatMatrixQuestion, isGraphics: boolean): React.CSSProperties {
66
+ if (question.type === 'RATING_MATRIX') {
67
+ return {
68
+ textAlign: 'center',
69
+ fontSize: '12px',
70
+ fontWeight: 600,
71
+ ...ratingAnchorLabelPillStyle(),
72
+ };
73
+ }
74
+ if (question.type === 'CSAT_MATRIX') {
75
+ return {
76
+ textAlign: 'center',
77
+ fontSize: '12px',
78
+ fontWeight: 600,
79
+ ...csatAnchorLabelPillStyle(),
80
+ };
81
+ }
82
+ return {
83
+ textAlign: 'center',
84
+ fontSize: isGraphics ? '12px' : '13px',
85
+ fontWeight: 600,
86
+ lineHeight: 1.3,
87
+ whiteSpace: isGraphics ? 'normal' : 'nowrap',
88
+ maxWidth: isGraphics ? '72px' : undefined,
89
+ ...anchorLabelStyle(question),
90
+ };
27
91
  }
28
92
 
29
93
  function columnHeaderLabelStyle(question: CsatMatrixQuestion) {
@@ -227,24 +291,27 @@ function CsatMatrixGrid({
227
291
  <div style={{ display: 'flex', alignItems: 'flex-end', marginBottom: '12px', minWidth: 0 }}>
228
292
  <div style={{ width: MATRIX_ROW_LABEL_WIDTH, flexShrink: 0, minWidth: 0 }} />
229
293
  <div style={{ flex: 1, display: 'flex', minWidth: 0 }}>
230
- <div style={{ flex: 1, position: 'relative', minHeight: '24px', minWidth: 0, ...(isGraphics ? { margin: '0 8px' } : {}) }}>
294
+ <div style={{
295
+ flex: 1,
296
+ position: 'relative',
297
+ minHeight: anchorLabelRowMinHeight(question, Boolean(showLabels)),
298
+ minWidth: 0,
299
+ ...(isGraphics || question.type === 'RATING_MATRIX' || question.type === 'CSAT_MATRIX'
300
+ ? { margin: '0 8px' }
301
+ : {}),
302
+ }}>
231
303
  {labels!.map((label, i) => {
232
- const startPercent = isGraphics ? 0 : 0.5 / m;
233
- const rangePercent = isGraphics ? 1 : (m - 1) / m;
234
- const percent = n === 1 ? 50 : (startPercent + (i / (n - 1)) * rangePercent) * 100;
304
+ const percent = anchorLabelPositionPercent(i, n, m, isGraphics, question);
235
305
 
236
306
  return (
237
307
  <div key={i} style={{
238
308
  position: 'absolute',
239
309
  left: `${percent}%`,
310
+ bottom: 0,
240
311
  transform: 'translateX(-50%)',
241
- textAlign: 'center', fontSize: isGraphics ? '12px' : '13px', fontWeight: 600,
242
- lineHeight: 1.3,
243
- whiteSpace: isGraphics ? 'normal' : 'nowrap',
244
- maxWidth: isGraphics ? '72px' : undefined,
245
- ...anchorLabelStyle(question),
312
+ ...anchorLabelBoxStyle(question, isGraphics),
246
313
  }}>
247
- {label}
314
+ {renderAnchorLabelContent(label, question)}
248
315
  </div>
249
316
  );
250
317
  })}
@@ -367,7 +434,6 @@ function CsatMatrixGrid({
367
434
  } : isStar ? {
368
435
  padding: '4px',
369
436
  transform: isHovered ? 'scale(1.1)' : 'scale(1)',
370
- color: isSelected ? cellSelectedVar : focusRingVar,
371
437
  ...(isSelected ? {} : unselectedOpacityStyle()),
372
438
  } : isNumbered ? {
373
439
  width: '40px', height: '40px', borderRadius: '8px',
@@ -376,8 +442,8 @@ function CsatMatrixGrid({
376
442
  ? scaleCellSelectedStyle(true)
377
443
  : {
378
444
  border: '1px solid #d1d5db',
379
- backgroundColor: '#fff',
380
- color: '#111827',
445
+ backgroundColor: resolveNumberLabelColor(displayIdx, '#fff'),
446
+ color: '#fff',
381
447
  ...unselectedOpacityStyle(),
382
448
  }),
383
449
  transition: 'all 0.15s',
@@ -465,24 +531,31 @@ function CsatMatrixCarousel({
465
531
  <h3 style={{ fontSize: '18px', fontWeight: 500, color: '#111827', marginBottom: '32px', textAlign: 'center' }} dangerouslySetInnerHTML={{ __html: row.statementText }} />
466
532
 
467
533
  {showLabels && scaleColumns.length > 0 && (
468
- <div style={{ width: '100%', maxWidth: '100%', minWidth: 0, position: 'relative', height: '24px', marginBottom: '16px' }}>
534
+ <div style={{
535
+ width: '100%',
536
+ maxWidth: '100%',
537
+ minWidth: 0,
538
+ position: 'relative',
539
+ minHeight: anchorLabelRowMinHeight(question, Boolean(showLabels)),
540
+ marginBottom: '16px',
541
+ ...(isGraphics || question.type === 'RATING_MATRIX' || question.type === 'CSAT_MATRIX'
542
+ ? { padding: '0 8px' }
543
+ : {}),
544
+ }}>
469
545
  {labels!.map((label, i) => {
470
546
  const m = scaleColumns.length;
471
547
  const n = labels!.length;
472
- const startPercent = isGraphics ? 0 : 0.5 / m;
473
- const rangePercent = isGraphics ? 1 : (m - 1) / m;
474
- const percent = n === 1 ? 50 : (startPercent + (i / (n - 1)) * rangePercent) * 100;
548
+ const percent = anchorLabelPositionPercent(i, n, m, isGraphics, question);
475
549
 
476
550
  return (
477
551
  <div key={i} style={{
478
- position: 'absolute', left: `${percent}%`, transform: 'translateX(-50%)',
479
- textAlign: 'center', fontSize: isGraphics ? '12px' : '13px', fontWeight: 600,
480
- lineHeight: 1.3,
481
- whiteSpace: isGraphics ? 'normal' : 'nowrap',
482
- maxWidth: isGraphics ? '72px' : undefined,
483
- ...anchorLabelStyle(question),
552
+ position: 'absolute',
553
+ left: `${percent}%`,
554
+ bottom: 0,
555
+ transform: 'translateX(-50%)',
556
+ ...anchorLabelBoxStyle(question, isGraphics),
484
557
  }}>
485
- {label}
558
+ {renderAnchorLabelContent(label, question)}
486
559
  </div>
487
560
  );
488
561
  })}
@@ -551,8 +624,8 @@ function CsatMatrixCarousel({
551
624
  ? scaleCellSelectedStyle(true)
552
625
  : {
553
626
  border: '1px solid #d1d5db',
554
- backgroundColor: '#fff',
555
- color: '#111827',
627
+ backgroundColor: resolveNumberLabelColor(colIdx, '#fff'),
628
+ color: '#fff',
556
629
  ...unselectedOpacityStyle(),
557
630
  }),
558
631
  } : { padding: '8px', ...(!isSelected ? unselectedOpacityStyle() : {}) }),
@@ -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>
@@ -10,6 +10,7 @@ import {
10
10
  getAccentColor,
11
11
  isScaleAnswerSelected,
12
12
  npsValueFromOption,
13
+ resolveNumberLabelColor,
13
14
  starValueFromOption,
14
15
  } from './surveyUiScaleUtils';
15
16
  import { getEmojiForIndex } from './surveyUiIcons';
@@ -23,25 +24,6 @@ type RatingScaleProps = {
23
24
  buttonStyle?: 'standard' | 'numbered' | 'emoji' | 'pill';
24
25
  };
25
26
 
26
- function resolveBadgeColor(index: number, value: number): string {
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();
31
- if (mode === 'monochrome') {
32
- return (
33
- getComputedStyle(root).getPropertyValue('--cfm-number-mono-color').trim() || getAccentColor(value)
34
- );
35
- }
36
- if (mode === 'individual') {
37
- return (
38
- getComputedStyle(root).getPropertyValue(`--cfm-number-color-${index}`).trim() ||
39
- getAccentColor(value)
40
- );
41
- }
42
- return getAccentColor(value);
43
- }
44
-
45
27
  export default function RatingScale({
46
28
  questionId,
47
29
  options,
@@ -57,7 +39,9 @@ export default function RatingScale({
57
39
  : npsValueFromOption(option as NpsScalePoint, index);
58
40
  const numericValue = typeof value === 'number' ? value : index;
59
41
  const color =
60
- variant === 'nps' ? resolveBadgeColor(index, numericValue) : 'var(--cfm-primary, #e20074)';
42
+ variant === 'nps'
43
+ ? resolveNumberLabelColor(index, getAccentColor(numericValue))
44
+ : 'var(--cfm-primary, #e20074)';
61
45
  return { id: option.id, label: option.optionLabel, value, color };
62
46
  });
63
47
 
@@ -64,3 +64,47 @@ 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
+ /** CSAT matrix anchor pills — stacked text, fixed width, same look on every format. */
79
+ export function csatAnchorLabelPillStyle(): CSSProperties {
80
+ return {
81
+ ...columnLabelPillStyle('csat'),
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
+ }
94
+
95
+ /** Rating matrix anchor pills — stacked text, fixed width, same look on every format. */
96
+ export function ratingAnchorLabelPillStyle(): CSSProperties {
97
+ return {
98
+ ...columnLabelPillStyle('rating'),
99
+ display: 'inline-flex',
100
+ flexDirection: 'column',
101
+ alignItems: 'center',
102
+ justifyContent: 'center',
103
+ whiteSpace: 'normal',
104
+ maxWidth: '72px',
105
+ minWidth: '52px',
106
+ padding: '3px 6px',
107
+ wordBreak: 'break-word',
108
+ lineHeight: 1.15,
109
+ };
110
+ }
@@ -58,6 +58,11 @@ export const CsatStarIcons: { filled: React.ReactNode; empty: React.ReactNode }
58
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: 'var(--cfm-csat-emoji-size, 28px)', stroke: '#d1d5db', strokeWidth: '16px' },
61
+ style: {
62
+ color: 'var(--cfm-star-color, var(--cfm-csat-accent, var(--cfm-primary, #e20074)))',
63
+ fontSize: 'var(--cfm-csat-emoji-size, 28px)',
64
+ stroke: 'var(--cfm-star-color, var(--cfm-csat-accent, var(--cfm-primary, #e20074)))',
65
+ strokeWidth: '16px',
66
+ },
62
67
  }),
63
68
  };
@@ -136,6 +136,15 @@ export function resolveNpsButtonStyle(questionStyle?: string): NpsButtonStyle {
136
136
  const live = readLiveNpsButtonStyle();
137
137
  if (live) return live;
138
138
 
139
+ if (
140
+ questionStyle === 'standard' ||
141
+ questionStyle === 'numbered' ||
142
+ questionStyle === 'emoji' ||
143
+ questionStyle === 'pill'
144
+ ) {
145
+ return questionStyle;
146
+ }
147
+
139
148
  const fromJson = (
140
149
  surveyUiConfig.questionTypes as { NPS_SCALE?: { buttonStyle?: string } } | undefined
141
150
  )?.NPS_SCALE?.buttonStyle;
@@ -147,18 +156,24 @@ export function resolveNpsButtonStyle(questionStyle?: string): NpsButtonStyle {
147
156
  ) {
148
157
  return fromJson;
149
158
  }
150
- if (
151
- questionStyle === 'standard' ||
152
- questionStyle === 'numbered' ||
153
- questionStyle === 'emoji' ||
154
- questionStyle === 'pill'
155
- ) {
156
- return questionStyle;
157
- }
158
159
  return 'numbered';
159
160
  }
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
+
161
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
+
162
177
  const nps = (
163
178
  surveyUiConfig.questionTypes as { NPS_SCALE?: { hintMinText?: string; hintMaxText?: string } } | undefined
164
179
  )?.NPS_SCALE;
@@ -2,10 +2,21 @@
2
2
  var SNAPSHOT = 'CFM_UI_CONFIG_SNAPSHOT';
3
3
  var DELTA = 'CFM_UI_CONFIG_DELTA';
4
4
  var LEGACY = 'CFM_UI_CONFIG';
5
+ var PREVIEW_FONTS_HREF =
6
+ 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Roboto:wght@400;500;600;700&family=Open+Sans:wght@400;500;600;700&family=Lato:wght@400;500;600;700&family=Poppins:wght@400;500;600;700&family=Montserrat:wght@400;500;600;700&family=Nunito:wght@400;500;600;700&family=Raleway:wght@400;500;600;700&family=Source+Sans+3:wght@400;500;600;700&family=Merriweather:wght@400;500;600;700&family=Playfair+Display:wght@400;500;600;700&display=swap';
5
7
  var rafId = null;
6
8
  var pending = {};
7
9
  var liveEdit = false;
8
10
 
11
+ function ensurePreviewFonts() {
12
+ if (document.getElementById('cfm-preview-fonts')) return;
13
+ var link = document.createElement('link');
14
+ link.id = 'cfm-preview-fonts';
15
+ link.rel = 'stylesheet';
16
+ link.href = PREVIEW_FONTS_HREF;
17
+ document.head.appendChild(link);
18
+ }
19
+
9
20
  function flush() {
10
21
  rafId = null;
11
22
  var root = document.documentElement;
@@ -282,6 +293,7 @@ function setDisplay(selector, visible) {
282
293
 
283
294
  if (!liveEdit) {
284
295
  liveEdit = true;
296
+ ensurePreviewFonts();
285
297
  document.documentElement.classList.add('cfm-live-edit');
286
298
  }
287
299
 
@@ -0,0 +1,6 @@
1
+ <link id="cfm-preview-fonts" rel="preconnect" href="https://fonts.googleapis.com" />
2
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
3
+ <link
4
+ rel="stylesheet"
5
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Roboto:wght@400;500;600;700&family=Open+Sans:wght@400;500;600;700&family=Lato:wght@400;500;600;700&family=Poppins:wght@400;500;600;700&family=Montserrat:wght@400;500;600;700&family=Nunito:wght@400;500;600;700&family=Raleway:wght@400;500;600;700&family=Source+Sans+3:wght@400;500;600;700&family=Merriweather:wght@400;500;600;700&family=Playfair+Display:wght@400;500;600;700&display=swap"
6
+ />
@@ -122,14 +122,15 @@ const PREVIEW_PAGE_DEFS = [
122
122
  { key: 'RANK_dropdown', title: 'Rank Dropdown', body: `<div class="cfm-rank-item"><span>Item A</span><select class="cfm-input cfm-rank-select" style="width:80px;margin-left:auto"><option>1</option><option>2</option></select></div>` },
123
123
  ];
124
124
 
125
- function buildPreviewHtml(def, inlineCss, inlineJs) {
125
+ function buildPreviewHtml(def, inlineCss, inlineJs, fontsSnippet) {
126
+ const fonts = fontsSnippet ? `${fontsSnippet}\n` : '';
126
127
  return `<!DOCTYPE html>
127
128
  <html lang="en">
128
129
  <head>
129
130
  <meta charset="UTF-8" />
130
131
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
131
132
  <title>${def.title} — CFM Preview</title>
132
- <style>${inlineCss}\n${sharedStyles}</style>
133
+ ${fonts}<style>${inlineCss}\n${sharedStyles}</style>
133
134
  </head>
134
135
  <body>
135
136
  <div class="cfm-preview-root">${def.body}</div>
@@ -15,27 +15,38 @@ export type PreviewStatePatch = {
15
15
 
16
16
  export const PREVIEW_BRIDGE_SNAPSHOT = 'CFM_UI_CONFIG_SNAPSHOT';
17
17
  export const PREVIEW_BRIDGE_DELTA = 'CFM_UI_CONFIG_DELTA';
18
+ export const PREVIEW_BRIDGE_READY = 'CFM_PREVIEW_READY';
18
19
 
19
- const TRAFFIC = [
20
- '#ef4444', '#f97316', '#eab308', '#84cc16', '#22c55e', '#14b8a6',
21
- '#06b6d4', '#3b82f6', '#6366f1', '#8b5cf6', '#a855f7',
22
- ];
20
+ const SCALE_COLORS = [
21
+ '#e2001a', '#e4251b', '#ec610a', '#f18b00', '#f7b200', '#fcd900',
22
+ '#d2dc00', '#9fca00', '#6bb300', '#339a00', '#008000',
23
+ ] as const;
24
+
25
+ function trafficColor(index: number): string {
26
+ return SCALE_COLORS[index] ?? '#9ca3af';
27
+ }
23
28
 
24
29
  function readVar(name: string, fallback: string): string {
25
30
  return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
26
31
  }
27
32
 
28
- function applyNumberBadgeColors(): void {
33
+ function resolveLiveNumberLabelColor(index: number): string {
29
34
  const mode = readVar('--cfm-number-label-mode', 'traffic');
30
- const mono = readVar('--cfm-number-mono-color', '#9ca3af');
35
+ if (mode === 'monochrome') {
36
+ return readVar('--cfm-number-mono-color', '#9ca3af');
37
+ }
38
+ if (mode === 'individual') {
39
+ return readVar(`--cfm-number-color-${index}`, '#9ca3af');
40
+ }
41
+ return trafficColor(index);
42
+ }
43
+
44
+ function applyNumberBadgeColors(): void {
31
45
  document.querySelectorAll('[data-cfm-number-badge]').forEach((el) => {
32
46
  if (el.classList.contains('selected')) return;
33
47
  const i = parseInt(el.getAttribute('data-cfm-number-badge') ?? '', 10);
34
48
  if (Number.isNaN(i)) return;
35
- let bg = TRAFFIC[i] ?? '#9ca3af';
36
- if (mode === 'monochrome') bg = mono;
37
- if (mode === 'individual') bg = readVar(`--cfm-number-color-${i}`, '#9ca3af');
38
- (el as HTMLElement).style.backgroundColor = bg;
49
+ (el as HTMLElement).style.backgroundColor = resolveLiveNumberLabelColor(i);
39
50
  });
40
51
  }
41
52
 
@@ -51,6 +62,26 @@ function applyThemeSideEffects(): void {
51
62
  applyDropzoneStyle();
52
63
  }
53
64
 
65
+ function previewStateFromCssVars(
66
+ cssVars: Record<string, string>,
67
+ prev: PreviewStatePatch,
68
+ ): PreviewStatePatch {
69
+ const next: PreviewStatePatch = { ...prev };
70
+ if (cssVars['--cfm-matrix-multi-statement'] !== undefined) {
71
+ next.multiStatement = cssVars['--cfm-matrix-multi-statement'] === '1';
72
+ }
73
+ if (cssVars['--cfm-nps-button-style']) {
74
+ next.npsButtonStyle = cssVars['--cfm-nps-button-style'];
75
+ }
76
+ if (cssVars['--cfm-hint-min-text']) {
77
+ next.hintMinText = cssVars['--cfm-hint-min-text'];
78
+ }
79
+ if (cssVars['--cfm-hint-max-text']) {
80
+ next.hintMaxText = cssVars['--cfm-hint-max-text'];
81
+ }
82
+ return next;
83
+ }
84
+
54
85
  type PreviewConfigContextValue = {
55
86
  previewState: PreviewStatePatch;
56
87
  configRevision: number;
@@ -61,22 +92,60 @@ const PreviewConfigContext = createContext<PreviewConfigContextValue>({
61
92
  configRevision: 0,
62
93
  });
63
94
 
95
+ function enableLiveEditMode(): void {
96
+ document.documentElement.classList.add('cfm-live-edit');
97
+ }
98
+
64
99
  export function PreviewConfigProvider({ children }: { children: ReactNode }) {
65
100
  const [previewState, setPreviewState] = useState<PreviewStatePatch>({});
66
101
  const [configRevision, setConfigRevision] = useState(0);
67
102
 
68
103
  useEffect(() => {
104
+ enableLiveEditMode();
105
+
106
+ const requestSnapshot = () => {
107
+ try {
108
+ window.parent.postMessage({ type: PREVIEW_BRIDGE_READY }, '*');
109
+ } catch {
110
+ /* ignore */
111
+ }
112
+ };
113
+
114
+ requestSnapshot();
115
+
69
116
  const handler = (event: MessageEvent) => {
70
117
  const data = event.data;
71
118
  if (!data?.type) return;
72
119
  if (data.type !== PREVIEW_BRIDGE_SNAPSHOT && data.type !== PREVIEW_BRIDGE_DELTA) return;
73
120
 
74
- let shouldBumpRevision = false;
121
+ enableLiveEditMode();
122
+
123
+ const isSnapshot = data.type === PREVIEW_BRIDGE_SNAPSHOT;
124
+ let shouldBumpRevision = isSnapshot;
125
+
126
+ if (data.cssVars) {
127
+ const root = document.documentElement;
128
+ for (const [key, value] of Object.entries(data.cssVars)) {
129
+ root.style.setProperty(key, value as string);
130
+ }
131
+ shouldBumpRevision = true;
132
+ }
75
133
 
76
- if (data.contentPatch?.previewState) {
134
+ if (isSnapshot) {
135
+ const patchState = (data.contentPatch?.previewState ?? {}) as PreviewStatePatch;
136
+ const nextState = data.cssVars
137
+ ? previewStateFromCssVars(data.cssVars, patchState)
138
+ : patchState;
139
+ setPreviewState(nextState);
140
+ shouldBumpRevision = true;
141
+ } else if (data.contentPatch?.previewState) {
77
142
  setPreviewState((prev) => ({ ...prev, ...data.contentPatch.previewState }));
78
143
  shouldBumpRevision = true;
144
+ } else if (data.cssVars) {
145
+ setPreviewState((prev) => previewStateFromCssVars(data.cssVars, prev));
146
+ shouldBumpRevision = true;
79
147
  }
148
+
80
149
  if (data.contentPatch?.domAttributes) {
81
150
  const root = document.documentElement;
82
151
  for (const [key, value] of Object.entries(data.contentPatch.domAttributes)) {
@@ -84,12 +153,7 @@ export function PreviewConfigProvider({ children }: { children: ReactNode }) {
84
153
  }
85
154
  shouldBumpRevision = true;
86
155
  }
87
- if (data.cssVars) {
88
- const root = document.documentElement;
89
- for (const [key, value] of Object.entries(data.cssVars)) {
90
- root.style.setProperty(key, value as string);
91
- }
92
- }
156
+
93
157
  if (shouldBumpRevision) {
94
158
  setConfigRevision((r) => r + 1);
95
159
  }
@@ -34,7 +34,7 @@ function QuestionPreviewInner({ question, initialValue }: QuestionPreviewProps)
34
34
 
35
35
  const mergedQuestion = useMemo(
36
36
  () => mergePreviewQuestion(question, previewState),
37
- [question, previewState],
37
+ [question, previewState, configRevision],
38
38
  );
39
39
 
40
40
  const heatmapInitial =
@@ -22,15 +22,15 @@ function SurveyPagePreviewInner() {
22
22
 
23
23
  const mcqQuestion = useMemo(
24
24
  () => mergeSurveyPageQuestion(FIXTURE_SURVEY_PAGE_MCQ, previewState),
25
- [previewState],
25
+ [previewState, configRevision],
26
26
  );
27
27
  const matrixQuestion = useMemo(
28
28
  () => mergeSurveyPageQuestion(FIXTURE_SURVEY_PAGE_CFM_LIKERT, previewState),
29
- [previewState],
29
+ [previewState, configRevision],
30
30
  );
31
31
  const npsQuestion = useMemo(
32
32
  () => mergeSurveyPageQuestion(FIXTURE_SURVEY_PAGE_NPS, previewState),
33
- [previewState],
33
+ [previewState, configRevision],
34
34
  );
35
35
 
36
36
  const allQuestions = [mcqQuestion, matrixQuestion, npsQuestion];