@handled-ai/design-system 0.18.10 → 0.18.12

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 (43) hide show
  1. package/dist/components/account-contacts-popover.d.ts +5 -1
  2. package/dist/components/account-contacts-popover.js +25 -4
  3. package/dist/components/account-contacts-popover.js.map +1 -1
  4. package/dist/components/badge.d.ts +1 -1
  5. package/dist/components/button.d.ts +1 -1
  6. package/dist/components/data-table-condition-filter.d.ts +15 -3
  7. package/dist/components/data-table-condition-filter.js +199 -52
  8. package/dist/components/data-table-condition-filter.js.map +1 -1
  9. package/dist/components/data-table-filter.js +7 -8
  10. package/dist/components/data-table-filter.js.map +1 -1
  11. package/dist/components/entity-panel.d.ts +2 -1
  12. package/dist/components/entity-panel.js +52 -45
  13. package/dist/components/entity-panel.js.map +1 -1
  14. package/dist/components/pill.d.ts +1 -1
  15. package/dist/components/score-why-chips.d.ts +1 -1
  16. package/dist/components/signal-priority-popover.d.ts +1 -1
  17. package/dist/components/signal-priority-popover.js +4 -4
  18. package/dist/components/signal-priority-popover.js.map +1 -1
  19. package/dist/components/tabs.d.ts +1 -1
  20. package/dist/index.d.ts +2 -2
  21. package/dist/prototype/index.d.ts +1 -1
  22. package/dist/prototype/prototype-accounts-view.d.ts +1 -1
  23. package/dist/prototype/prototype-admin-view.d.ts +1 -1
  24. package/dist/prototype/prototype-config.d.ts +1 -1
  25. package/dist/prototype/prototype-inbox-view.d.ts +5 -3
  26. package/dist/prototype/prototype-inbox-view.js +11 -5
  27. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  28. package/dist/prototype/prototype-insights-view.d.ts +1 -1
  29. package/dist/prototype/prototype-shell.d.ts +1 -1
  30. package/dist/{signal-priority-popover-BT6CPYNs.d.ts → signal-priority-popover-BEDoPsNE.d.ts} +6 -0
  31. package/package.json +1 -2
  32. package/src/components/__tests__/account-contacts-popover.test.tsx +79 -0
  33. package/src/components/__tests__/data-table-condition-filter.test.tsx +239 -7
  34. package/src/components/__tests__/entity-panel-header.test.tsx +44 -0
  35. package/src/components/__tests__/signal-priority-popover.test.tsx +30 -0
  36. package/src/components/account-contacts-popover.tsx +29 -1
  37. package/src/components/data-table-condition-filter.tsx +278 -68
  38. package/src/components/data-table-filter.tsx +6 -9
  39. package/src/components/entity-panel.tsx +56 -40
  40. package/src/components/signal-priority-popover.tsx +15 -4
  41. package/src/prototype/__tests__/detail-view-title-slots.test.tsx +15 -0
  42. package/src/prototype/prototype-config.ts +2 -0
  43. package/src/prototype/prototype-inbox-view.tsx +17 -5
@@ -2,14 +2,11 @@
2
2
 
3
3
  import * as React from "react"
4
4
  import {
5
- CalendarDays,
6
- DollarSign,
5
+ Check,
7
6
  Eye,
8
- Hash,
9
7
  MoreHorizontal,
10
8
  Plus,
11
9
  Trash2,
12
- Type,
13
10
  } from "lucide-react"
14
11
 
15
12
  import { cn } from "../lib/utils"
@@ -36,15 +33,26 @@ export type ConditionOperator =
36
33
  | "is_null"
37
34
  | "is_not_null"
38
35
 
36
+ export interface ConditionOptionObject {
37
+ label: string
38
+ value: string
39
+ }
40
+
41
+ export type ConditionFieldOption = string | ConditionOptionObject
42
+
39
43
  export interface ConditionFieldDef {
40
44
  /** Unique field key (e.g., "Account_Balance__c") */
41
45
  id: string
42
46
  /** Display label (e.g., "Account Balance") */
43
47
  label: string
44
48
  /** Field data type — determines which operators are available and how the value input renders */
45
- type: "text" | "number" | "currency" | "date"
49
+ type: "text" | "number" | "currency" | "date" | "select" | "multi_select"
46
50
  /** Allowed operators for this field. Defaults based on type if not provided. */
47
51
  operators?: ConditionOperator[]
52
+ /** Options used by select and multi-select fields. Strings use the same label and value. */
53
+ options?: ConditionFieldOption[]
54
+ /** Show a search box for option-backed value inputs. */
55
+ searchable?: boolean | { threshold?: number }
48
56
  }
49
57
 
50
58
  export interface ConditionFilterValue {
@@ -52,7 +60,7 @@ export interface ConditionFilterValue {
52
60
  id: string
53
61
  field: string
54
62
  operator: ConditionOperator
55
- value: string | number | null
63
+ value: string | number | string[] | null
56
64
  }
57
65
 
58
66
  interface DataTableConditionFilterProps {
@@ -74,7 +82,7 @@ const OPERATOR_LABELS: Record<ConditionOperator, string> = {
74
82
  gte: "≥",
75
83
  lt: "<",
76
84
  lte: "≤",
77
- in: "contains",
85
+ in: "is any of",
78
86
  is_null: "is empty",
79
87
  is_not_null: "is not empty",
80
88
  }
@@ -90,11 +98,29 @@ const NUMERIC_OPERATORS: ConditionOperator[] = [
90
98
  "is_not_null",
91
99
  ]
92
100
 
101
+ const NAV_KEYS = new Set(["ArrowDown", "ArrowUp", "Enter", "Escape", "Tab"])
102
+
103
+ export function shouldShowOptionSearch(
104
+ searchable: ConditionFieldDef["searchable"],
105
+ optionCount: number,
106
+ defaultThreshold = 8,
107
+ ): boolean {
108
+ if (searchable === true) return true
109
+ if (searchable === false) return false
110
+ const threshold =
111
+ typeof searchable === "object"
112
+ ? (searchable.threshold ?? defaultThreshold)
113
+ : defaultThreshold
114
+ return optionCount > threshold
115
+ }
116
+
93
117
  const DEFAULT_OPERATORS: Record<ConditionFieldDef["type"], ConditionOperator[]> = {
94
118
  text: ["eq", "neq", "is_null", "is_not_null"],
95
119
  number: NUMERIC_OPERATORS,
96
120
  currency: NUMERIC_OPERATORS,
97
121
  date: ["gt", "gte", "lt", "lte", "eq", "neq", "is_null", "is_not_null"],
122
+ select: ["eq", "neq", "is_null", "is_not_null"],
123
+ multi_select: ["in", "is_null", "is_not_null"],
98
124
  }
99
125
 
100
126
  /** Generate a stable unique ID for a new condition row. */
@@ -108,7 +134,7 @@ function generateConditionId(): string {
108
134
  // ── Helpers ────────────────────────────────────────────────────
109
135
 
110
136
  function getOperators(field: ConditionFieldDef): ConditionOperator[] {
111
- return (field.operators ?? DEFAULT_OPERATORS[field.type]).filter((op) => op !== "in")
137
+ return field.operators ?? DEFAULT_OPERATORS[field.type]
112
138
  }
113
139
 
114
140
  function isUnaryOperator(op: ConditionOperator): boolean {
@@ -128,6 +154,18 @@ function createDraftCondition(field: ConditionFieldDef): ConditionFilterValue {
128
154
  }
129
155
  }
130
156
 
157
+ function normalizeConditionValue(
158
+ value: ConditionFilterValue["value"],
159
+ field: ConditionFieldDef,
160
+ operator: ConditionOperator,
161
+ ): ConditionFilterValue["value"] {
162
+ if (isUnaryOperator(operator)) return null
163
+ if (field.type === "multi_select") {
164
+ return Array.isArray(value) ? value : null
165
+ }
166
+ return Array.isArray(value) ? null : value
167
+ }
168
+
131
169
  function normalizeCondition(
132
170
  condition: ConditionFilterValue,
133
171
  fields: ConditionFieldDef[],
@@ -144,7 +182,7 @@ function normalizeCondition(
144
182
  ...condition,
145
183
  field: field.id,
146
184
  operator,
147
- value: isUnaryOperator(operator) ? null : condition.value,
185
+ value: normalizeConditionValue(condition.value, field, operator),
148
186
  }
149
187
  }
150
188
 
@@ -168,18 +206,40 @@ function isCompleteCondition(
168
206
  if (!field) return false
169
207
  if (!getOperators(field).includes(condition.operator)) return false
170
208
  if (isUnaryOperator(condition.operator)) return true
171
- return condition.value !== null && condition.value !== ""
209
+ if (field.type === "multi_select") {
210
+ return Array.isArray(condition.value) && condition.value.length > 0
211
+ }
212
+ return condition.value !== null && condition.value !== "" && !Array.isArray(condition.value)
213
+ }
214
+
215
+ function getConditionValueSignature(value: ConditionFilterValue["value"]): string {
216
+ return Array.isArray(value) ? JSON.stringify(value) : String(value)
172
217
  }
173
218
 
174
219
  function getConditionsSignature(conditions: ConditionFilterValue[]): string {
175
220
  return conditions
176
- .map((condition) => `${condition.id}:${condition.field}:${condition.operator}:${String(condition.value)}`)
221
+ .map((condition) => `${condition.id}:${condition.field}:${condition.operator}:${getConditionValueSignature(condition.value)}`)
177
222
  .join(";")
178
223
  }
179
224
 
225
+ function normalizeFieldOptions(field: ConditionFieldDef): ConditionOptionObject[] {
226
+ return (field.options ?? []).map((option) =>
227
+ typeof option === "string" ? { label: option, value: option } : option,
228
+ )
229
+ }
230
+
180
231
  function getFieldsSignature(fields: ConditionFieldDef[]): string {
181
232
  return fields
182
- .map((field) => `${field.id}:${field.type}:${field.label}:${(field.operators ?? []).join("|")}`)
233
+ .map((field) => {
234
+ const optionsSignature = normalizeFieldOptions(field)
235
+ .map((option) => `${option.label}:${option.value}`)
236
+ .join("|")
237
+ const searchSignature =
238
+ typeof field.searchable === "object"
239
+ ? `threshold:${field.searchable.threshold ?? ""}`
240
+ : String(field.searchable ?? "")
241
+ return `${field.id}:${field.type}:${field.label}:${(field.operators ?? []).join("|")}:${optionsSignature}:${searchSignature}`
242
+ })
183
243
  .join(";")
184
244
  }
185
245
 
@@ -192,14 +252,180 @@ function getCommittedConditions(
192
252
  .filter((condition) => isCompleteCondition(condition, fields))
193
253
  }
194
254
 
195
- function getFieldIcon(type: ConditionFieldDef["type"]) {
196
- if (type === "currency") return DollarSign
197
- if (type === "number") return Hash
198
- if (type === "date") return CalendarDays
199
- return Type
255
+ // ── Condition Row ──────────────────────────────────────────────
256
+
257
+ function getInputType(fieldType: ConditionFieldDef["type"]): "text" | "number" | "date" {
258
+ if (fieldType === "number" || fieldType === "currency") return "number"
259
+ if (fieldType === "date") return "date"
260
+ return "text"
261
+ }
262
+
263
+ function getInputPlaceholder(fieldType: ConditionFieldDef["type"]): string {
264
+ if (fieldType === "currency") return "Amount"
265
+ if (fieldType === "number") return "Enter number..."
266
+ if (fieldType === "date") return ""
267
+ return "Enter value..."
200
268
  }
201
269
 
202
- // ── Condition Row ──────────────────────────────────────────────
270
+ interface ConditionValueInputProps {
271
+ condition: ConditionFilterValue
272
+ fieldDef: ConditionFieldDef
273
+ onValueChange: (event: React.ChangeEvent<HTMLInputElement>) => void
274
+ onSelectValueChange: (value: string) => void
275
+ onMultiSelectValueToggle: (value: string) => void
276
+ onCommit: () => void
277
+ }
278
+
279
+ function SelectConditionValueInput({
280
+ condition,
281
+ fieldDef,
282
+ onSelectValueChange,
283
+ }: Pick<ConditionValueInputProps, "condition" | "fieldDef" | "onSelectValueChange">) {
284
+ const [query, setQuery] = React.useState("")
285
+ const options = normalizeFieldOptions(fieldDef)
286
+ const showSearch = shouldShowOptionSearch(fieldDef.searchable, options.length)
287
+ const normalizedQuery = showSearch ? query.trim().toLowerCase() : ""
288
+ const filteredOptions = normalizedQuery
289
+ ? options.filter((option) => option.label.toLowerCase().includes(normalizedQuery))
290
+ : options
291
+
292
+ React.useEffect(() => {
293
+ setQuery("")
294
+ }, [fieldDef.id])
295
+
296
+ return (
297
+ <Select
298
+ value={typeof condition.value === "string" ? condition.value : ""}
299
+ onValueChange={onSelectValueChange}
300
+ >
301
+ <SelectTrigger className="h-8 w-full" size="sm">
302
+ <SelectValue placeholder="Select value..." />
303
+ </SelectTrigger>
304
+ <SelectContent>
305
+ {showSearch ? (
306
+ <div className="sticky top-0 z-10 border-b border-border bg-popover p-1.5">
307
+ <Input
308
+ value={query}
309
+ onChange={(event) => setQuery(event.target.value)}
310
+ onClick={(event) => event.stopPropagation()}
311
+ onKeyDown={(event) => {
312
+ if (!NAV_KEYS.has(event.key)) {
313
+ event.stopPropagation()
314
+ }
315
+ }}
316
+ placeholder="Search options..."
317
+ className="h-7 text-xs"
318
+ />
319
+ </div>
320
+ ) : null}
321
+ {filteredOptions.length > 0 ? (
322
+ filteredOptions.map((option) => (
323
+ <SelectItem key={option.value} value={option.value}>
324
+ {option.label}
325
+ </SelectItem>
326
+ ))
327
+ ) : (
328
+ <div className="px-2 py-1.5 text-xs text-muted-foreground">No options</div>
329
+ )}
330
+ </SelectContent>
331
+ </Select>
332
+ )
333
+ }
334
+
335
+ function MultiSelectConditionValueInput({
336
+ condition,
337
+ fieldDef,
338
+ onMultiSelectValueToggle,
339
+ }: Pick<ConditionValueInputProps, "condition" | "fieldDef" | "onMultiSelectValueToggle">) {
340
+ const selectedValues = Array.isArray(condition.value) ? condition.value : []
341
+ const options = normalizeFieldOptions(fieldDef)
342
+
343
+ return (
344
+ <div className="max-h-28 overflow-y-auto rounded-md border border-border bg-background p-1">
345
+ {options.length > 0 ? (
346
+ options.map((option) => {
347
+ const checked = selectedValues.includes(option.value)
348
+ return (
349
+ <button
350
+ key={option.value}
351
+ type="button"
352
+ role="checkbox"
353
+ aria-checked={checked}
354
+ className={cn(
355
+ "flex w-full items-center gap-2 rounded-sm px-2 py-1 text-left text-xs hover:bg-muted",
356
+ checked && "text-brand-purple",
357
+ )}
358
+ onClick={(event) => {
359
+ event.stopPropagation()
360
+ onMultiSelectValueToggle(option.value)
361
+ }}
362
+ >
363
+ <span
364
+ className={cn(
365
+ "flex h-3.5 w-3.5 items-center justify-center rounded-sm border border-border",
366
+ checked && "border-brand-purple bg-brand-purple text-white",
367
+ )}
368
+ aria-hidden="true"
369
+ >
370
+ {checked ? <Check className="h-3 w-3" /> : null}
371
+ </span>
372
+ <span className="min-w-0 flex-1 truncate">{option.label}</span>
373
+ </button>
374
+ )
375
+ })
376
+ ) : (
377
+ <div className="px-2 py-1 text-xs text-muted-foreground">No options</div>
378
+ )}
379
+ </div>
380
+ )
381
+ }
382
+
383
+ function ScalarConditionValueInput({
384
+ condition,
385
+ fieldDef,
386
+ onValueChange,
387
+ onCommit,
388
+ }: Pick<ConditionValueInputProps, "condition" | "fieldDef" | "onValueChange" | "onCommit">) {
389
+ return (
390
+ <div className="relative flex items-center">
391
+ {fieldDef.type === "currency" ? (
392
+ <span className="pointer-events-none absolute left-2 text-sm text-muted-foreground">
393
+ $
394
+ </span>
395
+ ) : null}
396
+ <Input
397
+ type={getInputType(fieldDef.type)}
398
+ value={
399
+ condition.value != null && !Array.isArray(condition.value)
400
+ ? String(condition.value)
401
+ : ""
402
+ }
403
+ onChange={onValueChange}
404
+ onClick={(event) => event.stopPropagation()}
405
+ onKeyDown={(event) => {
406
+ event.stopPropagation()
407
+ if (event.key === "Enter") {
408
+ onCommit()
409
+ }
410
+ }}
411
+ placeholder={getInputPlaceholder(fieldDef.type)}
412
+ className={cn("h-8", fieldDef.type === "currency" && "pl-6")}
413
+ />
414
+ </div>
415
+ )
416
+ }
417
+
418
+ function ConditionValueInput(props: ConditionValueInputProps) {
419
+ if (props.fieldDef.type === "select") {
420
+ return <SelectConditionValueInput {...props} />
421
+ }
422
+
423
+ if (props.fieldDef.type === "multi_select") {
424
+ return <MultiSelectConditionValueInput {...props} />
425
+ }
426
+
427
+ return <ScalarConditionValueInput {...props} />
428
+ }
203
429
 
204
430
  interface ConditionRowProps {
205
431
  condition: ConditionFilterValue
@@ -221,8 +447,6 @@ function ConditionRow({
221
447
  const fieldDef = fields.find((f) => f.id === condition.field) ?? fields[0]
222
448
  const operators = getOperators(fieldDef)
223
449
  const isUnary = isUnaryOperator(condition.operator)
224
- const FieldIcon = getFieldIcon(fieldDef.type)
225
-
226
450
  const handleFieldChange = (newFieldId: string) => {
227
451
  const newFieldDef = fields.find((f) => f.id === newFieldId) ?? fields[0]
228
452
  if (!newFieldDef) return
@@ -238,7 +462,7 @@ function ConditionRow({
238
462
  onChange({
239
463
  ...condition,
240
464
  operator: newOp,
241
- value: isUnaryOperator(newOp) ? null : condition.value,
465
+ value: normalizeConditionValue(condition.value, fieldDef, newOp),
242
466
  })
243
467
  }
244
468
 
@@ -249,6 +473,26 @@ function ConditionRow({
249
473
  })
250
474
  }
251
475
 
476
+ const handleSelectValueChange = (value: string) => {
477
+ onChange({
478
+ ...condition,
479
+ value,
480
+ })
481
+ }
482
+
483
+ const handleMultiSelectValueToggle = (value: string) => {
484
+ const currentValues = Array.isArray(condition.value) ? condition.value : []
485
+ const nextValues = currentValues.includes(value)
486
+ ? currentValues.filter((currentValue) => currentValue !== value)
487
+ : [...currentValues, value]
488
+
489
+ onChange({
490
+ ...condition,
491
+ value: nextValues,
492
+ })
493
+ }
494
+
495
+
252
496
  return (
253
497
  <div
254
498
  className="grid grid-cols-[52px_minmax(150px,1fr)_120px_minmax(140px,1fr)_auto] items-center gap-2 rounded-lg border border-border/70 bg-background p-2 shadow-sm"
@@ -260,21 +504,14 @@ function ConditionRow({
260
504
 
261
505
  <Select value={condition.field} onValueChange={handleFieldChange}>
262
506
  <SelectTrigger className="h-8 w-full justify-start gap-2" size="sm">
263
- <FieldIcon className="h-3.5 w-3.5 text-muted-foreground" />
264
507
  <SelectValue placeholder={fieldDef.label} />
265
508
  </SelectTrigger>
266
509
  <SelectContent>
267
- {fields.map((field) => {
268
- const Icon = getFieldIcon(field.type)
269
- return (
270
- <SelectItem key={field.id} value={field.id}>
271
- <span className="inline-flex items-center gap-2">
272
- <Icon className="h-3.5 w-3.5 text-muted-foreground" />
273
- {field.label}
274
- </span>
275
- </SelectItem>
276
- )
277
- })}
510
+ {fields.map((field) => (
511
+ <SelectItem key={field.id} value={field.id}>
512
+ {field.label}
513
+ </SelectItem>
514
+ ))}
278
515
  </SelectContent>
279
516
  </Select>
280
517
 
@@ -299,41 +536,14 @@ function ConditionRow({
299
536
  No value needed
300
537
  </div>
301
538
  ) : (
302
- <div className="relative flex items-center">
303
- {fieldDef.type === "currency" ? (
304
- <span className="pointer-events-none absolute left-2 text-sm text-muted-foreground">
305
- $
306
- </span>
307
- ) : null}
308
- <Input
309
- type={
310
- fieldDef.type === "number" || fieldDef.type === "currency"
311
- ? "number"
312
- : fieldDef.type === "date"
313
- ? "date"
314
- : "text"
315
- }
316
- value={condition.value != null ? String(condition.value) : ""}
317
- onChange={handleValueChange}
318
- onClick={(event) => event.stopPropagation()}
319
- onKeyDown={(event) => {
320
- event.stopPropagation()
321
- if (event.key === "Enter") {
322
- onCommit()
323
- }
324
- }}
325
- placeholder={
326
- fieldDef.type === "currency"
327
- ? "Amount"
328
- : fieldDef.type === "number"
329
- ? "Enter number..."
330
- : fieldDef.type === "date"
331
- ? ""
332
- : "Enter value..."
333
- }
334
- className={cn("h-8", fieldDef.type === "currency" && "pl-6")}
335
- />
336
- </div>
539
+ <ConditionValueInput
540
+ condition={condition}
541
+ fieldDef={fieldDef}
542
+ onValueChange={handleValueChange}
543
+ onSelectValueChange={handleSelectValueChange}
544
+ onMultiSelectValueToggle={handleMultiSelectValueToggle}
545
+ onCommit={onCommit}
546
+ />
337
547
  )}
338
548
 
339
549
  <div className="flex items-center gap-1">
@@ -9,6 +9,7 @@ import { cn } from "../lib/utils"
9
9
  import { Button } from "./button"
10
10
  import {
11
11
  DataTableConditionFilter,
12
+ shouldShowOptionSearch,
12
13
  type ConditionFieldDef,
13
14
  type ConditionFilterValue,
14
15
  } from "./data-table-condition-filter"
@@ -214,15 +215,11 @@ export function DataTableFilter({
214
215
  getOptionLabel(opt).toLowerCase().includes(subQuery)
215
216
  )
216
217
  : category.options
217
- const shouldShowSubmenuSearch = (() => {
218
- if (category.searchable === true) return true
219
- if (category.searchable === false) return false
220
- const threshold =
221
- typeof category.searchable === "object"
222
- ? (category.searchable.threshold ?? optionSearchThreshold)
223
- : optionSearchThreshold
224
- return category.options.length > threshold
225
- })()
218
+ const shouldShowSubmenuSearch = shouldShowOptionSearch(
219
+ category.searchable,
220
+ category.options.length,
221
+ optionSearchThreshold,
222
+ )
226
223
 
227
224
  return (
228
225
  <DropdownMenuSub
@@ -135,14 +135,16 @@ export function EntityPanelHeader({
135
135
  icon,
136
136
  title,
137
137
  badgeLabel,
138
- subtitle: _subtitle,
138
+ subtitle,
139
139
  headerAction,
140
+ headerSecondaryAction,
140
141
  }: {
141
142
  icon?: React.ReactNode
142
143
  title: string
143
144
  badgeLabel?: string
144
145
  subtitle?: string
145
146
  headerAction?: React.ReactNode
147
+ headerSecondaryAction?: React.ReactNode
146
148
  }) {
147
149
  const { panelMode, cyclePanelMode, onClose } = useEntityPanel()
148
150
 
@@ -150,49 +152,63 @@ export function EntityPanelHeader({
150
152
  panelMode === 'default' ? 'Wide' : panelMode === 'wide' ? 'Fullscreen' : 'Exit fullscreen'
151
153
 
152
154
  return (
153
- <div className="flex items-center justify-between mb-3">
154
- <div className="flex items-center gap-2 min-w-0">
155
- {icon ?? <CalendarDays className="w-5 h-5 text-muted-foreground shrink-0" />}
156
- <h2 className="text-[16px] font-semibold text-foreground truncate">{title}</h2>
157
- {badgeLabel && (
158
- <Badge
159
- variant="outline"
160
- className="text-blue-600 border-blue-300 dark:border-blue-700 dark:text-blue-400 shadow-none px-2 py-0.5 text-[11px] font-medium shrink-0"
155
+ <div className="mb-3 space-y-2">
156
+ <div className="flex items-center justify-between">
157
+ <div className="flex items-center gap-2 min-w-0">
158
+ {icon ?? <CalendarDays className="w-5 h-5 text-muted-foreground shrink-0" />}
159
+ <h2 className="text-[16px] font-semibold text-foreground truncate">{title}</h2>
160
+ {badgeLabel && (
161
+ <Badge
162
+ variant="outline"
163
+ className="text-blue-600 border-blue-300 dark:border-blue-700 dark:text-blue-400 shadow-none px-2 py-0.5 text-[11px] font-medium shrink-0"
164
+ >
165
+ {badgeLabel}
166
+ </Badge>
167
+ )}
168
+ </div>
169
+ <div className="flex items-center gap-1 shrink-0 ml-4 text-muted-foreground">
170
+ {headerAction}
171
+ <button
172
+ type="button"
173
+ className="p-1.5 rounded-md hover:bg-secondary transition-colors"
174
+ title="Copy Link"
175
+ >
176
+ <LinkIcon className="w-4 h-4" />
177
+ </button>
178
+ <button
179
+ type="button"
180
+ onClick={cyclePanelMode}
181
+ className="p-1.5 rounded-md hover:bg-secondary transition-colors"
182
+ title={sizeButtonTitle}
161
183
  >
162
- {badgeLabel}
163
- </Badge>
164
- )}
184
+ {panelMode === 'fullscreen' ? (
185
+ <Minimize2 className="w-4 h-4" />
186
+ ) : (
187
+ <Maximize2 className="w-4 h-4" />
188
+ )}
189
+ </button>
190
+ <button
191
+ type="button"
192
+ onClick={onClose}
193
+ className="p-1.5 rounded-md hover:bg-secondary transition-colors"
194
+ title="Close"
195
+ >
196
+ <X className="w-4 h-4" />
197
+ </button>
198
+ </div>
165
199
  </div>
166
- <div className="flex items-center gap-1 shrink-0 ml-4 text-muted-foreground">
167
- {headerAction}
168
- <button
169
- type="button"
170
- className="p-1.5 rounded-md hover:bg-secondary transition-colors"
171
- title="Copy Link"
172
- >
173
- <LinkIcon className="w-4 h-4" />
174
- </button>
175
- <button
176
- type="button"
177
- onClick={cyclePanelMode}
178
- className="p-1.5 rounded-md hover:bg-secondary transition-colors"
179
- title={sizeButtonTitle}
180
- >
181
- {panelMode === 'fullscreen' ? (
182
- <Minimize2 className="w-4 h-4" />
200
+ {(subtitle || headerSecondaryAction) && (
201
+ <div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
202
+ {subtitle ? (
203
+ <p className="min-w-0 flex-1 text-xs text-muted-foreground">{subtitle}</p>
183
204
  ) : (
184
- <Maximize2 className="w-4 h-4" />
205
+ <div className="min-w-0 flex-1" />
185
206
  )}
186
- </button>
187
- <button
188
- type="button"
189
- onClick={onClose}
190
- className="p-1.5 rounded-md hover:bg-secondary transition-colors"
191
- title="Close"
192
- >
193
- <X className="w-4 h-4" />
194
- </button>
195
- </div>
207
+ {headerSecondaryAction ? (
208
+ <div className="flex shrink-0 items-center gap-2">{headerSecondaryAction}</div>
209
+ ) : null}
210
+ </div>
211
+ )}
196
212
  </div>
197
213
  )
198
214
  }
@@ -40,8 +40,12 @@ export interface PriorityFactor {
40
40
  tone: "alert" | "warn" | "info"
41
41
  /** Explicit semantic label - NOT inferred from score+weight. */
42
42
  direction: "raises" | "lowers" | "neutral"
43
+ /** Optional display label for the direction text. Keeps semantic direction icon/color unchanged. */
44
+ directionLabel?: string
43
45
  /** 0-100 */
44
46
  score: number
47
+ /** Optional display label rendered instead of the numeric score cell. */
48
+ displayValueLabel?: string
45
49
  /** Evidence text (e.g. "$3.4M moved in 8h - current treasury balance $0.00"). */
46
50
  rationale: string
47
51
  }
@@ -168,12 +172,13 @@ function PriorityFactorRow({ factor, initialFeedback, onFactorFeedback }: Priori
168
172
  const IconComponent = FACTOR_ICONS[factor.icon] ?? Activity
169
173
  const toneClasses = TONE_ICON_CLASSES[factor.tone]
170
174
  const directionClasses = DIRECTION_CLASSES[factor.direction]
171
- const directionLabel =
175
+ const directionLabel = factor.directionLabel ?? (
172
176
  factor.direction === "raises"
173
177
  ? "Raises"
174
178
  : factor.direction === "lowers"
175
179
  ? "Lowers"
176
180
  : "Neutral"
181
+ )
177
182
 
178
183
  return (
179
184
  <div
@@ -206,10 +211,16 @@ function PriorityFactorRow({ factor, initialFeedback, onFactorFeedback }: Priori
206
211
  </span>
207
212
  </div>
208
213
 
209
- {/* Score number */}
214
+ {/* Score number / display label */}
210
215
  <div className="flex items-center text-right">
211
- <span className="text-sm font-bold tabular-nums">{factor.score}</span>
212
- <span className="text-xs font-normal text-muted-foreground">/100</span>
216
+ {factor.displayValueLabel ? (
217
+ <span className="text-xs font-semibold text-foreground">{factor.displayValueLabel}</span>
218
+ ) : (
219
+ <>
220
+ <span className="text-sm font-bold tabular-nums">{factor.score}</span>
221
+ <span className="text-xs font-normal text-muted-foreground">/100</span>
222
+ </>
223
+ )}
213
224
  </div>
214
225
 
215
226
  {/* empty grid cell under icon column */}