@handled-ai/design-system 0.18.5 → 0.18.6

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 (34) hide show
  1. package/dist/components/badge.d.ts +1 -1
  2. package/dist/components/button.d.ts +1 -1
  3. package/dist/components/feedback-primitives.d.ts +41 -2
  4. package/dist/components/feedback-primitives.js +241 -6
  5. package/dist/components/feedback-primitives.js.map +1 -1
  6. package/dist/components/pill.d.ts +1 -1
  7. package/dist/components/score-why-chips.d.ts +1 -1
  8. package/dist/components/score-why-chips.js +26 -5
  9. package/dist/components/score-why-chips.js.map +1 -1
  10. package/dist/components/signal-priority-popover.d.ts +1 -1
  11. package/dist/components/signal-priority-popover.js +32 -6
  12. package/dist/components/signal-priority-popover.js.map +1 -1
  13. package/dist/components/tabs.d.ts +1 -1
  14. package/dist/index.d.ts +2 -2
  15. package/dist/index.js +2 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/prototype/index.d.ts +1 -1
  18. package/dist/prototype/prototype-accounts-view.d.ts +1 -1
  19. package/dist/prototype/prototype-admin-view.d.ts +1 -1
  20. package/dist/prototype/prototype-config.d.ts +1 -1
  21. package/dist/prototype/prototype-inbox-view.d.ts +1 -1
  22. package/dist/prototype/prototype-inbox-view.js +4 -1
  23. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  24. package/dist/prototype/prototype-insights-view.d.ts +1 -1
  25. package/dist/prototype/prototype-shell.d.ts +1 -1
  26. package/dist/{signal-priority-popover-DQ_VuHac.d.ts → signal-priority-popover-DWaAMhPI.d.ts} +26 -2
  27. package/package.json +1 -1
  28. package/src/components/__tests__/wit-636-feedback-states.test.tsx +546 -0
  29. package/src/components/feedback-primitives.tsx +333 -26
  30. package/src/components/score-why-chips.tsx +28 -2
  31. package/src/components/signal-priority-popover.tsx +44 -4
  32. package/src/index.ts +2 -2
  33. package/src/prototype/prototype-config.ts +11 -1
  34. package/src/prototype/prototype-inbox-view.tsx +3 -0
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from "react"
4
- import { ThumbsUp, ThumbsDown } from "lucide-react"
4
+ import { ThumbsUp, ThumbsDown, Check, Pencil } from "lucide-react"
5
5
  import { cn } from "../lib/utils"
6
6
 
7
7
  // ---------------------------------------------------------------------------
@@ -25,6 +25,19 @@ export interface FeedbackSubmitData {
25
25
  detail: string
26
26
  }
27
27
 
28
+ /**
29
+ * Persisted feedback data from a previous submission, used to hydrate the
30
+ * footer into its "already submitted" visual state.
31
+ */
32
+ export interface PersistedFeedbackData {
33
+ sentiment: "positive" | "negative"
34
+ reasonTop?: string
35
+ reasonSub?: string
36
+ pills?: string[]
37
+ detail?: string
38
+ ownershipLabel: "Your feedback" | "Team feedback"
39
+ }
40
+
28
41
  /**
29
42
  * Defines a tier-1 chip that may have tier-2 sub-chips.
30
43
  */
@@ -185,6 +198,13 @@ export interface FeedbackFooterProps {
185
198
  negativeChips?: FeedbackChipTree[]
186
199
  positiveChips?: string[]
187
200
  className?: string
201
+ /** Pre-existing feedback to hydrate from (e.g. after page reload). */
202
+ initialFeedback?: PersistedFeedbackData | null
203
+ /** Label shown in the transient confirmation pill after submit. */
204
+ submittedLabel?: string
205
+ /** Stable key for syncing initialFeedback into local state. When this
206
+ * changes, the component resets to the new initialFeedback value. */
207
+ feedbackKey?: string
188
208
  }
189
209
 
190
210
  const SENTIMENT_BUTTON_ACTIVE: Record<"positive" | "negative", string> = {
@@ -205,6 +225,9 @@ export function FeedbackFooter({
205
225
  negativeChips = [],
206
226
  positiveChips = [],
207
227
  className,
228
+ initialFeedback,
229
+ submittedLabel = "Saved",
230
+ feedbackKey,
208
231
  }: FeedbackFooterProps) {
209
232
  const [expanded, setExpanded] = React.useState(false)
210
233
  const [selectedTier1, setSelectedTier1] = React.useState<string | null>(null)
@@ -214,6 +237,48 @@ export function FeedbackFooter({
214
237
  const [activeTreeIndex, setActiveTreeIndex] = React.useState<number | null>(
215
238
  null,
216
239
  )
240
+ /** Transient "Saved" confirmation — shown after successful submit. */
241
+ const [submitted, setSubmitted] = React.useState(false)
242
+ /** Persisted feedback shown as a clickable indicator (survives reload). */
243
+ const [persisted, setPersisted] = React.useState<PersistedFeedbackData | null>(
244
+ initialFeedback ?? null,
245
+ )
246
+ /** Tracks whether the user is actively editing (ref to guard against prop overwrites without triggering re-syncs). */
247
+ const isEditingRef = React.useRef(false)
248
+ /** Track the last synced feedbackKey to detect key changes. */
249
+ const lastKeyRef = React.useRef<string | undefined>(feedbackKey)
250
+
251
+ /** Helper to update the editing ref. */
252
+ const setIsEditing = React.useCallback((value: boolean) => {
253
+ isEditingRef.current = value
254
+ }, [])
255
+
256
+ // Sync initialFeedback into local state via useEffect keyed on feedbackKey.
257
+ // When feedbackKey changes, reset to new target. Preserve active edits
258
+ // when feedbackKey stays the same.
259
+ React.useEffect(() => {
260
+ const keyChanged = feedbackKey !== lastKeyRef.current
261
+ lastKeyRef.current = feedbackKey
262
+
263
+ if (keyChanged) {
264
+ // Key changed — full reset to new target
265
+ setPersisted(initialFeedback ?? null)
266
+ setSubmitted(false)
267
+ setExpanded(false)
268
+ isEditingRef.current = false
269
+ if (initialFeedback) {
270
+ onFeedbackChange(initialFeedback.sentiment)
271
+ } else {
272
+ onFeedbackChange(null)
273
+ }
274
+ } else if (!isEditingRef.current) {
275
+ // Same key, not actively editing — safe to sync
276
+ setPersisted(initialFeedback ?? null)
277
+ if (initialFeedback) {
278
+ onFeedbackChange(initialFeedback.sentiment)
279
+ }
280
+ }
281
+ }, [initialFeedback, feedbackKey, onFeedbackChange])
217
282
 
218
283
  // Reset state when feedback collapses
219
284
  const resetState = React.useCallback(() => {
@@ -223,7 +288,8 @@ export function FeedbackFooter({
223
288
  setAdditionalPills([])
224
289
  setDetailText("")
225
290
  setActiveTreeIndex(null)
226
- }, [])
291
+ setIsEditing(false)
292
+ }, [setIsEditing])
227
293
 
228
294
  const handleSentimentClick = React.useCallback(
229
295
  (sentiment: "positive" | "negative") => {
@@ -231,10 +297,26 @@ export function FeedbackFooter({
231
297
  // Reset chip state when switching sentiment, then expand
232
298
  resetState()
233
299
  setExpanded(true)
300
+ setSubmitted(false)
301
+ setPersisted(null)
302
+ setIsEditing(true)
234
303
  },
235
- [onFeedbackChange, resetState],
304
+ [onFeedbackChange, resetState, setIsEditing],
236
305
  )
237
306
 
307
+ /** Open the persisted indicator for editing. */
308
+ const handlePersistedClick = React.useCallback(() => {
309
+ if (!persisted) return
310
+ onFeedbackChange(persisted.sentiment)
311
+ setSelectedTier1(persisted.reasonTop ?? null)
312
+ setSelectedTier2(persisted.reasonSub ?? null)
313
+ setAdditionalPills(persisted.pills ?? [])
314
+ setDetailText(persisted.detail ?? "")
315
+ setExpanded(true)
316
+ setSubmitted(false)
317
+ setIsEditing(true)
318
+ }, [persisted, onFeedbackChange, setIsEditing])
319
+
238
320
  const handleTier1Toggle = React.useCallback(
239
321
  (chipLabel: string) => {
240
322
  if (selectedTier1 === chipLabel) {
@@ -295,6 +377,9 @@ export function FeedbackFooter({
295
377
  pills: additionalPills,
296
378
  detail: detailText,
297
379
  })
380
+ // Show transient "Saved" confirmation
381
+ setSubmitted(true)
382
+ // Collapse expansion but keep sentiment visible
298
383
  resetState()
299
384
  }, [
300
385
  feedback,
@@ -323,38 +408,75 @@ export function FeedbackFooter({
323
408
  const activeTree =
324
409
  activeTreeIndex !== null ? negativeChips[activeTreeIndex] : null
325
410
 
411
+ // Determine if we should show the persisted indicator instead of bare buttons
412
+ const showPersistedIndicator = persisted && !expanded && !submitted
413
+
326
414
  return (
327
415
  <div className={cn("space-y-3", className)}>
328
416
  {/* Sentiment buttons + meta text bar */}
329
417
  <div className="flex items-center justify-between">
330
- <div className="flex items-center gap-3">
418
+ {showPersistedIndicator ? (
419
+ /* Persisted feedback indicator — clickable to reopen editor */
331
420
  <button
332
421
  type="button"
333
- onClick={() => handleSentimentClick("positive")}
334
- className={cn(
335
- "flex gap-1 items-center text-[11px] font-medium rounded-md px-2 py-1 transition-colors",
336
- feedback === "positive"
337
- ? SENTIMENT_BUTTON_ACTIVE.positive
338
- : SENTIMENT_BUTTON_IDLE,
339
- )}
422
+ onClick={handlePersistedClick}
423
+ className="group flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
424
+ data-testid="persisted-feedback-indicator"
340
425
  >
341
- <ThumbsUp className="h-[11px] w-[11px]" />
342
- Helpful
343
- </button>
344
- <button
345
- type="button"
346
- onClick={() => handleSentimentClick("negative")}
347
- className={cn(
348
- "flex gap-1 items-center text-[11px] font-medium rounded-md px-2 py-1 transition-colors",
349
- feedback === "negative"
350
- ? SENTIMENT_BUTTON_ACTIVE.negative
351
- : SENTIMENT_BUTTON_IDLE,
426
+ <span className="font-medium">{persisted.ownershipLabel}:</span>
427
+ {persisted.sentiment === "positive" ? (
428
+ <ThumbsUp className="h-[11px] w-[11px]" />
429
+ ) : (
430
+ <ThumbsDown className="h-[11px] w-[11px]" />
352
431
  )}
353
- >
354
- <ThumbsDown className="h-[11px] w-[11px]" />
355
- Not helpful
432
+ {persisted.detail && (
433
+ <span className="max-w-[200px] truncate text-muted-foreground/70">
434
+ {persisted.detail}
435
+ </span>
436
+ )}
437
+ <Pencil className="h-[9px] w-[9px] opacity-0 group-hover:opacity-100 transition-opacity" />
356
438
  </button>
357
- </div>
439
+ ) : (
440
+ <div className="flex items-center gap-3">
441
+ <button
442
+ type="button"
443
+ onClick={() => handleSentimentClick("positive")}
444
+ className={cn(
445
+ "flex gap-1 items-center text-[11px] font-medium rounded-md px-2 py-1 transition-colors",
446
+ feedback === "positive"
447
+ ? SENTIMENT_BUTTON_ACTIVE.positive
448
+ : SENTIMENT_BUTTON_IDLE,
449
+ )}
450
+ >
451
+ <ThumbsUp className="h-[11px] w-[11px]" />
452
+ Helpful
453
+ </button>
454
+ <button
455
+ type="button"
456
+ onClick={() => handleSentimentClick("negative")}
457
+ className={cn(
458
+ "flex gap-1 items-center text-[11px] font-medium rounded-md px-2 py-1 transition-colors",
459
+ feedback === "negative"
460
+ ? SENTIMENT_BUTTON_ACTIVE.negative
461
+ : SENTIMENT_BUTTON_IDLE,
462
+ )}
463
+ >
464
+ <ThumbsDown className="h-[11px] w-[11px]" />
465
+ Not helpful
466
+ </button>
467
+ {/* Transient "Saved" confirmation pill */}
468
+ {submitted && feedback && (
469
+ <span
470
+ className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-600"
471
+ role="status"
472
+ data-testid="feedback-submitted-pill"
473
+ >
474
+ <Check className="h-[11px] w-[11px]" />
475
+ {submittedLabel}
476
+ </span>
477
+ )}
478
+ </div>
479
+ )}
358
480
  {metaText && (
359
481
  <span className="text-[11px] text-muted-foreground">{metaText}</span>
360
482
  )}
@@ -422,3 +544,188 @@ export function FeedbackFooter({
422
544
  </div>
423
545
  )
424
546
  }
547
+
548
+ // ---------------------------------------------------------------------------
549
+ // InlineFeedbackControl — shared thumb+detail inline feedback widget
550
+ // ---------------------------------------------------------------------------
551
+
552
+ export interface InlineFeedbackControlProps {
553
+ /** Unique key identifying the feedback target (e.g. factor key). */
554
+ feedbackKey: string
555
+ /** Persisted/initial feedback to hydrate from. */
556
+ initialFeedback?: { type: "up" | "down"; detail: string; ownershipLabel?: string }
557
+ /** Called when user submits or clears feedback. */
558
+ onFeedback?: (key: string, type: "up" | "down" | null, detail?: string) => void
559
+ /** Test ID prefix for all sub-elements. */
560
+ testIdPrefix?: string
561
+ }
562
+
563
+ /**
564
+ * Compact inline thumb-up/thumb-down feedback with optional detail text.
565
+ * Used by PriorityFactorRow and any other component that needs
566
+ * a lightweight feedback control.
567
+ */
568
+ export function InlineFeedbackControl({
569
+ feedbackKey,
570
+ initialFeedback,
571
+ onFeedback,
572
+ testIdPrefix = "inline-feedback",
573
+ }: InlineFeedbackControlProps) {
574
+ const [thumbState, setThumbState] = React.useState<"up" | "down" | null>(
575
+ initialFeedback?.type ?? null,
576
+ )
577
+ const [showInput, setShowInput] = React.useState(false)
578
+ const [detailText, setDetailText] = React.useState(initialFeedback?.detail ?? "")
579
+ const [saved, setSaved] = React.useState(!!initialFeedback)
580
+ const [savedDetail, setSavedDetail] = React.useState(initialFeedback?.detail ?? "")
581
+ const ownershipLabel = initialFeedback?.ownershipLabel ?? "Your feedback"
582
+
583
+ // Sync with initialFeedback prop changes
584
+ React.useEffect(() => {
585
+ if (initialFeedback) {
586
+ setThumbState(initialFeedback.type)
587
+ setSaved(true)
588
+ setSavedDetail(initialFeedback.detail)
589
+ }
590
+ }, [initialFeedback])
591
+
592
+ const handleThumbClick = React.useCallback(
593
+ (type: "up" | "down") => {
594
+ if (thumbState === type) {
595
+ // Toggle off
596
+ setThumbState(null)
597
+ setShowInput(false)
598
+ setSaved(false)
599
+ onFeedback?.(feedbackKey, null)
600
+ } else {
601
+ setThumbState(type)
602
+ setShowInput(true)
603
+ setSaved(false)
604
+ }
605
+ },
606
+ [thumbState, feedbackKey, onFeedback],
607
+ )
608
+
609
+ const handleSubmitDetail = React.useCallback(() => {
610
+ if (!thumbState) return
611
+ const text = detailText.trim()
612
+ onFeedback?.(feedbackKey, thumbState, text)
613
+ setSaved(true)
614
+ setSavedDetail(text)
615
+ setShowInput(false)
616
+ }, [thumbState, detailText, feedbackKey, onFeedback])
617
+
618
+ return (
619
+ <div>
620
+ {saved && !showInput ? (
621
+ /* Persisted / saved indicator */
622
+ <button
623
+ type="button"
624
+ onClick={() => {
625
+ setDetailText(savedDetail)
626
+ setShowInput(true)
627
+ setSaved(false)
628
+ }}
629
+ className="group flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
630
+ data-testid={`${testIdPrefix}-feedback-persisted-${feedbackKey}`}
631
+ >
632
+ <span className="font-medium">{ownershipLabel}:</span>
633
+ {thumbState === "up" ? (
634
+ <ThumbsUp className="h-[10px] w-[10px]" />
635
+ ) : (
636
+ <ThumbsDown className="h-[10px] w-[10px]" />
637
+ )}
638
+ {savedDetail && (
639
+ <span className="max-w-[180px] truncate text-muted-foreground/70">
640
+ {savedDetail}
641
+ </span>
642
+ )}
643
+ <Pencil className="h-[9px] w-[9px] opacity-0 group-hover:opacity-100 transition-opacity" />
644
+ </button>
645
+ ) : (
646
+ <div className="flex items-center gap-1.5">
647
+ {/* Inline thumb buttons */}
648
+ <button
649
+ type="button"
650
+ onClick={() => handleThumbClick("up")}
651
+ className={cn(
652
+ "p-1 rounded transition-colors",
653
+ thumbState === "up"
654
+ ? "text-foreground bg-muted"
655
+ : "text-muted-foreground/40 hover:text-foreground hover:bg-muted/50",
656
+ )}
657
+ title="This is accurate"
658
+ data-testid={`${testIdPrefix}-thumb-up-${feedbackKey}`}
659
+ >
660
+ <ThumbsUp className="h-[10px] w-[10px]" />
661
+ </button>
662
+ <button
663
+ type="button"
664
+ onClick={() => handleThumbClick("down")}
665
+ className={cn(
666
+ "p-1 rounded transition-colors",
667
+ thumbState === "down"
668
+ ? "text-red-600 bg-red-50"
669
+ : "text-muted-foreground/40 hover:text-red-600 hover:bg-red-50/50",
670
+ )}
671
+ title="Report issue"
672
+ data-testid={`${testIdPrefix}-thumb-down-${feedbackKey}`}
673
+ >
674
+ <ThumbsDown className="h-[10px] w-[10px]" />
675
+ </button>
676
+
677
+ {/* Transient "Saved" pill */}
678
+ {saved && (
679
+ <span
680
+ className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-600"
681
+ role="status"
682
+ data-testid={`${testIdPrefix}-saved-${feedbackKey}`}
683
+ >
684
+ <Check className="h-[10px] w-[10px]" />
685
+ Saved
686
+ </span>
687
+ )}
688
+ </div>
689
+ )}
690
+
691
+ {/* Inline detail input */}
692
+ {showInput && thumbState && (
693
+ <div className="mt-1.5">
694
+ <input
695
+ type="text"
696
+ value={detailText}
697
+ onChange={(e) => setDetailText(e.target.value)}
698
+ onKeyDown={(e) => {
699
+ if (e.key === "Enter") handleSubmitDetail()
700
+ if (e.key === "Escape") setShowInput(false)
701
+ }}
702
+ placeholder={
703
+ thumbState === "up"
704
+ ? "What\u2019s accurate? (optional)"
705
+ : "What\u2019s wrong? (optional)"
706
+ }
707
+ className="w-full h-6 rounded border border-border bg-background px-2 text-[11px] text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-ring"
708
+ data-testid={`${testIdPrefix}-detail-input-${feedbackKey}`}
709
+ />
710
+ <div className="mt-1 flex items-center gap-1.5">
711
+ <button
712
+ type="button"
713
+ onClick={handleSubmitDetail}
714
+ className="bg-foreground text-background rounded px-2 py-0.5 text-[10px] font-semibold"
715
+ data-testid={`${testIdPrefix}-submit-${feedbackKey}`}
716
+ >
717
+ Submit
718
+ </button>
719
+ <button
720
+ type="button"
721
+ onClick={() => setShowInput(false)}
722
+ className="border border-border rounded px-2 py-0.5 text-[10px] font-medium"
723
+ >
724
+ Cancel
725
+ </button>
726
+ </div>
727
+ </div>
728
+ )}
729
+ </div>
730
+ )
731
+ }
@@ -15,7 +15,7 @@ import {
15
15
  } from "lucide-react"
16
16
  import type { LucideIcon } from "lucide-react"
17
17
  import { FeedbackFooter } from "./feedback-primitives"
18
- import type { FeedbackChipTree, FeedbackSubmitData } from "./feedback-primitives"
18
+ import type { FeedbackChipTree, FeedbackSubmitData, PersistedFeedbackData } from "./feedback-primitives"
19
19
  import { cn } from "../lib/utils"
20
20
  import type {
21
21
  QueueItem,
@@ -266,6 +266,7 @@ function StructuredSignalRow({ item, bucketKey, signal, tone, onOpenSignalBucket
266
266
  const IconComponent = resolveIcon(signal.signalTypeName)
267
267
  const toneClass = tone ? (SIGNAL_TONE_CLASSES[tone] ?? DEFAULT_TONE_CLASS) : DEFAULT_TONE_CLASS
268
268
  const isCombined = signal.signalTypeName === "combined_signal" && signal.components && signal.components.length > 0
269
+ const hasBalance = Boolean(signal.currentBalance || signal.balanceContext)
269
270
 
270
271
  const rowContent = (
271
272
  <>
@@ -304,6 +305,26 @@ function StructuredSignalRow({ item, bucketKey, signal, tone, onOpenSignalBucket
304
305
 
305
306
  {/* Slot 5: Chevron */}
306
307
  <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/60 transition-transform group-hover:translate-x-0.5 group-hover:text-foreground/50" />
308
+
309
+ {/* Balance context strip — spans full row below grid columns */}
310
+ {hasBalance && (
311
+ <div
312
+ className="col-span-full mt-0.5 text-[10px] text-muted-foreground/70"
313
+ data-testid="balance-context-strip"
314
+ >
315
+ {signal.currentBalance && (
316
+ <span>
317
+ Current balance <span className="font-medium text-muted-foreground">{signal.currentBalance}</span>
318
+ </span>
319
+ )}
320
+ {signal.balanceContext && (
321
+ <span>
322
+ {signal.currentBalance ? " · " : ""}
323
+ {signal.balanceContext}
324
+ </span>
325
+ )}
326
+ </div>
327
+ )}
307
328
  </>
308
329
  )
309
330
 
@@ -405,9 +426,11 @@ interface WhyCardProps {
405
426
  panelId: string
406
427
  onOpenSignalBucket?: ScoreWhyChipsProps["onOpenSignalBucket"]
407
428
  onBucketFeedback?: (bucketKey: string, data: FeedbackSubmitData) => void
429
+ /** Persisted bucket-level feedback to hydrate from. */
430
+ initialBucketFeedback?: PersistedFeedbackData | null
408
431
  }
409
432
 
410
- function WhyCard({ bucket, signals, item, panelId, onOpenSignalBucket, onBucketFeedback }: WhyCardProps) {
433
+ function WhyCard({ bucket, signals, item, panelId, onOpenSignalBucket, onBucketFeedback, initialBucketFeedback }: WhyCardProps) {
411
434
  const [showAll, setShowAll] = React.useState(false)
412
435
  const [bucketFeedback, setBucketFeedback] = React.useState<"positive" | "negative" | null>(null)
413
436
  const totalCount = bucket.signalCount ?? signals.length
@@ -488,6 +511,8 @@ function WhyCard({ bucket, signals, item, panelId, onOpenSignalBucket, onBucketF
488
511
  negativeChips={BUCKET_NEGATIVE_CHIPS}
489
512
  negativePrompt="Was this bucket useful?"
490
513
  positivePrompt="Thanks! What was useful about this bucket?"
514
+ initialFeedback={initialBucketFeedback}
515
+ feedbackKey={bucket.key}
491
516
  />
492
517
  </div>
493
518
  )}
@@ -561,6 +586,7 @@ export function ScoreWhyChips({
561
586
  panelId={selectedPanelId}
562
587
  onOpenSignalBucket={onOpenSignalBucket}
563
588
  onBucketFeedback={signalData.onBucketFeedback}
589
+ initialBucketFeedback={signalData.initialBucketFeedback?.[selectedBucket.key]}
564
590
  />
565
591
  )}
566
592
  </div>
@@ -19,8 +19,8 @@ import {
19
19
  Info,
20
20
  } from "lucide-react"
21
21
  import { cn } from "../lib/utils"
22
- import { FeedbackFooter } from "./feedback-primitives"
23
- import type { FeedbackChipTree, FeedbackSubmitData } from "./feedback-primitives"
22
+ import { FeedbackFooter, InlineFeedbackControl } from "./feedback-primitives"
23
+ import type { FeedbackChipTree, FeedbackSubmitData, PersistedFeedbackData } from "./feedback-primitives"
24
24
  import type { SignalScoreUrgencyLabel } from "../prototype/prototype-config"
25
25
  import { getSignalScoreUrgencyLabel, scoreRangeForUrgency, SIGNAL_TONE_CLASSES } from "./score-why-chips"
26
26
 
@@ -58,6 +58,12 @@ export interface SignalPriorityPopoverProps {
58
58
  feedbackChips?: FeedbackChipTree[]
59
59
  onFeedbackSubmit?: (data: FeedbackSubmitData) => void
60
60
  className?: string
61
+ /** Persisted factor-level feedback (keyed by factor key). */
62
+ initialFactorFeedback?: Record<string, { type: "up" | "down"; detail: string; ownershipLabel?: string }>
63
+ /** Callback when user submits factor-level feedback. */
64
+ onFactorFeedback?: (factorKey: string, type: "up" | "down" | null, detail?: string) => void
65
+ /** Persisted priority-level feedback for the footer. */
66
+ initialPriorityFeedback?: PersistedFeedbackData | null
61
67
  }
62
68
 
63
69
  // ---------------------------------------------------------------------------
@@ -152,7 +158,13 @@ function DirectionIcon({ direction }: { direction: PriorityFactor["direction"] }
152
158
  // PriorityFactorRow
153
159
  // ---------------------------------------------------------------------------
154
160
 
155
- function PriorityFactorRow({ factor }: { factor: PriorityFactor }) {
161
+ interface PriorityFactorRowProps {
162
+ factor: PriorityFactor
163
+ initialFeedback?: { type: "up" | "down"; detail: string; ownershipLabel?: string }
164
+ onFactorFeedback?: (factorKey: string, type: "up" | "down" | null, detail?: string) => void
165
+ }
166
+
167
+ function PriorityFactorRow({ factor, initialFeedback, onFactorFeedback }: PriorityFactorRowProps) {
156
168
  const IconComponent = FACTOR_ICONS[factor.icon] ?? Activity
157
169
  const toneClasses = TONE_ICON_CLASSES[factor.tone]
158
170
  const directionClasses = DIRECTION_CLASSES[factor.direction]
@@ -224,6 +236,21 @@ function PriorityFactorRow({ factor }: { factor: PriorityFactor }) {
224
236
 
225
237
  {/* empty grid cell under score column */}
226
238
  <div />
239
+
240
+ {/* Factor-level feedback row (spans icon + content columns) */}
241
+ {onFactorFeedback && (
242
+ <>
243
+ <div />
244
+ <div className="col-span-2 mt-1">
245
+ <InlineFeedbackControl
246
+ feedbackKey={factor.key}
247
+ initialFeedback={initialFeedback}
248
+ onFeedback={onFactorFeedback}
249
+ testIdPrefix="factor"
250
+ />
251
+ </div>
252
+ </>
253
+ )}
227
254
  </div>
228
255
  )
229
256
  }
@@ -241,6 +268,9 @@ export function SignalPriorityPopover({
241
268
  feedbackChips,
242
269
  onFeedbackSubmit,
243
270
  className,
271
+ initialFactorFeedback,
272
+ onFactorFeedback,
273
+ initialPriorityFeedback,
244
274
  }: SignalPriorityPopoverProps) {
245
275
  const urgencyLabel = getSignalScoreUrgencyLabel(score, providedLabel)
246
276
  const scoreRange = scoreRangeForUrgency(urgencyLabel)
@@ -252,6 +282,9 @@ export function SignalPriorityPopover({
252
282
  const triggerHover = URGENCY_TRIGGER_HOVER[urgencyLabel]
253
283
  const triggerOpen = URGENCY_TRIGGER_OPEN[urgencyLabel]
254
284
 
285
+ // Derive a stable feedbackKey for the footer from score + urgencyLabel
286
+ const footerFeedbackKey = `priority-${score}-${urgencyLabel}`
287
+
255
288
  return (
256
289
  <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
257
290
  <PopoverPrimitive.Trigger asChild>
@@ -332,7 +365,12 @@ export function SignalPriorityPopover({
332
365
  {/* Factor rows */}
333
366
  <div className="divide-y divide-border/40">
334
367
  {factors.map((factor) => (
335
- <PriorityFactorRow key={factor.key} factor={factor} />
368
+ <PriorityFactorRow
369
+ key={factor.key}
370
+ factor={factor}
371
+ initialFeedback={initialFactorFeedback?.[factor.key]}
372
+ onFactorFeedback={onFactorFeedback}
373
+ />
336
374
  ))}
337
375
  </div>
338
376
  </>
@@ -349,6 +387,8 @@ export function SignalPriorityPopover({
349
387
  negativeChips={feedbackChips ?? DEFAULT_PRIORITY_FEEDBACK_CHIPS}
350
388
  positivePrompt="Thanks. Anything to keep about this score?"
351
389
  className="px-4 py-3"
390
+ initialFeedback={initialPriorityFeedback}
391
+ feedbackKey={footerFeedbackKey}
352
392
  />
353
393
  </div>
354
394
  )}
package/src/index.ts CHANGED
@@ -38,8 +38,8 @@ export * from "./components/dialog"
38
38
  export * from "./components/dropdown-menu"
39
39
  export * from "./components/empty-state"
40
40
  export * from "./components/entity-panel"
41
- export { FeedbackFooter, FeedbackChipGroup, FeedbackInput, FeedbackActions } from "./components/feedback-primitives"
42
- export type { FeedbackFooterProps, FeedbackChipTree, FeedbackChipGroupProps, FeedbackInputProps, FeedbackActionsProps, FeedbackSubmitData } from "./components/feedback-primitives"
41
+ export { FeedbackFooter, FeedbackChipGroup, FeedbackInput, FeedbackActions, InlineFeedbackControl } from "./components/feedback-primitives"
42
+ export type { FeedbackFooterProps, FeedbackChipTree, FeedbackChipGroupProps, FeedbackInputProps, FeedbackActionsProps, FeedbackSubmitData, PersistedFeedbackData, InlineFeedbackControlProps } from "./components/feedback-primitives"
43
43
  export { SignalPriorityPopover } from "./components/signal-priority-popover"
44
44
  export type { SignalPriorityPopoverProps, PriorityFactor } from "./components/signal-priority-popover"
45
45
  export * from "./components/filter-chip"
@@ -16,7 +16,7 @@ import type { TimelineEvent } from "../components/timeline-activity"
16
16
  import type { ApprovalState } from "../components/signal-feedback-inline"
17
17
  import type { LucideIcon } from "lucide-react"
18
18
  import type { PriorityFactor } from "../components/signal-priority-popover"
19
- import type { FeedbackChipTree, FeedbackSubmitData } from "../components/feedback-primitives"
19
+ import type { FeedbackChipTree, FeedbackSubmitData, PersistedFeedbackData } from "../components/feedback-primitives"
20
20
 
21
21
  // ---------------------------------------------------------------------------
22
22
  // Shared
@@ -57,6 +57,10 @@ export interface SignalScoreExplanationSignal {
57
57
  counterparty?: string
58
58
  /** Component breakdown for combined signals. */
59
59
  components?: Array<{ type: string; count: number }>
60
+ /** Current balance value (e.g., "$3.0M") for balance context strip. */
61
+ currentBalance?: string
62
+ /** Additional balance context text (e.g., "down from $23M"). */
63
+ balanceContext?: string
60
64
  }
61
65
 
62
66
  export interface SignalScoreExplanationBucket {
@@ -97,6 +101,12 @@ export interface SignalScoreData {
97
101
  /** @deprecated The compact score UX no longer renders score-level thumbs by default. */
98
102
  initialScoreFeedback?: { type: "up" | "down"; pills: string[]; detail: string } | null
99
103
  initialFactorFeedback?: Record<string, { type: "up" | "down"; detail: string }>
104
+ /** Factor-level feedback for the priority popover rows (keyed by factor key). */
105
+ initialFactorPopoverFeedback?: Record<string, { type: "up" | "down"; detail: string; ownershipLabel?: string }>
106
+ /** Persisted bucket-level feedback, keyed by bucket key. */
107
+ initialBucketFeedback?: Record<string, PersistedFeedbackData>
108
+ /** Persisted priority-level feedback for the popover footer. */
109
+ initialPriorityFeedback?: PersistedFeedbackData | null
100
110
  /** Priority factors for the popover breakdown. */
101
111
  priorityFactors?: PriorityFactor[]
102
112
  /** Negative feedback chip tree for the priority popover. */
@@ -272,6 +272,9 @@ export function DetailView({
272
272
  metaText={undefined}
273
273
  feedbackChips={signalData.priorityFeedbackChips}
274
274
  onFeedbackSubmit={signalData.onPriorityFeedback}
275
+ initialFactorFeedback={signalData.initialFactorPopoverFeedback}
276
+ onFactorFeedback={signalData.onFactorFeedback}
277
+ initialPriorityFeedback={signalData.initialPriorityFeedback}
275
278
  />
276
279
  {signalData.timeChipLabel && (
277
280
  <Badge variant="outline" title={signalData.timeChipDetail ?? undefined}>