feat(general): «Общие настройки» по референсу — серия 2 из четырёх (#600)

§5 SPEC.md референса. Семь карточек #598 остались, менялось содержимое:

- Display — две полные строки с иконкой, подписью и тумблером справа; пояснение
  радара по §5.2 стало подписью строки (текст прежний, ключ переехал в ленивый
  словарь как gs.radar_show_live_hint, «?» у строки больше нет);
- Zigbee links — заголовок и «?» функции (topology.help) рисует карточка, а
  блок hp-zigbee-topology-settings получил режим embedded: строка-тумблер,
  callout «сохраните настройку», подзаголовки ZHA / Zigbee2MQTT, поле тем и
  строки действий контролами набора. Заголовок больше не удваивается
  (дефект 1). Лист набора вносится в теневой корень компонента; токены
  --hpf-* наследуются с хоста. Прежний render без embedded не тронут — его
  держит контракт #459;
- Room fill colors — три подзаголовка (Lights / Temperature / Zigbee signal,
  Q6) и плитки цвета по три в ряд: название на свотче, hex, число
  прозрачности; свотч — trigger прежнего hp-color-opacity (Q5);
- Light-source glow — две плитки и поле радиуса с единицей внутри рамки;
- Plan — плашка цвета стен, сегмент фона, при Static — плашка фона с Reset и
  подсказкой (тема / свой цвет);
- Sun — gs.sun_missing остаётся callout'ом (К5); север: поле градусов с «°»,
  ссылка Clear, «N» и компас 44 px справа — индикатор набора, но тянуть его
  по-прежнему можно (smoke_sun гоняет настоящий drag; старый 120-px SVG и его
  CSS в plan.styles.ts удалены); лучи — строка-тумблер с подписью; источник
  лучей — сегмент (sun-settings-view.ts переписан с <select> на segmented,
  логика та же);
- Data — подзаголовки с «?» и строки действий набора.

К10 (Q1): Save только при изменениях и без ошибок (радиус > 0, север 0–359),
статус в футере, вопрос при закрытии с изменениями; снимок при открытии —
editors/general-form-state.ts поверх общего editors/dialog-baseline.ts (на него
же переведён space-form-state.ts). _settingsDialog не получил новых ключей.

Удалено мёртвое: _renderColorRow (рантайм и карточка), .sunrow/.suncol,
ключи gs.wall_group, gs.bg_group, gs.bg_theme, gs.radar_show_live.help(.aria)
в четырёх локалях. Девять новых ключей — в i18n/settings.

Смоки переписаны осознанно, с пометкой #600: smoke_general_settings (инвентарь
по классам набора, подзаголовки .hpf-sub h4, пояснения под «?» и подписью,
ни одного .rhint), smoke_sun (сегмент источника лучей вместо select, поле
#gs-north, radius drag 18 px), smoke_backup_transfer (подзаголовок и строка
действий), smoke_settings_dialog_cards (callout вместо .rhint). Новый фокусный
свидетель demo/smoke_general_settings_form.mjs: семь карточек и отсутствие
старой разметки, один заголовок у Zigbee, десять плиток, компас 44, цели 44,
запись плиток/плашки/радиуса/севера/сегментов в свои ключи, Save/dirty/вопрос
при закрытии.

Мутанты: general-save-enabled-without-changes,
general-tile-opacity-writes-a-neighbour-key, general-north-clear-writes-zero,
topology-embedded-draws-its-own-heading; якоря state-callout-hidden-under-help
и dialog-card-loses-its-heading переставлены на новую разметку.

Контрактные тесты: houseplan-source.mjs приводит `host.` к `this.` и в телах
вынесенных диалогов; color-picker — три явных showOpacity=true (плитки, плашка
стены, цвет комнаты); счётчик производных .aria 35 → 34.

Бюджет: стартовый граф 290 711 — потолок ОПУЩЕН 292 500 → 292 200 (ушёл
120-px компас из eager-листа и мёртвые ключи); ленивый редакторский 237 929 —
потолок 236 300 → 238 600 с разбором в bundle-budget.mjs.

Гейты: typecheck, build, bundle:sync, npm test 2792 pass, mutation-gate
--check, семь смоков «Общих», smoke_color_picker_consumers без правок.

Issue: #600
User-Visible: no
This commit is contained in:
Claude
2026-09-20 11:04:39 +03:00
parent 91a008db9c
commit de71bd7967
82 changed files with 2080 additions and 1685 deletions
+5 -2
View File
@@ -77,8 +77,11 @@ test('the shared component keeps its API and contains no nested native color pic
assert.match(defaultPicker, /this\._updateDecorStyle\(\{ \.\.\.this\._decorStyle, \.\.\.e\.detail \}\)/);
assert.equal((card.match(/\.showOpacity=\$\{false\}/g) || []).length, 4,
'Glow, ripple and both background pickers stay color-only');
assert.equal((card.match(/\.showOpacity=\$\{true\}/g) || []).length, 2,
'general fill rows and room color explicitly keep their existing opacity');
// #600: плитки палитры (одна общая функция), плашка заливки стен и цвет
// границ комнаты — три явных `showOpacity=true`; раньше стена шла общей
// строкой с палитрой.
assert.equal((card.match(/\.showOpacity=\$\{true\}/g) || []).length, 3,
'general fill tiles, the wall-fill plate and room color explicitly keep their existing opacity');
assert.match(card, /confirm:\s*this\._t\('color_picker\.confirm'\)/);
for (const locale of ['en', 'ru', 'de', 'fr']) {
const messages = JSON.parse(readFileSync(new URL(`../src/i18n/${locale}.json`, import.meta.url), 'utf8'));
+4 -1
View File
@@ -86,6 +86,9 @@
.hpf-unit > span { padding: 0 11px 0 3px; white-space: nowrap; color: var(--hpf-muted); font-size: .875rem; }
.hpf-unit:focus-within { outline: 2px solid var(--hpf-accent); outline-offset: 2px; }
.hpf-unit.hpf-unit-wide, .hpf-unit.hpf-unit-wide > input { width: 100%; min-width: 0; }
.hpf-field > .hpf-unit:not(.hpf-unit-wide) { justify-self: start; }
.hpf-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; }
.hpf-actions > input[type="file"] { display: none; }
.hpf-row {
display: flex;
min-width: 0;
@@ -294,7 +297,7 @@
.hpf-north > .hpf-labelrow { grid-area: 1 / 1; min-height: 28px; }
.hpf-north > .hpf-select, .hpf-north > .hpf-unit, .hpf-north > .hpf-input { grid-area: 2 / 1; }
.hpf-north > .hpf-north-n { grid-area: 1 / 2; text-align: center; font-size: .875rem; line-height: 28px; color: var(--hpf-muted); }
.hpf-compass { grid-area: 2 / 2; display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; border: 1px solid var(--hpf-line); border-radius: 50%; background: var(--hpf-canvas); color: var(--hpf-accent); }
.hpf-compass { grid-area: 2 / 2; box-sizing: border-box; display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; border: 1px solid var(--hpf-line); border-radius: 50%; background: var(--hpf-canvas); color: var(--hpf-accent); }
.hpf-compass svg { width: 24px; height: 24px; transform-origin: 50% 50%; transform-box: view-box; }
.hpf-compass.hpf-unset { color: var(--hpf-muted); }
.hpf-tint { padding: 14px; border: 1px solid var(--hpf-tint-line); border-radius: 9px; background: var(--hpf-tint); }
+4 -1
View File
@@ -72,6 +72,9 @@
.hpf-unit > span { padding: 0 11px 0 3px; white-space: nowrap; color: var(--hpf-muted); font-size: .875rem; }
.hpf-unit:focus-within { outline: 2px solid var(--hpf-accent); outline-offset: 2px; }
.hpf-unit.hpf-unit-wide, .hpf-unit.hpf-unit-wide > input { width: 100%; min-width: 0; }
.hpf-field > .hpf-unit:not(.hpf-unit-wide) { justify-self: start; }
.hpf-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; }
.hpf-actions > input[type="file"] { display: none; }
.hpf-row {
display: flex;
min-width: 0;
@@ -280,7 +283,7 @@
.hpf-north > .hpf-labelrow { grid-area: 1 / 1; min-height: 28px; }
.hpf-north > .hpf-select, .hpf-north > .hpf-unit, .hpf-north > .hpf-input { grid-area: 2 / 1; }
.hpf-north > .hpf-north-n { grid-area: 1 / 2; text-align: center; font-size: .875rem; line-height: 28px; color: var(--hpf-muted); }
.hpf-compass { grid-area: 2 / 2; display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; border: 1px solid var(--hpf-line); border-radius: 50%; background: var(--hpf-canvas); color: var(--hpf-accent); }
.hpf-compass { grid-area: 2 / 2; box-sizing: border-box; display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; border: 1px solid var(--hpf-line); border-radius: 50%; background: var(--hpf-canvas); color: var(--hpf-accent); }
.hpf-compass svg { width: 24px; height: 24px; transform-origin: 50% 50%; transform-box: view-box; }
.hpf-compass.hpf-unset { color: var(--hpf-muted); }
.hpf-tint { padding: 14px; border: 1px solid var(--hpf-tint-line); border-radius: 9px; background: var(--hpf-tint); }
+5 -2
View File
@@ -59,7 +59,10 @@ export function readHouseplanProductionSource() {
const fn = file.statements.find((statement) => ts.isFunctionDeclaration(statement)
&& statement.name?.text === exported);
if (!fn) throw new Error(`Houseplan dialog module is missing ${exported}`);
const body = source.slice(source.indexOf('{', fn.getStart(file)), fn.end);
// #600: тела диалогов держат `const host = this.host` и пишут `host.…`;
// для контрактных регулярок это тот же `this.…`.
const body = source.slice(source.indexOf('{', fn.getStart(file)), fn.end)
.replaceAll(/(?<!this\.)\bhost\./g, 'this.');
moved.set(method, body);
}
const replacements = [];
@@ -92,7 +95,7 @@ export function readHouseplanProductionSource() {
const shared = readFileSync(new URL(path, import.meta.url), 'utf8')
.replaceAll('port.host.', 'this.')
.replaceAll('port.help(', 'this._help(')
.replaceAll(/\bhost\./g, 'this.');
.replaceAll(/(?<!this\.)\bhost\./g, 'this.');
reconstructed += `\n// --- shared dialog module ${path} ---\n${shared}`;
}
return reconstructed;
+4 -2
View File
@@ -71,8 +71,10 @@ test('help accessibility copy is derived from every literal help consumer', () =
// `space.layers.help` вместо них), плюс шесть новых из ленивого словаря
// (подложка, границы, слои, названия, размер шрифта, свечение); коллектор
// научился видеть `help(...)` порта — и `radar.help` из секции радара, который
// раньше проходил мимо счётчика, встал на учёт — 30 → 35.
assert.equal(consumers.derivedHelpAria.size, 35, 'the current settings surface has 35 statically derived help descriptions');
// раньше проходил мимо счётчика, встал на учёт — 30 → 35. Серия 2, «Общие
// настройки» по §5: пояснение радара стало подписью строки-тумблера (текст
// тот же, «?» у него нет) — 35 → 34.
assert.equal(consumers.derivedHelpAria.size, 34, 'the current settings surface has 34 statically derived help descriptions');
for (const key of consumers.derivedHelpAria) {
assert.equal(typeof dictionary[key], 'string', `${key} must accompany its .help consumer`);
}