redesign: complete UI overhaul for 54 nodes and 26 projects

- Replace D3 force graph with categorized grid layout for topology
  (54 nodes organized by type: infrastructure, servers, PCs, etc.)
- Replace individual task cards with project summary cards
  (progress bars and stage chips instead of 1700+ task cards)
- Compact node cards with status-colored backgrounds
- Better calendar empty state with centered icon
- Refined dark theme with more color depth
- Remove D3 dependency (no longer needed)
- Fix text sizes for 4K TV readability at distance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 10:18:46 +00:00
parent 5e3d8d45de
commit c7f2d650c4
8 changed files with 345 additions and 249 deletions

View File

@@ -18,8 +18,6 @@
"vite": "^7.3.1" "vite": "^7.3.1"
}, },
"dependencies": { "dependencies": {
"@types/d3": "^7.4.3",
"d3": "^7.9.0",
"framer-motion": "^12.34.0", "framer-motion": "^12.34.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4"

View File

@@ -14,12 +14,15 @@ import { useWebSocket } from "./hooks/useWebSocket";
import { useRotation } from "./hooks/useRotation"; import { useRotation } from "./hooks/useRotation";
import type { WSMessage } from "./types"; import type { WSMessage } from "./types";
const VIEW_NAMES = ["Topologia de Red", "Proyectos Odoo", "Calendario"]; const VIEW_NAMES = ["Topología de Red", "Proyectos", "Calendario"];
function LoadingScreen({ label }: { label: string }) { function LoadingScreen({ label }: { label: string }) {
return ( return (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<p className="text-2xl text-text-secondary animate-pulse">{label}</p> <div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-accent border-t-transparent rounded-full animate-spin" />
<p className="text-xl text-text-secondary">{label}</p>
</div>
</div> </div>
); );
} }
@@ -57,14 +60,14 @@ function App() {
{topology.data ? ( {topology.data ? (
<NetworkGraph nodes={topology.data.nodes} /> <NetworkGraph nodes={topology.data.nodes} />
) : ( ) : (
<LoadingScreen label="Cargando topologia..." /> <LoadingScreen label="Cargando topología..." />
)} )}
</div> </div>
<div className="h-full"> <div className="h-full">
{tasks.data ? ( {tasks.data ? (
<KanbanBoard projects={tasks.data.projects} /> <KanbanBoard projects={tasks.data.projects} />
) : ( ) : (
<LoadingScreen label="Cargando tareas..." /> <LoadingScreen label="Cargando proyectos..." />
)} )}
</div> </div>
<div className="h-full"> <div className="h-full">

View File

@@ -24,53 +24,88 @@ export function CalendarView({ events }: CalendarViewProps) {
}); });
}; };
const hasEvents = events.length > 0;
return ( return (
<div className="flex flex-col h-full p-12 overflow-hidden"> <div className="flex flex-col h-full">
<section className="mb-10"> {/* Summary bar */}
<h2 className="text-3xl font-bold text-text-primary mb-6"> <div className="flex items-center gap-8 px-16 py-4 bg-bg-secondary border-b border-border">
Hoy &mdash; {formatDate(today)} <span className="text-lg text-text-secondary">
</h2> <span className="font-bold text-text-primary">{events.length}</span> eventos esta
{todayEvents.length === 0 ? ( semana
<p className="text-xl text-text-secondary">Sin eventos programados</p> </span>
</div>
<div className="flex-1 overflow-y-auto px-16 py-8">
{!hasEvents ? (
<div className="flex flex-col items-center justify-center h-full gap-6 -mt-16">
<span className="text-8xl opacity-30">📅</span>
<p className="text-3xl font-semibold text-text-secondary">
Sin eventos programados
</p>
<p className="text-xl text-text-muted">
Los próximos eventos del calendario de Odoo aparecerán aquí
</p>
</div>
) : ( ) : (
<div className="space-y-4"> <div className="grid grid-cols-3 gap-10 h-full">
{todayEvents.map((e) => ( {/* Today */}
<EventCard key={e.id} event={e} /> <section className="flex flex-col">
))} <h2 className="text-2xl font-bold text-text-primary mb-2">Hoy</h2>
<p className="text-base text-text-muted capitalize mb-6">{formatDate(today)}</p>
{todayEvents.length === 0 ? (
<p className="text-lg text-text-secondary">Sin eventos</p>
) : (
<div className="space-y-4">
{todayEvents.map((e) => (
<EventCard key={e.id} event={e} />
))}
</div>
)}
</section>
{/* Tomorrow */}
<section className="flex flex-col">
<h2 className="text-2xl font-bold text-text-primary mb-2">Mañana</h2>
<p className="text-base text-text-muted capitalize mb-6">
{formatDate(tomorrow)}
</p>
{tomorrowEvents.length === 0 ? (
<p className="text-lg text-text-secondary">Sin eventos</p>
) : (
<div className="space-y-4">
{tomorrowEvents.map((e) => (
<EventCard key={e.id} event={e} />
))}
</div>
)}
</section>
{/* This week */}
<section className="flex flex-col">
<h2 className="text-2xl font-bold text-text-primary mb-2">Esta semana</h2>
<p className="text-base text-text-muted mb-6">Próximos días</p>
{laterEvents.length === 0 ? (
<p className="text-lg text-text-secondary">Sin eventos</p>
) : (
<div className="space-y-3">
{laterEvents.map((e) => (
<div
key={e.id}
className="flex gap-4 text-base bg-bg-card rounded-lg px-4 py-3 border border-border"
>
<span className="font-mono text-text-muted min-w-[160px] capitalize">
{formatDate(e.start)}
</span>
<span className="text-text-primary truncate">{e.name}</span>
</div>
))}
</div>
)}
</section>
</div> </div>
)} )}
</section> </div>
<section className="mb-10">
<h2 className="text-2xl font-bold text-text-primary mb-4">
Ma&ntilde;ana &mdash; {formatDate(tomorrow)}
</h2>
{tomorrowEvents.length === 0 ? (
<p className="text-lg text-text-secondary">Sin eventos programados</p>
) : (
<div className="space-y-4">
{tomorrowEvents.map((e) => (
<EventCard key={e.id} event={e} />
))}
</div>
)}
</section>
{laterEvents.length > 0 && (
<section>
<h2 className="text-2xl font-bold text-text-primary mb-4">Esta semana</h2>
<div className="space-y-3">
{laterEvents.map((e) => (
<div key={e.id} className="flex gap-4 text-lg text-text-secondary">
<span className="font-mono min-w-[200px] capitalize">
{formatDate(e.start)}
</span>
<span className="text-text-primary">{e.name}</span>
</div>
))}
</div>
</section>
)}
</div> </div>
); );
} }

View File

@@ -27,18 +27,25 @@ export function Header({ viewName, connected }: HeaderProps) {
}); });
return ( return (
<header className="flex items-center justify-between px-12 py-6 bg-bg-secondary border-b border-border"> <header className="flex items-center justify-between px-16 py-5 bg-bg-secondary border-b border-border">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1> <h1 className="text-3xl font-bold tracking-tight text-text-primary">
<span className="text-lg text-text-secondary">{viewName}</span> Consultoria AS
</h1>
<span className="text-xl text-text-muted">|</span>
<span className="text-xl font-medium text-accent">{viewName}</span>
</div> </div>
<div className="flex items-center gap-8"> <div className="flex items-center gap-10">
<span className="text-lg text-text-secondary capitalize">{dateStr}</span> <span className="text-lg text-text-secondary capitalize">{dateStr}</span>
<span className="text-2xl font-mono font-bold">{timeStr}</span> <span className="text-3xl font-mono font-bold tabular-nums">{timeStr}</span>
<span <div className="flex items-center gap-2">
className={`w-4 h-4 rounded-full ${connected ? "bg-success" : "bg-danger"}`} <span
title={connected ? "Conectado" : "Desconectado"} className={`w-3 h-3 rounded-full ${connected ? "bg-success animate-pulse" : "bg-danger"}`}
/> />
<span className="text-sm text-text-secondary">
{connected ? "Conectado" : "Sin conexión"}
</span>
</div>
</div> </div>
</header> </header>
); );

View File

@@ -1,58 +1,106 @@
import { TaskCard } from "./TaskCard";
import type { Project } from "../../types"; import type { Project } from "../../types";
interface KanbanBoardProps { interface KanbanBoardProps {
projects: Project[]; projects: Project[];
} }
export function KanbanBoard({ projects }: KanbanBoardProps) { const STAGE_COLORS = [
const allStages = new Set<string>(); "bg-blue-500",
for (const project of projects) { "bg-emerald-500",
for (const stage of Object.keys(project.stages)) { "bg-amber-500",
allStages.add(stage); "bg-purple-500",
} "bg-rose-500",
} "bg-cyan-500",
const stageList = Array.from(allStages); "bg-orange-500",
"bg-teal-500",
];
function ProjectRow({ project }: { project: Project }) {
const stages = Object.entries(project.stages);
const totalTasks = stages.reduce((sum, [, tasks]) => sum + tasks.length, 0);
if (totalTasks === 0) return null;
return ( return (
<div className="flex flex-col h-full p-8 overflow-hidden"> <div className="bg-bg-card border border-border rounded-xl p-5 flex flex-col gap-3">
<div {/* Project header */}
className="grid gap-4 mb-6" <div className="flex items-center justify-between">
style={{ gridTemplateColumns: `200px repeat(${stageList.length}, 1fr)` }} <h3 className="text-lg font-bold text-text-primary truncate pr-4">
> {project.name}
<div className="text-lg font-bold text-text-secondary uppercase tracking-wider"> </h3>
Proyecto <span className="text-sm font-mono text-text-muted shrink-0">
</div> {totalTasks} tareas
{stageList.map((stage) => ( </span>
<div
key={stage}
className="text-lg font-bold text-text-secondary uppercase tracking-wider text-center"
>
{stage}
</div>
))}
</div> </div>
<div className="flex-1 overflow-auto space-y-4"> {/* Progress bar */}
{projects.map((project) => ( <div className="flex h-3 rounded-full overflow-hidden bg-bg-primary">
<div {stages.map(([stageName, tasks], i) => {
key={project.id} const pct = (tasks.length / totalTasks) * 100;
className="grid gap-4 bg-bg-secondary rounded-xl p-4 border border-border" if (pct < 1) return null;
style={{ gridTemplateColumns: `200px repeat(${stageList.length}, 1fr)` }} return (
<div
key={stageName}
className={`${STAGE_COLORS[i % STAGE_COLORS.length]} transition-all`}
style={{ width: `${pct}%` }}
title={`${stageName}: ${tasks.length}`}
/>
);
})}
</div>
{/* Stage chips */}
<div className="flex flex-wrap gap-2">
{stages.map(([stageName, tasks], i) => (
<span
key={stageName}
className="inline-flex items-center gap-1.5 text-xs text-text-secondary bg-bg-secondary rounded-md px-2.5 py-1"
> >
<div className="flex items-start"> <span
<span className="text-xl font-bold text-text-primary">{project.name}</span> className={`w-2 h-2 rounded-full ${STAGE_COLORS[i % STAGE_COLORS.length]}`}
</div> />
{stageList.map((stage) => ( <span className="truncate max-w-[180px]">{stageName}</span>
<div key={stage} className="space-y-2 min-h-[80px]"> <span className="font-mono font-bold text-text-primary">{tasks.length}</span>
{(project.stages[stage] || []).map((task) => ( </span>
<TaskCard key={task.id} task={task} />
))}
</div>
))}
</div>
))} ))}
</div> </div>
</div> </div>
); );
} }
export function KanbanBoard({ projects }: KanbanBoardProps) {
const activeProjects = projects.filter((p) => {
const total = Object.values(p.stages).reduce((s, t) => s + t.length, 0);
return total > 0;
});
const totalTasks = activeProjects.reduce(
(sum, p) => sum + Object.values(p.stages).reduce((s, t) => s + t.length, 0),
0
);
return (
<div className="flex flex-col h-full">
{/* Summary bar */}
<div className="flex items-center gap-8 px-16 py-4 bg-bg-secondary border-b border-border">
<span className="text-lg text-text-secondary">
<span className="font-bold text-text-primary">{activeProjects.length}</span> proyectos
activos
</span>
<span className="text-text-muted">|</span>
<span className="text-lg text-text-secondary">
<span className="font-bold text-text-primary">{totalTasks}</span> tareas totales
</span>
</div>
{/* Projects grid */}
<div className="flex-1 overflow-y-auto px-16 py-6">
<div className="grid grid-cols-3 gap-4">
{activeProjects.map((project) => (
<ProjectRow key={project.id} project={project} />
))}
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,3 @@
import { useEffect, useRef, useState } from "react";
import * as d3 from "d3";
import { NodeCard } from "./NodeCard"; import { NodeCard } from "./NodeCard";
import type { NetworkNode } from "../../types"; import type { NetworkNode } from "../../types";
@@ -7,105 +5,86 @@ interface NetworkGraphProps {
nodes: NetworkNode[]; nodes: NetworkNode[];
} }
interface SimNode extends d3.SimulationNodeDatum { const CATEGORY_ORDER: [string, string[]][] = [
id: string; ["Infraestructura", ["firewall", "router", "switch", "ap"]],
data: NetworkNode; ["Servidores", ["server"]],
} ["Almacenamiento", ["nas"]],
["Equipos", ["pc"]],
["Periféricos", ["printer", "phone", "camera"]],
["Otros", ["device"]],
];
interface SimLink extends d3.SimulationLinkDatum<SimNode> { function categorizeNodes(nodes: NetworkNode[]) {
source: SimNode; const categorized: { label: string; nodes: NetworkNode[] }[] = [];
target: SimNode; const assigned = new Set<string>();
for (const [label, icons] of CATEGORY_ORDER) {
const matching = nodes.filter(
(n) => icons.includes(n.icon) && !assigned.has(n.ip)
);
if (matching.length > 0) {
categorized.push({ label, nodes: matching });
for (const n of matching) assigned.add(n.ip);
}
}
// Any remaining nodes
const remaining = nodes.filter((n) => !assigned.has(n.ip));
if (remaining.length > 0) {
categorized.push({ label: "Otros", nodes: remaining });
}
return categorized;
} }
export function NetworkGraph({ nodes }: NetworkGraphProps) { export function NetworkGraph({ nodes }: NetworkGraphProps) {
const svgRef = useRef<SVGSVGElement>(null); const categories = categorizeNodes(nodes);
const [positions, setPositions] = useState<Map<string, { x: number; y: number }>>(new Map());
useEffect(() => {
if (!svgRef.current || nodes.length === 0) return;
const width = 3840;
const height = 1900;
const simNodes: SimNode[] = nodes.map((n) => ({
id: n.ip,
data: n,
x: width / 2 + (Math.random() - 0.5) * 800,
y: height / 2 + (Math.random() - 0.5) * 600,
}));
const nodeMap = new Map(simNodes.map((n) => [n.data.name, n]));
const simLinks: SimLink[] = [];
for (const node of nodes) {
for (const connName of node.connections || []) {
const target = nodeMap.get(connName);
const source = nodeMap.get(node.name);
if (source && target) {
simLinks.push({ source, target });
}
}
}
const sim = d3
.forceSimulation(simNodes)
.force(
"link",
d3.forceLink(simLinks).id((d: d3.SimulationNodeDatum) => (d as SimNode).id).distance(350)
)
.force("charge", d3.forceManyBody().strength(-2000))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide(150))
.on("tick", () => {
// Update link positions via D3
const svg = d3.select(svgRef.current);
svg
.selectAll<SVGLineElement, SimLink>("line.link")
.data(simLinks)
.join("line")
.attr("class", "link")
.attr("x1", (d) => d.source.x!)
.attr("y1", (d) => d.source.y!)
.attr("x2", (d) => d.target.x!)
.attr("y2", (d) => d.target.y!)
.attr("stroke", "#2a2a3e")
.attr("stroke-width", 3);
// Update React state for node positions
const newPositions = new Map<string, { x: number; y: number }>();
for (const n of simNodes) {
newPositions.set(n.id, { x: n.x!, y: n.y! });
}
setPositions(newPositions);
});
return () => {
sim.stop();
};
}, [nodes]);
const onlineCount = nodes.filter((n) => n.status === "up").length; const onlineCount = nodes.filter((n) => n.status === "up").length;
const offlineCount = nodes.filter((n) => n.status === "down").length; const total = nodes.length;
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<svg ref={svgRef} width="3840" height="1900" className="flex-1"> {/* Summary bar */}
{nodes.map((node) => { <div className="flex items-center gap-8 px-16 py-4 bg-bg-secondary border-b border-border">
const pos = positions.get(node.ip); <div className="flex items-center gap-3">
if (!pos) return null; <span className="w-3 h-3 rounded-full bg-success" />
return ( <span className="text-lg text-text-secondary">
<NodeCard key={node.ip} node={node} x={pos.x} y={pos.y} /> <span className="font-bold text-text-primary">{onlineCount}</span> online
); </span>
})} </div>
</svg> <div className="flex items-center gap-3">
<div className="flex items-center justify-center gap-8 py-4 bg-bg-secondary border-t border-border text-lg"> <span className="w-3 h-3 rounded-full bg-danger" />
<span className="flex items-center gap-2"> <span className="text-lg text-text-secondary">
<span className="w-4 h-4 rounded-full bg-success" /> {onlineCount} online <span className="font-bold text-text-primary">{total - onlineCount}</span> offline
</span> </span>
<span className="flex items-center gap-2"> </div>
<span className="w-4 h-4 rounded-full bg-danger" /> {offlineCount} offline <span className="text-text-muted">|</span>
<span className="text-lg text-text-secondary">
<span className="font-bold text-text-primary">{total}</span> dispositivos
</span> </span>
</div> </div>
{/* Scrollable grid */}
<div className="flex-1 overflow-y-auto px-16 py-6 space-y-6">
{categories.map((cat) => (
<section key={cat.label}>
<h3 className="text-sm font-bold text-text-muted uppercase tracking-widest mb-3">
{cat.label}
<span className="ml-2 text-text-muted font-normal">({cat.nodes.length})</span>
</h3>
<div
className="grid gap-3"
style={{
gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))",
}}
>
{cat.nodes.map((node) => (
<NodeCard key={node.ip} node={node} />
))}
</div>
</section>
))}
</div>
</div> </div>
); );
} }

View File

@@ -2,63 +2,70 @@ import { useState } from "react";
import type { NetworkNode } from "../../types"; import type { NetworkNode } from "../../types";
const ICON_MAP: Record<string, string> = { const ICON_MAP: Record<string, string> = {
router: "\uD83C\uDF10", router: "🌐",
firewall: "\uD83D\uDEE1\uFE0F", firewall: "🛡️",
server: "\uD83D\uDDA5\uFE0F", server: "🖥️",
switch: "\uD83D\uDD00", switch: "🔀",
ap: "\uD83D\uDCE1", ap: "📡",
pc: "\uD83D\uDCBB", pc: "💻",
nas: "\uD83D\uDCBE", nas: "💾",
printer: "\uD83D\uDDA8\uFE0F", printer: "🖨️",
phone: "\uD83D\uDCDE", phone: "📞",
camera: "\uD83D\uDCF7", camera: "📷",
device: "\uD83D\uDCF1", device: "📱",
}; };
interface NodeCardProps { interface NodeCardProps {
node: NetworkNode; node: NetworkNode;
x: number;
y: number;
} }
export function NodeCard({ node, x, y }: NodeCardProps) { export function NodeCard({ node }: NodeCardProps) {
const [showPassword, setShowPassword] = useState(false); const [expanded, setExpanded] = useState(false);
const statusColor = const isUp = node.status === "up";
node.status === "up" const statusBg = isUp ? "bg-success-dim" : "bg-danger-dim";
? "bg-success" const statusBorder = isUp ? "border-success/30" : "border-danger/30";
: node.status === "down" const statusDot = isUp ? "bg-success" : "bg-danger";
? "bg-danger"
: "bg-warning";
return ( return (
<foreignObject x={x - 120} y={y - 80} width={240} height={180}> <div
<div className={`relative ${statusBg} border ${statusBorder} rounded-lg px-4 py-3 cursor-pointer transition-all duration-200 hover:bg-bg-card-hover`}
className="bg-bg-card border border-border rounded-xl p-4 flex flex-col items-center gap-1 shadow-lg cursor-pointer select-none" onClick={() => setExpanded((prev) => !prev)}
onClick={() => setShowPassword((prev) => !prev)} >
> <div className="flex items-center gap-3">
<span className="text-3xl">{ICON_MAP[node.icon] || "\uD83D\uDCE6"}</span> <span className={`w-2.5 h-2.5 rounded-full ${statusDot} shrink-0`} />
<span className="text-base font-bold text-text-primary truncate w-full text-center"> <span className="text-xl shrink-0">{ICON_MAP[node.icon] || "📦"}</span>
{node.name} <div className="min-w-0 flex-1">
</span> <p className="text-base font-semibold text-text-primary truncate leading-tight">
<span className="text-sm text-text-secondary font-mono">{node.ip}</span> {node.name}
{node.username && ( </p>
<span className="text-sm text-text-secondary"> <p className="text-sm font-mono text-text-secondary leading-tight">{node.ip}</p>
{node.username} / {showPassword ? node.password : "\u2022\u2022\u2022\u2022"}
</span>
)}
{node.public_url && (
<span className="text-xs text-accent truncate w-full text-center">
{node.public_url}
</span>
)}
<div className="flex items-center gap-2 mt-1">
<span className={`w-3 h-3 rounded-full ${statusColor}`} />
<span className="text-xs text-text-secondary">
{node.status === "up" ? "Online" : node.status === "down" ? "Offline" : "Unknown"}
</span>
</div> </div>
{node.public_url && (
<span className="text-xs text-accent truncate max-w-[180px] shrink-0 hidden 2xl:block">
{node.public_url.replace(/^https?:\/\//, "")}
</span>
)}
</div> </div>
</foreignObject>
{expanded && (node.username || node.public_url) && (
<div className="mt-2 pt-2 border-t border-border-light text-sm space-y-1">
{node.username && (
<p className="text-text-secondary">
<span className="text-text-muted">User:</span> {node.username}
{node.password && (
<span className="ml-2">
<span className="text-text-muted">Pass:</span>{" "}
<span className="font-mono text-warning">{node.password}</span>
</span>
)}
</p>
)}
{node.public_url && (
<p className="text-accent truncate">{node.public_url}</p>
)}
</div>
)}
</div>
); );
} }

View File

@@ -1,16 +1,23 @@
@import "tailwindcss"; @import "tailwindcss";
@theme { @theme {
--color-bg-primary: #0a0a0f; --color-bg-primary: #0b0d14;
--color-bg-secondary: #12121a; --color-bg-secondary: #111520;
--color-bg-card: #1a1a2e; --color-bg-card: #181d2a;
--color-border: #2a2a3e; --color-bg-card-hover: #1f2638;
--color-text-primary: #e4e4e7; --color-border: #252d3f;
--color-text-secondary: #a1a1aa; --color-border-light: #2e3750;
--color-accent: #3b82f6; --color-text-primary: #e8eaf0;
--color-success: #22c55e; --color-text-secondary: #8892a8;
--color-danger: #ef4444; --color-text-muted: #5c6478;
--color-warning: #f59e0b; --color-accent: #4f8ff7;
--color-accent-dim: #2a4a80;
--color-success: #34d399;
--color-success-dim: #0d3d2e;
--color-danger: #f87171;
--color-danger-dim: #3d1515;
--color-warning: #fbbf24;
--color-warning-dim: #3d2e0a;
} }
html { html {
@@ -20,9 +27,21 @@ html {
body { body {
background-color: var(--color-bg-primary); background-color: var(--color-bg-primary);
color: var(--color-text-primary); color: var(--color-text-primary);
font-family: "Inter", system-ui, sans-serif; font-family: "Inter", system-ui, -apple-system, sans-serif;
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
width: 3840px; width: 3840px;
height: 2160px; height: 2160px;
} }
/* Smooth scrollbar for overflow areas */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}