<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Css | Julien Maxant</title><link>https://www.julien-maxant.com/tags/css/</link><description>Articles, projets et veille sur le thème « Css ».</description><generator>Hugo -- gohugo.io</generator><language>fr-FR</language><copyright>2026 - Julien Maxant</copyright><lastBuildDate>Wed, 23 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://www.julien-maxant.com/tags/css/index.xml" rel="self" type="application/rss+xml"/><item><title>Compiler sa propre police variable : le cas Spectral</title><link>https://www.julien-maxant.com/blog/construire-une-police-variable/</link><guid>https://www.julien-maxant.com/blog/construire-une-police-variable/</guid><pubDate>Wed, 23 Sep 2026 00:00:00 +0000</pubDate><description>Pas de version variable officielle pour la police serif de ce site : reconstruire un font variable à partir des sources, quatre fois plus léger que les fichiers statiques.</description><content:encoded><![CDATA[<p>Ce site sert la police serif Spectral en 2 fichiers <code>.woff2</code> de ~64 Ko chacun. Avant,
c&rsquo;était 6 fichiers statiques pour ~532 Ko au total — un par graisse (light, regular,
semibold) et par style (romain, italique). Le problème : Spectral n&rsquo;a <strong>aucune version
variable officielle</strong>. Google Fonts ne la sert qu&rsquo;en statique. Il a donc fallu la
compiler soi-même à partir des sources.</p>
<h2 id="statique-vs-variable-en-une-phrase">Statique vs variable, en une phrase</h2>
<p>Une police &ldquo;statique&rdquo; est un fichier par graisse : un pour le 300, un pour le 400, un
pour le 600. Une police &ldquo;variable&rdquo; encode un axe continu (<code>wght</code>) dans un seul
fichier — le navigateur interpole entre les graisses définies plutôt que de charger
un fichier différent pour chacune. Sur ce site, <code>--font-weight-light</code> (300),
<code>--font-weight-normal</code> (400) et <code>--font-weight-bold</code> (600) tiennent donc dans un seul
fichier par style, romain et italique.</p>
<h2 id="pas-de-release-officielle-mais-des-sources-buildables">Pas de release officielle, mais des sources buildables</h2>
<p>Les sources UFO (le format d&rsquo;échange des designers de fontes) de Spectral sont
publiques, maintenues par Google, dans
<a href="https://github.com/googlefonts/spectral"><code>googlefonts/spectral</code></a> (licence OFL-1.1).
Elles incluent deux fichiers <code>.designspace</code> — un pour le romain, un pour l&rsquo;italique —
qui décrivent comment interpoler entre les masters de graisse. Le pipeline de build
officiel du dépôt ne produit que la release statique et dépend d&rsquo;une demi-douzaine
d&rsquo;outils externes (<code>afdko</code>, <code>ttfautohint</code>&hellip;). Recompiler la même source avec
<code>fontmake -o variable</code> produit une police variable, avec seulement <code>fontmake</code> et
<code>fonttools</code> — les deux seuls outils dont ce projet a besoin (<code>docs/fonts.md</code>).</p>
<p>Comme Debian empêche un <code>pip install</code> au niveau système et que le module <code>venv</code> n&rsquo;est
pas toujours présent sans <code>sudo</code>, la toolchain Python vit dans un venv local créé par
<a href="https://docs.astral.sh/uv/"><code>uv</code></a> (<code>task setup:fonts</code>), avec les versions figées dans
<code>scripts/fonts/requirements.txt</code> — le même principe que <code>package.json</code> pour les
dépendances JS.</p>
<h2 id="ne-garder-que-ce-qui-sert">Ne garder que ce qui sert</h2>
<p>Rien n&rsquo;oblige à livrer la police telle que ses sources la définissent :</p>
<ul>
<li><strong>L&rsquo;axe de graisse</strong> couvre 200 à 800 en amont ; seuls 300/400/600 sont utilisés
dans ce projet. Après le premier build, <code>fonttools varLib.instancer</code> restreint
l&rsquo;axe à 300–600 — la police reste variable, juste sur une plage plus étroite.</li>
<li><strong>Le jeu de glyphes</strong> couvre en amont le cyrillique, le grec, l&rsquo;arménien, le
géorgien — 1481 glyphes, aucun atteignable par du contenu français (ou anglais).
<code>fonttools subset</code> réduit à Latin + Latin-1 + Latin étendu A/B + ponctuation
générale + <code>€</code>, soit 810 glyphes — de quoi couvrir le français, l&rsquo;anglais, et une
marge pour la plupart des langues latines (polonais, tchèque, turc&hellip;).</li>
</ul>
<p>Combiné, l&rsquo;axe restreint et le sous-ensemble de glyphes font passer romain + italique
de ~532 Ko à ~128 Ko : environ 4 fois plus léger, pour un rendu strictement identique
sur tout ce que le site affiche réellement.</p>
<h2 id="trois-bugs-de-compilation-qui-ne-se-voient-quà-lusage">Trois bugs de compilation qui ne se voient qu&rsquo;à l&rsquo;usage</h2>
<p>Les sources brutes ne compilent pas telles quelles :</p>
<ol>
<li><strong>L&rsquo;axe <code>wght</code> n&rsquo;a pas de correspondance déclarée.</strong> En interne, les coordonnées de
design de Spectral ne sont pas linéaires — le SemiBold est stocké à la position 440
plutôt qu&rsquo;à 600. Sans <code>&lt;map&gt;</code> pour traduire les valeurs standard OpenType
(200/300&hellip;/800) vers ces coordonnées internes, une règle CSS comme
<code>font-weight: 300 600</code> aurait fini par pointer vers la mauvaise zone de l&rsquo;espace de
design. Un <code>&lt;map&gt;</code> a été ajouté aux deux <code>.designspace</code> avant le build.</li>
<li><strong>Des glyphes structurellement incompatibles entre masters</strong> (quelques glyphes
cyrilliques, des compositions d&rsquo;accents) — jamais un problème pour le pipeline
statique de Spectral, qui n&rsquo;a jamais besoin d&rsquo;interpoler entre eux. Plutôt que de
les supprimer (ce qui aurait cassé des références dans <code>features.fea</code>), leurs
contours ont été rendus identiques entre masters en copiant ceux du master
Regular — sans effet sur le contenu réel du site.</li>
<li><strong>Une entrée cmap ambiguë</strong> : <code>apostrophemod</code> et <code>uni02BC</code> réclamaient le même
point de code (U+02BC), ce que <code>ufo2ft</code> refuse. Le point de code a été retiré de
l&rsquo;un des deux.</li>
</ol>
<p>Vérification avant commit — que l&rsquo;axe <code>fvar</code> reflète bien 300/400/600 et pas les
coordonnées internes brutes :</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-py" data-lang="py"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">fontTools.ttLib</span> <span class="kn">import</span> <span class="n">TTFont</span>
</span></span><span class="line"><span class="cl"><span class="n">f</span> <span class="o">=</span> <span class="n">TTFont</span><span class="p">(</span><span class="s1">&#39;assets/styles/fonts/Spectral-Variable.woff2&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">a</span> <span class="o">=</span> <span class="n">f</span><span class="p">[</span><span class="s1">&#39;fvar&#39;</span><span class="p">]</span><span class="o">.</span><span class="n">axes</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">a</span><span class="o">.</span><span class="n">axisTag</span><span class="p">,</span> <span class="n">a</span><span class="o">.</span><span class="n">minValue</span><span class="p">,</span> <span class="n">a</span><span class="o">.</span><span class="n">defaultValue</span><span class="p">,</span> <span class="n">a</span><span class="o">.</span><span class="n">maxValue</span><span class="p">)</span></span></span></code></pre></div><h2 id="deux-fichiers-pas-un">Deux fichiers, pas un</h2>
<p>Une police variable peut en théorie combiner plusieurs axes — graisse <em>et</em> italique
dans un seul fichier, avec un axe <code>ital</code>. Les sources de Spectral n&rsquo;en ont pas :
chaque <code>.designspace</code> ne porte qu&rsquo;un axe <code>wght</code>. Fusionner romain et italique
demanderait de retravailler les sources UFO elles-mêmes pour leur ajouter un axe —
un travail de conception de police, pas de compilation, et fragile à maintenir face
aux mises à jour amont. Deux fichiers restent le plancher réaliste ici, contre six
avant.</p>
<h2 id="licence--pas-de-renommage-nécessaire">Licence : pas de renommage nécessaire</h2>
<p>Le nom d&rsquo;une police modifiée ne doit être changé, sous OFL, que si la licence
originale déclare un <em>Reserved Font Name</em> — pas systématiquement à chaque
modification. <code>assets/styles/fonts/OFL.txt</code> n&rsquo;en déclare aucun : rien ne suit la
ligne de copyright, et &ldquo;Spectral&rdquo; n&rsquo;apparaît nulle part ailleurs dans le texte de
licence. Cette compilation reste une &ldquo;Modified Version&rdquo; au sens de l&rsquo;OFL (reconstruite
depuis les sources, axe remappé, glyphes normalisés et sous-ensemble), mais conserve
légitimement le nom &ldquo;Spectral&rdquo; — dans les métadonnées internes de la police comme
dans <code>--font-serif</code>.</p>
]]></content:encoded></item><item><title>Portfolio Hugo</title><link>https://www.julien-maxant.com/projets/portfolio-hugo/</link><guid>https://www.julien-maxant.com/projets/portfolio-hugo/</guid><pubDate>Sun, 12 Jul 2026 00:00:00 +0000</pubDate><description>Étude de cas : le site que vous consultez — pipeline CSS et qualité outillés pour tenir une baseline navigateurs et un contraste AAA sans y repasser à l'œil à chaque changement.</description><content:encoded><![CDATA[<p><strong>En bref :</strong> un site que je peux faire évoluer sans craindre de casser l&rsquo;existant.
Accessibilité, cohérence visuelle et qualité du build sont vérifiées automatiquement à
chaque modification, plutôt que relues à la main.</p>
<p>Techniquement : Hugo sans thème (layouts et partials écrits à la main), CSS sur mesure
sans framework, pipeline qualité (lint, tokens, contraste, build) qui tourne
à l&rsquo;identique en local et en CI.</p>
<h2 id="pourquoi-hugo">Pourquoi Hugo</h2>
<p>Projet perso avec un double objectif : une vitrine professionnelle pour recruteurs et
clients freelance, et un terrain d&rsquo;entraînement pour monter en compétence sur du
templating Go, du CSS sans dépendance, et une chaîne CI/CD tenue de bout en bout — sans
sacrifier la lisibilité du contenu à la démonstration technique.</p>
<p>Un générateur de site statique répond aux deux à la fois. Côté vitrine, la cible
(recruteurs, agents de recherche) n&rsquo;a pas de rendu JS à franchir pour indexer le
contenu, contrairement à une SPA. Côté apprentissage, le HTML/CSS produit reste la
sortie principale — pas de couche framework JS entre l&rsquo;auteur et le résultat, donc
chaque décision (tokens, contraste, breakpoints) reste visible et vérifiable dans la
feuille de style elle-même plutôt que dissoute dans un système de composants.</p>
<h2 id="contexte">Contexte</h2>
<ul>
<li>Site personnel, parti d&rsquo;un squelette : thème installé, contenu en Lorem Ipsum, une
seule page <code>content/_index.md</code></li>
<li>Aucune contrainte de delivery externe — le calendrier est le seul arbitre du scope</li>
<li>Exigence posée dès la Phase 0 (avant tout contenu) : les quality gates et la CI
existent avant que le contenu s&rsquo;accumule, pas après</li>
<li>Cible recruteurs/clients : le site doit rester une vitrine lisible, pas seulement un
prétexte à empiler des scripts de vérification</li>
</ul>
<h2 id="les-contraintes">Les contraintes</h2>
<ul>
<li>Pas de framework CSS, pas de PostCSS, pas d&rsquo;autoprefixer — seul <code>css.Build</code> (esbuild,
natif Hugo) prend en charge la transpilation et les préfixes vendeur</li>
<li>Une baseline navigateurs explicite (Chrome 105+, Firefox 121+, Safari 16+, Edge 105+)
à tenir, alors que deux mécanismes différents la couvrent : transpilation de syntaxe
d&rsquo;un côté, blocage de features runtime non transpilables de l&rsquo;autre</li>
<li>Contraste ciblé à AAA (7:1) plutôt que le AA/RGAA (4.5:1), sans dérive silencieuse
tolérée à mesure que la palette ou les composants évoluent</li>
<li>Aucune valeur de couleur, d&rsquo;espacement ou de breakpoint écrite en dur dans un
composant — tout doit venir d&rsquo;un token déclaré une seule fois</li>
<li>Le hook pre-commit local et la CI doivent exécuter la même définition, pour qu&rsquo;aucun
contrôle qualité ne puisse diverger entre les deux</li>
</ul>
<h2 id="les-choix-écartés">Les choix écartés</h2>
<ul>
<li>Une revue manuelle du contraste à chaque changement de palette : tolérable une fois,
pas répétable sans dérive — d&rsquo;autant que la palette est passée de AA à AAA en cours
de route, avec des marges initiales aussi fines qu&rsquo;entre 7,00 et 7,06</li>
<li>Une exemption CSS ajoutée « au cas où » dans la config Stylelint plutôt que prouvée :
une feature ignorée sans vérification empirique dans <code>public/</code> reste ignorée même
quand la baseline évolue et que le support natif la couvre déjà</li>
<li>Dupliquer la logique de vérification entre pre-commit et CI (deux configs qui
divergent tôt ou tard) plutôt qu&rsquo;une définition unique appelée par les deux</li>
</ul>
<h2 id="décisions-techniques">Décisions techniques</h2>
<ul>
<li><strong>Tokens CSS à deux niveaux d&rsquo;indirection</strong> : palettes brutes (<code>--light-*</code>,
<code>--dark-*</code>) jamais consommées directement par un composant, tokens sémantiques
(<code>--color-surface</code>, <code>--color-text-soft</code>…) seuls exposés. Le dark mode change en
réassignant les tokens sémantiques dans un seul fichier, sans toucher aux
composants.</li>
<li><strong>Règle « zéro valeur en dur » appliquée par script</strong>, pas seulement documentée :
<code>check-tokens.mjs</code> échoue sur toute couleur, taille ou durée littérale hors
<code>base/tokens.css</code> ; l&rsquo;échappatoire est un commentaire <code>token-exception</code> justifié
inline, jamais un ajout silencieux à une liste d&rsquo;ignore.</li>
<li><strong>Contraste vérifié automatiquement</strong> : <code>check-contrast.mjs</code> lit les valeurs hex
directement dans <code>tokens.css</code> (aucune valeur dupliquée dans le script) et calcule
chaque paire de couleurs, texte à 7:1, composants à 3:1. Un token de couleur non
couvert par une paire est aussi un échec — sinon un token ajouté plus tard n&rsquo;est
simplement jamais mesuré.</li>
<li><strong>Breakpoints déclaratifs mais vérifiés à l&rsquo;exécution</strong> : les media queries ne
peuvent pas lire une custom property, donc les valeurs (<code>768px</code>, <code>576px</code>) restent en
dur — mais <code>check-breakpoints.mjs</code> échoue sur une largeur qui ne correspond à aucun
token <code>--bp-*</code>, et sur un token que plus aucune query n&rsquo;utilise.</li>
<li><strong>Deux rôles distincts pour tenir la baseline navigateurs</strong> : <code>css.Build</code> transpile
la syntaxe (nesting, media query range syntax) à la compilation ; Stylelint
(<code>stylelint-no-unsupported-browser-features</code>) bloque au lint les features runtime
qu&rsquo;aucun transpileur ne peut simuler (container queries, <code>subgrid</code>). Chaque entrée de
la liste d&rsquo;ignore Stylelint est justifiée par une vérification dans <code>public/</code> après
build, pas supposée — deux entrées obsolètes (<code>:has()</code>, <code>scroll-behavior</code>) ont été
retirées une fois la baseline remontée et le support natif confirmé.</li>
<li><strong><code>lefthook.yml</code> comme unique source de vérité qualité</strong> : le hook pre-commit local
tourne sur les fichiers stagés, la CI appelle la même commande sur l&rsquo;ensemble des
fichiers trackés — aucune règle qualité ne peut exister dans l&rsquo;un sans exister dans
l&rsquo;autre.</li>
<li><strong>Contenu piloté par cascade Hugo plutôt que par template dédié</strong> : la section
<code>/veille/</code> (teaser-only) utilise <code>build.render = 'link'</code> en cascade pour rester dans
les collections (donc alimenter les pages <code>/tags/*</code>) sans générer de page de détail —
contre <code>render = 'never'</code>, qui exclurait l&rsquo;entrée de toute collection. Comportement
vérifié après build (<code>--cleanDestinationDir</code>) plutôt que supposé : aucune page
<code>public/veille/&lt;entrée&gt;/</code> générée, sitemap propre, RSS global exempt.</li>
</ul>
<h2 id="résultat">Résultat</h2>
<p>Pas de métrique de production comparable au cas Drupal — c&rsquo;est un site personnel, pas
un site à trafic. L&rsquo;angle est différent : une CI qui tolère zéro <code>WARN</code> Hugo, un
contraste et des tokens vérifiés par script plutôt que revus à l&rsquo;œil, et un pre-commit
qui ne peut pas diverger de la CI par construction. Le pipeline qualité tient à jour
avec le contenu, pas après coup.</p>
]]></content:encoded></item></channel></rss>