@e-llm-studio/feedback-flow 0.1.31 → 0.1.33

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.
@@ -6,6 +6,8 @@ export interface FeedbackExperienceProps {
6
6
  /** Optional for feature-level feedback; chat responses should provide their request ID. */
7
7
  requestId?: string;
8
8
  featureId: string;
9
+ featureName?: string;
10
+ featureDescription?: string;
9
11
  /** Consulted only for feature-level feedback, never for chat responses. */
10
12
  featureAvailabilityApi?: FeatureAvailabilityApi;
11
13
  /** Backend mode UUID used to list its features. */
@@ -14,18 +14,18 @@ const useFeedbackPersistence_1 = require("./useFeedbackPersistence");
14
14
  const featureAvailability_1 = require("./featureAvailability");
15
15
  /** Standalone experience for hosts that do not need chat-specific anchoring. */
16
16
  function FeedbackExperience(props) {
17
- const available = (0, featureAvailability_1.useFeatureAvailability)(props.featureAvailabilityModeId, props.featureId, props.featureAvailabilityApi, !props.requestId);
17
+ const { available, feature } = (0, featureAvailability_1.useFeatureAvailabilityDetails)(props.featureAvailabilityModeId, props.featureId, props.featureAvailabilityApi, !props.requestId);
18
18
  if (!available)
19
19
  return null;
20
- return (0, jsx_runtime_1.jsx)(FeedbackExperienceContent, { ...props, requestId: props.requestId || props.featureId });
20
+ return (0, jsx_runtime_1.jsx)(FeedbackExperienceContent, { ...props, featureName: props.featureName ?? feature?.name ?? undefined, featureDescription: props.featureDescription ?? feature?.description ?? undefined, requestId: props.requestId || props.featureId });
21
21
  }
22
- function FeedbackExperienceContent({ requestId, featureId, metadata, tracking, copy, theme, onSubmit, api, className, style, styles, classNames, align = "start", slots, }) {
22
+ function FeedbackExperienceContent({ requestId, featureId, featureName, featureDescription, metadata, tracking, copy, theme, onSubmit, api, className, style, styles, classNames, align = "start", slots, }) {
23
23
  const feedbackMetadata = (0, tracking_1.createFeedbackMetadata)(tracking, metadata);
24
24
  const flow = (0, useFeedbackFlow_1.useFeedbackFlow)(requestId, featureId, feedbackMetadata);
25
25
  const { saveFeedback, saveProgress, saveOnDismiss, isLoading, isSaving, saveError } = (0, useFeedbackPersistence_1.useFeedbackPersistence)(flow, feedbackMetadata, onSubmit, api);
26
26
  const anchorRef = (0, react_1.useRef)(null);
27
27
  const [promptPosition, setPromptPosition] = (0, react_1.useState)(null);
28
- const prompt = ((0, jsx_runtime_1.jsx)(FeedbackPrompt_1.FeedbackPrompt, { flow: flow, loading: isLoading, disabled: isSaving, promptRef: anchorRef, copy: copy ?? types_1.defaultFeedbackCopy, theme: theme, styles: styles?.prompt, classNames: classNames?.prompt, onRatingSelected: (rating) => {
28
+ const prompt = ((0, jsx_runtime_1.jsx)(FeedbackPrompt_1.FeedbackPrompt, { flow: flow, loading: isLoading, disabled: isSaving, promptRef: anchorRef, copy: copy ?? types_1.defaultFeedbackCopy, theme: theme, featureName: featureName, featureDescription: featureDescription, styles: styles?.prompt, classNames: classNames?.prompt, onRatingSelected: (rating) => {
29
29
  const nextStep = rating === 3 || rating === 4 ? "rating" : rating <= 2 ? "gap" : "time";
30
30
  void saveProgress({ requestId, featureId, rating, gaps: [], metadata: feedbackMetadata }, nextStep)
31
31
  .catch((error) => console.error("Could not save rating", error));
@@ -17,6 +17,10 @@ function FeedbackModal(props) {
17
17
  }
18
18
  function FeedbackModalContent({ flow, copy: copyOverrides, theme, onSubmit, onProgress, onDismiss, saving = false, resolveScreenshotUrl, anchorRef, overlayHostRef, anchorKey, slots, className, style, styles, classNames, backdropClassName, backdropStyle, dialogClassName, dialogStyle, }) {
19
19
  const copy = (0, types_1.createFeedbackCopy)(copyOverrides);
20
+ const hasEnoughDetail = (value) => wordCount(value) >= copy.usefulMinimumWords;
21
+ const requiresGapWordMinimum = flow.ratingBase !== 5;
22
+ const gapHasRequiredDetail = (gap) => hasEnoughDetail(gap.description) || (!requiresGapWordMinimum && gap.detailRequested);
23
+ const detailRequirement = (_value, prompt) => prompt;
20
24
  const mergedTheme = (0, types_1.createFeedbackTheme)(theme);
21
25
  const [customTime, setCustomTime] = (0, react_1.useState)(false);
22
26
  const [introDismissed, setIntroDismissed] = (0, react_1.useState)(false);
@@ -30,6 +34,10 @@ function FeedbackModalContent({ flow, copy: copyOverrides, theme, onSubmit, onPr
30
34
  const [screenshotPreview, setScreenshotPreview] = (0, react_1.useState)(null);
31
35
  const [capturingScreenshot, setCapturingScreenshot] = (0, react_1.useState)(false);
32
36
  const [captureError, setCaptureError] = (0, react_1.useState)('');
37
+ const [rangeActive, setRangeActive] = (0, react_1.useState)(false);
38
+ const [rangeTooltipPosition, setRangeTooltipPosition] = (0, react_1.useState)(null);
39
+ const rangeRef = (0, react_1.useRef)(null);
40
+ const rangeTooltipRef = (0, react_1.useRef)(null);
33
41
  const submittingRef = (0, react_1.useRef)(false);
34
42
  const [position, setPosition] = (0, react_1.useState)({ left: 12, top: 12, width: 480, maxHeight: 600 });
35
43
  const dialogRef = (0, react_1.useRef)(null);
@@ -38,6 +46,31 @@ function FeedbackModalContent({ flow, copy: copyOverrides, theme, onSubmit, onPr
38
46
  const dismissRef = (0, react_1.useRef)(() => undefined);
39
47
  const wasOpenRef = (0, react_1.useRef)(false);
40
48
  const isOpen = flow.step !== 'closed';
49
+ (0, react_1.useLayoutEffect)(() => {
50
+ if (!rangeActive || flow.step !== 'rating' || flow.rating === null)
51
+ return;
52
+ const rating = flow.rating;
53
+ const updatePosition = () => {
54
+ const range = rangeRef.current;
55
+ const tooltip = rangeTooltipRef.current;
56
+ if (!range || !tooltip)
57
+ return;
58
+ const rangeRect = range.getBoundingClientRect();
59
+ const tooltipRect = tooltip.getBoundingClientRect();
60
+ const fraction = Math.max(0, Math.min(1, rating - flow.ratingBase));
61
+ const thumbX = rangeRect.left + rangeRect.width * fraction;
62
+ const left = Math.max(8, Math.min(window.innerWidth - tooltipRect.width - 8, thumbX - tooltipRect.width / 2));
63
+ const top = Math.max(8, rangeRect.top - tooltipRect.height - 16);
64
+ setRangeTooltipPosition({ top, left });
65
+ };
66
+ updatePosition();
67
+ window.addEventListener('resize', updatePosition);
68
+ window.addEventListener('scroll', updatePosition, true);
69
+ return () => {
70
+ window.removeEventListener('resize', updatePosition);
71
+ window.removeEventListener('scroll', updatePosition, true);
72
+ };
73
+ }, [rangeActive, flow.step, flow.rating, flow.ratingBase]);
41
74
  const dismiss = () => {
42
75
  if (flow.step !== 'success' && flow.step !== 'closed' && flow.submission) {
43
76
  void onDismiss?.(flow.submission, flow.step, flow.completed)
@@ -75,7 +108,7 @@ function FeedbackModalContent({ flow, copy: copyOverrides, theme, onSubmit, onPr
75
108
  if (isOpen && !wasOpenRef.current) {
76
109
  setShowUseful((flow.rating ?? 0) >= 4 && flow.timeSavedMinutes !== undefined);
77
110
  setShowLowRatingTime((flow.rating ?? 0) < 4 && flow.timeSavedMinutes !== undefined);
78
- const firstShortGap = flow.gaps.findIndex((gap) => !gap.description.trim() || (wordCount(gap.description) < copy.usefulMinimumWords && !gap.detailRequested));
111
+ const firstShortGap = flow.gaps.findIndex((gap) => !gap.description.trim() || (!hasEnoughDetail(gap.description) && !gap.detailRequested));
79
112
  setRevealedGapCount(firstShortGap < 0 ? flow.gaps.length : firstShortGap);
80
113
  setReviewPageIndex(0);
81
114
  }
@@ -188,7 +221,7 @@ function FeedbackModalContent({ flow, copy: copyOverrides, theme, onSubmit, onPr
188
221
  : current > index ? current - 1 : current);
189
222
  };
190
223
  const canAdvanceGap = revealedGapCount >= flow.gaps.length && flow.gaps.every((gap) => Boolean(gap.severity) && Boolean(gap.description.trim()) &&
191
- (wordCount(gap.description) >= copy.usefulMinimumWords || gap.detailRequested));
224
+ gapHasRequiredDetail(gap));
192
225
  const persistAndAdvance = (nextStep, advance, submission = flow.submission) => {
193
226
  if (!submission || submittingRef.current)
194
227
  return;
@@ -206,9 +239,7 @@ function FeedbackModalContent({ flow, copy: copyOverrides, theme, onSubmit, onPr
206
239
  if (!flow.submission || submittingRef.current)
207
240
  return;
208
241
  if (!flow.mostUseful.trim() ||
209
- (wordCount(flow.mostUseful) < copy.usefulMinimumWords && !flow.usefulDetailShown) ||
210
- flow.gaps.some((gap) => !gap.description.trim() ||
211
- (wordCount(gap.description) < copy.usefulMinimumWords && !gap.detailRequested)) ||
242
+ flow.gaps.some((gap) => !gap.description.trim() || !gapHasRequiredDetail(gap)) ||
212
243
  flow.timeSavedMinutes === undefined)
213
244
  return;
214
245
  submittingRef.current = true;
@@ -229,21 +260,21 @@ function FeedbackModalContent({ flow, copy: copyOverrides, theme, onSubmit, onPr
229
260
  }
230
261
  };
231
262
  const nextReviewPage = () => {
232
- if (!canContinueReview)
233
- return;
234
263
  if (reviewPage === 'gap') {
235
- const gaps = flow.gaps.map((gap) => wordCount(gap.description) < copy.usefulMinimumWords
264
+ const gaps = flow.gaps.map((gap) => !hasEnoughDetail(gap.description) && !gap.detailRequested
236
265
  ? { ...gap, detailRequested: true } : gap);
237
- if (gaps.some((gap, index) => gap.detailRequested && !flow.gaps[index].detailRequested)) {
266
+ if (flow.gaps.some((gap) => !gapHasRequiredDetail(gap))) {
238
267
  flow.setGaps(gaps);
239
268
  return;
240
269
  }
241
270
  }
242
271
  else if ((reviewPage === 'useful' || reviewPage === 'time') &&
243
- wordCount(flow.mostUseful) < copy.usefulMinimumWords && !flow.usefulDetailShown) {
272
+ !hasEnoughDetail(flow.mostUseful) && !flow.usefulDetailShown) {
244
273
  flow.setUsefulDetailShown(true);
245
274
  return;
246
275
  }
276
+ if (!canContinueReview)
277
+ return;
247
278
  if (reviewPageIndex < reviewPages.length - 1) {
248
279
  const nextPage = reviewPages[reviewPageIndex + 1];
249
280
  void persistAndAdvance(nextPage, () => setReviewPageIndex(reviewPageIndex + 1));
@@ -256,9 +287,7 @@ function FeedbackModalContent({ flow, copy: copyOverrides, theme, onSubmit, onPr
256
287
  setReviewPageIndex(reviewPageIndex - 1);
257
288
  };
258
289
  const continueUseful = () => {
259
- if (!flow.mostUseful.trim())
260
- return;
261
- if (wordCount(flow.mostUseful) < copy.usefulMinimumWords && !flow.usefulDetailShown) {
290
+ if (!hasEnoughDetail(flow.mostUseful) && !flow.usefulDetailShown) {
262
291
  flow.setUsefulDetailShown(true);
263
292
  return;
264
293
  }
@@ -270,9 +299,7 @@ function FeedbackModalContent({ flow, copy: copyOverrides, theme, onSubmit, onPr
270
299
  void submitFeedback();
271
300
  };
272
301
  const continueTime = () => {
273
- if (showUseful && !flow.mostUseful.trim())
274
- return;
275
- if (showUseful && wordCount(flow.mostUseful) < copy.usefulMinimumWords && !flow.usefulDetailShown) {
302
+ if (showUseful && !hasEnoughDetail(flow.mostUseful) && !flow.usefulDetailShown) {
276
303
  flow.setUsefulDetailShown(true);
277
304
  return;
278
305
  }
@@ -461,10 +488,7 @@ function FeedbackModalContent({ flow, copy: copyOverrides, theme, onSubmit, onPr
461
488
  ((flow.step === 'time' && flow.ratingBase === 4) ||
462
489
  (flow.step === 'gap' && flow.rating !== null && flow.rating > 2) ||
463
490
  (flow.step === 'useful' && isGapFirstFlow) ||
464
- flow.step === 'success') && ((0, jsx_runtime_1.jsxs)("div", { className: ['cw-feedback-dialog__topbar', classNames?.topbar].filter(Boolean).join(' '), style: styles?.topbar, children: [flow.step === 'time' || flow.step === 'gap' || flow.step === 'useful' ? ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: back, "aria-label": copy.backLabel, className: classNames?.backButton, style: styles?.backButton, children: (0, jsx_runtime_1.jsx)(lucide_react_1.ArrowLeft, { size: 19 }) })) : ((0, jsx_runtime_1.jsx)("span", {})), flow.step === 'success' && ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: flow.close, "aria-label": copy.closeLabel, className: classNames?.closeButton, style: styles?.closeButton, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 19 }) }))] })), flow.step === 'rating' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("h2", { className: classNames?.title, style: styles?.title, children: copy.ratingTitle }), (0, jsx_runtime_1.jsxs)("p", { className: ['cw-feedback-subtitle', classNames?.subtitle].filter(Boolean).join(' '), style: styles?.subtitle, children: ["\u2728 ", copy.ratingSubtitle] }), (0, jsx_runtime_1.jsxs)("div", { className: ['cw-feedback-range', classNames?.rangeContainer].filter(Boolean).join(' '), style: styles?.rangeContainer, children: [flow.rating !== null && ((0, jsx_runtime_1.jsx)("div", { className: ['cw-feedback-range-tooltip', classNames?.rangeTooltip].filter(Boolean).join(' '), style: {
465
- left: `clamp(150px, ${(flow.rating - ratingBase) * 100}%, calc(100% - 150px))`,
466
- ...styles?.rangeTooltip,
467
- }, children: copy.ratingValueLabel(flow.rating) })), (0, jsx_runtime_1.jsx)("input", { "aria-label": copy.ratingControlLabel, type: "range", min: ratingBase, max: ratingBase + 1, step: "0.1", value: flow.rating ?? ratingBase, className: classNames?.rangeInput, style: styles?.rangeInput, onChange: (e) => (isReview ? flow.setReviewRating : flow.setRating)(Number(e.target.value)), onPointerUp: (event) => selectDecimalRating(Number(event.currentTarget.value)), onKeyUp: (event) => {
491
+ flow.step === 'success') && ((0, jsx_runtime_1.jsxs)("div", { className: ['cw-feedback-dialog__topbar', classNames?.topbar].filter(Boolean).join(' '), style: styles?.topbar, children: [flow.step === 'time' || flow.step === 'gap' || flow.step === 'useful' ? ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: back, "aria-label": copy.backLabel, className: classNames?.backButton, style: styles?.backButton, children: (0, jsx_runtime_1.jsx)(lucide_react_1.ArrowLeft, { size: 19 }) })) : ((0, jsx_runtime_1.jsx)("span", {})), flow.step === 'success' && ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: flow.close, "aria-label": copy.closeLabel, className: classNames?.closeButton, style: styles?.closeButton, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 19 }) }))] })), flow.step === 'rating' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("h2", { className: classNames?.title, style: styles?.title, children: copy.ratingTitle }), (0, jsx_runtime_1.jsxs)("p", { className: ['cw-feedback-subtitle', classNames?.subtitle].filter(Boolean).join(' '), style: styles?.subtitle, children: ["\u2728 ", copy.ratingSubtitle] }), (0, jsx_runtime_1.jsxs)("div", { className: ['cw-feedback-range', classNames?.rangeContainer].filter(Boolean).join(' '), style: styles?.rangeContainer, children: [rangeActive && flow.rating !== null && (0, jsx_runtime_1.jsx)("span", { className: "cw-feedback-range__pointer", style: { left: `clamp(8px, ${(flow.rating - ratingBase) * 100}%, calc(100% - 8px))` }, "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("input", { ref: rangeRef, "aria-label": copy.ratingControlLabel, type: "range", min: ratingBase, max: ratingBase + 1, step: "0.1", value: flow.rating ?? ratingBase, onPointerEnter: () => setRangeActive(true), onPointerLeave: () => setRangeActive(false), onFocus: () => setRangeActive(true), onBlur: () => setRangeActive(false), className: classNames?.rangeInput, style: styles?.rangeInput, onChange: (e) => (isReview ? flow.setReviewRating : flow.setRating)(Number(e.target.value)), onPointerUp: (event) => selectDecimalRating(Number(event.currentTarget.value)), onKeyUp: (event) => {
468
492
  if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
469
493
  selectDecimalRating(Number(event.currentTarget.value));
470
494
  }
@@ -474,7 +498,7 @@ function FeedbackModalContent({ flow, copy: copyOverrides, theme, onSubmit, onPr
474
498
  event.stopPropagation();
475
499
  selectDecimalRating(Number(event.currentTarget.value));
476
500
  }
477
- } })] }), (0, jsx_runtime_1.jsx)("div", { className: ['cw-feedback-rating-options', classNames?.ratingOptionsContainer].filter(Boolean).join(' '), style: styles?.ratingOptionsContainer, children: decimalRatings.map((value) => {
501
+ } }), (0, jsx_runtime_1.jsx)("div", { className: "cw-feedback-range__ticks", "aria-hidden": "true", children: Array.from({ length: 11 }, (_, index) => (0, jsx_runtime_1.jsx)("span", { className: index % 2 === 0 ? 'is-major' : '' }, index)) })] }), (0, jsx_runtime_1.jsx)("div", { className: ['cw-feedback-rating-options', classNames?.ratingOptionsContainer].filter(Boolean).join(' '), style: styles?.ratingOptionsContainer, children: decimalRatings.map((value) => {
478
502
  const isSelected = flow.rating === value;
479
503
  return ((0, jsx_runtime_1.jsx)("button", { type: "button", className: [
480
504
  isSelected ? 'is-selected' : '',
@@ -517,7 +541,7 @@ function FeedbackModalContent({ flow, copy: copyOverrides, theme, onSubmit, onPr
517
541
  event.preventDefault();
518
542
  event.stopPropagation();
519
543
  selectTimeSaved(flow.timeSavedMinutes);
520
- } }), ' ', copy.minuteUnit] }))] }), !showUseful && flow.timeSavedMinutes === undefined && ((0, jsx_runtime_1.jsx)("p", { className: "cw-feedback-time-hint", children: copy.enterHint })), showUseful && ((0, jsx_runtime_1.jsxs)("label", { className: ['cw-feedback-field', classNames?.usefulField].filter(Boolean).join(' '), style: styles?.usefulField, children: [(0, jsx_runtime_1.jsx)("span", { children: copy.usefulTitle }), flow.usefulDetailShown && wordCount(flow.mostUseful) < copy.usefulMinimumWords && ((0, jsx_runtime_1.jsxs)("span", { className: "cw-feedback-inline-detail-prompt", children: ["*", copy.usefulMoreDetailPrompt] })), (0, jsx_runtime_1.jsx)("textarea", { autoFocus: true, value: flow.mostUseful, className: classNames?.usefulTextarea, style: styles?.usefulTextarea, onChange: (e) => flow.setMostUseful(e.target.value), placeholder: copy.usefulPlaceholder, onKeyDown: (e) => {
544
+ } }), ' ', copy.minuteUnit] }))] }), !showUseful && flow.timeSavedMinutes === undefined && ((0, jsx_runtime_1.jsx)("p", { className: "cw-feedback-time-hint", children: copy.enterHint })), showUseful && ((0, jsx_runtime_1.jsxs)("label", { className: ['cw-feedback-field', 'cw-feedback-useful-field', classNames?.usefulField].filter(Boolean).join(' '), style: styles?.usefulField, children: [(0, jsx_runtime_1.jsx)("span", { children: copy.usefulTitle }), flow.usefulDetailShown && !hasEnoughDetail(flow.mostUseful) && ((0, jsx_runtime_1.jsx)("span", { className: "cw-feedback-inline-detail-prompt", children: detailRequirement(flow.mostUseful, copy.usefulMoreDetailPrompt) })), (0, jsx_runtime_1.jsx)("textarea", { autoFocus: true, value: flow.mostUseful, className: classNames?.usefulTextarea, style: styles?.usefulTextarea, onChange: (e) => flow.setMostUseful(e.target.value), placeholder: copy.usefulPlaceholder, onKeyDown: (e) => {
521
545
  if (e.key === 'Enter' && !e.shiftKey && flow.mostUseful.trim()) {
522
546
  e.preventDefault();
523
547
  if (isReview)
@@ -556,22 +580,24 @@ function FeedbackModalContent({ flow, copy: copyOverrides, theme, onSubmit, onPr
556
580
  event.preventDefault();
557
581
  event.stopPropagation();
558
582
  selectTimeSaved(flow.timeSavedMinutes);
559
- } }), ' ', copy.minuteUnit] })), showLowRatingTime && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("h2", { className: ['cw-feedback-useful-title', classNames?.title, classNames?.usefulTitle].filter(Boolean).join(' '), style: { ...styles?.title, ...styles?.usefulTitle }, children: copy.lowRatingUsefulTitle }), flow.usefulDetailShown && wordCount(flow.mostUseful) < copy.usefulMinimumWords && ((0, jsx_runtime_1.jsxs)("p", { className: ['cw-feedback-useful-prompt', classNames?.usefulPrompt].filter(Boolean).join(' '), style: styles?.usefulPrompt, children: ["*", copy.usefulMoreDetailPrompt] })), (0, jsx_runtime_1.jsx)("label", { className: ['cw-feedback-field', 'cw-feedback-useful-field', classNames?.usefulField].filter(Boolean).join(' '), style: styles?.usefulField, children: (0, jsx_runtime_1.jsx)("textarea", { autoFocus: true, value: flow.mostUseful, className: classNames?.usefulTextarea, style: styles?.usefulTextarea, onChange: (event) => flow.setMostUseful(event.target.value), placeholder: copy.usefulPlaceholder, onKeyDown: (event) => {
583
+ } }), ' ', copy.minuteUnit] })), showLowRatingTime && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("h2", { className: ['cw-feedback-useful-title', classNames?.title, classNames?.usefulTitle].filter(Boolean).join(' '), style: { ...styles?.title, ...styles?.usefulTitle }, children: copy.lowRatingUsefulTitle }), flow.usefulDetailShown && !hasEnoughDetail(flow.mostUseful) && ((0, jsx_runtime_1.jsx)("p", { className: ['cw-feedback-useful-prompt', 'cw-feedback-inline-detail-prompt', classNames?.usefulPrompt].filter(Boolean).join(' '), style: styles?.usefulPrompt, children: detailRequirement(flow.mostUseful, copy.usefulMoreDetailPrompt) })), (0, jsx_runtime_1.jsx)("label", { className: ['cw-feedback-field', 'cw-feedback-useful-field', classNames?.usefulField].filter(Boolean).join(' '), style: styles?.usefulField, children: (0, jsx_runtime_1.jsx)("textarea", { autoFocus: true, value: flow.mostUseful, className: classNames?.usefulTextarea, style: styles?.usefulTextarea, onChange: (event) => flow.setMostUseful(event.target.value), placeholder: copy.usefulPlaceholder, onKeyDown: (event) => {
560
584
  if (event.key !== 'Enter' || event.shiftKey)
561
585
  return;
562
586
  event.preventDefault();
563
587
  continueUseful();
564
- } }) })] }))] })), flow.step === 'gap' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [showLowRatingIntro && ((0, jsx_runtime_1.jsxs)("div", { className: "cw-feedback-low-rating-intro", children: [(0, jsx_runtime_1.jsx)("div", { className: "cw-feedback-low-rating-intro__emoji", "aria-hidden": "true", children: "\uD83D\uDE14\uD83E\uDD7A" }), (0, jsx_runtime_1.jsx)("strong", { children: copy.lowRatingIntroTitle }), (0, jsx_runtime_1.jsx)("p", { children: copy.lowRatingIntroDescription })] })), (0, jsx_runtime_1.jsx)("h2", { className: classNames?.title, style: styles?.title, children: flow.rating === 5 ? copy.fiveStarGapTitle : copy.gapTitle }), copy.gapSubtitle && (!isLowRatingGap || (flow.gaps.length === 1 && flow.gaps[0].detailRequested)) && ((0, jsx_runtime_1.jsx)("p", { className: [
565
- flow.gaps.length === 1 && flow.gaps[0].detailRequested && wordCount(flow.gaps[0].description) < copy.usefulMinimumWords
588
+ } }) })] }))] })), flow.step === 'gap' && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [showLowRatingIntro && ((0, jsx_runtime_1.jsxs)("div", { className: "cw-feedback-low-rating-intro", children: [(0, jsx_runtime_1.jsx)("div", { className: "cw-feedback-low-rating-intro__emoji", "aria-hidden": "true", children: "\uD83D\uDE14\uD83E\uDD7A" }), (0, jsx_runtime_1.jsx)("strong", { children: copy.lowRatingIntroTitle }), (0, jsx_runtime_1.jsx)("p", { children: copy.lowRatingIntroDescription })] })), (0, jsx_runtime_1.jsx)("h2", { className: classNames?.title, style: styles?.title, children: flow.rating === 5 ? copy.fiveStarGapTitle : copy.gapTitle }), copy.gapSubtitle && ((0, jsx_runtime_1.jsx)("p", { className: [
589
+ flow.gaps.length === 1 && flow.gaps[0].detailRequested && !hasEnoughDetail(flow.gaps[0].description)
566
590
  ? 'cw-feedback-inline-detail-prompt' : 'cw-feedback-subtitle',
567
591
  classNames?.subtitle,
568
- ].filter(Boolean).join(' '), style: styles?.subtitle, children: flow.gaps.length === 1 && flow.gaps[0].detailRequested && wordCount(flow.gaps[0].description) < copy.usefulMinimumWords
569
- ? `*${copy.gapMoreDetailPrompt}`
570
- : `✨ ${copy.gapSubtitle}` })), (0, jsx_runtime_1.jsx)("div", { className: ['cw-feedback-gap-list', classNames?.gapList].filter(Boolean).join(' '), style: styles?.gapList, children: flow.gaps.map((gap, index) => ((0, jsx_runtime_1.jsxs)("section", { className: ['cw-feedback-gap', flow.gaps.length >= 2 && index !== activeGapIndex ? 'cw-feedback-gap--collapsed' : '', classNames?.gapItem].filter(Boolean).join(' '), style: styles?.gapItem, children: [flow.gaps.length >= 2 && ((0, jsx_runtime_1.jsxs)("div", { className: "cw-feedback-gap__header", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "cw-feedback-gap__toggle", onClick: () => setActiveGapIndex(index), "aria-expanded": index === activeGapIndex, children: ["Gap ", index + 1, gap.severity && (0, jsx_runtime_1.jsx)("span", { className: `cw-feedback-gap__severity ${gap.severity}`, children: copy.severityLabels[gap.severity] }), index !== activeGapIndex && ((0, jsx_runtime_1.jsx)("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("path", { d: "m6 9 6 6 6-6" }) }))] }), index === activeGapIndex && (0, jsx_runtime_1.jsx)("button", { type: "button", className: "cw-feedback-gap__remove", onClick: () => removeGap(index), "aria-label": `Delete gap ${index + 1}`, title: `Delete gap ${index + 1}`, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 16, "aria-hidden": "true" }) })] })), (flow.gaps.length < 2 || index === activeGapIndex) && (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [flow.gaps.length >= 2 && gap.detailRequested && wordCount(gap.description) < copy.usefulMinimumWords && ((0, jsx_runtime_1.jsxs)("p", { className: "cw-feedback-inline-detail-prompt cw-feedback-gap__detail-prompt", children: ["*", copy.gapMoreDetailPrompt] })), (0, jsx_runtime_1.jsx)("textarea", { autoFocus: index === 0 && !isLowRatingGap, value: gap.description, className: classNames?.gapTextarea, style: styles?.gapTextarea, onFocus: () => { if (isLowRatingGap)
592
+ ].filter(Boolean).join(' '), style: flow.gaps.length === 1 && flow.gaps[0].detailRequested && !hasEnoughDetail(flow.gaps[0].description)
593
+ ? { ...styles?.subtitle, marginTop: 10, marginBottom: 4 }
594
+ : styles?.subtitle, children: flow.gaps.length === 1 && flow.gaps[0].detailRequested && !hasEnoughDetail(flow.gaps[0].description)
595
+ ? detailRequirement(flow.gaps[0].description, copy.gapMoreDetailPrompt)
596
+ : `✨ ${copy.gapSubtitle}` })), (0, jsx_runtime_1.jsx)("div", { className: ['cw-feedback-gap-list', classNames?.gapList].filter(Boolean).join(' '), style: styles?.gapList, children: flow.gaps.map((gap, index) => ((0, jsx_runtime_1.jsxs)("section", { className: ['cw-feedback-gap', flow.gaps.length >= 2 && index !== activeGapIndex ? 'cw-feedback-gap--collapsed' : '', classNames?.gapItem].filter(Boolean).join(' '), style: styles?.gapItem, children: [flow.gaps.length >= 2 && ((0, jsx_runtime_1.jsxs)("div", { className: "cw-feedback-gap__header", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "cw-feedback-gap__toggle", onClick: () => setActiveGapIndex(index === activeGapIndex ? -1 : index), "aria-expanded": index === activeGapIndex, children: ["Gap ", index + 1, index !== activeGapIndex && gap.severity && (0, jsx_runtime_1.jsx)("span", { className: `cw-feedback-gap__severity ${gap.severity}`, children: copy.severityLabels[gap.severity] }), ((0, jsx_runtime_1.jsx)("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("path", { d: index === activeGapIndex ? 'm6 15 6-6 6 6' : 'm6 9 6 6 6-6' }) }))] }), index === activeGapIndex && !gap.description.trim() && gap.screenshots.length === 0 && (0, jsx_runtime_1.jsx)("button", { type: "button", className: "cw-feedback-gap__remove", onClick: () => removeGap(index), "aria-label": `Delete gap ${index + 1}`, title: `Delete gap ${index + 1}`, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 16, "aria-hidden": "true" }) })] })), (flow.gaps.length < 2 || index === activeGapIndex) && (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [flow.gaps.length >= 2 && gap.detailRequested && !hasEnoughDetail(gap.description) && ((0, jsx_runtime_1.jsx)("p", { className: "cw-feedback-inline-detail-prompt cw-feedback-gap__detail-prompt", children: detailRequirement(gap.description, copy.gapMoreDetailPrompt) })), (0, jsx_runtime_1.jsx)("textarea", { autoFocus: index === 0 && !isLowRatingGap, value: gap.description, className: classNames?.gapTextarea, style: styles?.gapTextarea, onFocus: () => { if (isLowRatingGap)
571
597
  setIntroDismissed(true); }, onChange: (e) => updateGap(index, { description: e.target.value }), onKeyDown: (event) => {
572
598
  if (event.key === 'Enter' && !event.shiftKey && gap.description.trim()) {
573
599
  event.preventDefault();
574
- if (wordCount(gap.description) < copy.usefulMinimumWords && !gap.detailRequested) {
600
+ if (!gapHasRequiredDetail(gap)) {
575
601
  updateGap(index, { detailRequested: true });
576
602
  return;
577
603
  }
@@ -620,7 +646,7 @@ function FeedbackModalContent({ flow, copy: copyOverrides, theme, onSubmit, onPr
620
646
  const gap = flow.gaps[index];
621
647
  if (!gap?.description.trim())
622
648
  return;
623
- if (wordCount(gap.description) < copy.usefulMinimumWords && !gap.detailRequested) {
649
+ if (!gapHasRequiredDetail(gap)) {
624
650
  updateGap(index, { detailRequested: true });
625
651
  return;
626
652
  }
@@ -638,7 +664,7 @@ function FeedbackModalContent({ flow, copy: copyOverrides, theme, onSubmit, onPr
638
664
  ...styles?.enterHintButton,
639
665
  }, type: "button", onClick: continueUseful, children: copy.enterHint })), showLowRatingTime && flow.timeSavedMinutes !== undefined &&
640
666
  Boolean(flow.mostUseful.trim()) &&
641
- (wordCount(flow.mostUseful) >= copy.usefulMinimumWords || flow.usefulDetailShown) && ((0, jsx_runtime_1.jsx)("button", { className: ['cw-feedback-primary', classNames?.primaryButton].filter(Boolean).join(' '), style: styles?.primaryButton, type: "button", onClick: submitFeedback, children: copy.doneLabel }))] })), isReview && ((0, jsx_runtime_1.jsxs)("div", { className: ['cw-feedback-review-nav', classNames?.reviewNav].filter(Boolean).join(' '), style: styles?.reviewNav, children: [(0, jsx_runtime_1.jsx)("button", { className: ['cw-feedback-review-nav__button', classNames?.reviewNavButton].filter(Boolean).join(' '), style: styles?.reviewNavButton, type: "button", onClick: previousReviewPage, disabled: reviewPageIndex === 0, "aria-label": copy.backLabel, children: (0, jsx_runtime_1.jsx)(lucide_react_1.ArrowLeft, { size: 18 }) }), (0, jsx_runtime_1.jsxs)("button", { className: [
667
+ (hasEnoughDetail(flow.mostUseful) || flow.usefulDetailShown) && ((0, jsx_runtime_1.jsx)("button", { className: ['cw-feedback-primary', classNames?.primaryButton].filter(Boolean).join(' '), style: styles?.primaryButton, type: "button", onClick: submitFeedback, children: copy.doneLabel }))] })), isReview && ((0, jsx_runtime_1.jsxs)("div", { className: ['cw-feedback-review-nav', classNames?.reviewNav].filter(Boolean).join(' '), style: styles?.reviewNav, children: [(0, jsx_runtime_1.jsx)("button", { className: ['cw-feedback-review-nav__button', classNames?.reviewNavButton].filter(Boolean).join(' '), style: styles?.reviewNavButton, type: "button", onClick: previousReviewPage, disabled: reviewPageIndex === 0, "aria-label": copy.backLabel, children: (0, jsx_runtime_1.jsx)(lucide_react_1.ArrowLeft, { size: 18 }) }), (0, jsx_runtime_1.jsxs)("button", { className: [
642
668
  'cw-feedback-review-nav__button',
643
669
  'cw-feedback-review-nav__button--next',
644
670
  classNames?.reviewNavButton,
@@ -653,7 +679,17 @@ function FeedbackModalContent({ flow, copy: copyOverrides, theme, onSubmit, onPr
653
679
  if (event.target === event.currentTarget)
654
680
  setScreenshotPreview(null);
655
681
  }, children: (0, jsx_runtime_1.jsxs)("div", { ref: screenshotPreviewRef, className: "cw-feedback-image-preview-dialog", role: "dialog", "aria-modal": "true", "aria-label": `Screenshot preview: ${screenshotPreview.name}`, tabIndex: -1, children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "cw-feedback-image-preview-close", onClick: () => setScreenshotPreview(null), "aria-label": "Close screenshot preview", children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 22 }) }), (0, jsx_runtime_1.jsx)("img", { src: screenshotPreview.url, alt: screenshotPreview.name })] }) }));
682
+ const rangeTooltip = rangeActive && flow.step === 'rating' && flow.rating !== null && ((0, jsx_runtime_1.jsx)("div", { ref: rangeTooltipRef, role: "tooltip", className: ['cw-feedback-range-tooltip', classNames?.rangeTooltip].filter(Boolean).join(' '), style: {
683
+ ...(0, feedbackTheme_1.feedbackThemeStyle)(theme),
684
+ ...styles?.rangeTooltip,
685
+ position: 'fixed',
686
+ zIndex: 10002,
687
+ top: rangeTooltipPosition?.top ?? -1000,
688
+ left: rangeTooltipPosition?.left ?? -1000,
689
+ bottom: 'auto',
690
+ transform: 'none',
691
+ }, children: copy.ratingValueLabel(flow.rating) }));
656
692
  return typeof document !== 'undefined'
657
- ? (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, react_dom_1.createPortal)(backdrop, document.body), (0, react_dom_1.createPortal)(dialog, document.body), previewDialog && (0, react_dom_1.createPortal)(previewDialog, document.body)] })
658
- : (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [backdrop, dialog, previewDialog] });
693
+ ? (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, react_dom_1.createPortal)(backdrop, document.body), (0, react_dom_1.createPortal)(dialog, document.body), rangeTooltip && (0, react_dom_1.createPortal)(rangeTooltip, document.body), previewDialog && (0, react_dom_1.createPortal)(previewDialog, document.body)] })
694
+ : (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [backdrop, dialog, rangeTooltip, previewDialog] });
659
695
  }
@@ -6,6 +6,11 @@ export interface FeedbackPromptProps {
6
6
  copy?: Partial<FeedbackCopy>;
7
7
  theme?: FeedbackTheme;
8
8
  compact?: boolean;
9
+ compactRatingLabel?: boolean;
10
+ ratingCardLabel?: string;
11
+ animateUnrated?: boolean;
12
+ featureName?: string;
13
+ featureDescription?: string;
9
14
  promptRef?: Ref<HTMLDivElement>;
10
15
  extraContent?: ReactNode;
11
16
  loading?: boolean;
@@ -17,4 +22,4 @@ export interface FeedbackPromptProps {
17
22
  styles?: FeedbackPromptStyles;
18
23
  classNames?: FeedbackPromptClassNames;
19
24
  }
20
- export declare function FeedbackPrompt({ flow, copy: copyOverrides, theme, compact, promptRef, extraContent, loading, disabled, onBeforeOpen, onRatingSelected, className, style, styles, classNames, }: FeedbackPromptProps): JSX.Element | null;
25
+ export declare function FeedbackPrompt({ flow, copy: copyOverrides, theme, compact, compactRatingLabel, ratingCardLabel, animateUnrated, featureName, featureDescription, promptRef, extraContent, loading, disabled, onBeforeOpen, onRatingSelected, className, style, styles, classNames, }: FeedbackPromptProps): JSX.Element | null;
@@ -3,19 +3,60 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.FeedbackPrompt = FeedbackPrompt;
4
4
  const jsx_runtime_1 = require("react/jsx-runtime");
5
5
  const react_1 = require("react");
6
+ const react_dom_1 = require("react-dom");
6
7
  const types_1 = require("./types");
7
8
  const feedbackTheme_1 = require("./feedbackTheme");
8
9
  const StarRating_1 = require("./StarRating");
9
- function FeedbackPrompt({ flow, copy: copyOverrides, theme, compact = false, promptRef, extraContent, loading = false, disabled = false, onBeforeOpen, onRatingSelected, className, style, styles, classNames, }) {
10
+ function FeedbackPrompt({ flow, copy: copyOverrides, theme, compact = false, compactRatingLabel = false, ratingCardLabel, animateUnrated = false, featureName, featureDescription, promptRef, extraContent, loading = false, disabled = false, onBeforeOpen, onRatingSelected, className, style, styles, classNames, }) {
10
11
  const copy = (0, types_1.createFeedbackCopy)(copyOverrides);
11
12
  const [hovered, setHovered] = (0, react_1.useState)(null);
12
- const [selectedTooltipStar, setSelectedTooltipStar] = (0, react_1.useState)(null);
13
+ const [pendingStar, setPendingStar] = (0, react_1.useState)(null);
14
+ const [tooltipPosition, setTooltipPosition] = (0, react_1.useState)(null);
15
+ const tooltipRef = (0, react_1.useRef)(null);
16
+ const hoveredButtonRef = (0, react_1.useRef)(null);
17
+ const reselectTimerRef = (0, react_1.useRef)(null);
18
+ const tooltipVisible = !disabled && flow.step === 'closed' && flow.rating !== null && hovered === Math.ceil(flow.rating);
19
+ const fullRatingLabel = flow.rating === null ? null : flow.rating % 1
20
+ ? copy.ratingValueLabel(flow.rating)
21
+ : copy.starTooltipLabel?.(flow.rating) ?? copy.starLabel(flow.rating);
22
+ const cardLabel = flow.rating !== null && ratingCardLabel ? ratingCardLabel : fullRatingLabel
23
+ ? compactRatingLabel ? fullRatingLabel.split(/[—–]/)[0].trim() : fullRatingLabel
24
+ : copy.prompt;
25
+ (0, react_1.useEffect)(() => setPendingStar(null), [flow.requestId, flow.featureId, flow.step]);
26
+ (0, react_1.useEffect)(() => () => { if (reselectTimerRef.current)
27
+ clearTimeout(reselectTimerRef.current); }, []);
28
+ (0, react_1.useLayoutEffect)(() => {
29
+ if (!tooltipVisible)
30
+ return;
31
+ const updatePlacement = () => {
32
+ const tooltip = tooltipRef.current;
33
+ const button = hoveredButtonRef.current;
34
+ if (!tooltip || !button)
35
+ return;
36
+ const buttonRect = button.getBoundingClientRect();
37
+ const tooltipRect = tooltip.getBoundingClientRect();
38
+ const spaceBelow = window.innerHeight - buttonRect.bottom;
39
+ const top = spaceBelow >= tooltipRect.height + 8
40
+ ? buttonRect.bottom + 8
41
+ : Math.max(8, buttonRect.top - tooltipRect.height - 8);
42
+ const left = Math.max(8, Math.min(window.innerWidth - tooltipRect.width - 8, buttonRect.left + buttonRect.width / 2 - tooltipRect.width / 2));
43
+ setTooltipPosition((current) => current?.top === top && current.left === left ? current : { top, left });
44
+ };
45
+ updatePlacement();
46
+ window.addEventListener('resize', updatePlacement);
47
+ window.addEventListener('scroll', updatePlacement, true);
48
+ return () => {
49
+ window.removeEventListener('resize', updatePlacement);
50
+ window.removeEventListener('scroll', updatePlacement, true);
51
+ };
52
+ }, [tooltipVisible, hovered]);
13
53
  if (!flow)
14
54
  return null;
15
55
  return ((0, jsx_runtime_1.jsxs)("div", { ref: promptRef, className: [
16
56
  'cw-feedback-prompt',
17
57
  loading ? 'cw-feedback-prompt--loading' : '',
18
58
  compact ? 'cw-feedback-prompt--compact' : '',
59
+ animateUnrated && flow.rating === null && !loading ? 'cw-feedback-prompt--new' : '',
19
60
  className,
20
61
  classNames?.root,
21
62
  ]
@@ -24,20 +65,47 @@ function FeedbackPrompt({ flow, copy: copyOverrides, theme, compact = false, pro
24
65
  ...(0, feedbackTheme_1.feedbackThemeStyle)(theme),
25
66
  ...style,
26
67
  ...styles?.root,
27
- }, "aria-busy": loading, children: [(0, jsx_runtime_1.jsx)("button", { type: "button", disabled: disabled, className: ['cw-feedback-prompt__label', classNames?.labelButton].filter(Boolean).join(' '), style: styles?.labelButton, onClick: () => {
68
+ }, "aria-busy": loading, children: [featureName && ((0, jsx_runtime_1.jsxs)("div", { className: "cw-feedback-prompt__feature-context", children: [(0, jsx_runtime_1.jsx)("strong", { children: featureName }), featureDescription && (0, jsx_runtime_1.jsx)("span", { title: featureDescription, children: featureDescription })] })), (0, jsx_runtime_1.jsx)("button", { type: "button", disabled: disabled, className: ['cw-feedback-prompt__label', classNames?.labelButton].filter(Boolean).join(' '), style: styles?.labelButton, onClick: () => {
28
69
  if (flow.rating === null || flow.step !== 'closed')
29
70
  return;
71
+ if (reselectTimerRef.current)
72
+ clearTimeout(reselectTimerRef.current);
73
+ reselectTimerRef.current = null;
74
+ setPendingStar(null);
30
75
  onBeforeOpen?.();
31
76
  flow.setStep(flow.completed ? (flow.ratingBase >= 3 && flow.ratingBase < 5 ? 'rating' : flow.ratingBase === 5 ? 'time' : 'gap') : flow.resumeStep);
32
- }, children: flow.completed ? copy.savedLabel : copy.prompt }), (0, jsx_runtime_1.jsx)(StarRating_1.StarRating, { value: flow.rating, disabled: disabled || (flow.ratingLocked && flow.step === 'closed'), groupLabel: copy.starGroupLabel, starLabel: copy.starLabel, theme: theme, className: classNames?.stars, style: styles?.stars, styles: styles?.starRating, classNames: classNames?.starRating, onHoverChange: setHovered, onChange: (star) => {
33
- onBeforeOpen?.();
34
- setSelectedTooltipStar(star);
35
- flow.selectRating(star);
36
- onRatingSelected?.(star);
77
+ }, children: cardLabel }), (0, jsx_runtime_1.jsx)(StarRating_1.StarRating, { value: flow.rating, disabled: disabled, groupLabel: copy.starGroupLabel, starLabel: copy.starLabel, theme: theme, className: classNames?.stars, style: styles?.stars, styles: styles?.starRating, classNames: classNames?.starRating, onHoverChange: (star, target) => { hoveredButtonRef.current = target ?? null; setHovered(star); }, onChange: (star) => {
78
+ if (reselectTimerRef.current) {
79
+ clearTimeout(reselectTimerRef.current);
80
+ reselectTimerRef.current = null;
81
+ }
82
+ if (flow.rating !== null && pendingStar !== star) {
83
+ setPendingStar(star);
84
+ return;
85
+ }
86
+ setPendingStar(null);
87
+ if (flow.rating !== null) {
88
+ flow.setRating(star);
89
+ onRatingSelected?.(star);
90
+ reselectTimerRef.current = setTimeout(() => {
91
+ onBeforeOpen?.();
92
+ flow.selectRating(star);
93
+ reselectTimerRef.current = null;
94
+ }, 350);
95
+ }
96
+ else {
97
+ onBeforeOpen?.();
98
+ flow.selectRating(star);
99
+ onRatingSelected?.(star);
100
+ }
37
101
  } }), flow.rating !== null && flow.step === 'closed' && ((0, jsx_runtime_1.jsx)("button", { type: "button", disabled: disabled, className: ['cw-feedback-review', classNames?.reviewButton].filter(Boolean).join(' '), style: styles?.reviewButton, onClick: () => {
102
+ if (reselectTimerRef.current)
103
+ clearTimeout(reselectTimerRef.current);
104
+ reselectTimerRef.current = null;
105
+ setPendingStar(null);
38
106
  onBeforeOpen?.();
39
107
  flow.setStep(flow.completed ? (flow.ratingBase >= 3 && flow.ratingBase < 5 ? 'rating' : flow.ratingBase === 5 ? 'time' : 'gap') : flow.resumeStep);
40
- }, children: flow.completed ? copy.reviewLabel : copy.continueLabel })), !flow.completed && (hovered !== null || selectedTooltipStar !== null) && ((0, jsx_runtime_1.jsx)("div", { className: ['cw-feedback-tooltip', classNames?.tooltip].filter(Boolean).join(' '), style: styles?.tooltip, children: hovered === 5 && !copy.starTooltipLabel
108
+ }, children: flow.completed ? copy.reviewLabel : copy.continueLabel })), flow.rating !== null && pendingStar !== null && (0, jsx_runtime_1.jsx)("span", { className: "cw-feedback-prompt__change-hint", role: "alert", children: "Click the star again to change your rating" }), tooltipVisible && typeof document !== 'undefined' && (0, react_dom_1.createPortal)((0, jsx_runtime_1.jsx)("div", { ref: tooltipRef, role: "tooltip", className: ['cw-feedback-tooltip', classNames?.tooltip].filter(Boolean).join(' '), style: { ...(0, feedbackTheme_1.feedbackThemeStyle)(theme), ...styles?.tooltip, display: 'block', position: 'fixed', zIndex: 2147483647, top: tooltipPosition?.top ?? -1000, left: tooltipPosition?.left ?? -1000, right: 'auto' }, children: hovered === 5 && !copy.starTooltipLabel
41
109
  ? copy.fiveStarTooltip
42
- : copy.starTooltipLabel?.(hovered ?? selectedTooltipStar ?? 5) ?? copy.starLabel(hovered ?? selectedTooltipStar ?? 5) })), extraContent && ((0, jsx_runtime_1.jsx)("div", { className: classNames?.extraContent, style: styles?.extraContent, children: extraContent })), loading && (0, jsx_runtime_1.jsxs)("div", { className: "cw-feedback-prompt__loading", role: "status", children: [(0, jsx_runtime_1.jsx)("span", { className: "cw-feedback-prompt__spinner" }), " Loading feedback\u2026"] })] }));
110
+ : copy.starTooltipLabel?.(hovered) ?? copy.starLabel(hovered) }), document.body), extraContent && ((0, jsx_runtime_1.jsx)("div", { className: classNames?.extraContent, style: styles?.extraContent, children: extraContent })), loading && (0, jsx_runtime_1.jsxs)("div", { className: "cw-feedback-prompt__loading", role: "status", children: [(0, jsx_runtime_1.jsx)("span", { className: "cw-feedback-prompt__spinner" }), " Loading feedback\u2026"] })] }));
43
111
  }
@@ -7,7 +7,7 @@ export interface StarRatingProps {
7
7
  groupLabel?: string;
8
8
  starLabel?: (star: number) => string;
9
9
  theme?: FeedbackTheme;
10
- onHoverChange?: (star: number | null) => void;
10
+ onHoverChange?: (star: number | null, target?: HTMLButtonElement) => void;
11
11
  className?: string;
12
12
  style?: CSSProperties;
13
13
  styles?: StarRatingStyles;
@@ -11,6 +11,6 @@ function StarRating({ value, onChange, disabled = false, groupLabel = 'Rate this
11
11
  const displayedRating = hovered ?? (value === null ? 0 : Math.floor(value) + (value % 1 ? 0.5 : 0));
12
12
  return ((0, jsx_runtime_1.jsx)("div", { className: ['cw-feedback-stars', className, classNames?.root].filter(Boolean).join(' '), style: { ...style, ...styles?.root }, role: "radiogroup", "aria-label": groupLabel, children: [1, 2, 3, 4, 5].map((star) => {
13
13
  const fill = Math.max(0, Math.min(1, displayedRating - star + 1)) * 100;
14
- return ((0, jsx_runtime_1.jsxs)("button", { type: "button", role: "radio", "aria-checked": Math.ceil(value ?? 0) === star, "aria-label": starLabel(star), disabled: disabled, className: classNames?.starButton, style: styles?.starButton, onMouseEnter: () => { setHovered(star); onHoverChange?.(star); }, onMouseLeave: () => { setHovered(null); onHoverChange?.(null); }, onFocus: () => { setHovered(star); onHoverChange?.(star); }, onBlur: () => { setHovered(null); onHoverChange?.(null); }, onClick: () => onChange(star), children: [(0, jsx_runtime_1.jsx)("svg", { viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("path", { d: "M10.0003 2.5L12.2503 7.16667L17.3337 7.91667L13.667 11.5L14.5003 16.5833L10.0003 14.1667L5.50033 16.5833L6.33366 11.5L2.66699 7.91667L7.75033 7.16667L10.0003 2.5Z", stroke: mergedTheme.starEmpty ?? '#cbd5e1', strokeWidth: "1.33333", strokeLinejoin: "round" }) }), fill > 0 && ((0, jsx_runtime_1.jsx)("svg", { viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true", className: "cw-feedback-stars__filled", style: { clipPath: `inset(0 ${100 - fill}% 0 0)` }, children: (0, jsx_runtime_1.jsx)("path", { d: "M10.0003 2.5L12.2503 7.16667L17.3337 7.91667L13.667 11.5L14.5003 16.5833L10.0003 14.1667L5.50033 16.5833L6.33366 11.5L2.66699 7.91667L7.75033 7.16667L10.0003 2.5Z", fill: mergedTheme.accent ?? '#5b21e6', stroke: mergedTheme.accent ?? '#5b21e6', strokeWidth: "1.33333", strokeLinejoin: "round" }) }))] }, star));
14
+ return ((0, jsx_runtime_1.jsxs)("button", { type: "button", role: "radio", "aria-checked": Math.ceil(value ?? 0) === star, "aria-label": starLabel(star), disabled: disabled, className: classNames?.starButton, style: { width: 20, height: 20, flex: '0 0 20px', ...styles?.starButton }, onMouseEnter: (event) => { setHovered(star); onHoverChange?.(star, event.currentTarget); }, onMouseLeave: () => { setHovered(null); onHoverChange?.(null); }, onFocus: (event) => { setHovered(star); onHoverChange?.(star, event.currentTarget); }, onBlur: () => { setHovered(null); onHoverChange?.(null); }, onClick: () => onChange(star), children: [(0, jsx_runtime_1.jsx)("svg", { viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("path", { d: "M10.0003 2.5L12.2503 7.16667L17.3337 7.91667L13.667 11.5L14.5003 16.5833L10.0003 14.1667L5.50033 16.5833L6.33366 11.5L2.66699 7.91667L7.75033 7.16667L10.0003 2.5Z", stroke: mergedTheme.starEmpty ?? '#cbd5e1', strokeWidth: "1.33333", strokeLinejoin: "round" }) }), fill > 0 && ((0, jsx_runtime_1.jsx)("svg", { viewBox: "0 0 20 20", fill: "none", "aria-hidden": "true", className: "cw-feedback-stars__filled", style: { clipPath: `inset(0 ${100 - fill}% 0 0)` }, children: (0, jsx_runtime_1.jsx)("path", { d: "M10.0003 2.5L12.2503 7.16667L17.3337 7.91667L13.667 11.5L14.5003 16.5833L10.0003 14.1667L5.50033 16.5833L6.33366 11.5L2.66699 7.91667L7.75033 7.16667L10.0003 2.5Z", fill: mergedTheme.accent ?? '#5b21e6', stroke: mergedTheme.accent ?? '#5b21e6', strokeWidth: "1.33333", strokeLinejoin: "round" }) }))] }, star));
15
15
  }) }));
16
16
  }
@@ -1,5 +1,7 @@
1
1
  export interface FeatureAvailabilityWindow {
2
2
  enabled?: boolean;
3
+ name?: string | null;
4
+ description?: string | null;
3
5
  startsAt?: string | null;
4
6
  endsAt?: string | null;
5
7
  uiNewTtl?: string | null;
@@ -18,4 +20,8 @@ export interface HttpFeatureAvailabilityOptions {
18
20
  export declare function createHttpFeatureAvailabilityApi(options: HttpFeatureAvailabilityOptions): FeatureAvailabilityApi;
19
21
  export declare function isFeatureAvailable(window: FeatureAvailabilityWindow | null, now?: number): boolean;
20
22
  /** Feature-only gate; chat responses with request IDs never call this API. */
23
+ export declare function useFeatureAvailabilityDetails(modeId: string | undefined, featureId: string, api: FeatureAvailabilityApi | undefined, enabled: boolean): {
24
+ available: boolean;
25
+ feature: FeatureAvailabilityWindow | null;
26
+ };
21
27
  export declare function useFeatureAvailability(modeId: string | undefined, featureId: string, api: FeatureAvailabilityApi | undefined, enabled: boolean): boolean;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createMockFeatureAvailabilityApi = createMockFeatureAvailabilityApi;
4
4
  exports.createHttpFeatureAvailabilityApi = createHttpFeatureAvailabilityApi;
5
5
  exports.isFeatureAvailable = isFeatureAvailable;
6
+ exports.useFeatureAvailabilityDetails = useFeatureAvailabilityDetails;
6
7
  exports.useFeatureAvailability = useFeatureAvailability;
7
8
  const react_1 = require("react");
8
9
  /** Replace this adapter with the feature-availability endpoint when its contract is available. */
@@ -47,6 +48,8 @@ function createHttpFeatureAvailabilityApi(options) {
47
48
  return null;
48
49
  return {
49
50
  enabled: true,
51
+ name: feature.feature_name,
52
+ description: feature.feature_description,
50
53
  startsAt: feature.start_date,
51
54
  endsAt: feature.end_date,
52
55
  uiNewTtl: feature.ui_new_ttl,
@@ -65,7 +68,7 @@ function isFeatureAvailable(window, now = Date.now()) {
65
68
  (ttl === null || (Number.isFinite(ttl) && now < ttl));
66
69
  }
67
70
  /** Feature-only gate; chat responses with request IDs never call this API. */
68
- function useFeatureAvailability(modeId, featureId, api, enabled) {
71
+ function useFeatureAvailabilityDetails(modeId, featureId, api, enabled) {
69
72
  const [result, setResult] = (0, react_1.useState)(null);
70
73
  const [now, setNow] = (0, react_1.useState)(() => Date.now());
71
74
  const window = result && result.modeId === modeId && result.featureId === featureId ? result.window : null;
@@ -100,5 +103,8 @@ function useFeatureAvailability(modeId, featureId, api, enabled) {
100
103
  document.removeEventListener("visibilitychange", update);
101
104
  };
102
105
  }, [enabled, now, window]);
103
- return !enabled || (Boolean(modeId) && isFeatureAvailable(window, now));
106
+ return { available: !enabled || (Boolean(modeId) && isFeatureAvailable(window, now)), feature: window };
107
+ }
108
+ function useFeatureAvailability(modeId, featureId, api, enabled) {
109
+ return useFeatureAvailabilityDetails(modeId, featureId, api, enabled).available;
104
110
  }
package/dist/feedback.css CHANGED
@@ -54,9 +54,46 @@
54
54
  font-size: 13px;
55
55
  font-weight: 600;
56
56
  }
57
+ .cw-feedback-prompt__feature-context {
58
+ display: grid;
59
+ gap: 2px;
60
+ max-width: 240px;
61
+ margin-bottom: 7px;
62
+ color: #344054;
63
+ font-size: 11px;
64
+ line-height: 1.3;
65
+ }
66
+ .cw-feedback-prompt__feature-context strong {
67
+ font-size: 12px;
68
+ }
69
+ .cw-feedback-prompt__feature-context span {
70
+ display: -webkit-box;
71
+ overflow: hidden;
72
+ -webkit-box-orient: vertical;
73
+ -webkit-line-clamp: 2;
74
+ color: #667085;
75
+ }
57
76
  .cw-feedback-stars {
58
77
  display: flex;
59
- gap: 3px;
78
+ gap: 5px;
79
+ }
80
+ .cw-feedback-prompt__change-hint {
81
+ display: block;
82
+ margin-top: 4px;
83
+ color: #dc2626;
84
+ font-size: 10px;
85
+ line-height: 1.2;
86
+ }
87
+ .cw-feedback-prompt--new .cw-feedback-stars button {
88
+ animation: cw-feedback-star-wave 2.5s ease-in-out infinite;
89
+ }
90
+ .cw-feedback-prompt--new .cw-feedback-stars button:nth-child(2) { animation-delay: .16s; }
91
+ .cw-feedback-prompt--new .cw-feedback-stars button:nth-child(3) { animation-delay: .32s; }
92
+ .cw-feedback-prompt--new .cw-feedback-stars button:nth-child(4) { animation-delay: .48s; }
93
+ .cw-feedback-prompt--new .cw-feedback-stars button:nth-child(5) { animation-delay: .64s; }
94
+ @keyframes cw-feedback-star-wave {
95
+ 0%, 32%, 100% { filter: drop-shadow(0 0 0 transparent); transform: translateY(0); }
96
+ 14% { filter: drop-shadow(0 0 5px #8b5cf6); transform: translateY(-2px); }
60
97
  }
61
98
  .cw-feedback-stars button {
62
99
  position: relative;
@@ -83,13 +120,18 @@
83
120
  position: absolute;
84
121
  right: 0;
85
122
  top: calc(100% + 7px);
86
- white-space: nowrap;
123
+ max-width: min(320px, calc(100vw - 16px));
124
+ white-space: normal;
87
125
  background: #273349;
88
126
  color: #fff;
89
127
  border-radius: 5px;
90
128
  padding: 6px 9px;
91
129
  font-size: 11px;
92
130
  }
131
+ .cw-feedback-tooltip--above {
132
+ top: auto;
133
+ bottom: calc(100% + 7px);
134
+ }
93
135
  .cw-feedback-backdrop {
94
136
  position: fixed;
95
137
  inset: 0;
@@ -462,10 +504,39 @@ body.cw-feedback-capturing .cw-feedback-prompt {
462
504
  cursor: pointer;
463
505
  }
464
506
  .cw-feedback-prompt:has(.cw-feedback-review) {
465
- min-width: 270px;
507
+ display: flex;
508
+ flex-direction: column;
509
+ align-items: center;
510
+ row-gap: 4px;
511
+ min-width: 0;
466
512
  }
467
513
  .cw-feedback-prompt:has(.cw-feedback-review) .cw-feedback-stars {
468
- padding-right: 64px;
514
+ flex: 0 0 auto;
515
+ padding-right: 0;
516
+ }
517
+ .cw-feedback-prompt:has(.cw-feedback-review) .cw-feedback-prompt__change-hint {
518
+ grid-column: 1 / -1;
519
+ width: 100%;
520
+ max-width: 240px;
521
+ justify-self: center;
522
+ margin-top: 2px;
523
+ text-align: center;
524
+ white-space: normal;
525
+ overflow-wrap: anywhere;
526
+ }
527
+ .cw-feedback-prompt:has(.cw-feedback-review) .cw-feedback-prompt__label {
528
+ width: 100%;
529
+ min-width: 0;
530
+ overflow-wrap: anywhere;
531
+ }
532
+ .cw-feedback-prompt:has(.cw-feedback-review) .cw-feedback-prompt__feature-context {
533
+ width: 100%;
534
+ }
535
+ .cw-feedback-prompt:has(.cw-feedback-review) .cw-feedback-review {
536
+ position: static;
537
+ margin: 2px 0 0;
538
+ padding: 0;
539
+ white-space: nowrap;
469
540
  }
470
541
  .cw-feedback-stars button:disabled {
471
542
  cursor: default;
@@ -497,7 +568,11 @@ body.cw-feedback-capturing .cw-feedback-prompt {
497
568
  border-radius: 50%;
498
569
  background: #bce8ce;
499
570
  color: #fff;
500
- animation: cw-feedback-success-pop 600ms cubic-bezier(.2, 1.5, .35, 1) both;
571
+ animation: cw-feedback-success-pop 600ms cubic-bezier(.2, 1.5, .35, 1) both, cw-feedback-success-glow 2.4s 600ms ease-in-out infinite;
572
+ }
573
+ @keyframes cw-feedback-success-glow {
574
+ 0%, 100% { box-shadow: 0 0 0 0 #16a34a20; }
575
+ 50% { box-shadow: 0 0 0 12px #16a34a16, 0 0 28px 7px #16a34a50; }
501
576
  }
502
577
  .cw-feedback-success__check::after {
503
578
  content: "";
@@ -527,6 +602,8 @@ body.cw-feedback-capturing .cw-feedback-prompt {
527
602
  to { stroke-dashoffset: 0; }
528
603
  }
529
604
  @media (prefers-reduced-motion: reduce) {
605
+ .cw-feedback-prompt--new .cw-feedback-stars button,
606
+ .cw-feedback-inline-detail-prompt,
530
607
  .cw-feedback-success__check,
531
608
  .cw-feedback-success__check::after,
532
609
  .cw-feedback-success__check svg :is(path, polyline) {
@@ -596,7 +673,7 @@ body.cw-feedback-capturing .cw-feedback-prompt {
596
673
  color: #16a34a;
597
674
  }
598
675
  .cw-feedback-prompt:has(.cw-feedback-review) .cw-feedback-stars {
599
- padding-right: 76px;
676
+ padding-right: 0;
600
677
  }
601
678
  .cw-feedback-hint {
602
679
  font-size: 12px;
@@ -703,8 +780,8 @@ body.cw-feedback-capturing .cw-feedback-prompt {
703
780
  }
704
781
  .cw-feedback-tooltip,
705
782
  .cw-feedback-range-tooltip {
706
- background: var(--cw-feedback-tooltip-bg);
707
- color: var(--cw-feedback-tooltip-text);
783
+ background: var(--cw-feedback-tooltip-bg, #273349);
784
+ color: var(--cw-feedback-tooltip-text, #fff);
708
785
  }
709
786
  .cw-feedback-range-tooltip:after {
710
787
  border-top-color: var(--cw-feedback-tooltip-bg);
@@ -759,7 +836,48 @@ body.cw-feedback-capturing .cw-feedback-prompt {
759
836
  font-weight: 500;
760
837
  }
761
838
  .cw-feedback-range {
762
- margin-top: 24px;
839
+ margin-top: 18px;
840
+ }
841
+ .cw-feedback-range-tooltip {
842
+ width: max-content;
843
+ max-width: calc(100vw - 16px);
844
+ white-space: nowrap;
845
+ bottom: calc(100% + 20px);
846
+ transform: none;
847
+ }
848
+ @media (max-width: 480px) {
849
+ .cw-feedback-range-tooltip {
850
+ white-space: normal;
851
+ }
852
+ }
853
+ .cw-feedback-range-tooltip::after { display: none; }
854
+ .cw-feedback-range__pointer {
855
+ position: absolute;
856
+ bottom: calc(100% + 8px);
857
+ width: 0;
858
+ height: 0;
859
+ border: 6px solid transparent;
860
+ border-top-color: var(--cw-feedback-tooltip-bg, #344054);
861
+ transform: translateX(-50%);
862
+ pointer-events: none;
863
+ }
864
+ .cw-feedback-range__ticks {
865
+ display: flex;
866
+ justify-content: space-between;
867
+ height: 8px;
868
+ margin: -1px 2px 0;
869
+ pointer-events: none;
870
+ }
871
+ .cw-feedback-range__ticks span {
872
+ width: 1px;
873
+ height: 5px;
874
+ background: #cbd5e1;
875
+ }
876
+ .cw-feedback-range__ticks span.is-major {
877
+ height: 8px;
878
+ }
879
+ .cw-feedback-rating-options {
880
+ margin-top: 8px;
763
881
  }
764
882
  .cw-feedback-low-rating-intro {
765
883
  margin: 0 0 10px;
@@ -788,17 +906,31 @@ body.cw-feedback-capturing .cw-feedback-prompt {
788
906
  font-size: 13px;
789
907
  }
790
908
  .cw-feedback-inline-detail-prompt {
791
- margin: 0;
909
+ margin: 10px 0 2px;
792
910
  color: var(--cw-feedback-accent);
793
911
  font-size: 13px;
794
912
  font-weight: 400;
795
913
  line-height: 1.35;
914
+ animation: cw-feedback-detail-arrive 1.15s ease-out both;
796
915
  }
797
916
  .cw-feedback-dialog__content > .cw-feedback-inline-detail-prompt {
798
- margin-bottom: 10px;
917
+ margin-top: 14px;
918
+ margin-bottom: 3px;
919
+ }
920
+ @keyframes cw-feedback-detail-arrive {
921
+ 0% { opacity: 0; filter: drop-shadow(0 0 8px var(--cw-feedback-accent)); transform: translateY(3px); }
922
+ 55% { opacity: 1; filter: drop-shadow(0 0 5px var(--cw-feedback-accent)); }
923
+ 100% { opacity: 1; filter: none; transform: translateY(0); }
799
924
  }
800
925
  .cw-feedback-useful-field {
801
- margin-top: 8px;
926
+ margin-top: 6px;
927
+ gap: 3px;
928
+ }
929
+ .cw-feedback-useful-field .cw-feedback-inline-detail-prompt {
930
+ margin: 2px 0 0;
931
+ }
932
+ .cw-feedback-useful-prompt.cw-feedback-inline-detail-prompt {
933
+ margin: 4px 0 2px;
802
934
  }
803
935
  .cw-feedback-time-title {
804
936
  margin-top: 5px !important;
@@ -1047,7 +1179,7 @@ body .cw-feedback-dialog {
1047
1179
  .cw-feedback-gap__severity.medium { color: #ca8a04; }
1048
1180
  .cw-feedback-gap__severity.low { color: #64748b; }
1049
1181
  .cw-feedback-gap__detail-prompt {
1050
- margin: 0 0 8px;
1182
+ margin: 10px 0 2px;
1051
1183
  }
1052
1184
  .cw-feedback-gap__remove {
1053
1185
  display: inline-flex;
package/dist/types.js CHANGED
@@ -61,15 +61,15 @@ exports.defaultFeedbackCopy = {
61
61
  usefulTitle: "What part did you find most useful?",
62
62
  lowRatingUsefulTitle: "What did you find most useful?",
63
63
  usefulMoreDetailTitle: "What did you find most useful?",
64
- usefulMoreDetailPrompt: "Could you share a little more detail to understand you better?",
64
+ usefulMoreDetailPrompt: "Could you share a little more detail before you move forward?",
65
65
  usefulPlaceholder: "The part that helped me most was...",
66
- usefulMinimumWords: 10,
67
- gapTitle: "What’s the biggest gap we could improve to make it better?",
66
+ usefulMinimumWords: 5,
67
+ gapTitle: "What is the biggest gap we could address to improve this response?",
68
68
  fiveStarGapTitle: "Is there anything we could improve to make this even better?",
69
69
  lowRatingIntroTitle: "We’re truly sorry. You deserve a better experience!",
70
70
  lowRatingIntroDescription: "Tell us what went wrong in a few quick steps, so we can make things better for you.",
71
- gapSubtitle: "Help us help you better",
72
- gapMoreDetailPrompt: "Could you share a little more detail to help you better?",
71
+ gapSubtitle: "Tell us what was missing, incorrect, or difficult to use.",
72
+ gapMoreDetailPrompt: "Could you share a little more detail before you move forward?",
73
73
  gapPlaceholder: "Tell us about the gap...",
74
74
  severityQuestion: "How severely does this issue impact your work?",
75
75
  severityLabels: { critical: "Critical", high: "High", medium: "Medium", low: "Low" },
@@ -44,20 +44,17 @@ function useFeedbackFlow(requestId, featureId, metadata) {
44
44
  }, []);
45
45
  const changeRating = (0, react_1.useCallback)((value) => {
46
46
  hasInteracted.current = true;
47
- if (rating !== value)
47
+ if (rating !== value || completed)
48
48
  clearDraft();
49
49
  setRating(value);
50
- }, [clearDraft, rating]);
50
+ }, [clearDraft, completed, rating]);
51
51
  const close = (0, react_1.useCallback)(() => {
52
52
  setRatingLocked(true);
53
53
  setStep("closed");
54
54
  }, []);
55
55
  const selectRating = (0, react_1.useCallback)((value) => {
56
56
  hasInteracted.current = true;
57
- // Keep the stars usable while their detail popup is open. Once dismissed,
58
- // the closed card is read-only and the user resumes with Continue.
59
- if (ratingLocked && step === "closed")
60
- return;
57
+ // A confirmed re-rating can reopen a completed response.
61
58
  changeRating(value);
62
59
  setRatingBase(value);
63
60
  if (value === 5) {
@@ -72,7 +69,7 @@ function useFeedbackFlow(requestId, featureId, metadata) {
72
69
  }
73
70
  else
74
71
  setStep("closed");
75
- }, [changeRating, ratingLocked, step]);
72
+ }, [changeRating]);
76
73
  const submission = (0, react_1.useMemo)(() => rating === null
77
74
  ? null
78
75
  : {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@e-llm-studio/feedback-flow",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "private": false,
5
5
  "description": "Reusable configurable feedback rating and progressive feedback screens",
6
6
  "main": "./dist/index.js",