Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
61 changes: 61 additions & 0 deletions design-tokens/README.md
Original file line number Diff line number Diff line change
@@ -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@<commit-or-tag>
```

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.
71 changes: 71 additions & 0 deletions design-tokens/generate.mjs
Original file line number Diff line number Diff line change
@@ -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);
62 changes: 62 additions & 0 deletions design-tokens/generated/astrea.tokens.css
Original file line number Diff line number Diff line change
@@ -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);
}
47 changes: 47 additions & 0 deletions design-tokens/generated/astrea_tokens.dart
Original file line number Diff line number Diff line change
@@ -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;
}
53 changes: 53 additions & 0 deletions design-tokens/tokens.json
Original file line number Diff line number Diff line change
@@ -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
}
}
45 changes: 45 additions & 0 deletions design-tokens/validate.mjs
Original file line number Diff line number Diff line change
@@ -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.`);
Loading
Loading