Four defects, all found by the owner on the live router, all of the same family: something declared itself working while it was not. WIREGUARD WAS DEAD IN THE SHIPPED BINARY (B17). Setting up WireGuard gave "create WireGuard device: gVisor is not included in this build". The router tag set carried with_wireguard and with_awg but not with_gvisor, so sing-tun compiled its stub instead of the netstack every WireGuard device needs. FEATURES.md marks WireGuard [MVP] and AmneziaWG "a driving requirement", so this was a broken promise, not a trim. The tag itself was the small half. The tag set was the ONE build configuration nothing in the repo tested: TestAmneziaWGEndpoint passes because tests build with the full upstream tags. So the set now lives in one file (scripts/router-tags.sh) and two guards hold it to the feature list -- a static check that needs no tags, no Linux and no network (so the next such gap fails on the developer's machine), and a behavioural one that constructs every declared protocol through box.New UNDER THE SHIPPED TAGS, where skipping is forbidden. Removing the tag now fails with the feature name, the missing tag, and why: "Either add the tag back, or stop declaring the feature -- those are the only two honest options." Cost: +2.8 MB raw, +0.6-0.7 MB packed per arch. D23; D9 corrected. THE PANEL CALLED A DIRECT-ONLY ROUTER "PROTECTED" (B16). The headline came from plane === 'full', which reports whether the data plane is installed -- nft table, policy routing, live engine -- and says nothing about where the traffic goes. On a config with one `default -> direct` rule and no groups the plane is fully installed and every packet leaves in the clear, so the worst possible state rendered as the reassuring one. The verdict is now computed on the daemon FROM THE GENERATED OPTIONS at the moment they reach the engine, not from the model: buildRoute changes the answer (a scheduled rule outside its window is never emitted, only the last condition-less rule reaches Final, an unresolved target is rewritten by ruleKillFallback), and re-deriving it anywhere else is a second implementation that will drift -- model/reachability.go exists because two already did. Four verdicts, not three: `blocked` is separate because under a closed kill-switch with no catch-all nothing leaks, and calling that "going out directly" is a lie in the alarm direction. Rider: Overview's defaultTarget printed the highest-Order enabled rule as the default; a rule becomes Final by having no conditions, whatever its Order. "PREVENT THIS PAGE FROM CREATING ADDITIONAL DIALOGS" KILLED EVERY DELETE (B15). Once the browser suppresses dialogs, window.confirm returns false immediately, so all 15 confirmations across 7 pages read as "cancelled" and silently did nothing, with no way to recover from inside the panel. Replaced with an in-app dialog the browser cannot mute: focus trapped and parked on Cancel, Esc and veil cancel, focus returned to the opener, crit styling for destructive commits. useConfirm() throws if the provider is missing rather than falling back to a quiet false -- the failure mode being fixed. HYSTERIA2 AND TUIC NODES WERE DROPPED (B6). No share-link parser existed, so a feed's nodes of those types vanished. The real landmine was one layer up: ParseSubscriptionBody splits a feed by scheme prefix before parsing, so without schemePrefixes the links were gone before any parser ran and the fix would have looked complete. Undeliverable parameters are refused when the node cannot work or would be less secure than the link asked (obfs, pinSHA256, tuic v4/non-UUID) and flagged via Proxy.Warnings when it survives -- shaterd nodes shows both. uTLS is dropped for QUIC: it cannot produce a QUIC TLS config, and that fails at dial time, not at box.New. Also: nodes added by hand can be named and renamed. The name is the outbound tag, so a rename rewrites every reference in one PUT -- rule targets, group members, chain hops, detours -- in the spelling each already uses, and is refused outright when a group answers to the same bare name. Subscription nodes state why they cannot be renamed instead of hiding the control. go build, go vet, go test ./shater/... (13 packages), panel npm run build and npm test (13/13) all green. NOT yet verified on hardware. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4PcWfrBRyg4eWN58axaGN
780 lines
16 KiB
CSS
780 lines
16 KiB
CSS
/* Nodes & Subscriptions page — inherits the Faceplate tokens and the shared
|
|
* page chrome from App.css (.page, .toast, .page-error, .linkish). Only the
|
|
* list/table + inline-editor specifics live here. Orange stays an accent. */
|
|
|
|
.nodes-page {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
/* ---- saved-but-not-applied banner ---- */
|
|
.apply-banner {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: calc(var(--u, 8px) * 1.5);
|
|
margin-top: calc(var(--u, 8px) * 2);
|
|
padding: 10px 12px 10px 14px;
|
|
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--groove));
|
|
border-radius: 8px;
|
|
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 9%, var(--raised)), var(--raised));
|
|
box-shadow: 0 1px 0 var(--edge) inset;
|
|
}
|
|
.apply-banner-msg {
|
|
flex: 1;
|
|
min-width: 0;
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
letter-spacing: 0.02em;
|
|
color: var(--ink);
|
|
}
|
|
|
|
/* ---- section ---- */
|
|
.node-section {
|
|
margin-top: calc(var(--u, 8px) * 3.5);
|
|
}
|
|
.sec-hd {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 12px;
|
|
padding-bottom: 10px;
|
|
border-bottom: 1px solid var(--groove);
|
|
}
|
|
.sec-title {
|
|
margin: 0;
|
|
font-family: var(--font-mono);
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
letter-spacing: var(--track-label);
|
|
text-transform: uppercase;
|
|
color: var(--dim);
|
|
}
|
|
.sec-count {
|
|
font-size: 11px;
|
|
letter-spacing: 0.06em;
|
|
color: var(--faint);
|
|
}
|
|
/* Where health went, and why. This page is inventory; reachability is measured
|
|
per group, on the path that group actually dials. */
|
|
.node-note {
|
|
margin: 10px 2px 0;
|
|
font-family: var(--font-sans);
|
|
font-size: 12.5px;
|
|
line-height: 1.55;
|
|
color: var(--dim);
|
|
max-width: 68ch;
|
|
}
|
|
|
|
/* ---- add row (inline editor) ---- */
|
|
.add-row {
|
|
display: flex;
|
|
gap: calc(var(--u, 8px) * 1.25);
|
|
margin-top: calc(var(--u, 8px) * 2);
|
|
}
|
|
.add-row--sub {
|
|
flex-wrap: wrap;
|
|
}
|
|
.fp-input {
|
|
flex: 1;
|
|
min-width: 0;
|
|
padding: 9px 12px;
|
|
border: 1px solid var(--groove);
|
|
border-radius: 7px;
|
|
background: var(--sink);
|
|
color: var(--ink);
|
|
font-family: var(--font-mono);
|
|
font-size: 12.5px;
|
|
letter-spacing: 0.02em;
|
|
box-shadow: 0 1px 2px var(--shadow) inset;
|
|
transition: border-color 0.15s, box-shadow 0.15s;
|
|
}
|
|
.fp-input::placeholder {
|
|
color: var(--faint);
|
|
}
|
|
.fp-input:focus-visible {
|
|
border-color: var(--accent);
|
|
outline: 2px solid var(--accent);
|
|
outline-offset: 1px;
|
|
}
|
|
.fp-input:disabled {
|
|
opacity: 0.55;
|
|
}
|
|
.fp-input--name {
|
|
flex: 0 1 12rem;
|
|
}
|
|
|
|
.field-err {
|
|
margin: 8px 2px 0;
|
|
font-family: var(--font-mono);
|
|
font-size: 11.5px;
|
|
line-height: 1.5;
|
|
color: var(--crit);
|
|
}
|
|
|
|
/* ---- rows list ---- */
|
|
.rows-list {
|
|
list-style: none;
|
|
margin: calc(var(--u, 8px) * 2) 0 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
.row-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: calc(var(--u, 8px) * 1.5);
|
|
padding: 12px 14px;
|
|
border: 1px solid var(--groove);
|
|
border-radius: 8px;
|
|
background: linear-gradient(
|
|
180deg,
|
|
var(--raised),
|
|
color-mix(in srgb, var(--raised) 82%, var(--panel))
|
|
);
|
|
box-shadow: 0 1px 0 var(--edge) inset;
|
|
}
|
|
.row-main {
|
|
flex: 1;
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
.row-line1 {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.row-name {
|
|
font-family: var(--font-mono);
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.01em;
|
|
color: var(--ink);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
max-width: 22ch;
|
|
}
|
|
.row-line2 {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
font-size: 11.5px;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
.row-host {
|
|
color: var(--dim);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
max-width: 30ch;
|
|
}
|
|
.row-origin {
|
|
color: var(--faint);
|
|
text-transform: lowercase;
|
|
}
|
|
|
|
/* protocol / format pill — groove-bordered, not orange (accent stays reserved) */
|
|
.badge {
|
|
display: inline-block;
|
|
padding: 2px 7px;
|
|
border: 1px solid var(--groove);
|
|
border-radius: 5px;
|
|
background: color-mix(in srgb, var(--sink) 60%, transparent);
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
color: var(--dim);
|
|
white-space: nowrap;
|
|
}
|
|
.badge--warn {
|
|
border-color: color-mix(in srgb, var(--amber) 55%, var(--groove));
|
|
color: var(--amber);
|
|
}
|
|
|
|
/* masked-credential marker */
|
|
.masked {
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
letter-spacing: 0.08em;
|
|
color: var(--faint);
|
|
text-transform: uppercase;
|
|
cursor: help;
|
|
}
|
|
|
|
/* per-row controls */
|
|
.row-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.row-del {
|
|
flex: none;
|
|
padding: 6px 12px;
|
|
font-size: 10.5px;
|
|
}
|
|
.row-lock {
|
|
flex: none;
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
color: var(--faint);
|
|
cursor: default;
|
|
}
|
|
|
|
/* (The per-node health LED + latency chip lived here. A node's base outbound is
|
|
not what a group bound to an egress dials, so that column reported one of a
|
|
node's several health states and called it the truth. Health is per group now:
|
|
see .gh-* in Targets.css.) */
|
|
|
|
/* ---- empty plate ---- */
|
|
.empty-plate {
|
|
margin-top: calc(var(--u, 8px) * 2);
|
|
padding: calc(var(--u, 8px) * 3);
|
|
border: 1px dashed var(--groove);
|
|
border-radius: 9px;
|
|
background: color-mix(in srgb, var(--raised) 55%, transparent);
|
|
text-align: center;
|
|
}
|
|
.empty-title {
|
|
display: block;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.06em;
|
|
color: var(--dim);
|
|
}
|
|
.empty-body {
|
|
margin: 8px auto 0;
|
|
max-width: 44ch;
|
|
font-family: var(--font-sans);
|
|
font-size: 13px;
|
|
line-height: 1.55;
|
|
color: var(--dim);
|
|
}
|
|
|
|
/* ---- loading skeleton ---- */
|
|
.skel-row {
|
|
height: 62px;
|
|
border: 1px solid var(--groove);
|
|
border-radius: 8px;
|
|
background: linear-gradient(90deg, var(--raised), var(--sink), var(--raised));
|
|
background-size: 200% 100%;
|
|
animation: skel-shift 1.4s ease-in-out infinite;
|
|
}
|
|
@keyframes skel-shift {
|
|
from {
|
|
background-position: 200% 0;
|
|
}
|
|
to {
|
|
background-position: -200% 0;
|
|
}
|
|
}
|
|
|
|
/* ---- expandable subscription / node row + options editor ---- */
|
|
.sub-row,
|
|
.node-row {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
.row-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: calc(var(--u, 8px) * 1.5);
|
|
}
|
|
.sub-row--open,
|
|
.node-row--open {
|
|
border-color: color-mix(in srgb, var(--accent) 32%, var(--groove));
|
|
}
|
|
|
|
/* collapsed-row egress binding marker — a muted mono chip so the "dial via
|
|
<egress>" pin is legible without expanding the row. Distinct from the plain
|
|
"via <sub>" origin text: bordered, tinted, and carries a hover explanation. */
|
|
.egress-chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 2px 7px;
|
|
border: 1px solid color-mix(in srgb, var(--accent) 26%, var(--groove));
|
|
border-radius: 5px;
|
|
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
|
font-family: var(--font-mono);
|
|
font-size: 10.5px;
|
|
letter-spacing: 0.04em;
|
|
color: var(--dim);
|
|
white-space: nowrap;
|
|
cursor: help;
|
|
}
|
|
|
|
/* one-field egress picker under a node row — leans on the shared .opt-field /
|
|
.opt-actions vocabulary from the subscription editor. */
|
|
.node-egress {
|
|
margin-top: 12px;
|
|
padding-top: 14px;
|
|
border-top: 1px solid var(--groove);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
}
|
|
|
|
.sub-options {
|
|
margin-top: 12px;
|
|
padding-top: 14px;
|
|
border-top: 1px solid var(--groove);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
}
|
|
|
|
/* a labelled group of options */
|
|
.opt-group {
|
|
margin: 0;
|
|
padding: 12px 12px 14px;
|
|
border: 1px solid var(--groove);
|
|
border-radius: 8px;
|
|
background: color-mix(in srgb, var(--sink) 45%, transparent);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
min-width: 0;
|
|
}
|
|
.opt-group:disabled {
|
|
opacity: 0.6;
|
|
}
|
|
.opt-legend {
|
|
padding: 0 6px;
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
letter-spacing: var(--track-label);
|
|
text-transform: uppercase;
|
|
color: var(--faint);
|
|
}
|
|
|
|
.opt-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
|
gap: 12px;
|
|
}
|
|
.opt-field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
min-width: 0;
|
|
}
|
|
.opt-field--wide {
|
|
grid-column: 1 / -1;
|
|
}
|
|
.opt-label {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
color: var(--dim);
|
|
}
|
|
.opt-hint {
|
|
font-family: var(--font-sans);
|
|
font-size: 11px;
|
|
line-height: 1.45;
|
|
color: var(--faint);
|
|
}
|
|
/* selects inherit .fp-input; give them a little room for the native arrow */
|
|
select.fp-input {
|
|
appearance: none;
|
|
padding-right: 26px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* extra-headers key/value rows */
|
|
.hdr-field {
|
|
gap: 8px;
|
|
}
|
|
.hdr-rows {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
.hdr-row {
|
|
display: grid;
|
|
grid-template-columns: minmax(9rem, 1fr) 2fr auto;
|
|
gap: 8px;
|
|
align-items: center;
|
|
}
|
|
.hdr-key {
|
|
font-family: var(--font-mono);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.hdr-x {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 18px;
|
|
height: 18px;
|
|
padding: 0;
|
|
border: none;
|
|
border-radius: 4px;
|
|
background: transparent;
|
|
color: var(--faint);
|
|
font-size: 15px;
|
|
line-height: 1;
|
|
cursor: pointer;
|
|
transition: color 0.15s, background 0.15s;
|
|
}
|
|
.hdr-x:hover:not(:disabled) {
|
|
color: var(--crit);
|
|
background: color-mix(in srgb, var(--crit) 14%, transparent);
|
|
}
|
|
.hdr-x:disabled {
|
|
opacity: 0.5;
|
|
cursor: default;
|
|
}
|
|
|
|
/* editor footer */
|
|
.opt-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 12px;
|
|
}
|
|
.opt-status {
|
|
font-size: 11px;
|
|
letter-spacing: 0.04em;
|
|
color: var(--faint);
|
|
}
|
|
|
|
/* ---- responsive ---- */
|
|
@media (max-width: 640px) {
|
|
.row-item {
|
|
flex-wrap: wrap;
|
|
}
|
|
.row-head {
|
|
flex-wrap: wrap;
|
|
}
|
|
.row-main {
|
|
flex-basis: calc(100% - 90px);
|
|
}
|
|
.row-actions,
|
|
.row-del,
|
|
.row-lock {
|
|
margin-left: auto;
|
|
}
|
|
.fp-input--name {
|
|
flex-basis: 100%;
|
|
}
|
|
.opt-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.skel-row {
|
|
animation: none;
|
|
}
|
|
}
|
|
|
|
/* ---- add-node: mode segmented control + textarea ---- */
|
|
.add-node {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
margin-top: calc(var(--u, 8px) * 2);
|
|
}
|
|
.add-node .add-row {
|
|
margin-top: 0;
|
|
}
|
|
|
|
.seg {
|
|
display: inline-flex;
|
|
align-self: flex-start;
|
|
gap: 4px;
|
|
padding: 3px;
|
|
border: 1px solid var(--groove);
|
|
border-radius: 8px;
|
|
background: var(--sink);
|
|
box-shadow: 0 1px 2px var(--shadow) inset;
|
|
}
|
|
.seg-btn {
|
|
padding: 6px 12px;
|
|
border: 1px solid transparent;
|
|
border-radius: 6px;
|
|
background: transparent;
|
|
color: var(--dim);
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
cursor: pointer;
|
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|
}
|
|
.seg-btn:hover:not(:disabled):not(.seg-btn--on) {
|
|
color: var(--ink);
|
|
}
|
|
.seg-btn--on {
|
|
background: color-mix(in srgb, var(--accent) 16%, var(--raised));
|
|
border-color: color-mix(in srgb, var(--accent) 40%, var(--groove));
|
|
color: var(--ink);
|
|
}
|
|
.seg-btn:focus-visible {
|
|
outline: 2px solid var(--accent);
|
|
outline-offset: 1px;
|
|
}
|
|
.seg-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: default;
|
|
}
|
|
|
|
.fp-textarea {
|
|
min-height: 96px;
|
|
resize: vertical;
|
|
line-height: 1.5;
|
|
padding: 10px 12px;
|
|
white-space: pre-wrap;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.add-row--conf {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
.add-row--conf .btn {
|
|
align-self: flex-end;
|
|
}
|
|
.add-hint {
|
|
margin: 0 2px;
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
line-height: 1.5;
|
|
color: var(--faint);
|
|
}
|
|
|
|
/* ---- node search ---- */
|
|
.node-search {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-top: calc(var(--u, 8px) * 2.5);
|
|
}
|
|
.search-count {
|
|
flex: none;
|
|
font-size: 11px;
|
|
letter-spacing: 0.06em;
|
|
color: var(--faint);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* ---- collapsible node groups ---- */
|
|
.node-groups {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
margin-top: calc(var(--u, 8px) * 2);
|
|
}
|
|
.node-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.group-hd-wrap {
|
|
margin: 0;
|
|
}
|
|
.group-hd {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 9px 12px;
|
|
border: 1px solid var(--groove);
|
|
border-radius: 8px;
|
|
background: color-mix(in srgb, var(--sink) 40%, transparent);
|
|
color: var(--dim);
|
|
cursor: pointer;
|
|
text-align: left;
|
|
transition: border-color 0.15s, background 0.15s;
|
|
}
|
|
.group-hd:hover {
|
|
border-color: color-mix(in srgb, var(--accent) 30%, var(--groove));
|
|
background: color-mix(in srgb, var(--sink) 60%, transparent);
|
|
}
|
|
.group-hd:focus-visible {
|
|
outline: 2px solid var(--accent);
|
|
outline-offset: 1px;
|
|
}
|
|
.node-group--open .group-hd {
|
|
border-bottom-left-radius: 0;
|
|
border-bottom-right-radius: 0;
|
|
border-bottom-color: transparent;
|
|
}
|
|
.group-caret {
|
|
flex: none;
|
|
width: 0;
|
|
height: 0;
|
|
border-left: 5px solid var(--faint);
|
|
border-top: 4px solid transparent;
|
|
border-bottom: 4px solid transparent;
|
|
transition: transform 0.15s ease;
|
|
}
|
|
.node-group--open .group-caret {
|
|
transform: rotate(90deg);
|
|
}
|
|
.group-name {
|
|
flex: 1;
|
|
min-width: 0;
|
|
font-family: var(--font-mono);
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
letter-spacing: var(--track-label);
|
|
text-transform: uppercase;
|
|
color: var(--dim);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.group-count {
|
|
flex: none;
|
|
font-size: 11px;
|
|
letter-spacing: 0.06em;
|
|
color: var(--faint);
|
|
}
|
|
.group-rows {
|
|
margin-top: 8px;
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.seg {
|
|
align-self: stretch;
|
|
}
|
|
.seg-btn {
|
|
flex: 1;
|
|
}
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.group-caret {
|
|
transition: none;
|
|
}
|
|
}
|
|
|
|
/* ---- provider-reported account state (quota + expiry) ----
|
|
Sits under the subscription row head as a quiet instrument strip. There is no
|
|
meter when the provider didn't report an allowance — a 0-of-0 bar would be a
|
|
fabricated fact, so that case reads as plain text instead. */
|
|
.sub-account {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px 28px;
|
|
padding: 10px 14px 12px 62px;
|
|
border-top: 1px solid var(--groove);
|
|
}
|
|
.sub-acct-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 3px;
|
|
min-width: 0;
|
|
}
|
|
.sub-acct-k {
|
|
font-family: var(--font-mono);
|
|
font-size: 10px;
|
|
letter-spacing: var(--track-label, 0.18em);
|
|
text-transform: uppercase;
|
|
color: var(--faint);
|
|
}
|
|
.sub-acct-v {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 12.5px;
|
|
color: var(--ink);
|
|
}
|
|
.sub-acct-sub {
|
|
font-size: 11px;
|
|
color: var(--faint);
|
|
}
|
|
.sub-acct-meter {
|
|
display: block;
|
|
width: 11rem;
|
|
max-width: 100%;
|
|
height: 5px;
|
|
margin-top: 2px;
|
|
border-radius: 3px;
|
|
background: var(--seg-off);
|
|
overflow: hidden;
|
|
}
|
|
.sub-acct-fill {
|
|
display: block;
|
|
height: 100%;
|
|
border-radius: 3px;
|
|
background: var(--led-on);
|
|
}
|
|
.sub-acct-fill--amber {
|
|
background: var(--amber);
|
|
}
|
|
.sub-acct-fill--crit {
|
|
background: var(--crit);
|
|
}
|
|
|
|
@media (max-width: 560px) {
|
|
.sub-account {
|
|
padding-left: 14px;
|
|
gap: 10px 18px;
|
|
}
|
|
.sub-acct-meter {
|
|
width: 9rem;
|
|
}
|
|
}
|
|
|
|
/* ---- inline node rename ----
|
|
The pencil / input pair itself is shared (.inline-rename[-input] in App.css);
|
|
only the row-local sizing and the refusal message live here. A node name is
|
|
longer than a device name (it carries a protocol and a host), so the field is
|
|
given more room than the shared 24ch default. */
|
|
.node-name-input {
|
|
max-width: 32ch;
|
|
font-family: var(--font-mono);
|
|
font-size: 12.5px;
|
|
}
|
|
/* Why a rename was refused, pinned under the row it was typed in. Semantic crit:
|
|
the name did not change, and that must not be mistaken for a saved edit. */
|
|
.row-err {
|
|
margin: 2px 0 0;
|
|
font-size: 11.5px;
|
|
line-height: 1.45;
|
|
color: var(--crit);
|
|
max-width: 68ch;
|
|
}
|
|
/* Stated once per subscription bucket: the same rule the locked control in every
|
|
row carries, so the absent rename is explained before it is looked for. */
|
|
.group-note {
|
|
margin: 0;
|
|
padding: 8px 12px;
|
|
border: 1px solid var(--groove);
|
|
border-top: 0;
|
|
background: color-mix(in srgb, var(--sink) 25%, transparent);
|
|
font-size: 11.5px;
|
|
line-height: 1.5;
|
|
color: var(--faint);
|
|
}
|
|
/* The optional name sits beside the link input on a wide row and drops onto its
|
|
own line when the row can no longer hold both. */
|
|
.add-name {
|
|
flex: 0 1 22ch;
|
|
min-width: 12ch;
|
|
}
|
|
.add-row--conf .add-name {
|
|
flex: none;
|
|
align-self: stretch;
|
|
}
|
|
@media (max-width: 640px) {
|
|
.add-row {
|
|
flex-wrap: wrap;
|
|
}
|
|
.add-name {
|
|
flex: 1 1 100%;
|
|
}
|
|
}
|