diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e19c57..bd42b2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,5 +33,15 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Validate design tokens + run: pnpm tokens:validate + + - name: Check generated design tokens + run: | + test -f design-tokens/generated/astrea.tokens.css + test -f design-tokens/generated/astrea_tokens.dart + pnpm tokens:generate + git diff --exit-code -- design-tokens/generated + - name: Build handbook run: pnpm build diff --git a/design-tokens/README.md b/design-tokens/README.md new file mode 100644 index 0000000..e706396 --- /dev/null +++ b/design-tokens/README.md @@ -0,0 +1,61 @@ +# Astrea design tokens + +`tokens.json` is the platform-agnostic representation of the foundations from the `10 - Foundations` page in the Astrea Figma file. + +The `generated/astrea.tokens.css` and `generated/astrea_tokens.dart` files are produced by: + +```bash +node design-tokens/generate.mjs +``` + +Contrast-pair validation is run with: + +```bash +node design-tokens/validate.mjs +``` + +The applications consume these outputs in their own repositories: + +- `app-web/src/styles/tokens.css` +- `app-mobile/lib/core/design_tokens.dart` + +Synchronization must regenerate both files. Values must not be edited directly in generated outputs. + +## Update procedure + +When a foundation changes in Figma: + +1. Update `tokens.json` with the approved values from `10 - Foundations`. +2. Run `pnpm tokens:validate`. +3. Run `pnpm tokens:generate`. +4. Copy `generated/astrea.tokens.css` to `app-web/src/styles/tokens.css`. +5. Copy `generated/astrea_tokens.dart` to `app-mobile/lib/core/design_tokens.dart`. +6. Verify that the outputs match the synchronized files. +7. Run the local validations for all three repositories. +8. Open the pull requests linked to the same issue. + +The `app-web` and `app-mobile` pull requests must state the source used in their description: + +```text +Design tokens source: docs@ +``` + +The commit or tag identifies the version of `tokens.json` used to generate the outputs. + +## Usage rules + +- `color/border` is reserved for separators and decorative borders. +- `color/border-strong` is used for fields, focus states, and interactive components. +- `color/primary` and `color/accent` are used as backgrounds. +- `color/primary-strong` and `color/accent-strong` are used for text, icons, and markers. +- Text must meet a minimum contrast ratio of `4.5:1`. +- Borders and graphical components must meet a minimum contrast ratio of `3:1`. + +## Fonts + +The applications bundle the following weights locally: + +- Montserrat: `400`, `600`, `700` +- Inter: `400`, `600`, `700` + +The files are distributed under the SIL Open Font License. Each application repository keeps the `OFL.txt` notice with its font files. diff --git a/design-tokens/generate.mjs b/design-tokens/generate.mjs new file mode 100644 index 0000000..1250833 --- /dev/null +++ b/design-tokens/generate.mjs @@ -0,0 +1,71 @@ +import { readFile, mkdir, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = dirname(fileURLToPath(import.meta.url)); +const tokens = JSON.parse(await readFile(resolve(root, 'tokens.json'), 'utf8')); +const outputDirectory = resolve(root, 'generated'); + +const cssName = (name) => name.replace(/[^a-z0-9]+/gi, '-').toLowerCase(); +const dartName = (name, numericPrefix = 'value') => { + const identifier = name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); + return /^[0-9]/.test(identifier) ? `${numericPrefix}${identifier}` : identifier; +}; +const dartColor = (value) => `0xFF${value.slice(1)}`; + +const css = `:root { +${Object.entries(tokens.color).map(([name, value]) => ` --color-${cssName(name)}: ${value.toLowerCase()};`).join('\n')} + + --font-family-heading: '${tokens.font.heading}', sans-serif; + --font-family-body: '${tokens.font.body}', sans-serif; +${Object.entries(tokens.type).flatMap(([name, value]) => [ + ` --font-size-${cssName(name)}: ${value['font-size']}px;`, + ` --line-height-${cssName(name)}: ${value['line-height']}px;` +]).join('\n')} +${Object.entries(tokens.spacing).map(([name, value]) => ` --spacing-${cssName(name)}: ${value}px;`).join('\n')} +} + +.text-petit { + font-family: var(--font-family-body); + font-size: var(--font-size-petit); + line-height: var(--line-height-petit); +} + +.text-petit-gras { + font-family: var(--font-family-body); + font-size: var(--font-size-petit-gras); + font-weight: 700; + line-height: var(--line-height-petit-gras); +} + +.text-titre { + font-family: var(--font-family-heading); + font-weight: 600; +} + +.text-corps { + font-family: var(--font-family-body); +} +`; + +const dart = `import 'package:flutter/material.dart'; + +abstract final class AstreaColors { +${Object.entries(tokens.color).map(([name, value]) => ` static const ${dartName(name)} = Color(${dartColor(value)});`).join('\n')} +} + +abstract final class AstreaTypography { + static const heading = '${tokens.font.heading}'; + static const body = '${tokens.font.body}'; +${Object.entries(tokens.type).map(([name, value]) => ` static const ${dartName(name)}FontSize = ${value['font-size']}.0; + static const ${dartName(name)}LineHeight = ${value['line-height']}.0;`).join('\n')} +} + +abstract final class AstreaSpacing { +${Object.entries(tokens.spacing).map(([name, value]) => ` static const ${dartName(name, 'spacing')} = ${value}.0;`).join('\n')} +} +`; + +await mkdir(outputDirectory, { recursive: true }); +await writeFile(resolve(outputDirectory, 'astrea.tokens.css'), css); +await writeFile(resolve(outputDirectory, 'astrea_tokens.dart'), dart); diff --git a/design-tokens/generated/astrea.tokens.css b/design-tokens/generated/astrea.tokens.css new file mode 100644 index 0000000..857fc27 --- /dev/null +++ b/design-tokens/generated/astrea.tokens.css @@ -0,0 +1,62 @@ +:root { + --color-primary: #4caf50; + --color-primary-hover: #43a047; + --color-primary-strong: #2e7d32; + --color-on-primary: #212529; + --color-accent: #ff9800; + --color-accent-strong: #b34700; + --color-on-accent: #212529; + --color-tertiary: #2b6cb0; + --color-ink: #212529; + --color-muted: #5b6560; + --color-border: #ddd8ce; + --color-border-strong: #7c847f; + --color-error: #b3261e; + --color-error-border: #b3261e; + --color-error-bg: #fdedeb; + --color-surface: #ffffff; + --color-page: #faf7f2; + --color-badge: #e8f5e9; + --color-map: #efeae0; + --color-brand-gradient-start: #43a047; + --color-brand-gradient-end: #ffa726; + --color-on-error: #ffffff; + + --font-family-heading: 'Montserrat', sans-serif; + --font-family-body: 'Inter', sans-serif; + --font-size-petit: 12px; + --line-height-petit: 16px; + --font-size-petit-gras: 12px; + --line-height-petit-gras: 16px; + --spacing-12: 12px; + --spacing-20: 20px; + --spacing-32: 32px; + --spacing-40: 40px; + --spacing-56: 56px; + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; +} + +.text-petit { + font-family: var(--font-family-body); + font-size: var(--font-size-petit); + line-height: var(--line-height-petit); +} + +.text-petit-gras { + font-family: var(--font-family-body); + font-size: var(--font-size-petit-gras); + font-weight: 700; + line-height: var(--line-height-petit-gras); +} + +.text-titre { + font-family: var(--font-family-heading); + font-weight: 600; +} + +.text-corps { + font-family: var(--font-family-body); +} diff --git a/design-tokens/generated/astrea_tokens.dart b/design-tokens/generated/astrea_tokens.dart new file mode 100644 index 0000000..27bd237 --- /dev/null +++ b/design-tokens/generated/astrea_tokens.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +abstract final class AstreaColors { + static const primary = Color(0xFF4CAF50); + static const primaryHover = Color(0xFF43A047); + static const primaryStrong = Color(0xFF2E7D32); + static const onPrimary = Color(0xFF212529); + static const accent = Color(0xFFFF9800); + static const accentStrong = Color(0xFFB34700); + static const onAccent = Color(0xFF212529); + static const tertiary = Color(0xFF2B6CB0); + static const ink = Color(0xFF212529); + static const muted = Color(0xFF5B6560); + static const border = Color(0xFFDDD8CE); + static const borderStrong = Color(0xFF7C847F); + static const error = Color(0xFFB3261E); + static const errorBorder = Color(0xFFB3261E); + static const errorBg = Color(0xFFFDEDEB); + static const surface = Color(0xFFFFFFFF); + static const page = Color(0xFFFAF7F2); + static const badge = Color(0xFFE8F5E9); + static const map = Color(0xFFEFEAE0); + static const brandGradientStart = Color(0xFF43A047); + static const brandGradientEnd = Color(0xFFFFA726); + static const onError = Color(0xFFFFFFFF); +} + +abstract final class AstreaTypography { + static const heading = 'Montserrat'; + static const body = 'Inter'; + static const petitFontSize = 12.0; + static const petitLineHeight = 16.0; + static const petitGrasFontSize = 12.0; + static const petitGrasLineHeight = 16.0; +} + +abstract final class AstreaSpacing { + static const spacing12 = 12.0; + static const spacing20 = 20.0; + static const spacing32 = 32.0; + static const spacing40 = 40.0; + static const spacing56 = 56.0; + static const xs = 4.0; + static const sm = 8.0; + static const md = 16.0; + static const lg = 24.0; +} diff --git a/design-tokens/tokens.json b/design-tokens/tokens.json new file mode 100644 index 0000000..e63f757 --- /dev/null +++ b/design-tokens/tokens.json @@ -0,0 +1,53 @@ +{ + "color": { + "primary": "#4CAF50", + "primary-hover": "#43A047", + "primary-strong": "#2E7D32", + "on-primary": "#212529", + "accent": "#FF9800", + "accent-strong": "#B34700", + "on-accent": "#212529", + "tertiary": "#2B6CB0", + "ink": "#212529", + "muted": "#5B6560", + "border": "#DDD8CE", + "border-strong": "#7C847F", + "error": "#B3261E", + "error-border": "#B3261E", + "error-bg": "#FDEDEB", + "surface": "#FFFFFF", + "page": "#FAF7F2", + "badge": "#E8F5E9", + "map": "#EFEAE0", + "brand-gradient-start": "#43A047", + "brand-gradient-end": "#FFA726", + "on-error": "#FFFFFF" + }, + "font": { + "heading": "Montserrat", + "body": "Inter" + }, + "type": { + "petit": { + "font-family": "body", + "font-size": 12, + "line-height": 16 + }, + "petit-gras": { + "font-family": "body", + "font-size": 12, + "line-height": 16 + } + }, + "spacing": { + "xs": 4, + "sm": 8, + "md": 16, + "lg": 24, + "12": 12, + "20": 20, + "32": 32, + "40": 40, + "56": 56 + } +} diff --git a/design-tokens/validate.mjs b/design-tokens/validate.mjs new file mode 100644 index 0000000..156e2bf --- /dev/null +++ b/design-tokens/validate.mjs @@ -0,0 +1,45 @@ +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = dirname(fileURLToPath(import.meta.url)); +const tokens = JSON.parse(await readFile(resolve(root, 'tokens.json'), 'utf8')); + +const luminance = (hex) => { + const channels = [1, 3, 5].map((offset) => parseInt(hex.slice(offset, offset + 2), 16) / 255); + const [r, g, b] = channels.map((channel) => + channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4, + ); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; + +const contrast = (foreground, background) => { + const values = [luminance(foreground), luminance(background)].sort((a, b) => b - a); + return (values[0] + 0.05) / (values[1] + 0.05); +}; + +const color = (name) => tokens.color[name]; +const checks = [ + ['ink', 'page', 4.5], + ['ink', 'surface', 4.5], + ['muted', 'page', 4.5], + ['muted', 'surface', 4.5], + ['primary-strong', 'page', 4.5], + ['accent-strong', 'page', 4.5], + ['error', 'error-bg', 4.5], + ['on-error', 'error', 4.5], + ['border-strong', 'surface', 3], + ['border-strong', 'page', 3], + ['border-strong', 'error-bg', 3], +]; + +for (const [foreground, background, threshold] of checks) { + const ratio = contrast(color(foreground), color(background)); + if (ratio < threshold) { + throw new Error( + `${foreground}/${background}: ${ratio.toFixed(2)}:1 < ${threshold.toFixed(1)}:1`, + ); + } +} + +console.log(`Validated ${checks.length} WCAG token pairs.`); diff --git a/docs/standards/design-tokens.md b/docs/standards/design-tokens.md new file mode 100644 index 0000000..1c54c6f --- /dev/null +++ b/docs/standards/design-tokens.md @@ -0,0 +1,49 @@ +--- +title: Design tokens +sidebar_position: 1 +description: Source and synchronization of Astrea's shared visual foundations. +--- + +# Shared design tokens + +Astrea's visual foundations are defined in the `10 - Foundations` Figma page and represented in [`design-tokens/tokens.json`](https://github.com/Astrea-EIP/docs/blob/main/design-tokens/tokens.json). + +The JSON is the source consumed by the generated outputs: + +- CSS for `app-web` +- Dart for `app-mobile` + +Generated outputs must not be edited manually. An update starts in Figma, then updates `tokens.json`, regenerates the outputs, and synchronizes both application repositories. + +## Synchronization procedure + +After an approved change in Figma: + +1. Update `design-tokens/tokens.json`. +2. Run `pnpm tokens:validate`. +3. Run `pnpm tokens:generate`. +4. Synchronize `design-tokens/generated/astrea.tokens.css` with `app-web/src/styles/tokens.css`. +5. Synchronize `design-tokens/generated/astrea_tokens.dart` with `app-mobile/lib/core/design_tokens.dart`. +6. Validate all three repositories with their usual local commands. +7. Open one pull request per repository, all linked to the same issue. + +The Web and Mobile pull requests must state the commit or tag of the source used: + +```text +Design tokens source: docs@ +``` + +The `docs` repository CI validates contrast ratios and fails if generation modifies the versioned outputs. + +## Accessibility + +- Text must reach `4.5:1`. +- Components and borders must reach `3:1`. +- `color/border` is reserved for separators and decorative borders. +- `color/border-strong` is reserved for fields, focus states, and interactive components. + +Figma values that fail when used outside their intended context remain available, but their usage must follow these rules. + +## Fonts + +`app-web` and `app-mobile` bundle Montserrat and Inter locally, with weights `400`, `600`, and `700`. The files are distributed under the SIL Open Font License; `OFL.txt` notices are kept in each application repository. diff --git a/package.json b/package.json index ed56793..be1d209 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,8 @@ "version": "0.0.0", "private": true, "scripts": { + "tokens:generate": "node design-tokens/generate.mjs", + "tokens:validate": "node design-tokens/validate.mjs", "docusaurus": "docusaurus", "start": "docusaurus start", "build": "docusaurus build", diff --git a/sidebars.js b/sidebars.js index 51b1589..82a019c 100644 --- a/sidebars.js +++ b/sidebars.js @@ -52,6 +52,16 @@ const sidebars = { }, items: [{ type: 'autogenerated', dirName: 'operations' }], }, + { + type: 'category', + label: 'Standards', + link: { + type: 'generated-index', + title: 'Standards', + description: 'Shared engineering and design standards.', + }, + items: [{ type: 'autogenerated', dirName: 'standards' }], + }, ], };