This commit is contained in:
valenti
2026-09-08 07:21:34 -04:00
commit a4b224da8e
169 changed files with 27483 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
export type BadgeTone =
| "neutral"
| "brand"
| "green"
| "amber"
| "red"
| "violet"
| "blue";
const TONES: Record<BadgeTone, string> = {
neutral: "bg-slate-100 text-slate-700 border-slate-200",
brand: "bg-brand-50 text-brand-700 border-brand-100",
green: "bg-emerald-50 text-emerald-700 border-emerald-100",
amber: "bg-amber-50 text-amber-700 border-amber-100",
red: "bg-red-50 text-red-700 border-red-100",
violet: "bg-violet-50 text-violet-700 border-violet-100",
blue: "bg-sky-50 text-sky-700 border-sky-100",
};
export function Badge({
tone = "neutral",
className,
children,
...rest
}: React.HTMLAttributes<HTMLSpanElement> & { tone?: BadgeTone }) {
return (
<span
className={cn(
"inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full border",
TONES[tone],
className,
)}
{...rest}
>
{children}
</span>
);
}
export function JobStatusBadge({ status }: { status: string }) {
const map: Record<string, BadgeTone> = {
queued: "neutral",
running: "blue",
completed: "green",
failed: "red",
cancelled: "amber",
};
return <Badge tone={map[status] ?? "neutral"}>{status}</Badge>;
}
export function ArtifactTypeBadge({ type }: { type: string }) {
const map: Record<string, BadgeTone> = {
requirement: "brand",
epic: "violet",
story: "brand",
task: "blue",
architecture_decision: "violet",
document: "neutral",
source_file: "neutral",
commit: "green",
pull_request: "blue",
build: "amber",
test: "green",
security_finding: "red",
deployment: "green",
release: "green",
runtime_event: "amber",
incident: "red",
ai_analysis: "violet",
};
return <Badge tone={map[type] ?? "neutral"}>{type}</Badge>;
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
type Variant = "primary" | "secondary" | "ghost" | "danger";
type Size = "sm" | "md" | "lg";
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
as?: "button" | "a";
href?: string;
}
const variantClass: Record<Variant, string> = {
primary: "asf-btn-primary",
secondary: "asf-btn-secondary",
ghost: "asf-btn-ghost",
danger: "asf-btn bg-red-50 text-red-700 hover:bg-red-100 border-red-100",
};
const sizeClass: Record<Size, string> = {
sm: "text-xs px-2.5 py-1.5",
md: "text-sm px-3.5 py-2",
lg: "text-base px-5 py-2.5",
};
export function Button({
variant = "primary",
size = "md",
className,
as = "button",
href,
children,
...rest
}: ButtonProps) {
const cls = cn(variantClass[variant], sizeClass[size], className);
if (as === "a" && href) {
return (
<a href={href} className={cls} {...(rest as unknown as React.AnchorHTMLAttributes<HTMLAnchorElement>)}>
{children}
</a>
);
}
return (
<button className={cls} {...rest}>
{children}
</button>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
export function Card({
className,
children,
...rest
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("asf-card", className)} {...rest}>{children}</div>;
}
export function CardHeader({
className,
children,
...rest
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("asf-card-header", className)} {...rest}>{children}</div>;
}
export function CardTitle({
className,
children,
...rest
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("asf-card-title", className)} {...rest}>{children}</div>;
}
export function CardBody({
className,
children,
...rest
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("asf-card-body", className)} {...rest}>{children}</div>;
}
export function CardFooter({
className,
children,
...rest
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("px-5 py-3 border-t border-slate-100 text-xs text-slate-500 flex items-center justify-between", className)} {...rest}>
{children}
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export interface Option<T = string> {
value: T;
label: React.ReactNode;
description?: string;
}
export function Dropdown<T extends string>({
options,
value,
onChange,
placeholder = "Select...",
className,
label,
size = "md",
tone = "secondary",
icon,
empty = "No options",
}: {
options: Option<T>[];
value?: T;
onChange: (v: T) => void;
placeholder?: string;
className?: string;
label?: string;
size?: "sm" | "md";
tone?: "secondary" | "ghost";
icon?: React.ReactNode;
empty?: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function h(e: MouseEvent) {
if (!ref.current) return;
if (!ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", h);
return () => document.removeEventListener("mousedown", h);
}, []);
const current = options.find((o) => o.value === value);
const btnClass = cn(
"inline-flex items-center gap-2 border rounded-lg transition-colors",
size === "sm" ? "text-xs px-2.5 py-1.5" : "text-sm px-3 py-2",
tone === "secondary"
? "bg-white border-slate-200 text-slate-700 hover:bg-slate-50"
: "bg-transparent border-transparent text-slate-700 hover:bg-slate-100",
);
return (
<div className={cn("relative inline-block text-left", className)} ref={ref}>
{label && <div className="text-[11px] uppercase tracking-wide text-slate-500 mb-1">{label}</div>}
<button
type="button"
className={btnClass}
onClick={() => setOpen((v) => !v)}
>
{icon && <span className="text-slate-500">{icon}</span>}
<span className={cn("truncate", !current && "text-slate-400")}>
{current?.label ?? placeholder}
</span>
<ChevronDown className={cn("w-4 h-4 text-slate-400 transition-transform", open && "rotate-180")} />
</button>
{open && (
<div className="absolute z-30 mt-1 right-0 min-w-[240px] rounded-xl bg-white border border-slate-200 shadow-soft overflow-hidden animate-fade-in">
{options.length === 0 ? (
<div className="px-4 py-6 text-sm text-slate-500 text-center">{empty}</div>
) : (
<ul className="max-h-80 overflow-auto py-1">
{options.map((o) => (
<li key={String(o.value)}>
<button
type="button"
className={cn(
"w-full text-left px-3 py-2 text-sm transition-colors",
value === o.value
? "bg-brand-50 text-brand-700 font-medium"
: "text-slate-700 hover:bg-slate-50",
)}
onClick={() => {
onChange(o.value);
setOpen(false);
}}
>
<div className="truncate">{o.label}</div>
{o.description && (
<div className="text-xs text-slate-500 mt-0.5 truncate">{o.description}</div>
)}
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,43 @@
"use client";
import React from "react";
import { Inbox, ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "./Button";
export function EmptyState({
title = "Nothing here yet",
description = "Create some data in ASF or wait a moment for it to load.",
icon: Icon = Inbox,
action,
actionLabel,
className,
}: {
title?: string;
description?: string;
icon?: React.ComponentType<{ className?: string }>;
action?: () => void;
actionLabel?: string;
className?: string;
}) {
return (
<div
className={cn(
"flex flex-col items-center justify-center py-14 px-6 text-center border border-dashed border-slate-200 rounded-xl bg-slate-50/60 animate-fade-in",
className,
)}
>
<div className="w-14 h-14 rounded-full bg-white border border-slate-200 grid place-items-center text-slate-500 shadow-sm mb-4">
<Icon className="w-6 h-6" />
</div>
<h3 className="text-base font-semibold text-slate-800 mb-1">{title}</h3>
<p className="text-sm text-slate-500 max-w-md mb-5">{description}</p>
{action && actionLabel && (
<Button variant="primary" size="sm" onClick={action}>
<ShieldCheck className="w-4 h-4" />
{actionLabel}
</Button>
)}
</div>
);
}
@@ -0,0 +1,111 @@
"use client";
import React from "react";
import { TrendingUp, TrendingDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Card, CardBody } from "./Card";
import { Badge } from "./Badge";
export function MetricCard({
label,
value,
delta,
tone = "neutral",
icon,
subtitle,
isPercentage,
className,
}: {
label: string;
value: number | string;
delta?: number; // percentage change, e.g. +5.2
tone?: "neutral" | "brand" | "green" | "red" | "amber" | "violet" | "blue";
icon?: React.ReactNode;
subtitle?: string;
isPercentage?: boolean;
className?: string;
}) {
const showValue = isPercentage
? `${Number(value).toFixed(1)}%`
: typeof value === "number"
? value.toLocaleString()
: value;
return (
<Card className={cn("relative overflow-hidden", className)}>
<CardBody className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide">{label}</div>
<div className="mt-1.5 text-2xl font-semibold text-slate-900 tabular-nums">
{showValue}
</div>
{subtitle !== undefined && (
<div className="mt-1 text-xs text-slate-500">{subtitle}</div>
)}
{delta !== undefined && (
<div className="mt-2 inline-flex items-center gap-1 text-xs">
{delta >= 0 ? (
<>
<TrendingUp className="w-3.5 h-3.5 text-emerald-600" />
<span className="text-emerald-700 font-medium">+{delta.toFixed(1)}%</span>
</>
) : (
<>
<TrendingDown className="w-3.5 h-3.5 text-red-600" />
<span className="text-red-700 font-medium">{delta.toFixed(1)}%</span>
</>
)}
<span className="text-slate-400 ml-1">vs last period</span>
</div>
)}
</div>
{icon && (
<div
className={cn(
"w-10 h-10 rounded-lg grid place-items-center shrink-0 border",
tone === "brand" && "bg-brand-50 border-brand-100 text-brand-600",
tone === "green" && "bg-emerald-50 border-emerald-100 text-emerald-600",
tone === "red" && "bg-red-50 border-red-100 text-red-600",
tone === "amber" && "bg-amber-50 border-amber-100 text-amber-600",
tone === "violet" && "bg-violet-50 border-violet-100 text-violet-600",
tone === "blue" && "bg-sky-50 border-sky-100 text-sky-600",
tone === "neutral" && "bg-slate-50 border-slate-200 text-slate-500",
)}
>
{icon}
</div>
)}
</CardBody>
</Card>
);
}
export function MetricPill({
label,
value,
tone = "neutral",
isPercentage,
}: {
label: string;
value: number | string;
tone?: "neutral" | "brand" | "green" | "red" | "amber" | "blue";
isPercentage?: boolean;
}) {
const shown = isPercentage ? `${Number(value).toFixed(1)}%` : value;
const toneMap: Record<typeof tone, string> = {
neutral: "bg-slate-100 text-slate-700",
brand: "bg-brand-50 text-brand-700",
blue: "bg-sky-50 text-sky-700",
green: "bg-emerald-50 text-emerald-700",
red: "bg-red-50 text-red-700",
amber: "bg-amber-50 text-amber-700",
};
return (
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">{label}</span>
<Badge tone={tone === "neutral" ? "neutral" : tone === "brand" ? "brand" : tone === "green" ? "green" : tone === "red" ? "red" : "amber"}>
<span className="tabular-nums font-semibold">{shown}</span>
</Badge>
</div>
);
}
@@ -0,0 +1,82 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
/**
* Donut-style progress ring.
*
* Used in the Overview dashboard for things like implementation coverage,
* test coverage, project health score, traceability score.
*/
export function ProgressRing({
value,
size = 80,
stroke = 8,
tone = "brand",
label,
sublabel,
className,
}: {
value: number; // 0..100
size?: number;
stroke?: number;
tone?: "brand" | "green" | "amber" | "red" | "violet";
label?: string;
sublabel?: string;
className?: string;
}) {
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
const clamped = Math.max(0, Math.min(100, Number(value) || 0));
const offset = c - (clamped / 100) * c;
const toneMap: Record<string, string> = {
brand: "text-brand-600",
green: "text-emerald-600",
amber: "text-amber-600",
red: "text-red-600",
violet: "text-violet-600",
};
return (
<div className={cn("flex items-center gap-4", className)}>
<div className="relative shrink-0" style={{ width: size, height: size }}>
<svg width={size} height={size} className={cn("-rotate-90", toneMap[tone])}>
<circle
cx={size / 2}
cy={size / 2}
r={r}
stroke="currentColor"
strokeOpacity={0.12}
fill="transparent"
strokeWidth={stroke}
/>
<circle
cx={size / 2}
cy={size / 2}
r={r}
stroke="currentColor"
fill="transparent"
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={offset}
className="transition-all duration-500 ease-out"
/>
</svg>
<div className="absolute inset-0 grid place-items-center">
<div className="text-sm font-semibold tabular-nums text-slate-900">
{clamped.toFixed(0)}%
</div>
</div>
</div>
{(label || sublabel) && (
<div className="min-w-0">
{label && <div className="text-sm font-medium text-slate-800">{label}</div>}
{sublabel && <div className="text-xs text-slate-500 mt-0.5">{sublabel}</div>}
</div>
)}
</div>
);
}