A system view could say everything about the star it was inside and nothing about where that star was. The four nearest catalogue stars are now named around the edge of it, each with its distance, each a button that flies there — so a chain of neighbours can be walked without pulling back out to the field between hops. These are bearings, not sky positions, and that is the one deliberate compromise here. A true direction was tried first and does not work: at this field of view the visible cone is about 30 degrees, so on average one neighbour in fifteen falls inside the frame — measured, not guessed, at one label of four in Sol and none at all after a small orbit. What survives the ring is the half of the direction a viewer can act on, which way to turn to face it, and the ring reads as instrument rather than as scene because it sits at a fixed radius. Real distance was never an option: Proxima is 268 000 AU from Sol, thirteen far planes out, so the distance goes on the type line. Proximity is answered by a new pure module rather than by a scan. A uniform grid over the catalogue answers both "the k nearest to this star" and "every star within n parsecs", the second being what the jump-link graph in the next PR is built from — one scan per node, and the quadratic would show. Its spec pins the grid against a brute-force sweep of a pseudo-random cloud, because a spatial index is an optimisation and never a different answer. Where the ring meets the HUD, the HUD wins: placement is given the boxes the readout, the strip and the object card occupy, and slides a name along the ring until it clears them, or drops it rather than print it half hidden. That rule is a pure function with its own spec. Four defects found while verifying this, three of them older than it: The dock's flex column was pointer-events-auto and as wide as its strip, so an invisible band above the strip swallowed every click in it — including, but not only, a neighbour's. The column is transparent now and each surface opts back in. The ring was sized against the frame's height alone, which on a phone held upright put it a viewport and a half wide: no neighbour was reachable on any portrait screen. It is sized against the shorter side. Picking a search result reopened the readout, which on a narrow viewport is a sheet over most of the scene — reopening it onto whatever was just flown to. Below sm it now folds away. A selectable label's two lines are adjacent spans, so it announced as "Sirius2.64 pc"; it carries an explicit label saying what it does. Verified: build clean, 558/558 unit, 7/7 end-to-end including a new spec that flies Sol to Barnard's Star by its label, design detector clean, screenshots at 1440x900 and 390x844 in Sol and Proxima Centauri, and the keyboard path walked: both names are in the tab order, focusable, with the accent ring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
242 lines
8.1 KiB
CSS
242 lines
8.1 KiB
CSS
/* You can add global styles to this file, and also import other style files */
|
|
@import 'tailwindcss';
|
|
|
|
/* 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-body: 'JetBrains Mono', ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', monospace;
|
|
|
|
--color-void: #04060c;
|
|
--color-panel: #070d16;
|
|
--color-accent: #4dd7ff;
|
|
--color-text: #e3ecf7;
|
|
--color-muted: #90a6bd;
|
|
--color-border: #24455a;
|
|
}
|
|
|
|
html,
|
|
body {
|
|
height: 100%;
|
|
margin: 0;
|
|
background: var(--color-void);
|
|
color: var(--color-text);
|
|
overflow: hidden;
|
|
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
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
/* Darkens the edges of the frame so the map reads as a lit display rather than a flat page. */
|
|
.hud-vignette {
|
|
pointer-events: none;
|
|
background: radial-gradient(ellipse at center, transparent 45%, color-mix(in srgb, var(--color-void) 72%, transparent) 100%);
|
|
}
|
|
|
|
/* Star name labels rendered by CSS2DRenderer (see StarLabelOverlay). These live outside
|
|
* Angular's view encapsulation as plain DOM nodes, so they're styled here rather than through
|
|
* a scoped component stylesheet. */
|
|
.star-label-layer {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
}
|
|
|
|
/* 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,
|
|
* 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 {
|
|
position: relative;
|
|
margin-left: 0.875rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
line-height: 1.15;
|
|
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);
|
|
}
|
|
|
|
/* The quieter voice: a star outside the system the camera is in, named so its direction can be
|
|
* read without leaving. Dimmed rather than shrunk — at the same size it still reads as a name,
|
|
* where a smaller one would read as a footnote to the system rather than a place beyond it. */
|
|
.map-label--ghost .map-label-name {
|
|
/* 65% of the accent over the void is 5.4:1 — a ghost is interactive, so it holds the same
|
|
* contrast floor as body text rather than the lower one large or decorative text could take. */
|
|
color: color-mix(in oklab, var(--color-accent) 65%, transparent);
|
|
}
|
|
|
|
.map-label--ghost .map-label-kind {
|
|
/* Quieter than the name it belongs to, and still 4.7:1. */
|
|
color: color-mix(in oklab, var(--color-accent) 60%, transparent);
|
|
}
|
|
|
|
.map-label--ghost::before {
|
|
background: color-mix(in oklab, var(--color-accent) 30%, transparent);
|
|
}
|
|
|
|
/* Selectable labels are buttons: the only ones the pointer can reach, since the layer they
|
|
* live in is transparent to it. They brighten to full strength on approach, which is the whole
|
|
* hover affordance — a ghost lighting up is the map saying this one is somewhere to go. */
|
|
.map-label--select {
|
|
pointer-events: auto;
|
|
cursor: pointer;
|
|
background: none;
|
|
border: 0;
|
|
padding: 0;
|
|
font: inherit;
|
|
}
|
|
|
|
.map-label--select:hover .map-label-name,
|
|
.map-label--select:focus-visible .map-label-name {
|
|
color: var(--color-accent);
|
|
}
|
|
|
|
.map-label--select:hover::before,
|
|
.map-label--select:focus-visible::before {
|
|
background: color-mix(in oklab, var(--color-accent) 60%, transparent);
|
|
}
|
|
|
|
.map-label--select:focus-visible {
|
|
outline: 1px solid var(--color-accent);
|
|
outline-offset: 3px;
|
|
}
|
|
|
|
/* The mirror: text hangs off the point's left, right-aligned, leader line on its right. */
|
|
.map-label--left {
|
|
margin-left: 0;
|
|
margin-right: 0.875rem;
|
|
align-items: flex-end;
|
|
text-align: right;
|
|
}
|
|
|
|
.map-label--left::before {
|
|
left: auto;
|
|
right: -0.75rem;
|
|
}
|
|
|
|
.map-label-name {
|
|
font-size: 11px;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: var(--color-accent);
|
|
}
|
|
|
|
.map-label-kind {
|
|
font-size: 8px;
|
|
letter-spacing: 0.3em;
|
|
text-transform: uppercase;
|
|
color: color-mix(in oklab, var(--color-accent) 70%, transparent);
|
|
}
|
|
|
|
/* The selection mark on a body in the scene: two hairline arcs bracketing the point — the one
|
|
* mark borrowed from the ARK's control disc, at hairline weight. Sized in pixels because it sits
|
|
* on the body's projected position and should read the same however far the camera is. Centred
|
|
* on the point by CSS2DRenderer's default (0.5, 0.5) anchor. */
|
|
.map-select {
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 50%;
|
|
border: 1px solid transparent;
|
|
border-left-color: var(--color-accent);
|
|
border-right-color: var(--color-accent);
|
|
pointer-events: none;
|
|
}
|