@explorer02/cfm-survey-sdk 0.2.8 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/cli/index.js +38 -41
  2. package/dist/cli/index.mjs +36 -39
  3. package/package.json +1 -1
  4. package/templates/docs/00-integration/agent-execution-flow.md +6 -6
  5. package/templates/docs/00-integration/agent-operating-contract.md +2 -1
  6. package/templates/docs/00-integration/component-checklist.md +1 -0
  7. package/templates/docs/00-integration/ui-customization-wizard.md +29 -7
  8. package/templates/docs/00-integration/wizard-chrome-contract.md +11 -0
  9. package/templates/docs/00-integration/wizard-config-handoff.md +11 -9
  10. package/templates/docs/00-integration/wizard-post-customize-build.md +6 -0
  11. package/templates/docs/00-integration/wizard-question-type-styling.md +4 -3
  12. package/templates/docs/00-integration/wizard-troubleshooting.md +20 -4
  13. package/templates/docs/index.md +1 -1
  14. package/templates/docs/templates/CsatMatrixScale.tsx +56 -53
  15. package/templates/docs/templates/CustomSliderTrack.tsx +17 -8
  16. package/templates/docs/templates/FileUploadScale.tsx +11 -3
  17. package/templates/docs/templates/Header.tsx +32 -48
  18. package/templates/docs/templates/HeatmapScale.tsx +7 -4
  19. package/templates/docs/templates/LikertMatrixScale.tsx +58 -43
  20. package/templates/docs/templates/MatrixDropdown.tsx +12 -11
  21. package/templates/docs/templates/Question.tsx +4 -23
  22. package/templates/docs/templates/RankOrderScale.tsx +46 -6
  23. package/templates/docs/templates/RatingScale.tsx +38 -0
  24. package/templates/docs/templates/SliderMatrixScale.tsx +4 -3
  25. package/templates/docs/templates/verify-agent-build.sh +17 -0
  26. package/templates/preview-harness/preview-bridge.inline.js +10 -0
  27. package/templates/preview-harness/previewPages.ts +5 -7
  28. package/templates/preview-harness/vite-app/src/QuestionPreview.tsx +11 -6
  29. package/templates/preview-harness/vite-app/src/fixtures/questions.ts +2 -10
  30. package/templates/preview-harness/vite-app/src/preview-live-overrides.css +11 -6
  31. package/templates/previewBridge.ts +13 -0
  32. package/templates/survey-theme.css +17 -17
  33. package/templates/wizard-dist/assets/{PreviewMock-tTMvwuEy.js → PreviewMock-CysRws9G.js} +1 -1
  34. package/templates/wizard-dist/assets/TypePanel-DWX1LL3q.js +1 -0
  35. package/templates/wizard-dist/assets/index-DIuc7AU2.js +34 -0
  36. package/templates/wizard-dist/assets/index-pCM8n0FT.css +1 -0
  37. package/templates/wizard-dist/index.html +2 -2
  38. package/templates/wizard-dist/assets/TypePanel-B9Ls7BSq.js +0 -1
  39. package/templates/wizard-dist/assets/index-CjkIlOUI.css +0 -1
  40. package/templates/wizard-dist/assets/index-CoCrnnxt.js +0 -34
@@ -26,11 +26,11 @@ function HeatmapSpot({ spot }: { spot: { id: string; x: number; y: number } }) {
26
26
  style={{
27
27
  left: `${spot.x * 100}%`,
28
28
  top: `${spot.y * 100}%`,
29
- width: 16,
30
- height: 16,
29
+ width: 'var(--cfm-heatmap-pin-size, 16px)',
30
+ height: 'var(--cfm-heatmap-pin-size, 16px)',
31
31
  transform: 'translate(-50%, -50%)',
32
- backgroundColor: '#F97316',
33
- border: '2px solid white',
32
+ backgroundColor: 'var(--cfm-heatmap-pin)',
33
+ border: '2px solid var(--cfm-heatmap-pin-border, white)',
34
34
  boxShadow: '0 1px 3px rgba(0,0,0,0.25)',
35
35
  }}
36
36
  />
@@ -63,6 +63,9 @@ export function HeatmapScale({ question, selectedValue = [], onSelect }: Heatmap
63
63
  }
64
64
 
65
65
  onSelect([...spots, createNewSpot(x, y)]);
66
+ if (typeof window !== 'undefined' && window.parent !== window) {
67
+ window.parent.postMessage({ type: 'CFM_HEATMAP_PIN', pin: { x, y } }, '*');
68
+ }
66
69
  }
67
70
 
68
71
  function handleImageLoad() {
@@ -1,5 +1,12 @@
1
1
  import React, { useState } from 'react';
2
2
  import type { CfmMatrixQuestion, MatrixRowAnswers } from '@explorer02/cfm-survey-sdk';
3
+ import { matrixColumnLabelStyle } from '@/lib/surveyUi/labelStyles';
4
+ import {
5
+ cellSelectedVar,
6
+ focusRingVar,
7
+ matrixRadioDotStyle,
8
+ matrixRadioRingStyle,
9
+ } from '@/lib/surveyUi/selectionStyles';
3
10
  import { columnSubmitValue } from './surveyUiScaleUtils';
4
11
  import MatrixDropdown from './MatrixDropdown';
5
12
 
@@ -111,8 +118,8 @@ function LikertMatrixCarouselLayout({
111
118
  <div style={{
112
119
  display: 'flex', alignItems: 'center', justifyContent: 'center',
113
120
  width: '18px', height: '18px', borderRadius: '4px',
114
- border: selected ? '2px solid #e20074' : '2px solid #d1d5db',
115
- backgroundColor: selected ? '#e20074' : '#fff',
121
+ border: selected ? `2px solid ${focusRingVar}` : '2px solid #d1d5db',
122
+ backgroundColor: selected ? cellSelectedVar : '#fff',
116
123
  flexShrink: 0, transition: 'all 0.15s'
117
124
  }}>
118
125
  {selected && (
@@ -122,13 +129,8 @@ function LikertMatrixCarouselLayout({
122
129
  )}
123
130
  </div>
124
131
  ) : (
125
- <div style={{
126
- display: 'flex', width: '18px', height: '18px', flexShrink: 0,
127
- alignItems: 'center', justifyContent: 'center', borderRadius: '50%',
128
- border: selected ? '2px solid #e20074' : '1.5px solid #9ca3af',
129
- backgroundColor: '#fff',
130
- }}>
131
- {selected && <div style={{ width: '10px', height: '10px', borderRadius: '50%', backgroundColor: '#e20074' }} />}
132
+ <div style={matrixRadioRingStyle(selected)}>
133
+ {selected && <div style={matrixRadioDotStyle(selected, '10px')} />}
132
134
  </div>
133
135
  )}
134
136
  <span style={{ fontSize: '15px', fontWeight: 400, color: '#374151', userSelect: 'none' }} dangerouslySetInnerHTML={{ __html: col.label }} />
@@ -206,7 +208,7 @@ function LikertMatrixGridLayout({
206
208
  <div style={{ display: 'flex', flexDirection: 'column', marginBottom: '16px' }}>
207
209
  {hasLabels && (
208
210
  <div style={{ display: 'flex', alignItems: 'flex-end', minHeight: '24px', marginBottom: '8px' }}>
209
- <div style={{ flex: '0 0 25%', paddingRight: '16px', boxSizing: 'border-box' }} />
211
+ <div style={{ flex: `0 0 var(--cfm-matrix-bipolar-width, 25%)`, paddingRight: '16px', boxSizing: 'border-box' }} />
210
212
  <div style={{ flex: 1, position: 'relative', height: '20px' }}>
211
213
  {labels.map((lbl, idx) => {
212
214
  let leftPos = '50%';
@@ -224,23 +226,23 @@ function LikertMatrixGridLayout({
224
226
  );
225
227
  })}
226
228
  </div>
227
- <div style={{ flex: '0 0 25%', paddingLeft: '16px', boxSizing: 'border-box' }} />
229
+ <div style={{ flex: `0 0 var(--cfm-matrix-bipolar-width, 25%)`, paddingLeft: '16px', boxSizing: 'border-box' }} />
228
230
  </div>
229
231
  )}
230
232
  {showColumnHeaders && (
231
233
  <div style={{ display: 'flex', alignItems: 'flex-end', minHeight: '24px' }}>
232
- <div style={{ flex: isBipolar ? '0 0 25%' : '0 0 180px', paddingRight: '16px', boxSizing: 'border-box' }} />
234
+ <div style={{ flex: isBipolar ? '0 0 var(--cfm-matrix-bipolar-width, 25%)' : '0 0 var(--cfm-matrix-row-width, 180px)', paddingRight: '16px', boxSizing: 'border-box' }} />
233
235
  <div style={{ flex: 1, display: 'flex' }}>
234
236
  <div style={{ flex: 1, display: 'grid', gridTemplateColumns: `repeat(${gridCols.length}, 1fr)` }}>
235
237
  {gridCols.map((col) => (
236
238
  <div key={col.id} style={{ display: 'flex', justifyContent: 'center', padding: '0 4px' }}>
237
- <span style={{ fontSize: '13px', fontWeight: 500, color: '#4b5563', textAlign: 'center', lineHeight: 1.2 }}
239
+ <span style={{ fontSize: '13px', fontWeight: 500, textAlign: 'center', lineHeight: 1.2, ...matrixColumnLabelStyle() }}
238
240
  dangerouslySetInnerHTML={{ __html: transposeTable ? ('statementText' in col ? col.statementText : col.label) : ('label' in col ? col.label : (col as { statementText: string }).statementText) }} />
239
241
  </div>
240
242
  ))}
241
243
  </div>
242
244
  </div>
243
- {isBipolar && <div style={{ flex: '0 0 25%', paddingLeft: '16px', boxSizing: 'border-box' }} />}
245
+ {isBipolar && <div style={{ flex: `0 0 var(--cfm-matrix-bipolar-width, 25%)`, paddingLeft: '16px', boxSizing: 'border-box' }} />}
244
246
  </div>
245
247
  )}
246
248
  </div>
@@ -258,10 +260,10 @@ function LikertMatrixGridLayout({
258
260
  {repeatColumnHeaders && renderHeader()}
259
261
  <div style={{
260
262
  display: 'flex', alignItems: 'center', borderRadius: '8px',
261
- padding: '12px 0', backgroundColor: !repeatColumnHeaders && rowIdx % 2 === 0 ? 'rgba(249, 250, 251, 0.8)' : '#fff',
263
+ padding: '12px 0', backgroundColor: !repeatColumnHeaders && rowIdx % 2 === 0 ? 'var(--cfm-zebra-row)' : '#fff',
262
264
  boxSizing: 'border-box'
263
265
  }}>
264
- <div style={{ flex: isBipolar ? '0 0 25%' : '0 0 180px', padding: '0 16px', boxSizing: 'border-box' }}>
266
+ <div style={{ flex: isBipolar ? '0 0 var(--cfm-matrix-bipolar-width, 25%)' : '0 0 var(--cfm-matrix-row-width, 180px)', padding: '0 16px', boxSizing: 'border-box' }}>
265
267
  <span style={{ fontSize: '14px', fontWeight: 500, color: '#111827', lineHeight: 1.4, wordWrap: 'break-word', display: 'block' }}
266
268
  dangerouslySetInnerHTML={{ __html: transposeTable ? ('label' in rowItem ? rowItem.label : '') : ('statementText' in rowItem ? rowItem.statementText : '') }} />
267
269
  </div>
@@ -279,32 +281,45 @@ function LikertMatrixGridLayout({
279
281
 
280
282
  return (
281
283
  <div key={colItem.id} style={{ display: 'flex', justifyContent: 'center' }}>
282
- <label style={{
283
- cursor: isDisabled ? 'not-allowed' : 'pointer',
284
- opacity: isDisabled ? 0.4 : 1,
285
- padding: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center'
286
- }}>
287
- <input
288
- type={isMultiple ? "checkbox" : "radio"}
289
- checked={selected}
290
- disabled={isDisabled}
291
- onClick={(e) => {
292
- if (!isMultiple && selected) {
293
- onCellSelect(actualRowId, undefined);
294
- }
295
- }}
296
- onChange={(e) => {
297
- if (isMultiple || !selected) {
298
- onCellSelect(actualRowId, actualColValue);
299
- }
300
- }}
301
- style={{
302
- width: '18px', height: '18px', accentColor: '#e20074',
303
- cursor: isDisabled ? 'not-allowed' : 'pointer',
304
- ...(isMultiple ? { borderRadius: '4px' } : {})
305
- }}
306
- />
307
- </label>
284
+ <button
285
+ type="button"
286
+ disabled={isDisabled}
287
+ onClick={() => {
288
+ if (isDisabled) return;
289
+ if (!isMultiple && selected) {
290
+ onCellSelect(actualRowId, undefined);
291
+ } else {
292
+ onCellSelect(actualRowId, actualColValue);
293
+ }
294
+ }}
295
+ style={{
296
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
297
+ opacity: isDisabled ? 0.4 : 1,
298
+ padding: 'var(--cfm-matrix-cell-padding, 8px)',
299
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
300
+ border: 'none', background: 'transparent',
301
+ }}
302
+ >
303
+ {isMultiple ? (
304
+ <div style={{
305
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
306
+ width: '18px', height: '18px', borderRadius: '4px',
307
+ border: selected ? `2px solid ${focusRingVar}` : '2px solid #d1d5db',
308
+ backgroundColor: selected ? cellSelectedVar : '#fff',
309
+ transition: 'all 0.15s',
310
+ }}>
311
+ {selected && (
312
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
313
+ <polyline points="20 6 9 17 4 12" />
314
+ </svg>
315
+ )}
316
+ </div>
317
+ ) : (
318
+ <div style={matrixRadioRingStyle(selected)}>
319
+ {selected && <div style={matrixRadioDotStyle(selected)} />}
320
+ </div>
321
+ )}
322
+ </button>
308
323
  </div>
309
324
  );
310
325
  })}
@@ -312,7 +327,7 @@ function LikertMatrixGridLayout({
312
327
  </div>
313
328
 
314
329
  {isBipolar && (
315
- <div style={{ flex: '0 0 25%', padding: '0 16px', textAlign: 'right', boxSizing: 'border-box' }}>
330
+ <div style={{ flex: '0 0 var(--cfm-matrix-bipolar-width, 25%)', padding: '0 16px', textAlign: 'right', boxSizing: 'border-box' }}>
316
331
  <span style={{ fontSize: '14px', fontWeight: 500, color: '#111827', lineHeight: 1.4, wordWrap: 'break-word', display: 'block' }}
317
332
  dangerouslySetInnerHTML={{ __html: transposeTable ? '' : ('oppositeStatementText' in rowItem ? rowItem.oppositeStatementText ?? '' : '') }} />
318
333
  </div>
@@ -9,6 +9,7 @@
9
9
  import React, { useState, useRef, useEffect } from 'react';
10
10
  import type { ScaleColumn } from '@explorer02/cfm-survey-sdk';
11
11
  import { matrixColumnStoredValue } from '@explorer02/cfm-survey-sdk';
12
+ import { cellSelectedVar, focusRingVar } from '@/lib/surveyUi/selectionStyles';
12
13
 
13
14
  type MatrixDropdownProps = {
14
15
  options: ScaleColumn[];
@@ -72,9 +73,9 @@ export default function MatrixDropdown({
72
73
  style={{
73
74
  display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px',
74
75
  width: '100%', padding: '10px 14px', backgroundColor: '#fff',
75
- border: `1px solid ${isOpen ? '#e20074' : '#d1d5db'}`, borderRadius: '8px',
76
+ border: `1px solid ${isOpen ? focusRingVar : '#d1d5db'}`, borderRadius: '8px',
76
77
  cursor: 'pointer', transition: 'all 0.2s', minHeight: '44px',
77
- boxShadow: isOpen ? '0 0 0 3px rgba(226, 0, 116, 0.1)' : '0 1px 2px rgba(0,0,0,0.05)',
78
+ boxShadow: isOpen ? '0 0 0 3px var(--cfm-mcq-selected-bg)' : '0 1px 2px rgba(0,0,0,0.05)',
78
79
  }}
79
80
  >
80
81
  {multiValues.length === 0 ? (
@@ -124,9 +125,9 @@ export default function MatrixDropdown({
124
125
  style={{
125
126
  display: 'flex', alignItems: 'center', justifyContent: 'space-between',
126
127
  width: '100%', padding: '14px 16px', backgroundColor: '#fff',
127
- border: `1px solid ${isOpen ? '#e20074' : '#d1d5db'}`, borderRadius: '8px',
128
+ border: `1px solid ${isOpen ? focusRingVar : '#d1d5db'}`, borderRadius: '8px',
128
129
  cursor: 'pointer', transition: 'all 0.2s',
129
- boxShadow: isOpen ? '0 0 0 3px rgba(226, 0, 116, 0.1)' : '0 1px 2px rgba(0,0,0,0.05)',
130
+ boxShadow: isOpen ? '0 0 0 3px var(--cfm-mcq-selected-bg)' : '0 1px 2px rgba(0,0,0,0.05)',
130
131
  }}
131
132
  >
132
133
  <span
@@ -165,12 +166,12 @@ export default function MatrixDropdown({
165
166
  key={col.id}
166
167
  style={{
167
168
  padding: '12px 16px', cursor: isDisabled ? 'not-allowed' : 'pointer', transition: 'background-color 0.15s',
168
- backgroundColor: isSelected ? '#fdf2f8' : '#fff',
169
+ backgroundColor: isSelected ? 'var(--cfm-mcq-selected-bg)' : '#fff',
169
170
  opacity: isDisabled ? 0.5 : 1,
170
171
  display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px',
171
172
  }}
172
- onMouseEnter={e => { if (!isDisabled) e.currentTarget.style.backgroundColor = '#fdf2f8'; }}
173
- onMouseLeave={e => { if (!isDisabled) e.currentTarget.style.backgroundColor = isSelected ? '#fdf2f8' : '#fff'; }}
173
+ onMouseEnter={e => { if (!isDisabled) e.currentTarget.style.backgroundColor = 'var(--cfm-mcq-selected-bg)'; }}
174
+ onMouseLeave={e => { if (!isDisabled) e.currentTarget.style.backgroundColor = isSelected ? 'var(--cfm-mcq-selected-bg)' : '#fff'; }}
174
175
  onClick={e => {
175
176
  e.preventDefault();
176
177
  if (isDisabled) return;
@@ -187,8 +188,8 @@ export default function MatrixDropdown({
187
188
  <div style={{
188
189
  display: 'flex', alignItems: 'center', justifyContent: 'center',
189
190
  width: '18px', height: '18px', borderRadius: '4px',
190
- border: isSelected ? '2px solid #e20074' : '2px solid #d1d5db',
191
- backgroundColor: isSelected ? '#e20074' : '#fff',
191
+ border: isSelected ? `2px solid ${focusRingVar}` : '2px solid #d1d5db',
192
+ backgroundColor: isSelected ? cellSelectedVar : '#fff',
192
193
  flexShrink: 0, transition: 'all 0.15s',
193
194
  }}>
194
195
  {isSelected && (
@@ -198,11 +199,11 @@ export default function MatrixDropdown({
198
199
  )}
199
200
  </div>
200
201
  )}
201
- <span style={{ fontSize: '15px', fontWeight: isSelected ? 600 : 400, color: isSelected ? '#e20074' : '#374151', userSelect: 'none' }}
202
+ <span style={{ fontSize: '15px', fontWeight: isSelected ? 600 : 400, color: isSelected ? focusRingVar : '#374151', userSelect: 'none' }}
202
203
  dangerouslySetInnerHTML={{ __html: col.label }} />
203
204
  </div>
204
205
  {!multiple && isSelected && (
205
- <svg style={{ width: '18px', height: '18px', color: '#e20074', flexShrink: 0 }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
206
+ <svg style={{ width: '18px', height: '18px', color: focusRingVar, flexShrink: 0 }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
206
207
  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
207
208
  </svg>
208
209
  )}
@@ -140,29 +140,6 @@ export default function Question({
140
140
 
141
141
  {question.type === QUESTION_TYPE.NPS_SCALE && (
142
142
  <div className="space-y-3">
143
- {(question.minLabel || question.midLabel || question.maxLabel) && (
144
- <div className="relative w-full h-6 text-xs font-semibold text-gray-500">
145
- <span
146
- className="absolute left-0 top-0 max-w-[30%] text-left leading-tight"
147
- dangerouslySetInnerHTML={{ __html: question.minLabel ?? '' }}
148
- />
149
- {question.midLabel && (
150
- <span
151
- className="absolute top-0 -translate-x-1/2 text-center max-w-[40%] leading-tight"
152
- style={{
153
- left: `${question.midLabelIndex !== undefined && question.options.length > 1
154
- ? (question.midLabelIndex / (question.options.length - 1)) * 100
155
- : 50}%`,
156
- }}
157
- dangerouslySetInnerHTML={{ __html: question.midLabel }}
158
- />
159
- )}
160
- <span
161
- className="absolute right-0 top-0 max-w-[30%] text-right leading-tight"
162
- dangerouslySetInnerHTML={{ __html: question.maxLabel ?? '' }}
163
- />
164
- </div>
165
- )}
166
143
  <RatingScale
167
144
  questionId={question.id}
168
145
  options={question.options}
@@ -170,6 +147,10 @@ export default function Question({
170
147
  onSelect={onSelect}
171
148
  variant="nps"
172
149
  buttonStyle={question.buttonStyle}
150
+ minLabel={question.minLabel}
151
+ midLabel={question.midLabel}
152
+ maxLabel={question.maxLabel}
153
+ midLabelIndex={question.midLabelIndex}
173
154
  />
174
155
  </div>
175
156
  )}
@@ -25,6 +25,7 @@ import {
25
25
  verticalListSortingStrategy,
26
26
  } from '@dnd-kit/sortable';
27
27
  import { CSS } from '@dnd-kit/utilities';
28
+ import { focusRingVar } from '@/lib/surveyUi/selectionStyles';
28
29
  import type {
29
30
  RankOrderAnswers,
30
31
  RankOrderOption,
@@ -119,11 +120,37 @@ function RankOrderOptionRow({
119
120
  rankBadge,
120
121
  }: RankOrderOptionRowProps) {
121
122
  return (
122
- <div className="flex items-center gap-3 rounded-lg border border-[#e5e5e5] bg-white px-4 py-3 transition-colors hover:bg-gray-50/50">
123
+ <div
124
+ style={{
125
+ display: 'flex',
126
+ alignItems: 'center',
127
+ gap: 'var(--cfm-rank-item-gap, 12px)',
128
+ borderRadius: '8px',
129
+ border: '1px solid #e5e5e5',
130
+ backgroundColor: 'var(--cfm-rank-item-bg, #fff)',
131
+ padding: 'var(--cfm-rank-item-padding, 12px 16px)',
132
+ transition: 'background-color 0.15s',
133
+ }}
134
+ className="hover:bg-gray-50/50"
135
+ >
123
136
  {rankSelect}
124
137
  {dragHandle}
125
138
  {rankBadge !== undefined && (
126
- <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[#fdf2f8] text-sm font-semibold text-[#e20074]">
139
+ <span
140
+ style={{
141
+ display: 'flex',
142
+ height: '28px',
143
+ width: '28px',
144
+ flexShrink: 0,
145
+ alignItems: 'center',
146
+ justifyContent: 'center',
147
+ borderRadius: '9999px',
148
+ backgroundColor: 'var(--cfm-rank-badge)',
149
+ fontSize: '14px',
150
+ fontWeight: 600,
151
+ color: '#fff',
152
+ }}
153
+ >
127
154
  {rankBadge}
128
155
  </span>
129
156
  )}
@@ -147,7 +174,7 @@ function DropdownRankLayout({
147
174
  };
148
175
 
149
176
  return (
150
- <div className="space-y-3">
177
+ <div className="space-y-3" style={{ display: 'flex', flexDirection: 'column', gap: 'var(--cfm-rank-item-gap, 12px)' }}>
151
178
  {question.options.map(option => {
152
179
  const currentRank = rankAnswers[option.id];
153
180
 
@@ -161,7 +188,19 @@ function DropdownRankLayout({
161
188
  aria-label={`Rank for ${option.optionLabel || option.id}`}
162
189
  value={currentRank ?? ''}
163
190
  onChange={event => handleRankChange(option.id, event.target.value)}
164
- className="h-10 w-16 shrink-0 rounded border border-gray-300 bg-white px-2 text-sm outline-none focus:border-[#e20074] focus:ring-1 focus:ring-[#e20074]"
191
+ style={{
192
+ height: '40px',
193
+ width: '64px',
194
+ flexShrink: 0,
195
+ borderRadius: '4px',
196
+ border: '1px solid var(--cfm-rank-select-border, #d1d5db)',
197
+ backgroundColor: '#fff',
198
+ padding: '0 8px',
199
+ fontSize: '14px',
200
+ outline: 'none',
201
+ }}
202
+ onFocus={e => { e.currentTarget.style.borderColor = focusRingVar; }}
203
+ onBlur={e => { e.currentTarget.style.borderColor = 'var(--cfm-rank-select-border, #d1d5db)'; }}
165
204
  >
166
205
  <option value="">-</option>
167
206
  {rankChoices.map(rank => (
@@ -218,7 +257,8 @@ function SortableRankItem({
218
257
  <button
219
258
  type="button"
220
259
  aria-label={`Drag to reorder ${option.optionLabel || option.id}`}
221
- className="flex h-10 w-8 shrink-0 cursor-grab items-center justify-center rounded text-gray-500 hover:bg-gray-100 active:cursor-grabbing"
260
+ className="flex h-10 w-8 shrink-0 cursor-grab items-center justify-center rounded active:cursor-grabbing"
261
+ style={{ color: 'var(--cfm-rank-handle, #6b7280)' }}
222
262
  {...attributes}
223
263
  {...listeners}
224
264
  >
@@ -316,7 +356,7 @@ function DragDropRankLayout({
316
356
  return (
317
357
  <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
318
358
  <SortableContext items={orderedOptionIds} strategy={verticalListSortingStrategy}>
319
- <div className="space-y-3">
359
+ <div className="space-y-3" style={{ display: 'flex', flexDirection: 'column', gap: 'var(--cfm-rank-item-gap, 12px)' }}>
320
360
  {orderedOptionIds.map((optionId, index) => {
321
361
  const option = question.options.find(item => item.id === optionId);
322
362
  if (!option) {
@@ -6,6 +6,7 @@ import {
6
6
  scaleRadioDotStyle,
7
7
  scaleCellSelectedStyle,
8
8
  } from '@/lib/surveyUi/selectionStyles';
9
+ import { hintLabelStyle } from '@/lib/surveyUi/labelStyles';
9
10
  import { getAccentColor, npsValueFromOption, starValueFromOption } from './surveyUiScaleUtils';
10
11
  import { getEmojiForIndex } from './surveyUiIcons';
11
12
 
@@ -16,6 +17,10 @@ type RatingScaleProps = {
16
17
  onSelect: (value: AnswerValue) => void;
17
18
  variant?: 'nps' | 'star';
18
19
  buttonStyle?: 'standard' | 'numbered' | 'emoji';
20
+ minLabel?: string;
21
+ midLabel?: string;
22
+ maxLabel?: string;
23
+ midLabelIndex?: number;
19
24
  };
20
25
 
21
26
  function resolveBadgeColor(index: number, value: number): string {
@@ -44,6 +49,10 @@ export default function RatingScale({
44
49
  onSelect,
45
50
  variant = 'nps',
46
51
  buttonStyle = 'numbered',
52
+ minLabel,
53
+ midLabel,
54
+ maxLabel,
55
+ midLabelIndex,
47
56
  }: RatingScaleProps) {
48
57
  const resolvedOptions = options.map((option, index) => {
49
58
  const value =
@@ -63,9 +72,37 @@ export default function RatingScale({
63
72
  gap: 'var(--cfm-nps-cell-gap, 2px)',
64
73
  };
65
74
 
75
+ const hintRow = (minLabel || midLabel || maxLabel) ? (
76
+ <div className="relative mb-3 h-6 w-full text-xs font-semibold">
77
+ <span
78
+ className="absolute left-0 top-0 max-w-[30%] text-left leading-tight"
79
+ style={hintLabelStyle()}
80
+ dangerouslySetInnerHTML={{ __html: minLabel ?? '' }}
81
+ />
82
+ {midLabel && (
83
+ <span
84
+ className="absolute top-0 -translate-x-1/2 max-w-[40%] text-center leading-tight"
85
+ style={{
86
+ ...hintLabelStyle(),
87
+ left: `${midLabelIndex !== undefined && columnCount > 1
88
+ ? (midLabelIndex / (columnCount - 1)) * 100
89
+ : 50}%`,
90
+ }}
91
+ dangerouslySetInnerHTML={{ __html: midLabel }}
92
+ />
93
+ )}
94
+ <span
95
+ className="absolute right-0 top-0 max-w-[30%] text-right leading-tight"
96
+ style={hintLabelStyle()}
97
+ dangerouslySetInnerHTML={{ __html: maxLabel ?? '' }}
98
+ />
99
+ </div>
100
+ ) : null;
101
+
66
102
  if (buttonStyle === 'emoji') {
67
103
  return (
68
104
  <div role="radiogroup" className="w-full overflow-x-auto" data-cfm-nps-area>
105
+ {hintRow}
69
106
  <div className="min-w-0" style={gridStyle}>
70
107
  {resolvedOptions.map((option, index) => {
71
108
  const isSelected = selectedValue === option.value;
@@ -90,6 +127,7 @@ export default function RatingScale({
90
127
 
91
128
  return (
92
129
  <div role="radiogroup" className="w-full overflow-x-auto" data-cfm-nps-area>
130
+ {hintRow}
93
131
  <div className="mb-3 min-w-0" style={gridStyle}>
94
132
  {resolvedOptions.map((option, index) => {
95
133
  const isSelected = selectedValue === option.value;
@@ -7,6 +7,7 @@
7
7
 
8
8
  import React from 'react';
9
9
  import type { SliderMatrixQuestion, MatrixRowAnswers } from '@explorer02/cfm-survey-sdk';
10
+ import { sliderTickLabelStyle } from '@/lib/surveyUi/labelStyles';
10
11
  import { tickColorFromIndex } from './surveyUiScaleUtils';
11
12
  import { CustomSliderTrack } from './CustomSliderTrack';
12
13
 
@@ -97,7 +98,7 @@ export function SliderMatrixScale({ question, selectedValue = {}, onSelect }: Sl
97
98
  width: `${sliceWidth}%`, display: 'flex', justifyContent: 'center',
98
99
  }}>
99
100
  {sliderType === 'graphics' ? (
100
- <div style={{ wordBreak: 'break-word', textAlign: 'center', lineHeight: '1.2', fontWeight: 500, color: '#4b5563', fontSize: '13px' }}>
101
+ <div style={{ wordBreak: 'break-word', textAlign: 'center', lineHeight: '1.2', fontWeight: 500, fontSize: '13px', ...sliderTickLabelStyle() }}>
101
102
  {tv.label}
102
103
  </div>
103
104
  ) : (
@@ -121,7 +122,7 @@ export function SliderMatrixScale({ question, selectedValue = {}, onSelect }: Sl
121
122
  position: 'absolute', left: `${percentage}%`, transform: 'translateX(-50%)',
122
123
  width: `${sliceWidth}%`, display: 'flex', justifyContent: 'center',
123
124
  }}>
124
- <span style={{ wordBreak: 'break-word', textAlign: 'center', lineHeight: '1.2' }}>{m}</span>
125
+ <span style={{ wordBreak: 'break-word', textAlign: 'center', lineHeight: '1.2', ...sliderTickLabelStyle() }}>{m}</span>
125
126
  </div>
126
127
  );
127
128
  })
@@ -151,7 +152,7 @@ export function SliderMatrixScale({ question, selectedValue = {}, onSelect }: Sl
151
152
  return (
152
153
  <div key={row.id} style={{
153
154
  display: 'flex', width: '100%', alignItems: 'center',
154
- backgroundColor: '#f3f4f6', borderRadius: '12px', padding: '16px', boxSizing: 'border-box',
155
+ backgroundColor: 'var(--cfm-slider-row-band)', borderRadius: '12px', padding: '16px', boxSizing: 'border-box',
155
156
  }}>
156
157
  <div style={{ flex: '0 0 25%', paddingRight: '16px', fontSize: '15px', color: '#111827', wordWrap: 'break-word', display: 'flex', alignItems: 'center' }}>
157
158
  {row.statementText !== 'Question' && row.statementText !== '' ? (
@@ -102,6 +102,23 @@ if ! grep -q "layoutFlags" "$PREVIEW_BRIDGE"; then
102
102
  fi
103
103
  echo "OK: previewBridge.ts present with layoutFlags"
104
104
 
105
+ # Scale templates must consume wizard CSS vars (not only RatingScale)
106
+ SCALE_CFM_COUNT=0
107
+ for candidate in src/components/LikertMatrixScale.tsx components/LikertMatrixScale.tsx app/components/LikertMatrixScale.tsx; do
108
+ if [ -f "$candidate" ] && grep -q -- '--cfm-' "$candidate"; then SCALE_CFM_COUNT=1; break; fi
109
+ done
110
+ if [ "$SCALE_CFM_COUNT" -eq 0 ]; then
111
+ echo "WARN: LikertMatrixScale.tsx missing --cfm-* vars — copy from docs/templates"
112
+ fi
113
+
114
+ SCALE_CSAT_COUNT=0
115
+ for candidate in src/components/CsatMatrixScale.tsx components/CsatMatrixScale.tsx app/components/CsatMatrixScale.tsx; do
116
+ if [ -f "$candidate" ] && grep -q -- '--cfm-' "$candidate"; then SCALE_CSAT_COUNT=1; break; fi
117
+ done
118
+ if [ "$SCALE_CSAT_COUNT" -eq 0 ]; then
119
+ echo "WARN: CsatMatrixScale.tsx missing --cfm-* vars — copy from docs/templates"
120
+ fi
121
+
105
122
  REQUIRED_TYPES=(MCQ TEXTFIELD NPS_SCALE CFM_MATRIX CSAT_MATRIX RATING_MATRIX SLIDER_MATRIX FILE_UPLOAD TEXT_AND_MEDIA HEATMAP RANK_ORDER)
106
123
  QUESTION_FILE=""
107
124
  for candidate in src/components/Question.tsx components/Question.tsx app/components/Question.tsx; do
@@ -176,6 +176,16 @@
176
176
  el.setAttribute('placeholder', patch.previewState.textfieldPlaceholder || '');
177
177
  });
178
178
  }
179
+ if (patch.previewState && patch.previewState.hintMinText !== undefined) {
180
+ document.querySelectorAll('[data-cfm-hint-min]').forEach(function (el) {
181
+ el.textContent = patch.previewState.hintMinText || '';
182
+ });
183
+ }
184
+ if (patch.previewState && patch.previewState.hintMaxText !== undefined) {
185
+ document.querySelectorAll('[data-cfm-hint-max]').forEach(function (el) {
186
+ el.textContent = patch.previewState.hintMaxText || '';
187
+ });
188
+ }
179
189
  }
180
190
 
181
191
  function handleMessage(event) {
@@ -3,8 +3,9 @@ export type PreviewPageDef = { key: string; title: string; body: string };
3
3
 
4
4
  const sharedStyles = `
5
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); }
6
- .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:var(--cfm-primary); }
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; }
6
+ .cfm-header-brand { display:flex; align-items:center; gap:var(--cfm-header-brand-gap,16px); }
7
+ .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:var(--cfm-header-logo-bg,transparent); }
8
+ .cfm-header-company { font-size:var(--cfm-header-company-size); font-weight:var(--cfm-header-company-weight); color:var(--cfm-header-company-color); }
8
9
  .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; }
9
10
  .cfm-footer-links { display:flex; gap:24px; flex-wrap:wrap; }
10
11
  .cfm-footer-links a { color:var(--cfm-footer-link); text-decoration:none; font-size:12px; }
@@ -28,7 +29,7 @@ const sharedStyles = `
28
29
  .cfm-slider-track { height:8px; background:var(--cfm-slider-track); border-radius:4px; position:relative; margin:24px 0; }
29
30
  .cfm-slider-thumb { width:20px; height:20px; background:var(--cfm-slider-thumb); border-radius:50%; position:absolute; top:-6px; left:60%; }
30
31
  .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; }
31
- .cfm-dropzone { border:2px dashed var(--cfm-upload-accent); border-radius:var(--cfm-border-radius); padding:var(--cfm-upload-padding); text-align:center; color:var(--cfm-text); }
32
+ .cfm-dropzone { border:2px dashed var(--cfm-upload-border-color); border-radius:var(--cfm-border-radius); padding:var(--cfm-upload-padding); background:var(--cfm-upload-fill); text-align:center; color:var(--cfm-text); }
32
33
  .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; }
33
34
  .cfm-input:focus { outline:2px solid var(--cfm-input-focus-ring); }
34
35
  .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; }
@@ -59,10 +60,7 @@ export const PREVIEW_PAGE_DEFS: PreviewPageDef[] = [
59
60
  {
60
61
  key: 'header',
61
62
  title: 'Header',
62
- body: `<header class="cfm-header">
63
- <img data-cfm-logo class="cfm-header-logo" src="" alt="Logo" style="display:none" />
64
- <span data-cfm-company class="cfm-header-company">Company Name</span>
65
- </header>`,
63
+ body: `<header class="cfm-header"><div class="cfm-header-brand"><img data-cfm-logo class="cfm-header-logo" src="" alt="Logo" style="display:none" /><span data-cfm-company class="cfm-header-company"></span></div></header>`,
66
64
  },
67
65
  {
68
66
  key: 'footer',
@@ -65,12 +65,17 @@ function QuestionPreviewInner({ question, initialValue }: QuestionPreviewProps)
65
65
  }
66
66
  }
67
67
 
68
- if (previewState.multiStatement && 'statementRows' in q && q.statementRows.length === 1) {
69
- const extra = {
70
- id: 'r2',
71
- statementText: 'Second statement row',
72
- };
73
- q = { ...q, statementRows: [...q.statementRows, extra] } as typeof q;
68
+ const matrixTypes = new Set(['CFM_MATRIX', 'CSAT_MATRIX', 'RATING_MATRIX', 'SLIDER_MATRIX']);
69
+ if ('statementRows' in q && matrixTypes.has(q.type)) {
70
+ const rows = q.statementRows ?? [];
71
+ if (previewState.multiStatement) {
72
+ if (rows.length === 1) {
73
+ const extra = { id: 'r2', statementText: 'Second statement row' };
74
+ q = { ...q, statementRows: [...rows, extra] } as typeof q;
75
+ }
76
+ } else if (rows.length > 1) {
77
+ q = { ...q, statementRows: rows.slice(0, 1) } as typeof q;
78
+ }
74
79
  }
75
80
 
76
81
  if (previewState.rankVariant === 'drag' && q.type === 'RANK_ORDER') {