Author SHA1 Message Date
Claude 650b3b37a0 Fix what the review confirmed: anchoring, overlap, honesty, one voice
Fourteen findings from an adversarial review pass, applied together because
most share two roots — geometry written against assumptions the renderer does
not hold, and a design language spelled out longhand until it drifted.

Correctness. The leader line pointed at empty space: CSS2DObject centres a
label on its anchor by default, and margin only nudges the centred box — the
overlay now anchors the left edge (center 0, 0.5) so the hairline meets the
star. The info panel and not-found panel returned to top-4 at sm, under a
search field that is 26rem wide and centred, covering the back button on
every viewport from 640 to 1088px; they now wait for xl. The search empty
state asserted "nothing matches" while the catalogues were still loading —
and forever if they failed — so it now waits for the index. "Matches 8" was
the page size wearing the costume of a count; the header now reports the
real total, with the cap stated when it bites. The nameplate un-hides at lg
instead of sm, clear of the rail and the object card. theme-color matches
the void again.

Accessibility. Tabbing to search changed one hairline's hue; the wrapper now
carries the old ring as a visible focus indicator.

Structure. hud-surface names the panel recipe that had been inlined at seven
sites and had already forked into /85 and /92; the backdrop blur it carried
is gone from every near-opaque panel — the canvas beneath redraws every
frame, so each blur was re-sampled continuously for an effect the fill hid —
and survives only as blur-sm on the genuinely translucent search shell.
type-label and type-eyebrow name the two label voices, retiring 0.14em and
the stray hover:bg-accent/10. The Measured/Derived rows both cards pasted
twice each live once in ReadoutSectionsComponent; the reticle and chevron
each draw from a single geometry, the reticle's stroke held in screen pixels
so one mark serves every size. The acquire wipe plays once per search, not
once per keystroke, because both outcomes now share one panel. hud-banner,
which styled nothing since the rule was deleted, is a data-testid — the
convention its own file already used.

527 unit tests, 6/6 end-to-end, production build, and a driven screenshot:
every label now hangs off its star with the hairline touching the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
2026-08-17 15:19:53 +00:00
SenrokaiandClaude Fable 5 0dcd49c777 Merge main again: readouts built once, said in the instrument's voice
Main moved while the previous merge was being verified. It brought the
shared bodyReadouts builder — one source for a body's measured and derived
rows, used by the detail page and the system view's new object card — plus
the derived-value asterisk in the HUD readout panel.

All of that data flow is kept. The templates it arrived in are restyled to
this branch's idiom: the info panel and the object card share the same
organism (header, full-bleed readout rows, provenance line, route rail),
the object card's route rail sits at the bottom because there the route is
the next step rather than the way back, and the derived asterisk and its
footnote keep their meaning in sentence case. The card also needed the
restyle to render at all — it arrived wearing hud-panel, a class the
observatory system no longer defines.

Verified: build clean, 527/527 unit, 6/6 e2e, design detector clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-17 16:16:50 +02:00
SenrokaiandClaude Fable 5 a965aed1db Merge main, carrying the observatory instrument onto its rebuilt chrome
Two HUD redesigns happened in parallel: this branch restyled the old chrome
as a precision observatory instrument, while main rebuilt the chrome against
screenshots of the Star Citizen starmap — a scale ladder, a nameplate, a
readout panel, and two-line labels that say what a thing is, not just what
it is called.

This merge keeps everything main's rebuild learned and says it in this
branch's voice. Kept: the ladder's reachability semantics and test hooks,
the two-line name/kind labels and their shared declutter logic, system-view
body labels, every readout. Restyled: chamfered clip-path panels become
hairline frames with corner-tick brackets; Orbitron is gone and one readout
face carries the hierarchy; the hexagon reticle becomes the same
circle-and-ticks mark the search field wears; focus states move to real
focus-visible outlines; the long uppercase caveat notes drop to sentence
case so they read as sentences.

Two merge-borne fixes along the way: the labels' translate offset moved into
.map-label as a margin (CSS2DRenderer rewrites the inline transform every
frame, the margin is the offset it cannot touch), and the info panel's new
Derived section picked up the horizontal padding it lost when the panel
moved to per-block padding.

Verified: build clean, 496/496 unit, 6/6 e2e (on a free port — 4300 is
occupied on this machine), design detector clean, screenshots reviewed at
1440x900 and 390x844 across all four views plus search states.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-17 16:09:33 +02:00
SenrokaiandClaude Opus 5 836816ffbd 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
2026-08-07 17:42:14 +02:00
13 changed files with 345 additions and 185 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> <app-search></app-search>
<router-outlet></router-outlet> <router-outlet></router-outlet>
@@ -13,6 +13,7 @@ import { BodyRecord } from '../../shared/models/body.model';
import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
import { StarRecord } from '../../shared/models/star.model'; import { StarRecord } from '../../shared/models/star.model';
import { NavigationStore } from '../../shared/state/navigation.store'; import { NavigationStore } from '../../shared/state/navigation.store';
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
import { BodyDetailViewModel } from './body-detail.model'; import { BodyDetailViewModel } from './body-detail.model';
import { buildBodyViewModel } from './body-view-model'; import { buildBodyViewModel } from './body-view-model';
import { InfoPanelComponent } from './info-panel.component'; import { InfoPanelComponent } from './info-panel.component';
@@ -36,16 +37,23 @@ const GLOW_SCALE = 2.6;
@Component({ @Component({
selector: 'app-body-detail-scene', selector: 'app-body-detail-scene',
providers: [EngineService], providers: [EngineService],
imports: [InfoPanelComponent, RouterLink], imports: [ChevronIconComponent, InfoPanelComponent, RouterLink],
template: ` template: `
<div class="relative h-full w-full"> <div class="relative h-full w-full">
<canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas> <canvas #canvas data-testid="scene-canvas" class="block h-full w-full"></canvas>
@if (viewModel()) { @if (viewModel()) {
<app-info-panel [body]="viewModel()!" /> <app-info-panel [body]="viewModel()!" />
} @else if (notFound()) { } @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"> <div class="hud-brackets hud-acquire hud-surface absolute top-20 right-4 w-80 max-w-[calc(100%-2rem)] p-4 font-body text-text xl:top-4">
<p class="mb-2 text-sm">Couldn't find that body.</p> <p class="type-eyebrow text-accent">No record</p>
<a routerLink="/" class="text-sm text-accent hover:underline">Back to the galaxy</a> <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="type-label mt-4 inline-flex items-center gap-2 border border-border/60 px-3 py-1.5 text-muted transition-colors hover:border-accent/70 hover:text-accent focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
<app-chevron-icon class="h-3 w-3" direction="left" />
Back to the galaxy
</a>
</div> </div>
} }
</div> </div>
@@ -1,54 +1,43 @@
import { Component, computed, input } from '@angular/core'; import { Component, computed, input } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
import { bodyReadouts } from './body-readouts'; import { bodyReadouts } from './body-readouts';
import { BodyDetailViewModel } from './body-detail.model'; import { BodyDetailViewModel } from './body-detail.model';
import { ReadoutSectionsComponent } from './readout-sections.component';
/** /**
* Displays the real NASA data for the currently selected body/exoplanet: kind, physical * Displays the real NASA data for the currently selected body/exoplanet: kind, physical
* size/mass, orbital elements, and (for exoplanets) discovery year. Presentational only — * size/mass, orbital elements, and (for exoplanets) discovery year. Presentational only —
* `BodyDetailSceneComponent` supplies the view model and owns navigation state. * `BodyDetailSceneComponent` supplies the view model and owns navigation state.
* *
* The rows come from `bodyReadouts`, shared with the system view's object card so the same body * The rows come from `bodyReadouts` via `ReadoutSectionsComponent`, both shared with the system
* cannot read differently in the two places it can be inspected. * view's object card so the same body cannot read differently in the two places it can be
* inspected.
*/ */
@Component({ @Component({
selector: 'app-info-panel', selector: 'app-info-panel',
imports: [ChevronIconComponent, ReadoutSectionsComponent],
template: ` 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 until xl, beside it from there. The search field is 26rem
wide and centred, so this right-anchored 20rem panel only clears it once the viewport
passes ~1088px — at sm they still overlap and the search would cover the back button. -->
<div class="hud-brackets hud-acquire hud-surface absolute top-20 right-4 w-80 max-w-[calc(100%-2rem)] font-body text-text xl:top-4">
<button <button
type="button" type="button"
(click)="goBack()" (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="type-label flex w-full items-center gap-2 border-b border-border/40 px-4 py-2 text-muted 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"> <app-chevron-icon class="h-3 w-3" direction="left" />
<path d="M15 6l-6 6 6 6" />
</svg>
System System
</button> </button>
<h1 class="mb-0.5 font-display text-lg font-semibold tracking-wide text-text">{{ body().name }}</h1> <header class="px-4 pt-4 pb-3">
<p class="mb-4 text-xs tracking-wide text-accent uppercase">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p> <h1 class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</h1>
<p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
</header>
@if (readouts().measured.length) { <app-readout-sections [readouts]="readouts()" />
<p class="mt-4 mb-1.5 text-[10px] tracking-[0.18em] text-muted uppercase">Measured</p>
<dl class="grid grid-cols-[auto_1fr] gap-y-1.5 gap-x-3 text-sm">
@for (row of readouts().measured; track row.label) {
<dt class="text-muted">{{ row.label }}</dt>
<dd class="text-right text-text tabular-nums">{{ row.value }}</dd>
}
</dl>
}
<p class="mt-4 mb-1.5 text-[10px] tracking-[0.18em] text-muted uppercase">Derived</p>
<dl class="grid grid-cols-[auto_1fr] gap-y-1.5 gap-x-3 text-sm">
@for (row of readouts().derived; track row.label) {
<dt class="text-muted">{{ row.label }}</dt>
<dd class="text-right text-text tabular-nums">{{ row.value }}</dd>
}
</dl>
<p class="mt-3 border-t border-border/50 pt-2 text-[10px] leading-relaxed text-muted">{{ readouts().provenance }}</p>
</div> </div>
` `
}) })
@@ -0,0 +1,43 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { BodyReadouts } from './body-readouts';
/**
* The Measured/Derived rows and the provenance footnote, shared by the detail page's info panel
* and the system view's object card. `bodyReadouts` already guarantees the two surfaces agree
* on the numbers; this guarantees they agree on the pixels — the markup used to be pasted four
* times across the two templates, which is exactly the drift the shared data model exists to
* prevent.
*/
@Component({
selector: 'app-readout-sections',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (readouts().measured.length) {
<p class="type-label border-t border-border/40 px-4 pt-3 pb-1 text-muted">Measured</p>
<dl class="divide-y divide-border/25">
@for (row of readouts().measured; track row.label) {
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
<dt class="type-label text-muted">{{ row.label }}</dt>
<dd class="text-sm tabular-nums">{{ row.value }}</dd>
</div>
}
</dl>
}
<p class="type-label border-t border-border/40 px-4 pt-3 pb-1 text-muted">Derived</p>
<dl class="divide-y divide-border/25">
@for (row of readouts().derived; track row.label) {
<div class="flex items-baseline justify-between gap-4 px-4 py-2">
<dt class="type-label text-muted">{{ row.label }}</dt>
<dd class="text-sm tabular-nums">{{ row.value }}</dd>
</div>
}
</dl>
<p class="border-t border-border/40 px-4 py-3 text-[10px] leading-relaxed text-muted">{{ readouts().provenance }}</p>
`
})
export class ReadoutSectionsComponent {
readonly readouts = input.required<BodyReadouts>();
}
@@ -79,9 +79,11 @@ export class StarLabelOverlay {
private addLabel(point: LabeledPoint): void { private addLabel(point: LabeledPoint): void {
const element = document.createElement('div'); const element = document.createElement('div');
// Tailwind utility classes assigned directly since this element lives outside Angular's // Classes assigned directly since this element lives outside Angular's view encapsulation
// view encapsulation (see the class comment above) rather than through a component template. // (see the class comment above). The offset and leader line live in `.map-label` itself:
element.className = 'map-label translate-x-1.5 -translate-y-1.5 whitespace-nowrap font-body'; // CSS2DRenderer rewrites this element's inline transform every frame, so a translate here
// would be overwritten — the margin is the offset it cannot touch.
element.className = 'map-label whitespace-nowrap font-body';
const name = document.createElement('span'); const name = document.createElement('span');
name.className = 'map-label-name'; name.className = 'map-label-name';
@@ -96,6 +98,11 @@ export class StarLabelOverlay {
} }
const object = new CSS2DObject(element); const object = new CSS2DObject(element);
// Anchor the label's left edge at the point, vertically centred. The default center of
// (0.5, 0.5) makes CSS2DRenderer emit translate(-50%,-50%), keeping the box centred on the
// star — under which `.map-label`'s margin offset only nudges the centred box sideways and
// the leader line points at empty space half the label's width from the star.
object.center.set(0, 0.5);
object.position.set(point.x, point.y, point.z); object.position.set(point.x, point.y, point.z);
this.scene.add(object); this.scene.add(object);
this.labelObjects.set(point.id, object); this.labelObjects.set(point.id, object);
@@ -94,11 +94,11 @@ describe('StarmapHudComponent', () => {
fixture.componentRef.setInput('title', 'Sol'); fixture.componentRef.setInput('title', 'Sol');
fixture.detectChanges(); fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.hud-banner')?.textContent?.trim()).toBe('Sol'); expect((fixture.nativeElement as HTMLElement).querySelector('[data-testid="hud-banner"]')?.textContent?.trim()).toBe('Sol');
}); });
it('shows no banner when the view is holding nothing', () => { it('shows no banner when the view is holding nothing', () => {
// An empty nameplate is worse than none: it reads as a selection that failed to resolve. // An empty nameplate is worse than none: it reads as a selection that failed to resolve.
expect(render('galaxy').querySelector('.hud-banner')).toBeNull(); expect(render('galaxy').querySelector('[data-testid="hud-banner"]')).toBeNull();
}); });
}); });
@@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
import { ViewLevel } from '../../shared/state/navigation.store'; import { ViewLevel } from '../../shared/state/navigation.store';
import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component';
export interface HudReadout { export interface HudReadout {
readonly label: string; readonly label: string;
@@ -45,29 +46,26 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
@Component({ @Component({
selector: 'app-starmap-hud', selector: 'app-starmap-hud',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReticleIconComponent],
host: { class: 'pointer-events-none absolute inset-0 block select-none' }, host: { class: 'pointer-events-none absolute inset-0 block select-none' },
template: ` template: `
<div class="hud-frame absolute inset-0"></div>
<div class="hud-vignette absolute inset-0"></div> <div class="hud-vignette absolute inset-0"></div>
@if (showReticle()) { @if (showReticle()) {
<!-- A hexagon rather than a square bracket: the shape reads as a sensor lock on a body, <!-- The same circle-and-ticks reticle the search field wears, scaled up: one lock mark
and stays distinct from the rectangular panel chrome everywhere else on screen. --> for the whole instrument, whether it is holding a query or a body. -->
<svg class="absolute top-1/2 left-1/2 h-14 w-14 -translate-x-1/2 -translate-y-1/2 text-accent/70" viewBox="0 0 56 56" fill="none" aria-hidden="true"> <app-reticle-icon class="absolute top-1/2 left-1/2 h-14 w-14 -translate-x-1/2 -translate-y-1/2 text-accent/70" [strokeWidth]="1" />
<polygon points="28,4 48,16 48,40 28,52 8,40 8,16" stroke="currentColor" stroke-width="1" />
<path d="M28 22v-6M28 40v-6M22 28h-6M40 28h-6" stroke="currentColor" stroke-width="1" opacity="0.8" />
</svg>
} }
<!-- Top rail: which scale the view is at, and what it is holding. Both sit on one line <!-- Top rail: which scale the view is at, and what it is holding. Both sit on one line
across the top of the display, clear of the search field above them. --> across the top of the display, clear of the search field above them. -->
<nav aria-label="Map scale" class="pointer-events-auto absolute top-16 left-6 flex items-stretch gap-px"> <nav aria-label="Map scale" class="hud-brackets hud-surface pointer-events-auto absolute top-16 left-6 flex items-stretch divide-x divide-border/40">
@for (step of ladder(); track step.level) { @for (step of ladder(); track step.level) {
@if (step.reachable) { @if (step.reachable) {
<button <button
type="button" type="button"
(click)="levelSelected.emit(step.level)" (click)="levelSelected.emit(step.level)"
class="hud-tab border-y border-border/70 bg-panel/70 px-4 py-1.5 font-display text-[10px] tracking-[0.22em] text-muted uppercase backdrop-blur-sm transition-colors hover:bg-accent/15 hover:text-accent focus:bg-accent/15 focus:text-accent focus:outline-none" class="type-eyebrow px-4 py-1.5 text-muted 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"
> >
{{ step.label }} {{ step.label }}
</button> </button>
@@ -75,8 +73,8 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
<span <span
[attr.aria-current]="step.state === 'current' ? 'step' : null" [attr.aria-current]="step.state === 'current' ? 'step' : null"
[attr.data-testid]="step.state === 'current' ? 'hud-current-level' : null" [attr.data-testid]="step.state === 'current' ? 'hud-current-level' : null"
class="hud-tab border-y px-4 py-1.5 font-display text-[10px] tracking-[0.22em] uppercase backdrop-blur-sm" class="type-eyebrow px-4 py-1.5"
[class]="step.state === 'current' ? 'border-accent/70 bg-accent/20 text-accent' : 'border-border/40 bg-panel/40 text-border'" [class]="step.state === 'current' ? 'bg-accent/15 text-accent' : 'text-muted/40'"
>{{ step.label }}</span >{{ step.label }}</span
> >
} }
@@ -84,21 +82,21 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
</nav> </nav>
@if (title()) { @if (title()) {
<div class="absolute top-16 left-1/2 -translate-x-1/2"> <!-- Hidden below lg: the readout panel names the same thing, and at narrower widths a
<div class="hud-banner flex items-center gap-2.5 border-b border-accent/60 bg-panel/75 px-6 py-1.5 backdrop-blur-sm"> long star name runs into the scale rail on its left and under the object card on its
<svg class="h-3 w-3 shrink-0 text-accent" viewBox="0 0 12 12" fill="none" aria-hidden="true"> right — all three share the top-16 line. -->
<polygon points="6,1 10.5,3.5 10.5,8.5 6,11 1.5,8.5 1.5,3.5" stroke="currentColor" stroke-width="1" /> <div class="absolute top-16 left-1/2 hidden -translate-x-1/2 lg:block">
<circle cx="6" cy="6" r="1.4" fill="currentColor" /> <div data-testid="hud-banner" class="hud-brackets hud-acquire hud-surface flex items-center gap-2.5 px-6 py-1.5">
</svg> <app-reticle-icon class="h-3 w-3 shrink-0 text-accent" />
<span class="font-display text-[11px] tracking-[0.3em] text-accent uppercase">{{ title() }}</span> <span class="text-[11px] tracking-[0.3em] text-accent uppercase">{{ title() }}</span>
</div> </div>
</div> </div>
} }
<div class="absolute right-6 bottom-6 left-6 flex flex-wrap items-end justify-between gap-4"> <div class="absolute right-6 bottom-6 left-6 flex flex-wrap items-end justify-between gap-4">
<div class="hud-panel max-w-lg px-4 py-3"> <div class="hud-brackets hud-acquire hud-surface max-w-lg px-4 py-3">
<p class="font-display text-[10px] tracking-[0.28em] text-muted uppercase">{{ eyebrow() }}</p> <p class="type-label text-muted">{{ eyebrow() }}</p>
<p data-testid="hud-title" class="mt-1 font-display text-xl tracking-[0.06em] text-text">{{ title() }}</p> <p data-testid="hud-title" class="mt-1 text-lg font-bold tracking-[0.04em] text-text uppercase">{{ title() }}</p>
@if (subtitle()) { @if (subtitle()) {
<p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p> <p class="mt-0.5 text-xs text-muted">{{ subtitle() }}</p>
} }
@@ -106,20 +104,20 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
<dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1"> <dl class="mt-3 flex flex-wrap gap-x-6 gap-y-1">
@for (readout of readouts(); track readout.label) { @for (readout of readouts(); track readout.label) {
<div> <div>
<dt class="text-[10px] tracking-[0.18em] text-muted uppercase">{{ readout.label }}@if (readout.derived) {<span class="text-accent/80" aria-hidden="true">*</span>}</dt> <dt class="type-label text-muted">{{ readout.label }}@if (readout.derived) {<span class="text-accent/80" aria-hidden="true">*</span>}</dt>
<dd class="text-sm text-text tabular-nums">{{ readout.value }}</dd> <dd class="mt-0.5 text-sm text-text tabular-nums">{{ readout.value }}</dd>
</div> </div>
} }
</dl> </dl>
} }
@if (note() || hasDerived()) { @if (note() || hasDerived()) {
<p class="mt-3 border-t border-border/50 pt-2 text-[10px] leading-relaxed tracking-[0.08em] text-muted uppercase">@if (hasDerived()) {<span class="text-accent/80">*</span> Derived, not catalogued. }{{ note() }}</p> <p class="mt-3 border-t border-border/40 pt-2 text-[10px] leading-relaxed text-muted">@if (hasDerived()) {<span class="text-accent/80">*</span> Derived, not catalogued. }{{ note() }}</p>
} }
</div> </div>
<div class="hud-panel px-4 py-3 text-right"> <div class="hud-brackets hud-acquire hud-surface px-4 py-3 text-right">
<p class="font-display text-[10px] tracking-[0.28em] text-muted uppercase">Range</p> <p class="type-label text-muted">Range</p>
<p class="mt-1 font-display text-lg text-accent tabular-nums">{{ range() }}</p> <p class="mt-1 text-lg text-accent tabular-nums">{{ range() }}</p>
</div> </div>
</div> </div>
` `
@@ -2,6 +2,8 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@an
import { BodyDetailViewModel } from '../body-detail/body-detail.model'; import { BodyDetailViewModel } from '../body-detail/body-detail.model';
import { bodyReadouts } from '../body-detail/body-readouts'; import { bodyReadouts } from '../body-detail/body-readouts';
import { ReadoutSectionsComponent } from '../body-detail/readout-sections.component';
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
/** /**
* The card shown for a body picked in the system view, without leaving it. * The card shown for a body picked in the system view, without leaving it.
@@ -11,31 +13,34 @@ import { bodyReadouts } from '../body-detail/body-readouts';
* shows the same numbers over the live view, and keeps the route as the deliberate step for the * shows the same numbers over the live view, and keeps the route as the deliberate step for the
* full 3D inspection. * full 3D inspection.
* *
* The rows themselves come from `bodyReadouts`, shared with the detail page. Presentational only. * The rows come from `bodyReadouts` via `ReadoutSectionsComponent`, both shared with the detail
* page. Presentational only.
*/ */
@Component({ @Component({
selector: 'app-system-object-card', selector: 'app-system-object-card',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ChevronIconComponent, ReadoutSectionsComponent],
// Positioned and full-bleed like the HUD's own host, so the panel inside it resolves against // Positioned and full-bleed like the HUD's own host, so the panel inside it resolves against
// the scene rather than against whatever box the inline default would have left it in — which // the scene rather than against whatever box the inline default would have left it in — which
// put the card off the bottom-left corner of the viewport entirely. // put the card off the bottom-left corner of the viewport entirely.
host: { class: 'pointer-events-none absolute inset-0 block' }, host: { class: 'pointer-events-none absolute inset-0 block' },
template: ` template: `
<!-- Positioning and panel styling stay on separate elements. The hud-panel rule sets <!-- Positioning and panel styling stay on separate elements, so the panel's own layout
position: relative for its own inset border, which silently overrides an absolute on the never fights the absolute placement. Same organism as the detail page's info panel:
same element and turns top/right into offsets from wherever the box already sat. --> header, shared readout sections, and a route rail — there at the top, here at the
bottom, because here the route is the next step rather than the way back. -->
<div class="pointer-events-auto absolute top-16 right-6 w-80 max-w-[calc(100%-3rem)]"> <div class="pointer-events-auto absolute top-16 right-6 w-80 max-w-[calc(100%-3rem)]">
<div data-testid="object-card" class="hud-panel px-4 py-3 font-body"> <div data-testid="object-card" class="hud-brackets hud-acquire hud-surface font-body text-text">
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3 px-4 pt-4 pb-3">
<div class="min-w-0"> <header class="min-w-0">
<p class="truncate font-display text-lg tracking-[0.04em] text-text">{{ body().name }}</p> <p class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</p>
<p class="mt-0.5 font-display text-[10px] tracking-[0.22em] text-accent uppercase">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p> <p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
</div> </header>
<button <button
type="button" type="button"
(click)="dismissed.emit()" (click)="dismissed.emit()"
aria-label="Close" aria-label="Close"
class="-mt-1 -mr-1 shrink-0 rounded-sm p-1 text-muted transition-colors hover:text-accent focus:text-accent focus:outline-none" class="-mt-2 -mr-2 shrink-0 p-1 text-muted transition-colors hover:text-accent focus-visible:text-accent focus-visible:outline-1 focus-visible:outline-accent"
> >
<svg class="h-3.5 w-3.5" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"> <svg class="h-3.5 w-3.5" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M3 3l8 8M11 3l-8 8" /> <path d="M3 3l8 8M11 3l-8 8" />
@@ -43,35 +48,15 @@ import { bodyReadouts } from '../body-detail/body-readouts';
</button> </button>
</div> </div>
@if (readouts().measured.length) { <app-readout-sections [readouts]="readouts()" />
<p class="mt-3 text-[10px] tracking-[0.18em] text-muted uppercase">Measured</p>
<dl class="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm">
@for (row of readouts().measured; track row.label) {
<dt class="text-muted">{{ row.label }}</dt>
<dd class="text-right text-text tabular-nums">{{ row.value }}</dd>
}
</dl>
}
<p class="mt-3 text-[10px] tracking-[0.18em] text-muted uppercase">Derived</p>
<dl class="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm">
@for (row of readouts().derived; track row.label) {
<dt class="text-muted">{{ row.label }}</dt>
<dd class="text-right text-text tabular-nums">{{ row.value }}</dd>
}
</dl>
<p class="mt-3 border-t border-border/50 pt-2 text-[10px] leading-relaxed text-muted">{{ readouts().provenance }}</p>
<button <button
type="button" type="button"
(click)="openRequested.emit()" (click)="openRequested.emit()"
class="mt-3 flex w-full items-center justify-center gap-1.5 border border-border/70 bg-panel/60 px-3 py-1.5 font-display text-[10px] tracking-[0.22em] text-muted uppercase transition-colors hover:border-accent hover:text-accent focus:border-accent focus:text-accent focus:outline-none" class="type-label flex w-full items-center justify-center gap-1.5 border-t border-border/40 px-3 py-2 text-muted 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"
> >
Full view Full view
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <app-chevron-icon class="h-3 w-3" direction="right" />
<path d="M9 6l6 6-6 6" />
</svg>
</button> </button>
</div> </div>
</div> </div>
+45 -13
View File
@@ -3,6 +3,7 @@ import { Router } from '@angular/router';
import { DataLoaderService } from '../../core/data/data-loader.service'; import { DataLoaderService } from '../../core/data/data-loader.service';
import { NavigationStore } from '../../shared/state/navigation.store'; import { NavigationStore } from '../../shared/state/navigation.store';
import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component';
import { buildSearchIndex, IndexedSearchEntry, rankSearchResults, SearchEntry, SearchResultKind } from './search-ranking'; import { buildSearchIndex, IndexedSearchEntry, rankSearchResults, SearchEntry, SearchResultKind } from './search-ranking';
const MAX_RESULTS = 8; const MAX_RESULTS = 8;
@@ -22,37 +23,55 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
*/ */
@Component({ @Component({
selector: 'app-search', selector: 'app-search',
imports: [ReticleIconComponent],
template: ` 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="fixed top-4 left-1/2 z-20 w-[26rem] max-w-[calc(100%-2rem)] -translate-x-1/2 font-body">
<div class="relative"> <!-- The one genuinely translucent surface, so it alone carries a (small) backdrop blur.
<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"> The ring is the keyboard-focus indicator: a border hue shift alone is invisible over
<circle cx="11" cy="11" r="7" /> the star field. -->
<path d="M21 21l-4.3-4.3" /> <div class="hud-brackets hud-surface relative backdrop-blur-sm transition-colors focus-within:border-accent focus-within:ring-1 focus-within:ring-accent/50">
</svg> <app-reticle-icon class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-accent/70" />
<input <input
type="text" type="text"
placeholder="Search stars, planets, exoplanets…" placeholder="Search stars, planets, exoplanets…"
[value]="query()" [value]="query()"
(input)="onInput($event)" (input)="onInput($event)"
(keydown.escape)="clear()" (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> </div>
<!-- One panel for both outcomes, so crossing the match boundary while typing swaps only
the rows instead of remounting the panel and replaying its acquire wipe per
keystroke. Gated on the index being ready: "no matches" may only ever describe a
search that actually ran against the loaded catalogues. -->
@if (indexReady() && hasQuery()) {
<div class="hud-acquire hud-surface mt-2">
@if (results().length) { @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"> <p class="type-label border-b border-border/40 px-3 py-1.5 text-muted">
Matches <span class="text-accent tabular-nums">{{ matchTotal() }}</span>
@if (matchTotal() > results().length) {
<span> · top {{ results().length }}</span>
}
</p>
<ul data-testid="search-results" class="divide-y divide-border/25">
@for (result of results(); track result.bodyId ?? result.starId) { @for (result of results(); track result.bodyId ?? result.starId) {
<li> <li>
<button <button
type="button" type="button"
(click)="select(result)" (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" 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="text-sm text-text">{{ result.name }}</span> <span class="min-w-0 flex-1 truncate text-sm text-text">{{ result.name }}</span>
<span class="text-xs tracking-wide text-muted uppercase">{{ kindLabel(result.kind) }} · {{ result.subtitle }}</span> <span class="type-label max-w-[45%] shrink-0 truncate text-muted">{{ kindLabel(result.kind) }} · {{ result.subtitle }}</span>
</button> </button>
</li> </li>
} }
</ul> </ul>
} @else {
<p class="type-label px-3 py-2.5 text-center text-muted">Nothing in the catalog matches <span class="normal-case text-text">“{{ query().trim() }}”</span></p>
}
</div>
} }
</div> </div>
` `
@@ -61,15 +80,27 @@ export class SearchComponent {
readonly query = signal(''); readonly query = signal('');
/** Pre-normalised once on load; re-deriving it per keystroke would stutter the render loop. */ /** Pre-normalised once on load; re-deriving it per keystroke would stutter the render loop. */
private readonly index = signal<IndexedSearchEntry[]>([]); private readonly index = signal<IndexedSearchEntry[]>([]);
/** False until the catalogues have loaded — and forever if they fail, which beats a false
* "no matches" for bodies that exist but could not be fetched. */
readonly indexReady = signal(false);
readonly results = computed(() => { /** 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);
/** Every match, ranked. Kept separate from the display slice so the "Matches" count can
* report the real total rather than the page size. */
private readonly matches = computed(() => {
const query = this.query().trim(); const query = this.query().trim();
if (query.length < MIN_QUERY_LENGTH) { if (query.length < MIN_QUERY_LENGTH) {
return []; return [];
} }
return rankSearchResults(this.index(), query, MAX_RESULTS); return rankSearchResults(this.index(), query, Number.POSITIVE_INFINITY);
}); });
readonly matchTotal = computed(() => this.matches().length);
readonly results = computed(() => this.matches().slice(0, MAX_RESULTS));
constructor( constructor(
private readonly dataLoader: DataLoaderService, private readonly dataLoader: DataLoaderService,
private readonly navigationStore: NavigationStore, private readonly navigationStore: NavigationStore,
@@ -115,6 +146,7 @@ export class SearchComponent {
]; ];
this.index.set(buildSearchIndex(entries)); this.index.set(buildSearchIndex(entries));
this.indexReady.set(true);
} catch (error) { } catch (error) {
console.error('Failed to build the search index.', error); console.error('Failed to build the search index.', error);
} }
@@ -0,0 +1,20 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
/**
* The route-rail chevron — "back to" when pointing left, "on to" when pointing right. One
* geometry for every rail button, where each previously inlined its own copy of the svg.
* Size and colour come from the classes on the host; the svg fills it.
*/
@Component({
selector: 'app-chevron-icon',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'block', 'aria-hidden': 'true' },
template: `
<svg class="h-full w-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path [attr.d]="direction() === 'left' ? 'M15 6l-6 6 6 6' : 'M9 6l6 6-6 6'" />
</svg>
`
})
export class ChevronIconComponent {
readonly direction = input<'left' | 'right'>('left');
}
@@ -0,0 +1,25 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
/**
* The circle-and-ticks lock mark the instrument brands itself with. The search field, the
* nameplate and the viewport reticle all render this one geometry — it used to be drawn three
* times, once with hand-rescaled coordinates, and the copies could drift apart.
*
* Size and colour come from the classes on the host (`h-4 w-4 text-accent/70` etc.); the svg
* fills it. `vector-effect: non-scaling-stroke` keeps the stroke a screen-pixel width however
* far the 24-unit viewBox is scaled, so `strokeWidth` means the same at every size.
*/
@Component({
selector: 'app-reticle-icon',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'block', 'aria-hidden': 'true' },
template: `
<svg class="h-full w-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" [attr.stroke-width]="strokeWidth()" stroke-linecap="round">
<circle cx="12" cy="12" r="5" vector-effect="non-scaling-stroke" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" vector-effect="non-scaling-stroke" />
</svg>
`
})
export class ReticleIconComponent {
readonly strokeWidth = input(1.5);
}
+2 -2
View File
@@ -9,7 +9,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="An interactive 3D star map built from real astronomical data: 68,388 catalogued stars out to 250 parsecs, planets on their measured orbits, and the NASA figures behind every body."> <meta name="description" content="An interactive 3D star map built from real astronomical data: 68,388 catalogued stars out to 250 parsecs, planets on their measured orbits, and the NASA figures behind every body.">
<!-- Matches --color-void, so the browser chrome does not flash white around a black sky. --> <!-- Matches --color-void, so the browser chrome does not flash white around a black sky. -->
<meta name="theme-color" content="#05070d"> <meta name="theme-color" content="#04060c">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">
<!-- Link previews. These URLs must be absolute — a scraper resolves them against nothing — so <!-- Link previews. These URLs must be absolute — a scraper resolves them against nothing — so
@@ -24,7 +24,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>
+109 -62
View File
@@ -1,19 +1,20 @@
/* You can add global styles to this file, and also import other style files */ /* You can add global styles to this file, and also import other style files */
@import 'tailwindcss'; @import 'tailwindcss';
/* Design tokens for the sci-fi "HUD" look shared across the app. `--color-*`/`--font-*` names /* Design tokens for the "observatory instrument" HUD shared across the app. `--color-*`/`--font-*`
* automatically become Tailwind utilities (e.g. `--color-accent` -> `bg-accent`/`text-accent`). */ * names automatically become Tailwind utilities (e.g. `--color-accent` -> `bg-accent`/`text-accent`).
*
* One family only: every string in this UI is a measurement, an identifier, or a catalog label, so
* the readout face carries the whole hierarchy through weight, size, and tracking. */
@theme { @theme {
--font-display: 'Orbitron', 'Segoe UI', sans-serif; --font-body: 'JetBrains Mono', ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', monospace;
--font-body: 'JetBrains Mono', 'Segoe UI', monospace;
--color-void: #05070d; --color-void: #04060c;
--color-panel: #0b121f; --color-panel: #070d16;
--color-accent: #4dd7ff; --color-accent: #4dd7ff;
--color-accent-dim: #1c4f66; --color-text: #e3ecf7;
--color-text: #dbe6f3; --color-muted: #90a6bd;
--color-muted: #7f93ab; --color-border: #24455a;
--color-border: #2a4a5c;
} }
html, html,
@@ -26,48 +27,85 @@ body {
font-family: var(--font-body); font-family: var(--font-body);
} }
/* --- HUD chrome ------------------------------------------------------------------------- */ /* The panel surface every HUD element shares: a hairline border over a near-opaque fill. One
* definition, because spelling the recipe out per call site is how it forked into /85 and /92
/* Angled top-left/bottom-right corners, a thin lit edge and a blurred dark fill: the panel * variants within a single change. Deliberately no backdrop blur: the canvas underneath is
* shape the whole heads-up display is built from. Written here rather than as utilities * redrawn every frame by the render loop, so a blur here is re-sampled continuously — a
* because `clip-path` and the layered border need to travel together. */ * standing GPU cost for an effect a 92%-opaque fill mostly hides anyway. */
.hud-panel { @utility hud-surface {
position: relative; border: 1px solid color-mix(in oklab, var(--color-border) 60%, transparent);
background: color-mix(in srgb, var(--color-panel) 78%, transparent); background-color: color-mix(in oklab, var(--color-panel) 92%, transparent);
border: 1px solid color-mix(in srgb, var(--color-border) 85%, transparent);
backdrop-filter: blur(8px);
clip-path: polygon(14px 0, 100% 0, 100% calc(100% - 14px), calc(100% - 14px) 100%, 0 100%, 0 14px);
} }
/* The lit inner hairline that reads as a bevel on the two square corners. */ /* The two label voices of the single-face type system, named so call sites cannot drift a few
.hud-panel::after { * thousandths of an em apart. `type-label` is the working voice — section headings, readout
content: ''; * labels, buttons; `type-eyebrow` is the wider-tracked identity line above a title. */
position: absolute; @utility type-label {
inset: 3px; font-size: 10px;
border-top: 1px solid color-mix(in srgb, var(--color-accent) 22%, transparent); letter-spacing: 0.16em;
border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 22%, transparent); text-transform: uppercase;
pointer-events: none;
} }
/* Corner brackets around the whole viewport, drawn as four gradients so the element stays a @utility type-eyebrow {
* single node with no children. */ font-size: 10px;
.hud-frame { letter-spacing: 0.18em;
pointer-events: none; text-transform: uppercase;
--bracket: color-mix(in srgb, var(--color-accent) 45%, transparent); }
--arm-long: 76px;
--arm-thin: 1px; /* The HUD's signature: four corner tick brackets cut into a panel's hairline frame. Drawn as
* eight background gradients so any panel (or the viewport frame) picks them up without the
* eight extra DOM nodes the same mark would otherwise cost. Override `--hud-tick` per element
* (e.g. `[--hud-tick:1.75rem]`) to scale the marks with the surface they frame. */
@utility hud-brackets {
--hud-tick: 0.625rem;
--hud-mark: color-mix(in oklab, var(--color-accent) 65%, transparent);
background-image: background-image:
linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--hud-mark) 0 0), linear-gradient(var(--hud-mark) 0 0),
linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--hud-mark) 0 0), linear-gradient(var(--hud-mark) 0 0),
linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--hud-mark) 0 0), linear-gradient(var(--hud-mark) 0 0),
linear-gradient(var(--bracket), var(--bracket)), linear-gradient(var(--bracket), var(--bracket)); linear-gradient(var(--hud-mark) 0 0), linear-gradient(var(--hud-mark) 0 0);
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: background-size:
var(--arm-long) var(--arm-thin), var(--arm-thin) var(--arm-long), var(--arm-long) var(--arm-thin), var(--arm-thin) var(--arm-long), var(--hud-tick) 1px,
var(--arm-long) var(--arm-thin), var(--arm-thin) var(--arm-long), var(--arm-long) var(--arm-thin), var(--arm-thin) var(--arm-long); 1px var(--hud-tick),
var(--hud-tick) 1px,
1px var(--hud-tick),
var(--hud-tick) 1px,
1px var(--hud-tick),
var(--hud-tick) 1px,
1px var(--hud-tick);
background-position: background-position:
18px 18px, 18px 18px, right 18px top 18px, right 18px top 18px, 18px bottom 18px, 18px bottom 18px, right 18px bottom 18px, left top,
right 18px bottom 18px; left top,
right top,
right top,
left bottom,
left bottom,
right bottom,
right bottom;
}
/* The one authored moment: a panel doesn't fade in, it acquires — wiping down under an
* exponential ease-out the way a readout fills once an instrument locks onto a target. */
@utility hud-acquire {
animation: hud-acquire 380ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes hud-acquire {
from {
opacity: 0;
clip-path: inset(0 0 100% 0);
}
to {
opacity: 1;
clip-path: inset(0 0 0 0);
}
}
@media (prefers-reduced-motion: reduce) {
.hud-acquire {
animation-duration: 1ms;
}
} }
/* Darkens the edges of the frame so the map reads as a lit display rather than a flat page. */ /* Darkens the edges of the frame so the map reads as a lit display rather than a flat page. */
@@ -76,22 +114,9 @@ body {
background: radial-gradient(ellipse at center, transparent 45%, color-mix(in srgb, var(--color-void) 72%, transparent) 100%); background: radial-gradient(ellipse at center, transparent 45%, color-mix(in srgb, var(--color-void) 72%, transparent) 100%);
} }
/* A chamfered tab: the angled-corner shape the scale ladder and the selection banner share.
* The cut is on the leading and trailing edges rather than the panel's diagonal corners, which
* is what makes a row of them read as tabs on a rail instead of a row of cards. */
.hud-tab {
clip-path: polygon(9px 0, 100% 0, calc(100% - 9px) 100%, 0 100%);
}
/* The selected-object banner: symmetric, flaring outward toward its lit bottom edge, so it
* reads as a nameplate hanging off the top of the display rather than another tab. */
.hud-banner {
clip-path: polygon(14px 0, calc(100% - 14px) 0, 100% 100%, 0 100%);
}
/* Star name labels rendered by CSS2DRenderer (see StarLabelOverlay). These live outside /* Star name labels rendered by CSS2DRenderer (see StarLabelOverlay). These live outside
* Angular's view encapsulation as plain DOM nodes, so they're styled with Tailwind utility * Angular's view encapsulation as plain DOM nodes, so they're styled here rather than through
* classes assigned directly in TypeScript rather than a scoped component stylesheet. */ * a scoped component stylesheet. */
.star-label-layer { .star-label-layer {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -100,17 +125,39 @@ body {
/* Two-line map label: what the thing is called, then what it is. The type line is deliberately /* Two-line map label: what the thing is called, then what it is. The type line is deliberately
* much quieter than the name — it should be readable when looked at and invisible when not, * much quieter than the name — it should be readable when looked at and invisible when not,
* because on a crowded view it is printed as many times as there are labels. */ * because on a crowded view it is printed as many times as there are labels.
*
* Offset from the anchor with a margin, not a translate: `CSS2DRenderer` rewrites the inline
* `transform` every frame, and the margin is the one offset it can never overwrite. The
* hairline `::before` is the leader line back to the point the label names. */
.map-label { .map-label {
position: relative;
margin-left: 0.875rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
line-height: 1.15; line-height: 1.15;
text-shadow: 0 0 4px rgba(0, 0, 0, 0.9); text-shadow:
0 0 6px var(--color-void),
0 0 3px var(--color-void);
}
/* Geometry assumes the label's left edge sits at the star — StarLabelOverlay sets the
* CSS2DObject's center to (0, 0.5) to make that true. Under the default (0.5, 0.5) the
* renderer keeps the box centred on the point and this line floats in empty space. */
.map-label::before {
content: '';
position: absolute;
top: 50%;
left: -0.75rem;
height: 1px;
width: 0.625rem;
background: color-mix(in oklab, var(--color-accent) 60%, transparent);
} }
.map-label-name { .map-label-name {
font-size: 11px; font-size: 11px;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-accent); color: var(--color-accent);
} }
@@ -118,5 +165,5 @@ body {
font-size: 8px; font-size: 8px;
letter-spacing: 0.3em; letter-spacing: 0.3em;
text-transform: uppercase; text-transform: uppercase;
color: color-mix(in srgb, var(--color-accent) 55%, transparent); color: color-mix(in oklab, var(--color-accent) 70%, transparent);
} }