mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
- settings.volumetric_view (General settings › Display, third switch) replaces the alpha entry, the header projection toggle and the phone-menu item; one rule for card, sidebar page and kiosk; editors stay Flat; backend accepts only a boolean, support package copies it. - Raised tiles in 2.5D View: no ring, rounded body min(0.275 D, 0.3 h), edge 0.1 D with the lab filters, ×1.12 size also in layout/collision, lift 0.075 D, one floor-shadow layer under all markers (theme × floor table), frames hugging tile and edge (Alert > Focus > Selected > Hover), forced colours without edge and shadow (src/iso-tiles.ts, styles/iso-tiles). - Soft sun wash instead of projected Flat wedges (src/iso-sun.ts): same gates and windows as sun_rays, length from elevation, parallelogram along the sun, tone and streaks by floor lightness, sill line. - Walls take the user's wall colour (top opaque, sides × .77/.68/.60); theme rules for walls/openings/labels removed; furniture uses the Flat stroke rule. - Smokes smoke_iso_tiles/iso_sun/iso_theme_walls/volumetric_setting, 16 new mutants, acceptance scenes STAGE6_ACCEPTANCE_SCENARIOS (golden with their Linux CI baselines), ACCEPTANCE.md side by side. Issue: #649 User-Visible: yes
336 lines
14 KiB
JavaScript
336 lines
14 KiB
JavaScript
// #82: every discrete camera command uses one short, retargetable transition;
|
|
// direct pointer gestures and structural/lifecycle changes remain atomic.
|
|
import { launchColdView, checkAll, finish } from './serve.mjs';
|
|
|
|
const { page, browser } = await launchColdView({ width: 900, height: 720 });
|
|
|
|
const out = await page.evaluate(async () => {
|
|
const card = window.__card;
|
|
const root = () => card.shadowRoot || card.renderRoot;
|
|
const stage = () => root().querySelector('.stage');
|
|
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
|
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
const camera = () => ({ zoom: card._zoom, view: { ...card._view } });
|
|
const close = (a, b, epsilon = 1e-4) => Math.abs(a - b) <= epsilon;
|
|
const sameView = (a, b, epsilon = 1e-4) => close(a.x, b.x, epsilon)
|
|
&& close(a.y, b.y, epsilon) && close(a.w, b.w, epsilon)
|
|
&& close(a.h, b.h, epsilon);
|
|
const settle = async () => {
|
|
const samples = [];
|
|
const started = performance.now();
|
|
do {
|
|
await frame();
|
|
samples.push(camera());
|
|
} while (card._cameraTransition.active && performance.now() - started < 1200);
|
|
await card.updateComplete;
|
|
return samples;
|
|
};
|
|
const directBaseline = (zoom = 1) => {
|
|
const vb = card._baseVb();
|
|
card._applyView(zoom, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
|
|
};
|
|
|
|
card._setMode('view');
|
|
await card.updateComplete;
|
|
directBaseline(1);
|
|
const originalSave = card._saveZoom.bind(card);
|
|
let saveCount = 0;
|
|
card._saveZoom = () => { saveCount++; originalSave(); };
|
|
|
|
// Toolbar button: intermediate pixels exist and exact legacy target wins.
|
|
const buttonExpected = card._cameraTargetAt(
|
|
stage().clientWidth / 2, stage().clientHeight / 2, 1.4,
|
|
).target;
|
|
card._stepZoom(1);
|
|
const buttonActivatesSafeDayCycleOutline = stage().classList.contains(
|
|
'hp-safe-daycycle-outline',
|
|
);
|
|
const buttonSamples = await settle();
|
|
const buttonFinal = camera();
|
|
const buttonSaves = saveCount;
|
|
|
|
// Wheel anchor is calculated from the actually painted frame. A reversal
|
|
// replaces the target and RAF rather than queueing a second transition.
|
|
directBaseline(1);
|
|
saveCount = 0;
|
|
const rect = stage().getBoundingClientRect();
|
|
const anchor = { x: rect.width * 0.31, y: rect.height * 0.67 };
|
|
stage().dispatchEvent(new WheelEvent('wheel', {
|
|
bubbles: true, cancelable: true, deltaY: -100,
|
|
clientX: rect.left + anchor.x, clientY: rect.top + anchor.y,
|
|
}));
|
|
const firstToken = card._cameraTransition.state?.token;
|
|
await frame();
|
|
const presentedCameraBeforeReverse = camera();
|
|
const presentedBeforeReverse = presentedCameraBeforeReverse.zoom;
|
|
const worldAtReverse = {
|
|
x: presentedCameraBeforeReverse.view.x
|
|
+ anchor.x / rect.width * presentedCameraBeforeReverse.view.w,
|
|
y: presentedCameraBeforeReverse.view.y
|
|
+ anchor.y / rect.height * presentedCameraBeforeReverse.view.h,
|
|
};
|
|
stage().dispatchEvent(new WheelEvent('wheel', {
|
|
bubbles: true, cancelable: true, deltaY: 100,
|
|
clientX: rect.left + anchor.x, clientY: rect.top + anchor.y,
|
|
}));
|
|
const secondToken = card._cameraTransition.state?.token;
|
|
const reverseFrom = card._cameraTransition.state?.from.zoom;
|
|
const wheelSamples = await settle();
|
|
const worldAfter = {
|
|
x: card._view.x + anchor.x / rect.width * card._view.w,
|
|
y: card._view.y + anchor.y / rect.height * card._view.h,
|
|
};
|
|
const wheelSaves = saveCount;
|
|
|
|
// A real pointerdown freezes the presented camera: the obsolete target may
|
|
// not settle later under a drag/select gesture.
|
|
directBaseline(1);
|
|
card._stepZoom(1);
|
|
await frame();
|
|
const pointerPresented = camera();
|
|
stage().dispatchEvent(new PointerEvent('pointerdown', {
|
|
bubbles: true, pointerId: 82, pointerType: 'mouse', buttons: 1,
|
|
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
|
|
}));
|
|
await wait(260);
|
|
const pointerFrozen = camera();
|
|
stage().dispatchEvent(new PointerEvent('pointerup', {
|
|
bubbles: true, pointerId: 82, pointerType: 'mouse', buttons: 0,
|
|
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
|
|
}));
|
|
|
|
// Pinch's production primitive is direct and cancels a running tween.
|
|
directBaseline(1);
|
|
card._stepZoom(1);
|
|
await frame();
|
|
card._zoomAt(rect.width / 2, rect.height / 2, 2);
|
|
const pinchDirect = camera();
|
|
await wait(240);
|
|
const pinchAfterDeadline = camera();
|
|
|
|
// Fit/home/double-tap share the same exact fit but retain their observable
|
|
// reason, so their entry points cannot silently regress to a snap.
|
|
const reasons = {};
|
|
for (const reason of ['fit', 'home', 'double-tap']) {
|
|
directBaseline(2);
|
|
if (reason === 'home') card._fitAll('home');
|
|
else if (reason === 'fit') card._fitAll('fit');
|
|
else card._fitAll('double-tap');
|
|
reasons[reason] = {
|
|
liveReason: card._cameraTransition.state?.reason,
|
|
samples: (await settle()).map((sample) => sample.zoom),
|
|
final: card._zoom,
|
|
};
|
|
}
|
|
card._showFar = false;
|
|
card._frame = null;
|
|
directBaseline(2);
|
|
card._fitFar();
|
|
const farFitReason = card._cameraTransition.state?.reason;
|
|
const farFitSamples = await settle();
|
|
const farFitFinal = card._zoom;
|
|
|
|
// Reduced motion before and during a command is authoritative immediately.
|
|
directBaseline(1);
|
|
card._reducedMotion = true;
|
|
card._stepZoom(1);
|
|
const reducedBefore = { active: card._cameraTransition.active, zoom: card._zoom };
|
|
card._reducedMotion = false;
|
|
directBaseline(1);
|
|
card._stepZoom(1);
|
|
await frame();
|
|
card._onMotionChange({ matches: true });
|
|
const reducedDuring = { active: card._cameraTransition.active, zoom: card._zoom };
|
|
card._reducedMotion = false;
|
|
|
|
// Projection owns an atomic structural conversion and cancels the camera.
|
|
directBaseline(1);
|
|
card._stepZoom(1);
|
|
await frame();
|
|
await window.__hpHarnessProjection(card, 'iso');
|
|
await window.__hpEnsureHarnessIsoRuntime(card);
|
|
const projectionCancelled = !card._cameraTransition.active;
|
|
await card.updateComplete;
|
|
await frame();
|
|
await frame();
|
|
const isoExpected = card._cameraTargetAt(
|
|
stage().clientWidth / 2, stage().clientHeight / 2, card._zoom * 1.4,
|
|
).target;
|
|
card._stepZoom(1);
|
|
await settle();
|
|
const isoFinal = camera();
|
|
await window.__hpHarnessProjection(card, 'flat');
|
|
|
|
// Hidden commits the user's target and no old RAF resumes later.
|
|
directBaseline(1);
|
|
card._stepZoom(1);
|
|
card._pageVisibility({ kind: 'hidden' });
|
|
const hiddenCommitted = { active: card._cameraTransition.active, zoom: card._zoom };
|
|
await wait(240);
|
|
const hiddenAfterDeadline = card._zoom;
|
|
|
|
// Exact fit is a no-op: neither a RAF nor persistence write is created.
|
|
card._reducedMotion = true;
|
|
card._resetZoom('fit');
|
|
card._reducedMotion = false;
|
|
saveCount = 0;
|
|
card._resetZoom('fit');
|
|
const fitNoop = { active: card._cameraTransition.active, saves: saveCount };
|
|
card._applyView(8);
|
|
saveCount = 0;
|
|
card._stepZoom(1);
|
|
const maxNoop = { active: card._cameraTransition.active, saves: saveCount };
|
|
card._applyView(1 / 3);
|
|
saveCount = 0;
|
|
card._stepZoom(-1);
|
|
const minNoop = { active: card._cameraTransition.active, saves: saveCount };
|
|
|
|
const glowBefore = {
|
|
spots: root().querySelectorAll('[data-glow-spot]').length,
|
|
blend: root().querySelector('.glow-pools')?.getAttribute('data-blend') || '',
|
|
rooms: root().querySelectorAll('.room').length,
|
|
base: [...card._baseVb()],
|
|
};
|
|
card._stepZoom(1);
|
|
await settle();
|
|
const glowAfter = {
|
|
spots: root().querySelectorAll('[data-glow-spot]').length,
|
|
blend: root().querySelector('.glow-pools')?.getAttribute('data-blend') || '',
|
|
rooms: root().querySelectorAll('.room').length,
|
|
base: [...card._baseVb()],
|
|
};
|
|
|
|
const resources = performance.getEntriesByType('resource')
|
|
.map((entry) => new URL(entry.name).pathname);
|
|
// #396 идёт последним намеренно: проверки выше считают saveCount
|
|
// абсолютным значением, а пользовательская отмена теперь тоже пишет.
|
|
// #396: interrupted zoom, structural cancellation, fast wheel anchor.
|
|
const spaceOf = () => card._space;
|
|
const storedZoom = () => {
|
|
try {
|
|
return (JSON.parse(localStorage.getItem('houseplan_card_zoom_v1') || '{}') || {})[spaceOf()];
|
|
} catch { return undefined; }
|
|
};
|
|
const wheelAt = (x, y, deltaY) => stage().dispatchEvent(new WheelEvent('wheel', {
|
|
clientX: x, clientY: y, deltaY, bubbles: true, cancelable: true, composed: true,
|
|
}));
|
|
const pointerDownAt = (x, y) => stage().dispatchEvent(new PointerEvent('pointerdown', {
|
|
clientX: x, clientY: y, pointerId: 1, isPrimary: true, button: 0,
|
|
bubbles: true, cancelable: true, composed: true,
|
|
}));
|
|
|
|
directBaseline(1);
|
|
await settle();
|
|
const stageRect = () => stage().getBoundingClientRect();
|
|
// (1) wheel, then touch the plan mid-flight: the shown zoom must be stored.
|
|
wheelAt(stageRect().left + 600, stageRect().top + 400, -100);
|
|
await frame();
|
|
await frame();
|
|
const interruptedShown0 = card._zoom;
|
|
pointerDownAt(stageRect().left + 200, stageRect().top + 200);
|
|
await card.updateComplete;
|
|
const interruptedShown = card._zoom;
|
|
const interruptedStored = storedZoom();
|
|
const interruptedFroze = !card._cameraTransition.active
|
|
&& close(interruptedShown, interruptedShown0, 1e-9);
|
|
|
|
// (2) structural cancellation writes nothing.
|
|
directBaseline(1);
|
|
await settle();
|
|
const structuralStoredBefore = storedZoom();
|
|
wheelAt(stageRect().left + 600, stageRect().top + 400, -100);
|
|
await frame();
|
|
await frame();
|
|
const structuralSavesBefore = saveCount;
|
|
card._cancelCameraTransition(false);
|
|
await card.updateComplete;
|
|
const structuralSaves = saveCount - structuralSavesBefore;
|
|
const structuralStored = storedZoom();
|
|
|
|
// (3) six notches, one animation frame apart: the anchor must stay put.
|
|
directBaseline(1);
|
|
await settle();
|
|
const anchorScreen = { x: stageRect().left + 600, y: stageRect().top + 400 };
|
|
const worldUnder = () => {
|
|
const box = card._view;
|
|
const box2 = { x: box.x, y: box.y, w: box.w, h: box.h };
|
|
const r = stageRect();
|
|
return {
|
|
x: box2.x + ((anchorScreen.x - r.left) / r.width) * box2.w,
|
|
y: box2.y + ((anchorScreen.y - r.top) / r.height) * box2.h,
|
|
};
|
|
};
|
|
const anchorBefore = worldUnder();
|
|
for (let i = 0; i < 6; i++) {
|
|
wheelAt(anchorScreen.x, anchorScreen.y, -100);
|
|
await frame();
|
|
}
|
|
await settle();
|
|
const anchorAfter = worldUnder();
|
|
const anchorDriftUnits = Math.hypot(anchorAfter.x - anchorBefore.x,
|
|
anchorAfter.y - anchorBefore.y);
|
|
const anchorDriftPx = anchorDriftUnits * (stageRect().width / card._view.w);
|
|
directBaseline(1);
|
|
await settle();
|
|
|
|
return {
|
|
programmaticCameraActivatesSafeDayCycleOutline: buttonActivatesSafeDayCycleOutline,
|
|
buttonHasIntermediateFrame: buttonSamples.some((sample) =>
|
|
sample.zoom > 1.001 && sample.zoom < buttonExpected.zoom - 0.001),
|
|
buttonSettlesAtExactTarget: close(buttonFinal.zoom, buttonExpected.zoom)
|
|
&& sameView(buttonFinal.view, buttonExpected.viewBox),
|
|
buttonPersistsOnce: buttonSaves === 1,
|
|
wheelRetargetsRunningTween: Number.isFinite(firstToken) && Number.isFinite(secondToken)
|
|
&& firstToken !== secondToken && close(reverseFrom, presentedBeforeReverse, 0.02),
|
|
wheelHasIntermediateFrames: wheelSamples.some((sample) => sample.zoom > 1.001),
|
|
wheelReversalKeepsAnchor: Math.hypot(
|
|
(worldAfter.x - worldAtReverse.x) / card._view.w * rect.width,
|
|
(worldAfter.y - worldAtReverse.y) / card._view.h * rect.height,
|
|
) <= 0.5,
|
|
wheelStreamPersistsOnce: wheelSaves === 1,
|
|
pointerdownFreezesPresentedFrame: !card._cameraTransition.active
|
|
&& close(pointerFrozen.zoom, pointerPresented.zoom, 0.001)
|
|
&& sameView(pointerFrozen.view, pointerPresented.view, 0.001),
|
|
pinchIsDirectWithoutPostAnimation: close(pinchDirect.zoom, 2)
|
|
&& !card._cameraTransition.active
|
|
&& close(pinchAfterDeadline.zoom, 2)
|
|
&& sameView(pinchAfterDeadline.view, pinchDirect.view),
|
|
fitHomeAndDoubleTapAnimate: Object.entries(reasons).every(([reason, value]) =>
|
|
value.liveReason === reason
|
|
&& value.samples.some((zoom) => zoom > 1.001 && zoom < 1.999)
|
|
&& close(value.final, 1)),
|
|
farFitUsesTheSameTransition: farFitReason === 'fit'
|
|
&& farFitSamples.some((sample) => sample.zoom > 1.001 && sample.zoom < 1.999)
|
|
&& close(farFitFinal, 1),
|
|
reducedMotionIsImmediate: !reducedBefore.active && close(reducedBefore.zoom, 1.4)
|
|
&& !reducedDuring.active && close(reducedDuring.zoom, 1.4),
|
|
projectionCancelsAndIsoSettlesExactly: projectionCancelled
|
|
&& close(isoFinal.zoom, isoExpected.zoom)
|
|
&& sameView(isoFinal.view, isoExpected.viewBox),
|
|
hiddenCommitsTargetOnce: !hiddenCommitted.active && close(hiddenCommitted.zoom, 1.4)
|
|
&& close(hiddenAfterDeadline, hiddenCommitted.zoom),
|
|
exactLimitsAndFitAreNoops: !fitNoop.active && fitNoop.saves === 0
|
|
&& !maxNoop.active && maxNoop.saves === 0
|
|
&& !minNoop.active && minNoop.saves === 0,
|
|
glowAndStructuralFrameStayStable: JSON.stringify(glowAfter) === JSON.stringify(glowBefore),
|
|
coldViewDoesNotLoadEditorRuntime: resources.every((path) =>
|
|
!/houseplan-editor-runtime-/.test(path)),
|
|
// #396 AC1: touching the plan freezes the animated frame — and the frozen
|
|
// frame is what the user sees, so it must be what gets persisted.
|
|
userCancelPersistsTheShownZoom: interruptedShown > 1.001
|
|
&& close(interruptedStored, interruptedShown, 1e-6),
|
|
// #396 AC2: a structural cancellation writes nothing.
|
|
structuralCancelPersistsNothing: structuralSaves === 0
|
|
&& close(structuralStored, structuralStoredBefore, 1e-9),
|
|
// #396 AC3: the anchor under the pointer survives a fast wheel series.
|
|
fastWheelKeepsTheAnchor: anchorDriftPx < 0.5,
|
|
diagnostics: { projectionCancelled, isoExpected, isoFinal, glowBefore, glowAfter,
|
|
interruptedShown, interruptedStored, interruptedFroze, structuralSaves,
|
|
structuralStored, structuralStoredBefore, anchorDriftPx },
|
|
};
|
|
});
|
|
|
|
const { diagnostics, ...checks } = out;
|
|
checkAll(checks);
|
|
await finish(browser, out);
|