feat: scaffolding client Vite/React + design system Gruvbox
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||||
@@ -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);
|
||||||
|
})();
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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; }
|
||||||
@@ -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); }
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user