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 { 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). * Lockup horizontal : + "Rubis" (+ optionnel "sur l'ongle" en suffixe italique muted).
* À utiliser dans les headers, le sidebar, les emails. * À 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. * Cf. /docs/marque.md §2 et le pattern de la landing.
*/ */
type BrandProps = { type BrandProps = {
/** Affiche le suffixe "sur l'ongle" en italique muted. */ /** Affiche le suffixe "sur l'ongle" en italique muted. */
withSuffix?: boolean; 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; gemSize?: number;
className?: string; /** Mode "logo seul" : que la gem, sans le wordmark. */
onlyImage?: boolean; 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 ( return (
<span <span
className={cn( className={cn(
@ -25,11 +51,7 @@ export function Brand({ withSuffix = false, onlyImage = false, className }: Bran
className, className,
)} )}
> >
{onlyImage ? ( <Gem size={resolvedSize} />
<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"> <span className="leading-none">
Rubis Rubis
{withSuffix && ( {withSuffix && (
@ -43,8 +65,6 @@ export function Brand({ withSuffix = false, onlyImage = false, className }: Bran
</span> </span>
)} )}
</span> </span>
</>
)}
</span> </span>
); );
} }

View File

@ -2,17 +2,25 @@ import { cn } from "@/lib/utils";
/** /**
* Le gem facetté, signature de la marque. * 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. * Construction : losange divisé en 4 facettes triangulaires qui se rencontrent
* Couleur : `currentColor` hérite du contexte. Default text-rubis. * 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). * Cf. /docs/marque.md §2 (logo direction A) et §5 (icônes spéciales).
*/ */
type GemProps = { type GemProps = {
/** Taille en pixels (carré). Default 22. */ /** Taille en pixels (carré). Default 22. */
size?: number; 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; glow?: boolean;
className?: string; className?: string;
"aria-label"?: string; "aria-label"?: string;
@ -23,7 +31,7 @@ export function Gem({ size = 22, glow = false, className, ...props }: GemProps)
<svg <svg
width={size} width={size}
height={size} height={size}
viewBox="0 0 200 200" viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
className={cn( className={cn(
"text-rubis shrink-0", "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"} aria-hidden={props["aria-label"] ? undefined : "true"}
{...props} {...props}
> >
<polygon points="100,10 190,100 100,190 10,100" fill="currentColor" /> {/* Facette haut-gauche — la plus claire (côté lumière). */}
{/* Table du gem */} <polygon
<line x1="10" y1="100" x2="190" y2="100" stroke="rgba(255,255,255,0.55)" strokeWidth="3" /> points="50,6 6,50 50,50"
{/* Facettes hautes */} fill="currentColor"
<line x1="55" y1="55" x2="100" y2="100" stroke="rgba(255,255,255,0.4)" strokeWidth="2" /> fillOpacity="1"
<line x1="145" y1="55" x2="100" y2="100" stroke="rgba(255,255,255,0.4)" strokeWidth="2" /> />
{/* 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> </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="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"> <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>
<div className="flex-1 min-w-0"> <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 { Link } from "@tanstack/react-router";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { import {
ChevronsLeft,
ChevronsRight,
LayoutDashboard, LayoutDashboard,
FileText, FileText,
ListChecks, ListChecks,
@ -12,36 +16,106 @@ import { Brand } from "@/components/brand/Brand";
import { Gem } from "@/components/brand/Gem"; import { Gem } from "@/components/brand/Gem";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import { formatRubisToHours } from "@/lib/format"; import { formatRubisToHours } from "@/lib/format";
import { cn } from "@/lib/utils";
import { NavLink } from "./NavLink"; import { NavLink } from "./NavLink";
const STORAGE_KEY = "rubis.sidebar.collapsed";
/** /**
* Sidebar desktop 240px wide, sticky. * Sidebar desktop repliable.
* - Brand en haut
* - Nav verticale au centre
* - Compteur rubis en bas (gratification permanente, cf. wireframe 4.1)
* *
* Le compteur rubis dans le sidebar n'est pas négociable : c'est ce qui * - Déployée (240px) : brand + nav avec labels + compteur rubis en bas
* fait dire au user "putain j'ai gagné 24h ce mois". * - 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 }) { export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number }) {
const { user: _user } = useAuth(); 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 ( 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"> <aside
<Link to="/" className="mb-10 px-2"> className={cn(
<Brand /> "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> </Link>
<nav className="flex flex-col gap-1"> <nav className="flex flex-col gap-1">
<NavLink to="/" icon={<LayoutDashboard size={17} />} label="Dashboard" /> <NavLink
<NavLink to="/factures" icon={<FileText size={17} />} label="Factures" /> to="/"
<NavLink to="/plans" icon={<ListChecks size={17} />} label="Plans de relance" /> icon={<LayoutDashboard size={17} />}
<NavLink to="/clients" icon={<Users size={17} />} label="Clients" /> label="Dashboard"
<NavLink to="/insights" icon={<TrendingUp size={17} />} label="Insights" /> collapsed={collapsed}
<NavLink to="/parametres" icon={<Settings size={17} />} label="Paramètres" /> />
<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> </nav>
<div className="mt-auto"> <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"> <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"> <p className="text-[10.5px] font-semibold uppercase tracking-[0.12em] text-ink-3">
Rubis ce mois Rubis ce mois
@ -56,7 +130,69 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number })
{formatRubisToHours(rubisThisMonth)} libérées {formatRubisToHours(rubisThisMonth)} libérées
</p> </p>
</div> </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> </div>
</aside> </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 { Link, type LinkProps } from "@tanstack/react-router";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** /**
* NavLink item de nav du sidebar / tab bar. * NavLink item de nav du sidebar / tab bar.
*
* État actif : bord gauche rubis (desktop) ou label rubis (tab bar). * État actif : bord gauche rubis (desktop) ou label rubis (tab bar).
* Pas de bg plein quand inactif, le sidebar reste calme. * 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"; type Variant = "sidebar" | "tab-bar";
@ -12,20 +18,37 @@ type NavLinkProps = Omit<LinkProps, "children"> & {
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
variant?: Variant; variant?: Variant;
/** Sidebar uniquement : repliée → icône seule + tooltip. */
collapsed?: boolean;
className?: string; 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"; const isSidebar = variant === "sidebar";
return ( const isCollapsed = isSidebar && collapsed;
const link = (
<Link <Link
{...linkProps} {...linkProps}
activeOptions={{ exact: linkProps.to === "/" }} activeOptions={{ exact: linkProps.to === "/" }}
aria-label={isCollapsed ? label : undefined}
className={cn( className={cn(
"group relative flex items-center transition-colors duration-150", "group relative flex items-center transition-colors duration-150",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
isSidebar 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", : "flex-1 flex-col gap-0.5 py-2 text-[10.5px] font-semibold text-ink-3 hover:text-ink",
className, className,
)} )}
@ -35,7 +58,7 @@ export function NavLink({ icon, label, variant = "sidebar", className, ...linkPr
: "text-rubis", : "text-rubis",
}} }}
> >
{/* Marqueur rubis vertical sur le sidebar quand actif */} {/* Marqueur rubis vertical (sidebar actif) — préservé en mode replié. */}
{isSidebar && ( {isSidebar && (
<span <span
aria-hidden="true" aria-hidden="true"
@ -49,12 +72,41 @@ export function NavLink({ icon, label, variant = "sidebar", className, ...linkPr
<span <span
className={cn( className={cn(
"shrink-0", "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} {icon}
</span> </span>
{!isCollapsed && (
<span className={cn(isSidebar ? "" : "leading-none")}>{label}</span> <span className={cn(isSidebar ? "" : "leading-none")}>{label}</span>
)}
</Link> </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>
);
} }