radio / src /app.css
tfrere's picture
tfrere HF Staff
Cursor
feat(ui): use icon in unlock modal with Reachy Radio title
f4f6c4e
Raw History Blame Contribute Delete
27.9 kB
/* Everything specific to the radio faceplate: chassis, dial, knobs, hero and
the settings modal. Loaded from `dispatch.ts` right after `theme.css`, which
holds the tokens and the reusable primitives these rules build on. */
/* App shell: center the radio chassis */
.radio-app {
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.25rem 1rem max(1.25rem, env(safe-area-inset-bottom));
padding-top: max(1.25rem, env(safe-area-inset-top));
}
/* Single old-school radio faceplate */
.radio-chassis {
position: relative; /* anchors the app icon and the two head buttons */
width: 100%;
max-width: 380px;
/* The icon straddles the top edge, so keep its upper half off the bezel. */
margin-top: 3.5rem;
padding: 1.1rem 1.15rem 1rem;
border-radius: 18px;
background: var(--surface);
border: 1px solid var(--border);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
/* App icon, half out of the faceplate, same idea as the unlock card. */
.chassis-badge {
position: absolute;
top: 0;
left: 50%;
width: 150px;
height: 150px;
/* Less than half its height above the edge, so it sits into the card
rather than balancing on it. */
transform: translate(-50%, -64%);
z-index: 2;
pointer-events: none;
}
/* The icon and the trace share the badge's box, so the trace can be drawn
in the illustration's own coordinates. */
.chassis-icon,
.chassis-trace {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.chassis-trace[hidden] {
display: none;
}
/* Loud peaks reach past the antenna tips, i.e. outside the viewBox, and
the default would clip them flat. */
.chassis-trace {
overflow: visible;
}
/* Seismograph in the gap between the two antennas, fed by the same mixed
bus as the bars beside the title. */
.chassis-trace path {
/* Closed outline, so the body between the two mirrored crests can carry a
wash. Kept faint: at this size a solid shape reads as a smudge and
swallows the serration on the crests. */
fill: var(--primary-tint);
stroke: color-mix(in srgb, var(--primary) 85%, transparent);
stroke-width: 2.1px;
stroke-linecap: round;
stroke-linejoin: round;
/* Thin line whatever the icon is scaled to. */
vector-effect: non-scaling-stroke;
}
/* The centred title needs the whole line, so the buttons take the corners.
Doubled up with `.icon-btn` on purpose: the base sizes further down would
otherwise win on order alone. */
.icon-btn.head-btn {
position: absolute;
top: 1.1rem;
z-index: 3;
width: 2.5rem;
height: 2.5rem;
border-radius: 10px;
}
/* Both head icons are sized here rather than per button, so the sun and the
cog can never end up on different scales. */
.icon-btn.head-btn svg {
display: block;
width: 1.35rem;
height: 1.35rem;
}
/* Takes the target to the 44px minimum without taking the chrome there too:
at that size the outlines would read as buttons competing with the CTA. */
.head-btn::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 44px;
height: 44px;
transform: translate(-50%, -50%);
}
.head-btn-left {
left: 1.15rem;
}
.head-btn-right {
right: 1.15rem;
}
.chassis-head {
/* Clears the part of the icon that hangs inside the card. */
padding-top: 3.6rem;
margin-bottom: 0.8rem;
}
.now-playing {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
text-align: center;
/* Holds the worst case (description wrapping to two lines) so the knobs
below never shift when the text changes. */
min-height: 6rem;
min-width: 0;
}
.now-playing .np-kicker {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--muted);
min-height: 0.85em;
display: block;
}
.now-playing .np-title-row {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 2.05rem;
min-width: 0;
width: fit-content;
max-width: 100%;
}
.now-playing .np-name {
font-size: 1.7rem;
font-weight: 800;
letter-spacing: -0.01em;
line-height: 1.12;
min-height: 0;
flex: 0 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Sized against the blurb it trails, not the title. */
.np-spinner {
flex-shrink: 0;
width: 11px;
height: 11px;
border: 1.5px solid rgba(255, 255, 255, 0.1);
border-top-color: var(--muted);
border-radius: 50%;
animation: np-spin 0.75s linear infinite;
}
.np-spinner[hidden] {
display: none;
}
[data-theme="light"] .np-spinner {
border-color: rgba(0, 0, 0, 0.07);
border-top-color: var(--muted);
}
@keyframes np-spin {
to {
transform: rotate(360deg);
}
}
.now-playing .np-name.off-dial {
color: var(--muted);
font-weight: 700;
}
.now-playing .np-name.idle {
color: var(--muted);
opacity: 0.85;
}
/* The blurb and, while the stream buffers, the spinner that trails it. */
.now-playing .np-blurb {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-width: 0;
max-width: 100%;
}
/* Carries the track title when the station publishes one, the tag list
otherwise, so it is sized for either. */
.now-playing .np-genre {
min-width: 0;
font-size: 0.88rem;
color: var(--muted);
line-height: 1.3;
/* Even out the two lines rather than leaving a one-word orphan. */
text-wrap: balance;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
/* Controls: knobs centered, linear dial at bottom */
.controls-panel {
display: flex;
flex-direction: column;
/* Same value as the gap between the two knob panels, so the three
panels sit on one rhythm. */
gap: 1rem;
}
.knobs-row {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
/* One block per knob. No surface of its own: the knobs sit straight on the
chassis, so the dial stays the only recessed panel on the faceplate.
Horizontal padding stays thin on purpose: two 150px knobs leave only
about 43px of slack inside the chassis, and each wrap already carries
16px of empty space either side of the knob itself. */
.knob-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.18rem;
padding: 0.5rem 0.3rem 0.6rem;
}
.knob-label {
font-size: 0.52rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--muted);
min-height: 0.85em;
margin-top: -0.55rem;
}
.knob-wrap {
position: relative;
flex-shrink: 0;
}
.knob-wrap-tune,
.knob-wrap-vol {
width: 150px;
height: 150px;
}
.knob-scale {
position: absolute;
pointer-events: none;
z-index: 0;
}
.knob-wrap-tune .knob-scale,
.knob-wrap-vol .knob-scale {
width: 150px;
height: 150px;
left: 0;
top: 0;
}
.knob-tick {
position: absolute;
left: 50%;
top: 1px;
width: 1px;
height: 7px;
margin-left: -0.5px;
transform-origin: 50% var(--tick-r, 74px);
/* Mixed toward the chassis, the surface they are actually drawn against. */
background: color-mix(in srgb, var(--muted) 60%, var(--surface));
opacity: 0.55;
border-radius: 1px;
}
.knob-tick.station {
height: 8px;
opacity: 0.7;
}
.knob {
position: absolute;
z-index: 1;
border-radius: 50%;
cursor: grab;
touch-action: none;
user-select: none;
background: var(--primary-tint);
border: 1px solid var(--primary);
}
.knob-wrap-tune .knob,
.knob-wrap-vol .knob {
width: 110px;
height: 110px;
left: 20px;
top: 20px;
}
/* outer ring — flat, no gradient */
.knob::before {
content: "";
position: absolute;
inset: -4px;
border-radius: 50%;
border: 1px solid var(--border);
background: transparent;
z-index: -1;
}
.knob:active {
cursor: grabbing;
background: var(--primary-tint);
}
.knob:hover {
background: var(--primary-tint);
border-color: var(--primary);
}
.knob:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 5px;
}
/* rotating face carrying the pointer mark */
.knob-face {
position: absolute;
inset: 0;
border-radius: 50%;
transform: rotate(0deg);
transition: transform 0.09s linear;
}
.knob-mark {
position: absolute;
left: 50%;
top: 7px;
width: 2.5px;
height: 18px;
transform: translateX(-50%);
border-radius: 3px;
background: var(--primary);
}
/* Frequency dial — light inset panel + station ticks */
.radio-tuner {
display: flex;
flex-direction: column;
}
.tuner-track {
position: relative;
height: 70px;
cursor: pointer;
touch-action: none;
user-select: none;
background: var(--inset-surface);
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border);
}
.tuner-track:focus-visible {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
.tuner-scale {
position: absolute;
inset: 0;
pointer-events: none;
}
.tuner-fine-tick {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
transform: translateX(-50%);
background: color-mix(in srgb, var(--inset-muted) 55%, var(--inset-surface));
opacity: 0.35;
border-radius: 0;
}
.tuner-station-tick {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 2px;
transform: translateX(-50%);
background: var(--inset-fg);
opacity: 0.1;
border-radius: 0;
z-index: 1;
}
.tuner-station-tick.active {
background: var(--primary);
opacity: 0.6;
}
/* Names sit inside the panel, low enough to clear the needle's head while
the ticks keep running behind them. */
.tuner-labels {
position: absolute;
inset: auto 0 0.3rem;
height: 0.75rem;
pointer-events: none;
z-index: 3;
}
.tuner-label {
position: absolute;
top: 0;
transform: translateX(-50%);
font-size: 0.58rem;
/* Same weight active or not: the colour already says which station is
tuned, and reflowing the glyphs on lock made the whole row twitch. */
font-weight: 700;
letter-spacing: 0.02em;
color: var(--inset-muted);
white-space: nowrap;
/* Names are trimmed to their slot in JS, ellipsis included: a CSS
ellipsis is its own text run and its halo would sit on top of the
last letter. See `fitTunerLabels`. */
/* Halo in the panel's own colour rather than a literal white, so the
names stay legible over a tick or the needle in either theme. Stroke
first then fill, or the outline would eat into the glyphs. */
-webkit-text-stroke: 9px var(--inset-surface);
paint-order: stroke fill;
}
.tuner-label.active {
color: var(--primary);
}
/* Tuning needles: target (orange) + actual antenna (gray) until reconciled */
.tuner-needle {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
transform: translateX(-50%);
border-radius: 0;
pointer-events: none;
}
.tuner-needle-target {
width: 3px;
background: var(--primary);
opacity: 1;
z-index: 2;
transition: left 0.06s linear;
}
/*
* While a finger owns the needle, the finger is the continuity, and the
* transition can only ever trail it: restarted on every frame it never
* completes, so the needle sits a speed-dependent distance behind.
*/
.tuner-needle-target.is-dragging {
transition: none;
}
.tuner-needle-actual {
width: 2px;
background: var(--primary);
opacity: 0.5;
z-index: 3;
transition: left 0.14s linear;
}
.tuner-needle-actual[hidden] {
display: none;
}
/* Gesture hint + status */
.gesture {
width: 100%;
max-width: 380px;
margin: 0.65rem 0 0;
font-size: 0.68rem;
color: var(--muted);
text-align: center;
line-height: 1.4;
}
.gesture b {
color: var(--fg);
font-weight: 600;
}
/* Shared overlay chrome (start unlock + settings): `hidden` gates display,
`.is-open` drives the fade; the first child gets a soft rise/scale.
Open/close lifecycle lives in overlay.ts. */
.overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.28s ease;
}
.overlay[hidden] {
display: none;
}
.overlay.is-open {
opacity: 1;
}
.overlay > :first-child {
transform: translateY(10px) scale(0.98);
transition: transform 0.28s ease;
}
.overlay.is-open > :first-child {
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.overlay,
.overlay > :first-child {
transition: none;
}
}
/* Autoplay unlock overlay. Mounted already open (`is-open` in the markup) so
the dimmed backdrop covers the app from the very first paint - no flash of
the un-dimmed UI. Only the card animates in, as a one-shot. */
.unlock-overlay {
z-index: 100;
padding: 1.5rem;
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(12px);
}
.unlock-overlay .unlock-stack {
animation: overlay-rise 0.32s ease both;
}
@keyframes overlay-rise {
from {
opacity: 0;
transform: translateY(10px) scale(0.98);
}
}
@media (prefers-reduced-motion: reduce) {
.unlock-overlay .unlock-stack {
animation: none;
}
}
.unlock-stack {
position: relative;
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
align-items: center;
}
.unlock-icon {
display: block;
width: 140px;
height: 140px;
margin-bottom: -50px;
position: relative;
z-index: 2;
border: 0;
border-radius: 0;
box-shadow: none;
}
.unlock-title {
margin: 0 0 0.7rem;
font-size: 1.15rem;
font-weight: 700;
color: var(--fg);
}
.unlock-card {
width: 100%;
padding: 4.4rem 1.35rem 1.4rem;
border-radius: var(--radius);
background: var(--surface);
border: 1px solid var(--border);
text-align: center;
}
.unlock-copy {
margin: 0 0 1rem;
font-size: 0.82rem;
line-height: 1.45;
color: var(--muted);
}
.unlock-copy b {
color: var(--fg);
font-weight: 600;
}
.unlock-antennas {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.7rem;
margin: 0 0 1.2rem;
}
.ant-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 0.45rem;
padding: 0.75rem 0.6rem 0.7rem;
border: 1px solid var(--border);
border-radius: 14px;
text-align: center;
}
.ant-diag {
width: 80px;
height: auto;
flex-shrink: 0;
}
.diag-ap {
fill: none;
stroke-width: 10;
stroke-linejoin: round;
}
.diag-ap-off {
stroke: color-mix(in srgb, var(--muted) 34%, transparent);
}
.diag-ap-on {
stroke: var(--fg);
}
.diag-ghost {
fill: none;
stroke: var(--primary);
stroke-width: 8;
stroke-linecap: round;
stroke-dasharray: 2 15;
opacity: 0.5;
}
.diag-arc {
fill: none;
stroke: var(--primary);
stroke-width: 8;
stroke-linecap: round;
}
.diag-arrow {
fill: var(--primary);
opacity: 0.4;
}
.diag-deg {
fill: var(--primary);
font-family: inherit;
font-size: 38px;
font-weight: 800;
letter-spacing: -0.5px;
}
.diag-sub {
fill: var(--primary);
font-family: inherit;
font-size: 46px;
font-weight: 800;
}
.ant-card-text {
font-size: 0.78rem;
line-height: 1.32;
color: var(--muted);
}
.ant-card-text b {
color: var(--fg);
font-weight: 700;
}
/* The kit's outlined primary button stretched into a full-width call to action.
Footprint only: the type scale, the colours, the hover tint and the press all
stay the kit's, so this button cannot drift from every other primary. */
.unlock-cta {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.65rem;
width: 100%;
min-height: 3.25rem;
padding: 1.15rem 1rem;
border-radius: 14px;
}
/* Waiting on the browser to hand us audio. The accent goes away entirely: an
orange border on something you cannot press reads as the thing to press.
Kept at full opacity though — the label and spinner still have to be read. */
.unlock-cta:disabled {
opacity: 1;
cursor: wait;
border-color: var(--border);
color: var(--muted);
}
.unlock-cta-spinner {
/* Sized in em so it tracks the label rather than the root. */
width: 1.1em;
height: 1.1em;
/* Follows the label, so it is primary while offered and grey while waiting. */
border: 2px solid color-mix(in srgb, currentColor 22%, transparent);
border-top-color: currentColor;
border-radius: 50%;
animation: np-spin 0.75s linear infinite;
flex-shrink: 0;
}
.unlock-cta-spinner[hidden] {
display: none;
}
.icon-btn {
appearance: none;
border: 0;
background: transparent;
color: var(--muted);
font-size: 1.35rem;
line-height: 1;
width: 2rem;
height: 2rem;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-btn:hover {
color: var(--fg);
background: var(--surface-2);
}
/* Modal close: primary outlined, same family as the cog that opened it. */
.icon-btn.modal-close {
color: var(--primary);
border: 1px solid var(--primary);
}
.icon-btn.modal-close:hover {
color: var(--primary);
background: var(--primary-tint);
}
.icon-btn.cog-btn {
flex-shrink: 0;
color: var(--primary);
border: 1px solid var(--primary);
background: transparent;
}
/* Rainy-mood toggle — icon button matching the cog, sun ⇄ rain cloud. */
.icon-btn.rain-btn {
flex-shrink: 0;
color: var(--muted);
border: 1px solid var(--border);
background: transparent;
transition:
color 0.28s ease,
border-color 0.28s ease,
background 0.28s ease;
}
.icon-btn.rain-btn svg {
transform-origin: center;
will-change: transform;
overflow: visible;
}
.icon-btn.rain-btn:hover:not(:disabled) {
color: var(--fg);
border-color: color-mix(in srgb, var(--primary) 45%, var(--border));
background: var(--surface-2);
}
.icon-btn.rain-btn.toggled {
color: var(--primary);
border-color: var(--primary);
background: var(--primary-tint);
}
/* While switching: locked (no re-clicks), animation still plays. */
.icon-btn.rain-btn:disabled {
cursor: default;
opacity: 0.9;
}
/* Waiting on the rain stream: the whole button reads as disabled - grey
ring, grey border, no tint - so nothing invites a press until it lands. */
.icon-btn.rain-btn.loading {
color: var(--muted);
border-color: var(--border);
background: transparent;
}
/* Shown only once the rain stream has kept the bed silent for a while
(see RAIN_STALL_GRACE_MS): a switch that connects promptly goes from
the sun straight to the cloud without it. */
.icon-btn.rain-btn .rain-spinner {
display: none;
width: 17px;
height: 17px;
border: 2px solid rgba(255, 255, 255, 0.14);
border-top-color: currentColor;
border-radius: 50%;
animation: np-spin 0.7s linear infinite;
}
[data-theme="light"] .icon-btn.rain-btn .rain-spinner {
border-color: rgba(0, 0, 0, 0.08);
border-top-color: currentColor;
}
.icon-btn.rain-btn.loading svg {
display: none;
}
.icon-btn.rain-btn.loading .rain-spinner {
display: block;
}
/* Sun ⇄ rain-cloud morph. Sun is the "off" face, cloud the "on" face;
each scales around its own bbox so the swap feels springy. */
.icon-btn.rain-btn .ico-sun,
.icon-btn.rain-btn .ico-cloud {
transform-box: fill-box;
transform-origin: center;
transition:
opacity 0.3s ease,
transform 0.42s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.icon-btn.rain-btn .ico-sun {
opacity: 1;
transform: scale(1) rotate(0deg);
}
.icon-btn.rain-btn .ico-cloud {
opacity: 0;
transform: scale(0.35);
}
.icon-btn.rain-btn.toggled .ico-sun {
opacity: 0;
transform: scale(0.35) rotate(-90deg);
}
.icon-btn.rain-btn.toggled .ico-cloud {
opacity: 1;
transform: scale(1);
}
/* Rain drops: still by default, falling loop while the mood is on. */
.icon-btn.rain-btn .rain-drop {
transform-box: fill-box;
transform-origin: center;
}
.icon-btn.rain-btn.toggled .rain-drop {
animation: rain-fall 1.15s cubic-bezier(0.55, 0.1, 0.6, 1) infinite;
animation-delay: calc(var(--i) * -0.38s);
}
@keyframes rain-fall {
0% {
opacity: 0;
transform: translateY(-3px);
}
22% {
opacity: 1;
}
68% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(4px);
}
}
@media (prefers-reduced-motion: reduce) {
.icon-btn.rain-btn .rain-drop {
animation: none !important;
}
.icon-btn.rain-btn .ico-sun,
.icon-btn.rain-btn .ico-cloud {
transition: opacity 0.2s ease !important;
transform: none !important;
}
}
.icon-btn.cog-btn:hover {
color: var(--primary);
background: var(--primary-tint);
}
.btn.ghost {
background: transparent;
padding: 0.32rem 0.7rem;
font-size: 0.78rem;
}
/* Settings modal */
.modal-overlay {
z-index: 50;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(6px);
padding: 0.75rem;
}
.modal {
width: 100%;
max-width: 380px;
max-height: min(92dvh, 760px);
display: flex;
flex-direction: column;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 1.1rem 1.1rem 0.6rem;
}
.modal-head h2 {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
}
/* Reset link rides the header, tucked against the close button. */
.btn-link {
appearance: none;
border: 0;
background: transparent;
font: inherit;
font-size: 0.72rem;
color: var(--primary);
cursor: pointer;
padding: 0.2rem 0;
margin-left: auto;
text-decoration: underline;
text-underline-offset: 2px;
}
.btn-link:hover {
color: color-mix(in srgb, var(--primary) 78%, var(--fg));
}
.modal-body {
flex: 1;
overflow-y: auto;
min-height: 0;
padding: 0.2rem 1.1rem 1.1rem;
}
.modal-block {
padding: 0.85rem 0;
}
.modal-block + .modal-block {
border-top: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
}
.modal-block-stations {
min-height: 0;
}
.modal-block-title {
margin: 0 0 0.6rem;
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
}
.modal-count {
font-weight: 600;
letter-spacing: 0;
}
/* Anchor for the results dropdown. The corner radius is a variable so the
open/closed corner juggling below follows the size variant for free. */
.search-group {
position: relative;
--bar-radius: 8px;
}
.search-group.big {
--bar-radius: 10px;
}
/* Results as a floating dropdown glued to the input group (see dropdown.ts):
same width as the bar, borders collapsed with it, only the bottom corners
rounded - the bar flattens its own bottom corners while open (below). */
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
/* One shared 1px line between the bar and the panel. */
margin-top: -1px;
z-index: 5;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0 0 var(--bar-radius) var(--bar-radius);
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.32);
padding: 0.3rem 0.75rem;
/* Kept modest so the panel stays inside the modal instead of getting
clipped by its rounded bottom edge; longer result sets scroll. */
max-height: min(30vh, 240px);
overflow-y: auto;
}
.search-results[hidden] {
display: none;
}
/* Airier rows inside the dropdown than in the stations list. */
.search-results .list-row {
padding: 0.66rem 0.1rem;
}
/* While the panel is out, the bar's bottom corners go square so the two
read as one attached control. */
.search-group.is-open .search-bar input[type="search"] {
border-radius: var(--bar-radius) 0 0 0;
}
.search-group.is-open .search-bar .btn {
border-radius: 0 var(--bar-radius) 0 0;
}
.setting-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.7rem;
min-width: 0;
}
.setting-label {
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
}
.setting-value {
font-size: 0.68rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--muted);
min-width: 2.1rem;
text-align: right;
}
/* Input group: the field and its button share an edge (collapsed border),
only the outer corners are rounded. */
.search-bar {
display: flex;
align-items: stretch;
}
/* Wraps the input so the clear cross can sit inside it. */
.search-field {
position: relative;
flex: 1;
min-width: 0;
display: flex;
}
.search-bar input[type="search"] {
width: 100%;
min-width: 0;
appearance: none;
font: inherit;
font-size: 0.82rem;
color: var(--fg);
background: transparent;
border: 1px solid var(--border);
border-radius: var(--bar-radius) 0 0 var(--bar-radius);
/* Room on the right for the clear cross. */
padding: 0.45rem 2rem 0.45rem 0.75rem;
outline: none;
}
/* Big variant: pairs with .btn-big on the submit button (see theme.css). */
.search-group.big .search-bar input[type="search"] {
font-size: 0.92rem;
padding: 0.62rem 2.35rem 0.62rem 0.9rem;
}
.search-group.big .search-clear {
width: 1.8rem;
height: 1.8rem;
right: 0.5rem;
}
.search-group.big .search-clear svg {
width: 15px;
height: 15px;
}
/* The platform's own cancel blob (emoji-looking on some systems) is replaced
by our SVG cross below. */
.search-bar input[type="search"]::-webkit-search-cancel-button,
.search-bar input[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
appearance: none;
}
.search-bar input[type="search"]:focus {
border-color: var(--primary);
position: relative;
z-index: 1;
}
.search-clear {
position: absolute;
top: 50%;
right: 0.4rem;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 1.55rem;
height: 1.55rem;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--muted);
cursor: pointer;
/* Above the focused input (which raises itself to z-index 1). */
z-index: 2;
}
.search-clear:hover {
color: var(--fg);
background: var(--surface-2);
}
.search-clear[hidden] {
display: none;
}
.search-bar .btn {
border-radius: 0 var(--bar-radius) var(--bar-radius) 0;
margin-left: -1px;
}
.btn-compact {
padding: 0.38rem 0.72rem;
font-size: 0.76rem;
white-space: nowrap;
}
/* Grows with its content: the station list itself never scrolls (the modal
body is the single scroll container when space runs out). */
.station-list {
margin: 0 -0.15rem;
}
.list-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.48rem 0.15rem;
border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
}
.list-row:last-child {
border-bottom: 0;
}
.station-row {
cursor: grab;
touch-action: none;
}
.station-row:active {
cursor: grabbing;
}
.station-row.is-dragging {
opacity: 0.35;
}
.station-row.drop-target {
box-shadow: inset 0 2px 0 var(--primary);
}
.drag-handle {
flex-shrink: 0;
display: flex;
align-items: center;
color: var(--muted);
opacity: 0.55;
pointer-events: none;
}
.station-row:hover .drag-handle {
opacity: 0.9;
}
.row-info {
display: flex;
flex-direction: column;
gap: 0.04rem;
min-width: 0;
flex: 1;
}
.row-name {
font-size: 0.8rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Station names are edited in place: a bare input dressed as the name,
with a whisper of chrome on hover/focus so it reads as editable. */
.row-name-input {
appearance: none;
font: inherit;
font-size: 0.8rem;
font-weight: 600;
color: var(--fg);
background: transparent;
border: 0;
border-radius: 6px;
/* Negative margin keeps the text flush with the meta line below. */
padding: 0.08rem 0.3rem;
margin-left: -0.3rem;
width: 100%;
min-width: 0;
outline: none;
}
.row-name-input:hover {
background: var(--surface-2);
}
.row-name-input:focus {
background: var(--surface-2);
box-shadow: inset 0 0 0 1px var(--border);
}
.row-meta {
font-size: 0.64rem;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-action {
flex-shrink: 0;
}
.list-empty {
margin: 0;
padding: 0.35rem 0.15rem;
font-size: 0.72rem;
color: var(--muted);
}
.icon-btn-sm {
width: 1.5rem;
height: 1.5rem;
font-size: 1rem;
line-height: 1;
flex-shrink: 0;
}
.btn.blocked {
color: var(--muted);
border-color: var(--border);
cursor: not-allowed;
}