Files
flowdeck/images/Guide-complet-de-d-veloppement-du-panneau-lat-ral-de-navigat.html
T
bruno c8c08f5589
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
Add comprehensive Notion sidebar development guide
2026-07-09 11:30:05 -04:00

1692 lines
90 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Guide complet de développement du panneau latéral de navigation Notion</title>
<meta name="description" content="Difficulté : Avancé
Temps de développement estimé : 40 à 60 heures selon le stack et les intégrations
Prérequis techniques : React ou framework moderne, ges">
<meta property="og:title" content="Guide complet de développement du panneau latéral de navigation Notion">
<meta property="og:description" content="Difficulté : Avancé
Temps de développement estimé : 40 à 60 heures selon le stack et les intégrations
Prérequis techniques : React ou framework moderne, ges">
<meta property="og:type" content="article">
<meta property="og:image" content="https://www.notion.comhttps://img.youtube.com/vi/lSmgY5OsZmU/maxresdefault.jpg">
<meta name="theme-color" content="#b8543a" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#131214" media="(prefers-color-scheme: dark)">
<link rel="icon" href="data:image/svg+xml,&lt;svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'&gt;&lt;text y='75' font-size='75'&gt;O&lt;/text&gt;&lt;/svg&gt;">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--font-display: 'Charter', 'Iowan Old Style', Georgia, serif;
--font-body: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--bg: #fbf9f4;
--bg-surface: #ffffff;
--bg-surface-alt: #f1ede4;
--border: rgba(0,0,0,0.08);
--border-strong: rgba(0,0,0,0.16);
--text: #1a1817;
--text-dim: #5a5651;
--text-muted: #8a8580;
--accent: #b8543a;
--accent-light: #d97a5e;
--accent-bg: rgba(184,84,58,0.06);
--gold: #c9952e;
--gold-bg: rgba(201,149,46,0.09);
--aurora-a: rgba(184,84,58,0.10);
--aurora-b: rgba(201,149,46,0.08);
--aurora-c: rgba(64,98,128,0.07);
--radius: 12px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.05);
--shadow-md: 0 4px 24px rgba(0,0,0,0.07);
--max-w: 760px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #131214; --bg-surface: #1c1a1e; --bg-surface-alt: #25232a;
--border: rgba(255,255,255,0.07); --border-strong: rgba(255,255,255,0.16);
--text: #ece8e2; --text-dim: #a8a39c; --text-muted: #6f6b66;
--accent: #e88f73; --accent-light: #f4ad95; --accent-bg: rgba(232,143,115,0.09);
--gold: #e8c05a; --gold-bg: rgba(232,192,90,0.09);
--aurora-a: rgba(232,143,115,0.13);
--aurora-b: rgba(232,192,90,0.09);
--aurora-c: rgba(125,180,224,0.10);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.4); --shadow-md: 0 4px 28px rgba(0,0,0,0.55);
}
}
html {
scroll-behavior: smooth;
/* Give smooth-scroll some breathing room so anchors land below the
fixed toolbar instead of being shoved straight under it. */
scroll-padding-top: 4rem;
}
body {
font-family: var(--font-body);
background: var(--bg);
color: var(--text);
line-height: 1.75;
font-size: 17px;
font-feature-settings: 'ss01', 'cv11';
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
position: relative;
min-height: 100vh;
}
/* ── Aurora background ─────────────────────────────────
Slowly-drifting layered blobs in the accent palette. Sits behind
the content, fixed to the viewport so scrolling doesn't reset the
composition. Subtle grain on top stops it reading as 'flat CSS'. */
body::before {
content: '';
position: fixed;
inset: -20vh -20vw;
z-index: -2;
background:
radial-gradient(40vw 50vh at 18% 22%, var(--aurora-a) 0%, transparent 60%),
radial-gradient(45vw 55vh at 82% 12%, var(--aurora-b) 0%, transparent 65%),
radial-gradient(55vw 60vh at 50% 88%, var(--aurora-c) 0%, transparent 70%);
filter: blur(20px);
animation: aurora-drift 28s ease-in-out infinite alternate;
pointer-events: none;
}
body::after {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
/* Subtle film-grain — SVG turbulence baked to a data-URL. */
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.32 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
opacity: 0.045;
mix-blend-mode: overlay;
}
@keyframes aurora-drift {
0% { transform: translate3d(0,0,0) scale(1); }
50% { transform: translate3d(2vw,-1vh,0) scale(1.04); }
100% { transform: translate3d(-1vw,1.5vh,0) scale(1.02); }
}
@media (prefers-reduced-motion: reduce) {
body::before { animation: none; }
}
/* ── Toolbar (top-right) ──────────────────────────── */
.toolbar {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 100;
display: flex;
gap: 0.4rem;
opacity: 0.7;
transition: opacity 0.2s;
}
.toolbar:hover { opacity: 1; }
.toolbar button {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 14px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--bg-surface);
color: var(--text);
font-family: inherit;
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
box-shadow: var(--shadow-sm);
transition: background 0.15s;
position: relative;
}
.toolbar button:hover { background: var(--bg-surface-alt); }
.toolbar button svg { width: 14px; height: 14px; flex-shrink: 0; }
.toolbar .toast {
position: absolute;
top: calc(100% + 6px);
right: 0;
background: var(--text);
color: var(--bg);
padding: 4px 10px;
border-radius: 6px;
font-size: 0.72rem;
white-space: nowrap;
opacity: 0;
transition: opacity 0.15s;
pointer-events: none;
}
.toolbar .toast.show { opacity: 1; }
.dropdown { position: relative; }
.dropdown-menu {
display: none;
position: absolute;
top: calc(100% + 4px);
right: 0;
background: var(--bg-surface);
border: 1px solid var(--border-strong);
border-radius: 8px;
box-shadow: var(--shadow-md);
overflow: hidden;
min-width: 140px;
}
.dropdown-menu.open { display: block; }
.dropdown-menu button {
display: block;
width: 100%;
padding: 8px 14px;
border: none;
background: none;
color: var(--text);
font-family: inherit;
font-size: 0.8rem;
text-align: left;
cursor: pointer;
}
.dropdown-menu button:hover { background: var(--bg-surface-alt); }
/* ── Hero ──────────────────────────────────────────── */
.hero {
position: relative;
background: transparent;
color: var(--text);
padding: 5.5rem 2rem 2.5rem;
text-align: center;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 70% 60% at 50% 40%, color-mix(in srgb, var(--accent) 10%, transparent) 0%, transparent 70%);
pointer-events: none;
}
/* A hair-thin gradient hairline divider under the hero to anchor it
without putting it on a heavy boxed background. */
.hero::after {
content: '';
position: absolute;
left: 50%; bottom: 0;
width: min(60%, 320px);
height: 1px;
transform: translateX(-50%);
background: linear-gradient(90deg, transparent, var(--border-strong), transparent);
}
.hero-label {
position: relative;
text-transform: uppercase;
letter-spacing: 0.28em;
font-size: 0.68rem;
font-weight: 600;
color: var(--accent);
opacity: 0.85;
margin-bottom: 1.4rem;
font-family: var(--font-body);
}
.hero h1 {
position: relative;
font-family: var(--font-display);
font-size: clamp(2rem, 4.5vw, 3rem);
font-weight: 600;
font-variation-settings: 'opsz' 120, 'SOFT' 50;
line-height: 1.15;
max-width: 720px;
margin: 0 auto;
letter-spacing: -0.02em;
color: var(--text);
}
/* ── Hero image ───────────────────────────────────── */
.hero-image {
max-width: var(--max-w);
margin: -2rem auto 0;
position: relative;
z-index: 1;
padding: 0 2rem;
}
.hero-image img {
width: 100%;
max-height: 360px;
object-fit: cover;
border-radius: var(--radius);
box-shadow: var(--shadow-md);
display: block;
}
/* ── Section images ───────────────────────────────── */
.section-image {
margin: 1.5rem 0;
position: relative;
}
.section-image img {
width: 100%;
max-height: 300px;
object-fit: cover;
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
display: block;
}
/* ── Per-image hide button ────────────────────────────
A small X that appears on hover (or always on touch) so users can
remove irrelevant OG images. Click POSTs the URL to the backend so
the next render skips it. */
.img-hide-btn {
position: absolute;
top: 10px; right: 10px;
width: 28px; height: 28px;
display: inline-flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.55);
color: #fff;
border: none;
border-radius: 50%;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease, background 0.15s ease, transform 0.05s ease;
z-index: 2;
padding: 0;
}
.hero-image .img-hide-btn { top: 14px; right: 2.5rem; }
/* Reroll sits just to the left of the hide button. */
.img-reroll-btn {
position: absolute;
top: 10px; right: 46px;
width: 28px; height: 28px;
display: inline-flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.55);
color: #fff;
border: none;
border-radius: 50%;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease, background 0.15s ease, transform 0.05s ease;
z-index: 2;
padding: 0;
}
.hero-image .img-reroll-btn { top: 14px; right: calc(2.5rem + 36px); }
.section-image:hover .img-hide-btn,
.section-image:hover .img-reroll-btn,
.hero-image:hover .img-hide-btn,
.hero-image:hover .img-reroll-btn { opacity: 1; }
.img-hide-btn:hover { background: var(--accent); }
.img-reroll-btn:hover { background: var(--accent); }
.img-hide-btn:active,
.img-reroll-btn:active { transform: scale(0.92); }
.img-reroll-btn.spinning svg { animation: img-reroll-spin 0.6s linear infinite; }
.img-reroll-btn:disabled { display: none; }
@keyframes img-reroll-spin { to { transform: rotate(360deg); } }
@media (hover: none) {
/* Touch devices have no hover — show the buttons at low opacity always. */
.img-hide-btn, .img-reroll-btn { opacity: 0.7; }
}
.section-image.fading,
.hero-image.fading {
opacity: 0;
transform: scale(0.96);
transition: opacity 0.25s ease, transform 0.25s ease;
}
/* ── Stats bar ─────────────────────────────────────── */
.stats-bar {
display: flex;
justify-content: center;
gap: 1.5rem;
flex-wrap: wrap;
padding: 0.9rem 2rem;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
font-size: 0.82rem;
color: var(--text-dim);
}
.stat { display: flex; align-items: center; gap: 0.35rem; }
.stat-value { font-weight: 600; color: var(--text); }
/* ── Layout ────────────────────────────────────────── */
.layout {
display: grid;
grid-template-columns: 200px 1fr;
max-width: calc(var(--max-w) + 260px);
margin: 0 auto;
}
@media (max-width: 900px) {
.layout { grid-template-columns: 1fr; }
.toc-sidebar { display: none; }
}
/* ── TOC sidebar ───────────────────────────────────── */
.toc-sidebar {
position: sticky; top: 0; height: 100vh; overflow-y: auto;
padding: 3.2rem 0.8rem 2rem 1.4rem;
border-right: 1px solid var(--border);
font-size: 0.78rem;
}
.toc-sidebar nav { position: relative; }
.toc-sidebar nav a {
position: relative;
display: block;
color: var(--text-dim);
text-decoration: none;
padding: 0.42rem 0.7rem 0.42rem 0.85rem;
margin: 1px 0;
border-radius: 6px;
line-height: 1.4;
letter-spacing: -0.005em;
transition: color 0.18s ease, background 0.18s ease, padding-left 0.18s ease;
}
/* Sliding accent indicator on the left edge of each TOC link */
.toc-sidebar nav a::before {
content: '';
position: absolute;
left: 0; top: 50%;
width: 2px; height: 0;
background: var(--accent);
transform: translateY(-50%);
border-radius: 1px;
transition: height 0.18s ease, opacity 0.18s ease;
opacity: 0;
}
.toc-sidebar nav a:hover {
color: var(--text);
background: var(--accent-bg);
padding-left: 1rem;
}
.toc-sidebar nav a:hover::before {
height: 60%;
opacity: 1;
}
.toc-sidebar nav a.active {
color: var(--accent);
font-weight: 600;
background: var(--accent-bg);
}
.toc-sidebar nav a.active::before {
height: 80%;
opacity: 1;
}
.toc-sidebar nav a.depth-3 {
padding-left: 1.3rem;
font-size: 0.72rem;
color: var(--text-muted);
}
.toc-sidebar nav a.depth-3:hover { padding-left: 1.45rem; }
/* ── Content ───────────────────────────────────────── */
.content { max-width: var(--max-w); padding: 3rem 2.5rem 4rem; }
/* Display headings — Fraunces optical-size driven so they get more
contrast and personality at the larger end. */
.content h2 {
font-family: var(--font-display);
font-size: clamp(1.55rem, 2.4vw, 1.85rem);
font-weight: 600;
font-variation-settings: 'opsz' 96, 'SOFT' 50;
margin: 3rem 0 1rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid transparent;
border-image: linear-gradient(90deg, var(--accent) 0%, transparent 65%) 1;
letter-spacing: -0.022em;
line-height: 1.2;
color: var(--text);
}
.content h2:first-child { margin-top: 0; }
.content h3 {
font-family: var(--font-display);
font-size: 1.22rem;
font-weight: 600;
font-variation-settings: 'opsz' 32;
margin: 2.2rem 0 0.6rem;
letter-spacing: -0.015em;
color: var(--text);
}
.content h4 {
font-family: var(--font-body);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-dim);
margin: 1.6rem 0 0.5rem;
}
.content p { margin-bottom: 1.1rem; hanging-punctuation: first last; }
/* Drop cap on the very first paragraph of the body — old-school editorial
touch that anchors the reader. */
.content > p:first-of-type::first-letter,
.content > h2:first-child + p::first-letter {
font-family: var(--font-display);
font-weight: 700;
font-variation-settings: 'opsz' 144;
font-size: 3.6em;
line-height: 0.85;
float: left;
margin: 0.15em 0.12em 0 -0.04em;
color: var(--accent);
}
.content a {
color: var(--accent);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--accent) 35%, transparent);
text-decoration-thickness: 1.5px;
text-underline-offset: 3px;
transition: text-decoration-color 0.15s, color 0.15s;
}
.content a:hover {
text-decoration-color: var(--accent);
color: var(--accent-light);
}
.content ul, .content ol { margin: 0 0 1.1rem 1.6rem; }
.content li { margin-bottom: 0.4rem; }
.content li::marker { color: var(--accent); }
.content li > ul, .content li > ol { margin-top: 0.4rem; margin-bottom: 0; }
.content blockquote {
position: relative;
border-left: 3px solid var(--gold);
background: var(--gold-bg);
padding: 1.1rem 1.4rem 1.1rem 2.6rem;
margin: 1.5rem 0;
border-radius: 0 var(--radius) var(--radius) 0;
color: var(--text);
font-family: var(--font-display);
font-style: italic;
font-size: 1.05rem;
line-height: 1.55;
}
.content blockquote::before {
content: '\201C';
position: absolute;
left: 0.5rem; top: 0.3rem;
font-family: var(--font-display);
font-size: 3rem;
font-style: normal;
color: var(--gold);
opacity: 0.5;
line-height: 1;
}
.content hr { border: none; height: 1px; background: linear-gradient(90deg, transparent, var(--border-strong), transparent); margin: 2rem 0; }
.content code { font-family: var(--font-mono); font-size: 0.86em; background: var(--bg-surface-alt); padding: 0.15em 0.4em; border-radius: 4px; }
.content pre { background: var(--bg-surface-alt); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem 1.5rem; overflow-x: auto; margin: 1.25rem 0; font-size: 0.86rem; line-height: 1.6; }
.content pre code { background: none; padding: 0; }
.content table { width: 100%; border-collapse: collapse; margin: 1.25rem 0; font-size: 0.9rem; border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-sm); }
.content th { text-align: left; padding: 0.7rem 1rem; background: var(--accent-bg); font-weight: 600; border-bottom: 2px solid var(--border-strong); }
.content td { padding: 0.6rem 1rem; border-bottom: 1px solid var(--border); vertical-align: top; }
.content tr:last-child td { border-bottom: none; }
.content tr:hover td { background: var(--accent-bg); }
/* ── Sources (collapsible list) ───────────────────── */
.sources-panel { margin-top: 3rem; border-top: 2px solid var(--border); padding-top: 1.5rem; }
.sources-panel details { margin: 0; }
.sources-panel summary {
display: flex; align-items: center; gap: 0.5rem;
cursor: pointer; font-size: 1rem; font-weight: 600;
color: var(--text); padding: 0.5rem 0; list-style: none;
user-select: none;
}
.sources-panel summary::-webkit-details-marker { display: none; }
.sources-panel summary::before {
content: '\25B6'; font-size: 0.65em; color: var(--text-muted);
transition: transform 0.2s;
}
.sources-panel details[open] summary::before { transform: rotate(90deg); }
.sources-list { padding: 0.5rem 0 0 0.25rem; }
.sources-list a {
display: flex; align-items: baseline; gap: 0.5rem;
padding: 0.35rem 0; font-size: 0.85rem;
color: var(--text); text-decoration: none;
transition: color 0.15s;
}
.sources-list a:hover { color: var(--accent); }
.sources-list .snum {
color: var(--text-muted); font-size: 0.75rem;
min-width: 1.5rem; text-align: right; flex-shrink: 0;
}
.sources-list .sdomain {
color: var(--text-muted); font-size: 0.75rem;
margin-left: auto; flex-shrink: 0;
}
/* ── Chat-about CTA ────────────────────────────────── */
.chat-cta {
margin: 3rem 0 1rem; padding: 1.5rem;
text-align: center;
border: 1px solid var(--border); border-radius: 12px;
background: var(--bg-surface);
}
.chat-cta-btn {
display: inline-flex; align-items: center; gap: 8px;
padding: 10px 18px; font-size: 0.95rem; font-weight: 600;
background: var(--accent); color: #fff;
border: none; border-radius: 8px; cursor: pointer;
font-family: inherit;
transition: filter 0.15s, transform 0.05s;
}
.chat-cta-btn:hover:not(:disabled) { filter: brightness(1.1); }
.chat-cta-btn:active:not(:disabled) { transform: translateY(1px); }
.chat-cta-btn:disabled { opacity: 0.6; cursor: progress; }
.chat-cta-hint {
margin-top: 8px; font-size: 0.8rem; color: var(--text-muted);
}
/* ── Footer ────────────────────────────────────────── */
.report-footer {
text-align: center; padding: 2rem; font-size: 0.75rem;
color: var(--text-muted); border-top: 1px solid var(--border); margin-top: 2rem;
}
/* ── Animations ────────────────────────────────────── */
@media (prefers-reduced-motion: no-preference) {
.content h2, .content h3, .content p, .content ul, .content ol,
.content blockquote, .content table, .content pre, .section-image {
animation: fadeUp 0.4s ease both;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
}
/* ── Print ─────────────────────────────────────────── */
@media print {
.toc-sidebar, .toolbar { display: none !important; }
.layout { grid-template-columns: 1fr; }
.hero { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
/* ── Category palettes ───────────────────────────────────
Override the accent + aurora vars per category so each report
type has a distinct visual identity. */
body.category-product {
--accent: #2a8a8c;
--accent-light: #4ab0b2;
--accent-bg: rgba(42,138,140,0.07);
--aurora-a: rgba(42,138,140,0.11);
--aurora-b: rgba(201,149,46,0.06);
--aurora-c: rgba(64,98,128,0.06);
}
body.category-comparison {
--accent: #7a4cb8;
--accent-light: #9d76d0;
--accent-bg: rgba(122,76,184,0.07);
--aurora-a: rgba(122,76,184,0.11);
--aurora-b: rgba(184,84,58,0.05);
--aurora-c: rgba(64,98,128,0.07);
}
body.category-howto {
--accent: #3d8a3d;
--accent-light: #62b162;
--accent-bg: rgba(61,138,61,0.07);
--aurora-a: rgba(61,138,61,0.11);
--aurora-b: rgba(201,149,46,0.07);
--aurora-c: rgba(42,138,140,0.05);
}
body.category-landscape {
--accent: #b88a2e;
--accent-light: #d4a955;
--accent-bg: rgba(184,138,46,0.08);
--aurora-a: rgba(184,138,46,0.13);
--aurora-b: rgba(184,84,58,0.06);
--aurora-c: rgba(122,76,184,0.05);
}
@media (prefers-color-scheme: dark) {
body.category-product {
--accent: #5cc8cb; --accent-light: #8fdde0;
--accent-bg: rgba(92,200,203,0.10);
--aurora-a: rgba(92,200,203,0.13);
--aurora-b: rgba(232,192,90,0.07);
--aurora-c: rgba(125,180,224,0.08);
}
body.category-comparison {
--accent: #b896e8; --accent-light: #d0b8f0;
--accent-bg: rgba(184,150,232,0.10);
--aurora-a: rgba(184,150,232,0.13);
--aurora-b: rgba(232,143,115,0.06);
--aurora-c: rgba(125,180,224,0.08);
}
body.category-howto {
--accent: #82c882; --accent-light: #a8dba8;
--accent-bg: rgba(130,200,130,0.09);
--aurora-a: rgba(130,200,130,0.12);
--aurora-b: rgba(232,192,90,0.07);
--aurora-c: rgba(92,200,203,0.07);
}
body.category-landscape {
--accent: #e6c069; --accent-light: #f0d390;
--accent-bg: rgba(230,192,105,0.10);
--aurora-a: rgba(230,192,105,0.15);
--aurora-b: rgba(232,143,115,0.07);
--aurora-c: rgba(184,150,232,0.06);
}
}
/* ── Per-category font pairings ───────────────────────
Body font shifts between serif (long-form categories) and sans
(practical/data categories) so each report reads as a different
publication, not just a re-tinted version of the same template. */
/* Long-form: literary serif for both display and body */
body:not([class*="category-"]),
body.category-landscape {
--font-body: 'Source Serif 4', 'Iowan Old Style', Georgia, serif;
}
/* Comparison: analytical serif display + clean sans body */
body.category-comparison {
--font-display: 'Playfair Display', Georgia, serif;
--font-body: 'Inter', system-ui, sans-serif;
}
/* How-to: friendly geometric sans, top to bottom */
body.category-howto {
--font-display: 'Manrope', system-ui, sans-serif;
--font-body: 'Inter', system-ui, sans-serif;
}
/* Product: techy/engineery — IBM Plex Sans display + Inter body */
body.category-product {
--font-display: 'IBM Plex Sans', system-ui, sans-serif;
--font-body: 'Inter', system-ui, sans-serif;
}
/* Source Serif sits visually larger than Inter at the same px — pull it
back one notch for the categories that use it as body so line length
and rhythm stay comparable across categories. */
body:not([class*="category-"]) body, /* no-op selector, kept for clarity */
body.category-landscape { font-size: 16.5px; }
/* Drop cap looks bad on geometric sans — kill it for those categories */
body.category-product .content > p:first-of-type::first-letter,
body.category-howto .content > p:first-of-type::first-letter,
body.category-comparison .content > p:first-of-type::first-letter,
body.category-product .content > h2:first-child + p::first-letter,
body.category-howto .content > h2:first-child + p::first-letter,
body.category-comparison .content > h2:first-child + p::first-letter {
font-size: 1em; float: none; margin: 0; color: inherit;
font-family: inherit; font-weight: inherit;
}
/* ── Per-category background effects ───────────────
Each category overrides body::before so the page reads as a
distinctly-textured surface. Aurora stays the default. */
/* Product → blueprint grid that slowly pans */
body.category-product::before {
background:
linear-gradient(to right, var(--aurora-a) 1px, transparent 1px),
linear-gradient(to bottom, var(--aurora-a) 1px, transparent 1px),
radial-gradient(70vw 60vh at 50% 50%, var(--aurora-a) 0%, transparent 75%);
background-size: 56px 56px, 56px 56px, 100% 100%;
filter: none;
animation: cat-grid-pan 60s linear infinite;
}
@keyframes cat-grid-pan {
to { background-position: 56px 56px, 56px 56px, 0 0; }
}
/* Comparison → dot grid + slow opacity pulse */
body.category-comparison::before {
background:
radial-gradient(circle, var(--aurora-a) 1.4px, transparent 1.8px),
radial-gradient(60vw 55vh at 25% 25%, var(--aurora-b) 0%, transparent 65%),
radial-gradient(60vw 55vh at 75% 75%, var(--aurora-c) 0%, transparent 65%);
background-size: 26px 26px, 100% 100%, 100% 100%;
filter: none;
animation: cat-dot-pulse 14s ease-in-out infinite alternate;
}
@keyframes cat-dot-pulse {
from { opacity: 0.65; }
to { opacity: 1; }
}
/* How-to → flat surface with a very subtle vignette. Drop the flow-lines
pattern — it competes visually with the step number rails on the
right-hand side of each H2. The reading should feel like an O'Reilly
procedure: clean, scannable, no decoration in the way. */
body.category-howto::before {
background:
radial-gradient(70vw 70vh at 50% 0%, var(--aurora-a) 0%, transparent 60%),
radial-gradient(50vw 50vh at 50% 100%, var(--aurora-b) 0%, transparent 65%);
filter: blur(40px);
animation: none;
}
/* Landscape → horizontal horizon bands that slowly shift sideways */
body.category-landscape::before {
background:
linear-gradient(
180deg,
transparent 0%,
var(--aurora-a) 22%,
transparent 35%,
var(--aurora-b) 55%,
transparent 68%,
var(--aurora-c) 85%,
transparent 100%
);
background-size: 100% 200%;
filter: blur(40px);
animation: cat-horizon-drift 36s ease-in-out infinite alternate;
}
@keyframes cat-horizon-drift {
0% { background-position: 0 0; }
100% { background-position: 0 100%; }
}
@media (prefers-reduced-motion: reduce) {
body.category-product::before,
body.category-comparison::before,
body.category-howto::before,
body.category-landscape::before {
animation: none;
}
}
/* ─────────────────────────────────────────────────────
PER-CATEGORY STRUCTURAL TREATMENTS
Each category gets distinctive structural CSS so the page
reads as a different publication — not just retinted.
───────────────────────────────────────────────────── */
/* ── HOWTO: O'Reilly-style numbered procedure ─────── */
body.category-howto .content { counter-reset: howto-step; }
body.category-howto .content h2 {
counter-increment: howto-step;
display: flex; align-items: center; gap: 14px;
border-bottom: none;
padding-left: 0;
margin-top: 3.5rem;
}
body.category-howto .content h2::before {
content: counter(howto-step);
display: inline-flex; align-items: center; justify-content: center;
flex-shrink: 0;
width: 40px; height: 40px;
border-radius: 12px;
background: var(--accent);
color: #fff;
font-family: var(--font-display);
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0;
box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent);
}
/* Step body gets a colored left rail so you can scan "this is step 1's stuff" */
body.category-howto .content h2 ~ p,
body.category-howto .content h2 ~ ul,
body.category-howto .content h2 ~ ol,
body.category-howto .content h2 ~ pre,
body.category-howto .content h2 ~ blockquote {
border-left: 2px solid color-mix(in srgb, var(--accent) 25%, transparent);
padding-left: 1rem;
margin-left: 4px;
}
body.category-howto .content h2:has(+ *) ~ h2 ~ * { border-left: none; padding-left: 0; margin-left: 0; }
/* Terminal-style code blocks — green $ prompt, monospaced, dark surface */
body.category-howto .content pre {
background: #1a1a1e;
color: #d4e4d4;
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
border-radius: 8px;
position: relative;
padding-left: 2.6rem;
}
body.category-howto .content pre::before {
content: '$';
position: absolute;
left: 1.1rem; top: 1.15rem;
color: var(--accent);
font-family: var(--font-mono);
font-weight: 700;
font-size: 0.86rem;
opacity: 0.85;
}
body.category-howto .content pre code { color: inherit; }
/* ── LANDSCAPE: editorial briefing with H3 player cards ─ */
body.category-landscape .content h3 {
/* Each H3 in landscape = a "player" in the field — give it a card frame */
margin-top: 2.5rem;
padding: 14px 18px 4px;
border-left: 3px solid var(--accent);
background: color-mix(in srgb, var(--accent) 4%, transparent);
border-radius: 0 8px 8px 0;
font-family: var(--font-display);
font-size: 1.18rem;
}
body.category-landscape .content h3 + p {
margin-top: 0;
padding: 0 18px 14px;
background: color-mix(in srgb, var(--accent) 4%, transparent);
border-left: 3px solid var(--accent);
margin-left: 0;
border-radius: 0 0 8px 0;
}
/* Pull-quote treatment for any standalone blockquote */
body.category-landscape .content blockquote {
font-size: 1.2rem;
line-height: 1.5;
max-width: 90%;
margin: 2rem auto;
text-align: center;
border-left: none;
border-top: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
background: transparent;
border-radius: 0;
padding: 1.5rem 1rem;
font-style: italic;
}
body.category-landscape .content blockquote::before {
display: none;
}
/* ── COMPARISON: lab-report tables with winner badges ─ */
body.category-comparison .content {
font-feature-settings: 'tnum' on, 'ss01'; /* tabular numerals for tables */
}
body.category-comparison .content table {
font-size: 0.92rem;
box-shadow: 0 6px 20px rgba(0,0,0,0.06);
}
body.category-comparison .content th {
background: color-mix(in srgb, var(--accent) 18%, var(--bg-surface));
color: var(--text);
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.72rem;
font-weight: 700;
}
body.category-comparison .content td:first-child {
font-weight: 600;
background: color-mix(in srgb, var(--accent) 6%, transparent);
}
/* The first H3 inside a comparison report often names the recommended pick */
body.category-comparison .content h3:first-of-type::after {
content: 'Pick';
display: inline-block;
margin-left: 10px;
padding: 2px 10px;
background: var(--accent);
color: #fff;
font-family: var(--font-body);
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
border-radius: 999px;
vertical-align: middle;
}
/* ── PRODUCT: spec-sheet cards for each H3 ─────────── */
body.category-product .content h3 {
/* Each product gets a spec-card frame — bordered, slight bg lift */
margin-top: 2.4rem;
padding: 16px 18px;
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border));
background: var(--bg-surface);
border-radius: 10px;
display: flex; align-items: baseline; gap: 10px;
font-family: var(--font-display);
letter-spacing: -0.01em;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
body.category-product .content h3::after {
/* small "spec" tag on each product heading */
content: 'SPEC';
margin-left: auto;
font-family: var(--font-body);
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.18em;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
padding: 3px 8px;
border-radius: 4px;
}
body.category-product .content h3 + p,
body.category-product .content h3 + ul,
body.category-product .content h3 + table {
margin-top: 0.8rem;
padding-left: 4px;
}
/* How-to category */
.category-howto .content h2 {
counter-increment:step-counter;
}
.category-howto .content h2::before {
content:counter(step-counter);
display:inline-flex; align-items:center; justify-content:center;
width:28px; height:28px; border-radius:50%;
background:var(--accent); color:#fff; font-size:0.8em; font-weight:700;
margin-right:10px; flex-shrink:0;
}
.category-howto .content { counter-reset:step-counter; }
.category-howto .content blockquote {
border-left:3px solid var(--accent); background:color-mix(in srgb, var(--accent) 8%, transparent);
padding:12px 16px; border-radius:0 6px 6px 0; margin:1em 0;
}
.category-howto .content blockquote strong:first-child {
display:inline-block; margin-bottom:4px; text-transform:uppercase;
font-size:0.82em; letter-spacing:0.5px;
}
.category-howto .content h2#quick-guide + ol,
.category-howto .content h2#quick-guide ~ ol:first-of-type {
background:color-mix(in srgb, var(--accent) 8%, transparent);
border:1px solid color-mix(in srgb, var(--accent) 20%, transparent);
border-radius:8px; padding:14px 14px 14px 32px; font-size:0.95em; line-height:1.8;
}
.category-howto .content h2#quick-guide {
counter-increment:none;
}
.category-howto .content h2#quick-guide::before {
content:'\26A1'; background:none; width:auto; height:auto; margin-right:6px;
}
</style>
</head>
<body class="category-howto">
<!-- Toolbar: Export + Restore hidden images -->
<div class="toolbar">
<div class="dropdown">
<button id="btn-export" title="Export">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
Export ▾
</button>
<div class="dropdown-menu" id="export-menu">
<button id="btn-pdf">Save as PDF</button>
<button id="btn-html">Download HTML</button>
</div>
</div>
</div>
<div class="hero">
<div class="hero-label">Odysseus — Deep Research Report</div>
<h1>Guide complet de développement du panneau latéral de navigation Notion</h1>
</div>
<div class="hero-image" data-img-url="https://www.notion.comhttps://img.youtube.com/vi/lSmgY5OsZmU/maxresdefault.jpg" style="display: none;"><img src="https://www.notion.comhttps://img.youtube.com/vi/lSmgY5OsZmU/maxresdefault.jpg" alt="" loading="lazy" onerror="this.parentElement.style.display='none'"><button class="img-reroll-btn" type="button" title="Swap for another image" disabled=""><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"></path></svg></button><button class="img-hide-btn" type="button" title="Hide image"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></div>
<div class="stats-bar">
<div class="stat"><span class="stat-value">463.0s</span> Duration</div>
<div class="stat"><span class="stat-value">3</span> Rounds</div>
<div class="stat"><span class="stat-value">7</span> Queries</div>
<div class="stat"><span class="stat-value">23</span> URLs Analyzed</div>
<div class="stat"><span class="stat-value">deepseek-v4-pro</span> Model</div>
<div class="stat"><span class="stat-value">searxng</span> Search</div>
</div>
<div class="layout">
<aside class="toc-sidebar">
<nav>
<a href="#quick-guide-raccourci-pour-d-veloppeurs" class="depth-2">Quick Guide (Raccourci pour développeurs)</a>
<a href="#pr-requis" class="depth-2">Prérequis</a>
<a href="#step-1-mise-en-place-du-conteneur-et-du-style-de-base" class="depth-2">Step 1: Mise en place du conteneur et du style de base</a>
<a href="#sp-cifications-ui" class="depth-3">Spécifications UI</a>
<a href="#step-2-en-t-te-du-workspace" class="depth-2">Step 2: En-tête du workspace</a>
<a href="#composant-workspaceheader" class="depth-3">Composant WorkspaceHeader</a>
<a href="#step-3-onglets-de-navigation-primaires" class="depth-2">Step 3: Onglets de navigation primaires</a>
<a href="#step-4-sections-de-pages-repliables" class="depth-2 active">Step 4: Sections de pages repliables</a>
<a href="#4-1-section-recents" class="depth-3">4.1 Section Recents</a>
<a href="#4-2-section-favorites" class="depth-3">4.2 Section Favorites</a>
<a href="#4-3-section-agents" class="depth-3">4.3 Section Agents</a>
<a href="#4-4-section-shared" class="depth-3">4.4 Section Shared</a>
<a href="#4-5-section-private" class="depth-3">4.5 Section Private</a>
<a href="#4-6-section-notion-apps" class="depth-3">4.6 Section Notion apps</a>
<a href="#step-5-liens-utilitaires" class="depth-2">Step 5: Liens utilitaires</a>
<a href="#step-6-pied-d-actions-rapides" class="depth-2">Step 6: Pied d’actions rapides</a>
<a href="#step-7-interactions-avanc-es-et-dynamiques" class="depth-2">Step 7: Interactions avancées et dynamiques</a>
<a href="#mode-r-duit-ic-nes-seules" class="depth-3">Mode réduit (icônes seules)</a>
<a href="#recherche-unifi-e-quick-find" class="depth-3">Recherche unifiée (Quick Find)</a>
<a href="#badge-inbox-en-temps-r-el" class="depth-3">Badge Inbox en temps réel</a>
<a href="#drag-drop-dans-les-favorites-rappel" class="depth-3">Drag &amp; drop dans les Favorites (rappel)</a>
<a href="#step-8-affichage-conditionnel-selon-le-plan-utilisateur" class="depth-2">Step 8: Affichage conditionnel selon le plan utilisateur</a>
<a href="#step-9-accessibilit-et-responsive-design" class="depth-2">Step 9: Accessibilité et responsive design</a>
<a href="#step-10-test-et-validation-ux" class="depth-2">Step 10: Test et validation UX</a>
<a href="#erreurs-courantes" class="depth-2">Erreurs courantes</a>
<a href="#conclusion" class="depth-2">Conclusion</a>
</nav>
</aside>
<main class="content">
<p><strong>Difficulté :</strong> Avancé<br>
<strong>Temps de développement estimé :</strong> 40 à 60 heures (selon le stack et les intégrations)<br>
<strong>Prérequis techniques :</strong> React (ou framework moderne), gestion d’état (Redux/Zustand), CSS-in-JS ou SCSS, bibliothèque de drag &amp; drop comme <code>dnd-kit</code>, connaissance des design tokens pour thèmes clair/foncé.</p>
<p>Ce guide vous donne toutes les clés pour reproduire à l’identique le menu de navigation gauche de Notion, en couvrant chaque composant visuel, chaque interaction utilisateur et chaque règle d’affichage conditionnelle. Il s’appuie directement sur la documentation officielle<sup><a href="#fn:1">1</a></sup><sup><a href="#fn:2">2</a></sup> et les meilleures pratiques UX<sup><a href="#fn:3">3</a></sup> pour garantir une expérience fluide et fidèle.</p>
<hr>
<h2 id="quick-guide-raccourci-pour-d-veloppeurs">Quick Guide (Raccourci pour développeurs)</h2>
<ol>
<li>Créer un conteneur fixe de 240 px de large, 100 vh de hauteur, fond sombre.</li>
<li>Ajouter l’en-tête du workspace (avatar + nom), avec un menu déroulant contextuel.</li>
<li>Empiler les 5 onglets principaux : Home, Chat (si IA activée), Meetings (si plan payant), Inbox (avec badge), Search (déclenche Quick Find).</li>
<li>Structurer les sections repliables titrées : Recents, Favorites (drag &amp; drop autorisé), Agents, Shared (avec sous-niveaux Teamspaces), Private, Notion apps.</li>
<li>Placer les liens utilitaires fixes (My Tasks, Library, Marketplace, Help, Trash) au-dessus du pied.</li>
<li>Ajouter un pied sticky contenant le bouton “+ New” (ouvre un menu de création) et “New Chat” (si IA).</li>
<li>Gérer la réduction de la sidebar à 56 px (mode icônes seules) via un hamburger, avec infobulles au survol.</li>
<li>Conditionner l’affichage des onglets Chat, Meetings, Agents et New Chat selon les droits utilisateur.</li>
<li>Implémenter le drag &amp; drop uniquement sur la section Favorites, avec persistance immédiate.</li>
<li>Sauvegarder localement les préférences (sections dépliées, ordre des favoris, mode réduit).</li>
<li>Appliquer les design tokens pour supporter les thèmes clair et foncé.</li>
</ol>
<hr>
<h2 id="pr-requis">Prérequis</h2>
<p>Avant de commencer, assurez-vous de maîtriser les concepts suivants :</p>
<ul>
<li><strong>React et le state management</strong> : la sidebar est un composant racine qui consomme des données dynamiques (pages, notifications, profil utilisateur).</li>
<li><strong>Design responsive et accessibilité</strong> : bien que le guide se concentre sur la version desktop, une adaption mobile (off‑canvas) est nécessaire ; nous aborderons le mode réduit mais pas le responsive mobile en détail.</li>
<li><strong>Notion API ou faux backend</strong> : pour les tests, vous aurez besoin d’une source de données modélisant les pages, les teamspaces et les permissions.</li>
<li><strong>Compréhension des conditions Notion</strong> : l’affichage de Chat, New Chat, Meetings et Agents dépend du plan et de l’activation de l’IA. Référez-vous à la documentation<sup><a href="#fn:2">2</a></sup> pour le mapping exact des droits.</li>
</ul>
<hr>
<h2 id="step-1-mise-en-place-du-conteneur-et-du-style-de-base">Step 1: Mise en place du conteneur et du style de base</h2>
<h3 id="sp-cifications-ui">Spécifications UI</h3>
<p>Le panneau latéral est un élément <code>aside</code> (ou <code>nav</code>) avec les propriétés suivantes :</p>
<div class="code"><pre><span></span><code><span class="p">.</span><span class="nc">sidebar</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="k">width</span><span class="p">:</span><span class="w"> </span><span class="mi">240</span><span class="kt">px</span><span class="p">;</span>
<span class="w"> </span><span class="k">min-width</span><span class="p">:</span><span class="w"> </span><span class="mi">240</span><span class="kt">px</span><span class="p">;</span>
<span class="w"> </span><span class="k">height</span><span class="p">:</span><span class="w"> </span><span class="mi">100</span><span class="kt">vh</span><span class="p">;</span>
<span class="w"> </span><span class="k">position</span><span class="p">:</span><span class="w"> </span><span class="kc">fixed</span><span class="p">;</span>
<span class="w"> </span><span class="k">left</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span>
<span class="w"> </span><span class="k">top</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span>
<span class="w"> </span><span class="k">display</span><span class="p">:</span><span class="w"> </span><span class="kc">flex</span><span class="p">;</span>
<span class="w"> </span><span class="k">flex-direction</span><span class="p">:</span><span class="w"> </span><span class="kc">column</span><span class="p">;</span>
<span class="w"> </span><span class="k">overflow-y</span><span class="p">:</span><span class="w"> </span><span class="kc">auto</span><span class="p">;</span>
<span class="w"> </span><span class="k">overflow-x</span><span class="p">:</span><span class="w"> </span><span class="kc">hidden</span><span class="p">;</span>
<span class="w"> </span><span class="k">background-color</span><span class="p">:</span><span class="w"> </span><span class="nf">var</span><span class="p">(</span><span class="nv">--sidebar-bg</span><span class="p">);</span>
<span class="w"> </span><span class="k">color</span><span class="p">:</span><span class="w"> </span><span class="nf">var</span><span class="p">(</span><span class="nv">--sidebar-text</span><span class="p">);</span>
<span class="w"> </span><span class="k">border-right</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="kt">px</span><span class="w"> </span><span class="kc">solid</span><span class="w"> </span><span class="nf">var</span><span class="p">(</span><span class="nv">--border-color</span><span class="p">);</span>
<span class="w"> </span><span class="k">box-shadow</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="mi">8</span><span class="kt">px</span><span class="w"> </span><span class="nb">rgba</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span><span class="mi">0</span><span class="p">,</span><span class="mi">0</span><span class="p">,</span><span class="mf">0.05</span><span class="p">);</span>
<span class="w"> </span><span class="k">transition</span><span class="p">:</span><span class="w"> </span><span class="k">width</span><span class="w"> </span><span class="mf">0.25</span><span class="kt">s</span><span class="w"> </span><span class="kc">ease</span><span class="p">,</span><span class="w"> </span><span class="k">min-width</span><span class="w"> </span><span class="mf">0.25</span><span class="kt">s</span><span class="w"> </span><span class="kc">ease</span><span class="p">;</span>
<span class="p">}</span>
<span class="p">.</span><span class="nc">sidebar</span><span class="p">.</span><span class="nc">collapsed</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="k">width</span><span class="p">:</span><span class="w"> </span><span class="mi">56</span><span class="kt">px</span><span class="p">;</span>
<span class="w"> </span><span class="k">min-width</span><span class="p">:</span><span class="w"> </span><span class="mi">56</span><span class="kt">px</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>
<p>Utilisez des variables CSS pour les couleurs afin d’assurer la compatibilité avec un thème sombre/clair. Par exemple :
- <code>--sidebar-bg: #f7f6f3;</code> (clair), <code>--sidebar-bg: #191919;</code> (sombre)
- <code>--sidebar-text: #37352f;</code> (clair), <code>--sidebar-text: #d3d3d3;</code> (sombre)
- <code>--accent: #2383e2;</code></p>
<p>La hauteur pleine écran est gérée par <code>height: 100vh</code>. Le défilement vertical interne permet aux très longs contenus de rester accessibles.</p>
<blockquote>
<p><strong>Tip:</strong> Pour éviter un double défilement avec le contenu principal, appliquez <code>overflow: hidden</code> sur <code>body</code> lorsque la sidebar est en mode off‑canvas sur mobile.</p>
</blockquote>
<hr>
<h2 id="step-2-en-t-te-du-workspace">Step 2: En-tête du workspace</h2>
<figure class="section-image" data-img-url="https://play-lh.googleusercontent.com/-GY1Xv9hhhK_RFJCCb-rvDX8OMR8Fj68AgcRIFcD8zIXlOA8pG-L-cuOJbgSs_U6GGGyGs8qiiQsjIfEBazE8g"><img src="https://play-lh.googleusercontent.com/-GY1Xv9hhhK_RFJCCb-rvDX8OMR8Fj68AgcRIFcD8zIXlOA8pG-L-cuOJbgSs_U6GGGyGs8qiiQsjIfEBazE8g" alt="" loading="lazy" onerror="this.parentElement.style.display='none'"><button class="img-reroll-btn" type="button" title="Swap for another image" disabled=""><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"></path></svg></button><button class="img-hide-btn" type="button" title="Hide image"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></figure>
<h3 id="composant-workspaceheader">Composant <code>WorkspaceHeader</code></h3>
<p>Affichez un avatar rond (32×32 px) et le nom du workspace. Le nom est tronqué avec <code>text-overflow: ellipsis</code> si nécessaire.</p>
<div class="code"><pre><span></span><code><span class="p">&lt;</span><span class="nt">button</span><span class="w"> </span><span class="na">className</span><span class="o">=</span><span class="s">"workspace-header"</span><span class="w"> </span><span class="na">onClick</span><span class="o">=</span><span class="p">{</span><span class="nx">toggleMenu</span><span class="p">}&gt;</span>
<span class="w"> </span><span class="p">&lt;</span><span class="nt">img</span><span class="w"> </span><span class="na">src</span><span class="o">=</span><span class="p">{</span><span class="nx">workspace</span><span class="p">.</span><span class="nx">icon</span><span class="p">}</span><span class="w"> </span><span class="na">alt</span><span class="o">=</span><span class="s">""</span><span class="w"> </span><span class="na">className</span><span class="o">=</span><span class="s">"workspace-avatar"</span><span class="w"> </span><span class="p">/&gt;</span>
<span class="w"> </span><span class="p">&lt;</span><span class="nt">span</span><span class="w"> </span><span class="na">className</span><span class="o">=</span><span class="s">"workspace-name"</span><span class="p">&gt;{</span><span class="nx">workspace</span><span class="p">.</span><span class="nx">name</span><span class="p">}&lt;/</span><span class="nt">span</span><span class="p">&gt;</span>
<span class="w"> </span><span class="p">&lt;</span><span class="nt">ChevronDownIcon</span><span class="w"> </span><span class="p">/&gt;</span>
<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span>
</code></pre></div>
<p>L’interaction :
- Un clic ouvre un panneau contextuel aligné sous l’en-tête, affichant : “Changer de workspace”, “Paramètres du workspace”, “Notifications”, etc.
- Le menu se ferme au clic extérieur ou sur un élément.</p>
<p>UX : cet en-tête ancre l’identité du workspace courant et donne un accès rapide aux réglages globaux.</p>
<blockquote>
<p><strong>Warning:</strong> Ne pas utiliser <code>position: absolute</code> pour le menu flottant sans avoir un conteneur parent <code>position: relative</code>. Une alternative est d’utiliser un portal React pour éviter les débordements de la sidebar.</p>
</blockquote>
<hr>
<h2 id="step-3-onglets-de-navigation-primaires">Step 3: Onglets de navigation primaires</h2>
<p>Cinq éléments empilés verticalement, chacun composé d’une icône (16 px) et d’un libellé (font-size 14 px, graisse normale). L’onglet actif reçoit un fond légèrement plus clair et une barre verticale gauche de 3 px.</p>
<p>Structure d’un onglet :</p>
<div class="code"><pre><span></span><code><span class="p">&lt;</span><span class="nt">NavItem</span>
<span class="w"> </span><span class="na">icon</span><span class="o">=</span><span class="p">{&lt;</span><span class="nt">HomeIcon</span><span class="w"> </span><span class="p">/&gt;}</span>
<span class="w"> </span><span class="na">label</span><span class="o">=</span><span class="s">"Home"</span>
<span class="w"> </span><span class="na">to</span><span class="o">=</span><span class="s">"/home"</span>
<span class="w"> </span><span class="na">active</span><span class="o">=</span><span class="p">{</span><span class="nx">currentRoute</span><span class="w"> </span><span class="o">===</span><span class="w"> </span><span class="s1">'/home'</span><span class="p">}</span>
<span class="p">/&gt;</span>
</code></pre></div>
<p>Comportements spécifiques :</p>
<table>
<thead>
<tr>
<th>Onglet</th>
<th>Icône</th>
<th>Rendu conditionnel</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr>
<td>Home</td>
<td>🏠</td>
<td>Toujours affiché</td>
<td>Navigation vers <code>/home</code></td>
</tr>
<tr>
<td>Chat</td>
<td>💬</td>
<td><code>userPlan.aiEnabled === true</code></td>
<td>Navigation vers l’interface IA, affiche éventuellement un indicateur de conversation active</td>
</tr>
<tr>
<td>Meetings</td>
<td>📅</td>
<td><code>userPlan.includesMeetings === true</code></td>
<td>Filtre les pages avec blocs de type réunion</td>
</tr>
<tr>
<td>Inbox</td>
<td>🔔</td>
<td>Toujours affiché</td>
<td>Ouvre une modale latérale listant les notifications ; badge non‑lu mis à jour en temps réel</td>
</tr>
<tr>
<td>Search</td>
<td>🔍</td>
<td>Toujours affiché</td>
<td>Déclenche <code>Cmd/Ctrl+K</code> en appelant la fonction <code>openQuickFind()</code></td>
</tr>
</tbody>
</table>
<p>Détail de l’Inbox :
- La propriété <code>unreadCount</code> est récupérée via polling ou WebSocket.
- Le badge est un petit cercle rouge avec le nombre, affiché seulement si <code>unreadCount &gt; 0</code>.
- Au clic, une modale s’ouvre. L’utilisateur peut marquer tout comme lu, ce qui réinitialise le badge.</p>
<blockquote>
<p><strong>Tip:</strong> Pour reproduire exactement le comportement Notion, le clic sur Search ne navigue pas mais active une modale superposée avec un champ de saisie autofocusé. Implémentez <code>QuickFindModal</code> indépendamment.</p>
</blockquote>
<hr>
<h2 id="step-4-sections-de-pages-repliables">Step 4: Sections de pages repliables</h2>
<figure class="section-image" data-img-url="https://app.notion.com/images/meta/default.png"><img src="https://app.notion.com/images/meta/default.png" alt="" loading="lazy" onerror="this.parentElement.style.display='none'"><button class="img-reroll-btn" type="button" title="Swap for another image" disabled=""><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"></path></svg></button><button class="img-hide-btn" type="button" title="Hide image"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></figure>
<p>Chaque section est un composant générique <code>SidebarSection</code> avec :
- un en-tête cliquable (chevron animé + titre en majuscules 12 px, lettre-spacing 0.5 px) ;
- une liste d’items <code>SidebarItem</code>.</p>
<p>L’ordre des sections est imposé : Recents, Favorites, Agents, Shared, Private, Notion apps. Elles sont empilées dans l’ordre vertical.</p>
<h3 id="4-1-section-recents">4.1 Section Recents</h3>
<ul>
<li>Générée automatiquement, tri antéchronologique.</li>
<li>Pas d’interaction manuelle (pas de suppression ni de drag).</li>
<li>Se met à jour immédiatement quand une page est consultée.</li>
</ul>
<h3 id="4-2-section-favorites">4.2 Section Favorites</h3>
<ul>
<li>L’utilisateur épingle une page via le menu “Add to Favorites”.</li>
<li>Chaque item est draggable ; toute la ligne est déplaçable (poignée invisible).</li>
<li>Ordre sauvegardé immédiatement après <code>drop</code>.</li>
<li>Survol : un bouton “…” apparaît pour un menu contextuel (Retirer des favoris, Copier le lien, etc.).</li>
</ul>
<p>Implémentation du drag &amp; drop :</p>
<div class="code"><pre><span></span><code><span class="p">&lt;</span><span class="nt">DndContext</span><span class="w"> </span><span class="na">onDragEnd</span><span class="o">=</span><span class="p">{</span><span class="nx">handleDragEnd</span><span class="p">}&gt;</span>
<span class="w"> </span><span class="p">&lt;</span><span class="nt">SortableContext</span><span class="w"> </span><span class="na">items</span><span class="o">=</span><span class="p">{</span><span class="nx">favorites</span><span class="p">}&gt;</span>
<span class="w"> </span><span class="p">{</span><span class="nx">favorites</span><span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">page</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">(</span>
<span class="w"> </span><span class="p">&lt;</span><span class="nt">SortableItem</span><span class="w"> </span><span class="na">key</span><span class="o">=</span><span class="p">{</span><span class="nx">page</span><span class="p">.</span><span class="nx">id</span><span class="p">}</span><span class="w"> </span><span class="na">id</span><span class="o">=</span><span class="p">{</span><span class="nx">page</span><span class="p">.</span><span class="nx">id</span><span class="p">}&gt;</span>
<span class="w"> </span><span class="p">&lt;</span><span class="nt">SidebarItem</span><span class="w"> </span><span class="na">page</span><span class="o">=</span><span class="p">{</span><span class="nx">page</span><span class="p">}</span><span class="w"> </span><span class="p">/&gt;</span>
<span class="w"> </span><span class="p">&lt;/</span><span class="nt">SortableItem</span><span class="p">&gt;</span>
<span class="w"> </span><span class="p">))}</span>
<span class="w"> </span><span class="p">&lt;/</span><span class="nt">SortableContext</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="nt">DndContext</span><span class="p">&gt;</span>
</code></pre></div>
<blockquote>
<p><strong>Warning:</strong> Seule la section Favorites doit accepter le réordonnancement. Toute autre tentative de glisser un élément doit être ignorée pour coller au comportement original.</p>
</blockquote>
<h3 id="4-3-section-agents">4.3 Section Agents</h3>
<ul>
<li>Affichage conditionnel : section masquée si aucun agent IA n’est configuré.</li>
<li>Chaque agent est un lien vers une conversation ou un contexte dédié.</li>
</ul>
<h3 id="4-4-section-shared">4.4 Section Shared</h3>
<ul>
<li>Regroupe les pages partagées et les Teamspaces.</li>
<li>Possibilité d’un sous‑niveau par Teamspace : l’en-tête de teamspace est en gras, avec une icône d’équipe et un chevron pour replier ses pages.</li>
<li>Des badges “Nouveau” peuvent apparaître sur les pages récemment mises à jour.</li>
<li>L’ordre des Teamspaces et des pages n’est pas modifiable librement par l’utilisateur final (il est défini par les administrateurs).</li>
</ul>
<p>Modèle de données :</p>
<div class="code"><pre><span></span><code><span class="kd">interface</span><span class="w"> </span><span class="nx">SharedSection</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nx">teamspaces</span><span class="o">:</span><span class="w"> </span><span class="kt">Array</span><span class="o">&lt;</span><span class="p">{</span>
<span class="w"> </span><span class="nx">id</span><span class="o">:</span><span class="w"> </span><span class="kt">string</span><span class="p">;</span>
<span class="w"> </span><span class="nx">name</span><span class="o">:</span><span class="w"> </span><span class="kt">string</span><span class="p">;</span>
<span class="w"> </span><span class="nx">pages</span><span class="o">:</span><span class="w"> </span><span class="kt">Page</span><span class="p">[];</span>
<span class="w"> </span><span class="nx">collapsed</span><span class="o">:</span><span class="w"> </span><span class="kt">boolean</span><span class="p">;</span>
<span class="w"> </span><span class="p">}</span><span class="o">&gt;</span><span class="p">;</span>
<span class="w"> </span><span class="nx">directSharedPages</span><span class="o">:</span><span class="w"> </span><span class="kt">Page</span><span class="p">[];</span>
<span class="p">}</span>
</code></pre></div>
<h3 id="4-5-section-private">4.5 Section Private</h3>
<ul>
<li>Pages personnelles non partagées.</li>
<li>Aucun badge de partage ; seule l’icône standard.</li>
</ul>
<h3 id="4-6-section-notion-apps">4.6 Section Notion apps</h3>
<ul>
<li>Intégrations tierces (Jira, Figma, etc.).</li>
<li>Masquée si aucune application n’est connectée.</li>
</ul>
<p>Comportements communs :
- Survol : fond plus clair <code>var(--hover-bg)</code>, curseur pointer.
- Clic : navigue vers la page ; l’élément prend l’état actif (surlignage).
- Clic droit : menu contextuel natif (Ouvrir dans un nouvel onglet, Dupliquer, etc.).
- Réduction/extension : le chevron pivote de 90° à l’état replié avec une transition <code>transform 0.2s ease</code>. L’état est sauvegardé localement par utilisateur.</p>
<blockquote>
<p><strong>Tip:</strong> Stockez l’état <code>collapsed</code> de chaque section dans un objet <code>userPreferences.sectionsState</code>. Utilisez <code>localStorage</code> pour une persistance instantanée, tout en synchronisant de temps en temps avec le backend.</p>
</blockquote>
<hr>
<h2 id="step-5-liens-utilitaires">Step 5: Liens utilitaires</h2>
<p>Une série de boutons simples, sans chevron ni sous-section, placés dans une <code>div</code> séparée au-dessus du pied.</p>
<p>Liste fixe dans l’ordre :
1. My Tasks
2. Library
3. Marketplace
4. Help
5. Trash</p>
<p>Ces liens ne supportent ni le drag, ni le réordonnancement. Leur comportement est un simple <code>history.push</code> ou <code>window.location</code>. La corbeille (Trash) pointe vers une page dédiée. L’icône de chaque lien est une illustration spécifique (fournie par le set d’icônes Notion).</p>
<hr>
<h2 id="step-6-pied-d-actions-rapides">Step 6: Pied d’actions rapides</h2>
<figure class="section-image" data-img-url="https://react.dev/images/og-home.png"><img src="https://react.dev/images/og-home.png" alt="" loading="lazy" onerror="this.parentElement.style.display='none'"><button class="img-reroll-btn" type="button" title="Swap for another image" disabled=""><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"></path></svg></button><button class="img-hide-btn" type="button" title="Hide image"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></figure>
<p>Deux boutons always‑visible en bas de la barre, positionnés en <code>sticky</code> :</p>
<div class="code"><pre><span></span><code><span class="p">.</span><span class="nc">sidebar-footer</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="k">position</span><span class="p">:</span><span class="w"> </span><span class="kc">sticky</span><span class="p">;</span>
<span class="w"> </span><span class="k">bottom</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span>
<span class="w"> </span><span class="k">background</span><span class="p">:</span><span class="w"> </span><span class="nf">var</span><span class="p">(</span><span class="nv">--sidebar-bg</span><span class="p">);</span>
<span class="w"> </span><span class="k">padding</span><span class="p">:</span><span class="w"> </span><span class="mi">12</span><span class="kt">px</span><span class="w"> </span><span class="mi">16</span><span class="kt">px</span><span class="p">;</span>
<span class="w"> </span><span class="k">border-top</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="kt">px</span><span class="w"> </span><span class="kc">solid</span><span class="w"> </span><span class="nf">var</span><span class="p">(</span><span class="nv">--border-color</span><span class="p">);</span>
<span class="w"> </span><span class="k">margin-top</span><span class="p">:</span><span class="w"> </span><span class="kc">auto</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>
<p><strong>Bouton “+ New”</strong> :
- Bouton principal, largeur pleine, couleur d’accentuation <code>var(--accent)</code>.
- Icône “+” à gauche.
- <code>onClick</code> → ouvre un menu flottant (portal) proposant : Page, Base de données, Calendrier, Import, etc.
- La sélection déclenche la création et l’ouverture de l’élément.
- Le menu se ferme au clic extérieur ou après sélection.</p>
<p><strong>Bouton “New Chat”</strong> :
- Bouton secondaire avec une icône de bulle.
- Visible uniquement si <code>userPlan.aiEnabled === true</code><sup><a href="#fn:2">2</a></sup>.
- <code>onClick</code> → bascule l’onglet Chat et démarre une nouvelle conversation IA.</p>
<blockquote>
<p><strong>Tip:</strong> Associez un raccourci clavier <code>Cmd/Ctrl + N</code> pour l’action “+ New” et affichez le raccourci dans une infobulle au survol.</p>
</blockquote>
<hr>
<h2 id="step-7-interactions-avanc-es-et-dynamiques">Step 7: Interactions avancées et dynamiques</h2>
<h3 id="mode-r-duit-ic-nes-seules">Mode réduit (icônes seules)</h3>
<p>Un bouton hamburger (ou un raccourci <code>Cmd/Ctrl + \</code>) réduit la sidebar à 56 px. Pendant la transition, les libellés disparaissent en fondu (<code>opacity: 0</code>). Les sections deviennent inaccessibles directement ; au survol d’une icône, un tooltip affiche le nom complet, et un clic sur une section ouvre un popover listant ses items.</p>
<div class="code"><pre><span></span><code><span class="p">&lt;</span><span class="nt">button</span><span class="w"> </span><span class="na">onClick</span><span class="o">=</span><span class="p">{</span><span class="nx">toggleCollapsed</span><span class="p">}</span><span class="w"> </span><span class="na">className</span><span class="o">=</span><span class="s">"collapse-btn"</span><span class="p">&gt;</span>
<span class="w"> </span><span class="p">&lt;</span><span class="nt">HamburgerIcon</span><span class="w"> </span><span class="p">/&gt;</span>
<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span>
</code></pre></div>
<h3 id="recherche-unifi-e-quick-find">Recherche unifiée (Quick Find)</h3>
<p>Le composant <code>QuickFindModal</code> s’affiche en overlay, centré, avec un champ de saisie autofocusé. La frappe filtre en temps réel les résultats (pages, bases de données, commentaires). Implémentez un système de recherche côté client pour la vitesse.</p>
<h3 id="badge-inbox-en-temps-r-el">Badge Inbox en temps réel</h3>
<p>Abonnez-vous à un flux de notifications (WebSocket) ou faites du polling toutes les 30 secondes. Mettez à jour l’état global <code>unreadCount</code> ; le badge se met à jour réactivement.</p>
<h3 id="drag-drop-dans-les-favorites-rappel">Drag &amp; drop dans les Favorites (rappel)</h3>
<p>Utilisez exclusivement <code>dnd-kit</code> et les événements <code>onDragEnd</code> pour sauvegarder le nouvel ordre via l’API Notion ou un store local immédiat.</p>
<hr>
<h2 id="step-8-affichage-conditionnel-selon-le-plan-utilisateur">Step 8: Affichage conditionnel selon le plan utilisateur</h2>
<figure class="section-image" data-img-url="https://images.ctfassets.net/spoqsaf9291f/2HM5BPlsww5fF5gJDpI71B/94b7676aa48c86aa4d3fe90583812b4a/New_to_Notion_-_Chapter_Hero.png"><img src="https://images.ctfassets.net/spoqsaf9291f/2HM5BPlsww5fF5gJDpI71B/94b7676aa48c86aa4d3fe90583812b4a/New_to_Notion_-_Chapter_Hero.png" alt="" loading="lazy" onerror="this.parentElement.style.display='none'"><button class="img-reroll-btn" type="button" title="Swap for another image" disabled=""><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"></path></svg></button><button class="img-hide-btn" type="button" title="Hide image"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></figure>
<p>Pour respecter les capacités du compte, créez un hook <code>useUserPermissions()</code> qui retourne :</p>
<div class="code"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nx">planType</span><span class="o">:</span><span class="w"> </span><span class="s1">'free'</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="s1">'plus'</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="s1">'business'</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="s1">'enterprise'</span><span class="p">;</span>
<span class="w"> </span><span class="nx">aiEnabled</span><span class="o">:</span><span class="w"> </span><span class="kt">boolean</span><span class="p">;</span>
<span class="w"> </span><span class="nx">meetingsEnabled</span><span class="o">:</span><span class="w"> </span><span class="kt">boolean</span><span class="p">;</span>
<span class="w"> </span><span class="nx">agentsActive</span><span class="o">:</span><span class="w"> </span><span class="kt">boolean</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>
<p>Appliquez ces conditions dans le rendu :
- Onglet Chat → <code>aiEnabled</code>
- Onglet Meetings → <code>planType !== 'free'</code>
- Section Agents → <code>agentsActive</code>
- Bouton New Chat → <code>aiEnabled</code></p>
<p>Si une condition n’est pas remplie, le composant correspondant n’est pas rendu (pas simplement masqué en CSS, pour éviter les trous dans la mise en page).</p>
<blockquote>
<p><strong>Warning:</strong> Ne jamais se baser uniquement sur le cache local pour ces permissions ; vérifiez-les au chargement et après chaque changement de plan.</p>
</blockquote>
<hr>
<h2 id="step-9-accessibilit-et-responsive-design">Step 9: Accessibilité et responsive design</h2>
<p>Respectez les critères WCAG AA :
- Tous les items sont focusables (tabIndex), l’ordre de tabulation suit l’ordre visuel.
- Rôles ARIA : <code>navigation</code> sur le conteneur, <code>list</code> et <code>listitem</code> sur les sections, <code>button</code> pour les actions.
- En mode réduit, utilisez l’attribut <code>aria-label</code> sur les icônes et des tooltips pilotés par <code>aria-describedby</code>.
- Contraste suffisant entre le texte et le fond dans les deux thèmes.</p>
<p>Pour le responsive (écrans &lt; 768 px), remplacez la sidebar fixe par un bouton hamburger qui déclenche un tiroir <code>off‑canvas</code> coulissant depuis la gauche. L’implémentation de cette version mobile sort du cadre de ce guide centré sur la reproduction exacte de la sidebar desktop, mais prévoyez une architecture qui facilite ce basculement.</p>
<hr>
<h2 id="step-10-test-et-validation-ux">Step 10: Test et validation UX</h2>
<figure class="section-image" data-img-url="https://miro.medium.com/v2/resize:fit:1200/1*lLdcw_Duk78_sYZKE1ES4w.png"><img src="https://miro.medium.com/v2/resize:fit:1200/1*lLdcw_Duk78_sYZKE1ES4w.png" alt="" loading="lazy" onerror="this.parentElement.style.display='none'"><button class="img-reroll-btn" type="button" title="Swap for another image" disabled=""><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"></path></svg></button><button class="img-hide-btn" type="button" title="Hide image"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></figure>
<p>Testez les scénarios suivants pour garantir la conformité avec Notion :</p>
<ul>
<li>Navigation fluide entre les onglets et les sections.</li>
<li>Ajout/suppression d’un favori et réordonnancement persistant.</li>
<li>Réduction/expansion de la sidebar et affichage des tooltips.</li>
<li>Badge de notifications réactif et remise à zéro.</li>
<li>Rendu conditionnel : connectez-vous avec un compte free, un compte plus sans IA, puis avec IA activée, et vérifiez que les composants appropriés apparaissent/disparaissent.</li>
<li>Raccourcis clavier : <code>Cmd/Ctrl+K</code> doit ouvrir Quick Find, <code>Cmd/Ctrl+\</code> doit réduire/étendre la sidebar.</li>
<li>Accessibilité : testez au lecteur d’écran la navigation complète.</li>
</ul>
<hr>
<h2 id="erreurs-courantes">Erreurs courantes</h2>
<ol>
<li><strong>Oublier de conditionner l’affichage des onglets Meetings et Chat</strong> → les utilisateurs sur plan gratuit verront des onglets inactifs ou manquants, ce qui nuit à l’expérience.</li>
<li><strong>Permettre le drag sur d’autres sections que Favorites</strong> → le comportement n’est pas authentique et peut provoquer des suppressions accidentelles.</li>
<li><strong>Ne pas persister l’état des sections pliées/dépliées</strong> → à chaque rechargement, l’utilisateur devra tout réorganiser, ce qui frustre.</li>
<li><strong>Ne pas animer la rotation du chevron ou la réduction de la sidebar</strong> → l’absence de transition donne une impression de bug.</li>
<li><strong>Stocker l’ordre des favoris uniquement en local sans le synchro serveur</strong> → en cas de changement d’appareil, les favoris seront perdus. Implémentez une sauvegarde côté backend via l’API Notion.</li>
<li><strong>Mettre le bouton Search sous forme de champ texte dans la sidebar</strong> → Notion utilise un bouton déclenchant une modale, ce qui économise l’espace et garde l’interface propre.</li>
<li><strong>Utiliser des tailles de police ou des espacements différents</strong> → respectez scrupuleusement les 14 px pour les libellés, 12 px pour les titres de section, et les marges internes de 8-12 px pour une reproduction fidèle.</li>
<li><strong>Négliger la gestion du thème</strong> → sans variables CSS, le basculement clair/foncé cassera l’apparence. Prévoir dès le départ les deux jeux de couleurs.</li>
</ol>
<hr>
<h2 id="conclusion">Conclusion</h2>
<figure class="section-image" data-img-url="https://cdn.prod.website-files.com/65676438ff1c480b81cf01a6/681e51ac1d8ef91e56aac45a_blog%20post-sidebat.avif"><img src="https://cdn.prod.website-files.com/65676438ff1c480b81cf01a6/681e51ac1d8ef91e56aac45a_blog%20post-sidebat.avif" alt="" loading="lazy" onerror="this.parentElement.style.display='none'"><button class="img-reroll-btn" type="button" title="Swap for another image" disabled=""><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"></path></svg></button><button class="img-hide-btn" type="button" title="Hide image"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button></figure>
<p>Ce guide vous a fourni l’ensemble des spécifications UI, des modèles d’interaction et des conditions métier nécessaires pour reproduire la barre de navigation gauche de Notion. En suivant les étapes détaillées, vous disposerez d’une sidebar réactive, personnalisée et fidèle à l’original, capable de s’adapter au plan de l’utilisateur et à ses préférences. La clé du succès réside dans une attention méticuleuse aux détails : chaque pixel, chaque transition et chaque condition d’affichage compte pour offrir une expérience utilisateur identique à celle que des millions d’utilisateurs connaissent.</p>
<p><em>Sources utilisées :</em> </p>
<div class="footnote">
<hr>
<ol>
<li>
<p>Notion Help Center – “Navigate with the sidebar” : <a href="https://www.notion.com/help/navigate-with-the-sidebar](https://www.notion.com/help/navigate-with-the-sidebar)" rel="noopener noreferrer" target="_blank">[https://www.notion.com/help/navigate-with-the-sidebar](https://www.notion.com/help/navigate-with-the-sidebar</a>&nbsp;<a href="#fnref:1" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
<li>
<p>Notion Help Center – “Sidebar navigation” : <a href="https://www.notion.com/help/category/sidebar-navigation](https://www.notion.com/help/category/sidebar-navigation)" rel="noopener noreferrer" target="_blank">[https://www.notion.com/help/category/sidebar-navigation](https://www.notion.com/help/category/sidebar-navigation</a>&nbsp;<a href="#fnref:2" title="Jump back to footnote 2 in the text">↩</a><a href="#fnref2:2" title="Jump back to footnote 2 in the text">↩</a><a href="#fnref3:2" title="Jump back to footnote 2 in the text">↩</a></p>
</li>
<li>
<p>UX Planet – “Best UX Practices for Designing a Sidebar” : <a href="https://uxplanet.org/best-ux-practices-for-designing-a-sidebar-9174ee0ecaa2](https://uxplanet.org/best-ux-practices-for-designing-a-sidebar-9174ee0ecaa2)" rel="noopener noreferrer" target="_blank">[https://uxplanet.org/best-ux-practices-for-designing-a-sidebar-9174ee0ecaa2](https://uxplanet.org/best-ux-practices-for-designing-a-sidebar-9174ee0ecaa2</a>&nbsp;<a href="#fnref:3" title="Jump back to footnote 3 in the text">↩</a></p>
</li>
</ol>
</div>
<div class="sources-panel">
<details>
<summary>Sources (8)</summary>
<div class="sources-list">
<a href="https://www.notion.com/help/navigate-with-the-sidebar" target="_blank" rel="noopener noreferrer"><span class="snum">1.</span><span>Navigate with the sidebar – Notion Help Center</span><span class="sdomain">notion.com</span></a>
<a href="https://www.navbar.gallery/blog/best-side-bar-navigation-menu-design-examples" target="_blank" rel="noopener noreferrer"><span class="snum">2.</span><span>8+ Best Sidebar Menu Design Examples of 2026 (With UI Inspiration)</span><span class="sdomain">navbar.gallery</span></a>
<a href="https://uxplanet.org/best-ux-practices-for-designing-a-sidebar-9174ee0ecaa2" target="_blank" rel="noopener noreferrer"><span class="snum">3.</span><span>Best UX Practices for Designing a Sidebar</span><span class="sdomain">uxplanet.org</span></a>
<a href="https://www.notion.com/help/category/sidebar-navigation" target="_blank" rel="noopener noreferrer"><span class="snum">4.</span><span>Sidebar navigation - Notion Help Center</span><span class="sdomain">notion.com</span></a>
<a href="https://react.dev/" target="_blank" rel="noopener noreferrer"><span class="snum">5.</span><span>React</span><span class="sdomain">react.dev</span></a>
<a href="https://www.notion.so/min-0/" target="_blank" rel="noopener noreferrer"><span class="snum">6.</span><span>Notion</span><span class="sdomain">notion.so</span></a>
<a href="https://en.wikipedia.org/wiki/Notion_(productivity_software)" target="_blank" rel="noopener noreferrer"><span class="snum">7.</span><span>Notion (productivity software) - Wikipedia</span><span class="sdomain">en.wikipedia.org</span></a>
<a href="https://play.google.com/store/apps/details?id=notion.id&amp;hl=en-US" target="_blank" rel="noopener noreferrer"><span class="snum">8.</span><span>Notion: Notes, Tasks, AI - Apps on Google Play</span><span class="sdomain">play.google.com</span></a>
</div>
</details>
</div>
<div class="chat-cta"><button id="btn-chat-about" class="chat-cta-btn" data-research-id="rp-6c5e2ffa2b09"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg><span>Discuss</span></button><div class="chat-cta-hint">Opens a new chat with this report as context.</div></div>
</main>
</div>
<div class="report-footer">
Generated by Odysseus Deep Research · July 09, 2026 at 15:26
</div>
<script>
(function() {
// ESC closes the report tab. window.close() works when the tab was
// opened via window.open() (which is how the panel launches it). If the
// browser blocks self-close (rare — e.g. report opened by direct URL),
// fall back to history.back() so ESC still feels responsive.
document.addEventListener('keydown', function(e) {
if (e.key !== 'Escape' || e.defaultPrevented) return;
// Don't hijack ESC while typing in a field or with an open dropdown.
var t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
var menu = document.getElementById('export-menu');
if (menu && menu.classList.contains('open')) { menu.classList.remove('open'); return; }
try { window.close(); } catch (err) {}
// window.close() is a no-op when the tab wasn't script-opened; in that
// case fall back to navigation so the key isn't ignored.
setTimeout(function() { if (!window.closed) history.back(); }, 50);
});
// Export dropdown toggle
var exportBtn = document.getElementById('btn-export');
var exportMenu = document.getElementById('export-menu');
exportBtn.addEventListener('click', function(e) {
e.stopPropagation();
exportMenu.classList.toggle('open');
});
document.addEventListener('click', function() { exportMenu.classList.remove('open'); });
// Save as PDF (browser print)
document.getElementById('btn-pdf').addEventListener('click', function() {
exportMenu.classList.remove('open');
window.print();
});
// Download HTML
document.getElementById('btn-html').addEventListener('click', function() {
exportMenu.classList.remove('open');
var blob = new Blob([document.documentElement.outerHTML], { type: 'text/html' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = document.title.replace(/[^a-z0-9]+/gi, '-').substring(0, 60) + '.html';
a.click();
});
// Per-image hide — fades the image out, then POSTs to the backend so
// future renders of this report skip the URL. Falls back to a silent
// no-op if there's no session_id (e.g. the report was opened from a
// saved-HTML download where the backend isn't reachable).
var __sessionId = "rp-6c5e2ffa2b09";
// Unused scraped images — the reroll pool. Each is used at most once.
var __spareImages = [];
// Persist a rejected URL so future renders skip it.
function __persistHide(url) {
if (!__sessionId || !url) return;
fetch('/api/research/' + encodeURIComponent(__sessionId) + '/hide-image', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url }),
}).catch(function(err) { console.warn('hide-image POST failed', err); });
}
// Once the pool is empty, there's nothing to swap to — hide all reroll btns.
function __syncRerollAvailability() {
if (__spareImages.length === 0) {
document.querySelectorAll('.img-reroll-btn').forEach(function(b) { b.disabled = true; });
}
}
document.querySelectorAll('.img-hide-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault(); e.stopPropagation();
var wrap = btn.closest('[data-img-url]');
if (!wrap) return;
var url = wrap.dataset.imgUrl;
wrap.classList.add('fading');
setTimeout(function() { wrap.remove(); }, 280);
__persistHide(url);
});
});
// Reroll — swap the current image for the next unused scraped one, and
// persist-hide the rejected URL so it won't resurface on reload.
document.querySelectorAll('.img-reroll-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.preventDefault(); e.stopPropagation();
// Per-button busy flag — a rapid double-click would otherwise both
// shift the spare pool, but only the second probe's image would land,
// silently consuming the first one. Bail until finish() clears it.
if (btn.dataset._busy === '1') return;
if (__spareImages.length === 0) { btn.disabled = true; return; }
var wrap = btn.closest('[data-img-url]');
if (!wrap) return;
var img = wrap.querySelector('img');
if (!img) return;
btn.dataset._busy = '1';
var oldUrl = wrap.dataset.imgUrl;
var newUrl = __spareImages.shift();
btn.classList.add('spinning');
// Swap once the new image has loaded (or failed) to avoid a flash of empty.
var probe = new Image();
var done = false;
var finish = function(ok) {
if (done) return; done = true;
btn.classList.remove('spinning');
delete btn.dataset._busy;
if (ok) {
img.src = newUrl;
wrap.dataset.imgUrl = newUrl;
__persistHide(oldUrl);
} else {
// Bad candidate — persist-hide it so it can't resurface on reload,
// then try the next spare if any remain. Busy flag already cleared
// so the synthetic click below proceeds.
__persistHide(newUrl);
if (__spareImages.length) btn.click();
}
__syncRerollAvailability();
};
probe.onload = function() { finish(true); };
probe.onerror = function() { finish(false); };
probe.src = newUrl;
});
});
__syncRerollAvailability();
// "Show hidden (N)" button — clears the hidden_images list on the
// server, then reloads the page so all images come back.
var restoreBtn = document.getElementById('btn-restore-images');
if (restoreBtn && __sessionId) {
restoreBtn.addEventListener('click', function() {
restoreBtn.disabled = true;
restoreBtn.textContent = 'Restoring…';
fetch('/api/research/' + encodeURIComponent(__sessionId) + '/unhide-images', {
method: 'POST', credentials: 'same-origin',
}).then(function() { window.location.reload(); })
.catch(function(err) {
restoreBtn.disabled = false;
restoreBtn.textContent = 'Failed — retry?';
console.warn('unhide-images POST failed', err);
});
});
}
// TOC: explicit smooth-scroll handler (some browsers/anchor plugins
// bypass the CSS `scroll-behavior: smooth` rule on hash clicks).
// Also keeps the URL hash updated and toggles an `.active` highlight.
var tocLinks = document.querySelectorAll('.toc-sidebar nav a[href^="#"]');
tocLinks.forEach(function(link) {
link.addEventListener('click', function(e) {
var id = link.getAttribute('href').slice(1);
var target = document.getElementById(id);
if (!target) return;
e.preventDefault();
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
history.replaceState(null, '', '#' + id);
});
});
// Highlight the TOC entry that matches whichever heading is currently
// closest to the top of the viewport. IntersectionObserver keeps it
// cheap (no scroll listener spam).
var tocMap = {};
tocLinks.forEach(function(link) {
tocMap[link.getAttribute('href').slice(1)] = link;
});
var activeId = null;
function setActive(id) {
if (id === activeId) return;
if (activeId && tocMap[activeId]) tocMap[activeId].classList.remove('active');
if (id && tocMap[id]) tocMap[id].classList.add('active');
activeId = id;
}
var headings = document.querySelectorAll('.content h2[id], .content h3[id]');
if (headings.length && 'IntersectionObserver' in window) {
var visible = new Set();
var io = new IntersectionObserver(function(entries) {
entries.forEach(function(en) {
if (en.isIntersecting) visible.add(en.target.id);
else visible.delete(en.target.id);
});
// Pick the visible heading that's furthest down in document order
// before the current scroll — i.e. the section we're reading.
var current = null;
for (var i = 0; i < headings.length; i++) {
if (visible.has(headings[i].id)) { current = headings[i].id; break; }
}
if (current) setActive(current);
}, { rootMargin: '-10% 0px -75% 0px', threshold: 0 });
headings.forEach(function(h) { io.observe(h); });
}
// Chat about this research — POST to spinoff and redirect to the new chat
var chatBtn = document.getElementById('btn-chat-about');
if (chatBtn) {
chatBtn.addEventListener('click', function() {
var researchId = chatBtn.dataset.researchId;
if (!researchId) return;
var origLabel = chatBtn.innerHTML;
chatBtn.disabled = true;
chatBtn.innerHTML = '<span>Creating chat…</span>';
fetch('/api/research/spinoff/' + encodeURIComponent(researchId), {
method: 'POST', credentials: 'same-origin',
}).then(function(res) {
if (!res.ok) {
return res.json().then(function(d) {
throw new Error(d && d.detail ? d.detail : ('HTTP ' + res.status));
}, function() { throw new Error('HTTP ' + res.status); });
}
return res.json();
}).then(function(data) {
if (!data || !data.session_id) {
throw new Error('Server did not return a session id');
}
var url = '/#' + data.session_id;
var opened = false;
// The report typically opens in a new tab — if we have access to the
// original Odysseus tab, navigate it and close this report tab so the
// user lands directly in the new chat.
try {
if (window.opener && !window.opener.closed) {
window.opener.location.href = url;
window.opener.location.reload();
window.opener.focus();
opened = true;
window.close();
}
} catch (e) { /* cross-origin or detached opener — fall through */ }
if (!opened) {
// No opener (report was opened directly via URL) — open the chat in a
// new tab so the report stays available.
var w = window.open(url, '_blank');
if (w) {
chatBtn.disabled = false;
chatBtn.innerHTML = '<span>Chat opened in new tab</span>';
} else {
// Popup blocked — navigate this tab as a last resort.
window.location.href = url;
window.location.reload();
}
}
}).catch(function(err) {
chatBtn.disabled = false;
chatBtn.innerHTML = origLabel;
alert('Could not start follow-up chat: ' + err.message);
});
});
}
})();
// Colorize comparison table cells
if (document.body.classList.contains('category-comparison')) {
const pos = /^(yes|excellent|best|great|strong|fast|high|superior|winner|free|unlimited|native|full|advanced|built[- ]in|✓|✅|⭐)/i;
const neg = /^(no|none|poor|weak|slow|low|limited|lacking|missing|basic|minimal|✗|❌|N\/A$)/i;
const mid = /^(moderate|average|fair|partial|some|decent|okay|mixed|varies|depends)/i;
document.querySelectorAll('.content table td').forEach(td => {
if (td.cellIndex === 0) return;
const t = td.textContent.trim();
if (pos.test(t)) td.classList.add('cmp-pos');
else if (neg.test(t)) td.classList.add('cmp-neg');
else if (mid.test(t)) td.classList.add('cmp-mid');
});
}
</script>
</body></html>