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
141 lines
6.4 KiB
CSS
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}
|
|
}
|