swarm-ui: drop the redundant outer dialog card, move close into the panel header

Dialog and Panel both drew their own bordered/backgrounded card in the
same --bg-elev, so a Panel-based dialog (create-agent, link-matrix-account)
rendered as two concentric cards with a floating close button on the
outer one and no purpose to it.

Give Dialog a "plain" mode that drops its own card chrome (border,
background, padding) and floating close button, and give Panel an
optional onClose that renders a close button at the end of its own
header row instead. AgentsPage's two Panel-backed dialogs now use
plain + Panel's onClose, so the Panel is the dialog's only visible
card. ConfirmDialog (no Panel of its own) is unaffected — plain
defaults to false, unchanged card + floating close button.

Added a ComponentsPage sample demonstrating the plain + onClose
pairing. Verified both dialog modes via a real headless-chromium
screenshot (plain dialog: single card, close button in the header
bar; default dialog: unchanged floating close button).
This commit is contained in:
iris 2026-09-13 00:58:53 +02:00
commit 8a4c613e4e
8 changed files with 158 additions and 18 deletions

View file

@ -219,6 +219,36 @@ function DialogSample() {
); );
} }
// `plain`: a `Panel`'s own header bar/border is the dialog's
// only card, not a second one nested inside `Dialog`'s own — pair
// `plain` with `Panel`'s `onClose` so the close button still exists,
// just moved into that header instead of floating over the whole thing.
function PlainDialogSample() {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>open plain dialog</Button>
<Dialog
open={open}
onClose={() => setOpen(false)}
label="sample plain dialog"
plain
>
<Panel
title="a form-shaped dialog"
icon="🔗"
onClose={() => setOpen(false)}
>
<p>
One card, one header bar the close button lives here instead of
floating over a second, purposeless outer one.
</p>
</Panel>
</Dialog>
</>
);
}
// Own state for the same reason `TextFieldSample` needs it — a // Own state for the same reason `TextFieldSample` needs it — a
// controlled sample has to actually respond to interaction. // controlled sample has to actually respond to interaction.
function MultiselectFilterSample() { function MultiselectFilterSample() {
@ -458,6 +488,9 @@ export function ComponentsPage() {
<Sample label="modal, caller-owned content"> <Sample label="modal, caller-owned content">
<DialogSample /> <DialogSample />
</Sample> </Sample>
<Sample label="plain — content supplies its own card + close">
<PlainDialogSample />
</Sample>
</Section> </Section>
<Section title="ConfirmDialog"> <Section title="ConfirmDialog">

View file

@ -72,7 +72,14 @@ type SubmitState =
// exception report on this page. // exception report on this page.
const NAME_PATTERN = "[a-z0-9\\-]{1,63}"; const NAME_PATTERN = "[a-z0-9\\-]{1,63}";
export function CreateAgentForm() { export function CreateAgentForm({
onClose,
}: {
/** Rendered as the form panel's own close button this form always
* mounts inside a `plain` `Dialog` (see `AgentsPage`), which no longer
* floats its own. */
onClose?: () => void;
}) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [hive, setHive] = useState(""); const [hive, setHive] = useState("");
// `null` = still loading, `[]` = loaded but empty (a real, if unusual, // `null` = still loading, `[]` = loaded but empty (a real, if unusual,
@ -132,7 +139,7 @@ export function CreateAgentForm() {
return ( return (
<div class="create-agent-layout"> <div class="create-agent-layout">
<div class="create-agent-form-col"> <div class="create-agent-form-col">
<Panel title="create agent" icon="🤖"> <Panel title="create agent" icon="🤖" onClose={onClose}>
<p class="create-agent-intro"> <p class="create-agent-intro">
Create a new agent's swarm-level identity. This only queues the job Create a new agent's swarm-level identity. This only queues the job
check <Link href="/jobs">jobs</Link> to watch it settle. check <Link href="/jobs">jobs</Link> to watch it settle.

View file

@ -63,9 +63,14 @@ const MODE_OPTIONS = [
export function LinkMatrixAccountForm({ export function LinkMatrixAccountForm({
hive, hive,
agent, agent,
onClose,
}: { }: {
hive: string; hive: string;
agent: string; agent: string;
/** Rendered as the panel header's own close button this form always
* mounts inside a `plain` `Dialog` (see `AgentsPage`), which no longer
* floats its own. */
onClose?: () => void;
}) { }) {
const [account, setAccount] = useState(""); const [account, setAccount] = useState("");
const [mode, setMode] = useState<Mode>("token"); const [mode, setMode] = useState<Mode>("token");
@ -127,7 +132,11 @@ export function LinkMatrixAccountForm({
} }
return ( return (
<Panel title={`link a matrix account — ${agent}`} icon="🔗"> <Panel
title={`link a matrix account — ${agent}`}
icon="🔗"
onClose={onClose}
>
<p> <p>
Writes the credential to the swarm secret store and notifies{" "} Writes the credential to the swarm secret store and notifies{" "}
<strong>{hive}</strong> to deliver it. The agent's own matrix daemon <strong>{hive}</strong> to deliver it. The agent's own matrix daemon

View file

@ -592,13 +592,15 @@ export function AgentsPage() {
open={createOpen} open={createOpen}
onClose={() => setCreateOpen(false)} onClose={() => setCreateOpen(false)}
label="create agent" label="create agent"
plain
> >
<CreateAgentForm /> <CreateAgentForm onClose={() => setCreateOpen(false)} />
</Dialog> </Dialog>
<Dialog <Dialog
open={matrixTarget !== null} open={matrixTarget !== null}
onClose={() => setMatrixTarget(null)} onClose={() => setMatrixTarget(null)}
label="link a matrix account" label="link a matrix account"
plain
> >
{/* `matrixTarget.hive` is non-null here the trigger badge above {/* `matrixTarget.hive` is non-null here the trigger badge above
is disabled without one, so this can only open with a real is disabled without one, so this can only open with a real
@ -607,6 +609,7 @@ export function AgentsPage() {
<LinkMatrixAccountForm <LinkMatrixAccountForm
hive={matrixTarget.hive} hive={matrixTarget.hive}
agent={matrixTarget.name} agent={matrixTarget.name}
onClose={() => setMatrixTarget(null)}
/> />
) : null} ) : null}
</Dialog> </Dialog>

View file

@ -32,6 +32,16 @@
.ui-dialog::backdrop { .ui-dialog::backdrop {
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
} }
/* `plain` (see Dialog.tsx's file-top comment): drop this dialog's own
card look entirely sizing/backdrop/scroll behavior above are the
only things left that are actually this component's job once the
content supplies its own bordered card (a `Panel`). */
.ui-dialog-plain {
border: none;
border-radius: 0;
background: none;
padding: 0;
}
.ui-dialog-close { .ui-dialog-close {
position: absolute; position: absolute;
top: 0.75em; top: 0.75em;
@ -60,3 +70,9 @@
render directly under the button instead of beside it. */ render directly under the button instead of beside it. */
padding-right: 3.25em; padding-right: 3.25em;
} }
/* `plain` has no floating close button of its own to clear the
content's own `Panel` header carries one instead (see `.ui-panel-close`
in Panel.css). */
.ui-dialog-body-plain {
padding-right: 0;
}

View file

@ -10,6 +10,18 @@
// from its former life as a standalone route — reusing that unchanged // from its former life as a standalone route — reusing that unchanged
// is simpler and lower-risk than re-deriving a modal-specific layout // is simpler and lower-risk than re-deriving a modal-specific layout
// for content mara has already reviewed). // for content mara has already reviewed).
//
// `plain` (mara, screenshot of the matrix-account dialog: "the outer
// card does not serve a purpose. move the close btn into the bar and
// drop one layer"): content that's already a `Panel` — a bordered
// card with its own header bar — nested inside this dialog's own
// bordered card produced two concentric boxes in the same `--bg-elev`,
// one serving no purpose. `plain` drops this dialog's own card chrome
// (border/background/padding) and its floating close button, so a
// `Panel` (with its own `onClose` wired into its header) becomes the
// dialog's sole visible card instead of a second one nested inside it.
// Escape and backdrop-click still close it either way — those are the
// native `<dialog>`'s own behavior, not this button's.
import { useEffect, useRef } from "preact/hooks"; import { useEffect, useRef } from "preact/hooks";
import type { ComponentChildren } from "preact"; import type { ComponentChildren } from "preact";
import "./Dialog.css"; import "./Dialog.css";
@ -19,6 +31,7 @@ export function Dialog({
onClose, onClose,
label, label,
children, children,
plain = false,
}: { }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@ -26,6 +39,10 @@ export function Dialog({
// why content owns its own heading. // why content owns its own heading.
label: string; label: string;
children: ComponentChildren; children: ComponentChildren;
/** Drop this dialog's own card chrome + floating close button see
* file-top comment. Pair with a `Panel` (or similar) that renders its
* own header + `onClose` button as the dialog's content. */
plain?: boolean;
}) { }) {
const ref = useRef<HTMLDialogElement>(null); const ref = useRef<HTMLDialogElement>(null);
@ -57,7 +74,7 @@ export function Dialog({
return ( return (
<dialog <dialog
ref={ref} ref={ref}
class="ui-dialog" class={plain ? "ui-dialog ui-dialog-plain" : "ui-dialog"}
aria-label={label} aria-label={label}
// A click lands on the `<dialog>` element itself (not any child) // A click lands on the `<dialog>` element itself (not any child)
// exactly when it's outside the rendered content box — inside // exactly when it's outside the rendered content box — inside
@ -67,6 +84,7 @@ export function Dialog({
if (e.target === ref.current) onClose(); if (e.target === ref.current) onClose();
}} }}
> >
{plain ? null : (
<button <button
type="button" type="button"
class="ui-dialog-close" class="ui-dialog-close"
@ -75,7 +93,12 @@ export function Dialog({
> >
</button> </button>
<div class="ui-dialog-body">{children}</div> )}
<div
class={plain ? "ui-dialog-body ui-dialog-body-plain" : "ui-dialog-body"}
>
{children}
</div>
</dialog> </dialog>
); );
} }

View file

@ -20,14 +20,43 @@
line-height: 1; line-height: 1;
} }
/* `margin-left: auto` (not `justify-content: space-between` on the /* `margin-left: auto` (not `justify-content: space-between` on the
header) so actions still land at the right edge even on the rare header) so this cluster still lands at the right edge even on the
panel that has actions but no title. */ rare panel that has actions/onClose but no title. `actions` and the
.ui-panel-actions { close button (`onClose`) share this one wrapper rather than each
getting their own `margin-left: auto` slot see Panel.tsx's file-top
comment. */
.ui-panel-end {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5em; gap: 0.5em;
margin-left: auto; margin-left: auto;
} }
.ui-panel-actions {
display: flex;
align-items: center;
gap: 0.5em;
}
/* Same interactive treatment as `Dialog`'s own `.ui-dialog-close`
(border/background on hover), scaled down to fit a header row instead
of floating absolutely over the whole card. */
.ui-panel-close {
display: flex;
align-items: center;
justify-content: center;
width: 1.8em;
height: 1.8em;
border: 1px solid transparent;
border-radius: 0.4em;
background: none;
color: var(--fg);
font-size: 1em;
line-height: 1;
cursor: pointer;
}
.ui-panel-close:hover {
border-color: var(--border);
background: var(--bg);
}
.ui-panel-body { .ui-panel-body {
padding: 1em; padding: 1em;
} }

View file

@ -12,6 +12,8 @@
// content either). `HivesPage`'s refresh-interval picker is the // content either). `HivesPage`'s refresh-interval picker is the
// motivating caller. // motivating caller.
// //
// `onClose`: end-of-header close (✕) for `Dialog`'s `plain` mode.
//
// `icon` is a small header glyph, left of the title — the swarm-ui // `icon` is a small header glyph, left of the title — the swarm-ui
// design guide's own whimsy reference (before this, the guide only // design guide's own whimsy reference (before this, the guide only
// pointed at the dashboard's matrix-rain background). Grew out of // pointed at the dashboard's matrix-rain background). Grew out of
@ -33,12 +35,16 @@ export function Panel({
title, title,
icon, icon,
actions, actions,
onClose,
children, children,
class: extraClass, class: extraClass,
}: { }: {
title?: string; title?: string;
icon?: string; icon?: string;
actions?: ComponentChildren; actions?: ComponentChildren;
/** Renders a close () button at the end of the header see file-top
* comment. Omit for a panel that isn't a dialog's own content. */
onClose?: () => void;
children: ComponentChildren; children: ComponentChildren;
/** Extra class on the outer `<section>` e.g. flex-sizing a panel /** Extra class on the outer `<section>` e.g. flex-sizing a panel
* that's one of several sharing a row (`AgentsPage`'s list/detail * that's one of several sharing a row (`AgentsPage`'s list/detail
@ -47,7 +53,7 @@ export function Panel({
}) { }) {
return ( return (
<section class={extraClass ? `ui-panel ${extraClass}` : "ui-panel"}> <section class={extraClass ? `ui-panel ${extraClass}` : "ui-panel"}>
{title || icon || actions ? ( {title || icon || actions || onClose ? (
<div class="ui-panel-header"> <div class="ui-panel-header">
{icon ? ( {icon ? (
<span class="ui-panel-icon" aria-hidden="true"> <span class="ui-panel-icon" aria-hidden="true">
@ -55,7 +61,21 @@ export function Panel({
</span> </span>
) : null} ) : null}
{title ? <h2 class="ui-panel-title">{title}</h2> : null} {title ? <h2 class="ui-panel-title">{title}</h2> : null}
{actions || onClose ? (
<div class="ui-panel-end">
{actions ? <div class="ui-panel-actions">{actions}</div> : null} {actions ? <div class="ui-panel-actions">{actions}</div> : null}
{onClose ? (
<button
type="button"
class="ui-panel-close"
aria-label="close"
onClick={onClose}
>
</button>
) : null}
</div>
) : null}
</div> </div>
) : null} ) : null}
<div class="ui-panel-body">{children}</div> <div class="ui-panel-body">{children}</div>