Name the neighbours, from inside the system

A system view could say everything about the star it was inside and nothing
about where that star was. The four nearest catalogue stars are now named
around the edge of it, each with its distance, each a button that flies there
— so a chain of neighbours can be walked without pulling back out to the field
between hops.

These are bearings, not sky positions, and that is the one deliberate
compromise here. A true direction was tried first and does not work: at this
field of view the visible cone is about 30 degrees, so on average one
neighbour in fifteen falls inside the frame — measured, not guessed, at one
label of four in Sol and none at all after a small orbit. What survives the
ring is the half of the direction a viewer can act on, which way to turn to
face it, and the ring reads as instrument rather than as scene because it sits
at a fixed radius. Real distance was never an option: Proxima is 268 000 AU
from Sol, thirteen far planes out, so the distance goes on the type line.

Proximity is answered by a new pure module rather than by a scan. A uniform
grid over the catalogue answers both "the k nearest to this star" and "every
star within n parsecs", the second being what the jump-link graph in the next
PR is built from — one scan per node, and the quadratic would show. Its spec
pins the grid against a brute-force sweep of a pseudo-random cloud, because a
spatial index is an optimisation and never a different answer.

Where the ring meets the HUD, the HUD wins: placement is given the boxes the
readout, the strip and the object card occupy, and slides a name along the
ring until it clears them, or drops it rather than print it half hidden. That
rule is a pure function with its own spec.

Four defects found while verifying this, three of them older than it:

The dock's flex column was pointer-events-auto and as wide as its strip, so
an invisible band above the strip swallowed every click in it — including,
but not only, a neighbour's. The column is transparent now and each surface
opts back in.

The ring was sized against the frame's height alone, which on a phone held
upright put it a viewport and a half wide: no neighbour was reachable on any
portrait screen. It is sized against the shorter side.

Picking a search result reopened the readout, which on a narrow viewport is a
sheet over most of the scene — reopening it onto whatever was just flown to.
Below sm it now folds away.

A selectable label's two lines are adjacent spans, so it announced as
"Sirius2.64 pc"; it carries an explicit label saying what it does.

Verified: build clean, 558/558 unit, 7/7 end-to-end including a new spec that
flies Sol to Barnard's Star by its label, design detector clean, screenshots
at 1440x900 and 390x844 in Sol and Proxima Centauri, and the keyboard path
walked: both names are in the tab order, focusable, with the accent ring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
2026-08-20 17:06:25 +02:00
co-authored by Claude Fable 5
parent 7591bcc0ea
commit 44c6a1f15e
12 changed files with 822 additions and 22 deletions
+12 -7
View File
@@ -67,18 +67,21 @@ function isWideViewport(): boolean {
imports: [SearchComponent],
host: { class: 'pointer-events-none fixed inset-x-2 bottom-2 z-20 block font-body sm:inset-x-6 sm:bottom-6' },
template: `
<div class="pointer-events-auto flex flex-col items-start">
<!-- The column is transparent to the pointer and each surface in it opts back in: it is as
wide as the strip and as tall as the open panel, so a solid one would swallow every
click in the empty band beside the panel — where the scene, and its labels, are. -->
<div class="flex flex-col items-start">
@if (activeTab(); as tab) {
<!-- Switching tabs remounts the panel and replays its acquire wipe: a new readout
locking on, once per switch, never per keystroke. -->
@switch (tab) {
@case ('search') {
<section id="dock-panel-search" role="tabpanel" aria-labelledby="dock-tab-search" class="hud-acquire mb-2 w-full max-w-xl">
<section id="dock-panel-search" role="tabpanel" aria-labelledby="dock-tab-search" class="hud-acquire pointer-events-auto mb-2 w-full max-w-xl">
<app-search (picked)="onPicked()" />
</section>
}
@case ('readout') {
<section id="dock-panel-readout" role="tabpanel" aria-labelledby="dock-tab-readout" class="hud-acquire hud-brackets hud-surface mb-2 w-full max-w-lg px-4 py-3">
<section id="dock-panel-readout" role="tabpanel" aria-labelledby="dock-tab-readout" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3">
<p class="type-label text-muted">{{ eyebrow() }}</p>
<p data-testid="hud-title" class="mt-1 text-lg font-bold tracking-[0.04em] text-text uppercase">{{ title() }}</p>
@if (subtitle()) {
@@ -100,7 +103,7 @@ function isWideViewport(): boolean {
</section>
}
@case ('display') {
<section id="dock-panel-display" role="tabpanel" aria-labelledby="dock-tab-display" class="hud-acquire hud-brackets hud-surface mb-2 w-full max-w-lg px-4 py-3">
<section id="dock-panel-display" role="tabpanel" aria-labelledby="dock-tab-display" class="hud-acquire hud-brackets hud-surface pointer-events-auto mb-2 w-full max-w-lg px-4 py-3">
<p class="type-label text-muted">Layers</p>
<div class="mt-2 flex flex-wrap gap-2">
@for (layer of layers; track layer.key) {
@@ -122,7 +125,7 @@ function isWideViewport(): boolean {
}
}
<div class="hud-brackets hud-surface flex w-full items-stretch">
<div class="hud-brackets hud-surface pointer-events-auto flex w-full items-stretch">
<div role="tablist" aria-label="Dock" class="flex items-stretch divide-x divide-border/40">
@for (tab of tabs(); track tab) {
<button
@@ -204,8 +207,10 @@ export class HudDockComponent implements OnInit {
onPicked(): void {
// A result was chosen: the thing to look at is now the scene, so hand the panel back to the
// readout where there is one, and fold the sheet away where there is not.
this.activeTab.set(this.title() ? 'readout' : null);
// readout where there is one, and fold the sheet away where there is not. On a narrow
// viewport it always folds away — there the panel is a sheet over most of the scene, and
// reopening it onto whatever was just flown to is the opposite of what was asked for.
this.activeTab.set(isWideViewport() && this.title() ? 'readout' : null);
}
/** `/` opens the search from anywhere, unless something else is already taking text. */