feat(card): the LED strip tool in the Devices editor (#780)

Stage 4 of #780 (ТЗ §4–§5). «LED strip» next to «Add» draws a chain with
clean clicks only (pan, pinch, a second finger, cancel and the synthetic
click after navigation add nothing), snaps to the grid and to physical wall
faces / zero-wall axes, stops at the first face of masonry, partitions,
columns and windows (doors, gates and passages are cut by geometry), Shift
gives 45°. Ctrl+Z removes the chain's own point first, Esc finishes, a
double click or a click on the first point (≥3 vertices) closes; fewer than
two distinct points write nothing. A finished strip opens the device picker
(lights first, taken markers explained, «New device…» binds in that
dialog's own write, «Later» keeps unbound geometry).

A selected strip shows its vertex handles (a drag re-checks both neighbours
and the whole path) and a new Devices tray branch: device settings,
bind/change, unbind, show as icon, delete. The device dialog gets the
representation section: show as strip (restores a hidden shape at once or
draws one for this marker, behind the dialog's own save/discard guard),
show as icon, unbind/delete for a hidden shape. Every geometry or
representation change is one optimistic write and one LED command of the
device history; Undo/Redo restores only its own strip record and refuses
when a newer change sits on it. A rebinding renames the link in the marker
save, a deleted marker leaves an unbound strip, a bound marker may not move
to another space. Plan/Background show strips as a passive translucent mark.

The tool, its `led` dictionary (en static, ru/de/fr lazy) and placement
geometry are a new lazy `led-strip-editor` chunk (9.5 KB gzip), loaded only
on the tool, an editable strip in the shown space or a strip device's
dialog. The initial graph gets the loader and delegation only
(src/led-strip-card.ts); the lazy editor graph +122 B, inside its ceiling.
Mutant anchors follow the moved code (marker dialog guard, static LED layer).

Issue: #780
User-Visible: no
This commit is contained in:
Codex
2026-10-02 12:13:23 +03:00
parent c7c67830f6
commit fa89d655ed
28 changed files with 1479 additions and 49 deletions
+16 -6
View File
@@ -299,7 +299,7 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
}
assert.throws(() => assertBundleBudget({
...manifest, lazyNamespaceLocaleFiles: manifest.lazyNamespaceLocaleFiles.slice(1),
}, 1_000_000, undefined, ...lazyCeilings), /lazy namespace locale graph has 11 files, expected 12/);
}, 1_000_000, undefined, ...lazyCeilings), /lazy namespace locale graph has 14 files, expected 15/);
// #661 AC8: a moon pulled into the first frame (a static import leaves no
// lazy moon graph — the builder subtracts initial) is refused by name.
assert.throws(() => assertBundleBudget({ ...manifest, lazyMoonFiles: [] }, 1_000_000, undefined, ...lazyCeilings),
@@ -540,7 +540,8 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
+ 'new URL("__HOUSEPLAN_PDF_RETRY_ASSET__", import.meta.url);'
+ 'new URL("__HOUSEPLAN_MOON_RETRY_ASSET__", import.meta.url);'
+ 'new URL("__HOUSEPLAN_LED_RETRY_ASSET__", import.meta.url);'
+ 'new URL("__HOUSEPLAN_LED_FIELD_RETRY_ASSET__", import.meta.url)', modules: {},
+ 'new URL("__HOUSEPLAN_LED_FIELD_RETRY_ASSET__", import.meta.url);'
+ 'new URL("__HOUSEPLAN_LED_EDITOR_RETRY_ASSET__", import.meta.url)', modules: {},
},
// #627: the lazy namespace chunk that owns the nine second-attempt tokens.
'houseplan-assets/backdrop-pick-HASH.js': {
@@ -589,6 +590,11 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
type: 'chunk', fileName: 'houseplan-assets/led-strip-field-HASH.js', code: '',
modules: { '/repo/src/led-strip-field.ts': {} },
},
// #780: the Devices-editor LED tool, loaded only on an explicit entry.
'houseplan-assets/led-strip-editor-HASH.js': {
type: 'chunk', fileName: 'houseplan-assets/led-strip-editor-HASH.js', code: '',
modules: { '/repo/src/led-strip-editor.ts': {} },
},
};
plugin.generateBundle({}, bundle);
assert.equal(
@@ -602,7 +608,8 @@ test('retry URL points at the content-hashed runtime chunk after naming', () =>
+ 'new URL("./pdf-export-HASH.js", import.meta.url);'
+ 'new URL("./moon-runtime-HASH.js", import.meta.url);'
+ 'new URL("./led-strip-runtime-HASH.js", import.meta.url);'
+ 'new URL("./led-strip-field-HASH.js", import.meta.url)',
+ 'new URL("./led-strip-field-HASH.js", import.meta.url);'
+ 'new URL("./led-strip-editor-HASH.js", import.meta.url)',
);
assert.equal(
bundle['houseplan-assets/backdrop-pick-HASH.js'].code,
@@ -615,7 +622,7 @@ test('#627 namespace retry tokens stay strict: exactly one each, every chunk emi
const bundle = {
'houseplan-assets/houseplan-card.js': {
type: 'chunk', fileName: 'houseplan-assets/houseplan-card.js', modules: {},
code: ['EDITOR', 'ONBOARDING', 'ISO', 'DE', 'FR', 'FURNITURE_ART', 'PDF', 'MOON', 'LED', 'LED_FIELD']
code: ['EDITOR', 'ONBOARDING', 'ISO', 'DE', 'FR', 'FURNITURE_ART', 'PDF', 'MOON', 'LED', 'LED_FIELD', 'LED_EDITOR']
.map((name) => `"__HOUSEPLAN_${name}_RETRY_ASSET__"`).join(';'),
},
...Object.fromEntries([
@@ -625,6 +632,7 @@ test('#627 namespace retry tokens stay strict: exactly one each, every chunk emi
['fr', '/src/i18n/fr.ts'], ['furniture-plan-art.generated', '/src/furniture-plan-art.generated.ts'],
['pdf-export', '/src/pdf/pdf-export.ts'], ['moon-runtime', '/src/moon-runtime.ts'],
['led-strip-runtime', '/src/led-strip-runtime.ts'], ['led-strip-field', '/src/led-strip-field.ts'],
['led-strip-editor', '/src/led-strip-editor.ts'],
].map(([name, module]) => [`houseplan-assets/${name}-HASH.js`, {
type: 'chunk', fileName: `houseplan-assets/${name}-HASH.js`, code: '', modules: { [`/repo${module}`]: {} },
}])),
@@ -1009,6 +1017,7 @@ const runBudgetCli = (initialViewGzipBytes) => {
english('settings'), english('support'), english('topology'), english('tools'),
].join('\n'));
writeFileSync(join(dir, 'dist/onboarding.js'), english('settings'));
writeFileSync(join(dir, 'dist/led-editor.js'), english('led'));
for (const entry of NAMESPACE_LOCALE_CHUNKS) {
const marker = namespaceMarkers.find((candidate) => candidate.namespace === entry.namespace
&& candidate.language === entry.language).text;
@@ -1040,6 +1049,7 @@ const runBudgetCli = (initialViewGzipBytes) => {
initialPanelOnlyFiles: ['houseplan-panel.js'],
initialPanelOnlyGzipBytes: 1,
lazyEditorFiles: ['editor.js'],
lazyLedEditorFiles: ['led-editor.js'],
// #593: у ленивых графов теперь свои потолки, и фикстура обязана лежать
// внутри полосы — иначе CLI краснеет не на том, что проверяет тест.
lazyEditorGzipBytes: LAZY_EDITOR_GZIP_CEILING - 1_000,
@@ -1205,6 +1215,6 @@ test('#627 AC3 проверка владения краснеет на кажд
}
// Маркеры реальных словарей не совпадают ни с одной строкой основного каталога.
const real = namespaceLocaleMarkers();
assert.equal(real.length, 16);
assert.equal(new Set(real.map((entry) => entry.text)).size, 16);
assert.equal(real.length, 20);
assert.equal(new Set(real.map((entry) => entry.text)).size, 20);
});
+4 -1
View File
@@ -7,7 +7,7 @@ import { existsSync, readFileSync } from 'node:fs';
import { NAMESPACE_LOCALE_CHUNKS } from '../scripts/bundle-manifest.mjs';
const NAMESPACES = ['settings', 'support', 'topology', 'tools'];
const NAMESPACES = ['settings', 'support', 'topology', 'tools', 'led'];
const LAZY_LANGUAGES = ['ru', 'de', 'fr'];
const source = (path) => readFileSync(new URL(`../src/i18n/${path}`, import.meta.url), 'utf8');
const json = (path) => JSON.parse(source(path));
@@ -60,18 +60,21 @@ test('#627 рантайм пространства: en готов сразу, ru
support: await import('../test-build/i18n/support.js'),
topology: await import('../test-build/i18n/topology.js'),
tools: await import('../test-build/i18n/tools.js'),
led: await import('../test-build/i18n/led.js'),
};
const runtimes = {
settings: modules.settings.SETTINGS_LANGUAGE_RUNTIME,
support: modules.support.SUPPORT_LANGUAGE_RUNTIME,
topology: modules.topology.TOPOLOGY_LANGUAGE_RUNTIME,
tools: modules.tools.TOOLS_LANGUAGE_RUNTIME,
led: modules.led.LED_LANGUAGE_RUNTIME,
};
const translate = {
settings: modules.settings.settingsT,
support: modules.support.supportT,
topology: modules.topology.topologyT,
tools: modules.tools.toolsT,
led: modules.led.ledT,
};
for (const namespace of NAMESPACES) {
const runtime = runtimes[namespace];
+272
View File
@@ -0,0 +1,272 @@
// #780 stage 4: the Devices-editor LED tool (ТЗ §4, §5) against a structural
// host — clean-click input, chain/undo/Esc order, one transaction per command,
// atomic links, conversion and the guarded LED history. Results are judged,
// not the source text.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createLedStripEditor } from '../test-build/led-strip-editor.js';
globalThis.window ??= { matchMedia: () => ({ matches: false }) };
globalThis.queueMicrotask ??= (fn) => Promise.resolve().then(fn);
function setup({ strips = [], markers = [], devices = [], fail = false } = {}) {
const pushed = [];
const toasts = [];
const rolled = [];
const saves = [];
const dialogs = [];
const space = { id: 's', vb: [0, 0, 1000, 1000], rooms: [], partitions: [], wall_columns: [], wall_segments: [], stairs: [] };
const host = {
hass: { states: {}, language: 'en' }, _config: { language: 'en' },
_serverCfg: { spaces: [{ id: 's', led_strips: strips }, { id: 'other', led_strips: [] }], markers, settings: {} },
_curSpaceCfg: {}, _space: 's', _mode: 'devices', _devices: devices, _suppressClick: false,
_markerDialog: null, _cellCm: 5, _gridPitch: 10, _wallKeyPitch: 10, _spaceWalls: [], _openingsR: [],
_devicePositionHistory: { push: (command) => pushed.push(command) },
_adoption: { beginOptimistic: (base, candidate) => ({ base, candidate }), stageLocalConfig: (config) => { host._serverCfg = config; } },
_saveConfigDebounced: { pending: () => false, cancel: () => {} },
_regSignature: 'x',
_spaceModel: () => space, _floorKey: () => 'k', _screenToVb: (x, y) => [x, y], _openCuts: () => [],
_roomWallOpeningInputs: () => [], _partitionOpeningCuts: () => [],
_rollbackOptimistic: (attempt) => { rolled.push(attempt); host._serverCfg = attempt.base; return true; },
_maybeRebuildDevices: () => {}, _showToast: (message) => toasts.push(message), _errText: (e) => String(e?.message || e),
requestUpdate: () => {},
_editorRuntime: {
_svgPoint: (ev) => [ev.clientX, ev.clientY],
_snap: (p) => p,
_prepareConfigCandidate: (c) => c,
_saveConfigNow: async (attempt) => { saves.push(attempt.candidate); if (fail) throw new Error('offline'); },
_openMarkerDialog: (d) => { dialogs.push(d); host._markerDialog = { devId: d?.id }; },
},
};
const led = createLedStripEditor(host);
return { led, host, pushed, toasts, rolled, saves, dialogs };
}
const ev = (x, y, extra = {}) => ({ clientX: x, clientY: y, pointerId: 1, button: 0, pointerType: 'mouse', detail: 1, shiftKey: false, ...extra });
const click = (led, x, y, extra = {}) => {
led.onDown(ev(x, y, extra));
led.onUp(ev(x, y, extra));
};
const stripsOf = (host, id = 's') => host._serverCfg.spaces.find((space) => space.id === id).led_strips;
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
test('ТЗ §4 п.2, п.6: only a clean click adds a point — drag, second finger, cancel and synthetic clicks do not', () => {
const { led, host } = setup();
led.open();
click(led, 100, 100);
assert.equal(led.chain.points.length, 1);
// A pan: the pointer travels past the slop before release.
led.onDown(ev(200, 100));
led.onMove(ev(260, 100));
led.onUp(ev(260, 100));
assert.equal(led.chain.points.length, 1, 'pan is not a point');
// A pinch: a second finger joins.
led.onDown(ev(300, 100, { pointerId: 1, pointerType: 'touch' }));
led.onDown(ev(320, 100, { pointerId: 2, pointerType: 'touch' }));
led.onUp(ev(300, 100, { pointerId: 1, pointerType: 'touch' }));
led.onUp(ev(320, 100, { pointerId: 2, pointerType: 'touch' }));
assert.equal(led.chain.points.length, 1, 'pinch is not a point');
// pointercancel ends the sequence without a point.
led.onDown(ev(400, 100));
led.onCancel(ev(400, 100));
led.onUp(ev(400, 100));
assert.equal(led.chain.points.length, 1, 'cancel is not a point');
// The synthetic click after navigation is suppressed by the card.
host._suppressClick = true;
click(led, 500, 100);
assert.equal(led.chain.points.length, 1, 'suppressed click is not a point');
host._suppressClick = false;
click(led, 500, 100);
assert.equal(led.chain.points.length, 2);
// A repeat click on the last point writes no zero segment.
click(led, 500, 100);
assert.equal(led.chain.points.length, 2);
});
test('ТЗ §4 п.3, п.9: Ctrl+Z removes the chain point first; Esc finishes, then leaves, then deselects', async () => {
const { led, host, pushed } = setup();
led.open();
click(led, 100, 100);
click(led, 300, 100);
click(led, 300, 300);
assert.equal(led.key({ key: 'z' }, true), true);
assert.equal(led.chain.points.length, 2, 'own point, not someone else’s command');
assert.equal(pushed.length, 0);
assert.equal(led.key({ key: 'Escape' }, false), true);
await settle();
const strips = stripsOf(host);
assert.equal(strips.length, 1, 'Esc finished the correct chain, it did not roll back');
assert.deepEqual(strips[0].points, [[0.1, 0.1], [0.3, 0.1]]);
assert.equal(strips[0].marker, null);
assert.equal(led.picker, strips[0].id, 'the device picker opens after a new strip');
assert.equal(pushed.length, 1);
assert.equal(pushed[0].before.strip, null);
assert.equal(led.key({ key: 'Escape' }, false), true, 'Esc closes the picker first');
assert.equal(led.picker, null);
assert.equal(led.key({ key: 'Escape' }, false), true);
assert.equal(led.sel, null, 'then drops the selection');
assert.equal(led.key({ key: 'z' }, true), false, 'without a chain Ctrl+Z is the history’s');
});
test('ТЗ §4 п.4, п.5: a click on the first point closes a ≥3-vertex chain; <2 distinct points writes nothing', async () => {
const { led, host, saves } = setup();
led.open();
click(led, 100, 100);
click(led, 300, 100);
click(led, 300, 300);
click(led, 102, 101);
await settle();
const strip = stripsOf(host)[0];
assert.deepEqual(strip.points[0], strip.points[strip.points.length - 1], 'last stored point equals the first');
assert.equal(strip.points.length, 4);
led.open();
click(led, 600, 600);
led.close();
await settle();
assert.equal(stripsOf(host).length, 1, 'a one-point chain is dropped');
assert.equal(saves.length, 1);
});
test('ТЗ §4 п.3: leaving the tool finishes an unfinished correct chain without an extra segment', async () => {
const { led, host } = setup();
led.open();
click(led, 100, 100);
led.onMove(ev(400, 400));
click(led, 200, 100);
led.close();
await settle();
assert.deepEqual(stripsOf(host)[0].points, [[0.1, 0.1], [0.2, 0.1]], 'the hover preview is not a point');
});
test('ТЗ §5: binding creates the live marker with the strip space; a taken marker is refused', async () => {
const devices = [
{ id: 'lamp', name: 'Lamp', entities: ['light.lamp'], space: 's', bindingKind: 'device', bindingRef: 'lamp', icon: '' },
{ id: 'fan', name: 'Fan', entities: ['fan.f'], space: 's', bindingKind: 'device', bindingRef: 'fan', icon: '' },
];
const { led, host, toasts } = setup({
devices,
strips: [{ id: 'a', points: [[0, 0], [0.1, 0]], marker: null }, { id: 'b', points: [[0, 0.2], [0.1, 0.2]], marker: 'fan' }],
markers: [{ id: 'fan', binding: 'device:fan', space: 's' }],
});
await led.bind('a', 'lamp');
const strip = stripsOf(host).find((item) => item.id === 'a');
assert.equal(strip.marker, 'lamp');
assert.notEqual(strip.active, false);
assert.deepEqual(host._serverCfg.markers.find((item) => item.id === 'lamp'), { id: 'lamp', binding: 'device:lamp', space: 's' });
await led.bind('a', 'fan');
assert.equal(stripsOf(host).find((item) => item.id === 'a').marker, 'lamp', 'not silently taken from strip b');
assert.equal(toasts.at(-1), 'Already bound to another LED strip');
});
test('ТЗ §5: "Show as icon" keeps the shape hidden; "Show as strip" restores the same record', async () => {
const shape = { id: 'a', points: [[0, 0], [0.1, 0], [0.1, 0.1]], marker: 'lamp' };
const { led, host, pushed } = setup({ strips: [shape], markers: [{ id: 'lamp', binding: 'device:lamp', space: 's' }] });
led.select('a');
await led.setActive('a', false);
assert.deepEqual(stripsOf(host)[0], { ...shape, active: false });
assert.equal(led.sel, null);
await led.setActive('a', true);
assert.deepEqual(stripsOf(host)[0], { ...shape, active: true }, 'points, closure and id kept exactly');
assert.equal(pushed.length, 2, 'each representation change is one command');
});
test('ТЗ §5: drawing for an icon converts it in one write; Cancel converts nothing', async () => {
const devices = [{ id: 'lamp', name: 'Lamp', entities: ['light.lamp'], space: 's', bindingKind: 'device', bindingRef: 'lamp', icon: '' }];
const { led, host, saves } = setup({ devices, markers: [{ id: 'lamp', binding: 'device:lamp' }] });
led.open('lamp');
click(led, 100, 100);
click(led, 300, 100);
await led.finish();
const strip = stripsOf(host)[0];
assert.equal(strip.marker, 'lamp');
assert.equal(strip.active, true);
assert.equal(led.picker, null, 'no second device choice');
assert.equal(host._serverCfg.markers[0].space, 's', 'the empty marker space is written');
assert.equal(saves.length, 1);
led.open('lamp');
click(led, 500, 500);
led.chain = null; led.tool = false; // the tray's Cancel
await settle();
assert.equal(stripsOf(host).length, 1);
});
test('ТЗ §4 п.9: a failed write rolls back and records nothing; a no-op is no command', async () => {
const { led, host, pushed, rolled, toasts } = setup({ fail: true });
led.open();
click(led, 100, 100);
click(led, 300, 100);
await led.finish();
assert.equal(rolled.length, 1);
assert.equal(stripsOf(host).length, 0);
assert.equal(pushed.length, 0);
assert.match(toasts.at(-1), /offline/);
const ok = setup({ strips: [{ id: 'a', points: [[0, 0], [0.1, 0]], marker: null }] });
await ok.led.bind('a', null);
assert.equal(ok.saves.length, 0);
assert.equal(ok.pushed.length, 0);
});
test('ТЗ §4 п.9: history restores only its own strip record and never a newer foreign change', async () => {
const { led, host } = setup({ strips: [{ id: 'a', points: [[0, 0], [0.1, 0]], marker: null }] });
const before = { kind: 'led', spaceId: 's', stripId: 'a', strip: { id: 'a', points: [[0, 0], [0.1, 0]], marker: null } };
const after = { kind: 'led', spaceId: 's', stripId: 'a', strip: { id: 'a', points: [[0, 0], [0.2, 0]], marker: null } };
stripsOf(host)[0].points = [[0, 0], [0.2, 0]];
assert.equal(await led.applyHistory(before, after), 'ok');
assert.deepEqual(stripsOf(host)[0].points, [[0, 0], [0.1, 0]]);
// Someone else changed the strip meanwhile: the redo is refused.
stripsOf(host)[0].points = [[0, 0], [0.5, 0]];
assert.equal(await led.applyHistory(after, before), 'stale');
assert.deepEqual(stripsOf(host)[0].points, [[0, 0], [0.5, 0]]);
});
test('ТЗ §5: a rebinding renames the link, deletion unbinds, a bound marker cannot change space', () => {
const { led, host, toasts } = setup({ strips: [{ id: 'a', points: [[0, 0], [0.1, 0]], marker: 'old', active: false }] });
const candidate = JSON.parse(JSON.stringify(host._serverCfg));
led.linkMarker(candidate, 'old', 'new');
assert.equal(candidate.spaces[0].led_strips[0].marker, 'new');
led.unlinkMarkers(candidate, new Set(['new']));
assert.deepEqual(candidate.spaces[0].led_strips[0], { id: 'a', points: [[0, 0], [0.1, 0]], marker: null, active: true });
assert.equal(led.markerMoveBlocked('old', 's'), false);
assert.equal(led.markerMoveBlocked('old', 'other'), true);
assert.match(toasts.at(-1), /Unbind the LED strip first/);
assert.equal(led.markerMoveBlocked('free', 'other'), false);
});
test('ТЗ §5: "New device…" binds the pending strip in the dialog’s own write; closing the dialog forgets it', () => {
const { led, host } = setup({ strips: [{ id: 'a', points: [[0, 0], [0.1, 0]], marker: null }] });
led.picker = 'a';
led.newDevice('a');
const candidate = JSON.parse(JSON.stringify(host._serverCfg));
candidate.markers.push({ id: 'v_1', binding: 'virtual' });
led.linkMarker(candidate, undefined, 'v_1');
assert.equal(candidate.spaces[0].led_strips[0].marker, 'v_1');
assert.equal(candidate.markers[0].space, 's');
led.newDevice('a');
host._markerDialog = null;
led.chrome();
const other = JSON.parse(JSON.stringify(host._serverCfg));
led.linkMarker(other, undefined, 'v_2');
assert.equal(other.spaces[0].led_strips[0].marker, null, 'a cancelled "New device…" binds nothing later');
});
test('ТЗ §4 п.9: the card’s LED history branch — stale clears, failure restores the stack position', async () => {
const { ledHistory } = await import('../test-build/led-strip-card.js');
const calls = [];
const card = {
_ledEditor: { applyHistory: async () => card.result },
_devicePositionBusy: false,
_devicePositionHistory: { undo: () => calls.push('undo'), redo: () => calls.push('redo'), clear: () => calls.push('clear') },
_t: (key) => key, _showToast: (message) => calls.push(message), requestUpdate: () => {},
};
const command = { name: 'LED strip', before: { kind: 'led' }, after: { kind: 'led' } };
card.result = 'stale';
await ledHistory(card, 'undo', command);
assert.deepEqual(calls.splice(0), ['clear', 'history.device_stale']);
card.result = 'failed';
await ledHistory(card, 'undo', command);
assert.deepEqual(calls.splice(0), ['redo'], 'the undone command goes back on the stack, no success toast');
card.result = 'ok';
await ledHistory(card, 'redo', command);
assert.deepEqual(calls.splice(0), ['history.redone']);
assert.equal(card._devicePositionBusy, false);
});
+1 -1
View File
@@ -139,7 +139,7 @@ test('#659 marker handlers preserve callouts, baselines and explicit user choice
"? callout({ kind: 'warning', role: 'status', text: t('marker.run_target_gone', { id: d.tapTarget }) })",
'&& (edit ? dirty : true);',
'@click=${() => { void this._saveMarker(); }}',
'if (!edit || !dirty) { forgetMarkerBaseline(this.host); this._closeMarkerDialog(); return; }',
'if (edit && dirty && !await this.host._confirmDanger({',
"${effectiveTapAction === 'run' || effectiveTapAction === 'toggle'",
"markerglowblock ${glowSourceDisabled ? 'hpf-disabled' : ''}",
'valueBadgePosition: position,\n valueBadgeTouched: true,',