Files
star-map/src/styles.css
T
SenrokaiandClaude Fable 5 68a919bd84 Route between stars, through the crossings a chosen range allows
The map could say where a star is and what is near it, and nothing about
getting from one to another. This adds the question and the answer: pick a
departure and a destination, choose how far a single crossing may be, and get
the chain — how many jumps, how far in total, and every star on the way, each
one a step you can fly to.

A jump link is not a feature of space. There are no corridors out there; a
link is a question asked of the catalogue, which is why the range is the
user's control rather than a constant. Two facts about that catalogue decide
what the answers look like, and both are stated in the code because they read
as defects otherwise. It is magnitude-limited, so it is dense around the Sun
and thins with distance — within 50 pc a 3 pc range links 99% of it into one
piece, while over the whole 250 pc reach the same range leaves most stars
alone. And a gap in it is a gap in what has been catalogued, not in what is
there.

That is why "no route" is not the end of the answer. Where no chain exists at
the range asked for, the panel says which range would open one — the chain
whose longest hop is as short as possible, found by the same search with the
cost of arriving somewhere being the worst hop taken rather than the sum — and
offers that number as a control to accept.

Departure defaults to wherever the view already is, so one field is usually
enough. Sol to Vega at 3 pc: four jumps, 10 pc, by way of Barnard's Star,
Struve 2398 B and HD 155876. Narrow it to 0.8 pc and it says 2.26 would reach.

The graph is drawn as one buffer of line segments and the route as a second,
brighter one over it, with the graph stepping back while a route is up: near
the Sun the links are a haze, and a thread through a bright cloud is not a
thread. Both fade out with the local layer, since from outside the Galaxy the
graph is a smear.

Two measurements shaped this. Asking the index for each star's neighbours in
turn — sixty-eight thousand sorted lists, thrown away — took eight seconds; the
grid now walks its own cells once and pairs them, which takes a quarter of one.
And the range control emits per pixel dragged, so the rebuild waits for the
hand to settle.

Three defects fixed on the way, all older than the routing:

hud-acquire animated with fill-mode `both`, which leaves its closing keyframe
applied for good — and that keyframe carries a clip-path. Every panel wearing
it has been clipping its own box ever since, so anything that had to escape
one was cut away and could not even be clicked. Nothing had needed to escape
until this panel's dropdown opened upward.

The routing fields returned nothing when typed into before the catalogue
finished loading, and stayed nothing until the next keystroke. The options are
derived from the query and the index together now, so they appear when the
second of the two arrives, whichever that is.

And a link was `3-7` walking one way and `7-3` walking the other, which is two
links to anything comparing them.

Verified: build clean, 571/571 unit, 9/9 end-to-end including two new specs —
one plotting Sol to Sirius, one narrowing the range until there is no route and
accepting the one it names — design detector clean, screenshots at 1440x900
and 390x844.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jxMkwA2rbicdGxHosecYi
2026-08-20 18:39:22 +02:00

247 lines
8.5 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. */
/* `backwards`, not `both`: `both` leaves the final keyframe applied for good, and that keyframe
* carries a clip-path — which goes on clipping the panel's own box forever after, so anything
* that has to escape it (a dropdown opening upward, a tooltip) is cut away and cannot even be
* clicked. `backwards` holds the opening frame before the wipe and lets the element return to
* its own styles after it. */
@utility hud-acquire {
animation: hud-acquire 380ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
}
@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;
}