Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6baae8a
подключить шрифт Noto Sans
Aug 21, 2026
49ae833
починить конфиг демо-приложения
Aug 21, 2026
87db47e
добавить проверку dt-ссылок на токены
Aug 21, 2026
c6b5839
перейти на новый файл токенов
Aug 21, 2026
1ce6380
перевести стили на прямые пути токенов
Aug 21, 2026
102cb4f
убрать переопределение базового font-size
Aug 21, 2026
23865d4
переписать генератор tailwind-темы под новую схему токенов
Aug 21, 2026
01e408e
исправить подстановку литералов в шаблонных строках стилей
Aug 21, 2026
c9a9ad0
добавить гейты качества стилей компонента
Aug 21, 2026
ce284d6
переписать стили card на токены без слоя синонимов
Aug 21, 2026
e251e42
Merge pull request #98 from cdek-it/size.card
ichiesov Aug 21, 2026
ebc2f9f
восстановить размерную лестницу select-button по токенам togglebutton
Aug 21, 2026
8b06bc6
сравнивать токены по имени CSS-переменной, а не по пути
Aug 21, 2026
1222324
выровнять высоты togglebutton по единой размерной лестнице
Aug 21, 2026
e6c5522
применить цвет подписи message по типам
Aug 21, 2026
9a9f426
Merge pull request #101 from cdek-it/size.togglebutton
ichiesov Aug 21, 2026
baf3346
Merge pull request #100 from cdek-it/size.select-button
ichiesov Aug 21, 2026
c71aa74
Merge pull request #102 from cdek-it/size.message
ichiesov Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
"build:storybook": "ng run angular-ui-kit:build-storybook",
"build:lib": "ng build angular-ui-kit-lib && npm run generate:tailwind && node tailwind/copy-to-dist.mjs",
"generate:tailwind": "node tailwind/generate.mjs",
"check:tokens": "node scripts/check-dt-refs.mjs",
"check:component": "node scripts/check-component.mjs",
"security:check": "npm audit --production --audit-level high",
"copy:dist": "cpx 'src/lib/providers/prime-preset/**/*.{json,ts}' dist/ && cpx 'src-tokens/theme.preset.ts' dist/",
"validate:figma-cc": "node scripts/figma-code-connect/validate.mjs"
Expand Down
186 changes: 186 additions & 0 deletions scripts/check-component.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/**
* Гейты качества для стилей одного компонента.
*
* Запуск: `npm run check:component -- button` (или несколько имён через пробел,
* без аргументов — все компоненты).
*
* Имя компонента = имя файла в src/lib/providers/prime-preset/tokens/components без `.ts`.
*
* Гейты делятся на жёсткие (FAIL, ненулевой код возврата) и мягкие (WARN, требуют осознанного
* решения человека). Полное описание причин — .claude/skills/migrate-component-tokens/references/gates.md
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { dirname, resolve, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
const PRESET = resolve(ROOT, 'src/lib/providers/prime-preset');
const CSS_DIR = resolve(PRESET, 'tokens/components');
const tokens = JSON.parse(readFileSync(resolve(PRESET, 'tokens/tokens.json'), 'utf8'));
const Aura = require('@primeuix/themes/aura').default ?? require('@primeuix/themes/aura');

// Корневые ветки токенов, общие для всех компонентов — на них ссылаться можно всегда.
const SHARED_ROOTS = new Set(['fonts', 'colors', 'sizing', 'dimension', 'effects', 'color']);

// CSS-значения, которые не являются размерами и потому не обязаны быть токенами.
const VALUE_ALLOWLIST =
/^(0|auto|none|inherit|initial|unset|transparent|currentColor|min-content|max-content|fit-content|100%|50%|-50%)$/;

const get = (obj, segments) =>
segments.reduce((acc, key) => (acc && typeof acc === 'object' && key in acc ? acc[key] : undefined), obj);

const resolvesInTokens = (path) => {
const segments = path.split('.');
for (const scope of [tokens.components, tokens.semantic, tokens.primitive, tokens.semantic.colorScheme?.light]) {
if (get(scope, segments) !== undefined) return true;
}
const scheme = get(tokens.components?.[segments[0]], ['colorScheme', 'light']);
return get(scheme, segments.slice(1)) !== undefined;
};

/**
* Имя CSS-переменной, которую PrimeUIX выпустит для пути токена: служебные сегменты `root`
* и `extend` отбрасываются, camelCase разбивается дефисами.
*/
const cssVarName = (path) =>
'--p-' +
path
.split('.')
.filter((s, i, all) => s !== 'root' && s !== 'extend' && !(s === 'colorScheme') && !(all[i - 1] === 'colorScheme'))
.join('-')
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.toLowerCase();

/**
* Тема, в которой живёт токен. Варианты light и dark выпускаются в разные блоки (`:root` и `.dark`)
* и потому законно делят одно имя переменной — коллизией это не является.
*/
const themeScope = (path) => {
const m = path.match(/(?:^|\.)colorScheme\.(\w+)(?:\.|$)/);
return m ? m[1] : 'base';
};

/** Все листовые пути внутри объекта токенов, с префиксом. */
function leafPaths(node, prefix = []) {
if (!node || typeof node !== 'object' || Array.isArray(node)) return [prefix.join('.')];
return Object.entries(node).flatMap(([key, value]) => leafPaths(value, [...prefix, key]));
}

/** Убрать из исходника комментарии и селекторы — остаются только значения CSS-свойств. */
function declarationValues(source) {
return [...source.matchAll(/^[^:{}\n]*:\s*([^;\n]+);/gm)]
.map((m) => m[1])
.filter((v) => !v.includes('--p-') || v.includes('${'));
}

function checkComponent(name) {
const file = resolve(CSS_DIR, `${name}.ts`);
if (!existsSync(file)) return [{ level: 'FAIL', gate: 'файл', text: `${name}.ts не найден` }];

const source = readFileSync(file, 'utf8');
const issues = [];
const fail = (gate, text) => issues.push({ level: 'FAIL', gate, text });
const warn = (gate, text) => issues.push({ level: 'WARN', gate, text });

const refs = [...source.matchAll(/dt\(\s*'([^']+)'\s*\)/g)].map((m) => m[1]);

// G1. Каждая dt()-ссылка резолвится в tokens.json.
for (const ref of [...new Set(refs)]) {
if (!resolvesInTokens(ref)) fail('G1 мёртвая ссылка', ref);
}

// G2. Значения CSS-свойств — только токены. Сырые размеры и цвета запрещены.
for (const value of declarationValues(source)) {
const cleaned = value.replace(/\$\{[^}]*\}/g, '·').trim();
if (/#[0-9a-fA-F]{3,8}\b|\b(rgba?|hsla?|color-mix)\s*\(/.test(cleaned)) {
fail('G2 сырой цвет', value.trim());
continue;
}
for (const literal of cleaned.matchAll(/(?<![\w.·-])(\d*\.?\d+)(px|rem|em|ch|vh|vw|%)/g)) {
if (!VALUE_ALLOWLIST.test(literal[0])) fail('G2 сырой размер', `${literal[0]} в "${value.trim()}"`);
}
}

// G3. Обращение к теме только через dt() — сырое var(--p-*) не проверяется ничем.
for (const raw of new Set([...source.matchAll(/var\((--p-[a-z0-9-]+)/g)].map((m) => m[1]))) {
fail('G3 сырая var', raw);
}

// G4. Ровно один экспорт: <name>Css. Мёртвые дубли не тащим.
if (/export\s+default/.test(source)) fail('G4 экспорт', 'есть export default — дубль/мёртвый код');
const exports = [...source.matchAll(/export\s+const\s+(\w+)/g)].map((m) => m[1]);
if (exports.length !== 1) fail('G4 экспорт', `экспортов ${exports.length}, ожидался один: ${exports.join(', ')}`);

// G5. Слоя синонимов быть не должно: PrimeUIX уже выпускает переменную для каждого токена.
if (/:root\s*\{/.test(source)) fail('G5 слой синонимов', 'блок :root — переменные уже выпускает тема');

// G6. Ссылки на чужой компонент — скрытая связность, требует осознанного решения.
const foreign = new Set();
for (const ref of refs) {
const head = ref.split('.')[0];
if (head !== name.replace(/-/g, '') && !SHARED_ROOTS.has(head) && head in tokens.components) foreign.add(ref);
}
for (const ref of foreign) warn('G6 чужой компонент', ref);

// G7. Коллизии: два пути токена схлопываются в одну CSS-переменную.
const own = tokens.components[name.replace(/-/g, '')];
if (own) {
const byVar = new Map();
for (const path of leafPaths(own)) {
const key = `${themeScope(path)} ${cssVarName(`${name.replace(/-/g, '')}.${path}`)}`;
if (!byVar.has(key)) byVar.set(key, []);
byVar.get(key).push(path);
}
for (const [key, paths] of byVar) {
if (paths.length > 1) fail('G7 коллизия имён', `${key.split(' ')[1]} ← ${paths.join(' и ')}`);
}
}

// G8. Собственные токены компонента (те, которых нет у Aura) обязаны использоваться в CSS:
// PrimeNG про них не знает, поэтому неиспользованный токен = дизайн задал, а мы не применили.
//
// Сравнение идёт по ИМЕНИ CSS-переменной, а не по пути: Aura держит цвета под
// `colorScheme.light.root.background`, наш экспорт — под `root.background`, но переменная
// у обоих одна (`--p-togglebutton-background`), и стили Aura её применяют. Сравнение путей
// давало на такие токены ложные срабатывания.
if (own) {
const component = name.replace(/-/g, '');
const auraVars = new Set(leafPaths(Aura.components?.[component] ?? {}).map((p) => cssVarName(`${component}.${p}`)));
const usedVars = new Set(refs.map(cssVarName));
for (const path of leafPaths(own)) {
const varName = cssVarName(`${component}.${path}`);
if (auraVars.has(varName)) continue; // переменную применяют стили Aura
if (usedVars.has(varName)) continue; // применяем сами
warn('G8 токен не применён', path);
}
}

return issues;
}

const names = process.argv.slice(2).filter((a) => !a.startsWith('-'));
const targets = names.length
? names
: readdirSync(CSS_DIR)
.filter((f) => f.endsWith('.ts'))
.map((f) => basename(f, '.ts'));

let failed = 0;
for (const name of targets) {
const issues = checkComponent(name);
const fails = issues.filter((i) => i.level === 'FAIL');
const warns = issues.filter((i) => i.level === 'WARN');
if (!issues.length) {
if (names.length) console.log(`✓ ${name}: все гейты пройдены`);
continue;
}
console.log(`${fails.length ? '✗' : '!'} ${name}: ${fails.length} FAIL, ${warns.length} WARN`);
for (const i of [...fails, ...warns]) console.log(` ${i.level} ${i.gate.padEnd(24)} ${i.text}`);
if (fails.length) failed++;
}

if (!names.length) console.log(`\nКомпонентов с FAIL: ${failed} из ${targets.length}`);
process.exit(failed ? 1 : 0);
134 changes: 134 additions & 0 deletions scripts/check-dt-refs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* Проверка ссылок на токены из компонентных стилей.
*
* Стили компонентов (src/lib/providers/prime-preset/tokens/components/*.ts) обращаются к токенам
* через `dt('путь.к.токену')`. Если такого пути нет в tokens.json, PrimeNG подставит ссылку на
* несуществующую CSS-переменную — свойство просто не применится, молча и без ошибки сборки.
*
* Скрипт ловит два класса проблем:
* 1. мёртвые ссылки — `dt()` на путь, которого нет в tokens.json;
* 2. коллизии имён — два разных пути схлопываются в одну CSS-переменную, потому что PrimeUIX
* отбрасывает служебные сегменты (`root`, `extend`): `multiselect.extend.paddingX` и
* `multiselect.root.paddingX` дают один и тот же `--p-multiselect-padding-x`.
*
* Запуск: `npm run check:tokens`. Ненулевой код возврата — если найдены мёртвые ссылки.
*/
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
const PRESET = resolve(ROOT, 'src/lib/providers/prime-preset');
const TOKENS_PATH = resolve(PRESET, 'tokens/tokens.json');
const CSS_DIR = resolve(PRESET, 'tokens/components');

const tokens = JSON.parse(readFileSync(TOKENS_PATH, 'utf8'));

/** Собрать все уникальные `dt('...')` из компонентных стилей, с указанием файла. */
function collectRefs() {
const refs = new Map(); // путь → Set(файлов)
for (const file of readdirSync(CSS_DIR).filter((f) => f.endsWith('.ts'))) {
const src = readFileSync(resolve(CSS_DIR, file), 'utf8');
for (const m of src.matchAll(/dt\(\s*'([^']+)'\s*\)/g)) {
if (!refs.has(m[1])) refs.set(m[1], new Set());
refs.get(m[1]).add(file);
}
}
return refs;
}

function walk(obj, segments) {
let node = obj;
for (const key of segments) {
if (node && typeof node === 'object' && key in node) node = node[key];
else return undefined;
}
return node;
}

/**
* Резолв пути из `dt()`. Пресет собирается как definePreset(Aura, tokens), поэтому путь может
* указывать в components, semantic, primitive, в light-схему семантики или в colorScheme.light
* конкретного компонента.
*/
function resolves(path) {
const segments = path.split('.');
const scopes = [tokens.components, tokens.semantic, tokens.primitive, tokens.semantic.colorScheme?.light];
for (const scope of scopes) {
if (walk(scope, segments) !== undefined) return true;
}
const component = tokens.components?.[segments[0]];
const scheme = walk(component, ['colorScheme', 'light']);
return walk(scheme, segments.slice(1)) !== undefined;
}

/**
* Имя CSS-переменной, которую PrimeUIX выпустит для пути токена: служебные сегменты `root`
* и `extend` отбрасываются, camelCase разбивается дефисами.
*/
function cssVarName(path) {
return (
'--p-' +
path
.split('.')
.filter((s) => s !== 'root' && s !== 'extend')
.join('-')
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.toLowerCase()
);
}

/** Коллизии: два разных пути токенов дают одну и ту же CSS-переменную. */
function collectCollisions() {
const byVar = new Map();
const visit = (node, path) => {
for (const [key, value] of Object.entries(node ?? {})) {
const next = [...path, key];
if (value && typeof value === 'object' && !Array.isArray(value)) visit(value, next);
else {
const full = next.join('.');
const name = cssVarName(full);
if (!byVar.has(name)) byVar.set(name, []);
byVar.get(name).push(full);
}
}
};
for (const [component, definition] of Object.entries(tokens.components ?? {})) {
visit(definition, [component]);
}
return [...byVar.entries()].filter(([, paths]) => paths.length > 1);
}

const refs = collectRefs();
const dead = [...refs.entries()].filter(([path]) => !resolves(path));
const collisions = collectCollisions();

console.log(`Ссылок dt() в компонентных стилях: ${refs.size}`);
console.log(`Мёртвых: ${dead.length}`);

if (dead.length) {
const byFile = new Map();
for (const [path, files] of dead) {
for (const file of files) {
if (!byFile.has(file)) byFile.set(file, []);
byFile.get(file).push(path);
}
}
console.log('');
for (const [file, paths] of [...byFile.entries()].sort((a, b) => b[1].length - a[1].length)) {
console.log(` ${file} (${paths.length})`);
for (const path of paths.sort()) console.log(` ${path}`);
}
}

if (collisions.length) {
console.log('');
console.log(`Коллизии имён CSS-переменных: ${collisions.length}`);
for (const [name, paths] of collisions) {
console.log(` ${name}`);
for (const path of paths) console.log(` ${path}`);
}
}

process.exit(dead.length ? 1 : 0);
3 changes: 1 addition & 2 deletions src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ import { provideRouter } from '@angular/router';

import { routes } from './app.routes';
import { providePrimeNG } from 'primeng/config';
import Preset from '../prime-preset/theme.preset';
import { RU_TRANSLATION } from '../prime-preset/locale/ru';
import Preset from '../lib/providers/prime-preset/theme.preset';
import { RU_TRANSLATION } from '../lib/providers/prime-preset/locale/ru';

export const appConfig: ApplicationConfig = {
providers: [
Expand Down
Binary file added src/assets/fonts/noto-sans/NotoSans-Bold.woff2
Binary file not shown.
Binary file not shown.
4 changes: 2 additions & 2 deletions src/lib/providers/prime-preset/tokens/components/accordion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
export const accordionCss = ({ dt }: { dt: (token: string) => string }): string => `
/* ─── Шрифт заголовка ─── */
.p-accordionheader {
font-family: ${dt('accordion.extend.fonts_fontFamily_base')};
font-size: ${dt('accordion.extend.fonts_fontSize_300')};
font-family: ${dt('fonts.fontFamily.base')};
font-size: ${dt('fonts.fontSize.300')};
}

/* ─── Размер иконки toggle ─── */
Expand Down
Loading
Loading