@exxatdesignux/ui 0.2.6 → 0.2.7

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 (139) hide show
  1. package/package.json +2 -1
  2. package/template/.agents/skills/shadcn/SKILL.md +242 -0
  3. package/template/.agents/skills/shadcn/agents/openai.yml +5 -0
  4. package/template/.agents/skills/shadcn/assets/shadcn-small.png +0 -0
  5. package/template/.agents/skills/shadcn/assets/shadcn.png +0 -0
  6. package/template/.agents/skills/shadcn/cli.md +257 -0
  7. package/template/.agents/skills/shadcn/customization.md +202 -0
  8. package/template/.agents/skills/shadcn/evals/evals.json +47 -0
  9. package/template/.agents/skills/shadcn/mcp.md +94 -0
  10. package/template/.agents/skills/shadcn/rules/base-vs-radix.md +306 -0
  11. package/template/.agents/skills/shadcn/rules/composition.md +195 -0
  12. package/template/.agents/skills/shadcn/rules/forms.md +192 -0
  13. package/template/.agents/skills/shadcn/rules/icons.md +101 -0
  14. package/template/.agents/skills/shadcn/rules/styling.md +162 -0
  15. package/template/.claude/skills/exxat-ds-skill/SKILL.md +712 -0
  16. package/template/.cursor/rules/exxat-accessibility.mdc +33 -0
  17. package/template/.cursor/rules/exxat-command-menu.mdc +23 -0
  18. package/template/.cursor/rules/exxat-dashboard-view-charts.mdc +53 -0
  19. package/template/.cursor/rules/exxat-data-tables.mdc +31 -0
  20. package/template/.cursor/rules/exxat-ds-agents.mdc +26 -0
  21. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +100 -0
  22. package/template/.cursor/rules/exxat-list-page-connected-views.mdc +16 -0
  23. package/template/.cursor/rules/exxat-no-toast.mdc +26 -0
  24. package/template/.cursor/rules/exxat-page-vs-drawer.mdc +22 -0
  25. package/template/.cursor/rules/exxat-table-properties-drawer.mdc +40 -0
  26. package/template/AGENTS.md +52 -11
  27. package/template/app/(app)/dashboard/page.tsx +1 -1
  28. package/template/app/(app)/data-list/[id]/page.tsx +24 -8
  29. package/template/app/(app)/data-list/new/page.tsx +7 -4
  30. package/template/app/(app)/data-list/page.tsx +1 -1
  31. package/template/app/(app)/examples/page.tsx +41 -0
  32. package/template/app/(app)/question-bank/page.tsx +3 -3
  33. package/template/app/globals.css +1 -1
  34. package/template/components/app-sidebar.tsx +52 -35
  35. package/template/components/compliance-table.tsx +79 -0
  36. package/template/components/data-list-client.tsx +36 -25
  37. package/template/components/data-list-table.tsx +797 -10
  38. package/template/components/data-views/finder-panel-view.tsx +405 -0
  39. package/template/components/data-views/folder-grid-view.tsx +86 -0
  40. package/template/components/data-views/index.ts +59 -0
  41. package/template/components/data-views/list-page-split-details-placeholder.tsx +39 -0
  42. package/template/components/data-views/list-page-split-hub-chrome.tsx +60 -0
  43. package/template/components/data-views/list-page-split-hub-tokens.ts +16 -0
  44. package/template/components/data-views/list-page-tree-column-header.tsx +31 -0
  45. package/template/components/data-views/list-page-tree-panel-shell.tsx +91 -0
  46. package/template/components/data-views/list-page-view-frame.tsx +53 -0
  47. package/template/components/data-views/os-folder-glyph.tsx +121 -0
  48. package/template/components/folder-details-shell.tsx +230 -0
  49. package/template/components/hub-tree-panel-view.tsx +672 -0
  50. package/template/components/list-hub-status-badge.tsx +17 -3
  51. package/template/components/placements-page-header.tsx +14 -8
  52. package/template/components/placements-table-columns.tsx +8 -8
  53. package/template/components/question-bank-client.tsx +157 -40
  54. package/template/components/question-bank-new-folder-sheet.tsx +248 -0
  55. package/template/components/question-bank-os-folder-view.tsx +648 -0
  56. package/template/components/question-bank-page-header.tsx +3 -3
  57. package/template/components/question-bank-panel-activator.tsx +9 -0
  58. package/template/components/question-bank-secondary-nav.tsx +226 -0
  59. package/template/components/question-bank-table.tsx +707 -22
  60. package/template/components/secondary-panel.tsx +41 -107
  61. package/template/components/sites-table.tsx +66 -0
  62. package/template/components/team-client.tsx +7 -0
  63. package/template/components/team-table.tsx +156 -1
  64. package/template/components/templates/list-page.tsx +2 -2
  65. package/template/components/ui/avatar.tsx +1 -1
  66. package/template/components/ui/badge.tsx +1 -1
  67. package/template/components/ui/banner.tsx +1 -1
  68. package/template/components/ui/breadcrumb.tsx +1 -1
  69. package/template/components/ui/button.tsx +1 -1
  70. package/template/components/ui/calendar.tsx +1 -1
  71. package/template/components/ui/card.tsx +1 -1
  72. package/template/components/ui/chart.tsx +1 -1
  73. package/template/components/ui/checkbox.tsx +1 -1
  74. package/template/components/ui/coach-mark.tsx +1 -1
  75. package/template/components/ui/collapsible.tsx +1 -1
  76. package/template/components/ui/command.tsx +1 -1
  77. package/template/components/ui/date-picker-field.tsx +1 -1
  78. package/template/components/ui/dialog.tsx +1 -1
  79. package/template/components/ui/drag-handle-grip.tsx +1 -1
  80. package/template/components/ui/drawer.tsx +1 -1
  81. package/template/components/ui/dropdown-menu.tsx +1 -1
  82. package/template/components/ui/field.tsx +1 -1
  83. package/template/components/ui/form.tsx +1 -1
  84. package/template/components/ui/input-group.tsx +1 -1
  85. package/template/components/ui/input-mask.tsx +1 -1
  86. package/template/components/ui/input.tsx +1 -1
  87. package/template/components/ui/kbd.tsx +1 -1
  88. package/template/components/ui/label.tsx +1 -1
  89. package/template/components/ui/payment-card-fields.tsx +1 -1
  90. package/template/components/ui/popover.tsx +1 -1
  91. package/template/components/ui/radio-group.tsx +1 -1
  92. package/template/components/ui/resizable.tsx +68 -0
  93. package/template/components/ui/select.tsx +1 -1
  94. package/template/components/ui/selection-tile-grid.tsx +1 -1
  95. package/template/components/ui/separator.tsx +1 -1
  96. package/template/components/ui/sheet.tsx +1 -1
  97. package/template/components/ui/sidebar.tsx +1 -1
  98. package/template/components/ui/skeleton.tsx +1 -1
  99. package/template/components/ui/sonner.tsx +1 -1
  100. package/template/components/ui/status-badge.tsx +1 -1
  101. package/template/components/ui/table.tsx +1 -1
  102. package/template/components/ui/tabs.tsx +1 -1
  103. package/template/components/ui/textarea.tsx +1 -1
  104. package/template/components/ui/tip.tsx +1 -1
  105. package/template/components/ui/toggle-group.tsx +1 -1
  106. package/template/components/ui/toggle-switch.tsx +1 -1
  107. package/template/components/ui/toggle.tsx +1 -1
  108. package/template/components/ui/tooltip.tsx +1 -1
  109. package/template/components/ui/view-segmented-control.tsx +1 -1
  110. package/template/docs/data-views-pattern.md +7 -0
  111. package/template/hooks/use-app-theme.ts +1 -1
  112. package/template/hooks/use-coach-mark.ts +1 -1
  113. package/template/hooks/use-location-hash.ts +15 -0
  114. package/template/hooks/use-mobile.ts +1 -1
  115. package/template/hooks/use-mod-key-label.ts +1 -1
  116. package/template/hooks/use-sidebar-reflow-zoom.ts +40 -0
  117. package/template/lib/ask-leo-route-context.ts +25 -57
  118. package/template/lib/coach-mark-registry.ts +13 -13
  119. package/template/lib/command-menu-config.ts +28 -23
  120. package/template/lib/command-menu-search-data.ts +10 -9
  121. package/template/lib/data-list-view-surface.ts +12 -1
  122. package/template/lib/data-list-view.ts +6 -3
  123. package/template/lib/date-filter.ts +1 -1
  124. package/template/lib/mock/dashboard.ts +11 -11
  125. package/template/lib/mock/navigation.tsx +22 -63
  126. package/template/lib/mock/placements-kpi.ts +19 -19
  127. package/template/lib/mock/question-bank-folders.ts +167 -0
  128. package/template/lib/mock/question-bank-inspector.ts +109 -0
  129. package/template/lib/mock/question-bank-kpi.ts +1 -1
  130. package/template/lib/mock/question-bank.ts +80 -0
  131. package/template/lib/question-bank-nav.ts +91 -0
  132. package/template/lib/utils.ts +1 -1
  133. package/template/next.config.mjs +8 -0
  134. package/template/package.json +1 -0
  135. package/template/public/folders/icons8-folder-windows-11.svg +1 -0
  136. package/template/app/(app)/compliance/page.tsx +0 -10
  137. package/template/app/(app)/rotations/page.tsx +0 -15
  138. package/template/app/(app)/sites/all/page.tsx +0 -13
  139. package/template/app/(app)/team/page.tsx +0 -10
@@ -0,0 +1,31 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "@/lib/utils"
5
+
6
+ export interface ListPageTreeColumnHeaderProps {
7
+ title: string
8
+ /** Right side (e.g. icon buttons) — keep touch targets ≥ 24px */
9
+ trailing?: React.ReactNode
10
+ className?: string
11
+ }
12
+
13
+ /**
14
+ * Shared left-column header for tree / outline surfaces — matches Question bank “Questions” bar.
15
+ */
16
+ export function ListPageTreeColumnHeader({
17
+ title,
18
+ trailing,
19
+ className,
20
+ }: ListPageTreeColumnHeaderProps) {
21
+ return (
22
+ <div className={cn("shrink-0 border-b border-border/50 bg-muted/10 px-3 py-2", className)}>
23
+ <div className="flex h-9 items-center justify-between gap-2">
24
+ <h3 className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{title}</h3>
25
+ {trailing ? (
26
+ <div className="flex shrink-0 items-center gap-0.5">{trailing}</div>
27
+ ) : null}
28
+ </div>
29
+ </div>
30
+ )
31
+ }
@@ -0,0 +1,91 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Generic two-pane layout: scrollable **tree / outline** column + **details** column,
5
+ * with persisted split sizes (`ResizablePanelGroup` `id`) and shared **split hub chrome**
6
+ * (`ListPageSplitHubChrome`) so tree views match finder / folder panels across the app.
7
+ *
8
+ * Domain hubs pass `tree` and `details` nodes; this module stays entity-agnostic.
9
+ */
10
+
11
+ import * as React from "react"
12
+ import {
13
+ ResizableHandle,
14
+ ResizablePanel,
15
+ ResizablePanelGroup,
16
+ } from "@/components/ui/resizable"
17
+ import { ListPageSplitHubChrome } from "@/components/data-views/list-page-split-hub-chrome"
18
+ import {
19
+ LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS,
20
+ LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS,
21
+ LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS,
22
+ } from "@/components/data-views/list-page-split-hub-tokens"
23
+ import {
24
+ LIST_PAGE_VIEW_FRAME_GUTTER,
25
+ LIST_PAGE_VIEW_FRAME_MAX_WIDE,
26
+ } from "@/components/data-views/list-page-view-frame"
27
+
28
+ export interface ListPageTreePanelShellProps {
29
+ /** Stable id for `react-resizable-panels` layout persistence (per hub / route). */
30
+ resizableGroupId: string
31
+ /** Left column (tree chrome + body). */
32
+ tree: React.ReactNode
33
+ /** Right column (detail / inspector). */
34
+ details: React.ReactNode
35
+ /** Accessible name for the split surface, e.g. “Curriculum tree and details”. */
36
+ ariaLabel: string
37
+ treePanelId?: string
38
+ detailsPanelId?: string
39
+ treeDefaultSize?: string
40
+ treeMinSize?: string
41
+ treeMaxSize?: string
42
+ detailsDefaultSize?: string
43
+ detailsMinSize?: string
44
+ gutterClassName?: string
45
+ maxWidthClassName?: string
46
+ }
47
+
48
+ export function ListPageTreePanelShell({
49
+ resizableGroupId,
50
+ tree,
51
+ details,
52
+ ariaLabel,
53
+ treePanelId = "tree",
54
+ detailsPanelId = "details",
55
+ treeDefaultSize = "40%",
56
+ treeMinSize = "20%",
57
+ treeMaxSize = "60%",
58
+ detailsDefaultSize = "60%",
59
+ detailsMinSize = "30%",
60
+ gutterClassName = LIST_PAGE_VIEW_FRAME_GUTTER,
61
+ maxWidthClassName = LIST_PAGE_VIEW_FRAME_MAX_WIDE,
62
+ }: ListPageTreePanelShellProps) {
63
+ return (
64
+ <ListPageSplitHubChrome
65
+ aria-label={ariaLabel}
66
+ gutterClassName={gutterClassName}
67
+ maxWidthClassName={maxWidthClassName}
68
+ >
69
+ <ResizablePanelGroup id={resizableGroupId} direction="horizontal" className="h-full min-h-0 w-full flex-1">
70
+ <ResizablePanel
71
+ id={treePanelId}
72
+ defaultSize={treeDefaultSize}
73
+ minSize={treeMinSize}
74
+ maxSize={treeMaxSize}
75
+ className={LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS}
76
+ >
77
+ {tree}
78
+ </ResizablePanel>
79
+ <ResizableHandle withHandle className={LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS} />
80
+ <ResizablePanel
81
+ id={detailsPanelId}
82
+ defaultSize={detailsDefaultSize}
83
+ minSize={detailsMinSize}
84
+ className={LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS}
85
+ >
86
+ {details}
87
+ </ResizablePanel>
88
+ </ResizablePanelGroup>
89
+ </ListPageSplitHubChrome>
90
+ )
91
+ }
@@ -0,0 +1,53 @@
1
+ "use client"
2
+
3
+ /**
4
+ * ListPageViewFrame — shared horizontal gutter + optional centered max-width for list-hub **view bodies**
5
+ * (folder icon grid, finder panel chrome, OS-style folder explorer, dashboard slices, etc.).
6
+ *
7
+ * **MUST** be used instead of ad-hoc `mx-4 lg:mx-6` + `mx-auto max-w-*` pairs on each page — see
8
+ * `AGENTS.md` §4.5 and `.cursor/rules/exxat-list-page-view-shells.mdc`.
9
+ *
10
+ * **MUST NOT** wrap `DataTable` when its toolbar already applies the same inset (avoid double gutter);
11
+ * use this for **non-table** view branches or **sections below** the shared toolbar.
12
+ */
13
+
14
+ import * as React from "react"
15
+ import { cn } from "@/lib/utils"
16
+
17
+ /** Default horizontal rhythm for view bodies under `ListPageTemplate` (matches `FolderGridView`). */
18
+ export const LIST_PAGE_VIEW_FRAME_GUTTER = "mx-4 mb-6 lg:mx-6"
19
+
20
+ /** Typical max width for icon grids / dense tile views on ultra-wide monitors. */
21
+ export const LIST_PAGE_VIEW_FRAME_MAX_ICON_GRID = "max-w-6xl"
22
+
23
+ /** Slightly wider shell when a view includes toolbar + breadcrumbs + grid (e.g. OS folder explorer). */
24
+ export const LIST_PAGE_VIEW_FRAME_MAX_WIDE = "max-w-7xl"
25
+
26
+ export interface ListPageViewFrameProps extends React.HTMLAttributes<HTMLDivElement> {
27
+ children: React.ReactNode
28
+ /**
29
+ * When set, children are wrapped in `mx-auto w-full min-w-0` + this max-width so the block stays
30
+ * centered inside the primary page column.
31
+ */
32
+ maxWidthClassName?: string
33
+ /** Override outer gutter; default `LIST_PAGE_VIEW_FRAME_GUTTER`. */
34
+ gutterClassName?: string
35
+ }
36
+
37
+ export function ListPageViewFrame({
38
+ children,
39
+ className,
40
+ maxWidthClassName,
41
+ gutterClassName = LIST_PAGE_VIEW_FRAME_GUTTER,
42
+ ...rest
43
+ }: ListPageViewFrameProps) {
44
+ return (
45
+ <div className={cn(gutterClassName, className)} {...rest}>
46
+ {maxWidthClassName ? (
47
+ <div className={cn("mx-auto w-full min-w-0", maxWidthClassName)}>{children}</div>
48
+ ) : (
49
+ children
50
+ )}
51
+ </div>
52
+ )
53
+ }
@@ -0,0 +1,121 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Windows 11–style folder art (Icons8) + optional FA glyph on the pocket.
5
+ * Static asset: `public/folders/icons8-folder-windows-11.svg`
6
+ */
7
+
8
+ import * as React from "react"
9
+ import { cn } from "@/lib/utils"
10
+ import type { QuestionBankFolderColorKey } from "@/lib/mock/question-bank-folders"
11
+
12
+ /** Served from `apps/web/public/folders/` (copied from Icons8 “folder windows 11 color”). */
13
+ export const OS_FOLDER_GLYPH_SRC = "/folders/icons8-folder-windows-11.svg"
14
+
15
+ /** Subtle hue tweak so “color” choice still reads on the shared yellow asset. */
16
+ const COLOR_TINT_FILTER: Record<QuestionBankFolderColorKey, string> = {
17
+ brand: "hue-rotate(-42deg) saturate(1.25) brightness(0.97)",
18
+ success: "hue-rotate(82deg) saturate(1.2) brightness(0.95)",
19
+ warning: "hue-rotate(-5deg) saturate(1.35) brightness(1.02)",
20
+ destructive: "hue-rotate(300deg) saturate(1.15) brightness(0.92)",
21
+ muted: "saturate(0.15) brightness(1.08)",
22
+ chart1: "hue-rotate(200deg) saturate(1.2) brightness(0.96)",
23
+ chart2: "hue-rotate(95deg) saturate(1.15) brightness(0.96)",
24
+ chart3: "hue-rotate(265deg) saturate(1.2) brightness(0.96)",
25
+ }
26
+
27
+ const SIZE_MAP = {
28
+ /** Compact — folder inspector / column headers (matches ~36px row height). */
29
+ xs: "h-9 w-[2.6rem]",
30
+ sm: "h-[3.35rem] w-[3.85rem]",
31
+ md: "h-[4.6rem] w-[5.25rem]",
32
+ lg: "h-[6.5rem] w-[7.25rem]",
33
+ }
34
+
35
+ const ICON_TEXT: Record<keyof typeof SIZE_MAP, string> = {
36
+ xs: "text-[13px] leading-none",
37
+ sm: "text-lg",
38
+ md: "text-2xl",
39
+ lg: "text-4xl",
40
+ }
41
+
42
+ /** Darker version of each folder color for punched icon appearance. */
43
+ const ICON_COLOR: Record<QuestionBankFolderColorKey, string> = {
44
+ brand: "text-orange-800 dark:text-orange-600",
45
+ success: "text-emerald-800 dark:text-emerald-600",
46
+ warning: "text-amber-800 dark:text-amber-600",
47
+ destructive: "text-red-800 dark:text-red-600",
48
+ muted: "text-slate-600 dark:text-slate-400",
49
+ chart1: "text-blue-800 dark:text-blue-600",
50
+ chart2: "text-lime-800 dark:text-lime-600",
51
+ chart3: "text-purple-800 dark:text-purple-600",
52
+ }
53
+
54
+ export interface OsFolderGlyphProps {
55
+ colorKey: QuestionBankFolderColorKey
56
+ /** Font Awesome icon classes without weight (e.g. `fa-stethoscope`). */
57
+ icon: string
58
+ size?: keyof typeof SIZE_MAP
59
+ className?: string
60
+ variant?: "solid" | "outline"
61
+ /**
62
+ * When false, exposes `role="img"` + `aria-label` (use with a short label, e.g. sheet preview).
63
+ * When true (default), hides the glyph from AT — parent control should name the action.
64
+ */
65
+ decorative?: boolean
66
+ /** Required when `decorative={false}` */
67
+ label?: string
68
+ }
69
+
70
+ export function OsFolderGlyph({
71
+ colorKey,
72
+ icon,
73
+ size = "md",
74
+ className,
75
+ variant = "solid",
76
+ decorative = true,
77
+ label,
78
+ }: OsFolderGlyphProps) {
79
+ const outline = variant === "outline"
80
+ const tint = COLOR_TINT_FILTER[colorKey]
81
+
82
+ return (
83
+ <div
84
+ className={cn(
85
+ "group relative shrink-0 select-none transition-[transform,box-shadow] duration-200 ease-out",
86
+ "hover:z-[1] hover:scale-105 motion-reduce:transform-none motion-reduce:hover:scale-100",
87
+ SIZE_MAP[size],
88
+ className,
89
+ )}
90
+ role={!decorative && label ? "img" : undefined}
91
+ aria-label={!decorative ? label : undefined}
92
+ aria-hidden={decorative ? true : undefined}
93
+ >
94
+ <img
95
+ src={OS_FOLDER_GLYPH_SRC}
96
+ alt=""
97
+ width={240}
98
+ height={240}
99
+ draggable={false}
100
+ className={cn(
101
+ "h-full w-full object-contain",
102
+ "transition-[filter] duration-200",
103
+ outline && "opacity-75 saturate-[0.65]",
104
+ )}
105
+ style={outline ? undefined : { filter: tint }}
106
+ />
107
+ <span
108
+ className={cn(
109
+ "pointer-events-none absolute inset-0 flex items-center justify-center",
110
+ size === "xs" ? "translate-y-[0.18rem]" : "translate-y-[0.35rem]",
111
+ ICON_TEXT[size],
112
+ outline
113
+ ? "text-muted-foreground"
114
+ : cn(ICON_COLOR[colorKey], "opacity-100"),
115
+ )}
116
+ >
117
+ <i className={cn("fa-solid", icon)} aria-hidden="true" />
118
+ </span>
119
+ </div>
120
+ )
121
+ }
@@ -0,0 +1,230 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Folder details panel — OsFolderGlyph header + aggregates (`question-bank-inspector` helpers today).
5
+ * Reusable across list hubs that share `QuestionBankFolder` / `QuestionBankItem` shapes or adapters.
6
+ */
7
+
8
+ import * as React from "react"
9
+ import { ChevronRightIcon, X } from "lucide-react"
10
+ import { OsFolderGlyph } from "@/components/data-views/os-folder-glyph"
11
+ import { Button } from "@/components/ui/button"
12
+ import { Separator } from "@/components/ui/separator"
13
+ import { Tip } from "@/components/ui/tip"
14
+ import { cn } from "@/lib/utils"
15
+ import type { QuestionBankItem } from "@/lib/mock/question-bank"
16
+ import type { QuestionBankFolder } from "@/lib/mock/question-bank-folders"
17
+ import {
18
+ aggregateFolderQuestions,
19
+ BLOOM_LEVEL_ORDER,
20
+ questionsInFolderSubtree,
21
+ } from "@/lib/mock/question-bank-inspector"
22
+
23
+ function DetailBreadcrumbNav({ segments }: { segments: { id: string; label: string }[] }) {
24
+ if (segments.length === 0) return null
25
+ return (
26
+ <nav aria-label="Path in folder tree" className="flex min-w-0 flex-wrap items-center gap-1 text-xs text-muted-foreground">
27
+ {segments.map((seg, idx) => (
28
+ <React.Fragment key={seg.id}>
29
+ {idx > 0 && <ChevronRightIcon className="size-3 shrink-0 opacity-40" aria-hidden />}
30
+ <span
31
+ className={cn(
32
+ "min-w-0 truncate",
33
+ idx === segments.length - 1 ? "font-medium text-foreground" : "text-muted-foreground",
34
+ )}
35
+ >
36
+ {seg.label}
37
+ </span>
38
+ </React.Fragment>
39
+ ))}
40
+ </nav>
41
+ )
42
+ }
43
+
44
+ function DetailSection({
45
+ title,
46
+ children,
47
+ className,
48
+ }: {
49
+ title: string
50
+ children: React.ReactNode
51
+ className?: string
52
+ }) {
53
+ return (
54
+ <section className={cn("min-w-0", className)}>
55
+ <h3 className="mb-2 text-xs font-medium text-muted-foreground">{title}</h3>
56
+ {children}
57
+ </section>
58
+ )
59
+ }
60
+
61
+ function InspectorSectionTitle({ children, id }: { children: React.ReactNode; id?: string }) {
62
+ return (
63
+ <p
64
+ id={id}
65
+ className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"
66
+ >
67
+ {children}
68
+ </p>
69
+ )
70
+ }
71
+
72
+ export interface FolderDetailsShellProps {
73
+ folder: QuestionBankFolder
74
+ folders: QuestionBankFolder[]
75
+ questions: QuestionBankItem[]
76
+ /** Clears selection (tree inspector dismiss). */
77
+ onClearSelection?: () => void
78
+ }
79
+
80
+ export function FolderDetailsShell({
81
+ folder,
82
+ folders,
83
+ questions,
84
+ onClearSelection,
85
+ }: FolderDetailsShellProps) {
86
+ const subtreeQuestions = questionsInFolderSubtree(folders, questions, folder.id)
87
+ const agg = aggregateFolderQuestions(subtreeQuestions)
88
+ const { totalQuestions, difficulty: diffAgg, bloom, avgPbi, scoredCount } = agg
89
+ const diffSum = diffAgg.easy + diffAgg.medium + diffAgg.hard
90
+ const maxBloomCount = Math.max(1, ...BLOOM_LEVEL_ORDER.map(level => bloom[level] ?? 0))
91
+
92
+ const breadcrumbs: QuestionBankFolder[] = []
93
+ let cur: QuestionBankFolder | undefined = folder
94
+ while (cur) {
95
+ breadcrumbs.unshift(cur)
96
+ cur = folders.find(f => f.id === cur?.parentId)
97
+ }
98
+ const pathSegments = breadcrumbs.map(f => ({ id: f.id, label: f.name }))
99
+
100
+ const diffHeadingId = `folder-details-difficulty-${folder.id}`
101
+ const bloomHeadingId = `folder-details-bloom-${folder.id}`
102
+
103
+ return (
104
+ <div className="flex h-full min-h-0 flex-col overflow-hidden bg-card">
105
+ <header className="shrink-0 border-b border-border/60 bg-muted/10 px-4 pb-4 pt-4">
106
+ <div className="flex items-start justify-between gap-3">
107
+ <div className="flex min-w-0 flex-1 items-start gap-3">
108
+ <OsFolderGlyph
109
+ colorKey={folder.colorKey}
110
+ icon={folder.icon}
111
+ size="xs"
112
+ className="shrink-0"
113
+ />
114
+ <div className="min-w-0 flex-1">
115
+ <h2 className="text-base font-semibold leading-tight tracking-tight text-foreground">
116
+ <span className="truncate">{folder.name}</span>
117
+ <span className="font-normal text-muted-foreground"> · Question Bank</span>
118
+ </h2>
119
+ <p className="mt-1 text-sm font-semibold tabular-nums text-foreground">
120
+ {totalQuestions} question{totalQuestions !== 1 ? "s" : ""}
121
+ </p>
122
+ </div>
123
+ </div>
124
+ {onClearSelection ? (
125
+ <Tip label="Close details" side="bottom">
126
+ <Button
127
+ type="button"
128
+ variant="ghost"
129
+ size="icon-sm"
130
+ className="shrink-0 text-muted-foreground hover:text-foreground"
131
+ onClick={onClearSelection}
132
+ aria-label="Close details"
133
+ >
134
+ <X className="size-4" aria-hidden />
135
+ </Button>
136
+ </Tip>
137
+ ) : null}
138
+ </div>
139
+ </header>
140
+
141
+ <div className="flex min-h-0 flex-1 flex-col gap-0 overflow-y-auto">
142
+ <div className="space-y-5 px-4 py-4">
143
+ <section aria-labelledby={diffHeadingId}>
144
+ <InspectorSectionTitle id={diffHeadingId}>Difficulty</InspectorSectionTitle>
145
+ <div
146
+ className="flex h-2 w-full overflow-hidden rounded-full bg-muted"
147
+ role="img"
148
+ aria-label={`Difficulty mix: ${diffAgg.easy} easy, ${diffAgg.medium} medium, ${diffAgg.hard} hard of ${diffSum || 0} questions`}
149
+ >
150
+ {diffSum === 0 ? (
151
+ <div className="h-full w-full bg-muted" />
152
+ ) : (
153
+ <>
154
+ <div
155
+ className="h-full bg-emerald-400/85 dark:bg-emerald-500/70"
156
+ style={{ width: `${(diffAgg.easy / diffSum) * 100}%` }}
157
+ />
158
+ <div
159
+ className="h-full bg-amber-400/90 dark:bg-amber-500/75"
160
+ style={{ width: `${(diffAgg.medium / diffSum) * 100}%` }}
161
+ />
162
+ <div className="h-full bg-slate-500 dark:bg-slate-600" style={{ width: `${(diffAgg.hard / diffSum) * 100}%` }} />
163
+ </>
164
+ )}
165
+ </div>
166
+ <div className="mt-2 flex flex-wrap gap-x-4 gap-y-1.5 text-xs text-foreground">
167
+ <span className="inline-flex items-center gap-1.5">
168
+ <span className="size-2 shrink-0 rounded-sm bg-emerald-400/85 dark:bg-emerald-500/70" aria-hidden />
169
+ Easy: <span className="tabular-nums font-medium">{diffAgg.easy}</span>
170
+ </span>
171
+ <span className="inline-flex items-center gap-1.5">
172
+ <span className="size-2 shrink-0 rounded-sm bg-amber-400/90 dark:bg-amber-500/75" aria-hidden />
173
+ Medium: <span className="tabular-nums font-medium">{diffAgg.medium}</span>
174
+ </span>
175
+ <span className="inline-flex items-center gap-1.5">
176
+ <span className="size-2 shrink-0 rounded-sm bg-slate-500 dark:bg-slate-600" aria-hidden />
177
+ Hard: <span className="tabular-nums font-medium">{diffAgg.hard}</span>
178
+ </span>
179
+ </div>
180
+ </section>
181
+
182
+ <Separator className="bg-border/60" />
183
+
184
+ <section aria-labelledby={bloomHeadingId}>
185
+ <InspectorSectionTitle id={bloomHeadingId}>Bloom&apos;s</InspectorSectionTitle>
186
+ <ul className="flex flex-col gap-2.5" aria-label="Bloom taxonomy counts">
187
+ {BLOOM_LEVEL_ORDER.map(level => {
188
+ const count = bloom[level] ?? 0
189
+ const barPct = maxBloomCount ? (count / maxBloomCount) * 100 : 0
190
+ return (
191
+ <li key={level} className="grid grid-cols-[minmax(5.5rem,auto)_1fr_auto] items-center gap-2 text-sm">
192
+ <span className="text-muted-foreground">{level}</span>
193
+ <div className="min-w-0">
194
+ <div className="h-2 overflow-hidden rounded-full bg-muted">
195
+ <div
196
+ className="h-full rounded-full bg-pink-500/75 dark:bg-pink-500/65"
197
+ style={{ width: `${barPct}%` }}
198
+ />
199
+ </div>
200
+ </div>
201
+ <span className="w-7 shrink-0 text-right tabular-nums font-medium text-foreground">{count}</span>
202
+ </li>
203
+ )
204
+ })}
205
+ </ul>
206
+ </section>
207
+
208
+ <Separator className="bg-border/60" />
209
+
210
+ <p className="text-xs text-muted-foreground">
211
+ Avg. pBIS:{" "}
212
+ <span className="font-semibold text-foreground">{avgPbi != null ? avgPbi.toFixed(2) : "—"}</span>{" "}
213
+ <span className="tabular-nums">
214
+ ({scoredCount} of {totalQuestions} scored)
215
+ </span>
216
+ </p>
217
+
218
+ {pathSegments.length > 0 ? (
219
+ <>
220
+ <Separator className="bg-border/60" />
221
+ <DetailSection title="Location">
222
+ <DetailBreadcrumbNav segments={pathSegments} />
223
+ </DetailSection>
224
+ </>
225
+ ) : null}
226
+ </div>
227
+ </div>
228
+ </div>
229
+ )
230
+ }