Files
6krrt/admin/frontend/navbar.css
adlee-was-taken 242a01bf2c feat(admin): an open tab now notices the frontend changed under it
A page opened before a frontend edit keeps running the JavaScript it loaded.
It surfaced as a 422 about a field the shipped code no longer sends, which
reads as a backend bug and is not one.

It is also not an HTTP caching bug, and checking that first was worth the
time: every frontend route already sets Cache-Control: no-cache and
FileResponse supplies ETag/Last-Modified, so any page LOAD revalidates.
Verified against the live service. The tab simply never loads again, so
there is no request to attach a header to. The page has to ask instead.

GET /admin/api/frontend-version returns a digest over (name, st_mtime_ns,
st_size) for every served frontend file -- no contents read, since the
question is only whether the files moved. mtime is in there as well as size
because the case this exists for is a hand-edit during iteration, which
often does not change a length. admin._FRONTEND_FILES is pinned against the
directory listing, because a page added to the portal but not to that list
would be invisible to the poll -- the same failure, reopened.

navbar.js is the one file all eight pages load, so the client half lands
there once: capture the digest on load, re-check every 30s and whenever the
tab becomes visible again (the moment that matches the failure's shape), and
on a change show a fixed pill reading "This page is out of date" with a
Reload button. Fixed and hidden until it fires, so it shifts nothing; not a
slot in the navbar cluster, because appearing there would shove the status
dot and the profile switcher sideways, and the navbar scrolls away while the
reason to reload does not.

It never reloads by itself. An operator may be mid-edit in a profile modal
or holding a dirty config row, and discarding that silently costs more than
the staleness does.

The interval is a constant, not a config knob: the "every knob belongs in
config.yaml" rule is about config the router reads, and nothing in the
routing path depends on a browser timer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRQXz5SYZYVWscxS1QqF6U
2026-09-14 00:33:24 -04:00

141 lines
6.4 KiB
CSS

/* Shared navbar right-cluster styling: SSE dot + profile switcher.
Paired with navbar.js; same reason for living in one file rather than
eight copies. Follows plans/admin-design-standards.md -- glass surfaces,
desaturated translucent status colours, no neon. */
/* The navbar must own a stacking context ABOVE the page body, or its dropdowns
paint underneath the cards.
*
* Every card sets backdrop-filter, which creates a stacking context of its own
* at z-index auto -- so with the navbar also at auto, painting falls back to
* DOM order and the cards, being later, win. The profile menu then renders
* behind the spend total and the runway card.
*
* plans/admin-design-standards.md already recorded this for the warnings bell,
* and five pages carried the rule in their own <style>. The three that did not
* (controls, models, quota) had no navbar dropdown until the switcher put one
* on all eight. Declaring it here fixes those three and means a new page
* cannot forget it -- this file loads before each page's own <style>, and no
* page declares z-index on header.navbar, so nothing overrides it. */
header.navbar{position:relative;z-index:1030}
.nav-sse-dot{
display:inline-block;width:8px;height:8px;border-radius:999px;
background:#fbbf24;box-shadow:0 0 6px #fbbf24;
margin-right:10px;flex:0 0 auto;
transition:background .3s ease, box-shadow .3s ease;
}
/* The switcher reads as a control, not a badge: it is the one thing in this
cluster that changes routing when clicked. */
.nav-profile-btn{
display:inline-flex;align-items:center;gap:7px;
background:linear-gradient(135deg, rgba(59,130,246,.14), rgba(139,92,246,.10));
border:1px solid rgba(255,255,255,.10);border-radius:999px;
padding:4px 12px;color:var(--tblr-body-color);
font-size:.82rem;font-weight:600;line-height:1.3;white-space:nowrap;
backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);cursor:pointer;
}
.nav-profile-btn:hover{
background:linear-gradient(135deg, rgba(59,130,246,.22), rgba(139,92,246,.16));
border-color:rgba(255,255,255,.18);
}
.nav-profile-cap{
font-size:.62rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;
color:var(--tblr-secondary);
}
.nav-profile-caret{font-size:.7rem;color:var(--tblr-secondary)}
.nav-profile-menu-wrap{
min-width:280px;margin-top:12px!important;padding:6px;
background:rgba(31,41,55,.92);
backdrop-filter:blur(18px) saturate(140%);
-webkit-backdrop-filter:blur(18px) saturate(140%);
border:1px solid rgba(255,255,255,.08);
}
.nav-profile-row{display:flex;align-items:stretch;gap:2px;border-radius:6px}
.nav-profile-row:hover{background:rgba(255,255,255,.05)}
.nav-profile-row.active{background:rgba(59,130,246,.12)}
.nav-profile-pick{
flex:1 1 auto;display:flex;align-items:center;gap:8px;
background:none;border:0;color:var(--tblr-body-color);
padding:6px 8px;font-size:.84rem;text-align:left;cursor:pointer;border-radius:6px;
}
/* Fixed-width tick column so names align whether or not one is active --
without it the active row's name sits ~14px right of every other. */
.nav-profile-tick{width:14px;flex:0 0 14px;color:#4ade80;font-size:.8rem}
.nav-profile-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* The pin is a boot-default marker, deliberately quiet until it means
something: an unset pin is a dim outline, the set one is amber. */
.nav-profile-pin{
flex:0 0 auto;background:none;border:0;cursor:pointer;
padding:0 10px;font-size:.6rem;line-height:1;border-radius:6px;
color:rgba(255,255,255,.18);
}
.nav-profile-pin:hover{color:rgba(251,191,36,.75);background:rgba(255,255,255,.06)}
.nav-profile-pin.on{color:#fbbf24}
.nav-profile-foot{
margin-top:6px;padding:7px 9px;border-top:1px solid rgba(255,255,255,.07);
font-size:.72rem;line-height:1.5;color:var(--tblr-secondary);
}
.nav-profile-drift{color:#fbbf24}
.nav-profile-flash{padding:0 9px;font-size:.72rem;line-height:1.5}
/* ── Stale-page notice ──────────────────────────────────────────────────────
Fixed, NOT a slot in the navbar cluster, for two reasons. Appearing inside
the cluster would push the status dot and the profile switcher sideways, and
a notice that rearranges the page it is warning about is its own small
insult. And header.navbar scrolls away, while the reason to reload does not.
Same vocabulary as the rest of the portal: the glass surface of the profile
menu, and the amber the warnings bell and the drift label already use for
"true, and yours to act on". Nothing here is a full-width banner -- see
plans/admin-design-standards.md, subtle cues over neon alerts. */
.nav-stale{
position:fixed;z-index:1085;right:16px;bottom:16px;
display:none;align-items:center;gap:.55rem;
padding:.4rem .5rem .4rem .75rem;border-radius:999px;
background:rgba(31,41,55,.95);
backdrop-filter:blur(18px) saturate(140%);
-webkit-backdrop-filter:blur(18px) saturate(140%);
border:1px solid rgba(251,191,36,.35);
box-shadow:0 12px 30px -12px rgba(0,0,0,.7);
font-size:.8rem;line-height:1.3;white-space:nowrap;
color:var(--tblr-body-color);
}
.nav-stale.show{display:flex}
.nav-stale-dot{
width:8px;height:8px;border-radius:999px;flex:0 0 auto;
background:#fbbf24;box-shadow:0 0 6px #fbbf24;
}
.nav-stale-reload{
background:rgba(251,191,36,.16);border:1px solid rgba(251,191,36,.45);
color:#fbbf24;border-radius:999px;padding:2px 12px;
font-size:.78rem;font-weight:600;line-height:1.4;cursor:pointer;
}
.nav-stale-reload:hover{background:rgba(251,191,36,.26);color:#fcd34d}
.nav-stale-close{
background:none;border:0;cursor:pointer;padding:0 6px;
font-size:.9rem;line-height:1;color:rgba(255,255,255,.35);
}
.nav-stale-close:hover{color:rgba(255,255,255,.7)}
/* Narrow enough that a corner pill would overhang: span the width instead of
letting it push <body> into a horizontal scroll (the Standards doc's bug). */
@media (max-width: 575.98px){
.nav-stale{left:16px;right:16px;justify-content:center;white-space:normal}
}
/* Below the navbar's own collapse point the cluster is already tight; the
caption is the first thing to go, then the profile name truncates rather
than pushing the cluster past the right edge (the <body>-scroll bug the
Standards doc records). */
@media (max-width: 899.98px){
.nav-profile-cap{display:none}
.nav-profile-btn{max-width:150px}
#nav-profile-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
}