|
|
/**
|
|
|
* theming-census — how much of each recipe's APPEARANCE can a theme reach
|
|
|
* through the component's own public tokens?
|
|
|
*
|
|
|
* node --import tsx/esm scripts/theming-census.ts # summary + table
|
|
|
* node --import tsx/esm scripts/theming-census.ts --json # machine-readable
|
|
|
* node --import tsx/esm scripts/theming-census.ts --only tabs
|
|
|
* node --import tsx/esm scripts/theming-census.ts --report # docs/audit/theming/
|
|
|
* node --import tsx/esm scripts/theming-census.ts --names # naming grammar (D-TH.6)
|
|
|
*
|
|
|
* WHY. The recipe contract already says every recipe declares its knobs in
|
|
|
* `lib/recipes/base.ts` (recipe-contract §1, theming §6), and the R-4.x guards
|
|
|
* make sure nothing is a literal. But nothing asserted that a knob is REACHABLE
|
|
|
* by a theme: a recipe that binds every knob to a global primitive
|
|
|
* (`var(--radius-md)`) or to a private (`--_c-radius`) passes every guard and
|
|
|
* can only be themed by moving the whole system. `navigation-menu` shipped that
|
|
|
* way until 2026-08-19 (PLAN-theming.md §0). This script is the baseline and the
|
|
|
* per-component gate of that axis: coverage must go UP, literals must go DOWN,
|
|
|
* and the number must be reproducible by anyone — never quoted from memory.
|
|
|
*
|
|
|
* WHAT COUNTS. A declaration is a KNOB when its property is one of
|
|
|
* `KNOB_PROPS` (appearance: fill, ink, border, radius, shadow, spacing, type,
|
|
|
* size). Layout mechanics (display, position, flex, grid, overflow, …) are not
|
|
|
* knobs. Each knob is classed by WHERE its value comes from:
|
|
|
* public — `var(--{c}-…)` the component's own contract token
|
|
|
* private — `var(--_{c}-…)` a component-internal name (a theme cannot
|
|
|
* name it; fine ONLY if the private derives
|
|
|
* from a public — the report's §3 per component)
|
|
|
* system — a transversal system token the recipe contract says a recipe
|
|
|
* CONSUMES rather than owns: state layer, focus ring, depth
|
|
|
* planes, motion, z-bands, opacity, shape, floating gap
|
|
|
* global — any other `var(--…)`: a raw primitive (`--space-*`,
|
|
|
* `--radius-*`, `--color-*`, `--font-size-*`, `--size-*`…)
|
|
|
* literal — no `var(` at all (`8px`, `1.25`, `#fff`)
|
|
|
* Reach = public / (public + private + global + literal). `system` is reported
|
|
|
* but excluded from the ratio on purpose: it is themeable at the system level by
|
|
|
* design (recipe-contract §2).
|
|
|
*
|
|
|
* REPORT. `--report` writes the audit under `docs/audit/theming/`: a root
|
|
|
* `README.md` with the whole-catalogue view, and one `{c}.md` per component
|
|
|
* with its analysis AND a proposed correction (the token to declare, its
|
|
|
* value-preserving definition, the TSC scope). The proposal derives names from
|
|
|
* recipe-contract §1 + theming §6.7 — it never invents a name, and it marks
|
|
|
* `⚠ decisión` wherever the doctrine does not decide for us.
|
|
|
*
|
|
|
* LIMITS (honest). A regex over CSS text: a declaration carrying SEVERAL tokens
|
|
|
* is classed by the first class that matches, in order public → private →
|
|
|
* system → global (so `outline: var(--focus-ring-width) solid
|
|
|
* var(--color-primary-border)` reads as `system`); values inside `@keyframes`
|
|
|
* count like any other; another component's public token (`--calendar-*`
|
|
|
* consumed from `range-calendar`) counts as `global`, because the needle is
|
|
|
* this component's own directory name — the report flags those as BORROWED
|
|
|
* separately. It over-reports, never under-reports, so a PASS here is a floor.
|
|
|
* The guard that makes this binding lives in `component-audit` (R-5,
|
|
|
* PLAN-theming.md F0/F3).
|
|
|
*/
|
|
|
|
|
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
|
import { join, resolve } from 'node:path';
|
|
|
|
|
|
import { compileMorfo } from '../src/uix/morfo/compile';
|
|
|
import type { Morfo } from '../src/uix/morfo/types';
|
|
|
|
|
|
const ROOT = resolve('src/uix/eidos/components');
|
|
|
const REPORT_DIR = resolve('docs/audit/theming');
|
|
|
const CONTRACT = readFileSync(resolve('src/uix/eidos/lib/recipes/base.ts'), 'utf8').replace(
|
|
|
/\r\n/g,
|
|
|
'\n'
|
|
|
);
|
|
|
|
|
|
const KNOB_PROPS =
|
|
|
/^(background|background-color|background-image|color|border|border-color|border-width|border-radius|border-block|border-inline|border-top|border-bottom|border-left|border-right|border-block-start|border-block-end|border-inline-start|border-inline-end|box-shadow|outline|outline-color|outline-width|padding|padding-inline|padding-block|padding-top|padding-bottom|padding-left|padding-right|padding-inline-start|padding-inline-end|padding-block-start|padding-block-end|gap|row-gap|column-gap|font-size|font-weight|font-family|line-height|letter-spacing|min-block-size|block-size|min-inline-size|inline-size|height|min-height|width|min-width|opacity|fill|stroke|accent-color|caret-color|text-decoration-color|filter|backdrop-filter)$/;
|
|
|
|
|
|
/** Transversal systems a recipe CONSUMES (themeable at system level — not per component). */
|
|
|
const SYSTEM =
|
|
|
/var\(--(state-(hover|press|selected)|focus-ring[a-z-]*|depth-[a-z-]+|motion-[a-z-]+|duration-[a-z-]+|ease-[a-z-]+|z-index-[a-z-]+|opacity-[a-z-]+|shape-[a-z-]+|floating-gap[a-z-]*|ring-inset-[a-z-]+)\b/;
|
|
|
|
|
|
const INERT = new Set([
|
|
|
'0',
|
|
|
'none',
|
|
|
'auto',
|
|
|
'inherit',
|
|
|
'initial',
|
|
|
'unset',
|
|
|
'transparent',
|
|
|
'currentColor',
|
|
|
'currentcolor',
|
|
|
'revert',
|
|
|
'revert-layer'
|
|
|
]);
|
|
|
|
|
|
export type KnobClass = 'public' | 'private' | 'system' | 'global' | 'literal';
|
|
|
|
|
|
export interface Knob {
|
|
|
file: string;
|
|
|
line: number;
|
|
|
selector: string;
|
|
|
prop: string;
|
|
|
value: string;
|
|
|
klass: KnobClass;
|
|
|
}
|
|
|
|
|
|
/** A `--_{c}-x: …` declaration found in the component's own CSS. */
|
|
|
export interface PrivateDecl {
|
|
|
name: string;
|
|
|
file: string;
|
|
|
line: number;
|
|
|
selector: string;
|
|
|
value: string;
|
|
|
/** Where the private's own value comes from — the same five classes. */
|
|
|
source: KnobClass;
|
|
|
}
|
|
|
|
|
|
export interface CensusRow {
|
|
|
component: string;
|
|
|
knobs: number;
|
|
|
public: number;
|
|
|
private: number;
|
|
|
system: number;
|
|
|
global: number;
|
|
|
literal: number;
|
|
|
/** public / (public + private + global + literal), 0..1 — `system` excluded. */
|
|
|
reach: number;
|
|
|
contractKeys: number;
|
|
|
hasSize: boolean;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Comments are blanked, not deleted, so `file:line` stays exact and the scanned
|
|
|
* text keeps the same offsets as the source.
|
|
|
*/
|
|
|
function strip(css: string): string {
|
|
|
return css.replace(/\r\n/g, '\n').replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Typographic primitives (D-TH.2, signed 2026-08-20 — option (b) of the
|
|
|
* `heading` sheet). Their theming surface IS the named-style layer, so
|
|
|
* `--style-*` counts as a transversal SYSTEM for them, never as a global that
|
|
|
* they should have minted a token for. Minting `--heading-*` would be one
|
|
|
* alias per axis × level over a layer that is already public and live — the
|
|
|
* class of alias the changelog §39 purge killed.
|
|
|
*
|
|
|
* The criterion is measured, not guessed: either the recipe SELECTS by
|
|
|
* `data-style` (`heading`, `text`, `s-text` — the named style is their API)
|
|
|
* or it is bound wholesale to ONE named style (`code` → `--style-code-*`,
|
|
|
* `display` → `--style-hero-*`, `label` → `--style-label-*`).
|
|
|
*
|
|
|
* `code-block` is deliberately NOT here: it reads a couple of style tokens for
|
|
|
* its text but owns real box chrome (border, padding, surface), and that IS
|
|
|
* its to own.
|
|
|
*/
|
|
|
const TYPOGRAPHIC_PRIMITIVES = new Set(['heading', 'text', 's-text', 'code', 'display', 'label']);
|
|
|
|
|
|
const STYLE_LAYER = /var\(--style-[a-z0-9-]+/;
|
|
|
|
|
|
/**
|
|
|
* The vocabulary a SHARED LAYER publishes, and who consumes it (D-TH.2-b
|
|
|
* precedent, extended 2026-08-21 with the `calendar-surface` design).
|
|
|
*
|
|
|
* Same argument as the typographic primitives: when the layer owns an axis,
|
|
|
* the consumer's theming surface for that axis IS the layer, so reading
|
|
|
* `--{layer-vocabulary}-*` is reaching a live public surface — not a raw
|
|
|
* global the component should have minted a token for. Minting one would be
|
|
|
* the parallel vocabulary the layer doctrine forbids, AND it would outrank the
|
|
|
* layer for every other consumer.
|
|
|
*
|
|
|
* Until this existed the census PENALISED doing the correct thing:
|
|
|
* `range-calendar`, `month-grid` and `year-grid` mint zero tokens and read
|
|
|
* 114 / 77 / 77 references from `--calendar-*` — measured — and all three
|
|
|
* scored 0 % reach.
|
|
|
*
|
|
|
* DELIBERATELY not registered yet: `list-surface`. Its consumers bridge
|
|
|
* through PRIVATES (`--_listbox-item-*: var(--list-item-*)`), which is a
|
|
|
* different shape than reading the layer directly, and flipping it moves ten
|
|
|
* components at once — a separate decision (next-features §13).
|
|
|
*/
|
|
|
const LAYER_VOCABULARY: { layer: string; consumers: Set<string>; needle: RegExp }[] = [
|
|
|
{
|
|
|
layer: 'calendar-surface',
|
|
|
consumers: new Set(['range-calendar', 'month-grid', 'year-grid', 'date-picker']),
|
|
|
needle: /var\(\s*--calendar-[a-z0-9-]+/
|
|
|
}
|
|
|
];
|
|
|
|
|
|
function classify(
|
|
|
value: string,
|
|
|
pubNeedle: string,
|
|
|
privNeedle: string,
|
|
|
component?: string
|
|
|
): KnobClass {
|
|
|
if (value.includes(pubNeedle)) return 'public';
|
|
|
// BEFORE the private check, and on purpose: a typographic primitive writes
|
|
|
// `var(--_heading-font-size, var(--style-h2-font-size))`, where the private
|
|
|
// is the per-INSTANCE escape hatch the wrapper sets from a prop and the
|
|
|
// named style is the THEMING surface. Reading the private first would score
|
|
|
// the whole primitive as unreachable when it is, in fact, fully themeable —
|
|
|
// through the layer that owns it.
|
|
|
if (component && TYPOGRAPHIC_PRIMITIVES.has(component) && STYLE_LAYER.test(value))
|
|
|
return 'system';
|
|
|
// Same reasoning, one layer up: a shared layer's vocabulary read by one of
|
|
|
// its declared consumers IS that consumer's theming surface for the axis.
|
|
|
if (component && LAYER_VOCABULARY.some((l) => l.consumers.has(component) && l.needle.test(value)))
|
|
|
return 'system';
|
|
|
if (value.includes(privNeedle)) return 'private';
|
|
|
if (SYSTEM.test(value)) return 'system';
|
|
|
if (value.includes('var(--')) return 'global';
|
|
|
return 'literal';
|
|
|
}
|
|
|
|
|
|
function recipeBlock(c: string): string | null {
|
|
|
const a = CONTRACT.indexOf("\n\t'" + c + "': {");
|
|
|
const b = CONTRACT.indexOf('\n\t' + c + ': {');
|
|
|
const start = a >= 0 ? a : b;
|
|
|
if (start < 0) return null;
|
|
|
const end = CONTRACT.indexOf('\n\t},', start);
|
|
|
return CONTRACT.slice(start, end < 0 ? undefined : end);
|
|
|
}
|
|
|
|
|
|
function contractKeysFor(c: string): number {
|
|
|
const block = recipeBlock(c);
|
|
|
if (block === null) return 0;
|
|
|
return (block.match(/^\t\t'?[a-z0-9-]+'?\s*:/gm) ?? []).length;
|
|
|
}
|
|
|
|
|
|
/** Every top-level key of the recipe block — public AND `_private` forwards. */
|
|
|
function contractKeyNames(c: string): string[] {
|
|
|
const block = recipeBlock(c);
|
|
|
if (block === null) return [];
|
|
|
return [...block.matchAll(/^\t\t'?(_?[a-z0-9-]+)'?\s*:/gm)].map((m) => m[1]);
|
|
|
}
|
|
|
|
|
|
export interface Scan {
|
|
|
row: CensusRow;
|
|
|
knobs: Knob[];
|
|
|
privates: PrivateDecl[];
|
|
|
/** Privates the CSS consumes but never declares — from `base.ts` or an inline style. */
|
|
|
privatesFromContract: string[];
|
|
|
files: string[];
|
|
|
}
|
|
|
|
|
|
/** Every component directory — used to spot a token BORROWED from another recipe. */
|
|
|
const COMPONENT_DIRS = readdirSync(ROOT).filter((d) => statSync(join(ROOT, d)).isDirectory());
|
|
|
|
|
|
export function scanComponent(dir: string): Scan | null {
|
|
|
const d = join(ROOT, dir);
|
|
|
if (!statSync(d).isDirectory()) return null;
|
|
|
const files = readdirSync(d).filter((f) => f.endsWith('.css'));
|
|
|
if (files.length === 0) return null;
|
|
|
|
|
|
const pubNeedle = 'var(--' + dir + '-';
|
|
|
const privNeedle = 'var(--_' + dir + '-';
|
|
|
const row: CensusRow = {
|
|
|
component: dir,
|
|
|
knobs: 0,
|
|
|
public: 0,
|
|
|
private: 0,
|
|
|
system: 0,
|
|
|
global: 0,
|
|
|
literal: 0,
|
|
|
reach: 0,
|
|
|
contractKeys: contractKeysFor(dir),
|
|
|
hasSize: false
|
|
|
};
|
|
|
const knobs: Knob[] = [];
|
|
|
const privates: PrivateDecl[] = [];
|
|
|
const usedPrivates = new Set<string>();
|
|
|
|
|
|
for (const file of files) {
|
|
|
const body = strip(readFileSync(join(d, file), 'utf8'));
|
|
|
if (body.includes('data-size')) row.hasSize = true;
|
|
|
const lineStarts: number[] = [0];
|
|
|
for (let i = 0; i < body.length; i++) if (body.charCodeAt(i) === 10) lineStarts.push(i + 1);
|
|
|
const lineAt = (idx: number) => {
|
|
|
let lo = 0;
|
|
|
let hi = lineStarts.length - 1;
|
|
|
while (lo < hi) {
|
|
|
const mid = (lo + hi + 1) >> 1;
|
|
|
if (lineStarts[mid] <= idx) lo = mid;
|
|
|
else hi = mid - 1;
|
|
|
}
|
|
|
return lo + 1;
|
|
|
};
|
|
|
// Block-wise so every declaration carries the selector it lives under.
|
|
|
// `[^{}]` cannot cross a brace, so an @media's inner rules are matched
|
|
|
// individually and the at-rule prelude never leaks into the selector.
|
|
|
for (const block of body.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
|
// A statement at-rule (`@import '…';`) sits in the same run as the next
|
|
|
// selector — keep what follows its `;`, or the whole rule is skipped and
|
|
|
// its knobs vanish (measured: color-picker lost 2).
|
|
|
const selector = (block[1].split(';').pop() ?? '').trim().replace(/\s+/g, ' ');
|
|
|
if (selector.startsWith('@')) continue; // @property / @font-face: no knobs
|
|
|
const inner = block[2];
|
|
|
const innerStart = (block.index ?? 0) + block[1].length + 1;
|
|
|
// Not anchored to the line start: a one-line rule (`[x] { padding: 8px; }`)
|
|
|
// counts too — the first version of this regex missed it, and a mutation
|
|
|
// test (PLAN-theming.md §7.5) is what caught it.
|
|
|
for (const m of inner.matchAll(/(?<=^\s*|;\s*)(--[a-z0-9_-]+|[a-z-]+)\s*:\s*([^;]+);/gm)) {
|
|
|
const prop = m[1];
|
|
|
const val = m[2].trim().replace(/\s+/g, ' ');
|
|
|
const line = lineAt(innerStart + (m.index ?? 0));
|
|
|
for (const p of val.matchAll(/var\(\s*(--_[a-z0-9-]+)/g)) usedPrivates.add(p[1]);
|
|
|
if (prop.startsWith('--')) {
|
|
|
if (prop.startsWith('--_' + dir + '-'))
|
|
|
privates.push({
|
|
|
name: prop,
|
|
|
file,
|
|
|
line,
|
|
|
selector,
|
|
|
value: val,
|
|
|
source: classify(val, pubNeedle, privNeedle, dir)
|
|
|
});
|
|
|
continue;
|
|
|
}
|
|
|
if (!KNOB_PROPS.test(prop) || INERT.has(val)) continue;
|
|
|
const klass = classify(val, pubNeedle, privNeedle, dir);
|
|
|
row[klass]++;
|
|
|
knobs.push({ file, line, selector, prop, value: val, klass });
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ── Second pass: a private that DERIVES from a public is reachable ──
|
|
|
// F0 asked for this and it was only ever printed in the report's §3, never
|
|
|
// applied to the count: a recipe that writes `--_c-x: var(--c-y)` and reads
|
|
|
// `var(--_c-x)` is doing exactly what the doctrine prescribes (privates are
|
|
|
// fine IF they derive from a public), yet every such knob scored as debt.
|
|
|
// Measured 2026-08-22: 240 privates across 59 components were in that state.
|
|
|
// The closure is TRANSITIVE — `--_a: calc(var(--_b) * .3)` reaches a public
|
|
|
// whenever `--_b` does — with a visited set so a cycle cannot hang it.
|
|
|
const declBySource = new Map<string, string[]>();
|
|
|
for (const pv of privates) declBySource.set(pv.name, [...(declBySource.get(pv.name) ?? []), pv.source]);
|
|
|
const privValues = new Map<string, string[]>();
|
|
|
for (const pv of privates) privValues.set(pv.name, [...(privValues.get(pv.name) ?? []), pv.value]);
|
|
|
const derivesCache = new Map<string, boolean>();
|
|
|
const derivesFromPublic = (name: string, seen = new Set<string>()): boolean => {
|
|
|
if (derivesCache.has(name)) return derivesCache.get(name)!;
|
|
|
if (seen.has(name)) return false;
|
|
|
seen.add(name);
|
|
|
const sources = declBySource.get(name);
|
|
|
if (!sources || sources.length === 0) return false;
|
|
|
let ok = sources.every((s) => {
|
|
|
if (s === 'public') return true;
|
|
|
if (s !== 'private') return false;
|
|
|
return true; // resolved below, per referenced private
|
|
|
});
|
|
|
if (ok && sources.some((s) => s === 'private')) {
|
|
|
// every private this one reads must itself reach a public
|
|
|
ok = (privValues.get(name) ?? []).every((v) => {
|
|
|
const refs = [...v.matchAll(/var\(\s*(--_[a-z0-9-]+)/g)].map((m) => m[1]);
|
|
|
if (refs.length === 0) return !v.includes('var(--') || v.includes(pubNeedle);
|
|
|
return refs.every((r) => derivesFromPublic(r, seen));
|
|
|
});
|
|
|
}
|
|
|
derivesCache.set(name, ok);
|
|
|
return ok;
|
|
|
};
|
|
|
for (const k of knobs) {
|
|
|
if (k.klass !== 'private') continue;
|
|
|
const refs = [...k.value.matchAll(/var\(\s*(--_[a-z0-9-]+)/g)].map((m) => m[1]);
|
|
|
if (refs.length === 0 || !refs.every((r) => derivesFromPublic(r))) continue;
|
|
|
row.private--;
|
|
|
row.public++;
|
|
|
k.klass = 'public';
|
|
|
}
|
|
|
|
|
|
const themeable = row.public + row.private + row.global + row.literal;
|
|
|
row.knobs = themeable + row.system;
|
|
|
row.reach = themeable === 0 ? 1 : row.public / themeable;
|
|
|
const declared = new Set(privates.map((p) => p.name));
|
|
|
const privatesFromContract = [...usedPrivates]
|
|
|
.filter((n) => n.startsWith('--_' + dir + '-') && !declared.has(n))
|
|
|
.sort();
|
|
|
return { row, knobs, privates, privatesFromContract, files };
|
|
|
}
|
|
|
|
|
|
export function census(only?: string): CensusRow[] {
|
|
|
const rows: CensusRow[] = [];
|
|
|
for (const dir of readdirSync(ROOT)) {
|
|
|
if (only && dir !== only) continue;
|
|
|
const scan = scanComponent(dir);
|
|
|
if (scan) rows.push(scan.row);
|
|
|
}
|
|
|
return rows;
|
|
|
}
|
|
|
|
|
|
// ─── The proposal — derived from doctrine, never invented ────────────────────
|
|
|
|
|
|
/**
|
|
|
* property → token slot. recipe-contract §1 (dimensional names: logical axes,
|
|
|
* `{part}-height-{size}`, `padding-inline[-{size}]`, `gap`, `radius`,
|
|
|
* `font-size-{size}`, `icon-size-{size}`) + theming §6.7 (colour slots:
|
|
|
* `bg, fg, border, on-bg, on-fg, on-border, hover-bg, on-hover-bg`).
|
|
|
* `null` = the system owns it; a recipe must not mint a token for it.
|
|
|
*/
|
|
|
const PROP_SLOT: Record<string, string | null> = {
|
|
|
background: 'bg',
|
|
|
'background-color': 'bg',
|
|
|
'background-image': 'bg-image',
|
|
|
color: 'fg',
|
|
|
'border-color': 'border',
|
|
|
'border-width': 'border-width',
|
|
|
'border-radius': 'radius',
|
|
|
'box-shadow': 'shadow',
|
|
|
outline: null,
|
|
|
'outline-color': null,
|
|
|
'outline-width': null,
|
|
|
'padding-inline': 'padding-inline',
|
|
|
'padding-block': 'padding-block',
|
|
|
'padding-inline-start': 'padding-inline',
|
|
|
'padding-inline-end': 'padding-inline',
|
|
|
'padding-block-start': 'padding-block',
|
|
|
'padding-block-end': 'padding-block',
|
|
|
gap: 'gap',
|
|
|
'row-gap': 'row-gap',
|
|
|
'column-gap': 'column-gap',
|
|
|
'font-size': 'font-size',
|
|
|
'font-weight': 'font-weight',
|
|
|
'font-family': 'font-family',
|
|
|
'line-height': 'line-height',
|
|
|
'letter-spacing': 'letter-spacing',
|
|
|
'min-block-size': 'height',
|
|
|
'block-size': 'height',
|
|
|
'min-inline-size': 'width',
|
|
|
'inline-size': 'width',
|
|
|
opacity: 'opacity',
|
|
|
fill: 'fill',
|
|
|
stroke: 'stroke',
|
|
|
filter: 'filter',
|
|
|
'backdrop-filter': 'backdrop-filter',
|
|
|
'accent-color': 'accent',
|
|
|
'caret-color': 'caret',
|
|
|
'text-decoration-color': 'underline'
|
|
|
};
|
|
|
|
|
|
/** Physical axes are forbidden as token keys (R-4.4) — they need a decision. */
|
|
|
const PHYSICAL_PROPS = new Set([
|
|
|
'padding',
|
|
|
'padding-top',
|
|
|
'padding-bottom',
|
|
|
'padding-left',
|
|
|
'padding-right',
|
|
|
'border',
|
|
|
'border-top',
|
|
|
'border-bottom',
|
|
|
'border-left',
|
|
|
'border-right',
|
|
|
'border-block',
|
|
|
'border-inline',
|
|
|
'border-block-start',
|
|
|
'border-block-end',
|
|
|
'border-inline-start',
|
|
|
'border-inline-end',
|
|
|
'height',
|
|
|
'min-height',
|
|
|
'width',
|
|
|
'min-width'
|
|
|
]);
|
|
|
|
|
|
const SIZE_KEYS = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl', 'xxl'];
|
|
|
|
|
|
/** Shared layers own an axis; a consumer must NOT mint `--{c}-{axis}` for it. */
|
|
|
const SHARED_LAYERS: Record<string, string[]> = {
|
|
|
'list-surface': [
|
|
|
'color-field',
|
|
|
'combobox',
|
|
|
'command',
|
|
|
'context-menu',
|
|
|
'dropdown-menu',
|
|
|
'fab',
|
|
|
'listbox',
|
|
|
'menu-dial',
|
|
|
'menubar',
|
|
|
'select'
|
|
|
],
|
|
|
'menu-indicator': [
|
|
|
'context-menu',
|
|
|
'dropdown-menu',
|
|
|
'grid-list',
|
|
|
'listbox',
|
|
|
'menubar',
|
|
|
'navigation-menu'
|
|
|
],
|
|
|
'calendar-surface': ['calendar', 'date-picker', 'month-grid', 'range-calendar', 'year-grid'],
|
|
|
'sliding-indicator': ['radio-group'],
|
|
|
'viewport-placement': ['affix', 'fab', 'menu-dial'],
|
|
|
'spin-field': ['css-field', 'knob', 'number-field'],
|
|
|
'field-segment-state': [
|
|
|
'color-field',
|
|
|
'color-picker',
|
|
|
'date-field',
|
|
|
'date-range-picker',
|
|
|
'time-field'
|
|
|
],
|
|
|
'picker-shell': [
|
|
|
'chronos',
|
|
|
'color-picker',
|
|
|
'date-picker',
|
|
|
'date-range-picker',
|
|
|
'gradient-builder',
|
|
|
'gradient-picker',
|
|
|
'month-grid',
|
|
|
'natural-time-picker',
|
|
|
'palabras',
|
|
|
'time-picker',
|
|
|
'time-range-picker',
|
|
|
'year-grid'
|
|
|
]
|
|
|
};
|
|
|
|
|
|
export interface Proposal {
|
|
|
/** The token key as it goes into `lib/recipes/base.ts` (no `--{c}-` prefix). */
|
|
|
key: string | null;
|
|
|
/** The value — verbatim from today's CSS, so the default cannot move. */
|
|
|
value: string;
|
|
|
/** TSC scope (canon/tsc.md): root · host · color:X · variant:X · size:X. */
|
|
|
scope: string;
|
|
|
/** Why there is no mechanical name (⚠ decisión) — or '' when there is. */
|
|
|
warning: string;
|
|
|
knob: Knob;
|
|
|
}
|
|
|
|
|
|
/** part · size · variant · color · state, read off the selector. */
|
|
|
function readSelector(selector: string, component: string) {
|
|
|
// `:not(:disabled)` asserts the ABSENCE of a state — reading it as the state
|
|
|
// produced `hover-disabled-trigger-bg` for a plain hover. Strip the negations
|
|
|
// before looking for anything.
|
|
|
const sel = selector.replace(/:not\([^)]*\)/g, '');
|
|
|
const partMatch = sel.match(new RegExp('\\[data-' + component + '-([a-z0-9-]+)[\\]=]'));
|
|
|
const part = partMatch ? partMatch[1] : '';
|
|
|
const size = sel.match(/\[data-size='([a-z]+)'\]/)?.[1] ?? '';
|
|
|
const variant = sel.match(/\[data-variant='([a-z-]+)'\]/)?.[1] ?? '';
|
|
|
const color = sel.match(/\[data-color='([a-z-]+)'\]/)?.[1] ?? '';
|
|
|
const state: string[] = [];
|
|
|
if (/:hover/.test(sel)) state.push('hover');
|
|
|
if (/:active|\[data-pressed\]/.test(sel)) state.push('press');
|
|
|
if (/\[data-state='open'\]|\[data-open\]/.test(sel)) state.push('open');
|
|
|
if (/\[data-state='checked'\]|\[data-checked\]/.test(sel)) state.push('on');
|
|
|
if (/\[data-state='selected'\]|\[data-selected\]/.test(sel)) state.push('selected');
|
|
|
if (/\[data-disabled\]|:disabled/.test(sel)) state.push('disabled');
|
|
|
if (/\[data-invalid\]/.test(sel)) state.push('invalid');
|
|
|
if (/:focus-visible|\[data-focused\]/.test(sel)) state.push('focus');
|
|
|
return { part, size, variant, color, state };
|
|
|
}
|
|
|
|
|
|
/** Layout literals are geometry, not a theme knob — flagged, never named. */
|
|
|
const LAYOUT_LITERAL = /^(100%|100vw|100dvh|100vh|1|auto)$/;
|
|
|
|
|
|
function propose(knob: Knob, scan: Scan): Proposal[] {
|
|
|
const component = scan.row.component;
|
|
|
const { part, size, variant, color, state } = readSelector(knob.selector, component);
|
|
|
const slot = PROP_SLOT[knob.prop];
|
|
|
const base = (s: string) => [part, s].filter(Boolean).join('-');
|
|
|
const blocked = (warning: string): Proposal[] => [
|
|
|
{ key: null, value: knob.value, scope: '—', warning, knob }
|
|
|
];
|
|
|
|
|
|
if (slot === null)
|
|
|
return blocked(
|
|
|
'foco: lo posee el sistema (`--focus-ring-*`, theming §32) — no acuñar token propio'
|
|
|
);
|
|
|
if (PHYSICAL_PROPS.has(knob.prop))
|
|
|
return blocked(
|
|
|
'⚠ decisión: `' +
|
|
|
knob.prop +
|
|
|
'` es shorthand o eje físico — hay que partirlo en ejes lógicos antes de nombrarlo (recipe-contract §1, R-4.4)'
|
|
|
);
|
|
|
if (!slot) return blocked('⚠ decisión: la propiedad no tiene slot canónico en el vocabulario');
|
|
|
if (/--state-|--focus-ring/.test(knob.value))
|
|
|
return blocked(
|
|
|
'capa de estado / anillo de foco: sistema transversal — no acuñar (recipe-contract §2)'
|
|
|
);
|
|
|
if (LAYOUT_LITERAL.test(knob.value))
|
|
|
return blocked(
|
|
|
'⚠ decisión: `' +
|
|
|
knob.value +
|
|
|
'` es un valor identidad o geometría de layout, no un knob de tema — el perímetro de «knob» es D-TH.2, sin firmar'
|
|
|
);
|
|
|
|
|
|
// The colour slot vocabulary puts the modifier FIRST (theming §6.7:
|
|
|
// `hover-bg`, `on-bg`); the dimensional one puts the size LAST
|
|
|
// (recipe-contract §1: `padding-inline-{size}`).
|
|
|
const mod = state.filter((s) => s !== 'focus').join('-');
|
|
|
const name = (sizeKey: string) => [variant, mod, base(slot), sizeKey].filter(Boolean).join('-');
|
|
|
const scopeFor = (value: string, sizeKey: string) =>
|
|
|
color
|
|
|
? `color:${color}`
|
|
|
: sizeKey && !size
|
|
|
? `size:${sizeKey}`
|
|
|
: /var\(--[a-z-]*palette-/.test(value)
|
|
|
? 'host'
|
|
|
: 'root';
|
|
|
|
|
|
const note = (value: string, sizeKey: string) => {
|
|
|
if (color)
|
|
|
return 'declaración por color: `declarations[]` con scope `color:' + color + '` (tsc.md)';
|
|
|
if (sizeKey && !SIZE_KEYS.includes(sizeKey)) return '⚠ decisión: talla fuera del canon';
|
|
|
if (/calc\(|min\(|max\(|clamp\(/.test(value))
|
|
|
return '⚠ decisión: el valor es una expresión — el token puede llevar la expresión entera o sólo su término variable';
|
|
|
return '';
|
|
|
};
|
|
|
|
|
|
// A knob reading a PRIVATE resolves through it: the public token takes the
|
|
|
// private's own value, and if the private is redeclared per size, the token
|
|
|
// is minted per size (recipe-contract §1: `{part}-{eje}-{k}`).
|
|
|
if (knob.klass === 'private') {
|
|
|
const ref = knob.value.match(new RegExp('var\\(\\s*(--_' + component + '-[a-z0-9-]+)'))?.[1];
|
|
|
const decls = ref ? scan.privates.filter((p) => p.name === ref) : [];
|
|
|
// The private already reads a public: the knob IS reachable and there is
|
|
|
// nothing to mint. The census counts it as `private` because a regex cannot
|
|
|
// follow the indirection (PLAN-theming §1.3) — that is a measurement limit,
|
|
|
// not debt.
|
|
|
if (decls.length > 0 && decls.every((d) => d.source === 'public'))
|
|
|
return blocked(
|
|
|
'ya alcanzable: el privado `' +
|
|
|
ref +
|
|
|
'` deriva de un público (' +
|
|
|
list([...new Set(decls.map((d) => d.value))].slice(0, 4)) +
|
|
|
') — sin acción; el censo lo cuenta como no alcanzable por el límite del regex'
|
|
|
);
|
|
|
if (decls.length > 0) {
|
|
|
const resolved = decls.map((d) => ({
|
|
|
size: readSelector(d.selector, component).size,
|
|
|
value: d.value
|
|
|
}));
|
|
|
const multi = resolved.filter((r) => r.size).length > 1;
|
|
|
// The size-less declaration in a per-size set IS the default step: the
|
|
|
// catalogue names every one of them (`font-size-{size}`), so leaving it
|
|
|
// bare would mint a name the vocabulary does not have.
|
|
|
if (multi) {
|
|
|
const present = new Set(resolved.map((r) => r.size).filter(Boolean));
|
|
|
const missing = SIZE_KEYS.filter((k) => !present.has(k) && present.size >= 2);
|
|
|
const dflt = missing.includes('md') ? 'md' : '';
|
|
|
for (const r of resolved) if (!r.size && dflt) r.size = dflt;
|
|
|
}
|
|
|
return resolved.map((r) => ({
|
|
|
key: name(multi ? r.size : size),
|
|
|
value: r.value,
|
|
|
scope: scopeFor(r.value, multi ? r.size : ''),
|
|
|
warning:
|
|
|
(note(r.value, multi ? r.size : size)
|
|
|
? note(r.value, multi ? r.size : size) + ' · '
|
|
|
: '') +
|
|
|
'el privado `' +
|
|
|
ref +
|
|
|
'` debe pasar a leer este público (o desaparecer)',
|
|
|
knob
|
|
|
}));
|
|
|
}
|
|
|
return blocked(
|
|
|
'⚠ decisión: el privado que alimenta este knob no se declara en el CSS (viene de `base.ts` o de un estilo inline) — hay que resolverlo antes de nombrarlo'
|
|
|
);
|
|
|
}
|
|
|
|
|
|
return [
|
|
|
{
|
|
|
key: name(size),
|
|
|
value: knob.value,
|
|
|
scope: scopeFor(knob.value, ''),
|
|
|
warning: note(knob.value, size),
|
|
|
knob
|
|
|
}
|
|
|
];
|
|
|
}
|
|
|
|
|
|
// ─── Report ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
const MARK_START = '<!-- veredicto:start -->';
|
|
|
const MARK_END = '<!-- veredicto:end -->';
|
|
|
const VERDICT_SEED =
|
|
|
MARK_START + '\n\n_(pendiente — lo escribe el autor; se conserva al regenerar)_\n\n' + MARK_END;
|
|
|
|
|
|
const pct = (n: number, d: number) => (d === 0 ? '—' : `${Math.round((100 * n) / d)}%`);
|
|
|
const cell = (s: string) => '`' + s.replace(/\|/g, '\\|') + '`';
|
|
|
const reachOf = (r: CensusRow) => pct(r.public, r.public + r.private + r.global + r.literal);
|
|
|
const list = (xs: string[]) => xs.map((k) => '`' + k + '`').join(', ');
|
|
|
|
|
|
/**
|
|
|
* A token BORROWED from another recipe — verified against that recipe's own
|
|
|
* keys in `base.ts`, not by prefix. `--icon-size-sm` is the global icon scale,
|
|
|
* not a token of the `icon` component, and a prefix test called it a loan.
|
|
|
*/
|
|
|
const OWNER_KEYS = new Map<string, Set<string>>();
|
|
|
function borrowedFrom(value: string, self: string): string[] {
|
|
|
const owners = new Set<string>();
|
|
|
for (const m of value.matchAll(/var\(\s*--(_?)([a-z0-9-]+)/g)) {
|
|
|
const priv = m[1] === '_';
|
|
|
const name = m[2];
|
|
|
for (const c of COMPONENT_DIRS) {
|
|
|
if (c === self || !name.startsWith(c + '-')) continue;
|
|
|
if (!OWNER_KEYS.has(c)) OWNER_KEYS.set(c, new Set(contractKeyNames(c)));
|
|
|
const key = (priv ? '_' : '') + name.slice(c.length + 1);
|
|
|
if (OWNER_KEYS.get(c)!.has(key)) owners.add(c);
|
|
|
}
|
|
|
}
|
|
|
return [...owners].sort();
|
|
|
}
|
|
|
|
|
|
function knobTable(rows: Knob[], self: string): string {
|
|
|
if (rows.length === 0) return '_Ninguno._\n';
|
|
|
const out = [
|
|
|
'| # | fichero:línea | selector | propiedad | valor |',
|
|
|
'| ---: | --- | --- | --- | --- |'
|
|
|
];
|
|
|
rows.forEach((k, i) => {
|
|
|
const borrow = borrowedFrom(k.value, self);
|
|
|
const val = cell(k.value) + (borrow.length ? ` ⤴ prestado de ${list(borrow)}` : '');
|
|
|
out.push(
|
|
|
`| ${i + 1} | ${cell(k.file + ':' + k.line)} | ${cell(k.selector)} | ${cell(k.prop)} | ${val} |`
|
|
|
);
|
|
|
});
|
|
|
return out.join('\n') + '\n';
|
|
|
}
|
|
|
|
|
|
function proposalSection(scan: Scan): string {
|
|
|
const c = scan.row.component;
|
|
|
const targets = scan.knobs.filter((k) => k.klass !== 'public' && k.klass !== 'system');
|
|
|
if (targets.length === 0) return '_Nada que proponer: no hay knobs fuera de alcance._\n';
|
|
|
|
|
|
const proposals = targets.flatMap((k) => propose(k, scan));
|
|
|
const minted = new Map<string, { value: Set<string>; scope: string; uses: number }>();
|
|
|
const blocked: Proposal[] = [];
|
|
|
for (const p of proposals) {
|
|
|
if (!p.key) {
|
|
|
blocked.push(p);
|
|
|
continue;
|
|
|
}
|
|
|
const e = minted.get(p.key) ?? { value: new Set<string>(), scope: p.scope, uses: 0 };
|
|
|
e.value.add(p.value);
|
|
|
e.uses++;
|
|
|
minted.set(p.key, e);
|
|
|
}
|
|
|
|
|
|
const out: string[] = [];
|
|
|
out.push(
|
|
|
`### 4.1 Tokens a declarar en \`lib/recipes/base.ts\` (${minted.size})`,
|
|
|
'',
|
|
|
'Valor **verbatim** del CSS de hoy: el default no se mueve, sólo cambia quién',
|
|
|
'puede moverlo. Nombres derivados de recipe-contract §1 (ejes lógicos, talla',
|
|
|
'al final) y theming §6.7 (slots de color, modificador delante). Un token con',
|
|
|
'DOS valores distintos es una colisión de nombre: son dos knobs, o el nombre',
|
|
|
'no distingue lo que debería — se marca `⚠`.',
|
|
|
'',
|
|
|
'| token (`--' + c + '-…`) | scope TSC | valor propuesto | usos |',
|
|
|
'| --- | --- | --- | ---: |'
|
|
|
);
|
|
|
const existing = new Set(contractKeyNames(c));
|
|
|
for (const [key, e] of [...minted.entries()].sort((a, b) => b[1].uses - a[1].uses)) {
|
|
|
const values = [...e.value];
|
|
|
const shown =
|
|
|
values.length === 1 ? cell(values[0]) : '⚠ ' + values.map((v) => cell(v)).join(' / ');
|
|
|
const name = cell(key) + (existing.has(key) ? ' _(ya existe)_' : '');
|
|
|
out.push(`| ${name} | ${cell(e.scope)} | ${shown} | ${e.uses} |`);
|
|
|
}
|
|
|
out.push('');
|
|
|
|
|
|
if (blocked.length > 0) {
|
|
|
const byWarn = new Map<string, Knob[]>();
|
|
|
for (const p of blocked) byWarn.set(p.warning, [...(byWarn.get(p.warning) ?? []), p.knob]);
|
|
|
out.push(`### 4.2 Sin nombre mecánico (${blocked.length})`, '');
|
|
|
for (const [warn, ks] of byWarn) {
|
|
|
out.push(`- **${warn}** — ${ks.length}: ` + list([...new Set(ks.map((k) => k.prop))]) + '.');
|
|
|
}
|
|
|
out.push('');
|
|
|
}
|
|
|
|
|
|
const withWarn = proposals.filter((p) => p.key && p.warning);
|
|
|
if (withWarn.length > 0) {
|
|
|
const byWarn = new Map<string, Set<string>>();
|
|
|
for (const p of withWarn)
|
|
|
byWarn.set(p.warning, (byWarn.get(p.warning) ?? new Set()).add('--' + c + '-' + p.key));
|
|
|
out.push(`### 4.3 Avisos sobre los tokens propuestos (${byWarn.size})`, '');
|
|
|
for (const [warn, keys] of byWarn) out.push(`- **${warn}** — ${list([...keys])}`);
|
|
|
out.push('');
|
|
|
}
|
|
|
return out.join('\n');
|
|
|
}
|
|
|
|
|
|
function doctrineNotes(scan: Scan): string {
|
|
|
const c = scan.row.component;
|
|
|
const notes: string[] = [];
|
|
|
for (const [layer, consumers] of Object.entries(SHARED_LAYERS)) {
|
|
|
if (consumers.includes(c))
|
|
|
notes.push(
|
|
|
`- **Consume la capa compartida \`${layer}\`.** Un eje que la capa posee se consume como \`var(--_x, var(--x))\`; el consumidor **no acuña** \`--${c}-{eje}\` para él — sería un vocabulario paralelo (README de \`eidos/components\`, «Capas compartidas» regla 2).`
|
|
|
);
|
|
|
}
|
|
|
const borrow = new Set<string>();
|
|
|
for (const k of scan.knobs) for (const o of borrowedFrom(k.value, c)) borrow.add(o);
|
|
|
if (borrow.size > 0)
|
|
|
notes.push(
|
|
|
`- **Consume tokens públicos de ${list([...borrow].sort())}.** Un token prestado importa la semántica de su dueño: la corrección no es duplicarlo con prefijo propio, sino la decisión de familia que la auditoría de fase 1 dejó registrada (\`theming-audit.md\` §B, familia calendar).`
|
|
|
);
|
|
|
if (scan.row.knobs === 0)
|
|
|
notes.push(
|
|
|
'- **Sin knobs de apariencia**: el visual vive en un componente compuesto o en una capa compartida. «Declarado y nunca pintado» no es deuda por sí solo (architecture/eidos.md, columna `unused`).'
|
|
|
);
|
|
|
if (scan.row.hasSize)
|
|
|
notes.push(
|
|
|
`- **Tiene eje \`size\`**: los tokens dimensionales van por talla (\`{part}-{eje}-{k}\`) apuntando al bundle \`--size-{k}-*\`, nunca al primitivo crudo (theming §5; el guard \`recipe-css-contract\` prohíbe el primitivo).`
|
|
|
);
|
|
|
return notes.length ? notes.join('\n') + '\n' : '';
|
|
|
}
|
|
|
|
|
|
function privateTable(scan: Scan): string {
|
|
|
if (scan.privates.length === 0) return '_La receta no declara privados propios en su CSS._\n';
|
|
|
const byName = new Map<string, PrivateDecl[]>();
|
|
|
for (const p of scan.privates) byName.set(p.name, [...(byName.get(p.name) ?? []), p]);
|
|
|
const rows = [...byName.entries()].map(([name, decls]) => {
|
|
|
const sources = [...new Set(decls.map((d) => d.source))].sort();
|
|
|
const derives = sources.length === 1 && sources[0] === 'public';
|
|
|
const values = [...new Set(decls.map((d) => d.value))];
|
|
|
const shown = list(values.slice(0, 6)) + (values.length > 6 ? ` …(+${values.length - 6})` : '');
|
|
|
return `| ${cell(name)} | ${decls.length} | ${shown} | ${sources.join(', ')} | ${derives ? '**sí**' : 'no'} |`;
|
|
|
});
|
|
|
return (
|
|
|
[
|
|
|
'| privado | declaraciones | valor(es) | origen | ¿deriva de un público? |',
|
|
|
'| --- | ---: | --- | --- | :-: |',
|
|
|
...rows
|
|
|
].join('\n') + '\n'
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* A component directory with NO `.css` at all: nothing to measure, and that is
|
|
|
* an answer, not a gap. Where its visual actually lives is MEASURED — the
|
|
|
* components it composes, the shared layer it consumes, the foreign recipe that
|
|
|
* styles its attrs — never guessed.
|
|
|
*/
|
|
|
export interface EmptyScan {
|
|
|
component: string;
|
|
|
files: string[];
|
|
|
/** eidos components imported by its wrappers. */
|
|
|
composes: string[];
|
|
|
/** shared layers its wrappers import. */
|
|
|
layers: string[];
|
|
|
/** other components' CSS that selects `[data-{c}…]`. */
|
|
|
styledIn: string[];
|
|
|
contractKeys: string[];
|
|
|
}
|
|
|
|
|
|
const SHARED_LAYER_FILES = [
|
|
|
'list-surface',
|
|
|
'menu-indicator',
|
|
|
'sliding-indicator',
|
|
|
'viewport-placement',
|
|
|
'field-segment-state'
|
|
|
];
|
|
|
|
|
|
export function scanEmpty(dir: string): EmptyScan | null {
|
|
|
const d = join(ROOT, dir);
|
|
|
if (!statSync(d).isDirectory()) return null;
|
|
|
const files = readdirSync(d);
|
|
|
if (files.some((f) => f.endsWith('.css'))) return null;
|
|
|
|
|
|
const src = files
|
|
|
.filter((f) => f.endsWith('.svelte') || f.endsWith('.ts'))
|
|
|
.map((f) => readFileSync(join(d, f), 'utf8'))
|
|
|
.join('\n');
|
|
|
const composes = COMPONENT_DIRS.filter(
|
|
|
(c) => c !== dir && new RegExp('components/' + c + "['/]").test(src)
|
|
|
);
|
|
|
const layers = SHARED_LAYER_FILES.filter((l) => src.includes(l));
|
|
|
const styledIn: string[] = [];
|
|
|
for (const other of COMPONENT_DIRS) {
|
|
|
if (other === dir) continue;
|
|
|
const od = join(ROOT, other);
|
|
|
for (const f of readdirSync(od).filter((x) => x.endsWith('.css'))) {
|
|
|
if (new RegExp('\\[data-' + dir + '[\\]-]').test(readFileSync(join(od, f), 'utf8')))
|
|
|
styledIn.push(`${other}/${f}`);
|
|
|
}
|
|
|
}
|
|
|
return { component: dir, files, composes, layers, styledIn, contractKeys: contractKeyNames(dir) };
|
|
|
}
|
|
|
|
|
|
function emptySheet(scan: EmptyScan, today: string, verdict: string): string {
|
|
|
const c = scan.component;
|
|
|
const why: string[] = [];
|
|
|
if (scan.composes.length > 0)
|
|
|
why.push(
|
|
|
`- **Compone** ${list(scan.composes)}: su apariencia es la de ${scan.composes.length === 1 ? 'ese componente, y se tema en SU ficha' : 'esos componentes, y se tema en SUS fichas'}. Un wrapper no vuelve a pintar lo que compone (component-guide §4).`
|
|
|
);
|
|
|
if (scan.layers.length > 0)
|
|
|
why.push(
|
|
|
`- **Consume la capa compartida** ${list(scan.layers)}: la geometría la posee la capa, y el consumidor no acuña \`--${c}-{eje}\` (README de \`eidos/components\`, «Capas compartidas»).`
|
|
|
);
|
|
|
if (scan.styledIn.length > 0)
|
|
|
why.push(
|
|
|
`- **Sus attrs se pintan desde otra receta**: ${list(scan.styledIn)}. Esos knobs YA están contados en la ficha del anfitrión — contarlos aquí sería contarlos dos veces.`
|
|
|
);
|
|
|
if (why.length === 0)
|
|
|
why.push(
|
|
|
'- **No pinta**: el wrapper aporta estructura, formato o comportamiento, sin superficie propia que un tema pueda mover.'
|
|
|
);
|
|
|
|
|
|
const contract =
|
|
|
scan.contractKeys.length > 0
|
|
|
? `\n> ⚠ **Tiene ${scan.contractKeys.length} clave(s) en \`lib/recipes/base.ts\` sin receta que las consuma** (${list(scan.contractKeys)}). Es la deuda INVERSA de este eje — un token público sin consumidor real; \`recipe-css-contract.test.ts\` falla si un alias público queda huérfano. Disposición: consumirlo o podarlo.\n`
|
|
|
: '';
|
|
|
|
|
|
return `# ${c} — sin receta CSS
|
|
|
|
|
|
> Generado por \`node --import tsx/esm scripts/theming-census.ts --report\`.
|
|
|
> Vista de conjunto: [README](./README.md) · método:
|
|
|
> [\`PLAN-theming.md\`](../../process/PLAN-theming.md) §1.
|
|
|
|
|
|
- **Medido**: ${today} · **Alcance**: **—** (no hay knobs que medir)
|
|
|
- **Ficheros del componente**: ${list(scan.files)} — **ningún \`.css\`**
|
|
|
- **Contrato en \`lib/recipes/base.ts\`**: ${scan.contractKeys.length === 0 ? 'sin entrada' : `${scan.contractKeys.length} clave(s)`}
|
|
|
${contract}
|
|
|
## 1. Dónde vive su visual
|
|
|
|
|
|
${why.join('\n')}
|
|
|
|
|
|
«Declarado y nunca pintado» **no es deuda por sí solo**: el morfo declara la
|
|
|
superficie de COMPORTAMIENTO del componente, no sólo la pintable
|
|
|
(architecture/eidos.md, columna \`unused\`). Un componente sin receta sólo es
|
|
|
deuda si declaró un eje visual que nadie consume — el aviso de arriba lo dice
|
|
|
cuando ocurre.
|
|
|
|
|
|
## 2. Propuesta de corrección
|
|
|
|
|
|
**Ninguna.** Sin receta no hay knob que tokenizar, y acuñar tokens para un
|
|
|
componente que no pinta crearía un vocabulario huérfano. Si algún día pinta,
|
|
|
entra por la puerta normal: receta + tokens en \`base.ts\` + esta ficha con
|
|
|
cifras.
|
|
|
|
|
|
## 3. Veredicto
|
|
|
|
|
|
${verdict}
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function sheet(scan: Scan, today: string, verdict: string): string {
|
|
|
const { row } = scan;
|
|
|
const c = row.component;
|
|
|
const themeable = row.public + row.private + row.global + row.literal;
|
|
|
const keys = contractKeyNames(c);
|
|
|
const pub = keys.filter((k) => !k.startsWith('_'));
|
|
|
const priv = keys.filter((k) => k.startsWith('_'));
|
|
|
const by = (k: KnobClass) => scan.knobs.filter((x) => x.klass === k);
|
|
|
const contractLine =
|
|
|
keys.length === 0
|
|
|
? '**sin entrada en `base.ts`**'
|
|
|
: `${pub.length} pública(s)${pub.length ? ` — ${list(pub)}` : ''}${priv.length ? ` · ${priv.length} privada(s) forward — ${list(priv)}` : ''}`;
|
|
|
const notes = doctrineNotes(scan);
|
|
|
|
|
|
return `# ${c} — alcance de tema: análisis y propuesta
|
|
|
|
|
|
> Generado por \`node --import tsx/esm scripts/theming-census.ts --report\`.
|
|
|
> Lo **medido** y la **propuesta** se regeneran; el **Veredicto** (§5) se conserva.
|
|
|
> Vista de conjunto: [README](./README.md) · método y protocolo:
|
|
|
> [\`PLAN-theming.md\`](../../process/PLAN-theming.md) §1, §2, §7.
|
|
|
|
|
|
- **Medido**: ${today} · **Alcance**: **${reachOf(row)}** — ${row.public} de ${themeable} knobs por token público
|
|
|
- **Knobs de apariencia**: ${row.knobs} — público ${row.public} · privado ${row.private} · global ${row.global} · literal ${row.literal} · sistema ${row.system} _(fuera del ratio)_
|
|
|
- **Contrato hoy** (\`lib/recipes/base.ts\`): ${contractLine}
|
|
|
- **Eje \`size\`**: ${row.hasSize ? 'sí' : 'no'} · **ficheros**: ${list(scan.files)}
|
|
|
|
|
|
## 1. Knobs fuera de alcance
|
|
|
|
|
|
### 1.1 Directo a primitivo global (${by('global').length})
|
|
|
|
|
|
${knobTable(by('global'), c)}
|
|
|
### 1.2 A través de un privado (${by('private').length})
|
|
|
|
|
|
${knobTable(by('private'), c)}
|
|
|
### 1.3 Literales (${by('literal').length})
|
|
|
|
|
|
${knobTable(by('literal'), c)}
|
|
|
## 2. Sistema transversal (${row.system}) — informativo, fuera del ratio
|
|
|
|
|
|
Un tema los alcanza **a nivel de sistema**, por diseño (recipe-contract §2).
|
|
|
|
|
|
${knobTable(by('system'), c)}
|
|
|
## 3. Privados de la receta — ¿de dónde sale su valor?
|
|
|
|
|
|
${privateTable(scan)}${
|
|
|
scan.privatesFromContract.length > 0
|
|
|
? `\nConsumidos y **no declarados en el CSS** (vienen de \`base.ts\` o de un estilo inline del wrapper): ${list(scan.privatesFromContract)}.\n`
|
|
|
: ''
|
|
|
}
|
|
|
## 4. Propuesta de corrección
|
|
|
|
|
|
${notes ? notes + '\n' : ''}${proposalSection(scan)}
|
|
|
### 4.4 Lo que hay que comprobar a mano (PLAN-theming §1.3 · §7.4)
|
|
|
|
|
|
- [ ] **Privado que no deriva de un público** — §3 lo marca; el privado debe leer el público o desaparecer.
|
|
|
- [ ] **Velo o acento en el nodo equivocado** (\`archetype: 'item'\` en un envoltorio, un \`background\` en shorthand que mata la capa de estado) — se mide desde el píxel hacia arriba.
|
|
|
- [ ] **Doble animación** al mover un sello a una superficie con animación propia — registro de \`animationstart\`/\`animationend\`.
|
|
|
- [ ] **Diff de computed = 0** en reposo · hover · abierto · disabled · foco, por talla, antes y después.
|
|
|
- [ ] **Centinela por token nuevo**: valor imposible en el root → el nodo lo sigue. Si no, el token miente.
|
|
|
|
|
|
## 5. Veredicto
|
|
|
|
|
|
${verdict}
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function rootReadme(scans: Scan[], empties: EmptyScan[], today: string, verdict: string): string {
|
|
|
const rows = scans.map((s) => s.row);
|
|
|
const sum = (k: keyof CensusRow) => rows.reduce((a, r) => a + (r[k] as number), 0);
|
|
|
const themeable = sum('public') + sum('private') + sum('global') + sum('literal');
|
|
|
const sorted = [...rows].sort((a, b) => a.reach - b.reach || b.knobs - a.knobs);
|
|
|
const noContract = rows.filter((r) => r.contractKeys === 0).map((r) => r.component);
|
|
|
const worst = [...rows]
|
|
|
.map((r) => ({ c: r.component, out: r.private + r.global + r.literal, r }))
|
|
|
.sort((a, b) => b.out - a.out)
|
|
|
.slice(0, 20);
|
|
|
|
|
|
return `# Auditoría de alcance de tema — el catálogo entero
|
|
|
|
|
|
> **Generado**, no escrito a mano: \`node --import tsx/esm scripts/theming-census.ts --report\`.
|
|
|
> Este README es la vista de conjunto; **una ficha por componente** al lado, con
|
|
|
> su análisis y su **propuesta de corrección**. Método, clases, protocolo de
|
|
|
> verificación y fases: [\`PLAN-theming.md\`](../../process/PLAN-theming.md).
|
|
|
> La auditoría del SISTEMA de theming (fase 1, cerrada 2026-07-07) es
|
|
|
> [\`theming-audit.md\`](../theming-audit.md); ésta es la deuda de adopción que
|
|
|
> aquélla dejó apuntada en su §5.3-3.
|
|
|
|
|
|
- **Medido**: ${today} · **${rows.length} recetas** con CSS + **${empties.length} componentes sin receta** = ${rows.length + empties.length} fichas, el árbol entero de \`eidos/components/\`
|
|
|
- **La pregunta**: ¿cuánto de la apariencia de cada componente puede cambiar un tema **sin tocar el sistema ni la receta**?
|
|
|
- **Alcance global**: **${pct(sum('public'), themeable)}** — ${sum('public')} de ${themeable} knobs pasan por un token público del componente
|
|
|
- **Reparto**: público ${sum('public')} · privado ${sum('private')} · global ${sum('global')} · literal ${sum('literal')} · sistema transversal ${sum('system')} _(fuera del ratio)_
|
|
|
- **Sin token público propio**: ${noContract.length} · **alcance < 20 %**: ${rows.filter((r) => r.reach < 0.2).length} · **alcance 100 %**: ${rows.filter((r) => r.reach === 1 && r.public > 0).length} · **con eje \`size\`**: ${rows.filter((r) => r.hasSize).length}
|
|
|
|
|
|
## Cómo se lee
|
|
|
|
|
|
| clase | qué es | ¿lo alcanza un tema del componente? |
|
|
|
| --- | --- | --- |
|
|
|
| \`public\` | \`var(--{c}-…)\`, el contrato del componente | **sí** |
|
|
|
| \`private\` | \`var(--_{c}-…)\`, nombre interno | sólo si el privado deriva de un público (cada ficha lo dice en §3) |
|
|
|
| \`global\` | primitivo del sistema (\`--space-*\`, \`--radius-*\`, \`--color-*\`, bundle \`--size-{k}-*\`…) | sólo moviendo el sistema entero |
|
|
|
| \`literal\` | ni token: \`8px\`, \`1.25\`, \`#fff\` | no — y viola R-2/R-4 |
|
|
|
| \`system\` | sistemas transversales que la receta CONSUME por contrato (capa de estado, anillo de foco, planos de depth, motion, bandas z, opacidad, shape, floating-gap) | sí, **a nivel de sistema**, por diseño (recipe-contract §2) — fuera del ratio |
|
|
|
|
|
|
**Alcance** = \`public / (public + private + global + literal)\`.
|
|
|
|
|
|
**Límites de la medida** (regex sobre el CSS; sobre-reporta, nunca infra-reporta):
|
|
|
una declaración con varios tokens se clasifica por la primera clase que casa
|
|
|
(público → privado → sistema → global); el token público de OTRO componente
|
|
|
cuenta como \`global\` y la ficha lo marca «⤴ prestado»; los valores dentro de
|
|
|
\`@keyframes\` cuentan como cualquier otro. Tres defectos que ningún regex ve —
|
|
|
privado que no deriva, velo en el nodo equivocado, doble animación — van en la
|
|
|
checklist §4.4 de cada ficha.
|
|
|
|
|
|
## Qué NO propone una ficha
|
|
|
|
|
|
La propuesta deriva nombres de la doctrina; **no la contradice**. Por eso una
|
|
|
ficha nunca propone acuñar un token para:
|
|
|
|
|
|
- **lo que posee una capa compartida** (\`list-surface\`, \`spin-field\`,
|
|
|
\`picker-shell\`, \`viewport-placement\`, \`field-segment-state\`,
|
|
|
\`menu-indicator\`, \`sliding-indicator\`): un eje = un token público de la capa
|
|
|
+ una ranura privada, consumido \`var(--_x, var(--x))\` — el consumidor no acuña
|
|
|
\`--{c}-{eje}\` (README de \`eidos/components\`, «Capas compartidas» regla 2);
|
|
|
- **el foco** (\`--focus-ring-*\`, theming §32) ni **la capa de estado**
|
|
|
(\`--state-*\`, §38): un knob del sistema, no del componente;
|
|
|
- **un token prestado de otro componente** (la familia \`calendar\` en los
|
|
|
pickers): la corrección es la decisión de familia registrada en
|
|
|
[\`theming-audit.md\`](../theming-audit.md) §B, no un duplicado con prefijo propio;
|
|
|
- **un shorthand o un eje físico** (\`padding\`, \`border\`, \`height\`): primero se
|
|
|
parte en ejes lógicos (recipe-contract §1, R-4.4), y eso es decisión.
|
|
|
|
|
|
## Sin receta CSS (${empties.length})
|
|
|
|
|
|
Ni un \`.css\` en su directorio: **no hay knob que medir**, y eso es una
|
|
|
respuesta, no un hueco. Cada ficha dice —medido, no supuesto— dónde vive su
|
|
|
visual: el componente que compone, la capa compartida que consume, o la receta
|
|
|
ajena que pinta sus attrs (y cuyos knobs ya están contados allí).
|
|
|
|
|
|
${empties.map((e) => `[\`${e.component}\`](./${e.component}.md)`).join(' · ')}
|
|
|
|
|
|
Y el **vocabulario de nombres tiene una desviación medida**: el catálogo habla
|
|
|
mayoritariamente con el modificador al final (\`-bg-hover\`, \`-bg-active\`) mientras
|
|
|
theming §6.7 lo pone delante (\`hover-bg\`). Las fichas proponen la forma
|
|
|
**documentada** y lo dejan anotado: la normalización es **D-TH.6**, sin firmar.
|
|
|
|
|
|
## Los 20 con más knobs fuera del contrato
|
|
|
|
|
|
| # | componente | fuera de alcance | alcance | knobs | contrato |
|
|
|
| ---: | --- | ---: | ---: | ---: | ---: |
|
|
|
${worst.map((w, i) => `| ${i + 1} | [${w.c}](./${w.c}.md) | ${w.out} | ${reachOf(w.r)} | ${w.r.knobs} | ${w.r.contractKeys} |`).join('\n')}
|
|
|
|
|
|
## Sin ningún token público propio (${noContract.length})
|
|
|
|
|
|
Ni una clave pública en \`lib/recipes/base.ts\` — alguno tiene privados forward
|
|
|
(\`_palette-*\`), que no son contrato: un tema no puede nombrarlos.
|
|
|
|
|
|
${noContract.map((c) => `[\`${c}\`](./${c}.md)`).join(' · ')}
|
|
|
|
|
|
## Tabla completa (${rows.length} recetas, por alcance ascendente)
|
|
|
|
|
|
La columna «contrato» cuenta las claves **públicas** del bloque del componente.
|
|
|
|
|
|
| componente | alcance | knobs | público | privado | global | literal | sistema | contrato | size |
|
|
|
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | :-: |
|
|
|
${sorted
|
|
|
.map(
|
|
|
(r) =>
|
|
|
`| [${r.component}](./${r.component}.md) | ${reachOf(r)} | ${r.knobs} | ${r.public} | ${r.private} | ${r.global} | ${r.literal} | ${r.system} | ${r.contractKeys} | ${r.hasSize ? 'y' : '–'} |`
|
|
|
)
|
|
|
.join('\n')}
|
|
|
|
|
|
## Veredicto y decisiones
|
|
|
|
|
|
${verdict}
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function readVerdict(path: string): string {
|
|
|
if (!existsSync(path)) return VERDICT_SEED;
|
|
|
const prev = readFileSync(path, 'utf8');
|
|
|
const a = prev.indexOf(MARK_START);
|
|
|
const b = prev.indexOf(MARK_END);
|
|
|
if (a < 0 || b <= a) return VERDICT_SEED;
|
|
|
return prev.slice(a, b + MARK_END.length).replace(/\r\n/g, '\n');
|
|
|
}
|
|
|
|
|
|
function writeReport() {
|
|
|
mkdirSync(REPORT_DIR, { recursive: true });
|
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
|
const scans: Scan[] = [];
|
|
|
const empties: EmptyScan[] = [];
|
|
|
for (const dir of readdirSync(ROOT)) {
|
|
|
const scan = scanComponent(dir);
|
|
|
if (scan) {
|
|
|
scans.push(scan);
|
|
|
continue;
|
|
|
}
|
|
|
const empty = scanEmpty(dir);
|
|
|
if (empty) empties.push(empty);
|
|
|
}
|
|
|
for (const scan of scans) {
|
|
|
const path = join(REPORT_DIR, `${scan.row.component}.md`);
|
|
|
writeFileSync(path, sheet(scan, today, readVerdict(path)), 'utf8');
|
|
|
}
|
|
|
for (const empty of empties) {
|
|
|
const path = join(REPORT_DIR, `${empty.component}.md`);
|
|
|
writeFileSync(path, emptySheet(empty, today, readVerdict(path)), 'utf8');
|
|
|
}
|
|
|
const readmePath = join(REPORT_DIR, 'README.md');
|
|
|
writeFileSync(readmePath, rootReadme(scans, empties, today, readVerdict(readmePath)), 'utf8');
|
|
|
console.log(
|
|
|
`wrote docs/audit/theming/README.md + ${scans.length} recipe sheets + ${empties.length} no-recipe sheets`
|
|
|
);
|
|
|
}
|
|
|
|
|
|
// ─── `--names`: the naming grammar (D-TH.6, signed 2026-08-20) ───────────────
|
|
|
|
|
|
/**
|
|
|
* The signed grammar, as data. Three families, three shapes:
|
|
|
*
|
|
|
* role/palette `{part-}?{role|palette}-{COLOR_ROLE_SLOT}` modifier BEHIND
|
|
|
* (`primary-solid-hover`) — canonical BY CONSTRUCTION, the
|
|
|
* slot inventory itself carries `hover`/`active`/`solid-hover`.
|
|
|
* system `--state-*`, `--opacity-*`, `--focus-ring-*` — not a recipe
|
|
|
* key; out of this classifier's reach.
|
|
|
* recipe-level `[variant-]?[modifier-]?[part-]?{slot}[-{size}]` — modifier
|
|
|
* IN FRONT (theming §6.7 r7): "delante lo interactivo, detrás
|
|
|
* lo dimensional y contextual".
|
|
|
*
|
|
|
* The ink slot is `fg`; `color` as a slot is dead (2026-08-20).
|
|
|
*
|
|
|
* WHAT MOVES TO THE FRONT is the INTERACTIVE state, and only it — the closed
|
|
|
* universal vocabulary below (`current` included: it is an ARIA state). A
|
|
|
* qualifier that is contextual (`meter.indicator-bg-below`, `progress`'s
|
|
|
* `loaded`), orientational (`radio-group.gap-vertical`) or modal
|
|
|
* (`sidebar.width-icon`) stays BEHIND: that is the other half of the signed
|
|
|
* sentence, and promoting it would rewrite `--sidebar-width-icon` (the width
|
|
|
* of the icon-collapsed rail) into `--sidebar-icon-width` (the width of an
|
|
|
* icon), which is a different thing. The morfo's declared values are the
|
|
|
* source for VALIDATING that trailing qualifier, never for promoting it.
|
|
|
* Where the author wanted a specific promotion, it is a signed name in
|
|
|
* `NAME_OVERRIDE`, never a rule that generalises silently.
|
|
|
*/
|
|
|
const UNIVERSAL_MODIFIERS = [
|
|
|
'hover',
|
|
|
'active',
|
|
|
'selected',
|
|
|
'disabled',
|
|
|
'checked',
|
|
|
'open',
|
|
|
'focus',
|
|
|
'invalid',
|
|
|
'current'
|
|
|
];
|
|
|
|
|
|
/** Colour-role slots as EMITTED (kebab) — `COLOR_ROLE_SLOTS` + the generated extras. */
|
|
|
const ROLE_SLOT_TAILS = [
|
|
|
'solid-hover',
|
|
|
'surface-hover',
|
|
|
'text-strong',
|
|
|
'hover',
|
|
|
'active',
|
|
|
'text',
|
|
|
'solid',
|
|
|
'surface',
|
|
|
'element',
|
|
|
'track',
|
|
|
'bg2',
|
|
|
'separator',
|
|
|
'border',
|
|
|
'contrast'
|
|
|
];
|
|
|
|
|
|
const ROLES = [
|
|
|
'primary',
|
|
|
'secondary',
|
|
|
'tertiary',
|
|
|
'neutral',
|
|
|
'affirm',
|
|
|
'fulfill',
|
|
|
'risk',
|
|
|
'threat',
|
|
|
'loss',
|
|
|
'palette'
|
|
|
];
|
|
|
|
|
|
/** Recipe-level variant archetypes that carry their own `-hover`/`-active` pair. */
|
|
|
const VARIANT_TAILS = /(^|-)(solid|surface|soft|outline|ghost|subtle|element)-(hover|active)$/;
|
|
|
|
|
|
/**
|
|
|
* The three false-friend groups the author signed as KEEPING their `color`
|
|
|
* segment (2026-08-20 acta): the system's focus-ring family, colour as a NOUN,
|
|
|
* and `stop-color` as a PART name.
|
|
|
*/
|
|
|
const NAME_EXEMPT: Array<{ re: RegExp; why: string }> = [
|
|
|
{ re: /focus-ring-color$/, why: 'espeja la familia del SISTEMA --focus-ring-*' },
|
|
|
{ re: /^orb-color-/, why: 'color como sustantivo del orbe, no slot' },
|
|
|
{ re: /^stop-color(-|$)/, why: 'stop-color ES la parte (gradient-builder-stop-color.svelte)' }
|
|
|
];
|
|
|
|
|
|
/**
|
|
|
* Destinations the author decided by NAME in the 2026-08-20 acta, where the
|
|
|
* generic grammar cannot deduce them. Each one is a reading of the component,
|
|
|
* not a rule: `on-dark` reads as the canonical `on-` accent prefix and had to
|
|
|
* move away from it; `played`/`buffered` are waveform REGIONS (pseudo-parts),
|
|
|
* so they lead like a part; `inactive` IS rating-group's resting state, and a
|
|
|
* resting state carries no modifier.
|
|
|
*/
|
|
|
const NAME_OVERRIDE: Record<string, string> = {
|
|
|
'background.scrim-color-on-dark': 'scrim-fg-over-dark',
|
|
|
'background.scrim-color-on-light': 'scrim-fg-over-light',
|
|
|
'waveform.color-played': 'played-fg',
|
|
|
'waveform.color-buffered': 'buffered-fg',
|
|
|
'rating-group.item-color-inactive': 'item-fg',
|
|
|
// Estados de ITEM que el autor nombró uno a uno: son interactivos/de estatus
|
|
|
// y van delante, pero no salen de una regla — salen de su firma.
|
|
|
'rating-group.item-color-active': 'active-item-fg',
|
|
|
'rating-group.item-color-partial': 'partial-item-fg',
|
|
|
'chat-message.status-color-read': 'read-status-fg',
|
|
|
'chat-message.status-color-failed': 'failed-status-fg'
|
|
|
};
|
|
|
|
|
|
export type NameClass =
|
|
|
| 'ok' // already speaks the grammar
|
|
|
| 'role-slot' // canonical by construction — never touch
|
|
|
| 'exempt' // signed false friend
|
|
|
| 'state-layer' // neutral hover: MIGRATES (firma 3), not renamed
|
|
|
| 'ink' // `color` as slot → `fg`
|
|
|
| 'modifier' // modifier behind → in front
|
|
|
| 'both';
|
|
|
|
|
|
export interface NameFinding {
|
|
|
component: string;
|
|
|
key: string;
|
|
|
klass: NameClass;
|
|
|
to?: string;
|
|
|
why?: string;
|
|
|
}
|
|
|
|
|
|
/** Values every `data-*` axis of the component's morfo declares (lowercased). */
|
|
|
async function morfoModifiers(component: string): Promise<Set<string>> {
|
|
|
const out = new Set<string>();
|
|
|
const path = resolve(`src/uix/morfo/components/${component}.ts`);
|
|
|
if (!existsSync(path)) return out;
|
|
|
try {
|
|
|
const mod = (await import(`../src/uix/morfo/components/${component}`)) as Record<
|
|
|
string,
|
|
|
unknown
|
|
|
>;
|
|
|
const key = `${component.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())}Morfo`;
|
|
|
const morfo = mod[key];
|
|
|
if (!morfo) return out;
|
|
|
const compiled = compileMorfo(morfo as Morfo);
|
|
|
for (const contracts of compiled.contracts.dataAttrsByPart.values())
|
|
|
for (const c of contracts) for (const v of c.values ?? []) out.add(v.toLowerCase());
|
|
|
} catch {
|
|
|
/* a morfo that will not import is the eidos-lint's business, not the census's */
|
|
|
}
|
|
|
return out;
|
|
|
}
|
|
|
|
|
|
/** Every component recipe CSS, read once — a token may be consumed by a SIBLING. */
|
|
|
let cssIndex: Array<{ component: string; text: string }> | null = null;
|
|
|
function allComponentCss(): Array<{ component: string; text: string }> {
|
|
|
if (cssIndex) return cssIndex;
|
|
|
cssIndex = [];
|
|
|
for (const dir of readdirSync(ROOT)) {
|
|
|
const path = join(ROOT, dir, `${dir}.css`);
|
|
|
if (!existsSync(path)) continue;
|
|
|
cssIndex.push({ component: dir, text: readFileSync(path, 'utf8').replace(/\r\n/g, '\n') });
|
|
|
}
|
|
|
return cssIndex;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* The CSS properties a token feeds, following ONE hop through a private
|
|
|
* (`--_switch-track-bg-hover: var(--switch-track-bg-off-hover)`) and across
|
|
|
* components (the calendar family lends `--calendar-control-*` to month-grid,
|
|
|
* range-calendar and year-grid).
|
|
|
*/
|
|
|
function propertiesFed(token: string): string[] {
|
|
|
const props: string[] = [];
|
|
|
const privates: string[] = [];
|
|
|
for (const { text } of allComponentCss()) {
|
|
|
if (!text.includes(token)) continue;
|
|
|
for (const line of text.split('\n')) {
|
|
|
if (!line.includes(token)) continue;
|
|
|
const m = line.match(/^\s*(--[a-z0-9_-]+|[a-z-]+)\s*:/);
|
|
|
if (!m) continue;
|
|
|
if (m[1].startsWith('--')) privates.push(m[1]);
|
|
|
else props.push(m[1]);
|
|
|
}
|
|
|
}
|
|
|
for (const priv of privates)
|
|
|
for (const { text } of allComponentCss()) {
|
|
|
if (!text.includes(priv)) continue;
|
|
|
for (const line of text.split('\n')) {
|
|
|
if (!line.includes(priv)) continue;
|
|
|
const m = line.match(/^\s*([a-z-]+)\s*:/);
|
|
|
if (m) props.push(m[1]);
|
|
|
}
|
|
|
}
|
|
|
return props;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* A knob heads for the state-layer migration (§38 + R-4.3) only when it is
|
|
|
* BOTH neutral in value AND painting a BACKGROUND — the veil of §38 is
|
|
|
* `background-image: linear-gradient(var(--state-hover), var(--state-hover))`
|
|
|
* and it reaches nothing else.
|
|
|
*
|
|
|
* Measured 2026-08-20, and it corrected this classifier: of the 47 knobs the
|
|
|
* value test alone had queued, only SIX paint a background. Twenty-two move a
|
|
|
* BORDER and nineteen move the INK — neither is the state layer, whatever
|
|
|
* their value, so they are ordinary naming debt and the codemod renames them.
|
|
|
* Whether a per-component border/ink hover should exist AT ALL is a separate
|
|
|
* question that §38 does not settle (R-4.3 guards `background*` only).
|
|
|
*
|
|
|
* The value test still runs first: a VALENCED hover is the recipe's palette
|
|
|
* swap (recipe-contract §2), legitimate and renamed like any other key —
|
|
|
* `dropdown-menu.item-bg-hover` reads `var(--color-primary-element)` and only
|
|
|
* its NAME looks neutral.
|
|
|
*/
|
|
|
function isNeutralHover(component: string, key: string): boolean {
|
|
|
if (!/(^|-)hover(-|$)/.test(key)) return false;
|
|
|
if (VARIANT_TAILS.test(key)) return false;
|
|
|
const block = recipeBlock(component);
|
|
|
if (block === null) return false;
|
|
|
const decl = block.match(new RegExp("^\\t\\t'?" + key + "'?\\s*:\\s*(.+)$", 'm'));
|
|
|
const value = decl?.[1] ?? '';
|
|
|
const valenced = new RegExp('var\\(--color-(' + ROLES.join('|') + ')-').test(value);
|
|
|
if (valenced) return false;
|
|
|
if (!/var\(--color-(surface|content|border|neutral)[-)]/.test(value)) return false;
|
|
|
return propertiesFed(`--${component}-${key}`).some((p) => /^background/.test(p));
|
|
|
}
|
|
|
|
|
|
/** Classify one recipe key against the signed grammar. */
|
|
|
function classifyName(component: string, key: string, modifiers: Set<string>): NameFinding {
|
|
|
const at = (klass: NameClass, to?: string, why?: string): NameFinding => ({
|
|
|
component,
|
|
|
key,
|
|
|
klass,
|
|
|
to,
|
|
|
why
|
|
|
});
|
|
|
|
|
|
const override = NAME_OVERRIDE[component + '.' + key];
|
|
|
if (override) return at(/(^|-)color(-|$)/.test(key) ? 'ink' : 'modifier', override, 'acta');
|
|
|
|
|
|
const exempt = NAME_EXEMPT.find((e) => e.re.test(key));
|
|
|
if (exempt) return at('exempt', undefined, exempt.why);
|
|
|
|
|
|
const roleSlot =
|
|
|
ROLES.some((r) => new RegExp('(^|-)' + r + '-(' + ROLE_SLOT_TAILS.join('|') + ')$').test(key)) ||
|
|
|
VARIANT_TAILS.test(key);
|
|
|
if (roleSlot) return at('role-slot');
|
|
|
|
|
|
if (isNeutralHover(component, key)) return at('state-layer', undefined, 'migra a --state-hover');
|
|
|
|
|
|
// Peel the trailing INTERACTIVE state only. A contextual qualifier keeps its
|
|
|
// place (see the header): `modifiers` validates it, it never promotes it.
|
|
|
const valid = new Set(UNIVERSAL_MODIFIERS);
|
|
|
const mods: string[] = [];
|
|
|
let base = key;
|
|
|
for (;;) {
|
|
|
const hit = [...valid].find((m) => base.endsWith('-' + m) && base.length > m.length + 1);
|
|
|
if (!hit) break;
|
|
|
mods.unshift(hit);
|
|
|
base = base.slice(0, -(hit.length + 1));
|
|
|
}
|
|
|
|
|
|
const ink = /(^|-)color(-|$)/.test(base) && !/^color-scheme/.test(base);
|
|
|
if (!ink && mods.length === 0) return at('ok');
|
|
|
|
|
|
// `color` as the ink slot → `fg`; `color` as a medial segment keeps its
|
|
|
// neighbours (`status-color` → `status-fg`).
|
|
|
if (ink) base = base === 'color' ? 'fg' : base.replace(/(^|-)color(-|$)/, (_, a, b) => a + 'fg' + b);
|
|
|
const to = mods.length ? mods.join('-') + '-' + base : base;
|
|
|
if (to === key) return at('ok');
|
|
|
return at(ink && mods.length ? 'both' : ink ? 'ink' : 'modifier', to);
|
|
|
}
|
|
|
|
|
|
export async function names(only?: string): Promise<NameFinding[]> {
|
|
|
const out: NameFinding[] = [];
|
|
|
for (const dir of readdirSync(ROOT)) {
|
|
|
if (only && dir !== only) continue;
|
|
|
if (recipeBlock(dir) === null) continue;
|
|
|
const modifiers = await morfoModifiers(dir);
|
|
|
for (const key of contractKeyNames(dir)) {
|
|
|
if (key.startsWith('_')) continue;
|
|
|
out.push(classifyName(dir, key, modifiers));
|
|
|
}
|
|
|
}
|
|
|
return out;
|
|
|
}
|
|
|
|
|
|
async function reportNames(only?: string, asJson = false) {
|
|
|
const findings = await names(only);
|
|
|
if (asJson) {
|
|
|
process.stdout.write(JSON.stringify(findings, null, '\t') + '\n');
|
|
|
return;
|
|
|
}
|
|
|
const of = (k: NameClass) => findings.filter((f) => f.klass === k);
|
|
|
const deviated = [...of('ink'), ...of('modifier'), ...of('both')];
|
|
|
const comps = new Set(deviated.map((f) => f.component));
|
|
|
console.log(`theming-census --names — ${findings.length} claves públicas`);
|
|
|
console.log(
|
|
|
` DESVIADAS ${deviated.length} en ${comps.size} componentes · ink ${of('ink').length} · modificador ${of('modifier').length} · ambas ${of('both').length}`
|
|
|
);
|
|
|
console.log(
|
|
|
` conformes ${of('ok').length} · role-slot canónicas ${of('role-slot').length} · exentas ${of('exempt').length} · capa de estado ${of('state-layer').length}`
|
|
|
);
|
|
|
const per = new Map<string, number>();
|
|
|
for (const f of deviated) per.set(f.component, (per.get(f.component) ?? 0) + 1);
|
|
|
console.log(
|
|
|
' peores: ' +
|
|
|
[...per.entries()]
|
|
|
.sort((a, b) => b[1] - a[1])
|
|
|
.slice(0, 10)
|
|
|
.map(([c, n]) => `${c}:${n}`)
|
|
|
.join(' ')
|
|
|
);
|
|
|
console.log('\n— DESVIADAS (el codemod las renombra) —');
|
|
|
for (const f of deviated) console.log(` ${f.component}.${f.key} → ${f.to}`);
|
|
|
console.log('\n— CAPA DE ESTADO (migran por la firma 3, NO se renombran) —');
|
|
|
for (const f of of('state-layer')) console.log(` ${f.component}.${f.key}`);
|
|
|
console.log('\n— EXENTAS (firmadas) —');
|
|
|
for (const f of of('exempt')) console.log(` ${f.component}.${f.key} (${f.why})`);
|
|
|
}
|
|
|
|
|
|
function main() {
|
|
|
const args = process.argv.slice(2);
|
|
|
const only = args.includes('--only') ? args[args.indexOf('--only') + 1] : undefined;
|
|
|
if (args.includes('--names')) {
|
|
|
void reportNames(only, args.includes('--json'));
|
|
|
return;
|
|
|
}
|
|
|
if (args.includes('--report')) {
|
|
|
writeReport();
|
|
|
return;
|
|
|
}
|
|
|
const rows = census(only);
|
|
|
if (args.includes('--json')) {
|
|
|
process.stdout.write(JSON.stringify(rows, null, '\t') + '\n');
|
|
|
return;
|
|
|
}
|
|
|
const sum = (k: keyof CensusRow) => rows.reduce((s, r) => s + (r[k] as number), 0);
|
|
|
const themeable = sum('public') + sum('private') + sum('global') + sum('literal');
|
|
|
console.log(`theming-census — ${rows.length} component recipe(s)`);
|
|
|
console.log(
|
|
|
` appearance knobs ${sum('knobs')} · public ${sum('public')} (${pct(sum('public'), themeable)} reach) · private ${sum('private')} · global ${sum('global')} · literal ${sum('literal')} · system ${sum('system')}`
|
|
|
);
|
|
|
console.log(
|
|
|
` no contract entry: ${rows.filter((r) => r.contractKeys === 0).length} · reach < 20%: ${rows.filter((r) => r.reach < 0.2).length} · reach = 100%: ${rows.filter((r) => r.reach === 1 && r.public > 0).length} · with data-size: ${rows.filter((r) => r.hasSize).length}`
|
|
|
);
|
|
|
console.log('');
|
|
|
const sorted = [...rows].sort((a, b) => a.reach - b.reach || b.knobs - a.knobs);
|
|
|
console.log(
|
|
|
'component'.padEnd(24) +
|
|
|
'reach'.padStart(6) +
|
|
|
'knobs'.padStart(7) +
|
|
|
'public'.padStart(8) +
|
|
|
'private'.padStart(9) +
|
|
|
'global'.padStart(8) +
|
|
|
'literal'.padStart(9) +
|
|
|
'system'.padStart(8) +
|
|
|
'contract'.padStart(10) +
|
|
|
' size'
|
|
|
);
|
|
|
for (const r of sorted) {
|
|
|
console.log(
|
|
|
r.component.padEnd(24) +
|
|
|
reachOf(r).padStart(6) +
|
|
|
String(r.knobs).padStart(7) +
|
|
|
String(r.public).padStart(8) +
|
|
|
String(r.private).padStart(9) +
|
|
|
String(r.global).padStart(8) +
|
|
|
String(r.literal).padStart(9) +
|
|
|
String(r.system).padStart(8) +
|
|
|
String(r.contractKeys).padStart(10) +
|
|
|
(r.hasSize ? ' y' : ' -')
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (process.argv[1] && /theming-census\.ts$/.test(process.argv[1].replace(/\\/g, '/'))) main();
|