From 70035605697b4b33e00435bb08193d1b15b7af3f Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 11 Sep 2026 22:14:21 +0200 Subject: [PATCH] swarm-ui: extract Card/MultiselectFilter/SplitView per component-first design Mara: "result looks like the shape i am looking for, but the code does not. you did not follow component first principle" - the card's clickable/selectable mechanics, the card-view filter trigger+popover, and the list+detail split layout were all one-off page-local JSX in AgentsPage.tsx instead of docs/web-ui/design-guide.md's "Component-first design" primitives. Three new ui/ components, each with a same-day /components demo section per that doc's own rule: - ui/card/Card.tsx - the role=button/keyboard-activation/selected mechanics AgentCard now wraps agent-specific content around, instead of owning them itself. - ui/multiselect-filter/MultiselectFilter.tsx - the checkbox-list trigger+popover control. This was also a straight duplicate of Table's own inline popover content once AgentsPage's filter toolbar needed the identical thing; Table now renders the same MultiselectFilterOptions piece too (keeping its own th-anchored trigger and fixed+portal positioning, which are genuinely table-specific), not a second copy. - ui/split-view/SplitView.tsx - the list+detail flex-wrap layout, no opinion on what's inside either pane. AgentsPage.tsx's own CSS shrinks to just the agent-specific content inside these primitives (card line/message layout, detail-panel field grid, the name-search input) - the container/positioning rules moved to each component's own colocated CSS. No behavior change for any other Table caller (HivesPage, IssueReportPage, the components demo's own Table samples) - the popover's visual output is identical, just sourced from the shared component instead of inline JSX. Verified: typecheck/build clean, real screenshots of both AgentsPage (pixel-identical to before) and the three new /components sections, plus a live click confirming MultiselectFilter's popover opens correctly on the demo page too. --- .../swarm-ui/src/pages/AgentsPage.css | 65 +-- .../swarm-ui/src/pages/AgentsPage.tsx | 536 ++++++++---------- .../swarm-ui/src/pages/ComponentsPage.tsx | 77 +++ .../packages/swarm-ui/src/ui/card/Card.css | 26 + .../packages/swarm-ui/src/ui/card/Card.tsx | 64 +++ .../multiselect-filter/MultiselectFilter.css | 51 ++ .../multiselect-filter/MultiselectFilter.tsx | 140 +++++ .../swarm-ui/src/ui/split-view/SplitView.css | 14 + .../swarm-ui/src/ui/split-view/SplitView.tsx | 33 ++ .../packages/swarm-ui/src/ui/table/Table.css | 22 +- .../packages/swarm-ui/src/ui/table/Table.tsx | 22 +- 11 files changed, 648 insertions(+), 402 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/ui/card/Card.css create mode 100644 frontend/packages/swarm-ui/src/ui/card/Card.tsx create mode 100644 frontend/packages/swarm-ui/src/ui/multiselect-filter/MultiselectFilter.css create mode 100644 frontend/packages/swarm-ui/src/ui/multiselect-filter/MultiselectFilter.tsx create mode 100644 frontend/packages/swarm-ui/src/ui/split-view/SplitView.css create mode 100644 frontend/packages/swarm-ui/src/ui/split-view/SplitView.tsx diff --git a/frontend/packages/swarm-ui/src/pages/AgentsPage.css b/frontend/packages/swarm-ui/src/pages/AgentsPage.css index aa59f3c6..e539a20c 100644 --- a/frontend/packages/swarm-ui/src/pages/AgentsPage.css +++ b/frontend/packages/swarm-ui/src/pages/AgentsPage.css @@ -2,7 +2,9 @@ old table body (mara: "more like card per agent"). Full-width rows, not a grid: the second line (the agent's free-form status message) reads better wrapping the card's own width than squeezed into a fixed - grid cell. */ + grid cell. Each card's own clickable/selected/border styling is + `Card`'s (`ui/card/`) — this file only lays out agent-specific + content inside one. */ .ui-agent-card-list { display: flex; flex-direction: column; @@ -45,29 +47,6 @@ .ui-agents-view-toggle button:not(.active):hover { color: var(--fg); } -.ui-agent-card { - display: flex; - flex-direction: column; - gap: 0.35em; - padding: 0.75em 1em; - border: 1px solid var(--border); - border-radius: 0.5em; - background: var(--bg-elev); - cursor: pointer; -} -.ui-agent-card:hover, -.ui-agent-card:focus-visible { - border-color: var(--purple); - outline: none; -} -/* The row currently shown in the detail panel — a solid border (not - just the hover tint above, which needs to keep meaning "hovering", - not double as "selected") plus a faint fill so it still reads once - the pointer moves away. */ -.ui-agent-card-selected { - border-color: var(--purple); - background: color-mix(in srgb, var(--purple) 10%, var(--bg-elev)); -} .ui-agent-card-line1 { display: flex; align-items: center; @@ -110,27 +89,11 @@ gap: 0.75em; } -/* List panel + detail panel side by side — `flex-wrap`, not a - `@media` breakpoint, so a narrow viewport stacks them the same - content-driven way the shell's own nav already wraps (`Shell.css`), - rather than picking a second, independent magic-number breakpoint. */ -.ui-agents-layout { - display: flex; - flex-wrap: wrap; - gap: 1em; - align-items: flex-start; -} -.ui-agents-list-panel { - flex: 2 1 480px; - min-width: 0; -} -.ui-agents-detail-panel { - flex: 1 1 320px; - min-width: 0; -} - /* Card view's filter toolbar — `Table`'s per-column popovers, minus the - table to hang them off of. */ + table to hang them off of. Each `MultiselectFilter` (`ui/ + multiselect-filter/`) owns its own trigger/popover styling; this file + only lays out the row they sit in plus the name-search input, which + has no shared-component equivalent (a plain text input). */ .ui-agents-filter-bar { display: flex; flex-wrap: wrap; @@ -147,17 +110,3 @@ font: inherit; font-size: 0.9em; } -.ui-agents-filter { - position: relative; -} -/* `.ui-table-filter-popover` (Table.css) supplies the panel chrome - (background/border/shadow) — this just positions it under the - trigger, `position: absolute` rather than the fixed/portal recipe - `Table`'s own version needs: nothing here clips this popover, so the - simpler positioning is enough (see this component's own comment). */ -.ui-agents-filter-popover { - position: absolute; - top: 100%; - left: 0; - margin-top: 0.25em; -} diff --git a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx index c7344a54..0ea72450 100644 --- a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx @@ -28,7 +28,7 @@ // // The "wanted" column is one `WantedMenu` badge+dropdown per row — see // that component's own comment above `AgentsPage` for why. -import { useEffect, useRef, useState } from "preact/hooks"; +import { useRef, useState } from "preact/hooks"; import type { ComponentChildren } from "preact"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; @@ -36,8 +36,10 @@ import { Badge, type BadgeTone } from "@hive/shared/badge.js"; import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js"; import { LinkIcon } from "@hive/shared/icons.js"; import { Button } from "../ui/button/Button.js"; +import { Card } from "../ui/card/Card.js"; import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.js"; import { Dialog } from "../ui/dialog/Dialog.js"; +import { MultiselectFilter } from "../ui/multiselect-filter/MultiselectFilter.js"; import { Panel } from "../ui/panel/Panel.js"; import { RelativeTime } from "../ui/relative-time/RelativeTime.js"; import { @@ -45,6 +47,7 @@ import { useRefreshInterval, type RefreshIntervalMs, } from "../ui/refresh-interval/RefreshInterval.js"; +import { SplitView } from "../ui/split-view/SplitView.js"; import { Table, useTableFilters, type TableColumn } from "../ui/table/Table.js"; import { CreateAgentForm } from "./CreateAgentForm.js"; import { LinkMatrixAccountForm } from "./LinkMatrixAccountForm.js"; @@ -279,12 +282,11 @@ function WantedMenu({ // esbuild config can't consume those yet (same gap `Dialog.tsx`'s own // comment already flags for `hive-dialog`). Revisit once that closes. // -// A `role="button"` div, not a real ` -
- +
- cards - - -
- setViewMode("cards")} + > + cards + + +
+ + + } + > + {error ? ( + - - } - > - {error ? ( - - ) : null} - {!error && rows === null ?

loading…

: null} - {rows && rows.length === 0 ? ( -

- no agents yet — the swarm-wide identity store has no agents - registered on any hive -

- ) : null} - {/* Card view's own filter toolbar — table view keeps its + ) : null} + {!error && rows === null ?

loading…

: null} + {rows && rows.length === 0 ? ( +

+ no agents yet — the swarm-wide identity store has no agents + registered on any hive +

+ ) : null} + {/* Card view's own filter toolbar — table view keeps its existing per-column popovers instead (see `columns` above); both read/write the same `useTableFilters` state (same `storageKey`), so switching the view toggle doesn't reset or hide whatever's filtered. */} - {rows && rows.length > 0 && viewMode === "cards" ? ( - - ) : null} - {rows && rows.length > 0 && viewMode === "cards" ? ( -
- {visibleRows.length === 0 ? ( -

no rows match the current filter

- ) : ( - visibleRows.map((a) => ( - - )) - )} -
- ) : null} - {rows && rows.length > 0 && viewMode === "table" ? ( - a.name} - storageKey="swarm-ui:agents:table-filters" - /> - ) : null} - - {/* A second on-page `Panel`, not a modal — mara: "why no separate - panel? ... i mean a second panel on agent page." Always - mounted (an empty state when nothing's selected) rather than - conditionally rendered, so picking an agent never causes the - page's own layout to jump. `.ui-agents-layout`'s `flex-wrap` - stacks this below the list on a narrow viewport, same - content-driven-not-a-fixed-breakpoint approach the shell's own - nav already uses (`Shell.css`), rather than a new media query. */} - - {detailTarget ? ( -
-
-
status
-
- {(() => { - const { tone, label } = FRESHNESS[detailTarget.freshness]; - return ( - - {label} - {detailTarget.last_seen_unix !== null ? ( - <> - {" "} - ( - - ) - - ) : null} - - } - /> - ); - })()} -
-
message
-
{detailTarget.snapshot?.status_text ?? "—"}
-
wanted
-
- {/* Full menu (destroy included) — mara: "destroy is + {rows && rows.length > 0 && viewMode === "cards" ? ( + + ) : null} + {rows && rows.length > 0 && viewMode === "cards" ? ( +
+ {visibleRows.length === 0 ? ( +

+ no rows match the current filter +

+ ) : ( + visibleRows.map((a) => ( + + )) + )} +
+ ) : null} + {rows && rows.length > 0 && viewMode === "table" ? ( +
a.name} + storageKey="swarm-ui:agents:table-filters" + /> + ) : null} + + } + // A second on-page `Panel`, not a modal — mara: "why no separate + // panel? ... i mean a second panel on agent page." Always mounted + // (an empty state when nothing's selected) rather than + // conditionally rendered, so picking an agent never causes the + // page's own layout to jump. `SplitView`'s `flex-wrap` stacks + // this below the list on a narrow viewport, same content-driven- + // not-a-fixed-breakpoint approach the shell's own nav already + // uses (`Shell.css`), rather than a new media query. + secondary={ + + {detailTarget ? ( +
+
+
status
+
+ {(() => { + const { tone, label } = FRESHNESS[detailTarget.freshness]; + return ( + + {label} + {detailTarget.last_seen_unix !== null ? ( + <> + {" "} + ( + + ) + + ) : null} + + } + /> + ); + })()} +
+
message
+
{detailTarget.snapshot?.status_text ?? "—"}
+
wanted
+
+ {/* Full menu (destroy included) — mara: "destroy is already available via wanted state", no separate button needed. */} - -
-
hive
-
{detailTarget.hive ?? "—"}
-
config PR
-
- {detailTarget.config_pr ? ( - - #{detailTarget.config_pr.pr_number} - - ) : ( - `#${detailTarget.config_pr.pr_number}` - ) - } + - ) : ( - "—" - )} -
-
-
- } - value="link matrix account" - onClick={ - detailTarget.hive - ? () => setMatrixTarget(detailTarget) - : undefined - } - disabled={!detailTarget.hive} - title={ - detailTarget.hive - ? `link a matrix account to ${detailTarget.name}` - : "no hive on record for this agent — nothing to link against" - } - /> + +
hive
+
{detailTarget.hive ?? "—"}
+
config PR
+
+ {detailTarget.config_pr ? ( + + #{detailTarget.config_pr.pr_number} + + ) : ( + `#${detailTarget.config_pr.pr_number}` + ) + } + /> + ) : ( + "—" + )} +
+ +
+ } + value="link matrix account" + onClick={ + detailTarget.hive + ? () => setMatrixTarget(detailTarget) + : undefined + } + disabled={!detailTarget.hive} + title={ + detailTarget.hive + ? `link a matrix account to ${detailTarget.name}` + : "no hive on record for this agent — nothing to link against" + } + /> +
-
- ) : ( -

select an agent to see its details

- )} -
- + ) : ( +

select an agent to see its details

+ )} + + } + /> setCreateOpen(false)} diff --git a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx index bb4f3fcf..98280748 100644 --- a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx @@ -7,12 +7,18 @@ // swarm-controller's API is up or not. import { useRef, useState } from "preact/hooks"; import type { ComponentChildren } from "preact"; +import { Card } from "../ui/card/Card.js"; +import { + MultiselectFilter, + type MultiselectFilterState, +} from "../ui/multiselect-filter/MultiselectFilter.js"; import { Panel } from "../ui/panel/Panel.js"; import { RelativeTime } from "../ui/relative-time/RelativeTime.js"; import { RefreshIntervalPicker, type RefreshIntervalMs, } from "../ui/refresh-interval/RefreshInterval.js"; +import { SplitView } from "../ui/split-view/SplitView.js"; import { Table, type TableColumn } from "../ui/table/Table.js"; import { TextField } from "../ui/text-field/TextField.js"; import { @@ -212,6 +218,31 @@ function DialogSample() { ); } +// Own state for the same reason `TextFieldSample` needs it — a +// controlled sample has to actually respond to interaction. +function MultiselectFilterSample() { + const [filter, setFilter] = useState({ + values: [], + negate: false, + }); + return ( + + setFilter((f) => ({ + ...f, + values: f.values.includes(v) + ? f.values.filter((x) => x !== v) + : [...f.values, v], + })) + } + onNegateChange={(negate) => setFilter((f) => ({ ...f, negate }))} + /> + ); +} + function ConfirmDialogSample() { const [open, setOpen] = useState(false); return ( @@ -269,6 +300,23 @@ export function ComponentsPage() { +
+ + + primary content — e.g. AgentsPage's roster + + } + secondary={ + + secondary content — e.g. the selected row + + } + /> + +
+
+
+
+ + + alpha +
plain content, not clickable
+
+
+ + {}}> + beta +
click, or Enter/Space when focused
+
+
+ + {}}> + gamma +
same card, with selected set
+
+
+
+
+ +
+ + + +
+
diff --git a/frontend/packages/swarm-ui/src/ui/card/Card.css b/frontend/packages/swarm-ui/src/ui/card/Card.css new file mode 100644 index 00000000..72abb806 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/card/Card.css @@ -0,0 +1,26 @@ +.ui-card { + display: flex; + flex-direction: column; + gap: 0.35em; + padding: 0.75em 1em; + border: 1px solid var(--border); + border-radius: 0.5em; + background: var(--bg-elev); +} +/* Only an interactive `Card` (has `onClick`) gets pointer/hover + affordance — a plain static one shouldn't look clickable. */ +[role="button"].ui-card { + cursor: pointer; +} +[role="button"].ui-card:hover, +[role="button"].ui-card:focus-visible { + border-color: var(--purple); + outline: none; +} +/* A solid border (not just the hover tint above, which needs to keep + meaning "hovering", not double as "selected") plus a faint fill so a + selected card still reads once the pointer moves away. */ +.ui-card-selected { + border-color: var(--purple); + background: color-mix(in srgb, var(--purple) 10%, var(--bg-elev)); +} diff --git a/frontend/packages/swarm-ui/src/ui/card/Card.tsx b/frontend/packages/swarm-ui/src/ui/card/Card.tsx new file mode 100644 index 00000000..8e3e687b --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/card/Card.tsx @@ -0,0 +1,64 @@ +// — the clickable/selectable-container mechanics (role="button", +// keyboard activation, hover + selected styling) any card-shaped list +// item needs, with zero opinion on its content — same "primitive owns +// the mechanics, caller owns the content" split as `Panel`/`Dialog`. +// Extracted out of `AgentsPage`'s `AgentCard` (design-guide's +// "Component-first design": a page had hand-rolled this rather than +// reaching for a `src/ui/` primitive) — `AgentCard` now wraps agent- +// specific content (name/badges/message) around this instead of owning +// the click/keyboard/selected logic itself. +// +// A `role="button"` `
`, not a real `
`) and its +// own positioning (`position: fixed` + portal, to escape +// `.ui-table-scroll`'s clip — see that file's own comment), genuinely +// different concerns from a plain toolbar's; only the checkbox-list +// *content* moves here, as `MultiselectFilterOptions`. A caller with no +// clipping context and no header row to anchor an icon to — a plain +// toolbar, `AgentsPage`'s first use — reaches for `MultiselectFilter` +// instead: the same content, wrapped in its own `Badge` trigger and a +// much simpler `position: absolute` popover. +import { useEffect, useRef, useState } from "preact/hooks"; +import { Badge } from "@hive/shared/badge.js"; +import "./MultiselectFilter.css"; + +export interface MultiselectFilterState { + values: string[]; + negate: boolean; +} + +export function MultiselectFilterOptions({ + options, + filter, + onToggle, +}: { + options: string[]; + filter: MultiselectFilterState; + onToggle: (value: string) => void; +}) { + return ( +
+ {options.map((v) => ( + + ))} +
+ ); +} + +// The negate toggle — mara: "you should be able to change if the filter +// is negated or not (eg search for this text vs exclude it)", same +// reasoning as `Table`'s own copy. Small enough (one checkbox+label) +// that keeping it inline here rather than a third exported piece isn't +// worth the indirection; `Table` keeps rendering its own copy for text- +// mode filters, which don't otherwise touch this file at all. +function NegateToggle({ + negate, + onChange, +}: { + negate: boolean; + onChange: (negate: boolean) => void; +}) { + return ( + + ); +} + +export function MultiselectFilter({ + label, + options, + filter, + onToggle, + onNegateChange, +}: { + label: string; + options: string[]; + filter: MultiselectFilterState; + onToggle: (value: string) => void; + onNegateChange: (negate: boolean) => void; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + function handlePointerDown(e: PointerEvent) { + if (e.target instanceof Node && ref.current?.contains(e.target)) return; + setOpen(false); + } + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("pointerdown", handlePointerDown, true); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("pointerdown", handlePointerDown, true); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + return ( +
+ 0 + ? `${label} (${filter.values.length})` + : label + } + onClick={() => setOpen((o) => !o)} + expanded={open} + /> + {open ? ( + + ) : null} +
+ ); +} diff --git a/frontend/packages/swarm-ui/src/ui/split-view/SplitView.css b/frontend/packages/swarm-ui/src/ui/split-view/SplitView.css new file mode 100644 index 00000000..6e70efd4 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/split-view/SplitView.css @@ -0,0 +1,14 @@ +.ui-split-view { + display: flex; + flex-wrap: wrap; + gap: 1em; + align-items: flex-start; +} +.ui-split-view-primary { + flex: 2 1 480px; + min-width: 0; +} +.ui-split-view-secondary { + flex: 1 1 320px; + min-width: 0; +} diff --git a/frontend/packages/swarm-ui/src/ui/split-view/SplitView.tsx b/frontend/packages/swarm-ui/src/ui/split-view/SplitView.tsx new file mode 100644 index 00000000..09931966 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/split-view/SplitView.tsx @@ -0,0 +1,33 @@ +// — a primary/secondary pane laid out side by side, wrapping +// to stacked on a narrow viewport. Extracted out of `AgentsPage`'s own +// list+detail row (design-guide's "Component-first design": a page had +// built this inline for its own list/detail split, `AgentsPage`'s +// `Panel`s reaching for it is the first real use, per the doc's "build +// the primitive before or alongside the first real page that needs it"). +// +// `flex-wrap`, not a `@media` breakpoint — content-driven stacking, same +// approach the shell's own nav row already uses (`shell/Shell.css`) +// rather than a second, independent magic-number breakpoint. No opinion +// on what's inside either pane (a `Panel`, anything else) — same "own +// the mechanics, not the content" split as `Card`. +import type { ComponentChildren } from "preact"; +import "./SplitView.css"; + +export function SplitView({ + primary, + secondary, +}: { + /** The wider pane — a list, a table, whatever the page's main content + * is. `flex: 2` against `secondary`'s `flex: 1`. */ + primary: ComponentChildren; + /** The narrower pane — a detail view, a summary, anything that reads + * as "more about the thing selected in `primary`." */ + secondary: ComponentChildren; +}) { + return ( +
+
{primary}
+
{secondary}
+
+ ); +} diff --git a/frontend/packages/swarm-ui/src/ui/table/Table.css b/frontend/packages/swarm-ui/src/ui/table/Table.css index 6be32e7a..06277b49 100644 --- a/frontend/packages/swarm-ui/src/ui/table/Table.css +++ b/frontend/packages/swarm-ui/src/ui/table/Table.css @@ -141,25 +141,9 @@ font-size: 0.9em; } -/* `filterMode: "multiselect"` — a scrollable checkbox list rather than - a ` toggleFilterValue(c.key, v)} - /> - {v} - - ))} - + toggleFilterValue(c.key, v)} + /> {renderNegateToggle(c)} );