chore: add the #266 styles splitter and refactor-proof diff tools

styles-split.mjs — механический генератор слайсов (зоны в порядке финального
сборщика, инлайн-остаток промежуточных состояний сохраняет относительные
позиции финала); styles-diff.mjs — нормализованное множество правил с ключом
«полный путь вложенности + селектор» для доказательства refactor-only.

Issue: #266
User-Visible: no
This commit is contained in:
Codex
2026-08-26 01:34:20 +03:00
parent 34335d1e83
commit cbdc248987
2 changed files with 264 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
/**
* #266: refactor-only proof for the styles split. Extracts every css`...`
* template from the style sources (they contain no interpolations), parses
* the text into a normalised multiset of rules (media-scope + selector ->
* sorted declarations) and prints sorted JSON. Run on the base commit and on
* the branch, then diff the outputs: an empty diff proves the split moved
* rules without editing, adding or dropping a single declaration.
*
* node scripts/dev/styles-diff.mjs [files…] > after.json
*/
import { readFileSync } from 'node:fs';
// Default file set: src/styles.ts plus whatever surface files it actually
// imports — so intermediate slices are compared honestly, not against stale
// files left in src/styles/ by other runs. The multiset ignores order.
const detect = () => {
const aggregator = readFileSync('src/styles.ts', 'utf8');
const files = [];
if (/css`/.test(aggregator)) files.push('src/styles.ts');
for (const m of aggregator.matchAll(/from '\.\/styles\/([\w-]+)\.styles'/g)) {
files.push(`src/styles/${m[1]}.styles.ts`);
}
return files.length ? files : ['src/styles.ts'];
};
const files = process.argv.slice(2).length ? process.argv.slice(2) : detect();
const cssText = files.map((file) => {
const source = readFileSync(file, 'utf8');
const chunks = [...source.matchAll(/css`([\s\S]*?)`/g)].map((m) => m[1]);
if (!chunks.length) throw new Error(`${file}: no css\`\` template found`);
if (chunks.some((chunk) => chunk.includes('${'))) {
throw new Error(`${file}: interpolation found — the parser assumes plain CSS`);
}
return chunks.join('\n');
}).join('\n');
const text = cssText.replace(/\/\*[\s\S]*?\*\//g, '');
const rules = [];
const normalise = (s) => s.replace(/\s+/g, ' ').trim();
const walk = (chunk, scope) => {
let i = 0;
while (i < chunk.length) {
const open = chunk.indexOf('{', i);
if (open === -1) break;
const header = chunk.slice(i, open).trim();
let depth = 1;
let j = open + 1;
while (j < chunk.length && depth > 0) {
if (chunk[j] === '{') depth++;
else if (chunk[j] === '}') depth--;
j++;
}
const body = chunk.slice(open + 1, j - 1);
if (header.startsWith('@') && body.includes('{')) {
walk(body, `${scope} ${normalise(header)}`.trim());
} else {
rules.push({
scope,
selector: normalise(header),
declarations: body.split(';').map(normalise).filter(Boolean).sort(),
});
}
i = j;
}
};
walk(text, '');
rules.sort((a, b) => (a.scope + '|' + a.selector).localeCompare(b.scope + '|' + b.selector)
|| JSON.stringify(a.declarations).localeCompare(JSON.stringify(b.declarations)));
console.log(JSON.stringify(rules, null, 1));
+194
View File
@@ -0,0 +1,194 @@
/**
* #266: one-shot mechanical splitter. Reads src/styles.ts, cuts the css``
* template into top-level blocks (rule, @media, @keyframes — with their
* leading comments), classifies every block by the owner of its first
* selector token, and writes five surface files plus the aggregator.
* A token missing from the table aborts the run: no silent "misc" bucket.
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
const ZONES = { base: [], plan: [], devices: [], chrome: [], dialogs: [] };
const T = {};
const assign = (zone, tokens) => { for (const t of tokens.split(/\s+/)) T[t] = zone; };
assign('base', ':host .sr-only ha-card .bootveil .toast .empty .spacer');
assign('plan', `.stage .plan-svg .hp-paper .hp-static-stage .hp-day-cycle-bg .hp-day-cycle-sun .hp-day-cycle-env
.seg .vertex .pathline .preview .active-axis .active-vertex .physical-hit .physical-chrome .physical-drag
.drawwall .drawwall-preview .drawwall-preview-fill .openwall .openwall-preview .wall-repair-preview
.wallbody .wallbody-fill .wallthick-hover .griddot .boundary-point
.plan-snap-node .plan-snap-line .plan-snap-overlay .hidden-wall-line .hidden-wall-node .hidden-wall-diagnostic
.room .room-outline .room-hover-fill .room-hover-halo .room-hover-outline .roomlabel
.rlhandle .rlgear .rlgearbtn .rlname .rlgo
.measurelabel .measurelayer .compass .homearrow .zoomwrap .zoomctl .zoombadge
.decorlayer .glow-spot .glow-pools .glow-pools-frame .glow-base-layer .glow-base-tunnels .sunlayer
.iso-opening-panel .iso-ambient-shadow .iso-contact-shadow .iso-leaf-shadow .iso-wall-side .iso-wall-top
.iso-floor-side .iso-side-hi .iso-side-lo .iso-top-hi .iso-top-lo .iso-shadows-svg .iso-underlay-svg
.iso-walls-svg .iso-underlay .iso-shadows .iso-walls .iso-openings
.opening-preview .opening-preview-dot .opening-dimension .opening-dimensions .opening-dimension-line
.opening-dimension-tick .op-leaf .op-arc .op-hit .op-outline .passage-preview-boundary .passage-preview-cut
.aligndot .alignline .alignmsg .rszhandle .rszicon .rszleader .rszhalo .rszink .rszmeasurehalo
.rszmeasureink .rszmeasurelayer .bdframe .dtframe .dtarea .devlayer .fixedfloor-loading .fixedfloor-error
line .projection-toggle`);
assign('devices', `.dev .device-pulse .device-shell .device-shell-frame .device-core .device-sections
.devicepreview-empty .activity-dot .vactrail .vacpuck .temprange .kdot .kioskdots`);
assign('chrome', `.editbar .editbar-end .editbar-tools .tab .tabs .modetab .modes .decorbar
.editorchrome .editorchrome-inner .menu .menuwrap .dropbtn .droppanel .hdr .farhint .rhint .tip .togglehint`);
assign('dialogs', `hp-dialog .btn .oplock .oplock-core .oplock-shell .rrow .colorrow .ripple-colorrow
.ripple-sizerow .optimize-details .optimize-live .optimize-cleanup .optimize-selected .recoveryoverlay
.savedplan .savedplans .savedmeta .backupdetails .backupactions .backupfile .backupcontent .backupplanonly
.backupplanonlystatus .backupupload .backupchoices .backupconfirm .backuperror .backupsummary .backupwarn
.backupcounts .backupbody .pdf .pdfedit .pdflist .pdftag .planprev .planrow .planname .cardpreview
.namein .descin .areasel .entrow .entlist .entbtn .cand .candlist .srcrow .gsrow .inforow .infodesc
.dispsection .floorrow .fileupload .filebtn .furnsize .furnhd .furnitem .furnbody .furngroup .furnhint
.furnpalette .furnprev .furnrow .wallthick-dlg .vacpicker .vacsource .vacsource-list .vacsource-meta
.vacsource-warning .vacdiag .vacfit .vacfitdot .vacfithandle .vacfitknob .vacbox .vaccalbar .vacxcme
.sunrow .suncol .aboutlink .aboutver .count .head .title .markerhelplabel .markerhelpfield .markerradios
.markeractions .markersaveactions .markerlightgroup .markerlightdisabled .markerglowvalue
.markerbadgetechnical .opening-entity-candidate .opening-entity-empty .habindingbanner .bindsel
.bindharow .curbind .ctrlchip .ctrlchips .ctrlopt .ctrlstate .ctrlstates .ctrllist .oprow .iconauto
.rtest .rtesticon ha-icon-picker`);
const argValue = (name, dflt) => {
const i = process.argv.indexOf(`--${name}`);
return i === -1 ? dflt : process.argv[i + 1];
};
const sourcePath = argValue('source', 'src/styles.ts');
// Cumulative slice list in the FINAL aggregator order suffix (#266 §1.4):
// e.g. --take chrome,dialogs keeps base+plan+devices inline and appends the
// two extracted files after the inline remainder.
const TAKE = (argValue('take', 'base,plan,devices,chrome,dialogs') || '')
.split(',').map((z) => z.trim()).filter(Boolean);
const FINAL_ORDER = ['base', 'plan', 'devices', 'chrome', 'dialogs'];
for (const zone of TAKE) if (!FINAL_ORDER.includes(zone)) throw new Error(`unknown zone ${zone}`);
const source = readFileSync(sourcePath, 'utf8');
const m = source.match(/css`([\s\S]*)`;\s*$/);
if (!m) throw new Error('styles.ts: css template not found');
const cssBody = m[1];
// Cut into top-level blocks with leading comments/blank lines attached.
const blocks = [];
let i = 0;
let pending = '';
while (i < cssBody.length) {
const rest = cssBody.slice(i);
const cm = rest.match(/^\s*\/\*[\s\S]*?\*\//);
if (cm && !rest.slice(0, rest.indexOf('/*')).includes('{')) {
// comment before any brace: attach to the next block
pending += cm[0];
i += cm[0].length;
continue;
}
const open = cssBody.indexOf('{', i);
if (open === -1) { pending += cssBody.slice(i); break; }
const header = cssBody.slice(i, open);
let depth = 1, j = open + 1;
while (j < cssBody.length && depth > 0) {
if (cssBody[j] === '{') depth++;
else if (cssBody[j] === '}') depth--;
j++;
}
blocks.push({ text: pending + header + cssBody.slice(open, j), header: header.trim() });
pending = '';
i = j;
}
const zoneOfSelector = (header) => {
const first = (sel) => {
const t = sel.trim().match(/^[.:#\[]?[\w-]+/);
return t ? t[0] : null;
};
const zones = new Set();
for (const sel of header.split(',')) {
const token = first(sel);
if (!token) continue;
const zone = T[token];
if (!zone) throw new Error(`unclassified token «${token}» in «${header.slice(0, 80)}»`);
zones.add(zone);
}
if (!zones.size) throw new Error(`no tokens in «${header.slice(0, 80)}»`);
return zones.size === 1 ? [...zones][0] : 'base';
};
const innerHeader = (header) => header.replace(/^@(media|supports)[^{]*$/s, '').trim();
for (const block of blocks) {
let zone;
if (/^@keyframes/.test(block.header)) {
// keyframes are owned by their consumers; classify by name prefix
const name = block.header.replace(/^@keyframes\s+/, '');
zone = /^(hp-dev|dev|pulse|vac)/.test(name) ? 'devices'
: /^(hp-spin|spin|fade|toast)/.test(name) ? 'base' : 'base';
} else if (/^@(media|supports)/.test(block.header)) {
// classify by the first inner selector
const inner = block.text.slice(block.text.indexOf('{') + 1);
const sel = inner.slice(0, inner.indexOf('{')).replace(/\/\*[\s\S]*?\*\//g, '').trim();
zone = zoneOfSelector(sel);
} else {
zone = zoneOfSelector(block.header);
}
ZONES[zone].push(block.text);
block.zone = zone;
}
mkdirSync('src/styles', { recursive: true });
const taken = FINAL_ORDER.filter((zone) => TAKE.includes(zone));
const kept = FINAL_ORDER.filter((zone) => !TAKE.includes(zone));
const NAMES = { base: 'base', plan: 'plan', devices: 'devices', chrome: 'chrome', dialogs: 'dialogs' };
const DOC = {
base: 'Host, variables, resets and cross-surface rules',
plan: 'The plan scene: stage, walls, axes, snap, decor, iso and resize ink',
devices: 'Device markers, shells, pulses and vacuum presentation',
chrome: 'Editor chrome: toolbars, tabs, menus and hints',
dialogs: 'Dialogs, forms, buttons and pickers',
};
const tidy = (chunk) => chunk.replace(/^\n+/, '').replace(/\n+$/, '');
for (const zone of taken) {
const body = ZONES[zone].map(tidy).join('\n');
writeFileSync(`src/styles/${NAMES[zone]}.styles.ts`,
`/** ${DOC[zone]} (#266, split from styles.ts). */\nimport { css } from 'lit';\n\nexport const ${zone}Styles = css\`\n${body}\n\`;\n`);
console.log(zone, ZONES[zone].length, 'blocks ->', `src/styles/${NAMES[zone]}.styles.ts`);
}
if (kept.length) {
// Intermediate slice: the not-yet-extracted zones stay inline IN SOURCE
// ORDER, extracted zones append after them in the final-order suffix, so
// every relative position matches the final aggregator and golden can gate
// each slice.
const keptBlocks = blocks.filter((block) => !taken.includes(block.zone));
const imports = taken.map((zone) =>
`import { ${zone}Styles } from './styles/${NAMES[zone]}.styles';`).join('\n');
writeFileSync('src/styles.ts', `/** Styles of the House Plan card — being split by surface (#266). */
import { css } from 'lit';
import type { CSSResultGroup } from 'lit';
${imports}
export { ${taken.map((zone) => `${zone}Styles`).join(', ')} };
const inlineStyles = css\`
${keptBlocks.map((block) => tidy(block.text)).join('\n')}
\`;
export const cardStyles: CSSResultGroup = [
inlineStyles, ${taken.map((zone) => `${zone}Styles`).join(', ')},
];
`);
console.log('aggregator with inline remainder written:', kept.join('+'), 'inline;', taken.join(', '), 'extracted');
} else writeFileSync('src/styles.ts', `/**
* Styles of the House Plan card — assembled from the surface files (#266).
*
* The ORDER of this array is part of the cascade contract: rules of equal
* specificity resolve by position, and the golden set was accepted against
* exactly this order. Do not reorder without re-reviewing the golden set.
*/
import type { CSSResultGroup } from 'lit';
import { baseStyles } from './styles/base.styles';
import { planStyles } from './styles/plan.styles';
import { devicesStyles } from './styles/devices.styles';
import { chromeStyles } from './styles/chrome.styles';
import { dialogsStyles } from './styles/dialogs.styles';
export { baseStyles, planStyles, devicesStyles, chromeStyles, dialogsStyles };
export const cardStyles: CSSResultGroup = [
baseStyles, planStyles, devicesStyles, chromeStyles, dialogsStyles,
];
`);
console.log('aggregator written');