feat(layout): sidebar repliable + Gem SVG soignée partout
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 21s

Sidebar : nouveau bouton chevron en bas qui replie/déplie (240px ↔ 68px).
Choix persisté en localStorage (`rubis.sidebar.collapsed`). En mode replié :
  - Brand → gem seule (28px)
  - NavLinks → icône centrée + tooltip Radix au hover qui montre le label
  - Compteur rubis → version ultra-compacte (gem 16px + chiffre empilés),
    tooltip réaffiche "Rubis ce mois · ≈ Xh libérées"
  - Marqueur rubis vertical de l'item actif préservé

Gem : refonte du SVG. 4 facettes triangulaires se rejoignent au centre,
chacune avec une opacité différente (1.0 / 0.8 / 0.65 / 0.48) pour suggérer
le jeu de lumière d'une pierre taillée — sans gradient, qui devient pâteux
à petite taille. Contour propre `strokeLinejoin: round` pour la silhouette.

Drop `logo.png` : `<Brand/>` utilise maintenant la Gem SVG en interne. Plus
aucune dépendance à l'asset PNG bordé/arrondi qui rendait flou aux grosses
tailles. Toutes les surfaces (sidebar, RubisHero, compteur) partagent la
même icône scalable héritant de `currentColor`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-07 13:32:46 +02:00
parent b96b62aab6
commit 639191bef9
6 changed files with 312 additions and 70 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 846 KiB

View File

@ -1,22 +1,48 @@
import { cn } from "@/lib/utils";
import logo from "@/assets/logo.png";
import { Gem } from "./Gem";
/**
* Lockup horizontal : + "Rubis" (+ optionnel "sur l'ongle" en suffixe italique muted).
* À utiliser dans les headers, le sidebar, les emails.
*
* Architecture : on s'appuie sur le composant <Gem/> SVG pas de PNG, pas
* de border externe. La pierre EST le logo. Plus rien ne casse à l'export
* en taille variable, et le rendu reste identique du SPA aux PDFs aux
* favicons (si on génère).
*
* Cf. /docs/marque.md §2 et le pattern de la landing.
*/
type BrandProps = {
/** Affiche le suffixe "sur l'ongle" en italique muted. */
withSuffix?: boolean;
/** Taille du gem (le wordmark s'aligne dessus). */
/**
* Taille de la gem en pixels. Default 22 (lockup) ou 32 (onlyImage).
* Pour les usages héros : passer 56-72.
*/
gemSize?: number;
className?: string;
/** Mode "logo seul" : que la gem, sans le wordmark. */
onlyImage?: boolean;
className?: string;
};
export function Brand({ withSuffix = false, onlyImage = false, className }: BrandProps) {
export function Brand({
withSuffix = false,
onlyImage = false,
gemSize,
className,
}: BrandProps) {
const resolvedSize = gemSize ?? (onlyImage ? 32 : 22);
if (onlyImage) {
return (
<Gem
size={resolvedSize}
className={className}
aria-label="Rubis"
/>
);
}
return (
<span
className={cn(
@ -25,26 +51,20 @@ export function Brand({ withSuffix = false, onlyImage = false, className }: Bran
className,
)}
>
{onlyImage ? (
<img src={logo} alt="Logo" className="w-10 h-10 border-2 border-rubis rounded-xl" />
) : (
<>
<img src={logo} alt="Logo" className="w-8 h-8 border-2 border-rubis rounded-xl" />
<span className="leading-none">
Rubis
{withSuffix && (
<span
className={cn(
"ml-1 font-display italic font-medium text-ink-3",
"text-[12.5px] tracking-[-0.005em]",
)}
>
sur l&apos;ongle
</span>
<Gem size={resolvedSize} />
<span className="leading-none">
Rubis
{withSuffix && (
<span
className={cn(
"ml-1 font-display italic font-medium text-ink-3",
"text-[12.5px] tracking-[-0.005em]",
)}
>
sur l&apos;ongle
</span>
</>
)}
)}
</span>
</span>
);
}

View File

@ -2,17 +2,25 @@ import { cn } from "@/lib/utils";
/**
* Le gem facetté, signature de la marque.
* SVG inline (pas une icône Lucide, jamais).
*
* 4 facettes suggérées + ligne médiane "table" du gem.
* Couleur : `currentColor` hérite du contexte. Default text-rubis.
* Construction : losange divisé en 4 facettes triangulaires qui se rencontrent
* au centre. Chacune a une opacité différente pour suggérer le jeu de lumière
* d'une pierre taillée, sans gradient (qui rend pâteux à petite taille).
*
* - facette haut-gauche : 100% (lumière)
* - facette haut-droite : 80%
* - facette bas-gauche : 65%
* - facette bas-droite : 48% (zone d'ombre)
*
* Plus un fin contour qui définit le pourtour. Le tout en `currentColor`
* hérite du parent (default rubis).
*
* Cf. /docs/marque.md §2 (logo direction A) et §5 (icônes spéciales).
*/
type GemProps = {
/** Taille en pixels (carré). Default 22. */
size?: number;
/** Si true, applique un drop-shadow doux rubis pour les héros. */
/** Drop-shadow doux rubis — pour les usages héros uniquement. */
glow?: boolean;
className?: string;
"aria-label"?: string;
@ -23,7 +31,7 @@ export function Gem({ size = 22, glow = false, className, ...props }: GemProps)
<svg
width={size}
height={size}
viewBox="0 0 200 200"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
className={cn(
"text-rubis shrink-0",
@ -34,12 +42,38 @@ export function Gem({ size = 22, glow = false, className, ...props }: GemProps)
aria-hidden={props["aria-label"] ? undefined : "true"}
{...props}
>
<polygon points="100,10 190,100 100,190 10,100" fill="currentColor" />
{/* Table du gem */}
<line x1="10" y1="100" x2="190" y2="100" stroke="rgba(255,255,255,0.55)" strokeWidth="3" />
{/* Facettes hautes */}
<line x1="55" y1="55" x2="100" y2="100" stroke="rgba(255,255,255,0.4)" strokeWidth="2" />
<line x1="145" y1="55" x2="100" y2="100" stroke="rgba(255,255,255,0.4)" strokeWidth="2" />
{/* Facette haut-gauche — la plus claire (côté lumière). */}
<polygon
points="50,6 6,50 50,50"
fill="currentColor"
fillOpacity="1"
/>
{/* Facette haut-droite */}
<polygon
points="50,6 94,50 50,50"
fill="currentColor"
fillOpacity="0.8"
/>
{/* Facette bas-gauche */}
<polygon
points="6,50 50,50 50,94"
fill="currentColor"
fillOpacity="0.65"
/>
{/* Facette bas-droite — la plus sombre (côté ombre). */}
<polygon
points="50,50 94,50 50,94"
fill="currentColor"
fillOpacity="0.48"
/>
{/* Contour propre — épaisseur fixe pour rester lisible à 14px. */}
<polygon
points="50,6 94,50 50,94 6,50"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@ -46,7 +46,7 @@ export function RubisHero({
<div className="relative z-10 flex flex-col gap-6 sm:flex-row sm:items-center sm:gap-8">
<div className="flex shrink-0 items-center gap-4 sm:flex-col sm:items-center sm:gap-1">
<Brand onlyImage={true} />
<Brand onlyImage gemSize={64} />
</div>
<div className="flex-1 min-w-0">

View File

@ -1,5 +1,9 @@
import { useEffect, useState } from "react";
import { Link } from "@tanstack/react-router";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import {
ChevronsLeft,
ChevronsRight,
LayoutDashboard,
FileText,
ListChecks,
@ -12,51 +16,183 @@ import { Brand } from "@/components/brand/Brand";
import { Gem } from "@/components/brand/Gem";
import { useAuth } from "@/lib/auth";
import { formatRubisToHours } from "@/lib/format";
import { cn } from "@/lib/utils";
import { NavLink } from "./NavLink";
const STORAGE_KEY = "rubis.sidebar.collapsed";
/**
* Sidebar desktop 240px wide, sticky.
* - Brand en haut
* - Nav verticale au centre
* - Compteur rubis en bas (gratification permanente, cf. wireframe 4.1)
* Sidebar desktop repliable.
*
* Le compteur rubis dans le sidebar n'est pas négociable : c'est ce qui
* fait dire au user "putain j'ai gagné 24h ce mois".
* - Déployée (240px) : brand + nav avec labels + compteur rubis en bas
* - Repliée (64px) : juste les icônes (tooltip au hover) + gem du brand
*
* Le choix est persisté en localStorage (`rubis.sidebar.collapsed`) pour
* survivre aux reloads. Toggle via un petit bouton chevron en bas.
*
* Le compteur rubis disparaît en mode replié (l'écran est trop étroit pour
* en faire quelque chose de lisible) on le retrouve en dépliant. La gem
* en haut reste, donc l'identité de marque ne disparaît jamais.
*/
export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number }) {
const { user: _user } = useAuth();
const [collapsed, setCollapsed] = useState(false);
// Lecture localStorage à l'init (côté client uniquement).
useEffect(() => {
if (typeof window === "undefined") return;
setCollapsed(window.localStorage.getItem(STORAGE_KEY) === "1");
}, []);
const toggle = () => {
setCollapsed((prev) => {
const next = !prev;
if (typeof window !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, next ? "1" : "0");
}
return next;
});
};
return (
<aside className="hidden lg:flex h-screen sticky top-0 w-[240px] shrink-0 flex-col border-r border-line bg-cream-2/60 px-4 py-6">
<Link to="/" className="mb-10 px-2">
<Brand />
<aside
className={cn(
"hidden lg:flex h-screen sticky top-0 shrink-0 flex-col border-r border-line bg-cream-2/60 py-6",
"transition-[width,padding] duration-200 ease-out",
collapsed ? "w-[68px] px-2" : "w-[240px] px-4",
)}
>
<Link
to="/"
className={cn(
"mb-10 flex items-center justify-center",
collapsed ? "px-0" : "px-2 justify-start",
)}
aria-label="Accueil Rubis"
>
{collapsed ? <Brand onlyImage gemSize={28} /> : <Brand />}
</Link>
<nav className="flex flex-col gap-1">
<NavLink to="/" icon={<LayoutDashboard size={17} />} label="Dashboard" />
<NavLink to="/factures" icon={<FileText size={17} />} label="Factures" />
<NavLink to="/plans" icon={<ListChecks size={17} />} label="Plans de relance" />
<NavLink to="/clients" icon={<Users size={17} />} label="Clients" />
<NavLink to="/insights" icon={<TrendingUp size={17} />} label="Insights" />
<NavLink to="/parametres" icon={<Settings size={17} />} label="Paramètres" />
<NavLink
to="/"
icon={<LayoutDashboard size={17} />}
label="Dashboard"
collapsed={collapsed}
/>
<NavLink
to="/factures"
icon={<FileText size={17} />}
label="Factures"
collapsed={collapsed}
/>
<NavLink
to="/plans"
icon={<ListChecks size={17} />}
label="Plans de relance"
collapsed={collapsed}
/>
<NavLink
to="/clients"
icon={<Users size={17} />}
label="Clients"
collapsed={collapsed}
/>
<NavLink
to="/insights"
icon={<TrendingUp size={17} />}
label="Insights"
collapsed={collapsed}
/>
<NavLink
to="/parametres"
icon={<Settings size={17} />}
label="Paramètres"
collapsed={collapsed}
/>
</nav>
<div className="mt-auto">
<div className="rounded-soft border border-line bg-white px-3.5 py-3">
<p className="text-[10.5px] font-semibold uppercase tracking-[0.12em] text-ink-3">
Rubis ce mois
</p>
<div className="mt-1.5 flex items-end gap-2">
<Gem size={18} />
<span className="font-display text-[22px] font-bold leading-none tabular-nums">
{rubisThisMonth}
</span>
<div className="mt-auto flex flex-col gap-3">
{collapsed ? (
<RubisCounterCompact value={rubisThisMonth} />
) : (
<div className="rounded-soft border border-line bg-white px-3.5 py-3">
<p className="text-[10.5px] font-semibold uppercase tracking-[0.12em] text-ink-3">
Rubis ce mois
</p>
<div className="mt-1.5 flex items-end gap-2">
<Gem size={18} />
<span className="font-display text-[22px] font-bold leading-none tabular-nums">
{rubisThisMonth}
</span>
</div>
<p className="mt-1 text-[11px] text-ink-3">
{formatRubisToHours(rubisThisMonth)} libérées
</p>
</div>
<p className="mt-1 text-[11px] text-ink-3">
{formatRubisToHours(rubisThisMonth)} libérées
</p>
</div>
)}
{/* Toggle replier / déplier */}
<button
type="button"
onClick={toggle}
aria-label={collapsed ? "Déplier la sidebar" : "Replier la sidebar"}
title={collapsed ? "Déplier" : "Replier"}
className={cn(
"flex h-8 items-center justify-center rounded-default border border-line bg-white text-ink-3 cursor-pointer",
"transition-colors hover:text-rubis hover:border-rubis",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
)}
>
{collapsed ? (
<ChevronsRight size={14} aria-hidden="true" />
) : (
<ChevronsLeft size={14} aria-hidden="true" />
)}
</button>
</div>
</aside>
);
}
/**
* Compteur rubis ultra-compact pour le mode replié gem + chiffre empilés
* verticalement, tooltip au hover qui rappelle "Rubis ce mois · ≈ Xh libérées".
*/
function RubisCounterCompact({ value }: { value: number }) {
return (
<TooltipPrimitive.Provider delayDuration={200}>
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger asChild>
<div
className={cn(
"flex flex-col items-center justify-center gap-0.5 rounded-soft border border-line bg-white py-2",
"cursor-default",
)}
role="status"
aria-label={`${value} rubis ce mois, soit ${formatRubisToHours(value)} libérées`}
>
<Gem size={16} />
<span className="font-display text-[15px] font-bold leading-none tabular-nums text-ink">
{value}
</span>
</div>
</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
side="right"
sideOffset={8}
className={cn(
"z-50 rounded-default bg-ink px-2.5 py-1.5 text-[12px] font-medium text-white shadow-soft",
"data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95",
)}
>
<span className="font-semibold">Rubis ce mois</span>
<span className="text-ink-3"> · {formatRubisToHours(value)} libérées</span>
<TooltipPrimitive.Arrow className="fill-ink" width={8} height={4} />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
</TooltipPrimitive.Provider>
);
}

View File

@ -1,10 +1,16 @@
import { Link, type LinkProps } from "@tanstack/react-router";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
/**
* NavLink item de nav du sidebar / tab bar.
*
* État actif : bord gauche rubis (desktop) ou label rubis (tab bar).
* Pas de bg plein quand inactif, le sidebar reste calme.
*
* En mode `collapsed` (sidebar repliée), seul l'icône est rendue le
* label devient un tooltip qui apparaît au hover. Garde l'identité de
* navigation tout en récupérant ~170px de surface utile.
*/
type Variant = "sidebar" | "tab-bar";
@ -12,20 +18,37 @@ type NavLinkProps = Omit<LinkProps, "children"> & {
icon: React.ReactNode;
label: string;
variant?: Variant;
/** Sidebar uniquement : repliée → icône seule + tooltip. */
collapsed?: boolean;
className?: string;
};
export function NavLink({ icon, label, variant = "sidebar", className, ...linkProps }: NavLinkProps) {
export function NavLink({
icon,
label,
variant = "sidebar",
collapsed = false,
className,
...linkProps
}: NavLinkProps) {
const isSidebar = variant === "sidebar";
return (
const isCollapsed = isSidebar && collapsed;
const link = (
<Link
{...linkProps}
activeOptions={{ exact: linkProps.to === "/" }}
aria-label={isCollapsed ? label : undefined}
className={cn(
"group relative flex items-center transition-colors duration-150",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
isSidebar
? "gap-3 rounded-default px-3 py-2.5 text-[14px] font-medium text-ink-2 hover:text-ink hover:bg-cream"
? cn(
"rounded-default text-[14px] font-medium text-ink-2 hover:text-ink hover:bg-cream",
isCollapsed
? "justify-center py-2.5"
: "gap-3 px-3 py-2.5",
)
: "flex-1 flex-col gap-0.5 py-2 text-[10.5px] font-semibold text-ink-3 hover:text-ink",
className,
)}
@ -35,7 +58,7 @@ export function NavLink({ icon, label, variant = "sidebar", className, ...linkPr
: "text-rubis",
}}
>
{/* Marqueur rubis vertical sur le sidebar quand actif */}
{/* Marqueur rubis vertical (sidebar actif) — préservé en mode replié. */}
{isSidebar && (
<span
aria-hidden="true"
@ -49,12 +72,41 @@ export function NavLink({ icon, label, variant = "sidebar", className, ...linkPr
<span
className={cn(
"shrink-0",
isSidebar ? "text-ink-3 group-aria-[current=page]:text-rubis" : "text-current",
isSidebar
? "text-ink-3 group-aria-[current=page]:text-rubis"
: "text-current",
)}
>
{icon}
</span>
<span className={cn(isSidebar ? "" : "leading-none")}>{label}</span>
{!isCollapsed && (
<span className={cn(isSidebar ? "" : "leading-none")}>{label}</span>
)}
</Link>
);
// En mode replié, on wrappe dans un Tooltip Radix qui montre le label
// côté droit au hover. En déployé : pas de tooltip (label déjà visible).
if (!isCollapsed) return link;
return (
<TooltipPrimitive.Provider delayDuration={200}>
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger asChild>{link}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
side="right"
sideOffset={8}
className={cn(
"z-50 rounded-default bg-ink px-2.5 py-1.5 text-[12px] font-medium text-white shadow-soft",
"data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95",
)}
>
{label}
<TooltipPrimitive.Arrow className="fill-ink" width={8} height={4} />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
</TooltipPrimitive.Provider>
);
}