feat: scaffolding client Vite/React + design system Gruvbox

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 04:18:33 +02:00
parent f6fcf4dbb6
commit bd87e84742
6 changed files with 908 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>System Update</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+658
View File
@@ -0,0 +1,658 @@
// @ts-nocheck
import React from "react";
/* ============================================================
ui-kit.jsx
Composants haute-fid Gruvbox Seventies.
Tout est purement décoratif/interactif côté composant.
Effets : transparence (glass), hover glow, click 3D, tooltips.
============================================================ */
const { useState, useRef, useEffect } = React;
/* ============================================================
Icônes — Font Awesome 6 Free.
Mapping nom logique → classe FA. Le CSS de FA est chargé en CDN
dans le <head>. Le composant garde la MÊME API qu'avant (name,
size, style) pour ne rien casser ailleurs.
============================================================ */
const ICON_MAP = {
cpu: 'microchip',
memory: 'memory',
disk: 'hard-drive',
network: 'network-wired',
clock: 'clock',
grid: 'table-cells',
list: 'list',
cog: 'gear',
alert: 'triangle-exclamation',
bell: 'bell',
server: 'server',
chart: 'chart-line',
bars: 'chart-simple',
terminal: 'terminal',
refresh: 'arrows-rotate',
play: 'play',
pause: 'pause',
power: 'power-off',
sun: 'sun',
moon: 'moon',
search: 'magnifying-glass',
close: 'xmark',
chevR: 'chevron-right',
chevL: 'chevron-left',
chevD: 'chevron-down',
chevU: 'chevron-up',
plus: 'plus',
filter: 'filter',
download: 'download',
folder: 'folder',
node: 'circle-nodes',
user: 'user',
};
const Icon = ({ name, size = 16, style }) => {
const fa = ICON_MAP[name] || 'circle-question';
return (
<i className={`fa-solid fa-${fa}`} aria-hidden="true" style={{
fontSize: size,
width: size,
height: size,
lineHeight: `${size}px`,
textAlign: 'center',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flex: '0 0 auto',
color: 'currentColor',
...style,
}} />
);
};
/* ============================================================
Tooltip — apparaît au hover après 300ms, position auto.
============================================================ */
function Tooltip({ children, label, side = 'top' }) {
const [show, setShow] = useState(false);
const t = useRef();
const onEnter = () => { t.current = setTimeout(() => setShow(true), 280); };
const onLeave = () => { clearTimeout(t.current); setShow(false); };
const sides = {
top: { bottom: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
bottom: { top: 'calc(100% + 8px)', left: '50%', transform: 'translateX(-50%)' },
left: { right: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
right: { left: 'calc(100% + 8px)', top: '50%', transform: 'translateY(-50%)' },
};
return (
<span style={{ position: 'relative', display: 'inline-flex' }}
onMouseEnter={onEnter} onMouseLeave={onLeave}>
{children}
{show && (
<span className="glass-strong" style={{
position: 'absolute', ...sides[side],
padding: '6px 10px',
borderRadius: 6,
fontSize: 12, lineHeight: 1.3,
color: 'var(--ink-1)',
whiteSpace: 'nowrap',
boxShadow: 'var(--shadow-2)',
zIndex: 1000,
pointerEvents: 'none',
fontFamily: 'JetBrains Mono, monospace',
letterSpacing: '0.02em',
}}>{label}</span>
)}
</span>
);
}
/* ============================================================
IconButton — bouton icône seul + tooltip obligatoire.
============================================================ */
function IconButton({ icon, label, onClick, active, danger, size = 34, primary }) {
const bg = active ? 'var(--accent-tint)'
: primary ? 'var(--accent)'
: 'var(--bg-3)';
const fg = active ? 'var(--accent)'
: primary ? 'var(--bg-1)'
: danger ? 'var(--err)'
: 'var(--ink-2)';
const bd = active ? 'var(--accent-soft)' : 'var(--border-2)';
return (
<Tooltip label={label}>
<button onClick={onClick} className="interactive" style={{
width: size, height: size,
background: bg,
color: fg,
border: `1px solid ${bd}`,
borderRadius: 8,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 0, cursor: 'pointer',
boxShadow: primary ? '0 2px 6px var(--accent-glow)' : 'var(--shadow-1)',
}}>
<Icon name={icon} size={Math.round(size * 0.5)} />
</button>
</Tooltip>
);
}
/* ============================================================
Toggle on/off — switch tactile avec glow accent quand ON
============================================================ */
function Toggle({ on, onChange, label, icon }) {
return (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{icon && <Icon name={icon} size={14} style={{ color: on ? 'var(--accent)' : 'var(--ink-3)' }} />}
{label && <span className="label" style={{ color: on ? 'var(--ink-1)' : 'var(--ink-3)' }}>{label}</span>}
<button onClick={() => onChange(!on)} className="interactive" style={{
width: 42, height: 22, borderRadius: 12,
background: on ? 'var(--accent)' : 'var(--bg-4)',
border: `1px solid ${on ? 'var(--accent-soft)' : 'var(--border-2)'}`,
boxShadow: on ? `0 0 10px var(--accent-glow), var(--shadow-1)` : 'var(--shadow-press)',
position: 'relative', cursor: 'pointer', padding: 0,
}}>
<span style={{
position: 'absolute', top: 1, left: on ? 21 : 1,
width: 18, height: 18, borderRadius: '50%',
background: on ? 'var(--bg-1)' : 'var(--ink-2)',
transition: 'left .18s cubic-bezier(.5,.2,.3,1.3), background .15s',
boxShadow: 'var(--shadow-1)',
}} />
</button>
</div>
);
}
/* ============================================================
Status LED — pastille pulsante (effet halo si critique)
============================================================ */
function StatusLed({ status = 'ok', size = 10, pulse }) {
const map = {
ok: { c: 'var(--ok)', g: 'var(--ok-glow)' },
warn: { c: 'var(--warn)', g: 'var(--warn-glow)' },
err: { c: 'var(--err)', g: 'var(--err-glow)' },
off: { c: 'var(--ink-4)', g: 'transparent' },
info: { c: 'var(--info)', g: 'var(--info-glow)' },
};
const { c, g } = map[status];
const id = `pulse-${status}-${size}`;
return (
<>
{pulse && (
<style>{`@keyframes ${id} { 0%{box-shadow:0 0 0 0 ${g}} 70%{box-shadow:0 0 0 6px transparent} 100%{box-shadow:0 0 0 0 transparent} }`}</style>
)}
<span style={{
display: 'inline-block',
width: size, height: size,
borderRadius: '50%',
background: c,
boxShadow: status === 'off' ? 'none' : `0 0 6px ${g}, inset 0 0 2px rgba(0,0,0,0.3)`,
animation: pulse ? `${id} 1.8s ease-out infinite` : 'none',
flex: '0 0 auto',
}} />
</>
);
}
/* ============================================================
BatteryGauge — jauge horizontale style batterie
- Pas de bandes (couleur unie + léger gloss interne)
- Pas de graduations verticales
- Hover : glow lumineux dans la couleur de la jauge
- Mode compact : label [bar] valeur sur une seule ligne
============================================================ */
function BatteryGauge({ value = 60, label, max = 100, unit = '%', warnAt = 70, errAt = 90, height = 22, compact = false, color: colorOverride, icon }) {
const pct = Math.max(0, Math.min(100, (value / max) * 100));
const color = colorOverride
|| (pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)');
const glowVar = pct >= errAt ? 'var(--err-glow)'
: pct >= warnAt ? 'var(--warn-glow)'
: 'var(--ok-glow)';
// Variante compacte : label [bar] valeur sur une seule ligne
if (compact) {
return (
<div className="bg-hover" style={{
display: 'flex', alignItems: 'center', gap: 10, minWidth: 0,
'--bg-glow': glowVar,
}}>
{(icon || label) && (
<span style={{
flex: '0 0 auto', display: 'inline-flex', alignItems: 'center', gap: 6,
minWidth: 90,
}}>
{icon && <Icon name={icon} size={12} style={{ color: 'var(--ink-3)' }} />}
{label && <span className="label" style={{ fontSize: 11 }}>{label}</span>}
</span>
)}
<div className="bg-bar" style={{
flex: 1, height: 12, borderRadius: 3,
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.4)',
overflow: 'hidden', position: 'relative',
transition: 'border-color .2s',
}}>
<div className="bg-fill" style={{
position: 'absolute', top: 1, left: 1, bottom: 1,
width: `calc((100% - 2px) * ${pct / 100})`,
background: color,
borderRadius: 2,
transition: 'width .4s cubic-bezier(.3,.6,.3,1), box-shadow .2s',
}} />
</div>
<span className="mono" style={{
flex: '0 0 auto', fontSize: 13,
color: 'var(--ink-1)', minWidth: 52, textAlign: 'right',
}}>
{value}<span style={{ color: 'var(--ink-3)', marginLeft: 2 }}>{unit}</span>
</span>
</div>
);
}
return (
<div className="bg-hover" style={{
display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0,
'--bg-glow': glowVar,
}}>
{label && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span className="label">{label}</span>
<span className="mono" style={{ fontSize: 13, color: 'var(--ink-1)' }}>
{value}<span style={{ color: 'var(--ink-3)', marginLeft: 2 }}>{unit}</span>
</span>
</div>
)}
<div className="bg-bar" style={{
position: 'relative',
height, borderRadius: 4,
background: 'var(--bg-1)',
border: '1px solid var(--border-2)',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.4)',
overflow: 'hidden',
transition: 'border-color .2s',
}}>
<div className="bg-fill" style={{
position: 'absolute', top: 1, left: 1, bottom: 1,
width: `calc((100% - 2px) * ${pct / 100})`,
background: color,
borderRadius: 3,
transition: 'width .4s cubic-bezier(.3,.6,.3,1), box-shadow .2s',
}} />
{/* Gloss interne très léger (un seul highlight haut, pas de bande inférieure) */}
<div style={{
position: 'absolute', top: 1, left: 1, right: 1, height: '40%',
background: 'linear-gradient(180deg, rgba(255,255,255,0.18), transparent)',
borderRadius: '3px 3px 0 0',
pointerEvents: 'none',
}} />
</div>
</div>
);
}
/* ============================================================
RadialGauge — jauge ronde, version épurée
============================================================ */
function RadialGauge({ value = 64, label, size = 120, warnAt = 70, errAt = 90 }) {
const pct = Math.max(0, Math.min(100, value));
const color = pct >= errAt ? 'var(--err)' : pct >= warnAt ? 'var(--warn)' : 'var(--ok)';
const glow = pct >= errAt ? 'var(--err-glow)' : pct >= warnAt ? 'var(--warn-glow)' : 'var(--ok-glow)';
const r = size / 2 - 10;
const cx = size / 2;
const cy = size / 2 + 6;
const circ = Math.PI * r;
const offset = circ - (pct / 100) * circ;
return (
<div className="gauge-hover" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<svg width={size} height={size * 0.72} viewBox={`0 0 ${size} ${size * 0.8}`}>
<defs>
<filter id={`glow-${label}`} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2.5" />
</filter>
</defs>
{/* arc background */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke="var(--bg-4)" strokeWidth="6" strokeLinecap="round" />
{/* arc value glow */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke={color} strokeWidth="8" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
filter={`url(#glow-${label})`} opacity="0.7" />
{/* arc value crisp */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke={color} strokeWidth="5" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
style={{ transition: 'stroke-dashoffset .5s cubic-bezier(.3,.6,.3,1)' }} />
</svg>
<div style={{ marginTop: -10, textAlign: 'center' }}>
<div className="mono" style={{ fontSize: size * 0.22, fontWeight: 600, color: 'var(--ink-1)', lineHeight: 1 }}>
{value}<span style={{ fontSize: '0.55em', color: 'var(--ink-3)', marginLeft: 2 }}>%</span>
</div>
{label && <div className="label" style={{ marginTop: 2 }}>{label}</div>}
</div>
</div>
);
}
/* ============================================================
BigRadialGauge — la grande jauge cockpit "santé système"
============================================================ */
function BigRadialGauge({ value = 87, label = 'score santé · stable' }) {
const size = 320;
const r = 130;
const cx = size / 2;
const cy = size / 2 + 30;
const circ = Math.PI * r;
const offset = circ - (value / 100) * circ;
const color = value >= 80 ? 'var(--ok)' : value >= 50 ? 'var(--warn)' : 'var(--err)';
return (
<div className="gauge-hover" style={{ position: 'relative', width: size, height: size * 0.78 }}>
<svg width={size} height={size * 0.85}>
<defs>
<filter id="biggauge-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="4" />
</filter>
<linearGradient id="biggauge-grad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stopColor={color} stopOpacity="0.7"/>
<stop offset="1" stopColor={color}/>
</linearGradient>
</defs>
{/* tics */}
{Array.from({ length: 21 }).map((_, i) => {
const a = Math.PI - (i / 20) * Math.PI;
const major = i % 5 === 0;
const inner = major ? r + 8 : r + 11;
const outer = major ? r + 20 : r + 15;
return <line key={i}
x1={cx + Math.cos(a) * inner} y1={cy - Math.sin(a) * inner}
x2={cx + Math.cos(a) * outer} y2={cy - Math.sin(a) * outer}
stroke={major ? 'var(--ink-3)' : 'var(--ink-4)'} strokeWidth={major ? 1.5 : 0.8}
/>;
})}
{[0, 50, 100].map(v => {
const a = Math.PI - (v / 100) * Math.PI;
const x = cx + Math.cos(a) * (r + 32);
const y = cy - Math.sin(a) * (r + 32) + 4;
return <text key={v} x={x} y={y} textAnchor="middle"
fontFamily="JetBrains Mono" fontSize="11"
fill="var(--ink-3)">{v}</text>;
})}
{/* arc bg */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke="var(--bg-4)" strokeWidth="10" strokeLinecap="round" />
{/* arc value glow */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke={color} strokeWidth="14" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
filter="url(#biggauge-glow)" opacity="0.55" />
{/* arc value */}
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`}
fill="none" stroke="url(#biggauge-grad)" strokeWidth="9" strokeLinecap="round"
strokeDasharray={circ} strokeDashoffset={offset}
style={{ transition: 'stroke-dashoffset .8s cubic-bezier(.3,.6,.3,1)' }} />
{/* needle */}
<line x1={cx} y1={cy}
x2={cx + Math.cos(Math.PI - (value / 100) * Math.PI) * (r - 14)}
y2={cy - Math.sin(Math.PI - (value / 100) * Math.PI) * (r - 14)}
stroke="var(--accent)" strokeWidth="3" strokeLinecap="round"
style={{ filter: 'drop-shadow(0 0 4px var(--accent-glow))' }} />
<circle cx={cx} cy={cy} r="9" fill="var(--bg-3)" stroke="var(--border-3)" strokeWidth="1.5"/>
<circle cx={cx} cy={cy} r="3" fill="var(--accent)" />
</svg>
<div style={{ position: 'absolute', bottom: 12, left: 0, right: 0, textAlign: 'center' }}>
<div className="mono" style={{
fontSize: 64, fontWeight: 700, lineHeight: 1,
color: 'var(--ink-1)',
textShadow: `0 0 20px ${color}33`,
}}>{value}</div>
<div className="label" style={{ marginTop: 6 }}>{label}</div>
</div>
</div>
);
}
/* ============================================================
Popup — modale glassmorphism centrée + bouton fermer
============================================================ */
function Popup({ open, onClose, title, children, footer, width = 460 }) {
if (!open) return null;
return (
<div style={{
position: 'absolute', inset: 0, zIndex: 100,
background: 'rgba(0,0,0,0.45)',
backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'fadein .2s ease-out',
}} onClick={onClose}>
<style>{`
@keyframes fadein { from { opacity: 0 } to { opacity: 1 } }
@keyframes popin { from { opacity: 0; transform: translateY(8px) scale(.98) } to { opacity: 1; transform: translateY(0) scale(1) } }
`}</style>
<div className="glass-strong" onClick={e => e.stopPropagation()} style={{
width, maxWidth: '90%',
borderRadius: 12,
boxShadow: 'var(--shadow-3)',
animation: 'popin .25s cubic-bezier(.3,.7,.3,1.2)',
overflow: 'hidden',
}}>
<div style={{
padding: '14px 16px',
borderBottom: '1px solid var(--border-1)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: 'var(--bg-3)',
}}>
<div style={{ fontWeight: 600, fontSize: 15, color: 'var(--ink-1)' }}>{title}</div>
<IconButton icon="close" label="Fermer" onClick={onClose} size={28} />
</div>
<div style={{ padding: 18 }}>{children}</div>
{footer && (
<div style={{
padding: '12px 16px',
borderTop: '1px solid var(--border-1)',
background: 'var(--bg-2)',
display: 'flex', justifyContent: 'flex-end', gap: 8,
}}>{footer}</div>
)}
</div>
</div>
);
}
/* ============================================================
Button — bouton classique avec variantes
============================================================ */
function Button({ children, icon, onClick, variant = 'default', size = 'md' }) {
const sizes = {
sm: { padding: '5px 10px', fontSize: 12, h: 28 },
md: { padding: '7px 14px', fontSize: 13, h: 34 },
lg: { padding: '10px 18px', fontSize: 14, h: 40 },
}[size];
const variants = {
default: { bg: 'var(--bg-3)', fg: 'var(--ink-1)', bd: 'var(--border-2)' },
primary: { bg: 'var(--accent)', fg: 'var(--bg-1)', bd: 'var(--accent-soft)' },
ghost: { bg: 'transparent', fg: 'var(--ink-2)', bd: 'var(--border-2)' },
danger: { bg: 'var(--bg-3)', fg: 'var(--err)', bd: 'var(--err)' },
}[variant];
return (
<button onClick={onClick} className="interactive" style={{
height: sizes.h,
padding: sizes.padding,
background: variants.bg,
color: variants.fg,
border: `1px solid ${variants.bd}`,
borderRadius: 8,
display: 'inline-flex', alignItems: 'center', gap: 8,
fontFamily: 'inherit', fontSize: sizes.fontSize, fontWeight: 500,
cursor: 'pointer',
boxShadow: variant === 'primary' ? '0 2px 8px var(--accent-glow)' : 'var(--shadow-1)',
}}>
{icon && <Icon name={icon} size={14} />}
{children}
</button>
);
}
/* ============================================================
TreeNav — arbre dépliable avec icône en tête (style B)
============================================================ */
function TreeNav({ groups, activeId, onSelect }) {
const [open, setOpen] = useState(() =>
Object.fromEntries(groups.map(g => [g.id, g.open !== false]))
);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{groups.map(g => (
<div key={g.id}>
<div className="interactive" onClick={() => setOpen({ ...open, [g.id]: !open[g.id] })}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px', borderRadius: 6,
color: 'var(--ink-2)',
background: 'transparent',
border: '1px solid transparent',
cursor: 'pointer',
}}>
<Icon name="chevR" size={12} style={{
transform: open[g.id] ? 'rotate(90deg)' : 'rotate(0)',
transition: 'transform .15s',
color: 'var(--ink-3)',
}} />
<Icon name={g.icon || 'folder'} size={15} style={{ color: 'var(--accent)' }} />
<span style={{ flex: 1, fontSize: 13, fontWeight: 500 }}>{g.label}</span>
{g.count != null && (
<span className="mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>
{g.count}
</span>
)}
</div>
{open[g.id] && (
<div style={{ marginLeft: 18, marginTop: 2, display: 'flex', flexDirection: 'column', gap: 1, paddingLeft: 8, borderLeft: '1px dashed var(--border-1)' }}>
{g.children.map(c => {
const active = c.id === activeId;
return (
<div key={c.id} className="interactive" onClick={() => onSelect && onSelect(c.id)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 10px', borderRadius: 6,
background: active ? 'var(--accent-tint)' : 'transparent',
color: active ? 'var(--ink-1)' : 'var(--ink-2)',
borderLeft: active ? '2px solid var(--accent)' : '2px solid transparent',
marginLeft: active ? 0 : 2,
fontSize: 12.5,
}}>
<StatusLed status={c.status} size={8} pulse={c.status === 'err'} />
<span className="mono" style={{ fontSize: 11.5, flex: 1 }}>{c.label}</span>
{c.meta && <span className="mono" style={{ fontSize: 10, color: 'var(--ink-3)' }}>{c.meta}</span>}
</div>
);
})}
</div>
)}
</div>
))}
</div>
);
}
/* ============================================================
Sparkline pour les KPI
============================================================ */
function Sparkline({ points = [], color = 'var(--accent)', h = 28 }) {
const w = 100;
const max = Math.max(...points);
const min = Math.min(...points);
const range = max - min || 1;
const step = w / (points.length - 1);
const path = points.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${(i * step).toFixed(1)} ${(h - 2 - ((p - min) / range) * (h - 4)).toFixed(1)}`
).join(' ');
const area = path + ` L ${w} ${h} L 0 ${h} Z`;
return (
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: '100%', height: h }}>
<path d={area} fill={color} opacity="0.12" />
<path d={path} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
</svg>
);
}
/* ============================================================
LineChart — grand graph multi-séries
============================================================ */
function LineChart({ series, h = 200, labels }) {
const w = 600;
const padding = { l: 36, r: 12, t: 12, b: 24 };
const innerW = w - padding.l - padding.r;
const innerH = h - padding.t - padding.b;
const all = series.flatMap(s => s.points);
const max = Math.max(...all) * 1.1;
const min = 0;
const range = max - min;
const ptsCount = series[0].points.length;
const step = innerW / (ptsCount - 1);
return (
<svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" style={{ width: '100%', height: h }}>
{/* grid horizontal */}
{[0, 0.25, 0.5, 0.75, 1].map(p => {
const y = padding.t + innerH * p;
const v = Math.round(max - range * p);
return (
<g key={p}>
<line x1={padding.l} x2={w - padding.r} y1={y} y2={y}
stroke="var(--border-1)" strokeWidth="1" strokeDasharray="3 5" />
<text x={padding.l - 6} y={y + 3} textAnchor="end"
fontFamily="JetBrains Mono" fontSize="9" fill="var(--ink-3)">{v}</text>
</g>
);
})}
{/* labels x */}
{labels && labels.map((lb, i) => (
i % Math.ceil(labels.length / 8) === 0 && (
<text key={i} x={padding.l + i * step} y={h - 6} textAnchor="middle"
fontFamily="JetBrains Mono" fontSize="9" fill="var(--ink-3)">{lb}</text>
)
))}
{/* séries */}
{series.map((s, si) => {
const path = s.points.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${(padding.l + i * step).toFixed(1)} ${(padding.t + innerH - ((p - min) / range) * innerH).toFixed(1)}`
).join(' ');
const area = path + ` L ${padding.l + (ptsCount - 1) * step} ${padding.t + innerH} L ${padding.l} ${padding.t + innerH} Z`;
return (
<g key={si}>
<path d={area} fill={s.color} opacity="0.12" />
<path d={path} fill="none" stroke={s.color} strokeWidth="1.8"
strokeLinejoin="round" strokeLinecap="round" />
</g>
);
})}
</svg>
);
}
/* Expose */
Object.assign(window, {
Icon, Tooltip, IconButton, Toggle, StatusLed,
BatteryGauge, RadialGauge, BigRadialGauge,
Popup, Button, TreeNav, Sparkline, LineChart,
});
/* Effets hover sur les jauges (sans effet au clic) */
(function injectGaugeHoverStyles() {
if (document.getElementById('gauge-hover-styles')) return;
const s = document.createElement('style');
s.id = 'gauge-hover-styles';
s.textContent = `
.bg-hover:hover .bg-bar {
border-color: color-mix(in oklch, var(--accent) 60%, var(--border-3));
}
.bg-hover:hover .bg-fill {
box-shadow: 0 0 14px var(--bg-glow, var(--accent-glow));
filter: brightness(1.15);
}
.gauge-hover { transition: filter .2s; }
.gauge-hover:hover { filter: drop-shadow(0 0 8px var(--accent-glow)) brightness(1.08); }
`;
document.head.appendChild(s);
})();
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
import { createRoot } from "react-dom/client";
import "./styles/app.css";
import { App } from "./App.js";
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+14
View File
@@ -0,0 +1,14 @@
@import "./tokens.css";
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body {
font-family: var(--font-ui);
background: var(--bg-1);
color: var(--ink-1);
}
.su-layout { display: flex; height: 100vh; }
.su-hermes { width: 280px; min-width: 200px; background: var(--bg-2); border-right: 1px solid var(--border-1); padding: 14px; }
.su-center { flex: 1; overflow: auto; padding: 18px; }
.su-terminal { width: 360px; min-width: 320px; background: var(--bg-0); border-left: 1px solid var(--border-1); }
.su-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
+204
View File
@@ -0,0 +1,204 @@
/* ============================================================
ui-tokens.css
Design tokens Gruvbox Seventies — dark (par défaut) + light.
Sombre délavé (pas noir intense) / gris clair usé (pas blanc pur).
============================================================ */
:root,
[data-theme="dark"] {
/* Couches de fond — sombre délavé, brun-gris chaud */
--bg-0: #221c17; /* niveau le plus profond (rare) */
--bg-1: #2a231d; /* fond app */
--bg-2: #322a23; /* panneaux */
--bg-3: #3c332a; /* cartes */
--bg-4: #4a4035; /* hover */
--bg-5: #5a4f43; /* press / actif */
/* Surfaces translucides */
--surf-glass: rgba(50, 42, 35, 0.72);
--surf-glass-strong: rgba(50, 42, 35, 0.92);
--surf-glass-soft: rgba(50, 42, 35, 0.42);
/* Bordures */
--border-1: rgba(168, 153, 132, 0.18);
--border-2: rgba(168, 153, 132, 0.32);
--border-3: rgba(168, 153, 132, 0.55);
/* Texte */
--ink-1: #f2e5c7; /* cream principal */
--ink-2: #d5c4a1; /* secondaire */
--ink-3: #a89984; /* labels / hints */
--ink-4: #7c6f64; /* désactivé */
/* Accent orange seventies */
--accent: #fe8019;
--accent-soft: #d65d0e;
--accent-glow: rgba(254, 128, 25, 0.35);
--accent-tint: rgba(254, 128, 25, 0.12);
/* Statuts */
--ok: #4dbb26;
--ok-glow: rgba(77, 187, 38, 0.45);
--warn: #fabd2f;
--warn-glow: rgba(250, 189, 47, 0.45);
--err: #fb4934;
--err-glow: rgba(251, 73, 52, 0.4);
--info: #83a598;
--info-glow: rgba(131, 165, 152, 0.4);
/* Couleurs additionnelles (datavis, badges, catégories) */
--blue: #3db0d1;
--blue-glow: rgba(61, 176, 209, 0.45);
--purple: #c882c8;
--purple-glow: rgba(200, 130, 200, 0.45);
/* Ombres */
--shadow-1: 0 1px 2px rgba(0,0,0,0.4);
--shadow-2: 0 4px 12px rgba(0,0,0,0.45);
--shadow-3: 0 12px 32px rgba(0,0,0,0.55);
--shadow-press: inset 0 2px 4px rgba(0,0,0,0.5);
/* Relief 3D pour tuiles : highlight haut + ombre bas + ombre portée */
--tile-3d:
inset 0 1px 0 rgba(255, 230, 180, 0.12),
inset 0 -1px 0 rgba(0, 0, 0, 0.45),
0 1px 0 rgba(0, 0, 0, 0.35),
0 2px 4px rgba(0, 0, 0, 0.4),
0 8px 18px rgba(0, 0, 0, 0.5);
--tile-3d-strong:
inset 0 1px 0 rgba(255, 230, 180, 0.18),
inset 0 -2px 0 rgba(0, 0, 0, 0.55),
0 1px 0 rgba(0, 0, 0, 0.4),
0 4px 8px rgba(0, 0, 0, 0.5),
0 14px 28px rgba(0, 0, 0, 0.55);
/* Polices */
--font-ui: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
--font-terminal: 'Share Tech Mono', 'VT323', 'Courier New', monospace;
}
[data-theme="light"] {
/* Gris clair usé, légèrement chaud (pas blanc pur) */
--bg-0: #b8b2a3;
--bg-1: #d5d0c5;
--bg-2: #dcd7cc;
--bg-3: #e3ded3;
--bg-4: #ccc6b8;
--bg-5: #bdb6a7;
--surf-glass: rgba(220, 215, 204, 0.72);
--surf-glass-strong: rgba(220, 215, 204, 0.94);
--surf-glass-soft: rgba(220, 215, 204, 0.42);
--border-1: rgba(60, 56, 54, 0.15);
--border-2: rgba(60, 56, 54, 0.28);
--border-3: rgba(60, 56, 54, 0.5);
--ink-1: #28241f;
--ink-2: #3c3836;
--ink-3: #5a544c;
--ink-4: #8a8278;
--accent: #af3a03;
--accent-soft: #d65d0e;
--accent-glow: rgba(175, 58, 3, 0.28);
--accent-tint: rgba(175, 58, 3, 0.08);
--ok: #3c911c;
--ok-glow: rgba(60, 145, 28, 0.32);
--warn: #b57614;
--warn-glow: rgba(181, 118, 20, 0.35);
--err: #9d0006;
--err-glow: rgba(157, 0, 6, 0.3);
--info: #427b58;
--info-glow: rgba(66, 123, 88, 0.3);
/* Couleurs additionnelles (datavis, badges, catégories) */
--blue: #2d82a3;
--blue-glow: rgba(45, 130, 163, 0.32);
--purple: #8c468c;
--purple-glow: rgba(140, 70, 140, 0.32);
--shadow-1: 0 1px 2px rgba(40,30,20,0.12);
--shadow-2: 0 4px 12px rgba(40,30,20,0.15);
--shadow-3: 0 12px 32px rgba(40,30,20,0.2);
--shadow-press: inset 0 2px 4px rgba(40,30,20,0.18);
/* Relief light : highlight haut blanc cassé + ombre marquée */
--tile-3d:
inset 0 1px 0 rgba(255, 255, 255, 0.55),
inset 0 -1px 0 rgba(60, 50, 40, 0.18),
0 1px 0 rgba(60, 50, 40, 0.1),
0 2px 4px rgba(60, 50, 40, 0.12),
0 8px 18px rgba(60, 50, 40, 0.18);
--tile-3d-strong:
inset 0 1px 0 rgba(255, 255, 255, 0.7),
inset 0 -2px 0 rgba(60, 50, 40, 0.22),
0 1px 0 rgba(60, 50, 40, 0.15),
0 4px 8px rgba(60, 50, 40, 0.18),
0 14px 28px rgba(60, 50, 40, 0.22);
}
/* ============================================================
Reset minimal + base typo
============================================================ */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: var(--font-ui);
font-size: 14px;
color: var(--ink-1);
background: var(--bg-1);
-webkit-font-smoothing: antialiased;
}
.mono { font-family: var(--font-mono); }
.terminal { font-family: var(--font-terminal); letter-spacing: 0.02em; }
.label {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-3);
}
/* ============================================================
Surfaces — relief 3D marqué, AUCUN effet hover
============================================================ */
.glass {
background: var(--surf-glass);
backdrop-filter: blur(12px) saturate(140%);
-webkit-backdrop-filter: blur(12px) saturate(140%);
border: 1px solid var(--border-2);
box-shadow: var(--tile-3d);
}
.glass-strong {
background: var(--surf-glass-strong);
backdrop-filter: blur(16px) saturate(150%);
-webkit-backdrop-filter: blur(16px) saturate(150%);
border: 1px solid var(--border-3);
box-shadow: var(--tile-3d-strong);
}
/* Élément cliquable : pas de hover, mais réelle pression 3D au clic */
.interactive {
cursor: pointer;
transition: transform .04s ease-out, box-shadow .04s, background .04s;
transform: translateY(0);
}
.interactive:active {
transform: translateY(1px);
box-shadow: var(--shadow-press) !important;
filter: brightness(0.92);
}
/* Scrollbar custom */
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
background: var(--border-2);
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover { background: var(--accent-soft); }
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
root: "client",
plugins: [react()],
resolve: { alias: { "@shared": new URL("./shared", import.meta.url).pathname } },
server: { proxy: { "/api": { target: "http://localhost:8787", ws: true } } },
build: { outDir: "../dist/client", emptyOutDir: true },
});