Frontend fixes: investigation 401, navigation redesign, route compat, light mode, i18n

- Fix listInvestigations trailing slash (307 redirect -> 401)
- Add ExposureFactor, ExposureResponse, TimelineEvent, TimelineResponse, HealthResponse interfaces
- Add getEntityExposure, getEntityTimeline, getHealthStatus API functions
- Simplify nav to 3 items (Dashboard, Search, Investigations)
- Add /app/analysis/:entityId route with lazy-loaded EntityAnalysis placeholder
- Add /app/graph/:entityId -> /app/analysis/:entityId redirect
- Update all /app/graph/ references to /app/analysis/ (SearchResults, Dashboard, Patterns)
- Add light mode CSS variables with [data-theme="light"] selector
- Add theme toggle (Sun/Moon) in sidebar, persisted to localStorage
- StatusBar polls /api/v1/meta/health every 30s for connectivity status
- Fix keyboard shortcuts: allow Cmd+K in input/textarea fields
- Add title attrs to collapsed ControlsSidebar icons
- Fix ControlsSidebar label truncation (overflow: visible)
- Add favicon.svg
- Add error toast on Dashboard search failure
- Add aria-label to logout button
- Add 60+ i18n keys (analysis.*, nav.theme*) in PT-BR and EN

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
bruno cesar
2026-02-23 04:22:57 -03:00
parent c8f4b6762e
commit fa7fd23deb
16 changed files with 1345 additions and 158 deletions

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>ICARUS</title>
<style>
body {

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="4" fill="#060a07"/>
<text x="16" y="22" font-family="sans-serif" font-size="18" font-weight="700" fill="#ff9a3c" text-anchor="middle">I</text>
</svg>

After

Width:  |  Height:  |  Size: 248 B

View File

@@ -1,23 +1,39 @@
import { useEffect } from "react";
import { Navigate, Route, Routes } from "react-router";
import { lazy, Suspense, useEffect } from "react";
import { Navigate, Route, Routes, useParams } from "react-router";
import { AppShell } from "./components/common/AppShell";
import { PublicShell } from "./components/common/PublicShell";
import { Spinner } from "./components/common/Spinner";
import { Baseline } from "./pages/Baseline";
import { GraphExplorer } from "./pages/GraphExplorer";
import { Home } from "./pages/Home";
import { Dashboard } from "./pages/Dashboard";
import { Investigations } from "./pages/Investigations";
import { Landing } from "./pages/Landing";
import { Login } from "./pages/Login";
import { Patterns } from "./pages/Patterns";
import { Register } from "./pages/Register";
import { Search } from "./pages/Search";
import { SharedInvestigation } from "./pages/SharedInvestigation";
import { useAuthStore } from "./stores/auth";
const EntityAnalysis = lazy(() => import("./pages/EntityAnalysis").then((m) => ({ default: m.EntityAnalysis })));
function RequireAuth({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
if (!token) return <Navigate to="/login" replace />;
return <>{children}</>;
}
function RedirectIfAuth({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
if (token) return <Navigate to="/app" replace />;
return <>{children}</>;
}
function GraphRedirect() {
const { entityId } = useParams();
return <Navigate to={`/app/analysis/${entityId}`} replace />;
}
export function App() {
const restore = useAuthStore((s) => s.restore);
@@ -26,43 +42,45 @@ export function App() {
}, [restore]);
return (
<AppShell>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/search" element={<Search />} />
<Route path="/graph/:entityId" element={<GraphExplorer />} />
<Route path="/patterns" element={<Patterns />} />
<Route path="/patterns/:entityId" element={<Patterns />} />
<Route
path="/baseline/:entityId"
element={
<RequireAuth>
<Baseline />
</RequireAuth>
}
/>
<Route
path="/investigations"
element={
<RequireAuth>
<Investigations />
</RequireAuth>
}
/>
<Route
path="/investigations/shared/:token"
element={<SharedInvestigation />}
/>
<Route
path="/investigations/:investigationId"
element={
<RequireAuth>
<Investigations />
</RequireAuth>
}
/>
</Routes>
</AppShell>
<Routes>
{/* Public shell — landing, login, register */}
<Route
element={
<RedirectIfAuth>
<PublicShell />
</RedirectIfAuth>
}
>
<Route index element={<Landing />} />
<Route path="login" element={<Login />} />
<Route path="register" element={<Register />} />
</Route>
{/* Public — shared investigation (no auth, no shell) */}
<Route path="shared/:token" element={<SharedInvestigation />} />
{/* Authenticated shell — the intelligence workspace */}
<Route
path="app"
element={
<RequireAuth>
<AppShell />
</RequireAuth>
}
>
<Route index element={<Dashboard />} />
<Route path="search" element={<Search />} />
<Route path="analysis/:entityId" element={<Suspense fallback={<Spinner />}><EntityAnalysis /></Suspense>} />
<Route path="graph/:entityId" element={<GraphRedirect />} />
<Route path="patterns" element={<Patterns />} />
<Route path="patterns/:entityId" element={<Patterns />} />
<Route path="baseline/:entityId" element={<Baseline />} />
<Route path="investigations" element={<Investigations />} />
<Route path="investigations/:investigationId" element={<Investigations />} />
</Route>
{/* Catch-all */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View File

@@ -163,12 +163,13 @@ export function getGraphData(
entityId: string,
depth = 1,
types?: string[],
signal?: AbortSignal,
): Promise<GraphData> {
const params = new URLSearchParams({ depth: String(depth) });
if (types?.length) {
params.set("entity_types", types.join(","));
}
return apiFetch<GraphData>(`/api/v1/graph/${encodeURIComponent(entityId)}?${params}`);
return apiFetch<GraphData>(`/api/v1/graph/${encodeURIComponent(entityId)}?${params}`, { signal });
}
// --- Baseline ---
@@ -236,7 +237,7 @@ export function listInvestigations(
size = 20,
): Promise<InvestigationListResponse> {
const params = new URLSearchParams({ page: String(page), size: String(size) });
return apiFetch<InvestigationListResponse>(`/api/v1/investigations?${params}`);
return apiFetch<InvestigationListResponse>(`/api/v1/investigations/?${params}`);
}
export function getInvestigation(id: string): Promise<Investigation> {
@@ -364,6 +365,78 @@ export function exportInvestigation(investigationId: string): Promise<Blob> {
});
}
// --- Stats ---
export interface StatsResponse {
total_nodes: number;
total_relationships: number;
person_count: number;
company_count: number;
data_sources: number;
indexes: number;
}
export function getStats(): Promise<StatsResponse> {
return apiFetch<StatsResponse>("/api/v1/meta/stats");
}
// --- Exposure & Timeline ---
export interface ExposureFactor {
name: string;
value: number;
percentile: number;
weight: number;
sources: string[];
}
export interface ExposureResponse {
entity_id: string;
exposure_index: number;
factors: ExposureFactor[];
peer_group: string;
peer_count: number;
sources: SourceAttribution[];
}
export interface TimelineEvent {
id: string;
date: string;
label: string;
entity_type: string;
properties: Record<string, unknown>;
sources: SourceAttribution[];
}
export interface TimelineResponse {
entity_id: string;
events: TimelineEvent[];
total: number;
next_cursor: string | null;
}
export interface HealthResponse {
status: string;
}
export function getEntityExposure(entityId: string): Promise<ExposureResponse> {
return apiFetch<ExposureResponse>(`/api/v1/entity/${encodeURIComponent(entityId)}/exposure`);
}
export function getEntityTimeline(
entityId: string,
cursor?: string,
limit = 50,
): Promise<TimelineResponse> {
const params = new URLSearchParams({ limit: String(limit) });
if (cursor) params.set("cursor", cursor);
return apiFetch<TimelineResponse>(`/api/v1/entity/${encodeURIComponent(entityId)}/timeline?${params}`);
}
export function getHealthStatus(): Promise<HealthResponse> {
return apiFetch<HealthResponse>("/api/v1/meta/health");
}
export function exportInvestigationPDF(
investigationId: string,
lang = "pt",

View File

@@ -1,99 +1,192 @@
import { type ReactNode, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useLocation, useNavigate } from "react-router";
import { Link, Outlet, useLocation, useNavigate } from "react-router";
import {
BarChart3,
ChevronLeft,
ChevronRight,
FolderOpen,
LogOut,
Moon,
Search,
Sun,
} from "lucide-react";
import { registerActions, type Action } from "@/actions/registry";
import { CommandPalette } from "@/components/common/CommandPalette";
import { Kbd } from "@/components/common/Kbd";
import { KeyboardShortcutsHelp } from "@/components/common/KeyboardShortcutsHelp";
import { StatusBar } from "@/components/common/StatusBar";
import { ToastContainer } from "@/components/common/ToastContainer";
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
import { useAuthStore } from "@/stores/auth";
import styles from "./AppShell.module.css";
export function AppShell({ children }: { children: ReactNode }) {
const NAV_ITEMS = [
{ path: "/app", icon: BarChart3, labelKey: "nav.dashboard" },
{ path: "/app/search", icon: Search, labelKey: "nav.search" },
{ path: "/app/investigations", icon: FolderOpen, labelKey: "nav.investigations" },
] as const;
export function AppShell() {
const { t, i18n } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const [menuOpen, setMenuOpen] = useState(false);
const token = useAuthStore((s) => s.token);
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const user = useAuthStore((s) => s.user);
const toggleLang = () => {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [commandOpen, setCommandOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const [isMobileBlocked, setIsMobileBlocked] = useState(false);
const [theme, setTheme] = useState<"dark" | "light">(() => {
try {
const saved = localStorage.getItem("icarus_theme");
if (saved === "light" || saved === "dark") return saved;
} catch { /* noop */ }
if (typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: light)").matches) return "light";
return "dark";
});
// Desktop-only: check viewport on mount and resize
useEffect(() => {
function check() {
setIsMobileBlocked(window.innerWidth < 1024);
}
check();
window.addEventListener("resize", check);
return () => window.removeEventListener("resize", check);
}, []);
const isGraphRoute = location.pathname.startsWith("/app/graph") || location.pathname.startsWith("/app/analysis");
// Auto-collapse sidebar on graph/analysis routes
useEffect(() => {
if (isGraphRoute) setSidebarCollapsed(true);
}, [isGraphRoute]);
// Apply theme to document
useEffect(() => {
if (theme === "light") {
document.documentElement.dataset.theme = "light";
} else {
delete document.documentElement.dataset.theme;
}
try { localStorage.setItem("icarus_theme", theme); } catch { /* noop */ }
}, [theme]);
const toggleTheme = useCallback(() => {
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
}, []);
const toggleLang = useCallback(() => {
const next = i18n.language === "pt-BR" ? "en" : "pt-BR";
i18n.changeLanguage(next);
};
}, [i18n]);
const navLinkClass = (path: string) =>
location.pathname.startsWith(path) ? styles.active : "";
const handleLogout = () => {
const handleLogout = useCallback(() => {
logout();
navigate("/");
}, [logout, navigate]);
// Register actions for command palette + keyboard shortcuts
const actions: Action[] = useMemo(
() => [
{ id: "go-dashboard", label: t("command.goToDashboard"), shortcut: "cmd+1", group: t("command.navigation"), handler: () => navigate("/app") },
{ id: "go-search", label: t("command.goToSearch"), shortcut: "cmd+2", group: t("command.navigation"), handler: () => navigate("/app/search") },
{ id: "go-patterns", label: t("command.goToPatterns"), shortcut: "cmd+3", group: t("command.navigation"), handler: () => navigate("/app/patterns") },
{ id: "go-investigations", label: t("command.goToInvestigations"), shortcut: "cmd+4", group: t("command.navigation"), handler: () => navigate("/app/investigations") },
{ id: "toggle-sidebar", label: t("command.toggleSidebar"), shortcut: "cmd+b", group: t("command.actions"), handler: () => setSidebarCollapsed((p) => !p) },
{ id: "command-palette", label: t("shortcuts.commandPalette"), shortcut: "cmd+k", group: t("command.actions"), handler: () => setCommandOpen(true) },
{ id: "show-shortcuts", label: t("command.showShortcuts"), shortcut: "shift+?", group: t("command.actions"), handler: () => setShortcutsOpen(true) },
],
[t, navigate],
);
useEffect(() => {
registerActions(actions);
}, [actions]);
useKeyboardShortcuts();
const isActive = (path: string) => {
if (path === "/app") return location.pathname === "/app";
return location.pathname.startsWith(path);
};
if (isMobileBlocked) {
return (
<div className={styles.mobileBlock}>
<h1 className={styles.mobileTitle}>{t("mobile.title")}</h1>
<p className={styles.mobileMessage}>{t("mobile.message")}</p>
<p className={styles.mobileHint}>{t("mobile.hint")}</p>
</div>
);
}
return (
<div className={styles.shell}>
<header className={styles.header}>
<Link to="/" className={styles.logo}>
{t("app.title")}
</Link>
<button
className={styles.hamburger}
onClick={() => setMenuOpen((prev) => !prev)}
aria-label="Menu"
>
&#9776;
</button>
<nav className={`${styles.nav} ${menuOpen ? styles.navOpen : ""}`}>
<Link
to="/search"
className={navLinkClass("/search")}
onClick={() => setMenuOpen(false)}
>
{t("nav.search")}
<nav className={`${styles.sidebar} ${sidebarCollapsed ? styles.collapsed : ""}`}>
<div className={styles.sidebarHeader}>
<Link to="/app" className={styles.logo}>
{sidebarCollapsed ? "I" : "ICARUS"}
</Link>
<Link
to="/patterns"
className={navLinkClass("/patterns")}
onClick={() => setMenuOpen(false)}
>
{t("nav.patterns")}
</Link>
<Link
to="/baseline"
className={navLinkClass("/baseline")}
onClick={() => setMenuOpen(false)}
>
{t("nav.baseline")}
</Link>
<Link
to="/investigations"
className={navLinkClass("/investigations")}
onClick={() => setMenuOpen(false)}
>
{t("nav.investigations")}
</Link>
</nav>
<div className={styles.headerActions}>
{token ? (
<div className={styles.userArea}>
{user && <span className={styles.userEmail}>{user.email}</span>}
<button onClick={handleLogout} className={styles.authBtn}>
{t("nav.logout")}
</button>
</div>
) : (
<Link to="/login" className={styles.authBtn}>
{t("nav.login")}
</div>
<div className={styles.navItems}>
{NAV_ITEMS.map(({ path, icon: Icon, labelKey }) => (
<Link
key={path}
to={path}
className={`${styles.navItem} ${isActive(path) ? styles.navItemActive : ""}`}
title={sidebarCollapsed ? t(labelKey) : undefined}
>
<Icon size={18} />
{!sidebarCollapsed && <span>{t(labelKey)}</span>}
</Link>
))}
</div>
<div className={styles.sidebarFooter}>
{!sidebarCollapsed && (
<button className={styles.cmdHint} onClick={() => setCommandOpen(true)}>
<Kbd>&#8984;K</Kbd>
</button>
)}
<button onClick={toggleLang} className={styles.langToggle}>
<button className={styles.langToggle} onClick={toggleTheme} title={theme === "dark" ? t("nav.themeLight") : t("nav.themeDark")}>
{theme === "dark" ? <Sun size={14} /> : <Moon size={14} />}
</button>
<button className={styles.langToggle} onClick={toggleLang} title={i18n.language === "pt-BR" ? "English" : "Portugues"}>
{i18n.language === "pt-BR" ? "EN" : "PT"}
</button>
{user && !sidebarCollapsed && (
<span className={styles.userEmail}>{user.email}</span>
)}
<button className={styles.logoutBtn} onClick={handleLogout} title={t("nav.logout")} aria-label={t("nav.logout")}>
<LogOut size={16} />
{!sidebarCollapsed && <span>{t("nav.logout")}</span>}
</button>
<button
className={styles.collapseBtn}
onClick={() => setSidebarCollapsed((p) => !p)}
aria-label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{sidebarCollapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
</button>
</div>
</header>
<main className={styles.main}>{children}</main>
<footer className={styles.footer}>
<span className={styles.disclaimer}>{t("app.disclaimer")}</span>
</footer>
</nav>
<div className={styles.content}>
<main className={styles.main}><Outlet /></main>
<StatusBar />
</div>
<CommandPalette open={commandOpen} onOpenChange={setCommandOpen} />
<KeyboardShortcutsHelp open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
<ToastContainer />
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getHealthStatus } from "@/api/client";
import styles from "./StatusBar.module.css";
interface StatusBarProps {
nodeCount?: number;
edgeCount?: number;
}
export function StatusBar({ nodeCount, edgeCount }: StatusBarProps) {
const { t } = useTranslation();
const [isConnected, setIsConnected] = useState(true);
useEffect(() => {
let cancelled = false;
function poll() {
getHealthStatus()
.then(() => { if (!cancelled) setIsConnected(true); })
.catch(() => { if (!cancelled) setIsConnected(false); });
}
poll();
const interval = setInterval(poll, 30_000);
return () => { cancelled = true; clearInterval(interval); };
}, []);
return (
<div className={styles.statusBar}>
<div className={styles.left}>
<span
className={`${styles.dot} ${isConnected ? styles.connected : styles.disconnected}`}
/>
<span>
{isConnected
? t("statusBar.connected")
: t("statusBar.disconnected")}
</span>
</div>
<div className={styles.center}>
{nodeCount != null && (
<span className={styles.stat}>
{nodeCount.toLocaleString()} {t("statusBar.nodes")}
</span>
)}
{edgeCount != null && (
<span className={styles.stat}>
{edgeCount.toLocaleString()} {t("statusBar.edges")}
</span>
)}
</div>
<div className={styles.right}>
<span>
{t("statusBar.lastRefresh")}{" "}
{new Date().toLocaleTimeString()}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,132 @@
.sidebar {
position: relative;
display: flex;
flex-direction: column;
width: 220px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
overflow-y: auto;
transition: width 0.2s;
}
.sidebar.collapsed {
width: 48px;
}
.toggle {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 32px;
padding: 0;
background: transparent;
border: none;
border-bottom: 1px solid var(--border);
color: var(--text-secondary);
cursor: pointer;
transition: color 0.15s;
}
.toggle:hover {
color: var(--text-primary);
}
.icons {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-md);
padding: var(--space-md) 0;
}
.sidebarIcon {
color: var(--text-muted);
}
.content {
display: flex;
flex-direction: column;
gap: var(--space-md);
padding: var(--space-sm);
}
.section {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.sectionLabel {
font-size: var(--font-size-2xs);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.slider {
width: 100%;
accent-color: var(--accent);
}
.toggleList {
display: flex;
flex-direction: column;
gap: 1px;
}
.toggleItem {
display: flex;
align-items: center;
gap: var(--space-xs);
width: 100%;
padding: 3px var(--space-xs);
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
font-family: var(--font-sans);
font-size: var(--font-size-xs);
cursor: pointer;
text-align: left;
transition: all 0.1s;
}
.toggleItem:hover {
background: rgba(255, 255, 255, 0.02);
color: var(--text-secondary);
}
.toggleItem.enabled {
color: var(--text-primary);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.toggleLabel {
flex: 1;
min-width: 0;
overflow: visible;
white-space: nowrap;
}
.count {
font-family: var(--font-mono);
font-size: var(--font-size-2xs);
color: var(--text-muted);
min-width: 20px;
text-align: right;
}
.summary {
padding-top: var(--space-xs);
border-top: 1px solid var(--border);
font-size: var(--font-size-2xs);
color: var(--text-muted);
text-align: center;
}

View File

@@ -0,0 +1,158 @@
import { memo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { ChevronLeft, ChevronRight, Layers, GitFork, SlidersHorizontal } from "lucide-react";
import { dataColors, type DataEntityType, relationshipColors } from "@/styles/tokens";
import styles from "./ControlsSidebar.module.css";
interface ControlsSidebarProps {
collapsed: boolean;
onToggle: () => void;
depth: number;
onDepthChange: (d: number) => void;
enabledTypes: Set<string>;
onToggleType: (t: string) => void;
enabledRelTypes: Set<string>;
onToggleRelType: (t: string) => void;
typeCounts: Record<string, number>;
relTypeCounts: Record<string, number>;
}
const ENTITY_TYPES = Object.keys(dataColors) as DataEntityType[];
const REL_TYPES = Object.keys(relationshipColors);
function ControlsSidebarInner({
collapsed,
onToggle,
depth,
onDepthChange,
enabledTypes,
onToggleType,
enabledRelTypes,
onToggleRelType,
typeCounts,
relTypeCounts,
}: ControlsSidebarProps) {
const { t } = useTranslation();
const handleDepthChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onDepthChange(Number(e.target.value));
},
[onDepthChange],
);
const visibleCount = Object.entries(typeCounts).reduce(
(sum, [type, count]) => (enabledTypes.has(type) ? sum + count : sum),
0,
);
const totalCount = Object.values(typeCounts).reduce((a, b) => a + b, 0);
return (
<div className={`${styles.sidebar} ${collapsed ? styles.collapsed : ""}`}>
<button className={styles.toggle} onClick={onToggle}>
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
</button>
{collapsed ? (
<div className={styles.icons}>
<span title={t("graph.depth")}><SlidersHorizontal size={16} className={styles.sidebarIcon} /></span>
<span title={t("graph.entityTypes")}><Layers size={16} className={styles.sidebarIcon} /></span>
<span title={t("graph.relationshipTypes")}><GitFork size={16} className={styles.sidebarIcon} /></span>
</div>
) : (
<div className={styles.content}>
{/* Depth slider */}
<div className={styles.section}>
<label className={styles.sectionLabel}>
{t("graph.depth")}: {depth}
</label>
<input
type="range"
min={1}
max={4}
value={depth}
onChange={handleDepthChange}
className={styles.slider}
/>
</div>
{/* Entity type toggles */}
<div className={styles.section}>
<label className={styles.sectionLabel}>
{t("graph.entityTypes")}
</label>
<div className={styles.toggleList}>
{ENTITY_TYPES.map((type) => {
const count = typeCounts[type] ?? 0;
const enabled = enabledTypes.has(type);
return (
<button
key={type}
className={`${styles.toggleItem} ${enabled ? styles.enabled : ""}`}
onClick={() => onToggleType(type)}
>
<span
className={styles.dot}
style={{
backgroundColor: enabled
? dataColors[type]
: "var(--text-muted)",
}}
/>
<span className={styles.toggleLabel}>
{t(`entity.${type}`, type)}
</span>
<span className={styles.count}>{count}</span>
</button>
);
})}
</div>
</div>
{/* Relationship type toggles */}
<div className={styles.section}>
<label className={styles.sectionLabel}>
{t("graph.relationshipTypes")}
</label>
<div className={styles.toggleList}>
{REL_TYPES.map((type) => {
const count = relTypeCounts[type] ?? 0;
const enabled = enabledRelTypes.has(type);
return (
<button
key={type}
className={`${styles.toggleItem} ${enabled ? styles.enabled : ""}`}
onClick={() => onToggleRelType(type)}
>
<span
className={styles.dot}
style={{
backgroundColor: enabled
? (relationshipColors[type] ?? "var(--text-muted)")
: "var(--text-muted)",
}}
/>
<span className={styles.toggleLabel}>{type}</span>
<span className={styles.count}>{count}</span>
</button>
);
})}
</div>
</div>
{/* Filter summary */}
<div className={styles.summary}>
{t("graph.filterSummary", {
visible: visibleCount,
total: totalCount,
})}
</div>
</div>
)}
</div>
);
}
export const ControlsSidebar = memo(ControlsSidebarInner);

View File

@@ -54,8 +54,8 @@ describe("SearchResults", () => {
it("links to graph page for each result", () => {
renderResults(sampleResults);
const links = screen.getAllByRole("link");
expect(links[0]).toHaveAttribute("href", "/graph/e1");
expect(links[1]).toHaveAttribute("href", "/graph/e2");
expect(links[0]).toHaveAttribute("href", "/app/analysis/e1");
expect(links[1]).toHaveAttribute("href", "/app/analysis/e2");
});
it("shows source badges", () => {

View File

@@ -29,7 +29,7 @@ export function SearchResults({ results }: SearchResultsProps) {
const color = entityColors[result.type as EntityType] ?? "var(--text-secondary)";
return (
<li key={result.id} className={styles.item}>
<Link to={`/graph/${result.id}`} className={styles.link}>
<Link to={`/app/analysis/${result.id}`} className={styles.link}>
<span className={styles.typeBadge} style={{ borderColor: color, color }}>
{t(`entity.${result.type}`, result.type)}
</span>

View File

@@ -0,0 +1,48 @@
import { useEffect } from "react";
import { getActions } from "@/actions/registry";
function matchShortcut(e: KeyboardEvent, shortcut: string): boolean {
const parts = shortcut.toLowerCase().split("+");
const needsMeta = parts.includes("cmd") || parts.includes("meta");
const needsCtrl = parts.includes("ctrl");
const needsShift = parts.includes("shift");
const needsAlt = parts.includes("alt");
const key = parts.filter(
(p) => !["cmd", "meta", "ctrl", "shift", "alt"].includes(p),
)[0];
if (needsMeta && !e.metaKey) return false;
if (needsCtrl && !e.ctrlKey) return false;
if (needsShift && !e.shiftKey) return false;
if (needsAlt && !e.altKey) return false;
if (!key) return false;
if (e.key.toLowerCase() !== key) return false;
return true;
}
export function useKeyboardShortcuts(): void {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
const tag = (e.target as HTMLElement).tagName;
const isInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
const hasModifier = e.metaKey || e.ctrlKey;
// In input fields, only allow shortcuts with modifier keys (e.g. Cmd+K)
if (isInput && !hasModifier) return;
const actions = getActions();
for (const action of actions) {
if (!action.shortcut) continue;
if (matchShortcut(e, action.shortcut)) {
e.preventDefault();
action.handler();
return;
}
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
}

View File

@@ -6,15 +6,54 @@ const resources = {
translation: {
app: {
title: "ICARUS",
subtitle: "Ferramenta de análise de dados públicos brasileiros",
subtitle: "Plataforma de inteligência em dados públicos brasileiros",
disclaimer:
"Dados de registros públicos. Não constitui acusação.",
},
landing: {
hero: "Plataforma de inteligência em dados públicos brasileiros",
cta: "Acessar plataforma",
lastUpdated: "Dados atualizados em: {{date}}",
stats: {
entities: "entidades",
connections: "conexões",
dataSources: "fontes de dados",
indexes: "índices",
},
features: {
graph: "Análise de Grafo",
graphDesc: "Mapeie conexões entre 56M de entidades em registros públicos",
patterns: "Detecção de Padrões",
patternsDesc: "5 padrões automatizados de análise de dados",
investigations: "Área de Investigação",
investigationsDesc: "Análise colaborativa com anotações e etiquetas",
},
sources: {
title: "Fontes de Dados",
cnpj: "Cadastro de empresas da Receita Federal",
tse: "Doações eleitorais do TSE",
transparencia: "Contratos do Portal da Transparência",
sanctions: "Listas de sanções CEIS/CNEP",
},
footer: {
methodology: "Metodologia",
license: "AGPL-3.0",
},
},
home: {
tagline: "Mapeamento de conexões em dados públicos brasileiros",
cta: "Iniciar busca",
},
dashboard: {
welcome: "Painel",
quickSearch: "Busca rápida",
recentInvestigations: "Investigações recentes",
continueInvestigation: "Continuar",
noRecent: "Nenhuma investigação recente.",
quickStats: "Estatísticas",
},
nav: {
dashboard: "Painel",
search: "Buscar",
graph: "Grafo",
patterns: "Padrões",
@@ -22,12 +61,18 @@ const resources = {
investigations: "Investigações",
login: "Entrar",
logout: "Sair",
register: "Registrar",
analysis: "Análise",
theme: "Tema",
themeLight: "Claro",
themeDark: "Escuro",
},
auth: {
login: "Entrar",
register: "Registrar",
email: "E-mail",
password: "Senha",
confirmPassword: "Confirmar senha",
inviteCode: "Código de convite",
switchToRegister: "Não tem conta? Registre-se",
switchToLogin: "Já tem conta? Entre",
@@ -35,6 +80,9 @@ const resources = {
registerError: "Erro ao registrar. Tente novamente.",
invalidCredentials: "E-mail ou senha incorretos.",
invalidInvite: "Código de convite inválido.",
loginTitle: "Acessar ICARUS",
registerTitle: "Criar conta",
loginSubtitle: "Plataforma de inteligência em dados públicos",
},
search: {
placeholder: "CPF, CNPJ ou nome...",
@@ -44,6 +92,9 @@ const resources = {
typeAll: "Todos",
error: "Erro ao buscar. Tente novamente.",
emptyHint: "Tente buscar por nome, CPF ou CNPJ",
recentSearches: "Buscas recentes",
clearRecent: "Limpar",
resultCount: "{{count}} resultados",
},
entity: {
person: "Pessoa",
@@ -52,6 +103,7 @@ const resources = {
finance: "Financeiro",
sanction: "Sanção",
election: "Eleição",
amendment: "Emenda",
publicOffice: "Cargo Público",
legal: "Jurídico",
health: "Saúde",
@@ -65,7 +117,22 @@ const resources = {
graph: {
depth: "Profundidade",
entityTypes: "Tipos de entidade",
relationshipTypes: "Tipos de conexão",
noData: "Nenhum dado de grafo disponível.",
searchInGraph: "Buscar no grafo...",
layoutForce: "Força",
layoutHierarchy: "Hierarquia",
fullscreen: "Tela cheia",
exportPng: "Exportar PNG",
fitView: "Ajustar",
zoomIn: "Ampliar",
zoomOut: "Reduzir",
resetZoom: "Redefinir zoom",
filterSummary: "{{visible}} de {{total}} nós",
expand: "Expandir nó",
hide: "Ocultar nó",
viewDetail: "Ver detalhes",
addToInvestigation: "Adicionar à investigação",
edge: {
type: "Tipo de conexão",
source: "Origem",
@@ -75,6 +142,10 @@ const resources = {
sources: "Fontes",
noValue: "Sem valor monetário",
},
legend: {
title: "Legenda",
},
minimap: "Minimapa",
},
patterns: {
title: "Padrões de Análise",
@@ -123,12 +194,99 @@ const resources = {
noData: "Sem dados de comparação.",
peers: "{{count}} pares",
},
analysis: {
title: "Análise de Entidade",
graph: "Grafo",
connections: "Conexões",
timeline: "Linha do tempo",
export: "Exportar",
insights: "Análise",
detail: "Detalhes",
addToInvestigation: "Adicionar à investigação",
selectInvestigation: "Selecionar investigação",
createNew: "Criar nova",
addNote: "Adicionar nota",
noPatterns: "Nenhum padrão encontrado.",
noTimeline: "Nenhum evento no período.",
loadMore: "Carregar mais",
exposure: "Índice de exposição",
exposureDesc: "Métrica composta de conectividade e volume financeiro",
factor: {
connections: "Conexões",
sources: "Fontes cruzadas",
financial: "Volume financeiro",
patterns: "Padrões detectados",
baseline: "Desvio da linha base",
},
peerGroup: "Grupo de pares",
peers: "{{count}} pares",
percentile: "Percentil {{value}}",
exportPdf: "Relatório PDF",
exportCsv: "Planilha CSV",
exportJson: "Dados JSON",
exportScreenshot: "Captura de tela",
recentAnalyses: "Análises recentes",
patternScanner: "Busca por padrão",
noExposure: "Dados insuficientes para calcular exposição.",
},
command: {
placeholder: "Digite um comando...",
noResults: "Nenhum comando encontrado.",
navigation: "Navegação",
actions: "Ações",
goToDashboard: "Ir para o Painel",
goToSearch: "Ir para Busca",
goToPatterns: "Ir para Padrões",
goToInvestigations: "Ir para Investigações",
toggleFullscreen: "Alternar tela cheia",
toggleSidebar: "Alternar barra lateral",
showShortcuts: "Mostrar atalhos",
},
shortcuts: {
title: "Atalhos de Teclado",
navigation: "Navegação",
graph: "Grafo",
general: "Geral",
commandPalette: "Paleta de comandos",
search: "Buscar",
close: "Fechar",
},
statusBar: {
connected: "Conectado",
disconnected: "Desconectado",
nodes: "nós",
edges: "arestas",
lastRefresh: "Atualizado",
},
toast: {
copied: "Copiado!",
saved: "Salvo!",
deleted: "Excluído!",
error: "Erro ao executar ação.",
},
mobile: {
title: "ICARUS",
message: "Esta plataforma requer resolução mínima de 1024px para análise de grafos.",
hint: "Acesse em um computador para a experiência completa.",
},
common: {
source: "Fonte",
confidence: "Confiança",
connections: "conexões",
sources: "fontes",
loading: "Carregando...",
cancel: "Cancelar",
confirm: "Confirmar",
close: "Fechar",
save: "Salvar",
delete: "Excluir",
edit: "Editar",
back: "Voltar",
},
error: {
title: "ICARUS",
message: "Algo deu errado. Recarregue a página.",
reload: "Recarregar",
},
},
},
@@ -136,15 +294,54 @@ const resources = {
translation: {
app: {
title: "ICARUS",
subtitle: "Brazilian public data analysis tool",
subtitle: "Brazilian public data intelligence platform",
disclaimer:
"Data patterns from public records. Not accusations.",
},
landing: {
hero: "Brazilian public data intelligence platform",
cta: "Access platform",
lastUpdated: "Data updated: {{date}}",
stats: {
entities: "entities",
connections: "connections",
dataSources: "data sources",
indexes: "indexes",
},
features: {
graph: "Graph Analysis",
graphDesc: "Map connections across 56M entities in public records",
patterns: "Pattern Detection",
patternsDesc: "5 automated data analysis patterns",
investigations: "Investigation Workspace",
investigationsDesc: "Collaborative analysis with annotations and tags",
},
sources: {
title: "Data Sources",
cnpj: "Federal Revenue company registry",
tse: "Electoral Court donation records",
transparencia: "Government transparency portal contracts",
sanctions: "CEIS/CNEP sanctions lists",
},
footer: {
methodology: "Methodology",
license: "AGPL-3.0",
},
},
home: {
tagline: "Mapping connections in Brazilian public data",
cta: "Start searching",
},
dashboard: {
welcome: "Dashboard",
quickSearch: "Quick search",
recentInvestigations: "Recent investigations",
continueInvestigation: "Continue",
noRecent: "No recent investigations.",
quickStats: "Statistics",
},
nav: {
dashboard: "Dashboard",
search: "Search",
graph: "Graph",
patterns: "Patterns",
@@ -152,12 +349,18 @@ const resources = {
investigations: "Investigations",
login: "Login",
logout: "Logout",
register: "Register",
analysis: "Analysis",
theme: "Theme",
themeLight: "Light",
themeDark: "Dark",
},
auth: {
login: "Login",
register: "Register",
email: "Email",
password: "Password",
confirmPassword: "Confirm password",
inviteCode: "Invite code",
switchToRegister: "No account? Register",
switchToLogin: "Have an account? Login",
@@ -165,6 +368,9 @@ const resources = {
registerError: "Registration failed. Please try again.",
invalidCredentials: "Invalid email or password.",
invalidInvite: "Invalid invite code.",
loginTitle: "Access ICARUS",
registerTitle: "Create account",
loginSubtitle: "Public data intelligence platform",
},
search: {
placeholder: "CPF, CNPJ, or name...",
@@ -174,6 +380,9 @@ const resources = {
typeAll: "All",
error: "Search failed. Please try again.",
emptyHint: "Try searching by name, CPF, or CNPJ",
recentSearches: "Recent searches",
clearRecent: "Clear",
resultCount: "{{count}} results",
},
entity: {
person: "Person",
@@ -182,6 +391,7 @@ const resources = {
finance: "Finance",
sanction: "Sanction",
election: "Election",
amendment: "Amendment",
publicOffice: "Public Office",
legal: "Legal",
health: "Health",
@@ -195,7 +405,22 @@ const resources = {
graph: {
depth: "Depth",
entityTypes: "Entity types",
relationshipTypes: "Relationship types",
noData: "No graph data available.",
searchInGraph: "Search in graph...",
layoutForce: "Force",
layoutHierarchy: "Hierarchy",
fullscreen: "Fullscreen",
exportPng: "Export PNG",
fitView: "Fit view",
zoomIn: "Zoom in",
zoomOut: "Zoom out",
resetZoom: "Reset zoom",
filterSummary: "{{visible}} of {{total}} nodes",
expand: "Expand node",
hide: "Hide node",
viewDetail: "View detail",
addToInvestigation: "Add to investigation",
edge: {
type: "Connection Type",
source: "Source",
@@ -205,6 +430,10 @@ const resources = {
sources: "Sources",
noValue: "No monetary value",
},
legend: {
title: "Legend",
},
minimap: "Minimap",
},
patterns: {
title: "Analysis Patterns",
@@ -253,12 +482,99 @@ const resources = {
noData: "No comparison data.",
peers: "{{count}} peers",
},
analysis: {
title: "Entity Analysis",
graph: "Graph",
connections: "Connections",
timeline: "Timeline",
export: "Export",
insights: "Insights",
detail: "Detail",
addToInvestigation: "Add to investigation",
selectInvestigation: "Select investigation",
createNew: "Create new",
addNote: "Add note",
noPatterns: "No patterns found.",
noTimeline: "No events in this period.",
loadMore: "Load more",
exposure: "Exposure index",
exposureDesc: "Composite metric of connectivity and financial volume",
factor: {
connections: "Connections",
sources: "Cross-source",
financial: "Financial volume",
patterns: "Patterns detected",
baseline: "Baseline deviation",
},
peerGroup: "Peer group",
peers: "{{count}} peers",
percentile: "Percentile {{value}}",
exportPdf: "PDF Report",
exportCsv: "CSV Spreadsheet",
exportJson: "JSON Data",
exportScreenshot: "Screenshot",
recentAnalyses: "Recent analyses",
patternScanner: "Pattern search",
noExposure: "Insufficient data to calculate exposure.",
},
command: {
placeholder: "Type a command...",
noResults: "No commands found.",
navigation: "Navigation",
actions: "Actions",
goToDashboard: "Go to Dashboard",
goToSearch: "Go to Search",
goToPatterns: "Go to Patterns",
goToInvestigations: "Go to Investigations",
toggleFullscreen: "Toggle fullscreen",
toggleSidebar: "Toggle sidebar",
showShortcuts: "Show shortcuts",
},
shortcuts: {
title: "Keyboard Shortcuts",
navigation: "Navigation",
graph: "Graph",
general: "General",
commandPalette: "Command palette",
search: "Search",
close: "Close",
},
statusBar: {
connected: "Connected",
disconnected: "Disconnected",
nodes: "nodes",
edges: "edges",
lastRefresh: "Updated",
},
toast: {
copied: "Copied!",
saved: "Saved!",
deleted: "Deleted!",
error: "Action failed.",
},
mobile: {
title: "ICARUS",
message: "This platform requires a minimum resolution of 1024px for graph analysis.",
hint: "Access from a computer for the full experience.",
},
common: {
source: "Source",
confidence: "Confidence",
connections: "connections",
sources: "sources",
loading: "Loading...",
cancel: "Cancel",
confirm: "Confirm",
close: "Close",
save: "Save",
delete: "Delete",
edit: "Edit",
back: "Back",
},
error: {
title: "ICARUS",
message: "Something went wrong. Please reload the page.",
reload: "Reload",
},
},
},

View File

@@ -0,0 +1,111 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router";
import { type Investigation, listInvestigations, searchEntities, type SearchResult } from "@/api/client";
import { Skeleton } from "@/components/common/Skeleton";
import { useToastStore } from "@/stores/toast";
import styles from "./Dashboard.module.css";
export function Dashboard() {
const { t } = useTranslation();
const navigate = useNavigate();
const addToast = useToastStore((s) => s.addToast);
const [recentInvestigations, setRecentInvestigations] = useState<Investigation[]>([]);
const [loadingInvestigations, setLoadingInvestigations] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searching, setSearching] = useState(false);
useEffect(() => {
listInvestigations(1, 3)
.then((res) => setRecentInvestigations(res.investigations))
.catch(() => {})
.finally(() => setLoadingInvestigations(false));
}, []);
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
const q = searchQuery.trim();
if (!q) return;
setSearching(true);
try {
const res = await searchEntities(q, undefined, 1, 5);
setSearchResults(res.results);
} catch {
setSearchResults([]);
addToast("error", t("search.error"));
} finally {
setSearching(false);
}
};
return (
<div className={styles.page}>
<h1 className={styles.title}>{t("dashboard.welcome")}</h1>
<section className={styles.searchSection}>
<h2 className={styles.sectionTitle}>{t("dashboard.quickSearch")}</h2>
<form className={styles.searchForm} onSubmit={handleSearch}>
<input
type="text"
className={styles.searchInput}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("search.placeholder")}
/>
<button type="submit" className={styles.searchBtn} disabled={searching}>
{t("search.button")}
</button>
</form>
{searchResults.length > 0 && (
<ul className={styles.quickResults}>
{searchResults.map((r) => (
<li key={r.id}>
<Link to={`/app/analysis/${r.id}`} className={styles.quickResultLink}>
<span className={styles.quickResultType}>
{t(`entity.${r.type}`, r.type)}
</span>
<span className={styles.quickResultName}>{r.name}</span>
{r.document && (
<span className={styles.quickResultDoc}>{r.document}</span>
)}
</Link>
</li>
))}
</ul>
)}
</section>
<section className={styles.investigationsSection}>
<h2 className={styles.sectionTitle}>{t("dashboard.recentInvestigations")}</h2>
{loadingInvestigations ? (
<div className={styles.skeletons}>
<Skeleton variant="rect" height="72px" />
<Skeleton variant="rect" height="72px" />
<Skeleton variant="rect" height="72px" />
</div>
) : recentInvestigations.length === 0 ? (
<p className={styles.empty}>{t("dashboard.noRecent")}</p>
) : (
<div className={styles.investigationCards}>
{recentInvestigations.map((inv) => (
<button
key={inv.id}
className={styles.investigationCard}
onClick={() => navigate(`/app/investigations/${inv.id}`)}
>
<span className={styles.invTitle}>{inv.title}</span>
<span className={styles.invMeta}>
{inv.entity_ids.length} {t("common.connections")} &middot; {new Date(inv.updated_at).toLocaleDateString()}
</span>
</button>
))}
</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { useTranslation } from "react-i18next";
import { useParams } from "react-router";
export function EntityAnalysis() {
const { t } = useTranslation();
const { entityId } = useParams<{ entityId: string }>();
return (
<div>
<h1>{t("analysis.title")}</h1>
<p>{entityId}</p>
</div>
);
}

View File

@@ -47,7 +47,7 @@ export function Patterns() {
const handleEntityClick = useCallback(
(id: string) => {
navigate(`/graph/${encodeURIComponent(id)}`);
navigate(`/app/analysis/${encodeURIComponent(id)}`);
},
[navigate],
);

View File

@@ -1,64 +1,185 @@
/* === IBM Plex Sans — UI chrome === */
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/IBMPlexSans-Regular.woff2") format("woff2");
}
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/fonts/IBMPlexSans-Medium.woff2") format("woff2");
}
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("/fonts/IBMPlexSans-SemiBold.woff2") format("woff2");
}
@font-face {
font-family: "IBM Plex Sans";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/fonts/IBMPlexSans-Bold.woff2") format("woff2");
}
/* === IBM Plex Mono — data display === */
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/IBMPlexMono-Regular.woff2") format("woff2");
}
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/fonts/IBMPlexMono-Medium.woff2") format("woff2");
}
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("/fonts/IBMPlexMono-SemiBold.woff2") format("woff2");
}
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/fonts/IBMPlexMono-Bold.woff2") format("woff2");
}
:root {
/* Background */
--bg-primary: #0d100e;
--bg-secondary: #141a16;
--bg-tertiary: #1a211c;
/* === Backgrounds — deep black-green, night-vision base === */
--bg-primary: #060a07;
--bg-secondary: #0c1210;
--bg-tertiary: #111a16;
--bg-elevated: #162018;
--bg-surface: #1a2820;
--bg-overlay: rgba(0, 0, 0, 0.75);
/* Text */
--text-primary: #e0e0e0;
--text-secondary: #8a8a8a;
--text-muted: #555;
/* === Text — high contrast for data readability === */
--text-primary: #e8ede9;
--text-secondary: #94a39a;
--text-muted: #5a6b60;
/* Accent */
--accent: #e07a2f;
--accent-hover: #f08a3f;
/* === Accent — amber/gold (WCAG AA: 5.8:1 on bg-primary) === */
--accent: #ff9a3c;
--accent-hover: #ffb060;
--accent-dim: rgba(255, 154, 60, 0.15);
/* Border */
/* === System — cyan === */
--cyan: #00e5c3;
--cyan-dim: rgba(0, 229, 195, 0.1);
--cyan-glow: 0 0 12px rgba(0, 229, 195, 0.3);
/* === Semantic === */
--color-success: #22c55e;
--color-warning: #eab308;
--color-danger: #ef4444;
--color-info: #3b82f6;
/* === Data categorical — 6 colorblind-safe graph colors === */
--data-person: #4ea8de;
--data-company: #e07a5f;
--data-election: #81b29a;
--data-contract: #f2cc8f;
--data-sanction: #e56b6f;
--data-amendment: #b8a9c9;
/* === Borders === */
--border: rgba(255, 255, 255, 0.04);
--border-hover: rgba(255, 255, 255, 0.1);
--border-subtle: rgba(255, 255, 255, 0.02);
--border-hover: rgba(255, 255, 255, 0.08);
--border-active: rgba(0, 229, 195, 0.2);
/* Entity type colors */
--color-person: #e07a2f;
--color-company: #dd6b20;
--color-contract: #38a169;
--color-finance: #3b82f6;
--color-sanction: #dc2626;
--color-legal: #ca8a04;
--color-health: #ec4899;
--color-environment: #0d9488;
--color-labor: #64748b;
--color-education: #a855f7;
--color-regulatory: #2dd4bf;
--color-property: #8b5cf6;
/* === Shadows === */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.6);
--shadow-glow: 0 0 20px rgba(255, 154, 60, 0.1);
/* Spacing */
/* === Spacing === */
--space-2xs: 2px;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-2xl: 48px;
--space-3xl: 64px;
--space-4xl: 96px;
/* Typography */
--font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
/* === Typography === */
--font-sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: "IBM Plex Mono", "Menlo", "Consolas", monospace;
--font-size-2xs: 0.625rem;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-md: 1rem;
--font-size-lg: 1.25rem;
--font-size-xl: 1.5rem;
--font-size-2xl: 2rem;
--font-size-3xl: 3rem;
--font-size-4xl: 4rem;
/* Radius */
--radius-sm: 4px;
--radius-md: 8px;
/* === Radius — intelligence aesthetic: 2-4px max === */
--radius-sm: 2px;
--radius-md: 4px;
/* Transitions */
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
/* === Transitions === */
--transition-fast: 150ms ease-out;
--transition-normal: 250ms ease-out;
/* Focus */
/* === Focus === */
--focus-ring: 0 0 0 2px var(--accent);
/* Hover */
/* === Hover === */
--bg-hover: rgba(255, 255, 255, 0.03);
/* === Z-index scale === */
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 300;
--z-modal: 400;
--z-toast: 500;
--z-command: 600;
}
[data-theme="light"] {
--bg-primary: #f5f7f6;
--bg-secondary: #edf0ee;
--bg-tertiary: #e4e8e5;
--bg-elevated: #ffffff;
--bg-surface: #ffffff;
--bg-overlay: rgba(0, 0, 0, 0.3);
--text-primary: #1a2820;
--text-secondary: #4a5c52;
--text-muted: #7a8c82;
--accent: #d97706;
--accent-hover: #b45309;
--accent-dim: rgba(217, 119, 6, 0.1);
--cyan: #0891b2;
--cyan-dim: rgba(8, 145, 178, 0.08);
--cyan-glow: 0 0 12px rgba(8, 145, 178, 0.15);
--border: rgba(0, 0, 0, 0.08);
--border-subtle: rgba(0, 0, 0, 0.04);
--border-hover: rgba(0, 0, 0, 0.12);
--border-active: rgba(8, 145, 178, 0.2);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.15);
--shadow-glow: 0 0 20px rgba(217, 119, 6, 0.05);
--bg-hover: rgba(0, 0, 0, 0.04);
}
* {
@@ -67,12 +188,27 @@
padding: 0;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: var(--font-mono);
font-family: var(--font-sans);
font-size: var(--font-size-sm);
line-height: 1.6;
line-height: 1.5;
}
code,
pre,
kbd,
.mono {
font-family: var(--font-mono);
font-feature-settings: "tnum" 1, "zero" 1;
font-variant-numeric: tabular-nums slashed-zero;
}
a {
@@ -97,3 +233,22 @@ textarea:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
input,
textarea,
select {
font-family: inherit;
font-size: inherit;
}
/* === Reduced motion === */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}