mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Шаг 1 эпика #591. Набор поднят из редактора боковой панели — он сделан по макетам дизайнера и уже живёт в продукте, — а не собран заново по прототипу. Стили набора выдаёт генератор, параметризованный именами: контейнер формы, префикс классов, карточки, футер и токены цвета. Это не украшение архитектуры, а условие переезда панели на общий источник: тест с замороженной фикстурой показывает, что с параметрами панели генератор отдаёт её нынешние правила дословно — карточку, обводку фокуса, выключенное состояние и ряд-переключатель. После этого переезд станет подстановкой, а не редизайном, и пиксели панели не дрогнут. Сама панель в этом коммите не тронута. Диалог комнаты собран в четыре карточки: «Основное», «Заливка», «Источники», «Размеры шрифтов». Порядок полей прежний, записи прежние — те же ключи черновика, те же условия «Сохранить», тот же `hp-color-opacity` с атомарной записью цвета и прозрачности. Пояснения уехали под «?» у заголовков групп (решение владельца 18.09), выбор источника стал сегментированным переключателем — под капотом это по-прежнему радиогруппа, поэтому стрелки и скринридер работают как раньше. Два измерения определили форму решения, и оба стоило сделать до кода. Первое: лист набора в синхронном графе дал +1459 Б gzip и пробил потолок initial View на 415 Б. Диалоги — инструмент администратора, во View их не открывают, поэтому лист уехал в ленивый редакторский граф и вносится в теневой корень при открытии диалога — тем же способом, которым это делает боковая панель. Потолок не поднимался. Второе: `css` с интерполяцией запрещён минификатором сборки (он намеренно падает, чтобы не зависеть от рантайма), поэтому лист собирается `unsafeCSS`. Цена — несжатые пробелы генератора, замерено 59 Б gzip; копия литералом стоила бы расхождения с генератором, то есть единого источника. Оставшийся рост initial View — 291 356 → 291 872 Б (+516) — это четыре текста подсказок в двух встроенных локалях. Тексты сокращены до одного предложения: подсказка под «?» и должна быть короткой. `_renderRoomSource` переехал в модуль комнаты вместе со стабом карточки: у него остался ровно один потребитель. Issue: #594 User-Visible: yes
94 lines
4.7 KiB
JavaScript
94 lines
4.7 KiB
JavaScript
/** #266: invariants of the styles split. */
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { cardStyles, baseStyles, planStyles, devicesStyles, chromeStyles, dialogsStyles }
|
|
from '../test-build/styles.js';
|
|
import { CARD_DIALOG_FORM_KIT, formKitCss } from '../test-build/styles/form-kit.styles.js';
|
|
|
|
const FILES = ['base', 'plan', 'devices', 'chrome', 'dialogs'];
|
|
const sourceOf = (name) =>
|
|
readFileSync(new URL(`../src/styles/${name}.styles.ts`, import.meta.url), 'utf8');
|
|
|
|
const selectorsOf = (tsSource) => {
|
|
// Only the css`` payload: the TS import/export scaffolding is not CSS.
|
|
const css = [...tsSource.matchAll(/css`([\s\S]*?)`/g)].map((m) => m[1]).join('\n');
|
|
const text = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
const out = new Set();
|
|
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).replace(/\s+/g, ' ').trim();
|
|
let depth = 1, j = open + 1;
|
|
while (j < chunk.length && depth > 0) {
|
|
if (chunk[j] === '{') depth++;
|
|
else if (chunk[j] === '}') depth--;
|
|
j++;
|
|
}
|
|
if (header.startsWith('@media') || header.startsWith('@supports')) {
|
|
walk(chunk.slice(open + 1, j - 1), `${scope}${header} :: `);
|
|
} else if (!header.startsWith('@')) {
|
|
// The key includes the media scope: the SAME selector inside and
|
|
// outside a wrapper are different cascade entries by construction
|
|
// (a multi-zone @media block lands in base as a whole).
|
|
for (const sel of header.split(',')) out.add(`${scope}${sel.trim()}`);
|
|
}
|
|
i = j;
|
|
}
|
|
};
|
|
walk(text, '');
|
|
return out;
|
|
};
|
|
|
|
test('issue 266 the aggregator is exactly the five surface files in the cascade order', () => {
|
|
// 2026-09-18, #594: набор контролов формы в этот массив НЕ входит — он живёт
|
|
// в ленивом редакторском графе и вносится в теневой корень при открытии
|
|
// диалога. Пятёрка и её порядок неприкосновенны.
|
|
assert.deepEqual(cardStyles, [baseStyles, planStyles, devicesStyles, chromeStyles, dialogsStyles],
|
|
'the cascade order is a contract — the golden set was accepted against it');
|
|
});
|
|
|
|
test('#594 the form kit adds selectors instead of overriding existing ones', () => {
|
|
// Набор вносится последним листом в тот же теневой корень, поэтому его правила
|
|
// обязаны только добавлять: совпавший селектор молча переопределил бы принятый
|
|
// кадр и не покраснел бы нигде, кроме глаз.
|
|
const existing = new Set();
|
|
for (const name of FILES) for (const sel of selectorsOf(sourceOf(name))) existing.add(sel);
|
|
const kit = selectorsOf(`css\`${formKitCss(CARD_DIALOG_FORM_KIT)}\``);
|
|
assert.ok(kit.size > 0, 'the form kit emits no selectors at all');
|
|
const clashes = [...kit].filter((sel) => existing.has(sel));
|
|
assert.deepEqual(clashes, [],
|
|
'a kit rule lands on a selector that already exists: the last position in the cascade '
|
|
+ 'would then silently change accepted frames');
|
|
});
|
|
|
|
test('issue 266 surface files do not share a single selector', () => {
|
|
const sets = FILES.map((name) => ({ name, set: selectorsOf(sourceOf(name)) }));
|
|
const clashes = [];
|
|
for (let i = 0; i < sets.length; i++) {
|
|
for (let j = i + 1; j < sets.length; j++) {
|
|
for (const sel of sets[i].set) {
|
|
if (sets[j].set.has(sel)) clashes.push(`${sel} (${sets[i].name} ∩ ${sets[j].name})`);
|
|
}
|
|
}
|
|
}
|
|
// Spec §1.3.2: the exception list is empty — a leading :host(...) gate is
|
|
// not ownership, so gated device groups live with their surface and no
|
|
// selector is shared between files.
|
|
assert.deepEqual(clashes, []);
|
|
});
|
|
|
|
test('issue 266 the media wrappers survived the move', () => {
|
|
const all = FILES.map((name) => sourceOf(name)).join('\n');
|
|
assert.equal((all.match(/@media \(forced-colors: active\)/g) || []).length, 2,
|
|
'both forced-colors blocks must survive — golden never emulates them');
|
|
// 10 historical source wrappers; two were MIXED-zone and split into
|
|
// per-zone copies by the generator. #437's overlay stylesheet is loaded
|
|
// with its lazy runtime and has its own source contract, so the five eager
|
|
// surface owners remain at the historical 12 wrappers.
|
|
assert.equal((all.match(/@media \(prefers-reduced-motion: reduce\)/g) || []).length, 12,
|
|
'reduced-motion wrappers must survive — golden always shoots reduced');
|
|
});
|