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..fe030188 --- /dev/null +++ b/design-tokens.json @@ -0,0 +1,165 @@ +{ + "$schema": "./docs/design-tokens.schema.json", + "meta": { + "name": "NextBlogApp Design Tokens", + "version": 1, + "sourceOfTruth": true + }, + "primitive": { + "color": { + "white": "#ffffff", + "neutral-25": "#f9fafb", + "neutral-50": "#f3f3f3", + "neutral-100": "#eeeeee", + "neutral-200": "#dddddd", + "neutral-250": "#d1d5db", + "neutral-300": "#cccccc", + "neutral-400": "#9ca3af", + "neutral-450": "#999999", + "neutral-500": "#6b7280", + "neutral-600": "#4b5563", + "neutral-700": "#374151", + "neutral-750": "#333333", + "neutral-800": "#262626", + "neutral-900": "#111827", + "blue-200": "#bfdbfe", + "blue-50": "#eaf4fc", + "blue-300": "#93c5fd", + "blue-400": "#60a5fa", + "blue-500": "#3b82f6", + "blue-600": "#2563eb", + "blue-700": "#1d4ed8", + "blue-800": "#1e40af", + "green-600": "#16a34a", + "green-50": "#ecffe9", + "green-500": "#00d084", + "red-50": "#ffecec", + "red-400": "#ff7f7f", + "red-600": "#dc2626", + "yellow-50": "#fffacd", + "yellow-400": "#ffd700", + "steel-600": "#4682b4", + "code-surface": "#0d1117", + "code-text": "#c9d1d9" + }, + "space": { + "0": 0, + "1": 4, + "2": 8, + "3": 12, + "4": 16, + "5": 20, + "6": 24, + "8": 32, + "12": 48 + }, + "radius": { + "none": 0, + "sm": 4, + "md": 6, + "lg": 8, + "full": 9999 + } + }, + "semantic": { + "color": { + "light": { + "text-primary": "{primitive.color.neutral-750}", + "text-secondary": "{primitive.color.neutral-600}", + "text-subtle": "{primitive.color.neutral-500}", + "text-inverse": "{primitive.color.white}", + "surface-page": "{primitive.color.white}", + "surface-subtle": "{primitive.color.neutral-50}", + "surface-selected": "{primitive.color.neutral-250}", + "border-default": "{primitive.color.neutral-200}", + "border-strong": "{primitive.color.neutral-250}", + "accent-default": "{primitive.color.blue-600}", + "accent-hover": "{primitive.color.blue-700}", + "link-default": "{primitive.color.blue-700}", + "link-hover": "{primitive.color.blue-800}", + "focus-ring": "{primitive.color.blue-600}", + "status-success": "{primitive.color.green-600}", + "status-danger": "{primitive.color.red-600}", + "code-surface": "{primitive.color.code-surface}", + "code-text": "{primitive.color.code-text}" + }, + "dark": { + "text-primary": "{primitive.color.white}", + "text-secondary": "{primitive.color.neutral-250}", + "text-subtle": "{primitive.color.neutral-400}", + "text-inverse": "{primitive.color.neutral-900}", + "surface-page": "{primitive.color.neutral-800}", + "surface-subtle": "{primitive.color.neutral-700}", + "surface-selected": "{primitive.color.neutral-500}", + "border-default": "{primitive.color.neutral-500}", + "border-strong": "{primitive.color.neutral-400}", + "accent-default": "{primitive.color.blue-400}", + "accent-hover": "{primitive.color.blue-300}", + "link-default": "{primitive.color.blue-300}", + "link-hover": "{primitive.color.blue-200}", + "focus-ring": "{primitive.color.blue-400}", + "status-success": "{primitive.color.green-600}", + "status-danger": "{primitive.color.red-600}", + "code-surface": "{primitive.color.code-surface}", + "code-text": "{primitive.color.code-text}" + } + } + }, + "typography": { + "fontFamily": { + "sans": "Geist", + "mono": "ui-monospace" + }, + "style": { + "display": { "fontSize": 32, "lineHeight": 42, "fontWeight": 700 }, + "heading": { "fontSize": 24, "lineHeight": 34, "fontWeight": 700 }, + "subheading": { "fontSize": 20, "lineHeight": 30, "fontWeight": 600 }, + "body-large": { "fontSize": 18, "lineHeight": 32, "fontWeight": 400 }, + "body": { "fontSize": 16, "lineHeight": 28, "fontWeight": 400 }, + "label": { "fontSize": 14, "lineHeight": 20, "fontWeight": 600 }, + "caption": { "fontSize": 12, "lineHeight": 18, "fontWeight": 400 } + } + }, + "effect": { + "shadow": { + "control": { "x": 0, "y": 1, "blur": 2, "spread": 0, "color": "#000000", "opacity": 0.08 }, + "card": { "x": 0, "y": 10, "blur": 15, "spread": -3, "color": "#000000", "opacity": 0.12 }, + "card-hover": { + "x": 0, + "y": 20, + "blur": 25, + "spread": -5, + "color": "#000000", + "opacity": 0.14 + }, + "dialog": { "x": 0, "y": 20, "blur": 25, "spread": -5, "color": "#000000", "opacity": 0.18 } + } + }, + "motion": { + "duration": { "fast": 150, "normal": 200 }, + "easing": { "standard": "ease" } + }, + "component": { + "focusRing": { "width": 2, "offset": 2 }, + "control": { "radius": "{primitive.radius.sm}" }, + "dialog": { "radius": "{primitive.radius.lg}" }, + "callout": { + "merit": { + "surface": "{primitive.color.green-50}", + "accent": "{primitive.color.green-500}" + }, + "demerit": { + "surface": "{primitive.color.red-50}", + "accent": "{primitive.color.red-400}" + }, + "point": { + "surface": "{primitive.color.yellow-50}", + "accent": "{primitive.color.yellow-400}" + }, + "common": { + "surface": "{primitive.color.blue-50}", + "accent": "{primitive.color.steel-600}" + } + } + } +} diff --git a/docs/design-tokens.md b/docs/design-tokens.md new file mode 100644 index 00000000..105f29c5 --- /dev/null +++ b/docs/design-tokens.md @@ -0,0 +1,38 @@ +# Design tokens + +`design-tokens.json` is the single source of truth for shared visual decisions in NextBlogApp. + +## Architecture + +- `primitive`: raw palette, spacing and radius scales. Application code should not normally use these directly. +- `semantic`: purpose-based colors with light and dark modes. New UI should prefer these names. +- `typography`: reusable text roles using the product font family. +- `effect` and `motion`: shared shadows and interaction timing. +- `component`: values that belong to a reusable UI pattern, not one isolated layout. + +One-off layout dimensions, responsive breakpoints and content-specific illustration colors are deliberately not tokens. A value becomes a token when it represents a repeated design decision rather than merely occurring in CSS. + +## Workflow + +1. Edit `design-tokens.json` only. +2. Run `pnpm tokens:generate`. +3. Commit the source and generated files together. +4. Import `figma/manifest.json` as a development plugin and run it in Figma. + +`pnpm dev` and `pnpm build` regenerate the Web CSS automatically. CI runs `pnpm tokens:check` and fails when generated files are stale. + +Generated files must not be edited directly: + +- `src/styles/design-tokens.generated.css` +- the leading `tokens` block in `figma/main.js` + +The Figma plugin reconciles the local paint, text and effect styles it owns: current styles are updated, removed tokens are deleted, and the specimen frame is replaced instead of duplicated. Legacy `Color/*` styles created by the previous plugin format are removed during migration. Running the plugin remains a manual step because unattended Variables REST API synchronization requires an Enterprise plan. + +Semantic text colors intended for normal-size copy must meet WCAG AA contrast against their paired surface. Decorative and disabled states should use separate component tokens rather than weakening shared text roles. + +## Naming rules + +- Use kebab-case names that describe intent, such as `text-primary` or `surface-selected`. +- Do not encode a color value in a semantic name. +- Add a component token only when a value is owned by a reusable component pattern. +- Light and dark semantic color modes must expose exactly the same token names. diff --git a/docs/design-tokens.schema.json b/docs/design-tokens.schema.json new file mode 100644 index 00000000..745053a0 --- /dev/null +++ b/docs/design-tokens.schema.json @@ -0,0 +1,171 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "NextBlogApp design tokens", + "type": "object", + "required": ["meta", "primitive", "semantic", "typography", "effect", "motion", "component"], + "additionalProperties": false, + "$defs": { + "hexColor": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }, + "reference": { "type": "string", "pattern": "^\\{[a-zA-Z0-9._-]+\\}$" }, + "colorValue": { "oneOf": [{ "$ref": "#/$defs/hexColor" }, { "$ref": "#/$defs/reference" }] }, + "nonNegativeNumber": { "type": "number", "minimum": 0 }, + "tokenMap": { + "type": "object", + "minProperties": 1, + "propertyNames": { "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" } + }, + "semanticColors": { + "allOf": [ + { "$ref": "#/$defs/tokenMap" }, + { "type": "object", "additionalProperties": { "$ref": "#/$defs/colorValue" } } + ] + }, + "typographyStyle": { + "type": "object", + "required": ["fontSize", "lineHeight", "fontWeight"], + "additionalProperties": false, + "properties": { + "fontSize": { "$ref": "#/$defs/nonNegativeNumber" }, + "lineHeight": { "$ref": "#/$defs/nonNegativeNumber" }, + "fontWeight": { "type": "integer", "enum": [400, 500, 600, 700] } + } + }, + "shadow": { + "type": "object", + "required": ["x", "y", "blur", "spread", "color", "opacity"], + "additionalProperties": false, + "properties": { + "x": { "type": "number" }, + "y": { "type": "number" }, + "blur": { "$ref": "#/$defs/nonNegativeNumber" }, + "spread": { "type": "number" }, + "color": { "$ref": "#/$defs/hexColor" }, + "opacity": { "type": "number", "minimum": 0, "maximum": 1 } + } + } + }, + "properties": { + "$schema": { "type": "string" }, + "meta": { + "type": "object", + "required": ["name", "version", "sourceOfTruth"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1 }, + "version": { "type": "integer", "minimum": 1 }, + "sourceOfTruth": { "const": true } + } + }, + "primitive": { + "type": "object", + "required": ["color", "space", "radius"], + "additionalProperties": false, + "properties": { + "color": { + "allOf": [ + { "$ref": "#/$defs/tokenMap" }, + { "type": "object", "additionalProperties": { "$ref": "#/$defs/hexColor" } } + ] + }, + "space": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/nonNegativeNumber" } + }, + "radius": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/nonNegativeNumber" } + } + } + }, + "semantic": { + "type": "object", + "required": ["color"], + "additionalProperties": false, + "properties": { + "color": { + "type": "object", + "required": ["light", "dark"], + "additionalProperties": false, + "properties": { + "light": { "$ref": "#/$defs/semanticColors" }, + "dark": { "$ref": "#/$defs/semanticColors" } + } + } + } + }, + "typography": { + "type": "object", + "required": ["fontFamily", "style"], + "additionalProperties": false, + "properties": { + "fontFamily": { + "type": "object", + "required": ["sans", "mono"], + "additionalProperties": false, + "properties": { "sans": { "type": "string" }, "mono": { "type": "string" } } + }, + "style": { "type": "object", "additionalProperties": { "$ref": "#/$defs/typographyStyle" } } + } + }, + "effect": { + "type": "object", + "required": ["shadow"], + "additionalProperties": false, + "properties": { + "shadow": { "type": "object", "additionalProperties": { "$ref": "#/$defs/shadow" } } + } + }, + "motion": { + "type": "object", + "required": ["duration", "easing"], + "additionalProperties": false, + "properties": { + "duration": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/nonNegativeNumber" } + }, + "easing": { "type": "object", "additionalProperties": { "type": "string", "minLength": 1 } } + } + }, + "component": { + "type": "object", + "required": ["focusRing", "control", "dialog", "callout"], + "additionalProperties": false, + "properties": { + "focusRing": { + "type": "object", + "required": ["width", "offset"], + "additionalProperties": false, + "properties": { + "width": { "$ref": "#/$defs/nonNegativeNumber" }, + "offset": { "$ref": "#/$defs/nonNegativeNumber" } + } + }, + "control": { + "type": "object", + "required": ["radius"], + "additionalProperties": false, + "properties": { "radius": { "$ref": "#/$defs/reference" } } + }, + "dialog": { + "type": "object", + "required": ["radius"], + "additionalProperties": false, + "properties": { "radius": { "$ref": "#/$defs/reference" } } + }, + "callout": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["surface", "accent"], + "additionalProperties": false, + "properties": { + "surface": { "$ref": "#/$defs/colorValue" }, + "accent": { "$ref": "#/$defs/colorValue" } + } + } + } + } + } + } +} diff --git a/figma/README.md b/figma/README.md index 9ea7fd4b..13adbc3d 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..fa45b6fc 100644 --- a/figma/main.js +++ b/figma/main.js @@ -1,48 +1,222 @@ 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": "#4b5563", + "text-subtle": "#6b7280", + "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": "#9ca3af", + "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 + }, + "primitiveRadius": { + "none": 0, + "sm": 4, + "md": 6, + "lg": 8, + "full": 9999 + }, + "colors": { + "textMain": "#333333", + "textSub": "#4b5563", + "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 + } + }, + "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: 999, + "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 + } }, - 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; @@ -94,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; @@ -125,8 +307,8 @@ function textNode(text, style, color) { } const node = figma.createText(); + node.fontName = { family: tokens.fontFamily, style: fontStyle(style.fontWeight) }; node.characters = text; - node.fontName = { family: 'Inter', style: style.fontWeight >= 700 ? 'Bold' : 'Regular' }; node.fontSize = style.fontSize; node.lineHeight = { unit: 'PIXELS', value: style.lineHeight }; node.fills = [paint(color)]; @@ -134,8 +316,15 @@ 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); + const label = textNode(name + '\n' + value, tokens.typography.caption); label.resize(260, 48); return label; } @@ -255,14 +444,17 @@ 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' ? await figma.getLocalTextStylesAsync() : []; + const expectedNames = new Set(); for (const name in tokens.typography) { if (!hasOwn(tokens.typography, name)) { continue; @@ -270,13 +462,15 @@ 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; - 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 }; } + removeStaleStyles(styles, expectedNames, 'Typography/'); } async function createPaintStyles() { @@ -285,16 +479,47 @@ async function createPaintStyles() { ? await figma.getLocalPaintStylesAsync() : []; - for (const name in tokens.colors) { - if (!hasOwn(tokens.colors, name)) { - continue; + 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; + 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; + expectedNames.add(styleName); + const existing = localStyle(styles, styleName); + const style = existing || figma.createPaintStyle(); + style.name = styleName; + style.paints = [paint(tokens.semanticColors[mode][name])]; } + } - const styleName = 'Color/' + name; + 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.colors[name])]; + 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(); } } @@ -304,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) { @@ -330,6 +558,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 +618,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 +642,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 +673,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; @@ -414,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/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..1c80eef2 --- /dev/null +++ b/scripts/generate-design-tokens.mjs @@ -0,0 +1,418 @@ +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 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); + assertHexColor(resolved, `semantic.color.${mode}.${name}`); + } + } + + 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'); + + 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) { + 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-primitive-color-${name}`, + value, + ]), + ); + const primitiveSpace = Object.fromEntries( + 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-primitive-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]), + ), + ); + 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 { +${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-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; + --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-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-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-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); +} + +.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); } +`; +} + +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..17477b3a --- /dev/null +++ b/src/styles/design-tokens.generated.css @@ -0,0 +1,186 @@ +/* Generated by scripts/generate-design-tokens.mjs. Do not edit. */ +:root { + --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: #4b5563; + --ds-color-text-subtle: #6b7280; + --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-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; + --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-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; + --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-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-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-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); +} + +.DarkTheme { + --ds-color-text-primary: #ffffff; + --ds-color-text-secondary: #d1d5db; + --ds-color-text-subtle: #9ca3af; + --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; +} + +/* 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 32980782..f3b047fd 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', @@ -20,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 ebe08997..155d6750 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, @@ -21,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 { @@ -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; }