Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions apps/playground/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ overlays», «Granularity app» и «Granularity issues». Панель можн
тем, кому адресован `Esc`, и судьбой фокуса. Секции пропов и токенов живут в штатном инспекторе компонентов, на
выбранном `Gr*`.

Стенд удобен и для проверки секции «tokens · consumed but empty»: разбор в `uno.config.ts` объясняет, почему
`themes.tokensFile` сносит шкалу радиусов, — включите его на минуту, и секция назовёт одиннадцать `--gr-*`
типографики и анимации, которые правила читают, а браузер отдаёт пустыми. Со штатным конфигом секция пуста.

Лента событий пишется, **только когда включена запись** — кнопка в правом верхнем углу вкладки Timeline. Пустая лента
чаще всего значит именно это.

Expand Down
2 changes: 1 addition & 1 deletion apps/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions apps/showcase/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions packages/granularity-charts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/granularity-chrono/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/granularity-dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
34 changes: 34 additions & 0 deletions packages/granularity-devtools/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,40 @@ 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.

- **"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).

## [v0.2.0] 2026-08-28

### Added
Expand Down
29 changes: 28 additions & 1 deletion packages/granularity-devtools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,31 @@
объявленные, но не применившиеся, и `--gr-*`, выставленные на элементе, которых
нет ни в одном реестре, — почти всегда опечатка.

**Токены, которые компонент читает** — четырьмя секциями по владельцу: `own`
(объявлены им самим), `from other components` (с именем владельца — правка
заденет и его), `foundation` (палитра, шкалы, тени, длительности) и
`unregistered` (не объявлен ни одним реестром — обычно опечатка). У каждого
токена фактическое значение и пометка `has fallback`, если он читается с
запасом.

Это обратная сторона предыдущей секции, и включения между ними нет ни в одну
сторону: объявленный токен может не потребляться, а потребляет компонент в
основном чужое. Живой `GrButton` на стенде читает двенадцать токенов, из них
свои — два. Отсюда виден и путь значения: `--gr-button-primary-bg` (точка
кастомизации, читается с запасом) отдаёт `#e546bd`, потому что его запас —
`--gr-primary`, перекрашенный приложением.

**Токены, разрешающиеся в пустоту** — отдельной секцией: `--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
Expand Down Expand Up @@ -94,7 +119,9 @@ await page.evaluate(() =>

## Чего панель не делает

Статические вопросы закрывает CLI пресета, и дублировать его незачем.
Статические вопросы закрывает CLI пресета, и дублировать его незачем: где
статики хватает, панель молчит — она добавляет только то, что видно
исключительно в браузере.
Промахи ключей i18n не считаются: для этого пришлось бы подменить `t` у чужого
адаптера, то есть писать в состояние приложения.

Expand Down
2 changes: 1 addition & 1 deletion packages/granularity-devtools/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// @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<string, (payload: unknown) => 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('<div class="trigger"></div>'))

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('<div class="trigger"></div>')).filter(row => row.type === EMPTY)).toEqual([])
})

it('чужой компонент не трогает', () => {
addStyle('.trigger { border-radius: var(--gr-radius-control) }')

expect(inspect(mount('<div class="trigger"></div>'), '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('<div class="alert"></div>'), '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('<div class="alert"></div>'), '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('<div class="alert"></div>'), 'GrAlert')

expect(state.find(row => row.key === '--gr-fg')?.value).toContain('has fallback')
})
})
104 changes: 104 additions & 0 deletions packages/granularity-devtools/src/__tests__/emptyTokens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// @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 index(map: Record<string, string[]>, 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<string, Record<string, string>>) {
return { read: (element: Element, token: string) => table[element.className.split(' ')[0]!]?.[token] ?? '' }
}

describe('токены, разрешающиеся в пустоту', () => {
it('находит потребляемый токен без значения', () => {
const root = markup('<div class="panel"></div>')
const consumed = index({ panel: ['--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('<div class="panel"></div>')
const consumed = index({ panel: ['--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('<div class="panel"></div>')
const consumed = index({ panel: ['--gr-bg'] })

const report = emptyTokens(root, consumed, values({ panel: { '--gr-bg': ' ' } }))

expect(report.empty).toHaveLength(1)
})

it('заходит к потомкам: промах живёт не на корне, а на внутреннем элементе', () => {
const root = markup('<div class="root"><span class="badge"></span></div>')
const consumed = index({ badge: ['--gr-badge-semi-radius-md'] })

const report = emptyTokens(root, consumed, values({}))

expect(report.empty.map(finding => finding.className)).toEqual(['badge'])
})

it('читает токен на том элементе, чьё правило его требует, а не на корне', () => {
const root = markup('<div class="root"><span class="inner"></span></div>')
const consumed = index({ inner: ['--gr-fg'] })

const report = emptyTokens(root, consumed, values({ inner: { '--gr-fg': '#111' } }))

expect(report.empty).toEqual([])
})

it('не повторяет токен, потребляемый несколькими классами', () => {
const root = markup('<div class="a"><span class="b"></span></div>')
const consumed = index({ a: ['--gr-brd'], b: ['--gr-brd'] })

const report = emptyTokens(root, consumed, values({}))

expect(report.empty).toHaveLength(1)
expect(report.checked).toBe(2)
})

it('потребление с запасом не считает: оно рисует запасным значением', () => {
const root = markup('<div class="rail"></div>')

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('<div class="shadow-sm"></div>')
const consumed = index({ 'shadow-sm': ['--un-shadow-inset'] })

expect(emptyTokens(root, consumed, values({}))).toEqual({ empty: [], checked: 0 })
})

it('классы без потребления не считаются проверенными', () => {
const root = markup('<div class="plain"></div>')

const report = emptyTokens(root, new Map(), values({}))

expect(report).toEqual({ empty: [], checked: 0 })
})
})
Loading
Loading