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`<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( diff --git a/src/styles/plan.styles.ts b/src/styles/plan.styles.ts index f2377afb..66c5cfd3 100644 --- a/src/styles/plan.styles.ts +++ b/src/styles/plan.styles.ts @@ -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; } `; diff --git a/test/stairs.test.mjs b/test/stairs.test.mjs index 0b5c4d60..57b06a42 100644 --- a/test/stairs.test.mjs +++ b/test/stairs.test.mjs @@ -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 помечает свои лестницы'); +});