& {
separator?: React.ReactNode
}
->(({ ...props }, ref) =>
)
+>(({ separator: _separator, ...props }, ref) =>
)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
@@ -94,15 +94,14 @@ const BreadcrumbEllipsis = ({
}: React.ComponentProps<"span">) => (
-
+
More
)
-BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
+BreadcrumbEllipsis.displayName = "BreadcrumbEllipsis"
export {
Breadcrumb,
diff --git a/src/UILayer/web/components/ui/button.tsx b/src/UILayer/web/src/components/ui/button.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/button.tsx
rename to src/UILayer/web/src/components/ui/button.tsx
diff --git a/src/UILayer/web/components/ui/calendar.tsx b/src/UILayer/web/src/components/ui/calendar.tsx
similarity index 50%
rename from src/UILayer/web/components/ui/calendar.tsx
rename to src/UILayer/web/src/components/ui/calendar.tsx
index ec7f274a..235d394f 100644
--- a/src/UILayer/web/components/ui/calendar.tsx
+++ b/src/UILayer/web/src/components/ui/calendar.tsx
@@ -20,42 +20,48 @@ function Calendar({
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
- months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
- month: "space-y-4",
- caption: "flex justify-center pt-1 relative items-center",
+ months: "flex flex-col sm:flex-row gap-2",
+ month: "flex flex-col gap-4",
+ month_caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
- nav: "space-x-1 flex items-center",
- nav_button: cn(
+ nav: "flex items-center gap-1",
+ button_previous: cn(
buttonVariants({ variant: "outline" }),
- "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
+ "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-1"
),
- nav_button_previous: "absolute left-1",
- nav_button_next: "absolute right-1",
- table: "w-full border-collapse space-y-1",
- head_row: "flex",
- head_cell:
+ button_next: cn(
+ buttonVariants({ variant: "outline" }),
+ "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-1"
+ ),
+ month_grid: "w-full border-collapse space-y-1",
+ weekdays: "flex",
+ weekday:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
- row: "flex w-full mt-2",
- cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
- day: cn(
+ week: "flex w-full mt-2",
+ day: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
+ day_button: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
- day_range_end: "day-range-end",
- day_selected:
+ range_end: "day-range-end",
+ selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
- day_today: "bg-accent text-accent-foreground",
- day_outside:
+ today: "bg-accent text-accent-foreground",
+ outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
- day_disabled: "text-muted-foreground opacity-50",
- day_range_middle:
+ disabled: "text-muted-foreground opacity-50",
+ range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
- day_hidden: "invisible",
+ hidden: "invisible",
...classNames,
}}
components={{
- IconLeft: () =>
,
- IconRight: () =>
,
+ Chevron: ({ orientation }) =>
+ orientation === "left" ? (
+
+ ) : (
+
+ ),
}}
{...props}
/>
diff --git a/src/UILayer/web/components/ui/card.tsx b/src/UILayer/web/src/components/ui/card.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/card.tsx
rename to src/UILayer/web/src/components/ui/card.tsx
diff --git a/src/UILayer/web/components/ui/carousel.tsx b/src/UILayer/web/src/components/ui/carousel.tsx
similarity index 94%
rename from src/UILayer/web/components/ui/carousel.tsx
rename to src/UILayer/web/src/components/ui/carousel.tsx
index ec505d00..45ce1915 100644
--- a/src/UILayer/web/components/ui/carousel.tsx
+++ b/src/UILayer/web/src/components/ui/carousel.tsx
@@ -87,15 +87,18 @@ const Carousel = React.forwardRef<
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent
) => {
- if (event.key === "ArrowLeft") {
+ const prevKey = orientation === "horizontal" ? "ArrowLeft" : "ArrowUp"
+ const nextKey = orientation === "horizontal" ? "ArrowRight" : "ArrowDown"
+
+ if (event.key === prevKey) {
event.preventDefault()
scrollPrev()
- } else if (event.key === "ArrowRight") {
+ } else if (event.key === nextKey) {
event.preventDefault()
scrollNext()
}
},
- [scrollPrev, scrollNext]
+ [orientation, scrollPrev, scrollNext]
)
React.useEffect(() => {
@@ -117,6 +120,7 @@ const Carousel = React.forwardRef<
return () => {
api?.off("select", onSelect)
+ api?.off("reInit", onSelect)
}
}, [api, onSelect])
diff --git a/src/UILayer/web/components/ui/chart.tsx b/src/UILayer/web/src/components/ui/chart.tsx
similarity index 84%
rename from src/UILayer/web/components/ui/chart.tsx
rename to src/UILayer/web/src/components/ui/chart.tsx
index 32dc873f..c9729264 100644
--- a/src/UILayer/web/components/ui/chart.tsx
+++ b/src/UILayer/web/src/components/ui/chart.tsx
@@ -67,6 +67,10 @@ const ChartContainer = React.forwardRef<
})
ChartContainer.displayName = "Chart"
+function sanitizeCssToken(str: string): string {
+ return str.replace(/[<>"'&;{}\\]/g, "")
+}
+
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
@@ -88,7 +92,7 @@ ${colorConfig
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
- return color ? ` --color-${key}: ${color};` : null
+ return color ? ` --color-${sanitizeCssToken(key)}: ${sanitizeCssToken(color)};` : null
})
.join("\n")}
}
@@ -104,14 +108,26 @@ const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
- React.ComponentProps &
- React.ComponentProps<"div"> & {
- hideLabel?: boolean
- hideIndicator?: boolean
- indicator?: "line" | "dot" | "dashed"
- nameKey?: string
- labelKey?: string
- }
+ React.ComponentProps<"div"> & {
+ active?: boolean
+ payload?: Array>
+ label?: string
+ labelFormatter?: (value: unknown, payload: Array>) => React.ReactNode
+ formatter?: (
+ value: unknown,
+ name: string,
+ item: Record,
+ index: number,
+ payload: unknown
+ ) => React.ReactNode
+ color?: string
+ hideLabel?: boolean
+ hideIndicator?: boolean
+ indicator?: "line" | "dot" | "dashed"
+ nameKey?: string
+ labelKey?: string
+ labelClassName?: string
+ }
>(
(
{
@@ -185,21 +201,22 @@ const ChartTooltipContent = React.forwardRef<
>
{!nestLabel ? tooltipLabel : null}
- {payload.map((item, index) => {
+ {payload.map((item: Record
, index: number) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
- const indicatorColor = color || item.payload.fill || item.color
+ const itemPayload = item.payload as Record | undefined
+ const indicatorColor = color || (itemPayload?.fill as string) || (item.color as string)
return (
svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
- formatter(item.value, item.name, item, index, item.payload)
+ formatter(item.value, item.name as string, item, index, itemPayload)
) : (
<>
{itemConfig?.icon ? (
@@ -235,12 +252,12 @@ const ChartTooltipContent = React.forwardRef<
{nestLabel ? tooltipLabel : null}
- {itemConfig?.label || item.name}
+ {itemConfig?.label || (item.name as string)}
- {item.value && (
+ {item.value != null && (
- {item.value.toLocaleString()}
+ {String(item.value)}
)}
@@ -260,11 +277,12 @@ const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
- React.ComponentProps<"div"> &
- Pick & {
- hideIcon?: boolean
- nameKey?: string
- }
+ React.ComponentProps<"div"> & {
+ payload?: Array>
+ verticalAlign?: "top" | "bottom"
+ hideIcon?: boolean
+ nameKey?: string
+ }
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
@@ -285,13 +303,13 @@ const ChartLegendContent = React.forwardRef<
className
)}
>
- {payload.map((item) => {
+ {payload.map((item: Record, index: number) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
@@ -302,7 +320,7 @@ const ChartLegendContent = React.forwardRef<
)}
diff --git a/src/UILayer/web/components/ui/checkbox.tsx b/src/UILayer/web/src/components/ui/checkbox.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/checkbox.tsx
rename to src/UILayer/web/src/components/ui/checkbox.tsx
diff --git a/src/UILayer/web/components/ui/collapsible.tsx b/src/UILayer/web/src/components/ui/collapsible.tsx
similarity index 59%
rename from src/UILayer/web/components/ui/collapsible.tsx
rename to src/UILayer/web/src/components/ui/collapsible.tsx
index 9fa48946..92d0a4a6 100644
--- a/src/UILayer/web/components/ui/collapsible.tsx
+++ b/src/UILayer/web/src/components/ui/collapsible.tsx
@@ -4,8 +4,8 @@ import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
-const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
+const CollapsibleTrigger = CollapsiblePrimitive.Trigger
-const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
+const CollapsibleContent = CollapsiblePrimitive.Content
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
diff --git a/src/UILayer/web/components/ui/command.tsx b/src/UILayer/web/src/components/ui/command.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/command.tsx
rename to src/UILayer/web/src/components/ui/command.tsx
diff --git a/src/UILayer/web/components/ui/context-menu.tsx b/src/UILayer/web/src/components/ui/context-menu.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/context-menu.tsx
rename to src/UILayer/web/src/components/ui/context-menu.tsx
diff --git a/src/UILayer/web/components/ui/dialog.tsx b/src/UILayer/web/src/components/ui/dialog.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/dialog.tsx
rename to src/UILayer/web/src/components/ui/dialog.tsx
diff --git a/src/UILayer/web/components/ui/drawer.tsx b/src/UILayer/web/src/components/ui/drawer.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/drawer.tsx
rename to src/UILayer/web/src/components/ui/drawer.tsx
diff --git a/src/UILayer/web/components/ui/dropdown-menu.tsx b/src/UILayer/web/src/components/ui/dropdown-menu.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/dropdown-menu.tsx
rename to src/UILayer/web/src/components/ui/dropdown-menu.tsx
diff --git a/src/UILayer/web/components/ui/form.tsx b/src/UILayer/web/src/components/ui/form.tsx
similarity index 97%
rename from src/UILayer/web/components/ui/form.tsx
rename to src/UILayer/web/src/components/ui/form.tsx
index ce264aef..a63e1989 100644
--- a/src/UILayer/web/components/ui/form.tsx
+++ b/src/UILayer/web/src/components/ui/form.tsx
@@ -44,14 +44,14 @@ const FormField = <
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
- const { getFieldState, formState } = useFormContext()
-
- const fieldState = getFieldState(fieldContext.name, formState)
- if (!fieldContext) {
+ if (!fieldContext.name) {
throw new Error("useFormField should be used within
")
}
+ const { getFieldState, formState } = useFormContext()
+ const fieldState = getFieldState(fieldContext.name, formState)
+
const { id } = itemContext
return {
@@ -147,7 +147,7 @@ const FormMessage = React.forwardRef<
React.HTMLAttributes
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
- const body = error ? String(error?.message) : children
+ const body = error ? (error.message ?? "") : children
if (!body) {
return null
diff --git a/src/UILayer/web/components/ui/hover-card.tsx b/src/UILayer/web/src/components/ui/hover-card.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/hover-card.tsx
rename to src/UILayer/web/src/components/ui/hover-card.tsx
diff --git a/src/UILayer/web/components/ui/input-otp.tsx b/src/UILayer/web/src/components/ui/input-otp.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/input-otp.tsx
rename to src/UILayer/web/src/components/ui/input-otp.tsx
diff --git a/src/UILayer/web/components/ui/input.tsx b/src/UILayer/web/src/components/ui/input.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/input.tsx
rename to src/UILayer/web/src/components/ui/input.tsx
diff --git a/src/UILayer/web/components/ui/label.tsx b/src/UILayer/web/src/components/ui/label.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/label.tsx
rename to src/UILayer/web/src/components/ui/label.tsx
diff --git a/src/UILayer/web/components/ui/menubar.tsx b/src/UILayer/web/src/components/ui/menubar.tsx
similarity index 99%
rename from src/UILayer/web/components/ui/menubar.tsx
rename to src/UILayer/web/src/components/ui/menubar.tsx
index 5586fa9b..5b18e578 100644
--- a/src/UILayer/web/components/ui/menubar.tsx
+++ b/src/UILayer/web/src/components/ui/menubar.tsx
@@ -214,7 +214,7 @@ const MenubarShortcut = ({
/>
)
}
-MenubarShortcut.displayname = "MenubarShortcut"
+MenubarShortcut.displayName = "MenubarShortcut"
export {
Menubar,
diff --git a/src/UILayer/web/components/ui/navigation-menu.tsx b/src/UILayer/web/src/components/ui/navigation-menu.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/navigation-menu.tsx
rename to src/UILayer/web/src/components/ui/navigation-menu.tsx
diff --git a/src/UILayer/web/components/ui/pagination.tsx b/src/UILayer/web/src/components/ui/pagination.tsx
similarity index 97%
rename from src/UILayer/web/components/ui/pagination.tsx
rename to src/UILayer/web/src/components/ui/pagination.tsx
index ea40d196..41503bfa 100644
--- a/src/UILayer/web/components/ui/pagination.tsx
+++ b/src/UILayer/web/src/components/ui/pagination.tsx
@@ -96,11 +96,10 @@ const PaginationEllipsis = ({
...props
}: React.ComponentProps<"span">) => (
-
+
More pages
)
diff --git a/src/UILayer/web/components/ui/popover.tsx b/src/UILayer/web/src/components/ui/popover.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/popover.tsx
rename to src/UILayer/web/src/components/ui/popover.tsx
diff --git a/src/UILayer/web/components/ui/progress.tsx b/src/UILayer/web/src/components/ui/progress.tsx
similarity index 97%
rename from src/UILayer/web/components/ui/progress.tsx
rename to src/UILayer/web/src/components/ui/progress.tsx
index 5c87ea48..7344a143 100644
--- a/src/UILayer/web/components/ui/progress.tsx
+++ b/src/UILayer/web/src/components/ui/progress.tsx
@@ -11,6 +11,7 @@ const Progress = React.forwardRef<
>(({ className, value, ...props }, ref) => (
) => (
- ) => (
+ & {
+}: React.ComponentProps & {
withHandle?: boolean
}) => (
- div]:rotate-90",
className
@@ -39,7 +40,7 @@ const ResizableHandle = ({
)}
-
+
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
diff --git a/src/UILayer/web/components/ui/scroll-area.tsx b/src/UILayer/web/src/components/ui/scroll-area.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/scroll-area.tsx
rename to src/UILayer/web/src/components/ui/scroll-area.tsx
diff --git a/src/UILayer/web/components/ui/select.tsx b/src/UILayer/web/src/components/ui/select.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/select.tsx
rename to src/UILayer/web/src/components/ui/select.tsx
diff --git a/src/UILayer/web/components/ui/separator.tsx b/src/UILayer/web/src/components/ui/separator.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/separator.tsx
rename to src/UILayer/web/src/components/ui/separator.tsx
diff --git a/src/UILayer/web/components/ui/sheet.tsx b/src/UILayer/web/src/components/ui/sheet.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/sheet.tsx
rename to src/UILayer/web/src/components/ui/sheet.tsx
diff --git a/src/UILayer/web/components/ui/sidebar.tsx b/src/UILayer/web/src/components/ui/sidebar.tsx
similarity index 99%
rename from src/UILayer/web/components/ui/sidebar.tsx
rename to src/UILayer/web/src/components/ui/sidebar.tsx
index eeb2d7ae..152d2d2e 100644
--- a/src/UILayer/web/components/ui/sidebar.tsx
+++ b/src/UILayer/web/src/components/ui/sidebar.tsx
@@ -315,7 +315,7 @@ const SidebarRail = React.forwardRef<
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef<
- HTMLDivElement,
+ HTMLElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
@@ -439,7 +439,7 @@ const SidebarGroupLabel = React.forwardRef<
ref={ref}
data-sidebar="group-label"
className={cn(
- "duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
+ "duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
diff --git a/src/UILayer/web/components/ui/skeleton.tsx b/src/UILayer/web/src/components/ui/skeleton.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/skeleton.tsx
rename to src/UILayer/web/src/components/ui/skeleton.tsx
diff --git a/src/UILayer/web/components/ui/slider.tsx b/src/UILayer/web/src/components/ui/slider.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/slider.tsx
rename to src/UILayer/web/src/components/ui/slider.tsx
diff --git a/src/UILayer/web/components/ui/sonner.tsx b/src/UILayer/web/src/components/ui/sonner.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/sonner.tsx
rename to src/UILayer/web/src/components/ui/sonner.tsx
diff --git a/src/UILayer/web/components/ui/switch.tsx b/src/UILayer/web/src/components/ui/switch.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/switch.tsx
rename to src/UILayer/web/src/components/ui/switch.tsx
diff --git a/src/UILayer/web/components/ui/table.tsx b/src/UILayer/web/src/components/ui/table.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/table.tsx
rename to src/UILayer/web/src/components/ui/table.tsx
diff --git a/src/UILayer/web/components/ui/tabs.tsx b/src/UILayer/web/src/components/ui/tabs.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/tabs.tsx
rename to src/UILayer/web/src/components/ui/tabs.tsx
diff --git a/src/UILayer/web/components/ui/textarea.tsx b/src/UILayer/web/src/components/ui/textarea.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/textarea.tsx
rename to src/UILayer/web/src/components/ui/textarea.tsx
diff --git a/src/UILayer/web/components/ui/toast.tsx b/src/UILayer/web/src/components/ui/toast.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/toast.tsx
rename to src/UILayer/web/src/components/ui/toast.tsx
diff --git a/src/UILayer/web/components/ui/toaster.tsx b/src/UILayer/web/src/components/ui/toaster.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/toaster.tsx
rename to src/UILayer/web/src/components/ui/toaster.tsx
diff --git a/src/UILayer/web/components/ui/toggle-group.tsx b/src/UILayer/web/src/components/ui/toggle-group.tsx
similarity index 95%
rename from src/UILayer/web/components/ui/toggle-group.tsx
rename to src/UILayer/web/src/components/ui/toggle-group.tsx
index 1c876bbe..0141114b 100644
--- a/src/UILayer/web/components/ui/toggle-group.tsx
+++ b/src/UILayer/web/src/components/ui/toggle-group.tsx
@@ -44,8 +44,8 @@ const ToggleGroupItem = React.forwardRef<
ref={ref}
className={cn(
toggleVariants({
- variant: context.variant || variant,
- size: context.size || size,
+ variant: variant || context.variant,
+ size: size || context.size,
}),
className
)}
diff --git a/src/UILayer/web/src/components/ui/toggle-switch.test.tsx b/src/UILayer/web/src/components/ui/toggle-switch.test.tsx
new file mode 100644
index 00000000..6d6f4300
--- /dev/null
+++ b/src/UILayer/web/src/components/ui/toggle-switch.test.tsx
@@ -0,0 +1,116 @@
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { ToggleRow, ToggleButton } from "./toggle-switch";
+
+describe("ToggleButton", () => {
+ it("should render with role switch and aria-checked false when unchecked", () => {
+ render(
+
+ );
+ const btn = screen.getByRole("switch", { name: "Test toggle" });
+ expect(btn).toBeInTheDocument();
+ expect(btn).toHaveAttribute("aria-checked", "false");
+ });
+
+ it("should render with aria-checked true when checked", () => {
+ render(
+
+ );
+ const btn = screen.getByRole("switch", { name: "Test toggle" });
+ expect(btn).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("should call onChange with toggled value when clicked", () => {
+ const onChange = jest.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByRole("switch"));
+ expect(onChange).toHaveBeenCalledWith(true);
+ });
+
+ it("should call onChange with false when checked is true and clicked", () => {
+ const onChange = jest.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByRole("switch"));
+ expect(onChange).toHaveBeenCalledWith(false);
+ });
+
+ it("should be disabled when disabled prop is true", () => {
+ render(
+
+ );
+ expect(screen.getByRole("switch")).toBeDisabled();
+ });
+
+ it("should not call onChange when disabled and clicked", () => {
+ const onChange = jest.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByRole("switch"));
+ expect(onChange).not.toHaveBeenCalled();
+ });
+});
+
+describe("ToggleRow", () => {
+ it("should render label text", () => {
+ render(
+
+ );
+ expect(screen.getByText("Dark mode")).toBeInTheDocument();
+ });
+
+ it("should render description when provided", () => {
+ render(
+
+ );
+ expect(screen.getByText("Enable dark theme")).toBeInTheDocument();
+ });
+
+ it("should not render description when not provided", () => {
+ const { container } = render(
+
+ );
+ expect(container.querySelector("p")).toBeNull();
+ });
+
+ it("should apply opacity class when disabled", () => {
+ const { container } = render(
+
+ );
+ const wrapper = container.firstChild as HTMLElement;
+ expect(wrapper.className).toContain("opacity-50");
+ });
+
+ it("should render the toggle button inside the row", () => {
+ render(
+
+ );
+ const toggle = screen.getByRole("switch", { name: "Dark mode" });
+ expect(toggle).toBeInTheDocument();
+ expect(toggle).toHaveAttribute("aria-checked", "true");
+ });
+});
diff --git a/src/UILayer/web/src/components/ui/toggle-switch.tsx b/src/UILayer/web/src/components/ui/toggle-switch.tsx
new file mode 100644
index 00000000..b4c6bcfb
--- /dev/null
+++ b/src/UILayer/web/src/components/ui/toggle-switch.tsx
@@ -0,0 +1,63 @@
+"use client"
+
+import { useId } from "react"
+
+export function ToggleRow({
+ label,
+ description,
+ checked,
+ onChange,
+ disabled,
+}: {
+ label: string
+ description?: string
+ checked: boolean
+ onChange: (v: boolean) => void
+ disabled?: boolean
+}) {
+ const id = useId()
+
+ return (
+
+
+
+ {label}
+
+ {description &&
{description}
}
+
+
+
+ )
+}
+
+export function ToggleButton({
+ checked,
+ onChange,
+ disabled,
+ label,
+}: {
+ checked: boolean
+ onChange: (v: boolean) => void
+ disabled?: boolean
+ label: string
+}) {
+ return (
+
+ )
+}
diff --git a/src/UILayer/web/components/ui/toggle.tsx b/src/UILayer/web/src/components/ui/toggle.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/toggle.tsx
rename to src/UILayer/web/src/components/ui/toggle.tsx
diff --git a/src/UILayer/web/components/ui/tooltip.tsx b/src/UILayer/web/src/components/ui/tooltip.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/tooltip.tsx
rename to src/UILayer/web/src/components/ui/tooltip.tsx
diff --git a/src/UILayer/web/src/components/visualizations/AgentNetworkGraph.tsx b/src/UILayer/web/src/components/visualizations/AgentNetworkGraph.tsx
index da25c32a..11ad2c5a 100644
--- a/src/UILayer/web/src/components/visualizations/AgentNetworkGraph.tsx
+++ b/src/UILayer/web/src/components/visualizations/AgentNetworkGraph.tsx
@@ -1,8 +1,8 @@
import React, { useEffect, useCallback, useMemo, useRef, useState } from 'react';
import * as d3 from 'd3';
-import { AgentNode, AgentConnection, VisualizationTheme } from '../types/visualization';
-import { useD3 } from '../hooks/useD3';
-import { defaultTheme } from '../themes/defaultTheme';
+import { AgentNode, AgentConnection, VisualizationTheme } from '@/lib/visualizations/types/visualization';
+import { useD3 } from '@/lib/visualizations/useD3';
+import { defaultTheme } from '@/lib/visualizations/themes/defaultTheme';
/**
* Props for the AgentNetworkGraph component.
diff --git a/src/UILayer/web/src/components/visualizations/AuditTimeline.tsx b/src/UILayer/web/src/components/visualizations/AuditTimeline.tsx
index 9ac71301..9b81279b 100644
--- a/src/UILayer/web/src/components/visualizations/AuditTimeline.tsx
+++ b/src/UILayer/web/src/components/visualizations/AuditTimeline.tsx
@@ -1,8 +1,8 @@
import React, { useEffect, useCallback, useMemo, useRef, useState } from 'react';
import * as d3 from 'd3';
-import { AuditEvent, VisualizationTheme, SeverityColorMap } from '../types/visualization';
-import { useD3 } from '../hooks/useD3';
-import { defaultTheme } from '../themes/defaultTheme';
+import { AuditEvent, VisualizationTheme, SeverityColorMap } from '@/lib/visualizations/types/visualization';
+import { useD3 } from '@/lib/visualizations/useD3';
+import { defaultTheme } from '@/lib/visualizations/themes/defaultTheme';
/**
* Props for the AuditTimeline component.
diff --git a/src/UILayer/web/src/components/visualizations/MetricsChart.tsx b/src/UILayer/web/src/components/visualizations/MetricsChart.tsx
index 592f835f..d79f5071 100644
--- a/src/UILayer/web/src/components/visualizations/MetricsChart.tsx
+++ b/src/UILayer/web/src/components/visualizations/MetricsChart.tsx
@@ -1,8 +1,8 @@
import React, { useEffect, useCallback, useMemo, useRef, useState } from 'react';
import * as d3 from 'd3';
-import { MetricDataPoint, VisualizationTheme, ThresholdLine } from '../types/visualization';
-import { useD3 } from '../hooks/useD3';
-import { defaultTheme } from '../themes/defaultTheme';
+import { MetricDataPoint, VisualizationTheme, ThresholdLine } from '@/lib/visualizations/types/visualization';
+import { useD3 } from '@/lib/visualizations/useD3';
+import { defaultTheme } from '@/lib/visualizations/themes/defaultTheme';
/**
* Props for the MetricsChart component.
diff --git a/src/UILayer/web/src/components/widgets/AdaptiveBalance/AdaptiveBalanceDashboard.tsx b/src/UILayer/web/src/components/widgets/AdaptiveBalance/AdaptiveBalanceDashboard.tsx
new file mode 100644
index 00000000..793c916e
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/AdaptiveBalance/AdaptiveBalanceDashboard.tsx
@@ -0,0 +1,189 @@
+'use client';
+
+import React, { useState, useEffect, useCallback } from 'react';
+import SpectrumSlider from './SpectrumSlider';
+import BalanceHistory from './BalanceHistory';
+import { getAdaptiveBalance, getSpectrumHistory, getReflexionStatus } from '../api';
+import type {
+ BalanceResponse,
+ SpectrumHistoryResponse,
+ ReflexionStatusResponse,
+} from '../types';
+
+/**
+ * FE-012: Adaptive Balance Dashboard widget.
+ *
+ * Visualizes the current balance state, spectrum positions, history of
+ * balance adjustments, and reflexion system status sourced from the
+ * AdaptiveBalanceController API.
+ */
+export default function AdaptiveBalanceDashboard() {
+ const [balance, setBalance] = useState(null);
+ const [selectedDim, setSelectedDim] = useState(null);
+ const [dimHistory, setDimHistory] = useState(null);
+ const [reflexion, setReflexion] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchData = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const [bal, ref] = await Promise.all([
+ getAdaptiveBalance(),
+ getReflexionStatus(),
+ ]);
+ setBalance(bal);
+ setReflexion(ref);
+ // Select first dimension by default
+ if (bal.dimensions.length > 0 && !selectedDim) {
+ setSelectedDim(bal.dimensions[0].dimension);
+ }
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : 'Failed to load adaptive balance data.';
+ setError(msg);
+ } finally {
+ setLoading(false);
+ }
+ }, [selectedDim]);
+
+ // Fetch history when dimension changes
+ useEffect(() => {
+ if (!selectedDim) return;
+ let cancelled = false;
+ getSpectrumHistory(selectedDim)
+ .then((h) => {
+ if (!cancelled) setDimHistory(h);
+ })
+ .catch(() => {
+ if (!cancelled) setDimHistory(null);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [selectedDim]);
+
+ useEffect(() => {
+ void fetchData();
+ }, [fetchData]);
+
+ // Loading skeleton
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
Error loading balance data
+
{error}
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
Adaptive Balance
+ {balance && (
+
+ Confidence: {(balance.overallConfidence * 100).toFixed(0)}% ·{' '}
+ Generated {new Date(balance.generatedAt).toLocaleDateString()}
+
+ )}
+
+
+
+
+ {/* Spectrum sliders */}
+ {balance && (
+
+
Spectrum Positions
+
+
+ )}
+
+ {/* Dimension selector + history */}
+ {balance && balance.dimensions.length > 0 && (
+
+
+
History
+
+
+ {dimHistory ? (
+
+ ) : (
+
Select a dimension to view history.
+ )}
+
+ )}
+
+ {/* Reflexion status */}
+ {reflexion && (
+
+
Reflexion System Status
+
+
+
Hallucination Rate
+
+ {(reflexion.hallucinationRate * 100).toFixed(1)}%
+
+
+
+
Average Confidence
+
+ {(reflexion.averageConfidence * 100).toFixed(1)}%
+
+
+
+ {reflexion.recentResults.length > 0 && (
+
+
Recent Evaluations
+
+ {reflexion.recentResults.slice(0, 5).map((r) => (
+
+ {r.result}
+
+ {(r.confidence * 100).toFixed(0)}% ·{' '}
+ {new Date(r.timestamp).toLocaleDateString()}
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/AdaptiveBalance/BalanceHistory.tsx b/src/UILayer/web/src/components/widgets/AdaptiveBalance/BalanceHistory.tsx
new file mode 100644
index 00000000..9f162ce4
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/AdaptiveBalance/BalanceHistory.tsx
@@ -0,0 +1,106 @@
+'use client';
+
+import React, { useEffect, useCallback } from 'react';
+import * as d3 from 'd3';
+import { useD3 } from '@/lib/visualizations/useD3';
+import type { SpectrumHistoryEntry } from '../types';
+
+interface BalanceHistoryProps {
+ dimension: string;
+ history: SpectrumHistoryEntry[];
+}
+
+/**
+ * D3 line chart showing historical balance adjustments for a given dimension.
+ */
+export default function BalanceHistory({ dimension, history }: BalanceHistoryProps) {
+ const render = useCallback(
+ (width: number, height: number) => {
+ const svg = d3.select(svgRef.current);
+ if (!svg.node()) return;
+ svg.selectAll('*').remove();
+
+ if (history.length === 0) {
+ svg
+ .append('text')
+ .attr('x', width / 2)
+ .attr('y', height / 2)
+ .attr('text-anchor', 'middle')
+ .attr('fill', 'rgb(107,114,128)')
+ .attr('font-size', 12)
+ .text(`No history for ${dimension}`);
+ return;
+ }
+
+ const margin = { top: 16, right: 16, bottom: 32, left: 40 };
+ const innerW = width - margin.left - margin.right;
+ const innerH = height - margin.top - margin.bottom;
+
+ const dates = history.map((h) => new Date(h.timestamp));
+ const x = d3
+ .scaleTime()
+ .domain(d3.extent(dates) as [Date, Date])
+ .range([0, innerW]);
+ const y = d3.scaleLinear().domain([0, 1]).range([innerH, 0]);
+
+ const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`);
+
+ // Axes
+ g.append('g')
+ .attr('transform', `translate(0,${innerH})`)
+ .call(d3.axisBottom(x).ticks(5).tickFormat(d3.timeFormat('%b %d') as (d: Date | d3.NumberValue) => string))
+ .selectAll('text')
+ .attr('fill', 'rgb(156,163,175)')
+ .attr('font-size', 10);
+ g.append('g')
+ .call(d3.axisLeft(y).ticks(5))
+ .selectAll('text')
+ .attr('fill', 'rgb(156,163,175)')
+ .attr('font-size', 10);
+ g.selectAll('.domain, line').attr('stroke', 'rgba(255,255,255,0.15)');
+
+ // Line
+ const line = d3
+ .line()
+ .x((d) => x(new Date(d.timestamp)))
+ .y((d) => y(d.value))
+ .curve(d3.curveMonotoneX);
+
+ g.append('path')
+ .datum(history)
+ .attr('fill', 'none')
+ .attr('stroke', '#3b82f6')
+ .attr('stroke-width', 2)
+ .attr('d', line);
+
+ // Dots
+ g.selectAll('circle')
+ .data(history)
+ .join('circle')
+ .attr('cx', (d) => x(new Date(d.timestamp)))
+ .attr('cy', (d) => y(d.value))
+ .attr('r', 3)
+ .attr('fill', '#3b82f6');
+ },
+ [dimension, history],
+ );
+
+ const { svgRef } = useD3(render);
+
+ useEffect(() => {
+ const el = svgRef.current;
+ if (el) {
+ const { width, height } = el.getBoundingClientRect();
+ if (width > 0 && height > 0) render(width, height);
+ }
+ }, [render, svgRef]);
+
+ return (
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/AdaptiveBalance/SpectrumSlider.tsx b/src/UILayer/web/src/components/widgets/AdaptiveBalance/SpectrumSlider.tsx
new file mode 100644
index 00000000..59378da5
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/AdaptiveBalance/SpectrumSlider.tsx
@@ -0,0 +1,59 @@
+'use client';
+
+import React from 'react';
+import type { SpectrumDimensionResult } from '../types';
+
+interface SpectrumSliderProps {
+ dimensions: SpectrumDimensionResult[];
+}
+
+function confidenceColor(value: number): string {
+ if (value >= 0.7) return 'bg-emerald-500';
+ if (value >= 0.4) return 'bg-yellow-500';
+ return 'bg-red-500';
+}
+
+/**
+ * Visual slider showing balance between autonomy and control for each
+ * spectrum dimension. Each dimension is rendered as a horizontal slider
+ * with confidence bounds.
+ */
+export default function SpectrumSlider({ dimensions }: SpectrumSliderProps) {
+ if (dimensions.length === 0) {
+ return No spectrum dimensions available.
;
+ }
+
+ return (
+
+ {dimensions.map((dim) => {
+ const pct = dim.value * 100;
+ const lowerPct = dim.lowerBound * 100;
+ const upperPct = dim.upperBound * 100;
+ return (
+
+
+ {dim.dimension}
+ {(dim.value * 100).toFixed(0)}%
+
+
+ {/* Confidence band */}
+
+ {/* Current value marker */}
+
+
+ {dim.rationale && (
+
{dim.rationale}
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/AdaptiveBalance/index.ts b/src/UILayer/web/src/components/widgets/AdaptiveBalance/index.ts
new file mode 100644
index 00000000..f9ba8ee7
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/AdaptiveBalance/index.ts
@@ -0,0 +1,3 @@
+export { default as AdaptiveBalanceDashboard } from './AdaptiveBalanceDashboard';
+export { default as SpectrumSlider } from './SpectrumSlider';
+export { default as BalanceHistory } from './BalanceHistory';
diff --git a/src/UILayer/web/src/components/widgets/CognitiveSandwich/BurndownChart.tsx b/src/UILayer/web/src/components/widgets/CognitiveSandwich/BurndownChart.tsx
new file mode 100644
index 00000000..8a9057c2
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/CognitiveSandwich/BurndownChart.tsx
@@ -0,0 +1,88 @@
+'use client';
+
+import React, { useEffect, useCallback } from 'react';
+import * as d3 from 'd3';
+import { useD3 } from '@/lib/visualizations/useD3';
+import type { PhaseAuditEntry } from '../types';
+
+interface BurndownChartProps {
+ totalPhases: number;
+ auditEntries: PhaseAuditEntry[];
+}
+
+/**
+ * D3 burndown chart showing Cognitive Sandwich process progress over time.
+ * Plots completed phases against the timeline derived from audit entries.
+ */
+export default function BurndownChart({ totalPhases, auditEntries }: BurndownChartProps) {
+ const render = useCallback(
+ (width: number, height: number) => {
+ const svg = d3.select(svgRef.current);
+ if (!svg.node()) return;
+ svg.selectAll('*').remove();
+
+ if (auditEntries.length === 0 || totalPhases === 0) {
+ svg.append('text').attr('x', width / 2).attr('y', height / 2).attr('text-anchor', 'middle').attr('fill', 'rgb(107,114,128)').attr('font-size', 12).text('No burndown data available.');
+ return;
+ }
+
+ const margin = { top: 16, right: 16, bottom: 32, left: 40 };
+ const innerW = width - margin.left - margin.right;
+ const innerH = height - margin.top - margin.bottom;
+
+ // Build burndown data: remaining phases over time
+ const sorted = [...auditEntries].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
+ let remaining = totalPhases;
+ const points = sorted.map((e) => {
+ if (e.eventType.includes('Completed') || e.eventType.includes('Transition')) {
+ remaining = Math.max(0, remaining - 1);
+ }
+ return { date: new Date(e.timestamp), remaining };
+ });
+
+ // Add start point
+ points.unshift({ date: new Date(sorted[0].timestamp), remaining: totalPhases });
+
+ const dates = points.map((p) => p.date);
+ const x = d3.scaleTime().domain(d3.extent(dates) as [Date, Date]).range([0, innerW]);
+ const y = d3.scaleLinear().domain([0, totalPhases]).range([innerH, 0]);
+
+ const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`);
+
+ // Axes
+ g.append('g').attr('transform', `translate(0,${innerH})`).call(d3.axisBottom(x).ticks(5).tickFormat(d3.timeFormat('%b %d') as (d: Date | d3.NumberValue) => string)).selectAll('text').attr('fill', 'rgb(156,163,175)').attr('font-size', 10);
+ g.append('g').call(d3.axisLeft(y).ticks(totalPhases)).selectAll('text').attr('fill', 'rgb(156,163,175)').attr('font-size', 10);
+ g.selectAll('.domain, line').attr('stroke', 'rgba(255,255,255,0.15)');
+
+ // Ideal line
+ if (points.length >= 2) {
+ g.append('line')
+ .attr('x1', x(dates[0]))
+ .attr('y1', y(totalPhases))
+ .attr('x2', x(dates[dates.length - 1]))
+ .attr('y2', y(0))
+ .attr('stroke', 'rgba(255,255,255,0.15)')
+ .attr('stroke-dasharray', '5,5');
+ }
+
+ // Actual burndown line
+ const line = d3.line<{ date: Date; remaining: number }>().x((d) => x(d.date)).y((d) => y(d.remaining)).curve(d3.curveStepAfter);
+ g.append('path').datum(points).attr('fill', 'none').attr('stroke', '#f59e0b').attr('stroke-width', 2).attr('d', line);
+
+ g.selectAll('circle').data(points).join('circle').attr('cx', (d) => x(d.date)).attr('cy', (d) => y(d.remaining)).attr('r', 3).attr('fill', '#f59e0b');
+ },
+ [totalPhases, auditEntries],
+ );
+
+ const { svgRef } = useD3(render);
+
+ useEffect(() => {
+ const el = svgRef.current;
+ if (el) {
+ const { width, height } = el.getBoundingClientRect();
+ if (width > 0 && height > 0) render(width, height);
+ }
+ }, [render, svgRef]);
+
+ return ;
+}
diff --git a/src/UILayer/web/src/components/widgets/CognitiveSandwich/CognitiveSandwichDashboard.tsx b/src/UILayer/web/src/components/widgets/CognitiveSandwich/CognitiveSandwichDashboard.tsx
new file mode 100644
index 00000000..356c11ef
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/CognitiveSandwich/CognitiveSandwichDashboard.tsx
@@ -0,0 +1,131 @@
+'use client';
+
+import React, { useState, useEffect, useCallback } from 'react';
+import PhaseStepper from './PhaseStepper';
+import BurndownChart from './BurndownChart';
+import { getSandwichProcess, getSandwichAuditTrail, getSandwichDebt } from '../api';
+import type { SandwichProcess, PhaseAuditEntry, CognitiveDebtAssessment } from '../types';
+
+interface CognitiveSandwichDashboardProps {
+ processId?: string;
+}
+
+export default function CognitiveSandwichDashboard({ processId = 'default-process' }: CognitiveSandwichDashboardProps) {
+ const [process, setProcess] = useState(null);
+ const [audit, setAudit] = useState([]);
+ const [debt, setDebt] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchData = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const [proc, trail, debtData] = await Promise.all([
+ getSandwichProcess(processId),
+ getSandwichAuditTrail(processId),
+ getSandwichDebt(processId),
+ ]);
+ setProcess(proc);
+ setAudit(trail);
+ setDebt(debtData);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load Cognitive Sandwich data.');
+ } finally {
+ setLoading(false);
+ }
+ }, [processId]);
+
+ useEffect(() => { void fetchData(); }, [fetchData]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
Error loading sandwich data
+
{error}
+
+
+ );
+ }
+
+ return (
+
+
+
+
Cognitive Sandwich
+ {process &&
{process.name} · State: {process.state}
}
+
+
+
+
+ {process && (
+ <>
+
+
+
+
+
Phases
+
{process.currentPhaseIndex + 1} / {process.phases.length}
+
+
+
Step-backs
+
{process.stepBackCount} / {process.maxStepBacks}
+
+
+
Debt Threshold
+
{process.cognitiveDebtThreshold}
+
+
+
Current Debt
+
+ {debt ? debt.debtScore.toFixed(1) : '-'}
+
+
+
+ >
+ )}
+
+ {debt && debt.recommendations.length > 0 && (
+
+
Debt Reduction Recommendations
+
+ {debt.recommendations.map((r, i) => - {r}
)}
+
+
+ )}
+
+
+
Burndown
+
+
+
+ {audit.length > 0 && (
+
+
Audit Trail
+
+ {audit.map((e) => (
+
+ {new Date(e.timestamp).toLocaleString()}
+ {e.eventType}
+ {e.details}
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/CognitiveSandwich/PhaseStepper.tsx b/src/UILayer/web/src/components/widgets/CognitiveSandwich/PhaseStepper.tsx
new file mode 100644
index 00000000..49bf6b87
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/CognitiveSandwich/PhaseStepper.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+import React from 'react';
+import type { Phase } from '../types';
+
+interface PhaseStepperProps {
+ phases: Phase[];
+ currentPhaseIndex: number;
+}
+
+function phaseTypeIcon(phaseType: string): string {
+ const lower = phaseType.toLowerCase();
+ if (lower.includes('human')) return 'H';
+ if (lower.includes('ai') || lower.includes('machine')) return 'AI';
+ return '?';
+}
+
+function statusColor(status: string, isCurrent: boolean): string {
+ if (isCurrent) return 'border-blue-500 bg-blue-500/20 text-blue-400';
+ if (status === 'Completed') return 'border-green-500 bg-green-500/20 text-green-400';
+ if (status === 'Skipped') return 'border-gray-600 bg-gray-600/20 text-gray-500';
+ return 'border-white/20 bg-white/5 text-gray-500';
+}
+
+/**
+ * Visual phase progression stepper for the Cognitive Sandwich pattern
+ * (Human -> AI -> Human).
+ */
+export default function PhaseStepper({ phases, currentPhaseIndex }: PhaseStepperProps) {
+ if (phases.length === 0) {
+ return No phases defined.
;
+ }
+
+ return (
+
+ {phases.map((phase, i) => {
+ const isCurrent = i === currentPhaseIndex;
+ return (
+
+ {i > 0 && (
+
+ )}
+
+ {phaseTypeIcon(phase.phaseType)}
+ {phase.phaseName}
+ {phase.status}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/CognitiveSandwich/index.ts b/src/UILayer/web/src/components/widgets/CognitiveSandwich/index.ts
new file mode 100644
index 00000000..5d73a9f9
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/CognitiveSandwich/index.ts
@@ -0,0 +1,3 @@
+export { default as CognitiveSandwichDashboard } from './CognitiveSandwichDashboard';
+export { default as PhaseStepper } from './PhaseStepper';
+export { default as BurndownChart } from './BurndownChart';
diff --git a/src/UILayer/web/src/components/widgets/ContextEngineering/ContextEngineeringDashboard.tsx b/src/UILayer/web/src/components/widgets/ContextEngineering/ContextEngineeringDashboard.tsx
new file mode 100644
index 00000000..3b46cfb2
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/ContextEngineering/ContextEngineeringDashboard.tsx
@@ -0,0 +1,92 @@
+'use client';
+
+import React from 'react';
+import TokenUsageChart from './TokenUsageChart';
+
+/**
+ * FE-016: Context Engineering Dashboard widget.
+ *
+ * Displays context windows, token usage, and prompt optimization metrics.
+ * Currently renders a placeholder layout since the backend API is not yet deployed.
+ */
+export default function ContextEngineeringDashboard() {
+ // Placeholder data for the skeleton layout demonstration
+ const placeholderTokenData = [
+ { contextType: 'System Prompt', tokensUsed: 0, maxTokens: 4096 },
+ { contextType: 'Conversation History', tokensUsed: 0, maxTokens: 16384 },
+ { contextType: 'Tool Results', tokensUsed: 0, maxTokens: 8192 },
+ { contextType: 'Retrieved Context', tokensUsed: 0, maxTokens: 8192 },
+ ];
+
+ return (
+
+ {/* Header */}
+
+
Context Engineering
+
+ Context window management, token usage, and prompt optimization
+
+
+
+ {/* Coming soon banner */}
+
+
+ Context Engineering data will be available when the backend API is deployed.
+
+
+ This dashboard will display real-time context window utilization, token
+ consumption analytics, and prompt optimization recommendations.
+
+
+
+ {/* Skeleton metrics row */}
+
+ {[
+ { label: 'Active Context Windows', value: '--' },
+ { label: 'Total Tokens Used', value: '--' },
+ { label: 'Optimization Score', value: '--' },
+ { label: 'Cache Hit Rate', value: '--' },
+ ].map((metric) => (
+
+
{metric.label}
+
{metric.value}
+
+ ))}
+
+
+ {/* Token usage chart placeholder */}
+
+
+ Token Usage by Context Type
+
+
+
+
+ {/* Prompt optimization section */}
+
+
+ Prompt Optimization Insights
+
+
+ {[
+ 'Compression ratio analysis',
+ 'Context window utilization trends',
+ 'Redundancy detection',
+ ].map((item) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/ContextEngineering/TokenUsageChart.tsx b/src/UILayer/web/src/components/widgets/ContextEngineering/TokenUsageChart.tsx
new file mode 100644
index 00000000..aa01b04f
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/ContextEngineering/TokenUsageChart.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+import React from 'react';
+
+interface TokenUsageEntry {
+ contextType: string;
+ tokensUsed: number;
+ maxTokens: number;
+}
+
+interface TokenUsageChartProps {
+ /** Token usage data broken down by context type. */
+ data: TokenUsageEntry[];
+}
+
+function getBarColor(ratio: number): string {
+ if (ratio < 0.5) return 'bg-green-500';
+ if (ratio < 0.75) return 'bg-yellow-500';
+ if (ratio < 0.9) return 'bg-orange-500';
+ return 'bg-red-500';
+}
+
+/**
+ * Bar chart showing token consumption by context type.
+ */
+export default function TokenUsageChart({ data }: TokenUsageChartProps) {
+ if (data.length === 0) {
+ return No token usage data available.
;
+ }
+
+ const maxTokens = Math.max(...data.map((d) => d.maxTokens), 1);
+
+ return (
+
+ {data.map((entry) => {
+ const ratio = entry.tokensUsed / Math.max(entry.maxTokens, 1);
+ const widthPct = (entry.tokensUsed / maxTokens) * 100;
+ return (
+
+
+ {entry.contextType}
+
+ {entry.tokensUsed.toLocaleString()} / {entry.maxTokens.toLocaleString()}
+
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/ContextEngineering/index.ts b/src/UILayer/web/src/components/widgets/ContextEngineering/index.ts
new file mode 100644
index 00000000..fe492bbd
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/ContextEngineering/index.ts
@@ -0,0 +1,2 @@
+export { default as ContextEngineeringDashboard } from './ContextEngineeringDashboard';
+export { default as TokenUsageChart } from './TokenUsageChart';
diff --git a/src/UILayer/web/src/components/widgets/Convener/ConvenerDashboard.tsx b/src/UILayer/web/src/components/widgets/Convener/ConvenerDashboard.tsx
new file mode 100644
index 00000000..4685b5b4
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/Convener/ConvenerDashboard.tsx
@@ -0,0 +1,93 @@
+'use client';
+
+import React from 'react';
+import SessionTimeline from './SessionTimeline';
+
+/**
+ * FE-018: Convener Dashboard widget.
+ *
+ * Displays meeting and session orchestration information.
+ * Currently renders a placeholder layout since the backend Convener API is not yet deployed.
+ */
+export default function ConvenerDashboard() {
+ return (
+
+ {/* Header */}
+
+
Convener
+
+ Session orchestration, meeting management, and collaboration coordination
+
+
+
+ {/* Coming soon banner */}
+
+
+ Convener data will be available when the backend API is deployed.
+
+
+ This dashboard will orchestrate multi-agent sessions, coordinate
+ collaborative reasoning, and manage decision-making workflows.
+
+
+
+ {/* Metrics row */}
+
+ {[
+ { label: 'Active Sessions', value: '--' },
+ { label: 'Total Sessions', value: '--' },
+ { label: 'Avg. Duration', value: '--' },
+ { label: 'Participants Today', value: '--' },
+ ].map((metric) => (
+
+
{metric.label}
+
{metric.value}
+
+ ))}
+
+
+ {/* Session timeline placeholder */}
+
+
Recent Sessions
+
+
+
+ {/* Orchestration modes */}
+
+
+ Orchestration Modes
+
+
+ {[
+ {
+ mode: 'Debate',
+ description: 'Multi-agent adversarial reasoning sessions',
+ },
+ {
+ mode: 'Sequential',
+ description: 'Ordered step-by-step agent collaboration',
+ },
+ {
+ mode: 'Strategic Simulation',
+ description: 'Scenario-based strategy evaluation',
+ },
+ ].map((item) => (
+
+
{item.mode}
+
{item.description}
+
+ Pending
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/Convener/SessionTimeline.tsx b/src/UILayer/web/src/components/widgets/Convener/SessionTimeline.tsx
new file mode 100644
index 00000000..5a53c26e
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/Convener/SessionTimeline.tsx
@@ -0,0 +1,87 @@
+'use client';
+
+import React from 'react';
+
+interface Session {
+ sessionId: string;
+ title: string;
+ status: 'scheduled' | 'active' | 'completed' | 'cancelled';
+ startedAt: string;
+ participants: number;
+}
+
+interface SessionTimelineProps {
+ /** Sessions to display in the timeline. */
+ sessions: Session[];
+}
+
+function getStatusColor(status: Session['status']): string {
+ switch (status) {
+ case 'active':
+ return 'bg-green-500';
+ case 'completed':
+ return 'bg-blue-500';
+ case 'cancelled':
+ return 'bg-red-500';
+ case 'scheduled':
+ default:
+ return 'bg-gray-500';
+ }
+}
+
+function getStatusLabel(status: Session['status']): string {
+ switch (status) {
+ case 'active':
+ return 'Active';
+ case 'completed':
+ return 'Completed';
+ case 'cancelled':
+ return 'Cancelled';
+ case 'scheduled':
+ default:
+ return 'Scheduled';
+ }
+}
+
+/**
+ * Timeline component showing convener session history.
+ */
+export default function SessionTimeline({ sessions }: SessionTimelineProps) {
+ if (sessions.length === 0) {
+ return No sessions recorded yet.
;
+ }
+
+ return (
+
+ {/* Vertical line */}
+
+
+ {sessions.map((session) => (
+
+ {/* Dot */}
+
+
+ {/* Content */}
+
+
+ {session.title}
+
+ {getStatusLabel(session.status)}
+
+
+
+ {new Date(session.startedAt).toLocaleString()}
+ {session.participants} participant{session.participants !== 1 ? 's' : ''}
+
+
+
+ ))}
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/Convener/index.ts b/src/UILayer/web/src/components/widgets/Convener/index.ts
new file mode 100644
index 00000000..0b2655ee
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/Convener/index.ts
@@ -0,0 +1,2 @@
+export { default as ConvenerDashboard } from './ConvenerDashboard';
+export { default as SessionTimeline } from './SessionTimeline';
diff --git a/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactMetricsDashboard.tsx b/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactMetricsDashboard.tsx
new file mode 100644
index 00000000..aa11e896
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactMetricsDashboard.tsx
@@ -0,0 +1,120 @@
+'use client';
+
+import React, { useState, useEffect, useCallback } from 'react';
+import SafetyGauge from './SafetyGauge';
+import ImpactRadar from './ImpactRadar';
+import ImpactTimeline from './ImpactTimeline';
+import { getImpactReport, getResistancePatterns } from '../api';
+import type { ImpactReport, ResistanceIndicator } from '../types';
+
+interface ImpactMetricsDashboardProps {
+ tenantId?: string;
+}
+
+export default function ImpactMetricsDashboard({ tenantId = 'default-tenant' }: ImpactMetricsDashboardProps) {
+ const [report, setReport] = useState(null);
+ const [resistance, setResistance] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchData = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const [rep, res] = await Promise.all([
+ getImpactReport(tenantId),
+ getResistancePatterns(tenantId),
+ ]);
+ setReport(rep);
+ setResistance(res);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load impact metrics.');
+ } finally {
+ setLoading(false);
+ }
+ }, [tenantId]);
+
+ useEffect(() => { void fetchData(); }, [fetchData]);
+
+ if (loading) {
+ return (
+
+
+
+ {[1, 2, 3].map((k) =>
)}
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
Error loading impact metrics
+
{error}
+
+
+ );
+ }
+
+ const radarLabels: string[] = [];
+ const radarValues: number[] = [];
+ if (report) {
+ radarLabels.push('Safety', 'Alignment', 'Adoption', 'Overall');
+ radarValues.push(report.safetyScore, report.alignmentScore * 100, report.adoptionRate * 100, report.overallImpactScore);
+ }
+
+ return (
+
+
+
+
Impact Metrics
+ {report &&
Report generated {new Date(report.generatedAt).toLocaleDateString()}
}
+
+
+
+
+ {report && (
+ <>
+
+
+
+
+
+
Alignment
+
{(report.alignmentScore * 100).toFixed(0)}%
+
+
+
Adoption Rate
+
{(report.adoptionRate * 100).toFixed(0)}%
+
+
+
Overall Impact
+
{report.overallImpactScore.toFixed(0)}
+
+
+
+
+
Impact Dimensions
+
+
+
+ {report.recommendations.length > 0 && (
+
+
Recommendations
+
+ {report.recommendations.map((r, i) => - {r}
)}
+
+
+ )}
+ >
+ )}
+
+
+
Resistance Patterns
+
+
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactRadar.tsx b/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactRadar.tsx
new file mode 100644
index 00000000..28c9afe7
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactRadar.tsx
@@ -0,0 +1,119 @@
+'use client';
+
+import React, { useEffect, useCallback } from 'react';
+import * as d3 from 'd3';
+import { useD3 } from '@/lib/visualizations/useD3';
+
+interface ImpactRadarProps {
+ /** Dimension labels. */
+ labels: string[];
+ /** Corresponding values (0-100 scale). */
+ values: number[];
+}
+
+/**
+ * D3 radar chart for impact metric dimensions (safety, alignment, adoption, etc.).
+ */
+export default function ImpactRadar({ labels, values }: ImpactRadarProps) {
+ const render = useCallback(
+ (width: number, height: number) => {
+ const svg = d3.select(svgRef.current);
+ if (!svg.node()) return;
+ svg.selectAll('*').remove();
+
+ if (labels.length === 0) {
+ svg
+ .append('text')
+ .attr('x', width / 2)
+ .attr('y', height / 2)
+ .attr('text-anchor', 'middle')
+ .attr('fill', 'rgb(107,114,128)')
+ .attr('font-size', 12)
+ .text('No impact data available.');
+ return;
+ }
+
+ const cx = width / 2;
+ const cy = height / 2;
+ const maxRadius = Math.min(cx, cy) - 36;
+ const numAxes = labels.length;
+ const angleSlice = (Math.PI * 2) / numAxes;
+ const rScale = d3.scaleLinear().domain([0, 100]).range([0, maxRadius]);
+
+ const g = svg.append('g').attr('transform', `translate(${cx},${cy})`);
+
+ // Grid
+ const levels = 4;
+ for (let lvl = 1; lvl <= levels; lvl++) {
+ const r = (maxRadius / levels) * lvl;
+ g.append('circle')
+ .attr('r', r)
+ .attr('fill', 'none')
+ .attr('stroke', 'rgba(255,255,255,0.08)')
+ .attr('stroke-dasharray', '2,3');
+ }
+
+ // Axes + labels
+ labels.forEach((label, i) => {
+ const angle = angleSlice * i - Math.PI / 2;
+ g.append('line')
+ .attr('x2', maxRadius * Math.cos(angle))
+ .attr('y2', maxRadius * Math.sin(angle))
+ .attr('stroke', 'rgba(255,255,255,0.08)');
+
+ const labelR = maxRadius + 18;
+ g.append('text')
+ .attr('x', labelR * Math.cos(angle))
+ .attr('y', labelR * Math.sin(angle))
+ .attr('text-anchor', 'middle')
+ .attr('dominant-baseline', 'central')
+ .attr('fill', 'rgb(156,163,175)')
+ .attr('font-size', 9)
+ .text(label);
+ });
+
+ // Polygon
+ const lineGen = d3
+ .lineRadial()
+ .angle((_, i) => angleSlice * i)
+ .radius((d) => rScale(d))
+ .curve(d3.curveLinearClosed);
+
+ g.append('path')
+ .datum(values)
+ .attr('d', lineGen)
+ .attr('fill', 'rgba(34,197,94,0.2)')
+ .attr('stroke', '#22c55e')
+ .attr('stroke-width', 2);
+
+ values.forEach((val, i) => {
+ const angle = angleSlice * i - Math.PI / 2;
+ g.append('circle')
+ .attr('cx', rScale(val) * Math.cos(angle))
+ .attr('cy', rScale(val) * Math.sin(angle))
+ .attr('r', 3.5)
+ .attr('fill', '#22c55e');
+ });
+ },
+ [labels, values],
+ );
+
+ const { svgRef } = useD3(render);
+
+ useEffect(() => {
+ const el = svgRef.current;
+ if (el) {
+ const { width, height } = el.getBoundingClientRect();
+ if (width > 0 && height > 0) render(width, height);
+ }
+ }, [render, svgRef]);
+
+ return (
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactTimeline.tsx b/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactTimeline.tsx
new file mode 100644
index 00000000..8eb91fda
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactTimeline.tsx
@@ -0,0 +1,36 @@
+'use client';
+
+import React from 'react';
+import type { ResistanceIndicator } from '../types';
+
+interface ImpactTimelineProps {
+ indicators: ResistanceIndicator[];
+}
+
+function severityBadgeClass(severity: string): string {
+ if (severity === 'High') return 'bg-red-500/20 text-red-400';
+ if (severity === 'Medium') return 'bg-yellow-500/20 text-yellow-400';
+ return 'bg-gray-500/20 text-gray-400';
+}
+
+export default function ImpactTimeline({ indicators }: ImpactTimelineProps) {
+ if (indicators.length === 0) {
+ return No resistance patterns detected.
;
+ }
+ return (
+
+ {indicators.map((ind) => (
+
+
+
+
+ {ind.pattern}
+ {ind.severity}
+
+
{ind.affectedUsers} affected users - {new Date(ind.detectedAt).toLocaleDateString()}
+
+
+ ))}
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/ImpactMetrics/SafetyGauge.tsx b/src/UILayer/web/src/components/widgets/ImpactMetrics/SafetyGauge.tsx
new file mode 100644
index 00000000..bde5535c
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/ImpactMetrics/SafetyGauge.tsx
@@ -0,0 +1,103 @@
+'use client';
+
+import React, { useEffect, useCallback } from 'react';
+import * as d3 from 'd3';
+import { useD3 } from '@/lib/visualizations/useD3';
+
+interface SafetyGaugeProps {
+ /** Safety score on a 0-100 scale. */
+ score: number;
+ /** Label displayed below the gauge. */
+ label?: string;
+}
+
+function gaugeColor(score: number): string {
+ if (score >= 75) return '#22c55e';
+ if (score >= 50) return '#3b82f6';
+ if (score >= 25) return '#f59e0b';
+ return '#ef4444';
+}
+
+/**
+ * D3 half-circle gauge for a psychological safety score (0-100).
+ */
+export default function SafetyGauge({ score, label }: SafetyGaugeProps) {
+ const render = useCallback(
+ (width: number, height: number) => {
+ const svg = d3.select(svgRef.current);
+ if (!svg.node()) return;
+ svg.selectAll('*').remove();
+
+ const size = Math.min(width, height);
+ const cx = width / 2;
+ const cy = height * 0.6;
+ const outerRadius = size * 0.42;
+ const innerRadius = outerRadius * 0.72;
+
+ const startAngle = -Math.PI / 2;
+ const endAngle = Math.PI / 2;
+ const range = endAngle - startAngle;
+ const clampedScore = Math.max(0, Math.min(100, score));
+ const valueAngle = startAngle + (clampedScore / 100) * range;
+
+ const arc = d3.arc().innerRadius(innerRadius).outerRadius(outerRadius).cornerRadius(4);
+
+ // Background arc
+ svg
+ .append('path')
+ .attr('transform', `translate(${cx},${cy})`)
+ .attr('d', arc({ startAngle, endAngle } as never) as string)
+ .attr('fill', 'rgba(255,255,255,0.1)');
+
+ // Value arc
+ svg
+ .append('path')
+ .attr('transform', `translate(${cx},${cy})`)
+ .attr('d', arc({ startAngle, endAngle: valueAngle } as never) as string)
+ .attr('fill', gaugeColor(clampedScore));
+
+ // Center text
+ svg
+ .append('text')
+ .attr('x', cx)
+ .attr('y', cy - 2)
+ .attr('text-anchor', 'middle')
+ .attr('dominant-baseline', 'auto')
+ .attr('fill', 'white')
+ .attr('font-size', size * 0.15)
+ .attr('font-weight', '700')
+ .text(Math.round(clampedScore));
+
+ if (label) {
+ svg
+ .append('text')
+ .attr('x', cx)
+ .attr('y', cy + size * 0.12)
+ .attr('text-anchor', 'middle')
+ .attr('fill', 'rgb(156,163,175)')
+ .attr('font-size', size * 0.06)
+ .text(label);
+ }
+ },
+ [score, label],
+ );
+
+ const { svgRef } = useD3(render);
+
+ useEffect(() => {
+ const el = svgRef.current;
+ if (el) {
+ const { width, height } = el.getBoundingClientRect();
+ if (width > 0 && height > 0) render(width, height);
+ }
+ }, [render, svgRef]);
+
+ return (
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/ImpactMetrics/index.ts b/src/UILayer/web/src/components/widgets/ImpactMetrics/index.ts
new file mode 100644
index 00000000..a0f955ee
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/ImpactMetrics/index.ts
@@ -0,0 +1,4 @@
+export { default as ImpactMetricsDashboard } from './ImpactMetricsDashboard';
+export { default as SafetyGauge } from './SafetyGauge';
+export { default as ImpactRadar } from './ImpactRadar';
+export { default as ImpactTimeline } from './ImpactTimeline';
diff --git a/src/UILayer/web/src/components/widgets/Marketplace/AgentCard.tsx b/src/UILayer/web/src/components/widgets/Marketplace/AgentCard.tsx
new file mode 100644
index 00000000..fe20c269
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/Marketplace/AgentCard.tsx
@@ -0,0 +1,95 @@
+'use client';
+
+import React from 'react';
+
+interface AgentCardProps {
+ /** Unique agent identifier. */
+ agentId: string;
+ /** Display name of the agent or capability. */
+ name: string;
+ /** Short description. */
+ description: string;
+ /** Category tag (e.g. "Reasoning", "Analytics"). */
+ category: string;
+ /** Version string. */
+ version: string;
+ /** Whether the agent is currently installed/active. */
+ installed: boolean;
+}
+
+function getCategoryColor(category: string): string {
+ switch (category.toLowerCase()) {
+ case 'reasoning':
+ return 'bg-purple-500/20 text-purple-400';
+ case 'analytics':
+ return 'bg-blue-500/20 text-blue-400';
+ case 'security':
+ return 'bg-red-500/20 text-red-400';
+ case 'compliance':
+ return 'bg-green-500/20 text-green-400';
+ default:
+ return 'bg-gray-500/20 text-gray-400';
+ }
+}
+
+/**
+ * Card component for a marketplace agent/capability listing.
+ */
+export default function AgentCard({
+ agentId,
+ name,
+ description,
+ category,
+ version,
+ installed,
+}: AgentCardProps) {
+ return (
+
+ {/* Header */}
+
+
+
+ {name.charAt(0).toUpperCase()}
+
+
+
{name}
+ v{version}
+
+
+
+ {category}
+
+
+
+ {/* Description */}
+
+ {description}
+
+
+ {/* Footer */}
+
+ {installed ? (
+
+
+ Installed
+
+ ) : (
+ Not installed
+ )}
+
+
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/Marketplace/MarketplaceDashboard.tsx b/src/UILayer/web/src/components/widgets/Marketplace/MarketplaceDashboard.tsx
new file mode 100644
index 00000000..3f1c474b
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/Marketplace/MarketplaceDashboard.tsx
@@ -0,0 +1,157 @@
+'use client';
+
+import React, { useState } from 'react';
+import AgentCard from './AgentCard';
+
+interface MarketplaceAgent {
+ agentId: string;
+ name: string;
+ description: string;
+ category: string;
+ version: string;
+ installed: boolean;
+}
+
+// Placeholder catalog — will be replaced by AgentRegistry API once available
+const PLACEHOLDER_AGENTS: MarketplaceAgent[] = [
+ {
+ agentId: 'agent-debate-reasoning',
+ name: 'Debate Reasoning',
+ description: 'Multi-agent adversarial reasoning engine using ConclAIve debate protocol for balanced decision-making.',
+ category: 'Reasoning',
+ version: '1.2.0',
+ installed: true,
+ },
+ {
+ agentId: 'agent-nist-compliance',
+ name: 'NIST Compliance',
+ description: 'Automated assessment and monitoring of AI systems against the NIST AI Risk Management Framework.',
+ category: 'Compliance',
+ version: '1.0.3',
+ installed: true,
+ },
+ {
+ agentId: 'agent-threat-intel',
+ name: 'Threat Intelligence',
+ description: 'Real-time threat detection and intelligence gathering with automated response recommendations.',
+ category: 'Security',
+ version: '0.9.1',
+ installed: false,
+ },
+ {
+ agentId: 'agent-value-diagnostic',
+ name: 'Value Diagnostic',
+ description: 'Organizational value assessment and blindness detection for AI deployment readiness.',
+ category: 'Analytics',
+ version: '1.1.0',
+ installed: true,
+ },
+ {
+ agentId: 'agent-ethical-reasoning',
+ name: 'Ethical Reasoning',
+ description: 'Brandom-Floridi ethical framework evaluation for responsible AI decision-making.',
+ category: 'Reasoning',
+ version: '1.0.0',
+ installed: false,
+ },
+ {
+ agentId: 'agent-cognitive-sandwich',
+ name: 'Cognitive Sandwich',
+ description: 'Human-AI-Human workflow orchestration with cognitive debt monitoring and phase management.',
+ category: 'Compliance',
+ version: '1.3.0',
+ installed: true,
+ },
+];
+
+const CATEGORIES = ['All', 'Reasoning', 'Analytics', 'Security', 'Compliance'] as const;
+
+/**
+ * FE-019: Marketplace Dashboard widget.
+ *
+ * Provides a browsable catalog of agents and capabilities for the Cognitive Mesh platform.
+ * Uses placeholder data until the AgentRegistry API is available.
+ */
+export default function MarketplaceDashboard() {
+ const [filter, setFilter] = useState('All');
+ const [search, setSearch] = useState('');
+
+ const filtered = PLACEHOLDER_AGENTS.filter((agent) => {
+ const matchesCategory = filter === 'All' || agent.category === filter;
+ const matchesSearch =
+ search === '' ||
+ agent.name.toLowerCase().includes(search.toLowerCase()) ||
+ agent.description.toLowerCase().includes(search.toLowerCase());
+ return matchesCategory && matchesSearch;
+ });
+
+ return (
+
+ {/* Header */}
+
+
Marketplace
+
+ Browse, install, and manage agents and integrations
+
+
+
+ {/* Search and filter bar */}
+
+
setSearch(e.target.value)}
+ className="rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500/40 focus:outline-none focus:ring-1 focus:ring-cyan-500/40"
+ aria-label="Search agents"
+ />
+
+ {CATEGORIES.map((cat) => (
+
+ ))}
+
+
+
+ {/* Agent grid */}
+ {filtered.length === 0 ? (
+
+
No agents match your search criteria.
+
+ ) : (
+
+ {filtered.map((agent) => (
+
+ ))}
+
+ )}
+
+ {/* Summary */}
+
+
+ Showing {filtered.length} of {PLACEHOLDER_AGENTS.length} agents
+
+
+ Agent Registry API integration pending
+
+
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/Marketplace/index.ts b/src/UILayer/web/src/components/widgets/Marketplace/index.ts
new file mode 100644
index 00000000..255ad870
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/Marketplace/index.ts
@@ -0,0 +1,2 @@
+export { default as MarketplaceDashboard } from './MarketplaceDashboard';
+export { default as AgentCard } from './AgentCard';
diff --git a/src/UILayer/web/src/components/widgets/NistCompliance/ComplianceTimeline.tsx b/src/UILayer/web/src/components/widgets/NistCompliance/ComplianceTimeline.tsx
new file mode 100644
index 00000000..08c1d51d
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/NistCompliance/ComplianceTimeline.tsx
@@ -0,0 +1,95 @@
+'use client';
+
+import React, { useEffect, useCallback } from 'react';
+import * as d3 from 'd3';
+import { useD3 } from '@/lib/visualizations/useD3';
+import type { NISTAuditEntry } from '../types';
+
+interface ComplianceTimelineProps {
+ entries: NISTAuditEntry[];
+}
+
+/**
+ * D3 timeline of NIST compliance audit events.
+ */
+export default function ComplianceTimeline({ entries }: ComplianceTimelineProps) {
+ const render = useCallback(
+ (width: number, height: number) => {
+ const svg = d3.select(svgRef.current);
+ if (!svg.node()) return;
+ svg.selectAll('*').remove();
+
+ if (entries.length === 0) {
+ svg
+ .append('text')
+ .attr('x', width / 2)
+ .attr('y', height / 2)
+ .attr('text-anchor', 'middle')
+ .attr('fill', 'rgb(107,114,128)')
+ .attr('font-size', 12)
+ .text('No audit events to display.');
+ return;
+ }
+
+ const margin = { top: 20, right: 20, bottom: 40, left: 60 };
+ const innerW = width - margin.left - margin.right;
+ const innerH = height - margin.top - margin.bottom;
+
+ const dates = entries.map((e) => new Date(e.performedAt));
+ const xExtent = d3.extent(dates) as [Date, Date];
+ const x = d3.scaleTime().domain(xExtent).range([0, innerW]).nice();
+
+ const actions = [...new Set(entries.map((e) => e.action))];
+ const y = d3.scaleBand().domain(actions).range([0, innerH]).padding(0.4);
+
+ const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`);
+
+ // X axis
+ g.append('g')
+ .attr('transform', `translate(0,${innerH})`)
+ .call(d3.axisBottom(x).ticks(5).tickFormat(d3.timeFormat('%b %d') as (d: Date | d3.NumberValue) => string))
+ .selectAll('text')
+ .attr('fill', 'rgb(156,163,175)')
+ .attr('font-size', 10);
+ g.selectAll('.domain, line').attr('stroke', 'rgba(255,255,255,0.15)');
+
+ // Y axis
+ g.append('g')
+ .call(d3.axisLeft(y))
+ .selectAll('text')
+ .attr('fill', 'rgb(156,163,175)')
+ .attr('font-size', 10);
+
+ // Dots
+ const color = d3.scaleOrdinal(d3.schemeTableau10).domain(actions);
+ g.selectAll('circle')
+ .data(entries)
+ .join('circle')
+ .attr('cx', (d) => x(new Date(d.performedAt)))
+ .attr('cy', (d) => (y(d.action) ?? 0) + y.bandwidth() / 2)
+ .attr('r', 5)
+ .attr('fill', (d) => color(d.action))
+ .attr('opacity', 0.85);
+ },
+ [entries],
+ );
+
+ const { svgRef } = useD3(render);
+
+ useEffect(() => {
+ const el = svgRef.current;
+ if (el) {
+ const { width, height } = el.getBoundingClientRect();
+ if (width > 0 && height > 0) render(width, height);
+ }
+ }, [render, svgRef]);
+
+ return (
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/NistCompliance/GapAnalysisTable.tsx b/src/UILayer/web/src/components/widgets/NistCompliance/GapAnalysisTable.tsx
new file mode 100644
index 00000000..d00a57ab
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/NistCompliance/GapAnalysisTable.tsx
@@ -0,0 +1,125 @@
+'use client';
+
+import React, { useState, useMemo } from 'react';
+import type { NISTGapItem } from '../types';
+
+interface GapAnalysisTableProps {
+ gaps: NISTGapItem[];
+}
+
+type SortField = 'statementId' | 'currentScore' | 'targetScore' | 'priority';
+type SortDir = 'asc' | 'desc';
+
+const PRIORITY_ORDER: Record = {
+ Critical: 0,
+ High: 1,
+ Medium: 2,
+ Low: 3,
+};
+
+function priorityColor(p: string): string {
+ switch (p) {
+ case 'Critical':
+ return 'text-red-400';
+ case 'High':
+ return 'text-orange-400';
+ case 'Medium':
+ return 'text-yellow-400';
+ default:
+ return 'text-gray-400';
+ }
+}
+
+/**
+ * Sortable table displaying NIST compliance gap items.
+ */
+export default function GapAnalysisTable({ gaps }: GapAnalysisTableProps) {
+ const [sortField, setSortField] = useState('priority');
+ const [sortDir, setSortDir] = useState('asc');
+
+ const sorted = useMemo(() => {
+ const copy = [...gaps];
+ copy.sort((a, b) => {
+ let cmp = 0;
+ switch (sortField) {
+ case 'statementId':
+ cmp = a.statementId.localeCompare(b.statementId);
+ break;
+ case 'currentScore':
+ cmp = a.currentScore - b.currentScore;
+ break;
+ case 'targetScore':
+ cmp = a.targetScore - b.targetScore;
+ break;
+ case 'priority':
+ cmp = (PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99);
+ break;
+ }
+ return sortDir === 'asc' ? cmp : -cmp;
+ });
+ return copy;
+ }, [gaps, sortField, sortDir]);
+
+ function handleSort(field: SortField) {
+ if (field === sortField) {
+ setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
+ } else {
+ setSortField(field);
+ setSortDir('asc');
+ }
+ }
+
+ function renderHeader(label: string, field: SortField) {
+ const active = sortField === field;
+ const arrow = active ? (sortDir === 'asc' ? ' \u25B2' : ' \u25BC') : '';
+ return (
+ handleSort(field)}
+ >
+ {label}
+ {arrow}
+ |
+ );
+ }
+
+ if (gaps.length === 0) {
+ return No compliance gaps identified.
;
+ }
+
+ return (
+
+
+
+
+ {renderHeader('Statement', 'statementId')}
+ {renderHeader('Current', 'currentScore')}
+ {renderHeader('Target', 'targetScore')}
+ {renderHeader('Priority', 'priority')}
+ |
+ Actions
+ |
+
+
+
+ {sorted.map((gap) => (
+
+ | {gap.statementId} |
+ {gap.currentScore} |
+ {gap.targetScore} |
+ {gap.priority} |
+
+
+ {gap.recommendedActions.map((action, i) => (
+ - {action}
+ ))}
+
+ |
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/NistCompliance/MaturityGauge.tsx b/src/UILayer/web/src/components/widgets/NistCompliance/MaturityGauge.tsx
new file mode 100644
index 00000000..c7d8179d
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/NistCompliance/MaturityGauge.tsx
@@ -0,0 +1,124 @@
+'use client';
+
+import React, { useEffect, useCallback } from 'react';
+import * as d3 from 'd3';
+import { useD3 } from '@/lib/visualizations/useD3';
+
+interface MaturityGaugeProps {
+ /** Overall maturity score (0-5 scale). */
+ score: number;
+ /** Label displayed below the gauge. */
+ label?: string;
+}
+
+const MATURITY_LEVELS = ['Partial', 'Risk Informed', 'Repeatable', 'Adaptive', 'Optimal'] as const;
+
+function getMaturityLabel(score: number): string {
+ if (score < 1) return MATURITY_LEVELS[0];
+ if (score < 2) return MATURITY_LEVELS[1];
+ if (score < 3) return MATURITY_LEVELS[2];
+ if (score < 4) return MATURITY_LEVELS[3];
+ return MATURITY_LEVELS[4];
+}
+
+function getGaugeColor(score: number): string {
+ if (score < 1.5) return '#ef4444';
+ if (score < 2.5) return '#f59e0b';
+ if (score < 3.5) return '#3b82f6';
+ return '#22c55e';
+}
+
+/**
+ * D3 radial gauge visualizing a NIST maturity score on a 0-5 scale.
+ */
+export default function MaturityGauge({ score, label }: MaturityGaugeProps) {
+ const render = useCallback(
+ (width: number, height: number) => {
+ const svg = d3.select(svgRef.current);
+ if (!svg.node()) return;
+ svg.selectAll('*').remove();
+
+ const size = Math.min(width, height);
+ const cx = width / 2;
+ const cy = height / 2;
+ const outerRadius = size * 0.42;
+ const innerRadius = outerRadius * 0.75;
+
+ const startAngle = -Math.PI * 0.75;
+ const endAngle = Math.PI * 0.75;
+ const range = endAngle - startAngle;
+ const valueAngle = startAngle + (score / 5) * range;
+
+ const arc = d3.arc().innerRadius(innerRadius).outerRadius(outerRadius).cornerRadius(4);
+
+ // Background arc
+ svg
+ .append('path')
+ .attr('transform', `translate(${cx},${cy})`)
+ .attr('d', arc({ startAngle, endAngle } as never) as string)
+ .attr('fill', 'rgba(255,255,255,0.1)');
+
+ // Value arc
+ svg
+ .append('path')
+ .attr('transform', `translate(${cx},${cy})`)
+ .attr('d', arc({ startAngle, endAngle: valueAngle } as never) as string)
+ .attr('fill', getGaugeColor(score));
+
+ // Center text — score
+ svg
+ .append('text')
+ .attr('x', cx)
+ .attr('y', cy - 4)
+ .attr('text-anchor', 'middle')
+ .attr('dominant-baseline', 'auto')
+ .attr('fill', 'white')
+ .attr('font-size', size * 0.16)
+ .attr('font-weight', '700')
+ .text(score.toFixed(1));
+
+ // Center text — maturity level
+ svg
+ .append('text')
+ .attr('x', cx)
+ .attr('y', cy + size * 0.1)
+ .attr('text-anchor', 'middle')
+ .attr('dominant-baseline', 'auto')
+ .attr('fill', 'rgb(156,163,175)')
+ .attr('font-size', size * 0.07)
+ .text(getMaturityLabel(score));
+
+ // Label below gauge
+ if (label) {
+ svg
+ .append('text')
+ .attr('x', cx)
+ .attr('y', cy + size * 0.35)
+ .attr('text-anchor', 'middle')
+ .attr('fill', 'rgb(156,163,175)')
+ .attr('font-size', size * 0.06)
+ .text(label);
+ }
+ },
+ [score, label],
+ );
+
+ const { svgRef } = useD3(render);
+
+ useEffect(() => {
+ const el = svgRef.current;
+ if (el) {
+ const { width, height } = el.getBoundingClientRect();
+ if (width > 0 && height > 0) render(width, height);
+ }
+ }, [render, svgRef]);
+
+ return (
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/NistCompliance/NistComplianceDashboard.tsx b/src/UILayer/web/src/components/widgets/NistCompliance/NistComplianceDashboard.tsx
new file mode 100644
index 00000000..2536c8d8
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/NistCompliance/NistComplianceDashboard.tsx
@@ -0,0 +1,164 @@
+'use client';
+
+import React, { useState, useEffect, useCallback } from 'react';
+import MaturityGauge from './MaturityGauge';
+import GapAnalysisTable from './GapAnalysisTable';
+import ComplianceTimeline from './ComplianceTimeline';
+import { getNistScore, getNistRoadmap, getNistAuditLog } from '../api';
+import type { NISTScoreResponse, NISTRoadmapResponse, NISTAuditEntry } from '../types';
+
+interface NistComplianceDashboardProps {
+ organizationId?: string;
+}
+
+/**
+ * FE-011: Main NIST Compliance Dashboard widget.
+ *
+ * Displays maturity scores, pillar breakdown, gap analysis, and an audit
+ * event timeline sourced from the NISTComplianceController API.
+ */
+export default function NistComplianceDashboard({
+ organizationId = 'default-org',
+}: NistComplianceDashboardProps) {
+ const [scoreData, setScoreData] = useState(null);
+ const [roadmapData, setRoadmapData] = useState(null);
+ const [auditEntries, setAuditEntries] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchData = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const [score, roadmap, audit] = await Promise.all([
+ getNistScore(organizationId),
+ getNistRoadmap(organizationId),
+ getNistAuditLog(organizationId, 50),
+ ]);
+ setScoreData(score);
+ setRoadmapData(roadmap);
+ setAuditEntries(audit.entries ?? []);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : 'Failed to load NIST compliance data.';
+ setError(msg);
+ } finally {
+ setLoading(false);
+ }
+ }, [organizationId]);
+
+ useEffect(() => {
+ void fetchData();
+ }, [fetchData]);
+
+ // Loading skeleton
+ if (loading) {
+ return (
+
+
+
+ {[1, 2, 3].map((k) => (
+
+ ))}
+
+
+
+ );
+ }
+
+ // Error state
+ if (error) {
+ return (
+
+
Error loading compliance data
+
{error}
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
NIST AI RMF Compliance
+ {scoreData && (
+
+ Assessed {new Date(scoreData.assessedAt).toLocaleDateString()}
+
+ )}
+
+
+
+
+ {/* Gauges row */}
+ {scoreData && (
+
+ {/* Overall maturity */}
+
+
+
+
+ {/* Top 3 pillar scores */}
+ {scoreData.pillarScores.slice(0, 3).map((ps) => (
+
+
+
+ ))}
+
+ )}
+
+ {/* Pillar breakdown table */}
+ {scoreData && scoreData.pillarScores.length > 0 && (
+
+
Pillar Breakdown
+
+ {scoreData.pillarScores.map((ps) => {
+ const pct = (ps.averageScore / 5) * 100;
+ return (
+
+
{ps.pillarName}
+
+
+ {ps.averageScore.toFixed(1)}
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* Gap analysis */}
+ {roadmapData && (
+
+
Gap Analysis
+
+
+ )}
+
+ {/* Audit timeline */}
+
+
Compliance Timeline
+
+
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/NistCompliance/index.ts b/src/UILayer/web/src/components/widgets/NistCompliance/index.ts
new file mode 100644
index 00000000..0656ee76
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/NistCompliance/index.ts
@@ -0,0 +1,4 @@
+export { default as NistComplianceDashboard } from './NistComplianceDashboard';
+export { default as MaturityGauge } from './MaturityGauge';
+export { default as GapAnalysisTable } from './GapAnalysisTable';
+export { default as ComplianceTimeline } from './ComplianceTimeline';
diff --git a/src/UILayer/web/src/components/widgets/OrgMesh/MeshTopology.tsx b/src/UILayer/web/src/components/widgets/OrgMesh/MeshTopology.tsx
new file mode 100644
index 00000000..114473a7
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/OrgMesh/MeshTopology.tsx
@@ -0,0 +1,100 @@
+'use client';
+
+import React from 'react';
+
+interface MeshNode {
+ nodeId: string;
+ label: string;
+ type: 'agent' | 'team' | 'department' | 'external';
+}
+
+interface MeshConnection {
+ from: string;
+ to: string;
+ strength: number;
+}
+
+interface MeshTopologyProps {
+ /** Nodes in the organizational mesh. */
+ nodes: MeshNode[];
+ /** Connections between nodes. */
+ connections: MeshConnection[];
+}
+
+function getNodeColor(type: MeshNode['type']): string {
+ switch (type) {
+ case 'agent':
+ return 'border-cyan-500 bg-cyan-500/20 text-cyan-400';
+ case 'team':
+ return 'border-blue-500 bg-blue-500/20 text-blue-400';
+ case 'department':
+ return 'border-purple-500 bg-purple-500/20 text-purple-400';
+ case 'external':
+ return 'border-orange-500 bg-orange-500/20 text-orange-400';
+ default:
+ return 'border-gray-500 bg-gray-500/20 text-gray-400';
+ }
+}
+
+/**
+ * Network graph visualization of organizational mesh connections.
+ *
+ * Renders a simplified CSS grid topology view. A full D3/WebGL network
+ * graph will be implemented when the backend API provides real-time data.
+ */
+export default function MeshTopology({ nodes, connections }: MeshTopologyProps) {
+ if (nodes.length === 0) {
+ return No mesh topology data available.
;
+ }
+
+ return (
+
+ {/* Node grid */}
+
+ {nodes.map((node) => {
+ const nodeConnections = connections.filter(
+ (c) => c.from === node.nodeId || c.to === node.nodeId
+ );
+ return (
+
+
{node.label}
+
{node.type}
+
+ {nodeConnections.length} connection{nodeConnections.length !== 1 ? 's' : ''}
+
+
+ );
+ })}
+
+
+ {/* Connection list */}
+ {connections.length > 0 && (
+
+
Connections
+
+ {connections.slice(0, 10).map((conn, idx) => {
+ const fromNode = nodes.find((n) => n.nodeId === conn.from);
+ const toNode = nodes.find((n) => n.nodeId === conn.to);
+ return (
+
+
{fromNode?.label ?? conn.from}
+
→
+
{toNode?.label ?? conn.to}
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/OrgMesh/OrgMeshDashboard.tsx b/src/UILayer/web/src/components/widgets/OrgMesh/OrgMeshDashboard.tsx
new file mode 100644
index 00000000..0990f51a
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/OrgMesh/OrgMeshDashboard.tsx
@@ -0,0 +1,81 @@
+'use client';
+
+import React from 'react';
+import MeshTopology from './MeshTopology';
+
+/**
+ * FE-020: Org Mesh Dashboard widget.
+ *
+ * Visualizes the organizational mesh — relationships between agents, teams,
+ * departments, and external connections within the Cognitive Mesh platform.
+ * Currently renders a placeholder layout since the backend API is not yet deployed.
+ */
+export default function OrgMeshDashboard() {
+ return (
+
+ {/* Header */}
+
+
Organization Mesh
+
+ Organizational topology, agent relationships, and collaboration networks
+
+
+
+ {/* Coming soon banner */}
+
+
+ Organization Mesh data will be available when the backend API is deployed.
+
+
+ This dashboard will visualize the relationships between agents, teams,
+ and departments, showing communication patterns and collaboration strength.
+
+
+
+ {/* Metrics row */}
+
+ {[
+ { label: 'Total Nodes', value: '--' },
+ { label: 'Active Connections', value: '--' },
+ { label: 'Network Density', value: '--' },
+ { label: 'Clusters', value: '--' },
+ ].map((metric) => (
+
+
{metric.label}
+
{metric.value}
+
+ ))}
+
+
+ {/* Topology placeholder */}
+
+
Mesh Topology
+
+
+
+ {/* Node types legend */}
+
+
Node Types
+
+ {[
+ { type: 'Agent', color: 'bg-cyan-500', description: 'AI agents in the mesh' },
+ { type: 'Team', color: 'bg-blue-500', description: 'Human teams' },
+ { type: 'Department', color: 'bg-purple-500', description: 'Organization units' },
+ { type: 'External', color: 'bg-orange-500', description: 'External integrations' },
+ ].map((item) => (
+
+
+
+
{item.type}
+
{item.description}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/OrgMesh/index.ts b/src/UILayer/web/src/components/widgets/OrgMesh/index.ts
new file mode 100644
index 00000000..12cac9ff
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/OrgMesh/index.ts
@@ -0,0 +1,2 @@
+export { default as OrgMeshDashboard } from './OrgMeshDashboard';
+export { default as MeshTopology } from './MeshTopology';
diff --git a/src/UILayer/web/src/components/widgets/ValueGeneration/BlindnessHeatmap.tsx b/src/UILayer/web/src/components/widgets/ValueGeneration/BlindnessHeatmap.tsx
new file mode 100644
index 00000000..ff3df4aa
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/ValueGeneration/BlindnessHeatmap.tsx
@@ -0,0 +1,60 @@
+'use client';
+
+import React from 'react';
+
+interface BlindnessHeatmapProps {
+ /** Risk score 0-1. */
+ riskScore: number;
+ /** Identified blind spots. */
+ blindSpots: string[];
+}
+
+function severityClass(index: number, total: number): string {
+ const pct = total > 0 ? index / total : 0;
+ if (pct < 0.33) return 'bg-red-500/80';
+ if (pct < 0.66) return 'bg-orange-500/70';
+ return 'bg-yellow-500/60';
+}
+
+/**
+ * Heatmap-style visualization of organizational blind spots.
+ * Each blind spot is rendered as a tile whose color intensity maps to
+ * its relative severity (position in the ordered list from the backend).
+ */
+export default function BlindnessHeatmap({ riskScore, blindSpots }: BlindnessHeatmapProps) {
+ if (blindSpots.length === 0) {
+ return No blind spots detected.
;
+ }
+
+ return (
+
+ {/* Risk badge */}
+
+ Blindness Risk Score
+ 0.6
+ ? 'bg-red-500/20 text-red-400'
+ : riskScore > 0.3
+ ? 'bg-yellow-500/20 text-yellow-400'
+ : 'bg-green-500/20 text-green-400'
+ }`}
+ >
+ {(riskScore * 100).toFixed(0)}%
+
+
+
+ {/* Blind spot tiles */}
+
+ {blindSpots.map((spot, i) => (
+
+ {spot}
+
+ ))}
+
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/ValueGeneration/ValueGenerationDashboard.tsx b/src/UILayer/web/src/components/widgets/ValueGeneration/ValueGenerationDashboard.tsx
new file mode 100644
index 00000000..1b588242
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/ValueGeneration/ValueGenerationDashboard.tsx
@@ -0,0 +1,183 @@
+'use client';
+
+import React, { useState, useEffect, useCallback } from 'react';
+import ValueRadarChart from './ValueRadarChart';
+import BlindnessHeatmap from './BlindnessHeatmap';
+import { runValueDiagnostic, detectOrgBlindness } from '../api';
+import type { ValueDiagnosticResponse, OrgBlindnessDetectionResponse } from '../types';
+
+interface ValueGenerationDashboardProps {
+ targetId?: string;
+ targetType?: string;
+ organizationId?: string;
+ tenantId?: string;
+}
+
+/**
+ * FE-013: Value Generation Dashboard widget.
+ *
+ * Displays value diagnostic results with a radar chart of value dimensions,
+ * and an organizational blindness heatmap sourced from the
+ * ValueGenerationController API.
+ */
+export default function ValueGenerationDashboard({
+ targetId = 'current-user',
+ targetType = 'User',
+ organizationId = 'default-org',
+ tenantId = 'default-tenant',
+}: ValueGenerationDashboardProps) {
+ const [diagnostic, setDiagnostic] = useState(null);
+ const [blindness, setBlindness] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchData = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const [diag, blind] = await Promise.all([
+ runValueDiagnostic(targetId, targetType, tenantId),
+ detectOrgBlindness(organizationId, tenantId),
+ ]);
+ setDiagnostic(diag);
+ setBlindness(blind);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : 'Failed to load value generation data.';
+ setError(msg);
+ } finally {
+ setLoading(false);
+ }
+ }, [targetId, targetType, organizationId, tenantId]);
+
+ useEffect(() => {
+ void fetchData();
+ }, [fetchData]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
Error loading value data
+
{error}
+
+
+ );
+ }
+
+ // Build radar data from diagnostic
+ const radarAxes: string[] = [];
+ const radarValues: number[] = [];
+ if (diagnostic) {
+ radarAxes.push('Score');
+ radarValues.push(Math.min(diagnostic.valueScore, 100));
+ diagnostic.strengths.forEach((s) => {
+ radarAxes.push(s);
+ radarValues.push(80); // strengths are inherently high
+ });
+ diagnostic.developmentOpportunities.forEach((d) => {
+ radarAxes.push(d);
+ radarValues.push(35); // opportunities are inherently low
+ });
+ }
+
+ return (
+
+ {/* Header */}
+
+
Value Generation
+
+
+
+ {/* Value diagnostic summary */}
+ {diagnostic && (
+
+
Diagnostic Summary
+
+
+
Value Score
+
{diagnostic.valueScore}
+
+
+
Profile
+
{diagnostic.valueProfile}
+
+
+
Strengths
+
{diagnostic.strengths.length}
+
+
+
Opportunities
+
+ {diagnostic.developmentOpportunities.length}
+
+
+
+
+ )}
+
+ {/* Radar chart */}
+ {radarAxes.length > 0 && (
+
+
Value Dimensions
+
+
+ )}
+
+ {/* Strengths & opportunities */}
+ {diagnostic && (
+
+
+
Strengths
+
+ {diagnostic.strengths.map((s, i) => (
+ -
+ {s}
+
+ ))}
+
+
+
+
Development Opportunities
+
+ {diagnostic.developmentOpportunities.map((d, i) => (
+ -
+ {d}
+
+ ))}
+
+
+
+ )}
+
+ {/* Organizational blindness */}
+ {blindness && (
+
+
Organizational Blindness
+
+
+ )}
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/ValueGeneration/ValueRadarChart.tsx b/src/UILayer/web/src/components/widgets/ValueGeneration/ValueRadarChart.tsx
new file mode 100644
index 00000000..a7b81872
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/ValueGeneration/ValueRadarChart.tsx
@@ -0,0 +1,125 @@
+'use client';
+
+import React, { useEffect, useCallback } from 'react';
+import * as d3 from 'd3';
+import { useD3 } from '@/lib/visualizations/useD3';
+
+interface ValueRadarChartProps {
+ /** Labels for each axis of the radar. */
+ axes: string[];
+ /** Values for each axis (0-100 scale). */
+ values: number[];
+}
+
+/**
+ * D3 radar / spider chart rendering value dimensions.
+ */
+export default function ValueRadarChart({ axes, values }: ValueRadarChartProps) {
+ const render = useCallback(
+ (width: number, height: number) => {
+ const svg = d3.select(svgRef.current);
+ if (!svg.node()) return;
+ svg.selectAll('*').remove();
+
+ if (axes.length === 0) {
+ svg
+ .append('text')
+ .attr('x', width / 2)
+ .attr('y', height / 2)
+ .attr('text-anchor', 'middle')
+ .attr('fill', 'rgb(107,114,128)')
+ .attr('font-size', 12)
+ .text('No data for radar chart.');
+ return;
+ }
+
+ const cx = width / 2;
+ const cy = height / 2;
+ const maxRadius = Math.min(cx, cy) - 40;
+ const numAxes = axes.length;
+ const angleSlice = (Math.PI * 2) / numAxes;
+
+ const rScale = d3.scaleLinear().domain([0, 100]).range([0, maxRadius]);
+
+ const g = svg.append('g').attr('transform', `translate(${cx},${cy})`);
+
+ // Grid circles
+ const levels = 4;
+ for (let lvl = 1; lvl <= levels; lvl++) {
+ const r = (maxRadius / levels) * lvl;
+ g.append('circle')
+ .attr('r', r)
+ .attr('fill', 'none')
+ .attr('stroke', 'rgba(255,255,255,0.1)')
+ .attr('stroke-dasharray', '3,3');
+ }
+
+ // Axis lines + labels
+ axes.forEach((label, i) => {
+ const angle = angleSlice * i - Math.PI / 2;
+ const xEnd = maxRadius * Math.cos(angle);
+ const yEnd = maxRadius * Math.sin(angle);
+ g.append('line')
+ .attr('x1', 0)
+ .attr('y1', 0)
+ .attr('x2', xEnd)
+ .attr('y2', yEnd)
+ .attr('stroke', 'rgba(255,255,255,0.1)');
+
+ const labelDist = maxRadius + 16;
+ g.append('text')
+ .attr('x', labelDist * Math.cos(angle))
+ .attr('y', labelDist * Math.sin(angle))
+ .attr('text-anchor', 'middle')
+ .attr('dominant-baseline', 'central')
+ .attr('fill', 'rgb(156,163,175)')
+ .attr('font-size', 10)
+ .text(label);
+ });
+
+ // Data polygon
+ const lineGen = d3
+ .lineRadial()
+ .angle((_, i) => angleSlice * i)
+ .radius((d) => rScale(d))
+ .curve(d3.curveLinearClosed);
+
+ g.append('path')
+ .datum(values)
+ .attr('d', lineGen)
+ .attr('fill', 'rgba(59,130,246,0.25)')
+ .attr('stroke', '#3b82f6')
+ .attr('stroke-width', 2);
+
+ // Data dots
+ values.forEach((val, i) => {
+ const angle = angleSlice * i - Math.PI / 2;
+ g.append('circle')
+ .attr('cx', rScale(val) * Math.cos(angle))
+ .attr('cy', rScale(val) * Math.sin(angle))
+ .attr('r', 4)
+ .attr('fill', '#3b82f6');
+ });
+ },
+ [axes, values],
+ );
+
+ const { svgRef } = useD3(render);
+
+ useEffect(() => {
+ const el = svgRef.current;
+ if (el) {
+ const { width, height } = el.getBoundingClientRect();
+ if (width > 0 && height > 0) render(width, height);
+ }
+ }, [render, svgRef]);
+
+ return (
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/ValueGeneration/index.ts b/src/UILayer/web/src/components/widgets/ValueGeneration/index.ts
new file mode 100644
index 00000000..6d182b08
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/ValueGeneration/index.ts
@@ -0,0 +1,3 @@
+export { default as ValueGenerationDashboard } from './ValueGenerationDashboard';
+export { default as ValueRadarChart } from './ValueRadarChart';
+export { default as BlindnessHeatmap } from './BlindnessHeatmap';
diff --git a/src/UILayer/web/src/components/widgets/api.ts b/src/UILayer/web/src/components/widgets/api.ts
new file mode 100644
index 00000000..0dd6fdff
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/api.ts
@@ -0,0 +1,145 @@
+/**
+ * API helper for Phase 15b widget dashboards.
+ *
+ * These endpoints are not yet in the auto-generated OpenAPI types, so we use
+ * a typed fetch wrapper that reads the same NEXT_PUBLIC_API_BASE_URL env var
+ * as the openapi-fetch client in `@/lib/api/client`.
+ *
+ * Once the OpenAPI spec is regenerated, migrate callers to `servicesApi.GET(...)`.
+ */
+
+function getApiBaseUrl(): string {
+ const url = process.env.NEXT_PUBLIC_API_BASE_URL;
+ if (url) return url;
+ return 'http://localhost:5000';
+}
+
+const BASE = getApiBaseUrl();
+
+async function fetchJson(path: string, init?: RequestInit): Promise {
+ const res = await fetch(`${BASE}${path}`, {
+ headers: { 'Content-Type': 'application/json', ...init?.headers },
+ ...init,
+ });
+ if (!res.ok) {
+ const text = await res.text().catch(() => res.statusText);
+ throw new Error(`API ${res.status}: ${text}`);
+ }
+ return res.json() as Promise;
+}
+
+// ───────────────────────────── NIST Compliance ─────────────────────────────
+
+import type {
+ NISTScoreResponse,
+ NISTRoadmapResponse,
+ NISTChecklistResponse,
+ NISTAuditLogResponse,
+ BalanceResponse,
+ SpectrumHistoryResponse,
+ ReflexionStatusResponse,
+ ValueDiagnosticResponse,
+ OrgBlindnessDetectionResponse,
+ PsychologicalSafetyScore,
+ ImpactReport,
+ ResistanceIndicator,
+ SandwichProcess,
+ PhaseAuditEntry,
+ CognitiveDebtAssessment,
+} from './types';
+
+export async function getNistScore(organizationId: string): Promise {
+ return fetchJson(`/api/v1/nist-compliance/organizations/${encodeURIComponent(organizationId)}/score`);
+}
+
+export async function getNistRoadmap(organizationId: string): Promise {
+ return fetchJson(`/api/v1/nist-compliance/organizations/${encodeURIComponent(organizationId)}/roadmap`);
+}
+
+export async function getNistChecklist(organizationId: string): Promise {
+ return fetchJson(`/api/v1/nist-compliance/organizations/${encodeURIComponent(organizationId)}/checklist`);
+}
+
+export async function getNistAuditLog(organizationId: string, maxResults = 50): Promise {
+ return fetchJson(`/api/v1/nist-compliance/organizations/${encodeURIComponent(organizationId)}/audit-log?maxResults=${maxResults}`);
+}
+
+// ──────────────────────────── Adaptive Balance ──────────────────────────────
+
+export async function getAdaptiveBalance(context: Record = {}): Promise {
+ return fetchJson('/api/v1/adaptive-balance/balance', {
+ method: 'POST',
+ body: JSON.stringify({ context }),
+ });
+}
+
+export async function getSpectrumHistory(dimension: string): Promise {
+ return fetchJson(`/api/v1/adaptive-balance/history/${encodeURIComponent(dimension)}`);
+}
+
+export async function getReflexionStatus(): Promise {
+ return fetchJson('/api/v1/adaptive-balance/reflexion-status');
+}
+
+// ─────────────────────────── Value Generation ───────────────────────────────
+
+export async function runValueDiagnostic(
+ targetId: string,
+ targetType: string,
+ tenantId: string,
+): Promise {
+ return fetchJson('/api/v1/ValueGeneration/value-diagnostic', {
+ method: 'POST',
+ body: JSON.stringify({ targetId, targetType, tenantId }),
+ });
+}
+
+export async function detectOrgBlindness(
+ organizationId: string,
+ tenantId: string,
+ departmentFilters: string[] = [],
+): Promise {
+ return fetchJson('/api/v1/ValueGeneration/org-blindness/detect', {
+ method: 'POST',
+ body: JSON.stringify({ organizationId, tenantId, departmentFilters }),
+ });
+}
+
+// ──────────────────────────── Impact Metrics ────────────────────────────────
+
+export async function getSafetyScoreHistory(
+ teamId: string,
+ tenantId: string,
+): Promise {
+ return fetchJson(`/api/v1/impact-metrics/safety-score/${encodeURIComponent(teamId)}/history?tenantId=${encodeURIComponent(tenantId)}`);
+}
+
+export async function getImpactReport(
+ tenantId: string,
+ periodStart?: string,
+ periodEnd?: string,
+): Promise {
+ const params = new URLSearchParams();
+ if (periodStart) params.set('periodStart', periodStart);
+ if (periodEnd) params.set('periodEnd', periodEnd);
+ const qs = params.toString();
+ return fetchJson(`/api/v1/impact-metrics/report/${encodeURIComponent(tenantId)}${qs ? `?${qs}` : ''}`);
+}
+
+export async function getResistancePatterns(tenantId: string): Promise {
+ return fetchJson(`/api/v1/impact-metrics/telemetry/${encodeURIComponent(tenantId)}/resistance`);
+}
+
+// ────────────────────────── Cognitive Sandwich ──────────────────────────────
+
+export async function getSandwichProcess(processId: string): Promise {
+ return fetchJson(`/api/v1/cognitive-sandwich/${encodeURIComponent(processId)}`);
+}
+
+export async function getSandwichAuditTrail(processId: string): Promise {
+ return fetchJson(`/api/v1/cognitive-sandwich/${encodeURIComponent(processId)}/audit`);
+}
+
+export async function getSandwichDebt(processId: string): Promise {
+ return fetchJson(`/api/v1/cognitive-sandwich/${encodeURIComponent(processId)}/debt`);
+}
diff --git a/src/UILayer/web/src/components/widgets/types.ts b/src/UILayer/web/src/components/widgets/types.ts
new file mode 100644
index 00000000..a02e5433
--- /dev/null
+++ b/src/UILayer/web/src/components/widgets/types.ts
@@ -0,0 +1,224 @@
+/**
+ * Shared TypeScript types for Phase 15b widget dashboards.
+ *
+ * These types mirror the C# backend models from the corresponding controllers
+ * (NISTComplianceController, AdaptiveBalanceController, ValueGenerationController,
+ * ImpactMetricsController, CognitiveSandwichController).
+ *
+ * Once the OpenAPI spec is regenerated to include these endpoints, these types
+ * can be replaced by the auto-generated ones from `services.d.ts`.
+ */
+
+// ───────────────────────────── NIST Compliance ─────────────────────────────
+
+export interface NISTChecklistPillarScore {
+ pillarId: string;
+ pillarName: string;
+ averageScore: number;
+ statementCount: number;
+}
+
+export interface NISTScoreResponse {
+ organizationId: string;
+ overallScore: number;
+ pillarScores: NISTChecklistPillarScore[];
+ assessedAt: string;
+}
+
+export interface NISTGapItem {
+ statementId: string;
+ currentScore: number;
+ targetScore: number;
+ priority: string;
+ recommendedActions: string[];
+}
+
+export interface NISTRoadmapResponse {
+ organizationId: string;
+ gaps: NISTGapItem[];
+ generatedAt: string;
+}
+
+export interface NISTChecklistStatement {
+ statementId: string;
+ text: string;
+ status: string;
+ evidenceCount: number;
+}
+
+export interface NISTChecklistPillar {
+ pillarId: string;
+ pillarName: string;
+ statements: NISTChecklistStatement[];
+}
+
+export interface NISTChecklistResponse {
+ organizationId: string;
+ pillars: NISTChecklistPillar[];
+ totalStatements: number;
+ completedStatements: number;
+}
+
+export interface NISTAuditEntry {
+ entryId: string;
+ action: string;
+ performedBy: string;
+ performedAt: string;
+ details: string;
+}
+
+export interface NISTAuditLogResponse {
+ entries: NISTAuditEntry[];
+}
+
+// ──────────────────────────── Adaptive Balance ──────────────────────────────
+
+export interface SpectrumDimensionResult {
+ dimension: string;
+ value: number;
+ lowerBound: number;
+ upperBound: number;
+ rationale: string;
+}
+
+export interface BalanceResponse {
+ dimensions: SpectrumDimensionResult[];
+ overallConfidence: number;
+ generatedAt: string;
+}
+
+export interface SpectrumHistoryEntry {
+ value: number;
+ timestamp: string;
+ source: string;
+}
+
+export interface SpectrumHistoryResponse {
+ dimension: string;
+ history: SpectrumHistoryEntry[];
+}
+
+export interface ReflexionStatusEntry {
+ evaluationId: string;
+ result: string;
+ confidence: number;
+ timestamp: string;
+}
+
+export interface ReflexionStatusResponse {
+ recentResults: ReflexionStatusEntry[];
+ hallucinationRate: number;
+ averageConfidence: number;
+}
+
+// ─────────────────────────── Value Generation ───────────────────────────────
+
+export interface ValueDiagnosticResponse {
+ valueScore: number;
+ valueProfile: string;
+ strengths: string[];
+ developmentOpportunities: string[];
+}
+
+export interface OrgBlindnessDetectionResponse {
+ blindnessRiskScore: number;
+ identifiedBlindSpots: string[];
+}
+
+// ──────────────────────────── Impact Metrics ────────────────────────────────
+
+export type SafetyDimension =
+ | 'TrustInAI'
+ | 'FearOfReplacement'
+ | 'ComfortWithAutomation'
+ | 'WillingnessToExperiment'
+ | 'TransparencyPerception'
+ | 'ErrorTolerance';
+
+export interface PsychologicalSafetyScore {
+ scoreId: string;
+ teamId: string;
+ tenantId: string;
+ overallScore: number;
+ dimensions: Record;
+ surveyResponseCount: number;
+ behavioralSignalCount: number;
+ calculatedAt: string;
+ confidenceLevel: string;
+}
+
+export interface ImpactReport {
+ reportId: string;
+ tenantId: string;
+ periodStart: string;
+ periodEnd: string;
+ safetyScore: number;
+ alignmentScore: number;
+ adoptionRate: number;
+ overallImpactScore: number;
+ recommendations: string[];
+ generatedAt: string;
+}
+
+export interface ResistanceIndicator {
+ indicatorId: string;
+ pattern: string;
+ severity: string;
+ affectedUsers: number;
+ detectedAt: string;
+}
+
+export interface ImpactAssessment {
+ assessmentId: string;
+ tenantId: string;
+ periodStart: string;
+ periodEnd: string;
+ productivityDelta: number;
+ qualityDelta: number;
+ timeToDecisionDelta: number;
+ userSatisfactionScore: number;
+ adoptionRate: number;
+ resistanceIndicators: ResistanceIndicator[];
+}
+
+// ────────────────────────── Cognitive Sandwich ──────────────────────────────
+
+export interface Phase {
+ phaseId: string;
+ phaseName: string;
+ phaseType: string;
+ status: string;
+ order: number;
+}
+
+export interface SandwichProcess {
+ processId: string;
+ tenantId: string;
+ name: string;
+ createdAt: string;
+ currentPhaseIndex: number;
+ phases: Phase[];
+ state: string;
+ maxStepBacks: number;
+ stepBackCount: number;
+ cognitiveDebtThreshold: number;
+}
+
+export interface PhaseAuditEntry {
+ entryId: string;
+ processId: string;
+ phaseId: string;
+ eventType: string;
+ timestamp: string;
+ userId: string;
+ details: string;
+}
+
+export interface CognitiveDebtAssessment {
+ processId: string;
+ phaseId: string;
+ debtScore: number;
+ isBreached: boolean;
+ recommendations: string[];
+ assessedAt: string;
+}
diff --git a/src/UILayer/web/src/contexts/AuthContext.test.tsx b/src/UILayer/web/src/contexts/AuthContext.test.tsx
new file mode 100644
index 00000000..c328fc43
--- /dev/null
+++ b/src/UILayer/web/src/contexts/AuthContext.test.tsx
@@ -0,0 +1,261 @@
+import React from "react";
+import { render, screen, act, waitFor } from "@testing-library/react";
+import { AuthProvider, useAuth } from "./AuthContext";
+
+// Mock the API client module
+jest.mock("@/lib/api/client", () => ({
+ setAuthToken: jest.fn(),
+ clearAuthToken: jest.fn(),
+}));
+
+import { setAuthToken, clearAuthToken } from "@/lib/api/client";
+
+// Helper to create a fake JWT
+function createFakeJwt(payload: Record): string {
+ const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }));
+ const body = btoa(JSON.stringify(payload));
+ const signature = "fake-signature";
+ return `${header}.${body}.${signature}`;
+}
+
+// Unexpired token (expires in 1 hour)
+function validToken(overrides: Record = {}) {
+ return createFakeJwt({
+ sub: "user-1",
+ email: "test@example.com",
+ name: "Test User",
+ tenant_id: "tenant-1",
+ roles: ["admin"],
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ ...overrides,
+ });
+}
+
+// Expired token
+function expiredToken() {
+ return createFakeJwt({
+ sub: "user-1",
+ email: "test@example.com",
+ name: "Test User",
+ tenant_id: "tenant-1",
+ roles: [],
+ exp: Math.floor(Date.now() / 1000) - 3600,
+ });
+}
+
+// Test component that exposes auth context
+function AuthConsumer() {
+ const { user, isAuthenticated, isLoading, login, logout } = useAuth();
+ return (
+
+ {String(isLoading)}
+ {String(isAuthenticated)}
+ {user?.email ?? "none"}
+ {user?.name ?? "none"}
+
+
+
+ );
+}
+
+// Reset between tests
+beforeEach(() => {
+ localStorage.clear();
+ document.cookie = "cm_access_token=; path=/; max-age=0";
+ jest.clearAllMocks();
+ (global.fetch as jest.Mock)?.mockReset?.();
+ global.fetch = jest.fn();
+});
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+describe("AuthContext", () => {
+ it("should throw when useAuth is used outside AuthProvider", () => {
+ // Suppress React error boundary console output
+ const spy = jest.spyOn(console, "error").mockImplementation(() => {});
+ expect(() => render()).toThrow(
+ "useAuth must be used within an AuthProvider"
+ );
+ spy.mockRestore();
+ });
+
+ it("should start with isLoading true and isAuthenticated false", () => {
+ render(
+
+
+
+ );
+ // Initially loading
+ expect(screen.getByTestId("authenticated").textContent).toBe("false");
+ });
+
+ it("should restore session from localStorage when token is valid", async () => {
+ const token = validToken();
+ localStorage.setItem("cm_access_token", token);
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("authenticated").textContent).toBe("true");
+ });
+ expect(screen.getByTestId("user-email").textContent).toBe(
+ "test@example.com"
+ );
+ expect(setAuthToken).toHaveBeenCalledWith(token);
+ });
+
+ it("should not authenticate when stored token is expired and no refresh token", async () => {
+ const token = expiredToken();
+ localStorage.setItem("cm_access_token", token);
+ // No refresh token stored
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("loading").textContent).toBe("false");
+ });
+ expect(screen.getByTestId("authenticated").textContent).toBe("false");
+ });
+
+ it("should login successfully and set user state", async () => {
+ const token = validToken();
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ accessToken: token,
+ refreshToken: "refresh-token-123",
+ }),
+ });
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("loading").textContent).toBe("false");
+ });
+
+ await act(async () => {
+ screen.getByTestId("login-btn").click();
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId("authenticated").textContent).toBe("true");
+ });
+ expect(screen.getByTestId("user-email").textContent).toBe(
+ "test@example.com"
+ );
+ expect(localStorage.getItem("cm_access_token")).toBe(token);
+ expect(localStorage.getItem("cm_refresh_token")).toBe("refresh-token-123");
+ });
+
+ it("should logout and clear state", async () => {
+ const token = validToken();
+ localStorage.setItem("cm_access_token", token);
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("authenticated").textContent).toBe("true");
+ });
+
+ await act(async () => {
+ screen.getByTestId("logout-btn").click();
+ });
+
+ expect(screen.getByTestId("authenticated").textContent).toBe("false");
+ expect(screen.getByTestId("user-email").textContent).toBe("none");
+ expect(localStorage.getItem("cm_access_token")).toBeNull();
+ expect(clearAuthToken).toHaveBeenCalled();
+ });
+
+ it("should throw on login failure", async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({
+ ok: false,
+ json: async () => ({ message: "Invalid credentials" }),
+ });
+
+ let loginError: Error | null = null;
+
+ function LoginErrorConsumer() {
+ const { login, isLoading } = useAuth();
+ return (
+
+ );
+ }
+
+ render(
+
+
+
+ );
+
+ // Wait for initial loading to complete
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 50));
+ });
+
+ await act(async () => {
+ screen.getByTestId("login-err").click();
+ });
+
+ expect(loginError).not.toBeNull();
+ expect(loginError!.message).toBe("Invalid credentials");
+ });
+
+ it("should extract roles from JWT payload", async () => {
+ const token = validToken({ roles: ["admin", "viewer"] });
+ localStorage.setItem("cm_access_token", token);
+
+ function RoleConsumer() {
+ const { user } = useAuth();
+ return (
+ {user?.roles?.join(",") ?? "none"}
+ );
+ }
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("roles").textContent).toBe("admin,viewer");
+ });
+ });
+});
diff --git a/src/UILayer/web/src/contexts/AuthContext.tsx b/src/UILayer/web/src/contexts/AuthContext.tsx
index 27148481..e8599419 100644
--- a/src/UILayer/web/src/contexts/AuthContext.tsx
+++ b/src/UILayer/web/src/contexts/AuthContext.tsx
@@ -68,11 +68,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
})
const applyToken = useCallback((accessToken: string) => {
+ if (isTokenExpired(accessToken)) return false
const user = extractUser(accessToken)
if (!user) return false
localStorage.setItem(TOKEN_KEY, accessToken)
// Also set cookie so Next.js middleware can check auth server-side
- document.cookie = `cm_access_token=${accessToken}; path=/; max-age=86400; SameSite=Lax`
+ const secure = typeof window !== "undefined" && window.location.protocol === "https:" ? "; Secure" : ""
+ document.cookie = `cm_access_token=${accessToken}; path=/; max-age=86400; SameSite=Lax${secure}`
setAuthToken(accessToken)
setState({ user, isAuthenticated: true, isLoading: false })
return true
@@ -96,6 +98,34 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
}, [applyToken])
+ const login = useCallback(
+ async (email: string, password: string) => {
+ const res = await fetch(`${API_BASE_URL}/api/v1/auth/login`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email, password }),
+ })
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ message: "Login failed" }))
+ throw new Error(err.message ?? "Login failed")
+ }
+ const data = await res.json()
+ localStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken)
+ if (!applyToken(data.accessToken)) {
+ throw new Error("Invalid token received")
+ }
+ },
+ [applyToken],
+ )
+
+ const logout = useCallback(() => {
+ localStorage.removeItem(TOKEN_KEY)
+ localStorage.removeItem(REFRESH_TOKEN_KEY)
+ document.cookie = "cm_access_token=; path=/; max-age=0"
+ clearAuthToken()
+ setState({ user: null, isAuthenticated: false, isLoading: false })
+ }, [])
+
// Restore session on mount
useEffect(() => {
const token = localStorage.getItem(TOKEN_KEY)
@@ -135,35 +165,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
})
}, refreshIn)
return () => clearTimeout(timer)
- }, [state.isAuthenticated, state.user, refreshToken])
-
- const login = useCallback(
- async (email: string, password: string) => {
- const res = await fetch(`${API_BASE_URL}/api/v1/auth/login`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ email, password }),
- })
- if (!res.ok) {
- const err = await res.json().catch(() => ({ message: "Login failed" }))
- throw new Error(err.message ?? "Login failed")
- }
- const data = await res.json()
- localStorage.setItem(REFRESH_TOKEN_KEY, data.refreshToken)
- if (!applyToken(data.accessToken)) {
- throw new Error("Invalid token received")
- }
- },
- [applyToken],
- )
-
- const logout = useCallback(() => {
- localStorage.removeItem(TOKEN_KEY)
- localStorage.removeItem(REFRESH_TOKEN_KEY)
- document.cookie = "cm_access_token=; path=/; max-age=0"
- clearAuthToken()
- setState({ user: null, isAuthenticated: false, isLoading: false })
- }, [])
+ }, [state.isAuthenticated, state.user, refreshToken, logout])
const value = useMemo(
() => ({ ...state, login, logout, refreshToken }),
diff --git a/src/UILayer/web/components/ui/use-mobile.tsx b/src/UILayer/web/src/hooks/use-mobile.tsx
similarity index 100%
rename from src/UILayer/web/components/ui/use-mobile.tsx
rename to src/UILayer/web/src/hooks/use-mobile.tsx
diff --git a/src/UILayer/web/src/hooks/use-toast.test.ts b/src/UILayer/web/src/hooks/use-toast.test.ts
new file mode 100644
index 00000000..0007ab31
--- /dev/null
+++ b/src/UILayer/web/src/hooks/use-toast.test.ts
@@ -0,0 +1,122 @@
+/**
+ * Tests for the toast reducer and dispatch logic.
+ *
+ * The use-toast module exports a pure `reducer` function that can be tested
+ * directly, plus `toast` and `useToast` for integration.
+ */
+
+// Mock the toast component types since we only need the reducer
+jest.mock("@/components/ui/toast", () => ({}));
+
+import { reducer, toast } from "./use-toast";
+
+describe("toast reducer", () => {
+ it("should add a toast to an empty state", () => {
+ const state = { toasts: [] };
+ const result = reducer(state, {
+ type: "ADD_TOAST",
+ toast: { id: "1", open: true, title: "Hello" } as never,
+ });
+ expect(result.toasts).toHaveLength(1);
+ expect(result.toasts[0].id).toBe("1");
+ });
+
+ it("should limit toasts to TOAST_LIMIT (1)", () => {
+ const state = { toasts: [] };
+ let result = reducer(state, {
+ type: "ADD_TOAST",
+ toast: { id: "1", open: true, title: "First" } as never,
+ });
+ result = reducer(result, {
+ type: "ADD_TOAST",
+ toast: { id: "2", open: true, title: "Second" } as never,
+ });
+ // Only 1 toast should remain (the newest)
+ expect(result.toasts).toHaveLength(1);
+ expect(result.toasts[0].id).toBe("2");
+ });
+
+ it("should update an existing toast by id", () => {
+ const state = {
+ toasts: [{ id: "1", open: true, title: "Original" } as never],
+ };
+ const result = reducer(state, {
+ type: "UPDATE_TOAST",
+ toast: { id: "1", title: "Updated" },
+ });
+ expect(result.toasts).toHaveLength(1);
+ expect((result.toasts[0] as { title: string }).title).toBe("Updated");
+ });
+
+ it("should not affect other toasts when updating", () => {
+ // Even though TOAST_LIMIT is 1, test update logic with a manually crafted state
+ const state = {
+ toasts: [{ id: "1", open: true, title: "Keep" } as never],
+ };
+ const result = reducer(state, {
+ type: "UPDATE_TOAST",
+ toast: { id: "999", title: "No match" },
+ });
+ expect((result.toasts[0] as { title: string }).title).toBe("Keep");
+ });
+
+ it("should set open to false when dismissing a specific toast", () => {
+ const state = {
+ toasts: [{ id: "1", open: true, title: "Test" } as never],
+ };
+ const result = reducer(state, {
+ type: "DISMISS_TOAST",
+ toastId: "1",
+ });
+ expect(result.toasts[0].open).toBe(false);
+ });
+
+ it("should dismiss all toasts when no toastId provided", () => {
+ const state = {
+ toasts: [{ id: "1", open: true, title: "Test" } as never],
+ };
+ const result = reducer(state, {
+ type: "DISMISS_TOAST",
+ });
+ expect(result.toasts.every((t) => t.open === false)).toBe(true);
+ });
+
+ it("should remove a specific toast by id", () => {
+ const state = {
+ toasts: [{ id: "1", open: true } as never],
+ };
+ const result = reducer(state, {
+ type: "REMOVE_TOAST",
+ toastId: "1",
+ });
+ expect(result.toasts).toHaveLength(0);
+ });
+
+ it("should remove all toasts when REMOVE_TOAST has no toastId", () => {
+ const state = {
+ toasts: [
+ { id: "1", open: true } as never,
+ ],
+ };
+ const result = reducer(state, {
+ type: "REMOVE_TOAST",
+ toastId: undefined,
+ });
+ expect(result.toasts).toHaveLength(0);
+ });
+});
+
+describe("toast function", () => {
+ it("should return an id, dismiss, and update", () => {
+ const result = toast({ title: "Test toast" } as never);
+ expect(result.id).toBeDefined();
+ expect(typeof result.dismiss).toBe("function");
+ expect(typeof result.update).toBe("function");
+ });
+
+ it("should generate unique ids for each toast", () => {
+ const a = toast({ title: "A" } as never);
+ const b = toast({ title: "B" } as never);
+ expect(a.id).not.toBe(b.id);
+ });
+});
diff --git a/src/UILayer/web/hooks/use-toast.ts b/src/UILayer/web/src/hooks/use-toast.ts
similarity index 99%
rename from src/UILayer/web/hooks/use-toast.ts
rename to src/UILayer/web/src/hooks/use-toast.ts
index 02e111d8..0fdf258f 100644
--- a/src/UILayer/web/hooks/use-toast.ts
+++ b/src/UILayer/web/src/hooks/use-toast.ts
@@ -182,7 +182,7 @@ function useToast() {
listeners.splice(index, 1)
}
}
- }, [state])
+ }, [])
return {
...state,
diff --git a/src/UILayer/web/src/hooks/useDashboardData.ts b/src/UILayer/web/src/hooks/useDashboardData.ts
deleted file mode 100644
index f3972dc1..00000000
--- a/src/UILayer/web/src/hooks/useDashboardData.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { Activity, Agent, Layer, Metric, SystemStatus, dashboardAPI } from '@/services/api';
-import { useCallback, useEffect, useState } from 'react';
-
-interface DashboardData {
- layers: Layer[];
- metrics: Metric[];
- agents: Agent[];
- activities: Activity[];
- systemStatus: SystemStatus;
-}
-
-interface UseDashboardDataReturn {
- data: DashboardData | null;
- loading: boolean;
- error: string | null;
- refetch: () => Promise;
- refreshMetrics: () => Promise;
- refreshActivities: () => Promise;
-}
-
-export const useDashboardData = (): UseDashboardDataReturn => {
- const [data, setData] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
- const fetchAllData = useCallback(async () => {
- try {
- setLoading(true);
- setError(null);
-
- const [layers, metrics, agents, activities, systemStatus] = await Promise.all([
- dashboardAPI.getLayers(),
- dashboardAPI.getMetrics(),
- dashboardAPI.getAgents(),
- dashboardAPI.getActivities(),
- dashboardAPI.getSystemStatus(),
- ]);
-
- setData({
- layers,
- metrics,
- agents,
- activities,
- systemStatus,
- });
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to fetch dashboard data');
- } finally {
- setLoading(false);
- }
- }, []);
-
- const refreshMetrics = useCallback(async () => {
- try {
- const metrics = await dashboardAPI.getMetrics();
- setData(prev => prev ? { ...prev, metrics } : null);
- } catch (err) {
- console.error('Failed to refresh metrics:', err);
- }
- }, []);
-
- const refreshActivities = useCallback(async () => {
- try {
- const activities = await dashboardAPI.getActivities();
- setData(prev => prev ? { ...prev, activities } : null);
- } catch (err) {
- console.error('Failed to refresh activities:', err);
- }
- }, []);
-
- useEffect(() => {
- fetchAllData();
- }, [fetchAllData]);
-
- return {
- data,
- loading,
- error,
- refetch: fetchAllData,
- refreshMetrics,
- refreshActivities,
- };
-};
\ No newline at end of file
diff --git a/src/UILayer/web/src/hooks/useSignalR.ts b/src/UILayer/web/src/hooks/useSignalR.ts
new file mode 100644
index 00000000..c77ee489
--- /dev/null
+++ b/src/UILayer/web/src/hooks/useSignalR.ts
@@ -0,0 +1,131 @@
+"use client"
+
+/**
+ * SignalR hook — establishes and manages the real-time connection to CognitiveMeshHub.
+ *
+ * Provides connection state, automatic reconnection with exponential backoff,
+ * and methods to subscribe/unsubscribe from groups.
+ */
+import { useEffect, useRef, useState, useCallback } from "react"
+import {
+ HubConnectionBuilder,
+ HubConnection,
+ HubConnectionState,
+ LogLevel,
+} from "@microsoft/signalr"
+import { useAuthStore } from "@/stores"
+
+type ConnectionStatus = "disconnected" | "connecting" | "connected" | "reconnecting"
+
+interface UseSignalROptions {
+ hubUrl?: string
+ enabled?: boolean
+}
+
+interface UseSignalRReturn {
+ status: ConnectionStatus
+ subscribe: (method: string, handler: (...args: unknown[]) => void) => void
+ unsubscribe: (method: string, handler: (...args: unknown[]) => void) => void
+ invoke: (method: string, ...args: unknown[]) => Promise
+ joinGroup: (group: string) => Promise
+ leaveGroup: (group: string) => Promise