agent: meta-nav popout + header height fix + badge row consistency
mara, live review: 'the login card looks like it is behind the header', 'badges and interactive elements on same row have inconsistent size', 'the links should have the same popout as the links in the nav bar of swarm-ui.' All three, addressed: - Root cause of the overlap: MetaNav rendered every link inline in the header's title row, a variable-width list that can grow the row past the fixed --agent-header-h the rest of the page (position: fixed header, position: absolute content below it) is offset against, so the header visually covers whatever's underneath it once it's actually taller than assumed. Fix: MetaNav is now a single fixed-size trigger (reuses @hive/shared's Badge, icon-only, same shape as the header's existing overflow trigger) in the pills cluster, not a variable-width list in the title row. A single badge can't grow the row regardless of how many links the backend sends. Verified at 1024px/1400px (no overlap, was previously untested below 1400px) — the deeper fragility (StatusChips' badge row itself can still wrap on a genuinely narrow/mobile viewport and hit the same class of bug) is real but predates this PR and is out of scope here. - The popover mara asked for: same visual language as swarm-ui's own LinksMenu (Shell/LinksMenu.tsx) and this page's Dropdown/OverflowMenu — quiet icon trigger, popover on click. Not built from @hive/shared's Dropdown itself (its items are always <button>s for command dispatch, which would lose real <a> link semantics — ctrl/middle-click, copy
This commit is contained in:
parent
df78236ce4
commit
3ce961e5bd
5 changed files with 152 additions and 43 deletions
|
|
@ -89,9 +89,10 @@ export function Root() {
|
||||||
<>
|
<>
|
||||||
<HeaderPill kind="inbox" icon="📬" label="inbox" count={state?.inbox.length ?? 0} onClick={() => setOpenPanel('inbox')} />
|
<HeaderPill kind="inbox" icon="📬" label="inbox" count={state?.inbox.length ?? 0} onClick={() => setOpenPanel('inbox')} />
|
||||||
<HeaderPill kind="todos" icon="📋" label="todos" count={todos.length} onClick={() => setOpenPanel('todos')} />
|
<HeaderPill kind="todos" icon="📋" label="todos" count={todos.length} onClick={() => setOpenPanel('todos')} />
|
||||||
{/* Needs dashboard_port from a loaded snapshot — omitted (like the
|
{/* Needs a loaded snapshot for their data (links/forge_public_url,
|
||||||
* old page's populateOverflowMenu) until the first /api/state
|
* dashboard_port) — both omitted (like the old page's
|
||||||
* resolves. */}
|
* populateOverflowMenu) until the first /api/state resolves. */}
|
||||||
|
{state ? <MetaNav links={state.links} forgePublicUrl={state.forge_public_url} /> : null}
|
||||||
{state ? <OverflowMenu label={state.label} dashboardBase={resolveDashboardBase(state.dashboard_port)} /> : null}
|
{state ? <OverflowMenu label={state.label} dashboardBase={resolveDashboardBase(state.dashboard_port)} /> : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -160,7 +161,6 @@ export function Root() {
|
||||||
<Header
|
<Header
|
||||||
label={state.label}
|
label={state.label}
|
||||||
hiveLabel={[state.swarm_name, state.hive_name].filter(Boolean).join(' / ') || null}
|
hiveLabel={[state.swarm_name, state.hive_name].filter(Boolean).join(' / ') || null}
|
||||||
nav={<MetaNav links={state.links} forgePublicUrl={state.forge_public_url} />}
|
|
||||||
pills={pills}
|
pills={pills}
|
||||||
>
|
>
|
||||||
<StatusChips
|
<StatusChips
|
||||||
|
|
|
||||||
|
|
@ -27,24 +27,29 @@ export interface HeaderProps {
|
||||||
label: string;
|
label: string;
|
||||||
hiveLabel?: string | null;
|
hiveLabel?: string | null;
|
||||||
children?: ComponentChildren;
|
children?: ComponentChildren;
|
||||||
/** Meta-nav links (stats/screen/forge/config/extras) — `<MetaNav>`
|
/** Right-cluster flyout triggers (inbox/todos/links pills, the
|
||||||
* lands here, in the title row alongside the `<h2>`, same as the old
|
* overflow menu button) — mirrors the old markup's
|
||||||
* markup's `<nav id="meta-links">` (docs/web-ui/agent.md::Header). */
|
* `.agent-header-pills` third column. Meta-nav (`<MetaNav>`) lives
|
||||||
nav?: ComponentChildren;
|
* here too, as a fixed-size trigger — NOT in the title row the old
|
||||||
/** Right-cluster flyout triggers (inbox/todos pills today, the
|
* markup put it in (`<nav id="meta-links">`, docs/web-ui/agent.md).
|
||||||
* overflow menu button lands here in a later commit) — mirrors the
|
* A variable-width inline link list there grows the title row's
|
||||||
* old markup's `.agent-header-pills` third column. */
|
* actual height past the fixed `--agent-header-h` the rest of the
|
||||||
|
* page's `position: fixed` header + `position: absolute` content
|
||||||
|
* below it are offset against, so on a narrow viewport the header
|
||||||
|
* silently overlaps the login-recovery card underneath it (mara,
|
||||||
|
* live: "the login card looks like it is behind the header"). A
|
||||||
|
* fixed-size trigger button can't do that regardless of how many
|
||||||
|
* links the backend sends. */
|
||||||
pills?: ComponentChildren;
|
pills?: ComponentChildren;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({ label, hiveLabel, children, nav, pills }: HeaderProps) {
|
export function Header({ label, hiveLabel, children, pills }: HeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header class="agent-header">
|
<header class="agent-header">
|
||||||
<img class="agent-icon" src="icon" alt="" onError={handleIconError} />
|
<img class="agent-icon" src="icon" alt="" onError={handleIconError} />
|
||||||
<div class="agent-header-main">
|
<div class="agent-header-main">
|
||||||
<div class="agent-header-row agent-header-title-row">
|
<div class="agent-header-row agent-header-title-row">
|
||||||
<h2 class="agent-header-title">◆ {label} ◆</h2>
|
<h2 class="agent-header-title">◆ {label} ◆</h2>
|
||||||
{nav}
|
|
||||||
</div>
|
</div>
|
||||||
{hiveLabel ? <div class="agent-header-row agent-hive-row">{hiveLabel}</div> : null}
|
{hiveLabel ? <div class="agent-header-row agent-hive-row">{hiveLabel}</div> : null}
|
||||||
<div class="agent-header-row">{children}</div>
|
<div class="agent-header-row">{children}</div>
|
||||||
|
|
|
||||||
42
frontend/packages/agent/src/components/MetaNav.css
Normal file
42
frontend/packages/agent/src/components/MetaNav.css
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
/* <MetaNav> popover — matches `@hive/shared`'s `Dropdown` (`.ui-dropdown`)
|
||||||
|
values exactly (background/border/radius/shadow, item padding/hover),
|
||||||
|
same "reuse the values, not the component" call `.login-card`
|
||||||
|
(LoginFlow.css) already made, for the same reason: this popover's
|
||||||
|
items are real `<a>` tags, not `Dropdown`'s command-dispatch
|
||||||
|
`<button>`s. Right-anchored, same as OverflowMenu.css's
|
||||||
|
`.header-overflow-anchor` — MetaNav sits in the same right-hand
|
||||||
|
pills cluster, so a left anchor would push it off-screen the same
|
||||||
|
way. */
|
||||||
|
.meta-nav-anchor {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.meta-nav-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.25em);
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 10em;
|
||||||
|
padding: 0.25em;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5em;
|
||||||
|
box-shadow: 0 0.4em 1em rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
.meta-nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
min-height: 2.75em;
|
||||||
|
padding: 0.4em 0.7em;
|
||||||
|
border-radius: 0.35em;
|
||||||
|
color: var(--fg);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.meta-nav-item:hover {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,31 @@
|
||||||
// <MetaNav> — the header's meta-nav strip (`<nav id="meta-links">` in
|
// <MetaNav> — the header's meta-nav trigger: a single fixed-size Badge
|
||||||
// the old markup): stats/screen/forge/config + any
|
// ("🔗") in the pills cluster that opens a popover listing
|
||||||
// `hyperhive.dashboardLinks` extras, sourced from the backend's
|
// stats/screen/forge/config + any `hyperhive.dashboardLinks` extras,
|
||||||
// `agent_links()` (the single source of truth — same list also feeds
|
// sourced from the backend's `agent_links()` (the single source of
|
||||||
// the dashboard card's icon strip). See
|
// truth — same list also feeds the dashboard card's icon strip).
|
||||||
// docs/web-ui/agent.md::Header for the kind → URL resolution table
|
//
|
||||||
// this ports verbatim from app.js's `refreshState` (lines ~1216-1244):
|
// v1 of this rendered every link inline in the header's title row —
|
||||||
// `container` → same-origin path (this page is itself container-local);
|
// mara, live: "the login card looks like it is behind the header"
|
||||||
// `forge` → `forgePublicUrl + url`, and the link is dropped entirely
|
// (a variable-width link list can grow the title row past the fixed
|
||||||
// when `forgePublicUrl` is unset (never guessed from `<host>:3000`);
|
// `--agent-header-h` the rest of the page is offset against — see
|
||||||
// `external` → already absolute. Plain JSX text (not `innerHTML`), so
|
// Header.tsx's comment) and "the links should have the same popout as
|
||||||
// this is XSS-safe by construction the same way app.js's `el()`-built
|
// the links in the nav bar of swarm-ui." This is that: same shape as
|
||||||
// anchors were — no new escaping to get right.
|
// swarm-ui's own `LinksMenu` (Shell/LinksMenu.tsx) — one quiet icon
|
||||||
|
// trigger, a popover on click, real `<a>` items (not a command-dispatch
|
||||||
|
// button list) so ctrl/middle-click and "copy link address" keep
|
||||||
|
// working. Trigger itself reuses `@hive/shared`'s `Badge` (icon-only,
|
||||||
|
// same shape as the header's `⋯` overflow trigger — same touch target,
|
||||||
|
// same hover/expanded treatment, one visual language for every header
|
||||||
|
// trigger) rather than a bespoke button. The popover isn't built from
|
||||||
|
// `@hive/shared`'s `Dropdown` — its items are always `<button>`s
|
||||||
|
// (command dispatch), which would lose real link semantics — but
|
||||||
|
// matches its `.ui-dropdown` visual values exactly (see MetaNav.css),
|
||||||
|
// the same "reuse the values, not the component" call `LoginFlow`'s
|
||||||
|
// `.login-card` already made for the same reason.
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import { Badge } from '@hive/shared/badge.js';
|
||||||
import type { AgentLink } from '../types.js';
|
import type { AgentLink } from '../types.js';
|
||||||
|
import './MetaNav.css';
|
||||||
|
|
||||||
export interface MetaNavProps {
|
export interface MetaNavProps {
|
||||||
links: AgentLink[];
|
links: AgentLink[];
|
||||||
|
|
@ -19,26 +33,59 @@ export interface MetaNavProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MetaNav({ links, forgePublicUrl }: MetaNavProps) {
|
export function MetaNav({ links, forgePublicUrl }: MetaNavProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Same "outside click or Escape closes" contract as Dropdown/OverflowMenu.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) setOpen(false);
|
||||||
|
}
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener('pointerdown', onPointerDown, true);
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', onPointerDown, true);
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Same kind -> URL resolution as app.js's old refreshState meta-links
|
||||||
|
// loop: `forge` needs `forgePublicUrl` set or the link is dropped
|
||||||
|
// entirely (never guessed from `<host>:3000`); `external` is already
|
||||||
|
// absolute; `container` is a same-origin path.
|
||||||
const visible = links.filter((lnk) => lnk.kind !== 'forge' || forgePublicUrl);
|
const visible = links.filter((lnk) => lnk.kind !== 'forge' || forgePublicUrl);
|
||||||
if (visible.length === 0) return null;
|
if (visible.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav class="agent-nav" id="meta-links">
|
<div class="meta-nav-anchor" ref={rootRef}>
|
||||||
{visible.map((lnk) => {
|
<Badge value="🔗" title="agent links" onClick={() => setOpen((o) => !o)} expanded={open} />
|
||||||
const href = lnk.kind === 'forge' ? `${forgePublicUrl}${lnk.url}` : lnk.url;
|
{open ? (
|
||||||
return (
|
<div class="meta-nav-popover" role="menu" aria-label="agent links">
|
||||||
<a
|
{visible.map((lnk) => {
|
||||||
key={lnk.url}
|
const href = lnk.kind === 'forge' ? `${forgePublicUrl}${lnk.url}` : lnk.url;
|
||||||
class="agent-nav-link"
|
return (
|
||||||
href={href}
|
<a
|
||||||
target="_blank"
|
key={lnk.url}
|
||||||
rel="noopener"
|
class="meta-nav-item"
|
||||||
title={lnk.label}
|
href={href}
|
||||||
>
|
target="_blank"
|
||||||
{`${lnk.icon} ${lnk.label}`.trim()} →
|
rel="noopener"
|
||||||
</a>
|
role="menuitem"
|
||||||
);
|
onClick={() => setOpen(false)}
|
||||||
})}
|
>
|
||||||
</nav>
|
{lnk.icon ? (
|
||||||
|
<span aria-hidden="true">{lnk.icon}</span>
|
||||||
|
) : null}
|
||||||
|
{lnk.label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,21 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
/* mara, live, on a screenshot of this row: "badges and interactive
|
||||||
|
elements on same row have inconsistent size." Real cause: Badge.css's
|
||||||
|
`.ui-badge-interactive` grows to a 2.75em WCAG touch-target floor
|
||||||
|
(model/effort pickers, pause) while a plain display `<span>` badge
|
||||||
|
(alive, state, ctx, cost — no `onClick`) stays compact, so the row
|
||||||
|
reads as two different pill sizes even though every badge here is
|
||||||
|
the same `<Badge>` component. Scoped to this row rather than
|
||||||
|
Badge.css itself — display-only badges elsewhere (e.g. the dashboard)
|
||||||
|
have no reason to grow, this is specifically about badges and
|
||||||
|
buttons sitting side by side in one row needing to read as one
|
||||||
|
family. */
|
||||||
|
.status-chips .ui-badge {
|
||||||
|
min-height: 2.75em;
|
||||||
|
padding-inline: 0.8em;
|
||||||
|
}
|
||||||
.status-chips-last-turn {
|
.status-chips-last-turn {
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue