Files
houseplan-card/src/space-order.ts
T
Codex a8aeecc32c fix: judge order dependence by the area in force, close the stuck drag
Review CODE-REVIEW-220-r1.

H1: markersNeedingPlacement decided who depends on the order by reading
marker.area alone, while resolveExplicitMarkerPlacement reads
`marker.area || <area of the HA device>`. The ordinary marker — bind an HA
device, store neither field — is anchored by the registry and never depended
on the order, yet it was being written a space it never asked for. Dormant
today, and the day that HA area changes it moves the marker to whatever space
used to be first. The resolver now asks for the area actually in force.

M1: a mouse released past the panel left the gesture stuck, swallowing the
next click. Pointer capture is the usual answer and is now taken, but it is
not a guarantee — the browser grants it only for a live pointer. The window
listener is what actually closes the gesture.

M2: the fifth mutant from the spec is registered, plus a sixth for the stuck
drag above.

Writing the smoke for M1 turned up why the first attempt passed against
broken code: synthetic PointerEvents default to composed:false and never
leave the shadow root, so nothing outside the panel could ever hear them.
Real pointer events are composed; the smoke now says so.

Issue: #220
User-Visible: no
2026-08-21 00:50:15 +03:00

132 lines
5.2 KiB
TypeScript

/**
* Reordering the space tabs.
*
* The order of `config.spaces` is not decoration: it feeds the marker
* placement fallback (`firstSpaceId`), the swipe neighbour and the positional
* `floor` of a fixed-floor card. Moving a tab must therefore move nothing else
* — see docs/specs/220-space-tab-reorder.md §8.
*
* Everything here is pure so the rules can be tested without a browser.
*/
/** How far the pointer must travel before a click becomes a drag. */
export const TAB_DRAG_THRESHOLD_PX = 4;
export interface TabDragContext {
/** The card allows editing at all. */
canEdit: boolean;
/** A wall panel never reorders anything. */
kiosk: boolean;
/** Current mode; reordering lives in the editors only (owner, 2026-08-20). */
mode: 'view' | 'plan' | 'devices' | 'decor';
/** Pointer that started the gesture. */
pointerType: string;
/** How many spaces the panel shows right now. */
spaceCount: number;
/** A card pinned to one floor shows a single tab and must not reorder. */
fixedFloor: boolean;
}
/**
* Whether a pointerdown on a tab may begin a reorder.
*
* Touch is deliberately excluded rather than degraded: the tabs live in View
* as well, where switching spaces is a fully supported touch interaction, and
* any gesture on a tab would compete with the tap that switches. The product
* decision is recorded as `Touch editor: not exposed`.
*/
export function canStartTabDrag(ctx: TabDragContext): boolean {
if (!ctx.canEdit || ctx.kiosk || ctx.fixedFloor) return false;
if (ctx.mode === 'view') return false;
if (ctx.pointerType !== 'mouse') return false;
return ctx.spaceCount > 1;
}
/** Has the pointer moved far enough to mean "drag" rather than "click"? */
export function passedDragThreshold(dx: number, dy: number): boolean {
return Math.hypot(dx, dy) >= TAB_DRAG_THRESHOLD_PX;
}
/**
* Move `movedId` so that it sits where `targetId` is, keeping every other id
* in its relative order. Returns the same array instance when nothing moves,
* so a caller can skip the write without comparing element by element.
*/
export function reorderSpaceIds(
ids: readonly string[], movedId: string, targetId: string,
): string[] {
const from = ids.indexOf(movedId);
const to = ids.indexOf(targetId);
if (from < 0 || to < 0 || from === to) return ids as string[];
const next = ids.slice();
next.splice(from, 1);
next.splice(to, 0, movedId);
return next;
}
/** Reorder the stored spaces to match `order`; ids missing from it keep their tail. */
export function applySpaceOrder<T extends { id?: unknown }>(
spaces: readonly T[], order: readonly string[],
): T[] {
const rank = new Map(order.map((id, index) => [id, index]));
// A stable sort keeps unknown ids (there should be none) in their old order
// instead of shuffling them by an accidental comparison result.
return spaces
.map((space, index) => ({ space, index }))
.sort((a, b) => {
const ra = rank.get(String(a.space?.id)) ?? Number.MAX_SAFE_INTEGER;
const rb = rank.get(String(b.space?.id)) ?? Number.MAX_SAFE_INTEGER;
return ra - rb || a.index - b.index;
})
.map((entry) => entry.space);
}
export interface PlacementMarker {
id?: unknown;
space?: unknown;
area?: unknown;
removed?: unknown;
}
/**
* Markers whose space is decided by the "first space" fallback, and where that
* fallback currently lands.
*
* Such a marker has neither an explicit `space` nor an area that names a
* space. Today it renders in whichever space happens to sit first; after a
* reorder that would be a different one — the marker would move on its own,
* which is the one thing a reorder may never do. Writing the answer it has
* right now makes the placement explicit and independent of order for good.
*
* **The area is not only the marker's own field.** `resolveExplicitMarkerPlacement`
* (`devices.ts`) reads `marker.area || <area of the HA device or entity>`, so a
* marker that simply binds an existing HA device — the ordinary case, saved
* without `area` or `space` — is anchored by the registry and never depended on
* the order at all. Judging by `marker.area` alone would classify it as
* order-dependent and write it a `space` it never asked for: a field that is
* dormant today and moves the marker the day its HA area changes. Hence
* `effectiveArea`, which answers with the area actually in force (review
* CODE-REVIEW-220-r1, H1).
*/
export function markersNeedingPlacement(
markers: readonly PlacementMarker[],
areaToSpace: Readonly<Record<string, string>>,
firstSpaceId: string,
effectiveArea: (markerId: string) => string = () => '',
): { id: string; space: string }[] {
if (!firstSpaceId) return [];
const out: { id: string; space: string }[] = [];
for (const marker of markers) {
if (!marker || marker.removed === true) continue;
const id = typeof marker.id === 'string' ? marker.id : '';
if (!id) continue;
const explicit = typeof marker.space === 'string' ? marker.space : '';
if (explicit) continue;
const own = typeof marker.area === 'string' ? marker.area : '';
const area = own || effectiveArea(id) || '';
if (area && areaToSpace[area]) continue;
out.push({ id, space: firstSpaceId });
}
return out;
}