From 836816ffbd13c80d260adb46f4d1bd24020635db Mon Sep 17 00:00:00 2001 From: Senrokai Date: Fri, 7 Aug 2026 17:42:14 +0200 Subject: [PATCH 1/2] Restyle the HUD as an observatory instrument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi --- src/app/app.html | 6 ++ .../body-detail-scene.component.ts | 15 +++- .../body-detail/info-panel.component.ts | 54 ++++++++----- .../galaxy-system-scene.component.ts | 4 +- .../galaxy-system/star-label-overlay.ts | 8 +- src/app/features/search/search.component.ts | 50 +++++++----- src/index.html | 2 +- src/styles.css | 77 ++++++++++++++++--- 8 files changed, 161 insertions(+), 55 deletions(-) diff --git a/src/app/app.html b/src/app/app.html index 0ed047e..5e07294 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,2 +1,8 @@ + + diff --git a/src/app/features/body-detail/body-detail-scene.component.ts b/src/app/features/body-detail/body-detail-scene.component.ts index ee2f969..7643ff2 100644 --- a/src/app/features/body-detail/body-detail-scene.component.ts +++ b/src/app/features/body-detail/body-detail-scene.component.ts @@ -47,9 +47,18 @@ const GLOW_SCALE = 2.6; @if (viewModel()) { } @else if (notFound()) { -
-

Couldn't find that body.

- Back to the galaxy +
+

No record

+

That id isn't in the catalog — it may have been renamed or mistyped.

+ + + + + Back to the galaxy +
}
diff --git a/src/app/features/body-detail/info-panel.component.ts b/src/app/features/body-detail/info-panel.component.ts index 1029704..cefdde5 100644 --- a/src/app/features/body-detail/info-panel.component.ts +++ b/src/app/features/body-detail/info-panel.component.ts @@ -19,45 +19,63 @@ const KIND_LABELS: Record = { @Component({ selector: 'app-info-panel', template: ` -
+ +
-

{{ body().name }}

-

{{ kindLabel() }} · {{ body().hostStarName }}

+
+

{{ body().name }}

+

{{ kindLabel() }} · {{ body().hostStarName }}

+
-
+ +
@if (body().radiusKm) { -
Radius
-
{{ body().radiusKm | number: '1.0-1' }} km
+
+
Radius
+
{{ body().radiusKm | number: '1.0-1' }} km
+
} @if (body().massEarth) { -
Mass
-
{{ body().massEarth | number: '1.0-2' }} Earth masses
+
+
Mass
+
{{ body().massEarth | number: '1.0-2' }} Earth masses
+
} @if (body().orbit.semiMajorAxisAu) { -
Semi-major axis
-
{{ body().orbit.semiMajorAxisAu | number: '1.0-4' }} AU
+
+
Semi-major axis
+
{{ body().orbit.semiMajorAxisAu | number: '1.0-4' }} AU
+
} @if (body().orbit.eccentricity !== undefined) { -
Eccentricity
-
{{ body().orbit.eccentricity | number: '1.0-4' }}
+
+
Eccentricity
+
{{ body().orbit.eccentricity | number: '1.0-4' }}
+
} @if (body().orbit.inclinationDeg !== undefined) { -
Inclination
-
{{ body().orbit.inclinationDeg | number: '1.0-2' }}°
+
+
Inclination
+
{{ body().orbit.inclinationDeg | number: '1.0-2' }}°
+
} @if (body().discoveryYear) { -
Discovered
-
{{ body().discoveryYear }}
+
+
Discovered
+
{{ body().discoveryYear }}
+
}
diff --git a/src/app/features/galaxy-system/galaxy-system-scene.component.ts b/src/app/features/galaxy-system/galaxy-system-scene.component.ts index b084b75..dcd73f4 100644 --- a/src/app/features/galaxy-system/galaxy-system-scene.component.ts +++ b/src/app/features/galaxy-system/galaxy-system-scene.component.ts @@ -75,9 +75,9 @@ const STAR_MARKER_RADIUS_AU = 0.2; - - } - +
+

Matches {{ results().length }}

+
    + @for (result of results(); track result.bodyId ?? result.starId) { +
  • + +
  • + } +
+
+ } @else if (hasQuery()) { +

Nothing in the catalog matches “{{ query().trim() }}”

}
` @@ -72,6 +78,10 @@ export class SearchComponent { readonly query = signal(''); private readonly index = signal([]); + /** 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) { diff --git a/src/index.html b/src/index.html index d70b2e4..fec8bf3 100644 --- a/src/index.html +++ b/src/index.html @@ -8,7 +8,7 @@ - + diff --git a/src/styles.css b/src/styles.css index 1c7f127..2f87771 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,19 +1,20 @@ /* You can add global styles to this file, and also import other style files */ @import 'tailwindcss'; -/* Design tokens for the sci-fi "HUD" look shared across the app. `--color-*`/`--font-*` names - * automatically become Tailwind utilities (e.g. `--color-accent` -> `bg-accent`/`text-accent`). */ +/* Design tokens for the "observatory instrument" HUD shared across the app. `--color-*`/`--font-*` + * 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 { - --font-display: 'Orbitron', 'Segoe UI', sans-serif; - --font-body: 'JetBrains Mono', 'Segoe UI', monospace; + --font-body: 'JetBrains Mono', ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', monospace; - --color-void: #05070d; - --color-panel: #0b121f; + --color-void: #04060c; + --color-panel: #070d16; --color-accent: #4dd7ff; - --color-accent-dim: #1c4f66; - --color-text: #dbe6f3; - --color-muted: #7f93ab; - --color-border: #2a4a5c; + --color-text: #e3ecf7; + --color-muted: #90a6bd; + --color-border: #24455a; } html, @@ -26,6 +27,62 @@ body { font-family: var(--font-body); } +/* 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: + linear-gradient(var(--hud-mark) 0 0), linear-gradient(var(--hud-mark) 0 0), + linear-gradient(var(--hud-mark) 0 0), linear-gradient(var(--hud-mark) 0 0), + linear-gradient(var(--hud-mark) 0 0), linear-gradient(var(--hud-mark) 0 0), + linear-gradient(var(--hud-mark) 0 0), linear-gradient(var(--hud-mark) 0 0); + background-repeat: no-repeat; + background-size: + var(--hud-tick) 1px, + 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: + left top, + 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; + } +} + /* 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 * classes assigned directly in TypeScript rather than a scoped component stylesheet. */ From 650b3b37a06cfe2775f2b100f03847c686342c0a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 15:19:53 +0000 Subject: [PATCH 2/2] Fix what the review confirmed: anchoring, overlap, honesty, one voice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G --- .../body-detail-scene.component.ts | 13 ++-- .../body-detail/info-panel.component.ts | 49 ++++-------- .../body-detail/readout-sections.component.ts | 43 +++++++++++ .../galaxy-system/star-label-overlay.ts | 5 ++ .../starmap-hud.component.spec.ts | 4 +- .../galaxy-system/starmap-hud.component.ts | 37 +++++----- .../system-object-card.component.ts | 44 +++-------- src/app/features/search/search.component.ts | 74 ++++++++++++------- src/app/shared/ui/chevron-icon.component.ts | 20 +++++ src/app/shared/ui/reticle-icon.component.ts | 25 +++++++ src/index.html | 2 +- src/styles.css | 30 +++++++- 12 files changed, 222 insertions(+), 124 deletions(-) create mode 100644 src/app/features/body-detail/readout-sections.component.ts create mode 100644 src/app/shared/ui/chevron-icon.component.ts create mode 100644 src/app/shared/ui/reticle-icon.component.ts diff --git a/src/app/features/body-detail/body-detail-scene.component.ts b/src/app/features/body-detail/body-detail-scene.component.ts index 3f03892..71a8f29 100644 --- a/src/app/features/body-detail/body-detail-scene.component.ts +++ b/src/app/features/body-detail/body-detail-scene.component.ts @@ -13,6 +13,7 @@ import { BodyRecord } from '../../shared/models/body.model'; import { ExoplanetRecord } from '../../shared/models/exoplanet.model'; import { StarRecord } from '../../shared/models/star.model'; import { NavigationStore } from '../../shared/state/navigation.store'; +import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component'; import { BodyDetailViewModel } from './body-detail.model'; import { buildBodyViewModel } from './body-view-model'; import { InfoPanelComponent } from './info-panel.component'; @@ -36,23 +37,21 @@ const GLOW_SCALE = 2.6; @Component({ selector: 'app-body-detail-scene', providers: [EngineService], - imports: [InfoPanelComponent, RouterLink], + imports: [ChevronIconComponent, InfoPanelComponent, RouterLink], template: `
@if (viewModel()) { } @else if (notFound()) { -
-

No record

+
+

No record

That id isn't in the catalog — it may have been renamed or mistyped.

- - - + Back to the galaxy
diff --git a/src/app/features/body-detail/info-panel.component.ts b/src/app/features/body-detail/info-panel.component.ts index 5e786ee..0cdbda9 100644 --- a/src/app/features/body-detail/info-panel.component.ts +++ b/src/app/features/body-detail/info-panel.component.ts @@ -1,64 +1,43 @@ import { Component, computed, input } from '@angular/core'; import { Router } from '@angular/router'; +import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component'; import { bodyReadouts } from './body-readouts'; 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 * size/mass, orbital elements, and (for exoplanets) discovery year. Presentational only — * `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 - * cannot read differently in the two places it can be inspected. + * The rows come from `bodyReadouts` via `ReadoutSectionsComponent`, both shared with the system + * view's object card so the same body cannot read differently in the two places it can be + * inspected. */ @Component({ selector: 'app-info-panel', + imports: [ChevronIconComponent, ReadoutSectionsComponent], template: ` - -
+ +

{{ body().name }}

-

{{ readouts().kindLabel }} · {{ body().hostStarName }}

+

{{ readouts().kindLabel }} · {{ body().hostStarName }}

- - @if (readouts().measured.length) { -

Measured

-
- @for (row of readouts().measured; track row.label) { -
-
{{ row.label }}
-
{{ row.value }}
-
- } -
- } - -

Derived

-
- @for (row of readouts().derived; track row.label) { -
-
{{ row.label }}
-
{{ row.value }}
-
- } -
- -

{{ readouts().provenance }}

+
` }) diff --git a/src/app/features/body-detail/readout-sections.component.ts b/src/app/features/body-detail/readout-sections.component.ts new file mode 100644 index 0000000..3909783 --- /dev/null +++ b/src/app/features/body-detail/readout-sections.component.ts @@ -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) { +

Measured

+
+ @for (row of readouts().measured; track row.label) { +
+
{{ row.label }}
+
{{ row.value }}
+
+ } +
+ } + +

Derived

+
+ @for (row of readouts().derived; track row.label) { +
+
{{ row.label }}
+
{{ row.value }}
+
+ } +
+ +

{{ readouts().provenance }}

+ ` +}) +export class ReadoutSectionsComponent { + readonly readouts = input.required(); +} diff --git a/src/app/features/galaxy-system/star-label-overlay.ts b/src/app/features/galaxy-system/star-label-overlay.ts index 05979ee..dad2d3f 100644 --- a/src/app/features/galaxy-system/star-label-overlay.ts +++ b/src/app/features/galaxy-system/star-label-overlay.ts @@ -98,6 +98,11 @@ export class StarLabelOverlay { } 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); this.scene.add(object); this.labelObjects.set(point.id, object); diff --git a/src/app/features/galaxy-system/starmap-hud.component.spec.ts b/src/app/features/galaxy-system/starmap-hud.component.spec.ts index 79ed7a0..980dce8 100644 --- a/src/app/features/galaxy-system/starmap-hud.component.spec.ts +++ b/src/app/features/galaxy-system/starmap-hud.component.spec.ts @@ -94,11 +94,11 @@ describe('StarmapHudComponent', () => { fixture.componentRef.setInput('title', 'Sol'); 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', () => { // 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(); }); }); diff --git a/src/app/features/galaxy-system/starmap-hud.component.ts b/src/app/features/galaxy-system/starmap-hud.component.ts index 9019aaa..cc9371d 100644 --- a/src/app/features/galaxy-system/starmap-hud.component.ts +++ b/src/app/features/galaxy-system/starmap-hud.component.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { ViewLevel } from '../../shared/state/navigation.store'; +import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component'; export interface HudReadout { readonly label: string; @@ -45,6 +46,7 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [ @Component({ selector: 'app-starmap-hud', changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ReticleIconComponent], host: { class: 'pointer-events-none absolute inset-0 block select-none' }, template: `
@@ -52,21 +54,18 @@ const LADDER: readonly { level: ViewLevel; label: string }[] = [ @if (showReticle()) { - + } -