Skip to content
Open
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
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Verify generated design tokens
run: pnpm tokens:check
- name: Typecheck
run: pnpm typecheck
- name: Test
Expand Down
165 changes: 165 additions & 0 deletions design-tokens.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
{
"$schema": "./docs/design-tokens.schema.json",
"meta": {
"name": "NextBlogApp Design Tokens",
"version": 1,
"sourceOfTruth": true
},
"primitive": {
"color": {
"white": "#ffffff",
"neutral-25": "#f9fafb",
"neutral-50": "#f3f3f3",
"neutral-100": "#eeeeee",
"neutral-200": "#dddddd",
"neutral-250": "#d1d5db",
"neutral-300": "#cccccc",
"neutral-400": "#9ca3af",
"neutral-450": "#999999",
"neutral-500": "#6b7280",
"neutral-600": "#4b5563",
"neutral-700": "#374151",
"neutral-750": "#333333",
"neutral-800": "#262626",
"neutral-900": "#111827",
"blue-200": "#bfdbfe",
"blue-50": "#eaf4fc",
"blue-300": "#93c5fd",
"blue-400": "#60a5fa",
"blue-500": "#3b82f6",
"blue-600": "#2563eb",
"blue-700": "#1d4ed8",
"blue-800": "#1e40af",
"green-600": "#16a34a",
"green-50": "#ecffe9",
"green-500": "#00d084",
"red-50": "#ffecec",
"red-400": "#ff7f7f",
"red-600": "#dc2626",
"yellow-50": "#fffacd",
"yellow-400": "#ffd700",
"steel-600": "#4682b4",
"code-surface": "#0d1117",
"code-text": "#c9d1d9"
},
"space": {
"0": 0,
"1": 4,
"2": 8,
"3": 12,
"4": 16,
"5": 20,
"6": 24,
"8": 32,
"12": 48
},
"radius": {
"none": 0,
"sm": 4,
"md": 6,
"lg": 8,
"full": 9999
}
},
"semantic": {
"color": {
"light": {
"text-primary": "{primitive.color.neutral-750}",
"text-secondary": "{primitive.color.neutral-600}",
"text-subtle": "{primitive.color.neutral-500}",
"text-inverse": "{primitive.color.white}",
"surface-page": "{primitive.color.white}",
"surface-subtle": "{primitive.color.neutral-50}",
"surface-selected": "{primitive.color.neutral-250}",
"border-default": "{primitive.color.neutral-200}",
"border-strong": "{primitive.color.neutral-250}",
"accent-default": "{primitive.color.blue-600}",
"accent-hover": "{primitive.color.blue-700}",
"link-default": "{primitive.color.blue-700}",
"link-hover": "{primitive.color.blue-800}",
"focus-ring": "{primitive.color.blue-600}",
"status-success": "{primitive.color.green-600}",
"status-danger": "{primitive.color.red-600}",
"code-surface": "{primitive.color.code-surface}",
"code-text": "{primitive.color.code-text}"
},
"dark": {
"text-primary": "{primitive.color.white}",
"text-secondary": "{primitive.color.neutral-250}",
"text-subtle": "{primitive.color.neutral-400}",
"text-inverse": "{primitive.color.neutral-900}",
"surface-page": "{primitive.color.neutral-800}",
"surface-subtle": "{primitive.color.neutral-700}",
"surface-selected": "{primitive.color.neutral-500}",
"border-default": "{primitive.color.neutral-500}",
"border-strong": "{primitive.color.neutral-400}",
"accent-default": "{primitive.color.blue-400}",
"accent-hover": "{primitive.color.blue-300}",
"link-default": "{primitive.color.blue-300}",
"link-hover": "{primitive.color.blue-200}",
"focus-ring": "{primitive.color.blue-400}",
"status-success": "{primitive.color.green-600}",
"status-danger": "{primitive.color.red-600}",
"code-surface": "{primitive.color.code-surface}",
"code-text": "{primitive.color.code-text}"
}
}
},
"typography": {
"fontFamily": {
"sans": "Geist",
"mono": "ui-monospace"
},
"style": {
"display": { "fontSize": 32, "lineHeight": 42, "fontWeight": 700 },
"heading": { "fontSize": 24, "lineHeight": 34, "fontWeight": 700 },
"subheading": { "fontSize": 20, "lineHeight": 30, "fontWeight": 600 },
"body-large": { "fontSize": 18, "lineHeight": 32, "fontWeight": 400 },
"body": { "fontSize": 16, "lineHeight": 28, "fontWeight": 400 },
"label": { "fontSize": 14, "lineHeight": 20, "fontWeight": 600 },
"caption": { "fontSize": 12, "lineHeight": 18, "fontWeight": 400 }
}
},
"effect": {
"shadow": {
"control": { "x": 0, "y": 1, "blur": 2, "spread": 0, "color": "#000000", "opacity": 0.08 },
"card": { "x": 0, "y": 10, "blur": 15, "spread": -3, "color": "#000000", "opacity": 0.12 },
"card-hover": {
"x": 0,
"y": 20,
"blur": 25,
"spread": -5,
"color": "#000000",
"opacity": 0.14
},
"dialog": { "x": 0, "y": 20, "blur": 25, "spread": -5, "color": "#000000", "opacity": 0.18 }
}
},
"motion": {
"duration": { "fast": 150, "normal": 200 },
"easing": { "standard": "ease" }
},
"component": {
"focusRing": { "width": 2, "offset": 2 },
"control": { "radius": "{primitive.radius.sm}" },
"dialog": { "radius": "{primitive.radius.lg}" },
"callout": {
"merit": {
"surface": "{primitive.color.green-50}",
"accent": "{primitive.color.green-500}"
},
"demerit": {
"surface": "{primitive.color.red-50}",
"accent": "{primitive.color.red-400}"
},
"point": {
"surface": "{primitive.color.yellow-50}",
"accent": "{primitive.color.yellow-400}"
},
"common": {
"surface": "{primitive.color.blue-50}",
"accent": "{primitive.color.steel-600}"
}
}
}
}
38 changes: 38 additions & 0 deletions docs/design-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Design tokens

`design-tokens.json` is the single source of truth for shared visual decisions in NextBlogApp.

## Architecture

- `primitive`: raw palette, spacing and radius scales. Application code should not normally use these directly.
- `semantic`: purpose-based colors with light and dark modes. New UI should prefer these names.
- `typography`: reusable text roles using the product font family.
- `effect` and `motion`: shared shadows and interaction timing.
- `component`: values that belong to a reusable UI pattern, not one isolated layout.

One-off layout dimensions, responsive breakpoints and content-specific illustration colors are deliberately not tokens. A value becomes a token when it represents a repeated design decision rather than merely occurring in CSS.

## Workflow

1. Edit `design-tokens.json` only.
2. Run `pnpm tokens:generate`.
3. Commit the source and generated files together.
4. Import `figma/manifest.json` as a development plugin and run it in Figma.

`pnpm dev` and `pnpm build` regenerate the Web CSS automatically. CI runs `pnpm tokens:check` and fails when generated files are stale.

Generated files must not be edited directly:

- `src/styles/design-tokens.generated.css`
- the leading `tokens` block in `figma/main.js`

The Figma plugin reconciles the local paint, text and effect styles it owns: current styles are updated, removed tokens are deleted, and the specimen frame is replaced instead of duplicated. Legacy `Color/*` styles created by the previous plugin format are removed during migration. Running the plugin remains a manual step because unattended Variables REST API synchronization requires an Enterprise plan.

Semantic text colors intended for normal-size copy must meet WCAG AA contrast against their paired surface. Decorative and disabled states should use separate component tokens rather than weakening shared text roles.

## Naming rules

- Use kebab-case names that describe intent, such as `text-primary` or `surface-selected`.
- Do not encode a color value in a semantic name.
- Add a component token only when a value is owned by a reusable component pattern.
- Light and dark semantic color modes must expose exactly the same token names.
171 changes: 171 additions & 0 deletions docs/design-tokens.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "NextBlogApp design tokens",
"type": "object",
"required": ["meta", "primitive", "semantic", "typography", "effect", "motion", "component"],
"additionalProperties": false,
"$defs": {
"hexColor": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
"reference": { "type": "string", "pattern": "^\\{[a-zA-Z0-9._-]+\\}$" },
"colorValue": { "oneOf": [{ "$ref": "#/$defs/hexColor" }, { "$ref": "#/$defs/reference" }] },
"nonNegativeNumber": { "type": "number", "minimum": 0 },
"tokenMap": {
"type": "object",
"minProperties": 1,
"propertyNames": { "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }
},
"semanticColors": {
"allOf": [
{ "$ref": "#/$defs/tokenMap" },
{ "type": "object", "additionalProperties": { "$ref": "#/$defs/colorValue" } }
]
},
"typographyStyle": {
"type": "object",
"required": ["fontSize", "lineHeight", "fontWeight"],
"additionalProperties": false,
"properties": {
"fontSize": { "$ref": "#/$defs/nonNegativeNumber" },
"lineHeight": { "$ref": "#/$defs/nonNegativeNumber" },
"fontWeight": { "type": "integer", "enum": [400, 500, 600, 700] }
}
},
"shadow": {
"type": "object",
"required": ["x", "y", "blur", "spread", "color", "opacity"],
"additionalProperties": false,
"properties": {
"x": { "type": "number" },
"y": { "type": "number" },
"blur": { "$ref": "#/$defs/nonNegativeNumber" },
"spread": { "type": "number" },
"color": { "$ref": "#/$defs/hexColor" },
"opacity": { "type": "number", "minimum": 0, "maximum": 1 }
}
}
},
"properties": {
"$schema": { "type": "string" },
"meta": {
"type": "object",
"required": ["name", "version", "sourceOfTruth"],
"additionalProperties": false,
"properties": {
"name": { "type": "string", "minLength": 1 },
"version": { "type": "integer", "minimum": 1 },
"sourceOfTruth": { "const": true }
}
},
"primitive": {
"type": "object",
"required": ["color", "space", "radius"],
"additionalProperties": false,
"properties": {
"color": {
"allOf": [
{ "$ref": "#/$defs/tokenMap" },
{ "type": "object", "additionalProperties": { "$ref": "#/$defs/hexColor" } }
]
},
"space": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/nonNegativeNumber" }
},
"radius": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/nonNegativeNumber" }
}
}
},
"semantic": {
"type": "object",
"required": ["color"],
"additionalProperties": false,
"properties": {
"color": {
"type": "object",
"required": ["light", "dark"],
"additionalProperties": false,
"properties": {
"light": { "$ref": "#/$defs/semanticColors" },
"dark": { "$ref": "#/$defs/semanticColors" }
}
}
}
},
"typography": {
"type": "object",
"required": ["fontFamily", "style"],
"additionalProperties": false,
"properties": {
"fontFamily": {
"type": "object",
"required": ["sans", "mono"],
"additionalProperties": false,
"properties": { "sans": { "type": "string" }, "mono": { "type": "string" } }
},
"style": { "type": "object", "additionalProperties": { "$ref": "#/$defs/typographyStyle" } }
}
},
"effect": {
"type": "object",
"required": ["shadow"],
"additionalProperties": false,
"properties": {
"shadow": { "type": "object", "additionalProperties": { "$ref": "#/$defs/shadow" } }
}
},
"motion": {
"type": "object",
"required": ["duration", "easing"],
"additionalProperties": false,
"properties": {
"duration": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/nonNegativeNumber" }
},
"easing": { "type": "object", "additionalProperties": { "type": "string", "minLength": 1 } }
}
},
"component": {
"type": "object",
"required": ["focusRing", "control", "dialog", "callout"],
"additionalProperties": false,
"properties": {
"focusRing": {
"type": "object",
"required": ["width", "offset"],
"additionalProperties": false,
"properties": {
"width": { "$ref": "#/$defs/nonNegativeNumber" },
"offset": { "$ref": "#/$defs/nonNegativeNumber" }
}
},
"control": {
"type": "object",
"required": ["radius"],
"additionalProperties": false,
"properties": { "radius": { "$ref": "#/$defs/reference" } }
},
"dialog": {
"type": "object",
"required": ["radius"],
"additionalProperties": false,
"properties": { "radius": { "$ref": "#/$defs/reference" } }
},
"callout": {
"type": "object",
"additionalProperties": {
"type": "object",
"required": ["surface", "accent"],
"additionalProperties": false,
"properties": {
"surface": { "$ref": "#/$defs/colorValue" },
"accent": { "$ref": "#/$defs/colorValue" }
}
}
}
}
}
}
}
Loading
Loading