Framework graphique solarpunk
Une identité graphique écrite une fois, puis dérivée pour chaque support au lieu d'être recopiée : un site, une application Android, un terminal, une feuille imprimée. Ce qu'on recopie à la main finit toujours par diverger.
Une tessera est la pièce unitaire d'un vitrail : un morceau de verre serti dans le plomb. Tout ce que l'outil produit est fait de ça. Une description en quinze lignes de YAML devient un SVG, puis un thème web, un VectorDrawable Android, une illustration imprimable.
Le fond de ce bloc en est un : une scène composée,
seedée, dont la règle est que le milieu reste vide — c'est là que ce
paragraphe est écrit. Il n'a pas de viewBox, donc ses
massifs sont ancrés aux coins et non étirés, et il est inliné
dans la page : posé en background-image il serait un
document séparé, n'hériterait d'aucune custom property, et les quatre
boutons ci-dessous ne le repeindraient pas.
Le principe
Tessera n'est pas une banque d'images. C'est une grammaire de style, un
vocabulaire de motifs et de rôles de couleur, et des générateurs
déterministes. Même source et même seed donnent la même image,
au bit près, ce qui permet à un humain de reproduire et à un agent d'itérer.
Un LLM ne sait pas dessiner, mais il écrit très bien du YAML qui respecte un schéma. Un humain écrit le même fichier.
Uniquement ce qu'un VectorDrawable Android sait représenter :
des path, des remplissages, des dégradés à deux arrêts. Ni
filtre, ni masque, ni texte.
Web, Android, raster. Chaque sortie embarque sa source : une image retrouvée dans dix ans se régénère.
La grammaire
Sept règles, chacune tirée d'une référence précise plutôt que d'un goût général. Elles contraignent la façon de dessiner, pas seulement le rendu.
check.papier, crépusculeLa palette
Les couleurs sont décrites en OKLCH, où deux teintes de même clarté se
lisent comme également claires. Un site, une application ou une icône ne
référence jamais leaf : elle référence ok.
Changer de schéma ne change donc aucun fichier consommateur.
D'autres palettes
Trois palettes complètes, toutes acceptées par
tessera palette check : mêmes rôles, mêmes règles de
contraste, mêmes seuils. holly est rouge et vert, la
combinaison classique du vitrail — et le pire cas possible, puisque
rouge et vert sont les deux bouts de l'axe que la dichromatie efface.
harbor est sa contre-épreuve sur l'axe bleu-jaune, celui
qu'elle conserve.
Les trois passent check sans une erreur, et
c'est bien le sujet : les seuils de contraste ne disent rien de ce qui
se passe quand un axe disparaît. La distance affichée sous chaque palette
est celle que palette check --json mesure entre ok
et error sous deutéranopie — elle n'est pas recalculée ici.
Cliquez sur l'une d'elles : la page entière change
de palette, y compris la planche de couleurs et la simulation de la vision
des couleurs plus bas. Rien n'est recalculé à la volée — chaque palette est
dérivée par palette build et sa feuille est simplement portée
sur son propre sélecteur.
Les fichiers sont dans palettes/ :
tessera palette check palettes/holly.yaml les juge comme il
juge la nôtre.
Les quatre schémas
Le sélecteur en haut de page n'en montre qu'un à la fois, et c'est
justement ce qui cache l'essentiel. Les quatre vignettes ci-dessous sont
le même fragment de HTML, au mot près : seul l'attribut
data-scheme change. Aucune couleur n'y est écrite, elles ne
référencent que des rôles — c'est ce que veut dire « changer de schéma ne
touche aucun fichier consommateur ».
noon
Du texte de corps, un lien, et les quatre statuts — chacun avec son glyphe.
dusk
Du texte de corps, un lien, et les quatre statuts — chacun avec son glyphe.
night
Du texte de corps, un lien, et les quatre statuts — chacun avec son glyphe.
paper
Du texte de corps, un lien, et les quatre statuts — chacun avec son glyphe.
Le quatrième, paper, est à une encre :
le verre n'y est pas peint et il ne reste que les traits. C'est le gabarit
de pyrogravure — et le test d'accessibilité, puisqu'une interface lisible à
une encre reste lisible sans vision des couleurs.
L'accessibilité
Le contraste contre le fond, que la section précédente mesure, est la couche la plus superficielle. La vraie question n'est pas « ce texte se lit-il sur ce fond » mais « ces deux choses qui veulent dire des choses différentes se distinguent-elles l'une de l'autre ».
L'œil ne mesure pas une longueur d'onde : il compare trois
réponses de cônes, que le cerveau recombine en une clarté et deux axes,
rouge–vert et bleu–jaune. Il manque un cône ? Ce n'est pas une
couleur qui disparaît, c'est un axe entier. Les quatre rôles de
statut ci-dessous sont simulés en direct, avec les matrices de Viénot,
Brettel et Mollon appliquées en sRGB linéaire — le même calcul que
tessera palette check.
Un statut est un glyphe et une couleur, jamais une couleur seule.
Ce n'est pas une précaution, c'est un résultat. Le plomb
doit garder 4,5 de contraste contre chaque pièce de verre, ce qui interdit
les remplissages sous l ≈ 0,60 ; il reste environ 0,30 de
plage de clarté. Quatre rôles séparés de 0,12 chacun en réclament 0,36.
Ça ne rentre pas — et sans vision des couleurs, la clarté est tout ce qui
reste. Aucun choix de couleurs ne répare ça, donc la forme porte ce que la
couleur ne peut pas porter. C'est aussi la seconde vie du schéma
paper : une interface lisible à une encre est une
interface lisible sans vision des couleurs.
La typographie
Tessera ne dessine pas de police et n'écrit pas de texte dans ses
SVG — une icône qui porte du texte ne se traduit pas en VectorDrawable
sans embarquer la fonte. Mais il nomme les caractères, exactement
comme il nomme les couleurs : un consommateur ne référence jamais
Fraunces, il référence display, comme il
référence ok et jamais leaf.
Trois rôles suffisent. Le choix des fontes est un défaut, que
chacun surcharge dans son propre tessera.yaml ; ce qui ne
se surcharge pas, c'est l'obligation de déclarer un fallback et une licence.
La ligne coup de fouet, mais dans une fonte.
Les deux échantillons sont le même fichier, à deux
réglages d'axes près. WONK fait partir les terminaisons de
travers, SOFT adoucit les extrémités : c'est la même
asymétrie de tension que le paramètre whip du motif
stem. C'est pour ça que Fraunces est là, et c'est ça qu'il
faudra chercher dans son remplaçant — pas « une serif à fort
contraste ».
linen et dusk, pas avec du blanc pur : la
lumière chaude appliquée au texte.Alegreya Sans — OFL 1.1, fallback system-ui!= doit
rester deux caractères.IBM Plex Mono — OFL 1.1, fallback ui-monospaceLa scale — base 17 px, ratio 1,25
Les tailles sont engendrées, pas choisies : une valeur
écrite à la main se détecte en recalculant son step. La measure du texte de
corps est celle du tableau ci-dessus, dans la plage 45–75 que
type check impose — c'est le défaut le plus fréquent d'un thème.
Les trois familles sont servies depuis ce dépôt et non depuis Google. Une page qui appelle un tiers à chaque visite n'est pas autonome, et cesse de s'afficher correctement le jour où le tiers s'arrête ; la spécification refuse d'ailleurs toute dépendance à l'exécution. Coût réel pour un lecteur francophone : 217 Kio, les plages latines seules.
La prose
Les éléments que produit le markdown sont une quinzaine, et la liste est close depuis vingt ans : titres, paragraphes, listes, citation, code, tableau, lien. C'est la même forme que le vocabulaire de motifs — court, fermé, donc dérivable vers chaque cible sans arbitrage humain à chaque fois.
Il est aujourd'hui stylé quatre fois à la main, dans quatre
langages : les classes prose de Tailwind pour un blog,
un markdownTypography() en Kotlin pour Android, des styles
ratatui pour un terminal, du CSS de carte pour Anki. Aucun des quatre ne
sait ce que font les trois autres.
Ce bloc est rendu avec les tokens ci-dessus, et rien d'autre. Le sélecteur de schéma en haut de page le reteint comme le reste.
Un filet de citation est du plomb : épais, uniforme, de la
couleur outline. Jamais un dégradé, jamais un filet fin.
link, qui est le seul rôle coloré
autorisé à porter du texte.Et un bloc de code est une pièce de verre : il porte son plomb et son aplat, et il défile horizontalement plutôt que de couper ou de replier. Une ligne de cent caractères sur un écran de 400 dp n'a pas d'autre issue — essayez de faire glisser celle-ci.
# Horizontal scroll is not a cop-out: it is the only honest way out.
fn status_indistinct_under_cvd(palette: &Palette, scheme: &str) -> Vec<Finding> {
// Eight token families cannot be told apart by colour alone.
let threshold = 0.10; // five times the perception threshold
}
Un token de code est en gras ou en italique et en couleur, jamais en couleur seule.
Même démonstration que pour les statuts, en pire :
huit familles de tokens séparées de 0,12 réclameraient 0,96 de plage de
clarté là où le plomb en laisse 0,30. Et il y a plus concret encore —
mesuré sur cette palette, aucun rôle coloré ne tient comme texte
sur surface en schéma noon : ok
fait 2,44, accent_alt 2,71, accent 1,09. Les rôles
colorés sont des remplissages clairs, contraints par le plomb ; posés
sur un fond clair ils ne sont pas du texte. Le bloc ci-dessus n'a donc que
deux encres et quatre distinctions, et c'est exactement pourquoi il reste
lisible en paper. Lui donner des couleurs demanderait des
rôles de texte qui n'existent pas encore : c'est Q6.
Les icônes
Une icône n'est pas un chemin SVG écrit à la main, mais une
composition de motifs : stem, leaf,
sun, hex, wave,
soil. C'est ce qui garantit le style quel que soit
l'auteur, humain ou agent — et c'est vérifiable, puisque
lint compte les fois où il a fallu sortir du vocabulaire.
Celles-ci sont rendues par tessera icon render depuis
icons/*.yaml, et elles sont tracées en
currentColor : une icône line n'a pas de
verre à séparer, elle prend donc la couleur du texte là où elle se pose.
C'est aussi ce qui la fait survivre au mode contraste forcé, où un
remplissage écrit en dur disparaît.
Chacune est montrée deux fois : en grand, et à 24 px — la taille à laquelle elle sert réellement. C'est pour cette seconde ligne que la règle existe : aucune pièce sous deux fois le plomb, faute de quoi le contour se referme sur elle et il ne reste qu'une tache.
Vingt-deux icônes, dont seize de besoins ordinaires — accueil, retour, valider, refuser, ignorer, rechercher. Une seule a eu besoin de l'échappatoire : le triangle d'avertissement, que ni l'hexagone ni la feuille ni le soleil ne donnent et que trois traits ne fermeraient pas. C'est la réponse à une question ouverte depuis le premier jour : 5 % contre un seuil fixé au tiers.
Deux d'entre elles disent le vocabulaire mieux que les autres.
delete est un composteur et pas une poubelle :
supprimer, ici, c'est rendre à la terre. Et retry est une
éolienne à axe vertical, dont les deux pales sont le même
stem courbé en sens inverse — réessayer, c'est un cycle qui
reprend, et une éolienne est ce qui tourne sans qu'on la relance.
Chaque icône sort deux fois, en SVG et en VectorDrawable Android, et
c'est le même dessin : le pathData du XML est
octet pour octet le d du SVG. Un seul détail les
sépare, et il est piégeux — un viewport de VectorDrawable est ancré en
0,0 et n'a pas d'origine à régler, alors que le viewBox
commence en négatif pour porter le bleed. Le décalage passe donc par un
<group android:translate> plutôt que par une réécriture
des coordonnées : une seule géométrie, émise deux fois, qui ne peut
pas diverger.
Le style pane — les mêmes fichiers avec
leurs aplats de verre — arrive en v0.4. Le même fichier donne les deux :
pane sans les remplissages est line, ce
qui est une contrainte sur la façon de dessiner et non une option de
rendu.
Les marques
Une icône appartient à un jeu, se lit à 24 px et n'a qu'une
couleur parce qu'elle prend celle de l'endroit où elle est posée. Une
marque est seule, porte son propre verre, et doit survivre à seize pixels
dans un onglet — en dessous de tout ce qu'icon lint a jamais
vérifié, ses règles ayant été écrites pour 24.
Quatre marques, un seul vocabulaire. Le vitrail de Tessera — celui du haut de cette page, et celui de l'onglet, sont le même fichier —, le feuillage vu de dessus de Canopée, le losange de Continuity, l'atelier nocturne d'Alex. Chacune est montrée en grand et à 20 px ; changez de schéma en haut de page, elles suivent.
Jusqu'à cette version, l'emblème en haut de page était écrit à la main
dans le fichier HTML : douze path posés un par un, la
seule chose que cette page montrait et que l'outil ne produisait pas.
C'est maintenant logos/tessera.yaml, rendu comme le
reste.
Canopée ne prend pas la palette de cette page : elle déclare
palette: palettes/rhizome.yaml et porte les verts de son
propre projet. Ses cames sont noires et épinglées, mais son
pourtour garde l'encre mesurée — une marque doit se voir sur le fond où
elle atterrit, et un cadre noir sur une page noire est une marque que
personne ne trouve.
Alex n'a pas eu besoin qu'on lui invente un sujet : il était écrit deux fois. Sa palette dit d'elle-même « a workshop at night: one lit bench, instruments around it », et son README décrit une configuration nommée Addressed : « a roster, one recipient — that agent answers and the others stay out, even though they are in the room ». Plusieurs, présents, un seul allumé. La version précédente était une ruche : elle disait « plusieurs cellules » et s'arrêtait là.
Deux essais ont été jetés en chemin, et savoir pourquoi vaut mieux que d'y revenir. Six secteurs autour d'un disque central donnent le symbole de radioactivité — impossible de ne plus le voir. Les mêmes six sans le disque font converger six plombs en un point, ce qui se lit comme un spinner de chargement. Deux plombs croisés suffisent.
Et la métaphore avait trois parties dont je n'avais dessiné que
deux : il manquait la fenêtre. Avec les seuls deux noirs et
la lueur ambre, la marque lisait comme un œil rouge dans le noir plutôt
que comme un atelier. Une pièce en info donne la seconde
source de lumière — une froide, une chaude — et la nuit reste, parce que
le fichier d'Alex appelle son schéma sombre « the honest
default » : un terminal l'est.
Continuity ne se dessine pas, elle se transpose : sa marque existait avant Tessera, un losange rouge dans un cercle noir sur blanc. Le cercle blanc devient l'hexagone, et les proportions du losange — ratio 1,32, demi-hauteur à 0,82 du rayon du disque — sont relevées sur l'icône Android existante plutôt que choisies. C'est ce qui fait qu'on reconnaît la même marque.
Son rouge d'origine, #cc0000, a été refusé par
palette check : un came noir posé dessus lit 2,94, sous
le seuil AA de 4,5, et c'est exactement la règle qui empêche un plomb de
disparaître dans son verre. Elle porte donc clay. Ce que ça
révèle et qui reste ouvert : une palette n'a aucun moyen de dire
« cette couleur est une marque, jamais un fond » — rien n'est posé sur ce
losange, et il est refusé parce que le seul rôle qu'il puisse emprunter
est un rôle sur lequel on écrit.
Elle a d'abord été une tortue, et le pavage n'y était pour rien. Ce qui faisait la carapace était le plomb de pourtour lourd autour d'un dôme de cellules vertes, plus l'absence totale de lumière. Décaler le réseau, allonger les mailles, monter le désordre : quatre essais, quatre dossières. Il a fallu retirer le rebord et ouvrir des trous de jour entre les feuilles — une canopée vue d'en haut en a, et sans eux le vert uniforme se lit comme de la corne.
La marque de Tessera n'est pas la seule à utiliser l'échappatoire, et
c'est délibéré : ses quatre pièces sont des path. Une pièce de
verre irrégulière est précisément ce que le vocabulaire de motifs ne
couvre pas — un hexagone les donnerait toutes identiques, et un pavage
d'hexagones réguliers est une ruche, ce qui est déjà la marque d'Alex.
alexcanopeecontinuitytesseraLe plomb de ces marques n'est pas outline. C'est
--ts-ink-on-surface, l'encre mesurée sur le
verre : outline sur surface vaut 1,17 dans
le schéma sombre, et le cadre de l'hexagone disparaissait purement et
simplement — présent dans le fichier, absent de l'écran.
Le mot n'est pas dans le fichier. Un lockup est la
marque à côté du nom composé en display, en HTML : le nom
reste sélectionnable, atteint un lecteur d'écran, et obéit au contrat
typographique qui existe déjà. Le figer en contours mettrait la fonte deux
fois dans le dépôt — le subset qui est servi, et les lettres soudées dans
la marque.
Les diagrammes
Un thème de diagramme est le même geste qu'une palette GIMP : un
outil tiers dessine, Tessera lui donne ses couleurs. Les deux schémas
ci-dessous ne sont pas des images — ils sont rendus par
d2 et par mermaid depuis les thèmes que
palette build émet, puis chaque couleur y est remplacée par
la variable dont elle vient. Changez de schéma en haut de page : ils
se repeignent avec le reste.
d2 — tessera.d2
mermaid — tessera-mermaid.json
tessera.yaml
check
web
android
diagrammes
Les deux schémas disent la même chose, et pourtant ils
ne reçoivent pas la palette de la même façon. D2 importe son thème
par ...@tessera : les couleurs arrivent par
vars, le plomb par classes, et un nœud prend une
catégorie en nommant une classe. Mermaid n'a pas d'import, et son thème ne
sait pas peindre un nœud de diagramme autrement que tous les
autres — primaryColor les remplit tous, et les
cScale ne servent qu'aux frises et aux camemberts. Les classes
doivent donc vivre dans le diagramme : ici elles sont engendrées depuis
la palette et ajoutées au rendu, chez un humain elles se collent.
Il reste une chose que mermaid ne peut pas prendre : ses variables de thème n'ont pas de réglage d'épaisseur, donc le plomb y est une couleur et pas un trait. C'est une limite de l'outil et non un choix, et elle se voit — les contours du schéma de gauche sont épais, ceux de droite ne le sont pas. Mermaid est là malgré ça, parce qu'il a deux consommateurs déjà en place : Obsidian le rend nativement, et le thème du blog l'embarque.
Les sources
Les deux contrats de tokens — la palette et les caractères — vivent côte à côte dans un seul fichier : une identité graphique est une chose, et la première question devant un dépôt inconnu est où est le fichier. Les dessins, eux, ont un fichier par pièce.
# tessera.yaml — un fichier, une identité
name: tessera
palette:
space: oklch
default_scheme: noon
dark_scheme: dusk
colors:
sun: { l: 0.860, c: 0.150, h: 85 }
leaf: { l: 0.620, c: 0.140, h: 140 }
lead: { l: 0.220, c: 0.020, h: 280 }
linen: { l: 0.960, c: 0.015, h: 85 }
schemes:
noon:
bg: linen
text: soil
outline: lead
ok: leaf
# ... les douze rôles
type:
stacks:
display: { family: Fraunces, licence: OFL-1.1,
fallback: [Georgia, serif],
axes: { SOFT: 55, WONK: 1 } }
body: { family: Alegreya Sans, licence: OFL-1.1,
fallback: [system-ui, sans-serif] }
mono: { family: IBM Plex Mono, licence: OFL-1.1,
fallback: [ui-monospace, monospace],
ligatures: false }
scale: { base: 17, ratio: 1.25, steps: [-1, 0, 1, 2, 3, 4] }
measure: 64
# icons/seedling.yaml
name: seedling
grid: 32
lead: 3
bleed: 1.5
motifs:
- soil: { y: 28, width: 16 }
- stem:
from: [16, 28]
to: [16, 12]
whip: 0.15
- leaf:
at: [16, 16]
dir: left
size: 7
fill: ok
- sun: { at: [24, 7], r: 4, fill: accent }
tags: [nature, growth, start]
Les sorties
| Objet | Web | Android | Raster et impression |
|---|---|---|---|
| palette | tessera.css, palette.json |
colors.xml, objet Compose |
tessera.gpl, swatch.svg, tessera.d2 |
| type | tessera.type.css, tessera.prose.css, les woff2 |
TesseraType.kt, markdownTypography() |
@media print du schéma paper |
| icon | SVG, sprite <symbol> |
VectorDrawable | PNG de 24 à 512 px |
| pattern | SVG <pattern> |
tuile par densité | JPG grande taille, métadonnées embarquées |
L'état
Le binaire lit tessera.yaml, le valide et en dérive le CSS,
le JSON, les ressources Android, l'objet Compose, la palette GIMP, le swatch de
revue et un thème D2. Trente-neuf tests, dont ceux qui épinglent les
hexadécimaux et les ratios affichés sur cette page.
La typographie est entrée ensuite, et les icônes ont glissé d'un cran. Le raisonnement est celui d'un consommateur plutôt que d'un goût : le premier usage réel de Tessera est un agent local, dont l'interface est du texte et du markdown rendu, et qui n'a besoin d'aucune icône pour démarrer.
C'est lui qui a révélé les trois défauts corrigés le 19 septembre : deux sorties promises et jamais écrites, qui lui coûtaient 282 lignes de générateur et une seconde conversion OKLCH ; quinze commentaires hexadécimaux faux sur vingt, d'où une règle qui les vérifie ; et un mapping Material 3 qu'aucune valeur fixe ne satisfait, puisque l'encre lisible sur un accent change avec le schéma. Un consommateur qui tourne trouve ce qu'aucune relecture ne trouve.
check a refusé la palette de la spécification le lendemain de
son écriture, sur trois points réels : sun hors du gamut
sRGB, water trop sombre pour que le plomb reste visible, et une
règle de contraste insatisfiable. C'est l'argument pour écrire les règles
avant les dessins : une palette qui a l'air juste ne l'est pas.
check, build. Faite.type check et type build. En cours — les fontes y sont, le fichier unique aussi.line : le vocabulaire de motifs, lint, SVG et VectorDrawable, vingt-deux icônes. Faites — le pathData du XML est octet pour octet le d du SVG.pane : remplissages par rôle, ressources Android et Compose.check mesure. Passés devant les patterns, parce que deux applications web en avaient besoin.schema : un agent produit une icône depuis une phrase.favicon.svg bi-schéma, .ico 16/32, apple-touch-icon et maskable. Faites — cette page sert les siennes.Ce qui reste ouvert
Le vocabulaire de motifs est-il assez expressif pour vingt icônes
d'interface sans recourir à l'échappatoire path ? Si
path dépasse un tiers des icônes, le vocabulaire est trop
court. Cela ne se tranche pas sur le papier : la v0.3 le mesure en
dessinant.
Sous quelle licence les icônes et les patterns ? CC0 laisse tout prendre, y compris fermer un dérivé. CC BY-SA protège le commun mais pose à chaque application la question de l'œuvre dérivée. Les assets étant générés, la valeur est dans le générateur, pas dans le PNG.
Le premier consommateur réel utilise Tailwind, qui n'attend pas douze rôles sémantiques mais trois familles de dix rampes. Cette projection est-elle une sortie de Tessera, régénérable et vérifiable ? Ce serait cohérent avec tout le reste, mais ça ferait entrer un thème tiers dans le contrat du framework — et c'est le premier à réclamer des rampes là où Tessera ne promet que des rôles.