Dock the HUD: every tool and readout on one rail along the bottom
The overlay had grown by accretion: a search box floating top-centre, a readout panel bottom-left, a range readout bottom-right, and nothing that said these were parts of one instrument. This puts them on one rail across the bottom of the viewport — the dock — with a tab strip pinned to the bottom edge and whichever panel is open growing upward from it. The top of the screen keeps only the scale ladder and the nameplate, so the map itself is what fills the frame. Three tabs. SEARCH is the old search, with its field pinned to the bottom of the panel and the results growing upward above it, so the thing being typed into never moves while the list grows. READOUT is the old bottom-left panel. DISPLAY is new: five layer toggles — labels, orbits, grid, deep sky, sky — each a real scene object switched by visibility, except the ones the galaxy crossfade already rewrites every frame, whose toggles fold into that crossfade instead of fighting it. The range readout sits on the strip itself, so it is readable whatever is open. Behaviour worth stating: choosing a search result hands the panel straight back to the readout, since the thing to look at is now the scene. `/` opens the search from anywhere. Below `sm` the dock is the strip alone; a tap opens a panel as a sheet, a tap on the scene folds it away. The body-detail page gets the same dock with only the search — the info panel is its reading. Two things found on the way. CSS2DRenderer gives every label its own z-index for depth order, and the label host created no stacking context, so labels painted over every HUD panel; `isolate` on the host keeps them under. And starmap-hud's readout tests were really tests of the panel that moved, so they moved with it. Verified: build clean, 535/535 unit, 6/6 end-to-end, design detector clean, screenshots at 1440×900 and 390×844 across galaxy, galactic, system, body-detail, all three tabs and the layers-off state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||
import { DEFAULT_HUD_DISPLAY, HudDisplay, HudDockComponent } from './hud-dock.component';
|
||||
|
||||
class EmptyDataLoaderService {
|
||||
loadStars() {
|
||||
return Promise.resolve({ stars: [], positions: new Float32Array(0) });
|
||||
}
|
||||
loadBodies() {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
loadExoplanets() {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
}
|
||||
|
||||
describe('HudDockComponent', () => {
|
||||
let fixture: ComponentFixture<HudDockComponent>;
|
||||
|
||||
function host(): HTMLElement {
|
||||
return fixture.nativeElement as HTMLElement;
|
||||
}
|
||||
|
||||
function tabNames(): string[] {
|
||||
return [...host().querySelectorAll('[role="tab"]')].map((tab) => tab.textContent?.trim() ?? '');
|
||||
}
|
||||
|
||||
function tab(name: string): HTMLButtonElement {
|
||||
const found = [...host().querySelectorAll<HTMLButtonElement>('[role="tab"]')].find((t) => t.textContent?.trim() === name);
|
||||
if (!found) {
|
||||
throw new Error(`no "${name}" tab`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function setReadout(): void {
|
||||
fixture.componentRef.setInput('eyebrow', 'Galactic Scale');
|
||||
fixture.componentRef.setInput('title', 'Milky Way');
|
||||
fixture.componentRef.setInput('subtitle', 'Barred spiral');
|
||||
fixture.componentRef.setInput('readouts', [{ label: 'Arms', value: '5' }, { label: 'Luminosity', value: '1 L☉', derived: true }]);
|
||||
fixture.componentRef.setInput('note', 'Illustrative model.');
|
||||
fixture.componentRef.setInput('range', '21.5 kpc');
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HudDockComponent],
|
||||
providers: [
|
||||
{ provide: DataLoaderService, useClass: EmptyDataLoaderService },
|
||||
{ provide: Router, useValue: { navigate: vi.fn().mockResolvedValue(true) } }
|
||||
]
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(HudDockComponent);
|
||||
});
|
||||
|
||||
it('offers only the search when it has nothing to read out and no layers', () => {
|
||||
fixture.detectChanges();
|
||||
expect(tabNames()).toEqual(['Search']);
|
||||
expect(host().querySelector('[role="tabpanel"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('grows a tab per thing it has been given', () => {
|
||||
setReadout();
|
||||
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
|
||||
fixture.detectChanges();
|
||||
expect(tabNames()).toEqual(['Search', 'Readout', 'Display']);
|
||||
});
|
||||
|
||||
it('opens the default tab on mount and renders the readout from its inputs', () => {
|
||||
setReadout();
|
||||
fixture.componentRef.setInput('defaultTab', 'readout');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(tab('Readout').getAttribute('aria-selected')).toBe('true');
|
||||
const text = host().textContent ?? '';
|
||||
for (const expected of ['Galactic Scale', 'Milky Way', 'Barred spiral', 'Arms', '5', 'Illustrative model.', 'Derived, not catalogued', '21.5 kpc']) {
|
||||
expect(text).toContain(expected);
|
||||
}
|
||||
expect(host().querySelector('[data-testid="hud-title"]')?.textContent?.trim()).toBe('Milky Way');
|
||||
});
|
||||
|
||||
it('leaves out the optional lines it was given nothing for', () => {
|
||||
fixture.componentRef.setInput('title', 'Local Stars');
|
||||
fixture.componentRef.setInput('defaultTab', 'readout');
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('dl')).toBeNull();
|
||||
expect(host().textContent).not.toContain('undefined');
|
||||
});
|
||||
|
||||
it('keeps the range on the strip whichever panel is open, and hides it when there is none', () => {
|
||||
setReadout();
|
||||
fixture.componentRef.setInput('defaultTab', 'search');
|
||||
fixture.detectChanges();
|
||||
expect(host().textContent).toContain('21.5 kpc');
|
||||
|
||||
fixture.componentRef.setInput('range', '');
|
||||
fixture.detectChanges();
|
||||
expect(host().textContent).not.toContain('Range');
|
||||
});
|
||||
|
||||
it('toggles a tab closed when it is clicked while open', () => {
|
||||
setReadout();
|
||||
fixture.componentRef.setInput('defaultTab', 'readout');
|
||||
fixture.detectChanges();
|
||||
|
||||
tab('Readout').click();
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('[role="tabpanel"]')).toBeNull();
|
||||
expect(tab('Readout').getAttribute('aria-selected')).toBe('false');
|
||||
|
||||
tab('Search').click();
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('input[type="text"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('emits the flipped layer and nothing else', () => {
|
||||
fixture.componentRef.setInput('display', DEFAULT_HUD_DISPLAY);
|
||||
fixture.componentRef.setInput('defaultTab', 'display');
|
||||
fixture.detectChanges();
|
||||
const emitted: HudDisplay[] = [];
|
||||
fixture.componentInstance.displayChange.subscribe((display) => emitted.push(display));
|
||||
|
||||
const orbits = [...host().querySelectorAll<HTMLButtonElement>('[aria-pressed]')].find((b) => b.textContent?.includes('Orbits'));
|
||||
orbits?.click();
|
||||
|
||||
expect(emitted).toEqual([{ ...DEFAULT_HUD_DISPLAY, orbits: false }]);
|
||||
});
|
||||
|
||||
it('reflects the layer state it is given as pressed buttons', () => {
|
||||
fixture.componentRef.setInput('display', { ...DEFAULT_HUD_DISPLAY, grid: false });
|
||||
fixture.componentRef.setInput('defaultTab', 'display');
|
||||
fixture.detectChanges();
|
||||
const pressed = [...host().querySelectorAll('[aria-pressed]')].map((b) => `${b.textContent?.trim()}=${b.getAttribute('aria-pressed')}`);
|
||||
expect(pressed).toEqual(['Labels=true', 'Orbits=true', 'Grid=false', 'Deep sky=true', 'Sky=true']);
|
||||
});
|
||||
|
||||
it('opens the search on "/" from anywhere but a text field', () => {
|
||||
setReadout();
|
||||
fixture.componentRef.setInput('defaultTab', 'readout');
|
||||
fixture.detectChanges();
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true }));
|
||||
fixture.detectChanges();
|
||||
expect(tab('Search').getAttribute('aria-selected')).toBe('true');
|
||||
});
|
||||
|
||||
it('hands the panel back to the readout once a result is picked', () => {
|
||||
setReadout();
|
||||
fixture.componentRef.setInput('defaultTab', 'search');
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentInstance.onPicked();
|
||||
fixture.detectChanges();
|
||||
expect(tab('Readout').getAttribute('aria-selected')).toBe('true');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user