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:
94
components/BaseLayout.astro
Normal file
94
components/BaseLayout.astro
Normal 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
142
components/Button.astro
Normal 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
71
components/Card.astro
Normal 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>
|
||||
31
components/Container.astro
Normal file
31
components/Container.astro
Normal 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
50
components/Footer.astro
Normal 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
292
components/Nav.astro
Normal 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
129
components/Prose.astro
Normal 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
49
components/Reveal.astro
Normal 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
40
components/Section.astro
Normal 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>
|
||||
Reference in New Issue
Block a user