From 6b3852cd7fd132445f71750904729f7ad974dd3d Mon Sep 17 00:00:00 2001 From: fureev Date: Fri, 28 Aug 2026 12:50:03 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(devtools):=20=D1=81=D0=B5=D0=BA=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D0=BE=D0=B2,=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D1=80=D0=B5=D1=88=D0=B0=D1=8E=D1=89=D0=B8?= =?UTF-8?q?=D1=85=D1=81=D1=8F=20=D0=B2=20=D0=BF=D1=83=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=82=D1=83;=20=D0=BF=D1=80=D0=B5=D1=81=D0=B5=D1=82=200.14.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--gr-*`, который правило компонента читает без запасного значения, а браузер отдаёт пустым: объявление отбраковывается на этапе вычисления, и компонент выходит без фона, без рамки, с прямыми углами — при зелёной сборке и валидном CSS. Индекс стилей попутно собирает «класс → токены без запаса» тем же обходом, что и «классы без правил»: `@media`/`@supports`/`@layer` внутрь, последний `var()` цепочки запасных учитывается — пустой, он роняет объявление так же. Считаются только `--gr-*`: UnoCSS читает свои `--un-shadow-inset`, `--un-ring-inset`, `--un-space-y-reverse` без запаса по всей утилитной раскладке, и на чистом стенде это три находки чужой внутренней механики. Идея — из `token-undefined`, заведённой в `granular doctor` пресетом 0.14.0. Статическая проверка слепа там, где приложения ошибаются чаще всего: `themes.tokensFile` заменяет `tokens.css` пакета, а доктор считает заданным объединение обоих файлов. Замерено на `apps/playground`: с подменой файла theme-CSS падает с 22 504 до 15 703 байт и теряет `--gr-radius-control`, доктор отчитывается теми же 17 находками, панель называет 11 пустых токенов типографики и анимации. В 0.14.1 ту же дыру закрыли для `themes.themeFiles`, для `tokensFile` — нет. Пресет поднят до 0.14.1. Его новая диагностика роняла бы `doctor --strict` в семи пакетах: на ядре 33 находки, и все до единой — токены, которые компонент выставляет себе сам инлайновым стилем. Гейтом теперь служит `scripts/granular-doctor.mjs`: `--strict` минус `token-undefined` на токенах, объявленных `tokens.json` любого пакета монорепо. Токен, которого не объявляет никто, роняет гейт — это опечатка в имени. --- apps/playground/README.md | 4 + apps/playground/package.json | 2 +- apps/showcase/package.json | 4 +- packages/granularity-charts/package.json | 4 +- packages/granularity-chrono/package.json | 4 +- packages/granularity-dashboard/package.json | 4 +- packages/granularity-devtools/CHANGELOG.md | 24 ++++ packages/granularity-devtools/README.md | 15 ++- packages/granularity-devtools/package.json | 2 +- .../__tests__/componentTokens.plugin.test.ts | 76 ++++++++++++ .../src/__tests__/emptyTokens.test.ts | 91 ++++++++++++++ .../src/__tests__/stylesheetIndex.test.ts | 47 +++++++ .../src/internal/stylesheetIndex.ts | 49 +++++++- .../src/plugin/componentTokens.ts | 13 ++ .../src/resolve/emptyTokens.ts | 78 ++++++++++++ packages/granularity-editor/package.json | 4 +- .../granularity-forms-schema/package.json | 4 +- packages/granularity-media/package.json | 4 +- packages/granularity-test-kit/package.json | 2 +- packages/granularity/package.json | 4 +- scripts/granular-doctor.mjs | 117 ++++++++++++++++++ yarn.lock | 8 +- 22 files changed, 531 insertions(+), 29 deletions(-) create mode 100644 packages/granularity-devtools/src/__tests__/componentTokens.plugin.test.ts create mode 100644 packages/granularity-devtools/src/__tests__/emptyTokens.test.ts create mode 100644 packages/granularity-devtools/src/resolve/emptyTokens.ts create mode 100644 scripts/granular-doctor.mjs diff --git a/apps/playground/README.md b/apps/playground/README.md index 4fde0491..71758c69 100644 --- a/apps/playground/README.md +++ b/apps/playground/README.md @@ -22,6 +22,10 @@ overlays», «Granularity app» и «Granularity issues». Панель можн тем, кому адресован `Esc`, и судьбой фокуса. Секции пропов и токенов живут в штатном инспекторе компонентов, на выбранном `Gr*`. +Стенд удобен и для проверки секции «tokens · consumed but empty»: разбор в `uno.config.ts` объясняет, почему +`themes.tokensFile` сносит шкалу радиусов, — включите его на минуту, и секция назовёт одиннадцать `--gr-*` +типографики и анимации, которые правила читают, а браузер отдаёт пустыми. Со штатным конфигом секция пуста. + Лента событий пишется, **только когда включена запись** — кнопка в правом верхнем углу вкладки Timeline. Пустая лента чаще всего значит именно это. diff --git a/apps/playground/package.json b/apps/playground/package.json index 4e83bf70..6b983558 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -8,7 +8,7 @@ "vue": "^3.5.42" }, "devDependencies": { - "@feugene/granularity-devtools": "^0.2.0", + "@feugene/granularity-devtools": "^0.3.0", "@unocss/reset": "^66.8.1", "@vitejs/plugin-vue": "^6.0.8", "rollup-plugin-visualizer": "^7.1.1", diff --git a/apps/showcase/package.json b/apps/showcase/package.json index 5b519a5d..cd4d1601 100644 --- a/apps/showcase/package.json +++ b/apps/showcase/package.json @@ -10,11 +10,11 @@ "@feugene/granularity-chrono": "^0.10.0", "@feugene/granularity-dashboard": "^0.6.0", "@feugene/granularity-datasource": "^0.1.2", - "@feugene/granularity-devtools": "^0.2.0", + "@feugene/granularity-devtools": "^0.3.0", "@feugene/granularity-editor": "^0.3.1", "@feugene/granularity-forms-schema": "^0.4.0", "@feugene/granularity-media": "^0.7.1", - "@feugene/unocss-preset-granular": "^0.13.0", + "@feugene/unocss-preset-granular": "^0.14.1", "@floating-ui/dom": "^1.8.0", "vue": "^3.5.42", "vue-router": "^4.6.4" diff --git a/packages/granularity-charts/package.json b/packages/granularity-charts/package.json index 25820b63..54fe8bf9 100644 --- a/packages/granularity-charts/package.json +++ b/packages/granularity-charts/package.json @@ -157,7 +157,7 @@ "@feugene/fint-i18n": "^0.7.0", "@feugene/granularity": "^0.38.0", "@feugene/granularity-test-kit": "^0.8.1", - "@feugene/unocss-preset-granular": "^0.13.0", + "@feugene/unocss-preset-granular": "^0.14.1", "@feugene/unplugin-granularity": "^0.7.0", "@types/node": "^26.4.0", "@vitejs/plugin-vue": "^6.0.8", @@ -178,7 +178,7 @@ "scripts": { "build": "vite build && gr-check-dist-dev-guard && vue-tsc -p tsconfig.build.json", "dev": "vite build --watch", - "doctor": "granular doctor ./granular.options.mjs --strict", + "doctor": "node ../../scripts/granular-doctor.mjs ./granular.options.mjs", "generate:registry": "node scripts/generate-registry.mjs", "check:messages": "fint-i18n-check-messages src/i18n/locales", "lint": "eslint . --cache", diff --git a/packages/granularity-chrono/package.json b/packages/granularity-chrono/package.json index b2477eb2..be9624c6 100644 --- a/packages/granularity-chrono/package.json +++ b/packages/granularity-chrono/package.json @@ -132,7 +132,7 @@ "@feugene/fint-i18n": "^0.7.0", "@feugene/granularity": "^0.38.0", "@feugene/granularity-test-kit": "^0.8.1", - "@feugene/unocss-preset-granular": "^0.13.0", + "@feugene/unocss-preset-granular": "^0.14.1", "@feugene/unplugin-granularity": "^0.7.0", "@types/node": "^26.4.0", "@vitejs/plugin-vue": "^6.0.8", @@ -153,7 +153,7 @@ "scripts": { "build": "vite build && vue-tsc -p tsconfig.build.json", "dev": "vite build --watch", - "doctor": "granular doctor ./granular.options.mjs --strict", + "doctor": "node ../../scripts/granular-doctor.mjs ./granular.options.mjs", "generate:registry": "node scripts/generate-registry.mjs", "lint": "eslint . --cache", "lint:fix": "eslint . --cache --fix", diff --git a/packages/granularity-dashboard/package.json b/packages/granularity-dashboard/package.json index 5ab737ba..ccecf884 100644 --- a/packages/granularity-dashboard/package.json +++ b/packages/granularity-dashboard/package.json @@ -133,7 +133,7 @@ "@feugene/fint-i18n": "^0.7.0", "@feugene/granularity": "^0.38.0", "@feugene/granularity-test-kit": "^0.8.1", - "@feugene/unocss-preset-granular": "^0.13.0", + "@feugene/unocss-preset-granular": "^0.14.1", "@feugene/unplugin-granularity": "^0.7.0", "@types/node": "^26.4.0", "@vitejs/plugin-vue": "^6.0.8", @@ -154,7 +154,7 @@ "scripts": { "build": "vite build && vue-tsc -p tsconfig.build.json", "dev": "vite build --watch", - "doctor": "granular doctor ./granular.options.mjs --strict", + "doctor": "node ../../scripts/granular-doctor.mjs ./granular.options.mjs", "generate:registry": "node scripts/generate-registry.mjs", "check:messages": "fint-i18n-check-messages src/i18n/locales", "lint": "eslint . --cache", diff --git a/packages/granularity-devtools/CHANGELOG.md b/packages/granularity-devtools/CHANGELOG.md index 77e0bbf2..c038a697 100644 --- a/packages/granularity-devtools/CHANGELOG.md +++ b/packages/granularity-devtools/CHANGELOG.md @@ -7,6 +7,30 @@ to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [v0.3.0] 2026-08-28 + +### Added + +- **"Tokens resolving to nothing" section** on the component inspector — a `--gr-*` token that a rule of the + component reads **without a fallback** while the browser resolves it to empty. Such a declaration is dropped + at computed-value time, so the component renders with no background, no border, square corners — on a green + build and valid CSS. + + The idea comes from `token-undefined`, added to `granular doctor` in preset `0.14.0`. The static check is + blind where applications get it wrong most often: `themes.tokensFile` **replaces** the package's `tokens.css`, + but the doctor treats the union of both files as defined. Measured on `apps/playground` against `0.14.1`, + which closed the same gap for `themes.themeFiles` but not for `themes.tokensFile`: with the token file + swapped, `getGranularThemeCss` drops from 22 504 to 15 703 bytes and loses `--gr-radius-control`, the doctor + still reports the same 17 findings as before, and the panel names 11 empty typography and motion tokens. + + Only `--gr-*` counts. UnoCSS reads its own `--un-shadow-inset`, `--un-ring-inset` and `--un-space-y-reverse` + without fallbacks across the utility layer; on a clean stand that is three findings of somebody else's + internal machinery. + +- The stylesheet index now also maps class → tokens its rules read without a fallback, reusing the same walk + that backs "classes without rules": `@media` / `@supports` / `@layer` included, the last `var()` of a fallback + chain counted (empty, it drops the declaration just the same). + ## [v0.2.0] 2026-08-28 ### Added diff --git a/packages/granularity-devtools/README.md b/packages/granularity-devtools/README.md index 648c862b..d725cf26 100644 --- a/packages/granularity-devtools/README.md +++ b/packages/granularity-devtools/README.md @@ -27,6 +27,17 @@ объявленные, но не применившиеся, и `--gr-*`, выставленные на элементе, которых нет ни в одном реестре, — почти всегда опечатка. +**Токены, разрешающиеся в пустоту** — четвёртой секцией: `--gr-*`, который +правило компонента читает **без запасного значения**, а браузер отдаёт пустым. +Такое объявление отбраковывается на этапе вычисления, и компонент выходит без +фона, без рамки, с прямыми углами — при зелёной сборке и валидном CSS. Смежную +проверку делает `granular doctor` (диагностика `token-undefined` с 0.14.0), но +она статическая и слепа ровно там, где ошибаются чаще всего: `themes.tokensFile` +**заменяет** `tokens.css` пакета, а доктор считает заданным объединение обоих +файлов. В `0.14.1` ту же дыру закрыли для `themes.themeFiles`, но не для +`themes.tokensFile`: стенд с такой подменой доктор по-прежнему проходит молча — +панель показывает одиннадцать пустых токенов типографики и анимации. + **Issues** — все предупреждения пакета одним списком со счётчиком повторов, вместо тонущих в консоли строк. Сюда же попадают **недостающие обязательные пропы**: production-сборка SFC стирает `required`, поэтому «Missing required @@ -94,7 +105,9 @@ await page.evaluate(() => ## Чего панель не делает -Статические вопросы закрывает CLI пресета, и дублировать его незачем. +Статические вопросы закрывает CLI пресета, и дублировать его незачем: где +статики хватает, панель молчит — она добавляет только то, что видно +исключительно в браузере. Промахи ключей i18n не считаются: для этого пришлось бы подменить `t` у чужого адаптера, то есть писать в состояние приложения. diff --git a/packages/granularity-devtools/package.json b/packages/granularity-devtools/package.json index f0c0f362..50e157c2 100644 --- a/packages/granularity-devtools/package.json +++ b/packages/granularity-devtools/package.json @@ -1,7 +1,7 @@ { "name": "@feugene/granularity-devtools", "description": "Vue DevTools panel for @feugene/granularity — where a prop value came from, the overlay layer stack and design-system warnings.", - "version": "0.2.0", + "version": "0.3.0", "license": "SEE LICENSE IN LICENSE", "author": { "name": "Evgeniy Fureev", diff --git a/packages/granularity-devtools/src/__tests__/componentTokens.plugin.test.ts b/packages/granularity-devtools/src/__tests__/componentTokens.plugin.test.ts new file mode 100644 index 00000000..2922cef7 --- /dev/null +++ b/packages/granularity-devtools/src/__tests__/componentTokens.plugin.test.ts @@ -0,0 +1,76 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' + +import { resetStylesheetIndex } from '../internal/stylesheetIndex' +import { registerComponentTokens } from '../plugin/componentTokens' + +interface StateRow { + type: string + key: string + value: string +} + +function fakeApi() { + const handlers: Record void> = {} + return { + on: { inspectComponent: (fn: (payload: unknown) => void) => { handlers.inspect = fn } }, + handlers, + } +} + +function inspect(el: HTMLElement, name = 'GrSelect'): StateRow[] { + const api = fakeApi() + registerComponentTokens(api as never) + + const state: StateRow[] = [] + api.handlers.inspect?.({ + componentInstance: { type: { __name: name }, vnode: { el } }, + instanceData: { state }, + }) + + return state +} + +function mount(html: string): HTMLElement { + document.body.innerHTML = html + return document.body.firstElementChild as HTMLElement +} + +function addStyle(css: string): void { + const style = document.createElement('style') + style.textContent = css + document.head.append(style) +} + +afterEach(() => { + document.head.querySelectorAll('style').forEach(style => style.remove()) + document.body.innerHTML = '' + resetStylesheetIndex() +}) + +const EMPTY = 'granularity tokens · consumed but empty' + +describe('секция «токены, разрешающиеся в пустоту»', () => { + it('называет токен и класс, чьё правило его читает', () => { + addStyle('.trigger { border-radius: var(--gr-radius-control) }') + const state = inspect(mount('
')) + + const rows = state.filter(row => row.type === EMPTY) + expect(rows).toHaveLength(1) + expect(rows[0]!.key).toBe('--gr-radius-control') + expect(rows[0]!.value).toContain('.trigger') + }) + + it('молчит, когда токен объявлен', () => { + addStyle(':root { --gr-radius-control: 6px }') + addStyle('.trigger { border-radius: var(--gr-radius-control) }') + + expect(inspect(mount('
')).filter(row => row.type === EMPTY)).toEqual([]) + }) + + it('чужой компонент не трогает', () => { + addStyle('.trigger { border-radius: var(--gr-radius-control) }') + + expect(inspect(mount('
'), 'RouterView')).toEqual([]) + }) +}) diff --git a/packages/granularity-devtools/src/__tests__/emptyTokens.test.ts b/packages/granularity-devtools/src/__tests__/emptyTokens.test.ts new file mode 100644 index 00000000..03e7d2f0 --- /dev/null +++ b/packages/granularity-devtools/src/__tests__/emptyTokens.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' + +import { emptyTokens } from '../resolve/emptyTokens' + +function markup(html: string): Element { + const host = document.createElement('div') + host.innerHTML = html + return host.firstElementChild! +} + +function values(table: Record>) { + return { read: (element: Element, token: string) => table[element.className.split(' ')[0]!]?.[token] ?? '' } +} + +describe('токены, разрешающиеся в пустоту', () => { + it('находит потребляемый токен без значения', () => { + const root = markup('
') + const consumed = new Map([['panel', new Set(['--gr-radius-control'])]]) + + const report = emptyTokens(root, consumed, values({})) + + expect(report.empty).toEqual([{ token: '--gr-radius-control', className: 'panel' }]) + expect(report.checked).toBe(1) + }) + + it('молчит, когда токен разрешается', () => { + const root = markup('
') + const consumed = new Map([['panel', new Set(['--gr-radius-control'])]]) + + const report = emptyTokens(root, consumed, values({ panel: { '--gr-radius-control': '6px' } })) + + expect(report.empty).toEqual([]) + expect(report.checked).toBe(1) + }) + + it('пробел за значение не считает: `getPropertyValue` возвращает его с ведущим пробелом', () => { + const root = markup('
') + const consumed = new Map([['panel', new Set(['--gr-bg'])]]) + + const report = emptyTokens(root, consumed, values({ panel: { '--gr-bg': ' ' } })) + + expect(report.empty).toHaveLength(1) + }) + + it('заходит к потомкам: промах живёт не на корне, а на внутреннем элементе', () => { + const root = markup('
') + const consumed = new Map([['badge', new Set(['--gr-badge-semi-radius-md'])]]) + + const report = emptyTokens(root, consumed, values({})) + + expect(report.empty.map(finding => finding.className)).toEqual(['badge']) + }) + + it('читает токен на том элементе, чьё правило его требует, а не на корне', () => { + const root = markup('
') + const consumed = new Map([['inner', new Set(['--gr-fg'])]]) + + const report = emptyTokens(root, consumed, values({ inner: { '--gr-fg': '#111' } })) + + expect(report.empty).toEqual([]) + }) + + it('не повторяет токен, потребляемый несколькими классами', () => { + const root = markup('
') + const consumed = new Map([ + ['a', new Set(['--gr-brd'])], + ['b', new Set(['--gr-brd'])], + ]) + + const report = emptyTokens(root, consumed, values({})) + + expect(report.empty).toHaveLength(1) + expect(report.checked).toBe(2) + }) + + it('чужие переменные не считает: `--un-*` ведёт сам UnoCSS', () => { + const root = markup('
') + const consumed = new Map([['shadow-sm', new Set(['--un-shadow-inset'])]]) + + expect(emptyTokens(root, consumed, values({}))).toEqual({ empty: [], checked: 0 }) + }) + + it('классы без потребления не считаются проверенными', () => { + const root = markup('
') + + const report = emptyTokens(root, new Map(), values({})) + + expect(report).toEqual({ empty: [], checked: 0 }) + }) +}) diff --git a/packages/granularity-devtools/src/__tests__/stylesheetIndex.test.ts b/packages/granularity-devtools/src/__tests__/stylesheetIndex.test.ts index 6ef7fe3d..797283d2 100644 --- a/packages/granularity-devtools/src/__tests__/stylesheetIndex.test.ts +++ b/packages/granularity-devtools/src/__tests__/stylesheetIndex.test.ts @@ -47,3 +47,50 @@ describe('индекс селекторов документа', () => { expect(stylesheetIndex()).toBe(stylesheetIndex()) }) }) + +describe('потребление токенов без запасного значения', () => { + it('записывает токен, читаемый правилом класса', () => { + addStyle('.panel { border-radius: var(--gr-radius-control) }') + + expect(stylesheetIndex().consumed.get('panel')).toEqual(new Set(['--gr-radius-control'])) + }) + + it('пропускает `var(--x, …)`: с запасом класса отказов нет', () => { + addStyle('.rail { background: var(--gr-slider-rail, #e2e8f0) }') + + expect(stylesheetIndex().consumed.get('rail')).toBeUndefined() + }) + + it('оставляет ПОСЛЕДНИЙ `var()` цепочки запасных: пуст он — объявление всё равно отбраковано', () => { + addStyle('.fill { background: var(--gr-slider-fill, var(--gr-primary)) }') + + expect(stylesheetIndex().consumed.get('fill')).toEqual(new Set(['--gr-primary'])) + }) + + it('собирает по всем объявлениям правила', () => { + addStyle('.card { background: var(--gr-bg); border-color: var(--gr-brd) }') + + expect(stylesheetIndex().consumed.get('card')).toEqual(new Set(['--gr-bg', '--gr-brd'])) + }) + + it('раздаёт токены каждому классу составного селектора', () => { + addStyle('.a .b { color: var(--gr-fg) }') + + const consumed = stylesheetIndex().consumed + expect(consumed.get('a')).toEqual(new Set(['--gr-fg'])) + expect(consumed.get('b')).toEqual(new Set(['--gr-fg'])) + }) + + it('заходит внутрь @media — иначе адаптивное правило осталось бы неучтённым', () => { + addStyle('@media (min-width: 768px) { .wide { gap: var(--gr-space-4) } }') + + expect(stylesheetIndex().consumed.get('wide')).toEqual(new Set(['--gr-space-4'])) + }) + + it('сливает токены из нескольких правил одного класса', () => { + addStyle('.btn { color: var(--gr-fg) }') + addStyle('.btn { background: var(--gr-bg) }') + + expect(stylesheetIndex().consumed.get('btn')).toEqual(new Set(['--gr-fg', '--gr-bg'])) + }) +}) diff --git a/packages/granularity-devtools/src/internal/stylesheetIndex.ts b/packages/granularity-devtools/src/internal/stylesheetIndex.ts index 022073f2..e590dcd2 100644 --- a/packages/granularity-devtools/src/internal/stylesheetIndex.ts +++ b/packages/granularity-devtools/src/internal/stylesheetIndex.ts @@ -10,10 +10,33 @@ import { classNamesFromSelector } from '../resolve/unstyledClasses' export interface StylesheetIndex { styled: Set + /** + * Класс → токены, которые его правила читают БЕЗ запасного значения. + * + * `var(--x)` без запасного значения — валидный CSS, который при пустом + * токене не красит вовсе: свойство становится недействительным на этапе + * вычисления. С запасным значением такого класса отказов нет, поэтому + * `var(--x, …)` сюда не попадает. + */ + consumed: Map> /** Листы, которые не удалось прочитать: кросс-доменные бросают `SecurityError`. */ unreadableSheets: number } +/** + * Токены, читаемые объявлением без запасного значения. + * + * Различает `var(--x)` и `var(--x, …)` по символу за именем: запятая — запас + * есть. Вложенные `var()` внутри запасного значения находятся тем же проходом, + * потому что разбор идёт по всем вхождениям, а не по одному верхнему. + */ +function tokensWithoutFallback(value: string, into: Set): void { + for (const match of value.matchAll(/var\(\s*(--[\w-]+)\s*([,)])/g)) { + if (match[2] === ')') + into.add(match[1]!) + } +} + let cached: StylesheetIndex | null = null let observer: MutationObserver | null = null @@ -22,7 +45,7 @@ let observer: MutationObserver | null = null * коллекции старого образца, и итератора у них нет ни в jsdom, ни в части * браузерных сред. */ -function collectFromRules(rules: CSSRuleList, styled: Set): void { +function collectFromRules(rules: CSSRuleList, styled: Set, consumed: Map>): void { for (let index = 0; index < rules.length; index += 1) { const rule = rules.item(index) if (!rule) @@ -34,8 +57,23 @@ function collectFromRules(rules: CSSRuleList, styled: Set): void { // список, теряя все селекторы разом. const selector = (rule as CSSStyleRule).selectorText if (selector) { - for (const name of classNamesFromSelector(selector)) + const names = classNamesFromSelector(selector) + for (const name of names) styled.add(name) + + const read = new Set() + const declarations = (rule as CSSStyleRule).style + for (let property = 0; property < (declarations?.length ?? 0); property += 1) + tokensWithoutFallback(declarations.getPropertyValue(declarations.item(property)), read) + + if (read.size) { + for (const name of names) { + const bucket = consumed.get(name) ?? new Set() + for (const token of read) + bucket.add(token) + consumed.set(name, bucket) + } + } continue } @@ -44,12 +82,13 @@ function collectFromRules(rules: CSSRuleList, styled: Set): void { // «без правил». const grouping = rule as CSSGroupingRule if (grouping.cssRules) - collectFromRules(grouping.cssRules, styled) + collectFromRules(grouping.cssRules, styled, consumed) } } function build(): StylesheetIndex { const styled = new Set() + const consumed = new Map>() let unreadableSheets = 0 const sheets = document.styleSheets @@ -57,7 +96,7 @@ function build(): StylesheetIndex { try { const sheet = sheets.item(index) if (sheet) - collectFromRules(sheet.cssRules, styled) + collectFromRules(sheet.cssRules, styled, consumed) } catch { // Кросс-доменный лист без CORS: `cssRules` бросает `SecurityError`. @@ -65,7 +104,7 @@ function build(): StylesheetIndex { } } - return { styled, unreadableSheets } + return { styled, consumed, unreadableSheets } } function watchStylesheets(): void { diff --git a/packages/granularity-devtools/src/plugin/componentTokens.ts b/packages/granularity-devtools/src/plugin/componentTokens.ts index c56766de..95d2b9ed 100644 --- a/packages/granularity-devtools/src/plugin/componentTokens.ts +++ b/packages/granularity-devtools/src/plugin/componentTokens.ts @@ -1,6 +1,8 @@ import type { PluginSetupFunction } from '@vue/devtools-kit' import type { TokenReading } from '../resolve/tokenUsage' +import { stylesheetIndex } from '../internal/stylesheetIndex' +import { emptyTokens } from '../resolve/emptyTokens' import { tokenSections } from '../resolve/tokenUsage' type DevtoolsApi = Parameters[0] @@ -51,10 +53,21 @@ export function registerComponentTokens(api: DevtoolsApi): void { inlineNames: Array.from(el.style), }) + const index = stylesheetIndex() + const resolved = emptyTokens(el, index.consumed, { + read: (element, token) => getComputedStyle(element).getPropertyValue(token), + }) + payload.instanceData.state.push( ...entries('granularity tokens', sections.applied), ...entries('granularity tokens · unset', sections.unset), ...entries('granularity tokens · not declared', sections.unknown), + ...resolved.empty.map(finding => ({ + type: 'granularity tokens · consumed but empty', + key: finding.token, + value: `read by .${finding.className} without a fallback — the declaration is dropped`, + editable: false, + })), ) }) } diff --git a/packages/granularity-devtools/src/resolve/emptyTokens.ts b/packages/granularity-devtools/src/resolve/emptyTokens.ts new file mode 100644 index 00000000..93ea96f6 --- /dev/null +++ b/packages/granularity-devtools/src/resolve/emptyTokens.ts @@ -0,0 +1,78 @@ +/** + * Токены, которые правила компонента читают, а браузер разрешает в пустоту. + * + * `var(--gr-x)` без запасного значения при пустом токене не красит вовсе: + * свойство отбраковывается на этапе вычисления, и компонент выходит без фона, + * без рамки, с прямыми углами. Сборка при этом зелёная — CSS валиден. + * + * Статический `granular doctor` отвечает на смежный вопрос («какой токен не + * задаёт ни один granular-слой») и слеп ровно там, где ошибаются чаще всего: + * `themes.tokensFile` **заменяет** `tokens.css` пакета, но доктор считает + * заданным объединение обоих файлов — и подмену базовых токенов приложением не + * замечает. Здесь источник истины — сам браузер: читается то, что получилось. + * + * Секция `unset` из `tokenUsage` отвечает на другой вопрос: там объявленные + * токены компонента, и пустота в них — норма (`kind: 'hook'`, состояния + * `:hover`). Здесь наоборот: пусто И потребляется без запаса. + */ + +export interface EmptyToken { + token: string + /** Класс, чьё правило читает токен, — с него начинать поиск причины. */ + className: string +} + +export interface EmptyTokenReport { + empty: EmptyToken[] + /** Сколько пар «класс × токен» проверено: без счётчика пустой список нечитаем. */ + checked: number +} + +/** + * Считаются только токены дизайн-системы. + * + * UnoCSS ведёт свои переменные (`--un-shadow-inset`, `--un-ring-inset`, + * `--un-space-y-reverse`) и читает их без запасного значения по всей утилитной + * раскладке: на чистом стенде их набирается три штуки, и каждая — не дефект, а + * внутренняя механика чужого генератора. Панель разбирает свою систему, и + * чужие переменные в ней только заслоняют настоящую находку. + */ +const OWN_PREFIX = '--gr-' + +export interface EmptyTokenProbe { + /** Значение токена в вычисленном стиле конкретного элемента. */ + read: (element: Element, token: string) => string +} + +/** + * Обход идёт по элементу и потомкам, а токен читается на том элементе, чьё + * правило его требует: пользовательские свойства наследуются, и замер с корня + * соврал бы там, где значение переопределено внутри. + */ +export function emptyTokens( + root: Element, + consumed: ReadonlyMap>, + probe: EmptyTokenProbe, +): EmptyTokenReport { + const empty: EmptyToken[] = [] + const seen = new Set() + let checked = 0 + + for (const element of [root, ...root.querySelectorAll('*')]) { + for (const className of element.classList) { + for (const token of consumed.get(className) ?? []) { + if (!token.startsWith(OWN_PREFIX)) + continue + + checked += 1 + if (seen.has(token) || probe.read(element, token).trim()) + continue + + seen.add(token) + empty.push({ token, className }) + } + } + } + + return { empty, checked } +} diff --git a/packages/granularity-editor/package.json b/packages/granularity-editor/package.json index 5fcd8667..d77419d3 100644 --- a/packages/granularity-editor/package.json +++ b/packages/granularity-editor/package.json @@ -102,7 +102,7 @@ "scripts": { "build": "vite build && gr-check-dist-dev-guard && vue-tsc -p tsconfig.build.json", "dev": "vite build --watch", - "doctor": "granular doctor ./granular.options.mjs --strict", + "doctor": "node ../../scripts/granular-doctor.mjs ./granular.options.mjs", "generate:registry": "node scripts/generate-registry.mjs", "lint": "eslint . --cache", "lint:fix": "eslint . --cache --fix", @@ -116,7 +116,7 @@ "@feugene/fint-i18n": "^0.7.0", "@feugene/granularity": "^0.38.0", "@feugene/granularity-test-kit": "^0.8.1", - "@feugene/unocss-preset-granular": "^0.13.0", + "@feugene/unocss-preset-granular": "^0.14.1", "@feugene/unplugin-granularity": "^0.7.0", "@tiptap/core": "^3.30.5", "@tiptap/extensions": "^3.30.5", diff --git a/packages/granularity-forms-schema/package.json b/packages/granularity-forms-schema/package.json index 0ec5dd95..857f4898 100644 --- a/packages/granularity-forms-schema/package.json +++ b/packages/granularity-forms-schema/package.json @@ -166,7 +166,7 @@ "@feugene/granularity": "^0.38.0", "@feugene/granularity-chrono": "^0.10.0", "@feugene/granularity-test-kit": "^0.8.1", - "@feugene/unocss-preset-granular": "^0.13.0", + "@feugene/unocss-preset-granular": "^0.14.1", "@feugene/unplugin-granularity": "^0.7.0", "@types/node": "^26.4.0", "@vitejs/plugin-vue": "^6.0.8", @@ -188,7 +188,7 @@ "scripts": { "build": "vite build && vue-tsc -p tsconfig.build.json", "dev": "vite build --watch", - "doctor": "granular doctor ./granular.options.mjs --strict", + "doctor": "node ../../scripts/granular-doctor.mjs ./granular.options.mjs", "generate:registry": "node scripts/generate-registry.mjs", "check:messages": "fint-i18n-check-messages src/i18n/locales", "lint": "eslint . --cache", diff --git a/packages/granularity-media/package.json b/packages/granularity-media/package.json index 38e503b3..eb477d2a 100644 --- a/packages/granularity-media/package.json +++ b/packages/granularity-media/package.json @@ -107,7 +107,7 @@ "scripts": { "build": "vite build && gr-check-dist-dev-guard && vue-tsc -p tsconfig.build.json", "dev": "vite build --watch", - "doctor": "granular doctor ./granular.options.mjs --strict", + "doctor": "node ../../scripts/granular-doctor.mjs ./granular.options.mjs", "generate:registry": "node scripts/generate-registry.mjs", "lint": "eslint . --cache", "lint:fix": "eslint . --cache --fix", @@ -121,7 +121,7 @@ "@feugene/fint-i18n": "^0.7.0", "@feugene/granularity": "^0.38.0", "@feugene/granularity-test-kit": "^0.8.1", - "@feugene/unocss-preset-granular": "^0.13.0", + "@feugene/unocss-preset-granular": "^0.14.1", "@feugene/unplugin-granularity": "^0.7.0", "@types/node": "^26.4.0", "@vitejs/plugin-vue": "^6.0.8", diff --git a/packages/granularity-test-kit/package.json b/packages/granularity-test-kit/package.json index 4a343639..52f5152c 100644 --- a/packages/granularity-test-kit/package.json +++ b/packages/granularity-test-kit/package.json @@ -105,7 +105,7 @@ "devDependencies": { "@antfu/eslint-config": "^9.3.0", "@axe-core/playwright": "^4.13.0", - "@feugene/unocss-preset-granular": "^0.13.0", + "@feugene/unocss-preset-granular": "^0.14.1", "@playwright/test": "^1.62.1", "@types/node": "^26.4.0", "@vue/test-utils": "^2.4.11", diff --git a/packages/granularity/package.json b/packages/granularity/package.json index a0e01a66..5a635c79 100644 --- a/packages/granularity/package.json +++ b/packages/granularity/package.json @@ -608,7 +608,7 @@ "@antfu/eslint-config": "^9.3.0", "@feugene/fint-i18n": "^0.7.0", "@feugene/granularity-test-kit": "^0.8.1", - "@feugene/unocss-preset-granular": "^0.13.0", + "@feugene/unocss-preset-granular": "^0.14.1", "@floating-ui/dom": "^1.8.0", "@iconify-json/lucide": "^1.2.126", "@types/node": "^25.9.5", @@ -632,7 +632,7 @@ "build": "vite build && gr-check-dist-dev-guard && node scripts/check-dist-no-tests.mjs && node scripts/generate-web-types.mjs && vue-tsc -p tsconfig.build.json && node scripts/check-dist-exports.mjs", "check:messages": "fint-i18n-check-messages src/i18n/locales", "dev": "vite build --watch", - "doctor": "granular doctor ./granular.options.mjs --strict", + "doctor": "node ../../scripts/granular-doctor.mjs ./granular.options.mjs", "generate:registry": "node scripts/generate-registry.mjs", "generate:tokens": "node --experimental-strip-types scripts/generate-tokens.mjs", "lint": "eslint . --cache", diff --git a/scripts/granular-doctor.mjs b/scripts/granular-doctor.mjs new file mode 100644 index 00000000..7eba933f --- /dev/null +++ b/scripts/granular-doctor.mjs @@ -0,0 +1,117 @@ +/** + * `granular doctor --strict` минус систематический ложняк `token-undefined`. + * + * Диагностика находит токен, который компонент потребляет, а granular не задаёт + * ни одним слоем. В приложении это дефект: `var(--x)` без запасного значения — + * валидный CSS, который молча не красит. В библиотеке — наоборот норма: токен + * выставляет сам компонент инлайновым стилем (`grAlertStyles.ts`, + * `grSegmentedStyles.ts`, `GrSwitch.vue`), и granular о нём знать не обязан. + * На ядре так выглядят все 33 находки до единой, поэтому `--strict` роняет CI + * на конвенции. Потребление с запасным значением пресет перестал считать + * находкой в 0.14.1 — до него тот же список был вчетверо длиннее. + * + * Отбор идёт по реестру: токен, объявленный `tokens.json` любого пакета + * монорепо, — заявленная точка расширения и находкой не считается; токен, + * которого не объявляет никто, — опечатка в имени либо расширение, забытое в + * реестре, и роняет гейт. Реестр берётся общий на монорепо, а не пакетный: + * пакеты потребляют токены друг друга, и пакетный реестр ругался бы на это. + * Плата — опечатка, случайно совпавшая с чужим токеном, пройдёт; она дешевле + * постоянного шума. + * + * Остальные диагностики роняют гейт как раньше, включая `error`. + */ +import { readFile, readdir } from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +import { countDoctorDiagnostics, formatDoctorReport, granularDoctor } from '@feugene/unocss-preset-granular/node' + +const REPO = path.resolve(import.meta.dirname, '..') +const EXEMPT = 'token-undefined' + +async function jsonOrNull(file) { + try { + return JSON.parse(await readFile(file, 'utf8')) + } + catch { + return null + } +} + +async function entriesIn(root, pick) { + try { + return (await readdir(root, { withFileTypes: true })).filter(pick).map(e => e.name) + } + catch { + return [] + } +} + +const dirsIn = root => entriesIn(root, e => e.isDirectory()) +const filesIn = root => entriesIn(root, e => e.isFile() && e.name.endsWith('.json')) + +/** + * Имена токенов из всех реестров монорепо — с префиксом `--`, как в отчёте. + * + * Форм две: покомпонентная (`{ tokens: [...] }`) и базовая (`{ groups: [{ tokens }] }`). + * Обход идёт по всем `tokens.json` внутри `src`, а не только по `components/*`: + * `--gr-floating-available-height` объявлен композаблом (`src/composables`), + * и обход одних компонентов принял бы его за опечатку. + */ +async function declaredTokens() { + const names = new Set() + const collect = (payload) => { + const lists = [payload?.tokens ?? [], ...(payload?.groups ?? []).map(group => group?.tokens ?? [])] + for (const token of lists.flat()) + if (token?.name) + names.add(token.name) + } + + for (const pkg of await dirsIn(path.join(REPO, 'packages'))) { + for (const file of await registriesIn(path.join(REPO, 'packages', pkg, 'src'))) + collect(await jsonOrNull(file)) + for (const file of await filesIn(path.join(REPO, 'packages', pkg, 'tokens'))) + collect(await jsonOrNull(path.join(REPO, 'packages', pkg, 'tokens', file))) + } + + return names +} + +/** Рекурсивный поиск `tokens.json` — реестры лежат и у компонентов, и у композаблов. */ +async function registriesIn(root) { + const found = [] + for (const entry of await entriesIn(root, () => true)) { + const full = path.join(root, entry) + if (entry === 'tokens.json') + found.push(full) + else if (!entry.includes('.')) + found.push(...await registriesIn(full)) + } + return found +} + +const optionsPath = process.argv[2] +if (!optionsPath) { + console.error('usage: node scripts/granular-doctor.mjs ') + process.exit(2) +} + +const options = (await import(pathToFileURL(path.resolve(optionsPath)).href)).default +const report = granularDoctor(options) +console.log(formatDoctorReport(report)) + +const known = await declaredTokens() +const blocking = report.diagnostics.filter(d => d.level === 'error' + || d.code !== EXEMPT + || !known.has(`--${d.subject.split(':').pop()}`)) + +const { errors, warnings } = countDoctorDiagnostics(report) +const exempted = report.diagnostics.length - blocking.length +console.log(`\nСтрогий гейт: ${blocking.length} блокирующих из ${errors + warnings}` + + ` (${exempted} × ${EXEMPT} на объявленных токенах пропущено).`) + +for (const d of blocking) + console.log(` ✗ [${d.code}] ${d.subject} — ${d.message}`) + +process.exit(blocking.length ? 1 : 0) diff --git a/yarn.lock b/yarn.lock index 54604c79..41feea32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -659,10 +659,10 @@ "@unocss/preset-mini" "^66.7.5" "@unocss/rule-utils" "^66.7.5" -"@feugene/unocss-preset-granular@^0.13.0": - version "0.13.0" - resolved "https://registry.yarnpkg.com/@feugene/unocss-preset-granular/-/unocss-preset-granular-0.13.0.tgz#72172c73b4e597c3273bacae71202ba15afff0fc" - integrity sha512-AwTa2+Ch//IaxkG2FK9Y/XMuBbROzz0orhBVMPgvcJ5JqiRF4vQqgMguubvfJQXRR9ShvfGO2obGHDviulfRPQ== +"@feugene/unocss-preset-granular@^0.14.1": + version "0.14.1" + resolved "https://registry.yarnpkg.com/@feugene/unocss-preset-granular/-/unocss-preset-granular-0.14.1.tgz#a9359fdbab9ad10d5af6b3db18c11e4adb551aa3" + integrity sha512-eNoY01qUwIPnal1cfVJCJO0wZzSYMjISJkJ148yOeqdH+pWkmLXQlhnC/Orv+IEIAVSjExOULLrSmsa0/dhiNA== dependencies: "@feugene/unocss-mini-extra-rules" "^0.8.1" From d48cb55bb07c4143cdd6294e22bd3351c8d500d1 Mon Sep 17 00:00:00 2001 From: fureev Date: Fri, 28 Aug 2026 12:55:13 +0300 Subject: [PATCH 2/2] =?UTF-8?q?feat(devtools):=20=D1=81=D0=B5=D0=BA=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D0=BE=D0=B2,=20?= =?UTF-8?q?=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=8B=D0=B5=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=20=D1=87=D0=B8=D1=82=D0=B0?= =?UTF-8?q?=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обратная сторона секции объявленных токенов: что компонент потребляет на самом деле — четырьмя группами по владельцу. `own` — объявлено им самим; `from other components` — с именем владельца, чтобы было видно, кого заденет правка; `foundation` — палитра, шкалы, тени, длительности; `unregistered` — не объявлен ни одним реестром, почти всегда опечатка. В строке фактическое значение и пометка `has fallback`. Включения между двумя множествами нет ни в одну сторону: объявленный токен может не потребляться, а потребляет компонент в основном чужое. Живой `GrButton` на стенде читает двенадцать токенов, своих из них два. Отсюда виден и путь значения: `--gr-button-primary-bg` отдаёт `#e546bd`, потому что его запас — `--gr-primary`, перекрашенный приложением. Индекс стилей теперь пишет потребление и с запасом тоже, помечая токен строгим, если хоть одно чтение идёт без запаса. Секция пустых токенов отбирает по этому флагу: чтение с запасом при пустом токене рисует запасным значением и находкой быть не может. --- packages/granularity-devtools/CHANGELOG.md | 12 +- packages/granularity-devtools/README.md | 16 ++- .../__tests__/componentTokens.plugin.test.ts | 31 +++++ .../src/__tests__/emptyTokens.test.ts | 33 +++-- .../src/__tests__/stylesheetIndex.test.ts | 34 ++++-- .../src/__tests__/usedTokens.test.ts | 100 +++++++++++++++ .../src/internal/stylesheetIndex.ts | 52 +++++--- .../src/plugin/componentTokens.ts | 26 +++- .../src/resolve/emptyTokens.ts | 12 +- .../src/resolve/usedTokens.ts | 115 ++++++++++++++++++ 10 files changed, 389 insertions(+), 42 deletions(-) create mode 100644 packages/granularity-devtools/src/__tests__/usedTokens.test.ts create mode 100644 packages/granularity-devtools/src/resolve/usedTokens.ts diff --git a/packages/granularity-devtools/CHANGELOG.md b/packages/granularity-devtools/CHANGELOG.md index c038a697..f27bcf29 100644 --- a/packages/granularity-devtools/CHANGELOG.md +++ b/packages/granularity-devtools/CHANGELOG.md @@ -27,7 +27,17 @@ to [Semantic Versioning](https://semver.org/). without fallbacks across the utility layer; on a clean stand that is three findings of somebody else's internal machinery. -- The stylesheet index now also maps class → tokens its rules read without a fallback, reusing the same walk +- **"Tokens the component reads" — four sections by owner**: `own`, `from other components` (named, so it is + clear whose token a change would also touch), `foundation` and `unregistered`. Each row carries the value from + computed style and a `has fallback` mark. + + This is the reverse of the existing "component tokens" section, and neither set contains the other: a declared + token may go unconsumed, while what a component consumes is mostly somebody else's. A live `GrButton` on the + playground reads twelve tokens, two of them its own. The chain is visible too — `--gr-button-primary-bg`, the + customisation point, resolves to `#e546bd` because its fallback is `--gr-primary`, which the app repainted. + +- The stylesheet index now also maps class → tokens its rules read, with a per-token "read without a fallback at + least once" flag, reusing the same walk that backs "classes without rules": `@media` / `@supports` / `@layer` included, the last `var()` of a fallback chain counted (empty, it drops the declaration just the same). diff --git a/packages/granularity-devtools/README.md b/packages/granularity-devtools/README.md index d725cf26..e637e4a8 100644 --- a/packages/granularity-devtools/README.md +++ b/packages/granularity-devtools/README.md @@ -27,7 +27,21 @@ объявленные, но не применившиеся, и `--gr-*`, выставленные на элементе, которых нет ни в одном реестре, — почти всегда опечатка. -**Токены, разрешающиеся в пустоту** — четвёртой секцией: `--gr-*`, который +**Токены, которые компонент читает** — четырьмя секциями по владельцу: `own` +(объявлены им самим), `from other components` (с именем владельца — правка +заденет и его), `foundation` (палитра, шкалы, тени, длительности) и +`unregistered` (не объявлен ни одним реестром — обычно опечатка). У каждого +токена фактическое значение и пометка `has fallback`, если он читается с +запасом. + +Это обратная сторона предыдущей секции, и включения между ними нет ни в одну +сторону: объявленный токен может не потребляться, а потребляет компонент в +основном чужое. Живой `GrButton` на стенде читает двенадцать токенов, из них +свои — два. Отсюда виден и путь значения: `--gr-button-primary-bg` (точка +кастомизации, читается с запасом) отдаёт `#e546bd`, потому что его запас — +`--gr-primary`, перекрашенный приложением. + +**Токены, разрешающиеся в пустоту** — отдельной секцией: `--gr-*`, который правило компонента читает **без запасного значения**, а браузер отдаёт пустым. Такое объявление отбраковывается на этапе вычисления, и компонент выходит без фона, без рамки, с прямыми углами — при зелёной сборке и валидном CSS. Смежную diff --git a/packages/granularity-devtools/src/__tests__/componentTokens.plugin.test.ts b/packages/granularity-devtools/src/__tests__/componentTokens.plugin.test.ts index 2922cef7..1fc71063 100644 --- a/packages/granularity-devtools/src/__tests__/componentTokens.plugin.test.ts +++ b/packages/granularity-devtools/src/__tests__/componentTokens.plugin.test.ts @@ -74,3 +74,34 @@ describe('секция «токены, разрешающиеся в пусто expect(inspect(mount('
'), 'RouterView')).toEqual([]) }) }) + +describe('секции потребляемых токенов', () => { + it('раскладывает по владельцу: своё, чужое компонентное, базовое', () => { + addStyle('.alert { background: var(--gr-alert-bg); border-radius: var(--gr-radius-control) }') + addStyle('.alert { gap: var(--gr-button-radius) }') + + const state = inspect(mount('
'), 'GrAlert') + const of = (type: string) => state.filter(row => row.type === type).map(row => row.key) + + expect(of('granularity tokens · used · own')).toEqual(['--gr-alert-bg']) + expect(of('granularity tokens · used · foundation')).toEqual(['--gr-radius-control']) + expect(of('granularity tokens · used · from other components')).toEqual(['--gr-button-radius']) + }) + + it('в строке чужого токена виден владелец', () => { + addStyle('.alert { gap: var(--gr-button-radius) }') + + const state = inspect(mount('
'), 'GrAlert') + const row = state.find(item => item.key === '--gr-button-radius') + + expect(row?.value).toContain('GrButton') + }) + + it('чтение с запасом помечено — пустым оно не ломается', () => { + addStyle('.alert { color: var(--gr-fg, #111) }') + + const state = inspect(mount('
'), 'GrAlert') + + expect(state.find(row => row.key === '--gr-fg')?.value).toContain('has fallback') + }) +}) diff --git a/packages/granularity-devtools/src/__tests__/emptyTokens.test.ts b/packages/granularity-devtools/src/__tests__/emptyTokens.test.ts index 03e7d2f0..797d52ba 100644 --- a/packages/granularity-devtools/src/__tests__/emptyTokens.test.ts +++ b/packages/granularity-devtools/src/__tests__/emptyTokens.test.ts @@ -9,6 +9,14 @@ function markup(html: string): Element { return host.firstElementChild! } +/** Индекс потребления: по умолчанию токен читается без запаса. */ +function index(map: Record, withFallback: string[] = []) { + return new Map(Object.entries(map).map(([className, tokens]) => [ + className, + new Map(tokens.map(token => [token, { strict: !withFallback.includes(token) }])), + ])) +} + function values(table: Record>) { return { read: (element: Element, token: string) => table[element.className.split(' ')[0]!]?.[token] ?? '' } } @@ -16,7 +24,7 @@ function values(table: Record>) { describe('токены, разрешающиеся в пустоту', () => { it('находит потребляемый токен без значения', () => { const root = markup('
') - const consumed = new Map([['panel', new Set(['--gr-radius-control'])]]) + const consumed = index({ panel: ['--gr-radius-control'] }) const report = emptyTokens(root, consumed, values({})) @@ -26,7 +34,7 @@ describe('токены, разрешающиеся в пустоту', () => { it('молчит, когда токен разрешается', () => { const root = markup('
') - const consumed = new Map([['panel', new Set(['--gr-radius-control'])]]) + const consumed = index({ panel: ['--gr-radius-control'] }) const report = emptyTokens(root, consumed, values({ panel: { '--gr-radius-control': '6px' } })) @@ -36,7 +44,7 @@ describe('токены, разрешающиеся в пустоту', () => { it('пробел за значение не считает: `getPropertyValue` возвращает его с ведущим пробелом', () => { const root = markup('
') - const consumed = new Map([['panel', new Set(['--gr-bg'])]]) + const consumed = index({ panel: ['--gr-bg'] }) const report = emptyTokens(root, consumed, values({ panel: { '--gr-bg': ' ' } })) @@ -45,7 +53,7 @@ describe('токены, разрешающиеся в пустоту', () => { it('заходит к потомкам: промах живёт не на корне, а на внутреннем элементе', () => { const root = markup('
') - const consumed = new Map([['badge', new Set(['--gr-badge-semi-radius-md'])]]) + const consumed = index({ badge: ['--gr-badge-semi-radius-md'] }) const report = emptyTokens(root, consumed, values({})) @@ -54,7 +62,7 @@ describe('токены, разрешающиеся в пустоту', () => { it('читает токен на том элементе, чьё правило его требует, а не на корне', () => { const root = markup('
') - const consumed = new Map([['inner', new Set(['--gr-fg'])]]) + const consumed = index({ inner: ['--gr-fg'] }) const report = emptyTokens(root, consumed, values({ inner: { '--gr-fg': '#111' } })) @@ -63,10 +71,7 @@ describe('токены, разрешающиеся в пустоту', () => { it('не повторяет токен, потребляемый несколькими классами', () => { const root = markup('
') - const consumed = new Map([ - ['a', new Set(['--gr-brd'])], - ['b', new Set(['--gr-brd'])], - ]) + const consumed = index({ a: ['--gr-brd'], b: ['--gr-brd'] }) const report = emptyTokens(root, consumed, values({})) @@ -74,9 +79,17 @@ describe('токены, разрешающиеся в пустоту', () => { expect(report.checked).toBe(2) }) + it('потребление с запасом не считает: оно рисует запасным значением', () => { + const root = markup('
') + + const report = emptyTokens(root, index({ rail: ['--gr-slider-rail'] }, ['--gr-slider-rail']), values({})) + + expect(report).toEqual({ empty: [], checked: 0 }) + }) + it('чужие переменные не считает: `--un-*` ведёт сам UnoCSS', () => { const root = markup('
') - const consumed = new Map([['shadow-sm', new Set(['--un-shadow-inset'])]]) + const consumed = index({ 'shadow-sm': ['--un-shadow-inset'] }) expect(emptyTokens(root, consumed, values({}))).toEqual({ empty: [], checked: 0 }) }) diff --git a/packages/granularity-devtools/src/__tests__/stylesheetIndex.test.ts b/packages/granularity-devtools/src/__tests__/stylesheetIndex.test.ts index 797283d2..6a9023cd 100644 --- a/packages/granularity-devtools/src/__tests__/stylesheetIndex.test.ts +++ b/packages/granularity-devtools/src/__tests__/stylesheetIndex.test.ts @@ -52,45 +52,61 @@ describe('потребление токенов без запасного зна it('записывает токен, читаемый правилом класса', () => { addStyle('.panel { border-radius: var(--gr-radius-control) }') - expect(stylesheetIndex().consumed.get('panel')).toEqual(new Set(['--gr-radius-control'])) + expect(stylesheetIndex().consumed.get('panel')).toEqual(new Map([['--gr-radius-control', { strict: true }]])) }) - it('пропускает `var(--x, …)`: с запасом класса отказов нет', () => { + it('потребление с запасом записывает, но помечает нестрогим', () => { addStyle('.rail { background: var(--gr-slider-rail, #e2e8f0) }') - expect(stylesheetIndex().consumed.get('rail')).toBeUndefined() + expect(stylesheetIndex().consumed.get('rail')).toEqual(new Map([['--gr-slider-rail', { strict: false }]])) + }) + + it('строгим считает токен, прочитанный без запаса хотя бы раз', () => { + addStyle('.dual { color: var(--gr-fg, #111) }') + addStyle('.dual { border-color: var(--gr-fg) }') + + expect(stylesheetIndex().consumed.get('dual')).toEqual(new Map([['--gr-fg', { strict: true }]])) }) it('оставляет ПОСЛЕДНИЙ `var()` цепочки запасных: пуст он — объявление всё равно отбраковано', () => { addStyle('.fill { background: var(--gr-slider-fill, var(--gr-primary)) }') - expect(stylesheetIndex().consumed.get('fill')).toEqual(new Set(['--gr-primary'])) + expect(stylesheetIndex().consumed.get('fill')).toEqual(new Map([ + ['--gr-slider-fill', { strict: false }], + ['--gr-primary', { strict: true }], + ])) }) it('собирает по всем объявлениям правила', () => { addStyle('.card { background: var(--gr-bg); border-color: var(--gr-brd) }') - expect(stylesheetIndex().consumed.get('card')).toEqual(new Set(['--gr-bg', '--gr-brd'])) + expect(stylesheetIndex().consumed.get('card')).toEqual(new Map([ + ['--gr-bg', { strict: true }], + ['--gr-brd', { strict: true }], + ])) }) it('раздаёт токены каждому классу составного селектора', () => { addStyle('.a .b { color: var(--gr-fg) }') const consumed = stylesheetIndex().consumed - expect(consumed.get('a')).toEqual(new Set(['--gr-fg'])) - expect(consumed.get('b')).toEqual(new Set(['--gr-fg'])) + expect(consumed.get('a')).toEqual(new Map([['--gr-fg', { strict: true }]])) + expect(consumed.get('b')).toEqual(new Map([['--gr-fg', { strict: true }]])) }) it('заходит внутрь @media — иначе адаптивное правило осталось бы неучтённым', () => { addStyle('@media (min-width: 768px) { .wide { gap: var(--gr-space-4) } }') - expect(stylesheetIndex().consumed.get('wide')).toEqual(new Set(['--gr-space-4'])) + expect(stylesheetIndex().consumed.get('wide')).toEqual(new Map([['--gr-space-4', { strict: true }]])) }) it('сливает токены из нескольких правил одного класса', () => { addStyle('.btn { color: var(--gr-fg) }') addStyle('.btn { background: var(--gr-bg) }') - expect(stylesheetIndex().consumed.get('btn')).toEqual(new Set(['--gr-fg', '--gr-bg'])) + expect(stylesheetIndex().consumed.get('btn')).toEqual(new Map([ + ['--gr-fg', { strict: true }], + ['--gr-bg', { strict: true }], + ])) }) }) diff --git a/packages/granularity-devtools/src/__tests__/usedTokens.test.ts b/packages/granularity-devtools/src/__tests__/usedTokens.test.ts new file mode 100644 index 00000000..a7a5e2c4 --- /dev/null +++ b/packages/granularity-devtools/src/__tests__/usedTokens.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' + +import { groupUsedTokens, usedTokens } from '../resolve/usedTokens' + +function markup(html: string): Element { + const host = document.createElement('div') + host.innerHTML = html + return host.firstElementChild! +} + +function index(map: Record, withFallback: string[] = []) { + return new Map(Object.entries(map).map(([className, tokens]) => [ + className, + new Map(tokens.map(token => [token, { strict: !withFallback.includes(token) }])), + ])) +} + +const values = (table: Record) => ({ read: (_: Element, token: string) => table[token] ?? '' }) + +describe('токены, которые компонент потребляет', () => { + it('свой токен отличает от чужого компонентного', () => { + const root = markup('
') + const used = usedTokens(root, 'GrAlert', index({ alert: ['--gr-alert-bg', '--gr-button-radius'] }), values({})) + + expect(used.find(token => token.name === '--gr-alert-bg')?.origin).toBe('own') + + const foreign = used.find(token => token.name === '--gr-button-radius') + expect(foreign?.origin).toBe('component') + expect(foreign?.owner).toBe('GrButton') + }) + + it('базовый токен помечает foundation, а не «ничей»', () => { + const root = markup('
') + const used = usedTokens(root, 'GrAlert', index({ alert: ['--gr-radius-control'] }), values({})) + + expect(used[0]!.origin).toBe('foundation') + expect(used[0]!.owner).toBeUndefined() + }) + + it('токен вне реестров помечает unregistered: почти всегда опечатка', () => { + const root = markup('
') + const used = usedTokens(root, 'GrAlert', index({ alert: ['--gr-radius-controll'] }), values({})) + + expect(used[0]!.origin).toBe('unknown') + }) + + it('значение берёт из вычисленного стиля', () => { + const root = markup('
') + const used = usedTokens(root, 'GrAlert', index({ alert: ['--gr-alert-bg'] }), values({ '--gr-alert-bg': '#111827' })) + + expect(used[0]!.value).toBe('#111827') + }) + + it('различает чтение с запасом и без', () => { + const root = markup('
') + const used = usedTokens( + root, + 'GrAlert', + index({ alert: ['--gr-alert-bg', '--gr-fg'] }, ['--gr-fg']), + values({}), + ) + + expect(used.find(token => token.name === '--gr-alert-bg')?.strict).toBe(true) + expect(used.find(token => token.name === '--gr-fg')?.strict).toBe(false) + }) + + it('токен, прочитанный без запаса хоть одним классом, строгий', () => { + const root = markup('
') + const consumed = new Map([ + ['a', new Map([['--gr-fg', { strict: false }]])], + ['b', new Map([['--gr-fg', { strict: true }]])], + ]) + + expect(usedTokens(root, 'GrAlert', consumed, values({}))).toHaveLength(1) + expect(usedTokens(root, 'GrAlert', consumed, values({}))[0]!.strict).toBe(true) + }) + + it('чужие переменные не считает', () => { + const root = markup('
') + + expect(usedTokens(root, 'GrAlert', index({ 'shadow-sm': ['--un-shadow-inset'] }), values({}))).toEqual([]) + }) + + it('раскладка идёт своим, чужим компонентным, базовым, нереестровым', () => { + const root = markup('
') + const used = usedTokens( + root, + 'GrAlert', + index({ alert: ['--gr-alert-bg', '--gr-button-radius', '--gr-radius-control', '--gr-nope'] }), + values({}), + ) + + const grouped = groupUsedTokens(used) + expect(grouped.own.map(token => token.name)).toEqual(['--gr-alert-bg']) + expect(grouped.component.map(token => token.name)).toEqual(['--gr-button-radius']) + expect(grouped.foundation.map(token => token.name)).toEqual(['--gr-radius-control']) + expect(grouped.unknown.map(token => token.name)).toEqual(['--gr-nope']) + }) +}) diff --git a/packages/granularity-devtools/src/internal/stylesheetIndex.ts b/packages/granularity-devtools/src/internal/stylesheetIndex.ts index e590dcd2..b20e0720 100644 --- a/packages/granularity-devtools/src/internal/stylesheetIndex.ts +++ b/packages/granularity-devtools/src/internal/stylesheetIndex.ts @@ -11,29 +11,43 @@ import { classNamesFromSelector } from '../resolve/unstyledClasses' export interface StylesheetIndex { styled: Set /** - * Класс → токены, которые его правила читают БЕЗ запасного значения. + * Класс → токены, которые читают его правила, и флаг «хоть раз без запаса». * * `var(--x)` без запасного значения — валидный CSS, который при пустом * токене не красит вовсе: свойство становится недействительным на этапе - * вычисления. С запасным значением такого класса отказов нет, поэтому - * `var(--x, …)` сюда не попадает. + * вычисления. С `var(--x, …)` такого класса отказов нет, поэтому флаг у него + * `false`. Само потребление записывается в обоих случаях: «какие токены + * читает компонент» — вопрос отдельный от «где он сломается». */ - consumed: Map> + consumed: Map> /** Листы, которые не удалось прочитать: кросс-доменные бросают `SecurityError`. */ unreadableSheets: number } +export interface ConsumedToken { + /** + * Хотя бы одно потребление записано как `var(--x)` без запаса. Достаточно + * одного: пустой токен уронит именно это объявление, сколько бы соседних + * ни было написано с запасом. + */ + strict: boolean +} + /** - * Токены, читаемые объявлением без запасного значения. + * Токены, читаемые объявлением. * * Различает `var(--x)` и `var(--x, …)` по символу за именем: запятая — запас * есть. Вложенные `var()` внутри запасного значения находятся тем же проходом, * потому что разбор идёт по всем вхождениям, а не по одному верхнему. */ -function tokensWithoutFallback(value: string, into: Set): void { +function readTokens(value: string, into: Map): void { for (const match of value.matchAll(/var\(\s*(--[\w-]+)\s*([,)])/g)) { - if (match[2] === ')') - into.add(match[1]!) + const name = match[1]! + const strict = match[2] === ')' + const known = into.get(name) + if (known) + known.strict ||= strict + else into.set(name, { strict }) } } @@ -45,7 +59,11 @@ let observer: MutationObserver | null = null * коллекции старого образца, и итератора у них нет ни в jsdom, ни в части * браузерных сред. */ -function collectFromRules(rules: CSSRuleList, styled: Set, consumed: Map>): void { +function collectFromRules( + rules: CSSRuleList, + styled: Set, + consumed: Map>, +): void { for (let index = 0; index < rules.length; index += 1) { const rule = rules.item(index) if (!rule) @@ -61,16 +79,20 @@ function collectFromRules(rules: CSSRuleList, styled: Set, consumed: Map for (const name of names) styled.add(name) - const read = new Set() + const read = new Map() const declarations = (rule as CSSStyleRule).style for (let property = 0; property < (declarations?.length ?? 0); property += 1) - tokensWithoutFallback(declarations.getPropertyValue(declarations.item(property)), read) + readTokens(declarations.getPropertyValue(declarations.item(property)), read) if (read.size) { for (const name of names) { - const bucket = consumed.get(name) ?? new Set() - for (const token of read) - bucket.add(token) + const bucket = consumed.get(name) ?? new Map() + for (const [token, usage] of read) { + const known = bucket.get(token) + if (known) + known.strict ||= usage.strict + else bucket.set(token, { strict: usage.strict }) + } consumed.set(name, bucket) } } @@ -88,7 +110,7 @@ function collectFromRules(rules: CSSRuleList, styled: Set, consumed: Map function build(): StylesheetIndex { const styled = new Set() - const consumed = new Map>() + const consumed = new Map>() let unreadableSheets = 0 const sheets = document.styleSheets diff --git a/packages/granularity-devtools/src/plugin/componentTokens.ts b/packages/granularity-devtools/src/plugin/componentTokens.ts index 95d2b9ed..104e94b6 100644 --- a/packages/granularity-devtools/src/plugin/componentTokens.ts +++ b/packages/granularity-devtools/src/plugin/componentTokens.ts @@ -1,9 +1,11 @@ import type { PluginSetupFunction } from '@vue/devtools-kit' import type { TokenReading } from '../resolve/tokenUsage' +import type { UsedToken } from '../resolve/usedTokens' import { stylesheetIndex } from '../internal/stylesheetIndex' import { emptyTokens } from '../resolve/emptyTokens' import { tokenSections } from '../resolve/tokenUsage' +import { groupUsedTokens, usedTokens } from '../resolve/usedTokens' type DevtoolsApi = Parameters[0] @@ -21,6 +23,20 @@ function rootElement(instance: InspectPayload['componentInstance']): HTMLElement return el instanceof HTMLElement ? el : null } +/** + * Строка потребляемого токена: значение, владелец и пометка о чтении без + * запаса. Пустое значение показывается как `(empty)` — так же, как в секциях + * объявленного, чтобы обе читались одинаково. + */ +function usedEntries(type: string, tokens: UsedToken[]): unknown[] { + return tokens.map(token => ({ + type, + key: token.name, + value: `${token.value || '(empty)'}${token.owner ? ` · ${token.owner}` : ''}${token.strict ? '' : ' · has fallback'}`, + editable: false, + })) +} + function entries(type: string, readings: TokenReading[]): unknown[] { return readings.map(reading => ({ type, @@ -54,11 +70,15 @@ export function registerComponentTokens(api: DevtoolsApi): void { }) const index = stylesheetIndex() - const resolved = emptyTokens(el, index.consumed, { - read: (element, token) => getComputedStyle(element).getPropertyValue(token), - }) + const read = (element: Element, token: string): string => getComputedStyle(element).getPropertyValue(token) + const resolved = emptyTokens(el, index.consumed, { read }) + const used = groupUsedTokens(usedTokens(el, name, index.consumed, { read })) payload.instanceData.state.push( + ...usedEntries('granularity tokens · used · own', used.own), + ...usedEntries('granularity tokens · used · from other components', used.component), + ...usedEntries('granularity tokens · used · foundation', used.foundation), + ...usedEntries('granularity tokens · used · unregistered', used.unknown), ...entries('granularity tokens', sections.applied), ...entries('granularity tokens · unset', sections.unset), ...entries('granularity tokens · not declared', sections.unknown), diff --git a/packages/granularity-devtools/src/resolve/emptyTokens.ts b/packages/granularity-devtools/src/resolve/emptyTokens.ts index 93ea96f6..f5148aac 100644 --- a/packages/granularity-devtools/src/resolve/emptyTokens.ts +++ b/packages/granularity-devtools/src/resolve/emptyTokens.ts @@ -16,6 +16,8 @@ * `:hover`). Здесь наоборот: пусто И потребляется без запаса. */ +import type { ConsumedToken } from '../internal/stylesheetIndex' + export interface EmptyToken { token: string /** Класс, чьё правило читает токен, — с него начинать поиск причины. */ @@ -39,6 +41,8 @@ export interface EmptyTokenReport { */ const OWN_PREFIX = '--gr-' +export type ConsumedIndex = ReadonlyMap> + export interface EmptyTokenProbe { /** Значение токена в вычисленном стиле конкретного элемента. */ read: (element: Element, token: string) => string @@ -51,7 +55,7 @@ export interface EmptyTokenProbe { */ export function emptyTokens( root: Element, - consumed: ReadonlyMap>, + consumed: ConsumedIndex, probe: EmptyTokenProbe, ): EmptyTokenReport { const empty: EmptyToken[] = [] @@ -60,8 +64,10 @@ export function emptyTokens( for (const element of [root, ...root.querySelectorAll('*')]) { for (const className of element.classList) { - for (const token of consumed.get(className) ?? []) { - if (!token.startsWith(OWN_PREFIX)) + for (const [token, usage] of consumed.get(className) ?? []) { + // Потребление с запасом при пустом токене не ломается — оно рисует + // запасным значением, и находкой быть не может. + if (!usage.strict || !token.startsWith(OWN_PREFIX)) continue checked += 1 diff --git a/packages/granularity-devtools/src/resolve/usedTokens.ts b/packages/granularity-devtools/src/resolve/usedTokens.ts new file mode 100644 index 00000000..b7d109b1 --- /dev/null +++ b/packages/granularity-devtools/src/resolve/usedTokens.ts @@ -0,0 +1,115 @@ +import type { ConsumedIndex } from './emptyTokens' +import { grComponentTokens, grFoundationTokens } from '@feugene/granularity/tokens' + +/** + * Все токены дизайн-системы, которые компонент читает на самом деле, — с тем, + * кто их объявил, и с фактическим значением. + * + * Секция «токены компонента» отвечает на обратный вопрос: что компонент + * **объявляет** своим `tokens.json`. Между этими множествами нет включения ни в + * одну сторону. Объявленный токен может не потребляться (объявлен ради + * потребителя), а потребляется компонент в основном чужим: `--gr-primary`, + * `--gr-radius-control`, `--gr-muted-fg` объявлены не им. Из-за этого вопрос + * «какие токены надо задать, чтобы перекрасить вот это» до сих пор решался + * чтением исходников. + * + * Владелец берётся из реестров пакета (`@feugene/granularity/tokens`), а + * значение — из вычисленного стиля того элемента, чьё правило токен читает. + */ + +interface ComponentTokenDefinition { + owner: string + name: string +} + +const OWNER_BY_TOKEN = new Map( + (grComponentTokens as readonly ComponentTokenDefinition[]).map(token => [token.name, token.owner]), +) + +const FOUNDATION = new Set(grFoundationTokens.map(token => token.name)) + +/** Чей токен относительно выбранного компонента. */ +export type TokenOrigin + /** Объявлен этим же компонентом. */ + = | 'own' + /** Объявлен другим компонентом — правка заденет и его. */ + | 'component' + /** Базовый токен дизайн-системы: палитра, шкалы, тени, длительности. */ + | 'foundation' + /** Ни один реестр пакета его не объявляет. */ + | 'unknown' + +export interface UsedToken { + name: string + origin: TokenOrigin + /** Владелец при `origin: 'component'`. */ + owner?: string + value: string + /** Хоть раз читается без запасного значения — пустым уронит объявление. */ + strict: boolean + /** Класс, чьё правило читает токен. */ + className: string +} + +export interface UsedTokenProbe { + read: (element: Element, token: string) => string +} + +const OWN_PREFIX = '--gr-' + +function originOf(token: string, component: string): { origin: TokenOrigin, owner?: string } { + const owner = OWNER_BY_TOKEN.get(token) + if (owner) + return owner === component ? { origin: 'own' } : { origin: 'component', owner } + return FOUNDATION.has(token) ? { origin: 'foundation' } : { origin: 'unknown' } +} + +/** + * Обход тот же, что у `emptyTokens`: элемент и потомки, значение читается на + * том элементе, чьё правило токен требует. Токен, встреченный дважды, остаётся + * в списке один раз — первым попаданием, а не последним: чем ближе к корню, тем + * понятнее, откуда начинать. + */ +export function usedTokens( + root: Element, + component: string, + consumed: ConsumedIndex, + probe: UsedTokenProbe, +): UsedToken[] { + const found = new Map() + + for (const element of [root, ...root.querySelectorAll('*')]) { + for (const className of element.classList) { + for (const [name, usage] of consumed.get(className) ?? []) { + if (!name.startsWith(OWN_PREFIX)) + continue + + const known = found.get(name) + if (known) { + known.strict ||= usage.strict + continue + } + + found.set(name, { + name, + ...originOf(name, component), + value: probe.read(element, name).trim(), + strict: usage.strict, + className, + }) + } + } + } + + return [...found.values()] +} + +/** Раскладка по владельцу — в том порядке, в каком её читают: сначала своё. */ +export function groupUsedTokens(tokens: readonly UsedToken[]): Record { + return { + own: tokens.filter(token => token.origin === 'own'), + component: tokens.filter(token => token.origin === 'component'), + foundation: tokens.filter(token => token.origin === 'foundation'), + unknown: tokens.filter(token => token.origin === 'unknown'), + } +}