refactor: ground the stylesheet in visual tokens (#16)

Reorganize web/src/style.css around @layer tokens: colors, type scale,
line heights, weights and spacing are declared once and referenced with
var(). Raise the reading base from 1rem/1.6 to 1.125rem/1.7 (title and
Quick Connect code follow), keep :focus-visible and add a global
prefers-reduced-motion block for the coming visual tickets.

No selector, Vue file or interface logic changes; the DOM and behaviour
stay those of #14 and the e2e suite is untouched (34/34). A contract
test at web/src/style.test.ts locks the tokens, AA contrasts, focus and
reduced motion; npm run test:unit now includes it (28/28).

Rebuild the packaged plugin (dist/) with the new stylesheet.
This commit is contained in:
nessar committed 2026-09-18 15:48:56 +02:00
1 parent d53efb4be6
commit 89e1964872
7 files changed
+225 -13

No files matched your search

+3 -1
View File
@@ -105,7 +105,7 @@ le paquet fourni.
bash scripts/package.sh # construit l'interface (npm ci, Vite), compile C# avec avertissements bloquants et archive
npm ci
npm run typecheck # sources TypeScript et Vue vérifiées par vue-tsc
npm run test:unit # modules purs de l'interface, testés avec node:test
npm run test:unit # logique pure et contrat visuel de l'interface, testés avec node:test
bash tests/run.sh # serveur jetable, installation du paquet et tests réels
```
@@ -115,6 +115,8 @@ fixes. Pour itérer sans empaqueter, `JELLYFIN_URL=http://127.0.0.1:8096 npm run
sert l'interface sur `http://127.0.0.1:5173/` et relaie l'API à ce serveur
Jellyfin. La logique pure (estimation de position, historique, chevauchement de
l'aide, lecture des pistes) est dans `web/src/lib/` et testée sans navigateur.
La feuille de style est verrouillée par un test de contrat
(`web/src/style.test.ts`) : jetons, contrastes AA, focus et mouvement réduit.
`tests/run.sh` utilise un port local libre et supprime son propre conteneur en
sortie. Il crée deux comptes ordinaires, un compte administrateur de préparation
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
d7f980d9bb3d5c510986c0da03af1c4f8b90918b1a8988071f5556d8c58b5ac7 compagnon-0.7.0-jellyfin-12.0.0.zip
597ac928756dc7730585138eeb18f289aeb84b15c5d2953393399e0a7a3ff24e compagnon-0.7.0-jellyfin-12.0.0.zip
+67
View File
@@ -0,0 +1,67 @@
# Validation des jetons visuels et fondations typographiques
## Protocole
Paquet **0.7.0**, Jellyfin Server **12.0.0** (image figée dans `tests/run.sh`),
Chromium **149.0.7827.55**, Playwright **1.61.1**, Node.js **24.18.0**,
Vue **3.5.24**, Vite **7.3.6**, TypeScript **5.9.3**.
Compagnon de **375 × 812 pixels**, hébergement sans puis avec préfixe `/jellyfin`.
```sh
npm run typecheck
npm run test:unit
bash tests/run.sh
```
## Périmètre livré
`web/src/style.css` est réorganisé en deux couches : `@layer tokens` déclare les
jetons sur `:root`, `@layer base` ne contient plus que des références `var(...)`
pour les couleurs, la typographie et les espacements. Aucun sélecteur, aucun
fichier Vue et aucune logique TypeScript de l'interface n'est modifié : le DOM
et le comportement restent ceux du lot #14, aucun test e2e existant n'est
retouché. Seules des déclarations CSS changent, avec l'échelle typographique.
L'échelle typographique est relevée : corps de **1rem/1.6** à **1.125rem
(18 px)/1.7** (30,6 px d'interligne), titre de **1.8rem** à **2rem**, code de
**2.5rem** à **2.75rem**. Un bloc `@media (prefers-reduced-motion: reduce)`
neutralise désormais animations, transitions et défilement doux pour tous les
lots suivants. Le focus visible `:focus-visible` est conservé et repose sur
`--color-focus`.
Un test unitaire de contrat, `web/src/style.test.ts`, verrouille les critères
du lot : chaque `var(...)` est défini, toute couleur littérale est portée par un
jeton `--color-`, le contraste texte/fond atteint AA, l'anneau de focus
contraste avec le fond, la base de lecture et l'interligne sont relevés,
`prefers-reduced-motion` est présent et `#history li` conserve
`white-space: pre-line`.
## Résultats
Exécution complète du **18 septembre 2026** :
| Vérification | Résultat |
| --- | --- |
| `npm run typecheck` (`vue-tsc`) | 0 erreur |
| `npm run test:unit` | **28 tests sur 28**, dont 7 de contrat visuel |
| `bash tests/run.sh` | **34 tests sur 34**, 126,0 s |
Contrastes mesurés sur le fond `--color-canvas` (`#111827`), seuil AA de 4,5:1
pour le texte et 3:1 pour les composants :
| Élément | Couleur | Contraste |
| --- | --- | --- |
| Texte | `#f9fafb` | 16,98:1 |
| Texte atténué (`dt`) | `#cbd5e1` | 11,95:1 |
| Anneau de focus | `#fbbf24` | 10,63:1 |
Les styles calculés relevés au navigateur confirment la base de lecture :
`body` en 18 px/30,6 px, entrée d'historique en `white-space: pre-line`, et
l'anneau de focus reste visible au clavier sur les boutons.
## Limites
- Aucune hiérarchie visuelle n'est introduite : bloc courant, aide associée,
barre de statut, suspension et plein écran restent l'objet des lots #17 à #21.
- La feuille servie passe de 748 octets minifiés à 1,87 ko (0,75 ko compressés)
du fait de l'ajout des jetons et du bloc de mouvement réduit.
+1 -1
View File
@@ -6,7 +6,7 @@
"build": "vite build --config web/vite.config.ts",
"dev": "vite --config web/vite.config.ts",
"typecheck": "vue-tsc --noEmit -p web/tsconfig.json",
"test:unit": "node --test web/src/lib/*.test.ts",
"test:unit": "node --test web/src/lib/*.test.ts web/src/style.test.ts",
"test": "node --test tests/*.test.mjs"
},
"devDependencies": {
+66 -10
View File
@@ -1,16 +1,72 @@
@layer tokens, base;
@layer tokens {
:root {
--color-canvas: #111827;
--color-text: #f9fafb;
--color-text-muted: #cbd5e1;
--color-focus: #fbbf24;
--font-family-base: system-ui, sans-serif;
--font-size-base: 1.125rem;
--font-size-title: 2rem;
--font-size-code: 2.75rem;
--line-height-reading: 1.7;
--line-height-title: 1.2;
--font-weight-strong: bold;
--letter-spacing-code: .3em;
--space-xs: .5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--measure: 42rem;
--control-min-height: 2.75rem;
--focus-ring-width: .2rem;
--focus-ring-offset: .2rem;
}
}
@layer base {
* { box-sizing: border-box; }
body { margin: 0; background: #111827; color: #f9fafb; font: 1rem/1.6 system-ui, sans-serif; }
main { max-width: 42rem; margin-inline: auto; padding: 1.5rem; overflow-wrap: anywhere; }
h1 { font-size: 1.8rem; line-height: 1.2; }
body {
margin: 0;
background: var(--color-canvas);
color: var(--color-text);
font-family: var(--font-family-base);
font-size: var(--font-size-base);
line-height: var(--line-height-reading);
}
main { max-width: var(--measure); margin-inline: auto; padding: var(--space-lg); overflow-wrap: anywhere; }
h1 { font-size: var(--font-size-title); line-height: var(--line-height-title); }
label, input, select, button { display: block; }
input, select, button { font: inherit; min-height: 2.75rem; max-width: 100%; margin-block: .5rem 1rem; padding: .5rem; }
input, select, button {
font: inherit;
min-height: var(--control-min-height);
max-width: 100%;
margin-block: var(--space-xs) var(--space-md);
padding: var(--space-xs);
}
input, select { width: 100%; }
button { cursor: pointer; }
:focus-visible { outline: .2rem solid #fbbf24; outline-offset: .2rem; }
#code { font-size: 2.5rem; font-weight: bold; letter-spacing: .3em; font-variant-numeric: tabular-nums; }
dt { font-weight: bold; color: #cbd5e1; }
dd { margin-inline: 0; margin-block-end: 1rem; }
#history { padding-inline-start: 1.5rem; }
#history li { white-space: pre-line; margin-block-end: 1rem; }
:focus-visible {
outline-color: var(--color-focus);
outline-style: solid;
outline-width: var(--focus-ring-width);
outline-offset: var(--focus-ring-offset);
}
#code { font-size: var(--font-size-code); font-weight: var(--font-weight-strong); letter-spacing: var(--letter-spacing-code); font-variant-numeric: tabular-nums; }
dt { font-weight: var(--font-weight-strong); color: var(--color-text-muted); }
dd { margin-inline: 0; margin-block-end: var(--space-md); }
#history { padding-inline-start: var(--space-lg); }
#history li { white-space: pre-line; margin-block-end: var(--space-md); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
scroll-behavior: auto !important;
}
}
}
+87
View File
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const source = await readFile(new URL('./style.css', import.meta.url), 'utf8');
const tokens = new Map<string, string>();
for (const [, name, value] of source.matchAll(/(--[\w-]+)\s*:\s*([^;}]+)/g)) tokens.set(name, value.trim());
const declarations = [...source.matchAll(/([\w-]+)\s*:\s*([^;}]+)/g)]
.map(([, property, value]) => ({ property, value: value.trim() }));
function rule(selector: string): string {
return new RegExp(`(?:^|[\\s,}])${selector}\\s*\\{([^}]*)\\}`).exec(source)?.[1] ?? '';
}
function declaration(css: string, property: string): string {
return new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`).exec(css)?.[1]?.trim() ?? '';
}
function resolve(value: string): string {
const reference = /^var\((--[\w-]+)\)$/.exec(value);
if (!reference) return value;
const resolved = tokens.get(reference[1]);
assert.ok(resolved, `${reference[1]} must be defined`);
return resolved;
}
function luminance(color: string): number {
const digits = Number.parseInt(color.slice(1), 16);
const channels = [digits >> 16, (digits >> 8) & 0xff, digits & 0xff].map(channel => {
const value = channel / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrast(a: string, b: string): number {
const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (light + 0.05) / (dark + 0.05);
}
const body = rule('body');
const canvas = resolve(declaration(body, 'background'));
test('every token the rules reference is defined', () => {
const references = [...source.matchAll(/var\(\s*(--[\w-]+)/g)].map(match => match[1]);
assert.ok(references.length > 0, 'the stylesheet must be built on tokens');
for (const name of references) assert.ok(tokens.has(name), `${name} must be defined`);
});
test('colors are declared once as tokens and never as raw values in rules', () => {
const literal = /#[0-9a-f]{3,8}\b|\b(?:rgb|hsl)a?\(/i;
for (const { property, value } of declarations) {
if (literal.test(value)) assert.match(property, /^--color-/, `${property} must carry colors through a --color- token`);
}
const palette = [...tokens].filter(([name]) => name.startsWith('--color-'));
assert.ok(palette.length > 0, 'the palette must be declared as tokens');
for (const [name, value] of palette) assert.match(value, /^#[0-9a-f]{6}$/i, `${name} must be a six-digit hex color`);
});
test('body and muted text keep WCAG AA contrast on the canvas', () => {
assert.ok(contrast(resolve(declaration(body, 'color')), canvas) >= 4.5, 'body text must reach AA');
const muted = resolve(declaration(rule('dt'), 'color'));
assert.ok(contrast(muted, canvas) >= 4.5, 'muted text must reach AA');
});
test('the focus ring stays visible and contrasts with the canvas', () => {
const focus = rule(':focus-visible');
assert.ok(focus, ':focus-visible must be styled');
assert.ok(contrast(resolve(declaration(focus, 'outline-color')), canvas) >= 3, 'the focus ring must contrast with the canvas');
});
test('the reading base and line height are raised above the previous 1rem/1.6', () => {
const size = Number.parseFloat(resolve(declaration(body, 'font-size')));
const lineHeight = Number.parseFloat(resolve(declaration(body, 'line-height')));
assert.ok(size > 1, 'the base font size must be raised');
assert.ok(lineHeight > 1.6, 'the reading line height must be raised');
});
test('reduced motion is respected', () => {
assert.match(source, /@media\s*\(prefers-reduced-motion:\s*reduce\)/);
});
test('source line breaks are still preserved in history entries', () => {
assert.match(rule('#history li'), /white-space:\s*pre-line/);
});