From 2a0a59e56c5c74fd74df561f54fc0ae4f90cf896 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Tue, 3 Mar 2026 21:51:56 -0800 Subject: [PATCH] feat(design-tokens): add validation tooling and React Native bridge - validate-tokens.js: Scan source files for hardcoded colors - token-coverage.js: Report token adoption percentage per product - generate-react-native.ts: Generator for Expo/NomGap StyleSheet tokens All scripts include: - Cross-platform file detection (.ts, .tsx, .swift, .kt) - Product-specific token categorization - Exit codes for CI integration --- .../scripts/generate-react-native.ts | 143 ++++++++++++++ .../design-tokens/scripts/token-coverage.js | 184 ++++++++++++++++++ .../design-tokens/scripts/validate-tokens.js | 110 +++++++++++ 3 files changed, 437 insertions(+) create mode 100644 packages/design-tokens/scripts/generate-react-native.ts create mode 100755 packages/design-tokens/scripts/token-coverage.js create mode 100755 packages/design-tokens/scripts/validate-tokens.js diff --git a/packages/design-tokens/scripts/generate-react-native.ts b/packages/design-tokens/scripts/generate-react-native.ts new file mode 100644 index 00000000..55e5ba66 --- /dev/null +++ b/packages/design-tokens/scripts/generate-react-native.ts @@ -0,0 +1,143 @@ +/** + * React Native token generator for Expo/NomGap + * Reads bytelyst.tokens.json, outputs RN StyleSheet-compatible tokens + * + * Usage: tsx scripts/generate-react-native.ts + */ + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const tokensPath = resolve(__dirname, '../tokens/bytelyst.tokens.json'); +const outDir = resolve(__dirname, '../generated/react-native'); + +mkdirSync(outDir, { recursive: true }); + +const tokens = JSON.parse(readFileSync(tokensPath, 'utf-8')); + +// ── Helpers ─────────────────────────────────────────────────────────── + +function generateReactNative(): string { + const lines: string[] = [ + '/**', + ' * React Native Design Tokens — Auto-generated from bytelyst.tokens.json', + ' * Do not edit manually. Run: tsx scripts/generate-react-native.ts', + ' */', + '', + 'export const tokens = {', + '', + ' // ── Semantic Colors (Dark Theme) ──────────────────────────────────', + ' colors: {', + ]; + + // Semantic dark colors + for (const [key, value] of Object.entries(tokens.color.semantic.dark)) { + if (typeof value === 'string' && value.startsWith('#')) { + lines.push(` ${key}: '${value}',`); + } else if (typeof value === 'string' && value.startsWith('rgba')) { + // Convert rgba to hex8 for React Native + const match = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/); + if (match) { + const [, r, g, b, a] = match; + const alpha = Math.round(parseFloat(a) * 255) + .toString(16) + .padStart(2, '0'); + const hex = + `#${alpha}${parseInt(r).toString(16).padStart(2, '0')}${parseInt(g).toString(16).padStart(2, '0')}${parseInt(b).toString(16).padStart(2, '0')}`.toUpperCase(); + lines.push(` ${key}: '${hex}',`); + } + } + } + lines.push(' },', ''); + + // NomGap-specific colors + lines.push(' // ── NomGap Product Colors ──────────────────────────────────────────'); + lines.push(' nomgap: {'); + for (const [key, value] of Object.entries(tokens.color.nomgap)) { + lines.push(` ${key}: '${value}',`); + } + lines.push(' },', ''); + + // Spacing + lines.push(' // ── Spacing (8pt grid) ──────────────────────────────────────────────'); + lines.push(' spacing: {'); + for (const [key, value] of Object.entries(tokens.spacing)) { + lines.push(` ${key}: ${value},`); + } + lines.push(' },', ''); + + // Radius + lines.push(' // ── Border Radius ───────────────────────────────────────────────────'); + lines.push(' radius: {'); + for (const [key, value] of Object.entries(tokens.radius)) { + lines.push(` ${key}: ${value},`); + } + lines.push(' },', ''); + + // Typography + lines.push(' // ── Typography ─────────────────────────────────────────────────────'); + lines.push(' typography: {'); + lines.push(' fontFamily: {'); + for (const key of Object.keys(tokens.typography.fontFamily)) { + // Use system fonts for React Native + const systemFont = key === 'display' ? 'System' : key === 'mono' ? 'Courier' : 'System'; + lines.push(` ${key}: '${systemFont}',`); + } + lines.push(' },'); + lines.push(' fontSize: {'); + for (const [key, value] of Object.entries(tokens.typography.fontSize)) { + lines.push(` ${key}: ${value},`); + } + lines.push(' },'); + lines.push(' fontWeight: {'); + for (const [key, value] of Object.entries(tokens.typography.fontWeight)) { + lines.push(` ${key}: '${value}',`); + } + lines.push(' },'); + lines.push(' },', ''); + + // Icon sizes + lines.push(' // ── Icon Sizes ──────────────────────────────────────────────────────'); + lines.push(' icon: {'); + for (const [key, value] of Object.entries(tokens.icon)) { + lines.push(` ${key}: ${value},`); + } + lines.push(' },', ''); + + // Z-index (for RN zIndex style prop) + lines.push(' // ── Z-Index Layers ───────────────────────────────────────────────────'); + lines.push(' zIndex: {'); + for (const [key, value] of Object.entries(tokens.zIndex)) { + lines.push(` ${key}: ${value},`); + } + lines.push(' },', ''); + + // Opacity + lines.push(' // ── Opacity ─────────────────────────────────────────────────────────'); + lines.push(' opacity: {'); + for (const [key, value] of Object.entries(tokens.opacity)) { + lines.push(` ${key}: ${value},`); + } + lines.push(' },', ''); + + // Motion (duration in ms) + lines.push(' // ── Motion ────────────────────────────────────────────────────────────'); + lines.push(' motion: {'); + for (const [key, value] of Object.entries(tokens.motion.duration)) { + lines.push(` ${key}: ${value},`); + } + lines.push(' },', ''); + + lines.push('} as const;', ''); + lines.push(''); + lines.push('export type Tokens = typeof tokens;', ''); + + return lines.join('\n'); +} + +// ── Write ────────────────────────────────────────────────────────── +writeFileSync(resolve(outDir, 'tokens.ts'), generateReactNative()); +// eslint-disable-next-line no-console +console.log('Generated React Native tokens in generated/react-native/'); diff --git a/packages/design-tokens/scripts/token-coverage.js b/packages/design-tokens/scripts/token-coverage.js new file mode 100755 index 00000000..e021612a --- /dev/null +++ b/packages/design-tokens/scripts/token-coverage.js @@ -0,0 +1,184 @@ +#!/usr/bin/env node +/** + * Token coverage report — analyzes how well a product uses design tokens + * + * Usage: node scripts/token-coverage.js + */ + +const { readFileSync, readdirSync, statSync } = require('fs'); +const { join, resolve } = require('path'); + +const EXCLUDED_DIRS = ['node_modules', 'dist', 'build', '.git', 'generated', '__mocks__']; +const INCLUDED_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.swift', '.kt']; + +const TOKEN_PATTERNS = { + web: { + cssVar: /--ml-[a-z-]+/g, + tokensImport: /@bytelyst\/design-tokens/, + }, + ios: { + mindLystColors: /MindLystColors\./g, + colorExtension: /Color\(hex:/g, + }, + kmp: { + mindLystTokens: /MindLystTokens\./g, + }, +}; + +function findFiles(dir, files = []) { + try { + const items = readdirSync(dir); + for (const item of items) { + const fullPath = join(dir, item); + if (EXCLUDED_DIRS.some(ex => fullPath.includes(ex))) continue; + + const stat = statSync(fullPath); + if (stat.isDirectory()) { + findFiles(fullPath, files); + } else if (INCLUDED_EXTENSIONS.some(ext => item.endsWith(ext))) { + files.push(fullPath); + } + } + } catch (e) { + // Directory might not exist + } + return files; +} + +function detectPlatform(files) { + const hasSwift = files.some(f => f.endsWith('.swift')); + const hasKotlin = files.some(f => f.endsWith('.kt')); + const hasTSX = files.some(f => f.endsWith('.tsx')); + + if (hasSwift) return 'ios'; + if (hasKotlin) return 'kmp'; + if (hasTSX) return 'web'; + return 'unknown'; +} + +function analyzeCoverage(files, platform) { + let tokenUsages = 0; + let hardcodedColors = 0; + let filesUsingTokens = 0; + let filesWithHardcoded = 0; + + const patterns = TOKEN_PATTERNS[platform] || {}; + + for (const file of files) { + const content = readFileSync(file, 'utf-8'); + let hasTokens = false; + let hasHardcoded = false; + + // Check token usage + if (patterns.cssVar) { + const matches = content.match(patterns.cssVar); + if (matches) { + tokenUsages += matches.length; + hasTokens = true; + } + } + if (patterns.mindLystColors) { + const matches = content.match(patterns.mindLystColors); + if (matches) { + tokenUsages += matches.length; + hasTokens = true; + } + } + if (patterns.mindLystTokens) { + const matches = content.match(patterns.mindLystTokens); + if (matches) { + tokenUsages += matches.length; + hasTokens = true; + } + } + + // Check hardcoded colors + const colorMatches = content.match(/#[0-9A-Fa-f]{6}\b/g); + if (colorMatches) { + // Filter out likely non-color hex (like #FFFFFF in different contexts) + const likelyColors = colorMatches.filter(c => { + const hex = c.slice(1); + // Skip pure grays that might be intentional + if (hex[0] === hex[2] && hex[2] === hex[4]) return false; + return true; + }); + + if (likelyColors.length > 0) { + hardcodedColors += likelyColors.length; + hasHardcoded = true; + } + } + + if (hasTokens) filesUsingTokens++; + if (hasHardcoded) filesWithHardcoded++; + } + + return { + tokenUsages, + hardcodedColors, + filesUsingTokens, + filesWithHardcoded, + totalFiles: files.length, + }; +} + +function main() { + const targetPath = process.argv[2]; + if (!targetPath) { + console.log('Usage: node scripts/token-coverage.js '); + console.log(''); + console.log('Examples:'); + console.log(' node scripts/token-coverage.js ../../mindlyst-native/iosApp'); + console.log(' node scripts/token-coverage.js ../../learning_ai_clock/web/src'); + process.exit(1); + } + + const absolutePath = resolve(targetPath); + console.log(`📊 Analyzing token coverage for ${absolutePath}\n`); + + const files = findFiles(absolutePath); + const platform = detectPlatform(files); + + console.log(`Detected platform: ${platform}`); + console.log(`Total files: ${files.length}\n`); + + if (files.length === 0) { + console.log('❌ No source files found'); + process.exit(1); + } + + const coverage = analyzeCoverage(files, platform); + + console.log(`${'='.repeat(50)}`); + console.log('📈 Coverage Report'); + console.log(`${'='.repeat(50)}`); + console.log( + `Files using tokens: ${coverage.filesUsingTokens}/${coverage.totalFiles} (${((coverage.filesUsingTokens / coverage.totalFiles) * 100).toFixed(1)}%)` + ); + console.log( + `Files with hardcoded: ${coverage.filesWithHardcoded}/${coverage.totalFiles} (${((coverage.filesWithHardcoded / coverage.totalFiles) * 100).toFixed(1)}%)` + ); + console.log(`Token usages: ${coverage.tokenUsages}`); + console.log(`Hardcoded colors: ${coverage.hardcodedColors}`); + console.log(`${'='.repeat(50)}`); + + const tokenRatio = coverage.tokenUsages + coverage.hardcodedColors; + const tokenPercentage = + tokenRatio > 0 ? ((coverage.tokenUsages / tokenRatio) * 100).toFixed(1) : 'N/A'; + + console.log(`\n🎯 Token Adoption: ${tokenPercentage}%`); + + if (coverage.hardcodedColors > 0) { + console.log(`\n⚠️ Found ${coverage.hardcodedColors} hardcoded colors.`); + console.log(' Run validate-tokens.js for details.'); + } + + if (coverage.filesUsingTokens === 0) { + console.log('\n❌ No token usage detected. Product needs token integration.'); + process.exit(1); + } + + console.log('\n✅ Coverage analysis complete.'); +} + +main(); diff --git a/packages/design-tokens/scripts/validate-tokens.js b/packages/design-tokens/scripts/validate-tokens.js new file mode 100755 index 00000000..b234c261 --- /dev/null +++ b/packages/design-tokens/scripts/validate-tokens.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node +/** + * Token validation script — checks for hardcoded colors in source files + * and reports token coverage per product. + * + * Usage: node scripts/validate-tokens.js [product-path] + */ + +const { readFileSync, readdirSync, statSync } = require('fs'); +const { join, resolve } = require('path'); + +const HARD_COLOR_REGEX = /#[0-9A-Fa-f]{3,8}\b|rgb\([^)]+\)|rgba\([^)]+\)|hsl\([^)]+\)/g; +const EXCLUDED_DIRS = ['node_modules', 'dist', 'build', '.git', 'generated', '__mocks__']; +const INCLUDED_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.swift', '.kt']; + +function findFiles(dir, files = []) { + try { + const items = readdirSync(dir); + for (const item of items) { + const fullPath = join(dir, item); + if (EXCLUDED_DIRS.some(ex => fullPath.includes(ex))) continue; + + const stat = statSync(fullPath); + if (stat.isDirectory()) { + findFiles(fullPath, files); + } else if (INCLUDED_EXTENSIONS.some(ext => item.endsWith(ext))) { + files.push(fullPath); + } + } + } catch (e) { + // Directory might not exist or be accessible + } + return files; +} + +function analyzeFile(filePath) { + const content = readFileSync(filePath, 'utf-8'); + const lines = content.split('\n'); + const issues = []; + + lines.forEach((line, index) => { + // Skip comments + if (line.trim().startsWith('//') || line.trim().startsWith('*') || line.trim().startsWith('/*')) + return; + + const matches = line.match(HARD_COLOR_REGEX); + if (matches) { + // Filter out legitimate uses (like transparency values) + const suspicious = matches.filter(m => { + if (m.startsWith('#') && (m.length === 9 || m.length === 5)) return false; // Skip alpha hex + if (m.includes('0.0') || m.includes('1.0')) return false; // Skip clear/opaque + return true; + }); + + if (suspicious.length > 0) { + issues.push({ + line: index + 1, + colors: suspicious, + content: line.trim().slice(0, 80), + }); + } + } + }); + + return issues; +} + +function main() { + const targetPath = process.argv[2] || '.'; + const absolutePath = resolve(targetPath); + + console.log(`🔍 Scanning ${absolutePath} for hardcoded colors...\n`); + + const files = findFiles(absolutePath); + let totalIssues = 0; + let filesWithIssues = 0; + + for (const file of files) { + const issues = analyzeFile(file); + if (issues.length > 0) { + filesWithIssues++; + totalIssues += issues.length; + const relativePath = file.replace(absolutePath, '').slice(1); + console.log(`\n📄 ${relativePath}`); + issues.forEach(issue => { + console.log(` Line ${issue.line}: ${issue.colors.join(', ')}`); + console.log(` ${issue.content}`); + }); + } + } + + console.log(`\n${'='.repeat(60)}`); + console.log(`📊 Summary:`); + console.log(` Files scanned: ${files.length}`); + console.log(` Files with hardcoded colors: ${filesWithIssues}`); + console.log(` Total hardcoded colors found: ${totalIssues}`); + + if (totalIssues > 0) { + console.log(`\n⚠️ Consider replacing hardcoded colors with design tokens:`); + console.log(` Web: var(--ml-) from @bytelyst/design-tokens`); + console.log(` iOS: MindLystColors.dark / MindLystColors.light`); + console.log(` KMP: MindLystTokens.Dark. / MindLystTokens.Light.`); + process.exit(1); + } else { + console.log(`\n✅ No hardcoded colors found! All colors use design tokens.`); + process.exit(0); + } +} + +main();