|
|
/**
|
|
|
* 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)
|
|
|
* node --import tsx/esm scripts/theming-census.ts --residue [out.tsv]
|
|
|
* # privates still to adjudicate
|
|
|
*
|
|
|
* 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`)
|
|
|
* exception — a SIGNED deviation, in either of the canon's two written
|
|
|
* forms: a literal annotated `/* literal: <reason> */`
|
|
|
* (recipe-contract §3's valve, the same one `component-audit`
|
|
|
* honours) or a residue private annotated `/* private: <reason> */`
|
|
|
* (the same valve one class over, 2026-08-25 — see
|
|
|
* `ANNOTATED_PRIVATE`). A signed deviation is not debt.
|
|
|
* structural — EVERY knob of a component whose 0 % is its NATURE and not its
|
|
|
* debt, because it has no contract to write: the signed list is
|
|
|
* `STRUCTURAL_COMPONENTS` below.
|
|
|
* bridge — `var(--_{c}-palette-{slot})`: the private the THM-2 palette
|
|
|
* forward WRITES. The recipe reads the mechanism of the
|
|
|
* per-instance palette, not a name of its own.
|
|
|
* channel — `var(--_{c}-x, fallback)` where NOBODY declares `--_{c}-x`:
|
|
|
* soma or the wrapper writes it per INSTANCE (a %, a measured
|
|
|
* rect, the size a prop asks for). A theme must not reach it.
|
|
|
* Reach = public / (public + private + global + literal). `system`,
|
|
|
* `exception`, `structural`, `bridge` and `channel` are reported but excluded
|
|
|
* from the ratio on purpose: the first is themeable at the system level by
|
|
|
* design (recipe-contract §2), the second is a deviation the canon already
|
|
|
* accepted in writing (§3), the third has nothing a theme could name in the
|
|
|
* first place, and the last two are the two shapes of private the doctrine of
|
|
|
* this axis already adjudicated as NOT debt (see `PALETTE_BRIDGE` below).
|
|
|
*
|
|
|
* 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';
|
|
|
import { CENSUS_DEBT } from './theming-census-debt.ts';
|
|
|
|
|
|
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|border-top-color|border-bottom-color|border-left-color|border-right-color|border-block-start-color|border-block-end-color|border-inline-start-color|border-inline-end-color|border-top-width|border-bottom-width|border-left-width|border-right-width|border-block-start-width|border-block-end-width|border-inline-start-width|border-inline-end-width|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'
|
|
|
]);
|
|
|
|
|
|
/** recipe-contract §3's exception valve, as written in the canon. */
|
|
|
const ANNOTATED = new RegExp('[/][*][ ]*literal:');
|
|
|
|
|
|
/**
|
|
|
* The SAME valve, one class over: `/* private: <reason> */` (signature of
|
|
|
* 2026-08-25, F2 of the 318-privates adjudication).
|
|
|
*
|
|
|
* `literal:` signs a value that is not a token. `private:` signs a knob whose
|
|
|
* value routes through a private the doctrine of this axis already blesses but
|
|
|
* that no MECHANICAL test can see, because the blessed shape sits ONE LEVEL
|
|
|
* BELOW the knob: a variant / role CONMUTADOR whose branches are the THM-2
|
|
|
* palette bridge (`card`, `avatar`, `badge`, `surface`, `switch`, `timeline`),
|
|
|
* a per-instance value CHANNEL read through an alias (`drawer`, `popover`,
|
|
|
* `avatar`'s custom ring), a shared LAYER adopted through a private
|
|
|
* (`listbox` → `list-surface`), or the identity of a variant (`toolbar`'s
|
|
|
* ghost bar, `accordion`'s shadowless outline).
|
|
|
*
|
|
|
* Read in the same TWO places `literal:` is: on the private's own declaration
|
|
|
* (the economical shape — one note adjudicates every knob that reads it) and
|
|
|
* on the knob declaration itself. A knob is signed when its own declaration
|
|
|
* carries the note OR when EVERY private it reads carries it: `every`, not
|
|
|
* `some`, for the same reason the bridge test uses it — the unsigned half is
|
|
|
* exactly what still needs adjudicating. A private is signed ONCE, on the
|
|
|
* declaration that establishes the mechanism: a conmutador is ONE decision,
|
|
|
* not one per branch, and eight identical notes down a per-role cascade would
|
|
|
* be noise pretending to be eight acts.
|
|
|
*
|
|
|
* PLACEMENT differs from `literal:` in one way, and it is a prettier
|
|
|
* constraint, not a doctrinal one: the note may also sit in the comment block
|
|
|
* immediately ABOVE the declaration. These reasons cite a sheet and a date, so
|
|
|
* a trailing comment pushes the line past the 100-column print width and
|
|
|
* prettier answers by breaking the `var(…)` across lines — which would make an
|
|
|
* annotation commit rewrite the CSS it only meant to sign. The upward walk
|
|
|
* stops at the first line that carries actual CSS (tested on the STRIPPED
|
|
|
* body, where a comment-only line is blank), so a note can never leak onto the
|
|
|
* declaration that follows the one it was written for.
|
|
|
*
|
|
|
* Resulting class: `exception`. The vocabulary stays CLOSED — this is the
|
|
|
* canon's existing «signed deviation» class, not a new tier — and, like every
|
|
|
* exception, it leaves the ratio's denominator without leaving the listing.
|
|
|
*/
|
|
|
const ANNOTATED_PRIVATE = new RegExp('[/][*][ ]*private:');
|
|
|
|
|
|
export type KnobClass =
|
|
|
| 'public'
|
|
|
| 'private'
|
|
|
| 'system'
|
|
|
| 'global'
|
|
|
| 'literal'
|
|
|
| 'exception'
|
|
|
| 'structural'
|
|
|
| 'bridge'
|
|
|
| 'channel';
|
|
|
|
|
|
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;
|
|
|
/** A literal carrying its `/* literal: … */` annotation — recipe-contract §3. */
|
|
|
exception: number;
|
|
|
/** Knobs of a component whose 0 % is its nature — `STRUCTURAL_COMPONENTS`. */
|
|
|
structural: number;
|
|
|
/** Knobs reading the private the THM-2 palette forward writes — `PALETTE_BRIDGE`. */
|
|
|
bridge: number;
|
|
|
/** Knobs reading a private nobody declares: soma / the wrapper writes it per instance. */
|
|
|
channel: number;
|
|
|
/** public / (public + private + global + literal) — the other five out. */
|
|
|
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-]+/
|
|
|
}
|
|
|
];
|
|
|
|
|
|
/**
|
|
|
* A component whose 0 % is its NATURE, not its debt (next-features §13).
|
|
|
*
|
|
|
* Same shape as `LAYER_VOCABULARY` one floor down — the list AND the reason per
|
|
|
* component, so the contract lives IN the artifact instead of in a session's
|
|
|
* memory. The class is per COMPONENT, all of its knobs: what was measured is
|
|
|
* the component, not one declaration.
|
|
|
*
|
|
|
* Measured one by one on 2026-08-22 and SIGNED in the §5 verdict of each sheet
|
|
|
* (`docs/audit/theming/{c}.md`): the five read 0 % reach and NONE of them has a
|
|
|
* contract to write. While the census counted them like a component carrying
|
|
|
* real debt, the global figure lied downwards and F3's gate («census 100 %»)
|
|
|
* was unreachable BY CONSTRUCTION.
|
|
|
*
|
|
|
* Their knobs are still counted and still listed — a structural knob is a knob
|
|
|
* — they only leave the ratio's DENOMINATOR, exactly like `system` does.
|
|
|
*
|
|
|
* This is NOT a drawer for «this one is hard»: every entry is a written §5
|
|
|
* verdict, and a component that gains a real theme surface leaves the list.
|
|
|
*/
|
|
|
const STRUCTURAL_COMPONENTS = new Map<string, string>([
|
|
|
// Its only global is `var(--box-width, 100%)`, BORROWED from `box` (the sheet
|
|
|
// already marks it ⤴): `aspect-ratio` selects `[data-box][data-aspect-ratio]`,
|
|
|
// and `--aspect-ratio` is a per-instance value channel, not a theme surface.
|
|
|
[
|
|
|
'aspect-ratio',
|
|
|
'es una FACETA de `box`, no un componente: el eje lo posee `box` y su knob viene prestado (⤴); `--aspect-ratio` es canal de valor por instancia'
|
|
|
],
|
|
|
// The `1px` × 2 of the canonical sr-only technique on `[data-text-blur-sr]`.
|
|
|
[
|
|
|
'text-blur',
|
|
|
'sus dos knobs son el `1px` de la técnica sr-only: receta de accesibilidad idéntica en todo el catálogo, no estética'
|
|
|
],
|
|
|
// The reveal gate's `opacity` — motion-channel mechanics.
|
|
|
[
|
|
|
'cascade',
|
|
|
'su knob es el `opacity` del gate antiparpadeo: mecánica del canal de motion, cuyo valor tematizable vive en `EidosConfig.motion`'
|
|
|
],
|
|
|
[
|
|
|
'motion',
|
|
|
'igual que `cascade`: el `opacity` del gate `[data-animation-pending]` es mecánica del canal, tematizable desde `EidosConfig.motion`'
|
|
|
],
|
|
|
// `max-content` × 2 on the popover that hosts the calendar: a composition fix
|
|
|
// with ONE correct value. Its chrome comes from `field` / `date-field`.
|
|
|
[
|
|
|
'date-picker',
|
|
|
'sus dos knobs son la corrección `max-content` del pie del popover — un único valor correcto; su cromo vive en `field`, `calendar` y `picker-shell`'
|
|
|
]
|
|
|
]);
|
|
|
|
|
|
/**
|
|
|
* The two shapes of private this axis' doctrine already adjudicated as NOT
|
|
|
* debt — read off the EMITTER'S OUTPUT, never guessed from the name.
|
|
|
*
|
|
|
* The anchor is `src/uix/eidos/generated/base.css`: `generated-css.test.ts`
|
|
|
* keeps it byte-identical to `renderStaticCss()`, and `recipe-css-contract`
|
|
|
* already reads it to assert that every palette recipe emits its forward. So
|
|
|
* the question asked here is «what does the generator WRITE», not «what does
|
|
|
* this name look like» — and that difference is the whole point: adjudicating
|
|
|
* by name is the blindness this same axis was burned by.
|
|
|
*
|
|
|
* `bridge` — the THM-2 forward (`renderRecipePaletteForward` +
|
|
|
* `isPaletteSlotToken`, render-css.ts) writes `--_{c}-palette-{slot}` under
|
|
|
* the presence guard `[data-{c}]:where([data-color], [data-color-custom])`,
|
|
|
* with the component's OWN host default as the fallback. A recipe reading it
|
|
|
* consumes the MECHANISM of the per-instance palette, and it is reachable
|
|
|
* TWICE: through the shared `--palette-*` layer and through the component's
|
|
|
* own tone tokens (`--card-neutral-track`) — both public. A public on top
|
|
|
* would let a theme PIN it and kill the `color=` of every instance in
|
|
|
* silence; that is written, measured, in the §5 verdicts of `card`,
|
|
|
* `tags-input` and `avatar`.
|
|
|
* The test is the forward LINE, not a name shaped like one: only
|
|
|
* `--_{c}-palette-{slot}: var(--palette-{slot}, …)` counts, so a
|
|
|
* `--_c-palette-shadow` nobody forwards stays residue (same discipline as
|
|
|
* `isPaletteSlotToken`, which matches the slot const and not a loose regex).
|
|
|
*
|
|
|
* `channel` — the value channel: a private the recipe READS and NOBODY
|
|
|
* declares, neither the component's CSS nor the generator. Soma or the
|
|
|
* wrapper writes it per INSTANCE (`--_progress-value-pct`,
|
|
|
* `--_dialog-content-width-override`, `--_chronos-event-accent`). A theme
|
|
|
* must not reach it: pinning it breaks the behaviour, which is why `tabs`
|
|
|
* refused its §4.1 proposal in writing. Measured 2026-08-25: all 29 names in
|
|
|
* this class have a writer in a wrapper (`*.svelte`) or in soma.
|
|
|
*
|
|
|
* Both leave the ratio's DENOMINATOR, exactly like `system` and `structural`;
|
|
|
* their knobs are still counted and still listed. A private that is NEITHER
|
|
|
* stays `private`: the RESIDUE that signature deliberately left to be
|
|
|
* adjudicated key by key (`--residue`). That adjudication landed on
|
|
|
* 2026-08-25: of its 150 knobs, 84 carry a written `/* private: <reason> */`
|
|
|
* (class `exception`) because the shape the doctrine blesses sits one level
|
|
|
* BELOW the knob, and the other 66 are registered in the debt ledger. `private`
|
|
|
* is a `DebtClass` from that day, so a new one arrives NAMED.
|
|
|
*/
|
|
|
const GENERATED_CSS = readFileSync(resolve('src/uix/eidos/generated/base.css'), 'utf8').replace(
|
|
|
/\r\n/g,
|
|
|
'\n'
|
|
|
);
|
|
|
|
|
|
/** `--_{c}-palette-{slot}` AS THE FORWARD EMITS IT — the bridge, verbatim. */
|
|
|
const PALETTE_BRIDGE = new Set(
|
|
|
[
|
|
|
...GENERATED_CSS.matchAll(
|
|
|
/(--_[a-z0-9-]+-palette-([a-z-]+))\s*:\s*var\(\s*--palette-\2\s*[,)]/g
|
|
|
)
|
|
|
].map((m) => m[1])
|
|
|
);
|
|
|
|
|
|
/**
|
|
|
* Every private the GENERATOR declares — recipe keys, palette forwards and the
|
|
|
* gradient-finish ramp (`--_{c}-fill-finish`). A private in here is written by
|
|
|
* the build, so it is NOT a runtime channel however undeclared the CSS leaves
|
|
|
* it: without this test the four gradient-finish knobs read as «written by
|
|
|
* soma», which is the wrong reason for the right answer.
|
|
|
*/
|
|
|
const GENERATED_PRIVATES = new Set(
|
|
|
[...GENERATED_CSS.matchAll(/(?<=^\s*|;\s*)(--_[a-z0-9-]+)\s*:/gm)].map((m) => m[1])
|
|
|
);
|
|
|
|
|
|
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 {
|
|
|
// `avatar` is the ONLY entry whose value is an IIFE (a local matrix helper
|
|
|
// generates its 24 composite scopes), so its map lives in the `return {` one
|
|
|
// tab deeper and this reader saw NO contract for it — 84 keys reported as
|
|
|
// «sin entrada en base.ts». Read the returned map and dedent it once; the
|
|
|
// key regexes below work unchanged.
|
|
|
const iife = CONTRACT.indexOf('\n\t' + c + ': ((): RecipeTokenMap => {');
|
|
|
if (iife >= 0) {
|
|
|
const from = CONTRACT.indexOf('\n\t\treturn {', iife);
|
|
|
return CONTRACT.slice(from, CONTRACT.indexOf('\n\t\t};', from)).replace(/^\t/gm, '');
|
|
|
}
|
|
|
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,
|
|
|
exception: 0,
|
|
|
structural: 0,
|
|
|
bridge: 0,
|
|
|
channel: 0,
|
|
|
reach: 0,
|
|
|
contractKeys: contractKeysFor(dir),
|
|
|
hasSize: false
|
|
|
};
|
|
|
const knobs: Knob[] = [];
|
|
|
const privates: PrivateDecl[] = [];
|
|
|
const usedPrivates = new Set<string>();
|
|
|
/** `--_{c}-x` declarations carrying `/* private: … */` — the valve, one level down. */
|
|
|
const signedPrivates = new Set<string>();
|
|
|
/** Knobs carrying the note on their OWN declaration — the valve, at the knob. */
|
|
|
const signedKnobs = new Set<Knob>();
|
|
|
|
|
|
for (const file of files) {
|
|
|
// The stripped body is what gets CLASSIFIED (a comment must not read as a
|
|
|
// value), but the annotation of recipe-contract §3 lives IN a comment, so
|
|
|
// the raw lines are kept beside it. `strip` blanks comments in place, so
|
|
|
// the two are line-for-line aligned.
|
|
|
const raw = readFileSync(join(d, file), 'utf8').replace(/\r\n/g, '\n');
|
|
|
const rawLines = raw.split('\n');
|
|
|
const body = strip(raw);
|
|
|
const bodyLines = body.split('\n');
|
|
|
/**
|
|
|
* The `private:` note on the declaration at `line`, or in the comment
|
|
|
* block immediately above it. The walk climbs only over lines that are
|
|
|
* BLANK once comments are stripped — i.e. lines that were nothing but a
|
|
|
* comment — so it stops dead at the previous declaration, at the rule's
|
|
|
* `{`, and at the selector.
|
|
|
*/
|
|
|
const signedAt = (line: number, endLine: number): boolean => {
|
|
|
for (let ln = line; ln <= endLine; ln++)
|
|
|
if (ANNOTATED_PRIVATE.test(rawLines[ln - 1] ?? '')) return true;
|
|
|
for (let ln = line - 1; ln >= 1 && (bodyLines[ln - 1] ?? '').trim() === ''; ln--)
|
|
|
if (ANNOTATED_PRIVATE.test(rawLines[ln - 1] ?? '')) return true;
|
|
|
return false;
|
|
|
};
|
|
|
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 + '-')) {
|
|
|
// recipe-contract §3 again, one level down: a private whose value is a
|
|
|
// literal WITH its written reason is a signed absence, not debt. Button
|
|
|
// declares `--_button-bg: transparent` on the variants that ARE the absence
|
|
|
// of chrome; without this those privates score as non-derived and six knobs
|
|
|
// read unreachable while every OTHER branch of the same private reads a
|
|
|
// public. Same valve as the knob below, same requirement: the reason is
|
|
|
// written on the declaration.
|
|
|
let source = classify(val, pubNeedle, privNeedle, dir);
|
|
|
if (source === 'literal' && ANNOTATED.test(rawLines[line - 1] ?? ''))
|
|
|
source = 'exception';
|
|
|
// The `private:` valve. It does NOT touch `source`: a signed private
|
|
|
// must not read as «derives from a public», which would score the
|
|
|
// knob `public` and inflate the reach. It signs the knobs that READ
|
|
|
// it, in the fourth pass below.
|
|
|
if (signedAt(line, lineAt(innerStart + (m.index ?? 0) + m[0].length - 1)))
|
|
|
signedPrivates.add(prop);
|
|
|
privates.push({ name: prop, file, line, selector, value: val, source });
|
|
|
}
|
|
|
continue;
|
|
|
}
|
|
|
if (!KNOB_PROPS.test(prop) || INERT.has(val)) continue;
|
|
|
// A structural component is structural WHOLE: classifying its knobs one
|
|
|
// by one would ask a question its §5 verdict already answered.
|
|
|
let klass: KnobClass = STRUCTURAL_COMPONENTS.has(dir)
|
|
|
? 'structural'
|
|
|
: classify(val, pubNeedle, privNeedle, dir);
|
|
|
// recipe-contract §3: a deviation annotated on its own declaration is
|
|
|
// a SIGNED exception, not drift — the same valve `component-audit`
|
|
|
// honours for R-4.x. The census used to count all 81 of them as debt
|
|
|
// because `strip` blanks the comment before anything is classified.
|
|
|
// The whole declaration counts as "its own line": a value broken over
|
|
|
// several lines carries the note at the end.
|
|
|
if (klass === 'literal') {
|
|
|
const endLine = lineAt(innerStart + (m.index ?? 0) + m[0].length - 1);
|
|
|
for (let ln = line; ln <= endLine; ln++)
|
|
|
if (ANNOTATED.test(rawLines[ln - 1] ?? '')) {
|
|
|
klass = 'exception';
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
row[klass]++;
|
|
|
const knob: Knob = { file, line, selector, prop, value: val, klass };
|
|
|
// Same span as the `literal:` note above: a value broken over several
|
|
|
// lines carries its note at the end. Recorded now and applied in the
|
|
|
// fourth pass, because a knob can still be reclassified `public`,
|
|
|
// `bridge` or `channel` first — and where a mechanical test decides,
|
|
|
// a written note has nothing left to sign.
|
|
|
if (klass === 'private') {
|
|
|
const endLine = lineAt(innerStart + (m.index ?? 0) + m[0].length - 1);
|
|
|
if (signedAt(line, endLine)) signedKnobs.add(knob);
|
|
|
}
|
|
|
knobs.push(knob);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ── 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' || s === 'exception') 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';
|
|
|
}
|
|
|
|
|
|
// ── Third pass: the palette BRIDGE and the value CHANNEL ──
|
|
|
// The two shapes of private the doctrine adjudicated as not-debt, decided by
|
|
|
// what the GENERATOR writes (see `PALETTE_BRIDGE`). `every`, not `some`: a
|
|
|
// knob mixing a bridge with anything else stays residue, because the
|
|
|
// anything else is precisely what still needs adjudicating. A FOREIGN
|
|
|
// private (another component's `--_x-…`) can be a bridge — the forward is
|
|
|
// the same mechanism wherever it is emitted — but never a channel: this
|
|
|
// component's CSS is not where it would be declared.
|
|
|
const own = '--_' + dir + '-';
|
|
|
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) continue;
|
|
|
const klass: KnobClass | null = refs.every((r) => PALETTE_BRIDGE.has(r))
|
|
|
? 'bridge'
|
|
|
: refs.every((r) => r.startsWith(own) && !declBySource.has(r) && !GENERATED_PRIVATES.has(r))
|
|
|
? 'channel'
|
|
|
: null;
|
|
|
if (klass === null) continue;
|
|
|
row.private--;
|
|
|
row[klass]++;
|
|
|
k.klass = klass;
|
|
|
}
|
|
|
|
|
|
// ── Fourth pass: the WRITTEN valve for what no mechanical test can see ──
|
|
|
// LAST on purpose: the three passes above decide by MEASUREMENT, and a
|
|
|
// signature over a knob the machine already adjudicated would be an act with
|
|
|
// nothing left to sign. What survives to here is the residue — and a residue
|
|
|
// knob is `exception` when its own declaration carries `/* private: … */`,
|
|
|
// or when EVERY private it reads does (see `ANNOTATED_PRIVATE`).
|
|
|
for (const k of knobs) {
|
|
|
if (k.klass !== 'private') continue;
|
|
|
const refs = [...new Set([...k.value.matchAll(/var\(\s*(--_[a-z0-9-]+)/g)].map((m) => m[1]))];
|
|
|
if (!signedKnobs.has(k) && !(refs.length > 0 && refs.every((r) => signedPrivates.has(r))))
|
|
|
continue;
|
|
|
row.private--;
|
|
|
row.exception++;
|
|
|
k.klass = 'exception';
|
|
|
}
|
|
|
|
|
|
const themeable = row.public + row.private + row.global + row.literal;
|
|
|
row.knobs = themeable + row.system + row.structural + row.bridge + row.channel;
|
|
|
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[] {
|
|
|
return censusAudit(only).rows;
|
|
|
}
|
|
|
|
|
|
// ─── The debt ledger — R-5.1's per-key ratchet ───────────────────────────────
|
|
|
|
|
|
/**
|
|
|
* The THREE unreached classes the ratchet registers key by key.
|
|
|
*
|
|
|
* `private` joined `global` and `literal` on 2026-08-25 (F2 of the
|
|
|
* 318-privates adjudication), and with it the hole the ledger's header used to
|
|
|
* name is closed: a knob reading a private that derives from nothing — not a
|
|
|
* public, not the palette bridge, not a value channel, and carrying no
|
|
|
* `/* private: … */` signature — is now debt like any other. It has the same
|
|
|
* three exits (tokenize it, sign it, or register it) and the same STALE
|
|
|
* detector on the way out.
|
|
|
*/
|
|
|
export type DebtClass = Extract<KnobClass, 'global' | 'literal' | 'private'>;
|
|
|
|
|
|
const isDebt = (k: Knob): boolean =>
|
|
|
k.klass === 'global' || k.klass === 'literal' || k.klass === 'private';
|
|
|
|
|
|
/**
|
|
|
* The canonical identity of one debt knob: `{class} · {file} · {selector} ·
|
|
|
* {property}`.
|
|
|
*
|
|
|
* LINE-INDEPENDENT on purpose. A ledger keyed by `file:line` would report a
|
|
|
* regression every time a rule moved down three lines, and the noise would
|
|
|
* teach everyone to regenerate the baseline — which is exactly how a ratchet
|
|
|
* dies. The cost of the choice is that a SELECTOR rename reads as one STALE
|
|
|
* plus one new key; that is the ratchet working, not a defect (the debt moved
|
|
|
* and gets re-signed).
|
|
|
*/
|
|
|
export function debtKey(k: Knob): string {
|
|
|
return `${k.klass} · ${k.file} · ${k.selector} · ${k.prop}`;
|
|
|
}
|
|
|
|
|
|
export interface DebtFinding {
|
|
|
component: string;
|
|
|
key: string;
|
|
|
}
|
|
|
|
|
|
export interface DebtAudit {
|
|
|
/** Debt in the CSS that no ledger entry covers — a regression. */
|
|
|
newDebt: DebtFinding[];
|
|
|
/** Ledger entries with no debt behind them — tokenized, annotated or dead. */
|
|
|
stale: DebtFinding[];
|
|
|
/** Ledger entries in scope (the size of the frozen debt). */
|
|
|
registered: number;
|
|
|
/** Per component, for the audit rows. */
|
|
|
byComponent: Map<string, { newDebt: string[]; stale: string[]; registered: number }>;
|
|
|
}
|
|
|
|
|
|
export interface CensusAudit {
|
|
|
rows: CensusRow[];
|
|
|
/** component → its debt keys today, sorted (the multiset the ledger freezes). */
|
|
|
today: Map<string, string[]>;
|
|
|
debt: DebtAudit;
|
|
|
}
|
|
|
|
|
|
/** Occurrence count — the comparison is a MULTISET, so a duplicate is caught. */
|
|
|
function tally(xs: string[]): Map<string, number> {
|
|
|
const m = new Map<string, number>();
|
|
|
for (const x of xs) m.set(x, (m.get(x) ?? 0) + 1);
|
|
|
return m;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* ONE pass over the tree that answers both questions the axis asks: the
|
|
|
* classification (`rows`) and the state of the ledger (`debt`). Two passes
|
|
|
* would double the cost for every consumer that needs both — `component-audit`
|
|
|
* needs exactly that for its R-5.1 and R-5.2 rows.
|
|
|
*/
|
|
|
export function censusAudit(only?: string): CensusAudit {
|
|
|
const rows: CensusRow[] = [];
|
|
|
const today = new Map<string, string[]>();
|
|
|
// Sorted so the generated baseline is byte-identical between runs, whatever
|
|
|
// order the filesystem hands the directories back in.
|
|
|
for (const dir of [...readdirSync(ROOT)].sort()) {
|
|
|
if (only && dir !== only) continue;
|
|
|
const scan = scanComponent(dir);
|
|
|
if (!scan) continue;
|
|
|
rows.push(scan.row);
|
|
|
const keys = scan.knobs.filter(isDebt).map(debtKey).sort();
|
|
|
if (keys.length > 0) today.set(dir, keys);
|
|
|
}
|
|
|
|
|
|
const newDebt: DebtFinding[] = [];
|
|
|
const stale: DebtFinding[] = [];
|
|
|
const byComponent = new Map<string, { newDebt: string[]; stale: string[]; registered: number }>();
|
|
|
let registered = 0;
|
|
|
const components = [...new Set([...today.keys(), ...Object.keys(CENSUS_DEBT)])].sort();
|
|
|
for (const component of components) {
|
|
|
if (only && component !== only) continue;
|
|
|
const ledger = CENSUS_DEBT[component] ?? [];
|
|
|
const now = today.get(component) ?? [];
|
|
|
const a = tally(now);
|
|
|
const b = tally(ledger);
|
|
|
const entry = { newDebt: [] as string[], stale: [] as string[], registered: ledger.length };
|
|
|
for (const [key, n] of a)
|
|
|
for (let i = 0; i < n - (b.get(key) ?? 0); i++) entry.newDebt.push(key);
|
|
|
for (const [key, n] of b) for (let i = 0; i < n - (a.get(key) ?? 0); i++) entry.stale.push(key);
|
|
|
entry.newDebt.sort();
|
|
|
entry.stale.sort();
|
|
|
registered += ledger.length;
|
|
|
if (entry.newDebt.length > 0 || entry.stale.length > 0 || entry.registered > 0)
|
|
|
byComponent.set(component, entry);
|
|
|
for (const key of entry.newDebt) newDebt.push({ component, key });
|
|
|
for (const key of entry.stale) stale.push({ component, key });
|
|
|
}
|
|
|
return { rows, today, debt: { newDebt, stale, registered, byComponent } };
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Prettier's own quoting rule (fewest escapes): single quotes unless the
|
|
|
* string carries one and no double quote. Selectors DO carry single quotes
|
|
|
* (`[data-size='sm']`), so getting this wrong means the generated baseline
|
|
|
* fails `npm run lint` — and a baseline that cannot be committed clean is a
|
|
|
* baseline nobody regenerates correctly.
|
|
|
*/
|
|
|
function quote(s: string): string {
|
|
|
if (s.includes("'") && !s.includes('"')) return '"' + s.replace(/\\/g, '\\\\') + '"';
|
|
|
return "'" + s.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
|
|
|
}
|
|
|
|
|
|
/** The ledger's own source, rewritten below its doctrinal header. */
|
|
|
async function writeDebtBaseline() {
|
|
|
const path = resolve('scripts/theming-census-debt.ts');
|
|
|
const prev = readFileSync(path, 'utf8').replace(/\r\n/g, '\n');
|
|
|
const marker = '\nexport const CENSUS_DEBT: Record<string, string[]> = ';
|
|
|
const at = prev.indexOf(marker);
|
|
|
if (at < 0) throw new Error('theming-census-debt.ts: no `export const CENSUS_DEBT` to replace');
|
|
|
|
|
|
const { today, debt } = censusAudit();
|
|
|
const body = [...today.entries()]
|
|
|
.map(
|
|
|
([component, keys]) =>
|
|
|
`\t${/^[a-z][a-z0-9]*$/.test(component) ? component : quote(component)}: [\n` +
|
|
|
keys.map((k) => `\t\t${quote(k)}`).join(',\n') +
|
|
|
'\n\t]'
|
|
|
)
|
|
|
.join(',\n');
|
|
|
const source = prev.slice(0, at) + marker + '{\n' + body + '\n};\n';
|
|
|
|
|
|
// Formatted by prettier itself, not by hand: the file must land clean under
|
|
|
// `prettier --check .`, and prettier is deterministic, so two generations of
|
|
|
// the same census produce the same bytes.
|
|
|
const prettier = (await import('prettier')) as unknown as {
|
|
|
resolveConfig(p: string): Promise<Record<string, unknown> | null>;
|
|
|
format(src: string, opts: Record<string, unknown>): Promise<string>;
|
|
|
};
|
|
|
const config = (await prettier.resolveConfig(path)) ?? {};
|
|
|
writeFileSync(path, await prettier.format(source, { ...config, filepath: path }), 'utf8');
|
|
|
|
|
|
const total = [...today.values()].reduce((a, k) => a + k.length, 0);
|
|
|
console.log(
|
|
|
`theming-census --debt --write — ${total} debt key(s) across ${today.size} component(s)`
|
|
|
);
|
|
|
console.log(
|
|
|
` delta vs the ledger being replaced: +${debt.newDebt.length} new · -${debt.stale.length} retired`
|
|
|
);
|
|
|
if (debt.newDebt.length > 0 || debt.stale.length > 0)
|
|
|
console.log(' READ THAT DELTA: regenerating wholesale erases the ratchet (see the header).');
|
|
|
}
|
|
|
|
|
|
function reportDebt(only?: string, asJson = false) {
|
|
|
const { debt } = censusAudit(only);
|
|
|
// A check mode that cannot fail is half a guard: red findings set the exit
|
|
|
// code so `--debt` can gate a script or a hook, not only a human reader.
|
|
|
if (debt.newDebt.length > 0 || debt.stale.length > 0) process.exitCode = 1;
|
|
|
if (asJson) {
|
|
|
process.stdout.write(
|
|
|
JSON.stringify({ newDebt: debt.newDebt, stale: debt.stale }, null, '\t') + '\n'
|
|
|
);
|
|
|
return;
|
|
|
}
|
|
|
console.log(
|
|
|
`theming-census --debt — ${debt.registered} key(s) registered · ${debt.newDebt.length} new · ${debt.stale.length} stale`
|
|
|
);
|
|
|
if (debt.newDebt.length > 0) {
|
|
|
console.log('\n— NEW DEBT (a literal, a raw global or a bare private outside the ledger) —');
|
|
|
for (const f of debt.newDebt) console.log(` ${f.component} ${f.key}`);
|
|
|
}
|
|
|
if (debt.stale.length > 0) {
|
|
|
console.log('\n— STALE (registered as debt, no longer debt: delete the line) —');
|
|
|
for (const f of debt.stale) console.log(` ${f.component} ${f.key}`);
|
|
|
}
|
|
|
if (debt.newDebt.length === 0 && debt.stale.length === 0)
|
|
|
console.log(' the ledger matches the CSS exactly.');
|
|
|
}
|
|
|
|
|
|
// ─── `--residue`: the privates that are NEITHER bridge NOR channel ───────────
|
|
|
|
|
|
/**
|
|
|
* What the two mechanical signatures deliberately did NOT decide.
|
|
|
*
|
|
|
* The bridge and the channel are adjudicated by measurement; everything else
|
|
|
* that reads a private without deriving from a public is a per-KEY question —
|
|
|
* a switch whose branches are the identity of a variant (`toolbar`), a
|
|
|
* conmutador the TSC feeds (`avatar`), a private that reads a raw global in
|
|
|
* one branch (`proof-of-human`). Each row carries the knob AND where every
|
|
|
* private it reads is declared, with the class of each declaration, because
|
|
|
* that is the whole material the adjudication needs.
|
|
|
*
|
|
|
* It was a REPORT and not a gate until 2026-08-25; since F2 of that signature
|
|
|
* the residue is gated like every other unreached class (`DebtClass` above),
|
|
|
* so this listing is now the WORKING VIEW of the adjudication — it shows what
|
|
|
* is left to decide, with the material each decision needs, while the ledger
|
|
|
* and the `private:` valve hold the answer already given.
|
|
|
*/
|
|
|
export interface ResidueRow {
|
|
|
component: string;
|
|
|
file: string;
|
|
|
line: number;
|
|
|
selector: string;
|
|
|
prop: string;
|
|
|
value: string;
|
|
|
/** One entry per private read: `--_x → file:line class | file:line class`. */
|
|
|
sources: string[];
|
|
|
}
|
|
|
|
|
|
export function residue(only?: string): ResidueRow[] {
|
|
|
const out: ResidueRow[] = [];
|
|
|
for (const dir of [...readdirSync(ROOT)].sort()) {
|
|
|
if (only && dir !== only) continue;
|
|
|
const scan = scanComponent(dir);
|
|
|
if (!scan) continue;
|
|
|
for (const k of scan.knobs) {
|
|
|
if (k.klass !== 'private') continue;
|
|
|
const refs = [...new Set([...k.value.matchAll(/var\(\s*(--_[a-z0-9-]+)/g)].map((m) => m[1]))];
|
|
|
out.push({
|
|
|
component: dir,
|
|
|
file: k.file,
|
|
|
line: k.line,
|
|
|
selector: k.selector,
|
|
|
prop: k.prop,
|
|
|
value: k.value,
|
|
|
sources: refs.map((r) => {
|
|
|
const decls = scan.privates.filter((p) => p.name === r);
|
|
|
if (decls.length > 0)
|
|
|
return r + ' → ' + decls.map((d) => `${d.file}:${d.line} ${d.source}`).join(' | ');
|
|
|
return r + ' → ' + (GENERATED_PRIVATES.has(r) ? 'generated/base.css' : 'undeclared');
|
|
|
})
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
return out;
|
|
|
}
|
|
|
|
|
|
function reportResidue(only?: string, out?: string, asJson = false) {
|
|
|
const rows = residue(only);
|
|
|
if (asJson) {
|
|
|
process.stdout.write(JSON.stringify(rows, null, '\t') + '\n');
|
|
|
return;
|
|
|
}
|
|
|
const per = new Map<string, number>();
|
|
|
for (const r of rows) per.set(r.component, (per.get(r.component) ?? 0) + 1);
|
|
|
const tsv =
|
|
|
['component', 'file:line', 'selector', 'property', 'value', 'sources'].join('\t') +
|
|
|
'\n' +
|
|
|
rows
|
|
|
.map((r) =>
|
|
|
[
|
|
|
r.component,
|
|
|
`${r.file}:${r.line}`,
|
|
|
r.selector,
|
|
|
r.prop,
|
|
|
r.value,
|
|
|
r.sources.join(' ;; ')
|
|
|
].join('\t')
|
|
|
)
|
|
|
.join('\n') +
|
|
|
'\n';
|
|
|
console.log(
|
|
|
`theming-census --residue — ${rows.length} private knob(s) in ${per.size} component(s): ` +
|
|
|
'neither the palette bridge nor a value channel, so each one is a decision.'
|
|
|
);
|
|
|
console.log(
|
|
|
' worst: ' +
|
|
|
[...per.entries()]
|
|
|
.sort((a, b) => b[1] - a[1])
|
|
|
.slice(0, 12)
|
|
|
.map(([c, n]) => `${c}:${n}`)
|
|
|
.join(' ')
|
|
|
);
|
|
|
if (out) {
|
|
|
writeFileSync(resolve(out), tsv, 'utf8');
|
|
|
console.log(` wrote ${out}`);
|
|
|
return;
|
|
|
}
|
|
|
process.stdout.write('\n' + tsv);
|
|
|
}
|
|
|
|
|
|
// ─── 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, '\\|') + '`';
|
|
|
/** `strct` and not `0%`: the row has no denominator, and that IS the answer. */
|
|
|
const reachOf = (r: CensusRow) =>
|
|
|
STRUCTURAL_COMPONENTS.has(r.component)
|
|
|
? 'strct'
|
|
|
: 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;
|
|
|
// `structural` out with `public` and `system`: proposing a token for a knob
|
|
|
// whose §5 verdict says there is no contract to write would be inventing debt.
|
|
|
// `bridge` and `channel` out for the same reason, one floor down: a public
|
|
|
// over the palette forward or over a value channel is a token a theme could
|
|
|
// PIN, and pinning it kills the per-instance `color=` / the behaviour. The
|
|
|
// sheets of `card`, `tags-input` and `tabs` had to refuse exactly that
|
|
|
// proposal by hand — the classifier must not keep making it.
|
|
|
const targets = scan.knobs.filter(
|
|
|
(k) =>
|
|
|
k.klass !== 'public' &&
|
|
|
k.klass !== 'system' &&
|
|
|
k.klass !== 'structural' &&
|
|
|
k.klass !== 'bridge' &&
|
|
|
k.klass !== 'channel'
|
|
|
);
|
|
|
if (targets.length === 0)
|
|
|
return STRUCTURAL_COMPONENTS.has(c)
|
|
|
? '_Ninguna: el componente es **estructural** (§2-bis) — no hay contrato que escribir._\n'
|
|
|
: '_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.structural > 0
|
|
|
? 'ESTRUCTURAL: sin denominador que medir, y eso es la respuesta'
|
|
|
: `${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} · excepción ${row.exception} · estructural ${row.structural} · puente ${row.bridge} · canal ${row.channel} _(los cinco últimos, 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})
|
|
|
${
|
|
|
row.bridge + row.channel > 0
|
|
|
? '\nSólo el **residuo**: el puente de paleta (§2-ter) y el canal de valor\n(§2-quater) salen aparte, porque no son deuda ni tienen nombre que acuñar.\n'
|
|
|
: ''
|
|
|
}
|
|
|
${knobTable(by('private'), c)}
|
|
|
### 1.3 Literales (${by('literal').length})
|
|
|
|
|
|
${knobTable(by('literal'), c)}
|
|
|
### 1.4 Excepciones firmadas (${by('exception').length}) — fuera del ratio
|
|
|
|
|
|
Literales que llevan su anotación \`/* literal: <razón> */\` en la propia
|
|
|
declaración: la válvula de recipe-contract §3, la misma que honra
|
|
|
\`component-audit\`. **Una desviación firmada no es deuda** — se listan para que la
|
|
|
razón se lea, no para acuñarlas.
|
|
|
|
|
|
${knobTable(by('exception'), 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)}${
|
|
|
row.structural > 0
|
|
|
? `
|
|
|
## 2-bis. Estructural (${row.structural}) — fuera del ratio
|
|
|
|
|
|
Su 0 % es **naturaleza, no deuda**: ${STRUCTURAL_COMPONENTS.get(c)}.
|
|
|
|
|
|
La lista firmada vive en \`STRUCTURAL_COMPONENTS\` (\`scripts/theming-census.ts\`) y
|
|
|
el porqué del eje en §13 de \`next-features.md\`. Los knobs se listan para que se
|
|
|
lean, **no** para acuñarlos.
|
|
|
|
|
|
${knobTable(by('structural'), c)}`
|
|
|
: ''
|
|
|
}${
|
|
|
row.bridge > 0
|
|
|
? `
|
|
|
## 2-ter. Puente de paleta THM-2 (${row.bridge}) — fuera del ratio
|
|
|
|
|
|
La receta lee \`var(--_${c}-palette-{slot})\`, que **lo escribe el forward** de la
|
|
|
cascada de paleta bajo \`[data-${c}]:where([data-color], [data-color-custom])\`
|
|
|
(\`renderRecipePaletteForward\`, firma B′). Es el MECANISMO de la paleta por
|
|
|
instancia, y se alcanza **dos veces**: por la capa compartida \`--palette-*\` y
|
|
|
por los tonos públicos del propio componente. **No se acuña**: un público
|
|
|
encima dejaría que un tema lo fijara y matara en silencio el \`color=\` de cada
|
|
|
instancia (veredictos §5 de \`card\`, \`tags-input\`, \`avatar\`).
|
|
|
|
|
|
${knobTable(by('bridge'), c)}`
|
|
|
: ''
|
|
|
}${
|
|
|
row.channel > 0
|
|
|
? `
|
|
|
## 2-quater. Canal de valor (${row.channel}) — fuera del ratio
|
|
|
|
|
|
La receta lee un privado que **nadie declara** — ni su CSS ni el generador: lo
|
|
|
escribe soma o el envoltorio **por instancia** (un %, un rect medido, la talla
|
|
|
que pide una prop). Un tema no debe alcanzarlo: fijarlo rompe el
|
|
|
comportamiento, y por eso \`tabs\` rechazó por escrito esa misma propuesta.
|
|
|
|
|
|
${knobTable(by('channel'), 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 structural = rows.filter((r) => STRUCTURAL_COMPONENTS.has(r.component));
|
|
|
// A structural component has no contract BY NATURE: listing it as debt here
|
|
|
// is the same false signal the class exists to kill.
|
|
|
const noContract = rows
|
|
|
.filter((r) => r.contractKeys === 0 && !STRUCTURAL_COMPONENTS.has(r.component))
|
|
|
.map((r) => r.component);
|
|
|
const bridgeRows = rows.filter((r) => r.bridge > 0).sort((a, b) => b.bridge - a.bridge);
|
|
|
const channelRows = rows.filter((r) => r.channel > 0).sort((a, b) => b.channel - a.channel);
|
|
|
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')} · excepción firmada ${sum('exception')} · estructural ${sum('structural')} · puente de paleta ${sum('bridge')} · canal de valor ${sum('channel')} _(los cinco últimos, 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} · **estructurales**: ${structural.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 |
|
|
|
| \`exception\` | un literal con su anotación \`/* literal: <razón> */\` en la propia declaración | no hace falta: es la válvula de recipe-contract §3, una desviación ya firmada — fuera del ratio |
|
|
|
| \`structural\` | **todos** los knobs de un componente cuyo 0 % es su NATURALEZA y no su deuda: no hay contrato que escribir (lista firmada abajo) | no hace falta: no hay superficie que un tema pueda nombrar — fuera del ratio, y su fila lee \`strct\`, no \`0 %\` |
|
|
|
| \`bridge\` | \`var(--_{c}-palette-{slot})\`: el privado que **escribe el forward** de la cascada de paleta (THM-2) | sí, **por la paleta** — y también por los tonos públicos del componente; acuñar encima mataría el \`color=\` por instancia — fuera del ratio |
|
|
|
| \`channel\` | \`var(--_{c}-x, fallback)\` que **nadie declara**: lo escribe soma o el envoltorio por INSTANCIA (un %, un rect medido, la talla de una prop) | **no debe**: fijarlo desde un tema rompe el comportamiento — fuera del ratio |
|
|
|
|
|
|
**Alcance** = \`public / (public + private + global + literal)\`. \`system\`,
|
|
|
\`exception\`, \`structural\`, \`bridge\` y \`channel\` quedan fuera del denominador: el
|
|
|
primero es tematizable a nivel de sistema por diseño, el segundo es una
|
|
|
desviación que el canon ya aceptó por escrito, el tercero no tiene nada que un
|
|
|
tema pueda nombrar, y los dos últimos son las dos formas de privado que la
|
|
|
doctrina de este eje ya adjudicó como NO deuda — el mecanismo de la paleta y el
|
|
|
valor de runtime. Lo que queda en \`private\` es el **residuo**: se adjudica clave
|
|
|
por clave (\`theming-census.ts --residue\`).
|
|
|
|
|
|
**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.
|
|
|
|
|
|
## Estructurales (${structural.length}) — 0 % por naturaleza, no por deuda
|
|
|
|
|
|
Medidos uno a uno el 2026-08-22, con **veredicto §5 escrito** en su ficha:
|
|
|
ninguno tiene contrato que escribir. Mientras el censo los contaba como a un
|
|
|
componente con deuda real, la cifra global mentía por abajo y el gate de F3
|
|
|
(«censo 100 %») era inalcanzable **por construcción** (next-features §13). Sus
|
|
|
knobs se siguen contando y listando —un knob estructural sigue siendo un knob—;
|
|
|
sólo salen del **denominador**, igual que \`system\`. La lista firmada vive en
|
|
|
\`scripts/theming-census.ts\` (\`STRUCTURAL_COMPONENTS\`), con su razón al lado.
|
|
|
|
|
|
${structural.map((r) => `- [\`${r.component}\`](./${r.component}.md) (${r.structural}): ${STRUCTURAL_COMPONENTS.get(r.component)}`).join('\n')}
|
|
|
|
|
|
## Puente de paleta (${sum('bridge')} knobs) y canal de valor (${sum('channel')}) — privados que NO son deuda
|
|
|
|
|
|
Las dos formas de privado que la doctrina de este eje ya adjudicó, y que ahora
|
|
|
se distinguen **mecánicamente** — medidas contra el emisor, no adivinadas por el
|
|
|
nombre. La fuente es la SALIDA del generador (\`src/uix/eidos/generated/base.css\`,
|
|
|
que \`generated-css.test.ts\` mantiene idéntica a \`renderStaticCss()\`):
|
|
|
|
|
|
- **puente** — el nombre aparece en la CSS generada como la línea del forward
|
|
|
\`--_{c}-palette-{slot}: var(--palette-{slot}, …)\`. Un nombre con forma de
|
|
|
puente que nadie reenvía **no cuenta**.
|
|
|
- **canal** — el privado no lo declara nadie: ni la CSS del componente ni el
|
|
|
generador. Lo escribe soma o el envoltorio por instancia.
|
|
|
|
|
|
**Puente** (${bridgeRows.length}): ${bridgeRows.map((r) => `[\`${r.component}\`](./${r.component}.md) ${r.bridge}`).join(' · ')}
|
|
|
|
|
|
**Canal** (${channelRows.length}): ${channelRows.map((r) => `[\`${r.component}\`](./${r.component}.md) ${r.channel}`).join(' · ')}
|
|
|
|
|
|
Lo que NO cae en ninguna de las dos es el **residuo** (${sum('private')} knobs en ${rows.filter((r) => r.private > 0).length} componentes):
|
|
|
conmutadores por variante, privados que leen un global en una rama, escaleras
|
|
|
por talla sin público detrás. Se adjudican clave a clave — \`theming-census.ts
|
|
|
--residue\` los vuelca con las fuentes de cada declaración.
|
|
|
|
|
|
## 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 | estructural | puente | canal | 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.structural} | ${r.bridge} | ${r.channel} | ${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');
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* HAND-WRITTEN PROSE SURVIVES REGENERATION — anywhere, not just the verdict.
|
|
|
*
|
|
|
* The report rewrites the whole sheet from the census, so an elaboration a
|
|
|
* human added inside §1.1–§1.4 was silently replaced by the template: where
|
|
|
* someone had written «_Ninguno_ — los cuatro que había (…) están cosidos»,
|
|
|
* regeneration left «_Ninguno._». It happened TWICE in one day to the two
|
|
|
* `chat-*` sheets (2026-08-24). Mostly-harmless-occasionally-destructive is
|
|
|
* the worst possible split, because nobody reads the diff.
|
|
|
*
|
|
|
* The contract: wrap hand-written prose in `<!-- mano:start -->` …
|
|
|
* `<!-- mano:end -->` and it is re-inserted after the SAME heading it sat
|
|
|
* under. A block whose heading no longer exists is appended under a
|
|
|
* «rescatado» note rather than dropped — losing prose is never the default.
|
|
|
*/
|
|
|
const HAND_START = '<!-- mano:start -->';
|
|
|
const HAND_END = '<!-- mano:end -->';
|
|
|
|
|
|
type HandBlock = { heading: string; body: string };
|
|
|
|
|
|
function readHandBlocks(path: string): HandBlock[] {
|
|
|
if (!existsSync(path)) return [];
|
|
|
const prev = readFileSync(path, 'utf8').replace(/\r\n/g, '\n');
|
|
|
const out: HandBlock[] = [];
|
|
|
let from = 0;
|
|
|
for (;;) {
|
|
|
const a = prev.indexOf(HAND_START, from);
|
|
|
if (a < 0) break;
|
|
|
const b = prev.indexOf(HAND_END, a);
|
|
|
if (b < 0) break;
|
|
|
const headings = prev.slice(0, a).match(/^#{2,4} .*$/gm);
|
|
|
out.push({
|
|
|
heading: headings ? headings[headings.length - 1] : '',
|
|
|
body: prev.slice(a, b + HAND_END.length)
|
|
|
});
|
|
|
from = b + HAND_END.length;
|
|
|
}
|
|
|
return out;
|
|
|
}
|
|
|
|
|
|
function spliceHandBlocks(sheet: string, blocks: HandBlock[]): string {
|
|
|
if (!blocks.length) return sheet;
|
|
|
let out = sheet;
|
|
|
const orphans: string[] = [];
|
|
|
for (const { heading, body } of blocks) {
|
|
|
if (out.includes(body)) continue;
|
|
|
const i = heading ? out.indexOf(heading + '\n') : -1;
|
|
|
if (i < 0) {
|
|
|
orphans.push(body);
|
|
|
continue;
|
|
|
}
|
|
|
const cut = i + heading.length + 1;
|
|
|
out = out.slice(0, cut) + '\n' + body + '\n' + out.slice(cut);
|
|
|
}
|
|
|
if (orphans.length)
|
|
|
out +=
|
|
|
'\n## Prosa rescatada\n\n' +
|
|
|
'> Estos bloques estaban bajo un encabezado que ya no existe.\n' +
|
|
|
'> Reubicalos o borralos a mano.\n\n' +
|
|
|
orphans.join('\n\n') +
|
|
|
'\n';
|
|
|
return out;
|
|
|
}
|
|
|
|
|
|
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`);
|
|
|
const hand = readHandBlocks(path);
|
|
|
writeFileSync(path, spliceHandBlocks(sheet(scan, today, readVerdict(path)), hand), 'utf8');
|
|
|
}
|
|
|
for (const empty of empties) {
|
|
|
const path = join(REPORT_DIR, `${empty.component}.md`);
|
|
|
const handEmpty = readHandBlocks(path);
|
|
|
writeFileSync(path, spliceHandBlocks(emptySheet(empty, today, readVerdict(path)), handEmpty), '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.
|
|
|
*
|
|
|
* The fourth (2026-08-25 acta) is of a different kind: not a false friend but a
|
|
|
* measured one. The classifier decides by the FORM of the name; `group-carve-color`
|
|
|
* is the one key whose DESTINATION contradicts it.
|
|
|
*/
|
|
|
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)' },
|
|
|
{
|
|
|
re: /^group-carve-color$/,
|
|
|
why: 'alimenta box-shadow (la GEOMETRÍA del carve, no tinta): avatar.css `[data-carve-out] { box-shadow: 0 0 0 var(--avatar-group-carve-width) var(--avatar-group-carve-color) }`. Alcanza — medido en las 59 instancias de /uix/components/avatar-group (oklch(0.9911 0 0) → rgb(1,2,3), restaurado). `group-carve-fg` sería gramática correcta y semántica PEOR: nombraría tinta lo que es el filo que recorta la silueta contra el avatar anterior'
|
|
|
}
|
|
|
];
|
|
|
|
|
|
/**
|
|
|
* 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('--debt')) {
|
|
|
if (args.includes('--write')) void writeDebtBaseline();
|
|
|
else reportDebt(only, args.includes('--json'));
|
|
|
return;
|
|
|
}
|
|
|
if (args.includes('--residue')) {
|
|
|
const next = args[args.indexOf('--residue') + 1];
|
|
|
reportResidue(only, next && !next.startsWith('--') ? next : undefined, args.includes('--json'));
|
|
|
return;
|
|
|
}
|
|
|
if (args.includes('--report')) {
|
|
|
writeReport();
|
|
|
return;
|
|
|
}
|
|
|
const { rows, debt } = censusAudit(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')} · exception ${sum('exception')} · structural ${sum('structural')} · bridge ${sum('bridge')} · channel ${sum('channel')}`
|
|
|
);
|
|
|
console.log(
|
|
|
` no contract entry: ${rows.filter((r) => r.contractKeys === 0 && !STRUCTURAL_COMPONENTS.has(r.component)).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} · structural: ${rows.filter((r) => STRUCTURAL_COMPONENTS.has(r.component)).length}`
|
|
|
);
|
|
|
// The ratchet, in the same breath as the totals it used to hide behind:
|
|
|
// five regressions under five fixes moved neither `literal` nor `global`.
|
|
|
console.log(
|
|
|
` debt ledger: ${debt.registered} registered · ${debt.newDebt.length} NEW · ${debt.stale.length} STALE (detail: --debt)`
|
|
|
);
|
|
|
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) +
|
|
|
'excep'.padStart(7) +
|
|
|
'system'.padStart(8) +
|
|
|
'strct'.padStart(7) +
|
|
|
'bridge'.padStart(8) +
|
|
|
'chan'.padStart(6) +
|
|
|
'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.exception).padStart(7) +
|
|
|
String(r.system).padStart(8) +
|
|
|
String(r.structural).padStart(7) +
|
|
|
String(r.bridge).padStart(8) +
|
|
|
String(r.channel).padStart(6) +
|
|
|
String(r.contractKeys).padStart(10) +
|
|
|
(r.hasSize ? ' y' : ' -')
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (process.argv[1] && /theming-census\.ts$/.test(process.argv[1].replace(/\\/g, '/'))) main();
|