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
This commit is contained in:
@@ -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,23 +37,21 @@ 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="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">
|
<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="text-[10px] tracking-[0.18em] text-accent uppercase">No record</p>
|
<p class="type-eyebrow text-accent">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>
|
<p class="mt-2 text-sm text-muted">That id isn't in the catalog — it may have been renamed or mistyped.</p>
|
||||||
<a
|
<a
|
||||||
routerLink="/"
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<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="left" />
|
||||||
<path d="M15 6l-6 6 6 6" />
|
|
||||||
</svg>
|
|
||||||
Back to the galaxy
|
Back to the galaxy
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,64 +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: `
|
||||||
<!-- Sits below the search field on narrow viewports, beside it from sm up, so the two
|
<!-- Sits below the search field until xl, beside it from there. The search field is 26rem
|
||||||
top-anchored overlays never stack on top of each other on a phone. -->
|
wide and centred, so this right-anchored 20rem panel only clears it once the viewport
|
||||||
<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">
|
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="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"
|
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 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="left" />
|
||||||
<path d="M15 6l-6 6 6 6" />
|
|
||||||
</svg>
|
|
||||||
System
|
System
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<header class="px-4 pt-4 pb-3">
|
<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>
|
<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">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
<p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Readout rows: label left, figure right. Tabular figures keep the decimal points
|
<app-readout-sections [readouts]="readouts()" />
|
||||||
aligned down the column. -->
|
|
||||||
@if (readouts().measured.length) {
|
|
||||||
<p class="border-t border-border/40 px-4 pt-3 pb-1 text-[10px] tracking-[0.16em] text-muted uppercase">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="text-[10px] tracking-[0.16em] text-muted uppercase">{{ row.label }}</dt>
|
|
||||||
<dd class="text-sm tabular-nums">{{ row.value }}</dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
}
|
|
||||||
|
|
||||||
<p class="border-t border-border/40 px-4 pt-3 pb-1 text-[10px] tracking-[0.16em] text-muted uppercase">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="text-[10px] tracking-[0.16em] text-muted uppercase">{{ 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>
|
|
||||||
</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>();
|
||||||
|
}
|
||||||
@@ -98,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,6 +46,7 @@ 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-vignette absolute inset-0"></div>
|
<div class="hud-vignette absolute inset-0"></div>
|
||||||
@@ -52,21 +54,18 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
|||||||
@if (showReticle()) {
|
@if (showReticle()) {
|
||||||
<!-- The same circle-and-ticks reticle the search field wears, scaled up: one lock mark
|
<!-- The same circle-and-ticks reticle the search field wears, scaled up: one lock mark
|
||||||
for the whole instrument, whether it is holding a query or a body. -->
|
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" stroke="currentColor" stroke-width="1" 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" />
|
||||||
<circle cx="28" cy="28" r="15" />
|
|
||||||
<path d="M28 6v7M28 43v7M6 28h7M43 28h7" />
|
|
||||||
</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="hud-brackets pointer-events-auto absolute top-16 left-6 flex items-stretch divide-x divide-border/40 border border-border/60 bg-panel/85 backdrop-blur-md">
|
<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="px-4 py-1.5 text-[10px] tracking-[0.18em] text-muted uppercase transition-colors hover:bg-accent/10 hover:text-accent focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
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>
|
||||||
@@ -74,7 +73,7 @@ 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="px-4 py-1.5 text-[10px] tracking-[0.18em] uppercase"
|
class="type-eyebrow px-4 py-1.5"
|
||||||
[class]="step.state === 'current' ? 'bg-accent/15 text-accent' : 'text-muted/40'"
|
[class]="step.state === 'current' ? 'bg-accent/15 text-accent' : 'text-muted/40'"
|
||||||
>{{ step.label }}</span
|
>{{ step.label }}</span
|
||||||
>
|
>
|
||||||
@@ -83,22 +82,20 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@if (title()) {
|
@if (title()) {
|
||||||
<!-- Hidden on phones: the readout panel names the same thing, and at this width the
|
<!-- Hidden below lg: the readout panel names the same thing, and at narrower widths a
|
||||||
nameplate would sit on top of the scale rail. -->
|
long star name runs into the scale rail on its left and under the object card on its
|
||||||
<div class="absolute top-16 left-1/2 hidden -translate-x-1/2 sm:block">
|
right — all three share the top-16 line. -->
|
||||||
<div class="hud-banner hud-brackets hud-acquire flex items-center gap-2.5 border border-border/60 bg-panel/85 px-6 py-1.5 backdrop-blur-md">
|
<div class="absolute top-16 left-1/2 hidden -translate-x-1/2 lg:block">
|
||||||
<svg class="h-3 w-3 shrink-0 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true">
|
<div data-testid="hud-banner" class="hud-brackets hud-acquire hud-surface flex items-center gap-2.5 px-6 py-1.5">
|
||||||
<circle cx="12" cy="12" r="5" />
|
<app-reticle-icon class="h-3 w-3 shrink-0 text-accent" />
|
||||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
|
|
||||||
</svg>
|
|
||||||
<span class="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-brackets hud-acquire max-w-lg border border-border/60 bg-panel/92 px-4 py-3 backdrop-blur-md">
|
<div class="hud-brackets hud-acquire hud-surface max-w-lg px-4 py-3">
|
||||||
<p class="text-[10px] tracking-[0.16em] text-muted uppercase">{{ eyebrow() }}</p>
|
<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>
|
<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>
|
||||||
@@ -107,7 +104,7 @@ 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.16em] 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="mt-0.5 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>
|
||||||
}
|
}
|
||||||
@@ -118,8 +115,8 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hud-brackets hud-acquire border border-border/60 bg-panel/92 px-4 py-3 text-right backdrop-blur-md">
|
<div class="hud-brackets hud-acquire hud-surface px-4 py-3 text-right">
|
||||||
<p class="text-[10px] tracking-[0.16em] text-muted uppercase">Range</p>
|
<p class="type-label text-muted">Range</p>
|
||||||
<p class="mt-1 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,11 +13,13 @@ 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.
|
||||||
@@ -23,14 +27,14 @@ import { bodyReadouts } from '../body-detail/body-readouts';
|
|||||||
template: `
|
template: `
|
||||||
<!-- Positioning and panel styling stay on separate elements, so the panel's own layout
|
<!-- Positioning and panel styling stay on separate elements, so the panel's own layout
|
||||||
never fights the absolute placement. Same organism as the detail page's info panel:
|
never fights the absolute placement. Same organism as the detail page's info panel:
|
||||||
header, full-bleed readout rows, provenance, and a route rail — there at the top,
|
header, shared readout sections, and a route rail — there at the top, here at the
|
||||||
here at the bottom, because here the route is the next step rather than the way back. -->
|
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-brackets hud-acquire border border-border/60 bg-panel/92 font-body text-text backdrop-blur-md">
|
<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 px-4 pt-4 pb-3">
|
<div class="flex items-start justify-between gap-3 px-4 pt-4 pb-3">
|
||||||
<header class="min-w-0">
|
<header class="min-w-0">
|
||||||
<p class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</p>
|
<p class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</p>
|
||||||
<p class="mt-1 truncate text-[10px] tracking-[0.18em] text-accent uppercase">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
<p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
||||||
</header>
|
</header>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -44,39 +48,15 @@ import { bodyReadouts } from '../body-detail/body-readouts';
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (readouts().measured.length) {
|
<app-readout-sections [readouts]="readouts()" />
|
||||||
<p class="border-t border-border/40 px-4 pt-3 pb-1 text-[10px] tracking-[0.16em] text-muted uppercase">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="text-[10px] tracking-[0.16em] text-muted uppercase">{{ row.label }}</dt>
|
|
||||||
<dd class="text-sm tabular-nums">{{ row.value }}</dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
}
|
|
||||||
|
|
||||||
<p class="border-t border-border/40 px-4 pt-3 pb-1 text-[10px] tracking-[0.16em] text-muted uppercase">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="text-[10px] tracking-[0.16em] text-muted uppercase">{{ 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>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="openRequested.emit()"
|
(click)="openRequested.emit()"
|
||||||
class="flex w-full items-center justify-center gap-1.5 border-t border-border/40 px-3 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"
|
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>
|
||||||
|
|||||||
@@ -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,13 +23,14 @@ 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-[26rem] 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="hud-brackets relative border border-border/60 bg-panel/85 backdrop-blur-md transition-colors focus-within:border-accent/70">
|
<!-- 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-accent/70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
The ring is the keyboard-focus indicator: a border hue shift alone is invisible over
|
||||||
<circle cx="12" cy="12" r="5" />
|
the star field. -->
|
||||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
|
<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…"
|
||||||
@@ -39,26 +41,37 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (results().length) {
|
<!-- One panel for both outcomes, so crossing the match boundary while typing swaps only
|
||||||
<div class="hud-acquire mt-2 border border-border/60 bg-panel/92 backdrop-blur-md">
|
the rows instead of remounting the panel and replaying its acquire wipe per
|
||||||
<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>
|
keystroke. Gated on the index being ready: "no matches" may only ever describe a
|
||||||
<ul data-testid="search-results" class="divide-y divide-border/25">
|
search that actually ran against the loaded catalogues. -->
|
||||||
@for (result of results(); track result.bodyId ?? result.starId) {
|
@if (indexReady() && hasQuery()) {
|
||||||
<li>
|
<div class="hud-acquire hud-surface mt-2">
|
||||||
<button
|
@if (results().length) {
|
||||||
type="button"
|
<p class="type-label border-b border-border/40 px-3 py-1.5 text-muted">
|
||||||
(click)="select(result)"
|
Matches <span class="text-accent tabular-nums">{{ matchTotal() }}</span>
|
||||||
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"
|
@if (matchTotal() > results().length) {
|
||||||
>
|
<span> · top {{ results().length }}</span>
|
||||||
<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>
|
</p>
|
||||||
</button>
|
<ul data-testid="search-results" class="divide-y divide-border/25">
|
||||||
</li>
|
@for (result of results(); track result.bodyId ?? result.starId) {
|
||||||
}
|
<li>
|
||||||
</ul>
|
<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="type-label max-w-[45%] shrink-0 truncate text-muted">{{ kindLabel(result.kind) }} · {{ result.subtitle }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</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>
|
||||||
} @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>
|
</div>
|
||||||
`
|
`
|
||||||
@@ -67,19 +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);
|
||||||
|
|
||||||
/** True once the query is long enough to have been searched — so "no matches" is only ever
|
/** 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. */
|
* reported about a search that actually ran, never about a half-typed word. */
|
||||||
readonly hasQuery = computed(() => this.query().trim().length >= MIN_QUERY_LENGTH);
|
readonly hasQuery = computed(() => this.query().trim().length >= MIN_QUERY_LENGTH);
|
||||||
|
|
||||||
readonly results = computed(() => {
|
/** 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,
|
||||||
@@ -125,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);
|
||||||
|
}
|
||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+29
-1
@@ -27,6 +27,31 @@ body {
|
|||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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
|
||||||
|
* variants within a single change. Deliberately no backdrop blur: the canvas underneath is
|
||||||
|
* redrawn every frame by the render loop, so a blur here is re-sampled continuously — a
|
||||||
|
* standing GPU cost for an effect a 92%-opaque fill mostly hides anyway. */
|
||||||
|
@utility hud-surface {
|
||||||
|
border: 1px solid color-mix(in oklab, var(--color-border) 60%, transparent);
|
||||||
|
background-color: color-mix(in oklab, var(--color-panel) 92%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The two label voices of the single-face type system, named so call sites cannot drift a few
|
||||||
|
* thousandths of an em apart. `type-label` is the working voice — section headings, readout
|
||||||
|
* labels, buttons; `type-eyebrow` is the wider-tracked identity line above a title. */
|
||||||
|
@utility type-label {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility type-eyebrow {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
/* The HUD's signature: four corner tick brackets cut into a panel's hairline frame. Drawn as
|
/* 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 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
|
* eight extra DOM nodes the same mark would otherwise cost. Override `--hud-tick` per element
|
||||||
@@ -116,10 +141,13 @@ body {
|
|||||||
0 0 3px 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 {
|
.map-label::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 7px;
|
top: 50%;
|
||||||
left: -0.75rem;
|
left: -0.75rem;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
width: 0.625rem;
|
width: 0.625rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user