From 86047eb3aead0d8aa7c88c897d3da6bb9cd48ac8 Mon Sep 17 00:00:00 2001 From: Robin Lopez Date: Wed, 21 Oct 2026 11:25:32 +0200 Subject: [PATCH 1/4] feat(tokens): FSHSP-253 read DTCG typed durations, curves and dimensions --- CHANGELOG.md | 13 ++ scripts/tokens.build.mjs | 123 +++++++++++++++++-- scripts/tokens.build.spec.mjs | 196 ++++++++++++++++++++++++++++++ scripts/tokens.config.schema.json | 6 +- vitest.config.mts | 8 ++ 5 files changed, 336 insertions(+), 10 deletions(-) create mode 100644 scripts/tokens.build.spec.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e6fefa..6fc0241 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et le p ## [Unreleased] +### Added + +- **`tokens:build` lit les valeurs typées du format DTCG : durées, courbes, dimensions** + (FSHSP-253). Une courbe `[0.42, 0, 0.58, 1]` sort en `cubic-bezier(0.42, 0, 0.58, 1)`, une + durée `"80ms"` ou `{ "value": 80, "unit": "ms" }` en `80ms`, une dimension + `{ "value": 0.5, "unit": "rem" }` en `0.5rem`. C'est ce que Token Flow exporte pour les + variables Figma `TIMING` (`"80ms"`) et `EASING` (le tableau). Une durée sans unité fait + échouer le build au lieu d'être devinée (une variable TIMING de Figma compte en secondes), + tout comme une courbe dont un x sort de [0, 1], que CSS ignorerait. Un nombre est arrondi à + six chiffres significatifs : Figma stocke `0.96` en `0.9599999785423279`. Dans + `tokens.config.json`, un mode associé à `null` n'est pas émis. Les jetons écrits en texte + CSS sortent à l'identique. Même conversion que le starter Angular (FSHSP-252). + ## [0.2.0] - 2026-10-19 ### Added diff --git a/scripts/tokens.build.mjs b/scripts/tokens.build.mjs index b46417e..315c74d 100644 --- a/scripts/tokens.build.mjs +++ b/scripts/tokens.build.mjs @@ -16,8 +16,18 @@ import { createPropertyFormatter, usesReferences, getReferences } from 'style-di // --- Config & paths --------------------------------------------------------- -const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); -const CONFIG = JSON.parse(readFileSync(join(ROOT, 'tokens.config.json'), 'utf8')); +// `--config ` builds another tokens.config.json, relative paths resolved from it: +// what the tests use to run the pipeline in a throwaway folder. +const configArg = process.argv.indexOf('--config'); +const CONFIG_PATH = + configArg !== -1 && process.argv[configArg + 1] + ? isAbsolute(process.argv[configArg + 1]) + ? process.argv[configArg + 1] + : join(process.cwd(), process.argv[configArg + 1]) + : join(dirname(fileURLToPath(import.meta.url)), '..', 'tokens.config.json'); + +const ROOT = dirname(CONFIG_PATH); +const CONFIG = JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); const SRC = isAbsolute(CONFIG.sourceRoot) ? CONFIG.sourceRoot : join(ROOT, CONFIG.sourceRoot); const HEADER = `/* ${CONFIG.header ?? 'Generated : do not edit.'} */\n\n`; @@ -103,24 +113,123 @@ for (const c of CONFIG.collections) for (const k of refKeys(c)) byRefKey[k] = c; // --- Token file walking ------------------------------------------------------- -/** Flatten a DTCG tree into [{ path, token }] leaves ($value nodes). */ -function leaves(node, path = [], out = []) { +/** Flatten a DTCG tree into [{ file, path, token }] leaves ($value nodes). + * `file` travels with the leaf so an unreadable value can name the file to open. */ +function leaves(node, file, path = [], out = []) { if (!node || typeof node !== 'object') return out; if ('$value' in node) { - out.push({ path, token: node }); + out.push({ file, path, token: node }); return out; } - for (const k of Object.keys(node)) if (!k.startsWith('$')) leaves(node[k], [...path, k], out); + for (const k of Object.keys(node)) + if (!k.startsWith('$')) leaves(node[k], file, [...path, k], out); return out; } const leavesByCol = {}; for (const c of CONFIG.collections) { leavesByCol[c.id] = (c.files ?? []).flatMap((f) => - leaves(JSON.parse(readFileSync(join(SRC, f), 'utf8'))), + leaves(JSON.parse(readFileSync(join(SRC, f), 'utf8')), f), ); } +// --- Modes not emitted yet ------------------------------------------------------ +// A mode mapped to `null` in an axis exists in Figma but is not emitted: its tokens are +// dropped here, before anything reads them. Left in, a mode with no selector nor media +// would land in :root and override the default mode token by token. +for (const c of CONFIG.collections) { + const skipped = new Set( + (c.modeAxes ?? []).flatMap((a) => + Object.entries(a.map ?? {}) + .filter(([, target]) => target === null) + .map(([mode]) => mode), + ), + ); + if (skipped.size) + leavesByCol[c.id] = leavesByCol[c.id].filter((l) => !l.path.some((p) => skipped.has(p))); +} + +// --- Typed values ----------------------------------------------------------------- +// DTCG 2025.10 writes some values as structures: a duration or a dimension is +// `{ value, unit }`, a curve `[x1, y1, x2, y2]`. Left as is, `setLeaf` would split +// the object into sub-tokens and stringify the array. They take their CSS form here, +// before anything else reads them: `80ms`, `0.5rem`, `cubic-bezier(0.42, 0, 0.58, 1)`. +// A string (CSS text or a {…} reference) goes through untouched: it is what every +// file exported from Figma contains so far. +// +// A unitless duration is refused rather than guessed: Figma's TIMING variables count +// in seconds, and CSS has no unitless time to fall back on. + +/** `{ value: 80, unit: 'ms' }` → `80ms`; a string goes through. `units` restricts the unit. */ +function unitValue(value, units) { + if (typeof value === 'string') return value; + const readable = + value && + typeof value === 'object' && + Number.isFinite(value.value) && + typeof value.unit === 'string' && + (!units || units.includes(value.unit)); + return readable ? `${value.value}${value.unit}` : undefined; +} + +/** `[0.42, 0, 0.58, 1]` → `cubic-bezier(0.42, 0, 0.58, 1)`; a string goes through. */ +function cubicBezier(value) { + if (typeof value === 'string') return value; + if (!Array.isArray(value) || value.length !== 4 || !value.every(Number.isFinite)) + return undefined; + const [x1, , x2] = value; + // CSS drops the whole declaration when a curve's x leaves [0, 1]. + if (x1 < 0 || x1 > 1 || x2 < 0 || x2 > 1) return undefined; + return `cubic-bezier(${value.join(', ')})`; +} + +const TYPED_VALUES = { + duration: { + css: (v) => unitValue(v, ['ms', 's']), + expected: '{ "value": 80, "unit": "ms" } (ms ou s), ou une chaîne CSS ("80ms")', + }, + dimension: { + css: (v) => (typeof v === 'number' ? v : unitValue(v)), + expected: '{ "value": 0.5, "unit": "rem" }, ou une chaîne CSS ("8px")', + }, + cubicBezier: { + css: cubicBezier, + expected: '[x1, y1, x2, y2] avec x1 et x2 entre 0 et 1, ou une chaîne CSS ("ease-out")', + }, + // Figma stores numbers on 32 bits: 0.96 comes out as 0.9599999785423279. Six + // significant digits give the value back without touching a clean one (12, 1.25). + number: { + css: (v) => (typeof v === 'number' ? Number(v.toPrecision(6)) : v), + expected: 'un nombre, ou une référence {…}', + }, +}; + +function normalizeTypedValues() { + const unreadable = []; + for (const col of CONFIG.collections) { + for (const leaf of leavesByCol[col.id]) { + const typed = TYPED_VALUES[leaf.token.$type]; + if (!typed) continue; + const css = typed.css(leaf.token.$value); + if (css === undefined) unreadable.push({ ...leaf, expected: typed.expected }); + else if (css !== leaf.token.$value) leaf.token = { ...leaf.token, $value: css }; + } + } + if (!unreadable.length) return; + const lines = unreadable.map( + (u) => + ` ${u.file} → ${u.path.join('.')} (${u.token.$type})\n` + + ` reçu ${JSON.stringify(u.token.$value)}, attendu ${u.expected}`, + ); + throw new Error( + `Valeurs de jetons illisibles (${unreadable.length}) :\n${lines.join('\n')}\n\n` + + `Une durée sans unité n'est jamais devinée : une variable TIMING de Figma compte en ` + + `secondes, et CSS n'a pas de durée sans unité.`, + ); +} + +normalizeTypedValues(); + /** Detect all mode names from the JSON structure for a given axis. */ function detectModeNames(col, axis) { const modes = new Set(); diff --git a/scripts/tokens.build.spec.mjs b/scripts/tokens.build.spec.mjs new file mode 100644 index 0000000..58aabd9 --- /dev/null +++ b/scripts/tokens.build.spec.mjs @@ -0,0 +1,196 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; + +const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'tokens.build.mjs'); + +/** + * Le pipeline est piloté par `tokens.config.json` et écrit à côté : chaque cas + * s'exécute dans son propre dossier jetable, via `--config`, donc rien ne touche + * aux fichiers générés du dépôt. + */ +const dirs = []; +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +/** Une collection préfixée (`primitives`) et la collection `transitions` (préfixe vide, + * groupe `transition`), comme dans le dépôt, plus celles du cas. */ +function config(extraCollections = []) { + return { + sourceRoot: 'design-tokens', + header: 'test', + collections: [ + { + id: 'primitives', + prefix: 'primitives', + preserveCase: false, + files: ['primitives.json'], + modeAxes: [], + }, + { + id: 'transitions', + prefix: '', + preserveCase: false, + files: ['transitions.json'], + modeAxes: [], + }, + ...extraCollections, + ], + outputs: [ + { + id: 'css', + format: 'css-vars', + destination: 'out', + collections: ['primitives', 'transitions', ...extraCollections.map((c) => c.id)], + }, + ], + }; +} + +const PRIMITIVES = { grey: { 900: { $value: '#111111', $type: 'color' } } }; +const TRANSITIONS = { + transition: { + fast: { $value: '80ms', $type: 'duration' }, + default: { $value: '180ms', $type: 'duration' }, + }, +}; + +function run({ tokens, extraCollections = [] }) { + const dir = mkdtempSync(join(tmpdir(), 'tokens-build-')); + dirs.push(dir); + mkdirSync(join(dir, 'design-tokens'), { recursive: true }); + writeFileSync(join(dir, 'tokens.config.json'), JSON.stringify(config(extraCollections), null, 2)); + const files = { 'primitives.json': PRIMITIVES, 'transitions.json': TRANSITIONS, ...tokens }; + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(dir, 'design-tokens', name), JSON.stringify(content, null, 2)); + } + try { + execFileSync(process.execPath, [SCRIPT, '--config', join(dir, 'tokens.config.json')], { + encoding: 'utf8', + stdio: 'pipe', + }); + } catch (error) { + return { ok: false, message: `${error.stdout ?? ''}${error.stderr ?? ''}` }; + } + return { ok: true, css: (id) => readFileSync(join(dir, 'out', `_tokens-${id}.scss`), 'utf8') }; +} + +/** Construit `transitions.json` tel que l'exporterait Figma une fois les variables typées. */ +const typed = (transition) => run({ tokens: { 'transitions.json': { transition } } }); + +describe('tokens.build : valeurs typées (durée, dimension, courbe)', () => { + it('écrit en CSS les valeurs structurées du format DTCG', () => { + const result = typed({ + fast: { $value: { value: 80, unit: 'ms' }, $type: 'duration' }, + slower: { $value: { value: 0.4, unit: 's' }, $type: 'duration' }, + text: { $value: '120ms', $type: 'duration' }, + easeinout: { $value: [0.42, 0, 0.58, 1], $type: 'cubicBezier' }, + keyword: { $value: 'ease-out', $type: 'cubicBezier' }, + distance: { $value: { value: 0.5, unit: 'rem' }, $type: 'dimension' }, + }); + + expect(result.message ?? '').toBe(''); + const css = result.css('transitions'); + expect(css).toContain('--transition-fast: 80ms;'); + expect(css).toContain('--transition-slower: 0.4s;'); + expect(css).toContain('--transition-text: 120ms;'); + expect(css).toContain('--transition-easeinout: cubic-bezier(0.42, 0, 0.58, 1);'); + expect(css).toContain('--transition-keyword: ease-out;'); + expect(css).toContain('--transition-distance: 0.5rem;'); + }); + + it('refuse une durée sans unité, en nommant le fichier et le jeton', () => { + // Une variable TIMING de Figma compte en secondes : deviner des ms rendrait + // l'animation mille fois plus courte, deviner des secondes l'inverse. + const result = typed({ fast: { $value: 0.08, $type: 'duration' } }); + + expect(result.ok).toBe(false); + expect(result.message).toContain('transitions.json → transition.fast'); + expect(result.message).toContain('secondes'); + }); + + it('refuse une courbe dont un x sort de [0, 1], que CSS ignorerait en bloc', () => { + const result = typed({ wild: { $value: [1.2, 0, 0.58, 1], $type: 'cubicBezier' } }); + + expect(result.ok).toBe(false); + expect(result.message).toContain('transitions.json → transition.wild'); + }); +}); + +describe('tokens.build : modes et nombres', () => { + /** Une collection à deux modes, comme `motion`. */ + const motionCollection = (reduced) => ({ + id: 'motion', + prefix: 'motion', + preserveCase: false, + files: ['motion.json'], + modeAxes: [ + { + name: 'motion', + source: 'nested', + strategy: 'media', + default: 'modeStandard', + map: { modeStandard: ':root', modeReduced: reduced }, + }, + ], + }); + const build = (motion, reduced = null) => + run({ extraCollections: [motionCollection(reduced)], tokens: { 'motion.json': motion } }); + + const SLIDE = { + slide: { + modeStandard: { distance: { $value: '8px', $type: 'dimension' } }, + modeReduced: { distance: { $value: '0px', $type: 'dimension' } }, + }, + }; + + it("n'émet pas un mode associé à null, et ne le laisse pas écraser le mode par défaut", () => { + const result = build(SLIDE); + + expect(result.message ?? '').toBe(''); + const css = result.css('motion'); + expect(css).toContain('--motion-slide-distance: 8px;'); + expect(css).not.toContain('0px'); + expect(css).not.toContain('@media'); + }); + + it('émet un mode associé à une condition média sous cette condition', () => { + const css = build(SLIDE, '(prefers-reduced-motion: reduce)').css('motion'); + + expect(css).toMatch(/^:root \{\n {2}--motion-slide-distance: 8px;/m); + expect(css).toMatch( + /@media \(prefers-reduced-motion: reduce\) \{\n {2}:root \{\n {4}--motion-slide-distance: 0px;/, + ); + }); + + it('garde une recette en alias de sa primitive, dans chaque mode', () => { + const css = build( + { + toggle: { + modeStandard: { + duration: { $value: '{transitions.transition.default}', $type: 'duration' }, + }, + modeReduced: { duration: { $value: '{transitions.transition.fast}', $type: 'duration' } }, + }, + }, + '(prefers-reduced-motion: reduce)', + ).css('motion'); + + expect(css).toContain('--motion-toggle-duration: var(--transition-default);'); + expect(css).toContain('--motion-toggle-duration: var(--transition-fast);'); + }); + + it('arrondit un nombre stocké sur 32 bits par Figma, sans toucher un nombre propre', () => { + const css = build({ + zoom: { modeStandard: { scale: { $value: 0.9599999785423279, $type: 'number' } } }, + grid: { modeStandard: { columns: { $value: 12, $type: 'number' } } }, + }).css('motion'); + + expect(css).toContain('--motion-zoom-scale: 0.96;'); + expect(css).toContain('--motion-grid-columns: 12;'); + }); +}); diff --git a/scripts/tokens.config.schema.json b/scripts/tokens.config.schema.json index 48f80f7..0a9414f 100644 --- a/scripts/tokens.config.schema.json +++ b/scripts/tokens.config.schema.json @@ -44,7 +44,7 @@ "files": { "type": "array", "items": { "type": "string" }, - "description": "Token JSON files (DTCG format: nested groups with $value/$type leaves), relative to sourceRoot." + "description": "Token JSON files (DTCG format: nested groups with $value/$type leaves), relative to sourceRoot. Structured values get their CSS form: a duration or a dimension { \"value\": 80, \"unit\": \"ms\" } → 80ms, a cubicBezier [0.42, 0, 0.58, 1] → cubic-bezier(0.42, 0, 0.58, 1), a number rounded to 6 significant digits. A unitless duration fails the build (Figma's TIMING variables count in seconds)." }, "fontStacks": { "type": "object", @@ -94,8 +94,8 @@ }, "map": { "type": "object", - "additionalProperties": { "type": "string" }, - "description": "mode name → CSS selector (strategy \"selectors\") or media condition without \"@media\" (strategy \"media\")." + "additionalProperties": { "type": ["string", "null"] }, + "description": "mode name → CSS selector (strategy \"selectors\") or media condition without \"@media\" (strategy \"media\"). null: the mode exists in the token files but is not emitted yet (its tokens are dropped)." } } } diff --git a/vitest.config.mts b/vitest.config.mts index b5b2229..2778f1e 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -75,6 +75,14 @@ export default defineConfig({ environment: 'node', }, }, + { + // Les scripts du dépôt (jetons, contrôles) : des processus Node, comme le MCP. + test: { + name: 'scripts', + include: ['scripts/**/*.spec.mjs'], + environment: 'node', + }, + }, { // Le plugin lit la config Storybook, transforme chaque story en test et // injecte lui-même les annotations du `preview`. Rien à recopier ici. From 49f41f600d7b136e74f327377f75264b1e590301 Mon Sep 17 00:00:00 2001 From: Robin Lopez Date: Wed, 21 Oct 2026 11:32:43 +0200 Subject: [PATCH 2/4] feat(motion): FSHSP-253 sync the motion tokens from Figma and read the recipes in the kit --- AGENTS.md | 5 + CHANGELOG.md | 28 + design-tokens/manifest.json | 6 + design-tokens/motion.json | 913 ++++++++++++++++++ design-tokens/transitions.json | 128 ++- docs/DECISIONS.md | 31 + docs/DUAL-ENGINE.md | 21 +- figma/README.md | 2 +- figma/component-vars.json | 13 +- .../actions/ui-speed-dial/ui-speed-dial.scss | 2 +- .../ui-kit-react/src/core/motion/motion.mdx | 84 +- .../ui-bottom-sheet/ui-bottom-sheet.scss | 7 +- .../src/layout/ui-drawer/ui-drawer.scss | 7 +- .../src/layout/ui-modal/ui-modal.scss | 7 +- .../src/navigation/ui-sidebar/ui-sidebar.scss | 7 +- .../src/navigation/ui-tabs/ui-tabs.scss | 2 +- .../ui-kit-react/src/styles/base/_motion.scss | 13 +- .../src/styles/component-vars.scss | 7 +- .../src/styles/settings/_ui-config.scss | 30 +- .../src/styles/utils/_mixins.scss | 33 +- .../src/styles/utils/_motion.scss | 30 +- scripts/component-vars.build.mjs | 15 + storybook/docs/specifications/theme.mdx | 3 +- tokens.config.json | 19 + 24 files changed, 1314 insertions(+), 99 deletions(-) create mode 100644 design-tokens/motion.json diff --git a/AGENTS.md b/AGENTS.md index 66af320..920574b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -235,6 +235,11 @@ par `useSyncExternalStore` avec un instantané serveur distinct : voir `core/the - **Jetons uniquement** : jamais une couleur, un espacement ou un rayon en dur. Clair ET sombre à chaque fois. +- **Mouvement** : `--transition-*` sont les primitives, `--motion-*` les recettes par + usage (`hover`, `toggle`, `overlay-in`…). Un composant lit une recette, à travers les + mixins `utils` (`control-transition`, `motion-transition`, `overlay-motion`) ou + `ui-config`, jamais une primitive ni une durée écrite à la main : sinon ni un recadençage + du kit ni le mouvement réduit ne l'atteignent. Voir `Foundations / Motion`. - **Nommage** : racine `.ui-` ; sous-élément `&-` (→ `.ui-button-icon`) ; modifieur `&._` (→ `._small`, `._high`). - **États interactifs** (`hover`/`focus`/`active`/`disabled`) : pseudo-classes CSS via les diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fc0241..b9b0cdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,34 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et le p six chiffres significatifs : Figma stocke `0.96` en `0.9599999785423279`. Dans `tokens.config.json`, un mode associé à `null` n'est pas émis. Les jetons écrits en texte CSS sortent à l'identique. Même conversion que le starter Angular (FSHSP-252). +- **Jetons : recettes de mouvement `--motion-*`, lues par le kit** (FSHSP-253). Une nouvelle + collection associe chaque usage à une durée et une courbe, en alias des primitives + `--transition-*` : `hover`, `toggle`, `overlay-in` / `-out`, `disclosure-*`, `dialog-*`, + `notify-*`, plus `--motion-stagger-delay`, `--motion-slide-distance` et + `--motion-zoom-scale`. Elles valent le timing d'avant : rien ne change à l'écran. + `utils.control-transition` prend `toggle` pour une propriété qui déplace (`transform`, + `top`, `width`…) et `hover` pour une couleur ou une opacité ; `utils.motion-transition` lit + `toggle` ; les préréglages `ui-motion-*`, `utils.overlay-motion` et les fonds des modales, + tiroirs et bottom sheets lisent `overlay-in` à l'entrée et `overlay-out` à la sortie ; le + décalage du speed-dial lit `stagger`. Pour recadencer tout le kit, surcharger une recette sur + `:root`. Les surcharges de `--transition-*` et de `--ui-control-transition-*` continuent de + s'appliquer. Ce dernier hook ne figure plus dans `component-vars.scss` : il règle à la fois + les couleurs et les déplacements, la recette est désormais le réglage global. Mêmes noms que + le starter Angular. + +### Changed + +- **Les courbes `--transition-*` s'écrivent en `cubic-bezier()`** (FSHSP-253). Leurs variables + Figma sont passées du type `STRING` aux types `TIMING` et `EASING`, sous les mêmes noms. + Les durées ne changent pas (`80ms`, `180ms`, `280ms`). Les courbes valent la définition + exacte de leur ancien mot-clé : `--transition-easein` vaut `cubic-bezier(0.42, 0, 1, 1)` au + lieu de `ease-in`, `--transition-easeout` `cubic-bezier(0, 0, 0.58, 1)`, + `--transition-easeinout` `cubic-bezier(0.42, 0, 0.58, 1)` et `--transition-linear` + `cubic-bezier(0, 0, 1, 1)`. Le rendu est identique ; seul un test qui compare la courbe + calculée sous forme de texte (`ease-in-out`) est à mettre à jour. +- **`figma/component-vars.json` : un défaut lu avec repli devient un alias** (FSHSP-253). + `var(--motion-slide-distance, 0.5rem)` s'importe en `{motion.slide.distance}` au lieu d'une + chaîne, et `--ui-label-color` en `{semantics.form.high.content.default}`. ## [0.2.0] - 2026-10-19 diff --git a/design-tokens/manifest.json b/design-tokens/manifest.json index 6973b3e..d88b52c 100644 --- a/design-tokens/manifest.json +++ b/design-tokens/manifest.json @@ -36,6 +36,12 @@ "Mode 1": ["transitions.json"] } }, + "motion": { + "modes": { + "modeStandard": ["motion.json"], + "modeReduced": ["motion.json"] + } + }, "typography": { "modes": { "Mode 1": ["typography.json"] diff --git a/design-tokens/motion.json b/design-tokens/motion.json new file mode 100644 index 0000000..7928102 --- /dev/null +++ b/design-tokens/motion.json @@ -0,0 +1,913 @@ +{ + "$description": "motion", + "hover": { + "modeStandard": { + "duration": { + "$value": "{transitions.transition.default}", + "$description": "Changement d'état au survol (couleurs). Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:113", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "TIMING", + "description": "Changement d'état au survol (couleurs). Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-hover-duration)" + } + } + } + }, + "easing": { + "$value": "{transitions.transition.easeinout}", + "$description": "Changement d'état au survol (couleurs). Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "$type": "cubicBezier", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:114", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "EASING", + "description": "Changement d'état au survol (couleurs). Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-hover-easing)" + } + } + } + } + }, + "modeReduced": { + "duration": { + "$value": "{transitions.transition.default}", + "$description": "Changement d'état au survol (couleurs). Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:113", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "TIMING", + "description": "Changement d'état au survol (couleurs). Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-hover-duration)" + } + } + } + }, + "easing": { + "$value": "{transitions.transition.easeinout}", + "$description": "Changement d'état au survol (couleurs). Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "$type": "cubicBezier", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:114", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "EASING", + "description": "Changement d'état au survol (couleurs). Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-hover-easing)" + } + } + } + } + } + }, + "toggle": { + "modeStandard": { + "duration": { + "$value": "{transitions.transition.default}", + "$description": "Bascule d'un élément qui se déplace : pouce du toggle, indicateur d'onglet. Aujourd'hui : default, le timing actuel du kit.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:115", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "TIMING", + "description": "Bascule d'un élément qui se déplace : pouce du toggle, indicateur d'onglet. Aujourd'hui : default, le timing actuel du kit.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-toggle-duration)" + } + } + } + }, + "easing": { + "$value": "{transitions.transition.easeinout}", + "$description": "Bascule d'un élément qui se déplace : pouce du toggle, indicateur d'onglet. Aujourd'hui : default, le timing actuel du kit.", + "$type": "cubicBezier", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:116", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "EASING", + "description": "Bascule d'un élément qui se déplace : pouce du toggle, indicateur d'onglet. Aujourd'hui : default, le timing actuel du kit.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-toggle-easing)" + } + } + } + } + }, + "modeReduced": { + "duration": { + "$value": "{transitions.transition.fast}", + "$description": "Bascule d'un élément qui se déplace : pouce du toggle, indicateur d'onglet. Aujourd'hui : default, le timing actuel du kit.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:115", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "TIMING", + "description": "Bascule d'un élément qui se déplace : pouce du toggle, indicateur d'onglet. Aujourd'hui : default, le timing actuel du kit.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-toggle-duration)" + } + } + } + }, + "easing": { + "$value": "{transitions.transition.easeinout}", + "$description": "Bascule d'un élément qui se déplace : pouce du toggle, indicateur d'onglet. Aujourd'hui : default, le timing actuel du kit.", + "$type": "cubicBezier", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:116", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "EASING", + "description": "Bascule d'un élément qui se déplace : pouce du toggle, indicateur d'onglet. Aujourd'hui : default, le timing actuel du kit.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-toggle-easing)" + } + } + } + } + } + }, + "disclosure": { + "modeStandard": { + "in": { + "duration": { + "$value": "{transitions.transition.default}", + "$description": "Ouverture d'une section repliable. Aujourd'hui : default, le timing actuel du kit.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:117", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "TIMING", + "description": "Ouverture d'une section repliable. Aujourd'hui : default, le timing actuel du kit.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-disclosure-in-duration)" + } + } + } + }, + "easing": { + "$value": "{transitions.transition.easeout}", + "$description": "Ouverture d'une section repliable. Aujourd'hui : default, le timing actuel du kit.", + "$type": "cubicBezier", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:118", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "EASING", + "description": "Ouverture d'une section repliable. Aujourd'hui : default, le timing actuel du kit.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-disclosure-in-easing)" + } + } + } + } + }, + "out": { + "duration": { + "$value": "{transitions.transition.default}", + "$description": "Repli d'une section. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:119", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "TIMING", + "description": "Repli d'une section. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-disclosure-out-duration)" + } + } + } + }, + "easing": { + "$value": "{transitions.transition.easein}", + "$description": "Repli d'une section. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "$type": "cubicBezier", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:120", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "EASING", + "description": "Repli d'une section. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-disclosure-out-easing)" + } + } + } + } + } + }, + "modeReduced": { + "in": { + "duration": { + "$value": "{transitions.transition.fast}", + "$description": "Ouverture d'une section repliable. Aujourd'hui : default, le timing actuel du kit.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:117", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "TIMING", + "description": "Ouverture d'une section repliable. Aujourd'hui : default, le timing actuel du kit.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-disclosure-in-duration)" + } + } + } + }, + "easing": { + "$value": "{transitions.transition.easeout}", + "$description": "Ouverture d'une section repliable. Aujourd'hui : default, le timing actuel du kit.", + "$type": "cubicBezier", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:118", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "EASING", + "description": "Ouverture d'une section repliable. Aujourd'hui : default, le timing actuel du kit.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-disclosure-in-easing)" + } + } + } + } + }, + "out": { + "duration": { + "$value": "{transitions.transition.fast}", + "$description": "Repli d'une section. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:119", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "TIMING", + "description": "Repli d'une section. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-disclosure-out-duration)" + } + } + } + }, + "easing": { + "$value": "{transitions.transition.easein}", + "$description": "Repli d'une section. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "$type": "cubicBezier", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:120", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "EASING", + "description": "Repli d'une section. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-disclosure-out-easing)" + } + } + } + } + } + } + }, + "overlay": { + "modeStandard": { + "in": { + "duration": { + "$value": "{transitions.transition.default}", + "$description": "Apparition d'un menu, popover, tooltip, liste. Aujourd'hui : default, le timing actuel du kit.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:121", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "TIMING", + "description": "Apparition d'un menu, popover, tooltip, liste. Aujourd'hui : default, le timing actuel du kit.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-overlay-in-duration)" + } + } + } + }, + "easing": { + "$value": "{transitions.transition.easeout}", + "$description": "Apparition d'un menu, popover, tooltip, liste. Aujourd'hui : default, le timing actuel du kit.", + "$type": "cubicBezier", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:122", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "EASING", + "description": "Apparition d'un menu, popover, tooltip, liste. Aujourd'hui : default, le timing actuel du kit.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-overlay-in-easing)" + } + } + } + } + }, + "out": { + "duration": { + "$value": "{transitions.transition.default}", + "$description": "Disparition d'un menu, popover, tooltip, liste. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:123", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "TIMING", + "description": "Disparition d'un menu, popover, tooltip, liste. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-overlay-out-duration)" + } + } + } + }, + "easing": { + "$value": "{transitions.transition.easein}", + "$description": "Disparition d'un menu, popover, tooltip, liste. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "$type": "cubicBezier", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:124", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "EASING", + "description": "Disparition d'un menu, popover, tooltip, liste. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-overlay-out-easing)" + } + } + } + } + } + }, + "modeReduced": { + "in": { + "duration": { + "$value": "{transitions.transition.fast}", + "$description": "Apparition d'un menu, popover, tooltip, liste. Aujourd'hui : default, le timing actuel du kit.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:121", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "TIMING", + "description": "Apparition d'un menu, popover, tooltip, liste. Aujourd'hui : default, le timing actuel du kit.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-overlay-in-duration)" + } + } + } + }, + "easing": { + "$value": "{transitions.transition.easeout}", + "$description": "Apparition d'un menu, popover, tooltip, liste. Aujourd'hui : default, le timing actuel du kit.", + "$type": "cubicBezier", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:122", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "EASING", + "description": "Apparition d'un menu, popover, tooltip, liste. Aujourd'hui : default, le timing actuel du kit.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-overlay-in-easing)" + } + } + } + } + }, + "out": { + "duration": { + "$value": "{transitions.transition.fast}", + "$description": "Disparition d'un menu, popover, tooltip, liste. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:123", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "TIMING", + "description": "Disparition d'un menu, popover, tooltip, liste. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-overlay-out-duration)" + } + } + } + }, + "easing": { + "$value": "{transitions.transition.easein}", + "$description": "Disparition d'un menu, popover, tooltip, liste. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "$type": "cubicBezier", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:124", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "EASING", + "description": "Disparition d'un menu, popover, tooltip, liste. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : fast.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-overlay-out-easing)" + } + } + } + } + } + } + }, + "dialog": { + "modeStandard": { + "in": { + "duration": { + "$value": "{transitions.transition.default}", + "$description": "Apparition d'une modale, d'un drawer, d'une bottom-sheet. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : slow.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:125", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "TIMING", + "description": "Apparition d'une modale, d'un drawer, d'une bottom-sheet. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : slow.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-dialog-in-duration)" + } + } + } + }, + "easing": { + "$value": "{transitions.transition.easeout}", + "$description": "Apparition d'une modale, d'un drawer, d'une bottom-sheet. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : slow.", + "$type": "cubicBezier", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:126", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "EASING", + "description": "Apparition d'une modale, d'un drawer, d'une bottom-sheet. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : slow.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-dialog-in-easing)" + } + } + } + } + }, + "out": { + "duration": { + "$value": "{transitions.transition.default}", + "$description": "Disparition d'une modale, d'un drawer, d'une bottom-sheet. Aujourd'hui : default, le timing actuel du kit.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:127", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "TIMING", + "description": "Disparition d'une modale, d'un drawer, d'une bottom-sheet. Aujourd'hui : default, le timing actuel du kit.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-dialog-out-duration)" + } + } + } + }, + "easing": { + "$value": "{transitions.transition.easein}", + "$description": "Disparition d'une modale, d'un drawer, d'une bottom-sheet. Aujourd'hui : default, le timing actuel du kit.", + "$type": "cubicBezier", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:128", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "EASING", + "description": "Disparition d'une modale, d'un drawer, d'une bottom-sheet. Aujourd'hui : default, le timing actuel du kit.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-dialog-out-easing)" + } + } + } + } + } + }, + "modeReduced": { + "in": { + "duration": { + "$value": "{transitions.transition.fast}", + "$description": "Apparition d'une modale, d'un drawer, d'une bottom-sheet. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : slow.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:125", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "TIMING", + "description": "Apparition d'une modale, d'un drawer, d'une bottom-sheet. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : slow.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-dialog-in-duration)" + } + } + } + }, + "easing": { + "$value": "{transitions.transition.easeout}", + "$description": "Apparition d'une modale, d'un drawer, d'une bottom-sheet. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : slow.", + "$type": "cubicBezier", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:126", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "EASING", + "description": "Apparition d'une modale, d'un drawer, d'une bottom-sheet. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : slow.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-dialog-in-easing)" + } + } + } + } + }, + "out": { + "duration": { + "$value": "{transitions.transition.fast}", + "$description": "Disparition d'une modale, d'un drawer, d'une bottom-sheet. Aujourd'hui : default, le timing actuel du kit.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:127", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "TIMING", + "description": "Disparition d'une modale, d'un drawer, d'une bottom-sheet. Aujourd'hui : default, le timing actuel du kit.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-dialog-out-duration)" + } + } + } + }, + "easing": { + "$value": "{transitions.transition.easein}", + "$description": "Disparition d'une modale, d'un drawer, d'une bottom-sheet. Aujourd'hui : default, le timing actuel du kit.", + "$type": "cubicBezier", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:128", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "EASING", + "description": "Disparition d'une modale, d'un drawer, d'une bottom-sheet. Aujourd'hui : default, le timing actuel du kit.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-dialog-out-easing)" + } + } + } + } + } + } + }, + "notify": { + "modeStandard": { + "in": { + "duration": { + "$value": "{transitions.transition.default}", + "$description": "Apparition d'un toast. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : slow.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:129", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "TIMING", + "description": "Apparition d'un toast. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : slow.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-notify-in-duration)" + } + } + } + }, + "easing": { + "$value": "{transitions.transition.easeout}", + "$description": "Apparition d'un toast. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : slow.", + "$type": "cubicBezier", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:130", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "EASING", + "description": "Apparition d'un toast. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : slow.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-notify-in-easing)" + } + } + } + } + }, + "out": { + "duration": { + "$value": "{transitions.transition.default}", + "$description": "Disparition d'un toast. Aujourd'hui : default, le timing actuel du kit.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:131", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "TIMING", + "description": "Disparition d'un toast. Aujourd'hui : default, le timing actuel du kit.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-notify-out-duration)" + } + } + } + }, + "easing": { + "$value": "{transitions.transition.easein}", + "$description": "Disparition d'un toast. Aujourd'hui : default, le timing actuel du kit.", + "$type": "cubicBezier", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:132", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "EASING", + "description": "Disparition d'un toast. Aujourd'hui : default, le timing actuel du kit.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-notify-out-easing)" + } + } + } + } + } + }, + "modeReduced": { + "in": { + "duration": { + "$value": "{transitions.transition.fast}", + "$description": "Apparition d'un toast. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : slow.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:129", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "TIMING", + "description": "Apparition d'un toast. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : slow.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-notify-in-duration)" + } + } + } + }, + "easing": { + "$value": "{transitions.transition.easeout}", + "$description": "Apparition d'un toast. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : slow.", + "$type": "cubicBezier", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:130", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "EASING", + "description": "Apparition d'un toast. Aujourd'hui : default, le timing actuel du kit. Proposition atelier : slow.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-notify-in-easing)" + } + } + } + } + }, + "out": { + "duration": { + "$value": "{transitions.transition.fast}", + "$description": "Disparition d'un toast. Aujourd'hui : default, le timing actuel du kit.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:131", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "TIMING", + "description": "Disparition d'un toast. Aujourd'hui : default, le timing actuel du kit.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-notify-out-duration)" + } + } + } + }, + "easing": { + "$value": "{transitions.transition.easein}", + "$description": "Disparition d'un toast. Aujourd'hui : default, le timing actuel du kit.", + "$type": "cubicBezier", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:132", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "EASING", + "description": "Disparition d'un toast. Aujourd'hui : default, le timing actuel du kit.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-notify-out-easing)" + } + } + } + } + } + } + }, + "stagger": { + "modeStandard": { + "delay": { + "$value": "{transitions.transition.fast}", + "$description": "Décalage entre deux éléments qui apparaissent l'un après l'autre (speed-dial). Mouvement réduit : aucun décalage.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:133", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "TIMING", + "description": "Décalage entre deux éléments qui apparaissent l'un après l'autre (speed-dial). Mouvement réduit : aucun décalage.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-stagger-delay)" + } + } + } + } + }, + "modeReduced": { + "delay": { + "$value": "0ms", + "$description": "Décalage entre deux éléments qui apparaissent l'un après l'autre (speed-dial). Mouvement réduit : aucun décalage.", + "$type": "duration", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:133", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "TIMING", + "description": "Décalage entre deux éléments qui apparaissent l'un après l'autre (speed-dial). Mouvement réduit : aucun décalage.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--motion-stagger-delay)" + } + } + } + } + } + }, + "slide": { + "modeStandard": { + "distance": { + "$value": "8px", + "$description": "Glissement des préréglages slide-* (8 px = 0.5rem). Mouvement réduit : 0, le glissement devient un fondu.", + "$type": "dimension", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:134", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "FLOAT", + "description": "Glissement des préréglages slide-* (8 px = 0.5rem). Mouvement réduit : 0, le glissement devient un fondu.", + "codeSyntax": { + "WEB": "var(--motion-slide-distance)" + } + } + } + } + }, + "modeReduced": { + "distance": { + "$value": "0px", + "$description": "Glissement des préréglages slide-* (8 px = 0.5rem). Mouvement réduit : 0, le glissement devient un fondu.", + "$type": "dimension", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:134", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "FLOAT", + "description": "Glissement des préréglages slide-* (8 px = 0.5rem). Mouvement réduit : 0, le glissement devient un fondu.", + "codeSyntax": { + "WEB": "var(--motion-slide-distance)" + } + } + } + } + } + }, + "zoom": { + "modeStandard": { + "scale": { + "$value": 0.9599999785423279, + "$description": "Échelle de départ et d'arrivée du préréglage zoom. Mouvement réduit : 1, le zoom devient un fondu.", + "$type": "number", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:135", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:0", + "resolvedType": "FLOAT", + "description": "Échelle de départ et d'arrivée du préréglage zoom. Mouvement réduit : 1, le zoom devient un fondu.", + "codeSyntax": { + "WEB": "var(--motion-zoom-scale)" + } + } + } + } + }, + "modeReduced": { + "scale": { + "$value": 1, + "$description": "Échelle de départ et d'arrivée du préréglage zoom. Mouvement réduit : 1, le zoom devient un fondu.", + "$type": "number", + "$extensions": { + "com.figma": { + "variableId": "VariableID:3838:135", + "collectionId": "VariableCollectionId:3838:112", + "modeId": "3838:1", + "resolvedType": "FLOAT", + "description": "Échelle de départ et d'arrivée du préréglage zoom. Mouvement réduit : 1, le zoom devient un fondu.", + "codeSyntax": { + "WEB": "var(--motion-zoom-scale)" + } + } + } + } + } + } +} diff --git a/design-tokens/transitions.json b/design-tokens/transitions.json index b2dc189..74d08a0 100644 --- a/design-tokens/transitions.json +++ b/design-tokens/transitions.json @@ -3,85 +3,163 @@ "transition": { "fast": { "$value": "80ms", - "$type": "string", + "$description": "Primitive. Dans un composant, passer par une recette de la collection motion.", + "$type": "duration", "$extensions": { "com.figma": { - "variableId": "VariableID:966:120", + "variableId": "VariableID:3837:15239", "collectionId": "VariableCollectionId:966:119", "modeId": "966:1", - "resolvedType": "STRING" + "resolvedType": "TIMING", + "description": "Primitive. Dans un composant, passer par une recette de la collection motion.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--transition-fast)" + } } } }, "default": { "$value": "180ms", - "$type": "string", + "$description": "Primitive. Dans un composant, passer par une recette de la collection motion.", + "$type": "duration", "$extensions": { "com.figma": { - "variableId": "VariableID:966:121", + "variableId": "VariableID:3837:15240", "collectionId": "VariableCollectionId:966:119", "modeId": "966:1", - "resolvedType": "STRING" + "resolvedType": "TIMING", + "description": "Primitive. Dans un composant, passer par une recette de la collection motion.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--transition-default)" + } } } }, "slow": { "$value": "280ms", - "$type": "string", + "$description": "Primitive. Dans un composant, passer par une recette de la collection motion.", + "$type": "duration", "$extensions": { "com.figma": { - "variableId": "VariableID:966:122", + "variableId": "VariableID:3837:15241", "collectionId": "VariableCollectionId:966:119", "modeId": "966:1", - "resolvedType": "STRING" + "resolvedType": "TIMING", + "description": "Primitive. Dans un composant, passer par une recette de la collection motion.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--transition-slow)" + } } } }, "easein": { - "$value": "ease-in", - "$type": "string", + "$value": [0.42, 0, 1, 1], + "$description": "Primitive accélérée (CSS ease-in). Dans un composant, passer par une recette.", + "$type": "cubicBezier", "$extensions": { "com.figma": { - "variableId": "VariableID:966:123", + "variableId": "VariableID:3837:15242", "collectionId": "VariableCollectionId:966:119", "modeId": "966:1", - "resolvedType": "STRING" + "resolvedType": "EASING", + "description": "Primitive accélérée (CSS ease-in). Dans un composant, passer par une recette.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--transition-easein)" + }, + "easing": { + "type": "EASE_IN", + "easingFunctionCubicBezier": { + "x1": 0.42, + "y1": 0, + "x2": 1, + "y2": 1 + } + } } } }, "easeout": { - "$value": "ease-out", - "$type": "string", + "$value": [0, 0, 0.58, 1], + "$description": "Primitive décélérée (CSS ease-out). Dans un composant, passer par une recette.", + "$type": "cubicBezier", "$extensions": { "com.figma": { - "variableId": "VariableID:966:124", + "variableId": "VariableID:3837:15243", "collectionId": "VariableCollectionId:966:119", "modeId": "966:1", - "resolvedType": "STRING" + "resolvedType": "EASING", + "description": "Primitive décélérée (CSS ease-out). Dans un composant, passer par une recette.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--transition-easeout)" + }, + "easing": { + "type": "EASE_OUT", + "easingFunctionCubicBezier": { + "x1": 0, + "y1": 0, + "x2": 0.58, + "y2": 1 + } + } } } }, "easeinout": { - "$value": "ease-in-out", - "$type": "string", + "$value": [0.42, 0, 0.58, 1], + "$description": "Primitive symétrique (CSS ease-in-out). Dans un composant, passer par une recette.", + "$type": "cubicBezier", "$extensions": { "com.figma": { - "variableId": "VariableID:966:125", + "variableId": "VariableID:3837:15244", "collectionId": "VariableCollectionId:966:119", "modeId": "966:1", - "resolvedType": "STRING" + "resolvedType": "EASING", + "description": "Primitive symétrique (CSS ease-in-out). Dans un composant, passer par une recette.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--transition-easeinout)" + }, + "easing": { + "type": "EASE_IN_AND_OUT", + "easingFunctionCubicBezier": { + "x1": 0.42, + "y1": 0, + "x2": 0.58, + "y2": 1 + } + } } } }, "linear": { - "$value": "linear", - "$type": "string", + "$value": [0, 0, 1, 1], + "$description": "Primitive à vitesse constante (CSS linear). Dans un composant, passer par une recette.", + "$type": "cubicBezier", "$extensions": { "com.figma": { - "variableId": "VariableID:966:126", + "variableId": "VariableID:3837:15245", "collectionId": "VariableCollectionId:966:119", "modeId": "966:1", - "resolvedType": "STRING" + "resolvedType": "EASING", + "description": "Primitive à vitesse constante (CSS linear). Dans un composant, passer par une recette.", + "scopes": ["ALL_SCOPES"], + "codeSyntax": { + "WEB": "var(--transition-linear)" + }, + "easing": { + "type": "LINEAR", + "easingFunctionCubicBezier": { + "x1": 0, + "y1": 0, + "x2": 1, + "y2": 1 + } + } } } } diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index b68fdad..121a11c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -296,3 +296,34 @@ déclare deux projets, `unit` et `storybook`. Le second rend chaque story et la Le contrôle a trouvé sa première violation à la minute où il a été branché : un contraste de 1,75 sur le contre-exemple `OnColorOmitted` de `ui-button`, qui existe justement pour montrer ce défaut, et qui est donc exempté avec sa justification écrite. + +## D9, Mouvement : primitives typées, recettes par usage, mouvement réduit par mode + +**Retenu le 20 octobre (FSHSP-252 côté Angular, FSHSP-253 ici)** : le mouvement suit le +modèle de la couleur, en trois couches. Inspiration : motiondesignsystem.org. + +- **Primitives** : la collection `transitions`, `--transition-*`, sous les mêmes noms + qu'avant. Dans Figma, elles passent du type `STRING` aux types `TIMING` et `EASING` : + `tokens:build` lit donc les valeurs typées du format DTCG (`"80ms"`, `[x1, y1, x2, y2]`). +- **Recettes** : la collection `motion`, `--motion-*`, une par usage (`hover`, `toggle`, + `overlay-in`/`-out`, `disclosure`, `dialog`, `notify`, `stagger`, `slide`, `zoom`), en + alias des primitives. Les composants lisent les recettes, avec repli sur la primitive + d'avant (`var(--motion-hover-duration, var(--transition-default))`). +- **Chorégraphies** : les préréglages `ui-motion-*` et `utils.overlay-motion`, qui lisent + les recettes. + +**Écarté : renommer ou déprécier les `--transition-*`.** Ce serait une rupture pour tout +projet qui les surcharge, sans rien gagner : une recette qui les référence apporte le +vocabulaire par usage, et une surcharge de primitive continue de s'appliquer. + +**Le mouvement réduit est un mode de la collection `motion`**, `Reduced`, émis sous +`(prefers-reduced-motion: reduce)` : glissement à 0, échelle à 1, durées de déplacement à +`fast`. Un glissement devient un fondu court au lieu de disparaître : l'utilisateur voit +encore l'état changer. Les changements de couleur (`hover`) gardent leur durée, ce ne sont +pas des mouvements. `data-motion="off"` reste l'interrupteur dur, celui des tests de bout en +bout. Un panneau qui glisse depuis le bord de l'écran (tiroir, bottom sheet, barre latérale +en surimpression) ne lit pas la distance des recettes : il garde sa garde instantanée. + +Le mouvement ne passe que par des jetons : `pnpm motion:check` refuse une durée, une courbe +ou une primitive écrite dans le SCSS d'un composant. Les boucles (skeleton, spinner, +progress-bar) en sont exemptées tant que la recette `loop` n'existe pas. diff --git a/docs/DUAL-ENGINE.md b/docs/DUAL-ENGINE.md index dc2366e..d8ea6f7 100644 --- a/docs/DUAL-ENGINE.md +++ b/docs/DUAL-ENGINE.md @@ -7,16 +7,17 @@ doit être vérifié à la main, et le cahier des charges du contrôle qui l'aut ## Les invariants -| Invariant | Pourquoi il compte | -| --------------------------- | ----------------------------------------------------------------------------------------------------------- | -| **Nom du composant** | `ui-button` des deux côtés. C'est la clé qui relie une story, une page Figma et une entrée MCP. | -| **Valeurs des jetons** | `design-tokens/*.json` est la même donnée dupliquée (décision D2). Une divergence casse la parité visuelle. | -| **Noms de classes CSS** | `.ui-button`, `._high`, `._small`. Un projet qui migre d'un stack à l'autre garde ses surcharges. | -| **Noms des hooks `--ui-*`** | Le thème d'un projet (`component-vars.scss`) doit s'appliquer aux deux stacks sans réécriture. | -| **`node-id` Figma** | Un seul composant Figma par composant du DS, référencé par les deux stories. | -| **Sémantique des props** | Les noms peuvent différer (`ngModel` → `value`), le sens jamais. | -| **Titre dans Storybook** | `Components/ui//ui-` : la doc des deux stacks se lit au même endroit. | -| **Noms des tools MCP** | `list_components`, `get_component_doc`, `search_docs`, `get_shared_config` : un agent garde sa démarche. | +| Invariant | Pourquoi il compte | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Nom du composant** | `ui-button` des deux côtés. C'est la clé qui relie une story, une page Figma et une entrée MCP. | +| **Valeurs des jetons** | `design-tokens/*.json` est la même donnée dupliquée (décision D2). Une divergence casse la parité visuelle. | +| **Noms de classes CSS** | `.ui-button`, `._high`, `._small`. Un projet qui migre d'un stack à l'autre garde ses surcharges. | +| **Noms des hooks `--ui-*`** | Le thème d'un projet (`component-vars.scss`) doit s'appliquer aux deux stacks sans réécriture. | +| **`node-id` Figma** | Un seul composant Figma par composant du DS, référencé par les deux stories. | +| **Sémantique des props** | Les noms peuvent différer (`ngModel` → `value`), le sens jamais. | +| **Titre dans Storybook** | `Components/ui//ui-` : la doc des deux stacks se lit au même endroit. | +| **Noms des tools MCP** | `list_components`, `get_component_doc`, `search_docs`, `get_shared_config` : un agent garde sa démarche. | +| **Noms du mouvement** | Mêmes primitives `--transition-*` et mêmes recettes `--motion-*` des deux côtés (décision D9) : une surcharge de thème recadence les deux stacks. | ## Ce qui a le droit de diverger diff --git a/figma/README.md b/figma/README.md index d5080b7..b507a69 100644 --- a/figma/README.md +++ b/figma/README.md @@ -10,7 +10,7 @@ The components' `--ui-*` variables, in the same DTCG shape as one too. Generated by `pnpm docs:config`; `docs:config:check` fails if it is stale. **These are not design tokens.** A token belongs to the system (`--units-sm`); a -component variable belongs to one component, and 1269 of the 1433 entries merely _alias_ a +component variable belongs to one component, and 1271 of the 1433 entries merely _alias_ a token. Hence a **dedicated collection** (`$extensions.com.4sh.ui-kit.figmaCollection`) rather than adding them to `semantics` or `metrics`. diff --git a/figma/component-vars.json b/figma/component-vars.json index 4d1d005..f721836 100644 --- a/figma/component-vars.json +++ b/figma/component-vars.json @@ -11,13 +11,14 @@ ], "counts": { "tokens": 1433, - "alias": 1269, - "literal": 164, + "alias": 1271, + "literal": 162, "skipped": 63, "derivedDescriptions": 223 }, "aliasTargets": [ "metrics", + "motion", "primitives", "responsive", "semantics", @@ -72,12 +73,12 @@ { "cssVar": "--ui-control-transition-duration", "component": "ui-config", - "reason": "sans équivalent variable Figma (durée, easing, curseur, plan d’empilement, ratio)." + "reason": "2 défauts selon la variante rendue : en déclarer un seul écraserait les autres." }, { "cssVar": "--ui-control-transition-easing", "component": "ui-config", - "reason": "sans équivalent variable Figma (durée, easing, curseur, plan d’empilement, ratio)." + "reason": "2 défauts selon la variante rendue : en déclarer un seul écraserait les autres." }, { "cssVar": "--ui-datepicker-panel-max-height", @@ -18642,7 +18643,7 @@ }, "label": { "color": { - "$value": "var(--form-high-content-default, var(--global-text-default))", + "$value": "{semantics.form.high.content.default}", "$type": "color", "$description": "Couleur du texte, pilotable par un composant de formulaire parent (hover, disabled…).", "$extensions": { @@ -20325,7 +20326,7 @@ }, "motion": { "distance": { - "$value": "8px", + "$value": "{motion.slide.distance}", "$type": "dimension", "$description": "Distance du glissement d’entrée et de sortie, pour un exemplaire.", "$extensions": { diff --git a/packages/ui-kit-react/src/actions/ui-speed-dial/ui-speed-dial.scss b/packages/ui-kit-react/src/actions/ui-speed-dial/ui-speed-dial.scss index be08bd9..fc7878c 100644 --- a/packages/ui-kit-react/src/actions/ui-speed-dial/ui-speed-dial.scss +++ b/packages/ui-kit-react/src/actions/ui-speed-dial/ui-speed-dial.scss @@ -8,7 +8,7 @@ // --- Config ----------------------------------------------------------- $z-index: var(--ui-speed-dial-z-index, 10); /// Palier d'empilement du bouton et de ses actions au-dessus du masque. $item-gap: var(--ui-speed-dial-item-gap, var(--units-sm)); /// Espace entre le déclencheur et la première action, et entre deux actions empilées. -$item-delay: var(--ui-speed-dial-item-delay, var(--transition-fast)); /// Décalage d'apparition entre deux actions successives. +$item-delay: var(--ui-speed-dial-item-delay, #{utils.$motion-stagger-delay}); /// Décalage d'apparition entre deux actions successives. $radius-default: var( --ui-speed-dial-radius, calc(2 * var(--size-components-default)) diff --git a/packages/ui-kit-react/src/core/motion/motion.mdx b/packages/ui-kit-react/src/core/motion/motion.mdx index 08cb87c..878d4c3 100644 --- a/packages/ui-kit-react/src/core/motion/motion.mdx +++ b/packages/ui-kit-react/src/core/motion/motion.mdx @@ -179,38 +179,102 @@ allow-discrete` et `@starting-style`. `utils.overlay-motion-enter` en est la variante « entrée seule » : la sortie est instantanée, pour un panneau dont l'ancre peut disparaître sous lui, un sous-menu en cascade typiquement. +## Jetons : primitives et recettes + +Le mouvement suit le même modèle que la couleur. Les **primitives** `--transition-*` +(collection `transitions`) sont les valeurs. Les **recettes** `--motion-*` (collection +`motion`) les associent à un usage : une recette, c'est une durée et une courbe, en alias +d'une primitive. Les composants lisent les recettes, jamais les primitives. Les noms sont +les mêmes dans le starter Angular. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RecettePrimitivesUsage
{'--motion-hover-*'}default · easeinoutChangement d'apparence : couleur, opacité, ombre d'un contrôle.
{'--motion-toggle-*'}default · easeinoutDéplacement entre deux états : pouce du toggle, indicateur d'onglet, chevron, repli (utils.motion-transition).
{'--motion-overlay-in-*'} / {'-out-*'}default · easeout / easeinEntrée et sortie des préréglages {'ui-motion-*'} et des panneaux utils.overlay-motion.
{'--motion-disclosure-*'}, {'--motion-dialog-*'}, {'--motion-notify-*'}default · easeout / easeinSection repliable, modale, toast : prêtes pour l'atelier, pas encore lues par le kit.
--motion-stagger-delayfastDécalage entre deux éléments qui apparaissent l'un après l'autre (speed-dial).
--motion-slide-distance8pxGlissement des préréglages {'slide-*'} et des panneaux.
--motion-zoom-scale0.96Échelle de départ du préréglage zoom et des panneaux qui zooment.
+ +Chaque recette vaut aujourd'hui le timing que le kit avait avant elles. Pour recadencer +**tout le kit**, surchargez une recette sur `:root` (`--motion-hover-duration: 120ms`) : +seuls les usages qu'elle couvre changent. Surcharger une primitive (`--transition-default`) +fonctionne toujours, et touche toutes les recettes qui la référencent. + +Le mixin `utils.control-transition` choisit la recette selon la propriété : `transform`, +`top` ou `width` déplacent quelque chose et suivent `toggle`, une couleur ou une opacité suit +`hover`. Le hook `--ui-control-transition-duration` règle toujours les deux à la fois. + ## Personnalisation Chaque exemplaire se recadence par des custom properties, que le crochet pose depuis ses -options ou qu'on écrit directement en CSS. Les jetons servent de défauts. +options ou qu'on écrit directement en CSS. Les recettes servent de défauts. - + - - + + - + - + - + @@ -220,12 +284,12 @@ options ou qu'on écrit directement en CSS. Les jetons servent de défauts. - - + + - + diff --git a/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.scss b/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.scss index 135c227..1f7a556 100644 --- a/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.scss +++ b/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.scss @@ -84,13 +84,14 @@ $focus-ring-width: var(--ui-bottom-sheet-focus-ring-width, #{utils.$focus-ring-w background-color: $mask-background; opacity: 1; transition: - display allow-discrete var(--ui-motion-duration, #{utils.$motion-duration-base}), - overlay allow-discrete var(--ui-motion-duration, #{utils.$motion-duration-base}), - opacity var(--ui-motion-duration, #{utils.$motion-duration-base}); + display allow-discrete var(--ui-motion-duration, #{utils.$motion-overlay-in-duration}), + overlay allow-discrete var(--ui-motion-duration, #{utils.$motion-overlay-in-duration}), + opacity var(--ui-motion-duration, #{utils.$motion-overlay-in-duration}); } &:not([open])::backdrop { opacity: 0; + transition-duration: var(--ui-motion-duration, #{utils.$motion-overlay-out-duration}); } @starting-style { diff --git a/packages/ui-kit-react/src/layout/ui-drawer/ui-drawer.scss b/packages/ui-kit-react/src/layout/ui-drawer/ui-drawer.scss index 2a66c77..f683eac 100644 --- a/packages/ui-kit-react/src/layout/ui-drawer/ui-drawer.scss +++ b/packages/ui-kit-react/src/layout/ui-drawer/ui-drawer.scss @@ -50,14 +50,15 @@ $size-edge: var(--ui-drawer-size-edge, #{utils.rem-calc(160)}); &::backdrop { background-color: $mask-background; transition: - display allow-discrete var(--ui-motion-duration, #{utils.$motion-duration-base}), - overlay allow-discrete var(--ui-motion-duration, #{utils.$motion-duration-base}), - opacity var(--ui-motion-duration, #{utils.$motion-duration-base}); + display allow-discrete var(--ui-motion-duration, #{utils.$motion-overlay-in-duration}), + overlay allow-discrete var(--ui-motion-duration, #{utils.$motion-overlay-in-duration}), + opacity var(--ui-motion-duration, #{utils.$motion-overlay-in-duration}); opacity: 1; } &:not([open])::backdrop { opacity: 0; + transition-duration: var(--ui-motion-duration, #{utils.$motion-overlay-out-duration}); } @starting-style { diff --git a/packages/ui-kit-react/src/layout/ui-modal/ui-modal.scss b/packages/ui-kit-react/src/layout/ui-modal/ui-modal.scss index cea704b..60fb754 100644 --- a/packages/ui-kit-react/src/layout/ui-modal/ui-modal.scss +++ b/packages/ui-kit-react/src/layout/ui-modal/ui-modal.scss @@ -62,14 +62,15 @@ $mask-background: var(--ui-modal-mask-background, var(--primitives-black-50)); &::backdrop { background-color: $mask-background; transition: - display allow-discrete var(--ui-motion-duration, #{utils.$motion-duration-base}), - overlay allow-discrete var(--ui-motion-duration, #{utils.$motion-duration-base}), - opacity var(--ui-motion-duration, #{utils.$motion-duration-base}); + display allow-discrete var(--ui-motion-duration, #{utils.$motion-overlay-in-duration}), + overlay allow-discrete var(--ui-motion-duration, #{utils.$motion-overlay-in-duration}), + opacity var(--ui-motion-duration, #{utils.$motion-overlay-in-duration}); opacity: 1; } &:not([open])::backdrop { opacity: 0; + transition-duration: var(--ui-motion-duration, #{utils.$motion-overlay-out-duration}); } @starting-style { diff --git a/packages/ui-kit-react/src/navigation/ui-sidebar/ui-sidebar.scss b/packages/ui-kit-react/src/navigation/ui-sidebar/ui-sidebar.scss index 9831ee8..4e737ef 100644 --- a/packages/ui-kit-react/src/navigation/ui-sidebar/ui-sidebar.scss +++ b/packages/ui-kit-react/src/navigation/ui-sidebar/ui-sidebar.scss @@ -127,13 +127,14 @@ $rail-width-default: utils.rem-calc(72); &::backdrop { background-color: $backdrop-background; transition: - display allow-discrete var(--ui-motion-duration, #{utils.$motion-duration-base}), - overlay allow-discrete var(--ui-motion-duration, #{utils.$motion-duration-base}), - opacity var(--ui-motion-duration, #{utils.$motion-duration-base}); + display allow-discrete var(--ui-motion-duration, #{utils.$motion-overlay-in-duration}), + overlay allow-discrete var(--ui-motion-duration, #{utils.$motion-overlay-in-duration}), + opacity var(--ui-motion-duration, #{utils.$motion-overlay-in-duration}); opacity: 1; } &:not([open])::backdrop { opacity: 0; + transition-duration: var(--ui-motion-duration, #{utils.$motion-overlay-out-duration}); } @starting-style { &[open]::backdrop { diff --git a/packages/ui-kit-react/src/navigation/ui-tabs/ui-tabs.scss b/packages/ui-kit-react/src/navigation/ui-tabs/ui-tabs.scss index a985e01..806a354 100644 --- a/packages/ui-kit-react/src/navigation/ui-tabs/ui-tabs.scss +++ b/packages/ui-kit-react/src/navigation/ui-tabs/ui-tabs.scss @@ -243,7 +243,7 @@ $indicator-thickness: var(--ui-tabs-indicator-thickness, var(--stroke-default)); } &._active:not(._no-motion) { - animation: ui-motion-fade-in var(--transition-default) var(--transition-easeout) both; + animation: ui-motion-fade-in #{utils.$motion-overlay-in-duration} #{utils.$motion-overlay-in-easing} both; @include utils.motion-reduce { animation-duration: 0.01ms; diff --git a/packages/ui-kit-react/src/styles/base/_motion.scss b/packages/ui-kit-react/src/styles/base/_motion.scss index e4ffb04..6c41c41 100644 --- a/packages/ui-kit-react/src/styles/base/_motion.scss +++ b/packages/ui-kit-react/src/styles/base/_motion.scss @@ -7,20 +7,23 @@ @use 'sass:list'; @use 'utils'; -$_duration: var(--ui-motion-duration, #{utils.$motion-duration-base}); +// Resolved runtime values: the custom property, else the `overlay-in` / `overlay-out` +// recipes. `--ui-motion-duration` retimes both directions at once. +$_duration-enter: var(--ui-motion-duration, #{utils.$motion-overlay-in-duration}); +$_duration-leave: var(--ui-motion-duration, #{utils.$motion-overlay-out-duration}); $_delay: var(--ui-motion-delay, 0ms); -$_ease-enter: var(--ui-motion-easing-enter, #{utils.$motion-easing-enter}); -$_ease-leave: var(--ui-motion-easing-leave, #{utils.$motion-easing-leave}); +$_ease-enter: var(--ui-motion-easing-enter, #{utils.$motion-overlay-in-easing}); +$_ease-leave: var(--ui-motion-easing-leave, #{utils.$motion-overlay-out-easing}); $_distance: var(--ui-motion-distance, #{utils.$motion-distance}); $_scale: var(--ui-motion-scale, #{utils.$motion-scale}); $_presets: fade, slide-up, slide-down, slide-left, slide-right, zoom, collapse; @mixin _enter($name) { - animation: $name $_duration $_ease-enter $_delay both; + animation: $name $_duration-enter $_ease-enter $_delay both; } @mixin _leave($name) { - animation: $name $_duration $_ease-leave $_delay both; + animation: $name $_duration-leave $_ease-leave $_delay both; } // --- Enter/leave class pairs ----------------------------------------- diff --git a/packages/ui-kit-react/src/styles/component-vars.scss b/packages/ui-kit-react/src/styles/component-vars.scss index 53e2e1c..ecd2b9a 100644 --- a/packages/ui-kit-react/src/styles/component-vars.scss +++ b/packages/ui-kit-react/src/styles/component-vars.scss @@ -11,7 +11,7 @@ // design token instead : it follows brand, light/dark and viewport. // // Generated by `pnpm docs:config`, values read from the compiled CSS, do not -// edit here. 1373 variables; 123 more exist whose value depends on the +// edit here. 1371 variables; 125 more exist whose value depends on the // rendered variant, listed in each component’s “Theming” section in Storybook. // ===================================================================== @@ -76,7 +76,7 @@ // Couleurs --ui-speed-dial-mask-background: var(--primitives-black-50); // Motion - --ui-speed-dial-item-delay: var(--transition-fast); + --ui-speed-dial-item-delay: var(--motion-stagger-delay, var(--transition-fast)); // Divers --ui-speed-dial-z-index: 10; @@ -1735,9 +1735,6 @@ --ui-form-field-spinner-color-hover: var(--form-high-content-hover); --ui-form-field-spinner-stroke: var(--form-high-stroke-default); --ui-form-field-spinner-surface-hover: var(--form-high-surface-hover); - // Motion - --ui-control-transition-duration: var(--transition-default); - --ui-control-transition-easing: var(--transition-easeinout); // Divers --ui-overlay-panel-shadow: var(--shadow-default-md); diff --git a/packages/ui-kit-react/src/styles/settings/_ui-config.scss b/packages/ui-kit-react/src/styles/settings/_ui-config.scss index aa2fa97..2c3422b 100644 --- a/packages/ui-kit-react/src/styles/settings/_ui-config.scss +++ b/packages/ui-kit-react/src/styles/settings/_ui-config.scss @@ -8,20 +8,38 @@ $focus-ring-width: var(--ui-focus-ring-width, var(--units-2xs)); /// Épaisseur de l'anneau de focus, pour tout le kit. $control-stroke-width: var(--ui-control-stroke-width, var(--stroke-default)); /// Épaisseur de bordure des contrôles. -$control-transition-duration: var(--ui-control-transition-duration, var(--transition-default)); /// Durée des transitions d'état des contrôles. -$control-transition-easing: var(--ui-control-transition-easing, var(--transition-easeinout)); /// Courbe des transitions d'état des contrôles. + +// --- Global : motion recipes ------------------------------------------ +// One recipe per usage (collection `motion`), each an alias of a `--transition-*` +// primitive. The fallback is the primitive the kit read before the recipes: a project +// whose tokens predate the collection keeps its timing. +$motion-hover-duration: var(--motion-hover-duration, var(--transition-default)); /// Durée d'un changement d'apparence : couleur, opacité, ombre. +$motion-hover-easing: var(--motion-hover-easing, var(--transition-easeinout)); /// Courbe d'un changement d'apparence. +$motion-toggle-duration: var(--motion-toggle-duration, var(--transition-default)); /// Durée d'un déplacement entre deux états : pouce, indicateur, chevron, repli. +$motion-toggle-easing: var(--motion-toggle-easing, var(--transition-easeinout)); /// Courbe d'un déplacement entre deux états. +$motion-overlay-in-duration: var(--motion-overlay-in-duration, var(--transition-default)); /// Durée d'apparition des préréglages `ui-motion-*` et des panneaux du calque supérieur. +$motion-overlay-in-easing: var(--motion-overlay-in-easing, var(--transition-easeout)); /// Courbe d'apparition (décélérée). +$motion-overlay-out-duration: var(--motion-overlay-out-duration, var(--transition-default)); /// Durée de disparition des préréglages `ui-motion-*` et des panneaux du calque supérieur. +$motion-overlay-out-easing: var(--motion-overlay-out-easing, var(--transition-easein)); /// Courbe de disparition (accélérée). + +$control-transition-duration: var(--ui-control-transition-duration, #{$motion-hover-duration}); /// Durée des changements d'apparence des contrôles. +$control-transition-easing: var(--ui-control-transition-easing, #{$motion-hover-easing}); /// Courbe des changements d'apparence des contrôles. +$control-motion-duration: var(--ui-control-transition-duration, #{$motion-toggle-duration}); /// Durée des déplacements des contrôles (même hook : une surcharge vaut pour les deux). +$control-motion-easing: var(--ui-control-transition-easing, #{$motion-toggle-easing}); /// Courbe des déplacements des contrôles. // --- Global : motion -------------------------------------------------- -// Not hooked: `--ui-motion-*` already retunes each instance, and the kit-wide -// default is the `--transition-*` token. +// Not hooked: `--ui-motion-*` already retunes each instance. The kit reads the +// recipes above; the primitives below stay for code that picks a duration or a +// curve itself. $motion-duration-fast: var(--transition-fast); /// Durée « micro-feedback ». $motion-duration-base: var(--transition-default); /// Durée par défaut (apparition, disparition, collapse). $motion-duration-slow: var(--transition-slow); /// Durée « grande surface ». $motion-easing-standard: var(--transition-easeinout); /// Courbe symétrique (bascules). $motion-easing-enter: var(--transition-easeout); /// Courbe décélérée (apparition). $motion-easing-leave: var(--transition-easein); /// Courbe accélérée (disparition). -$motion-distance: 0.5rem; /// Distance du glissement des presets `slide`. -$motion-scale: 0.96; /// Échelle de départ / d’arrivée des presets `zoom`. +$motion-distance: var(--motion-slide-distance, 0.5rem); /// Distance du glissement des presets `slide`. +$motion-scale: var(--motion-zoom-scale, 0.96); /// Échelle de départ / d’arrivée des presets `zoom`. +$motion-stagger-delay: var(--motion-stagger-delay, var(--transition-fast)); /// Décalage entre deux éléments qui apparaissent l'un après l'autre. // --- Global : ripple --------------------------------------------------- // Not hooked: the inherited `--ui-ripple-*` properties already retune the wave. diff --git a/packages/ui-kit-react/src/styles/utils/_mixins.scss b/packages/ui-kit-react/src/styles/utils/_mixins.scss index 64f4c42..61c6e10 100644 --- a/packages/ui-kit-react/src/styles/utils/_mixins.scss +++ b/packages/ui-kit-react/src/styles/utils/_mixins.scss @@ -5,16 +5,39 @@ @use 'sass:list'; @use '../settings/ui-config'; +/// Properties that move something: they take the `toggle` recipe, every other one +/// (colour, opacity, shadow) the `hover` recipe. +$_moving-properties: ( + transform, + translate, + rotate, + scale, + top, + right, + bottom, + left, + inset, + width, + height, + inline-size, + block-size +); + /// Transition recipe (duration/easing from ui-config) for the given properties. +/// A property that moves something (transform, top, width…) follows the `toggle` +/// recipe, an appearance change (colour, opacity…) the `hover` one: the thumb of a +/// toggle and its colour can then be timed apart, from the tokens alone. /// e.g. @include utils.control-transition(background-color, border-color); @mixin control-transition($properties...) { $transitions: (); @each $property in $properties { - $transitions: list.append( - $transitions, - $property ui-config.$control-transition-duration ui-config.$control-transition-easing, - comma - ); + $duration: ui-config.$control-transition-duration; + $easing: ui-config.$control-transition-easing; + @if list.index($_moving-properties, $property) { + $duration: ui-config.$control-motion-duration; + $easing: ui-config.$control-motion-easing; + } + $transitions: list.append($transitions, $property $duration $easing, comma); } transition: $transitions; } diff --git a/packages/ui-kit-react/src/styles/utils/_motion.scss b/packages/ui-kit-react/src/styles/utils/_motion.scss index a7bab9e..0cfb2a5 100644 --- a/packages/ui-kit-react/src/styles/utils/_motion.scss +++ b/packages/ui-kit-react/src/styles/utils/_motion.scss @@ -8,15 +8,15 @@ /// Transition recipe timed by the motion system. /// Duration/easing/delay resolve from `--ui-motion-*` (per-instance override) -/// falling back to the motion tokens. Easing defaults to the symmetric curve. +/// falling back to the `toggle` recipe: an element that switches between two states. /// e.g. @include utils.motion-transition(opacity, translate); @mixin motion-transition($properties...) { $transitions: (); @each $property in $properties { $transitions: list.append( $transitions, - $property var(--ui-motion-duration, #{ui-config.$motion-duration-base}) - var(--ui-motion-easing, #{ui-config.$motion-easing-standard}) + $property var(--ui-motion-duration, #{ui-config.$motion-toggle-duration}) + var(--ui-motion-easing, #{ui-config.$motion-toggle-easing}) var(--ui-motion-delay, 0ms), comma ); @@ -31,13 +31,20 @@ /// Le `@content` décrit l'état FERMÉ, qui sert des deux côtés : point de départ /// de l'entrée, point d'arrivée de la sortie. /// +/// L'entrée suit la recette `overlay-in`, la sortie `overlay-out`. +/// /// e.g. @include utils.overlay-motion { opacity: 0; scale: 0.96; } @mixin overlay-motion($properties: (opacity, translate, scale)) { + // Le sens passe par deux propriétés privées : l'état fermé ne redéclare pas + // `transition-*`, donc une garde `motion-reduce` posée par le composant garde + // la main dans les deux sens. + --_overlay-duration: #{ui-config.$motion-overlay-in-duration}; + --_overlay-easing: #{ui-config.$motion-overlay-in-easing}; transition-property: list.join((display, overlay), $properties, $separator: comma); - transition-duration: var(--ui-motion-duration, #{ui-config.$motion-duration-base}); - // Un seul crochet pour les deux sens, `--ui-motion-easing`, comme - // `motion-transition` : les courbes par sens restent des jetons. - transition-timing-function: var(--ui-motion-easing, #{ui-config.$motion-easing-enter}); + // Un seul crochet pour les deux sens, `--ui-motion-duration` / `--ui-motion-easing`, + // comme `motion-transition` : les valeurs par sens restent des recettes. + transition-duration: var(--ui-motion-duration, var(--_overlay-duration)); + transition-timing-function: var(--ui-motion-easing, var(--_overlay-easing)); transition-delay: var(--ui-motion-delay, 0ms); transition-behavior: allow-discrete; @@ -56,9 +63,10 @@ } // La sortie accélère là où l'entrée décélère. `[data-unpositioned]` n'est pas - // visé : c'est une attente d'entrée, qui garde la courbe d'entrée. + // visé : c'est une attente d'entrée, qui garde la recette d'entrée. &:not(:popover-open):not([open]) { - transition-timing-function: var(--ui-motion-easing, #{ui-config.$motion-easing-leave}); + --_overlay-duration: #{ui-config.$motion-overlay-out-duration}; + --_overlay-easing: #{ui-config.$motion-overlay-out-easing}; } @include motion-reduce { @@ -77,8 +85,8 @@ &:popover-open, &[open] { transition-property: $properties; - transition-duration: var(--ui-motion-duration, #{ui-config.$motion-duration-base}); - transition-timing-function: var(--ui-motion-easing, #{ui-config.$motion-easing-enter}); + transition-duration: var(--ui-motion-duration, #{ui-config.$motion-overlay-in-duration}); + transition-timing-function: var(--ui-motion-easing, #{ui-config.$motion-overlay-in-easing}); transition-delay: var(--ui-motion-delay, 0ms); } diff --git a/scripts/component-vars.build.mjs b/scripts/component-vars.build.mjs index 62932f4..c6bdc73 100644 --- a/scripts/component-vars.build.mjs +++ b/scripts/component-vars.build.mjs @@ -632,6 +632,21 @@ function figmaValue(rawInput, expectedType) { reason: 'hook sans défaut : point de surcharge pur, le composant ne pose rien.', }; + // A token read with a fallback (`var(--motion-slide-distance, 0.5rem)`) aliases the + // token: the fallback only serves a project whose tokens predate it. + const withFallback = raw.match(/^var\(\s*(--[\w-]+)\s*,/); + if (withFallback && closingParen(raw, 3) === raw.length - 1) { + const target = tokenIndex.get(withFallback[1]); + if (target) { + return { + type: 'ALIAS', + collection: target.collection, + name: target.name, + cssVar: withFallback[1], + }; + } + } + const single = raw.match(SINGLE_VAR); if (single) { const target = tokenIndex.get(single[1]); diff --git a/storybook/docs/specifications/theme.mdx b/storybook/docs/specifications/theme.mdx index b98260e..8ef3eb0 100644 --- a/storybook/docs/specifications/theme.mdx +++ b/storybook/docs/specifications/theme.mdx @@ -86,7 +86,8 @@ packages/ui-kit-react/src/styles/generated/_tokens-*.scss (variables CSS) - + + diff --git a/tokens.config.json b/tokens.config.json index cf3052a..155cfd9 100644 --- a/tokens.config.json +++ b/tokens.config.json @@ -79,6 +79,24 @@ "files": ["transitions.json"], "modeAxes": [] }, + { + "id": "motion", + "prefix": "motion", + "preserveCase": false, + "files": ["motion.json"], + "modeAxes": [ + { + "name": "motion", + "source": "nested", + "strategy": "media", + "default": "modeStandard", + "map": { + "modeStandard": ":root", + "modeReduced": null + } + } + ] + }, { "id": "typography", "prefix": "", @@ -121,6 +139,7 @@ "responsive", "semantics", "transitions", + "motion", "typography" ] }, From c751395ce5ed5f78f4818b55fdfb55e5480ef5c8 Mon Sep 17 00:00:00 2001 From: Robin Lopez Date: Wed, 21 Oct 2026 11:34:12 +0200 Subject: [PATCH 3/4] feat(motion): FSHSP-253 turn the presets into short fades under reduced motion --- AGENTS.md | 9 +++++++++ CHANGELOG.md | 13 +++++++++++++ packages/ui-kit-react/src/core/motion/motion.mdx | 4 ++-- .../layout/ui-bottom-sheet/ui-bottom-sheet.scss | 6 ++++++ .../src/layout/ui-drawer/ui-drawer.scss | 6 ++++++ .../src/layout/ui-modal/ui-modal.mdx | 3 ++- .../src/layout/ui-modal/ui-modal.stories.tsx | 2 +- .../src/navigation/ui-menu/ui-menu.mdx | 2 +- .../ui-kit-react/src/styles/base/_motion.scss | 16 ++++++++-------- .../ui-kit-react/src/styles/utils/_motion.scss | 11 ++++------- tokens.config.json | 2 +- 11 files changed, 53 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 920574b..ec3ddb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -366,6 +366,15 @@ interne, invisible dans la doc. son `///`. 3. Mettre à jour story, MDX et test si l'API bouge ; `pnpm docs:config` si le SCSS a bougé. 4. Vérifier clair + sombre + les 3 marques. +5. Si le composant s'anime, le revoir aussi en mouvement réduit : outils du navigateur, ou + Playwright `browser.newContext({ reducedMotion: 'reduce' })`. Les jetons `motion` y + passent dans leur mode `Reduced` (glissement à 0, zoom à 1, déplacements à 80ms) : un + glissement ou un zoom doit devenir un fondu court, un changement de couleur garder sa + durée, et rien ne doit rester bloqué à mi-chemin. Lire + `getComputedStyle(el).transitionDuration`, ou, pour un préréglage, mettre son + `Animation` en pause à `currentTime = 0` et lire le `transform` de départ. Un test de + bout en bout qui ne doit attendre aucune animation pose `data-motion="off"` sur + ``. ### Ajouter ou modifier un jeton diff --git a/CHANGELOG.md b/CHANGELOG.md index b9b0cdc..a873a9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,19 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et le p s'appliquer. Ce dernier hook ne figure plus dans `component-vars.scss` : il règle à la fois les couleurs et les déplacements, la recette est désormais le réglage global. Mêmes noms que le starter Angular. +- **Mouvement réduit : les préréglages et les panneaux deviennent des fondus courts au lieu de + disparaître** (FSHSP-253). Sous `prefers-reduced-motion: reduce`, la collection `motion` + passe dans son mode `Reduced` : glissement à 0, échelle du `zoom` à 1, recettes de + déplacement à `--transition-fast` (80ms), décalage du speed-dial à 0. Avant, les préréglages + `ui-motion-*` et `utils.overlay-motion` tombaient à 0,01ms, fondu compris : la modale, le + popover, les menus et les listes déroulantes apparaissent désormais en un fondu de 80ms. Les + déplacements qui passent par `utils.control-transition` ou `utils.motion-transition` (pouce + du toggle, rotation du speed-dial, chevrons, replis), jusqu'ici non couverts, raccourcissent + aussi. Les changements de couleur gardent leur durée. Un composant qui pose sa propre garde + `utils.motion-reduce` reste instantané : tiroir et bottom sheet, qui glissent depuis le bord + de l'écran, en reçoivent une. `data-motion="off"` coupe toujours tout : c'est le réglage à + garder dans les tests de bout en bout qui s'appuyaient sur le mouvement réduit pour + supprimer les animations. ### Changed diff --git a/packages/ui-kit-react/src/core/motion/motion.mdx b/packages/ui-kit-react/src/core/motion/motion.mdx index 878d4c3..35034f2 100644 --- a/packages/ui-kit-react/src/core/motion/motion.mdx +++ b/packages/ui-kit-react/src/core/motion/motion.mdx @@ -313,11 +313,11 @@ Trois niveaux, du plus étroit au plus large, et le plus large gagne toujours. - + - +
Custom propertyDéfautDéfaut (recette) Rôle
--ui-motion-duration--transition-defaultDurée.--motion-overlay-in-duration / -out- (180ms) ; --motion-toggle-duration pour utils.motion-transitionDurée, dans les deux sens.
--ui-motion-easing--transition-easeinout--motion-toggle-easing ; --motion-overlay-in-easing / -out- pour les panneaux Courbe des transitions symétriques et des panneaux.
--ui-motion-easing-enter--transition-easeout--motion-overlay-in-easing Courbe d'entrée des préréglages, décélérée.
--ui-motion-easing-leave--transition-easein--motion-overlay-out-easing Courbe de sortie des préréglages, accélérée.
--ui-motion-distance0.5remDécalage des préréglages slide-*.--motion-slide-distance (8px)Décalage des préréglages {'slide-*'}.
--ui-motion-scale0.96--motion-zoom-scale (0.96) Échelle de départ et d'arrivée de zoom.
semantics(aucun)--actions-high-surface-default, --global-text-default
typography(aucun)--fontfamily-base, --weight-bold
responsive(aucun)--size-typography-title-lg, --spacing-md
transitions(aucun)--transition-fast
transitions(aucun)--transition-fast (primitives du mouvement)
motion{'--motion-*'}--motion-hover-duration, --motion-overlay-in-easing (recettes, alias des primitives, voir Foundations → Motion)
styles(aucun)--shadow-default-md (composite, voir Foundations → Shadows)
breakpoint{'$breakpoint-*'} (SCSS)$breakpoint-desktop (voir Spécifications → Responsive Design)
Toute l'applicationdata-motion="off" sur <html> : neutralise les animations et les transitions du kit.data-motion="off" sur <html> : neutralise les animations et les transitions du kit. C'est le réglage à poser dans les tests de bout en bout.
Préférence systèmeprefers-reduced-motion: reduce réduit tous les préréglages à un éclair : l'état change, sans mouvement.prefers-reduced-motion: reduce bascule la collection motion dans son mode Reduced : glissement à 0, échelle du zoom à 1, recettes de déplacement à 80ms. Chaque préréglage et chaque panneau devient un fondu court : l'état change, sans mouvement. Les changements de couleur (hover) gardent leur durée. Un composant qui pose sa propre garde utils.motion-reduce (tiroir, bottom sheet, barre latérale, tooltip) reste instantané.
diff --git a/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.scss b/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.scss index 1f7a556..1321688 100644 --- a/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.scss +++ b/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.scss @@ -80,6 +80,12 @@ $focus-ring-width: var(--ui-bottom-sheet-focus-ring-width, #{utils.$focus-ring-w translate: 0 100%; } + // La feuille monte depuis le bas de l'écran, pas de la distance des recettes : le + // mode `Reduced` ne l'atteint pas, elle reste donc instantanée. + @include utils.motion-reduce { + transition-duration: 0.01ms; + } + &::backdrop { background-color: $mask-background; opacity: 1; diff --git a/packages/ui-kit-react/src/layout/ui-drawer/ui-drawer.scss b/packages/ui-kit-react/src/layout/ui-drawer/ui-drawer.scss index f683eac..60b96b1 100644 --- a/packages/ui-kit-react/src/layout/ui-drawer/ui-drawer.scss +++ b/packages/ui-kit-react/src/layout/ui-drawer/ui-drawer.scss @@ -47,6 +47,12 @@ $size-edge: var(--ui-drawer-size-edge, #{utils.rem-calc(160)}); translate: var(--_motion-slide); } + // Le tiroir glisse depuis le bord de l'écran, pas de la distance des recettes : + // le mode `Reduced` ne l'atteint pas, il reste donc instantané. + @include utils.motion-reduce { + transition-duration: 0.01ms; + } + &::backdrop { background-color: $mask-background; transition: diff --git a/packages/ui-kit-react/src/layout/ui-modal/ui-modal.mdx b/packages/ui-kit-react/src/layout/ui-modal/ui-modal.mdx index f9d7c46..1498891 100644 --- a/packages/ui-kit-react/src/layout/ui-modal/ui-modal.mdx +++ b/packages/ui-kit-react/src/layout/ui-modal/ui-modal.mdx @@ -99,7 +99,8 @@ supérieur. La durée, la courbe, la distance et l'échelle viennent du système de motion (`--ui-motion-duration`, `--ui-motion-easing`, `--ui-motion-distance`, `--ui-motion-scale`). `motionDisabled` coupe l'animation pour un dialogue donné, et l'interrupteur -`data-motion="off"` du kit comme la préférence système de mouvement réduit la coupent partout. +`data-motion="off"` du kit la coupe partout. Sous la préférence système de mouvement réduit, +le dialogue ne glisse ni ne zoome plus : il apparaît en un fondu court. diff --git a/packages/ui-kit-react/src/layout/ui-modal/ui-modal.stories.tsx b/packages/ui-kit-react/src/layout/ui-modal/ui-modal.stories.tsx index bf7dfa7..13baa30 100644 --- a/packages/ui-kit-react/src/layout/ui-modal/ui-modal.stories.tsx +++ b/packages/ui-kit-react/src/layout/ui-modal/ui-modal.stories.tsx @@ -213,7 +213,7 @@ export const Motions: Story = { /** * `motionDisabled` met la durée du mouvement à zéro pour ce dialogue : il * apparaît et disparaît d'un coup. Même effet, à l'échelle du kit, avec - * `data-motion="off"` ou la préférence système de mouvement réduit. + * `data-motion="off"`. La préférence système de mouvement réduit en laisse un fondu court. */ export const MotionDisabled: Story = { args: { motionDisabled: true, header: 'Sans animation' }, diff --git a/packages/ui-kit-react/src/navigation/ui-menu/ui-menu.mdx b/packages/ui-kit-react/src/navigation/ui-menu/ui-menu.mdx index 5ea5dcf..bf3d233 100644 --- a/packages/ui-kit-react/src/navigation/ui-menu/ui-menu.mdx +++ b/packages/ui-kit-react/src/navigation/ui-menu/ui-menu.mdx @@ -169,7 +169,7 @@ d'état de sélection, il en rend un. Un groupe **imbriqué** est repliable par défaut, un groupe de premier niveau non : `toggleable` force l'un ou l'autre. Le repli est animé par le système de motion, coupé par -`motionDisabled` et par `prefers-reduced-motion`. +`motionDisabled` et raccourci par `prefers-reduced-motion`. diff --git a/packages/ui-kit-react/src/styles/base/_motion.scss b/packages/ui-kit-react/src/styles/base/_motion.scss index 6c41c41..493ceef 100644 --- a/packages/ui-kit-react/src/styles/base/_motion.scss +++ b/packages/ui-kit-react/src/styles/base/_motion.scss @@ -1,7 +1,9 @@ // ===================================================================== // Motion presets: global `.ui-motion--enter` / `-leave` classes, set by // `useUiMotion` (which keeps the leaving node mounted) or by hand. Timing reads -// `--ui-motion-*`; `data-motion="off"` on opts out globally. +// `--ui-motion-*`; `data-motion="off"` on opts out globally, and the user's +// reduced-motion preference turns every preset into a short fade (see the end of +// this file). // ===================================================================== @use 'sass:list'; @@ -110,13 +112,11 @@ $_presets: fade, slide-up, slide-down, slide-left, slide-right, zoom, collapse; to { opacity: 0; grid-template-rows: 0fr; } } -// --- Global opt-out -------------------------------------------------- -// Reduced motion: every preset collapses to a flash, so state still changes. -@media (prefers-reduced-motion: reduce) { - [class*='ui-motion-'] { - animation-duration: 0.01ms !important; - } -} +// --- Reduced motion ---------------------------------------------------- +// No rule here: the OS reduced-motion setting switches the `motion` tokens to their +// `Reduced` mode (generated/_tokens-motion.scss). The slide distance drops to 0 and +// the zoom scale to 1, the recipes shorten: every preset becomes a short fade, so the +// change of state is still seen, without the movement. // Kit-wide kill-switch (``): neutralises animations AND transitions. :root[data-motion='off'] { diff --git a/packages/ui-kit-react/src/styles/utils/_motion.scss b/packages/ui-kit-react/src/styles/utils/_motion.scss index 0cfb2a5..20b4a21 100644 --- a/packages/ui-kit-react/src/styles/utils/_motion.scss +++ b/packages/ui-kit-react/src/styles/utils/_motion.scss @@ -69,9 +69,10 @@ --_overlay-easing: #{ui-config.$motion-overlay-out-easing}; } - @include motion-reduce { - transition-duration: 0.01ms; - } + // Pas de garde `motion-reduce` : en mouvement réduit, les recettes raccourcissent et + // la distance et l'échelle tombent à zéro, donc un état fermé écrit avec + // `--ui-motion-distance` / `--ui-motion-scale` devient un fondu court. Un panneau + // qui glisse depuis le bord de l'écran (`translate: 100%`) pose sa propre garde. } /// Entrée SEULE d'un panneau du calque supérieur : la sortie est instantanée. @@ -101,10 +102,6 @@ @content; } } - - @include motion-reduce { - transition-duration: 0.01ms; - } } /// Reduced-motion guard. Wrap the neutralising declarations you want applied diff --git a/tokens.config.json b/tokens.config.json index 155cfd9..7c54820 100644 --- a/tokens.config.json +++ b/tokens.config.json @@ -92,7 +92,7 @@ "default": "modeStandard", "map": { "modeStandard": ":root", - "modeReduced": null + "modeReduced": "(prefers-reduced-motion: reduce)" } } ] From 5bc52ee408c61d0619f6bcccadfb4704d8353bdd Mon Sep 17 00:00:00 2001 From: Robin Lopez Date: Wed, 21 Oct 2026 11:35:28 +0200 Subject: [PATCH 4/4] chore(tooling): FSHSP-253 reject hand-written timing in component styles --- AGENTS.md | 10 ++- docs/ROADMAP.md | 6 +- package.json | 3 +- scripts/motion.check.mjs | 129 ++++++++++++++++++++++++++++++++++ scripts/motion.check.spec.mjs | 53 ++++++++++++++ 5 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 scripts/motion.check.mjs create mode 100644 scripts/motion.check.spec.mjs diff --git a/AGENTS.md b/AGENTS.md index ec3ddb7..cfcfa27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -399,7 +399,7 @@ pnpm tokens:build # Régénère les variables CSS depuis les JSON pnpm test # Tests de composants (Chromium via Playwright) pnpm lint:check # ESLint sans --fix pnpm typecheck # tsc sur les trois projets -pnpm docs:config:check # Les sept garde-fous, en une commande, voir ci-dessous +pnpm docs:config:check # Les huit garde-fous, en une commande, voir ci-dessous pnpm deps:check # La règle d'import des librairies tierces (chaîné dans le précédent) ``` @@ -414,7 +414,13 @@ committer**. Il échoue sur : - un point d'entrée absent de la table `exports` du `package.json` ; - un composant qui importe une librairie tierce au lieu d'une brique de `core/` ; - un renvoi de doc vers un fichier qui n'existe pas ; -- un tiret cadratin dans une prose, ou un tableau markdown dans un `.mdx`. +- un tiret cadratin dans une prose, ou un tableau markdown dans un `.mdx` ; +- une durée (`180ms`), une courbe (`ease-out`, `cubic-bezier(…)`) ou une primitive + `var(--transition-*)` écrite dans le `.scss` d'un composant (`pnpm motion:check`, + `scripts/motion.check.mjs`). Un composant lit une recette `--motion-*` à travers + `ui-config` ou les mixins `utils`, pour qu'un recadençage du kit et le mouvement réduit + l'atteignent. Une exception va dans le `ALLOWED` du script, avec sa raison ; une exception + devenue inutile fait échouer le contrôle aussi. À côté de lui, `git diff --exit-code` sur les fichiers **générés mais committés** (`packages/ui-kit-react/package.json` pour la table `exports`, diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index eaac575..315381b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -23,8 +23,8 @@ Puis, avant de coder : ```bash pnpm install # génère les jetons et la table exports -pnpm docs:config:check # les sept garde-fous, en une commande -pnpm test # 3 projets : tests écrits, chaque story passée à axe, serveur MCP +pnpm docs:config:check # les huit garde-fous, en une commande +pnpm test # 4 projets : tests écrits, chaque story passée à axe, serveur MCP, scripts ``` Et quand quelque chose se comporte bizarrement : chercher d'abord dans **Pièges déjà payés**. @@ -44,7 +44,7 @@ La liste est longue parce que chaque entrée a coûté du temps une fois. | 5 | MCP, doc publique, publication | ✅ `0.1.0` publiée le 13 octobre | | 6 | Contrôle de parité entre stacks | ⬜ pas commencée | -**Chiffres du jour** : 62 composants, 73 points d'entrée publics, 7 garde-fous en CI, +**Chiffres du jour** : 62 composants, 73 points d'entrée publics, 8 garde-fous en CI, **deux** dépendances runtime (`@floating-ui/react-dom` et `@tanstack/react-virtual`, un seul fichier chacune). diff --git a/package.json b/package.json index 56f43b5..3c90829 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,9 @@ "build-info": "node scripts/build-info.mjs", "docs:config": "node scripts/docs.config.mjs && node scripts/component-vars.build.mjs", "docs:search": "node scripts/docs.search.mjs", - "docs:config:check": "node scripts/docs.config.mjs --check && node scripts/component-vars.build.mjs --check && pnpm components:check && pnpm exports:check && pnpm deps:check && pnpm links:check && pnpm prose:check", + "docs:config:check": "node scripts/docs.config.mjs --check && node scripts/component-vars.build.mjs --check && pnpm components:check && pnpm exports:check && pnpm deps:check && pnpm links:check && pnpm prose:check && pnpm motion:check", "components:check": "node scripts/components.check.mjs", + "motion:check": "node scripts/motion.check.mjs", "lint": "eslint . --fix", "lint:check": "eslint .", "typecheck": "tsc -p packages/ui-kit-react/tsconfig.json && tsc -p tsconfig.json && tsc -p packages/ui-kit-react-mcp/tsconfig.json", diff --git a/scripts/motion.check.mjs b/scripts/motion.check.mjs new file mode 100644 index 0000000..fa972fb --- /dev/null +++ b/scripts/motion.check.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +/** + * motion.check.mjs : vérifie qu'aucun composant ne décide de son timing à la main. + * + * Pourquoi : le mouvement passe par des jetons (FSHSP-253, décision D9). Les primitives + * `--transition-*` portent les valeurs, les recettes `--motion-*` les associent à un + * usage, et le kit les lit à travers `ui-config` et les mixins `utils`. Une durée ou + * une courbe écrite dans le SCSS d'un composant échappe à tout cela : elle ne suit ni + * un recadençage du kit, ni le mode `Reduced` du mouvement réduit. + * + * Règles vérifiées, dans le SCSS de `packages/ui-kit-react/src/` hors `styles/settings` + * (où les recettes choisissent leurs primitives) et `styles/generated` : + * A. pas de durée brute (`180ms`, `1.2s`) dans une déclaration `transition*` ou + * `animation*`, ni dans une variable `$…-duration` / `$…-delay` ; + * B. pas de courbe brute (`ease-out`, `linear`, `cubic-bezier(…)`) dans ces + * mêmes déclarations ; + * C. pas de lecture directe d'une primitive `var(--transition-*)` : un composant lit + * une recette. + * `0`, `0ms`, `0s` et `0.01ms` (les coupe-circuits) restent permis. + * + * Une exception se déclare dans `ALLOWED`, avec sa raison : elle se voit dans la + * sortie, et une exception devenue inutile fait échouer la vérification. + * + * Même script que le starter Angular, aux chemins près. + * + * Usage : node scripts/motion.check.mjs + */ + +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { KIT_SRC } from './lib/entries.mjs'; + +const SKIPPED_DIRS = ['styles/settings', 'styles/generated']; +// Liste générée de chaque hook à sa valeur livrée (component-vars.build.mjs). +const SKIPPED_FILES = ['styles/component-vars.scss']; + +/** Fichier (relatif à `packages/ui-kit-react/src`) → raison de l'exception. */ +export const ALLOWED = { + 'informative/ui-skeleton/ui-skeleton.scss': + 'boucle infinie : attend la recette `loop` (atelier FSHSP-252).', + 'informative/ui-spinner/ui-spinner.scss': + 'boucle infinie : attend la recette `loop` (atelier FSHSP-252).', + 'informative/ui-progress-bar/ui-progress-bar.scss': + 'boucle infinie : attend la recette `loop` (atelier FSHSP-252).', +}; + +/** Valeur de chaque déclaration `transition*` / `animation*` d'une ligne. */ +const TIMED_DECLARATION = /(?:^|[\s{;])(?:transition|animation)(?:-[a-z-]+)?\s*:([^;}]*)/g; +/** Valeur d'une variable `$…-duration` / `$…-delay`. */ +const TIMING_VARIABLE = /^\s*\$[\w-]*(?:duration|delay)[\w-]*\s*:([^;]*)/; +const RAW_DURATION = /(? + [...value.matchAll(RAW_DURATION)].some(([, n]) => !KILL_SWITCH.has(String(Number(n)))); + +/** Problèmes de mouvement d'un fichier SCSS : [{ line, rule, text }]. */ +export function findMotionProblems(source) { + const problems = []; + source.split('\n').forEach((raw, i) => { + // Les commentaires (`//`, doc `///`) ne portent jamais de timing ; les noms de + // keyframes et de classes, jamais une courbe. + const line = raw.replace(/\/\/.*$/, ''); + const report = (rule) => problems.push({ line: i + 1, rule, text: raw.trim() }); + const declarations = [...line.matchAll(TIMED_DECLARATION)].map(([, value]) => value); + const variable = TIMING_VARIABLE.exec(line)?.[1]; + if ([...declarations, variable].some((v) => v !== undefined && hasRawDuration(v))) report('A'); + if (declarations.some((v) => RAW_EASING.test(v.replace(/(? { + const full = join(dir, entry.name); + if (entry.isDirectory()) return entry.name === 'node_modules' ? [] : scssFiles(full); + return entry.name.endsWith('.scss') ? [full] : []; + }); +} + +const RULES = { + A: 'durée écrite à la main', + B: 'courbe écrite à la main', + C: 'primitive --transition-* lue directement', +}; + +function main() { + const failures = []; + const allowedHits = new Set(); + for (const file of scssFiles(KIT_SRC)) { + const rel = relative(KIT_SRC, file).split(sep).join('/'); + if (SKIPPED_DIRS.some((d) => rel.startsWith(`${d}/`)) || SKIPPED_FILES.includes(rel)) continue; + const problems = findMotionProblems(readFileSync(file, 'utf8')); + if (!problems.length) continue; + if (ALLOWED[rel]) { + allowedHits.add(rel); + continue; + } + for (const p of problems) + failures.push(` ${rel}:${p.line} (${RULES[p.rule]})\n ${p.text}`); + } + const unused = Object.keys(ALLOWED).filter((rel) => !allowedHits.has(rel)); + for (const rel of unused) { + failures.push(` ${rel} : exception devenue inutile, la retirer de ALLOWED (motion.check.mjs)`); + } + + if (failures.length) { + console.error( + `✗ Mouvement : ${failures.length} problème(s).\n${failures.join('\n')}\n\n` + + `Un composant lit une recette --motion-* (via ui-config ou les mixins utils) : ` + + `une valeur écrite à la main ne suit ni un recadençage du kit ni le mouvement réduit.`, + ); + process.exit(1); + } + const note = allowedHits.size + ? ` ${allowedHits.size} exception(s) : ${[...new Set(Object.values(ALLOWED))].join(' ')}` + : ''; + console.log(`✓ Mouvement : aucun timing écrit à la main dans les composants.${note}`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) main(); diff --git a/scripts/motion.check.spec.mjs b/scripts/motion.check.spec.mjs new file mode 100644 index 0000000..4220cc3 --- /dev/null +++ b/scripts/motion.check.spec.mjs @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { findMotionProblems } from './motion.check.mjs'; + +const rules = (scss) => findMotionProblems(scss).map((p) => p.rule); + +describe('motion.check : timing écrit à la main', () => { + it('accepte un composant qui passe par les recettes', () => { + expect( + rules(` +.x { + @include utils.control-transition(background-color, transform); + animation: ui-motion-fade-in #{utils.$motion-overlay-in-duration} #{utils.$motion-overlay-in-easing} both; + --ui-motion-easing: #{utils.$motion-easing-enter}; +} +$item-delay: var(--ui-speed-dial-item-delay, #{utils.$motion-stagger-delay}); /// Décalage de 80ms. +`), + ).toEqual([]); + }); + + it('laisse passer les coupe-circuits', () => { + expect( + rules(` +.x { transition: none; } +.y { transition-duration: 0.01ms; animation-delay: 0ms; } +`), + ).toEqual([]); + }); + + it('refuse une durée brute, dans une déclaration comme dans une variable', () => { + expect(rules('.x { transition: opacity 180ms; }')).toContain('A'); + expect(rules('.x { animation: spin var(--ui-spinner-duration, 1.2s) infinite; }')).toContain( + 'A', + ); + expect(rules('$indeterminate-duration: var(--ui-x-duration, 1.6s);')).toEqual(['A']); + }); + + it('refuse une courbe brute, mot-clé ou fonction', () => { + expect(rules('.x { animation: ui-skeleton-wave var(--y) ease-in-out infinite; }')).toEqual([ + 'B', + ]); + expect(rules('.x { transition-timing-function: cubic-bezier(0.2, 0, 0, 1); }')).toEqual(['B']); + expect(rules('.x { animation: ui-spinner-rotate var(--y) linear infinite; }')).toEqual(['B']); + }); + + it('refuse la lecture directe d’une primitive', () => { + expect(rules('.x { animation: fade var(--transition-default) both; }')).toContain('C'); + }); + + it('ignore les commentaires', () => { + expect(rules('.x { color: red; } // transition: opacity 180ms ease-in')).toEqual([]); + }); +});