refactor(design-tokens): improve token validator
- ignore hsl(var(--...)) / rgb(var(--...)) - export generated/tokens entry
This commit is contained in:
parent
fab88a57a4
commit
2f7e3ad9b6
@ -31,6 +31,7 @@
|
||||
"@bytelyst/config": "workspace:*",
|
||||
"@bytelyst/cosmos": "workspace:*",
|
||||
"@bytelyst/datastore": "workspace:*",
|
||||
"@bytelyst/design-tokens": "workspace:*",
|
||||
"@bytelyst/errors": "workspace:*",
|
||||
"@bytelyst/extraction": "workspace:*",
|
||||
"@bytelyst/logger": "workspace:*",
|
||||
|
||||
@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { getActiveTheme } from '@/lib/platform-client';
|
||||
import { getRequestProductId } from '@/lib/product-config';
|
||||
import tokensJson from '@bytelyst/design-tokens/tokens.json';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
@ -10,43 +11,34 @@ export async function GET(req: NextRequest) {
|
||||
} catch (error) {
|
||||
logError('Failed to get active theme', error);
|
||||
// Return default theme on error to prevent app breakage
|
||||
|
||||
const defaultThemeColors = {
|
||||
primary: tokensJson.color.semantic.dark.success,
|
||||
secondary: tokensJson.color.palette.neutral['700'],
|
||||
accent: tokensJson.color.semantic.dark.accentPrimary,
|
||||
background: tokensJson.color.palette.neutral['0'],
|
||||
surface: tokensJson.color.palette.neutral['50'],
|
||||
error: tokensJson.color.semantic.dark.danger,
|
||||
warning: tokensJson.color.semantic.dark.warning,
|
||||
success: tokensJson.color.semantic.dark.success,
|
||||
};
|
||||
|
||||
const defaultDesktopExtras = {
|
||||
idle: tokensJson.color.semantic.dark.success,
|
||||
listening: tokensJson.color.lysnrai.hotkeyActive,
|
||||
processing: tokensJson.color.semantic.dark.warning,
|
||||
offline: tokensJson.color.semantic.dark.textSecondary,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
id: 'default',
|
||||
name: 'Default Green',
|
||||
description: 'Default green theme',
|
||||
ios: {
|
||||
primary: '#4caf50',
|
||||
secondary: '#2e7d32',
|
||||
accent: '#66bb6a',
|
||||
background: '#ffffff',
|
||||
surface: '#f5f5f5',
|
||||
error: '#f44336',
|
||||
warning: '#ff9800',
|
||||
success: '#4caf50',
|
||||
},
|
||||
android: {
|
||||
primary: '#4caf50',
|
||||
secondary: '#2e7d32',
|
||||
accent: '#66bb6a',
|
||||
background: '#ffffff',
|
||||
surface: '#f5f5f5',
|
||||
error: '#f44336',
|
||||
warning: '#ff9800',
|
||||
success: '#4caf50',
|
||||
},
|
||||
ios: defaultThemeColors,
|
||||
android: defaultThemeColors,
|
||||
desktop: {
|
||||
primary: '#4caf50',
|
||||
secondary: '#2e7d32',
|
||||
accent: '#66bb6a',
|
||||
background: '#ffffff',
|
||||
surface: '#f5f5f5',
|
||||
error: '#f44336',
|
||||
warning: '#ff9800',
|
||||
success: '#4caf50',
|
||||
idle: '#4caf50',
|
||||
listening: '#e94560',
|
||||
processing: '#f5a623',
|
||||
offline: '#9e9e9e',
|
||||
...defaultThemeColors,
|
||||
...defaultDesktopExtras,
|
||||
},
|
||||
is_active: true,
|
||||
is_default: true,
|
||||
|
||||
@ -40,7 +40,7 @@ export default function HealthPage() {
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ fontFamily: 'system-ui', padding: 40 }}>
|
||||
<h1 style={{ color: '#dc2626' }}>Health Check Failed</h1>
|
||||
<h1 style={{ color: 'hsl(var(--destructive))' }}>Health Check Failed</h1>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
);
|
||||
@ -53,12 +53,12 @@ export default function HealthPage() {
|
||||
<h1 style={{ marginBottom: 4 }}>
|
||||
{overall ? '✅' : '⚠️'} {data?.service}
|
||||
</h1>
|
||||
<p style={{ color: '#6b7280', marginTop: 0 }}>{data?.timestamp}</p>
|
||||
<p style={{ color: 'hsl(var(--muted-foreground))', marginTop: 0 }}>{data?.timestamp}</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: overall ? '#16a34a' : '#dc2626',
|
||||
color: overall ? 'hsl(var(--chart-2))' : 'hsl(var(--destructive))',
|
||||
}}
|
||||
>
|
||||
Status: {data?.status}
|
||||
@ -71,7 +71,7 @@ export default function HealthPage() {
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #e5e7eb' }}>
|
||||
<tr style={{ borderBottom: '2px solid hsl(var(--border))' }}>
|
||||
<th style={{ textAlign: 'left', padding: 8 }}>Check</th>
|
||||
<th style={{ textAlign: 'left', padding: 8 }}>Status</th>
|
||||
<th style={{ textAlign: 'left', padding: 8 }}>Details</th>
|
||||
@ -79,10 +79,10 @@ export default function HealthPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.checks.map(check => (
|
||||
<tr key={check.name} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
||||
<tr key={check.name} style={{ borderBottom: '1px solid hsl(var(--border))' }}>
|
||||
<td style={{ padding: 8, fontWeight: 500 }}>{check.name}</td>
|
||||
<td style={{ padding: 8 }}>{check.status === 'pass' ? '✅ pass' : '❌ fail'}</td>
|
||||
<td style={{ padding: 8, color: '#6b7280', fontSize: 14 }}>
|
||||
<td style={{ padding: 8, color: 'hsl(var(--muted-foreground))', fontSize: 14 }}>
|
||||
{check.message}
|
||||
{check.latencyMs != null && ` (${check.latencyMs}ms)`}
|
||||
</td>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Theme, PlatformTheme } from '@/types/theme';
|
||||
import tokensJson from '@bytelyst/design-tokens/tokens.json';
|
||||
|
||||
function ColorInput({
|
||||
label,
|
||||
@ -26,7 +27,7 @@ function ColorInput({
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="flex-1 px-2 py-1 border rounded text-sm font-mono"
|
||||
placeholder="#000000"
|
||||
placeholder="#00000000"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -65,22 +66,22 @@ function PlatformSection({
|
||||
<>
|
||||
<ColorInput
|
||||
label="Idle"
|
||||
value={colors.idle || '#4caf50'}
|
||||
value={colors.idle || tokensJson.color.semantic.dark.success}
|
||||
onChange={v => onChange('idle', v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Listening"
|
||||
value={colors.listening || '#e94560'}
|
||||
value={colors.listening || tokensJson.color.lysnrai.hotkeyActive}
|
||||
onChange={v => onChange('listening', v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Processing"
|
||||
value={colors.processing || '#f5a623'}
|
||||
value={colors.processing || tokensJson.color.semantic.dark.warning}
|
||||
onChange={v => onChange('processing', v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Offline"
|
||||
value={colors.offline || '#9e9e9e'}
|
||||
value={colors.offline || tokensJson.color.semantic.dark.textSecondary}
|
||||
onChange={v => onChange('offline', v)}
|
||||
/>
|
||||
</>
|
||||
@ -101,24 +102,24 @@ export default function ThemeEditor({ theme, onSave, onCancel }: ThemeEditorProp
|
||||
const [description, setDescription] = useState(theme?.description || '');
|
||||
const [iosColors, setIosColors] = useState<PlatformTheme>(
|
||||
theme?.ios || {
|
||||
primary: '#4caf50',
|
||||
secondary: '#2e7d32',
|
||||
accent: '#66bb6a',
|
||||
background: '#ffffff',
|
||||
surface: '#f5f5f5',
|
||||
error: '#f44336',
|
||||
warning: '#ff9800',
|
||||
success: '#4caf50',
|
||||
primary: tokensJson.color.semantic.dark.success,
|
||||
secondary: tokensJson.color.palette.neutral['700'],
|
||||
accent: tokensJson.color.semantic.dark.accentPrimary,
|
||||
background: tokensJson.color.palette.neutral['0'],
|
||||
surface: tokensJson.color.palette.neutral['50'],
|
||||
error: tokensJson.color.semantic.dark.danger,
|
||||
warning: tokensJson.color.semantic.dark.warning,
|
||||
success: tokensJson.color.semantic.dark.success,
|
||||
}
|
||||
);
|
||||
const [androidColors, setAndroidColors] = useState<PlatformTheme>(theme?.android || iosColors);
|
||||
const [desktopColors, setDesktopColors] = useState<PlatformTheme>(
|
||||
theme?.desktop || {
|
||||
...iosColors,
|
||||
idle: '#4caf50',
|
||||
listening: '#e94560',
|
||||
processing: '#f5a623',
|
||||
offline: '#9e9e9e',
|
||||
idle: tokensJson.color.semantic.dark.success,
|
||||
listening: tokensJson.color.lysnrai.hotkeyActive,
|
||||
processing: tokensJson.color.semantic.dark.warning,
|
||||
offline: tokensJson.color.semantic.dark.textSecondary,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@ -27,8 +27,11 @@ interface EntityChartProps {
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#5A8CFF', '#2EE6D6', '#34D399', '#F59E0B', '#FF6E6E',
|
||||
'#A78BFA', '#F472B6', '#38BDF8', '#FB923C', '#4ADE80',
|
||||
'hsl(var(--chart-1))',
|
||||
'hsl(var(--chart-2))',
|
||||
'hsl(var(--chart-3))',
|
||||
'hsl(var(--chart-4))',
|
||||
'hsl(var(--chart-5))',
|
||||
];
|
||||
|
||||
export function EntityBarChart({ extractions, title = 'Entities by Class' }: EntityChartProps) {
|
||||
@ -37,7 +40,7 @@ export function EntityBarChart({ extractions, title = 'Entities by Class' }: Ent
|
||||
acc[e.extraction_class] = (acc[e.extraction_class] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
const data = Object.entries(classCounts)
|
||||
@ -54,18 +57,24 @@ export function EntityBarChart({ extractions, title = 'Entities by Class' }: Ent
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={data} layout="vertical" margin={{ left: 20, right: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1A2335" />
|
||||
<XAxis type="number" stroke="#6C7C98" fontSize={12} />
|
||||
<YAxis type="category" dataKey="name" stroke="#6C7C98" fontSize={11} width={120} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||
<XAxis type="number" stroke="hsl(var(--muted-foreground))" fontSize={12} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={11}
|
||||
width={120}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#121725',
|
||||
border: '1px solid #1A2335',
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="count" fill="#5A8CFF" radius={[0, 4, 4, 0]} />
|
||||
<Bar dataKey="count" fill="hsl(var(--chart-1))" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
@ -79,7 +88,7 @@ export function EntityPieChart({ extractions, title = 'Class Distribution' }: En
|
||||
acc[e.extraction_class] = (acc[e.extraction_class] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
const data = Object.entries(classCounts)
|
||||
@ -104,9 +113,7 @@ export function EntityPieChart({ extractions, title = 'Class Distribution' }: En
|
||||
outerRadius={90}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
label={({ name, percent }) =>
|
||||
`${name} (${((percent ?? 0) * 100).toFixed(0)}%)`
|
||||
}
|
||||
label={({ name, percent }) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}
|
||||
labelLine={false}
|
||||
fontSize={11}
|
||||
>
|
||||
@ -116,15 +123,13 @@ export function EntityPieChart({ extractions, title = 'Class Distribution' }: En
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#121725',
|
||||
border: '1px solid #1A2335',
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11, color: '#A5B1C7' }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 11, color: 'hsl(var(--muted-foreground))' }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
@ -147,9 +152,20 @@ export function EntityTimeline({ extractions }: { extractions: ExtractionEntity[
|
||||
<div key={i} className="relative flex items-start gap-3">
|
||||
<div
|
||||
className="absolute -left-4 mt-1.5 h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: COLORS[Object.keys(
|
||||
extractions.reduce((acc, ex) => { acc[ex.extraction_class] = true; return acc; }, {} as Record<string, boolean>)
|
||||
).indexOf(e.extraction_class) % COLORS.length] }}
|
||||
style={{
|
||||
backgroundColor:
|
||||
COLORS[
|
||||
Object.keys(
|
||||
extractions.reduce(
|
||||
(acc, ex) => {
|
||||
acc[ex.extraction_class] = true;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, boolean>
|
||||
)
|
||||
).indexOf(e.extraction_class) % COLORS.length
|
||||
],
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
|
||||
6
dashboards/admin-web/src/types/design-tokens-json.d.ts
vendored
Normal file
6
dashboards/admin-web/src/types/design-tokens-json.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
declare module '@bytelyst/design-tokens/tokens.json' {
|
||||
import type { DesignTokens } from '@bytelyst/design-tokens';
|
||||
|
||||
const value: DesignTokens;
|
||||
export default value;
|
||||
}
|
||||
@ -8,6 +8,7 @@
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./tokens.json": "./tokens/bytelyst.tokens.json",
|
||||
"./generated/tokens": "./generated/tokens.ts",
|
||||
"./css": "./generated/tokens.css"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
|
||||
113
packages/design-tokens/scripts/validate-tokens.cjs
Executable file
113
packages/design-tokens/scripts/validate-tokens.cjs
Executable file
@ -0,0 +1,113 @@
|
||||
#!/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
|
||||
// Skip CSS-variable token usage (already tokenized)
|
||||
if ((m.startsWith('hsl(') || m.startsWith('rgb(') || m.startsWith('rgba(')) && m.includes('var(--'))
|
||||
return false;
|
||||
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-<token>) from @bytelyst/design-tokens`);
|
||||
console.log(` iOS: MindLystColors.dark<Token> / MindLystColors.light<Token>`);
|
||||
console.log(` KMP: MindLystTokens.Dark.<TOKEN> / MindLystTokens.Light.<TOKEN>`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`\n✅ No hardcoded colors found! All colors use design tokens.`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Loading…
Reference in New Issue
Block a user