diff --git a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx
index 94cec45f..0bdca539 100644
--- a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx
+++ b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx
@@ -101,6 +101,14 @@ export function ComponentsPage() {
panel body content, no title
+
+ {}} />}
+ >
+ panel body content
+
+
diff --git a/frontend/packages/swarm-ui/src/pages/HivesPage.tsx b/frontend/packages/swarm-ui/src/pages/HivesPage.tsx
index 5df35f72..7149e62f 100644
--- a/frontend/packages/swarm-ui/src/pages/HivesPage.tsx
+++ b/frontend/packages/swarm-ui/src/pages/HivesPage.tsx
@@ -113,8 +113,10 @@ export function HivesPage() {
});
return (
-
-
+ }
+ >
{error ? : null}
{!error && hives === null ?
loading…
: null}
{hives ?
h.name} /> : null}
diff --git a/frontend/packages/swarm-ui/src/ui/panel/Panel.css b/frontend/packages/swarm-ui/src/ui/panel/Panel.css
index 23a40980..0132d514 100644
--- a/frontend/packages/swarm-ui/src/ui/panel/Panel.css
+++ b/frontend/packages/swarm-ui/src/ui/panel/Panel.css
@@ -3,12 +3,26 @@
border-radius: 0.5em;
background: var(--bg-elev);
}
+.ui-panel-header {
+ display: flex;
+ align-items: center;
+ gap: 1em;
+ padding: 0.75em 1em;
+ border-bottom: 1px solid var(--border);
+}
.ui-panel-title {
margin: 0;
- padding: 0.75em 1em;
font-size: 1em;
font-weight: 600;
- border-bottom: 1px solid var(--border);
+}
+/* `margin-left: auto` (not `justify-content: space-between` on the
+ header) so actions still land at the right edge even on the rare
+ panel that has actions but no title. */
+.ui-panel-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5em;
+ margin-left: auto;
}
.ui-panel-body {
padding: 1em;
diff --git a/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx b/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx
index de8f88db..eea69b56 100644
--- a/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx
+++ b/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx
@@ -2,13 +2,35 @@
// bordered surface with an optional title, no other opinions. Not a
// card-with-actions/footer/whatever kit — those get added the first
// time a real page actually needs one, not speculatively ahead of it.
+//
+// `actions` is that one addition: a slot in the title row, right-
+// aligned, for a control that belongs next to the panel's own heading
+// rather than its own row inside the body (design guide: "a control
+// belongs next to the thing it affects, not tucked into a catch-all
+// menu" — and per mara's review on the refresh-interval PR, not a
+// dedicated row stealing vertical space from the panel's actual
+// content either). `HivesPage`'s refresh-interval picker is the
+// motivating caller.
import type { ComponentChildren } from 'preact';
import './Panel.css';
-export function Panel({ title, children }: { title?: string; children: ComponentChildren }) {
+export function Panel({
+ title,
+ actions,
+ children,
+}: {
+ title?: string;
+ actions?: ComponentChildren;
+ children: ComponentChildren;
+}) {
return (
- {title ?
{title}
: null}
+ {title || actions ? (
+
+ {title ?
{title}
: null}
+ {actions ?
{actions}
: null}
+
+ ) : null}
{children}
);
diff --git a/frontend/packages/swarm-ui/src/ui/refresh-interval/RefreshInterval.css b/frontend/packages/swarm-ui/src/ui/refresh-interval/RefreshInterval.css
new file mode 100644
index 00000000..0e1a134d
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/ui/refresh-interval/RefreshInterval.css
@@ -0,0 +1,42 @@
+/* Compact "clock icon · value · chevron" control — see RefreshInterval.tsx
+ for why this isn't `SelectField`'s label+bordered-control chrome. Quiet
+ until interacted with (no border/fill at rest), matching LinksMenu's
+ header-button treatment so it reads as chrome, not a form. */
+.ui-refresh-picker {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35em;
+ min-height: 2.75em;
+ padding: 0 0.6em;
+ border: 1px solid transparent;
+ border-radius: 0.4em;
+ color: var(--muted);
+ font-size: 0.9em;
+ cursor: pointer;
+}
+.ui-refresh-picker:hover,
+.ui-refresh-picker:focus-within {
+ border-color: var(--border);
+ background: var(--bg);
+ color: var(--fg);
+}
+/* The select drives the interaction (native listbox, keyboard, a11y) but
+ contributes no chrome of its own — its own box is invisible, sized to
+ just its selected option's text, and its default arrow is stripped
+ since the chevron span stands in for it. */
+.ui-refresh-picker-select {
+ appearance: none;
+ border: none;
+ background: none;
+ color: inherit;
+ font: inherit;
+ padding: 0;
+ margin: 0;
+ cursor: pointer;
+}
+.ui-refresh-picker-icon,
+.ui-refresh-picker-chevron {
+ font-size: 0.85em;
+ line-height: 1;
+}
diff --git a/frontend/packages/swarm-ui/src/ui/refresh-interval/RefreshInterval.tsx b/frontend/packages/swarm-ui/src/ui/refresh-interval/RefreshInterval.tsx
index ca98dac1..5a0979bd 100644
--- a/frontend/packages/swarm-ui/src/ui/refresh-interval/RefreshInterval.tsx
+++ b/frontend/packages/swarm-ui/src/ui/refresh-interval/RefreshInterval.tsx
@@ -8,12 +8,25 @@
// file since neither is useful without the other and splitting them
// would be two files for one concern.
//
+// The picker itself is a compact "clock icon · value · chevron" inline
+// control, not a labelled `SelectField` — per mara's review, a full
+// label+bordered-control form field reads as way too heavy for a
+// passive-until-touched setting that lives in a panel's title row
+// (`Panel`'s `actions` slot), not a form. Visually quiet (no border/
+// fill until hovered/focused) the same way `LinksMenu`'s header button
+// is, so it reads as chrome rather than another input to fill in. A
+// native `