-
+
{title}
{description ? (
-
+
{description}
) : null}
diff --git a/src/frontend/src/components/shared/PagePlaceholder.tsx b/src/frontend/src/components/shared/PagePlaceholder.tsx
index 73476a2..703f8fc 100644
--- a/src/frontend/src/components/shared/PagePlaceholder.tsx
+++ b/src/frontend/src/components/shared/PagePlaceholder.tsx
@@ -28,7 +28,7 @@ export function PagePlaceholder({
description="Shared placeholder so every route lives inside the same application shell."
icon={
}
>
-
+
Replace only the page content without rebuilding layout, navigation,
or chrome. This helps the team work in parallel without fragmenting
the app structure.
@@ -38,7 +38,7 @@ export function PagePlaceholder({
}
+ icon={}
>
-
+
+
{icon ? (
-
+
{icon}
) : null}
-
{title}
+
{title}
{description ? (
-
{description}
+
{description}
) : null}
{actions ?
{actions}
: null}
-
+
- {children}
-
+ {children}
+
);
}
diff --git a/src/frontend/src/components/shared/StatCard.tsx b/src/frontend/src/components/shared/StatCard.tsx
index 442c877..460860c 100644
--- a/src/frontend/src/components/shared/StatCard.tsx
+++ b/src/frontend/src/components/shared/StatCard.tsx
@@ -1,5 +1,8 @@
import type { ReactNode } from "react";
+import { Card } from "@/components/ui/card";
+import { cn } from "@/lib/utils";
+
type StatCardProps = {
label: string;
value: string;
@@ -10,11 +13,11 @@ type StatCardProps = {
};
const toneClassMap = {
- neutral: "bg-surface-hover text-fg-muted",
- success: "bg-success-soft text-success",
- warning: "bg-warning-soft text-warning",
- error: "bg-error-soft text-error",
- info: "bg-info-soft text-info",
+ neutral: "bg-muted text-muted-foreground",
+ success: "bg-success/10 text-success",
+ warning: "bg-warning/10 text-warning",
+ error: "bg-destructive/10 text-destructive",
+ info: "bg-info/10 text-info",
} as const;
export function StatCard({
@@ -26,18 +29,18 @@ export function StatCard({
isLoading = false,
}: StatCardProps) {
return (
-
+
-
{label}
+
{label}
{isLoading ? (
) : (
-
+
{value}
)}
@@ -45,14 +48,17 @@ export function StatCard({
{icon ? (
{icon}
) : null}
- {hint ?
{hint}
: null}
-
+ {hint ?
{hint}
: null}
+
);
}
diff --git a/src/frontend/src/components/shared/StatusBadge.tsx b/src/frontend/src/components/shared/StatusBadge.tsx
index e5f3c70..4ae3b67 100644
--- a/src/frontend/src/components/shared/StatusBadge.tsx
+++ b/src/frontend/src/components/shared/StatusBadge.tsx
@@ -1,3 +1,5 @@
+import { Badge } from "@/components/ui/badge";
+
type StatusBadgeTone = "success" | "warning" | "error" | "info" | "neutral";
type StatusBadgeProps = {
@@ -5,12 +7,12 @@ type StatusBadgeProps = {
tone?: StatusBadgeTone;
};
-const toneClassMap = {
- success: "bg-success-soft text-success",
- warning: "bg-warning-soft text-warning",
- error: "bg-error-soft text-error",
- info: "bg-info-soft text-info",
- neutral: "bg-surface-hover text-fg-muted",
+const toneVariantMap = {
+ success: "success",
+ warning: "warning",
+ error: "destructive",
+ info: "default",
+ neutral: "outline",
} as const;
export function StatusBadge({
@@ -18,11 +20,9 @@ export function StatusBadge({
tone = "neutral",
}: StatusBadgeProps) {
return (
-
+
{label}
-
+
);
}
diff --git a/src/frontend/src/components/ui/alert.tsx b/src/frontend/src/components/ui/alert.tsx
new file mode 100644
index 0000000..84b42c0
--- /dev/null
+++ b/src/frontend/src/components/ui/alert.tsx
@@ -0,0 +1,36 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const alertVariants = cva("rounded-lg border p-4 text-sm", {
+ variants: {
+ variant: {
+ default: "border-border bg-card text-card-foreground",
+ success: "border-success/30 bg-success/10 text-success",
+ destructive: "border-destructive/30 bg-destructive/10 text-destructive",
+ warning: "border-warning/30 bg-warning/10 text-warning",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+});
+
+export interface AlertProps
+ extends React.HTMLAttributes
,
+ VariantProps {}
+
+const Alert = React.forwardRef(
+ ({ className, variant, ...props }, ref) => (
+
+ ),
+);
+Alert.displayName = "Alert";
+
+export { Alert };
diff --git a/src/frontend/src/components/ui/badge.tsx b/src/frontend/src/components/ui/badge.tsx
new file mode 100644
index 0000000..1e19eae
--- /dev/null
+++ b/src/frontend/src/components/ui/badge.tsx
@@ -0,0 +1,33 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors",
+ {
+ variants: {
+ variant: {
+ default: "border-primary/30 bg-primary/10 text-primary",
+ secondary: "border-border bg-secondary text-secondary-foreground",
+ success: "border-success/30 bg-success/10 text-success",
+ warning: "border-warning/30 bg-warning/10 text-warning",
+ destructive: "border-destructive/30 bg-destructive/10 text-destructive",
+ outline: "border-border text-muted-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return ;
+}
+
+export { Badge };
diff --git a/src/frontend/src/components/ui/button.tsx b/src/frontend/src/components/ui/button.tsx
new file mode 100644
index 0000000..748bd7e
--- /dev/null
+++ b/src/frontend/src/components/ui/button.tsx
@@ -0,0 +1,58 @@
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex h-9 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
+ {
+ variants: {
+ variant: {
+ default:
+ "border border-primary/80 bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
+ destructive:
+ "border border-destructive/50 bg-destructive/10 text-destructive hover:bg-destructive/15",
+ outline:
+ "border border-border bg-background text-foreground shadow-sm hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "border border-border bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
+ ghost: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
+ link: "h-auto p-0 text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "px-4 py-2",
+ sm: "h-8 rounded-md px-3 text-xs",
+ lg: "h-10 rounded-md px-5",
+ icon: "h-9 w-9 p-0",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+
+ );
+ },
+);
+Button.displayName = "Button";
+
+export { Button };
diff --git a/src/frontend/src/components/ui/card.tsx b/src/frontend/src/components/ui/card.tsx
new file mode 100644
index 0000000..6aee981
--- /dev/null
+++ b/src/frontend/src/components/ui/card.tsx
@@ -0,0 +1,71 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+type CardProps = React.ComponentProps<"div"> & {
+ as?: "article" | "div" | "section";
+};
+
+const Card = React.forwardRef(
+ ({ as: Comp = "div", className, ...props }, ref) => (
+
+ ),
+);
+Card.displayName = "Card";
+
+const CardHeader = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardHeader.displayName = "CardHeader";
+
+const CardTitle = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardTitle.displayName = "CardTitle";
+
+const CardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.ComponentProps<"p">
+>(({ className, ...props }, ref) => (
+
+));
+CardDescription.displayName = "CardDescription";
+
+const CardContent = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardContent.displayName = "CardContent";
+
+const CardFooter = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardFooter.displayName = "CardFooter";
+
+export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
diff --git a/src/frontend/src/components/ui/checkbox.tsx b/src/frontend/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..fce6105
--- /dev/null
+++ b/src/frontend/src/components/ui/checkbox.tsx
@@ -0,0 +1,21 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Checkbox = React.forwardRef<
+ HTMLInputElement,
+ Omit, "type">
+>(({ className, ...props }, ref) => (
+
+));
+Checkbox.displayName = "Checkbox";
+
+export { Checkbox };
diff --git a/src/frontend/src/components/ui/dialog.tsx b/src/frontend/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..6b1dd2c
--- /dev/null
+++ b/src/frontend/src/components/ui/dialog.tsx
@@ -0,0 +1,93 @@
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Dialog = DialogPrimitive.Root;
+const DialogTrigger = DialogPrimitive.Trigger;
+const DialogPortal = DialogPrimitive.Portal;
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+
+
+
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogFooter.displayName = "DialogFooter";
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+};
diff --git a/src/frontend/src/components/ui/input.tsx b/src/frontend/src/components/ui/input.tsx
new file mode 100644
index 0000000..3a3e450
--- /dev/null
+++ b/src/frontend/src/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Input = React.forwardRef>(
+ ({ className, type, ...props }, ref) => (
+
+ ),
+);
+Input.displayName = "Input";
+
+export { Input };
diff --git a/src/frontend/src/components/ui/label.tsx b/src/frontend/src/components/ui/label.tsx
new file mode 100644
index 0000000..b444249
--- /dev/null
+++ b/src/frontend/src/components/ui/label.tsx
@@ -0,0 +1,16 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Label = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+Label.displayName = "Label";
+
+export { Label };
diff --git a/src/frontend/src/components/ui/select.tsx b/src/frontend/src/components/ui/select.tsx
new file mode 100644
index 0000000..e1e06fb
--- /dev/null
+++ b/src/frontend/src/components/ui/select.tsx
@@ -0,0 +1,22 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Select = React.forwardRef<
+ HTMLSelectElement,
+ React.ComponentProps<"select">
+>(({ className, children, ...props }, ref) => (
+
+));
+Select.displayName = "Select";
+
+export { Select };
diff --git a/src/frontend/src/components/ui/table.tsx b/src/frontend/src/components/ui/table.tsx
new file mode 100644
index 0000000..986fad5
--- /dev/null
+++ b/src/frontend/src/components/ui/table.tsx
@@ -0,0 +1,73 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Table = React.forwardRef<
+ HTMLTableElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+Table.displayName = "Table";
+
+const TableHeader = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+TableHeader.displayName = "TableHeader";
+
+const TableBody = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+TableBody.displayName = "TableBody";
+
+const TableRow = React.forwardRef<
+ HTMLTableRowElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+TableRow.displayName = "TableRow";
+
+const TableHead = React.forwardRef<
+ HTMLTableCellElement,
+ React.ThHTMLAttributes
+>(({ className, ...props }, ref) => (
+ |
+));
+TableHead.displayName = "TableHead";
+
+const TableCell = React.forwardRef<
+ HTMLTableCellElement,
+ React.TdHTMLAttributes
+>(({ className, ...props }, ref) => (
+ |
+));
+TableCell.displayName = "TableCell";
+
+export { Table, TableBody, TableCell, TableHead, TableHeader, TableRow };
diff --git a/src/frontend/src/features/auth/AuthPendingState.tsx b/src/frontend/src/features/auth/AuthPendingState.tsx
index 7a4f7bc..4409ae0 100644
--- a/src/frontend/src/features/auth/AuthPendingState.tsx
+++ b/src/frontend/src/features/auth/AuthPendingState.tsx
@@ -1,9 +1,11 @@
+import { Card } from "@/components/ui/card";
+
export function AuthPendingState() {
return (
-
-
+
+
Checking session...
-
+
);
}
diff --git a/src/frontend/src/features/logs/LogDetailModal.tsx b/src/frontend/src/features/logs/LogDetailModal.tsx
index c016d8c..bcd9f18 100644
--- a/src/frontend/src/features/logs/LogDetailModal.tsx
+++ b/src/frontend/src/features/logs/LogDetailModal.tsx
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { Modal } from "@/components/shared/Modal";
import { StatusBadge } from "@/components/shared/StatusBadge";
+import { Button } from "@/components/ui/button";
import type { Log, LogAction, LogSeverity } from "./types";
@@ -24,15 +25,15 @@ function severityTone(severity: LogSeverity) {
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
-
-
{label}
-
{children}
+
+
{label}
+ {children}
);
}
function Nullable({ value }: { value: string | number | null | undefined }) {
- return value !== null && value !== undefined ? <>{value}> :
—;
+ return value !== null && value !== undefined ? <>{value}> :
—;
}
export function LogDetailModal({ log, onClose }: LogDetailModalProps) {
@@ -41,9 +42,9 @@ export function LogDetailModal({ log, onClose }: LogDetailModalProps) {
title="Event details"
onClose={onClose}
footer={
-
}
>
@@ -92,7 +93,7 @@ export function LogDetailModal({ log, onClose }: LogDetailModalProps) {
{log.raw_context !== null && (
-
+
{JSON.stringify(log.raw_context, null, 2)}
diff --git a/src/frontend/src/features/policies/DeletePolicyDialog.tsx b/src/frontend/src/features/policies/DeletePolicyDialog.tsx
index 50acede..42699dd 100644
--- a/src/frontend/src/features/policies/DeletePolicyDialog.tsx
+++ b/src/frontend/src/features/policies/DeletePolicyDialog.tsx
@@ -1,6 +1,8 @@
import { useState } from "react";
import { Modal } from "@/components/shared/Modal";
+import { Alert } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { ApiError } from "@/lib/api-client";
@@ -39,32 +41,31 @@ export function DeletePolicyDialog({ policy, onSuccess, onClose }: DeletePolicyD
onClose={onClose}
footer={
<>
-
+
Cancel
-
-
+ void handleDelete()}
- className="rounded-[var(--radius-md)] bg-error px-4 py-2 text-sm font-semibold text-white transition hover:opacity-90 disabled:opacity-50"
+ variant="destructive"
>
{submitting ? "Deleting…" : "Delete"}
-
+
>
}
>
{serverError && (
-
{serverError}
-
+
)}
-
+
Are you sure you want to delete{" "}
- {policy.name}?
+ {policy.name}?
This action cannot be undone.
diff --git a/src/frontend/src/features/policies/PolicyFormModal.tsx b/src/frontend/src/features/policies/PolicyFormModal.tsx
index e02ebec..2c8727b 100644
--- a/src/frontend/src/features/policies/PolicyFormModal.tsx
+++ b/src/frontend/src/features/policies/PolicyFormModal.tsx
@@ -1,6 +1,12 @@
import { type FormEvent, useState } from "react";
import { Modal } from "@/components/shared/Modal";
+import { Alert } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select } from "@/components/ui/select";
import { useAuth } from "@/hooks/use-auth";
import { ApiError } from "@/lib/api-client";
@@ -83,118 +89,99 @@ export function PolicyFormModal(props: PolicyFormModalProps) {
onClose={onClose}
footer={
<>
-
+
Cancel
-
-
+
{submitting ? "Saving…" : props.mode === "create" ? "Create" : "Save"}
-
+
>
}
>
diff --git a/src/frontend/src/features/runtime/ApplyConfigButton.tsx b/src/frontend/src/features/runtime/ApplyConfigButton.tsx
index 3c7a81c..a4ae882 100644
--- a/src/frontend/src/features/runtime/ApplyConfigButton.tsx
+++ b/src/frontend/src/features/runtime/ApplyConfigButton.tsx
@@ -1,5 +1,7 @@
import { useState } from "react";
+import { UploadCloud } from "lucide-react";
+import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { ApiError } from "@/lib/api-client";
@@ -48,21 +50,23 @@ export function ApplyConfigButton({
}
return (
- void handleClick()}
- className="inline-flex items-center gap-2 rounded-[var(--radius-md)] bg-accent px-4 py-2 text-sm font-semibold text-white shadow-sm transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{isApplying ? (
<>
-
+
Applying…
>
) : (
- "Apply config"
+ <>
+
+ Apply config
+ >
)}
-
+
);
}
diff --git a/src/frontend/src/features/runtime/RuntimeStatusCard.tsx b/src/frontend/src/features/runtime/RuntimeStatusCard.tsx
index 6e0a9d1..e432f5f 100644
--- a/src/frontend/src/features/runtime/RuntimeStatusCard.tsx
+++ b/src/frontend/src/features/runtime/RuntimeStatusCard.tsx
@@ -2,6 +2,7 @@ import { ErrorState } from "@/components/shared/ErrorState";
import { LoadingState } from "@/components/shared/LoadingState";
import { SectionCard } from "@/components/shared/SectionCard";
import { StatusBadge } from "@/components/shared/StatusBadge";
+import { Alert } from "@/components/ui/alert";
import type { DeploymentState, RuntimeStatusResponse } from "./types";
@@ -86,9 +87,9 @@ export function RuntimeStatusCard({ status }: RuntimeStatusCardProps) {
value={formatTimestamp(latest_reload.created_at)}
/>
{latest_reload.status === "failed" && latest_reload.message ? (
-
+
{latest_reload.message}
-
+
) : null}
>
) : (
@@ -101,9 +102,9 @@ export function RuntimeStatusCard({ status }: RuntimeStatusCardProps) {
function StatusRow({ label, value }: { label: string; value: string }) {
return (
-
-
{label}
-
{value}
+
+ {label}
+ {value}
);
}
diff --git a/src/frontend/src/features/vhosts/DeleteVHostDialog.tsx b/src/frontend/src/features/vhosts/DeleteVHostDialog.tsx
index 35fd0e7..04b7fd4 100644
--- a/src/frontend/src/features/vhosts/DeleteVHostDialog.tsx
+++ b/src/frontend/src/features/vhosts/DeleteVHostDialog.tsx
@@ -1,6 +1,8 @@
import { useState } from "react";
import { Modal } from "@/components/shared/Modal";
+import { Alert } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { ApiError } from "@/lib/api-client";
@@ -39,32 +41,31 @@ export function DeleteVHostDialog({ vhost, onSuccess, onClose }: DeleteVHostDial
onClose={onClose}
footer={
<>
-
+
Cancel
-
-
+ void handleDelete()}
- className="rounded-[var(--radius-md)] bg-error px-4 py-2 text-sm font-semibold text-white transition hover:opacity-90 disabled:opacity-50"
+ variant="destructive"
>
{submitting ? "Deleting…" : "Delete"}
-
+
>
}
>
{serverError && (
-
{serverError}
-
+
)}
-
+
Are you sure you want to delete{" "}
- {vhost.domain}?
+ {vhost.domain}?
This action cannot be undone.
diff --git a/src/frontend/src/features/vhosts/VHostFormModal.tsx b/src/frontend/src/features/vhosts/VHostFormModal.tsx
index 7d6f71b..9b98437 100644
--- a/src/frontend/src/features/vhosts/VHostFormModal.tsx
+++ b/src/frontend/src/features/vhosts/VHostFormModal.tsx
@@ -1,6 +1,12 @@
import { type FormEvent, useState } from "react";
import { Modal } from "@/components/shared/Modal";
+import { Alert } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select } from "@/components/ui/select";
import { useAuth } from "@/hooks/use-auth";
import { ApiError } from "@/lib/api-client";
@@ -72,86 +78,72 @@ export function VHostFormModal(props: VHostFormModalProps) {
onClose={onClose}
footer={
<>
-
+
Cancel
-
-
+
{submitting ? "Saving…" : props.mode === "create" ? "Create" : "Save"}
-
+
>
}
>
);
diff --git a/src/frontend/src/layouts/AppLayout.tsx b/src/frontend/src/layouts/AppLayout.tsx
index 476fefd..0b715b8 100644
--- a/src/frontend/src/layouts/AppLayout.tsx
+++ b/src/frontend/src/layouts/AppLayout.tsx
@@ -4,7 +4,7 @@ import { NavBar } from "@/components/layout/NavBar";
export function AppLayout() {
return (
-
+
diff --git a/src/frontend/src/lib/utils.ts b/src/frontend/src/lib/utils.ts
new file mode 100644
index 0000000..a5ef193
--- /dev/null
+++ b/src/frontend/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/src/frontend/src/pages/dashboard/DashboardPage.tsx b/src/frontend/src/pages/dashboard/DashboardPage.tsx
index 85be8f0..5d0172f 100644
--- a/src/frontend/src/pages/dashboard/DashboardPage.tsx
+++ b/src/frontend/src/pages/dashboard/DashboardPage.tsx
@@ -6,6 +6,8 @@ import {
ServerIcon,
ShieldIcon,
} from "@/components/icons";
+import { Alert } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/shared/PageHeader";
import { RoleBadge } from "@/components/shared/RoleBadge";
import { SectionCard } from "@/components/shared/SectionCard";
@@ -42,22 +44,22 @@ export function DashboardPage() {
/>
{applyResult ? (
-
{applyResult.message}
- setApplyResult(null)}
- className="shrink-0 text-current opacity-60 hover:opacity-100"
+ variant="ghost"
+ size="sm"
+ className="h-6 px-2 text-current hover:bg-current/10"
+ aria-label="Dismiss apply result"
>
- ✕
-
-
+ Close
+
+
) : null}
@@ -149,12 +151,12 @@ type ActivityRowProps = {
function ActivityRow({ title, description, badge }: ActivityRowProps) {
return (
-
+
-
{title}
+ {title}
{badge}
-
{description}
+
{description}
);
}
diff --git a/src/frontend/src/pages/login/LoginPage.tsx b/src/frontend/src/pages/login/LoginPage.tsx
index a7959ef..f6eb110 100644
--- a/src/frontend/src/pages/login/LoginPage.tsx
+++ b/src/frontend/src/pages/login/LoginPage.tsx
@@ -1,8 +1,13 @@
import { type FormEvent, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
+import { Shield } from "lucide-react";
import { appRoutes } from "@/app/routes";
-import { ShieldIcon } from "@/components/icons";
+import { Alert } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
import { useAuth } from "@/hooks/use-auth";
export function LoginPage() {
@@ -35,74 +40,64 @@ export function LoginPage() {
}
return (
-
+
-
-
+
+
-
+
Guard Proxy
-
+
Sign in to the admin panel
-
+
+
-
+
Protected by Guard Proxy WAF
diff --git a/src/frontend/src/pages/logs/LogsPage.test.tsx b/src/frontend/src/pages/logs/LogsPage.test.tsx
index 0900260..b8bc76c 100644
--- a/src/frontend/src/pages/logs/LogsPage.test.tsx
+++ b/src/frontend/src/pages/logs/LogsPage.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, waitFor } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
@@ -127,6 +127,40 @@ describe("LogsPage", () => {
);
});
+ it("applies from and to date/time filters", async () => {
+ vi.mocked(logsApi.listLogs).mockResolvedValue(mockListResponse);
+ vi.mocked(vhostsApi.listPolicies).mockResolvedValue(mockPolicies);
+
+ renderPage();
+ await waitFor(() => expect(screen.getByText("app.example.com")).toBeInTheDocument());
+
+ fireEvent.change(screen.getByLabelText("From date"), {
+ target: { value: "2026-06-01" },
+ });
+ fireEvent.change(screen.getByLabelText("From time"), {
+ target: { value: "08:30" },
+ });
+ fireEvent.change(screen.getByLabelText("To date"), {
+ target: { value: "2026-06-02" },
+ });
+ fireEvent.change(screen.getByLabelText("To time"), {
+ target: { value: "17:45" },
+ });
+ await userEvent.click(screen.getByRole("button", { name: /apply/i }));
+
+ await waitFor(() =>
+ expect(vi.mocked(logsApi.listLogs)).toHaveBeenCalledWith(
+ "test-token",
+ expect.objectContaining({
+ date_from: "2026-06-01T08:30",
+ date_to: "2026-06-02T17:45",
+ page: 1,
+ }),
+ expect.anything(),
+ ),
+ );
+ });
+
it("clearing filters resets to empty params", async () => {
vi.mocked(logsApi.listLogs).mockResolvedValue(mockListResponse);
vi.mocked(vhostsApi.listPolicies).mockResolvedValue(mockPolicies);
diff --git a/src/frontend/src/pages/logs/LogsPage.tsx b/src/frontend/src/pages/logs/LogsPage.tsx
index c8f30c7..673f1f9 100644
--- a/src/frontend/src/pages/logs/LogsPage.tsx
+++ b/src/frontend/src/pages/logs/LogsPage.tsx
@@ -1,4 +1,5 @@
import { useState } from "react";
+import { CalendarClock } from "lucide-react";
import type { DataTableColumn } from "@/components/shared/DataTable";
import { DataTable } from "@/components/shared/DataTable";
@@ -7,10 +8,15 @@ import { LoadingState } from "@/components/shared/LoadingState";
import { PageHeader } from "@/components/shared/PageHeader";
import { SectionCard } from "@/components/shared/SectionCard";
import { StatusBadge } from "@/components/shared/StatusBadge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select } from "@/components/ui/select";
import { LogDetailModal } from "@/features/logs/LogDetailModal";
import { useLogs } from "@/features/logs/use-logs";
import type { Log, LogAction, LogFilters } from "@/features/logs/types";
import { EMPTY_FILTERS } from "@/features/logs/types";
+import { cn } from "@/lib/utils";
function actionTone(action: LogAction) {
if (action === "deny") return "error" as const;
@@ -23,7 +29,7 @@ const columns: DataTableColumn
[] = [
key: "timestamp",
header: "Timestamp",
cell: (row) => (
-
+
{new Date(row.event_at).toLocaleString()}
),
@@ -65,7 +71,7 @@ const columns: DataTableColumn[] = [
key: "rules",
header: "Top matched rules",
cell: (row) => {
- if (row.rule_id === null) return —;
+ if (row.rule_id === null) return —;
const label = row.rule_message
? `#${row.rule_id} — ${row.rule_message}`
: `#${row.rule_id}`;
@@ -78,6 +84,208 @@ const columns: DataTableColumn[] = [
},
];
+type DateRangePreset = {
+ label: string;
+ getRange: (now: Date) => Pick;
+};
+
+const dateRangePresets: DateRangePreset[] = [
+ {
+ label: "1 hour",
+ getRange: (now) => ({
+ date_from: toDateTimeLocal(new Date(now.getTime() - 60 * 60 * 1000)),
+ date_to: toDateTimeLocal(now),
+ }),
+ },
+ {
+ label: "24 hours",
+ getRange: (now) => ({
+ date_from: toDateTimeLocal(new Date(now.getTime() - 24 * 60 * 60 * 1000)),
+ date_to: toDateTimeLocal(now),
+ }),
+ },
+ {
+ label: "7 days",
+ getRange: (now) => ({
+ date_from: toDateTimeLocal(new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)),
+ date_to: toDateTimeLocal(now),
+ }),
+ },
+ {
+ label: "Today",
+ getRange: (now) => {
+ const start = new Date(now);
+ start.setHours(0, 0, 0, 0);
+ return {
+ date_from: toDateTimeLocal(start),
+ date_to: toDateTimeLocal(now),
+ };
+ },
+ },
+];
+
+function pad(value: number) {
+ return String(value).padStart(2, "0");
+}
+
+function toDateTimeLocal(date: Date) {
+ return [
+ date.getFullYear(),
+ "-",
+ pad(date.getMonth() + 1),
+ "-",
+ pad(date.getDate()),
+ "T",
+ pad(date.getHours()),
+ ":",
+ pad(date.getMinutes()),
+ ].join("");
+}
+
+function splitDateTime(value: string) {
+ const [date = "", time = ""] = value.split("T");
+ return {
+ date,
+ time,
+ };
+}
+
+function combineDateTime(date: string, time: string) {
+ if (!date) return "";
+ return `${date}T${time || "00:00"}`;
+}
+
+type DateTimeRangePickerProps = {
+ value: Pick;
+ onChange: (next: Pick) => void;
+};
+
+function DateTimeRangePicker({ value, onChange }: DateTimeRangePickerProps) {
+ const from = splitDateTime(value.date_from);
+ const to = splitDateTime(value.date_to);
+
+ function update(partial: Partial>) {
+ onChange({
+ date_from: partial.date_from ?? value.date_from,
+ date_to: partial.date_to ?? value.date_to,
+ });
+ }
+
+ return (
+
+
+
+
+
+
+ Time range
+
+
+
+ {dateRangePresets.map((preset) => (
+ onChange(preset.getRange(new Date()))}
+ >
+ {preset.label}
+
+ ))}
+
+
+
+
+
+ update({ date_from: combineDateTime(date, from.time) })
+ }
+ onTimeChange={(time) =>
+ update({ date_from: combineDateTime(from.date, time) })
+ }
+ />
+
+
+ update({ date_to: combineDateTime(date, to.time) })
+ }
+ onTimeChange={(time) =>
+ update({ date_to: combineDateTime(to.date, time) })
+ }
+ />
+
+
+ );
+}
+
+type DateTimeEndpointProps = {
+ label: "From" | "To";
+ dateId: string;
+ timeId: string;
+ date: string;
+ time: string;
+ onDateChange: (value: string) => void;
+ onTimeChange: (value: string) => void;
+};
+
+function DateTimeEndpoint({
+ label,
+ dateId,
+ timeId,
+ date,
+ time,
+ onDateChange,
+ onTimeChange,
+}: DateTimeEndpointProps) {
+ return (
+
+ );
+}
+
export function LogsPage() {
const { logs, total, page, pageSize, policies, isLoading, error, setPage, applyFilters, refresh } =
useLogs();
@@ -102,13 +310,14 @@ export function LogsPage() {
header: "",
className: "w-px whitespace-nowrap",
cell: (row) => (
- setSelected(row)}
- className="rounded-[var(--radius-sm)] border border-border bg-surface-hover px-3 py-1.5 text-xs font-semibold text-fg-muted transition hover:text-fg"
+ variant="outline"
+ size="sm"
>
View
-
+
),
},
];
@@ -123,41 +332,38 @@ export function LogsPage() {
-
-
- setDraft({ ...draft, date_to: e.target.value })}
- className="input-field"
- />
-
+
+ setDraft({ ...draft, ...range })}
+ />
-
+
Apply
-
-
+
+
Clear
-
+
@@ -209,9 +403,9 @@ export function LogsPage() {
title="Failed to load logs"
description={error}
action={
-
+
Retry
-
+
}
/>
) : (
@@ -226,26 +420,26 @@ export function LogsPage() {
{total > 0 && (
-
+
Page {page} of {totalPages} · {total} events
- setPage(page - 1)}
disabled={page <= 1}
- className="btn-ghost px-4 py-2 text-sm disabled:opacity-40"
+ variant="outline"
>
Prev
-
-
+ setPage(page + 1)}
disabled={page >= totalPages}
- className="btn-ghost px-4 py-2 text-sm disabled:opacity-40"
+ variant="outline"
>
Next
-
+
)}
diff --git a/src/frontend/src/pages/not-found/NotFoundPage.tsx b/src/frontend/src/pages/not-found/NotFoundPage.tsx
index 5f0f38c..196905b 100644
--- a/src/frontend/src/pages/not-found/NotFoundPage.tsx
+++ b/src/frontend/src/pages/not-found/NotFoundPage.tsx
@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import { appRoutes } from "@/app/routes";
import { EmptyState } from "@/components/shared/EmptyState";
import { PageHeader } from "@/components/shared/PageHeader";
+import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
export function NotFoundPage() {
@@ -11,7 +12,7 @@ export function NotFoundPage() {
const label = isAuthenticated ? "Return to dashboard" : "Go to login";
return (
-
+
- {label}
-
+
+ {label}
+
}
/>
diff --git a/src/frontend/src/pages/policies/PoliciesPage.tsx b/src/frontend/src/pages/policies/PoliciesPage.tsx
index 956d26e..333a361 100644
--- a/src/frontend/src/pages/policies/PoliciesPage.tsx
+++ b/src/frontend/src/pages/policies/PoliciesPage.tsx
@@ -8,6 +8,7 @@ import { LoadingState } from "@/components/shared/LoadingState";
import { PageHeader } from "@/components/shared/PageHeader";
import { SectionCard } from "@/components/shared/SectionCard";
import { StatusBadge } from "@/components/shared/StatusBadge";
+import { Button } from "@/components/ui/button";
import { DeletePolicyDialog } from "@/features/policies/DeletePolicyDialog";
import { PolicyFormModal } from "@/features/policies/PolicyFormModal";
import type { Policy } from "@/features/policies/types";
@@ -64,7 +65,7 @@ export function PoliciesPage() {
key: "thresholds",
header: "Inbound threshold",
cell: (row) => (
-
+
{row.inbound_anomaly_threshold}
),
@@ -89,22 +90,24 @@ export function PoliciesPage() {
const assigned = assignedPolicyIds.has(row.id);
return (
- setModal({ type: "edit", policy: row })}
- className="rounded-[var(--radius-sm)] border border-border bg-surface-hover px-3 py-1.5 text-xs font-semibold text-fg-muted transition hover:text-fg"
+ variant="outline"
+ size="sm"
>
Edit
-
+
- setModal({ type: "delete", policy: row })}
- className="rounded-[var(--radius-sm)] border border-error/50 px-3 py-1.5 text-xs font-semibold text-error transition hover:border-error hover:bg-error-soft disabled:cursor-not-allowed disabled:opacity-40"
+ variant="destructive"
+ size="sm"
>
Delete
-
+
);
@@ -121,13 +124,12 @@ export function PoliciesPage() {
description="Manage CRS-based WAF policies and assign them to virtual hosts."
actions={
isAdmin ? (
- setModal({ type: "create" })}
- className="btn-primary px-4 py-2 text-sm"
>
New policy
-
+
) : undefined
}
/>
@@ -140,13 +142,13 @@ export function PoliciesPage() {
title="Failed to load policies"
description={error}
action={
-
Retry
-
+
}
/>
) : (
diff --git a/src/frontend/src/pages/policies/PolicyDetailPage.tsx b/src/frontend/src/pages/policies/PolicyDetailPage.tsx
index 1fad2a6..5eed700 100644
--- a/src/frontend/src/pages/policies/PolicyDetailPage.tsx
+++ b/src/frontend/src/pages/policies/PolicyDetailPage.tsx
@@ -8,6 +8,7 @@ import { LoadingState } from "@/components/shared/LoadingState";
import { PageHeader } from "@/components/shared/PageHeader";
import { SectionCard } from "@/components/shared/SectionCard";
import { StatusBadge } from "@/components/shared/StatusBadge";
+import { Button } from "@/components/ui/button";
import { getPolicy } from "@/features/policies/api";
import type { PolicyDetail, RuleOverride } from "@/features/policies/types";
import { useAuth } from "@/hooks/use-auth";
@@ -69,7 +70,7 @@ export function PolicyDetailPage() {
row.comment ? (
{row.comment}
) : (
- —
+ —
),
},
];
@@ -88,13 +89,13 @@ export function PolicyDetailPage() {
title="Failed to load policy"
description={error}
action={
-
Retry
-
+
}
/>
) : policy ? (
@@ -102,7 +103,7 @@ export function PolicyDetailPage() {
-
- Enforcement mode
+ - Enforcement mode
-
-
- Status
+ - Status
-
-
- Paranoia level
- - {policy.paranoia_level}
+ - Paranoia level
+ - {policy.paranoia_level}
-
- Inbound threshold
- - {policy.inbound_anomaly_threshold}
+ - Inbound threshold
+ - {policy.inbound_anomaly_threshold}
diff --git a/src/frontend/src/pages/vhosts/VHostsPage.tsx b/src/frontend/src/pages/vhosts/VHostsPage.tsx
index c39d15b..5b09507 100644
--- a/src/frontend/src/pages/vhosts/VHostsPage.tsx
+++ b/src/frontend/src/pages/vhosts/VHostsPage.tsx
@@ -7,6 +7,7 @@ import { LoadingState } from "@/components/shared/LoadingState";
import { PageHeader } from "@/components/shared/PageHeader";
import { SectionCard } from "@/components/shared/SectionCard";
import { StatusBadge } from "@/components/shared/StatusBadge";
+import { Button } from "@/components/ui/button";
import { DeleteVHostDialog } from "@/features/vhosts/DeleteVHostDialog";
import { VHostFormModal } from "@/features/vhosts/VHostFormModal";
import { useVHosts } from "@/features/vhosts/use-vhosts";
@@ -36,8 +37,8 @@ export function VHostsPage() {
header: "Domain",
cell: (row) => (
-
{row.domain}
-
{row.backend_url}
+
{row.domain}
+
{row.backend_url}
),
},
@@ -47,7 +48,7 @@ export function VHostsPage() {
cell: (row) =>
row.policy_id != null
? (policyNameById[row.policy_id] ?? `#${row.policy_id}`)
- : None,
+ : None,
},
{
key: "status",
@@ -67,20 +68,22 @@ export function VHostsPage() {
className: "w-px whitespace-nowrap",
cell: (row: VHost) => (
- setModal({ type: "edit", vhost: row })}
- className="rounded-[var(--radius-sm)] border border-border bg-surface-hover px-3 py-1.5 text-xs font-semibold text-fg-muted transition hover:text-fg"
+ variant="outline"
+ size="sm"
>
Edit
-
-
+ setModal({ type: "delete", vhost: row })}
- className="rounded-[var(--radius-sm)] border border-error/50 px-3 py-1.5 text-xs font-semibold text-error transition hover:border-error hover:bg-error-soft"
+ variant="destructive"
+ size="sm"
>
Delete
-
+
),
} satisfies DataTableColumn,
@@ -95,13 +98,12 @@ export function VHostsPage() {
description="Manage the domains, backend targets, and WAF policies for each virtual host."
actions={
isAdmin ? (
- setModal({ type: "create" })}
- className="btn-primary px-4 py-2 text-sm"
>
New vhost
-
+
) : undefined
}
/>
@@ -114,13 +116,13 @@ export function VHostsPage() {
title="Failed to load virtual hosts"
description={error}
action={
-
Retry
-
+
}
/>
) : (
diff --git a/src/frontend/src/styles/globals.css b/src/frontend/src/styles/globals.css
index 05d11f6..bab1f06 100644
--- a/src/frontend/src/styles/globals.css
+++ b/src/frontend/src/styles/globals.css
@@ -1,82 +1,104 @@
-/* ═══════════════════════════════════════════════════════════
- Guard Proxy — dual-theme design system
- Themes: emerald (deep green), frost (cool blue)
- Switch via data-theme="emerald|frost" on
- ═══════════════════════════════════════════════════════════ */
-
@import "tailwindcss";
-/* ── Default tokens (emerald) — Tailwind reads these at build ── */
-
@theme {
- --color-app-bg: oklch(0.14 0.015 160);
- --color-surface: oklch(0.22 0.012 158);
- --color-surface-hover: oklch(0.26 0.014 158);
- --color-surface-raised: oklch(0.24 0.013 158);
- --color-border: oklch(0.32 0.012 158);
- --color-border-subtle: oklch(0.27 0.011 158);
- --color-fg: oklch(0.96 0.008 90);
- --color-fg-muted: oklch(0.8 0.01 160);
- --color-fg-subtle: oklch(0.68 0.01 160);
- --color-accent: oklch(0.72 0.19 163);
- --color-accent-hover: oklch(0.78 0.19 163);
- --color-accent-soft: oklch(0.72 0.19 163 / 0.12);
- --color-accent-fg: oklch(0.15 0.015 160);
- --color-success: oklch(0.68 0.19 155);
- --color-success-soft: oklch(0.68 0.19 155 / 0.20);
- --color-warning: oklch(0.80 0.16 80);
- --color-warning-soft: oklch(0.80 0.16 80 / 0.12);
- --color-error: oklch(0.70 0.19 25);
- --color-error-soft: oklch(0.70 0.19 25 / 0.12);
- --color-info: oklch(0.72 0.15 230);
- --color-info-soft: oklch(0.72 0.15 230 / 0.12);
- --color-ring: oklch(0.72 0.19 163 / 0.45);
- --font-sans: "DM Sans", system-ui, -apple-system, sans-serif;
- --radius-sm: 8px;
- --radius-md: 12px;
- --radius-lg: 16px;
- --radius-xl: 20px;
- --radius-full: 9999px;
-}
-
-/* ── Frost overrides ── */
-
-[data-theme="frost"] {
- --color-app-bg: oklch(0.10 0.018 242);
- --color-surface: oklch(0.14 0.016 242);
- --color-surface-hover: oklch(0.18 0.015 242);
- --color-surface-raised: oklch(0.19 0.015 242);
- --color-border: oklch(0.28 0.016 242);
- --color-border-subtle: oklch(0.22 0.014 242);
- --color-fg: oklch(0.95 0.008 242);
- --color-fg-muted: oklch(0.78 0.014 242);
- --color-fg-subtle: oklch(0.66 0.012 242);
- --color-accent: oklch(0.68 0.17 168);
- --color-accent-hover: oklch(0.74 0.17 168);
- --color-accent-soft: oklch(0.68 0.17 168 / 0.14);
- --color-accent-fg: oklch(0.10 0.02 168);
- --color-success: oklch(0.65 0.19 145);
- --color-success-soft: oklch(0.65 0.19 145 / 0.20);
- --color-warning: oklch(0.80 0.18 75);
- --color-warning-soft: oklch(0.80 0.18 75 / 0.14);
- --color-error: oklch(0.65 0.22 25);
- --color-error-soft: oklch(0.65 0.22 25 / 0.14);
- --color-info: oklch(0.68 0.16 245);
- --color-info-soft: oklch(0.68 0.16 245 / 0.14);
- --color-ring: oklch(0.68 0.17 168 / 0.45);
- --font-sans: "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-success: var(--success);
+ --color-warning: var(--warning);
+ --color-info: var(--info);
+ --color-app-bg: var(--background);
+ --color-surface: var(--card);
+ --color-surface-hover: var(--muted);
+ --color-surface-raised: var(--secondary);
+ --color-border-subtle: var(--border-subtle);
+ --color-fg: var(--foreground);
+ --color-fg-muted: var(--muted-foreground);
+ --color-fg-subtle: var(--subtle-foreground);
+ --color-accent-hover: var(--primary-hover);
+ --color-accent-soft: color-mix(in oklch, var(--primary), transparent 88%);
+ --color-accent-fg: var(--primary-foreground);
+ --color-success-soft: color-mix(in oklch, var(--success), transparent 88%);
+ --color-warning-soft: color-mix(in oklch, var(--warning), transparent 88%);
+ --color-error: var(--destructive);
+ --color-error-soft: color-mix(in oklch, var(--destructive), transparent 88%);
+ --color-info-soft: color-mix(in oklch, var(--info), transparent 88%);
+ --font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
- --radius-xl: 10px;
+ --radius-xl: 8px;
--radius-full: 9999px;
}
-/* ── Base reset ── */
+:root,
+[data-theme="emerald"] {
+ color-scheme: dark;
+ --background: oklch(0.145 0.014 255);
+ --foreground: oklch(0.965 0.006 255);
+ --card: oklch(0.19 0.014 255);
+ --card-foreground: var(--foreground);
+ --primary: oklch(0.71 0.16 163);
+ --primary-hover: oklch(0.77 0.16 163);
+ --primary-foreground: oklch(0.12 0.014 163);
+ --secondary: oklch(0.235 0.016 255);
+ --secondary-foreground: var(--foreground);
+ --muted: oklch(0.255 0.015 255);
+ --muted-foreground: oklch(0.72 0.02 255);
+ --subtle-foreground: oklch(0.62 0.018 255);
+ --accent: oklch(0.255 0.015 255);
+ --accent-foreground: var(--foreground);
+ --destructive: oklch(0.69 0.19 25);
+ --border: oklch(0.315 0.017 255);
+ --border-subtle: oklch(0.265 0.015 255);
+ --input: oklch(0.315 0.017 255);
+ --ring: oklch(0.71 0.16 163 / 0.45);
+ --success: oklch(0.70 0.15 152);
+ --warning: oklch(0.80 0.15 78);
+ --info: oklch(0.72 0.14 235);
+}
+
+[data-theme="frost"] {
+ color-scheme: light;
+ --background: oklch(0.975 0.006 250);
+ --foreground: oklch(0.18 0.02 255);
+ --card: oklch(1 0 0);
+ --card-foreground: var(--foreground);
+ --primary: oklch(0.48 0.15 162);
+ --primary-hover: oklch(0.43 0.15 162);
+ --primary-foreground: oklch(0.99 0.004 162);
+ --secondary: oklch(0.94 0.01 250);
+ --secondary-foreground: var(--foreground);
+ --muted: oklch(0.925 0.012 250);
+ --muted-foreground: oklch(0.48 0.028 255);
+ --subtle-foreground: oklch(0.58 0.025 255);
+ --accent: oklch(0.91 0.016 250);
+ --accent-foreground: var(--foreground);
+ --destructive: oklch(0.56 0.19 25);
+ --border: oklch(0.84 0.016 250);
+ --border-subtle: oklch(0.90 0.012 250);
+ --input: oklch(0.82 0.016 250);
+ --ring: oklch(0.48 0.15 162 / 0.35);
+ --success: oklch(0.50 0.13 152);
+ --warning: oklch(0.58 0.13 75);
+ --info: oklch(0.48 0.13 235);
+}
@layer base {
:root {
- color-scheme: dark;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
@@ -102,8 +124,8 @@
body {
min-width: 320px;
- background-color: var(--color-app-bg);
- color: var(--color-fg);
+ background-color: var(--background);
+ color: var(--foreground);
font-family: var(--font-sans);
}
@@ -120,140 +142,21 @@
}
::selection {
- background-color: var(--color-accent-soft);
- color: var(--color-fg);
+ background-color: color-mix(in oklch, var(--primary), transparent 76%);
+ color: var(--foreground);
}
}
-/* ── Shared utilities ── */
-
@layer utilities {
.bg-app {
- background-color: var(--color-app-bg);
- }
-
- .shadow-card {
- box-shadow:
- 0 1px 2px oklch(0 0 0 / 0.14),
- 0 10px 24px oklch(0 0 0 / 0.10);
+ background-color: var(--background);
}
- .shadow-card-lg {
- box-shadow:
- 0 2px 4px oklch(0 0 0 / 0.16),
- 0 16px 36px oklch(0 0 0 / 0.12);
- }
-
- .card-gradient {
- background-color: color-mix(
- in oklch,
- var(--color-surface-raised),
- var(--color-surface) 55%
- );
- background-image: linear-gradient(
- 180deg,
- oklch(1 0 0 / 0.03) 0%,
- oklch(1 0 0 / 0) 12%
- );
- }
-
- .btn-primary {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 0.5rem;
- background: linear-gradient(180deg, var(--color-accent-hover) 0%, var(--color-accent) 100%);
- color: var(--color-accent-fg);
- border: none;
- border-radius: var(--radius-md);
- font-weight: 600;
- cursor: pointer;
- transition: all 0.15s ease;
- }
- .btn-primary:hover {
- background: var(--color-accent-hover);
- }
- .btn-primary:active {
- transform: translateY(1px);
- box-shadow: inset 0 1px 3px oklch(0 0 0 / 0.2);
- }
- .btn-primary:disabled {
- opacity: 0.5;
- cursor: not-allowed;
- }
-
-
.nav-surface {
- background-color: color-mix(in oklch, var(--color-surface), transparent 20%);
- }
- .btn-ghost {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 0.5rem;
- background-color: transparent;
- color: var(--color-fg-muted);
- border: none;
- border-radius: var(--radius-md);
- font-weight: 500;
- cursor: pointer;
- transition: all 0.15s ease;
- }
- .btn-ghost:hover {
- background-color: var(--color-surface-hover);
- color: var(--color-fg);
- }
-
- .input-field {
- width: 100%;
- background-color: var(--color-surface);
- color: var(--color-fg);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-md);
- padding: 0.625rem 0.875rem;
- transition: border-color 0.15s ease, box-shadow 0.15s ease;
- }
- .input-field::placeholder {
- color: var(--color-fg-subtle);
- }
- .input-field:hover {
- border-color: var(--color-border-subtle);
- }
- .input-field:focus {
- outline: none;
- border-color: var(--color-accent);
- box-shadow: 0 0 0 3px var(--color-ring);
+ background-color: color-mix(in oklch, var(--card), transparent 8%);
}
}
-/* ── Theme-specific background gradients ── */
-
-[data-theme="emerald"] .bg-app {
- background-image:
- radial-gradient(ellipse at 20% 0%, oklch(0.72 0.19 163 / 0.07), transparent 50%),
- radial-gradient(ellipse at 80% 0%, oklch(0.72 0.15 195 / 0.05), transparent 45%),
- linear-gradient(
- 180deg,
- oklch(0.12 0.018 160) 0%,
- oklch(0.16 0.015 158) 50%,
- oklch(0.19 0.012 155) 100%
- );
-}
-
-[data-theme="frost"] .bg-app {
- background-image:
- radial-gradient(ellipse at 20% 0%, oklch(0.22 0.06 168 / 0.14), transparent 50%),
- radial-gradient(ellipse at 80% 0%, oklch(0.20 0.06 242 / 0.10), transparent 45%),
- linear-gradient(
- 180deg,
- oklch(0.08 0.020 242) 0%,
- oklch(0.11 0.018 242) 50%,
- oklch(0.14 0.016 242) 100%
- );
-}
-
-/* ── Dark Reader compatibility ── */
-
html[data-darkreader-mode] .bg-app {
background-image: none;
}
@@ -265,41 +168,17 @@ html[data-darkreader-mode] .nav-surface {
backdrop-filter: none;
}
-html[data-darkreader-mode] .card-gradient {
- background-color: var(--color-surface-raised);
- background-image: none;
-}
-
-html[data-darkreader-mode] .placeholder-card {
- background-color: var(--color-surface-raised);
- background-image: none;
-}
-
html[data-darkreader-mode] .nav-link-active {
background-color: var(--color-accent);
color: var(--color-accent-fg);
}
-html[data-darkreader-mode] .badge-accent {
- background-color: var(--color-accent);
- color: var(--color-accent-fg);
-}
-
-html[data-darkreader-mode] .shadow-card,
-html[data-darkreader-mode] .shadow-card-lg,
-html[data-darkreader-mode] .btn-primary:active,
-html[data-darkreader-mode] .input-field:focus {
- box-shadow: none;
-}
-
html[data-darkreader-mode] .backdrop-blur-sm,
html[data-darkreader-mode] .backdrop-blur-xl {
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
-/* ── Animations ── */
-
@keyframes slide-in-right {
from {
transform: translateX(100%);
@@ -317,18 +196,8 @@ html[data-darkreader-mode] .backdrop-blur-xl {
}
}
-[data-theme="emerald"] .nav-surface {
- background-image: linear-gradient(
- 180deg,
- color-mix(in oklch, var(--color-surface-raised), transparent 15%) 0%,
- color-mix(in oklch, var(--color-surface), transparent 20%) 100%
- );
-}
-
-[data-theme="frost"] .nav-surface {
- background-image: linear-gradient(
- 180deg,
- color-mix(in oklch, var(--color-surface), transparent 15%) 0%,
- color-mix(in oklch, var(--color-surface), var(--color-app-bg) 35%) 100%
- );
+@layer utilities {
+ .text-balance {
+ text-wrap: balance;
+ }
}
diff --git a/src/frontend/tests/darkreader-css.test.mjs b/src/frontend/tests/darkreader-css.test.mjs
index e04fe1f..d920405 100644
--- a/src/frontend/tests/darkreader-css.test.mjs
+++ b/src/frontend/tests/darkreader-css.test.mjs
@@ -8,28 +8,21 @@ const globalsCss = readFileSync(
"utf8",
);
-test("Dark Reader fallback strips layered background effects", () => {
+test("Dark Reader fallback strips app background effects", () => {
assert.match(globalsCss, /html\[data-darkreader-mode\] \.bg-app \{/);
- assert.match(globalsCss, /html\[data-darkreader-mode\] \.card-gradient \{/);
- assert.match(globalsCss, /html\[data-darkreader-mode\] \.placeholder-card \{/);
assert.match(globalsCss, /background-image: none;/);
});
-test("Dark Reader fallback disables blur and glow-heavy shadows", () => {
+test("Dark Reader fallback disables navigation blur", () => {
assert.match(globalsCss, /html\[data-darkreader-mode\] \.nav-surface \{/);
assert.match(
globalsCss,
/html\[data-darkreader-mode\] \.backdrop-blur-sm,\s*html\[data-darkreader-mode\] \.backdrop-blur-xl \{/,
);
- assert.match(
- globalsCss,
- /html\[data-darkreader-mode\] \.shadow-card,\s*html\[data-darkreader-mode\] \.shadow-card-lg,\s*html\[data-darkreader-mode\] \.btn-primary:active,\s*html\[data-darkreader-mode\] \.input-field:focus \{/,
- );
});
-test("Dark Reader fallback restores contrast for active nav items and accent badges", () => {
+test("Dark Reader fallback restores contrast for active nav items", () => {
assert.match(globalsCss, /html\[data-darkreader-mode\] \.nav-link-active \{/);
- assert.match(globalsCss, /html\[data-darkreader-mode\] \.badge-accent \{/);
assert.match(globalsCss, /background-color: var\(--color-accent\);/);
assert.match(globalsCss, /color: var\(--color-accent-fg\);/);
});