Compare commits

..
Author SHA1 Message Date
Claude 18c9f8e77c fix(stairs): View shows the link pointer over a stair, not the move cursor (#693)
The Plan editor rule `.hp-stair.input-enabled .hp-stair-hit { cursor: move }`
also matched the View layer, which sets input-enabled only to receive
clicks. The hit area sits over the outline, so the link's pointer on the
group was never visible and every stair in View showed a drag it cannot do.

- src/stairs-view.ts: View stairs carry `hp-stair-view`.
- plan.styles.ts: `move` applies only without it; in View the hit area
  keeps the group's cursor — pointer on a link, the stage's otherwise.
- demo/smoke_stairs.mjs: computed cursors in View (link, no target) and
  in the Plan editor; the two View checks are red on the old code.
- test/stairs.test.mjs: the cascade without Chromium; mutant
  view-stair-cursor-move-again.
- docs/STAIRS.md, changelogs.

Issue: #693
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-29 21:52:52 +03:00
8 changed files with 78 additions and 3 deletions
+8
View File
@@ -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)
+4
View File
@@ -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,
+4
View File
@@ -8,6 +8,10 @@
## Не выпущено
- В режиме View над лестницей-ссылкой теперь курсор-указатель вместо курсора
перемещения; у лестниц без действующей цели — обычный курсор плана
([#693](https://github.com/Matysh/houseplan-card/issues/693)).
## v1.78.0 — 2026-09-28
- Объёмный 2.5D-вид стал публичной опцией в общих настройках. Положение
+4 -1
View File
@@ -53,7 +53,10 @@ activation a no-op.
In View, hovering a stair with a mouse shows the card tooltip "Go to floor
<title>" 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
+13
View File
@@ -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 '
+1 -1
View File
@@ -86,7 +86,7 @@ export class StairViewRuntime {
|| !stair.target_space_id) return;
this.owner._tabClick(stair.target_space_id);
};
return svg`<g class="hp-stair ${active ? 'navigable' : ''} ${interactive ? 'input-enabled' : ''}"
return svg`<g class="hp-stair hp-stair-view ${active ? 'navigable' : ''} ${interactive ? 'input-enabled' : ''}"
data-hp="stair" data-id=${stair.id} data-kind=${stair.kind}
data-target-state=${targetState}
style=${stairStyleVars(
+4 -1
View File
@@ -1636,6 +1636,9 @@ export const planStyles = css`
.dthandle, .dtknob, dt-* cursors) painted in the top overlay; the draft
being drawn is inert until it is placed */
.hp-stair.draft .hp-stair-hit { pointer-events: none; }
.hp-stair.input-enabled .hp-stair-hit { cursor: move; }
/* #693: the move cursor belongs to the plan editor. The View layer sets
input-enabled only to receive clicks; its hit area keeps the group's
cursor: pointer on a link, the stage's otherwise. */
.hp-stair.input-enabled:not(.hp-stair-view) .hp-stair-hit { cursor: move; }
.alignmsg { margin: 0 0 8px; font-size: 13px; line-height: 1.45; }
`;
+40
View File
@@ -357,3 +357,43 @@ test('#669 AC2 a maximum stair collection keeps the room area and passes a bound
const actual = geometryAreaMinusStairs(room, stairs);
assert.ok(Math.abs(actual - expected) <= Math.max(expected, 1) * 1e-9, `${actual} vs ${expected}`);
});
// #693: в View над лестницей был курсор `move` — редакторское правило для
// `.hp-stair-hit` задевало и слой View, который ставит `input-enabled` только
// ради попадания. Браузерное доказательство — demo/smoke_stairs.mjs; здесь
// каскад закреплён без Chromium.
test('#693 курсор move над телом лестницы — только в редакторе плана', async () => {
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, /<g class="hp-stair hp-stair-view /, 'слой View помечает свои лестницы');
});