@exxatdesignux/ui 0.2.9 → 0.2.11

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 (126) hide show
  1. package/consumer-extras/cursor-skills/exxat-card-vs-list-rows/SKILL.md +20 -0
  2. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +33 -0
  3. package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +45 -0
  4. package/consumer-extras/cursor-skills/exxat-drawer-vs-dialog/SKILL.md +20 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +31 -5
  6. package/consumer-extras/cursor-skills/exxat-kpi-max-four/SKILL.md +19 -0
  7. package/consumer-extras/cursor-skills/exxat-kpi-trends/SKILL.md +27 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +1 -0
  9. package/consumer-extras/patterns/collaboration-access-pattern.md +114 -0
  10. package/consumer-extras/patterns/data-views-pattern.md +12 -4
  11. package/package.json +4 -1
  12. package/src/components/ui/banner.tsx +20 -7
  13. package/src/components/ui/date-picker-field.tsx +3 -3
  14. package/src/components/ui/dropdown-menu.tsx +17 -6
  15. package/src/components/ui/input-group.tsx +1 -1
  16. package/src/components/ui/input.tsx +1 -1
  17. package/src/components/ui/select.tsx +1 -1
  18. package/src/components/ui/separator.tsx +2 -2
  19. package/src/components/ui/sidebar.tsx +31 -3
  20. package/src/components/ui/textarea.tsx +1 -1
  21. package/src/globals.css +0 -1
  22. package/src/index.ts +1 -0
  23. package/src/lib/date-filter.ts +13 -4
  24. package/src/lib/dropdown-menu-surface.ts +13 -0
  25. package/template/.claude/skills/exxat-ds-skill/SKILL.md +27 -9
  26. package/template/.cursor/rules/exxat-data-tables.mdc +1 -0
  27. package/template/.nvmrc +1 -1
  28. package/template/AGENTS.md +82 -27
  29. package/template/app/(app)/examples/page.tsx +2 -1
  30. package/template/app/(app)/help/page.tsx +6 -0
  31. package/template/app/(app)/layout.tsx +7 -4
  32. package/template/app/(app)/question-bank/find/page.tsx +12 -0
  33. package/template/app/(app)/question-bank/layout.tsx +46 -0
  34. package/template/app/(app)/question-bank/library/page.tsx +11 -0
  35. package/template/app/(app)/question-bank/list/page.tsx +12 -0
  36. package/template/app/(app)/question-bank/page.tsx +4 -3
  37. package/template/app/globals.css +1 -2
  38. package/template/components/app-sidebar.tsx +51 -13
  39. package/template/components/ask-leo-composer.tsx +173 -45
  40. package/template/components/ask-leo-sidebar.tsx +9 -1
  41. package/template/components/chart-area-interactive.tsx +3 -13
  42. package/template/components/charts-overview.tsx +33 -6
  43. package/template/components/collaboration-access-flow.tsx +144 -0
  44. package/template/components/compliance-page-header.tsx +1 -1
  45. package/template/components/compliance-table.tsx +2 -2
  46. package/template/components/dashboard-tabs.tsx +4 -3
  47. package/template/components/data-list-table-cells.tsx +1 -1
  48. package/template/components/data-list-table.tsx +1 -1
  49. package/template/components/data-table/index.tsx +5 -5
  50. package/template/components/data-table/use-table-state.ts +18 -2
  51. package/template/components/data-view-dashboard-charts-compliance.tsx +8 -5
  52. package/template/components/data-view-dashboard-charts-team.tsx +8 -5
  53. package/template/components/data-view-dashboard-charts.tsx +62 -227
  54. package/template/components/dedicated-search-recents.tsx +96 -0
  55. package/template/components/dedicated-search-url-composer.tsx +112 -0
  56. package/template/components/getting-started.tsx +1 -1
  57. package/template/components/hub-tree-panel-view.tsx +10 -26
  58. package/template/components/invite-collaborators-drawer.tsx +453 -0
  59. package/template/components/key-metrics.tsx +54 -8
  60. package/template/components/nav-documents.tsx +1 -1
  61. package/template/components/new-placement-form.tsx +3 -3
  62. package/template/components/page-header.tsx +76 -59
  63. package/template/components/placements-board-view.tsx +3 -3
  64. package/template/components/placements-page-header.tsx +1 -1
  65. package/template/components/placements-table-columns.tsx +3 -2
  66. package/template/components/product-switcher.tsx +0 -1
  67. package/template/components/question-bank-board-view.tsx +35 -47
  68. package/template/components/question-bank-client.tsx +293 -81
  69. package/template/components/question-bank-dashboard-charts.tsx +174 -0
  70. package/template/components/question-bank-favorite-button.tsx +46 -0
  71. package/template/components/question-bank-hub-client.tsx +436 -0
  72. package/template/components/question-bank-list-view.tsx +26 -19
  73. package/template/components/question-bank-new-folder-sheet.tsx +56 -42
  74. package/template/components/question-bank-os-folder-view.tsx +3 -14
  75. package/template/components/question-bank-page-header.tsx +85 -53
  76. package/template/components/question-bank-panel-activator.tsx +3 -4
  77. package/template/components/question-bank-secondary-nav.tsx +523 -65
  78. package/template/components/question-bank-table.tsx +125 -343
  79. package/template/components/secondary-panel.tsx +130 -63
  80. package/template/components/settings-client.tsx +3 -1
  81. package/template/components/sidebar-shell.tsx +2 -0
  82. package/template/components/sites-all-client.tsx +1 -1
  83. package/template/components/sites-table.tsx +1 -1
  84. package/template/components/system-banner-slot.tsx +2 -1
  85. package/template/components/table-properties/drawer.tsx +3 -3
  86. package/template/components/table-properties/sort-card.tsx +1 -1
  87. package/template/components/team-page-header.tsx +1 -1
  88. package/template/components/team-table.tsx +8 -4
  89. package/template/components/templates/dedicated-search-landing-template.tsx +58 -0
  90. package/template/components/templates/dedicated-search-results-template.tsx +19 -0
  91. package/template/components/templates/discovery-hub-template.tsx +273 -0
  92. package/template/components/templates/list-page.tsx +11 -4
  93. package/template/components/templates/nested-secondary-panel-shell.tsx +57 -0
  94. package/template/components/templates/secondary-panel-hub-template.tsx +54 -0
  95. package/template/docs/card-vs-rows-pattern.md +36 -0
  96. package/template/docs/collaboration-access-pattern.md +114 -0
  97. package/template/docs/data-views-pattern.md +12 -4
  98. package/template/docs/drawer-vs-dialog-pattern.md +50 -0
  99. package/template/docs/kpi-strip-max-four-pattern.md +29 -0
  100. package/template/docs/kpi-trend-pattern.md +43 -0
  101. package/template/fontawesome-subset.manifest.json +2 -2
  102. package/template/hooks/use-location-hash.ts +14 -8
  103. package/template/hooks/use-secondary-panel-hub-nav.ts +98 -0
  104. package/template/lib/ask-leo-route-context.ts +24 -0
  105. package/template/lib/collaborator-access.ts +92 -0
  106. package/template/lib/command-menu-config.ts +8 -1
  107. package/template/lib/command-menu-search-data.ts +11 -8
  108. package/template/lib/data-list-display-options.ts +1 -1
  109. package/template/lib/data-view-dashboard-placements-layout.ts +215 -0
  110. package/template/lib/date-filter.ts +1 -0
  111. package/template/lib/dedicated-search-recents.ts +76 -0
  112. package/template/lib/dedicated-search-url.ts +23 -0
  113. package/template/lib/discovery-hub.ts +15 -0
  114. package/template/lib/list-status-badges.ts +1 -21
  115. package/template/lib/mock/navigation.tsx +4 -2
  116. package/template/lib/mock/placements.ts +9 -9
  117. package/template/lib/mock/question-bank-folders.ts +7 -0
  118. package/template/lib/mock/question-bank-header-collaborators.ts +45 -5
  119. package/template/lib/mock/question-bank-inspector.ts +1 -2
  120. package/template/lib/mock/question-bank-kpi.ts +38 -26
  121. package/template/lib/mock/question-bank.ts +43 -16
  122. package/template/lib/question-bank-dedicated-search.ts +19 -0
  123. package/template/lib/question-bank-hub-search.ts +90 -0
  124. package/template/lib/question-bank-nav.ts +322 -6
  125. package/template/lib/question-bank-recent-searches.ts +22 -0
  126. package/template/package.json +1 -2
@@ -16,7 +16,7 @@ import {
16
16
  PencilLine,
17
17
  X,
18
18
  } from "lucide-react"
19
- import { ListHubStatusBadge, LIST_HUB_INSPECTOR_CHIP_SHELL } from "@/components/list-hub-status-badge"
19
+ import { LIST_HUB_INSPECTOR_CHIP_SHELL } from "@/components/list-hub-status-badge"
20
20
  import { Badge } from "@/components/ui/badge"
21
21
  import { Button } from "@/components/ui/button"
22
22
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
@@ -35,10 +35,8 @@ import { QuestionBankNewFolderSheet } from "@/components/question-bank-new-folde
35
35
  import type {
36
36
  QuestionBankItem,
37
37
  QuestionBankDifficulty,
38
- QuestionBankStatus,
39
38
  } from "@/lib/mock/question-bank"
40
39
  import type { QuestionBankFolder, QuestionBankFolderColorKey } from "@/lib/mock/question-bank-folders"
41
- import { QUESTION_BANK_STATUS_BADGE_CLASS, QUESTION_BANK_STATUS_ICON } from "@/lib/list-status-badges"
42
40
  import { formatDateUS } from "@/lib/date-filter"
43
41
  import {
44
42
  deriveBloomLevel,
@@ -56,13 +54,6 @@ const DIFFICULTY_LABEL: Record<QuestionBankDifficulty, string> = {
56
54
  hard: "Hard",
57
55
  }
58
56
 
59
- /** Inspector header label — “Saved” for published matches reviewer checklist language. */
60
- const INSPECTOR_STATUS_LABEL: Record<QuestionBankStatus, string> = {
61
- published: "Saved",
62
- draft: "Draft",
63
- in_review: "In review",
64
- }
65
-
66
57
  // ============================================================================
67
58
  // TreeItem — recursive folder/question renderer using Collapsible
68
59
  // ============================================================================
@@ -181,7 +172,12 @@ function TreeItem({
181
172
  isSelected ? "fill-current opacity-80" : "text-muted-foreground",
182
173
  )}
183
174
  />
184
- <span className="truncate leading-tight">{question.stem}</span>
175
+ <span className="min-w-0 flex-1">
176
+ <span className="block truncate leading-tight">{question.stem}</span>
177
+ <span className="block truncate font-mono text-[11px] text-muted-foreground">
178
+ {question.questionId}
179
+ </span>
180
+ </span>
185
181
  </button>
186
182
  </div>
187
183
  )
@@ -268,12 +264,7 @@ function DetailsPanel({ selectedItemId, folders, questions, onClearSelection }:
268
264
  <div className="flex h-full min-h-0 flex-col overflow-hidden bg-card">
269
265
  <header className="shrink-0 border-b border-border/60 bg-muted/10 px-4 pb-4 pt-3">
270
266
  <div className="flex items-start justify-between gap-3">
271
- <ListHubStatusBadge
272
- surface="detail"
273
- label={INSPECTOR_STATUS_LABEL[question.status]}
274
- tintClassName={QUESTION_BANK_STATUS_BADGE_CLASS[question.status]}
275
- icon={QUESTION_BANK_STATUS_ICON[question.status]}
276
- />
267
+ <p className="font-mono text-xs text-muted-foreground">{question.questionId}</p>
277
268
  {onClearSelection ? (
278
269
  <Tip label="Close details" side="bottom">
279
270
  <Button
@@ -299,16 +290,9 @@ function DetailsPanel({ selectedItemId, folders, questions, onClearSelection }:
299
290
  </Button>
300
291
  </span>
301
292
  </Tip>
302
- <Tip
303
- label={
304
- question.status === "draft"
305
- ? "Already a draft."
306
- : "Revert connects when your assessments API is wired."
307
- }
308
- side="bottom"
309
- >
293
+ <Tip label="Revert connects when your assessments API is wired." side="bottom">
310
294
  <span className="inline-flex">
311
- <Button type="button" variant="outline" size="sm" className="gap-1.5" disabled={question.status === "draft"}>
295
+ <Button type="button" variant="outline" size="sm" className="gap-1.5" disabled>
312
296
  <Hourglass className="size-3.5" aria-hidden />
313
297
  Revert to draft
314
298
  </Button>
@@ -0,0 +1,453 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { useForm } from "react-hook-form"
5
+ import { z } from "zod"
6
+ import { zodResolver } from "@hookform/resolvers/zod"
7
+
8
+ import { devLog } from "@/lib/dev-log"
9
+ import {
10
+ COLLABORATOR_ACCESS_ICON_LIGHT,
11
+ COLLABORATOR_ACCESS_LABELS,
12
+ INVITE_COLLABORATOR_ACCESS_OPTIONS,
13
+ ROSTER_COLLABORATOR_ACCESS_OPTIONS,
14
+ canRemoveCollaboratorFromRoster,
15
+ canSetCollaboratorAccessRole,
16
+ collaboratorRemoveBlockedReason,
17
+ type CollaboratorAccessRole,
18
+ type InviteCollaboratorAccessRole,
19
+ } from "@/lib/collaborator-access"
20
+ import { initialsFromDisplayName } from "@/lib/initials-from-name"
21
+ import { cn } from "@/lib/utils"
22
+ import type { PageHeaderCollaborator } from "@/components/page-header"
23
+ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
24
+ import { Badge } from "@/components/ui/badge"
25
+ import { Button } from "@/components/ui/button"
26
+ import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"
27
+ import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"
28
+ import { Tip } from "@/components/ui/tip"
29
+ import { Kbd, KbdGroup } from "@/components/ui/kbd"
30
+ import { Shortcut } from "@/components/ui/dropdown-menu"
31
+ import {
32
+ Popover,
33
+ PopoverContent,
34
+ PopoverTrigger,
35
+ } from "@/components/ui/popover"
36
+ import {
37
+ Sheet,
38
+ SheetContent,
39
+ SheetTitle,
40
+ } from "@/components/ui/sheet"
41
+ import {
42
+ Dialog,
43
+ DialogContent,
44
+ DialogDescription,
45
+ DialogFooter,
46
+ DialogHeader,
47
+ DialogTitle,
48
+ } from "@/components/ui/dialog"
49
+ import {
50
+ Form,
51
+ FormControl,
52
+ FormField,
53
+ FormMessage,
54
+ } from "@/components/ui/form"
55
+
56
+ const inviteSchema = z.object({
57
+ email: z.string().min(1, "Email is required").email("Enter a valid email address"),
58
+ access: z.enum(["editor", "commenter", "viewer"]),
59
+ })
60
+
61
+ type InviteForm = z.infer<typeof inviteSchema>
62
+
63
+ export type InviteCollaboratorFormValues = InviteForm
64
+
65
+ export interface InviteCollaboratorsDrawerProps {
66
+ open: boolean
67
+ onOpenChange: (open: boolean) => void
68
+ collaborators: PageHeaderCollaborator[]
69
+ resourceLabel?: string
70
+ onInvite?: (values: InviteCollaboratorFormValues) => void
71
+ onCollaboratorAccessChange?: (id: string, access: CollaboratorAccessRole) => void
72
+ onCollaboratorRemove?: (id: string) => void
73
+ }
74
+
75
+ function collaboratorAccessLabel(access: PageHeaderCollaborator["access"]) {
76
+ if (!access) return "Viewer"
77
+ return COLLABORATOR_ACCESS_LABELS[access]
78
+ }
79
+
80
+ function isOverlaySelectorSheetTarget(target: EventTarget | null) {
81
+ return (
82
+ target instanceof Element
83
+ && target.closest(
84
+ '[data-slot="popover-content"], [data-slot="popover-trigger"], [data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-trigger"]',
85
+ ) != null
86
+ )
87
+ }
88
+
89
+ function CollaboratorAccessChipSelector({
90
+ value,
91
+ onValueChange,
92
+ options,
93
+ ariaLabel,
94
+ triggerId,
95
+ triggerClassName,
96
+ isOptionDisabled,
97
+ }: {
98
+ value: string
99
+ onValueChange: (value: string) => void
100
+ options: readonly { value: string; label: string }[]
101
+ ariaLabel: string
102
+ triggerId?: string
103
+ triggerClassName?: string
104
+ isOptionDisabled?: (value: string) => boolean
105
+ }) {
106
+ const [open, setOpen] = React.useState(false)
107
+ const selected = options.find(option => option.value === value)
108
+ const selectedLabel = selected?.label ?? value
109
+ const selectedIcon = COLLABORATOR_ACCESS_ICON_LIGHT[value as CollaboratorAccessRole]
110
+
111
+ return (
112
+ <Popover open={open} onOpenChange={setOpen} modal={false}>
113
+ <PopoverTrigger asChild>
114
+ <Badge
115
+ asChild
116
+ variant="secondary"
117
+ className={cn("font-normal hover:bg-secondary/80", triggerClassName)}
118
+ >
119
+ <button
120
+ type="button"
121
+ id={triggerId}
122
+ data-slot="popover-trigger"
123
+ aria-label={ariaLabel}
124
+ aria-haspopup="listbox"
125
+ aria-expanded={open}
126
+ >
127
+ <i className={cn("fa-light", selectedIcon)} aria-hidden="true" />
128
+ {selectedLabel}
129
+ <i
130
+ className="fa-light fa-chevron-down text-muted-foreground"
131
+ data-icon="inline-end"
132
+ aria-hidden="true"
133
+ />
134
+ </button>
135
+ </Badge>
136
+ </PopoverTrigger>
137
+ <PopoverContent align="end" sideOffset={4} className="w-44 p-1">
138
+ <div role="listbox" aria-label={ariaLabel} className="flex flex-col gap-0.5">
139
+ {options.map(option => {
140
+ const isSelected = option.value === value
141
+ const isDisabled = isOptionDisabled?.(option.value) ?? false
142
+ const optionIcon = COLLABORATOR_ACCESS_ICON_LIGHT[option.value as CollaboratorAccessRole]
143
+
144
+ return (
145
+ <button
146
+ key={option.value}
147
+ type="button"
148
+ role="option"
149
+ aria-selected={isSelected}
150
+ disabled={isDisabled}
151
+ onClick={() => {
152
+ if (isDisabled || isSelected) return
153
+ onValueChange(option.value)
154
+ setOpen(false)
155
+ }}
156
+ className={cn(
157
+ "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm",
158
+ isSelected
159
+ ? "bg-accent text-accent-foreground"
160
+ : "text-foreground hover:bg-interactive-hover",
161
+ isDisabled && "cursor-not-allowed opacity-50",
162
+ )}
163
+ >
164
+ <i className={cn("fa-light", optionIcon, "text-muted-foreground")} aria-hidden="true" />
165
+ <span className="min-w-0 flex-1">{option.label}</span>
166
+ {isSelected ? (
167
+ <i className="fa-light fa-check text-muted-foreground" aria-hidden="true" />
168
+ ) : null}
169
+ </button>
170
+ )
171
+ })}
172
+ </div>
173
+ </PopoverContent>
174
+ </Popover>
175
+ )
176
+ }
177
+
178
+ export function InviteCollaboratorsDrawer({
179
+ open,
180
+ onOpenChange,
181
+ collaborators,
182
+ resourceLabel = "this library",
183
+ onInvite,
184
+ onCollaboratorAccessChange,
185
+ onCollaboratorRemove,
186
+ }: InviteCollaboratorsDrawerProps) {
187
+ const form = useForm<InviteForm>({
188
+ resolver: zodResolver(inviteSchema),
189
+ defaultValues: {
190
+ email: "",
191
+ access: "editor",
192
+ },
193
+ })
194
+ const inviteAccess = form.watch("access")
195
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
196
+ const [removeTarget, setRemoveTarget] = React.useState<PageHeaderCollaborator | null>(null)
197
+
198
+ React.useEffect(() => {
199
+ if (!open) {
200
+ form.reset()
201
+ setIsSubmitting(false)
202
+ setRemoveTarget(null)
203
+ }
204
+ }, [open, form])
205
+
206
+ function confirmRemove() {
207
+ if (!removeTarget) return
208
+ onCollaboratorRemove?.(removeTarget.id)
209
+ setRemoveTarget(null)
210
+ }
211
+
212
+ async function onSubmit(values: InviteForm) {
213
+ setIsSubmitting(true)
214
+ await new Promise(resolve => window.setTimeout(resolve, 600))
215
+ devLog("Invite collaborator:", values)
216
+ onInvite?.(values)
217
+ setIsSubmitting(false)
218
+ onOpenChange(false)
219
+ form.reset()
220
+ }
221
+
222
+ return (
223
+ <Sheet open={open} onOpenChange={onOpenChange}>
224
+ <SheetContent
225
+ data-slot="invite-collaborators-drawer"
226
+ side="right"
227
+ showCloseButton={false}
228
+ showOverlay={false}
229
+ className="w-80 sm:max-w-80 p-0 gap-0 flex flex-col border border-border shadow-xl rounded-xl"
230
+ style={{ top: "0.5rem", bottom: "0.5rem", right: "0.5rem", height: "calc(100vh - 1rem)" }}
231
+ onPointerDownOutside={event => {
232
+ if (isOverlaySelectorSheetTarget(event.target)) {
233
+ event.preventDefault()
234
+ }
235
+ }}
236
+ onInteractOutside={event => {
237
+ if (isOverlaySelectorSheetTarget(event.target)) {
238
+ event.preventDefault()
239
+ }
240
+ }}
241
+ >
242
+ <div className="flex items-center justify-between gap-3 px-4 pt-5 pb-3">
243
+ <SheetTitle className="text-base font-semibold leading-tight">Collaborators</SheetTitle>
244
+ <Tip label="Close" side="bottom">
245
+ <Button
246
+ type="button"
247
+ variant="ghost"
248
+ size="icon-sm"
249
+ aria-label="Close"
250
+ onClick={() => onOpenChange(false)}
251
+ >
252
+ <i className="fa-light fa-xmark" aria-hidden="true" />
253
+ </Button>
254
+ </Tip>
255
+ </div>
256
+
257
+ <p className="px-4 pb-3 text-sm text-muted-foreground -mt-1">
258
+ Manage who can access {resourceLabel}.
259
+ </p>
260
+
261
+ <div className="flex flex-1 flex-col gap-6 overflow-y-auto px-4 pb-4">
262
+ <Form {...form}>
263
+ <form
264
+ id="invite-collaborators-form"
265
+ onSubmit={form.handleSubmit(onSubmit)}
266
+ >
267
+ <FieldGroup>
268
+ <FormField
269
+ control={form.control}
270
+ name="email"
271
+ render={({ field }) => (
272
+ <Field data-invalid={!!form.formState.errors.email}>
273
+ <FieldLabel htmlFor="invite-collaborator-email">Invite by email</FieldLabel>
274
+ <InputGroup>
275
+ <FormControl>
276
+ <InputGroupInput
277
+ {...field}
278
+ id="invite-collaborator-email"
279
+ type="email"
280
+ inputMode="email"
281
+ autoComplete="email"
282
+ placeholder="name@example.com"
283
+ aria-required="true"
284
+ aria-invalid={!!form.formState.errors.email}
285
+ />
286
+ </FormControl>
287
+ <InputGroupAddon align="inline-end">
288
+ <CollaboratorAccessChipSelector
289
+ value={inviteAccess}
290
+ onValueChange={value =>
291
+ form.setValue("access", value as InviteCollaboratorAccessRole, {
292
+ shouldDirty: true,
293
+ shouldValidate: true,
294
+ })}
295
+ options={INVITE_COLLABORATOR_ACCESS_OPTIONS}
296
+ ariaLabel="Access level"
297
+ triggerId="invite-collaborator-access"
298
+ />
299
+ </InputGroupAddon>
300
+ </InputGroup>
301
+ <FieldDescription>name@example.com</FieldDescription>
302
+ <FormMessage />
303
+ </Field>
304
+ )}
305
+ />
306
+ </FieldGroup>
307
+ <Button
308
+ type="submit"
309
+ className="w-full"
310
+ disabled={isSubmitting}
311
+ >
312
+ {isSubmitting ? (
313
+ <>
314
+ <i className="fa-light fa-spinner-third fa-spin" aria-hidden="true" />
315
+ Sending…
316
+ </>
317
+ ) : (
318
+ <>
319
+ <i className="fa-light fa-user-plus" aria-hidden="true" />
320
+ Send invite
321
+ <KbdGroup className="ml-1.5"><Kbd variant="bare">⏎</Kbd></KbdGroup>
322
+ </>
323
+ )}
324
+ </Button>
325
+ </form>
326
+ </Form>
327
+
328
+ <section aria-labelledby="invite-collaborators-list-heading" className="flex flex-col gap-3">
329
+ <h2
330
+ id="invite-collaborators-list-heading"
331
+ className="text-sm font-medium leading-none"
332
+ >
333
+ People with access
334
+ </h2>
335
+ <ul className="rounded-lg border border-border divide-y divide-border">
336
+ {collaborators.map(person => {
337
+ const access = person.access ?? "viewer"
338
+ const removeBlocked = onCollaboratorRemove
339
+ ? collaboratorRemoveBlockedReason(person, collaborators)
340
+ : undefined
341
+ const canRemove = onCollaboratorRemove && !removeBlocked
342
+
343
+ return (
344
+ <li
345
+ key={person.id}
346
+ className="flex items-start gap-3 px-3 py-2.5"
347
+ >
348
+ <Avatar size="sm" shape="circle" className="mt-0.5 shrink-0">
349
+ {person.imageUrl ? (
350
+ <AvatarImage src={person.imageUrl} alt="" referrerPolicy="no-referrer" />
351
+ ) : null}
352
+ <AvatarFallback className="text-xs font-semibold">
353
+ {(person.initials ?? initialsFromDisplayName(person.name)).toUpperCase()}
354
+ </AvatarFallback>
355
+ </Avatar>
356
+ <div className="min-w-0 flex-1">
357
+ <p className="truncate text-sm font-medium text-foreground">{person.name}</p>
358
+ {person.email ? (
359
+ <p className="truncate text-xs text-muted-foreground">{person.email}</p>
360
+ ) : null}
361
+ {person.roles && person.roles.length > 0 ? (
362
+ <div className="mt-1.5 flex flex-wrap gap-1">
363
+ {person.roles.map(role => (
364
+ <Badge key={role} variant="outline" className="font-normal">
365
+ {role}
366
+ </Badge>
367
+ ))}
368
+ </div>
369
+ ) : null}
370
+ </div>
371
+ <div className="flex shrink-0 items-center gap-1 self-start pt-0.5">
372
+ {access === "owner" || !onCollaboratorAccessChange ? (
373
+ <Badge variant="secondary" className="shrink-0 font-normal">
374
+ <i
375
+ className={cn("fa-light", COLLABORATOR_ACCESS_ICON_LIGHT[access])}
376
+ aria-hidden="true"
377
+ />
378
+ {collaboratorAccessLabel(person.access)}
379
+ </Badge>
380
+ ) : (
381
+ <CollaboratorAccessChipSelector
382
+ value={access}
383
+ onValueChange={value =>
384
+ onCollaboratorAccessChange(person.id, value as CollaboratorAccessRole)}
385
+ options={ROSTER_COLLABORATOR_ACCESS_OPTIONS}
386
+ ariaLabel={`Access for ${person.name}`}
387
+ triggerId={`collaborator-access-${person.id}`}
388
+ isOptionDisabled={value =>
389
+ value !== access
390
+ && !canSetCollaboratorAccessRole(person, collaborators, value as CollaboratorAccessRole)}
391
+ />
392
+ )}
393
+ {onCollaboratorRemove ? (
394
+ <Tip
395
+ side="bottom"
396
+ label={removeBlocked ?? "Remove access"}
397
+ >
398
+ <Button
399
+ type="button"
400
+ variant="ghost"
401
+ size="icon-sm"
402
+ className="shrink-0"
403
+ aria-label={`Remove access for ${person.name}`}
404
+ disabled={!canRemove}
405
+ onClick={() => setRemoveTarget(person)}
406
+ >
407
+ <i className="fa-light fa-xmark" aria-hidden="true" />
408
+ </Button>
409
+ </Tip>
410
+ ) : null}
411
+ </div>
412
+ </li>
413
+ )
414
+ })}
415
+ </ul>
416
+ </section>
417
+ </div>
418
+
419
+ <Shortcut keys="Enter" disabled={isSubmitting} onInvoke={() => form.handleSubmit(onSubmit)()} />
420
+
421
+ </SheetContent>
422
+
423
+ <Dialog open={removeTarget != null} onOpenChange={open => { if (!open) setRemoveTarget(null) }}>
424
+ <DialogContent className="max-w-sm">
425
+ <DialogHeader>
426
+ <DialogTitle>Remove access</DialogTitle>
427
+ <DialogDescription>
428
+ {removeTarget
429
+ ? `${removeTarget.name} will lose access to ${resourceLabel}.`
430
+ : null}
431
+ </DialogDescription>
432
+ </DialogHeader>
433
+ <DialogFooter className="gap-2 sm:gap-2">
434
+ <Button type="button" variant="outline" onClick={() => setRemoveTarget(null)}>
435
+ Cancel
436
+ <KbdGroup className="ml-1.5"><Kbd variant="bare">Esc</Kbd></KbdGroup>
437
+ </Button>
438
+ <Button
439
+ type="button"
440
+ variant="destructive"
441
+ onClick={confirmRemove}
442
+ disabled={!removeTarget || !canRemoveCollaboratorFromRoster(removeTarget, collaborators)}
443
+ >
444
+ Remove
445
+ <KbdGroup className="ml-1.5"><Kbd variant="bare">⏎</Kbd></KbdGroup>
446
+ </Button>
447
+ </DialogFooter>
448
+ </DialogContent>
449
+ </Dialog>
450
+ <Shortcut keys="Enter" disabled={!removeTarget} onInvoke={confirmRemove} />
451
+ </Sheet>
452
+ )
453
+ }
@@ -9,7 +9,8 @@
9
9
  *
10
10
  * AA checklist:
11
11
  * ✓ Trend text never relies on colour alone — icon + label (WCAG 1.4.1)
12
- * ✓ Trend icons have aria-hidden; sr-only label carries meaning (1.1.1)
12
+ * ✓ `trend` matches signed change; `trendPolarity` flips sentiment when “up” is bad (see `docs/kpi-trend-pattern.md`)
13
+ * ✓ Trend icons have aria-hidden; chip `aria-label` uses `metricTrendAriaQualifier` (1.1.1)
13
14
  * ✓ Select has accessible label via aria-label (4.1.2)
14
15
  * ✓ Insight action button has descriptive text (4.1.2)
15
16
  * ✓ Decorative dividers are aria-hidden (1.1.1)
@@ -66,6 +67,44 @@ function InsightAskLeoTooltip({
66
67
 
67
68
  /* ── Types ────────────────────────────────────────────────────────────────── */
68
69
 
70
+ /**
71
+ * Whether an **up** arrow should read as “good news” for tinting and assistive text.
72
+ * - **`higher_is_better`** (default) — revenue, pass rate, approved count: up = favorable.
73
+ * - **`lower_is_better`** — defects, overdue, **low PBI / quality flags**: more flags + up arrow = unfavorable.
74
+ * - **`informational`** — volume or mix only; keep arrows **muted** (direction without value judgment).
75
+ */
76
+ export type MetricTrendPolarity = "higher_is_better" | "lower_is_better" | "informational"
77
+
78
+ export type MetricTrendTone = "positive" | "negative" | "muted"
79
+
80
+ /** Maps `trend` + polarity to semantic tone for colours (arrow direction still follows `trend`). */
81
+ export function metricTrendTone(
82
+ trend: "up" | "down" | "neutral",
83
+ polarity: MetricTrendPolarity = "higher_is_better",
84
+ ): MetricTrendTone {
85
+ if (trend === "neutral") return "muted"
86
+ if (polarity === "informational") return "muted"
87
+ if (polarity === "higher_is_better") {
88
+ return trend === "up" ? "positive" : "negative"
89
+ }
90
+ return trend === "up" ? "negative" : "positive"
91
+ }
92
+
93
+ /** Short clause for `aria-label` on the trend chip (paired with the delta string). */
94
+ export function metricTrendAriaQualifier(
95
+ trend: "up" | "down" | "neutral",
96
+ polarity: MetricTrendPolarity = "higher_is_better",
97
+ ): string {
98
+ if (trend === "neutral") return "no net change"
99
+ if (polarity === "informational") {
100
+ return trend === "up" ? "increased" : "decreased"
101
+ }
102
+ if (polarity === "higher_is_better") {
103
+ return trend === "up" ? "increased, favorable" : "decreased, unfavorable"
104
+ }
105
+ return trend === "up" ? "increased, unfavorable" : "decreased, favorable"
106
+ }
107
+
69
108
  export interface MetricItem {
70
109
  /** Unique identifier for React keying */
71
110
  id: string
@@ -75,8 +114,13 @@ export interface MetricItem {
75
114
  value: string | number
76
115
  /** Change delta — e.g. "+5", "-3", "+12" */
77
116
  delta: string | number
78
- /** Visual + semantic trend direction */
117
+ /** Visual trend direction (arrow follows the signed change in the underlying metric). */
79
118
  trend: "up" | "down" | "neutral"
119
+ /**
120
+ * How to **tint** the trend chip. Omit = **`higher_is_better`** (legacy behaviour).
121
+ * Arrows always match `trend`; sentiment colours flip for **`lower_is_better`**.
122
+ */
123
+ trendPolarity?: MetricTrendPolarity
80
124
  /** Makes the cell a link */
81
125
  href?: string
82
126
  /** Makes the cell a button */
@@ -185,11 +229,12 @@ const DEFAULT_PERIODS: PeriodOption[] = [
185
229
  /* ── Sub-components ───────────────────────────────────────────────────────── */
186
230
 
187
231
  /** Single KPI cell inside the metrics grid */
188
- function MetricCell({
232
+ const MetricCell = React.memo(function MetricCell({
189
233
  label,
190
234
  value,
191
235
  delta,
192
236
  trend,
237
+ trendPolarity = "higher_is_better",
193
238
  href,
194
239
  onClick,
195
240
  metricVariant = "default",
@@ -198,6 +243,7 @@ function MetricCell({
198
243
  }: Omit<MetricItem, "id"> & { dense?: boolean; edgeGutter?: boolean }) {
199
244
  const isUp = trend === "up"
200
245
  const isDown = trend === "down"
246
+ const tone = metricTrendTone(trend, trendPolarity)
201
247
  const isInteractive = !!(href || onClick)
202
248
  const isHero = metricVariant === "hero"
203
249
 
@@ -248,11 +294,11 @@ function MetricCell({
248
294
  className={cn(
249
295
  "inline-flex items-center gap-1 font-medium leading-none",
250
296
  dense ? "text-xs sm:text-xs" : "text-xs sm:text-sm",
251
- isUp && "text-chart-2",
252
- isDown && "text-destructive",
253
- !isUp && !isDown && "text-muted-foreground"
297
+ tone === "positive" && "text-chart-2",
298
+ tone === "negative" && "text-destructive",
299
+ tone === "muted" && "text-muted-foreground",
254
300
  )}
255
- aria-label={`${isUp ? "up" : isDown ? "down" : "no change"} ${delta}`}
301
+ aria-label={`${metricTrendAriaQualifier(trend, trendPolarity)} ${delta}`}
256
302
  >
257
303
  {isUp && <i className="fa-light fa-arrow-trend-up text-[0.8rem]" aria-hidden="true" />}
258
304
  {isDown && <i className="fa-light fa-arrow-trend-down text-[0.8rem]" aria-hidden="true" />}
@@ -292,7 +338,7 @@ function MetricCell({
292
338
  }
293
339
 
294
340
  return <div className={sharedClass}>{inner}</div>
295
- }
341
+ })
296
342
 
297
343
  /** Body line for rail: `description`, else optional `statement` */
298
344
  function insightRailBody(insight: MetricInsight): string {
@@ -56,7 +56,7 @@ export function NavDocuments({
56
56
  </SidebarMenuAction>
57
57
  </DropdownMenuTrigger>
58
58
  <DropdownMenuContent
59
- className="w-24 rounded-lg"
59
+ className="rounded-lg"
60
60
  side={isMobile ? "bottom" : "right"}
61
61
  align={isMobile ? "end" : "start"}
62
62
  >
@@ -33,7 +33,7 @@ import { z } from "zod"
33
33
 
34
34
  import { cn } from "@/lib/utils"
35
35
  import { devLog } from "@/lib/dev-log"
36
- import { formatDateUS } from "@/lib/date-filter"
36
+ import { formatDateFromDate } from "@/lib/date-filter"
37
37
 
38
38
  import {
39
39
  Form,
@@ -883,8 +883,8 @@ function Step5({
883
883
  </ReviewSection>
884
884
 
885
885
  <ReviewSection title="Schedule" icon="fa-calendar-days" onEdit={() => goToStep(3)}>
886
- <ReviewRow label="Start Date" value={data.startDate ? formatDateUS(data.startDate.toISOString()) : undefined} />
887
- <ReviewRow label="End Date" value={data.endDate ? formatDateUS(data.endDate.toISOString()) : undefined} />
886
+ <ReviewRow label="Start Date" value={data.startDate ? formatDateFromDate(data.startDate) : undefined} />
887
+ <ReviewRow label="End Date" value={data.endDate ? formatDateFromDate(data.endDate) : undefined} />
888
888
  <ReviewRow label="Duration" value={data.duration} />
889
889
  <ReviewRow label="Hours / Week" value={data.hoursPerWeek ? `${data.hoursPerWeek} hrs` : undefined} />
890
890
  <ReviewRow label="Shift" value={data.shift} />