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
+38
View File
@@ -0,0 +1,38 @@
/**
* #600 К10: снимок состояния диалога на момент открытия.
*
* «Сохранить» активен только при изменениях, и изменение считается сравнением
* нормализованного черновика с этим снимком. Ничего не персистится: снимки
* живут в памяти рантайма, привязаны к хосту и различаются по виду диалога, а
* состояния самих диалогов не получают новых ключей (ТЗ: не в скоупе).
*
* Без снимка (диалог открыт путём, который его не сделал) ответ `true`:
* прежнее поведение — Save доступен — безопаснее заблокированной кнопки.
*/
export type DialogKind = 'space' | 'settings' | 'room' | 'marker';
const baselines = new WeakMap<object, Map<DialogKind, string>>();
export function rememberDialogBaseline(host: object, kind: DialogKind, key: string): void {
const map = baselines.get(host) ?? new Map<DialogKind, string>();
map.set(kind, key);
baselines.set(host, map);
}
export function forgetDialogBaseline(host: object, kind: DialogKind): void {
baselines.get(host)?.delete(kind);
}
export function dialogDirty(host: object, kind: DialogKind, key: string): boolean {
const baseline = baselines.get(host)?.get(kind);
if (baseline === undefined) return true;
return baseline !== key;
}
/** Устойчивый отпечаток объекта: ключи по алфавиту, без транзиентных. */
export function stableKey(value: Record<string, unknown>, transient: ReadonlySet<string> = new Set()): string {
const entries = Object.entries(value)
.filter(([k]) => !transient.has(k))
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
return JSON.stringify(entries);
}
+47
View File
@@ -281,6 +281,53 @@ export function colorField({
</div>`;
}
export interface ColorTileOptions {
label: string;
/** Текущий цвет `#rrggbb` — фон плитки и печать hex. */
hex: string;
opacity: number;
opacityLabel: string;
/** Готовый `hp-color-opacity` (Q5): свотч открывает существующую панель. */
picker: TemplateResult;
onOpacity: (opacity: number) => void;
}
/** Светлый ли цвет — для контраста подписи поверх свотча плитки. */
export function isLightHex(hex: string): boolean {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return true;
const n = parseInt(m[1], 16);
const r = (n >> 16) & 255; const g = (n >> 8) & 255; const b = n & 255;
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
}
/**
* Плитка цвета (§3.1 «Плитки цвета»): название поверх свотча на всю ширину,
* ниже hex и число прозрачности. Свотч — trigger существующего пикера.
*/
export function colorTile({ label, hex, opacity, opacityLabel, picker, onOpacity }: ColorTileOptions): TemplateResult {
const pct = Math.round(Math.min(1, Math.max(0, opacity)) * 100);
return html`<div class="hpf-colortile">
<div class="hpf-colortile-swatch" style=${`background:${hex};color:${isLightHex(hex) ? '#1f2a30' : '#fff'}`}>
${picker}<span>${label}</span>
</div>
<div class="hpf-colortile-meta">
<code class="hpf-hex">${hex}</code>
<span class="hpf-unit" title=${opacityLabel}><input type="number" inputmode="numeric" min="0" max="100" step="1"
.value=${String(pct)} aria-label=${opacityLabel}
@input=${(e: Event) => {
const n = Number((e.target as HTMLInputElement).value);
if (Number.isFinite(n)) onOpacity(Math.min(1, Math.max(0, n / 100)));
}} /><span>%</span></span>
</div>
</div>`;
}
/** Ряд плиток цвета: три колонки, на узком экране две. */
export function colorTiles(tiles: readonly TemplateResult[]): TemplateResult {
return html`<div class="hpf-colortiles">${tiles}</div>`;
}
export interface ColorRowOptions {
label: string;
help?: TemplateResult | typeof nothing;
+41
View File
@@ -0,0 +1,41 @@
/**
* #600 К10 для «Общих настроек»: снимок при открытии, dirty, валидация.
* Состояние `_settingsDialog` не получает новых ключей — снимок живёт здесь.
*/
import type { HouseplanEditorHostPort } from '../houseplan-editor-runtime';
import { dialogDirty, forgetDialogBaseline, rememberDialogBaseline, stableKey } from './dialog-baseline';
export type GeneralSettingsDraft = NonNullable<HouseplanEditorHostPort['_settingsDialog']>;
const TRANSIENT: ReadonlySet<string> = new Set(['busy']);
export function generalDraftKey(d: GeneralSettingsDraft): string {
return stableKey(d as unknown as Record<string, unknown>, TRANSIENT);
}
export function rememberGeneralBaseline(host: object, d: GeneralSettingsDraft): void {
rememberDialogBaseline(host, 'settings', generalDraftKey(d));
}
export function forgetGeneralBaseline(host: object): void {
forgetDialogBaseline(host, 'settings');
}
export function generalDirty(host: object, d: GeneralSettingsDraft): boolean {
return dialogDirty(host, 'settings', generalDraftKey(d));
}
export interface GeneralProblem {
field: string;
message: 'gs.error_glow_radius' | 'gs.error_north';
}
/** Ошибки черновика в порядке полей формы. */
export function generalProblems(d: GeneralSettingsDraft): GeneralProblem[] {
const problems: GeneralProblem[] = [];
if (!(Number.isFinite(d.glowRadius) && d.glowRadius > 0)) problems.push({ field: 'gs-glow-radius', message: 'gs.error_glow_radius' });
if (d.northDeg !== null && !(Number.isInteger(d.northDeg) && d.northDeg >= 0 && d.northDeg <= 359)) {
problems.push({ field: 'gs-north', message: 'gs.error_north' });
}
return problems;
}
+251 -190
View File
@@ -1,209 +1,270 @@
/**
* Диалог «Общие настройки».
* Диалог «Общие настройки» по референсу `docs/design/600-settings-dialogs/`
* (#600, серия 2; §5 SPEC.md).
*
* #592 вынес его сюда побайтово; #598 собрал девять подписей-заголовков в семь
* карточек общего набора. Состояние по-прежнему живёт на хосте — модуль только
* рисует, и каждый контрол пишет в тот же ключ черновика, что и раньше (#598 К1).
*
* Классы `.srcrow`, `.dispsection`, `.gsrow`, `.colorrow`, `.namein`, `.areasel`,
* `.tempin` сохранены намеренно: за них держатся одиннадцать смоков, и потеря
* любого превратила бы ревью в чтение переписанных свидетелей (#598 К6).
* Семь карточек #598 остались на месте — менялось содержимое, не состав:
* строки-тумблеры с иконкой и подписью, плитки цвета по три в ряд, компактный
* компас, сегмент источника лучей, футер со статусом. Каждый контрол пишет в
* тот же ключ `_settingsDialog`, что и раньше (К1); сообщение о состоянии
* `gs.sun_missing` остаётся callout'ом (К5).
*/
import { html, nothing, type TemplateResult } from 'lit';
import { ensureFormKitStyles, formCard, segmented } from './form-kit';
import { langOf } from '../i18n';
import {
callout, colorField, colorRow, colorTile, colorTiles, ensureFormKitStyles, field, footerStatus,
formCard, segmented, subsection, textLink, toggleRow, unitInput,
} from './form-kit';
import { forgetGeneralBaseline, generalDirty, generalProblems, type GeneralSettingsDraft } from './general-form-state';
import { langOf, type I18nKey } from '../i18n';
import { settingsT, type SettingsI18nKey } from '../i18n/settings';
import { supportT } from '../i18n/support';
import { DEFAULT_FILL_COLORS } from '../logic';
import { hasTopologyTranslation, topologyT } from '../i18n/topology';
import { DEFAULT_FILL_COLORS, type FillColors } from '../logic';
import { strictNumber } from '../space-dialog';
import { sunStateOf } from '../sun';
import { renderSunRayOriginSelect } from '../sun-settings-view';
import { renderSunRayOriginSegment } from '../sun-settings-view';
import { zigbeeTopologySettingsOf } from '../zigbee-topology-settings';
import type { ZigbeeTopologySettings } from '../zigbee-topology-settings';
import type { HouseplanEditorRuntime } from '../houseplan-editor-runtime';
export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): TemplateResult {
ensureFormKitStyles(this.host);
return html`<hp-dialog .hass=${this.host.hass} data-kind="settings" form-shell .title=${this.host._t('gs.title')} icon="mdi:cog-outline" wide
@hp-close=${() => (this.host._settingsDialog = null)}>
<div class="body hpf-form">
${formCard({
title: this.host._t('gs.card_display'),
body: html`
<label class="srcrow">
${this._boolInput(this.host._settingsDialog!.showRoomTooltip, (v) =>
(this.host._settingsDialog = { ...this.host._settingsDialog!, showRoomTooltip: v }))}
<span>${supportT(
langOf(this.host.hass, this.host._config?.language), 'gs.show_room_tooltip',
)}</span>
</label>
<label class="srcrow">
${this._boolInput(this.host._settingsDialog!.radarShowLive, (v) =>
(this.host._settingsDialog = { ...this.host._settingsDialog!, radarShowLive: v }))}
<span>${this.host._t('gs.radar_show_live')}</span>
${this._help('gs.radar_show_live.help')}
</label>`,
const host = this.host;
const d = host._settingsDialog!;
const t = host._t.bind(host);
const lang = langOf(host.hass, host._config?.language);
const st = (key: SettingsI18nKey, vars?: Record<string, string | number>) => settingsT(lang, key, vars);
const set = (patch: Partial<GeneralSettingsDraft>) => { host._settingsDialog = { ...host._settingsDialog!, ...patch }; };
const problems = generalProblems(d);
const problemFor = (fieldId: string) => problems.find((p) => p.field === fieldId);
const dirty = generalDirty(host, d);
const canSave = dirty && problems.length === 0 && !d.busy;
const close = () => { forgetGeneralBaseline(host); host._settingsDialog = null; };
const requestClose = async (event: Event) => {
if (!dirty || d.busy) { close(); return; }
const dialog = event.currentTarget as { rejectClose?: () => void } | null;
const discard = await host._confirmDanger({
key: 'discard-settings-dialog', kind: 'warning',
title: st('dialog.discard_title'), message: st('dialog.discard_message'),
confirmLabel: st('dialog.discard_confirm'), cancelLabel: st('dialog.discard_keep'),
});
if (discard) close(); else dialog?.rejectClose?.();
};
const reviewFirst = () => {
const first = problems[0];
if (!first) return;
const node = (host.renderRoot as ParentNode).querySelector<HTMLElement>(`#${first.field}`);
node?.scrollIntoView({ block: 'center' });
node?.focus();
};
/** Плитка цвета палитры: свотч — trigger пикера с той же атомарной записью. */
const tile = (key: keyof FillColors, labelKey: I18nKey) => {
const v = d.colors[key];
const label = t(labelKey);
return colorTile({
label, hex: v.c, opacity: v.a, opacityLabel: st('gs.opacity_of', { name: label }),
onOpacity: (a) => this._setFillColor(key, { c: v.c, a }),
picker: html`<hp-color-opacity .label=${label} hide-label
.opacityLabel=${t('space.opacity')}
.pickerLabels=${host._colorPickerLabels}
.color=${v.c} .opacity=${v.a} .showOpacity=${true}
@hp-color-opacity-change=${(e: CustomEvent<{ color: string; opacity: number }>) => {
this._setFillColor(key, { c: e.detail.color, a: e.detail.opacity });
}}></hp-color-opacity>`,
});
};
const glowProblem = problemFor('gs-glow-radius');
const northProblem = problemFor('gs-north');
const zigbeeHelp = hasTopologyTranslation(lang, 'help') && hasTopologyTranslation(lang, 'help_aria')
? html`<hp-help data-help-key="topology.help" .text=${topologyT(lang, 'help')} .ariaLabel=${topologyT(lang, 'help_aria')}></hp-help>`
: nothing;
return html`<hp-dialog .hass=${host.hass} data-kind="settings" form-shell wide
.title=${t('gs.title')} icon="mdi:cog-outline" @hp-close=${requestClose}>
<div class="body hpf-form">
${formCard({
id: 'display',
title: t('gs.card_display'),
body: html`
${toggleRow({
id: 'gs-room-tooltip', icon: 'mdi:tooltip-text-outline',
title: supportT(lang, 'gs.show_room_tooltip'), caption: st('gs.show_room_tooltip_hint'),
checked: d.showRoomTooltip, onChange: (v) => set({ showRoomTooltip: v }),
})}
${formCard({
title: this.host._t('gs.card_zigbee'),
body: html`<hp-zigbee-topology-settings .hass=${this.host.hass} .value=${this.host._settingsDialog!.zigbeeTopology} .savedEnabled=${zigbeeTopologySettingsOf(this.host._settings).enabled} .devices=${this.host._devices} .registry=${this.host._haRegistry} @hp-topology-settings-change=${(event: CustomEvent<ZigbeeTopologySettings>) => (this.host._settingsDialog = { ...this.host._settingsDialog!, zigbeeTopology: event.detail })}></hp-zigbee-topology-settings>`,
${toggleRow({
id: 'gs-radar-live', icon: 'mdi:radar',
title: t('gs.radar_show_live'), caption: st('gs.radar_show_live_hint'),
checked: d.radarShowLive, onChange: (v) => set({ radarShowLive: v }),
})}`,
})}
${formCard({
id: 'zigbee',
title: t('gs.card_zigbee'),
help: zigbeeHelp,
body: html`<hp-zigbee-topology-settings embedded .hass=${host.hass} .value=${d.zigbeeTopology}
.savedEnabled=${zigbeeTopologySettingsOf(host._settings).enabled} .devices=${host._devices} .registry=${host._haRegistry}
@hp-topology-settings-change=${(event: CustomEvent<ZigbeeTopologySettings>) => set({ zigbeeTopology: event.detail })}></hp-zigbee-topology-settings>`,
})}
${formCard({
id: 'fills',
title: t('gs.card_fills'),
help: this._help('gs.card_fills.help'),
body: html`
${subsection({ title: t('gs.light_group') })}
${colorTiles([tile('light_on', 'gs.light_on'), tile('light_off', 'gs.light_off'), tile('light_none', 'gs.light_none')])}
${subsection({ title: t('gs.temp_group') })}
${colorTiles([tile('temp_cold', 'gs.temp_cold'), tile('temp_ok', 'gs.temp_ok'), tile('temp_hot', 'gs.temp_hot')])}
${subsection({ title: t('gs.lqi_group') })}
${colorTiles([tile('lqi_low', 'gs.lqi_low'), tile('lqi_high', 'gs.lqi_high')])}`,
})}
${formCard({
id: 'glow',
title: t('gs.glow_group'),
body: html`
${colorTiles([tile('glow_base', 'gs.glow_base'), tile('glow_light', 'gs.glow_light')])}
${field({
label: t('gs.glow_radius'), htmlFor: 'gs-glow-radius', help: this._help('gs.glow_radius.help'),
error: glowProblem ? st(glowProblem.message) : undefined,
control: unitInput({
id: 'gs-glow-radius', value: String(d.glowRadius), min: 0.5, step: 0.5, invalid: !!glowProblem,
unit: host._imperial ? t('gs.unit_ft') : t('gs.unit_m'),
onInput: (raw) => {
const v = strictNumber(raw);
if (v != null && v > 0) set({ glowRadius: v });
},
}),
})}`,
})}
${formCard({
id: 'plan',
title: t('gs.card_plan'),
body: html`
${colorRow({
label: t('gs.wall_fill'),
picker: colorField({
hex: d.colors.wall_fill.c, opacity: d.colors.wall_fill.a, opacityLabel: t('space.opacity'),
onOpacity: (a) => this._setFillColor('wall_fill', { c: d.colors.wall_fill.c, a }),
picker: html`<hp-color-opacity .label=${t('gs.wall_fill')} hide-label
.opacityLabel=${t('space.opacity')} .pickerLabels=${host._colorPickerLabels}
.color=${d.colors.wall_fill.c} .opacity=${d.colors.wall_fill.a} .showOpacity=${true}
@hp-color-opacity-change=${(e: CustomEvent<{ color: string; opacity: number }>) => {
this._setFillColor('wall_fill', { c: e.detail.color, a: e.detail.opacity });
}}></hp-color-opacity>`,
}),
})}
${formCard({
title: this.host._t('gs.card_fills'),
help: this._help('gs.card_fills.help'),
body: html`
<label class="dispsection">${this.host._t('gs.light_group')}</label>
${this._renderColorRow('light_on', 'gs.light_on')}
${this._renderColorRow('light_off', 'gs.light_off')}
${this._renderColorRow('light_none', 'gs.light_none')}
<label class="dispsection">${this.host._t('gs.temp_group')}</label>
${this._renderColorRow('temp_cold', 'gs.temp_cold')}
${this._renderColorRow('temp_ok', 'gs.temp_ok')}
${this._renderColorRow('temp_hot', 'gs.temp_hot')}
<label class="dispsection">${this.host._t('gs.lqi_group')}</label>
${this._renderColorRow('lqi_low', 'gs.lqi_low')}
${this._renderColorRow('lqi_high', 'gs.lqi_high')}`,
${field({
label: t('gs.bg_mode'), help: this._help('gs.bg_mode.help'),
control: segmented({
name: 'gs-bg-mode',
value: d.bgMode === 'daynight' ? 'daynight' : 'static',
ariaLabel: t('gs.bg_mode'),
options: [
{ value: 'static', label: t('gs.bg_static') },
{ value: 'daynight', label: t('gs.bg_daynight') },
],
onChange: (value) => set({ bgMode: value === 'daynight' ? 'daynight' : 'static' }),
}),
})}
${formCard({
title: this.host._t('gs.glow_group'),
body: html`
${this._renderColorRow('glow_base', 'gs.glow_base')}
${this._renderColorRow('glow_light', 'gs.glow_light')}
<div class="colorrow gsrow">
<span class="gsl help-inline-label"><label for="gs-glow-radius">${this.host._t('gs.glow_radius')}</label>
${this._help('gs.glow_radius.help')}</span>
<input id="gs-glow-radius" type="number" class="tempin" min="0.5" step="0.5"
.value=${String(this.host._settingsDialog!.glowRadius)}
@input=${(e: Event) => {
const v = strictNumber((e.target as HTMLInputElement).value);
if (v != null && v > 0)
this.host._settingsDialog = { ...this.host._settingsDialog!, glowRadius: v };
}} />
<span class="opl">${this.host._imperial ? this.host._t('gs.unit_ft') : this.host._t('gs.unit_m')}</span>
</div>`,
${d.bgMode === 'static'
? field({
label: t('gs.bg_color'),
hint: st(d.bgColor ? 'gs.bg_custom_hint' : 'gs.bg_theme_hint'),
control: colorField({
hex: d.bgColor || host._stageBgHex(),
resetLabel: d.bgColor ? t('gs.bg_default') : undefined,
onReset: () => set({ bgColor: null }),
picker: html`<hp-color-opacity .label=${t('gs.bg_color')} hide-label
.pickerLabels=${host._colorPickerLabels}
.color=${d.bgColor || host._stageBgHex()} .opacity=${1} .showOpacity=${false}
@hp-color-opacity-change=${(e: CustomEvent<{ color: string }>) => set({ bgColor: e.detail.color })}></hp-color-opacity>`,
}),
})
: nothing}`,
})}
${formCard({
id: 'sun',
title: t('gs.sun_group'),
body: html`
${!sunStateOf(host.hass)
? callout({ kind: 'warning', role: 'status', text: t('gs.sun_missing') })
: nothing}
<div class="hpf-north">
<span class="hpf-labelrow"><label for="gs-north">${t('gs.north')}</label>${this._help('gs.north.help')}</span>
<div class="hpf-inline hpf-wrap">
${unitInput({
id: 'gs-north', value: d.northDeg === null ? '' : String(d.northDeg), unit: '°',
min: 0, max: 359, step: 1, placeholder: t('gs.north_ph'), invalid: !!northProblem,
onInput: (raw) => {
const trimmed = raw.trim();
const n = trimmed === '' ? null : Math.round(Number(trimmed));
set({ northDeg: n !== null && Number.isFinite(n) ? n : null });
},
})}
${d.northDeg !== null ? textLink(t('gs.north_clear'), () => set({ northDeg: null })) : nothing}
</div>
<span class="hpf-north-n" aria-hidden="true">${t('gs.north_letter')}</span>
${host._renderCompass()}
</div>
${northProblem ? html`<p class="hpf-error" role="alert">${st(northProblem.message)}</p>` : nothing}
${toggleRow({
id: 'gs-sun-rays', icon: 'mdi:weather-sunny',
title: t('gs.sun_rays'), caption: st('gs.sun_rays_hint'),
checked: d.sunRays, onChange: (v) => set({ sunRays: v }),
})}
${formCard({
title: this.host._t('gs.card_plan'),
body: html`
<label class="dispsection">${this.host._t('gs.wall_group')}</label>
${this._renderColorRow('wall_fill', 'gs.wall_fill')}
<label class="dispsection">${this.host._t('gs.bg_group')}</label>
<div class="colorrow gsrow">
<span class="gsl help-inline-label"><label for="gs-bg-mode">${this.host._t('gs.bg_mode')}</label>
${this._help('gs.bg_mode.help')}</span>
${segmented({
name: 'gs-bg-mode',
value: this.host._settingsDialog!.bgMode === 'daynight' ? 'daynight' : 'static',
ariaLabel: this.host._t('gs.bg_mode'),
options: [
{ value: 'static', label: this.host._t('gs.bg_static') },
{ value: 'daynight', label: this.host._t('gs.bg_daynight') },
],
onChange: (value) =>
(this.host._settingsDialog = { ...this.host._settingsDialog!, bgMode: value === 'daynight' ? 'daynight' : 'static' }),
})}
</div>
${this.host._settingsDialog!.bgMode === 'static'
? html`<div class="colorrow gsrow">
<hp-color-opacity .label=${this.host._t('gs.bg_color')}
.pickerLabels=${this.host._colorPickerLabels}
.color=${this.host._settingsDialog!.bgColor || this.host._stageBgHex()}
.opacity=${1} .showOpacity=${false}
@hp-color-opacity-change=${(e: CustomEvent<{ color: string }>) => {
this.host._settingsDialog = { ...this.host._settingsDialog!, bgColor: e.detail.color };
}}></hp-color-opacity>
${this.host._settingsDialog!.bgColor
? html`<button class="btn ghost" @click=${() =>
(this.host._settingsDialog = { ...this.host._settingsDialog!, bgColor: null })}>${this.host._t('gs.bg_default')}</button>`
: html`<span class="opl">${this.host._t('gs.bg_theme')}</span>`}
</div>`
: nothing}`,
})}
${formCard({
title: this.host._t('gs.sun_group'),
body: html`
${!sunStateOf(this.host.hass)
? html`<div class="rhint">${this.host._t('gs.sun_missing')}</div>`
: nothing}
<div class="sunrow">
${this.host._renderCompass()}
<div class="suncol">
<div class="helpfieldlabel compact">
<label for="gs-north">${this.host._t('gs.north')}</label>
${this._help('gs.north.help')}
</div>
<div class="colorrow">
<input id="gs-north" class="namein tempin" type="number" min="0" max="359" step="1"
placeholder=${this.host._t('gs.north_ph')}
.value=${this.host._settingsDialog!.northDeg === null ? '' : String(this.host._settingsDialog!.northDeg)}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value.trim();
const n = raw === '' ? null : Math.round(Number(raw));
this.host._settingsDialog = {
...this.host._settingsDialog!,
northDeg: n !== null && Number.isFinite(n) ? Math.min(359, Math.max(0, n)) : null,
};
}} />
${this.host._settingsDialog!.northDeg !== null
? html`<button class="btn ghost" @click=${() =>
(this.host._settingsDialog = { ...this.host._settingsDialog!, northDeg: null })}>${this.host._t('gs.north_clear')}</button>`
: nothing}
</div>
</div>
</div>
<label class="srcrow">
${this._boolInput(this.host._settingsDialog!.sunRays, (v) =>
(this.host._settingsDialog = { ...this.host._settingsDialog!, sunRays: v }))}
<span>${this.host._t('gs.sun_rays')}</span>
</label>
${renderSunRayOriginSelect(this.host._settingsDialog!.sunRayOrigin, (key) => this.host._t(key), (sunRayOrigin) => (this.host._settingsDialog = { ...this.host._settingsDialog!, sunRayOrigin }))}`,
})}
${formCard({
title: this.host._t('gs.card_data'),
body: html`
${this.host._canEdit ? html`
<label class="dispsection">${this.host._t('gs.backup_group')}${this._help('gs.backup_group.help')}</label>
<div class="backupactions">
<button class="btn ghost" @click=${() => this._openBackupExport()}>
<ha-icon icon="mdi:download"></ha-icon>${this.host._t('backup.export_open')}
</button>
<span class="backupupload">
<button class="btn ghost" type="button" @click=${(e: Event) =>
((e.currentTarget as HTMLElement).nextElementSibling as HTMLInputElement | null)?.click()}>
<ha-icon icon="mdi:upload"></ha-icon>${this.host._t('backup.import_open')}
</button>
<input type="file" accept="application/json,.json" @change=${(event: Event) => this._pickBackupImport(event)} />
</span>
${this.host._canOptimizeUndo && this.host._undoKind === 'import' ? html`
<button class="btn ghost" @click=${() => this._undoPlanOptimization()}
?disabled=${this.host._optimizeUndoBusy}>
<ha-icon icon="mdi:undo-variant"></ha-icon>${this.host._t('backup.undo_import')}
</button>` : nothing}
</div>` : nothing}
<label class="dispsection">${this.host._t('gs.grid_group')}${this._help('gs.grid_group.help')}</label>
<div class="colorrow gsrow">
<button class="btn ghost alignall" @click=${() => this._openAlignDialog()}>
<ha-icon icon="mdi:broom"></ha-icon>${this.host._t('gs.align_all')}
${renderSunRayOriginSegment(d.sunRayOrigin, (key) => t(key), (sunRayOrigin) => set({ sunRayOrigin }))}`,
})}
${formCard({
id: 'data',
title: t('gs.card_data'),
body: html`
${host._canEdit ? html`
${subsection({ title: t('gs.backup_group'), help: this._help('gs.backup_group.help') })}
<div class="hpf-actions">
<button class="btn ghost" @click=${() => this._openBackupExport()}>
<ha-icon icon="mdi:download"></ha-icon>${t('backup.export_open')}
</button>
<span class="backupupload">
<button class="btn ghost" type="button" @click=${(e: Event) =>
((e.currentTarget as HTMLElement).nextElementSibling as HTMLInputElement | null)?.click()}>
<ha-icon icon="mdi:upload"></ha-icon>${t('backup.import_open')}
</button>
</div>
${this.host._canOptimizeUndo && this.host._undoKind !== 'import' ? html`<div class="colorrow gsrow">
<button class="btn ghost alignall" @click=${() => this._undoPlanOptimization()}
?disabled=${this.host._optimizeUndoBusy}>
<ha-icon icon="mdi:undo-variant"></ha-icon>${this.host._t('gs.optimize_undo')}
</button>
</div>` : nothing}`,
})}
</div>
<div class="row" slot="footer">
<button class="btn ghost" @click=${() =>
(this.host._settingsDialog = { ...this.host._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this.host._imperial ? 9.8 : 3, bgColor: null, northDeg: null, bgMode: 'daynight', sunRays: false, sunRayOrigin: 'inner', showRoomTooltip: true, radarShowLive: true, zigbeeTopology: { enabled: false, z2mBaseTopics: [] } })}>
${this.host._t('gs.reset')}
</button>
<span class="spacer"></span>
<button class="btn ghost" data-hp="dialog-cancel" @click=${() => (this.host._settingsDialog = null)}>${this.host._t('btn.cancel')}</button>
<button class="btn on" data-hp="dialog-confirm" @click=${() => this._saveSettingsDialog()} ?disabled=${this.host._settingsDialog!.busy}>
<ha-icon icon="mdi:check"></ha-icon>${this.host._settingsDialog!.busy ? '…' : this.host._t('btn.save')}
</button>
</div>
</hp-dialog>`;
}
<input type="file" accept="application/json,.json" @change=${(event: Event) => this._pickBackupImport(event)} />
</span>
${host._canOptimizeUndo && host._undoKind === 'import' ? html`
<button class="btn ghost" @click=${() => this._undoPlanOptimization()} ?disabled=${host._optimizeUndoBusy}>
<ha-icon icon="mdi:undo-variant"></ha-icon>${t('backup.undo_import')}
</button>` : nothing}
</div>` : nothing}
${subsection({ title: t('gs.grid_group'), help: this._help('gs.grid_group.help') })}
<div class="hpf-actions">
<button class="btn ghost alignall" @click=${() => this._openAlignDialog()}>
<ha-icon icon="mdi:broom"></ha-icon>${t('gs.align_all')}
</button>
${host._canOptimizeUndo && host._undoKind !== 'import' ? html`
<button class="btn ghost alignall" @click=${() => this._undoPlanOptimization()} ?disabled=${host._optimizeUndoBusy}>
<ha-icon icon="mdi:undo-variant"></ha-icon>${t('gs.optimize_undo')}
</button>` : nothing}
</div>`,
})}
</div>
<div class="row dialog-action-footer hpf-footer" slot="footer">
<div class="dialog-action-group">
<button class="btn ghost" @click=${() => set({
colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: host._imperial ? 9.8 : 3,
bgColor: null, northDeg: null, bgMode: 'daynight', sunRays: false, sunRayOrigin: 'inner',
showRoomTooltip: true, radarShowLive: true, zigbeeTopology: { enabled: false, z2mBaseTopics: [] },
})}>${t('gs.reset')}</button>
</div>
${footerStatus(problems.length
? { action: textLink(st('dialog.review_fields', { n: String(problems.length) }), reviewFirst) }
: { text: dirty ? st('dialog.unsaved') : '' })}
<div class="dialog-action-group dialog-action-commit">
<button class="btn ghost" data-hp="dialog-cancel" @click=${requestClose}>${t('btn.cancel')}</button>
<button class="btn on" data-hp="dialog-confirm" @click=${() => this._saveSettingsDialog()} ?disabled=${!canSave}
title=${problems[0] ? st(problems[0].message) : ''}>
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '…' : t('btn.save')}
</button>
</div>
</div>
</hp-dialog>`;
}
+9 -17
View File
@@ -10,6 +10,7 @@
* граф, а эта логика нужна только открытому диалогу (К8).
*/
import type { SpaceDialogState } from '../space-dialog';
import { dialogDirty, forgetDialogBaseline, rememberDialogBaseline, stableKey } from './dialog-baseline';
/**
* Ключи, которые не являются настройками: транзиентные поля пикера файлов,
@@ -22,33 +23,24 @@ const SPACE_DIALOG_TRANSIENT_KEYS: ReadonlySet<string> = new Set([
/** Нормализованный отпечаток черновика: только настройки, ключи по алфавиту. */
export function spaceDialogDraftKey(d: SpaceDialogState): string {
const entries = Object.entries(d)
.filter(([key]) => !SPACE_DIALOG_TRANSIENT_KEYS.has(key))
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([key, value]) => [key, key === 'planFile' && value ? (value as { name: string }).name : value]);
return JSON.stringify(entries);
return stableKey(
{ ...d, planFile: d.planFile ? d.planFile.name : null },
SPACE_DIALOG_TRANSIENT_KEYS,
);
}
const spaceBaselines = new WeakMap<object, string>();
/** Запомнить состояние на момент открытия: вызывается тем, кто открыл диалог. */
export function rememberSpaceDialogBaseline(host: object, d: SpaceDialogState): void {
spaceBaselines.set(host, spaceDialogDraftKey(d));
rememberDialogBaseline(host, 'space', spaceDialogDraftKey(d));
}
export function forgetSpaceDialogBaseline(host: object): void {
spaceBaselines.delete(host);
forgetDialogBaseline(host, 'space');
}
/**
* Есть ли несохранённые изменения. Без снимка (диалог открыт путём, который
* его не сделал) ответ `true`: старое поведение — Save доступен — безопаснее
* заблокированной кнопки.
*/
/** Есть ли несохранённые изменения (см. `dialog-baseline.ts` о поведении без снимка). */
export function spaceDialogDirty(host: object, d: SpaceDialogState): boolean {
const baseline = spaceBaselines.get(host);
if (baseline === undefined) return true;
return baseline !== spaceDialogDraftKey(d);
return dialogDirty(host, 'space', spaceDialogDraftKey(d));
}
export interface SpaceDialogProblem {
+15 -17
View File
@@ -10651,11 +10651,20 @@ export class HouseplanCard extends LitElement {
this._settingsDialog = { ...this._settingsDialog!, northDeg: a };
}
/** The compass dial: drag the «N» arrow around the ring (docs/SUN.md). */
/**
* The compass dial: drag around the ring to set north (docs/SUN.md).
*
* #600 §3.1: the dial is the 44 px indicator of the reference — «N» sits on
* the label line outside it, the needle rotates to the effective angle and
* the degrees live in the number field next to it. The drag input stayed:
* it is an existing way to set north and `smoke_sun` drives it for real.
* Classes `.compass` / `.unset` are the stable hooks of that smoke.
*/
private _renderCompass(): TemplateResult {
const d = this._settingsDialog!;
const deg = d.northDeg;
return html`<svg class="compass ${deg === null ? 'unset' : ''}" viewBox="-60 -60 120 120"
return html`<span class="hpf-compass compass ${deg === null ? 'hpf-unset unset' : ''}" role="img"
aria-label=${this._t('gs.north')}
@pointerdown=${(e: PointerEvent) => {
(e.currentTarget as Element).setPointerCapture(e.pointerId);
this._compassDrag = true;
@@ -10664,18 +10673,10 @@ export class HouseplanCard extends LitElement {
@pointermove=${(e: PointerEvent) => { if (this._compassDrag) this._compassPoint(e); }}
@pointerup=${() => (this._compassDrag = false)}
@pointercancel=${() => (this._compassDrag = false)}>
<circle class="cring" r="50"></circle>
${[0, 45, 90, 135, 180, 225, 270, 315].map(
(t) => svg`<line class="ctick ${t % 90 ? 'minor' : ''}" x1="0" y1="-50" x2="0" y2="${t % 90 ? -46 : -43}"
transform="rotate(${t})"></line>`,
)}
<g class="cneedle" transform="rotate(${deg ?? 0})">
<line x1="0" y1="34" x2="0" y2="-28"></line>
<path d="M -7 -24 L 0 -42 L 7 -24 Z"></path>
<text x="0" y="-12" text-anchor="middle">${this._t('gs.north_letter')}</text>
</g>
<text class="cdeg" x="0" y="26" text-anchor="middle">${deg === null ? '—' : deg + '°'}</text>
</svg>`;
<svg viewBox="0 0 24 24" style=${`transform: rotate(${deg ?? 0}deg)`} aria-hidden="true">
<path d="M12 3v18M12 3l-4 7h8z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>`;
}
/**
@@ -10920,9 +10921,6 @@ export class HouseplanCard extends LitElement {
return this._editorRuntimeOrThrow()._rangeInput(min, max, step, value, onInput, disabled, ariaLabel);
}
private _renderColorRow(key: keyof FillColors, labelKey: string): TemplateResult {
return this._editorRuntimeOrThrow()._renderColorRow(key, labelKey);
}
/** Glow radius: stored in cm (config.settings.glow_radius_cm), default 3 m. */
private get _glowRadiusCm(): number {
+3 -15
View File
@@ -86,7 +86,6 @@ import {
rayRimEdges, rimStops, rimPeakAlpha, RIM_COLOR, type SunRayOrigin,
} from './sun';
import { dayCycleStageVars, renderDayCycleEnvironment } from './day-cycle-render';
import { renderSunRayOriginSelect } from './sun-settings-view';
import {
furnitureDefaultCm,
furnitureGraphic, furnitureCorners,
@@ -140,6 +139,7 @@ import {
touchSpaceDisplay, type SpaceDialogState,
} from './space-dialog';
import { rememberSpaceDialogBaseline } from './editors/space-form-state';
import { rememberGeneralBaseline } from './editors/general-form-state';
import { commitPlanOptimization } from './plan-optimize-write';
import { roomTemperatureControls } from './room-temperature-controls';
import { openSpaceCopyDialog, renderSpaceCopyDialog, saveSpaceCopy } from './space-copy-runtime';
@@ -8840,6 +8840,8 @@ public _openSettingsDialog = (): void => {
radarShowLive: this.host._settings.radar?.show_live !== false,
showRoomTooltip: showRoomTooltipOf(this.host._settings), zigbeeTopology: zigbeeTopologySettingsOf(this.host._settings), busy: false,
};
// #600 К10: снимок на момент открытия — от него считается «есть изменения».
rememberGeneralBaseline(this.host, this.host._settingsDialog);
};
public _openSupportDialog = (): void => {
@@ -10084,20 +10086,6 @@ public _rangeInput(
?disabled=${disabled} aria-label=${ariaLabel || nothing} @input=${h} />`;
}
public _renderColorRow(key: keyof FillColors, labelKey: string): TemplateResult {
const d = this.host._settingsDialog!;
const v = d.colors[key];
return html`<div class="colorrow gsrow">
<hp-color-opacity .label=${this.host._t(labelKey as any)}
.opacityLabel=${this.host._t('space.opacity')}
.pickerLabels=${this.host._colorPickerLabels}
.color=${v.c} .opacity=${v.a} .showOpacity=${true}
@hp-color-opacity-change=${(e: CustomEvent<{ color: string; opacity: number }>) => {
this._setFillColor(key, { c: e.detail.color, a: e.detail.opacity });
}}></hp-color-opacity>
</div>`;
}
public _renderAlignDialog(): TemplateResult {
const d = this.host._alignDialog!;
const r = d.report;
+71
View File
@@ -2,6 +2,7 @@ import { LitElement, css, html, nothing, type PropertyValues, type TemplateResul
import { langOf } from './i18n';
import { hasTopologyTranslation, topologyT, type TopologyI18nKey } from './i18n/topology';
import './hp-help';
import { ensureFormKitStyles } from './editors/form-kit';
import {
normalizeZ2mBaseTopic, type ZigbeeTopologySettings,
} from './zigbee-topology-settings';
@@ -18,6 +19,10 @@ export class HpZigbeeTopologySettings extends LitElement {
static properties = {
hass: { attribute: false },
value: { attribute: false },
// #600 §5.1: внутри карточки «Zigbee links» заголовок и «?» рисует карточка,
// а блок раскладывается контролами набора: строка-тумблер, callout,
// подзаголовки ZHA / Zigbee2MQTT, поле тем и строки действий.
embedded: { type: Boolean, reflect: true },
savedEnabled: { type: Boolean, attribute: 'saved-enabled' },
devices: { attribute: false },
registry: { attribute: false },
@@ -63,6 +68,16 @@ export class HpZigbeeTopologySettings extends LitElement {
}
button:disabled, textarea:disabled { opacity: .5; cursor: default; }
.warning { color: var(--warning-color, #d89300); font-size: 12px; line-height: 1.45; }
/* #600 embedded: примитивы набора внутри карточки; правила набора, которые
привязаны к форме диалога, здесь недоступны — они повторены точечно. */
:host([embedded]) { display: grid; gap: 12px; }
:host([embedded]) textarea.hpf-input { min-height: 72px; border-radius: 7px; padding: 8px 10px;
border-color: var(--hpf-line, var(--divider-color, #666)); background: var(--hpf-surface, var(--card-background-color, #202126)); }
:host([embedded]) textarea[aria-invalid="true"] { border-color: var(--hpf-danger, var(--error-color, #db543d)); }
:host([embedded]) .hpf-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; }
:host([embedded]) button { min-height: 44px; border-radius: 8px; padding: 9px 14px; font-weight: 400;
border-color: var(--hpf-line, var(--divider-color, #666)); }
:host([embedded]) .hpf-hint { margin: 0; }
`;
disconnectedCallback(): void {
@@ -89,6 +104,8 @@ export class HpZigbeeTopologySettings extends LitElement {
}
private get _admin(): boolean { return this.hass?.user?.is_admin === true; }
public embedded = false;
private _t(key: TopologyI18nKey, vars?: Record<string, string | number>): string {
return topologyT(langOf(this.hass), key, vars);
}
@@ -179,6 +196,7 @@ export class HpZigbeeTopologySettings extends LitElement {
}
protected render() {
if (this.embedded) return this._renderEmbedded();
const admin = this._admin;
const enabled = this.value.enabled;
const mayLoad = admin && this.savedEnabled;
@@ -228,6 +246,59 @@ export class HpZigbeeTopologySettings extends LitElement {
</div>` : nothing}
`;
}
/**
* Раскладка внутри карточки общего набора (#600, §5.1 SPEC.md). Лист набора
* вносится в свой теневой корень: селекторы примитивов не привязаны к форме,
* а токены `--hpf-*` наследуются с хоста через границу тени. Заголовок и «?»
* функции здесь не рисуются — их даёт карточка (`topology.title` /
* `topology.help`), иначе заголовок удваивался (дефект 1 из #600).
*/
private _renderEmbedded(): TemplateResult {
ensureFormKitStyles(this);
const admin = this._admin;
const enabled = this.value.enabled;
const mayLoad = admin && this.savedEnabled;
const topics = this._topics();
return html`
<label class="hpf-toggle">
<span class="hpf-toggle-icon" aria-hidden="true"><ha-icon icon="mdi:zigbee"></ha-icon></span>
<span class="hpf-toggle-title"><span>${this._t('toggle')}</span></span>
<span class="hpf-toggle-caption">${this._t('hint')}</span>
<input type="checkbox" .checked=${enabled} ?disabled=${!admin} aria-label=${this._t('toggle')}
@change=${(event: Event) => this._emit({ ...this.value,
enabled: (event.target as HTMLInputElement).checked })} />
</label>
${!admin ? html`<p class="hpf-hint">${this._t('admin_only')}</p>` : nothing}
${enabled ? html`
${!this.savedEnabled ? html`<div class="hpf-callout"><ha-icon icon="mdi:information-outline"></ha-icon><p>${this._t('save_first')}</p></div>` : nothing}
<div class="hpf-sub"><h4>${this._t('zha')}</h4></div>
<p class="hpf-hint">${this._t('zha_hint')}</p>
<div class="hpf-actions">
<button class="btn ghost" ?disabled=${!mayLoad || this._busy('zha')} @click=${this._readZha}>
<ha-icon icon="mdi:access-point-network"></ha-icon>${this._t('zha_read')}
</button>
<span class="hpf-hint">${this._status('zha')}</span>
</div>
<div class="hpf-sub"><h4>${this._t('z2m')}</h4></div>
<div class="hpf-field">
<span class="hpf-label hpf-labelrow"><label for="z2m-topics">${this._t('z2m_topics')}</label></span>
<textarea id="z2m-topics" class="hpf-input" rows="2" spellcheck="false" ?disabled=${!admin}
aria-invalid=${this._invalidTopic ? 'true' : nothing}
.value=${this._topicText} @input=${(event: Event) =>
this._editTopics((event.target as HTMLTextAreaElement).value)}></textarea>
${this._invalidTopic ? html`<p class="hpf-error" role="alert">${this._t('error_invalid_topic')}</p>` : nothing}
</div>
<div class="hpf-callout hpf-warning"><ha-icon icon="mdi:alert-outline"></ha-icon><p>${this._t('z2m_warning')}</p></div>
${topics.map((topic) => html`<div class="hpf-actions">
<button class="btn ghost" ?disabled=${!mayLoad || this._invalidTopic || this._busy(`z2m:${topic}`)}
@click=${() => this._refreshZ2m(topic)}>
<ha-icon icon="mdi:refresh"></ha-icon>${this._t('z2m_update')} · ${topic}
</button>
<span class="hpf-hint">${this._status(`z2m:${topic}`)}</span>
</div>`)}` : nothing}
`;
}
}
if (!customElements.get('hp-zigbee-topology-settings')) {
+3 -8
View File
@@ -455,14 +455,14 @@
"pdf.scale": "Maßstab 1:{n}",
"pdf.north": "Nord",
"gs.title": "Allgemeine Einstellungen",
"gs.light_group": "Füllung: Licht",
"gs.light_group": "Beleuchtung",
"gs.light_on": "Lichter an",
"gs.light_off": "Alle Lichter aus",
"gs.temp_group": "Füllung: Temperatur",
"gs.temp_group": "Temperatur",
"gs.temp_cold": "Kalt",
"gs.temp_ok": "Angenehm",
"gs.temp_hot": "Heiß",
"gs.lqi_group": "Füllung: Zigbee-Signal",
"gs.lqi_group": "Zigbee-Signal",
"gs.lqi_low": "Schwaches Signal",
"gs.lqi_high": "Starkes Signal",
"gs.reset": "Auf Standard zurücksetzen",
@@ -693,7 +693,6 @@
"gs.glow_group": "Glow von Lichtquellen",
"gs.glow_base": "Grunddunkelheit des Hauses",
"gs.glow_light": "Standardlichtfarbe / Intensität",
"gs.wall_group": "Wände",
"gs.wall_fill": "Wandfüllung",
"gs.glow_radius": "Glow-Radius",
"gs.glow_radius.help": "Legt den allgemeinen Radius des Lichtscheins in Metern oder Fuß fest; ein gerätespezifischer Radius überschreibt diesen Wert.",
@@ -983,10 +982,8 @@
"vac.trail_never": "Nie",
"vac.trail_cleaning": "Beim Reinigen",
"vac.trail_always": "Immer",
"gs.bg_group": "Bühnenhintergrund",
"gs.bg_color": "Hintergrund um den Plan",
"gs.bg_default": "Standardthema",
"gs.bg_theme": "Standardthema",
"gs.bg_mode": "Planhintergrund",
"gs.bg_mode.help": "„Folgt der Sonne“ verwendet sun.sun und ersatzweise die lokale Browserzeit; „Statische Farbe“ zeigt immer die ausgewählte Farbe.",
"gs.bg_mode.help.aria": "Hilfe: allgemeiner Planhintergrund",
@@ -1380,11 +1377,9 @@
"gs.card_fills": "Füllfarben der Räume",
"gs.card_plan": "Plan",
"gs.card_data": "Daten",
"gs.radar_show_live.help.aria": "Über die Live-Radaranwesenheit",
"gs.card_fills.help.aria": "Über die Füllfarben der Räume",
"gs.backup_group.help.aria": "Über Sicherung und Übertragung",
"gs.grid_group.help.aria": "Über die Planwartung",
"gs.radar_show_live.help": "Blendet alle Live-Radarziele ein oder aus. Eine Beobachtungsaufzeichnung wird dadurch weder gestartet noch beendet.",
"gs.backup_group.help": "Laden Sie ein tragbares JSON-Backup herunter oder sehen Sie eine Vorschau und importieren Sie ein Backup, das mit House Plan erstellt wurde.",
"gs.grid_group.help": "Aktualisiert Datenmodelle, richtet Planelemente am Raster aus und fasst redundante Wandfragmente zusammen. Ein exakter Bericht wird angezeigt, bevor etwas gespeichert wird.",
"gs.card_fills.help": "Füllfarben gelten für jeden Bereich; jede Farbe hat ihre eigene Deckkraft. Der Füllmodus wird in den Einstellungen des jeweiligen Bereichs festgelegt.",
+3 -8
View File
@@ -455,14 +455,14 @@
"pdf.scale": "Scale 1:{n}",
"pdf.north": "North",
"gs.title": "General settings",
"gs.light_group": "Fill: lights",
"gs.light_group": "Lights",
"gs.light_on": "Lights on",
"gs.light_off": "All lights off",
"gs.temp_group": "Fill: temperature",
"gs.temp_group": "Temperature",
"gs.temp_cold": "Cold",
"gs.temp_ok": "Comfortable",
"gs.temp_hot": "Hot",
"gs.lqi_group": "Fill: zigbee signal",
"gs.lqi_group": "Zigbee signal",
"gs.lqi_low": "Weak signal",
"gs.lqi_high": "Strong signal",
"gs.reset": "Reset to defaults",
@@ -693,7 +693,6 @@
"gs.glow_group": "Light-source glow",
"gs.glow_base": "House darkness",
"gs.glow_light": "Default light color / intensity",
"gs.wall_group": "Walls",
"gs.wall_fill": "Wall fill",
"gs.glow_radius": "Glow radius",
"gs.glow_radius.help": "Sets the default glow radius in metres or feet; a device-specific radius overrides it.",
@@ -983,10 +982,8 @@
"vac.trail_never": "Never",
"vac.trail_cleaning": "While cleaning",
"vac.trail_always": "Always",
"gs.bg_group": "Stage background",
"gs.bg_color": "Background around the plan",
"gs.bg_default": "Theme default",
"gs.bg_theme": "theme default",
"gs.bg_mode": "Plan background",
"gs.bg_mode.help": "“Follows the Sun” uses sun.sun, falling back to the browser's local clock; Static always shows the selected colour.",
"gs.bg_mode.help.aria": "Help: general plan background",
@@ -1380,11 +1377,9 @@
"gs.card_fills": "Room fill colors",
"gs.card_plan": "Plan",
"gs.card_data": "Data",
"gs.radar_show_live.help.aria": "About live radar presence",
"gs.card_fills.help.aria": "About room fill colors",
"gs.backup_group.help.aria": "About backup and transfer",
"gs.grid_group.help.aria": "About plan maintenance",
"gs.radar_show_live.help": "Hides or shows all live radar targets. This does not start or stop observation recording.",
"gs.backup_group.help": "Download a portable JSON backup or preview and import a backup made by House Plan.",
"gs.grid_group.help": "Updates data models, aligns plan elements to the grid and merges redundant wall fragments. An exact report is shown before anything is stored.",
"gs.card_fills.help": "Fill colors apply to every space; each color has its own opacity. Which fill mode a space uses is set in that space's dialog.",
+3 -8
View File
@@ -455,14 +455,14 @@
"pdf.scale": "Échelle 1:{n}",
"pdf.north": "Nord",
"gs.title": "Paramètres généraux",
"gs.light_group": "Remplissage : lumières",
"gs.light_group": "Éclairage",
"gs.light_on": "Lumières allumées",
"gs.light_off": "Toutes les lumières éteintes",
"gs.temp_group": "Remplissage : température",
"gs.temp_group": "Température",
"gs.temp_cold": "Froid",
"gs.temp_ok": "Confortable",
"gs.temp_hot": "Chaud",
"gs.lqi_group": "Remplissage : signal Zigbee",
"gs.lqi_group": "Signal Zigbee",
"gs.lqi_low": "Signal faible",
"gs.lqi_high": "Signal fort",
"gs.reset": "Réinitialiser par défaut",
@@ -693,7 +693,6 @@
"gs.glow_group": "Halo des sources lumineuses",
"gs.glow_base": "Obscurité de la maison",
"gs.glow_light": "Couleur / intensité de lumière par défaut",
"gs.wall_group": "Murs",
"gs.wall_fill": "Remplissage des murs",
"gs.glow_radius": "Rayon du halo",
"gs.glow_radius.help": "Définit le rayon général du halo en mètres ou en pieds ; le rayon propre à un appareil remplace cette valeur.",
@@ -983,10 +982,8 @@
"vac.trail_never": "Jamais",
"vac.trail_cleaning": "Pendant le nettoyage",
"vac.trail_always": "Toujours",
"gs.bg_group": "Arrière-plan de la scène",
"gs.bg_color": "Arrière-plan autour du plan",
"gs.bg_default": "Valeur par défaut du thème",
"gs.bg_theme": "valeur par défaut du thème",
"gs.bg_mode": "Arrière-plan du plan",
"gs.bg_mode.help": "« Suit le soleil » utilise sun.sun ou, à défaut, l’heure locale du navigateur ; « Couleur statique » affiche toujours la couleur choisie.",
"gs.bg_mode.help.aria": "Aide : arrière-plan général du plan",
@@ -1380,11 +1377,9 @@
"gs.card_fills": "Couleurs de remplissage des pièces",
"gs.card_plan": "Plan",
"gs.card_data": "Données",
"gs.radar_show_live.help.aria": "À propos de la présence radar en direct",
"gs.card_fills.help.aria": "À propos des couleurs de remplissage",
"gs.backup_group.help.aria": "À propos de la sauvegarde et du transfert",
"gs.grid_group.help.aria": "À propos de la maintenance du plan",
"gs.radar_show_live.help": "Masque ou affiche toutes les cibles radar en direct. Cela ne démarre ni n’arrête l’enregistrement des observations.",
"gs.backup_group.help": "Téléchargez une sauvegarde JSON portable ou prévisualisez et importez une sauvegarde créée par House Plan.",
"gs.grid_group.help": "Met à jour les modèles de données, aligne les éléments du plan sur la grille et fusionne les fragments de murs redondants. Un rapport précis est affiché avant tout enregistrement.",
"gs.card_fills.help": "Les couleurs de remplissage s’appliquent à tous les espaces ; chaque couleur possède sa propre opacité. Le mode de remplissage utilisé par un espace se règle dans la boîte de dialogue de cet espace.",
+3 -8
View File
@@ -455,14 +455,14 @@
"pdf.scale": "Масштаб 1:{n}",
"pdf.north": "Север",
"gs.title": "Общие настройки",
"gs.light_group": "Заливка: освещение",
"gs.light_group": "Освещение",
"gs.light_on": "Свет включён",
"gs.light_off": "Весь свет выключен",
"gs.temp_group": "Заливка: температура",
"gs.temp_group": "Температура",
"gs.temp_cold": "Холодно",
"gs.temp_ok": "Комфорт",
"gs.temp_hot": "Жарко",
"gs.lqi_group": "Заливка: зигби-сигнал",
"gs.lqi_group": "Сигнал Zigbee",
"gs.lqi_low": "Слабый сигнал",
"gs.lqi_high": "Сильный сигнал",
"gs.reset": "Сбросить к умолчаниям",
@@ -693,7 +693,6 @@
"gs.glow_group": "Свечение источников света",
"gs.glow_base": "Темнота дома",
"gs.glow_light": "Цвет света по умолчанию / интенсивность",
"gs.wall_group": "Стены",
"gs.wall_fill": "Цвет заливки стен",
"gs.glow_radius": "Радиус свечения",
"gs.glow_radius.help": "Задаёт общий радиус светового пятна в метрах или футах; персональный радиус устройства заменяет это значение.",
@@ -983,10 +982,8 @@
"vac.trail_never": "Не показывать никогда",
"vac.trail_cleaning": "Во время уборки",
"vac.trail_always": "Показывать всегда",
"gs.bg_group": "Фон сцены",
"gs.bg_color": "Цвет фона вокруг плана",
"gs.bg_default": "Как в теме",
"gs.bg_theme": "по умолчанию — из темы",
"gs.bg_mode": "Фон плана",
"gs.bg_mode.help": "«Следует за Солнцем» использует sun.sun, а при недоступности — локальные часы браузера; статичный режим всегда показывает выбранный цвет.",
"gs.bg_mode.help.aria": "Подсказка: общий фон плана",
@@ -1380,11 +1377,9 @@
"gs.card_fills": "Цвета заливки комнат",
"gs.card_plan": "План",
"gs.card_data": "Данные",
"gs.radar_show_live.help.aria": "О живом присутствии радаров",
"gs.card_fills.help.aria": "О цветах заливки комнат",
"gs.backup_group.help.aria": "О резервной копии и переносе",
"gs.grid_group.help.aria": "Об обслуживании плана",
"gs.radar_show_live.help": "Скрывает или показывает все живые цели радаров. Это не запускает и не останавливает запись наблюдений.",
"gs.backup_group.help": "Скачайте переносимую JSON-копию или проверьте и импортируйте копию, созданную House Plan.",
"gs.grid_group.help": "Обновляет модели данных, выравнивает элементы по сетке и объединяет лишние фрагменты стен. Перед записью будет показан точный отчёт.",
"gs.card_fills.help": "Цвета заливок действуют на все пространства; у каждого цвета своя прозрачность. Какой режим заливки использует пространство — задаётся в его диалоге.",
+9 -1
View File
@@ -49,5 +49,13 @@
"dialog.discard_title": "Änderungen verwerfen?",
"dialog.discard_message": "Ihre Änderungen wurden nicht gespeichert.",
"dialog.discard_confirm": "Änderungen verwerfen",
"dialog.discard_keep": "Weiter bearbeiten"
"dialog.discard_keep": "Weiter bearbeiten",
"gs.show_room_tooltip_hint": "Zeigen Sie auf einen Raum im Plan, um seine Übersicht zu sehen.",
"gs.radar_show_live_hint": "Blendet alle Live-Radarziele ein oder aus. Die Beobachtungsaufzeichnung wird dadurch nicht gestartet oder gestoppt.",
"gs.bg_custom_hint": "Eigene Hintergrundfarbe.",
"gs.bg_theme_hint": "Hintergrund des Themes wird verwendet.",
"gs.sun_rays_hint": "Sonnenstrahlen durch Fensteröffnungen zeichnen.",
"gs.error_glow_radius": "Geben Sie einen Radius größer als null ein.",
"gs.error_north": "Geben Sie eine ganze Zahl zwischen 0° und 359° ein.",
"gs.opacity_of": "Deckkraft: {name}"
}
+9 -1
View File
@@ -49,5 +49,13 @@
"dialog.discard_title": "Discard your changes?",
"dialog.discard_message": "Your changes haven’t been saved.",
"dialog.discard_confirm": "Discard changes",
"dialog.discard_keep": "Keep editing"
"dialog.discard_keep": "Keep editing",
"gs.show_room_tooltip_hint": "Hover a room on the plan to see its summary.",
"gs.radar_show_live_hint": "Hides or shows all live radar targets. This does not start or stop observation recording.",
"gs.bg_custom_hint": "Custom background color.",
"gs.bg_theme_hint": "Using the theme background.",
"gs.sun_rays_hint": "Draw sun rays through window openings.",
"gs.error_glow_radius": "Enter a radius greater than zero.",
"gs.error_north": "Enter a whole number between 0° and 359°.",
"gs.opacity_of": "{name} opacity"
}
+9 -1
View File
@@ -49,5 +49,13 @@
"dialog.discard_title": "Abandonner vos modifications ?",
"dialog.discard_message": "Vos modifications n’ont pas été enregistrées.",
"dialog.discard_confirm": "Abandonner",
"dialog.discard_keep": "Continuer la modification"
"dialog.discard_keep": "Continuer la modification",
"gs.show_room_tooltip_hint": "Survolez une pièce sur le plan pour voir son résumé.",
"gs.radar_show_live_hint": "Affiche ou masque toutes les cibles radar en direct. Cela ne démarre ni n’arrête l’enregistrement d’observation.",
"gs.bg_custom_hint": "Couleur de fond personnalisée.",
"gs.bg_theme_hint": "Fond du thème utilisé.",
"gs.sun_rays_hint": "Tracer les rayons du soleil à travers les ouvertures des fenêtres.",
"gs.error_glow_radius": "Saisissez un rayon supérieur à zéro.",
"gs.error_north": "Saisissez un nombre entier entre 0° et 359°.",
"gs.opacity_of": "Opacité : {name}"
}
+9 -1
View File
@@ -49,5 +49,13 @@
"dialog.discard_title": "Отменить изменения?",
"dialog.discard_message": "Изменения не сохранены.",
"dialog.discard_confirm": "Отменить изменения",
"dialog.discard_keep": "Продолжить редактирование"
"dialog.discard_keep": "Продолжить редактирование",
"gs.show_room_tooltip_hint": "Наведите на комнату на плане, чтобы увидеть сводку.",
"gs.radar_show_live_hint": "Показывает или скрывает все живые цели радаров. Запись наблюдения не запускается и не останавливается.",
"gs.bg_custom_hint": "Свой цвет фона.",
"gs.bg_theme_hint": "Используется фон темы.",
"gs.sun_rays_hint": "Рисовать солнечные лучи через оконные проёмы.",
"gs.error_glow_radius": "Введите радиус больше нуля.",
"gs.error_north": "Введите целое число от 0° до 359°.",
"gs.opacity_of": "Прозрачность: {name}"
}
-14
View File
@@ -67,20 +67,6 @@ export const dialogsStyles = css`
opacity: 0.5;
pointer-events: none;
}
/* the compass dial in the general settings (docs/SUN.md) */
.sunrow {
display: flex;
align-items: center;
gap: var(--sp-5);
margin: var(--sp-3) 0;
}
.suncol {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--sp-3);
min-width: 0;
}
.recoveryoverlay {
position: absolute;
inset: 0;
+4 -1
View File
@@ -205,6 +205,9 @@ function extrasCss(options: FormKitCssOptions): string {
.${p}-unit > span { padding: 0 11px 0 3px; white-space: nowrap; color: var(${tokens.muted}); font-size: .875rem; }
.${p}-unit:focus-within { outline: 2px solid var(${tokens.accent}); outline-offset: 2px; }
.${p}-unit.${p}-unit-wide, .${p}-unit.${p}-unit-wide > input { width: 100%; min-width: 0; }
.${p}-field > .${p}-unit:not(.${p}-unit-wide) { justify-self: start; }
.${p}-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; }
.${p}-actions > input[type="file"] { display: none; }
.${p}-row {
display: flex;
min-width: 0;
@@ -413,7 +416,7 @@ function extrasCss(options: FormKitCssOptions): string {
.${p}-north > .${p}-labelrow { grid-area: 1 / 1; min-height: 28px; }
.${p}-north > .${p}-select, .${p}-north > .${p}-unit, .${p}-north > .${p}-input { grid-area: 2 / 1; }
.${p}-north > .${p}-north-n { grid-area: 1 / 2; text-align: center; font-size: .875rem; line-height: 28px; color: var(${tokens.muted}); }
.${p}-compass { grid-area: 2 / 2; display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; border: 1px solid var(${tokens.line}); border-radius: 50%; background: var(${tokens.canvas}); color: var(${tokens.accent}); }
.${p}-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(${tokens.line}); border-radius: 50%; background: var(${tokens.canvas}); color: var(${tokens.accent}); }
.${p}-compass svg { width: 24px; height: 24px; transform-origin: 50% 50%; transform-box: view-box; }
.${p}-compass.${p}-unset { color: var(${tokens.muted}); }
.${p}-tint { padding: 14px; border: 1px solid var(${tokens.tintLine}); border-radius: 9px; background: var(${tokens.tint}); }
+2 -36
View File
@@ -128,10 +128,9 @@ export const planStyles = css`
.sunlayer {
pointer-events: none;
}
/* #600: компас — индикатор 44 px набора (класс hpf-compass), геометрия и
цвета живут в листе набора; здесь только поведение перетаскивания. */
.compass {
width: 120px;
height: 120px;
flex: none;
touch-action: none;
cursor: grab;
user-select: none;
@@ -139,39 +138,6 @@ export const planStyles = css`
.compass:active {
cursor: grabbing;
}
.compass .cring {
fill: rgba(255, 255, 255, 0.04);
stroke: var(--divider-color, #444);
stroke-width: 2;
}
.compass .ctick {
stroke: var(--secondary-text-color, #9aa4ad);
stroke-width: 2;
}
.compass .ctick.minor {
stroke-width: 1;
opacity: 0.6;
}
.compass .cneedle line {
stroke: var(--primary-color, #3ea6ff);
stroke-width: 2.5;
stroke-linecap: round;
}
.compass .cneedle path {
fill: var(--primary-color, #3ea6ff);
}
.compass .cneedle text {
fill: var(--text-primary-color, #fff);
font-size: 11px;
font-weight: 700;
}
.compass .cdeg {
fill: var(--secondary-text-color, #9aa4ad);
font-size: 13px;
}
.compass.unset .cneedle {
opacity: 0.35;
}
/* HP-1552: first-open boot veil — the plan hides until the stage height settles */
.stage.hpboot .zoomwrap,
.stage.hpboot .zoombadge {
+20 -12
View File
@@ -1,20 +1,28 @@
import { html, type TemplateResult } from 'lit';
import type { TemplateResult } from 'lit';
import { field, segmented } from './editors/form-kit';
import type { I18nKey } from './i18n';
import type { SunRayOrigin } from './sun';
export function renderSunRayOriginSelect(
/**
* Источник солнечных лучей — сегмент из двух вариантов (#600 §5.1 вместо
* `<select>`). Значение и ключ прежние: `sunRayOrigin` ∈ inner | outer.
*/
export function renderSunRayOriginSegment(
value: SunRayOrigin,
translate: (key: I18nKey) => string,
changed: (value: SunRayOrigin) => void,
): TemplateResult {
return html`<div class="colorrow gsrow">
<span class="gsl"><label for="gs-sun-ray-origin">${translate('gs.sun_ray_origin')}</label></span>
<select id="gs-sun-ray-origin" class="areasel"
@change=${(event: Event) => changed(
(event.target as HTMLSelectElement).value === 'outer' ? 'outer' : 'inner',
)}>
<option value="inner" ?selected=${value === 'inner'}>${translate('gs.sun_ray_origin.inner')}</option>
<option value="outer" ?selected=${value === 'outer'}>${translate('gs.sun_ray_origin.outer')}</option>
</select>
</div>`;
return field({
label: translate('gs.sun_ray_origin'),
control: segmented<SunRayOrigin>({
name: 'gs-sun-ray-origin',
value,
ariaLabel: translate('gs.sun_ray_origin'),
options: [
{ value: 'inner', label: translate('gs.sun_ray_origin.inner') },
{ value: 'outer', label: translate('gs.sun_ray_origin.outer') },
],
onChange: (next) => changed(next === 'outer' ? 'outer' : 'inner'),
}),
});
}