Restyle the HUD as an observatory instrument

Replaces the generic rounded dark cards floating over the scene with a
single instrument language: hairline frames cut by accent corner ticks, a
viewport frame that reads the whole screen as one panel, and readouts whose
figures are what the eye lands on.

- styles.css: retune the tokens (deeper void/panel, brighter text/muted so
  every label clears 4.5:1 even over the brightest part of the skybox) and
  add two utilities — `hud-brackets` draws the four corner ticks from eight
  background gradients so no panel pays for them in DOM, `hud-acquire` wipes
  a panel down on mount as its one authored moment (reduced-motion aware).
- Drop Orbitron: every string in this UI is a measurement, an identifier, or
  a catalog label, so one readout face carries the hierarchy through weight,
  size, and tracking — and the HUD paints one font request sooner.
- Search: reticle mark instead of a magnifier, transparent field inside a
  framed shell, results as a dense two-column list with a match count, and
  the previously missing empty state.
- Info panel: return rail, name/class header, and a readout table with
  tabular figures and the unit tinted back off the number.
- Star labels: a hairline leader back to the star they name. The offset moved
  from `translate-*` to a margin because CSS2DRenderer overwrites the inline
  transform every frame, which silently beat the old classes.
- Both top-anchored overlays drop below the search field under `sm` so they
  no longer stack on top of it on a phone, and focus moved from a removed
  outline plus ring to real `focus-visible` outlines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
This commit is contained in:
2026-08-07 17:42:14 +02:00
co-authored by Claude Opus 5
parent d7e8ea1d4d
commit 836816ffbd
8 changed files with 161 additions and 55 deletions
+6
View File
@@ -1,2 +1,8 @@
<!-- Viewport frame: the four corner marks that read the whole screen as one instrument, rather
than as unrelated panels floating on black. Decorative, so it never intercepts scene input. -->
<div
aria-hidden="true"
class="hud-brackets pointer-events-none fixed inset-2 z-30 [--hud-tick:1.75rem]"
></div>
<app-search></app-search>
<router-outlet></router-outlet>
@@ -47,9 +47,18 @@ const GLOW_SCALE = 2.6;
@if (viewModel()) {
<app-info-panel [body]="viewModel()!" />
} @else if (notFound()) {
<div class="absolute top-4 right-4 rounded-md border border-border bg-panel/80 p-5 font-body text-text backdrop-blur-md">
<p class="mb-2 text-sm">Couldn't find that body.</p>
<a routerLink="/" class="text-sm text-accent hover:underline">Back to the galaxy</a>
<div class="hud-brackets hud-acquire absolute top-20 right-4 w-80 max-w-[calc(100%-2rem)] border border-border/60 bg-panel/92 p-4 font-body text-text backdrop-blur-md sm:top-4">
<p class="text-[10px] tracking-[0.18em] text-accent uppercase">No record</p>
<p class="mt-2 text-sm text-muted">That id isn't in the catalog — it may have been renamed or mistyped.</p>
<a
routerLink="/"
class="mt-4 inline-flex items-center gap-2 border border-border/60 px-3 py-1.5 text-[10px] tracking-[0.16em] text-muted uppercase transition-colors hover:border-accent/70 hover:text-accent focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 6l-6 6 6 6" />
</svg>
Back to the galaxy
</a>
</div>
}
</div>
@@ -19,45 +19,63 @@ const KIND_LABELS: Record<BodyDetailViewModel['kind'], string> = {
@Component({
selector: 'app-info-panel',
template: `
<div class="absolute top-4 right-4 w-80 max-w-[calc(100%-2rem)] rounded-md border border-border bg-panel/80 p-5 font-body text-text backdrop-blur-md">
<!-- Sits below the search field on narrow viewports, beside it from sm up, so the two
top-anchored overlays never stack on top of each other on a phone. -->
<div class="hud-brackets hud-acquire absolute top-20 right-4 w-80 max-w-[calc(100%-2rem)] border border-border/60 bg-panel/92 font-body text-text backdrop-blur-md sm:top-4">
<button
type="button"
(click)="goBack()"
class="mb-3 flex items-center gap-1.5 rounded-md border border-border bg-panel/60 px-3 py-1.5 text-xs tracking-wide text-muted uppercase transition-colors hover:border-accent hover:text-accent focus:outline-none focus:ring-1 focus:ring-accent/50"
class="flex w-full items-center gap-2 border-b border-border/40 px-4 py-2 text-[10px] tracking-[0.16em] text-muted uppercase transition-colors hover:bg-accent/8 hover:text-accent focus-visible:bg-accent/12 focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 6l-6 6 6 6" />
</svg>
System
</button>
<h1 class="mb-0.5 font-display text-lg font-semibold tracking-wide text-text">{{ body().name }}</h1>
<p class="mb-4 text-xs tracking-wide text-accent uppercase">{{ kindLabel() }} · {{ body().hostStarName }}</p>
<header class="px-4 pt-4 pb-3">
<h1 class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</h1>
<p class="mt-1 truncate text-[10px] tracking-[0.18em] text-accent uppercase">{{ kindLabel() }} · {{ body().hostStarName }}</p>
</header>
<dl class="grid grid-cols-[auto_1fr] gap-y-1.5 gap-x-3 text-sm">
<!-- Readout rows: label left, figure right, unit tinted back so the number is what the eye
lands on. Tabular figures keep the decimal points aligned down the column. -->
<dl class="divide-y divide-border/25 border-t border-border/40">
@if (body().radiusKm) {
<dt class="text-muted">Radius</dt>
<dd class="text-right text-text">{{ body().radiusKm | number: '1.0-1' }} km</dd>
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Radius</dt>
<dd class="text-sm tabular-nums">{{ body().radiusKm | number: '1.0-1' }} <span class="text-muted">km</span></dd>
</div>
}
@if (body().massEarth) {
<dt class="text-muted">Mass</dt>
<dd class="text-right text-text">{{ body().massEarth | number: '1.0-2' }} Earth masses</dd>
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Mass</dt>
<dd class="text-sm tabular-nums">{{ body().massEarth | number: '1.0-2' }} <span class="text-muted">Earth masses</span></dd>
</div>
}
@if (body().orbit.semiMajorAxisAu) {
<dt class="text-muted">Semi-major axis</dt>
<dd class="text-right text-text">{{ body().orbit.semiMajorAxisAu | number: '1.0-4' }} AU</dd>
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Semi-major axis</dt>
<dd class="text-sm tabular-nums">{{ body().orbit.semiMajorAxisAu | number: '1.0-4' }} <span class="text-muted">AU</span></dd>
</div>
}
@if (body().orbit.eccentricity !== undefined) {
<dt class="text-muted">Eccentricity</dt>
<dd class="text-right text-text">{{ body().orbit.eccentricity | number: '1.0-4' }}</dd>
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Eccentricity</dt>
<dd class="text-sm tabular-nums">{{ body().orbit.eccentricity | number: '1.0-4' }}</dd>
</div>
}
@if (body().orbit.inclinationDeg !== undefined) {
<dt class="text-muted">Inclination</dt>
<dd class="text-right text-text">{{ body().orbit.inclinationDeg | number: '1.0-2' }}°</dd>
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Inclination</dt>
<dd class="text-sm tabular-nums">{{ body().orbit.inclinationDeg | number: '1.0-2' }}<span class="text-muted">°</span></dd>
</div>
}
@if (body().discoveryYear) {
<dt class="text-muted">Discovered</dt>
<dd class="text-right text-text">{{ body().discoveryYear }}</dd>
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
<dt class="text-[10px] tracking-[0.16em] text-muted uppercase">Discovered</dt>
<dd class="text-sm tabular-nums">{{ body().discoveryYear }}</dd>
</div>
}
</dl>
</div>
@@ -75,9 +75,9 @@ const STAR_MARKER_RADIUS_AU = 0.2;
<button
type="button"
(click)="exitSystem()"
class="absolute top-4 left-4 flex items-center gap-1.5 rounded-md border border-border bg-panel/70 px-3 py-1.5 font-body text-xs tracking-wide text-muted uppercase backdrop-blur-md transition-colors hover:border-accent hover:text-accent focus:outline-none focus:ring-1 focus:ring-accent/50"
class="hud-brackets hud-acquire absolute top-20 left-4 flex items-center gap-2 border border-border/60 bg-panel/85 px-3 py-2 font-body text-[10px] tracking-[0.16em] text-muted uppercase backdrop-blur-md transition-colors hover:border-accent/70 hover:text-accent focus-visible:border-accent/70 focus-visible:text-accent focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-accent sm:top-4"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 6l-6 6 6 6" />
</svg>
Galaxy
@@ -61,7 +61,13 @@ export class StarLabelOverlay {
const element = document.createElement('div');
// Tailwind utility classes assigned directly since this element lives outside Angular's
// view encapsulation (see the class comment above) rather than through a component template.
element.className = 'translate-x-1.5 -translate-y-1.5 whitespace-nowrap font-body text-[11px] text-accent [text-shadow:0_0_4px_rgba(0,0,0,0.9)]';
//
// The offset is a margin rather than a `translate-*` utility because `CSS2DRenderer` writes
// its own `transform` to the inline style every frame, which would win over any class. The
// `::before` hairline is the leader line back to the star the name belongs to — without it a
// margin-shifted label just floats near a point instead of pointing at it.
element.className =
"relative ml-3 whitespace-nowrap font-body text-[11px] tracking-[0.08em] text-accent uppercase [text-shadow:0_0_6px_var(--color-void),0_0_3px_var(--color-void)] before:absolute before:top-1/2 before:-left-3 before:h-px before:w-2.5 before:bg-accent/60 before:content-['']";
element.textContent = point.name;
const object = new CSS2DObject(element);
+30 -20
View File
@@ -34,11 +34,11 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
@Component({
selector: 'app-search',
template: `
<div class="fixed top-4 left-1/2 z-20 w-[22rem] max-w-[calc(100%-2rem)] -translate-x-1/2 font-body">
<div class="relative">
<svg class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.3-4.3" />
<div class="fixed top-4 left-1/2 z-20 w-[26rem] max-w-[calc(100%-2rem)] -translate-x-1/2 font-body">
<div class="hud-brackets relative border border-border/60 bg-panel/85 backdrop-blur-md transition-colors focus-within:border-accent/70">
<svg class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-accent/70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<circle cx="12" cy="12" r="5" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
</svg>
<input
type="text"
@@ -46,24 +46,30 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
[value]="query()"
(input)="onInput($event)"
(keydown.escape)="clear()"
class="w-full rounded-md border border-border bg-panel/85 py-2 pl-9 pr-3 text-sm text-text backdrop-blur-md transition-colors placeholder:text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/50"
class="w-full bg-transparent py-2.5 pr-3 pl-10 text-sm tracking-[0.02em] text-text caret-accent placeholder:text-muted focus:outline-none"
/>
</div>
@if (results().length) {
<ul data-testid="search-results" class="mt-1 divide-y divide-border/40 overflow-hidden rounded-md border border-border bg-panel/90 backdrop-blur-md">
@for (result of results(); track result.bodyId ?? result.starId) {
<li>
<button
type="button"
(click)="select(result)"
class="flex w-full flex-col gap-0.5 px-3 py-2 text-left transition-colors hover:bg-accent/10 focus:bg-accent/10 focus:outline-none"
>
<span class="text-sm text-text">{{ result.name }}</span>
<span class="text-xs tracking-wide text-muted uppercase">{{ kindLabel(result.kind) }} · {{ result.subtitle }}</span>
</button>
</li>
}
</ul>
<div class="hud-acquire mt-2 border border-border/60 bg-panel/92 backdrop-blur-md">
<p class="border-b border-border/40 px-3 py-1.5 text-[10px] tracking-[0.16em] text-muted uppercase">Matches <span class="text-accent tabular-nums">{{ results().length }}</span></p>
<ul data-testid="search-results" class="divide-y divide-border/25">
@for (result of results(); track result.bodyId ?? result.starId) {
<li>
<button
type="button"
(click)="select(result)"
class="flex w-full items-baseline gap-3 border-l border-transparent px-3 py-2 text-left transition-colors hover:border-l-accent hover:bg-accent/8 focus-visible:border-l-accent focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
>
<span class="min-w-0 flex-1 truncate text-sm text-text">{{ result.name }}</span>
<span class="max-w-[45%] shrink-0 truncate text-[10px] tracking-[0.14em] text-muted uppercase">{{ kindLabel(result.kind) }} · {{ result.subtitle }}</span>
</button>
</li>
}
</ul>
</div>
} @else if (hasQuery()) {
<p class="hud-acquire mt-2 border border-border/60 bg-panel/92 px-3 py-2.5 text-center text-[11px] tracking-[0.14em] text-muted uppercase backdrop-blur-md">Nothing in the catalog matches <span class="normal-case text-text">“{{ query().trim() }}”</span></p>
}
</div>
`
@@ -72,6 +78,10 @@ export class SearchComponent {
readonly query = signal('');
private readonly index = signal<SearchEntry[]>([]);
/** True once the query is long enough to have been searched — so "no matches" is only ever
* reported about a search that actually ran, never about a half-typed word. */
readonly hasQuery = computed(() => this.query().trim().length >= MIN_QUERY_LENGTH);
readonly results = computed(() => {
const query = this.query().trim().toLowerCase();
if (query.length < MIN_QUERY_LENGTH) {