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:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user