From eb34050d6e37be211905eded0f3837abfedbc5d4 Mon Sep 17 00:00:00 2001 From: Arata1202 Date: Mon, 3 Aug 2026 21:43:49 +0900 Subject: [PATCH 1/5] feat: establish design token source of truth --- .github/workflows/test.yml | 2 + design-tokens.json | 157 ++++++++ docs/design-tokens.md | 36 ++ docs/design-tokens.schema.json | 17 + figma/README.md | 14 +- figma/main.js | 344 +++++++++++++++--- package.json | 8 +- scripts/generate-design-tokens.mjs | 208 +++++++++++ .../Elements/Plugins/TabBox/index.module.css | 32 +- src/styles/design-tokens.generated.css | 129 +++++++ src/styles/designTokens.ts | 4 + src/styles/globals.css | 23 +- 12 files changed, 891 insertions(+), 83 deletions(-) create mode 100644 design-tokens.json create mode 100644 docs/design-tokens.md create mode 100644 docs/design-tokens.schema.json create mode 100644 scripts/generate-design-tokens.mjs create mode 100644 src/styles/design-tokens.generated.css diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ffa70318..0c7a3f10 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/design-tokens.json b/design-tokens.json new file mode 100644 index 00000000..7a82e8fb --- /dev/null +++ b/design-tokens.json @@ -0,0 +1,157 @@ +{ + "$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-450}", + "text-subtle": "{primitive.color.neutral-400}", + "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-500}", + "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 }, + "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}" + } + } + } +} diff --git a/docs/design-tokens.md b/docs/design-tokens.md new file mode 100644 index 00000000..22d6ec63 --- /dev/null +++ b/docs/design-tokens.md @@ -0,0 +1,36 @@ +# 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 updates local paint, text and effect styles with stable names. It replaces the existing token specimen frame instead of creating duplicates. Running the plugin remains a manual step because unattended Variables REST API synchronization requires an Enterprise plan. + +## 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. diff --git a/docs/design-tokens.schema.json b/docs/design-tokens.schema.json new file mode 100644 index 00000000..3b10027d --- /dev/null +++ b/docs/design-tokens.schema.json @@ -0,0 +1,17 @@ +{ + "$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, + "properties": { + "$schema": { "type": "string" }, + "meta": { "type": "object" }, + "primitive": { "type": "object" }, + "semantic": { "type": "object" }, + "typography": { "type": "object" }, + "effect": { "type": "object" }, + "motion": { "type": "object" }, + "component": { "type": "object" } + } +} diff --git a/figma/README.md b/figma/README.md index 9ea7fd4b..e50d09a6 100644 --- a/figma/README.md +++ b/figma/README.md @@ -1,6 +1,18 @@ -## 実行方法 +## NextBlogApp Design Tokens + +The source of truth is `../design-tokens.json`. Do not edit the generated `tokens` block at the beginning of `main.js` directly. + +## 更新方法 + +1. Update `design-tokens.json`. +2. Run `pnpm tokens:generate` at the repository root. +3. Run `pnpm tokens:check` before committing. + +## Figmaへの反映 1. Figmaを開きます。 2. `プラグイン` → `開発` → `マニフェストからプラグインをインポート...` を選択します。 3. `figma/manifest.json` を選択します。 4. `プラグイン` → `開発` → `NextBlogApp Design Tokens` を実行します。 + +同名のローカルスタイルは更新され、既存のトークン一覧フレームは置き換えられます。 diff --git a/figma/main.js b/figma/main.js index 9ff6a6ce..04a38ea2 100644 --- a/figma/main.js +++ b/figma/main.js @@ -1,48 +1,214 @@ const tokens = { - colors: { - textMain: '#333333', - textSub: '#999999', - surfaceLight: '#ffffff', - surfaceDark: '#262626', - surfaceSubtle: '#f3f3f3', - border: '#dddddd', - borderMuted: '#d1d5db', - borderDark: '#6b7280', - accent: '#2563eb', - accentHover: '#1e40af', - accentSoft: '#eaf4fc', - accentSoftText: '#111827', - codeBg: '#0d1117', - codeText: '#c9d1d9', - success: '#16a34a', - danger: '#dc2626', + "primitiveColors": { + "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" }, - typography: { - display: { fontSize: 32, lineHeight: 42, fontWeight: 700 }, - heading: { fontSize: 24, lineHeight: 34, fontWeight: 700 }, - subheading: { fontSize: 18, lineHeight: 28, fontWeight: 700 }, - body: { fontSize: 16, lineHeight: 28, fontWeight: 400 }, - small: { fontSize: 14, lineHeight: 22, fontWeight: 400 }, - caption: { fontSize: 12, lineHeight: 18, fontWeight: 400 }, + "semanticColors": { + "light": { + "text-primary": "#333333", + "text-secondary": "#999999", + "text-subtle": "#9ca3af", + "text-inverse": "#ffffff", + "surface-page": "#ffffff", + "surface-subtle": "#f3f3f3", + "surface-selected": "#d1d5db", + "border-default": "#dddddd", + "border-strong": "#d1d5db", + "accent-default": "#2563eb", + "accent-hover": "#1d4ed8", + "link-default": "#1d4ed8", + "link-hover": "#1e40af", + "focus-ring": "#2563eb", + "status-success": "#16a34a", + "status-danger": "#dc2626", + "code-surface": "#0d1117", + "code-text": "#c9d1d9" + }, + "dark": { + "text-primary": "#ffffff", + "text-secondary": "#d1d5db", + "text-subtle": "#6b7280", + "text-inverse": "#111827", + "surface-page": "#262626", + "surface-subtle": "#374151", + "surface-selected": "#6b7280", + "border-default": "#6b7280", + "border-strong": "#9ca3af", + "accent-default": "#60a5fa", + "accent-hover": "#93c5fd", + "link-default": "#93c5fd", + "link-hover": "#bfdbfe", + "focus-ring": "#60a5fa", + "status-success": "#16a34a", + "status-danger": "#dc2626", + "code-surface": "#0d1117", + "code-text": "#c9d1d9" + } + }, + "componentColors": { + "callout/merit/surface": "#ecffe9", + "callout/merit/accent": "#00d084", + "callout/demerit/surface": "#ffecec", + "callout/demerit/accent": "#ff7f7f", + "callout/point/surface": "#fffacd", + "callout/point/accent": "#ffd700", + "callout/common/surface": "#eaf4fc", + "callout/common/accent": "#4682b4" + }, + "primitiveSpacing": { + "0": 0, + "1": 4, + "2": 8, + "3": 12, + "4": 16, + "5": 20, + "6": 24, + "8": 32, + "12": 48 }, - spacing: { - xs: 4, - sm: 8, - md: 16, - lg: 24, - xl: 32, - '2xl': 48, + "primitiveRadius": { + "none": 0, + "sm": 4, + "md": 6, + "lg": 8, + "full": 9999 + }, + "colors": { + "textMain": "#333333", + "textSub": "#999999", + "surfaceLight": "#ffffff", + "surfaceDark": "#262626", + "surfaceSubtle": "#f3f3f3", + "border": "#dddddd", + "borderMuted": "#d1d5db", + "borderDark": "#6b7280", + "accent": "#2563eb", + "accentHover": "#1e40af", + "accentSoft": "#eaf4fc", + "accentSoftText": "#111827", + "codeBg": "#0d1117", + "codeText": "#c9d1d9", + "success": "#16a34a", + "danger": "#dc2626" + }, + "typography": { + "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 + } }, - radius: { - control: 4, - dialog: 8, - round: 999, + "spacing": { + "xs": 4, + "sm": 8, + "md": 16, + "lg": 24, + "xl": 32, + "2xl": 48 }, - 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 }, - dialog: { x: 0, y: 20, blur: 25, spread: -5, color: '#000000', opacity: 0.18 }, + "radius": { + "control": 4, + "dialog": 8, + "round": 9999 }, + "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 + }, + "dialog": { + "x": 0, + "y": 20, + "blur": 25, + "spread": -5, + "color": "#000000", + "opacity": 0.18 + } + }, + "motion": { + "duration": { + "fast": 150, + "normal": 200 + }, + "easing": { + "standard": "ease" + } + }, + "fontFamily": "Geist" }; figma.skipInvisibleInstanceChildren = true; @@ -126,7 +292,7 @@ function textNode(text, style, color) { const node = figma.createText(); node.characters = text; - node.fontName = { family: 'Inter', style: style.fontWeight >= 700 ? 'Bold' : 'Regular' }; + node.fontName = { family: tokens.fontFamily, style: fontStyle(style.fontWeight) }; node.fontSize = style.fontSize; node.lineHeight = { unit: 'PIXELS', value: style.lineHeight }; node.fills = [paint(color)]; @@ -134,6 +300,13 @@ function textNode(text, style, color) { return node; } +function fontStyle(weight) { + if (weight >= 700) return 'Bold'; + if (weight >= 600) return 'SemiBold'; + if (weight >= 500) return 'Medium'; + return 'Regular'; +} + function tokenLabel(name, value) { const label = textNode(name + '\n' + value, tokens.typography.small); label.resize(260, 48); @@ -255,8 +428,10 @@ function shadowToken(name, shadow) { } async function createTextStyles() { - await figma.loadFontAsync({ family: 'Inter', style: 'Regular' }); - await figma.loadFontAsync({ family: 'Inter', style: 'Bold' }); + const fontStyles = ['Regular', 'Medium', 'SemiBold', 'Bold']; + for (const fontStyleName of fontStyles) { + await figma.loadFontAsync({ family: tokens.fontFamily, style: fontStyleName }); + } const styles = typeof figma.getLocalTextStylesAsync === 'function' @@ -273,7 +448,7 @@ async function createTextStyles() { const existing = localStyle(styles, styleName); const style = existing || figma.createTextStyle(); style.name = styleName; - style.fontName = { family: 'Inter', style: typography.fontWeight >= 700 ? 'Bold' : 'Regular' }; + style.fontName = { family: tokens.fontFamily, style: fontStyle(typography.fontWeight) }; style.fontSize = typography.fontSize; style.lineHeight = { unit: 'PIXELS', value: typography.lineHeight }; } @@ -296,6 +471,36 @@ async function createPaintStyles() { style.name = styleName; style.paints = [paint(tokens.colors[name])]; } + + for (const name in tokens.primitiveColors) { + if (!hasOwn(tokens.primitiveColors, name)) continue; + const styleName = 'Color/Primitive/' + name; + const existing = localStyle(styles, styleName); + const style = existing || figma.createPaintStyle(); + style.name = styleName; + style.paints = [paint(tokens.primitiveColors[name])]; + } + + for (const mode in tokens.semanticColors) { + if (!hasOwn(tokens.semanticColors, mode)) continue; + for (const name in tokens.semanticColors[mode]) { + if (!hasOwn(tokens.semanticColors[mode], name)) continue; + const styleName = 'Color/Semantic/' + mode + '/' + name; + const existing = localStyle(styles, styleName); + const style = existing || figma.createPaintStyle(); + style.name = styleName; + style.paints = [paint(tokens.semanticColors[mode][name])]; + } + } + + for (const name in tokens.componentColors) { + if (!hasOwn(tokens.componentColors, name)) continue; + const styleName = 'Color/Component/' + name; + const existing = localStyle(styles, styleName); + const style = existing || figma.createPaintStyle(); + style.name = styleName; + style.paints = [paint(tokens.componentColors[name])]; + } } async function createEffectStyles() { @@ -330,6 +535,36 @@ function appendColorTokens(root) { } root.appendChild(colorSection); + + const primitiveSection = section('Tokens / Colors / Primitive'); + primitiveSection.appendChild(textNode('Primitive colors', tokens.typography.heading)); + for (const name in tokens.primitiveColors) { + if (hasOwn(tokens.primitiveColors, name)) { + primitiveSection.appendChild(colorToken(name, tokens.primitiveColors[name])); + } + } + root.appendChild(primitiveSection); + + for (const mode in tokens.semanticColors) { + if (!hasOwn(tokens.semanticColors, mode)) continue; + const semanticSection = section('Tokens / Colors / Semantic / ' + mode); + semanticSection.appendChild(textNode('Semantic colors / ' + mode, tokens.typography.heading)); + for (const name in tokens.semanticColors[mode]) { + if (hasOwn(tokens.semanticColors[mode], name)) { + semanticSection.appendChild(colorToken(name, tokens.semanticColors[mode][name])); + } + } + root.appendChild(semanticSection); + } + + const componentSection = section('Tokens / Colors / Component'); + componentSection.appendChild(textNode('Component colors', tokens.typography.heading)); + for (const name in tokens.componentColors) { + if (hasOwn(tokens.componentColors, name)) { + componentSection.appendChild(colorToken(name, tokens.componentColors[name])); + } + } + root.appendChild(componentSection); } function appendTypographyTokens(root) { @@ -360,6 +595,15 @@ function appendSpacingTokens(root) { } root.appendChild(spacingSection); + + const primitiveSection = section('Tokens / Spacing / Primitive'); + primitiveSection.appendChild(textNode('Primitive spacing', tokens.typography.heading)); + for (const name in tokens.primitiveSpacing) { + if (hasOwn(tokens.primitiveSpacing, name)) { + primitiveSection.appendChild(spacingToken(name, tokens.primitiveSpacing[name])); + } + } + root.appendChild(primitiveSection); } function appendRadiusTokens(root) { @@ -375,6 +619,15 @@ function appendRadiusTokens(root) { } root.appendChild(radiusSection); + + const primitiveSection = section('Tokens / Radius / Primitive'); + primitiveSection.appendChild(textNode('Primitive radius', tokens.typography.heading)); + for (const name in tokens.primitiveRadius) { + if (hasOwn(tokens.primitiveRadius, name)) { + primitiveSection.appendChild(radiusToken(name, tokens.primitiveRadius[name])); + } + } + root.appendChild(primitiveSection); } function appendShadowTokens(root) { @@ -397,6 +650,11 @@ async function main() { await createPaintStyles(); await createEffectStyles(); + const previousRoot = figma.currentPage.findOne( + (node) => node.type === 'FRAME' && node.name === 'NextBlogApp Design Tokens', + ); + if (previousRoot) previousRoot.remove(); + const root = figma.createFrame(); root.name = 'NextBlogApp Design Tokens'; root.x = figma.viewport.center.x - 420; diff --git a/package.json b/package.json index 33e44740..27913410 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,13 @@ }, "scripts": { "dev": "next dev --turbopack", + "predev": "pnpm tokens:generate", "build": "next build", + "prebuild": "pnpm tokens:generate", "build:e2e": "node scripts/e2e/build.mjs", "start": "pnpm dlx serve@latest out", - "lint": "eslint src e2e scripts playwright.config.ts vitest.config.mts", - "lint:fix": "eslint src e2e scripts playwright.config.ts vitest.config.mts --fix", + "lint": "eslint src e2e scripts figma playwright.config.ts vitest.config.mts", + "lint:fix": "eslint src e2e scripts figma playwright.config.ts vitest.config.mts --fix", "typecheck": "tsc --noEmit", "test": "vitest", "test:run": "vitest run", @@ -25,6 +27,8 @@ "test:e2e:headed": "playwright test --headed", "test:e2e:ui": "playwright test --ui", "test:e2e:report": "playwright show-report", + "tokens:generate": "node scripts/generate-design-tokens.mjs", + "tokens:check": "node scripts/generate-design-tokens.mjs --check", "prepare": "husky install", "format": "prettier --write --ignore-path .gitignore './**/*.{js,jsx,ts,tsx,mts,json,css,mjs}'" }, diff --git a/scripts/generate-design-tokens.mjs b/scripts/generate-design-tokens.mjs new file mode 100644 index 00000000..90d92a1c --- /dev/null +++ b/scripts/generate-design-tokens.mjs @@ -0,0 +1,208 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const sourcePath = path.join(root, 'design-tokens.json'); +const cssPath = path.join(root, 'src/styles/design-tokens.generated.css'); +const figmaPath = path.join(root, 'figma/main.js'); +const checkOnly = process.argv.includes('--check'); + +const source = JSON.parse(await readFile(sourcePath, 'utf8')); + +function getByPath(object, tokenPath) { + return tokenPath.split('.').reduce((value, key) => value?.[key], object); +} + +function resolve(value, stack = []) { + if (typeof value !== 'string') return value; + const match = value.match(/^\{(.+)}$/); + if (!match) return value; + if (stack.includes(match[1])) + throw new Error(`Circular token reference: ${[...stack, match[1]].join(' -> ')}`); + const referenced = getByPath(source, match[1]); + if (referenced === undefined) throw new Error(`Unknown token reference: ${value}`); + return resolve(referenced, [...stack, match[1]]); +} + +function assertTokenShape() { + for (const mode of ['light', 'dark']) { + const colors = source.semantic.color[mode]; + if (!colors || typeof colors !== 'object') + throw new Error(`Missing semantic color mode: ${mode}`); + for (const [name, value] of Object.entries(colors)) { + const resolved = resolve(value); + if (!/^#[0-9a-f]{6}$/i.test(resolved)) + throw new Error(`Invalid ${mode} color ${name}: ${resolved}`); + } + } + + const lightNames = Object.keys(source.semantic.color.light).sort().join('\n'); + const darkNames = Object.keys(source.semantic.color.dark).sort().join('\n'); + if (lightNames !== darkNames) + throw new Error('Light and dark semantic color tokens must have identical names'); +} + +function cssVariables(entries, mapper = (value) => value) { + return Object.entries(entries) + .map(([name, value]) => ` --${name}: ${mapper(resolve(value), name)};`) + .join('\n'); +} + +function generateCss() { + const primitiveColors = Object.fromEntries( + Object.entries(source.primitive.color).map(([name, value]) => [`ds-color-${name}`, value]), + ); + const primitiveSpace = Object.fromEntries( + Object.entries(source.primitive.space).map(([name, value]) => [`ds-space-${name}`, value]), + ); + const primitiveRadius = Object.fromEntries( + Object.entries(source.primitive.radius).map(([name, value]) => [`ds-radius-${name}`, value]), + ); + const semanticLight = Object.fromEntries( + Object.entries(source.semantic.color.light).map(([name, value]) => [`ds-color-${name}`, value]), + ); + const semanticDark = Object.fromEntries( + Object.entries(source.semantic.color.dark).map(([name, value]) => [`ds-color-${name}`, value]), + ); + const shadows = Object.fromEntries( + Object.entries(source.effect.shadow).map(([name, shadow]) => [ + `ds-shadow-${name}`, + `${shadow.x}px ${shadow.y}px ${shadow.blur}px ${shadow.spread}px rgb(0 0 0 / ${shadow.opacity})`, + ]), + ); + const componentColors = Object.fromEntries( + Object.entries(source.component.callout).flatMap(([variant, colors]) => + Object.entries(colors).map(([role, value]) => [`ds-callout-${variant}-${role}`, value]), + ), + ); + + return `/* Generated by scripts/generate-design-tokens.mjs. Do not edit. */ +:root { +${cssVariables(primitiveColors)} +${cssVariables(primitiveSpace, (value) => `${value}px`)} +${cssVariables(primitiveRadius, (value) => `${value}px`)} +${cssVariables(semanticLight)} +${cssVariables(shadows)} +${cssVariables(componentColors)} + --ds-font-family-sans: '${source.typography.fontFamily.sans}', sans-serif; + --ds-focus-ring-width: ${source.component.focusRing.width}px; + --ds-focus-ring-offset: ${source.component.focusRing.offset}px; + --ds-motion-duration-fast: ${source.motion.duration.fast}ms; + --ds-motion-duration-normal: ${source.motion.duration.normal}ms; + --ds-motion-easing-standard: ${source.motion.easing.standard}; + + /* Compatibility aliases. Prefer the ds-* semantic names in new code. */ + --color-text-main: var(--ds-color-text-primary); + --color-text-sub: var(--ds-color-text-secondary); + --color-bg-main: var(--ds-color-surface-page); + --color-bg-sub: var(--ds-color-surface-subtle); + --color-bg-code: var(--ds-color-code-surface); + --color-text-code: var(--ds-color-code-text); + --color-border-dark: var(--ds-color-border-strong); + --color-border: var(--ds-color-border-default); + --color-border-light: var(--ds-color-neutral-50); + --color-current: var(--ds-color-neutral-100); + --color-focus-ring: var(--ds-color-focus-ring); + --color-accent-soft-bg: var(--ds-color-blue-50); + --color-accent-soft-text: var(--ds-color-neutral-900); + --color-link: var(--ds-color-link-default); + --color-link-hover: var(--ds-color-link-hover); + --color-link-dark: var(--ds-color-blue-300); + --color-link-dark-hover: var(--ds-color-blue-200); + --border-radius: var(--ds-radius-sm); + --focus-ring-width: var(--ds-focus-ring-width); + --focus-ring-offset: var(--ds-focus-ring-offset); +} + +.DarkTheme { +${cssVariables(semanticDark)} +} +`; +} + +function generateFigmaTokens() { + const light = source.semantic.color.light; + return { + primitiveColors: source.primitive.color, + semanticColors: Object.fromEntries( + Object.entries(source.semantic.color).map(([mode, colors]) => [ + mode, + Object.fromEntries(Object.entries(colors).map(([name, value]) => [name, resolve(value)])), + ]), + ), + componentColors: Object.fromEntries( + Object.entries(source.component.callout).flatMap(([variant, colors]) => + Object.entries(colors).map(([role, value]) => [ + `callout/${variant}/${role}`, + resolve(value), + ]), + ), + ), + primitiveSpacing: source.primitive.space, + primitiveRadius: source.primitive.radius, + colors: { + textMain: resolve(light['text-primary']), + textSub: resolve(light['text-secondary']), + surfaceLight: resolve(light['surface-page']), + surfaceDark: resolve(source.semantic.color.dark['surface-page']), + surfaceSubtle: resolve(light['surface-subtle']), + border: resolve(light['border-default']), + borderMuted: resolve(source.primitive.color['neutral-250']), + borderDark: resolve(source.semantic.color.dark['border-default']), + accent: resolve(light['accent-default']), + accentHover: resolve(light['link-hover']), + accentSoft: resolve(source.primitive.color['blue-50']), + accentSoftText: resolve(source.primitive.color['neutral-900']), + codeBg: resolve(light['code-surface']), + codeText: resolve(light['code-text']), + success: resolve(light['status-success']), + danger: resolve(light['status-danger']), + }, + typography: source.typography.style, + spacing: { + xs: source.primitive.space['1'], + sm: source.primitive.space['2'], + md: source.primitive.space['4'], + lg: source.primitive.space['6'], + xl: source.primitive.space['8'], + '2xl': source.primitive.space['12'], + }, + radius: { + control: resolve(source.component.control.radius), + dialog: resolve(source.component.dialog.radius), + round: source.primitive.radius.full, + }, + shadow: source.effect.shadow, + motion: source.motion, + fontFamily: source.typography.fontFamily.sans, + }; +} + +async function updateFile(filePath, expected) { + let current = ''; + try { + current = await readFile(filePath, 'utf8'); + } catch { + // A generated file may not exist on the first run. + } + if (current === expected) return false; + if (checkOnly) + throw new Error(`${path.relative(root, filePath)} is out of date; run pnpm tokens:generate`); + await writeFile(filePath, expected); + return true; +} + +assertTokenShape(); +const cssChanged = await updateFile(cssPath, generateCss()); + +const figmaCurrent = await readFile(figmaPath, 'utf8'); +const figmaTokens = `const tokens = ${JSON.stringify(generateFigmaTokens(), null, 2)};`; +const figmaExpected = figmaCurrent.replace(/^const tokens = \{[\s\S]*?^\};/m, figmaTokens); +if (figmaExpected === figmaCurrent && !figmaCurrent.startsWith(figmaTokens)) { + throw new Error('Could not find the generated token block in figma/main.js'); +} +const figmaChanged = await updateFile(figmaPath, figmaExpected); + +if (!checkOnly) + console.log(`Design tokens generated${cssChanged || figmaChanged ? '' : ' (already current)'}.`); diff --git a/src/components/Features/Article/Elements/Plugins/TabBox/index.module.css b/src/components/Features/Article/Elements/Plugins/TabBox/index.module.css index 0dc8ee8b..796ec210 100644 --- a/src/components/Features/Article/Elements/Plugins/TabBox/index.module.css +++ b/src/components/Features/Article/Elements/Plugins/TabBox/index.module.css @@ -1,7 +1,7 @@ /* タブボックス (メリット) */ .tab_merit_box { padding: 10px; - background-color: #ecffe9; + background-color: var(--ds-callout-merit-surface); display: flex; margin-bottom: 2.5rem; margin-top: 2.5rem; @@ -13,14 +13,14 @@ } .tab_merit_box_icon { - color: #00d084; + color: var(--ds-callout-merit-accent); margin-left: 10px; } /* タブボックス (デメリット) */ .tab_demerit_box { padding: 10px; - background-color: #ffecec; + background-color: var(--ds-callout-demerit-surface); display: flex; margin-bottom: 2.5rem; margin-top: 2.5rem; @@ -32,14 +32,14 @@ } .tab_demerit_box_icon { - color: #ff7f7f; + color: var(--ds-callout-demerit-accent); margin-left: 10px; } /* タブボックス (ポイント) */ .tab_point_box { padding: 10px; - background-color: #fffacd; + background-color: var(--ds-callout-point-surface); display: flex; margin-bottom: 2.5rem; margin-top: 2.5rem; @@ -51,14 +51,14 @@ } .tab_point_box_icon { - color: #ffd700; + color: var(--ds-callout-point-accent); margin-left: 10px; } /* タブボックス (汎用) */ .tab_common_box { padding: 10px; - background-color: #eaf4fc; + background-color: var(--ds-callout-common-surface); display: flex; margin-bottom: 2.5rem; margin-top: 2.5rem; @@ -70,7 +70,7 @@ } .tab_common_box_icon { - color: #4682b4; + color: var(--ds-callout-common-accent); margin-left: 10px; } @@ -78,7 +78,7 @@ /* タブボックス (メリット) */ .tab_merit_box { padding: 10px; - background-color: #ecffe9; + background-color: var(--ds-callout-merit-surface); display: flex; margin-bottom: 2.5rem; margin-top: 2.5rem; @@ -90,14 +90,14 @@ } .tab_merit_box_icon { - color: #00d084; + color: var(--ds-callout-merit-accent); margin-left: 5px; } /* タブボックス (デメリット) */ .tab_demerit_box { padding: 10px; - background-color: #ffecec; + background-color: var(--ds-callout-demerit-surface); display: flex; margin-bottom: 2.5rem; margin-top: 2.5rem; @@ -109,14 +109,14 @@ } .tab_demerit_box_icon { - color: #ff7f7f; + color: var(--ds-callout-demerit-accent); margin-left: 5px; } /* タブボックス (ポイント) */ .tab_point_box { padding: 10px; - background-color: #fffacd; + background-color: var(--ds-callout-point-surface); display: flex; margin-bottom: 2.5rem; margin-top: 2.5rem; @@ -128,14 +128,14 @@ } .tab_point_box_icon { - color: #ffd700; + color: var(--ds-callout-point-accent); margin-left: 5px; } /* タブボックス (汎用) */ .tab_common_box { padding: 10px; - background-color: #eaf4fc; + background-color: var(--ds-callout-common-surface); display: flex; margin-bottom: 2.5rem; margin-top: 2.5rem; @@ -147,7 +147,7 @@ } .tab_common_box_icon { - color: #4682b4; + color: var(--ds-callout-common-accent); margin-left: 5px; } } diff --git a/src/styles/design-tokens.generated.css b/src/styles/design-tokens.generated.css new file mode 100644 index 00000000..8e9c7659 --- /dev/null +++ b/src/styles/design-tokens.generated.css @@ -0,0 +1,129 @@ +/* Generated by scripts/generate-design-tokens.mjs. Do not edit. */ +:root { + --ds-color-white: #ffffff; + --ds-color-neutral-25: #f9fafb; + --ds-color-neutral-50: #f3f3f3; + --ds-color-neutral-100: #eeeeee; + --ds-color-neutral-200: #dddddd; + --ds-color-neutral-250: #d1d5db; + --ds-color-neutral-300: #cccccc; + --ds-color-neutral-400: #9ca3af; + --ds-color-neutral-450: #999999; + --ds-color-neutral-500: #6b7280; + --ds-color-neutral-600: #4b5563; + --ds-color-neutral-700: #374151; + --ds-color-neutral-750: #333333; + --ds-color-neutral-800: #262626; + --ds-color-neutral-900: #111827; + --ds-color-blue-200: #bfdbfe; + --ds-color-blue-50: #eaf4fc; + --ds-color-blue-300: #93c5fd; + --ds-color-blue-400: #60a5fa; + --ds-color-blue-500: #3b82f6; + --ds-color-blue-600: #2563eb; + --ds-color-blue-700: #1d4ed8; + --ds-color-blue-800: #1e40af; + --ds-color-green-600: #16a34a; + --ds-color-green-50: #ecffe9; + --ds-color-green-500: #00d084; + --ds-color-red-50: #ffecec; + --ds-color-red-400: #ff7f7f; + --ds-color-red-600: #dc2626; + --ds-color-yellow-50: #fffacd; + --ds-color-yellow-400: #ffd700; + --ds-color-steel-600: #4682b4; + --ds-color-code-surface: #0d1117; + --ds-color-code-text: #c9d1d9; + --ds-space-0: 0px; + --ds-space-1: 4px; + --ds-space-2: 8px; + --ds-space-3: 12px; + --ds-space-4: 16px; + --ds-space-5: 20px; + --ds-space-6: 24px; + --ds-space-8: 32px; + --ds-space-12: 48px; + --ds-radius-none: 0px; + --ds-radius-sm: 4px; + --ds-radius-md: 6px; + --ds-radius-lg: 8px; + --ds-radius-full: 9999px; + --ds-color-text-primary: #333333; + --ds-color-text-secondary: #999999; + --ds-color-text-subtle: #9ca3af; + --ds-color-text-inverse: #ffffff; + --ds-color-surface-page: #ffffff; + --ds-color-surface-subtle: #f3f3f3; + --ds-color-surface-selected: #d1d5db; + --ds-color-border-default: #dddddd; + --ds-color-border-strong: #d1d5db; + --ds-color-accent-default: #2563eb; + --ds-color-accent-hover: #1d4ed8; + --ds-color-link-default: #1d4ed8; + --ds-color-link-hover: #1e40af; + --ds-color-focus-ring: #2563eb; + --ds-color-status-success: #16a34a; + --ds-color-status-danger: #dc2626; + --ds-color-code-surface: #0d1117; + --ds-color-code-text: #c9d1d9; + --ds-shadow-control: 0px 1px 2px 0px rgb(0 0 0 / 0.08); + --ds-shadow-card: 0px 10px 15px -3px rgb(0 0 0 / 0.12); + --ds-shadow-dialog: 0px 20px 25px -5px rgb(0 0 0 / 0.18); + --ds-callout-merit-surface: #ecffe9; + --ds-callout-merit-accent: #00d084; + --ds-callout-demerit-surface: #ffecec; + --ds-callout-demerit-accent: #ff7f7f; + --ds-callout-point-surface: #fffacd; + --ds-callout-point-accent: #ffd700; + --ds-callout-common-surface: #eaf4fc; + --ds-callout-common-accent: #4682b4; + --ds-font-family-sans: 'Geist', sans-serif; + --ds-focus-ring-width: 2px; + --ds-focus-ring-offset: 2px; + --ds-motion-duration-fast: 150ms; + --ds-motion-duration-normal: 200ms; + --ds-motion-easing-standard: ease; + + /* Compatibility aliases. Prefer the ds-* semantic names in new code. */ + --color-text-main: var(--ds-color-text-primary); + --color-text-sub: var(--ds-color-text-secondary); + --color-bg-main: var(--ds-color-surface-page); + --color-bg-sub: var(--ds-color-surface-subtle); + --color-bg-code: var(--ds-color-code-surface); + --color-text-code: var(--ds-color-code-text); + --color-border-dark: var(--ds-color-border-strong); + --color-border: var(--ds-color-border-default); + --color-border-light: var(--ds-color-neutral-50); + --color-current: var(--ds-color-neutral-100); + --color-focus-ring: var(--ds-color-focus-ring); + --color-accent-soft-bg: var(--ds-color-blue-50); + --color-accent-soft-text: var(--ds-color-neutral-900); + --color-link: var(--ds-color-link-default); + --color-link-hover: var(--ds-color-link-hover); + --color-link-dark: var(--ds-color-blue-300); + --color-link-dark-hover: var(--ds-color-blue-200); + --border-radius: var(--ds-radius-sm); + --focus-ring-width: var(--ds-focus-ring-width); + --focus-ring-offset: var(--ds-focus-ring-offset); +} + +.DarkTheme { + --ds-color-text-primary: #ffffff; + --ds-color-text-secondary: #d1d5db; + --ds-color-text-subtle: #6b7280; + --ds-color-text-inverse: #111827; + --ds-color-surface-page: #262626; + --ds-color-surface-subtle: #374151; + --ds-color-surface-selected: #6b7280; + --ds-color-border-default: #6b7280; + --ds-color-border-strong: #9ca3af; + --ds-color-accent-default: #60a5fa; + --ds-color-accent-hover: #93c5fd; + --ds-color-link-default: #93c5fd; + --ds-color-link-hover: #bfdbfe; + --ds-color-focus-ring: #60a5fa; + --ds-color-status-success: #16a34a; + --ds-color-status-danger: #dc2626; + --ds-color-code-surface: #0d1117; + --ds-color-code-text: #c9d1d9; +} diff --git a/src/styles/designTokens.ts b/src/styles/designTokens.ts index 32980782..1c6d200d 100644 --- a/src/styles/designTokens.ts +++ b/src/styles/designTokens.ts @@ -1,3 +1,7 @@ +/** + * Reusable Tailwind class recipes. + * Token values live in /design-tokens.json; do not add raw design values here. + */ export const themeClassNames = { light: 'LightTheme', dark: 'DarkTheme', diff --git a/src/styles/globals.css b/src/styles/globals.css index ebe08997..6773bf83 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -2,6 +2,7 @@ layer(base); @import 'tailwindcss'; +@import './design-tokens.generated.css'; /* The default border color has changed to `currentcolor` in Tailwind CSS v4, @@ -59,26 +60,6 @@ layer(base); ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono', 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro', 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace; - --color-text-main: #333; - --color-text-sub: #999; - --color-bg-main: #fff; - --color-bg-sub: #f3f3f3; - --color-bg-code: #0d1117; - --color-text-code: #c9d1d9; - --color-border-dark: #ccc; - --color-border: #ddd; - --color-border-light: #f3f3f3; - --color-current: #eee; - --color-focus-ring: #2563eb; - --color-accent-soft-bg: #eaf4fc; - --color-accent-soft-text: #111827; - --color-link: #1d4ed8; - --color-link-hover: #1e40af; - --color-link-dark: #93c5fd; - --color-link-dark-hover: #bfdbfe; - --border-radius: 4px; - --focus-ring-width: 2px; - --focus-ring-offset: 2px; } * { @@ -94,7 +75,7 @@ layer(base); } body { - font-family: 'Geist', sans-serif; + font-family: var(--ds-font-family-sans); color: var(--color-text-main); line-height: 1.8; } From 130da12a578bac81fad04df04f08c991b53262a4 Mon Sep 17 00:00:00 2001 From: Arata1202 Date: Mon, 3 Aug 2026 21:50:54 +0900 Subject: [PATCH 2/5] fix: set Figma font before text content --- figma/main.js | 380 +++++++++++++++++++++++++------------------------- 1 file changed, 190 insertions(+), 190 deletions(-) diff --git a/figma/main.js b/figma/main.js index 04a38ea2..a455b1f8 100644 --- a/figma/main.js +++ b/figma/main.js @@ -1,214 +1,214 @@ const tokens = { - "primitiveColors": { - "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" + primitiveColors: { + 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', }, - "semanticColors": { - "light": { - "text-primary": "#333333", - "text-secondary": "#999999", - "text-subtle": "#9ca3af", - "text-inverse": "#ffffff", - "surface-page": "#ffffff", - "surface-subtle": "#f3f3f3", - "surface-selected": "#d1d5db", - "border-default": "#dddddd", - "border-strong": "#d1d5db", - "accent-default": "#2563eb", - "accent-hover": "#1d4ed8", - "link-default": "#1d4ed8", - "link-hover": "#1e40af", - "focus-ring": "#2563eb", - "status-success": "#16a34a", - "status-danger": "#dc2626", - "code-surface": "#0d1117", - "code-text": "#c9d1d9" + semanticColors: { + light: { + 'text-primary': '#333333', + 'text-secondary': '#999999', + 'text-subtle': '#9ca3af', + 'text-inverse': '#ffffff', + 'surface-page': '#ffffff', + 'surface-subtle': '#f3f3f3', + 'surface-selected': '#d1d5db', + 'border-default': '#dddddd', + 'border-strong': '#d1d5db', + 'accent-default': '#2563eb', + 'accent-hover': '#1d4ed8', + 'link-default': '#1d4ed8', + 'link-hover': '#1e40af', + 'focus-ring': '#2563eb', + 'status-success': '#16a34a', + 'status-danger': '#dc2626', + 'code-surface': '#0d1117', + 'code-text': '#c9d1d9', + }, + dark: { + 'text-primary': '#ffffff', + 'text-secondary': '#d1d5db', + 'text-subtle': '#6b7280', + 'text-inverse': '#111827', + 'surface-page': '#262626', + 'surface-subtle': '#374151', + 'surface-selected': '#6b7280', + 'border-default': '#6b7280', + 'border-strong': '#9ca3af', + 'accent-default': '#60a5fa', + 'accent-hover': '#93c5fd', + 'link-default': '#93c5fd', + 'link-hover': '#bfdbfe', + 'focus-ring': '#60a5fa', + 'status-success': '#16a34a', + 'status-danger': '#dc2626', + 'code-surface': '#0d1117', + 'code-text': '#c9d1d9', }, - "dark": { - "text-primary": "#ffffff", - "text-secondary": "#d1d5db", - "text-subtle": "#6b7280", - "text-inverse": "#111827", - "surface-page": "#262626", - "surface-subtle": "#374151", - "surface-selected": "#6b7280", - "border-default": "#6b7280", - "border-strong": "#9ca3af", - "accent-default": "#60a5fa", - "accent-hover": "#93c5fd", - "link-default": "#93c5fd", - "link-hover": "#bfdbfe", - "focus-ring": "#60a5fa", - "status-success": "#16a34a", - "status-danger": "#dc2626", - "code-surface": "#0d1117", - "code-text": "#c9d1d9" - } }, - "componentColors": { - "callout/merit/surface": "#ecffe9", - "callout/merit/accent": "#00d084", - "callout/demerit/surface": "#ffecec", - "callout/demerit/accent": "#ff7f7f", - "callout/point/surface": "#fffacd", - "callout/point/accent": "#ffd700", - "callout/common/surface": "#eaf4fc", - "callout/common/accent": "#4682b4" + componentColors: { + 'callout/merit/surface': '#ecffe9', + 'callout/merit/accent': '#00d084', + 'callout/demerit/surface': '#ffecec', + 'callout/demerit/accent': '#ff7f7f', + 'callout/point/surface': '#fffacd', + 'callout/point/accent': '#ffd700', + 'callout/common/surface': '#eaf4fc', + 'callout/common/accent': '#4682b4', }, - "primitiveSpacing": { - "0": 0, - "1": 4, - "2": 8, - "3": 12, - "4": 16, - "5": 20, - "6": 24, - "8": 32, - "12": 48 + primitiveSpacing: { + 0: 0, + 1: 4, + 2: 8, + 3: 12, + 4: 16, + 5: 20, + 6: 24, + 8: 32, + 12: 48, }, - "primitiveRadius": { - "none": 0, - "sm": 4, - "md": 6, - "lg": 8, - "full": 9999 + primitiveRadius: { + none: 0, + sm: 4, + md: 6, + lg: 8, + full: 9999, }, - "colors": { - "textMain": "#333333", - "textSub": "#999999", - "surfaceLight": "#ffffff", - "surfaceDark": "#262626", - "surfaceSubtle": "#f3f3f3", - "border": "#dddddd", - "borderMuted": "#d1d5db", - "borderDark": "#6b7280", - "accent": "#2563eb", - "accentHover": "#1e40af", - "accentSoft": "#eaf4fc", - "accentSoftText": "#111827", - "codeBg": "#0d1117", - "codeText": "#c9d1d9", - "success": "#16a34a", - "danger": "#dc2626" + colors: { + textMain: '#333333', + textSub: '#999999', + surfaceLight: '#ffffff', + surfaceDark: '#262626', + surfaceSubtle: '#f3f3f3', + border: '#dddddd', + borderMuted: '#d1d5db', + borderDark: '#6b7280', + accent: '#2563eb', + accentHover: '#1e40af', + accentSoft: '#eaf4fc', + accentSoftText: '#111827', + codeBg: '#0d1117', + codeText: '#c9d1d9', + success: '#16a34a', + danger: '#dc2626', }, - "typography": { - "display": { - "fontSize": 32, - "lineHeight": 42, - "fontWeight": 700 + typography: { + display: { + fontSize: 32, + lineHeight: 42, + fontWeight: 700, }, - "heading": { - "fontSize": 24, - "lineHeight": 34, - "fontWeight": 700 + heading: { + fontSize: 24, + lineHeight: 34, + fontWeight: 700, }, - "subheading": { - "fontSize": 20, - "lineHeight": 30, - "fontWeight": 600 + subheading: { + fontSize: 20, + lineHeight: 30, + fontWeight: 600, }, - "body-large": { - "fontSize": 18, - "lineHeight": 32, - "fontWeight": 400 + 'body-large': { + fontSize: 18, + lineHeight: 32, + fontWeight: 400, }, - "body": { - "fontSize": 16, - "lineHeight": 28, - "fontWeight": 400 + body: { + fontSize: 16, + lineHeight: 28, + fontWeight: 400, }, - "label": { - "fontSize": 14, - "lineHeight": 20, - "fontWeight": 600 + label: { + fontSize: 14, + lineHeight: 20, + fontWeight: 600, + }, + caption: { + fontSize: 12, + lineHeight: 18, + fontWeight: 400, }, - "caption": { - "fontSize": 12, - "lineHeight": 18, - "fontWeight": 400 - } }, - "spacing": { - "xs": 4, - "sm": 8, - "md": 16, - "lg": 24, - "xl": 32, - "2xl": 48 + spacing: { + xs: 4, + sm: 8, + md: 16, + lg: 24, + xl: 32, + '2xl': 48, }, - "radius": { - "control": 4, - "dialog": 8, - "round": 9999 + radius: { + control: 4, + dialog: 8, + round: 9999, }, - "shadow": { - "control": { - "x": 0, - "y": 1, - "blur": 2, - "spread": 0, - "color": "#000000", - "opacity": 0.08 + 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: { + x: 0, + y: 10, + blur: 15, + spread: -3, + color: '#000000', + opacity: 0.12, + }, + dialog: { + x: 0, + y: 20, + blur: 25, + spread: -5, + color: '#000000', + opacity: 0.18, }, - "dialog": { - "x": 0, - "y": 20, - "blur": 25, - "spread": -5, - "color": "#000000", - "opacity": 0.18 - } }, - "motion": { - "duration": { - "fast": 150, - "normal": 200 + motion: { + duration: { + fast: 150, + normal: 200, + }, + easing: { + standard: 'ease', }, - "easing": { - "standard": "ease" - } }, - "fontFamily": "Geist" + fontFamily: 'Geist', }; figma.skipInvisibleInstanceChildren = true; @@ -291,8 +291,8 @@ function textNode(text, style, color) { } const node = figma.createText(); - node.characters = text; node.fontName = { family: tokens.fontFamily, style: fontStyle(style.fontWeight) }; + node.characters = text; node.fontSize = style.fontSize; node.lineHeight = { unit: 'PIXELS', value: style.lineHeight }; node.fills = [paint(color)]; From 91180f47d94f9654d4e0631ce0e4e8019e7e8079 Mon Sep 17 00:00:00 2001 From: Arata1202 Date: Mon, 3 Aug 2026 21:51:32 +0900 Subject: [PATCH 3/5] chore: restore generated Figma token format --- figma/main.js | 378 +++++++++++++++++++++++++------------------------- 1 file changed, 189 insertions(+), 189 deletions(-) diff --git a/figma/main.js b/figma/main.js index a455b1f8..28a94f0e 100644 --- a/figma/main.js +++ b/figma/main.js @@ -1,214 +1,214 @@ const tokens = { - primitiveColors: { - 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', + "primitiveColors": { + "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" }, - semanticColors: { - light: { - 'text-primary': '#333333', - 'text-secondary': '#999999', - 'text-subtle': '#9ca3af', - 'text-inverse': '#ffffff', - 'surface-page': '#ffffff', - 'surface-subtle': '#f3f3f3', - 'surface-selected': '#d1d5db', - 'border-default': '#dddddd', - 'border-strong': '#d1d5db', - 'accent-default': '#2563eb', - 'accent-hover': '#1d4ed8', - 'link-default': '#1d4ed8', - 'link-hover': '#1e40af', - 'focus-ring': '#2563eb', - 'status-success': '#16a34a', - 'status-danger': '#dc2626', - 'code-surface': '#0d1117', - 'code-text': '#c9d1d9', - }, - dark: { - 'text-primary': '#ffffff', - 'text-secondary': '#d1d5db', - 'text-subtle': '#6b7280', - 'text-inverse': '#111827', - 'surface-page': '#262626', - 'surface-subtle': '#374151', - 'surface-selected': '#6b7280', - 'border-default': '#6b7280', - 'border-strong': '#9ca3af', - 'accent-default': '#60a5fa', - 'accent-hover': '#93c5fd', - 'link-default': '#93c5fd', - 'link-hover': '#bfdbfe', - 'focus-ring': '#60a5fa', - 'status-success': '#16a34a', - 'status-danger': '#dc2626', - 'code-surface': '#0d1117', - 'code-text': '#c9d1d9', + "semanticColors": { + "light": { + "text-primary": "#333333", + "text-secondary": "#999999", + "text-subtle": "#9ca3af", + "text-inverse": "#ffffff", + "surface-page": "#ffffff", + "surface-subtle": "#f3f3f3", + "surface-selected": "#d1d5db", + "border-default": "#dddddd", + "border-strong": "#d1d5db", + "accent-default": "#2563eb", + "accent-hover": "#1d4ed8", + "link-default": "#1d4ed8", + "link-hover": "#1e40af", + "focus-ring": "#2563eb", + "status-success": "#16a34a", + "status-danger": "#dc2626", + "code-surface": "#0d1117", + "code-text": "#c9d1d9" }, + "dark": { + "text-primary": "#ffffff", + "text-secondary": "#d1d5db", + "text-subtle": "#6b7280", + "text-inverse": "#111827", + "surface-page": "#262626", + "surface-subtle": "#374151", + "surface-selected": "#6b7280", + "border-default": "#6b7280", + "border-strong": "#9ca3af", + "accent-default": "#60a5fa", + "accent-hover": "#93c5fd", + "link-default": "#93c5fd", + "link-hover": "#bfdbfe", + "focus-ring": "#60a5fa", + "status-success": "#16a34a", + "status-danger": "#dc2626", + "code-surface": "#0d1117", + "code-text": "#c9d1d9" + } }, - componentColors: { - 'callout/merit/surface': '#ecffe9', - 'callout/merit/accent': '#00d084', - 'callout/demerit/surface': '#ffecec', - 'callout/demerit/accent': '#ff7f7f', - 'callout/point/surface': '#fffacd', - 'callout/point/accent': '#ffd700', - 'callout/common/surface': '#eaf4fc', - 'callout/common/accent': '#4682b4', + "componentColors": { + "callout/merit/surface": "#ecffe9", + "callout/merit/accent": "#00d084", + "callout/demerit/surface": "#ffecec", + "callout/demerit/accent": "#ff7f7f", + "callout/point/surface": "#fffacd", + "callout/point/accent": "#ffd700", + "callout/common/surface": "#eaf4fc", + "callout/common/accent": "#4682b4" }, - primitiveSpacing: { - 0: 0, - 1: 4, - 2: 8, - 3: 12, - 4: 16, - 5: 20, - 6: 24, - 8: 32, - 12: 48, + "primitiveSpacing": { + "0": 0, + "1": 4, + "2": 8, + "3": 12, + "4": 16, + "5": 20, + "6": 24, + "8": 32, + "12": 48 }, - primitiveRadius: { - none: 0, - sm: 4, - md: 6, - lg: 8, - full: 9999, + "primitiveRadius": { + "none": 0, + "sm": 4, + "md": 6, + "lg": 8, + "full": 9999 }, - colors: { - textMain: '#333333', - textSub: '#999999', - surfaceLight: '#ffffff', - surfaceDark: '#262626', - surfaceSubtle: '#f3f3f3', - border: '#dddddd', - borderMuted: '#d1d5db', - borderDark: '#6b7280', - accent: '#2563eb', - accentHover: '#1e40af', - accentSoft: '#eaf4fc', - accentSoftText: '#111827', - codeBg: '#0d1117', - codeText: '#c9d1d9', - success: '#16a34a', - danger: '#dc2626', + "colors": { + "textMain": "#333333", + "textSub": "#999999", + "surfaceLight": "#ffffff", + "surfaceDark": "#262626", + "surfaceSubtle": "#f3f3f3", + "border": "#dddddd", + "borderMuted": "#d1d5db", + "borderDark": "#6b7280", + "accent": "#2563eb", + "accentHover": "#1e40af", + "accentSoft": "#eaf4fc", + "accentSoftText": "#111827", + "codeBg": "#0d1117", + "codeText": "#c9d1d9", + "success": "#16a34a", + "danger": "#dc2626" }, - typography: { - display: { - fontSize: 32, - lineHeight: 42, - fontWeight: 700, + "typography": { + "display": { + "fontSize": 32, + "lineHeight": 42, + "fontWeight": 700 }, - heading: { - fontSize: 24, - lineHeight: 34, - fontWeight: 700, + "heading": { + "fontSize": 24, + "lineHeight": 34, + "fontWeight": 700 }, - subheading: { - fontSize: 20, - lineHeight: 30, - fontWeight: 600, + "subheading": { + "fontSize": 20, + "lineHeight": 30, + "fontWeight": 600 }, - 'body-large': { - fontSize: 18, - lineHeight: 32, - fontWeight: 400, + "body-large": { + "fontSize": 18, + "lineHeight": 32, + "fontWeight": 400 }, - body: { - fontSize: 16, - lineHeight: 28, - fontWeight: 400, + "body": { + "fontSize": 16, + "lineHeight": 28, + "fontWeight": 400 }, - label: { - fontSize: 14, - lineHeight: 20, - fontWeight: 600, - }, - caption: { - fontSize: 12, - lineHeight: 18, - fontWeight: 400, + "label": { + "fontSize": 14, + "lineHeight": 20, + "fontWeight": 600 }, + "caption": { + "fontSize": 12, + "lineHeight": 18, + "fontWeight": 400 + } }, - spacing: { - xs: 4, - sm: 8, - md: 16, - lg: 24, - xl: 32, - '2xl': 48, + "spacing": { + "xs": 4, + "sm": 8, + "md": 16, + "lg": 24, + "xl": 32, + "2xl": 48 }, - radius: { - control: 4, - dialog: 8, - round: 9999, + "radius": { + "control": 4, + "dialog": 8, + "round": 9999 }, - shadow: { - control: { - x: 0, - y: 1, - blur: 2, - spread: 0, - color: '#000000', - opacity: 0.08, + "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, - }, - dialog: { - x: 0, - y: 20, - blur: 25, - spread: -5, - color: '#000000', - opacity: 0.18, + "card": { + "x": 0, + "y": 10, + "blur": 15, + "spread": -3, + "color": "#000000", + "opacity": 0.12 }, + "dialog": { + "x": 0, + "y": 20, + "blur": 25, + "spread": -5, + "color": "#000000", + "opacity": 0.18 + } }, - motion: { - duration: { - fast: 150, - normal: 200, - }, - easing: { - standard: 'ease', + "motion": { + "duration": { + "fast": 150, + "normal": 200 }, + "easing": { + "standard": "ease" + } }, - fontFamily: 'Geist', + "fontFamily": "Geist" }; figma.skipInvisibleInstanceChildren = true; From 4d3bdd3d8daef81dc3e74eda7401f958792b2d8a Mon Sep 17 00:00:00 2001 From: Arata1202 Date: Mon, 3 Aug 2026 22:57:01 +0900 Subject: [PATCH 4/5] fix: harden design token synchronization --- design-tokens.json | 14 +- docs/design-tokens.md | 4 +- docs/design-tokens.schema.json | 168 +++++++++++++++++- figma/README.md | 2 +- figma/main.js | 61 ++++--- scripts/generate-design-tokens.mjs | 234 +++++++++++++++++++++++-- src/styles/design-tokens.generated.css | 173 ++++++++++++------ src/styles/designTokens.ts | 74 ++++---- src/styles/globals.css | 16 +- 9 files changed, 598 insertions(+), 148 deletions(-) diff --git a/design-tokens.json b/design-tokens.json index 7a82e8fb..fe030188 100644 --- a/design-tokens.json +++ b/design-tokens.json @@ -65,8 +65,8 @@ "color": { "light": { "text-primary": "{primitive.color.neutral-750}", - "text-secondary": "{primitive.color.neutral-450}", - "text-subtle": "{primitive.color.neutral-400}", + "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}", @@ -86,7 +86,7 @@ "dark": { "text-primary": "{primitive.color.white}", "text-secondary": "{primitive.color.neutral-250}", - "text-subtle": "{primitive.color.neutral-500}", + "text-subtle": "{primitive.color.neutral-400}", "text-inverse": "{primitive.color.neutral-900}", "surface-page": "{primitive.color.neutral-800}", "surface-subtle": "{primitive.color.neutral-700}", @@ -124,6 +124,14 @@ "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 } } }, diff --git a/docs/design-tokens.md b/docs/design-tokens.md index 22d6ec63..105f29c5 100644 --- a/docs/design-tokens.md +++ b/docs/design-tokens.md @@ -26,7 +26,9 @@ Generated files must not be edited directly: - `src/styles/design-tokens.generated.css` - the leading `tokens` block in `figma/main.js` -The Figma plugin updates local paint, text and effect styles with stable names. It replaces the existing token specimen frame instead of creating duplicates. Running the plugin remains a manual step because unattended Variables REST API synchronization requires an Enterprise plan. +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 diff --git a/docs/design-tokens.schema.json b/docs/design-tokens.schema.json index 3b10027d..2f00a05d 100644 --- a/docs/design-tokens.schema.json +++ b/docs/design-tokens.schema.json @@ -4,14 +4,168 @@ "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" }, + { "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" }, - "primitive": { "type": "object" }, - "semantic": { "type": "object" }, - "typography": { "type": "object" }, - "effect": { "type": "object" }, - "motion": { "type": "object" }, - "component": { "type": "object" } + "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" }, + { "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" } + } + } + } + } + } } } diff --git a/figma/README.md b/figma/README.md index e50d09a6..13adbc3d 100644 --- a/figma/README.md +++ b/figma/README.md @@ -15,4 +15,4 @@ The source of truth is `../design-tokens.json`. Do not edit the generated `token 3. `figma/manifest.json` を選択します。 4. `プラグイン` → `開発` → `NextBlogApp Design Tokens` を実行します。 -同名のローカルスタイルは更新され、既存のトークン一覧フレームは置き換えられます。 +同名のローカルスタイルは更新され、削除済みトークンの管理対象スタイルは整理されます。既存のトークン一覧フレームも置き換えられます。 diff --git a/figma/main.js b/figma/main.js index 28a94f0e..fa45b6fc 100644 --- a/figma/main.js +++ b/figma/main.js @@ -38,8 +38,8 @@ const tokens = { "semanticColors": { "light": { "text-primary": "#333333", - "text-secondary": "#999999", - "text-subtle": "#9ca3af", + "text-secondary": "#4b5563", + "text-subtle": "#6b7280", "text-inverse": "#ffffff", "surface-page": "#ffffff", "surface-subtle": "#f3f3f3", @@ -59,7 +59,7 @@ const tokens = { "dark": { "text-primary": "#ffffff", "text-secondary": "#d1d5db", - "text-subtle": "#6b7280", + "text-subtle": "#9ca3af", "text-inverse": "#111827", "surface-page": "#262626", "surface-subtle": "#374151", @@ -107,7 +107,7 @@ const tokens = { }, "colors": { "textMain": "#333333", - "textSub": "#999999", + "textSub": "#4b5563", "surfaceLight": "#ffffff", "surfaceDark": "#262626", "surfaceSubtle": "#f3f3f3", @@ -190,6 +190,14 @@ const tokens = { "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, @@ -260,6 +268,14 @@ function localStyle(styles, name) { return null; } +function removeStaleStyles(styles, expectedNames, prefix) { + for (const style of styles) { + if (style.name.startsWith(prefix) && !expectedNames.has(style.name)) { + style.remove(); + } + } +} + function section(name, width) { if (width === undefined) { width = 720; @@ -308,7 +324,7 @@ function fontStyle(weight) { } function tokenLabel(name, value) { - const label = textNode(name + '\n' + value, tokens.typography.small); + const label = textNode(name + '\n' + value, tokens.typography.caption); label.resize(260, 48); return label; } @@ -438,6 +454,7 @@ async function createTextStyles() { ? await figma.getLocalTextStylesAsync() : []; + const expectedNames = new Set(); for (const name in tokens.typography) { if (!hasOwn(tokens.typography, name)) { continue; @@ -445,6 +462,7 @@ async function createTextStyles() { const typography = tokens.typography[name]; const styleName = 'Typography/' + name; + expectedNames.add(styleName); const existing = localStyle(styles, styleName); const style = existing || figma.createTextStyle(); style.name = styleName; @@ -452,6 +470,7 @@ async function createTextStyles() { style.fontSize = typography.fontSize; style.lineHeight = { unit: 'PIXELS', value: typography.lineHeight }; } + removeStaleStyles(styles, expectedNames, 'Typography/'); } async function createPaintStyles() { @@ -460,21 +479,12 @@ async function createPaintStyles() { ? await figma.getLocalPaintStylesAsync() : []; - for (const name in tokens.colors) { - if (!hasOwn(tokens.colors, name)) { - continue; - } - - const styleName = 'Color/' + name; - const existing = localStyle(styles, styleName); - const style = existing || figma.createPaintStyle(); - style.name = styleName; - style.paints = [paint(tokens.colors[name])]; - } + const expectedNames = new Set(); for (const name in tokens.primitiveColors) { if (!hasOwn(tokens.primitiveColors, name)) continue; const styleName = 'Color/Primitive/' + name; + expectedNames.add(styleName); const existing = localStyle(styles, styleName); const style = existing || figma.createPaintStyle(); style.name = styleName; @@ -486,6 +496,7 @@ async function createPaintStyles() { for (const name in tokens.semanticColors[mode]) { if (!hasOwn(tokens.semanticColors[mode], name)) continue; const styleName = 'Color/Semantic/' + mode + '/' + name; + expectedNames.add(styleName); const existing = localStyle(styles, styleName); const style = existing || figma.createPaintStyle(); style.name = styleName; @@ -496,11 +507,20 @@ async function createPaintStyles() { for (const name in tokens.componentColors) { if (!hasOwn(tokens.componentColors, name)) continue; const styleName = 'Color/Component/' + name; + expectedNames.add(styleName); const existing = localStyle(styles, styleName); const style = existing || figma.createPaintStyle(); style.name = styleName; style.paints = [paint(tokens.componentColors[name])]; } + removeStaleStyles(styles, expectedNames, 'Color/Primitive/'); + removeStaleStyles(styles, expectedNames, 'Color/Semantic/'); + removeStaleStyles(styles, expectedNames, 'Color/Component/'); + + const legacyNames = new Set(Object.keys(tokens.colors).map((name) => 'Color/' + name)); + for (const style of styles) { + if (legacyNames.has(style.name)) style.remove(); + } } async function createEffectStyles() { @@ -509,17 +529,20 @@ async function createEffectStyles() { ? await figma.getLocalEffectStylesAsync() : []; + const expectedNames = new Set(); for (const name in tokens.shadow) { if (!hasOwn(tokens.shadow, name)) { continue; } const styleName = 'Shadow/' + name; + expectedNames.add(styleName); const existing = localStyle(styles, styleName); const style = existing || figma.createEffectStyle(); style.name = styleName; style.effects = [effect(tokens.shadow[name])]; } + removeStaleStyles(styles, expectedNames, 'Shadow/'); } function appendColorTokens(root) { @@ -672,11 +695,7 @@ async function main() { root.appendChild(textNode('NextBlogApp Design Tokens', tokens.typography.display)); root.appendChild( - textNode( - 'Generated from src/styles/designTokens.ts conventions.', - tokens.typography.body, - tokens.colors.textSub, - ), + textNode('Generated from design-tokens.json.', tokens.typography.body, tokens.colors.textSub), ); appendColorTokens(root); diff --git a/scripts/generate-design-tokens.mjs b/scripts/generate-design-tokens.mjs index 90d92a1c..1c80eef2 100644 --- a/scripts/generate-design-tokens.mjs +++ b/scripts/generate-design-tokens.mjs @@ -25,15 +25,78 @@ function resolve(value, stack = []) { return resolve(referenced, [...stack, match[1]]); } +function assertObject(value, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } +} + +function assertNonNegativeNumber(value, label) { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + throw new Error(`${label} must be a non-negative finite number`); + } +} + +function assertHexColor(value, label) { + if (typeof value !== 'string' || !/^#[0-9a-f]{6}$/i.test(value)) { + throw new Error(`${label} must be a six-digit hex color`); + } +} + +function relativeLuminance(hex) { + const channels = [1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16) / 255); + const [red, green, blue] = channels.map((channel) => + channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4, + ); + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; +} + +function contrastRatio(first, second) { + const light = Math.max(relativeLuminance(first), relativeLuminance(second)); + const dark = Math.min(relativeLuminance(first), relativeLuminance(second)); + return (light + 0.05) / (dark + 0.05); +} + +function assertReferences(value, tokenPath = []) { + if (typeof value === 'string' && /^\{.+}$/.test(value)) { + resolve(value, [tokenPath.join('.')]); + return; + } + if (!value || typeof value !== 'object') return; + for (const [name, child] of Object.entries(value)) { + assertReferences(child, [...tokenPath, name]); + } +} + function assertTokenShape() { + const requiredSections = [ + 'meta', + 'primitive', + 'semantic', + 'typography', + 'effect', + 'motion', + 'component', + ]; + for (const section of requiredSections) assertObject(source[section], section); + + for (const [name, value] of Object.entries(source.primitive.color)) { + assertHexColor(value, `primitive.color.${name}`); + } + for (const [name, value] of Object.entries(source.primitive.space)) { + assertNonNegativeNumber(value, `primitive.space.${name}`); + } + for (const [name, value] of Object.entries(source.primitive.radius)) { + assertNonNegativeNumber(value, `primitive.radius.${name}`); + } + for (const mode of ['light', 'dark']) { const colors = source.semantic.color[mode]; if (!colors || typeof colors !== 'object') throw new Error(`Missing semantic color mode: ${mode}`); for (const [name, value] of Object.entries(colors)) { const resolved = resolve(value); - if (!/^#[0-9a-f]{6}$/i.test(resolved)) - throw new Error(`Invalid ${mode} color ${name}: ${resolved}`); + assertHexColor(resolved, `semantic.color.${mode}.${name}`); } } @@ -41,6 +104,72 @@ function assertTokenShape() { const darkNames = Object.keys(source.semantic.color.dark).sort().join('\n'); if (lightNames !== darkNames) throw new Error('Light and dark semantic color tokens must have identical names'); + + for (const mode of ['light', 'dark']) { + const colors = source.semantic.color[mode]; + const surface = resolve(colors['surface-page']); + for (const role of ['text-primary', 'text-secondary', 'text-subtle']) { + const ratio = contrastRatio(resolve(colors[role]), surface); + if (ratio < 4.5) { + throw new Error( + `semantic.color.${mode}.${role} must meet WCAG AA contrast (was ${ratio.toFixed(2)}:1)`, + ); + } + } + const inverseRatio = contrastRatio( + resolve(colors['text-inverse']), + resolve(colors['accent-default']), + ); + if (inverseRatio < 4.5) { + throw new Error( + `semantic.color.${mode}.text-inverse must meet WCAG AA contrast against accent-default (was ${inverseRatio.toFixed(2)}:1)`, + ); + } + } + + for (const [name, style] of Object.entries(source.typography.style)) { + assertObject(style, `typography.style.${name}`); + assertNonNegativeNumber(style.fontSize, `typography.style.${name}.fontSize`); + assertNonNegativeNumber(style.lineHeight, `typography.style.${name}.lineHeight`); + assertNonNegativeNumber(style.fontWeight, `typography.style.${name}.fontWeight`); + if (style.lineHeight < style.fontSize) { + throw new Error(`typography.style.${name}.lineHeight must not be smaller than fontSize`); + } + } + + for (const [name, shadow] of Object.entries(source.effect.shadow)) { + assertObject(shadow, `effect.shadow.${name}`); + for (const field of ['x', 'y', 'blur', 'spread']) { + if (typeof shadow[field] !== 'number' || !Number.isFinite(shadow[field])) { + throw new Error(`effect.shadow.${name}.${field} must be a finite number`); + } + } + assertHexColor(shadow.color, `effect.shadow.${name}.color`); + if (typeof shadow.opacity !== 'number' || shadow.opacity < 0 || shadow.opacity > 1) { + throw new Error(`effect.shadow.${name}.opacity must be between 0 and 1`); + } + } + + for (const [name, value] of Object.entries(source.motion.duration)) { + assertNonNegativeNumber(value, `motion.duration.${name}`); + } + if (!source.typography.fontFamily.sans || !source.typography.fontFamily.mono) { + throw new Error('typography.fontFamily.sans and typography.fontFamily.mono are required'); + } + for (const [name, value] of Object.entries(source.motion.easing)) { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`motion.easing.${name} must be a non-empty string`); + } + } + assertNonNegativeNumber(source.component.focusRing.width, 'component.focusRing.width'); + assertNonNegativeNumber(source.component.focusRing.offset, 'component.focusRing.offset'); + assertNonNegativeNumber(resolve(source.component.control.radius), 'component.control.radius'); + assertNonNegativeNumber(resolve(source.component.dialog.radius), 'component.dialog.radius'); + for (const [variant, colors] of Object.entries(source.component.callout)) { + assertHexColor(resolve(colors.surface), `component.callout.${variant}.surface`); + assertHexColor(resolve(colors.accent), `component.callout.${variant}.accent`); + } + assertReferences(source); } function cssVariables(entries, mapper = (value) => value) { @@ -51,13 +180,22 @@ function cssVariables(entries, mapper = (value) => value) { function generateCss() { const primitiveColors = Object.fromEntries( - Object.entries(source.primitive.color).map(([name, value]) => [`ds-color-${name}`, value]), + Object.entries(source.primitive.color).map(([name, value]) => [ + `ds-primitive-color-${name}`, + value, + ]), ); const primitiveSpace = Object.fromEntries( - Object.entries(source.primitive.space).map(([name, value]) => [`ds-space-${name}`, value]), + Object.entries(source.primitive.space).map(([name, value]) => [ + `ds-primitive-space-${name}`, + value, + ]), ); const primitiveRadius = Object.fromEntries( - Object.entries(source.primitive.radius).map(([name, value]) => [`ds-radius-${name}`, value]), + Object.entries(source.primitive.radius).map(([name, value]) => [ + `ds-primitive-radius-${name}`, + value, + ]), ); const semanticLight = Object.fromEntries( Object.entries(source.semantic.color.light).map(([name, value]) => [`ds-color-${name}`, value]), @@ -76,6 +214,22 @@ function generateCss() { Object.entries(colors).map(([role, value]) => [`ds-callout-${variant}-${role}`, value]), ), ); + const rootVariableNames = [ + primitiveColors, + primitiveSpace, + primitiveRadius, + semanticLight, + shadows, + componentColors, + ].flatMap((entries) => Object.keys(entries)); + const duplicateNames = rootVariableNames.filter( + (name, index) => rootVariableNames.indexOf(name) !== index, + ); + if (duplicateNames.length > 0) { + throw new Error( + `Duplicate generated CSS variables: ${[...new Set(duplicateNames)].join(', ')}`, + ); + } return `/* Generated by scripts/generate-design-tokens.mjs. Do not edit. */ :root { @@ -86,6 +240,9 @@ ${cssVariables(semanticLight)} ${cssVariables(shadows)} ${cssVariables(componentColors)} --ds-font-family-sans: '${source.typography.fontFamily.sans}', sans-serif; + --ds-radius-control: ${resolve(source.component.control.radius)}px; + --ds-radius-dialog: ${resolve(source.component.dialog.radius)}px; + --ds-radius-round: ${source.primitive.radius.full}px; --ds-focus-ring-width: ${source.component.focusRing.width}px; --ds-focus-ring-offset: ${source.component.focusRing.offset}px; --ds-motion-duration-fast: ${source.motion.duration.fast}ms; @@ -101,16 +258,16 @@ ${cssVariables(componentColors)} --color-text-code: var(--ds-color-code-text); --color-border-dark: var(--ds-color-border-strong); --color-border: var(--ds-color-border-default); - --color-border-light: var(--ds-color-neutral-50); - --color-current: var(--ds-color-neutral-100); + --color-border-light: var(--ds-primitive-color-neutral-50); + --color-current: var(--ds-primitive-color-neutral-100); --color-focus-ring: var(--ds-color-focus-ring); - --color-accent-soft-bg: var(--ds-color-blue-50); - --color-accent-soft-text: var(--ds-color-neutral-900); + --color-accent-soft-bg: var(--ds-primitive-color-blue-50); + --color-accent-soft-text: var(--ds-primitive-color-neutral-900); --color-link: var(--ds-color-link-default); --color-link-hover: var(--ds-color-link-hover); - --color-link-dark: var(--ds-color-blue-300); - --color-link-dark-hover: var(--ds-color-blue-200); - --border-radius: var(--ds-radius-sm); + --color-link-dark: var(--ds-color-link-default); + --color-link-dark-hover: var(--ds-color-link-hover); + --border-radius: var(--ds-radius-control); --focus-ring-width: var(--ds-focus-ring-width); --focus-ring-offset: var(--ds-focus-ring-offset); } @@ -118,6 +275,59 @@ ${cssVariables(componentColors)} .DarkTheme { ${cssVariables(semanticDark)} } + +/* Semantic utility classes consumed by src/styles/designTokens.ts. */ +.ds-transition-colors { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; + transition-duration: var(--ds-motion-duration-fast); + transition-timing-function: var(--ds-motion-easing-standard); +} +.ds-transition-shadow { + transition-property: box-shadow; + transition-duration: var(--ds-motion-duration-normal); + transition-timing-function: var(--ds-motion-easing-standard); +} +.ds-transition-transform { + transition-property: transform; + transition-duration: var(--ds-motion-duration-normal); + transition-timing-function: var(--ds-motion-easing-standard); +} +.ds-radius-control { border-radius: var(--ds-radius-control); } +.ds-radius-dialog { border-radius: var(--ds-radius-dialog); } +.ds-radius-round { border-radius: var(--ds-radius-round); } +.ds-shadow-control { box-shadow: var(--ds-shadow-control); } +.ds-shadow-card { box-shadow: var(--ds-shadow-card); } +.ds-shadow-card:hover { box-shadow: var(--ds-shadow-card-hover); } +.ds-shadow-dialog { box-shadow: var(--ds-shadow-dialog); } +.ds-bg-accent { background-color: var(--ds-color-accent-default); } +.ds-text-accent { color: var(--ds-color-accent-default); } +.ds-text-inverse { color: var(--ds-color-text-inverse); } +.ds-text-secondary { color: var(--ds-color-text-secondary); } +.ds-text-subtle { color: var(--ds-color-text-subtle); } +.ds-text-danger { color: var(--ds-color-status-danger); } +.ds-text-success { color: var(--ds-color-status-success); } +.ds-text-link { color: var(--ds-color-link-default); } +.ds-text-link:hover { color: var(--ds-color-link-hover); } +.ds-hover-text-accent:hover { color: var(--ds-color-accent-default); } +.ds-hover-border-accent:hover { border-color: var(--ds-color-accent-default); } +.ds-primary-button { + color: var(--ds-color-text-inverse); + background-color: var(--ds-color-accent-default); +} +.ds-primary-button:hover { background-color: var(--ds-color-accent-hover); } +.ds-placeholder-secondary::placeholder { color: var(--ds-color-text-secondary); } +.ds-active-accent { color: var(--ds-color-accent-default) !important; } +.ds-bordered-text { + color: var(--ds-color-text-primary); + border-color: var(--ds-color-border-strong); +} +.ds-hover-surface-subtle:hover { background-color: var(--ds-color-surface-subtle); } +.ds-selected-surface { + color: var(--ds-color-text-primary); + background-color: var(--ds-color-surface-selected); +} +.ds-surface-subtle { background-color: var(--ds-color-surface-subtle); } +.ds-ring-subtle { --tw-ring-color: color-mix(in srgb, var(--ds-color-border-strong) 5%, transparent); } `; } diff --git a/src/styles/design-tokens.generated.css b/src/styles/design-tokens.generated.css index 8e9c7659..17477b3a 100644 --- a/src/styles/design-tokens.generated.css +++ b/src/styles/design-tokens.generated.css @@ -1,56 +1,56 @@ /* Generated by scripts/generate-design-tokens.mjs. Do not edit. */ :root { - --ds-color-white: #ffffff; - --ds-color-neutral-25: #f9fafb; - --ds-color-neutral-50: #f3f3f3; - --ds-color-neutral-100: #eeeeee; - --ds-color-neutral-200: #dddddd; - --ds-color-neutral-250: #d1d5db; - --ds-color-neutral-300: #cccccc; - --ds-color-neutral-400: #9ca3af; - --ds-color-neutral-450: #999999; - --ds-color-neutral-500: #6b7280; - --ds-color-neutral-600: #4b5563; - --ds-color-neutral-700: #374151; - --ds-color-neutral-750: #333333; - --ds-color-neutral-800: #262626; - --ds-color-neutral-900: #111827; - --ds-color-blue-200: #bfdbfe; - --ds-color-blue-50: #eaf4fc; - --ds-color-blue-300: #93c5fd; - --ds-color-blue-400: #60a5fa; - --ds-color-blue-500: #3b82f6; - --ds-color-blue-600: #2563eb; - --ds-color-blue-700: #1d4ed8; - --ds-color-blue-800: #1e40af; - --ds-color-green-600: #16a34a; - --ds-color-green-50: #ecffe9; - --ds-color-green-500: #00d084; - --ds-color-red-50: #ffecec; - --ds-color-red-400: #ff7f7f; - --ds-color-red-600: #dc2626; - --ds-color-yellow-50: #fffacd; - --ds-color-yellow-400: #ffd700; - --ds-color-steel-600: #4682b4; - --ds-color-code-surface: #0d1117; - --ds-color-code-text: #c9d1d9; - --ds-space-0: 0px; - --ds-space-1: 4px; - --ds-space-2: 8px; - --ds-space-3: 12px; - --ds-space-4: 16px; - --ds-space-5: 20px; - --ds-space-6: 24px; - --ds-space-8: 32px; - --ds-space-12: 48px; - --ds-radius-none: 0px; - --ds-radius-sm: 4px; - --ds-radius-md: 6px; - --ds-radius-lg: 8px; - --ds-radius-full: 9999px; + --ds-primitive-color-white: #ffffff; + --ds-primitive-color-neutral-25: #f9fafb; + --ds-primitive-color-neutral-50: #f3f3f3; + --ds-primitive-color-neutral-100: #eeeeee; + --ds-primitive-color-neutral-200: #dddddd; + --ds-primitive-color-neutral-250: #d1d5db; + --ds-primitive-color-neutral-300: #cccccc; + --ds-primitive-color-neutral-400: #9ca3af; + --ds-primitive-color-neutral-450: #999999; + --ds-primitive-color-neutral-500: #6b7280; + --ds-primitive-color-neutral-600: #4b5563; + --ds-primitive-color-neutral-700: #374151; + --ds-primitive-color-neutral-750: #333333; + --ds-primitive-color-neutral-800: #262626; + --ds-primitive-color-neutral-900: #111827; + --ds-primitive-color-blue-200: #bfdbfe; + --ds-primitive-color-blue-50: #eaf4fc; + --ds-primitive-color-blue-300: #93c5fd; + --ds-primitive-color-blue-400: #60a5fa; + --ds-primitive-color-blue-500: #3b82f6; + --ds-primitive-color-blue-600: #2563eb; + --ds-primitive-color-blue-700: #1d4ed8; + --ds-primitive-color-blue-800: #1e40af; + --ds-primitive-color-green-600: #16a34a; + --ds-primitive-color-green-50: #ecffe9; + --ds-primitive-color-green-500: #00d084; + --ds-primitive-color-red-50: #ffecec; + --ds-primitive-color-red-400: #ff7f7f; + --ds-primitive-color-red-600: #dc2626; + --ds-primitive-color-yellow-50: #fffacd; + --ds-primitive-color-yellow-400: #ffd700; + --ds-primitive-color-steel-600: #4682b4; + --ds-primitive-color-code-surface: #0d1117; + --ds-primitive-color-code-text: #c9d1d9; + --ds-primitive-space-0: 0px; + --ds-primitive-space-1: 4px; + --ds-primitive-space-2: 8px; + --ds-primitive-space-3: 12px; + --ds-primitive-space-4: 16px; + --ds-primitive-space-5: 20px; + --ds-primitive-space-6: 24px; + --ds-primitive-space-8: 32px; + --ds-primitive-space-12: 48px; + --ds-primitive-radius-none: 0px; + --ds-primitive-radius-sm: 4px; + --ds-primitive-radius-md: 6px; + --ds-primitive-radius-lg: 8px; + --ds-primitive-radius-full: 9999px; --ds-color-text-primary: #333333; - --ds-color-text-secondary: #999999; - --ds-color-text-subtle: #9ca3af; + --ds-color-text-secondary: #4b5563; + --ds-color-text-subtle: #6b7280; --ds-color-text-inverse: #ffffff; --ds-color-surface-page: #ffffff; --ds-color-surface-subtle: #f3f3f3; @@ -68,6 +68,7 @@ --ds-color-code-text: #c9d1d9; --ds-shadow-control: 0px 1px 2px 0px rgb(0 0 0 / 0.08); --ds-shadow-card: 0px 10px 15px -3px rgb(0 0 0 / 0.12); + --ds-shadow-card-hover: 0px 20px 25px -5px rgb(0 0 0 / 0.14); --ds-shadow-dialog: 0px 20px 25px -5px rgb(0 0 0 / 0.18); --ds-callout-merit-surface: #ecffe9; --ds-callout-merit-accent: #00d084; @@ -78,6 +79,9 @@ --ds-callout-common-surface: #eaf4fc; --ds-callout-common-accent: #4682b4; --ds-font-family-sans: 'Geist', sans-serif; + --ds-radius-control: 4px; + --ds-radius-dialog: 8px; + --ds-radius-round: 9999px; --ds-focus-ring-width: 2px; --ds-focus-ring-offset: 2px; --ds-motion-duration-fast: 150ms; @@ -93,16 +97,16 @@ --color-text-code: var(--ds-color-code-text); --color-border-dark: var(--ds-color-border-strong); --color-border: var(--ds-color-border-default); - --color-border-light: var(--ds-color-neutral-50); - --color-current: var(--ds-color-neutral-100); + --color-border-light: var(--ds-primitive-color-neutral-50); + --color-current: var(--ds-primitive-color-neutral-100); --color-focus-ring: var(--ds-color-focus-ring); - --color-accent-soft-bg: var(--ds-color-blue-50); - --color-accent-soft-text: var(--ds-color-neutral-900); + --color-accent-soft-bg: var(--ds-primitive-color-blue-50); + --color-accent-soft-text: var(--ds-primitive-color-neutral-900); --color-link: var(--ds-color-link-default); --color-link-hover: var(--ds-color-link-hover); - --color-link-dark: var(--ds-color-blue-300); - --color-link-dark-hover: var(--ds-color-blue-200); - --border-radius: var(--ds-radius-sm); + --color-link-dark: var(--ds-color-link-default); + --color-link-dark-hover: var(--ds-color-link-hover); + --border-radius: var(--ds-radius-control); --focus-ring-width: var(--ds-focus-ring-width); --focus-ring-offset: var(--ds-focus-ring-offset); } @@ -110,7 +114,7 @@ .DarkTheme { --ds-color-text-primary: #ffffff; --ds-color-text-secondary: #d1d5db; - --ds-color-text-subtle: #6b7280; + --ds-color-text-subtle: #9ca3af; --ds-color-text-inverse: #111827; --ds-color-surface-page: #262626; --ds-color-surface-subtle: #374151; @@ -127,3 +131,56 @@ --ds-color-code-surface: #0d1117; --ds-color-code-text: #c9d1d9; } + +/* Semantic utility classes consumed by src/styles/designTokens.ts. */ +.ds-transition-colors { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; + transition-duration: var(--ds-motion-duration-fast); + transition-timing-function: var(--ds-motion-easing-standard); +} +.ds-transition-shadow { + transition-property: box-shadow; + transition-duration: var(--ds-motion-duration-normal); + transition-timing-function: var(--ds-motion-easing-standard); +} +.ds-transition-transform { + transition-property: transform; + transition-duration: var(--ds-motion-duration-normal); + transition-timing-function: var(--ds-motion-easing-standard); +} +.ds-radius-control { border-radius: var(--ds-radius-control); } +.ds-radius-dialog { border-radius: var(--ds-radius-dialog); } +.ds-radius-round { border-radius: var(--ds-radius-round); } +.ds-shadow-control { box-shadow: var(--ds-shadow-control); } +.ds-shadow-card { box-shadow: var(--ds-shadow-card); } +.ds-shadow-card:hover { box-shadow: var(--ds-shadow-card-hover); } +.ds-shadow-dialog { box-shadow: var(--ds-shadow-dialog); } +.ds-bg-accent { background-color: var(--ds-color-accent-default); } +.ds-text-accent { color: var(--ds-color-accent-default); } +.ds-text-inverse { color: var(--ds-color-text-inverse); } +.ds-text-secondary { color: var(--ds-color-text-secondary); } +.ds-text-subtle { color: var(--ds-color-text-subtle); } +.ds-text-danger { color: var(--ds-color-status-danger); } +.ds-text-success { color: var(--ds-color-status-success); } +.ds-text-link { color: var(--ds-color-link-default); } +.ds-text-link:hover { color: var(--ds-color-link-hover); } +.ds-hover-text-accent:hover { color: var(--ds-color-accent-default); } +.ds-hover-border-accent:hover { border-color: var(--ds-color-accent-default); } +.ds-primary-button { + color: var(--ds-color-text-inverse); + background-color: var(--ds-color-accent-default); +} +.ds-primary-button:hover { background-color: var(--ds-color-accent-hover); } +.ds-placeholder-secondary::placeholder { color: var(--ds-color-text-secondary); } +.ds-active-accent { color: var(--ds-color-accent-default) !important; } +.ds-bordered-text { + color: var(--ds-color-text-primary); + border-color: var(--ds-color-border-strong); +} +.ds-hover-surface-subtle:hover { background-color: var(--ds-color-surface-subtle); } +.ds-selected-surface { + color: var(--ds-color-text-primary); + background-color: var(--ds-color-surface-selected); +} +.ds-surface-subtle { background-color: var(--ds-color-surface-subtle); } +.ds-ring-subtle { --tw-ring-color: color-mix(in srgb, var(--ds-color-border-strong) 5%, transparent); } diff --git a/src/styles/designTokens.ts b/src/styles/designTokens.ts index 1c6d200d..f3b047fd 100644 --- a/src/styles/designTokens.ts +++ b/src/styles/designTokens.ts @@ -24,70 +24,70 @@ export const getThemeVariantClassName = ( }; export const transitionClassNames = { - color: 'transition-colors duration-150', - shadow: 'transition-shadow duration-200', - transform: 'transition-transform duration-200', + color: 'ds-transition-colors', + shadow: 'ds-transition-shadow', + transform: 'ds-transition-transform', } as const; export const radiusClassNames = { - control: 'rounded-md', - dialog: 'rounded-lg', - round: 'rounded-full', + control: 'ds-radius-control', + dialog: 'ds-radius-dialog', + round: 'ds-radius-round', } as const; export const shadowClassNames = { - control: 'shadow-xs', - card: 'shadow-lg hover:shadow-xl', - dialog: 'shadow-xl', + control: 'ds-shadow-control', + card: 'ds-shadow-card', + dialog: 'ds-shadow-dialog', } as const; export const colorClassNames = { - accentBadge: 'bg-blue-600 text-white', - accentText: 'text-blue-600', - accentHoverText: 'hover:text-blue-600', - accentBorderHover: 'hover:border-blue-600', - dangerText: 'text-red-600', - mutedText: 'text-gray-500', - placeholderMutedText: 'placeholder:text-gray-500', - primaryButton: 'bg-blue-600 hover:bg-blue-700 text-white', - subtleText: 'text-gray-400', - successText: 'text-green-600', - textLink: 'text-blue-700 hover:text-blue-800', - darkTextLink: 'text-blue-300 hover:text-blue-200', + accentBadge: 'ds-bg-accent ds-text-inverse', + accentText: 'ds-text-accent', + accentHoverText: 'ds-hover-text-accent', + accentBorderHover: 'ds-hover-border-accent', + dangerText: 'ds-text-danger', + mutedText: 'ds-text-secondary', + placeholderMutedText: 'ds-placeholder-secondary', + primaryButton: 'ds-primary-button', + subtleText: 'ds-text-subtle', + successText: 'ds-text-success', + textLink: 'ds-text-link', + darkTextLink: 'ds-text-link', } as const; export const themeVariantClassNames = { activeAccentText: { - light: 'text-blue-600!', - dark: 'text-blue-400!', + light: 'ds-active-accent', + dark: 'ds-active-accent', }, borderedText: { - light: 'border-gray-300 text-gray-700', - dark: 'border-gray-500 text-white', + light: 'ds-bordered-text', + dark: 'ds-bordered-text', }, controlHoverSurface: { - light: 'hover:bg-gray-50', - dark: 'hover:bg-gray-500', + light: 'ds-hover-surface-subtle', + dark: 'ds-hover-surface-subtle', }, mutedText: { - light: 'text-gray-500', - dark: 'text-gray-300', + light: 'ds-text-secondary', + dark: 'ds-text-secondary', }, selectedSurface: { - light: 'bg-gray-300 text-gray-700', - dark: 'bg-gray-500 text-white', + light: 'ds-selected-surface', + dark: 'ds-selected-surface', }, subtleSurface: { - light: 'bg-gray-50', - dark: 'bg-gray-700', + light: 'ds-surface-subtle', + dark: 'ds-surface-subtle', }, subtleIcon: { - light: 'text-gray-300', - dark: 'text-gray-500', + light: 'ds-text-subtle', + dark: 'ds-text-subtle', }, subtleRing: { - light: 'ring-gray-300/5', - dark: 'ring-gray-500/5', + light: 'ds-ring-subtle', + dark: 'ds-ring-subtle', }, } as const satisfies Record; diff --git a/src/styles/globals.css b/src/styles/globals.css index 6773bf83..155d6750 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -22,17 +22,17 @@ layer(base); } .LightTheme { - background-color: white; - color: rgb(55 65 81); - border-color: rgb(209 213 219); - --tw-ring-color: rgb(209 213 219); + background-color: var(--ds-color-surface-page); + color: var(--ds-color-text-primary); + border-color: var(--ds-color-border-strong); + --tw-ring-color: var(--ds-color-border-strong); } .DarkTheme { - background-color: #262626; - color: white; - border-color: rgb(107 114 128); - --tw-ring-color: rgb(107 114 128); + background-color: var(--ds-color-surface-page); + color: var(--ds-color-text-primary); + border-color: var(--ds-color-border-default); + --tw-ring-color: var(--ds-color-border-default); } .iframely-embed { From b613a88fb17d88dedd8076e52a1f643ce9919d10 Mon Sep 17 00:00:00 2001 From: Arata1202 Date: Mon, 3 Aug 2026 22:58:13 +0900 Subject: [PATCH 5/5] fix: make token schema strict-mode clean --- docs/design-tokens.schema.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design-tokens.schema.json b/docs/design-tokens.schema.json index 2f00a05d..745053a0 100644 --- a/docs/design-tokens.schema.json +++ b/docs/design-tokens.schema.json @@ -17,7 +17,7 @@ "semanticColors": { "allOf": [ { "$ref": "#/$defs/tokenMap" }, - { "additionalProperties": { "$ref": "#/$defs/colorValue" } } + { "type": "object", "additionalProperties": { "$ref": "#/$defs/colorValue" } } ] }, "typographyStyle": { @@ -64,7 +64,7 @@ "color": { "allOf": [ { "$ref": "#/$defs/tokenMap" }, - { "additionalProperties": { "$ref": "#/$defs/hexColor" } } + { "type": "object", "additionalProperties": { "$ref": "#/$defs/hexColor" } } ] }, "space": {