feat: sign interlude, window plate and a two-button control bay

The sign closes in over the road on a gantry truss, arcs into a wider
window and hands off to a flat 2D plate that mirrors its face; content
steps aside for the interlude and rises after. The bay keeps sound and
settings; volume and Pause motion move into the settings popover.
This commit is contained in:
2026-10-10 23:30:58 +03:00
parent 5757dd968f
commit bacc035c43
12 changed files with 823 additions and 287 deletions

View File

@@ -289,12 +289,13 @@ describe("DriveControls", () => {
});
expect(wrapper.get('[aria-label="Mute sound"]')).toBeTruthy();
expect(wrapper.get('[aria-label="Sound volume"]')).toBeTruthy();
expect(wrapper.get('[aria-label="Pause motion"]')).toBeTruthy();
const settings = wrapper.get('[aria-label="Drive settings"]');
await settings.trigger("click");
// Volume and Pause motion live in the settings panel (two-button bay).
const panel = wrapper.get('[role="dialog"][aria-label="Drive settings"]');
expect(panel.get('[aria-label="Sound volume level"]')).toBeTruthy();
expect(panel.get('[aria-label="Pause motion"]')).toBeTruthy();
expect(panel.text()).not.toContain("Motion: active");
expect(panel.find('[role="status"]').exists()).toBe(false);
expect(panel.get("select").attributes("aria-label")).toBe(

View File

@@ -98,21 +98,26 @@ describe('composed route signs', () => {
const allocation = h.resources.snapshot();
h.render(null);
expect(h.sign.group.visible).toBe(false);
// Between signs the face holds the resting chapter's label.
const resting = face.userData.textureUpdateCount;
h.render({ ...profile, phase: 'anticipate', progress: 0, opacity: 0 });
expect(h.sign.group.visible).toBe(false);
expect(face.userData.textureUpdateCount).toBe(1);
expect(face.userData.textureUpdateCount).toBe(resting + 1);
h.render({ ...profile, phase: 'approach', progress: 0.5, opacity: 0.7 });
expect(h.sign.group.visible).toBe(true);
expect(face.material.opacity).toBeGreaterThan(0);
expect(face.material.opacity).toBeLessThanOrEqual(0.7);
expect(face.material.opacity).toBeLessThanOrEqual(1);
// Docked, the 3D face hands off to the window's flat plate in 200 ms.
h.render(profile, 120);
expect(face.material.opacity).toBe(1);
expect(face.userData.textureUpdateCount).toBe(1);
expect(face.material.opacity).toBe(0);
expect(h.sign.group.visible).toBe(false);
expect(face.userData.textureUpdateCount).toBe(resting + 1);
expect(h.resources.snapshot()).toEqual(allocation);
h.render(null);
expect(h.sign.group.visible).toBe(false);
const between = face.userData.textureUpdateCount;
h.render({ ...profile, targetId: 'selected-builds', phase: 'anticipate', progress: 0, opacity: 0 });
expect(face.userData.textureUpdateCount).toBe(2);
expect(face.userData.textureUpdateCount).toBe(between + 1);
expect(routeLabelFor('selected-builds')).toBe('Selected builds');
expect(h.resources.snapshot()).toEqual(allocation);
h.resources.dispose();
@@ -155,46 +160,57 @@ describe('composed route signs', () => {
expectDocked(settled, h.dock);
});
it('lifts off the horizon and grows into the window while approaching', () => {
it('closes in over the road in the upper third, then arcs up into the window', () => {
const h = setup();
h.render({ ...profile, phase: 'approach', progress: 0, opacity: 1 }, 30);
const early = h.render({ ...profile, phase: 'approach', progress: 0.2, opacity: 1 })!.bounds;
const late = h.render({ ...profile, phase: 'approach', progress: 0.7, opacity: 1 })!.bounds;
const big = h.render({ ...profile, phase: 'approach', progress: 0.69, opacity: 1 })!.bounds;
const arcing = h.render({ ...profile, phase: 'approach', progress: 0.85, opacity: 1 })!.bounds;
const docked = h.render(profile)!.bounds;
expect(h.sign.dockState()).toBe('docked');
expect(early.width).toBeLessThan(late.width);
expect(late.width).toBeLessThan(docked.width + 1);
expect(early.top).toBeGreaterThan(late.top);
expect(late.top).toBeGreaterThan(docked.top - 1);
expect(early.top).toBeGreaterThan(900 * 0.3);
// Grows down the road to about 600 px, centred in the upper third.
expect(early.width).toBeLessThan(big.width);
expect(big.width).toBeGreaterThan(560);
expect(big.width).toBeLessThan(640);
expect((big.top + big.bottom) / 2).toBeGreaterThan(900 * 0.2);
expect((big.top + big.bottom) / 2).toBeLessThan(900 * 0.4);
// Then shrinks and rises into the window, landing within 2 px.
expect(arcing.width).toBeLessThan(big.width);
expect(arcing.top).toBeLessThan(big.top);
expectDocked(docked, h.dock);
});
it('passes overhead and out of the top of the frame as it departs', () => {
it('leaves the window to the flat plate as it departs', () => {
const h = setup();
const docked = h.render(profile, 120)!.bounds;
const passing = h.render({ ...profile, phase: 'depart', progress: 0.5, opacity: 0.6 })!.bounds;
h.render(profile, 120);
// The flat plate holds the window; the 3D face stays hidden as it departs.
h.render({ ...profile, phase: 'depart', progress: 0.5, opacity: 0.6 });
expect(h.sign.dockState()).toBe('passing');
expect(passing.top).toBeLessThan(docked.top);
expect(passing.width).toBeGreaterThan(docked.width);
const gone = h.render({ ...profile, phase: 'depart', progress: 1, opacity: 0.05 })!.bounds;
expect(gone.bottom).toBeLessThan(docked.bottom);
expect(h.sign.group.visible).toBe(false);
h.render(null);
expect(h.sign.group.visible).toBe(false);
expect(h.sign.dockState()).toBe('idle');
});
it('turns toward the pointer only while docked, and settles back', () => {
it('turns toward the pointer only while docked and hovered, and settles back', () => {
const h = setup();
h.render(profile, 120);
const group = h.sign.group as unknown as { quaternion: { clone(): { angleTo(q: unknown): number }; angleTo(q: unknown): number } };
const rest = group.quaternion.clone();
// Without hover the plate stays flat: no tilt.
h.setTilt({ x: 1, y: -1 });
h.render(profile, 60);
expect(group.quaternion.angleTo(rest)).toBeGreaterThan(0.2);
expect(group.quaternion.angleTo(rest)).toBeLessThan(0.01);
// Hovered, the 3D twin appears and leans at most about 4 degrees.
h.setTilt({ x: 1, y: -1, hover: true });
h.render(profile, 60);
expect(h.sign.group.visible).toBe(true);
expect(group.quaternion.angleTo(rest)).toBeGreaterThan(0.05);
expect(group.quaternion.angleTo(rest)).toBeLessThan(0.11);
h.setTilt({ x: 0, y: 0 });
h.render(profile, 120);
expect(group.quaternion.angleTo(rest)).toBeLessThan(0.01);
h.setTilt({ x: 1, y: 1 });
h.setTilt({ x: 1, y: 1, hover: true });
h.render(null);
h.render({ ...profile, phase: 'approach', progress: 0.5, opacity: 1 }, 60);
const approaching = group.quaternion.clone();