Watch
0
0
Fork
You've already forked hyperhive
0

the hive dashboard uses the swarm UI's links menu, beside the profile avatar

The links menu moves into @hive/shared and takes its links as a prop. The swarm
UI feeds it /api/links as before; the dashboard feeds it stats, API docs and the
swarm, and mounts it next to the profile menu in place of its own hamburger on
the left. A link opens in a new tab unless its entry says it is a page of the
same site.
This commit is contained in:
iris 2026-11-01 12:12:38 +01:00 • committed by mara
commit 464b52004b
12 changed files with 192 additions and 239 deletions

View file

@ -11,7 +11,7 @@ what you actually do here.
## Where things are
Everything starts at the **dashboard**, served at `/`. A pages menu
(the `☰` button at the left of the tab strip) links to the other
(the link icon beside the profile avatar, at the right of the tab strip) links to the other
surfaces — Stats, the API docs and the swarm UI — and each of
those pages links back to the dashboard.

View file

@ -17,8 +17,9 @@ is a standalone page reached from the pages menu, not from the
dashboard tab strip.
**Chrome header** (fixed, overlays the active tab pane):
- **Pages menu**: a `☰` button at the left of the chrome (`links-menu.js`)
opening a popover with links to **stats**
- **Pages menu**: a link icon at the right end of the chrome, beside the
profile avatar (`links-menu.js`, `@hive/shared`'s `LinksMenu`, the same
component the swarm UI uses for its service links), opening a popover with links to **stats**
(`/stats.html`), **api docs** (`/api/docs/`) and **swarm** (the swarm
UI at the swarm apex domain, opened in a new tab). The swarm entry
comes from `state.swarm_public_url`, which hive-c0re reads from

View file

@ -8,6 +8,7 @@
@import "@hive/shared/jobq-rollup.css";
@import "@hive/shared/jobq-graph.css";
@import "@hive/shared/user-menu.css";
@import "@hive/shared/links-menu.css";
/* ─── tabbed dashboard chrome ──────────────────────────────────────
Top-of-page sticky header with banner + tab strip. SSE stays
@ -43,71 +44,20 @@ body.dashboard-shell {
padding: 0.3em 1em;
margin: 0 0 1em;
}
/* Profile menu (user-menu.js): the avatar at the right end of the chrome.
Its own look is `@hive/shared/user-menu.css`'s. */
.user-menu-slot {
/* The pages menu and the profile menu, together at the right end of the
chrome. Their own looks are `@hive/shared/links-menu.css`'s and
`@hive/shared/user-menu.css`'s. */
.dashboard-chrome-actions {
display: flex;
align-items: center;
gap: 0.25em;
flex: none;
margin-left: auto;
}
/* Pages menu (links-menu.js): one icon button at the left of the chrome
that opens a popover of the standalone pages and the swarm UI. Same
shape as the swarm UI's LinksMenu — quiet until hovered or open. The
explicit `[hidden]` rule is needed because `display: flex` on the
popover would otherwise beat the UA `[hidden]` style. */
.links-menu {
position: relative;
.links-menu-slot,
.user-menu-slot {
flex: none;
}
.links-menu-button {
display: flex;
align-items: center;
justify-content: center;
width: 2em;
height: 2em;
padding: 0;
border: 1px solid transparent;
border-radius: 0.4em;
background: none;
color: var(--purple);
font-size: 1em;
line-height: 1;
cursor: pointer;
}
.links-menu-button:hover,
.links-menu-button[aria-expanded="true"] {
border-color: var(--border);
background: var(--bg-elev);
}
.links-menu-popover {
position: absolute;
top: calc(100% + 0.4em);
left: 0;
z-index: 10;
display: flex;
flex-direction: column;
min-width: 10em;
padding: 0.4em;
border: 1px solid var(--border);
border-radius: 0.5em;
background: var(--bg-elev);
box-shadow: 0 0.25em 0.75em rgba(0, 0, 0, 0.3);
}
.links-menu-popover[hidden] {
display: none;
}
.links-menu-item {
display: flex;
align-items: center;
gap: 0.5em;
padding: 0.4em 0.6em;
border-radius: 0.35em;
color: var(--fg);
text-decoration: none;
white-space: nowrap;
}
.links-menu-item:hover {
background: var(--bg);
}
.banner-thin {
text-align: center;
margin: 0;
@ -125,7 +75,7 @@ body.dashboard-shell {
flex-wrap: nowrap;
align-items: center;
gap: 0.2em;
/* On one row with the pages menu; the chrome owns the gutter +
/* On one row with the chrome's menus; the chrome owns the gutter +
the bottom divider, so no outer padding / border here. */
}
/* Shares the flat-pill aesthetic of the shared `.hive-tab` (sub-tabs on

View file

@ -17,10 +17,6 @@
button / shared links keep you on the same view. JS owns
the show/hide. -->
<header class="dashboard-chrome">
<!-- Pages menu: the standalone pages (stats, API docs) and the
swarm UI. Filled by links-menu.js from /api/state. The pages link
back to `/`, which is this dashboard. -->
<div class="links-menu" id="links-menu"></div>
<nav class="tabbar" id="tabbar" role="tablist">
<a
class="tab"
@ -74,11 +70,18 @@
under 4G3NTS. See tab-pane-agents.) -->
<!-- ST4TS is a standalone page reached from the pages
menu at the left of this strip, not tabs — the strip holds only
menu at the right of this strip, not tabs — the strip holds only
in-page tabs. -->
</nav>
<!-- Profile menu: the signed-in user, filled by user-menu.js. -->
<!-- The pages menu and the profile menu, together at the right.
Pages menu: the standalone pages (stats, API docs) and the swarm UI,
filled by links-menu.js from /api/state; the pages link back to `/`,
which is this dashboard. Profile menu: the signed-in user, filled by
user-menu.js. -->
<div class="dashboard-chrome-actions">
<div class="links-menu-slot" id="links-menu"></div>
<div class="user-menu-slot" id="user-menu"></div>
</div>
</header>
<!-- Tab panes. createTabStrip (@hive/shared/tabs.js) keeps exactly one

View file

@ -1,98 +1,36 @@
// Pages menu in the dashboard chrome: one button that opens a popover
// linking the standalone pages (stats, API docs) and the swarm UI.
// Modelled on the swarm UI's `LinksMenu`. The swarm entry only exists
// when `/api/state` carries `swarm_public_url`, so a hive without a
// configured swarm domain shows no dead link.
// Pages menu in the dashboard chrome: one icon button, beside the profile
// avatar at the right, that opens a popover linking the standalone pages
// (stats, API docs) and the swarm UI. It is `@hive/shared`'s `LinksMenu`, the
// same component the swarm UI mounts for its service links, rendered with
// Preact like the profile menu. The swarm entry only exists when `/api/state`
// carries `swarm_public_url`, so a hive without a configured swarm domain
// shows no dead link.
import { h, render } from "preact";
import { LinksMenu } from "@hive/shared/links-menu.js";
import { $ } from "./common.js";
import { el } from "@hive/shared/dom.js";
/** Menu entries for a state snapshot. `external` opens in a new tab. */
/** Menu entries for a state snapshot. The two pages open in place; the swarm
* UI is another site, so it opens in a new tab. */
export function menuLinks(state) {
const links = [
{ icon: "📊", label: "stats", url: "/stats.html" },
{ icon: "🧬", label: "api docs", url: "/api/docs/" },
{ icon: "📊", label: "stats", url: "/stats.html", external: false },
{ icon: "🧬", label: "api docs", url: "/api/docs/", external: false },
];
if (state && state.swarm_public_url) {
links.push({
icon: "🐝",
label: "swarm",
url: state.swarm_public_url,
external: true,
});
}
return links;
}
let wired = false;
function closeMenu(root) {
const pop = root.querySelector(".links-menu-popover");
const btn = root.querySelector(".links-menu-button");
if (pop) pop.hidden = true;
if (btn) btn.setAttribute("aria-expanded", "false");
}
function wire(root) {
if (wired) return;
wired = true;
root.querySelector(".links-menu-button").addEventListener("click", () => {
const pop = root.querySelector(".links-menu-popover");
const btn = root.querySelector(".links-menu-button");
pop.hidden = !pop.hidden;
btn.setAttribute("aria-expanded", String(!pop.hidden));
});
// Outside click / Escape close the popover.
document.addEventListener("pointerdown", (e) => {
if (!root.contains(e.target)) closeMenu(root);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeMenu(root);
});
}
/** (Re)render the menu into `#links-menu`. Safe to call on every state
* refresh: the open/closed state lives on the popover element, which is
* kept; only its items are rebuilt. */
* refresh: Preact diffs the same component, so an open popover stays open. */
export function renderLinksMenu(state) {
const root = $("links-menu");
if (!root) return;
if (!root.firstChild) {
root.append(
el(
"button",
{
type: "button",
class: "links-menu-button",
"aria-haspopup": "true",
"aria-expanded": "false",
"aria-label": "pages",
title: "pages",
},
"☰",
),
el("div", { class: "links-menu-popover", role: "menu", hidden: "" }),
);
}
const pop = root.querySelector(".links-menu-popover");
pop.replaceChildren(
...menuLinks(state).map((link) => {
const attrs = {
class: "links-menu-item",
href: link.url,
role: "menuitem",
};
if (link.external) {
attrs.target = "_blank";
attrs.rel = "noreferrer";
}
return el(
"a",
attrs,
el("span", { "aria-hidden": "true" }, link.icon),
link.label,
);
}),
);
wire(root);
render(h(LinksMenu, { links: menuLinks(state), label: "pages" }), root);
}

View file

@ -56,7 +56,10 @@
"./expand-details-setting.js": "./src/settings/ExpandDetailsSetting.tsx",
"./settings-menu.css": "./src/settings/SettingsMenu.css",
"./user-menu.js": "./src/user-menu/UserMenu.tsx",
"./user-menu.css": "./src/user-menu/UserMenu.css"
"./user-menu.css": "./src/user-menu/UserMenu.css",
"./links-menu.js": "./src/links-menu/LinksMenu.tsx",
"./links-menu.css": "./src/links-menu/LinksMenu.css",
"./links.js": "./src/links-menu/links.ts"
},
"files": [
"src/"

View file

@ -44,7 +44,7 @@
creates a new `.jg-node` DOM node when its `key` (the node id) is
genuinely new, so this only plays once per node, the first time it
appears in a fetched snapshot. Same shape as Shell.css's
`shell-page-enter` / LinksMenu.css's `links-menu-popover-enter` —
`shell-page-enter` / the links menu's `links-menu-popover-enter` —
see either for the three-rule motion-guard this mirrors. */
.jg-node {
margin: 0;

View file

@ -0,0 +1,87 @@
/* <LinksMenu> (@hive/shared) — a single header icon-button + popover. Kept visually
quiet (no border/fill until interacted with) so it reads as chrome,
not another nav item. Button is sized to the same touch-target floor
as the shared `ui/` kit (2.75em ≈ 44px, WCAG 2.5.5) even though it
isn't built from that kit — a bespoke icon trigger, not a form
control, but the floor applies regardless of which component drew
it. No `margin-left: auto` here: the mounting page's header owns pushing the
action group to the right edge (the swarm UI's `.shell-header-actions`,
the dashboard's `.dashboard-chrome-actions`). */
.links-menu {
position: relative;
}
.links-menu-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.75em;
height: 2.75em;
padding: 0;
border: 1px solid transparent;
border-radius: 0.4em;
background: none;
color: var(--fg);
font-size: 1em;
line-height: 1;
cursor: pointer;
}
.links-menu-button:hover,
.links-menu-button[aria-expanded="true"] {
border-color: var(--border);
background: var(--bg-elev);
}
.links-menu-popover {
position: absolute;
top: calc(100% + 0.4em);
right: 0;
z-index: 10;
display: flex;
flex-direction: column;
min-width: 10em;
padding: 0.4em;
border: 1px solid var(--border);
border-radius: 0.5em;
background: var(--bg-elev);
box-shadow: 0 0.25em 0.75em rgba(0, 0, 0, 0.3);
animation: links-menu-popover-enter 140ms ease;
}
/* Mount-triggered, not a state transition — the popover only exists in
the DOM while open (`{open ? <div> : null}`), so a keyframe animation
on entry is the right tool, same shape as `.shell-body`'s own
`shell-page-enter` (Shell.css) — that's the precedent every popover
in this issue's "menus should animate" ask follows. */
@keyframes links-menu-popover-enter {
from {
opacity: 0;
transform: translateY(-0.25em) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.links-menu-popover {
animation: none;
}
}
:root[data-motion="reduce"] .links-menu-popover {
animation: none;
}
:root[data-motion="allow"] .links-menu-popover {
animation: links-menu-popover-enter 140ms ease;
}
.links-menu-item {
display: flex;
align-items: center;
gap: 0.5em;
min-height: 2.75em;
padding: 0.4em 0.6em;
border-radius: 0.35em;
color: var(--fg);
text-decoration: none;
white-space: nowrap;
}
.links-menu-item:hover {
background: var(--bg);
}

View file

@ -0,0 +1,81 @@
// <LinksMenu> — a single header icon-button opening a popover of links, next
// to the profile avatar. The swarm UI hands it the swarm's service list and
// the hive dashboard its own pages; both look and behave the same, which is
// why it lives here (it was the swarm UI's, and the dashboard had its own
// copy with a hamburger on the left).
//
// A menu with no links renders nothing: no button beats a dead one.
//
// Styles live in `@hive/shared/links-menu.css`, which the mounting page
// imports or `@import`s itself: the dashboard's JS bundle loads `.css`
// imports as text, so a component that imported it would not style there
// (the same split as `UserMenu`).
import { useEffect, useRef, useState } from "preact/hooks";
import { LinkIcon } from "../icons.js";
import { linkAttrs, type MenuLink } from "./links.js";
export type { MenuLink };
export function LinksMenu({
links,
label = "links",
}: {
links: MenuLink[];
/** The button's accessible name. */
label?: string;
}) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function onPointerDown(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node))
setOpen(false);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
if (links.length === 0) return null;
return (
<div class="links-menu" ref={rootRef}>
<button
type="button"
class="links-menu-button"
aria-haspopup="true"
aria-expanded={open}
aria-label={label}
title={label}
onClick={() => setOpen((v) => !v)}
>
<LinkIcon />
</button>
{open ? (
<div class="links-menu-popover" role="menu">
{links.map((link) => (
<a
key={link.url}
class="links-menu-item"
href={link.url}
{...linkAttrs(link)}
role="menuitem"
onClick={() => setOpen(false)}
>
{link.icon ? <span aria-hidden="true">{link.icon}</span> : null}
{link.label}
</a>
))}
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,25 @@
// What the links menu shows for one link, without a DOM: where it opens and
// what it is called. Shared by the swarm UI (its `/api/links`) and the hive
// dashboard (its stats and API docs pages, and the swarm UI).
/** One entry of the links menu. */
export interface MenuLink {
label: string;
/** A glyph before the label; none leaves the label alone. */
icon?: string;
url: string;
/**
* Opens in a new tab. Defaults to true: a link the menu was handed from
* elsewhere (the swarm's service list) leaves the page. A page of the
* same site says `false` and opens in place.
*/
external?: boolean;
}
/** The anchor attributes that make a link open where its entry says. */
export function linkAttrs(link: MenuLink): {
target?: string;
rel?: string;
} {
return link.external === false ? {} : { target: "_blank", rel: "noreferrer" };
}

View file

@ -1,98 +1,27 @@
// <LinksMenu> — the "accessible from every screen" affordance for
// swarm-wide services (authelia, matrix, forge, this UI's own API
// docs, …): one fixed header button that never grows the header
// itself as the list grows, opening a popover with everything
// `GET /api/links` returns. Lives in `Shell` (not `ui/`) because it
// owns its own fetch — every other `ui/` primitive is presentational
// only, this one genuinely isn't reusable outside this one job.
//
// The list is entirely server-driven (see swarm-controller's
// `services.hyperhive.swarm.controller.links` option) — 0 entries
// hides the button rather than showing an empty popover, same "don't
// render a dead affordance" rule the dashboard's pages menu follows for
// its swarm entry.
import { useEffect, useRef, useState } from "preact/hooks";
import { LinkIcon } from "@hive/shared/icons.js";
import "./LinksMenu.css";
interface ServiceLink {
label: string;
icon: string;
url: string;
}
// <LinksMenu> — the swarm UI's links menu: `@hive/shared`'s, fed the swarm's
// service list from `GET /api/links` (the services behind the swarm's vhosts,
// each with its glyph). A failed fetch leaves the list empty, and an empty
// menu draws nothing: no button beats a broken one.
import { useEffect, useState } from "preact/hooks";
import {
LinksMenu as SharedLinksMenu,
type MenuLink,
} from "@hive/shared/links-menu.js";
import "@hive/shared/links-menu.css";
export function LinksMenu() {
const [links, setLinks] = useState<ServiceLink[] | null>(null);
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const [links, setLinks] = useState<MenuLink[]>([]);
useEffect(() => {
fetch("/api/links")
.then((r) =>
r.ok
? (r.json() as Promise<ServiceLink[]>)
? (r.json() as Promise<MenuLink[]>)
: Promise.reject(new Error(`http ${r.status}`)),
)
.then(setLinks)
.catch(() => setLinks([])); // best-effort: no button beats a broken one
.catch(() => setLinks([]));
}, []);
// Close on an outside click or Escape — only listens while open, so
// this costs nothing on every other render.
useEffect(() => {
if (!open) return;
function onPointerDown(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node))
setOpen(false);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
if (!links || links.length === 0) return null;
return (
<div class="links-menu" ref={rootRef}>
<button
type="button"
class="links-menu-button"
aria-haspopup="true"
aria-expanded={open}
aria-label="swarm services"
onClick={() => setOpen((v) => !v)}
>
{/* `@hive/shared/icons.js`'s `LinkIcon` — reported live by mara:
"links and settings icon styles different / all different
sizes". Now shared with `SettingsMenu`'s `GearIcon` and
agent's own `MetaNav` trigger, one rendering path instead of
three separate inline copies — see that file's comment. */}
<LinkIcon />
</button>
{open ? (
<div class="links-menu-popover" role="menu">
{links.map((link) => (
<a
key={link.url}
class="links-menu-item"
href={link.url}
target="_blank"
rel="noreferrer"
role="menuitem"
onClick={() => setOpen(false)}
>
{link.icon ? <span aria-hidden="true">{link.icon}</span> : null}
{link.label}
</a>
))}
</div>
) : null}
</div>
);
return <SharedLinksMenu links={links} label="swarm services" />;
}

View file

@ -0,0 +1,23 @@
// How a link in the links menu opens. Run by `npm test`, which Node runs with
// its built-in test runner and type stripping.
import assert from "node:assert/strict";
import { test } from "node:test";
import { linkAttrs } from "../../shared/src/links-menu/links.ts";
test("a link the menu was handed opens in a new tab, without a referrer", () => {
assert.deepEqual(linkAttrs({ label: "forge", url: "https://forge.x" }), {
target: "_blank",
rel: "noreferrer",
});
assert.deepEqual(
linkAttrs({ label: "forge", url: "https://forge.x", external: true }),
{ target: "_blank", rel: "noreferrer" },
);
});
test("a page of the same site opens in place", () => {
assert.deepEqual(
linkAttrs({ label: "stats", url: "/stats.html", external: false }),
{},
);
});