Compare commits

...
Author SHA1 Message Date
michele 7e872cfbc3 Drop .ts suffixes from frontend imports for Next typecheck.
Production next build rejects allowImportingTsExtensions-style paths.
2026-09-22 18:54:25 +02:00
michele 3046c4aeee Fix findings-tree TS: do not pass findings twice.
Spread FindingsTreeProps first, then override protocol findings for CertificationsBranch so Next production typecheck can build 2.83.0.
2026-09-22 18:53:13 +02:00
michele b32d581d27 Merge protocol-cert-m0 (vSafe UNKNOWN 2.83) into ui-apple-shell.
Keep Apple shell + findings tree; include USB-C vSafe UNKNOWN from catalog (no invented volts). Changelog 2.83.0 covers both.
2026-09-22 18:49:52 +02:00
michele d5fff7c6dd Apple-like UI shell: findings tree and HDMI 1.4 UNKNOWN.
Keep Error/Warning/Info folders with + disclosure, then domains/rules,
then Certificazioni/Protocolli. FAIL stays first. HDMI 1.4 TMDS Zdiff
renders UNKNOWN — catalog JSON unchanged.
2026-09-22 18:41:20 +02:00
18 changed files with 881 additions and 591 deletions
+8 -2
View File
@@ -2,10 +2,16 @@
What's new in Periscope.
## 2.83.0 — 2026-09-22 — USB-C vSafe still UNKNOWN (no USB-IF PD on file)
## 2.83.0 — 2026-09-22 — Apple-like shell; USB-C vSafe still UNKNOWN
Alta Frequenza zip and `af-allegati` were searched for vSafe5V / vSafe0V. Contents are TI SI (`snla027b` AN-807, `sdaa499` EMI) — they do not state vSafe voltages. USB Type-C CabCon R2.5 §1.6 p.35 points at USB PD without a volt range. Charger Figure 4-39 4.755.5 V stays not-vSafe. HDMI 1.4 TMDS stays UNKNOWN.
Shell Finder/Settings: sidebar, large title, system font, hairline. Findings tree keeps Error / Warning / Info folders (FAIL first) with + disclosure, then domains/rules, then Certificazioni/Protocolli. HDMI 1.4 TMDS electrical displays UNKNOWN — no guessed Zdiff.
Alta Frequenza zip and `af-allegati` were searched for vSafe5V / vSafe0V. Contents are TI SI (`snla027b` AN-807, `sdaa499` EMI) — they do not state vSafe voltages. USB Type-C CabCon R2.5 §1.6 p.35 points at USB PD without a volt range. Charger Figure 4-39 4.755.5 V stays not-vSafe.
- [Changed] App chrome (`sidebar`, `globals.css`, dashboard list, report large title).
- [Changed] Findings tree grouping + protocol section inside the tree.
- [Changed] Protocol UI: HDMI 1.4 TMDS Zdiff/limit/measured show `UNKNOWN`.
- [Changed] Frontend tests `findings-forest`, `protocol-report`.
- [Changed] `vsafe5v` / `vsafe0v` needed_document; `catalog.json`.
- [Changed] INDEX archives AF TI SI as not packed for vSafe.
@@ -6,16 +6,14 @@ import { useRouter, useSearchParams } from "next/navigation";
import {
ArrowRight,
CheckCircle2,
LayoutGrid,
List,
Loader2,
X,
} from "lucide-react";
import { useCredits } from "@/components/billing/credits-context";
import { CreateProjectDialog } from "@/components/dashboard/create-project-dialog";
import { OnboardingSurvey } from "@/components/dashboard/onboarding-survey";
import { ProjectCard } from "@/components/dashboard/project-card";
import { ProjectsTable } from "@/components/dashboard/projects-table";
import { PageHeader } from "@/components/layout/page-header";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
@@ -23,13 +21,10 @@ import {
fetchProjects,
reconcileCheckoutSession,
} from "@/lib/api";
import type { CreditSnapshot, Project } from "@/lib/types";
import { cn } from "@/lib/utils";
import type { Project } from "@/lib/types";
type LayoutKind = "cards" | "table";
type TopupBanner = "pending" | "activated" | "timeout" | "dismissed";
const LAYOUT_KEY = "periscopex:dashboard:view";
const CLONE_STASH_KEY = "periscopex:cloneAsNewProjectId";
export default function DashboardPage() {
@@ -62,28 +57,9 @@ function DashboardBody() {
const [bannerVisible, setBannerVisible] = useState(false);
const [topupPhase, setTopupPhase] = useState<TopupBanner>("pending");
const [activatedDetail, setActivatedDetail] = useState<string | null>(null);
const [layout, setLayout] = useState<LayoutKind>("cards");
const ordered = useMemo(() => newestFirst(projects), [projects]);
useEffect(() => {
try {
const stored = localStorage.getItem(LAYOUT_KEY);
if (stored === "cards" || stored === "table") setLayout(stored);
} catch {
/* storage blocked */
}
}, []);
function persistLayout(next: LayoutKind) {
setLayout(next);
try {
localStorage.setItem(LAYOUT_KEY, next);
} catch {
/* ignore */
}
}
function stripCheckoutQuery() {
const url = new URL(window.location.href);
url.searchParams.delete("topup");
@@ -208,7 +184,7 @@ function DashboardBody() {
}
return (
<div className="flex-1 p-6 max-w-5xl mx-auto w-full">
<div className="mx-auto w-full max-w-[720px] flex-1 px-6 py-6">
<OnboardingSurvey />
{bannerVisible && topupPhase !== "dismissed" && (
<TopupBanner
@@ -217,11 +193,10 @@ function DashboardBody() {
onDismiss={hideBanner}
/>
)}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-lg font-semibold">Projects</h1>
<CreditsLine credits={credits} />
</div>
<PageHeader
title="Progetti"
meta={credits ? `${credits.balance.toFixed(2)} crediti` : "Un progetto, una finestra."}
actions={
<CreateProjectDialog
rerunProject={rerunProject}
onRerunDone={() => setRerunProject(null)}
@@ -229,104 +204,33 @@ function DashboardBody() {
onCloneAsNewDone={() => setCloneAsNewProject(null)}
onCreateProject={upsertProject}
/>
</div>
{!loading && projects.length > 0 && (
<div className="flex items-center justify-end gap-2 mb-4">
<div className="inline-flex rounded-lg border border-input p-0.5">
<button
type="button"
onClick={() => persistLayout("cards")}
aria-pressed={layout === "cards"}
title="Card view"
className={cn(
"p-1 rounded-md transition-colors",
layout === "cards"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<LayoutGrid className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => persistLayout("table")}
aria-pressed={layout === "table"}
title="Table view"
className={cn(
"p-1 rounded-md transition-colors",
layout === "table"
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<List className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
}
/>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-36 rounded-lg" />
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 rounded-[11px]" />
))}
</div>
) : projects.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-12">
No projects yet. Create one to get started.
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center">
<p className="text-[15px] font-medium tracking-tight">Nessun progetto</p>
<p className="mt-1 text-[13px] text-neutral-500">
Crea un progetto e carica netlist, BOM e PCB. Lesame parte da .
</p>
) : layout === "table" ? (
</div>
) : (
<ProjectsTable
projects={ordered}
onDeleted={dropProject}
onRerun={setRerunProject}
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{ordered.map((p) => (
<ProjectCard
key={p.id}
project={p}
onDeleted={() => dropProject(p.id)}
onRerun={setRerunProject}
/>
))}
</div>
)}
</div>
);
}
function CreditsLine({ credits }: { credits: CreditSnapshot | null }) {
if (!credits) {
return (
<p className="text-sm text-muted-foreground">
Schematic validation projects
</p>
);
}
return (
<p className="text-sm text-muted-foreground">
{credits.balance.toFixed(2)} credits
{" · "}
<Link
href="/billing"
className="underline underline-offset-2 hover:text-foreground"
>
buy more
</Link>
{" · "}
<Link
href="/credits"
className="underline underline-offset-2 hover:text-foreground"
>
ledger
</Link>
</p>
);
}
function TopupBanner({
phase,
detail,
@@ -14,7 +14,7 @@ export default function AppShellLayout({
<AuthGate>
<div className="flex h-full">
<Sidebar />
<main className="flex-1 flex flex-col overflow-auto">
<main className="flex min-h-0 flex-1 flex-col overflow-auto bg-[#f5f5f7] dark:bg-[#1c1c1e]">
{children}
</main>
</div>
@@ -18,7 +18,7 @@ import { useReviewedFindings } from "@/hooks/use-reviewed-findings";
import { ReportSummary } from "@/components/report/report-summary";
import { FindingsList } from "@/components/report/findings-list";
import { FindingFocusView } from "@/components/report/finding-focus-view";
import { ProtocolSection } from "@/components/report/protocol-section";
import { PageHeader } from "@/components/layout/page-header";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Toast, useToast } from "@/components/ui/toast";
@@ -240,18 +240,14 @@ function ReportContent({ projectId }: { projectId: string }) {
return (
<div
ref={rootRef}
className={cn("p-6 mx-auto w-full", focus ? "max-w-[1920px]" : "max-w-5xl")}
className={cn("mx-auto w-full px-6 py-6", focus ? "max-w-[1920px]" : "max-w-[720px]")}
>
<div className={cn("space-y-6", focus && "hidden")}>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-lg font-semibold">Validation Report</h1>
<p className="text-sm text-muted-foreground">
{report.findings.length} findings &middot;{" "}
{new Date(report.timestamp).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-2">
<div className={cn("space-y-5", focus && "hidden")}>
<PageHeader
title={projectName || "Esame"}
meta={`${report.findings.length} findings · ${new Date(report.timestamp).toLocaleDateString()}`}
actions={
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
@@ -297,14 +293,14 @@ function ReportContent({ projectId }: { projectId: string }) {
Sign release
</Button>
</div>
</div>
}
/>
<ReportSummary
summary={report.summary}
reviewedCount={reviewedCount}
creditsSpent={creditsSpent}
totalCostUsd={totalCostUsd}
/>
<ProtocolSection section={report.protocol_certification} />
{failedReviews > 0 && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4 space-y-2">
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
@@ -344,12 +340,12 @@ function ReportContent({ projectId }: { projectId: string }) {
</ul>
</div>
)}
{report.summary.total === 0 ? (
<div className="rounded-lg border border-border bg-card p-8 text-center space-y-2">
<p className="text-sm font-medium">No findings</p>
<p className="text-xs text-muted-foreground">
No IC datasheets were available for review. Upload datasheets for each IC
on the project page and re-run the pipeline to get findings.
{report.summary.total === 0 &&
!(report.protocol_certification?.recognized_instances?.length) ? (
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center">
<p className="text-[15px] font-medium tracking-tight">Nessun finding</p>
<p className="mt-1 text-[13px] text-neutral-500">
Carica i datasheet degli IC sulla pagina progetto e rilancia lesame.
</p>
</div>
) : (
@@ -370,6 +366,7 @@ function ReportContent({ projectId }: { projectId: string }) {
reportedFindingIds={reportedFindingIds}
reviews={reviews}
onReviewSaved={handleReviewSaved}
protocolSection={report.protocol_certification}
/>
)}
</div>
+26 -24
View File
@@ -6,10 +6,11 @@
/* Map semantic tokens onto Tailwind theme keys. Values live on :root / .dark. */
@theme inline {
--font-sans: "Geist", "Geist Fallback", ui-sans-serif, system-ui, sans-serif;
--font-mono: "Geist Mono", "Geist Mono Fallback", ui-monospace, monospace;
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
system-ui, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Geist Mono", Menlo, monospace;
--font-heading: var(--font-sans);
--font-headline: var(--font-dm-serif-display), Georgia, serif;
--font-headline: var(--font-sans);
--color-background: var(--background);
--color-foreground: var(--foreground);
@@ -53,15 +54,15 @@
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--radius: 0.6875rem;
--background: #f5f5f7;
--foreground: #1d1d1f;
--card: #ffffff;
--card-foreground: #1d1d1f;
--popover: #ffffff;
--popover-foreground: #1d1d1f;
--primary: #007aff;
--primary-foreground: #ffffff;
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
@@ -69,22 +70,22 @@
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--border: rgba(0, 0, 0, 0.1);
--input: rgba(0, 0, 0, 0.1);
--ring: #007aff;
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--sidebar: #f5f5f7;
--sidebar-foreground: #1d1d1f;
--sidebar-primary: #007aff;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: rgba(0, 122, 255, 0.12);
--sidebar-accent-foreground: #007aff;
--sidebar-border: rgba(0, 0, 0, 0.1);
--sidebar-ring: #007aff;
}
.dark {
@@ -129,7 +130,8 @@
@apply font-sans;
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground antialiased;
font-feature-settings: "tnum" 1;
}
}
+1 -18
View File
@@ -1,5 +1,4 @@
import type { Metadata, Viewport } from "next";
import { DM_Serif_Display, Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme/theme-provider";
import { ClerkThemeProvider } from "@/components/theme/clerk-theme-provider";
@@ -13,22 +12,6 @@ import {
TWITTER_HANDLE,
} from "@/lib/site";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const dmSerifDisplay = DM_Serif_Display({
variable: "--font-dm-serif-display",
weight: "400",
subsets: ["latin"],
});
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
@@ -109,7 +92,7 @@ export default function RootLayout({
<html
lang="en"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} ${dmSerifDisplay.variable} h-full antialiased`}
className="h-full antialiased"
>
<body className="h-full">
<ThemeProvider>
@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
export function PageHeader({
title,
meta,
actions,
}: {
title: string;
meta?: string;
actions?: ReactNode;
}) {
return (
<header className="flex flex-wrap items-end justify-between gap-3 pb-4">
<div className="min-w-0">
<h1 className="text-[32px] font-semibold leading-[1.1] tracking-tight text-neutral-900 dark:text-neutral-50">
{title}
</h1>
{meta ? (
<p className="mt-1 text-[13px] text-neutral-500 tabular-nums">{meta}</p>
) : null}
</div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</header>
);
}
@@ -4,20 +4,15 @@ import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState, type ReactNode } from "react";
import {
ArrowLeft,
Boxes,
CircuitBoard,
BookOpen,
ClipboardList,
LayoutDashboard,
Folder,
Library,
Loader2,
MessageSquareWarning,
Ruler,
ScrollText,
ScanLine,
Settings,
Shield,
TableProperties,
Zap,
} from "lucide-react";
import { PeriscopeMark } from "@/components/brand/periscope-mark";
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
@@ -72,15 +67,15 @@ export function Sidebar() {
const isAdmin = user?.isAdmin ?? false;
return (
<aside className="w-56 shrink-0 border-r border-border bg-card flex flex-col min-h-0">
<div className="px-4 py-4 border-b border-border">
<aside className="flex w-[196px] shrink-0 flex-col border-r border-black/10 bg-[#f5f5f7]/90 backdrop-blur-md dark:border-white/10 dark:bg-[#1c1c1e]/90">
<div className="border-b border-black/10 px-3 py-3 dark:border-white/10">
<Link href="/dashboard" className="flex items-center gap-2">
<PeriscopeMark className="h-5 w-5" />
<span className="text-sm font-semibold tracking-tight">Periscope</span>
<span className="text-[13px] font-semibold tracking-tight">Periscope</span>
</Link>
</div>
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{projectId ? (
<Suspense fallback={<nav className="flex-1 px-2 py-3" aria-hidden />}>
<ProjectNav
@@ -95,23 +90,23 @@ export function Sidebar() {
)}
</div>
<div className="border-t border-border">
<div className="border-t border-black/10 dark:border-white/10">
<SidebarCredits />
<div className="px-2 py-1.5 border-b border-border/60">
<div className="border-b border-black/[0.06] px-2 py-1.5 dark:border-white/10">
<button
type="button"
onClick={() => setFeedbackOpen(true)}
className="flex items-center gap-2 w-full px-3 py-1.5 rounded-md text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
className="flex w-full items-center gap-2 rounded-[8px] px-2 py-1.5 text-[11px] text-neutral-500 transition-colors duration-150 ease-out hover:bg-black/[0.04] hover:text-neutral-900"
>
<MessageSquareWarning className="h-3.5 w-3.5" />
Feedback
</button>
</div>
<div className="px-4 py-3 flex items-center gap-2">
<div className="flex items-center gap-2 px-3 py-2.5">
<SidebarUserButton />
<Link
href="/changelog"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
className="text-[11px] tabular-nums text-neutral-500 hover:text-neutral-900"
>
v{APP_VERSION}
</Link>
@@ -125,36 +120,35 @@ export function Sidebar() {
function navItemClass(active: boolean) {
return cn(
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
"flex items-center gap-2 rounded-[8px] px-2 py-[6px] text-[12px] transition-colors duration-150 ease-out",
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-accent/50",
? "bg-[#007aff]/12 text-[#007aff]"
: "text-neutral-600 hover:bg-black/[0.04] hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-white/[0.06] dark:hover:text-white",
);
}
function DefaultNav({ pathname, isAdmin }: { pathname: string; isAdmin: boolean }) {
return (
<nav className="flex-1 px-2 py-3 space-y-0.5">
<nav className="flex-1 space-y-0.5 px-2 py-3">
<Link href="/dashboard" className={navItemClass(pathname === "/dashboard")}>
<LayoutDashboard className="h-4 w-4" />
Projects
<Folder className="h-3.5 w-3.5" />
Progetti
</Link>
<Link href="/library" className={navItemClass(pathname === "/library")}>
<Library className="h-4 w-4" />
Library
<Library className="h-3.5 w-3.5" />
Libreria
</Link>
<Link href="/feedback" className={navItemClass(pathname === "/feedback")}>
<MessageSquareWarning className="h-4 w-4" />
My Feedback
<MessageSquareWarning className="h-3.5 w-3.5" />
Feedback
</Link>
{isAdmin && (
<Link
href="/admin"
className={navItemClass(pathname === "/admin" || pathname.startsWith("/admin/"))}
>
<Shield className="h-4 w-4" />
<Shield className="h-3.5 w-3.5" />
Admin
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
</Link>
)}
</nav>
@@ -162,19 +156,18 @@ function DefaultNav({ pathname, isAdmin }: { pathname: string; isAdmin: boolean
}
type NavItem =
| { type: "route"; path: string; label: string; icon: typeof ClipboardList; adminOnly?: boolean }
| { type: "tab"; tab: string; label: string; icon: typeof ClipboardList; adminOnly?: boolean };
| { type: "route"; path: string; label: string; icon: typeof ClipboardList }
| { type: "tab"; tab: string; label: string; icon: typeof ClipboardList };
const PROJECT_NAV_ITEMS: NavItem[] = [
const PRIMARY_NAV: NavItem[] = [
{ type: "tab", tab: "bom", label: "Esame", icon: ScanLine },
{ type: "route", path: "/report", label: "Protocolli", icon: BookOpen },
{ type: "route", path: "/report", label: "Report", icon: ClipboardList },
{ type: "route", path: "/pcb", label: "Layout", icon: CircuitBoard },
{ type: "tab", tab: "bom", label: "BOM", icon: TableProperties },
{ type: "tab", tab: "domains", label: "Domains", icon: Boxes },
{ type: "tab", tab: "rails", label: "Power rails", icon: CircuitBoard },
{ type: "tab", tab: "derating", label: "Derating", icon: Zap },
{ type: "tab", tab: "impedance", label: "RF / Impedance", icon: Ruler },
{ type: "tab", tab: "logs", label: "Logs", icon: ScrollText, adminOnly: true },
{ type: "tab", tab: "settings", label: "Settings", icon: Settings },
];
const SECONDARY_NAV: NavItem[] = [
{ type: "route", path: "/pcb", label: "Layout", icon: ScanLine },
{ type: "tab", tab: "settings", label: "Impostazioni", icon: Settings },
];
function NavLink({
@@ -220,8 +213,11 @@ function ProjectNav({
const isRunning = project?.status === "running";
const isOnProgress = pathname === `${base}/progress`;
const onNestedRoute = pathname.startsWith(`${base}/`);
const onReport = pathname === `${base}/report`;
function isActive(item: NavItem): boolean {
if (item.label === "Protocolli") return onReport;
if (item.label === "Report") return onReport;
if (item.type === "route") {
return pathname === `${base}${item.path}` && !currentTab;
}
@@ -232,35 +228,12 @@ function ProjectNav({
}
function getHref(item: NavItem): string {
if (item.label === "Protocolli") return `${base}/report`;
if (item.type === "route") return `${base}${item.path}`;
return `${base}?tab=${item.tab}`;
}
return (
<nav className="flex-1 px-2 py-3 space-y-1">
<NavLink href="/dashboard" forceDocument={onNestedRoute}>
<ArrowLeft className="h-4 w-4" />
Dashboard
</NavLink>
<NavLink href="/library" forceDocument={onNestedRoute}>
<Library className="h-4 w-4" />
Library
</NavLink>
<div className="px-3 pt-3 pb-1">
<p className="text-xs font-semibold text-foreground truncate">
{project?.name ?? "Loading..."}
</p>
</div>
<div className="space-y-0.5">
{isRunning && (
<NavLink href={`${base}/progress`} active={isOnProgress}>
<Loader2 className="h-4 w-4 animate-spin" />
Processing
</NavLink>
)}
{PROJECT_NAV_ITEMS.filter((item) => !item.adminOnly || isAdmin).map((item) => {
function renderItem(item: NavItem) {
const active = isActive(item);
const href = getHref(item);
const forceDocument = item.type === "tab" && onNestedRoute;
@@ -268,26 +241,53 @@ function ProjectNav({
return (
<span
key={item.label}
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-muted-foreground/40 cursor-not-allowed"
className="flex cursor-not-allowed items-center gap-2 rounded-[8px] px-2 py-[6px] text-[12px] text-neutral-400"
>
<item.icon className="h-4 w-4" />
<item.icon className="h-3.5 w-3.5" />
{item.label}
{item.adminOnly && (
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
)}
</span>
);
}
return (
<NavLink key={item.label} href={href} active={active} forceDocument={forceDocument}>
<item.icon className="h-4 w-4" />
<item.icon className="h-3.5 w-3.5" />
{item.label}
{item.adminOnly && (
<Shield className="h-3 w-3 ml-auto text-amber-600/60 dark:text-amber-500/60" />
)}
</NavLink>
);
})}
}
return (
<nav className="flex-1 space-y-1 px-2 py-3">
<NavLink href="/dashboard" forceDocument={onNestedRoute}>
<Folder className="h-3.5 w-3.5" />
Progetti
</NavLink>
<NavLink href="/library" forceDocument={onNestedRoute}>
<Library className="h-3.5 w-3.5" />
Libreria
</NavLink>
<p className="truncate px-2 pt-3 pb-1 text-[11px] font-medium text-neutral-500">
{project?.name ?? "…"}
</p>
<div className="space-y-0.5">
{isRunning && (
<NavLink href={`${base}/progress`} active={isOnProgress}>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Esame in corso
</NavLink>
)}
{PRIMARY_NAV.map(renderItem)}
</div>
<div className="mt-3 space-y-0.5 border-t border-black/10 pt-2 dark:border-white/10">
{SECONDARY_NAV.map(renderItem)}
{isAdmin ? (
<NavLink href={`${base}?tab=logs`} forceDocument={onNestedRoute}>
<Shield className="h-3.5 w-3.5" />
Log
</NavLink>
) : null}
</div>
</nav>
);
@@ -12,6 +12,7 @@ import type {
FindingStatus,
DesignGraph,
Collaborator,
ProtocolCertificationSection,
} from "@/lib/types";
import { groupBy, getFindingKey } from "@/lib/utils";
import { isLayoutFinding } from "@/lib/layout-finding";
@@ -33,6 +34,7 @@ interface FindingsListProps {
reportedFindingIds?: Set<string>;
reviews?: Record<string, FindingReview>;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
protocolSection?: ProtocolCertificationSection | null;
}
const DEFAULT_STATUS: FindingStatus[] = ["ERROR", "WARNING", "INFO"];
@@ -54,6 +56,7 @@ export function FindingsList({
reportedFindingIds,
reviews,
onReviewSaved,
protocolSection,
}: FindingsListProps) {
const searchParams = useSearchParams();
const router = useRouter();
@@ -161,8 +164,11 @@ export function FindingsList({
reportedFindingIds,
reviews,
onReviewSaved,
protocolSection,
};
const showTree = filtered.length > 0 || (protocolSection?.recognized_instances?.length ?? 0) > 0;
return (
<div className="space-y-4">
<ReportFilters
@@ -180,7 +186,7 @@ export function FindingsList({
onDomainChange={(v) => updateParams({ domain: v })}
designators={designators}
/>
{filtered.length > 0 ? (
{showTree ? (
<FindingsTree findings={filtered} {...treeProps} />
) : reviewedFindings.length === 0 && findings.length > 0 ? (
<FindingsTreeEmpty />
@@ -1,10 +1,8 @@
"use client";
import { useState, type ReactNode } from "react";
import { ChevronRight } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { FindingCard } from "./finding-card";
import { StatusBadge } from "./status-badge";
import { ProtocolInstanceBody } from "./protocol-section";
import type {
Finding,
FindingComment,
@@ -12,10 +10,21 @@ import type {
Collaborator,
Component,
DesignGraph,
FindingStatus,
ProtocolCertificationSection,
} from "@/lib/types";
import { cn, sortFindings, subtypeLabel } from "@/lib/utils";
import { isAfAiFinding, isPcbExamFinding } from "@/lib/layout-finding";
import { groupByWorstStatus, worstStatus } from "@/lib/findings-forest";
import {
STATUS_TINT,
forestHasCertifications,
groupByWorstStatus,
isProtocolFinding,
protocolInstancesFromSection,
protocolInstancesForStatus,
worstStatus,
type DomainGroup,
type ProtocolTreeInstance,
} from "@/lib/findings-forest";
interface FindingsTreeProps {
findings: Finding[];
@@ -35,6 +44,9 @@ interface FindingsTreeProps {
reportedFindingIds?: Set<string>;
reviews?: Record<string, FindingReview>;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
protocolSection?: ProtocolCertificationSection | null;
selectedKey?: string | null;
onSelect?: (key: string) => void;
}
function pinNetLabel(f: Finding): string {
@@ -46,73 +58,126 @@ function pinNetLabel(f: Finding): string {
return f.rule_id || "Finding";
}
function DisclosureMark({ open }: { open: boolean }) {
return (
<span
aria-hidden
className={cn(
"inline-flex h-[11px] w-[11px] shrink-0 items-center justify-center rounded-[2px]",
"border border-black/20 text-[9px] leading-none text-neutral-600",
"dark:border-white/25 dark:text-neutral-300",
)}
>
{open ? "" : "+"}
</span>
);
}
function TreeBranch({
label,
extra,
findings,
children,
tint,
defaultOpen = false,
}: {
label: string;
extra?: string;
findings: Finding[];
findings: { status: FindingStatus }[];
children: ReactNode;
tint?: string;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(defaultOpen);
const worst = worstStatus(findings);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger
className="flex items-center gap-2 w-full py-2 px-2 rounded-md text-left hover:bg-muted/60 min-h-11 md:min-h-8"
>
<ChevronRight
<div>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-90",
"flex w-full items-center gap-2 px-2 py-[5px] text-left text-[13px] transition-colors duration-150 ease-out",
"hover:bg-black/[0.04] dark:hover:bg-white/[0.06]",
tint,
)}
/>
<span className="font-mono text-sm truncate">{label}</span>
>
<DisclosureMark open={open} />
<span className="min-w-0 truncate font-medium tracking-tight">{label}</span>
{extra ? (
<span className="text-xs text-muted-foreground truncate hidden sm:inline">{extra}</span>
) : null}
<span className="ml-auto flex items-center gap-1.5 shrink-0">
<StatusBadge status={worst} />
<span className="text-xs text-muted-foreground">{findings.length}</span>
<span className="hidden min-w-0 truncate text-[11px] text-neutral-500 sm:inline">
{extra}
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="pl-3 ml-3 border-l border-border space-y-0.5">{children}</div>
</CollapsibleContent>
</Collapsible>
) : null}
<span className="ml-auto shrink-0 font-mono text-[11px] tabular-nums text-neutral-500">
{findings.length}
</span>
<span className="sr-only">{worst}</span>
</button>
{open ? <div className="ml-[13px] border-l border-black/10 pl-2 dark:border-white/10">{children}</div> : null}
</div>
);
}
export function FindingsTree(props: FindingsTreeProps) {
const sorted = sortFindings(props.findings);
const af = sorted.filter(isAfAiFinding);
const pcb = sorted.filter((f) => isPcbExamFinding(f) && !isAfAiFinding(f));
const schematic = sorted.filter(
(f) => !isPcbExamFinding(f) && !isAfAiFinding(f),
);
const roots: { id: string; label: string; items: Finding[] }[] = [];
if (schematic.length) roots.push({ id: "schema", label: "Schematic", items: schematic });
if (pcb.length) roots.push({ id: "pcb", label: "PCB exam", items: pcb });
if (af.length) roots.push({ id: "af_ai", label: "AF Board + AI", items: af });
const protocolFindings = sorted.filter(isProtocolFinding);
const rest = sorted.filter((f) => !isProtocolFinding(f));
const folders = groupByWorstStatus(rest.length ? rest : sorted.filter((f) => !isProtocolFinding(f)));
const instances = protocolInstancesFromSection(props.protocolSection);
const order: FindingStatus[] = ["ERROR", "WARNING", "INFO"];
const showRoots = roots.length > 1;
const merged = order
.map((status) => {
const folder = folders.find((g) => g.status === status);
const cert = forestHasCertifications(instances, protocolFindings, status);
if (!folder && !cert) return null;
return { status, folder, cert };
})
.filter(Boolean) as {
status: FindingStatus;
folder: ReturnType<typeof groupByWorstStatus>[number] | undefined;
cert: boolean;
}[];
if (merged.length === 0 && instances.length === 0) {
return <FindingsTreeEmpty />;
}
return (
<nav aria-label="Findings tree" className="rounded-lg border border-border bg-card p-2">
{roots.map((root) => {
const body = (
<DesignatorForest
<nav
aria-label="Findings tree"
className="overflow-hidden rounded-[11px] border border-black/10 bg-white/70 dark:border-white/10 dark:bg-white/[0.04]"
>
{merged.map(({ status, folder, cert }, index) => {
const label =
status === "ERROR" ? "Error" : status === "WARNING" ? "Warning" : "Info";
const findings = [
...(folder?.findings ?? []),
...protocolFindings.filter((f) => f.status === status),
];
const protocolAtStatus = protocolFindings.filter((f) => f.status === status);
const inst = protocolInstancesForStatus(instances, status);
const count = Math.max(findings.length, inst.length);
return (
<TreeBranch
key={status}
label={label}
findings={count ? findings : [{ status }]}
tint={STATUS_TINT[status]}
defaultOpen={index === 0}
>
{(folder?.domains ?? []).map((domain) => (
<DomainBranch key={domain.id} domain={domain} {...props} />
))}
{cert ? (
<CertificationsBranch
{...props}
findings={root.items}
status={status}
instances={inst}
findings={protocolAtStatus}
/>
);
if (!showRoots) return <div key={root.id}>{body}</div>;
return (
<TreeBranch key={root.id} label={root.label} findings={root.items}>
{body}
) : null}
</TreeBranch>
);
})}
@@ -120,25 +185,18 @@ export function FindingsTree(props: FindingsTreeProps) {
);
}
function DesignatorForest({
findings,
graph,
...cardProps
}: FindingsTreeProps) {
const folders = groupByWorstStatus(findings);
function DomainBranch({
domain,
...props
}: { domain: DomainGroup } & FindingsTreeProps) {
return (
<div>
{folders.map((folder) => (
<TreeBranch
key={folder.status}
label={folder.label}
findings={folder.findings}
>
{folder.designators.map((row) => {
<TreeBranch label={domain.label} findings={domain.findings}>
{domain.rules.map((rule) => (
<TreeBranch key={rule.ruleId} label={rule.ruleId} findings={rule.findings}>
{rule.designators.map((row) => {
const d = row.designator;
const group = row.findings;
const component: Component | undefined = graph.components[d];
const component: Component | undefined = props.graph.components[d];
const extra = [
component?.mpn,
component?.component_subtype ? subtypeLabel(component.component_subtype) : "",
@@ -157,31 +215,31 @@ function DesignatorForest({
<TreeBranch key={d} label={d} extra={extra} findings={group}>
{leaves.map(([label, items]) => (
<TreeBranch key={`${d}:${label}`} label={label} findings={items}>
<div className="space-y-3 py-2">
<div className="space-y-2 py-1.5">
{sortFindings(items).map((item) => {
const key = cardProps.findingKeys.get(item);
const key = props.findingKeys.get(item);
return (
<FindingCard
key={key}
finding={item}
onViewReference={cardProps.onViewReference}
checked={key && cardProps.isReviewed ? cardProps.isReviewed(key) : undefined}
onViewReference={props.onViewReference}
checked={key && props.isReviewed ? props.isReviewed(key) : undefined}
onCheckedChange={
key && cardProps.onToggleReviewed
? () => cardProps.onToggleReviewed!(key)
key && props.onToggleReviewed
? () => props.onToggleReviewed!(key)
: undefined
}
comments={item.finding_id ? cardProps.comments?.[item.finding_id] : undefined}
projectId={cardProps.projectId}
collaborators={cardProps.collaborators}
currentUserId={cardProps.currentUserId}
currentUserName={cardProps.currentUserName}
onCommentAdded={cardProps.onCommentAdded}
onCommentDeleted={cardProps.onCommentDeleted}
onReportFinding={cardProps.onReportFinding}
isReported={!!(item.finding_id && cardProps.reportedFindingIds?.has(item.finding_id))}
review={item.finding_id ? cardProps.reviews?.[item.finding_id] : undefined}
onReviewSaved={cardProps.onReviewSaved}
comments={item.finding_id ? props.comments?.[item.finding_id] : undefined}
projectId={props.projectId}
collaborators={props.collaborators}
currentUserId={props.currentUserId}
currentUserName={props.currentUserName}
onCommentAdded={props.onCommentAdded}
onCommentDeleted={props.onCommentDeleted}
onReportFinding={props.onReportFinding}
isReported={!!(item.finding_id && props.reportedFindingIds?.has(item.finding_id))}
review={item.finding_id ? props.reviews?.[item.finding_id] : undefined}
onReviewSaved={props.onReviewSaved}
/>
);
})}
@@ -193,14 +251,88 @@ function DesignatorForest({
})}
</TreeBranch>
))}
</TreeBranch>
);
}
function CertificationsBranch({
status,
instances,
findings,
...props
}: {
status: FindingStatus;
instances: ProtocolTreeInstance[];
findings: Finding[];
} & FindingsTreeProps) {
const all = [...findings, ...instances.map(() => ({ status }))];
return (
<TreeBranch label="Certificazioni" findings={all.length ? all : [{ status }]} defaultOpen>
<TreeBranch
label="Protocolli"
findings={all.length ? all : [{ status }]}
defaultOpen
>
{instances.map((inst) => (
<TreeBranch
key={inst.instanceId}
label={`${inst.logicalId || "protocol"}${inst.physicalId || "—"}`}
extra={inst.recognition}
findings={[{ status: inst.status }]}
>
<ProtocolInstanceBody instance={inst} />
</TreeBranch>
))}
{findings.length > 0 ? (
<TreeBranch label="Findings" findings={findings}>
<div className="space-y-2 py-1.5">
{sortFindings(findings).map((item) => {
const key = props.findingKeys.get(item);
return (
<FindingCard
key={key}
finding={item}
onViewReference={props.onViewReference}
checked={key && props.isReviewed ? props.isReviewed(key) : undefined}
onCheckedChange={
key && props.onToggleReviewed
? () => props.onToggleReviewed!(key)
: undefined
}
comments={item.finding_id ? props.comments?.[item.finding_id] : undefined}
projectId={props.projectId}
collaborators={props.collaborators}
currentUserId={props.currentUserId}
currentUserName={props.currentUserName}
onCommentAdded={props.onCommentAdded}
onCommentDeleted={props.onCommentDeleted}
onReportFinding={props.onReportFinding}
isReported={!!(item.finding_id && props.reportedFindingIds?.has(item.finding_id))}
review={item.finding_id ? props.reviews?.[item.finding_id] : undefined}
onReviewSaved={props.onReviewSaved}
/>
);
})}
</div>
</TreeBranch>
) : null}
{instances.length === 0 && findings.length === 0 ? (
<p className="px-2 py-2 text-[13px] text-neutral-500">
{props.protocolSection?.message || "Nessun protocollo riconosciuto."}
</p>
) : null}
</TreeBranch>
</TreeBranch>
);
}
export function FindingsTreeEmpty() {
return (
<p className="text-sm text-muted-foreground text-center py-12">
No findings match your filters.
<div className="rounded-[11px] border border-black/10 px-5 py-10 text-center dark:border-white/10">
<p className="text-[15px] font-medium tracking-tight">Nessun finding visibile</p>
<p className="mt-1 text-[13px] text-neutral-500">
I filtri nascondono lelenco. Togli un filtro, oppure apri i findings già rivisti sotto.
</p>
</div>
);
}
@@ -1,176 +1,134 @@
"use client";
import type { ProtocolCertificationSection } from "@/lib/types";
import { StatusBadge } from "@/components/report/status-badge";
import {
formatLimit,
formatMargin,
formatMeasured,
formatProtocolChain,
protocolResultToFindingStatus,
sortProtocolChecks,
type ProtocolReportCheck,
} from "@/lib/protocol-report";
import {
protocolInstancesFromSection,
type ProtocolTreeInstance,
} from "@/lib/findings-forest";
import { cn } from "@/lib/utils";
function asChecks(row: Record<string, unknown>): ProtocolReportCheck[] {
const raw = row.report_checks;
if (Array.isArray(raw) && raw.length > 0) {
return sortProtocolChecks(raw as ProtocolReportCheck[]);
}
const fallback: ProtocolReportCheck[] = [];
for (const key of ["l0_checks", "l1_checks", "l2_checks", "l3_checks"] as const) {
const list = row[key];
if (!Array.isArray(list)) continue;
for (const c of list) {
const rec = c as Record<string, unknown>;
fallback.push({
check: String(rec.check ?? ""),
level: String(rec.level ?? ""),
result: String(rec.result ?? ""),
notes: String(rec.notes ?? ""),
skip_visible:
String(rec.result ?? "") !== "PASS" &&
String(rec.result ?? "") !== "FAIL",
});
}
}
return sortProtocolChecks(fallback);
}
export function ProtocolSection({
section,
}: {
section?: ProtocolCertificationSection | null;
}) {
const instances = section?.recognized_instances ?? [];
const message = section?.message || "nessun protocollo riconosciuto";
const maxLevel = section?.max_level_reached || "—";
export function ProtocolInstanceBody({ instance }: { instance: ProtocolTreeInstance }) {
const checks = sortProtocolChecks(instance.checks);
const fail = instance.worst === "FAIL" || instance.status === "ERROR";
const logical = instance.logicalId;
const physical = instance.physicalId;
return (
<section className="rounded-lg border border-border bg-card p-4 space-y-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-sm font-semibold">Protocolli</h2>
<p className="text-xs text-muted-foreground">
Livello massimo: <span className="font-mono text-foreground">{maxLevel}</span>
{" "}
(L0L3; L3 skip unless channel FACT exists no OpenEMS)
<div className="space-y-2 py-2 pr-2">
<p className="text-[11px] tabular-nums text-neutral-500">
{logical} {physical}
{instance.recognition ? ` · ${instance.recognition}` : ""}
{instance.worst ? ` · ${instance.worst}` : ""}
</p>
</div>
<p className="text-xs text-muted-foreground">
Logical protocol vs physical interface. L0 structural, L1 geometric,
L2 electrical (Z only from datasheet/stackup/fab or a cited pack).
L3 channel (timing budget / IL / RL / crosstalk) only from existing FACT.
L0/L1 are not electrical. Length is not delay. FAIL is listed first
it is not filed under Warning.
</p>
{instances.length === 0 ? (
<p className="text-sm">{message}</p>
) : (
<ul className="space-y-4">
{instances.map((row, i) => {
const rec = row as Record<string, unknown>;
const phys = String(rec.physical_interface_id ?? "");
const log = String(rec.logical_protocol_id ?? "");
const status = String(rec.recognition_status ?? "");
const worst = String(rec.worst_result ?? "");
const checks = asChecks(rec);
const fail = Number(rec.fail_count ?? 0) > 0 || worst === "FAIL";
return (
<li
key={String(rec.instance_id ?? i)}
className={cn(
"rounded-md border p-3 space-y-2",
fail
? "border-rose-500/50 bg-rose-500/5"
: "border-border",
)}
>
<div className="flex flex-wrap items-center gap-2">
<p className="font-mono text-xs">
{log} {phys}
{status ? ` · ${status}` : ""}
</p>
{worst ? (
<StatusBadge status={protocolResultToFindingStatus(worst)} />
) : null}
{fail ? (
<span className="text-xs font-medium text-rose-600 dark:text-rose-400">
FAIL visible not under Warning
</span>
<p className="text-[11px] text-[rgb(180,40,35)]">FAIL in testa non sotto Warning.</p>
) : null}
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<table className="w-full text-[11px] tabular-nums">
<thead>
<tr className="text-left text-muted-foreground">
<th className="pr-2 py-1 font-medium">Lv</th>
<th className="pr-2 py-1 font-medium">Check</th>
<th className="pr-2 py-1 font-medium">Result</th>
<th className="pr-2 py-1 font-medium">Measured</th>
<th className="pr-2 py-1 font-medium">Limit</th>
<th className="pr-2 py-1 font-medium">Margin</th>
<th className="pr-2 py-1 font-medium">Source</th>
<tr className="text-left text-neutral-500">
<th className="py-1 pr-2 font-medium">Lv</th>
<th className="py-1 pr-2 font-medium">Check</th>
<th className="py-1 pr-2 font-medium">Result</th>
<th className="py-1 pr-2 font-medium">Measured</th>
<th className="py-1 pr-2 font-medium">Limit</th>
<th className="py-1 pr-2 font-medium">Margin</th>
<th className="py-1 pr-2 font-medium">Source</th>
<th className="py-1 font-medium">Method</th>
</tr>
</thead>
<tbody>
{checks.map((c, j) => {
const isFail = c.result === "FAIL";
const skip = Boolean(c.skip_visible);
return (
<tr
{checks.map((c, j) => (
<ProtocolCheckRow
key={`${c.level}-${c.check}-${j}`}
className={cn(
isFail && "text-rose-700 dark:text-rose-400 font-medium",
skip && !isFail && "text-amber-800 dark:text-amber-300",
)}
>
<td className="pr-2 py-1 font-mono">{c.level}</td>
<td className="pr-2 py-1">{c.check}</td>
<td className="pr-2 py-1">
{c.result}
{skip ? " · skip" : ""}
</td>
<td className="pr-2 py-1 font-mono">{formatMeasured(c)}</td>
<td className="pr-2 py-1 font-mono">{formatLimit(c)}</td>
<td className="pr-2 py-1 font-mono">{formatMargin(c)}</td>
<td className="pr-2 py-1">{c.source || "—"}</td>
<td className="py-1">{c.method || "—"}</td>
</tr>
);
})}
check={c}
logicalId={logical}
physicalId={physical}
/>
))}
</tbody>
</table>
</div>
{String(rec.chain_example || "") ? (
<p className="text-[11px] text-muted-foreground break-all">
Catena: {String(rec.chain_example)}
</p>
{instance.chainExample ? (
<p className="break-all text-[11px] text-neutral-500">Catena: {instance.chainExample}</p>
) : null}
{checks
.filter((c) => c.result === "FAIL" || c.skip_visible)
.map((c, j) =>
c.chain ? (
<p
key={`chain-${j}`}
className="text-[11px] text-muted-foreground break-all"
>
<p key={`chain-${j}`} className="break-all text-[11px] text-neutral-500">
{c.level}/{c.check}: {formatProtocolChain(c.chain)}
</p>
) : null,
)}
{checks.some((c) => c.skip_visible && c.notes) ? (
<ul className="text-xs text-amber-800 dark:text-amber-300 space-y-0.5">
{checks
.filter((c) => c.skip_visible && c.notes)
.map((c, j) => (
<li key={`skip-${j}`}>{c.notes}</li>
))}
</ul>
) : null}
</li>
</div>
);
})}
}
function ProtocolCheckRow({
check: c,
logicalId,
physicalId,
}: {
check: ProtocolReportCheck;
logicalId: string;
physicalId: string;
}) {
const isFail = c.result === "FAIL";
const unknown = c.result === "UNKNOWN";
const measured = formatMeasured(c, logicalId, physicalId);
const limit = formatLimit(c, logicalId, physicalId);
const margin = formatMargin(c, logicalId, physicalId);
return (
<tr
className={cn(
"border-t border-black/[0.06] dark:border-white/10",
isFail && "font-medium text-[rgb(180,40,35)]",
unknown && !isFail && "text-neutral-600",
)}
>
<td className="py-1 pr-2 font-mono">{c.level}</td>
<td className="py-1 pr-2">{c.check}</td>
<td className="py-1 pr-2">{c.result}{c.skip_visible && c.result !== "FAIL" ? " · skip" : ""}</td>
<td className="py-1 pr-2 font-mono">{measured}</td>
<td className="py-1 pr-2 font-mono">{limit}</td>
<td className="py-1 pr-2 font-mono">{margin}</td>
<td className="py-1 pr-2">{c.source || "—"}</td>
<td className="py-1">{c.method || "—"}</td>
</tr>
);
}
/** Standalone block — used only if the tree is empty of findings. */
export function ProtocolSection({
section,
}: {
section?: ProtocolCertificationSection | null;
}) {
const instances = protocolInstancesFromSection(section);
const message = section?.message || "Nessun protocollo riconosciuto.";
const maxLevel = section?.max_level_reached || "—";
return (
<section className="space-y-2">
<p className="text-[11px] text-neutral-500">
Livello massimo {maxLevel} (L0L3). HDMI 1.4 TMDS resta UNKNOWN niente Zdiff inventato.
</p>
{instances.length === 0 ? (
<p className="text-[13px] text-neutral-600">{message}</p>
) : (
<ul className="space-y-3">
{instances.map((inst) => (
<li key={inst.instanceId}>
<ProtocolInstanceBody instance={inst} />
</li>
))}
</ul>
)}
</section>
@@ -1,6 +1,5 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
interface ReportSummaryProps {
@@ -10,81 +9,36 @@ interface ReportSummaryProps {
totalCostUsd?: number | null;
}
const CELLS = [
{ key: "ERROR", label: "Error", color: "text-rose-600 dark:text-rose-400", barColor: "bg-rose-500" },
{ key: "WARNING", label: "Warning", color: "text-amber-600 dark:text-amber-400", barColor: "bg-amber-500" },
{ key: "INFO", label: "Info", color: "text-blue-600 dark:text-blue-400", barColor: "bg-blue-500" },
{ key: "total", label: "Total", color: "text-foreground", barColor: "" },
];
export function ReportSummary({
summary,
reviewedCount,
creditsSpent,
totalCostUsd,
}: ReportSummaryProps) {
const total = summary.total || 0;
const showCredits = typeof creditsSpent === "number" && creditsSpent > 0;
const showCost = typeof totalCostUsd === "number" && totalCostUsd > 0;
const extraCols = (showCredits ? 1 : 0) + (showCost ? 1 : 0);
const grid =
extraCols === 2 ? "grid-cols-7" : extraCols === 1 ? "grid-cols-6" : "grid-cols-5";
const errors = summary.ERROR ?? 0;
const warnings = summary.WARNING ?? 0;
const infos = summary.INFO ?? 0;
const total = summary.total || errors + warnings + infos;
const outcome =
errors > 0 ? "Error" : warnings > 0 ? "Attenzione" : total > 0 ? "Completato" : "Senza findings";
const bits = [
`${errors} Error`,
`${warnings} Warning`,
`${infos} Info`,
`${reviewedCount} rivisti`,
];
if (typeof totalCostUsd === "number" && totalCostUsd > 0) {
bits.push(`$${totalCostUsd.toFixed(2)}`);
}
if (typeof creditsSpent === "number" && creditsSpent > 0) {
bits.push(`${creditsSpent.toFixed(2)} crediti`);
}
return (
<div className="space-y-4">
<div className={cn("grid gap-4", grid)}>
{CELLS.map(({ key, label, color }) => (
<Card key={key}>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">{label}</p>
<p className={cn("text-3xl font-semibold font-mono tabular-nums", color)}>
{summary[key] ?? 0}
<p className={cn("text-[13px] tabular-nums text-neutral-500")}>
<span className="font-medium text-neutral-800 dark:text-neutral-200">{outcome}</span>
{" · "}
{bits.join(" · ")}
</p>
</CardContent>
</Card>
))}
<Card>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">Checked</p>
<p className="text-3xl font-semibold font-mono tabular-nums text-emerald-600 dark:text-emerald-400">
{reviewedCount}
</p>
</CardContent>
</Card>
{showCost && (
<Card>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">API cost</p>
<p className="text-3xl font-semibold font-mono tabular-nums text-foreground">
${totalCostUsd!.toFixed(2)}
</p>
</CardContent>
</Card>
)}
{showCredits && (
<Card>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">Credits</p>
<p className="text-3xl font-semibold font-mono tabular-nums text-amber-600 dark:text-amber-400">
{creditsSpent!.toFixed(2)}
</p>
</CardContent>
</Card>
)}
</div>
{total > 0 && (
<div className="flex h-2 rounded-full overflow-hidden bg-muted">
{CELLS.filter((s) => s.key !== "total" && (summary[s.key] ?? 0) > 0).map(
({ key, barColor }) => (
<div
key={key}
className={cn("h-full", barColor)}
style={{ width: `${((summary[key] ?? 0) / total) * 100}%` }}
/>
),
)}
</div>
)}
</div>
);
}
@@ -1,17 +1,22 @@
import { Badge } from "@/components/ui/badge";
import type { FindingStatus } from "@/lib/types";
import { cn } from "@/lib/utils";
/** Footnote-weight status. Light fill only (~7%), never a saturated badge. */
const TONE: Record<FindingStatus, string> = {
ERROR: "bg-rose-500/10 text-rose-600 border-rose-500/30 dark:bg-rose-500/15 dark:text-rose-400",
WARNING: "bg-amber-500/10 text-amber-600 border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-400",
INFO: "bg-blue-500/10 text-blue-600 border-blue-500/30 dark:bg-blue-500/15 dark:text-blue-400",
ERROR: "bg-[rgba(255,59,48,0.07)] text-[rgb(160,30,28)]",
WARNING: "bg-[rgba(255,214,10,0.08)] text-[rgb(140,100,20)]",
INFO: "bg-[rgba(142,142,147,0.08)] text-[rgb(70,70,78)]",
};
export function StatusBadge({ status }: { status: FindingStatus }) {
return (
<Badge variant="outline" className={cn("text-xs font-medium", TONE[status])}>
<span
className={cn(
"inline-flex items-center rounded-[6px] px-1.5 py-px text-[11px] font-medium tracking-tight",
TONE[status],
)}
>
{status}
</Badge>
</span>
);
}
@@ -1,8 +1,8 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { groupByWorstStatus, worstStatus } from "./findings-forest.ts";
import { isPcbExamFinding } from "./layout-finding.ts";
import type { Finding } from "./types.ts";
import { groupByWorstStatus, groupDomains, isProtocolFinding, worstStatus } from "./findings-forest";
import { isPcbExamFinding } from "./layout-finding";
import type { Finding } from "./types";
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status">): Finding {
return {
@@ -50,6 +50,28 @@ test("Error folder lists J2 connectors, not only U*", () => {
assert.equal(groups[1].label, "Warning");
});
test("Error folder lists domains and rules after status, protocol stays out of those domains", () => {
const groups = groupByWorstStatus([
f({ designator: "U18", status: "ERROR", rule_id: "PE-PLC-002" }),
f({ designator: "U18", status: "WARNING", rule_id: "PE-PLC-001" }),
f({
designator: "J1",
status: "ERROR",
rule_id: "PE-PRT-L0-001",
source: "protocol_l0",
}),
]);
assert.equal(groups[0].label, "Error");
assert.deepEqual(
groups[0].domains.map((d) => d.id),
["PLC"],
);
assert.equal(groups[0].domains[0].rules.map((r) => r.ruleId).sort().join(","), "PE-PLC-001,PE-PLC-002");
assert.ok(groups[0].findings.some((x) => x.rule_id === "PE-PRT-L0-001"));
assert.ok(isProtocolFinding({ rule_id: "PE-PRT-L0-001", source: "protocol_l0" }));
assert.equal(groupDomains(groups[0].findings.filter((x) => !isProtocolFinding(x))).length, 1);
});
test("PE-PLC stays in the PCB exam bucket with PE-BOM", () => {
assert.equal(
isPcbExamFinding({ source: "placement_check", rule_id: "PE-PLC-002" }),
@@ -1,8 +1,11 @@
/** Group report findings for the tree: status folders, then every ref (J2 included). */
/** Group report findings for the tree: status folders, then domains/rules, then Certificazioni. */
import type { Finding, FindingStatus } from "./types";
const RANK: Record<FindingStatus, number> = { ERROR: 0, WARNING: 1, INFO: 2 };
import type { Finding, FindingStatus, ProtocolCertificationSection } from "./types";
import {
instanceWorstResult,
protocolResultToFindingStatus,
type ProtocolReportCheck,
} from "./protocol-report";
export const STATUS_FOLDER: Record<FindingStatus, string> = {
ERROR: "Error",
@@ -10,6 +13,46 @@ export const STATUS_FOLDER: Record<FindingStatus, string> = {
INFO: "Info",
};
export const STATUS_TINT: Record<FindingStatus, string> = {
ERROR: "bg-[rgba(255,59,48,0.07)]",
WARNING: "bg-[rgba(255,214,10,0.08)]",
INFO: "bg-[rgba(142,142,147,0.08)]",
};
const DOMAIN_LABEL: Record<string, string> = {
PLC: "Placement",
LAY: "Layout",
SI: "Signal integrity",
BOM: "BOM",
PWR: "Power",
THM: "Thermal",
VIA: "Vias",
KEL: "Kelvin",
STCH: "Stitching",
DDR: "DDR",
CPU: "CPU",
FPGA: "FPGA",
DRT: "Derating",
HIER: "Hierarchy",
TIM: "Timing",
PI: "Power integrity",
ESD: "ESD",
RET: "Return path",
SPOF: "SPOF",
EMI: "EMI",
STK: "Stackup",
ANT: "Antenna",
PDN: "PDN",
PD: "USB-PD",
AF: "AF Board + AI",
PRT: "Protocolli",
MUX: "Pin mux",
NC: "NC pins",
DEC: "Decoupling",
SCH: "Schematic",
PCB: "PCB exam",
};
export function worstStatus(findings: { status: FindingStatus }[]): FindingStatus {
if (findings.some((f) => f.status === "ERROR")) return "ERROR";
if (findings.some((f) => f.status === "WARNING")) return "WARNING";
@@ -21,11 +64,38 @@ export type DesignatorGroup = {
findings: Finding[];
};
export type RuleGroup = {
ruleId: string;
findings: Finding[];
designators: DesignatorGroup[];
};
export type DomainGroup = {
id: string;
label: string;
findings: Finding[];
rules: RuleGroup[];
};
export type StatusGroup = {
status: FindingStatus;
label: string;
findings: Finding[];
designators: DesignatorGroup[];
domains: DomainGroup[];
};
export type ProtocolTreeInstance = {
instanceId: string;
logicalId: string;
physicalId: string;
recognition: string;
worst: string;
status: FindingStatus;
checks: ProtocolReportCheck[];
notes: string;
chainExample: string;
raw: Record<string, unknown>;
};
/** Sidebar rows: ICs, connectors (J*), and any other ref. Never U-only. */
@@ -42,9 +112,68 @@ export function groupByDesignator(findings: Finding[]): DesignatorGroup[] {
.map(([designator, items]) => ({ designator, findings: items }));
}
export function isProtocolFinding(f: {
source?: string | null;
rule_id?: string | null;
}): boolean {
const src = f.source || "";
const rid = f.rule_id || "";
return src.startsWith("protocol_") || rid.startsWith("PE-PRT");
}
export function ruleDomain(f: Finding): { id: string; label: string } {
if (isProtocolFinding(f)) return { id: "PRT", label: "Protocolli" };
const rid = f.rule_id || "";
const m = rid.match(/^PE-([A-Z]+)/);
if (m) {
const id = m[1];
return { id, label: DOMAIN_LABEL[id] || id };
}
if ((f.finding_id || "").startsWith("PCB-") || (f.source || "").includes("pcb")) {
return { id: "PCB", label: DOMAIN_LABEL.PCB };
}
return { id: "SCH", label: DOMAIN_LABEL.SCH };
}
export function groupRules(findings: Finding[]): RuleGroup[] {
const byRule = new Map<string, Finding[]>();
for (const f of findings) {
const id = (f.rule_id || "").trim() || "(no rule)";
const list = byRule.get(id) ?? [];
list.push(f);
byRule.set(id, list);
}
return [...byRule.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([ruleId, items]) => ({
ruleId,
findings: items,
designators: groupByDesignator(items),
}));
}
export function groupDomains(findings: Finding[]): DomainGroup[] {
const byId = new Map<string, Finding[]>();
for (const f of findings) {
const { id } = ruleDomain(f);
const list = byId.get(id) ?? [];
list.push(f);
byId.set(id, list);
}
return [...byId.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([id, items]) => ({
id,
label: DOMAIN_LABEL[id] || id,
findings: items,
rules: groupRules(items),
}));
}
/**
* Error / Warning / Info folders. A ref lives in the folder of its worst child.
* U18 with PE-PLC-002 ERROR + five WARNING → Error, badge ERROR, count 6.
* Protocol findings (PE-PRT) are excluded from domain folders — they sit under Certificazioni.
*/
export function groupByWorstStatus(findings: Finding[]): StatusGroup[] {
const buckets: Record<FindingStatus, DesignatorGroup[]> = {
@@ -60,11 +189,80 @@ export function groupByWorstStatus(findings: Finding[]): StatusGroup[] {
.filter((status) => buckets[status].length > 0)
.map((status) => {
const designators = buckets[status];
const folderFindings = designators.flatMap((d) => d.findings);
const nonProtocol = folderFindings.filter((f) => !isProtocolFinding(f));
return {
status,
label: STATUS_FOLDER[status],
findings: designators.flatMap((d) => d.findings),
findings: folderFindings,
designators,
domains: groupDomains(nonProtocol),
};
});
}
function asChecks(row: Record<string, unknown>): ProtocolReportCheck[] {
const raw = row.report_checks;
if (Array.isArray(raw) && raw.length > 0) {
return raw as ProtocolReportCheck[];
}
const fallback: ProtocolReportCheck[] = [];
for (const key of ["l0_checks", "l1_checks", "l2_checks", "l3_checks"] as const) {
const list = row[key];
if (!Array.isArray(list)) continue;
for (const c of list) {
const rec = c as Record<string, unknown>;
fallback.push({
check: String(rec.check ?? ""),
level: String(rec.level ?? ""),
result: String(rec.result ?? ""),
notes: String(rec.notes ?? ""),
skip_visible:
String(rec.result ?? "") !== "PASS" &&
String(rec.result ?? "") !== "FAIL",
});
}
}
return fallback;
}
export function protocolInstancesFromSection(
section?: ProtocolCertificationSection | null,
): ProtocolTreeInstance[] {
const instances = section?.recognized_instances ?? [];
return instances.map((row, i) => {
const rec = row as Record<string, unknown>;
const worst = String(rec.worst_result ?? "");
const checks = asChecks(rec);
const results = checks.map((c) => c.result).filter(Boolean);
const worstResolved = worst || instanceWorstResult(results);
return {
instanceId: String(rec.instance_id ?? i),
logicalId: String(rec.logical_protocol_id ?? ""),
physicalId: String(rec.physical_interface_id ?? ""),
recognition: String(rec.recognition_status ?? ""),
worst: worstResolved,
status: protocolResultToFindingStatus(worstResolved),
checks,
notes: String(rec.notes ?? rec.message ?? ""),
chainExample: String(rec.chain_example ?? ""),
raw: rec,
};
});
}
export function protocolInstancesForStatus(
instances: ProtocolTreeInstance[],
status: FindingStatus,
): ProtocolTreeInstance[] {
return instances.filter((row) => row.status === status);
}
export function forestHasCertifications(
instances: ProtocolTreeInstance[],
protocolFindings: Finding[],
status: FindingStatus,
): boolean {
if (protocolInstancesForStatus(instances, status).length > 0) return true;
return protocolFindings.some((f) => f.status === status);
}
@@ -1,14 +1,17 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
formatLimit,
formatMeasured,
formatProtocolChain,
isHdmi14TmdsElectrical,
instanceWorstResult,
protocolResultRank,
protocolResultToFindingStatus,
sortProtocolChecks,
} from "./protocol-report.ts";
import { groupByWorstStatus } from "./findings-forest.ts";
import type { Finding } from "./types.ts";
} from "./protocol-report";
import { groupByWorstStatus } from "./findings-forest";
import type { Finding } from "./types";
function f(partial: Partial<Finding> & Pick<Finding, "designator" | "status">): Finding {
return {
@@ -75,3 +78,37 @@ test("protocol FAIL finding stays in Error folder with WARNING skips on same ref
assert.equal(groups[0].status, "ERROR");
assert.ok(groups[0].findings.some((x) => x.rule_id === "PE-PRT-L0-001"));
});
test("HDMI 1.4 TMDS Zdiff displays UNKNOWN, never a guessed ohm number", () => {
const row = {
check: "differential_impedance",
level: "L2",
result: "UNKNOWN",
measured: 100,
limit: 100,
margin: 0,
unit: "ohm",
chain: {
logical_protocol_id: "hdmi-1.4",
physical_interface_id: "hdmi-1.4-tmds-type-a",
},
};
assert.equal(isHdmi14TmdsElectrical(row), true);
assert.equal(formatLimit(row), "UNKNOWN");
assert.equal(formatMeasured(row), "UNKNOWN");
assert.ok(!formatLimit(row).includes("100"));
assert.ok(!formatMeasured(row).includes("Ω"));
});
test("HDMI 1.4 CEC is not treated as TMDS Zdiff", () => {
const row = {
check: "cec",
level: "L2",
result: "PASS",
limit: 27000,
unit: "ohm",
chain: { logical_protocol_id: "hdmi-1.4" },
};
assert.equal(isHdmi14TmdsElectrical(row), false);
assert.equal(formatLimit(row), "27000 ohm");
});
@@ -94,19 +94,80 @@ export function formatProtocolChain(chain?: ProtocolChain | null): string {
return keys.map((k) => chain[k] || "—").join(" → ");
}
export function formatMeasured(row: ProtocolReportCheck): string {
const HDMI14_TMDS_ELECTRICAL = new Set([
"differential_impedance",
"impedance_tolerance",
"intra_pair_skew",
"inter_lane_skew",
"clock_data_relationship",
"data_rate",
"transition_rate",
"channel_loss",
"insertion_loss",
"return_loss",
"crosstalk",
]);
export function isHdmi14Protocol(id?: string | null): boolean {
const s = (id || "").toLowerCase();
return s === "hdmi-1.4" || s.startsWith("hdmi-1.4-");
}
/** HDMI 1.4 TMDS electrical is UNKNOWN until the Adopter spec is on file. CEC from HDMI 1.3 stays. */
export function isHdmi14TmdsElectrical(
row: Pick<ProtocolReportCheck, "check" | "chain">,
logicalId?: string | null,
physicalId?: string | null,
): boolean {
const logical = logicalId || row.chain?.logical_protocol_id || "";
const physical = physicalId || row.chain?.physical_interface_id || "";
if (!isHdmi14Protocol(logical) && !isHdmi14Protocol(physical)) return false;
const check = (row.check || "").toLowerCase();
if (check === "cec" || check.startsWith("cec_")) return false;
if (HDMI14_TMDS_ELECTRICAL.has(check)) return true;
return check.includes("impedance") || check.includes("zdiff");
}
export function formatUnknownNotGuessed(
row: ProtocolReportCheck,
logicalId?: string | null,
physicalId?: string | null,
): boolean {
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return true;
return row.result === "UNKNOWN";
}
export function formatMeasured(
row: ProtocolReportCheck,
logicalId?: string | null,
physicalId?: string | null,
): string {
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return "UNKNOWN";
if (row.result === "UNKNOWN") return "UNKNOWN";
if (row.measured == null) return "—";
const unit = row.unit ? ` ${row.unit}` : "";
return `${row.measured}${unit}`;
}
export function formatLimit(row: ProtocolReportCheck): string {
export function formatLimit(
row: ProtocolReportCheck,
logicalId?: string | null,
physicalId?: string | null,
): string {
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return "UNKNOWN";
if (row.result === "UNKNOWN") return "UNKNOWN";
if (row.limit == null) return "—";
const unit = row.unit ? ` ${row.unit}` : "";
return `${row.limit}${unit}`;
}
export function formatMargin(row: ProtocolReportCheck): string {
export function formatMargin(
row: ProtocolReportCheck,
logicalId?: string | null,
physicalId?: string | null,
): string {
if (isHdmi14TmdsElectrical(row, logicalId, physicalId)) return "UNKNOWN";
if (row.result === "UNKNOWN") return "UNKNOWN";
if (row.margin == null) return "—";
const unit = row.unit ? ` ${row.unit}` : "";
return `${row.margin}${unit}`;
+1 -1
View File
@@ -1,3 +1,3 @@
/** Stamped from content/changelog.md by scripts/sync-version.mjs. */
export const APP_VERSION = "2.66.0";
export const APP_VERSION = "2.83.0";
export const APP_VERSION_DATE = "2026-09-22";