feat: режим «Значение + статичный значок» (#588)

Пятый вариант «Отображение»: маркер показывает выбранное значение и при этом
никогда не меняет цвет — ни по состоянию, ни по тревоге, ни по недоступности,
ни под цвет RGB-света, ни по активности.

Четыре прежних режима задавали одним выбором две независимые вещи: что
нарисовано внутри маркера и красится ли он состоянием. Поэтому вместо
сравнения с одним токеном появились два производных предиката рядом с
`normalizeDeviceDisplay` — `displayWantsValue` и `displayIsNeutral`, — и их
спрашивают политика, слой пульсации, внешний бейдж, редактор и три ветки
живого слоя пылесоса.

Две ловушки, из-за которых режим не сводится к одной строке в словаре:

- быстрый путь «статичному маркеру источники не нужны» (`sourceDetails: false`)
  — это основной путь рендера плана, карточки пространства и PDF. Он оставлен
  только режиму без значения: иначе число пропало бы именно на плане и
  осталось в предпросмотре редактора;
- три ветки живого пылесоса сравнивают режим строкой и не читают политику,
  поэтому зелёная политика их не гарантирует. Свидетель держит две
  конфигурации карты: сопоставленная доказывает puck и след, несопоставленная
  — бейдж маршрута (при совпадающей калибровке маршрут `ready`, и бейджа не
  было бы ни в одном режиме). Бейдж проверяется при наполненном буфере
  позиций, иначе его отсутствие объяснялось бы первой веткой.

Потолок initial View перецентрирован 291_700 → 292_400 без изменения общего
бюджета: измеренный факт 291 346 Б оставлял под прежним центром 354 Б —
внутри шумовой полосы метрики.

Эталон: в `device-icon-state-table-{light,dark}` включённая RGB-лампа
переведена в новый режим. Кадры обязаны разойтись; приёмка — отдельным
коммитом класса D с полного линуксового артефакта Validate.

Issue: #588
User-Visible: yes
This commit is contained in:
Claude
2026-09-18 13:40:57 +03:00
parent 03aaad3e2b
commit 99e1765d22
75 changed files with 1324 additions and 765 deletions
+9 -1
View File
@@ -282,8 +282,16 @@ export const LOW_HEADROOM_WARNING_BYTES = 15_000;
* синхронный View до первого движущегося кадра; общий renderer бумажных фигур
* удержал объединённый факт на 291 058 Б — 642 Б сверху и 1 358 Б до нижней
* границы полосы.
*
* 2026-09-18, #588: потолок перецентрирован 291_700 → 292_400 без изменения
* общего бюджета. Пятый режим «Отображение» — это два производных предиката
* вместо одной проверки токена и пятая строка в двух словарях ярлыков; всё это
* синхронный View, потому что режим решает, что нарисовано внутри маркера при
* первом кадре. Факт на `dev` был 291 156 Б, измеренный факт 291 346 Б (+190 Б)
* оставлял под прежним центром 354 Б сверху — внутри шумовой полосы метрики.
* Новый центр даёт 1 054 Б сверху и 946 Б до нижней границы полосы.
*/
export const INITIAL_VIEW_GZIP_CEILING = 291_700;
export const INITIAL_VIEW_GZIP_CEILING = 292_400;
export const INITIAL_VIEW_CEILING_BAND = 2_000;
/**
+3
View File
@@ -65,6 +65,9 @@
{
"const": "static_icon"
},
{
"const": "value_static_icon"
},
{
"type": "null"
}
+94 -4
View File
@@ -6035,8 +6035,10 @@ const MUTANT_DEFINITIONS = [
+ 'metrics, pulse and vacuum live together rather than relying on renderer-specific checks',
patches: [{
file: 'src/device-presentation-policy.ts',
find: ' } else if (staticIcon) {\n visual = NEUTRAL_VISUAL;',
replace: ' } else if (false && staticIcon) {\n visual = NEUTRAL_VISUAL;',
// #588 переименовал переменную: она отвечает уже не за один токен, а за
// «нейтральное лицо» обоих статичных режимов. Якорь переехал, смысл нет.
find: ' } else if (neutralFace) {\n visual = NEUTRAL_VISUAL;',
replace: ' } else if (false && neutralFace) {\n visual = NEUTRAL_VISUAL;',
}],
},
{
@@ -6100,7 +6102,9 @@ const MUTANT_DEFINITIONS = [
+ 'single gate either hides valid values or revives ambiguous arbitrary registry rows',
patches: [{
file: 'src/device-presentation-policy.ts',
find: " const face: PresentationFace = input.display === 'value'\n"
// #588: лицо значения теперь спрашивают предикатом, а не сравнением с
// одним токеном — режимов со значением стало два. Якорь переехал.
find: ' const face: PresentationFace = wantsValue\n'
+ " && !effectiveHidden && input.valueAvailable ? 'value' : 'icon';",
replace: " const face: PresentationFace = false\n"
+ " && !effectiveHidden && input.valueAvailable ? 'value' : 'icon';",
@@ -6115,7 +6119,9 @@ const MUTANT_DEFINITIONS = [
+ 'satellites escape a static face even though the core itself remains neutral',
patches: [{
file: 'src/device-presentation-policy.ts',
find: ' const metrics = !staticIcon && !effectiveHidden;',
// #588: переменная переименована в `neutralFace` — режимов с нейтральным
// лицом стало два. Якорь переехал, смысл прежний.
find: ' const metrics = !neutralFace && !effectiveHidden;',
replace: ' const metrics = true;',
}],
},
@@ -10043,6 +10049,90 @@ const MUTANT_DEFINITIONS = [
replace: " if (candidateTag && isStableTag(candidateTag) && candidateTag === 'v0.0.0-mutant') {",
}],
},
{
id: 'value-static-icon-keeps-live-colour',
guard: 'node --test test/device-presentation-policy.test.mjs',
because: '#588 AC1: «никогда не меняет цвет» — весь смысл режима. Стоит нейтральности '
+ 'снова стать свойством одного токена, и маркер начнёт краснеть тревогой, гаснуть '
+ 'недоступностью и брать цвет RGB-лампы — молча, потому что значение при этом видно',
patches: [{
file: 'src/device-presentation-policy.ts',
find: ' const neutralFace = displayIsNeutral(input.display);',
replace: " const neutralFace = input.display === 'static_icon';",
}],
},
{
id: 'value-static-icon-loses-value-fallback',
guard: 'node --test test/device-presentation.test.mjs',
because: '#588 AC2: когда значение получить нельзя, лицо откатывается к значку, и '
+ 'причина отката — единственное, что объясняет человеку, почему числа нет. Потеря '
+ 'причины оставляет пустой значок без объяснения ровно в новом режиме',
patches: [{
file: 'src/device-presentation.ts',
find: ' fallbackReason: displayWantsValue(display) ? value.fallback : null,',
replace: " fallbackReason: display === 'value' ? value.fallback : null,",
}],
},
{
id: 'value-static-icon-takes-the-sourceless-fast-path',
guard: 'node --test test/device-presentation.test.mjs',
because: '#588 AC3: быстрый путь «статичному маркеру источники не нужны» действует на '
+ 'основном пути рендера плана. Распространить его на режим со значением — значит '
+ 'потерять число именно на плане и сохранить его в предпросмотре редактора',
patches: [{
file: 'src/device-presentation.ts',
find: " const sourceless = display === 'static_icon';",
replace: ' const sourceless = neutralFace;',
}],
},
{
id: 'value-static-icon-pulses-on-alarm',
guard: 'node --test test/device-pulse.test.mjs',
because: '#588 AC4: тревога пробивает и выключенные живые состояния, и отсутствие '
+ 'активности. Если пульсация снова принадлежит одному токену, «маркер не меняет цвет» '
+ 'перестаёт быть правдой в самый заметный момент',
patches: [{
file: 'src/device-pulse.ts',
find: ' || displayIsNeutral(display)) {',
replace: " || display === 'static_icon') {",
}],
},
{
id: 'value-static-icon-keeps-live-vacuum',
guard: 'node demo/smoke_static_icon.mjs',
because: '#588 AC6 (К2а): три ветки живого слоя пылесоса сравнивают режим строкой и не '
+ 'читают политику, поэтому зелёный AC1 их не гарантирует. Возвращённые к одному токену, '
+ 'они оставляют над «неизменным» маркером едущий puck, след и бейдж маршрута',
patches: [{
file: 'src/houseplan-card.ts',
find: " if (displayIsNeutral(normalizeDeviceDisplay(d.marker?.display))) {\n this._vacRt.delete(d.id);",
replace: " if (normalizeDeviceDisplay(d.marker?.display) === 'static_icon') {\n this._vacRt.delete(d.id);",
}],
},
{
id: 'value-static-icon-keeps-vacuum-overlay',
guard: 'node demo/smoke_static_icon.mjs',
because: '#588 AC6 (К2а): вторая из трёх веток — рендер puck и следа. Она не зависит от '
+ '`vacuumLive` политики, и её одной достаточно, чтобы над нейтральным маркером ездил '
+ 'робот',
patches: [{
file: 'src/houseplan-card.ts',
find: ' if (displayIsNeutral(normalizeDeviceDisplay(d.marker?.display))) continue;',
replace: " if (normalizeDeviceDisplay(d.marker?.display) === 'static_icon') continue;",
}],
},
{
id: 'value-static-icon-keeps-route-warning',
guard: 'node demo/smoke_static_icon.mjs',
because: '#588 AC6 (К2а): третья ветка — бейдж предупреждения о маршруте. Он появляется '
+ 'только на несопоставленной карте, поэтому свидетель держит две конфигурации: при '
+ 'совпадающей калибровке маршрут `ready` и бейджа нет ни в одном режиме',
patches: [{
file: 'src/houseplan-card.ts',
find: ' if (displayIsNeutral(normalizeDeviceDisplay(d.marker?.display))) return nothing;',
replace: " if (normalizeDeviceDisplay(d.marker?.display) === 'static_icon') return nothing;",
}],
},
];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');