@explorer02/cfm-survey-sdk 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/cli/index.js +94 -83
  2. package/dist/cli/index.mjs +94 -83
  3. package/package.json +1 -1
  4. package/templates/docs/templates/CsatMatrixScale.tsx +18 -24
  5. package/templates/docs/templates/CustomSliderTrack.tsx +5 -3
  6. package/templates/docs/templates/FileUploadScale.tsx +143 -18
  7. package/templates/docs/templates/HeatmapScale.tsx +11 -2
  8. package/templates/docs/templates/LanguageSelector.tsx +42 -19
  9. package/templates/docs/templates/LikertMatrixScale.tsx +43 -33
  10. package/templates/docs/templates/MatrixDropdown.tsx +12 -11
  11. package/templates/docs/templates/Question.tsx +109 -44
  12. package/templates/docs/templates/RankOrderScale.tsx +22 -5
  13. package/templates/docs/templates/RatingScale.tsx +5 -3
  14. package/templates/docs/templates/SliderMatrixScale.tsx +44 -16
  15. package/templates/docs/templates/selectionStyles.ts +88 -1
  16. package/templates/docs/templates/surveyUiIcons.tsx +2 -2
  17. package/templates/docs/templates/surveyUiScaleUtils.ts +38 -5
  18. package/templates/docs/templates/uiConfig.ts +56 -0
  19. package/templates/preview-harness/previewPages.js +4 -7
  20. package/templates/preview-harness/previewPages.ts +7 -34
  21. package/templates/preview-harness/vite-app/src/PreviewConfigContext.tsx +7 -0
  22. package/templates/preview-harness/vite-app/src/QuestionPreview.tsx +59 -30
  23. package/templates/preview-harness/vite-app/src/SurveyPagePreview.tsx +36 -8
  24. package/templates/preview-harness/vite-app/src/fixtures/questions.ts +182 -82
  25. package/templates/survey-theme.css +13 -4
  26. package/templates/wizard-dist/assets/{PreviewMock-Bax7oRAL.js → PreviewMock-DbbLpHdF.js} +1 -1
  27. package/templates/wizard-dist/assets/TypePanel-DQbV2iCf.js +1 -0
  28. package/templates/wizard-dist/assets/index-CY7WMJ93.js +34 -0
  29. package/templates/wizard-dist/index.html +1 -1
  30. package/templates/wizard-dist/assets/TypePanel-D2t4FPSd.js +0 -1
  31. package/templates/wizard-dist/assets/index-c5lka74l.js +0 -34
@@ -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 { focusRingVar, mcqSelectedAccentVar, mcqSelectedBgVar } 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-input-focus-glow, rgba(226, 0, 116, 0.1))' : '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-input-focus-glow, rgba(226, 0, 116, 0.1))' : '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 ? mcqSelectedBgVar : '#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 = mcqSelectedBgVar; }}
174
+ onMouseLeave={e => { if (!isDisabled) e.currentTarget.style.backgroundColor = isSelected ? mcqSelectedBgVar : '#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 ${mcqSelectedAccentVar}` : '2px solid #d1d5db',
192
+ backgroundColor: isSelected ? mcqSelectedAccentVar : '#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 ? mcqSelectedAccentVar : '#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: mcqSelectedAccentVar, 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
  )}
@@ -11,7 +11,7 @@
11
11
 
12
12
  import React, { useMemo } from 'react';
13
13
  import type { SurveyQuestion, AnswerValue, SurveyAnswers, CustomFieldValueMap, HeatmapAnswer, HeatmapQuestion } from '@explorer02/cfm-survey-sdk';
14
- import { QUESTION_TYPE, resolveSurveyQuestionPlaceholders, getVisibleMatrixItemsForAnswers, getVisibleMcqOptionsForAnswers, getVisibleRankOrderOptionsForAnswers } from '@explorer02/cfm-survey-sdk';
14
+ import { QUESTION_TYPE, resolveSurveyQuestionPlaceholders, getVisibleMatrixItemsForAnswers, getVisibleMcqOptionsForAnswers, getVisibleRankOrderOptionsForAnswers, normalizeRankOrderAnswers } from '@explorer02/cfm-survey-sdk';
15
15
  import { CsatMatrixScale } from './CsatMatrixScale';
16
16
  import { SliderMatrixScale } from './SliderMatrixScale';
17
17
  import { LikertMatrixScale } from './LikertMatrixScale';
@@ -20,6 +20,13 @@ import { HeatmapScale } from './HeatmapScale';
20
20
  import { RankOrderScale } from './RankOrderScale';
21
21
  import RatingScale from './RatingScale';
22
22
  import { hintLabelStyle } from '@/lib/surveyUi/labelStyles';
23
+ import {
24
+ mcqCheckboxControlStyle,
25
+ mcqOptionCardStyle,
26
+ mcqRadioInnerStyle,
27
+ mcqRadioOuterStyle,
28
+ focusRingVar,
29
+ } from '@/lib/surveyUi/selectionStyles';
23
30
 
24
31
  type QuestionProps = {
25
32
  question: SurveyQuestion;
@@ -205,14 +212,8 @@ export default function Question({
205
212
  return (
206
213
  <label
207
214
  key={option.id}
208
- className="flex cursor-pointer items-center gap-4 transition-all"
209
- style={{
210
- borderRadius: 'var(--cfm-mcq-border-radius, 8px)',
211
- border: `2px solid ${isSelected ? 'var(--cfm-mcq-selected-accent, var(--cfm-primary))' : 'var(--cfm-card-border-color, #e5e7eb)'}`,
212
- background: isSelected ? 'var(--cfm-mcq-selected-bg, var(--cfm-secondary))' : 'var(--cfm-card-bg, #fff)',
213
- padding: 'var(--cfm-mcq-card-padding, 12px)',
214
- marginBottom: 'var(--cfm-mcq-option-gap, 8px)',
215
- }}
215
+ className="cfm-mcq-option flex cursor-pointer items-center gap-4 transition-all hover:bg-gray-50/50"
216
+ style={mcqOptionCardStyle(isSelected)}
216
217
  >
217
218
  <input
218
219
  type="checkbox"
@@ -221,6 +222,13 @@ export default function Question({
221
222
  onChange={() => handleToggle(option.id)}
222
223
  className="sr-only"
223
224
  />
225
+ <div style={mcqCheckboxControlStyle(isSelected)}>
226
+ {isSelected && (
227
+ <svg className="h-3 w-3 text-white" viewBox="0 0 12 12" fill="none" aria-hidden="true">
228
+ <path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
229
+ </svg>
230
+ )}
231
+ </div>
224
232
  <span
225
233
  className="text-[15px] font-medium text-gray-900 leading-tight"
226
234
  dangerouslySetInnerHTML={{ __html: option.optionLabel }}
@@ -241,14 +249,8 @@ export default function Question({
241
249
  return (
242
250
  <label
243
251
  key={option.id}
244
- className="flex cursor-pointer items-center gap-4 transition-all"
245
- style={{
246
- borderRadius: 'var(--cfm-mcq-border-radius, 8px)',
247
- border: `2px solid ${isSelected ? 'var(--cfm-mcq-selected-accent, var(--cfm-primary))' : 'var(--cfm-card-border-color, #e5e7eb)'}`,
248
- background: isSelected ? 'var(--cfm-mcq-selected-bg, var(--cfm-secondary))' : 'var(--cfm-card-bg, #fff)',
249
- padding: 'var(--cfm-mcq-card-padding, 12px)',
250
- marginBottom: 'var(--cfm-mcq-option-gap, 8px)',
251
- }}
252
+ className="cfm-mcq-option flex cursor-pointer items-center gap-4 transition-all hover:bg-gray-50/50"
253
+ style={mcqOptionCardStyle(isSelected)}
252
254
  >
253
255
  <input
254
256
  type="radio"
@@ -257,6 +259,9 @@ export default function Question({
257
259
  onChange={() => onSelect(option.id)}
258
260
  className="sr-only"
259
261
  />
262
+ <div style={mcqRadioOuterStyle(isSelected)}>
263
+ {isSelected && <div style={mcqRadioInnerStyle()} />}
264
+ </div>
260
265
  <span
261
266
  className="text-[15px] font-medium text-gray-900 leading-tight"
262
267
  dangerouslySetInnerHTML={{ __html: option.optionLabel }}
@@ -271,31 +276,48 @@ export default function Question({
271
276
  {question.type === QUESTION_TYPE.RANK_ORDER && rankOrderQuestion && (
272
277
  <RankOrderScale
273
278
  question={rankOrderQuestion}
274
- selectedValue={
275
- typeof selectedValue === 'object' && selectedValue !== null && !Array.isArray(selectedValue)
276
- ? selectedValue
277
- : undefined
278
- }
279
+ selectedValue={normalizeRankOrderAnswers(selectedValue)}
279
280
  onSelect={onSelect}
280
281
  />
281
282
  )}
282
283
 
283
284
  {question.type === QUESTION_TYPE.TEXTFIELD && (
284
- <textarea
285
- data-cfm-textfield
286
- rows={question.inputVariant === 'long' ? 4 : 1}
287
- value={(selectedValue as string) ?? ''}
288
- onChange={e => onSelect(e.target.value)}
289
- placeholder={question.placeholder || 'Type your response here...'}
290
- className="w-full outline-none"
291
- style={{
292
- height: question.inputVariant === 'long' ? undefined : 'var(--cfm-input-height, 44px)',
293
- borderRadius: 'var(--cfm-input-radius, 8px)',
294
- border: '1px solid var(--cfm-input-border, #d1d5db)',
295
- padding: 'var(--cfm-input-padding, 12px)',
296
- color: 'var(--cfm-text)',
297
- }}
298
- />
285
+ <div className="space-y-1.5">
286
+ <textarea
287
+ data-cfm-textfield
288
+ rows={question.inputVariant === 'long' ? 4 : 1}
289
+ value={(selectedValue as string) ?? ''}
290
+ onChange={e => onSelect(e.target.value)}
291
+ placeholder={question.placeholder || 'Type your response here...'}
292
+ className="w-full text-sm outline-none transition-colors"
293
+ style={{
294
+ height: question.inputVariant === 'long' ? undefined : 'var(--cfm-input-height, 44px)',
295
+ borderRadius: 'var(--cfm-input-radius, 4px)',
296
+ border: '1px solid var(--cfm-input-border, #d1d5db)',
297
+ padding: 'var(--cfm-input-padding, 16px)',
298
+ color: 'var(--cfm-text)',
299
+ }}
300
+ onFocus={e => {
301
+ e.currentTarget.style.borderColor = focusRingVar;
302
+ e.currentTarget.style.boxShadow = `0 0 0 1px ${focusRingVar}`;
303
+ }}
304
+ onBlur={e => {
305
+ e.currentTarget.style.borderColor = 'var(--cfm-input-border, #d1d5db)';
306
+ e.currentTarget.style.boxShadow = 'none';
307
+ }}
308
+ />
309
+ <div
310
+ className="text-right text-xs pr-1"
311
+ style={{ color: 'var(--cfm-input-placeholder, #6b7280)' }}
312
+ >
313
+ {Math.max(
314
+ 0,
315
+ (question.maxCharacterCount ?? 1000) -
316
+ (typeof selectedValue === 'string' ? selectedValue.length : 0)
317
+ )}{' '}
318
+ characters remaining
319
+ </div>
320
+ </div>
299
321
  )}
300
322
 
301
323
  {question.type === QUESTION_TYPE.CFM_MATRIX && (
@@ -336,21 +358,64 @@ export default function Question({
336
358
  )}
337
359
 
338
360
  {question.type === QUESTION_TYPE.TEXT_AND_MEDIA && question.media?.url && (
339
- <div className={`mt-4 flex w-full ${question.media.alignment?.includes('CENTER') ? 'justify-center' : question.media.alignment?.includes('RIGHT') ? 'justify-end' : 'justify-start'}`}>
340
- <div style={{ width: question.media.size ? `${question.media.size}%` : '100%' }}>
361
+ <div
362
+ className={`mt-4 flex w-full ${
363
+ question.media.alignment === 'TOP_CENTER' || question.media.alignment === 'BOTTOM_CENTER'
364
+ ? 'justify-center'
365
+ : question.media.alignment === 'TOP_RIGHT' || question.media.alignment === 'BOTTOM_RIGHT'
366
+ ? 'justify-end'
367
+ : 'justify-start'
368
+ }`}
369
+ >
370
+ <div
371
+ style={{
372
+ width: question.media.size
373
+ ? `${question.media.size}%`
374
+ : 'var(--cfm-media-max-width, 100%)',
375
+ maxWidth: 'var(--cfm-media-max-width, 100%)',
376
+ }}
377
+ className="flex flex-col gap-2"
378
+ >
341
379
  {question.media.mimeType?.startsWith('video/') || question.media.mimeType === 'VIDEO' ? (
342
- <video controls src={question.media.url} className="max-w-full rounded-md" />
380
+ <video
381
+ controls
382
+ src={question.media.url}
383
+ className="max-w-full shadow-sm"
384
+ style={{ borderRadius: 'var(--cfm-media-radius, 8px)' }}
385
+ />
343
386
  ) : (
344
- <img src={question.media.url} alt={question.media.title || ''} className="max-w-full rounded-md" />
387
+ <img
388
+ src={question.media.url}
389
+ alt={question.media.title || ''}
390
+ className="max-w-full shadow-sm"
391
+ style={{ borderRadius: 'var(--cfm-media-radius, 8px)' }}
392
+ />
393
+ )}
394
+ {question.media.title && (
395
+ <p
396
+ className="text-center"
397
+ style={{
398
+ color: 'var(--cfm-media-caption-color, #6b7280)',
399
+ fontSize: 'var(--cfm-media-caption-size, 14px)',
400
+ }}
401
+ >
402
+ {question.media.title}
403
+ </p>
345
404
  )}
346
- {question.media.title && <p className="text-sm text-gray-500 text-center mt-2">{question.media.title}</p>}
347
405
  </div>
348
406
  </div>
349
407
  )}
350
408
 
351
409
  {validationError && (
352
- <div className="mt-4 rounded border border-gray-800 bg-gray-200 py-3 px-4">
353
- <p className="text-sm font-bold text-gray-900">{validationError}</p>
410
+ <div
411
+ className="mt-4 rounded py-3.5 px-4 text-left"
412
+ style={{
413
+ border: '1px solid var(--cfm-validation-border, #333333)',
414
+ backgroundColor: 'var(--cfm-validation-bg, #d9d9d9)',
415
+ borderRadius: 'var(--cfm-input-radius, 4px)',
416
+ }}
417
+ >
418
+ <p className="text-[13px] sm:text-sm font-bold text-gray-900 leading-tight">{validationError}</p>
354
419
  </div>
355
420
  )}
356
421
  </section>
@@ -37,6 +37,7 @@ import {
37
37
  getOrderedOptionIdsFromRanks,
38
38
  normalizeRankOrderAnswers,
39
39
  } from '@explorer02/cfm-survey-sdk';
40
+ import { focusRingVar } from '@/lib/surveyUi/selectionStyles';
40
41
 
41
42
  type RankOrderScaleProps = {
42
43
  question: RankOrderQuestion;
@@ -120,15 +121,22 @@ function RankOrderOptionRow({
120
121
  }: RankOrderOptionRowProps) {
121
122
  return (
122
123
  <div
123
- className="flex items-center gap-3 rounded-lg border border-[#e5e5e5] px-4 py-3 transition-colors"
124
- style={{ backgroundColor: 'var(--cfm-rank-item-bg, #fff)', padding: 'var(--cfm-rank-item-padding, 12px 16px)', marginBottom: 'var(--cfm-rank-item-gap, 12px)' }}
124
+ className="flex items-center gap-3 rounded-lg border border-[#e5e5e5] transition-colors hover:bg-gray-50/50"
125
+ style={{
126
+ backgroundColor: 'var(--cfm-rank-item-bg, #fff)',
127
+ padding: 'var(--cfm-rank-item-padding, 12px 16px)',
128
+ marginBottom: 'var(--cfm-rank-item-gap, 12px)',
129
+ }}
125
130
  >
126
131
  {rankSelect}
127
132
  {dragHandle}
128
133
  {rankBadge !== undefined && (
129
134
  <span
130
- className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-sm font-semibold text-white"
131
- style={{ backgroundColor: 'var(--cfm-rank-badge, var(--cfm-primary))' }}
135
+ className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-sm font-semibold"
136
+ style={{
137
+ backgroundColor: 'var(--cfm-rank-badge-bg, var(--cfm-mcq-selected-bg, #fdf2f8))',
138
+ color: 'var(--cfm-rank-badge-text, var(--cfm-mcq-selected-accent, var(--cfm-primary, #e20074)))',
139
+ }}
132
140
  >
133
141
  {rankBadge}
134
142
  </span>
@@ -169,6 +177,14 @@ function DropdownRankLayout({
169
177
  onChange={event => handleRankChange(option.id, event.target.value)}
170
178
  className="h-10 w-16 shrink-0 rounded border bg-white px-2 text-sm outline-none"
171
179
  style={{ borderColor: 'var(--cfm-rank-select-border, #d1d5db)' }}
180
+ onFocus={e => {
181
+ e.currentTarget.style.borderColor = focusRingVar;
182
+ e.currentTarget.style.boxShadow = `0 0 0 1px ${focusRingVar}`;
183
+ }}
184
+ onBlur={e => {
185
+ e.currentTarget.style.borderColor = 'var(--cfm-rank-select-border, #d1d5db)';
186
+ e.currentTarget.style.boxShadow = 'none';
187
+ }}
172
188
  >
173
189
  <option value="">-</option>
174
190
  {rankChoices.map(rank => (
@@ -225,7 +241,8 @@ function SortableRankItem({
225
241
  <button
226
242
  type="button"
227
243
  aria-label={`Drag to reorder ${option.optionLabel || option.id}`}
228
- 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"
244
+ className="flex h-10 w-8 shrink-0 cursor-grab items-center justify-center rounded active:cursor-grabbing"
245
+ style={{ color: 'var(--cfm-rank-handle, #9ca3af)' }}
229
246
  {...attributes}
230
247
  {...listeners}
231
248
  >
@@ -20,7 +20,7 @@ type RatingScaleProps = {
20
20
  selectedValue?: AnswerValue;
21
21
  onSelect: (value: AnswerValue) => void;
22
22
  variant?: 'nps' | 'star';
23
- buttonStyle?: 'standard' | 'numbered' | 'emoji';
23
+ buttonStyle?: 'standard' | 'numbered' | 'emoji' | 'pill';
24
24
  };
25
25
 
26
26
  function resolveBadgeColor(index: number, value: number): string {
@@ -68,6 +68,7 @@ export default function RatingScale({
68
68
  gap: 'var(--cfm-nps-cell-gap, 2px)',
69
69
  };
70
70
  const showNumberBadges = buttonStyle !== 'standard';
71
+ const usePillCells = buttonStyle === 'pill';
71
72
 
72
73
  if (buttonStyle === 'emoji') {
73
74
  return (
@@ -92,7 +93,7 @@ export default function RatingScale({
92
93
  style={{
93
94
  ...scaleRadioRingStyle(isSelected, {
94
95
  pill: false,
95
- size: 'var(--cfm-csat-emoji-size, 48px)',
96
+ size: 'var(--cfm-csat-emoji-size, 40px)',
96
97
  }),
97
98
  fontSize: 'var(--cfm-csat-emoji-size, 24px)',
98
99
  }}
@@ -158,9 +159,10 @@ export default function RatingScale({
158
159
  className="flex items-center justify-center rounded-full border-2 transition-all"
159
160
  style={scaleRadioRingStyle(isSelected, {
160
161
  size: 'var(--cfm-nps-cell-size, 40px)',
162
+ pill: usePillCells,
161
163
  })}
162
164
  >
163
- <div style={scaleRadioDotStyle(isSelected)} />
165
+ <div style={scaleRadioDotStyle(isSelected, { pill: usePillCells })} />
164
166
  </div>
165
167
  </label>
166
168
  );
@@ -9,6 +9,10 @@ import React from 'react';
9
9
  import type { SliderMatrixQuestion, MatrixRowAnswers } from '@explorer02/cfm-survey-sdk';
10
10
  import { tickColorFromIndex } from './surveyUiScaleUtils';
11
11
  import { CustomSliderTrack } from './CustomSliderTrack';
12
+ import { hintLabelStyle, sliderTickLabelStyle } from '@/lib/surveyUi/labelStyles';
13
+
14
+ const MATRIX_ROW_LABEL_WIDTH = 'var(--cfm-matrix-row-width, 180px)';
15
+ const SLIDER_ROW_BAND = 'var(--cfm-slider-row-band, var(--cfm-row-band, #f3f4f6))';
12
16
 
13
17
  type SliderMatrixScaleProps = {
14
18
  question: SliderMatrixQuestion;
@@ -67,14 +71,22 @@ export function SliderMatrixScale({ question, selectedValue = {}, onSelect }: Sl
67
71
  display: 'flex', width: '100%', marginBottom: '4px', alignItems: 'flex-end',
68
72
  padding: '0 16px', boxSizing: 'border-box',
69
73
  }}>
70
- <div style={{ flex: '0 0 25%', paddingRight: '16px' }} />
74
+ <div style={{ flex: `0 0 ${MATRIX_ROW_LABEL_WIDTH}`, paddingRight: '16px' }} />
71
75
 
72
76
  <div style={{ flex: '1', position: 'relative' }}>
73
77
  {labels && labels.length > 0 && (
74
- <div style={{
75
- position: 'relative', display: 'flex', justifyContent: 'space-between',
76
- margin: '0 16px', marginBottom: '16px', color: '#111827', fontSize: '14px', fontWeight: 500,
77
- }}>
78
+ <div
79
+ style={{
80
+ position: 'relative',
81
+ display: 'flex',
82
+ justifyContent: 'space-between',
83
+ margin: '0 16px',
84
+ marginBottom: '16px',
85
+ fontSize: '14px',
86
+ fontWeight: 500,
87
+ ...hintLabelStyle(),
88
+ }}
89
+ >
78
90
  {labels.map((lbl, idx) => (
79
91
  <div key={idx} style={{ width: 0, display: 'flex', justifyContent: 'center' }}>
80
92
  <span style={{ whiteSpace: 'nowrap' }}>{lbl}</span>
@@ -83,10 +95,17 @@ export function SliderMatrixScale({ question, selectedValue = {}, onSelect }: Sl
83
95
  </div>
84
96
  )}
85
97
 
86
- <div style={{
87
- position: 'relative', margin: '0 16px', marginBottom: '8px',
88
- color: '#4b5563', fontSize: '14px', fontWeight: 400, height: '28px',
89
- }}>
98
+ <div
99
+ style={{
100
+ position: 'relative',
101
+ margin: '0 16px',
102
+ marginBottom: '8px',
103
+ fontSize: '14px',
104
+ fontWeight: 400,
105
+ height: '28px',
106
+ ...sliderTickLabelStyle(),
107
+ }}
108
+ >
90
109
  {tickValues.length > 0 ? (
91
110
  tickValues.map((tv, idx) => {
92
111
  const percentage = max !== min ? ((tv.value - min) / (max - min)) * 100 : 0;
@@ -101,11 +120,20 @@ export function SliderMatrixScale({ question, selectedValue = {}, onSelect }: Sl
101
120
  {tv.label}
102
121
  </div>
103
122
  ) : (
104
- <div style={{
105
- backgroundColor: tv.color, color: 'white', padding: '4px 8px', borderRadius: '4px',
106
- fontWeight: 600, wordBreak: 'break-word', textAlign: 'center', lineHeight: '1.2',
107
- boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
108
- }}>
123
+ <div
124
+ style={{
125
+ backgroundColor: tv.color,
126
+ color: 'white',
127
+ padding: '4px 8px',
128
+ borderRadius: '4px',
129
+ fontWeight: 600,
130
+ wordBreak: 'break-word',
131
+ textAlign: 'center',
132
+ lineHeight: '1.2',
133
+ boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
134
+ minWidth: 'var(--cfm-slider-tick-size, 28px)',
135
+ }}
136
+ >
109
137
  {tv.label}
110
138
  </div>
111
139
  )}
@@ -151,9 +179,9 @@ export function SliderMatrixScale({ question, selectedValue = {}, onSelect }: Sl
151
179
  return (
152
180
  <div key={row.id} style={{
153
181
  display: 'flex', width: '100%', alignItems: 'center',
154
- backgroundColor: '#f3f4f6', borderRadius: '12px', padding: '16px', boxSizing: 'border-box',
182
+ backgroundColor: SLIDER_ROW_BAND, borderRadius: '12px', padding: '16px', boxSizing: 'border-box',
155
183
  }}>
156
- <div style={{ flex: '0 0 25%', paddingRight: '16px', fontSize: '15px', color: '#111827', wordWrap: 'break-word', display: 'flex', alignItems: 'center' }}>
184
+ <div style={{ flex: `0 0 ${MATRIX_ROW_LABEL_WIDTH}`, paddingRight: '16px', fontSize: '15px', color: '#111827', wordWrap: 'break-word', display: 'flex', alignItems: 'center' }}>
157
185
  {row.statementText !== 'Question' && row.statementText !== '' ? (
158
186
  <span dangerouslySetInnerHTML={{ __html: row.statementText }} />
159
187
  ) : null}
@@ -8,9 +8,96 @@
8
8
  */
9
9
  import type { CSSProperties } from 'react';
10
10
 
11
- /** Terminal hex fallbacks match apps/client RatingScale — visible before survey-theme.css loads. */
11
+ /** Terminal hex fallbacks match apps/client — visible before survey-theme.css loads. */
12
12
  export const cellSelectedVar = 'var(--cfm-matrix-selected, var(--cfm-primary, #fbe8f3))';
13
13
  export const focusRingVar = 'var(--cfm-input-focus-ring, var(--cfm-primary, #e20074))';
14
+ export const mcqSelectedAccentVar = focusRingVar;
15
+ export const mcqSelectedBgVar = 'var(--cfm-mcq-selected-bg, var(--cfm-secondary, #fdf2f8))';
16
+ export const matrixCarouselDotVar = 'var(--cfm-matrix-carousel-dot, #2563eb)';
17
+
18
+ const unselectedBorder = 'var(--cfm-card-border-color, #e5e5e5)';
19
+
20
+ function readMcqOptionStyle(): string {
21
+ if (typeof document === 'undefined') return 'outlined';
22
+ const root = document.documentElement;
23
+ return (
24
+ root.getAttribute('data-cfm-mcq-style') ||
25
+ getComputedStyle(root).getPropertyValue('--cfm-mcq-option-style').trim() ||
26
+ 'outlined'
27
+ );
28
+ }
29
+
30
+ /** MCQ option card — border, background, padding (wizard: --cfm-mcq-*). */
31
+ export function mcqOptionCardStyle(isSelected: boolean): CSSProperties {
32
+ const optionStyle = readMcqOptionStyle();
33
+ const base: CSSProperties = {
34
+ borderRadius: 'var(--cfm-mcq-border-radius, 8px)',
35
+ border: `2px solid ${isSelected ? focusRingVar : unselectedBorder}`,
36
+ background: isSelected ? mcqSelectedBgVar : 'var(--cfm-card-bg, #fff)',
37
+ padding: 'var(--cfm-mcq-card-padding, 16px 20px)',
38
+ marginBottom: 'var(--cfm-mcq-option-gap, 8px)',
39
+ };
40
+
41
+ if (optionStyle === 'filled') {
42
+ return {
43
+ ...base,
44
+ background: isSelected ? mcqSelectedBgVar : 'var(--cfm-secondary, #f9fafb)',
45
+ };
46
+ }
47
+
48
+ if (optionStyle === 'minimal') {
49
+ return {
50
+ ...base,
51
+ border: isSelected ? `2px solid ${focusRingVar}` : '2px solid transparent',
52
+ boxShadow: 'none',
53
+ background: isSelected ? mcqSelectedBgVar : 'transparent',
54
+ };
55
+ }
56
+
57
+ return base;
58
+ }
59
+
60
+ /** MCQ checkbox control indicator. */
61
+ export function mcqCheckboxControlStyle(isSelected: boolean): CSSProperties {
62
+ return {
63
+ display: 'flex',
64
+ height: '20px',
65
+ width: '20px',
66
+ flexShrink: 0,
67
+ alignItems: 'center',
68
+ justifyContent: 'center',
69
+ borderRadius: '4px',
70
+ border: `1px solid ${isSelected ? focusRingVar : '#9ca3af'}`,
71
+ backgroundColor: isSelected ? focusRingVar : '#fff',
72
+ transition: 'all 0.15s',
73
+ };
74
+ }
75
+
76
+ /** MCQ radio outer ring. */
77
+ export function mcqRadioOuterStyle(isSelected: boolean): CSSProperties {
78
+ return {
79
+ display: 'flex',
80
+ height: '20px',
81
+ width: '20px',
82
+ flexShrink: 0,
83
+ alignItems: 'center',
84
+ justifyContent: 'center',
85
+ borderRadius: '50%',
86
+ border: isSelected ? `2px solid ${focusRingVar}` : '1px solid #9ca3af',
87
+ backgroundColor: isSelected ? cellSelectedVar : '#fff',
88
+ transition: 'all 0.15s',
89
+ };
90
+ }
91
+
92
+ /** MCQ radio inner dot — focus ring color. */
93
+ export function mcqRadioInnerStyle(): CSSProperties {
94
+ return {
95
+ height: '10px',
96
+ width: '10px',
97
+ borderRadius: '50%',
98
+ backgroundColor: focusRingVar,
99
+ };
100
+ }
14
101
 
15
102
  const unselectedRing = '#d1d5db';
16
103
  const unselectedDot = '#d1d5db';
@@ -55,9 +55,9 @@ export function getEmojiForIndex(index: number, total: number): React.ReactNode
55
55
 
56
56
  export const CsatStarIcons: { filled: React.ReactNode; empty: React.ReactNode } = {
57
57
  filled: React.createElement(FaStar as React.ElementType, {
58
- style: { color: '#e20074', fontSize: '28px' },
58
+ style: { color: 'var(--cfm-csat-accent, var(--cfm-primary, #e20074))', fontSize: 'var(--cfm-csat-emoji-size, 28px)' },
59
59
  }),
60
60
  empty: React.createElement(FaRegStar as React.ElementType, {
61
- style: { color: '#d1d5db', fontSize: '28px', stroke: '#d1d5db', strokeWidth: '16px' },
61
+ style: { color: '#d1d5db', fontSize: 'var(--cfm-csat-emoji-size, 28px)', stroke: '#d1d5db', strokeWidth: '16px' },
62
62
  }),
63
63
  };