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:
1 parent
d53efb4be6
commit
89e1964872
7 files changed
+225
-13
No files matched your search
@@ -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
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
d7f980d9bb3d5c510986c0da03af1c4f8b90918b1a8988071f5556d8c58b5ac7 compagnon-0.7.0-jellyfin-12.0.0.zip
|
||||
597ac928756dc7730585138eeb18f289aeb84b15c5d2953393399e0a7a3ff24e compagnon-0.7.0-jellyfin-12.0.0.zip
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in new issue
Block a user