Port the design tokens and the planning primitives

Brings in the token set from the Claude Design export: warm neutrals,
semantic status colours, and the twelve-hue categorical poste palette,
in light and dark. Declared through Tailwind's @theme so utilities
compile to var(--color-*) and follow the theme without per-component
branching.

The poste palette derives every hue from one formula with an
alternating lightness step, so neighbouring hues never land at the same
lightness. ShiftChip always prints the poste code alongside the fill:
colour carries meaning in the grid, and a grid has to stay readable in
print, in greyscale, and to someone who confuses red and green.

Week counters live in src/domain/counters rather than in the component,
because the same numbers will feed the hours report and the payroll
export later. Durations are whole minutes: decimal hours drift by a
minute in ways that are invisible on screen and wrong on a payslip.

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:08:08 +00:00
1 parent 41ac2fa0af
commit 8c0cfd3b0c
10 files changed
+856

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
import { cx } from '@/lib/cx';
export type AbsenceKind = 'cp' | 'rtt' | 'maladie' | 'sans-solde' | 'attente';
const KINDS: Record<AbsenceKind, { bg: string; ink: string; edge: string }> = {
cp: {
bg: 'var(--color-info-soft)',
ink: 'var(--color-info-soft-ink)',
edge: 'var(--color-info)',
},
rtt: {
bg: 'var(--color-accent-soft)',
ink: 'var(--color-accent-soft-ink)',
edge: 'var(--color-accent)',
},
maladie: {
bg: 'var(--color-surface-3)',
ink: 'var(--color-ink-2)',
edge: 'var(--color-line-3)',
},
'sans-solde': {
bg: 'var(--color-surface-2)',
ink: 'var(--color-ink-2)',
edge: 'var(--color-line-3)',
},
attente: {
bg: 'var(--color-warn-soft)',
ink: 'var(--color-warn-soft-ink)',
edge: 'var(--color-warn)',
},
};
export interface AbsenceBarProps {
kind: AbsenceKind;
label: string;
/** Durée déjà formatée, ex. « 5 j ». */
duration: string;
/** Index du premier jour couvert, 0 = lundi. */
startDay: number;
/** Nombre de jours couverts. */
span: number;
/** Nombre de colonnes de la grille. */
columns?: number;
className?: string;
}
/**
* Barre d'absence continue sur plusieurs jours.
*
* Positionnée en pourcentage de la largeur de grille plutôt qu'en colonnes CSS :
* la barre chevauche les cellules au lieu de s'y insérer, ce qui laisse voir
* qu'une absence est une période continue et non une suite de jours isolés.
*/
export function AbsenceBar({
kind,
label,
duration,
startDay,
span,
columns = 7,
className,
}: AbsenceBarProps) {
const tone = KINDS[kind];
return (
<div
className={cx(
'pointer-events-none absolute z-10 flex items-center gap-2 overflow-hidden rounded-2 px-2 py-0.5 text-micro font-medium',
className,
)}
style={{
left: `calc(100% / ${columns} * ${startDay} + 3px)`,
width: `calc(100% / ${columns} * ${span} - 6px)`,
background: tone.bg,
color: tone.ink,
border: `1px solid ${tone.edge}`,
}}
>
<span className="truncate">{label}</span>
<span className="tnum ml-auto shrink-0 opacity-80">{duration}</span>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { DeltaTone, WeekCountersView } from '@/domain/counters/week';
import { cx } from '@/lib/cx';
const DELTA_TONES: Record<DeltaTone, string> = {
flat: 'text-ink-1',
over: 'bg-warn-soft text-warn-soft-ink',
under: 'bg-danger-soft text-danger-soft-ink',
none: 'text-ink-3',
};
export interface CounterStripProps {
counters: WeekCountersView;
/** Vrai si le contrat est au forfait jours : pas de comparaison horaire. */
forfaitJours?: boolean;
className?: string;
}
function Cell({ label, value }: { label: string; value: string }) {
return (
<span className="flex items-baseline gap-1">
<span className="text-ink-3">{label}</span>
<span className="tnum text-ink-2">{value}</span>
</span>
);
}
/**
* Les cinq compteurs d'une ligne salarié : contrat, planifié, absences, écart,
* dimanches et repos. Rendus depuis `computeWeekCounters`, jamais recalculés
* localement.
*/
export function CounterStrip({
counters,
forfaitJours = false,
className,
}: CounterStripProps) {
if (forfaitJours) {
return (
<div className={cx('flex items-center gap-2 text-micro', className)}>
<span className="rounded-full bg-accent-soft px-1.5 py-px font-medium text-accent-soft-ink">
Forfait jours
</span>
<Cell label="Rep." value={String(counters.restDays)} />
</div>
);
}
return (
<div
className={cx('flex flex-wrap items-center gap-x-2 gap-y-px text-micro', className)}
>
<Cell label="Contr." value={counters.contractLabel} />
<span aria-hidden className="text-line-3">
·
</span>
<Cell label="Planif." value={counters.plannedLabel} />
<span
className={cx(
'tnum rounded-full px-1.5 py-px font-semibold',
DELTA_TONES[counters.tone],
)}
title="Écart au contrat"
>
{counters.deltaLabel}
</span>
<Cell label="Dim." value={String(counters.sundaysWorked)} />
<Cell label="Rep." value={String(counters.restDays)} />
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { posteShort, posteTokens, type PosteCode } from '@/lib/design/postes';
import { cx } from '@/lib/cx';
/**
* États d'un créneau, repris du design system.
*
* `alert` ajoute un liseré danger de 1,5 px : la couleur du poste occupe déjà
* le fond, l'alerte doit donc passer par la forme, pas par une seconde teinte
* qui entrerait en concurrence avec la palette catégorielle.
*/
export type ShiftState =
| 'published'
| 'validated'
| 'draft'
| 'unpublished'
| 'alert'
| 'unassigned';
export interface ShiftChipProps {
poste: PosteCode;
/** « 09:00–17:00 ». */
time: string;
state?: ShiftState;
/** Écart affiché en pastille, ex. « +2 h 55 ». */
delta?: string;
/** Motif de l'alerte, ex. « Repos < 11 h ». */
alert?: string;
/** Durée de pause, en minutes. */
breakMinutes?: number;
className?: string;
}
export function ShiftChip({
poste,
time,
state = 'published',
delta,
alert,
breakMinutes,
className,
}: ShiftChipProps) {
const tokens = posteTokens(poste);
const ghost = state === 'unassigned';
return (
<div
className={cx(
'flex min-w-0 flex-col gap-px rounded-2 px-1.5 py-1 text-micro',
state === 'draft' && 'border-dashed',
className,
)}
style={{
background: ghost ? 'transparent' : tokens.bg,
color: ghost ? 'var(--color-ink-2)' : tokens.fg,
border:
state === 'alert'
? '1.5px solid var(--color-danger)'
: ghost
? `1px dashed ${tokens.edge}`
: state === 'draft'
? `1px dashed ${tokens.edge}`
: state === 'unpublished'
? `1px dotted ${tokens.edge}`
: '1px solid transparent',
}}
>
<span className="flex items-center gap-1">
{/* Le code du poste est le second canal : la couleur ne suffit pas. */}
<span className="font-semibold tracking-wide">{posteShort(poste)}</span>
{state === 'validated' ? (
<span className="text-ok" title="Validé" aria-label="Validé">
✓
</span>
) : null}
{delta ? (
<span className="tnum ml-auto rounded-full bg-warn-soft px-1 font-semibold text-warn-soft-ink">
{delta}
</span>
) : null}
</span>
<span className="tnum truncate">
{time}
{breakMinutes ? (
<span className="opacity-70"> ({breakMinutes} mn)</span>
) : null}
</span>
{alert ? (
<span className="mt-px flex items-center gap-1 font-medium text-danger-soft-ink">
<span aria-hidden>▲</span>
<span className="truncate">{alert}</span>
</span>
) : null}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import type { ReactNode } from 'react';
import { cx } from '@/lib/cx';
export type Tone = 'neutral' | 'accent' | 'ok' | 'warn' | 'danger' | 'info';
const TONES: Record<Tone, string> = {
neutral: 'bg-surface-3 text-ink-2 border-line-2',
accent: 'bg-accent-soft text-accent-soft-ink border-accent',
ok: 'bg-ok-soft text-ok-soft-ink border-ok',
warn: 'bg-warn-soft text-warn-soft-ink border-warn',
danger: 'bg-danger-soft text-danger-soft-ink border-danger',
info: 'bg-info-soft text-info-soft-ink border-info',
};
export interface BadgeProps {
tone?: Tone;
/** Sans liseré : pour les compteurs discrets de la navigation. */
subtle?: boolean;
children: ReactNode;
className?: string;
}
/** Pastille compacte. Porte un état ou un décompte, jamais une action. */
export function Badge({
tone = 'neutral',
subtle = false,
children,
className,
}: BadgeProps) {
return (
<span
className={cx(
'inline-flex shrink-0 items-center rounded-full px-2 py-px text-micro font-semibold',
subtle ? 'border-transparent' : 'border',
TONES[tone],
className,
)}
>
{children}
</span>
);
}
+51
View File
@@ -0,0 +1,51 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cx } from '@/lib/cx';
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
export type ButtonSize = 'sm' | 'md';
const VARIANTS: Record<ButtonVariant, string> = {
primary:
'border-accent bg-accent text-accent-ink hover:bg-accent-hover active:bg-accent-press',
secondary:
'border-line-3 bg-surface text-ink-1 hover:bg-surface-2 active:bg-surface-3',
ghost:
'border-transparent bg-transparent text-ink-2 hover:bg-surface-2 hover:text-ink-1',
danger:
'border-danger bg-danger text-danger-ink hover:bg-danger-hover active:bg-danger-hover',
};
const SIZES: Record<ButtonSize, string> = {
sm: 'h-[26px] px-2.5 text-xs',
md: 'h-8 px-3.5 text-sm',
};
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
children: ReactNode;
}
export function Button({
variant = 'secondary',
size = 'md',
className,
type = 'button',
...rest
}: ButtonProps) {
return (
<button
type={type}
className={cx(
'inline-flex shrink-0 items-center justify-center gap-1.5 rounded-2 border font-medium',
'cursor-pointer transition-colors duration-[var(--d-1)] ease-organic',
'disabled:cursor-not-allowed disabled:opacity-50',
VARIANTS[variant],
SIZES[size],
className,
)}
{...rest}
/>
);
}
+67
View File
@@ -0,0 +1,67 @@
import type { ReactNode } from 'react';
import { cx } from '@/lib/cx';
export interface CardProps {
children: ReactNode;
className?: string;
}
export function Card({ children, className }: CardProps) {
return (
<section
className={cx(
'overflow-hidden rounded-3 border border-line-1 bg-surface',
className,
)}
>
{children}
</section>
);
}
export interface CardHeaderProps {
title: ReactNode;
/** Décompte ou statut affiché contre le titre. */
badge?: ReactNode;
action?: ReactNode;
}
export function CardHeader({ title, badge, action }: CardHeaderProps) {
return (
<header className="flex items-center gap-2.5 border-b border-line-1 px-4 py-3">
<h2 className="text-md font-semibold">{title}</h2>
{badge}
<span className="flex-1" />
{action}
</header>
);
}
export interface EmptyStateProps {
title: string;
description?: string;
className?: string;
}
export function EmptyState({ title, description, className }: EmptyStateProps) {
return (
<div
className={cx(
'flex flex-col items-center gap-2.5 px-6 py-16 text-center',
className,
)}
>
<span
aria-hidden
className="size-9 rounded-2 border-[1.5px] border-line-3 bg-surface-2"
/>
<p className="text-md font-semibold">{title}</p>
{description ? (
<p className="max-w-[46ch] text-sm leading-[var(--lh-prose)] text-ink-2">
{description}
</p>
) : null}
</div>
);
}