diff --git a/demo/smoke_stairs.mjs b/demo/smoke_stairs.mjs
index 9f618607..9d4596fa 100644
--- a/demo/smoke_stairs.mjs
+++ b/demo/smoke_stairs.mjs
@@ -340,6 +340,9 @@ const out = await page.evaluate(async () => {
await hp.setTool('select');
await selectStair(straight.id);
result.frameReturnsUnderSelect = !!frame();
+ // #693: the plan editor keeps `move` over the stair body.
+ result.editorStairBodyCursorIsMove = getComputedStyle(
+ stairNode(straight.id)?.querySelector('.hp-stair-hit') ?? document.body).cursor === 'move';
await hp.setTool('stairs');
// Saving the properties dialog untouched changes nothing (#676 AC5):
@@ -426,6 +429,10 @@ const out = await page.evaluate(async () => {
result.validLinkIsAccessible = linkedNode?.getAttribute('role') === 'link'
&& linkedNode?.getAttribute('data-target-state') === 'active'
&& getComputedStyle(linkedNode).cursor === 'pointer';
+ // #693: in View the hit area on top of the outline shows the link's
+ // pointer, not the editor's `move`.
+ const hitCursor = (node) => getComputedStyle(node?.querySelector('.hp-stair-hit') ?? document.body).cursor;
+ result.viewLinkHitCursorIsPointer = hitCursor(linkedNode) === 'pointer';
// Hover on a link announces the target floor; every other target state
// stays silent (#676 AC8). The mouse pointer type enables hover.
@@ -451,6 +458,7 @@ const out = await page.evaluate(async () => {
};
result.missingTargetHoverIsSilent = await hoverTip(await withTarget(null), null)
&& stairNode(linked.id)?.getAttribute('data-target-state') === 'missing';
+ result.viewStairWithoutTargetHasNoMoveOrPointer = !['move', 'pointer'].includes(hitCursor(stairNode(linked.id)));
result.selfTargetHoverIsSilent = await hoverTip(await withTarget('f1'), null)
&& stairNode(linked.id)?.getAttribute('data-target-state') === 'self';
result.deletedTargetHoverIsSilent = await hoverTip(await withTarget('no-such-space'), null)
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index bdc29262..5ef0f8e6 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased
+- In View, the mouse cursor over a stair link is now a pointer instead of the
+ move cursor; stairs without a valid target keep the plan's ordinary cursor
+ ([#693](https://github.com/Matysh/houseplan-card/issues/693)).
+
## v1.78.0 — 2026-09-28
- Volumetric 2.5D View is now a public General settings option. Device markers,
diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md
index 894b50f8..33ace2df 100755
--- a/docs/CHANGELOG.ru.md
+++ b/docs/CHANGELOG.ru.md
@@ -8,6 +8,10 @@
## Не выпущено
+- В режиме View над лестницей-ссылкой теперь курсор-указатель вместо курсора
+ перемещения; у лестниц без действующей цели — обычный курсор плана
+ ([#693](https://github.com/Matysh/houseplan-card/issues/693)).
+
## v1.78.0 — 2026-09-28
- Объёмный 2.5D-вид стал публичной опцией в общих настройках. Положение
diff --git a/docs/STAIRS.md b/docs/STAIRS.md
index 61c12b78..d8c9b414 100644
--- a/docs/STAIRS.md
+++ b/docs/STAIRS.md
@@ -53,7 +53,10 @@ activation a no-op.
In View, hovering a stair with a mouse shows the card tooltip "Go to floor
" when — and only when — the stair is a valid link (`active` target
-state); a missing, self, deleted or fixed-floor target shows no tooltip.
+state); a missing, self, deleted or fixed-floor target shows no tooltip. The
+mouse cursor follows the same condition: a pointer over a valid link, the
+plan's ordinary cursor over any other stair. The move cursor is the Plan
+editor's (#693).
In an ordinary multi-space card, a clean click/tap or keyboard activation on a
valid stair switches to the target tab and restores that floor's remembered
diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs
index 81aa214f..5c1cb4ec 100644
--- a/scripts/mutation-registry.mjs
+++ b/scripts/mutation-registry.mjs
@@ -6997,6 +6997,19 @@ const MUTANT_DEFINITIONS = [
replace: " if (false && input.type === 'passage') {",
}],
},
+ // #693: курсор move над лестницей — только у редактора плана.
+ {
+ id: 'view-stair-cursor-move-again',
+ guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ + '&& node --test --test-name-pattern="#693" test/stairs.test.mjs',
+ because: '#693: the View layer sets input-enabled only to receive clicks; an unscoped move cursor on '
+ + '.hp-stair-hit hid the link pointer and showed a drag the View cannot do',
+ patches: [{
+ file: 'src/styles/plan.styles.ts',
+ find: ' .hp-stair.input-enabled:not(.hp-stair-view) .hp-stair-hit { cursor: move; }',
+ replace: ' .hp-stair.input-enabled .hp-stair-hit { cursor: move; }',
+ }],
+ },
{
id: 'entity-marker-kept-in-parent-device',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
diff --git a/src/stairs-view.ts b/src/stairs-view.ts
index d7d4dff3..b68363cc 100644
--- a/src/stairs-view.ts
+++ b/src/stairs-view.ts
@@ -86,7 +86,7 @@ export class StairViewRuntime {
|| !stair.target_space_id) return;
this.owner._tabClick(stair.target_space_id);
};
- return svg` {
+ const { planStyles } = await import('../test-build/styles.js');
+ const css = planStyles.cssText.replace(/\/\*[\s\S]*?\*\//g, '');
+ const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)]
+ .map(([, selectors, body]) => [selectors.trim().split(/\s*,\s*/), body]);
+ const cursorOf = (body) => /(?:^|;)\s*cursor\s*:\s*([^;]+)/.exec(body)?.[1].trim() ?? null;
+ // Селектор вида «<составной селектор группы> .hp-stair-hit»; иная форма —
+ // повод расширить тест, а не молча её пропустить.
+ const matchesGroup = (compound, classes) => {
+ const parts = compound.match(/:not\(\.[\w-]+\)|\.[\w-]+|[^.:]+|:[\w-]+/g) ?? [];
+ return parts.every((part) => {
+ if (part.startsWith(':not(.')) return !classes.includes(part.slice(6, -1));
+ if (part.startsWith('.')) return classes.includes(part.slice(1));
+ throw new Error(`#693: неразобранная часть селектора «${part}» в «${compound}»`);
+ });
+ };
+ const hitCursor = (classes) => rules
+ .flatMap(([selectors, body]) => selectors
+ .filter((selector) => /\s\.hp-stair-hit$/.test(selector) && cursorOf(body))
+ .map((selector) => [selector.replace(/\s+\.hp-stair-hit$/, '').trim(), cursorOf(body)]))
+ .filter(([compound]) => {
+ assert.ok(!/\s/.test(compound), `#693: предок с потомком в «${compound}» — расширить тест`);
+ return matchesGroup(compound, classes);
+ })
+ .map(([, cursor]) => cursor);
+ assert.deepEqual(hitCursor(['hp-stair', 'input-enabled']), ['move'], 'редактор плана тащит лестницу за тело');
+ assert.deepEqual(hitCursor(['hp-stair', 'hp-stair-view', 'navigable', 'input-enabled']), [],
+ 'в View у области попадания своего курсора нет — виден pointer ссылки');
+ assert.deepEqual(hitCursor(['hp-stair', 'hp-stair-view', 'input-enabled']), [],
+ 'лестница без цели в View — курсор сцены, не move');
+ assert.ok(rules.some(([selectors, body]) => selectors.includes('.hp-stair.navigable') && cursorOf(body) === 'pointer'),
+ 'ссылка несёт pointer на группе');
+ const view = readFileSync(new URL('../src/stairs-view.ts', import.meta.url), 'utf8');
+ assert.match(view, /