Add the application shell, Aperçu and the week grid

Ports the shell and Aperçu screen from the Claude Design export, and
builds the week grid from the design system's grid specification.

The theme is applied by a nonced inline script before first paint.
Without it the page renders light and then swaps, which is a white
flash for anyone working in dark. The nonce our CSP already emits is
what makes an inline script possible at all.

Demo data lives in src/lib/demo behind a README stating it is fictional
and replaceable. Screens import from there and nowhere else, so the
directory can be deleted wholesale once real queries land.

Weekend columns are shaded because in retail those are the days that
carry counterparts — mayor-authorised Sundays and their compensatory
rest — and scheduling one by accident is the failure worth preventing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-07 22:12:23 +00:00
1 parent 8c0cfd3b0c
commit a24c1b821c
14 files changed
+1301 -5

No files matched your search

+139
View File
@@ -0,0 +1,139 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import type { ReactNode } from 'react';
import { ThemeToggle } from '@/components/shell/ThemeToggle';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { cx } from '@/lib/cx';
import {
isActive,
NAVIGATION,
sectionForPath,
type NavItem,
} from '@/components/shell/navigation';
export interface AppShellProps {
children: ReactNode;
}
export function AppShell({ children }: AppShellProps) {
const pathname = usePathname();
const section = sectionForPath(pathname);
return (
<div className="flex min-h-dvh flex-col bg-canvas text-ink-1">
<header className="flex h-13 flex-none items-center gap-5 border-b border-line-2 bg-surface px-5">
<Link
href="/"
className="flex items-center gap-2.5 rounded-2 text-lg font-semibold tracking-[-0.015em]"
>
<span aria-hidden className="size-5 rounded-2 bg-accent" />
PlanFlow
</Link>
<nav aria-label="Sections" className="flex h-full gap-0.5">
{NAVIGATION.map((entry) => {
const target = entry.items.find((item) => item.href);
const current = entry.id === section.id;
return (
<Link
key={entry.id}
href={target?.href ?? '/'}
aria-current={current ? 'page' : undefined}
className={cx(
'flex h-full items-center border-b-2 px-3.5 text-sm font-medium whitespace-nowrap',
current
? 'border-accent text-ink-1'
: 'border-transparent text-ink-3 hover:text-ink-1',
)}
>
{entry.label}
</Link>
);
})}
</nav>
<span className="flex-1" />
<Button size="sm">
Nantes Atlantis
<span aria-hidden className="text-[10px] text-ink-3">
▾
</span>
</Button>
<ThemeToggle />
<span
aria-hidden
className="flex size-7 items-center justify-center rounded-full bg-accent-soft text-xs font-semibold text-accent-soft-ink"
>
MR
</span>
</header>
<div className="flex min-h-0 flex-1">
<aside className="flex w-57 flex-none flex-col gap-0.5 overflow-auto border-r border-line-1 bg-surface p-3 pt-4">
<p className="px-2.5 pb-2.5 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
{section.label}
</p>
{section.items.map((item) => (
<SidebarLink key={item.id} item={item} pathname={pathname} />
))}
<span className="flex-1" />
<p className="border-t border-line-1 px-2.5 pt-3 text-micro leading-relaxed text-ink-3">
Instance auto-hébergée
<br />
Maison Rivage · 34 établissements
</p>
</aside>
<main className="min-w-0 flex-1 overflow-auto">{children}</main>
</div>
</div>
);
}
function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
const current = item.href ? isActive(item.href, pathname) : false;
const className = cx(
'flex w-full items-center justify-between gap-2 rounded-2 px-2.5 py-1.5 text-left text-sm',
'transition-colors duration-[var(--d-1)] ease-organic',
current
? 'bg-accent-soft font-semibold text-accent-soft-ink'
: 'text-ink-2 hover:bg-surface-2',
);
const content = (
<>
<span className="truncate">{item.label}</span>
{item.badge ? (
<Badge subtle tone={current ? 'neutral' : 'neutral'}>
{item.badge}
</Badge>
) : null}
</>
);
if (!item.href) {
return (
<Link
href={`/bientot?ecran=${encodeURIComponent(item.label)}`}
className={cx(className, 'text-ink-3')}
>
{content}
</Link>
);
}
return (
<Link
href={item.href}
aria-current={current ? 'page' : undefined}
className={className}
>
{content}
</Link>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { ReactNode } from 'react';
export interface PageHeaderProps {
title: string;
subtitle?: string;
actions?: ReactNode;
}
export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
return (
<div className="flex flex-wrap items-end gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-[-0.018em]">{title}</h1>
{subtitle ? (
<p className="mt-1 text-sm text-ink-2">{subtitle}</p>
) : null}
</div>
<span className="flex-1" />
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div>
);
}
export function PageBody({ children }: { children: ReactNode }) {
return (
<div className="flex flex-col gap-5 px-6 pt-5 pb-16">{children}</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
'use client';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/Button';
export type Theme = 'light' | 'dark';
export const THEME_STORAGE_KEY = 'planflow.theme';
/**
* Script appliqué avant le premier rendu.
*
* Sans lui, la page s'affiche en clair puis bascule — un flash blanc en pleine
* figure pour qui travaille en sombre. Il est injecté avec le nonce de la
* requête, seule façon d'exécuter de l'inline sous notre CSP (src/proxy.ts).
*/
export const THEME_INIT_SCRIPT = `
(function () {
try {
var stored = localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)});
var theme = stored === 'dark' || stored === 'light'
? stored
: (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
`.trim();
function readTheme(): Theme {
if (typeof document === 'undefined') return 'light';
return document.documentElement.getAttribute('data-theme') === 'dark'
? 'dark'
: 'light';
}
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme>('light');
// Le thème réel est posé par THEME_INIT_SCRIPT avant l'hydratation ; on le
// lit après montage plutôt que de le deviner au rendu serveur, où
// localStorage n'existe pas.
useEffect(() => setTheme(readTheme()), []);
function toggle() {
const next: Theme = theme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
try {
localStorage.setItem(THEME_STORAGE_KEY, next);
} catch {
// Stockage indisponible (navigation privée) : le thème reste valable
// pour la session, ce qui est préférable à une erreur.
}
setTheme(next);
}
return (
<Button
size="sm"
className="rounded-full"
onClick={toggle}
aria-pressed={theme === 'dark'}
>
Thème {theme === 'dark' ? 'sombre' : 'clair'}
</Button>
);
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Modèle de navigation, repris de l'export Claude Design.
*
* Deux niveaux : une section dans l'en-tête, ses entrées dans la barre
* latérale. Les badges portent un décompte à traiter.
*
* Les entrées sans `href` correspondent à des écrans du périmètre qui n'ont pas
* encore d'implémentation : elles restent visibles pour que la navigation
* reflète le produit réel, et mènent à un écran d'attente explicite plutôt qu'à
* une page blanche.
*/
export interface NavItem {
id: string;
label: string;
href?: string;
badge?: string;
}
export interface NavSection {
id: string;
label: string;
items: NavItem[];
}
export const NAVIGATION: NavSection[] = [
{
id: 'apercu',
label: 'Aperçu',
items: [
{ id: 'taches', label: 'Tâches RH', href: '/' },
{ id: 'entrees', label: 'Entrées et sorties' },
{ id: 'profils', label: 'Profils incomplets', badge: '6' },
{ id: 'soldes-cp', label: 'Compteurs de congés payés' },
],
},
{
id: 'plannings',
label: 'Plannings',
items: [
{ id: 'semaine', label: 'Vue semaine', href: '/planning/semaine' },
{ id: 'jour', label: 'Vue jour', href: '/planning/jour' },
{ id: 'besoins', label: 'Besoins non couverts', badge: '5' },
{ id: 'modeles', label: "Modèles d'ouverture" },
],
},
{
id: 'equipe',
label: 'Équipe',
items: [
{ id: 'membres', label: 'Membres', href: '/equipe' },
{ id: 'fiche', label: 'Fiche salarié', href: '/equipe/camille-ferrand' },
{ id: 'contrats', label: 'Modifications de contrat' },
],
},
{
id: 'conges',
label: 'Congés',
items: [
{ id: 'calendrier', label: 'Calendrier des absences', href: '/conges' },
{ id: 'attente', label: 'Demandes en attente', href: '/conges#attente', badge: '3' },
{ id: 'politiques', label: 'Politiques de congés' },
],
},
{
id: 'rapports',
label: 'Rapports',
items: [
{ id: 'heures', label: 'Heures travaillées' },
{ id: 'paie', label: 'Préparation de paie' },
{ id: 'activite', label: "Journal d'activité" },
],
},
{
id: 'reglages',
label: 'Réglages',
items: [
{ id: 'convention', label: 'Convention collective' },
{ id: 'postes', label: 'Postes et étiquettes' },
{ id: 'sites', label: 'Établissements' },
{ id: 'roles', label: 'Rôles et permissions' },
],
},
];
/** Section contenant la route donnée, ou la première par défaut. */
export function sectionForPath(pathname: string): NavSection {
const match = NAVIGATION.find((section) =>
section.items.some((item) => item.href && isActive(item.href, pathname)),
);
return match ?? (NAVIGATION[0] as NavSection);
}
export function isActive(href: string, pathname: string): boolean {
const base = href.split('#')[0] ?? href;
if (base === '/') return pathname === '/';
return pathname === base || pathname.startsWith(`${base}/`);
}