@explorer02/cfm-survey-sdk 0.3.0 → 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@explorer02/cfm-survey-sdk",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.mjs",
6
6
  "types": "dist/index.d.ts",
@@ -81,6 +81,7 @@ Exports used by portable scale templates:
81
81
  | `tickColorFromIndex(index, total)` | SLIDER_MATRIX header tick badge colors |
82
82
  | `matrixColumnStoredValue(col)` | Matrix answer storage — column `id` or `null` for N/A |
83
83
  | `npsValueFromOption` / `starValueFromOption` | NPS badge numeric values |
84
+ | `isScaleAnswerSelected` | NPS/star `checked` state — loose number compare |
84
85
  | `nonNaScaleColumns` | Filter N/A column from scale columns |
85
86
 
86
87
  Prefer SDK `matrixColumnStoredValue` when importing from package; template re-exports the same contract for co-located components.
@@ -22,7 +22,8 @@ Pipeline: [`wizard-preview-build-guide.md`](wizard-preview-build-guide.md) · [`
22
22
  | Scale selection colors not updating | Import `selectionStyles.ts`; use `var(--cfm-matrix-selected)` / `var(--cfm-input-focus-ring)` |
23
23
  | Company name not showing after logo | Copy `Header.tsx` template — brand row is `[logo][data-cfm-company]` in one flex container |
24
24
  | Company shows when empty | Bridge clears `[data-cfm-company]` when `companyName` is `""` — do not hardcode "Company Name" as visible default |
25
- | NPS colors / rings not updating | Copy `RatingScale.tsx` + `selectionStyles.ts` no hardcoded `#e20074` |
25
+ | NPS clicks do nothing / no visible selection before wizard | Copy latest `RatingScale.tsx` + `selectionStyles.ts` + `surveyUiScaleUtils.ts`; use `isScaleAnswerSelected()`; radio track row must use `label` + `onChange` → `onSelect(number)` — see `03-ui-specs/01-rating.md` |
26
+ | NPS colors / rings not updating | Copy `RatingScale.tsx` + `selectionStyles.ts` — hex fallbacks in `selectionStyles.ts` for pre-theme builds |
26
27
  | Text placeholder color not updating | `data-cfm-textfield` + `--cfm-input-placeholder` in CSS / `::placeholder` rule |
27
28
  | Matrix 2nd row toggle | Harness adds row via `previewMultiStatement` — `LikertMatrixScale` must render all `statementRows` from props |
28
29
  | Blank page after deploy | **Never** reference `UI_CONFIG` without import — use `src/lib/uiConfig.ts` |
@@ -50,6 +50,23 @@ If components use **hardcoded** colors (`bg-[#e20074]`) with no `var(--cfm-*)` h
50
50
 
51
51
  **Default for production wizard:** agent components. Use templates-only for SDK smoke tests, not client delivery.
52
52
 
53
+ ### Wizard static preview vs production survey
54
+
55
+ During `npx cfm-sdk customize`, the right panel loads **SDK static HTML** from `preview-harness/previewPages.js` — not your React tree. Layout matches the original survey-page mock; **customization** is driven by `--cfm-*` CSS vars + `preview-bridge.inline.js`.
56
+
57
+ | Wizard control | Static preview (iframe) | Production survey (after Phase 6c) |
58
+ |----------------|-------------------------|-------------------------------------|
59
+ | NPS hints + number labels | Hint text + badge colors via bridge | Copy `RatingScale.tsx` + `Question.tsx` hint row |
60
+ | Matrix multi-row toggle | Shows/hides `[data-cfm-matrix-row-extra]` | `LikertMatrixScale` maps all `statementRows` |
61
+ | Matrix row width + label items | `--cfm-matrix-row-width`, column label vars | `LikertMatrixScale` + `labelStyles.ts` |
62
+ | CSAT/Rating star/numbered/radio | Theme focus ring + cell selected on mock cells | `CsatMatrixScale.tsx` + `selectionStyles.ts` |
63
+ | Slider track + tick labels | `--cfm-slider-*` on static slider mock | `SliderMatrixScale` + `CustomSliderTrack` |
64
+ | File upload dropzone | `--cfm-upload-*` on `.cfm-dropzone` | `FileUploadScale.tsx` |
65
+ | Rank / heatmap | `--cfm-rank-*` / `--cfm-heatmap-pin-*` | `RankOrderScale.tsx` / `HeatmapScale.tsx` |
66
+ | Logo size slider | `--cfm-header-logo-width/height` + `max-height` in header | `Header.tsx` via `uiConfig.ts` |
67
+
68
+ **Rule:** If a wizard control updates the static preview but not localhost dev, the agent has not copied the matching template from `docs/templates/` — see [`wizard-chrome-contract.md`](wizard-chrome-contract.md).
69
+
53
70
  ---
54
71
 
55
72
  ## Required project artifacts (Phase 5)
@@ -21,9 +21,14 @@ Render a horizontal sequence of numbered badges with traffic-light colors (NPS 0
21
21
  ## UI/UX Rules (CRITICAL)
22
22
 
23
23
  1. **Shape**: Square badges with rounded corners — not full circles
24
- 2. **Unselected State**: Dim unselected when any item selected (`opacity-30`)
25
- 3. **Selected State**: `border-2 border-[#e20074] shadow-md scale-105`
26
- 4. **Tooltips**: `title={option.label}` on every badge
24
+ 2. **Interactive row**: Only the **radio track row** accepts clicks (`label` + hidden `input type="radio"`)
25
+ 3. **Selection compare**: Use `isScaleAnswerSelected(selectedValue, optionValue)` store/compare **numbers**, not option ids
26
+ 4. **Unselected State**: Gray ring (`#d1d5db`) + white fill on track row
27
+ 5. **Selected State**: Focus ring `#e20074` (or `--cfm-input-focus-ring`) + annulus `#fbe8f3` (or `--cfm-matrix-selected`) + filled dot
28
+ 6. **Tooltips**: `title={option.label}` on every clickable cell
29
+ 7. **`buttonStyle: 'standard'`**: Radio track only — omit numbered badge row
30
+
31
+ Reference implementation: [`apps/client/src/components/RatingScale.tsx`](../../../../apps/client/src/components/RatingScale.tsx)
27
32
 
28
33
  ## SDK Integration Checklist (agent)
29
34
 
@@ -39,6 +39,8 @@ function npsValueFromOption(option: NpsScalePoint, index: number): number {
39
39
 
40
40
  NPS questions use `question.type === 'NPS_SCALE'`. Store a **number** in `SurveyAnswers`. Badge colors: traffic-light table keyed by numeric value (0–10).
41
41
 
42
+ Compare selection with `isScaleAnswerSelected(selectedValue, npsValueFromOption(option, index))` — do not compare to `option.id`.
43
+
42
44
  ## MCQ
43
45
 
44
46
  - Selected answer: **`option.id`** (string), not a separate `optionValue` field
@@ -31,11 +31,22 @@ When `reverseScaleOrder === true`, map visual index `i` to stored index `options
31
31
  ## Answer Mutation
32
32
 
33
33
  ```typescript
34
+ import { isScaleAnswerSelected, npsValueFromOption } from '@/lib/surveyUiScaleUtils';
35
+
34
36
  const value = npsValueFromOption(option, index);
35
- onSelect(value) // number
37
+ onSelect(value); // number — never option.id
36
38
  // → onAction CHANGE { questionId, answerValue: number }
39
+
40
+ // Compare stored answer to option value (handles string/number drift):
41
+ const isSelected = isScaleAnswerSelected(selectedValue, value);
37
42
  ```
38
43
 
44
+ **Selection row (required):** Only the **radio track row** is interactive — use `<label>` + hidden `<input type="radio">` + `onChange={() => onSelect(option.value)}`. Mirror [`apps/client/src/components/RatingScale.tsx`](../../../../apps/client/src/components/RatingScale.tsx) (reference monorepo).
45
+
46
+ **Pre-customization defaults:** Copy `selectionStyles.ts` with `#e20074` / `#fbe8f3` hex fallbacks so selected rings are visible before `survey-theme.css` loads. Import `survey-theme.css` in `globals.css` for full wizard tokens.
47
+
48
+ **`buttonStyle`:** `standard` = radio track only (no numbered badge row). `numbered` = badge row + track. `emoji` = emoji cells.
49
+
39
50
  See [`02-reference/value-derivation.md`](../02-reference/value-derivation.md).
40
51
 
41
52
  ## Agent Checklist
@@ -59,6 +70,9 @@ See [`02-reference/value-derivation.md`](../02-reference/value-derivation.md).
59
70
  ### Wizard checklist
60
71
 
61
72
  - [ ] `selectionStyles.ts` imported in `RatingScale.tsx`
73
+ - [ ] `isScaleAnswerSelected()` used for `checked` state — not raw `===` on `selectedValue`
74
+ - [ ] Radio track: `label` + `sr-only` radio + `onChange` → `onSelect(number)` (see client `RatingScale.tsx`)
62
75
  - [ ] Selected radio: outer border + dot = focus ring; annulus = cell selected
63
76
  - [ ] Selected numbered badge: border = focus ring, fill = cell selected, text white
64
77
  - [ ] Unselected badges keep traffic-light / per-number `--cfm-number-*` colors
78
+ - [ ] `buttonStyle: 'standard'` hides badge row; track row remains clickable
@@ -56,6 +56,7 @@ export default function Header({ embedded = false }: HeaderProps) {
56
56
  style={{
57
57
  width: 'var(--cfm-header-logo-width, 120px)',
58
58
  height: 'var(--cfm-header-logo-height, 120px)',
59
+ maxHeight: 'calc(var(--cfm-header-height, 120px) - 8px)',
59
60
  padding: 'var(--cfm-header-logo-padding, 10px)',
60
61
  background: 'transparent',
61
62
  }}
@@ -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 = {
@@ -19,18 +24,18 @@ type RatingScaleProps = {
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,33 @@ 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';
65
71
 
66
72
  if (buttonStyle === 'emoji') {
67
73
  return (
68
74
  <div role="radiogroup" className="w-full overflow-x-auto" data-cfm-nps-area>
69
75
  <div className="min-w-0" style={gridStyle}>
70
76
  {resolvedOptions.map((option, index) => {
71
- const isSelected = selectedValue === option.value;
77
+ const isSelected = isScaleAnswerSelected(selectedValue, option.value);
72
78
  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" />
79
+ <label
80
+ key={`${questionId}-${option.value}`}
81
+ title={option.label}
82
+ className="flex cursor-pointer justify-center"
83
+ >
84
+ <input
85
+ type="radio"
86
+ name={questionId}
87
+ checked={isSelected}
88
+ onChange={() => onSelect(option.value)}
89
+ className="sr-only"
90
+ />
75
91
  <div
76
92
  style={{
77
- ...scaleRadioRingStyle(isSelected, { pill: false, size: 'var(--cfm-csat-emoji-size, 48px)' }),
93
+ ...scaleRadioRingStyle(isSelected, {
94
+ pill: false,
95
+ size: 'var(--cfm-csat-emoji-size, 48px)',
96
+ }),
78
97
  fontSize: 'var(--cfm-csat-emoji-size, 24px)',
79
98
  }}
80
99
  >
@@ -90,28 +109,30 @@ export default function RatingScale({
90
109
 
91
110
  return (
92
111
  <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}
112
+ {showNumberBadges && (
113
+ <div className="mb-3 min-w-0" style={gridStyle}>
114
+ {resolvedOptions.map((option) => {
115
+ const isSelected = isScaleAnswerSelected(selectedValue, option.value);
116
+ return (
117
+ <div key={`${questionId}-badge-${option.value}`} className="flex justify-center">
118
+ <div
119
+ className="flex items-center justify-center rounded-[3px] text-[10px] font-bold text-white shadow-sm sm:text-xs"
120
+ style={{
121
+ width: 'var(--cfm-nps-cell-size, 24px)',
122
+ height: 'var(--cfm-nps-cell-size, 24px)',
123
+ ...(isSelected
124
+ ? scaleCellSelectedStyle(true)
125
+ : { backgroundColor: option.color }),
126
+ }}
127
+ title={option.label}
128
+ >
129
+ {option.label}
130
+ </div>
110
131
  </div>
111
- </div>
112
- );
113
- })}
114
- </div>
132
+ );
133
+ })}
134
+ </div>
135
+ )}
115
136
 
116
137
  <div
117
138
  className="relative w-full rounded-md px-1 py-2"
@@ -119,11 +140,26 @@ export default function RatingScale({
119
140
  >
120
141
  <div className="min-w-0" style={gridStyle}>
121
142
  {resolvedOptions.map((option) => {
122
- const isSelected = selectedValue === option.value;
143
+ const isSelected = isScaleAnswerSelected(selectedValue, option.value);
123
144
  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)' })}>
145
+ <label
146
+ key={`${questionId}-${option.value}`}
147
+ title={option.label}
148
+ className="flex cursor-pointer justify-center"
149
+ >
150
+ <input
151
+ type="radio"
152
+ name={questionId}
153
+ checked={isSelected}
154
+ onChange={() => onSelect(option.value)}
155
+ className="sr-only"
156
+ />
157
+ <div
158
+ className="flex items-center justify-center rounded-full border-2 transition-all"
159
+ style={scaleRadioRingStyle(isSelected, {
160
+ size: 'var(--cfm-nps-cell-size, 40px)',
161
+ })}
162
+ >
127
163
  <div style={scaleRadioDotStyle(isSelected)} />
128
164
  </div>
129
165
  </label>
@@ -8,8 +8,9 @@
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 RatingScale — 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))';
13
14
 
14
15
  const unselectedRing = '#d1d5db';
15
16
  const unselectedDot = '#d1d5db';
@@ -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',
@@ -32,6 +32,20 @@ export function npsValueFromOption(option: NpsScalePoint, index: number): number
32
32
  return Number.isFinite(numeric) && option.optionLabel.trim() !== '' ? numeric : index;
33
33
  }
34
34
 
35
+ /** Loose match for NPS/star stored answers (number) vs derived option values. */
36
+ export function isScaleAnswerSelected(
37
+ selectedValue: AnswerValue | undefined,
38
+ optionValue: number,
39
+ ): boolean {
40
+ if (selectedValue === undefined || selectedValue === null) return false;
41
+ if (typeof selectedValue === 'number') return selectedValue === optionValue;
42
+ if (typeof selectedValue === 'string') {
43
+ const parsed = Number(selectedValue);
44
+ return Number.isFinite(parsed) && parsed === optionValue;
45
+ }
46
+ return false;
47
+ }
48
+
35
49
  /** Matches SDK matrixColumnStoredValue — scale-matrix answers store column ids. */
36
50
  export function matrixColumnStoredValue(column: { id: string; label: string }): string | null {
37
51
  return column.id === 'na' ? null : column.id;
@@ -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) {
@@ -1,8 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ /** Static wizard preview pages — layout matches previewPages.ts; tokens via --cfm-* + preview-bridge. */
3
4
  const sharedStyles = `
4
5
  .cfm-header { height:var(--cfm-header-height); background:var(--cfm-header-bg); border-bottom:1px solid var(--cfm-header-border); display:flex; align-items:center; padding:0 var(--cfm-header-padding-x); box-shadow:0 2px 8px rgba(0,0,0,0.06); }
5
- .cfm-header-logo { width:var(--cfm-header-logo-width); height:var(--cfm-header-logo-height); object-fit:contain; padding:var(--cfm-header-logo-padding); background:transparent; }
6
+ .cfm-header-logo { width:var(--cfm-header-logo-width); height:var(--cfm-header-logo-height); max-height:calc(var(--cfm-header-height, 120px) - 8px); object-fit:contain; padding:var(--cfm-header-logo-padding); background:transparent; }
6
7
  .cfm-header-company { font-size:var(--cfm-header-company-size); font-weight:var(--cfm-header-company-weight); color:var(--cfm-header-company-color); margin-left:16px; }
7
8
  .cfm-footer { background:var(--cfm-footer-bg); color:var(--cfm-footer-text); padding:var(--cfm-footer-padding-y) var(--cfm-footer-padding-x); display:flex; flex-wrap:wrap; gap:16px; justify-content:space-between; align-items:flex-end; }
8
9
  .cfm-footer-links { display:flex; gap:24px; flex-wrap:wrap; }
@@ -11,90 +12,117 @@ const sharedStyles = `
11
12
  .cfm-progress-fill { height:100%; width:45%; background:var(--cfm-progress-fill); border-radius:999px; }
12
13
  .cfm-mcq-option { border:2px solid #e5e7eb; border-radius:var(--cfm-mcq-border-radius); padding:var(--cfm-mcq-card-padding); margin-bottom:var(--cfm-mcq-option-gap); display:flex; align-items:center; gap:8px; }
13
14
  .cfm-mcq-option.selected { border-color:var(--cfm-mcq-selected-accent); background:var(--cfm-mcq-selected-bg); }
14
- .cfm-nps-row { display:flex; gap:var(--cfm-nps-cell-gap); flex-wrap:wrap; }
15
- .cfm-nps-cell { width:var(--cfm-nps-cell-size); height:var(--cfm-nps-cell-size); border-radius:var(--cfm-border-radius); background:var(--cfm-nps-track-bar,#f2f2f2); display:flex; align-items:center; justify-content:center; font-size:14px; box-sizing:border-box; }
16
- .cfm-nps-cell.selected { border:2px solid var(--cfm-input-focus-ring); background:var(--cfm-matrix-selected); color:#fff; }
17
15
  .cfm-hint-row { display:flex; justify-content:space-between; font-size:12px; font-weight:600; margin-bottom:8px; }
18
16
  .cfm-hint-min, .cfm-hint-max { color:var(--cfm-hint-label-color,#6b7280); background:var(--cfm-hint-label-bg,transparent); padding:2px 4px; border-radius:4px; }
19
- .cfm-matrix-col-header { font-size:13px; font-weight:500; text-align:center; color:var(--cfm-csat-column-label-color,var(--cfm-matrix-column-label-color,#4b5563)); background:var(--cfm-csat-column-label-bg,var(--cfm-matrix-column-label-bg,transparent)); padding:2px 6px; border-radius:4px; }
17
+ .cfm-nps-badges { display:grid; grid-template-columns:repeat(11,minmax(0,1fr)); gap:var(--cfm-nps-cell-gap,2px); margin-bottom:8px; }
18
+ .cfm-nps-badge { width:var(--cfm-nps-cell-size,24px); height:var(--cfm-nps-cell-size,24px); margin:0 auto; border-radius:3px; font-size:10px; font-weight:700; color:#fff; display:flex; align-items:center; justify-content:center; box-sizing:border-box; }
19
+ .cfm-nps-badge.selected { border:2px solid var(--cfm-input-focus-ring); background:var(--cfm-matrix-selected); }
20
+ .cfm-nps-track { background:var(--cfm-nps-track-bar,#f2f2f2); border-radius:6px; padding:8px 4px; }
21
+ .cfm-nps-row { display:grid; grid-template-columns:repeat(11,minmax(0,1fr)); gap:var(--cfm-nps-cell-gap,2px); }
22
+ .cfm-nps-radio { width:var(--cfm-nps-cell-size,32px); height:var(--cfm-nps-cell-size,32px); margin:0 auto; border-radius:50%; border:2px solid #d1d5db; background:#fff; box-sizing:border-box; display:flex; align-items:center; justify-content:center; }
23
+ .cfm-nps-radio.selected { border-color:var(--cfm-input-focus-ring); background:var(--cfm-matrix-selected); }
24
+ .cfm-nps-radio.selected::after { content:''; width:33%; height:33%; border-radius:50%; background:var(--cfm-input-focus-ring); }
20
25
  .cfm-matrix-row { display:flex; align-items:center; padding:8px 0; background:var(--cfm-row-band); margin-bottom:4px; }
21
- .cfm-matrix-label { width:var(--cfm-matrix-row-width); flex-shrink:0; padding:0 16px; font-size:14px; font-weight:500; }
22
- .cfm-matrix-cells { display:flex; gap:8px; flex:1; justify-content:center; }
26
+ .cfm-matrix-label { width:var(--cfm-matrix-row-width,180px); flex-shrink:0; padding:0 16px; font-size:14px; font-weight:500; box-sizing:border-box; }
27
+ .cfm-matrix-cells { display:flex; gap:8px; flex:1; justify-content:center; align-items:center; }
28
+ .cfm-matrix-col-label { font-size:13px; font-weight:500; text-align:center; color:var(--cfm-csat-column-label-color,var(--cfm-matrix-column-label-color,#4b5563)); background:var(--cfm-csat-column-label-bg,var(--cfm-matrix-column-label-bg,transparent)); padding:2px 6px; border-radius:4px; }
23
29
  .cfm-matrix-cell { width:var(--cfm-csat-cell-size,18px); height:var(--cfm-csat-cell-size,18px); border-radius:50%; border:2px solid #d1d5db; box-sizing:border-box; }
24
30
  .cfm-matrix-cell.selected { border-color:var(--cfm-input-focus-ring); background:var(--cfm-matrix-selected); }
25
- .cfm-emoji { font-size:var(--cfm-csat-emoji-size); opacity:var(--cfm-csat-unselected-opacity); }
26
- .cfm-emoji.selected { opacity:1; outline:2px solid var(--cfm-input-focus-ring); border-radius:8px; background:var(--cfm-matrix-selected); }
27
- .cfm-star { color:var(--cfm-input-focus-ring); font-size:var(--cfm-csat-emoji-size,24px); opacity:var(--cfm-csat-unselected-opacity); }
31
+ .cfm-emoji { font-size:var(--cfm-csat-emoji-size,28px); opacity:var(--cfm-csat-unselected-opacity,0.5); line-height:1; }
32
+ .cfm-emoji.selected { opacity:1; outline:2px solid var(--cfm-input-focus-ring); border-radius:8px; }
33
+ .cfm-star { color:var(--cfm-input-focus-ring); font-size:var(--cfm-csat-emoji-size,24px); opacity:var(--cfm-csat-unselected-opacity,0.5); }
28
34
  .cfm-star.selected { opacity:1; color:var(--cfm-matrix-selected); }
29
- .cfm-badge { width:40px; height:40px; border-radius:8px; background:var(--cfm-number-color-0,#9ca3af); color:white; display:flex; align-items:center; justify-content:center; font-weight:600; border:1px solid #d1d5db; box-sizing:border-box; }
35
+ .cfm-badge { width:40px; height:40px; border-radius:8px; color:#fff; display:flex; align-items:center; justify-content:center; font-weight:600; border:1px solid #d1d5db; box-sizing:border-box; }
30
36
  .cfm-badge.selected { border:2px solid var(--cfm-input-focus-ring); background:var(--cfm-matrix-selected); color:#fff; }
31
- .cfm-slider-track { height:8px; background:var(--cfm-slider-track); border-radius:4px; position:relative; margin:24px 0; }
32
- .cfm-slider-thumb { width:20px; height:20px; background:var(--cfm-slider-thumb); border-radius:50%; position:absolute; top:-6px; left:60%; }
33
- .cfm-tick { width:var(--cfm-slider-tick-size); height:var(--cfm-slider-tick-size); background:var(--cfm-slider-tick); border-radius:4px; font-size:11px; display:flex; align-items:center; justify-content:center; color:white; }
34
- .cfm-dropzone { border-width:2px; border-style:var(--cfm-upload-border-style,dashed); border-color:var(--cfm-upload-border-color,#d1d5db); border-radius:var(--cfm-border-radius); padding:var(--cfm-upload-padding,24px); background:var(--cfm-upload-fill,#f9fafb); text-align:center; color:var(--cfm-text); }
37
+ .cfm-slider-track { height:8px; background:var(--cfm-slider-track,#e5e7eb); border-radius:4px; position:relative; margin:24px 0; }
38
+ .cfm-slider-thumb { width:20px; height:20px; background:var(--cfm-slider-thumb,var(--cfm-input-focus-ring)); border-radius:50%; position:absolute; top:-6px; left:60%; border:2px solid var(--cfm-input-focus-ring); box-sizing:border-box; }
39
+ .cfm-tick { min-width:var(--cfm-slider-tick-size,24px); height:var(--cfm-slider-tick-size,24px); background:var(--cfm-slider-tick,#6b7280); border-radius:4px; font-size:11px; display:flex; align-items:center; justify-content:center; color:#fff; padding:0 4px; }
40
+ .cfm-slider-tick-label { font-size:12px; font-weight:500; color:var(--cfm-slider-tick-label-color,#4b5563); background:var(--cfm-slider-tick-label-bg,transparent); padding:2px 4px; border-radius:4px; }
41
+ .cfm-dropzone { border-width:2px; border-style:var(--cfm-upload-border-style,dashed); border-color:var(--cfm-upload-border-color,#d1d5db); border-radius:var(--cfm-border-radius,12px); padding:var(--cfm-upload-padding,24px); background:var(--cfm-upload-fill,#f9fafb); text-align:center; color:var(--cfm-text); }
35
42
  .cfm-input { width:100%; height:var(--cfm-input-height); border:1px solid var(--cfm-input-border); border-radius:var(--cfm-input-radius); padding:0 var(--cfm-input-padding); font-family:inherit; box-sizing:border-box; }
36
- .cfm-rank-item { background:var(--cfm-rank-item-bg); padding:var(--cfm-rank-item-padding); margin-bottom:var(--cfm-rank-item-gap); border-radius:var(--cfm-border-radius); display:flex; align-items:center; gap:12px; }
37
- .cfm-rank-badge { background:var(--cfm-rank-badge); color:white; width:24px; height:24px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:12px; }
38
- .cfm-handle { color:var(--cfm-rank-handle); cursor:grab; }
39
- .cfm-pin { width:var(--cfm-heatmap-pin-size); height:var(--cfm-heatmap-pin-size); background:var(--cfm-heatmap-pin); border:2px solid var(--cfm-heatmap-pin-border,#fff); border-radius:50%; position:absolute; }
43
+ .cfm-input:focus { outline:2px solid var(--cfm-input-focus-ring); }
44
+ .cfm-rank-item { background:var(--cfm-rank-item-bg,#fff); padding:var(--cfm-rank-item-padding,12px 16px); margin-bottom:var(--cfm-rank-item-gap,8px); border-radius:var(--cfm-border-radius,8px); display:flex; align-items:center; gap:12px; }
45
+ .cfm-rank-badge { background:var(--cfm-rank-badge,var(--cfm-primary)); color:#fff; width:24px; height:24px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:12px; flex-shrink:0; }
46
+ .cfm-handle { color:var(--cfm-rank-handle,#9ca3af); cursor:grab; }
47
+ .cfm-rank-select { border-color:var(--cfm-rank-select-border,#d1d5db) !important; }
48
+ .cfm-pin { width:var(--cfm-heatmap-pin-size,16px); height:var(--cfm-heatmap-pin-size,16px); background:var(--cfm-heatmap-pin,#f97316); border:2px solid var(--cfm-heatmap-pin-border,#fff); border-radius:50%; position:absolute; box-sizing:border-box; }
40
49
  .cfm-media { max-width:var(--cfm-media-max-width); border-radius:var(--cfm-media-radius); background:#e5e7eb; height:200px; display:flex; align-items:center; justify-content:center; }
41
50
  .cfm-btn-primary { background:var(--cfm-primary); color:var(--cfm-button-text); border:none; border-radius:var(--cfm-border-radius); padding:10px 20px; }
42
51
  .cfm-btn-back { background:transparent; color:var(--cfm-primary); border:1px solid var(--cfm-primary); border-radius:var(--cfm-border-radius); padding:10px 20px; }
43
52
  `;
44
53
 
54
+ const TRAFFIC = ['#ef4444', '#f97316', '#eab308', '#84cc16', '#22c55e', '#14b8a6', '#06b6d4', '#3b82f6', '#6366f1', '#8b5cf6', '#a855f7'];
55
+
45
56
  function cells(n) {
46
57
  return Array.from({ length: n }, (_, i) =>
47
58
  `<div class="cfm-matrix-cell${i === 2 ? ' selected' : ''}"></div>`,
48
59
  ).join('');
49
60
  }
50
61
 
51
- function matrixColHeaders(n) {
52
- return `<div class="cfm-matrix-row" style="background:transparent;margin-bottom:8px"><div class="cfm-matrix-label"></div><div class="cfm-matrix-cells">${Array.from({ length: n }, (_, i) => `<span class="cfm-matrix-col-header">${i + 1}</span>`).join('')}</div></div>`;
62
+ function colLabels(n) {
63
+ return Array.from({ length: n }, (_, i) => `<span class="cfm-matrix-col-label">${i + 1}</span>`).join('');
64
+ }
65
+
66
+ function matrixPreview(label, mode) {
67
+ if (mode === 'emoji') {
68
+ return `<div class="cfm-matrix-row"><div class="cfm-matrix-label">${label}</div><div class="cfm-matrix-cells"><span class="cfm-emoji">😞</span><span class="cfm-emoji selected">🙂</span><span class="cfm-emoji">😊</span></div></div>`;
69
+ }
70
+ return `<div class="cfm-matrix-row" style="background:transparent;margin-bottom:4px"><div class="cfm-matrix-label"></div><div class="cfm-matrix-cells">${colLabels(5)}</div></div>
71
+ <div class="cfm-matrix-row"><div class="cfm-matrix-label">${label}</div><div class="cfm-matrix-cells">${cells(5)}</div></div>`;
53
72
  }
54
73
 
55
- function matrixPreview(label) {
56
- return `${matrixColHeaders(5)}<div class="cfm-matrix-row"><div class="cfm-matrix-label">${label}</div><div class="cfm-matrix-cells">${cells(5)}</div></div>`;
74
+ function npsPreview() {
75
+ const badges = Array.from({ length: 11 }, (_, i) =>
76
+ `<div class="cfm-nps-badge${i === 8 ? ' selected' : ''}" data-cfm-number-badge="${i}">${i}</div>`,
77
+ ).join('');
78
+ const radios = Array.from({ length: 11 }, (_, i) =>
79
+ `<div class="cfm-nps-radio${i === 8 ? ' selected' : ''}"></div>`,
80
+ ).join('');
81
+ return `<p style="font-weight:600;margin-bottom:8px">How likely are you to recommend us?</p>
82
+ <div class="cfm-hint-row"><span class="cfm-hint-min" data-cfm-hint-min>Not likely</span><span class="cfm-hint-max" data-cfm-hint-max>Very likely</span></div>
83
+ <div class="cfm-nps-badges">${badges}</div>
84
+ <div class="cfm-nps-track"><div class="cfm-nps-row">${radios}</div></div>`;
57
85
  }
58
86
 
59
87
  const npsCells = Array.from({ length: 11 }, (_, i) =>
60
- `<div class="cfm-nps-cell${i === 8 ? ' selected' : ''}">${i}</div>`,
88
+ `<div class="cfm-nps-radio${i === 8 ? ' selected' : ''}"></div>`,
61
89
  ).join('');
62
90
 
63
91
  const PREVIEW_PAGE_DEFS = [
64
92
  { key: 'header', title: 'Header', body: `<header class="cfm-header"><img data-cfm-logo class="cfm-header-logo" src="" alt="Logo" style="display:none" /><span data-cfm-company class="cfm-header-company">Company Name</span></header>` },
65
93
  { key: 'footer', title: 'Footer', body: `<footer class="cfm-footer"><div><img data-cfm-logo style="width:var(--cfm-footer-logo-width);height:var(--cfm-footer-logo-height);object-fit:contain;display:none" src="" alt="" /><p data-cfm-copyright style="font-size:12px;margin-top:8px">© Company</p></div><nav data-cfm-footer-links class="cfm-footer-links"></nav></footer>` },
66
94
  { key: 'chrome', title: 'Chrome', body: `<header class="cfm-header"><img data-cfm-logo class="cfm-header-logo" src="" alt="" style="display:none" /><span data-cfm-company class="cfm-header-company">Company</span></header><div class="cfm-progress"><div class="cfm-progress-fill"></div></div><p data-cfm-survey-title style="font-size:20px;font-weight:var(--cfm-heading-weight);margin:16px 0">Survey Title</p><div style="display:flex;gap:12px;margin-top:24px"><button data-cfm-btn-back class="cfm-btn-back">Back</button><button data-cfm-btn-next class="cfm-btn-primary">Next</button></div>` },
67
- { key: 'survey_page', title: 'Survey Page', body: `<header class="cfm-header"><img data-cfm-logo class="cfm-header-logo" src="" alt="" style="display:none" /><span data-cfm-company class="cfm-header-company">Company</span></header><div data-cfm-progress class="cfm-progress"><div class="cfm-progress-fill" style="width:35%"></div></div><div data-cfm-language style="text-align:right;margin:16px 0"><select class="cfm-input" style="width:auto"><option>English</option></select></div><p style="font-weight:600"><span data-cfm-question-number>1.</span> Sample question <span data-cfm-required style="color:red">*</span></p><div class="cfm-mcq-option selected"><span>◉</span> Option A</div><p style="font-weight:600;margin-top:24px"><span data-cfm-question-number>2.</span> NPS question</p><div class="cfm-nps-row">${npsCells}</div><div style="display:flex;gap:12px;margin-top:24px"><button data-cfm-btn-back class="cfm-btn-back">Back</button><button data-cfm-btn-next class="cfm-btn-primary">Next</button></div><footer class="cfm-footer" style="margin-top:32px"><p data-cfm-copyright>© Company</p><nav data-cfm-footer-links class="cfm-footer-links"></nav></footer>` },
68
- { key: 'intro', title: 'Intro', body: `<h1 data-cfm-survey-title style="font-size:28px;font-weight:var(--cfm-heading-weight)">Welcome</h1><p style="margin:16px 0;color:#6b7280">Please share your feedback.</p><button class="cfm-btn-primary">Start Survey</button>` },
95
+ { key: 'survey_page', title: 'Survey Page', body: `<header class="cfm-header"><img data-cfm-logo class="cfm-header-logo" src="" alt="" style="display:none" /><span data-cfm-company class="cfm-header-company">Company</span></header><div data-cfm-progress class="cfm-progress"><div class="cfm-progress-fill" style="width:35%"></div></div><div data-cfm-language style="text-align:right;margin:16px 0"><select class="cfm-input" style="width:auto"><option>English</option><option>Deutsch</option></select></div><p style="font-weight:600"><span data-cfm-question-number>1.</span> Sample question <span data-cfm-required style="color:red">*</span></p><div class="cfm-mcq-option selected"><span>◉</span> Option A</div><p style="font-weight:600;margin-top:24px"><span data-cfm-question-number>2.</span> NPS question</p><div class="cfm-nps-track"><div class="cfm-nps-row">${npsCells}</div></div><div style="display:flex;gap:12px;margin-top:24px"><button data-cfm-btn-back class="cfm-btn-back">Back</button><button data-cfm-btn-next class="cfm-btn-primary">Next</button></div><footer class="cfm-footer" style="margin-top:32px"><p data-cfm-copyright>© Company</p><nav data-cfm-footer-links class="cfm-footer-links"></nav></footer>` },
96
+ { key: 'intro', title: 'Intro', body: `<h1 data-cfm-survey-title style="font-size:28px;font-weight:var(--cfm-heading-weight)">Welcome</h1><p style="margin:16px 0;color:#6b7280">Please take a few minutes to share your feedback.</p><button class="cfm-btn-primary">Start Survey</button>` },
69
97
  { key: 'end', title: 'End', body: `<h1 style="font-size:28px;font-weight:var(--cfm-heading-weight)">Thank you!</h1><p data-cfm-thank-you style="margin:16px 0">Thank you for your feedback.</p>` },
70
- { key: 'MCQ_single', title: 'MCQ Single', body: `<p style="font-weight:600;margin-bottom:16px">1. Select one option</p><div data-cfm-mcq-style="outlined"><div class="cfm-mcq-option selected"><span>◉</span> Option A</div><div class="cfm-mcq-option"><span>○</span> Option B</div></div>` },
98
+ { key: 'MCQ_single', title: 'MCQ Single', body: `<p style="font-weight:600;margin-bottom:16px">1. Select one option</p><div data-cfm-mcq-style="outlined"><div class="cfm-mcq-option selected"><span>◉</span> Option A</div><div class="cfm-mcq-option"><span>○</span> Option B</div><div class="cfm-mcq-option"><span>○</span> Option C</div></div>` },
71
99
  { key: 'MCQ_multiple', title: 'MCQ Multiple', body: `<p style="font-weight:600;margin-bottom:16px">Select all that apply</p><div class="cfm-mcq-option selected"><span>☑</span> Option A</div><div class="cfm-mcq-option"><span>☐</span> Option B</div>` },
72
- { key: 'TEXTFIELD_short', title: 'Text Short', body: `<p style="font-weight:600;margin-bottom:8px">Your name</p><input class="cfm-input" placeholder="Type here..." />` },
73
- { key: 'TEXTFIELD_long', title: 'Text Long', body: `<p style="font-weight:600;margin-bottom:8px">Comments</p><textarea class="cfm-input" style="height:120px;padding:12px" placeholder="Type your response..."></textarea>` },
74
- { key: 'NPS_SCALE', title: 'NPS', body: `<p style="font-weight:600;margin-bottom:8px">How likely are you to recommend us?</p><div class="cfm-hint-row"><span class="cfm-hint-min" data-cfm-hint-min>Not likely</span><span class="cfm-hint-max" data-cfm-hint-max>Very likely</span></div><div class="cfm-nps-row">${npsCells}</div>` },
75
- { key: 'CFM_likert', title: 'CFM Likert', body: matrixPreview('Statement item 1') },
76
- { key: 'CFM_bipolar', title: 'CFM Bipolar', body: `<div class="cfm-matrix-row"><div class="cfm-matrix-label" style="width:var(--cfm-matrix-bipolar-width)">Left</div><div class="cfm-matrix-cells">${cells(5)}</div><div class="cfm-matrix-label" style="width:var(--cfm-matrix-bipolar-width);text-align:right">Right</div></div>` },
77
- { key: 'CFM_transpose', title: 'CFM Transpose', body: matrixPreview('Scale item (transposed)') },
78
- { key: 'CFM_carousel', title: 'CFM Carousel', body: `${matrixPreview('Row 1 of 3')}<div style="text-align:center;margin-top:12px"><span style="color:var(--cfm-matrix-carousel-dot)">●</span> ○ ○</div>` },
79
- { key: 'CFM_dropdown', title: 'CFM Dropdown', body: `<div class="cfm-matrix-label">Statement 1</div><select class="cfm-input" style="margin-top:8px"><option>Select...</option></select>` },
80
- { key: 'CSAT_emoji', title: 'CSAT Emoji', body: `<div class="cfm-matrix-row"><div class="cfm-matrix-label">How satisfied?</div><div class="cfm-matrix-cells"><span class="cfm-emoji selected">😞</span><span class="cfm-emoji">🙂</span><span class="cfm-emoji">😊</span></div></div>` },
81
- { key: 'CSAT_star', title: 'CSAT Star', body: `<div class="cfm-matrix-row"><div class="cfm-matrix-label">Rate us</div><div class="cfm-matrix-cells"><span class="cfm-star selected">★</span><span class="cfm-star selected">★</span><span class="cfm-star">★</span></div></div>` },
82
- { key: 'CSAT_numbered', title: 'CSAT Numbered', body: `<div class="cfm-matrix-row"><div class="cfm-matrix-label">Score</div><div class="cfm-matrix-cells"><div class="cfm-badge">1</div><div class="cfm-badge selected">2</div><div class="cfm-badge">3</div></div></div>` },
100
+ { key: 'TEXTFIELD_short', title: 'Text Short', body: `<p style="font-weight:600;margin-bottom:8px">Your name</p><input class="cfm-input" data-cfm-textfield placeholder="Type here..." />` },
101
+ { key: 'TEXTFIELD_long', title: 'Text Long', body: `<p style="font-weight:600;margin-bottom:8px">Comments</p><textarea class="cfm-input" data-cfm-textfield style="height:120px;padding:12px" placeholder="Type your response..."></textarea>` },
102
+ { key: 'NPS_SCALE', title: 'NPS', body: npsPreview() },
103
+ { key: 'CFM_likert', title: 'CFM Likert', body: `${matrixPreview('Statement item 1', 'radio')}<div class="cfm-matrix-row" data-cfm-matrix-row-extra style="display:none"><div class="cfm-matrix-label">Statement item 2</div><div class="cfm-matrix-cells">${cells(5)}</div></div>` },
104
+ { key: 'CFM_bipolar', title: 'CFM Bipolar', body: `<div class="cfm-matrix-row"><div class="cfm-matrix-label" style="width:var(--cfm-matrix-bipolar-width,25%)">Left statement</div><div class="cfm-matrix-cells">${cells(5)}</div><div class="cfm-matrix-label" style="width:var(--cfm-matrix-bipolar-width,25%);text-align:right">Right statement</div></div>` },
105
+ { key: 'CFM_transpose', title: 'CFM Transpose', body: matrixPreview('Scale item (transposed row)', 'radio') },
106
+ { key: 'CFM_carousel', title: 'CFM Carousel', body: `${matrixPreview('Row 1 of 3', 'radio')}<div style="text-align:center;margin-top:12px"><span style="color:var(--cfm-matrix-selected)">●</span> ○ ○</div>` },
107
+ { key: 'CFM_dropdown', title: 'CFM Dropdown', body: `<div class="cfm-matrix-label" style="width:100%;margin-bottom:8px">Statement 1</div><select class="cfm-input"><option>Select...</option><option>Col 1</option><option>Col 2</option></select>` },
108
+ { key: 'CSAT_emoji', title: 'CSAT Emoji', body: `<div class="cfm-matrix-row" style="background:transparent;margin-bottom:4px"><div class="cfm-matrix-label"></div><div class="cfm-matrix-cells">${colLabels(5)}</div></div><div class="cfm-matrix-row"><div class="cfm-matrix-label">How satisfied?</div><div class="cfm-matrix-cells"><span class="cfm-emoji selected">😞</span><span class="cfm-emoji">😐</span><span class="cfm-emoji">🙂</span><span class="cfm-emoji">😊</span><span class="cfm-emoji">🤩</span></div></div>` },
109
+ { key: 'CSAT_star', title: 'CSAT Star', body: `<div class="cfm-matrix-row"><div class="cfm-matrix-label">Rate us</div><div class="cfm-matrix-cells"><span class="cfm-star selected">★</span><span class="cfm-star selected">★</span><span class="cfm-star">★</span><span class="cfm-star">★</span><span class="cfm-star">★</span></div></div>` },
110
+ { key: 'CSAT_numbered', title: 'CSAT Numbered', body: `<div class="cfm-matrix-row"><div class="cfm-matrix-label">Score</div><div class="cfm-matrix-cells">${[1,2,3,4,5].map((n,i)=>`<div class="cfm-badge${i===2?' selected':''}" data-cfm-number-badge="${i}">${n}</div>`).join('')}</div></div>` },
83
111
  { key: 'CSAT_graphics', title: 'CSAT Graphics', body: `<div class="cfm-matrix-row"><div class="cfm-matrix-label">Slider row</div><div style="flex:1;padding:0 16px"><div class="cfm-slider-track"><div class="cfm-slider-thumb"></div></div></div></div>` },
84
- { key: 'CSAT_carousel', title: 'CSAT Carousel', body: `${matrixPreview('Carousel row')}<div style="text-align:center"><span style="color:var(--cfm-csat-accent)">●</span> ○</div>` },
112
+ { key: 'CSAT_carousel', title: 'CSAT Carousel', body: `${matrixPreview('Carousel row', 'emoji')}<div style="text-align:center"><span style="color:var(--cfm-matrix-selected)">●</span> ○</div>` },
85
113
  { key: 'CSAT_dropdown', title: 'CSAT Dropdown', body: `<div class="cfm-matrix-label">Statement</div><select class="cfm-input" style="margin-top:8px"><option>Choose rating...</option></select>` },
86
- { key: 'RATING_star', title: 'Rating Star', body: `<div class="cfm-matrix-cells" style="justify-content:flex-start;padding:16px"><span class="cfm-star selected">★</span><span class="cfm-star selected">★</span><span class="cfm-star">★</span></div>` },
87
- { key: 'RATING_numbered', title: 'Rating Numbered', body: `<div class="cfm-matrix-cells" style="padding:16px"><div class="cfm-badge">1</div><div class="cfm-badge">2</div><div class="cfm-badge">3</div></div>` },
88
- { key: 'RATING_radio', title: 'Rating Radio', body: matrixPreview('Recommend to a friend') },
114
+ { key: 'RATING_star', title: 'Rating Star', body: `<div class="cfm-matrix-cells" style="justify-content:flex-start;padding:16px"><span class="cfm-star selected">★</span><span class="cfm-star selected">★</span><span class="cfm-star">★</span><span class="cfm-star">★</span><span class="cfm-star">★</span></div>` },
115
+ { key: 'RATING_numbered', title: 'Rating Numbered', body: `<div class="cfm-matrix-cells" style="padding:16px">${[1,2,3,4,5].map((n,i)=>`<div class="cfm-badge${i===1?' selected':''}" data-cfm-number-badge="${i}">${n}</div>`).join('')}</div>` },
116
+ { key: 'RATING_radio', title: 'Rating Radio', body: matrixPreview('Recommend to a friend', 'radio') },
89
117
  { key: 'RATING_emoji', title: 'Rating Emoji', body: `<div class="cfm-matrix-cells" style="padding:16px"><span class="cfm-emoji">🙂</span><span class="cfm-emoji selected">😊</span><span class="cfm-emoji">🤩</span></div>` },
90
118
  { key: 'RATING_graphics', title: 'Rating Graphics', body: `<div class="cfm-matrix-row"><div class="cfm-matrix-label">Experience</div><div style="flex:1;padding:0 16px"><div class="cfm-slider-track"><div class="cfm-slider-thumb"></div></div></div></div>` },
91
119
  { key: 'RATING_slider', title: 'Rating Slider', body: `<div class="cfm-matrix-row"><div class="cfm-matrix-label">Rate on slider</div><div style="flex:1;padding:0 16px"><div class="cfm-slider-track"><div class="cfm-slider-thumb"></div></div></div></div>` },
92
- { key: 'SLIDER_matrix', title: 'Slider Matrix', body: `<div class="cfm-matrix-row" style="background:transparent;margin-bottom:4px"><div class="cfm-matrix-label"></div><div style="flex:1;padding:0 16px;display:flex;justify-content:space-between"><span class="cfm-matrix-col-header">Low</span><span class="cfm-matrix-col-header">High</span></div></div><div class="cfm-matrix-row"><div class="cfm-matrix-label">Statement 1</div><div style="flex:1;padding:0 16px"><div class="cfm-slider-track"><div class="cfm-slider-thumb"></div></div><div style="display:flex;justify-content:space-between;margin-top:8px"><span class="cfm-tick">0</span><span class="cfm-tick">5</span><span class="cfm-tick">10</span></div></div></div>` },
93
- { key: 'FILE_UPLOAD', title: 'File Upload', body: `<div data-cfm-dropzone-style="dashed" class="cfm-dropzone">Drop files here or click to upload</div>` },
94
- { key: 'TEXT_AND_MEDIA', title: 'Text & Media', body: `<div class="cfm-media">Media preview</div>` },
95
- { key: 'HEATMAP', title: 'Heatmap', body: `<div style="position:relative;background:#e5e7eb;height:240px;border-radius:var(--cfm-border-radius)"><div class="cfm-pin" style="top:40%;left:55%"></div></div>` },
120
+ { key: 'SLIDER_matrix', title: 'Slider Matrix', body: `<div style="display:flex;gap:4px;margin-bottom:16px;padding-left:var(--cfm-matrix-row-width,180px)">${[0,1,2,3,4,5].map((n,i)=>`<div class="cfm-tick" data-cfm-number-badge="${i}">${n}</div>`).join('')}</div><div style="display:flex;gap:8px;margin-bottom:8px;padding-left:var(--cfm-matrix-row-width,180px)"><span class="cfm-slider-tick-label">Low</span><span class="cfm-slider-tick-label" style="margin-left:auto">High</span></div><div class="cfm-matrix-row"><div class="cfm-matrix-label">Statement 1</div><div style="flex:1;padding:0 16px"><div class="cfm-slider-track"><div class="cfm-slider-thumb"></div></div></div></div><div class="cfm-matrix-row" data-cfm-matrix-row-extra style="display:none"><div class="cfm-matrix-label">Statement 2</div><div style="flex:1;padding:0 16px"><div class="cfm-slider-track"><div class="cfm-slider-thumb" style="left:30%"></div></div></div></div>` },
121
+ { key: 'FILE_UPLOAD', title: 'File Upload', body: `<div class="cfm-dropzone" data-cfm-dropzone>Drop files here or click to upload</div>` },
122
+ { key: 'TEXT_AND_MEDIA', title: 'Text & Media', body: `<div class="cfm-media">Media preview</div><p style="font-size:var(--cfm-media-caption-size,12px);color:var(--cfm-media-caption-color,#6b7280);margin-top:8px">Caption text</p>` },
123
+ { key: 'HEATMAP', title: 'Heatmap', body: `<div style="position:relative;background:#e5e7eb;height:240px;border-radius:var(--cfm-border-radius)"><div class="cfm-pin" style="top:40%;left:55%"></div><div class="cfm-pin" style="top:60%;left:30%"></div></div>` },
96
124
  { key: 'RANK_drag', title: 'Rank Drag', body: `<div class="cfm-rank-item"><span class="cfm-handle">⠿</span><span class="cfm-rank-badge">1</span> Item A</div><div class="cfm-rank-item"><span class="cfm-handle">⠿</span><span class="cfm-rank-badge">2</span> Item B</div>` },
97
- { key: 'RANK_dropdown', title: 'Rank Dropdown', body: `<div style="display:flex;align-items:center;gap:12px"><span>Item A</span><select class="cfm-input" style="width:80px"><option>1</option></select></div>` },
125
+ { 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>` },
98
126
  ];
99
127
 
100
128
  function buildPreviewHtml(def, inlineCss, inlineJs) {
@@ -113,4 +141,4 @@ function buildPreviewHtml(def, inlineCss, inlineJs) {
113
141
  </html>`;
114
142
  }
115
143
 
116
- module.exports = { PREVIEW_PAGE_DEFS, buildPreviewHtml, sharedStyles, PREVIEW_KEYS: PREVIEW_PAGE_DEFS.map((d) => d.key) };
144
+ module.exports = { PREVIEW_PAGE_DEFS, buildPreviewHtml, sharedStyles, PREVIEW_KEYS: PREVIEW_PAGE_DEFS.map((d) => d.key), TRAFFIC };
@@ -42,6 +42,7 @@ function flushCssVars(): void {
42
42
  }
43
43
  delete pendingVars[key];
44
44
  }
45
+ applyThemeSideEffects();
45
46
  }
46
47
 
47
48
  function queueCssVars(vars: Record<string, string>): void {
@@ -56,6 +57,7 @@ function applyCssVarsImmediate(vars: Record<string, string>): void {
56
57
  for (const [key, value] of Object.entries(vars)) {
57
58
  root.style.setProperty(key, value);
58
59
  }
60
+ applyThemeSideEffects();
59
61
  }
60
62
 
61
63
  function setDisplay(selector: string, visible: boolean): void {
@@ -217,6 +219,37 @@ function applyContentPatch(patch: ContentPatch): void {
217
219
  (el as HTMLElement).textContent = hintMax;
218
220
  });
219
221
  }
222
+ if (patch.previewState?.multiStatement !== undefined) {
223
+ const show = Boolean(patch.previewState.multiStatement);
224
+ document.querySelectorAll('[data-cfm-matrix-row-extra]').forEach((el) => {
225
+ (el as HTMLElement).style.display = show ? '' : 'none';
226
+ });
227
+ }
228
+ applyThemeSideEffects();
229
+ }
230
+
231
+ const TRAFFIC_BADGE = ['#ef4444', '#f97316', '#eab308', '#84cc16', '#22c55e', '#14b8a6', '#06b6d4', '#3b82f6', '#6366f1', '#8b5cf6', '#a855f7'];
232
+
233
+ function readCssVar(name: string, fallback: string): string {
234
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
235
+ }
236
+
237
+ function applyThemeSideEffects(): void {
238
+ const mode = readCssVar('--cfm-number-label-mode', 'traffic');
239
+ const mono = readCssVar('--cfm-number-mono-color', '#9ca3af');
240
+ document.querySelectorAll('[data-cfm-number-badge]').forEach((el) => {
241
+ if (el.classList.contains('selected')) return;
242
+ const i = parseInt(el.getAttribute('data-cfm-number-badge') ?? '', 10);
243
+ if (Number.isNaN(i)) return;
244
+ let bg = TRAFFIC_BADGE[i] ?? '#9ca3af';
245
+ if (mode === 'monochrome') bg = mono;
246
+ if (mode === 'individual') bg = readCssVar(`--cfm-number-color-${i}`, '#9ca3af');
247
+ (el as HTMLElement).style.backgroundColor = bg;
248
+ });
249
+ const dropStyle = readCssVar('--cfm-upload-border-style', 'dashed');
250
+ document.querySelectorAll('[data-cfm-dropzone], .cfm-dropzone').forEach((el) => {
251
+ (el as HTMLElement).style.borderStyle = dropStyle;
252
+ });
220
253
  }
221
254
 
222
255
  function setLiveEditMode(on: boolean): void {