P1 fondation : tokens layerés, reset, thèmes (base/renovation/aep/tmip), 9 primitives Astro, pont Tailwind 4, démo kitchen sink

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 18:31:52 +02:00
commit 795ca7ed4e
28 changed files with 8043 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
---
/**
* BaseLayout — coquille HTML commune à tous les sites.
*
* Props :
* - title (string, requis) : <title> + og:title
* - description (string) : meta description + og:description
* - lang (string, 'fr') : attribut lang du <html>
* - ogImage (string) : URL absolue de l'image OG
* - canonical (string) : URL canonique
*
* Slots : default (contenu du <main>), header, footer, head (metas en plus).
*
* Le THÈME n'est pas importé ici : le site importe son theme/*.css dans son
* propre layout, à côté de <BaseLayout> (cf. README).
* Gestion clair/sombre : data-theme posé avant le premier paint depuis
* localStorage('tf-theme') ; helper global window.tfSetTheme('dark'|'light'|'auto').
*/
import '../tokens.css';
import '../reset.css';
interface Props {
title: string;
description?: string;
lang?: string;
ogImage?: string;
canonical?: string;
}
const { title, description, lang = 'fr', ogImage, canonical } = Astro.props;
---
<html lang={lang}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
{description && <meta name="description" content={description} />}
{canonical && <link rel="canonical" href={canonical} />}
<meta property="og:title" content={title} />
{description && <meta property="og:description" content={description} />}
<meta property="og:type" content="website" />
{ogImage && <meta property="og:image" content={ogImage} />}
<script is:inline>
(() => {
try {
const t = localStorage.getItem("tf-theme");
if (t === "dark" || t === "light") document.documentElement.dataset.theme = t;
} catch {}
document.documentElement.dataset.js = "";
})();
</script>
<slot name="head" />
</head>
<body>
<a class="tf-skip-link" href="#main">Aller au contenu</a>
<slot name="header" />
<main id="main"><slot /></main>
<slot name="footer" />
<script is:inline>
window.tfSetTheme = (mode) => {
const root = document.documentElement;
try {
if (mode === "auto") {
delete root.dataset.theme;
localStorage.removeItem("tf-theme");
} else {
root.dataset.theme = mode;
localStorage.setItem("tf-theme", mode);
}
} catch {}
};
</script>
</body>
</html>
<style>
.tf-skip-link {
position: absolute;
inset-inline-start: var(--space-md);
inset-block-start: var(--space-md);
z-index: var(--z-toast);
padding: var(--space-xs) var(--space-md);
background: var(--primary);
color: var(--on-primary);
border-radius: var(--radius-md);
text-decoration: none;
transform: translateY(-200%);
}
.tf-skip-link:focus-visible {
transform: none;
}
</style>

142
components/Button.astro Normal file
View File

@@ -0,0 +1,142 @@
---
/**
* Button — bouton ou lien-bouton.
*
* Props :
* - variant ('primary' | 'secondary' | 'ghost' | 'link', 'primary')
* - size ('sm' | 'md' | 'lg', 'md') — sur écran tactile, sm remonte à 44px
* - href (string) : rend un <a> au lieu d'un <button>
* - type ('button' | 'submit' | 'reset', 'button') — ignoré si href
* - disabled (boolean, false) — sur <a> : aria-disabled + href retiré
* - class (string) : classes additionnelles
* + tout autre attribut est passé tel quel (aria-*, data-*, target…)
*/
interface Props {
variant?: "primary" | "secondary" | "ghost" | "link";
size?: "sm" | "md" | "lg";
href?: string;
type?: "button" | "submit" | "reset";
disabled?: boolean;
class?: string;
[key: string]: unknown;
}
const {
variant = "primary",
size = "md",
href,
type = "button",
disabled = false,
class: className,
...rest
} = Astro.props;
const Tag = href ? "a" : "button";
const attrs = href
? disabled
? { "aria-disabled": "true", tabindex: "-1" }
: { href }
: { type, disabled: disabled || undefined };
---
<Tag
class:list={["tf-btn", `tf-btn--${variant}`, `tf-btn--${size}`, className]}
{...attrs}
{...rest}
>
<slot />
</Tag>
<style>
.tf-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
min-height: 2.75rem; /* 44px — cible tactile */
padding-inline: var(--space-lg);
border: 1px solid transparent;
border-radius: var(--radius-md);
font-family: var(--font-body);
font-size: var(--fs-base);
font-weight: var(--fw-medium);
line-height: 1;
text-decoration: none;
cursor: pointer;
transition: background-color var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out);
}
/* Tailles */
.tf-btn--sm {
min-height: 2.25rem;
padding-inline: var(--space-md);
font-size: var(--fs-sm);
}
.tf-btn--lg {
min-height: 3rem;
padding-inline: var(--space-xl);
font-size: var(--fs-lg);
}
/* Sur pointeur tactile, aucune taille ne descend sous 44px */
@media (pointer: coarse) {
.tf-btn--sm {
min-height: 2.75rem;
}
}
/* Variantes */
.tf-btn--primary {
background: var(--primary);
color: var(--on-primary);
}
.tf-btn--primary:hover {
background: var(--primary-hover);
color: var(--on-primary);
}
.tf-btn--secondary {
background: transparent;
color: var(--text);
border-color: var(--border-strong);
}
.tf-btn--secondary:hover {
background: var(--surface-2);
color: var(--text);
}
.tf-btn--ghost {
background: transparent;
color: var(--text);
}
.tf-btn--ghost:hover {
background: var(--surface-2);
color: var(--text);
}
.tf-btn--link {
background: transparent;
color: var(--link);
padding-inline: var(--space-2xs);
text-decoration: underline;
text-underline-offset: 0.2em;
}
.tf-btn--link:hover {
color: var(--link-hover);
}
/* États désactivés */
.tf-btn:disabled,
.tf-btn[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
</style>

71
components/Card.astro Normal file
View File

@@ -0,0 +1,71 @@
---
/**
* Card — surface de contenu, cliquable ou non.
*
* Props :
* - href (string) : rend toute la carte cliquable (<a>) + hover élévation légère
* - class (string) : classes additionnelles
*
* Slots : media (image/visuel pleine largeur en tête), default (corps).
* Pas de border-left accent (loi Impeccable) : la hiérarchie passe par la
* surface, la bordure fine et l'élévation au hover.
*/
interface Props {
href?: string;
class?: string;
}
const { href, class: className } = Astro.props;
const Tag = href ? "a" : "article";
---
<Tag class:list={["tf-card", { "tf-card--link": href }, className]} href={href}>
{
Astro.slots.has("media") && (
<div class="tf-card__media">
<slot name="media" />
</div>
)
}
<div class="tf-card__body"><slot /></div>
</Tag>
<style>
.tf-card {
display: flex;
flex-direction: column;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.tf-card--link {
color: inherit;
text-decoration: none;
transition: transform var(--dur-base) var(--ease-out),
box-shadow var(--dur-base) var(--ease-out),
border-color var(--dur-base) var(--ease-out);
}
.tf-card--link:hover {
color: inherit;
transform: translateY(-2px);
box-shadow: var(--shadow-md);
border-color: var(--border-strong);
}
.tf-card__media :global(img) {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
.tf-card__body {
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
</style>

View File

@@ -0,0 +1,31 @@
---
/**
* Container — largeur max + gouttières cohérentes.
*
* Props :
* - size ('base' | 'narrow', 'base') : --container-max ou 45rem (lecture)
* - class (string) : classes additionnelles
*/
interface Props {
size?: "base" | "narrow";
class?: string;
}
const { size = "base", class: className } = Astro.props;
---
<div class:list={["tf-container", { "tf-container--narrow": size === "narrow" }, className]}>
<slot />
</div>
<style>
.tf-container {
max-width: var(--container-max);
margin-inline: auto;
padding-inline: var(--container-pad);
}
.tf-container--narrow {
max-width: 45rem;
}
</style>

50
components/Footer.astro Normal file
View File

@@ -0,0 +1,50 @@
---
/**
* Footer — pied de page commun.
*
* Slots : default (colonnes / liens), legal (ligne basse : mentions, copyright).
*/
---
<footer class="tf-footer">
<div class="tf-footer__inner">
<div class="tf-footer__content"><slot /></div>
<div class="tf-footer__legal"><slot name="legal" /></div>
</div>
</footer>
<style>
.tf-footer {
margin-block-start: auto;
background: var(--bg-alt);
border-block-start: 1px solid var(--border);
color: var(--text-muted);
font-size: var(--fs-sm);
}
.tf-footer__inner {
max-width: var(--container-max);
margin-inline: auto;
padding-inline: var(--container-pad);
padding-block: var(--space-xl);
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.tf-footer__content {
display: flex;
flex-wrap: wrap;
gap: var(--space-lg) var(--space-2xl);
}
.tf-footer__legal {
border-block-start: 1px solid var(--border);
padding-block-start: var(--space-md);
color: var(--text-subtle);
}
.tf-footer__legal:empty {
display: none;
}
</style>

292
components/Nav.astro Normal file
View File

@@ -0,0 +1,292 @@
---
/**
* Nav — LA nav de référence de la constellation. Définie une seule fois ici.
*
* Comportement :
* - sticky en haut, se condense après 16px de scroll (padding réduit)
* - mobile (< 768px) : hamburger accessible — aria-expanded, aria-controls,
* fermeture Échap, focus trap dans le panneau ouvert, retour focus au bouton
* - sans JS : les liens restent visibles (rangée wrappée), rien n'est cassé
* - lien actif : aria-current="page" calculé depuis Astro.url.pathname
*
* Props :
* - links (NavLink[], requis) : { href, label }[]
* - label (string, 'Navigation principale') : aria-label du <nav>
*
* Slots : brand (logo / nom du site, à gauche), actions (CTA / toggle thème,
* rendu dans le panneau).
*/
export interface NavLink {
href: string;
label: string;
}
interface Props {
links: NavLink[];
label?: string;
}
const { links, label = "Navigation principale" } = Astro.props;
const current = Astro.url.pathname.replace(/\/$/, "") || "/";
const isActive = (href: string) => {
const h = href.replace(/\/$/, "") || "/";
return h === "/" ? current === "/" : current === h || current.startsWith(h + "/");
};
---
<header class="tf-nav" data-tf-nav>
<nav aria-label={label}>
<div class="tf-nav__inner">
<div class="tf-nav__brand"><slot name="brand" /></div>
<button
class="tf-nav__toggle"
type="button"
aria-expanded="false"
aria-controls="tf-nav-panel"
data-tf-nav-toggle
>
<span class="tf-nav__toggle-box" aria-hidden="true"
><span class="tf-nav__toggle-bar"></span></span
>
<span>Menu</span>
</button>
<div class="tf-nav__panel" id="tf-nav-panel" data-tf-nav-panel>
<ul class="tf-nav__list">
{
links.map((l) => (
<li>
<a href={l.href} aria-current={isActive(l.href) ? "page" : undefined}>
{l.label}
</a>
</li>
))
}
</ul>
<div class="tf-nav__actions"><slot name="actions" /></div>
</div>
</div>
</nav>
</header>
<style>
.tf-nav {
position: sticky;
inset-block-start: 0;
z-index: var(--z-nav);
background: var(--bg);
border-block-end: 1px solid var(--border);
}
.tf-nav__inner {
max-width: var(--container-max);
margin-inline: auto;
padding-inline: var(--container-pad);
padding-block: var(--space-md);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs) var(--space-lg);
transition: padding-block var(--dur-base) var(--ease-out);
}
.tf-nav.is-scrolled .tf-nav__inner {
padding-block: var(--space-xs);
}
.tf-nav__brand {
margin-inline-end: auto;
display: flex;
align-items: center;
min-height: 2.75rem;
font-family: var(--font-heading);
font-weight: var(--fw-semibold);
}
.tf-nav__brand :global(a) {
color: var(--text);
text-decoration: none;
}
/* Hamburger — caché tant que JS n'a pas pris la main, et toujours dès md */
.tf-nav__toggle {
display: none;
align-items: center;
gap: var(--space-xs);
min-height: 2.75rem;
min-width: 2.75rem;
padding: var(--space-2xs) var(--space-sm);
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-size: var(--fs-sm);
cursor: pointer;
}
.tf-nav__toggle-box {
display: block;
width: 1.125rem;
}
.tf-nav__toggle-bar,
.tf-nav__toggle-box::before,
.tf-nav__toggle-box::after {
content: "";
display: block;
height: 2px;
border-radius: var(--radius-full);
background: currentColor;
}
.tf-nav__toggle-bar {
margin-block: 4px;
}
.tf-nav__panel {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs) var(--space-md);
flex-basis: 100%;
}
.tf-nav__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--space-2xs) var(--space-xs);
}
.tf-nav__list a {
display: flex;
align-items: center;
min-height: 2.75rem;
padding-inline: var(--space-sm);
border-radius: var(--radius-md);
color: var(--text-muted);
text-decoration: none;
transition: color var(--dur-fast) var(--ease-out),
background-color var(--dur-fast) var(--ease-out);
}
.tf-nav__list a:hover {
color: var(--text);
background: var(--surface-2);
}
.tf-nav__list a[aria-current="page"] {
color: var(--text);
font-weight: var(--fw-semibold);
}
.tf-nav__actions {
display: flex;
align-items: center;
gap: var(--space-xs);
}
/* Mobile avec JS : hamburger visible, panneau replié en accordéon */
@media (max-width: 47.9375rem) {
:global(html[data-js]) .tf-nav__toggle {
display: inline-flex;
}
:global(html[data-js]) .tf-nav__panel {
display: none;
flex-direction: column;
align-items: stretch;
padding-block: var(--space-sm) var(--space-md);
}
:global(html[data-js]) .tf-nav.is-open .tf-nav__panel {
display: flex;
}
:global(html[data-js]) .tf-nav__list {
flex-direction: column;
align-items: stretch;
}
}
/* Desktop : panneau inline, jamais de hamburger */
@media (min-width: 48rem) {
.tf-nav__panel {
flex-basis: auto;
}
}
</style>
<script>
const nav = document.querySelector<HTMLElement>("[data-tf-nav]");
if (nav) {
const toggle = nav.querySelector<HTMLButtonElement>("[data-tf-nav-toggle]")!;
const panel = nav.querySelector<HTMLElement>("[data-tf-nav-panel]")!;
const mqDesktop = window.matchMedia("(min-width: 48rem)");
// Condensation au scroll (sticky-puis-compact)
let ticking = false;
const onScroll = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
nav.classList.toggle("is-scrolled", window.scrollY > 16);
ticking = false;
});
};
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
const isOpen = () => toggle.getAttribute("aria-expanded") === "true";
const focusables = () =>
Array.from(
panel.querySelectorAll<HTMLElement>("a[href], button:not([disabled])")
);
const close = (returnFocus = true) => {
toggle.setAttribute("aria-expanded", "false");
nav.classList.remove("is-open");
if (returnFocus) toggle.focus();
};
const open = () => {
toggle.setAttribute("aria-expanded", "true");
nav.classList.add("is-open");
focusables()[0]?.focus();
};
toggle.addEventListener("click", () => (isOpen() ? close() : open()));
nav.addEventListener("keydown", (e) => {
if (mqDesktop.matches || !isOpen()) return;
if (e.key === "Escape") {
e.preventDefault();
close();
return;
}
// Focus trap : Tab circule entre le bouton et le panneau ouvert
if (e.key === "Tab") {
const items = [toggle, ...focusables()];
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
});
panel.addEventListener("click", (e) => {
if ((e.target as HTMLElement).closest("a")) close(false);
});
mqDesktop.addEventListener("change", () => close(false));
}
</script>

129
components/Prose.astro Normal file
View File

@@ -0,0 +1,129 @@
---
/**
* Prose — enveloppe de contenu long (article, page éditoriale).
* Style tout le HTML slotté (titres, listes, blockquote, code, images, tables)
* depuis les tokens. Largeur de lecture confortable (65ch).
*
* Props :
* - class (string) : classes additionnelles
*/
interface Props {
class?: string;
}
const { class: className } = Astro.props;
---
<div class:list={["tf-prose", className]}>
<slot />
</div>
<style>
.tf-prose {
max-width: 65ch;
font-size: var(--fs-base);
line-height: var(--lh-loose);
}
.tf-prose > :global(* + *) {
margin-block-start: var(--space-md);
}
.tf-prose :global(h1) {
font-size: var(--fs-3xl);
}
.tf-prose :global(h2) {
font-size: var(--fs-2xl);
margin-block-start: var(--space-xl);
}
.tf-prose :global(h3) {
font-size: var(--fs-xl);
margin-block-start: var(--space-lg);
}
.tf-prose :global(h4) {
font-size: var(--fs-lg);
}
.tf-prose :global(ul),
.tf-prose :global(ol) {
padding-inline-start: 1.5em;
display: flex;
flex-direction: column;
gap: var(--space-2xs);
}
.tf-prose :global(li::marker) {
color: var(--text-subtle);
}
.tf-prose :global(blockquote) {
background: var(--surface-2);
border-radius: var(--radius-md);
padding: var(--space-md) var(--space-lg);
font-style: italic;
color: var(--text-muted);
}
.tf-prose :global(blockquote cite) {
display: block;
margin-block-start: var(--space-xs);
font-style: normal;
font-size: var(--fs-sm);
color: var(--text-subtle);
}
.tf-prose :global(code) {
background: var(--surface-2);
padding: 0.15em 0.4em;
border-radius: var(--radius-sm);
font-size: 0.9em;
}
.tf-prose :global(pre) {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-md) var(--space-lg);
overflow-x: auto;
}
.tf-prose :global(pre code) {
background: none;
padding: 0;
}
.tf-prose :global(img) {
border-radius: var(--radius-md);
}
.tf-prose :global(hr) {
border: none;
border-block-start: 1px solid var(--border);
margin-block: var(--space-xl);
}
.tf-prose :global(table) {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-sm);
}
.tf-prose :global(th),
.tf-prose :global(td) {
text-align: start;
padding: var(--space-xs) var(--space-sm);
border-block-end: 1px solid var(--border);
}
.tf-prose :global(th) {
font-weight: var(--fw-semibold);
color: var(--text-muted);
}
.tf-prose :global(a) {
text-underline-offset: 0.2em;
}
</style>

49
components/Reveal.astro Normal file
View File

@@ -0,0 +1,49 @@
---
/**
* Reveal — apparition au scroll (fade + léger translate), sobre.
*
* Props :
* - delay (number, 0) : décalage en ms (pour échelonner une grille)
* - class (string) : classes additionnelles
*
* Motion piloté-utilisateur : déclenché par le scroll (IntersectionObserver).
* Sous prefers-reduced-motion: reduce → contenu visible immédiatement, aucun
* mouvement. Sans JS → rien n'est masqué (garde html[data-js]).
*/
interface Props {
delay?: number;
class?: string;
}
const { delay = 0, class: className } = Astro.props;
---
<div
class:list={["tf-reveal", className]}
data-reveal
style={delay ? `--reveal-delay: ${delay}ms` : undefined}
>
<slot />
</div>
<style>
@media (prefers-reduced-motion: no-preference) {
:global(html[data-js]) .tf-reveal {
opacity: 0;
translate: 0 12px;
transition: opacity var(--dur-slow) var(--ease-out),
translate var(--dur-slow) var(--ease-out);
transition-delay: var(--reveal-delay, 0ms);
}
:global(html[data-js]) .tf-reveal.is-visible {
opacity: 1;
translate: 0 0;
}
}
</style>
<script>
import { initReveal } from "../js/reveal.js";
initReveal();
</script>

40
components/Section.astro Normal file
View File

@@ -0,0 +1,40 @@
---
/**
* Section — rythme vertical cohérent entre blocs de page.
*
* Props :
* - alt (boolean, false) : fond alterné (--bg-alt) pour rythmer la page
* - size ('base' | 'lg', 'base') : padding vertical --space-2xl ou --space-3xl
* - id (string) : ancre
* - class (string) : classes additionnelles
*/
interface Props {
alt?: boolean;
size?: "base" | "lg";
id?: string;
class?: string;
}
const { alt = false, size = "base", id, class: className } = Astro.props;
---
<section
class:list={["tf-section", { "tf-section--alt": alt, "tf-section--lg": size === "lg" }, className]}
id={id}
>
<slot />
</section>
<style>
.tf-section {
padding-block: var(--space-2xl);
}
.tf-section--lg {
padding-block: var(--space-3xl);
}
.tf-section--alt {
background: var(--bg-alt);
}
</style>