feat: shared toolbar, footer and host dot pieces for plugin UI

This commit is contained in:
LukeGus committed 2026-11-06 13:36:36 -06:00
1 parent 3c6229671c
commit 020f30541a
25 files changed
+539 -39

No files matched your search

+5
View File
@@ -11,6 +11,7 @@
- Plugins can build pages of tiles you drag and resize, and add many copies of one dashboard card, each with its own settings - Plugins can build pages of tiles you drag and resize, and add many copies of one dashboard card, each with its own settings
- A plugin a newer release ships is downloaded on its own when your image does not have it, and tried again on the next start when offline - A plugin a newer release ships is downloaded on its own when your image does not have it, and tried again on the next start when offline
- Plugins can decide who may connect to a host, so a host can need approved access before an SSH or remote desktop session opens - Plugins can decide who may connect to a host, so a host can need approved access before an SSH or remote desktop session opens
- When more than one plugin flags a host, its row shows one status dot with every reason in its tooltip
### Changed ### Changed
@@ -25,6 +26,7 @@
- The Linux Flatpak now installs from flatpak.termix.site on x86_64 and arm64, and `flatpak update` keeps it current - The Linux Flatpak now installs from flatpak.termix.site on x86_64 and arm64, and `flatpak update` keeps it current
- Dropdowns and text areas are the same size as the text fields next to them - Dropdowns and text areas are the same size as the text fields next to them
- A page's own tabs sit above its search bar - A page's own tabs sit above its search bar
- A plugin view that is still starting shows the same loading spinner as the rest of Termix
### Fixed ### Fixed
@@ -43,6 +45,9 @@
- The hint at the bottom of Browse and the stored data footer on plugin pages have the same padding as the rest of the page - The hint at the bottom of Browse and the stored data footer on plugin pages have the same padding as the rest of the page
- Sign in methods on the Security page have a line between them - Sign in methods on the Security page have a line between them
- Chart axis labels with long units, like KiB/s, are no longer cut off - Chart axis labels with long units, like KiB/s, are no longer cut off
- Filters, view toggles and small buttons in a toolbar are the same height as the search box next to them
- Save and Cancel at the bottom of a form are the same height as other buttons
- State badges in tables no longer stretch across the whole column
## 26.11.0 ## 26.11.0
+23
View File
@@ -1,5 +1,28 @@
# Changelog # Changelog
## 1.1.2
Plugin API 1.15.0.
### Added
- `/ui` exports `ToolbarBand` (a toolbar row outside the PanelShell toolbar), `ToolbarSelect` (a small select for those rows) and `TOOLBAR_FIT` (the classes that make every control in a row 32px)
- `PANEL.section`, the one inner layout for a SectionCard body
- `TextAreaField` takes `fill`, so a config or log editor uses the rest of the height
- `registerHostBadge` takes `useDot`. Every plugin's dots merge into one dot on the host row, with the worst tone and each reason in its tooltip. `/ui` exports `HostDotGroup` for layouts that draw host rows
### Changed
- Controls in the PanelShell toolbar, tab band and footer are all 32px, so small buttons line up with search boxes and selects
- `Segmented` and `ViewToggle` match the height of inputs and buttons, and `Segmented` keeps its natural width
- `FormFooter` buttons are full height
- `PANEL.body` is `p-3`, the same padding `InlineView` gives a form
- A body with a pinned footer no longer draws a closing line just above the footer's own line
- The placeholder for a plugin view that is still starting uses `LoadingState`
- `termix-plugin validate` warns about UI drift: raw loading spinners, small back buttons, native selects, stretched filters, filled headers, capped log views and footers that scroll away
- A `ToolbarBand` right inside a `TileStack` leaves the line under it to the stack
- `ListBadge` and `StatusBadge` keep their own width in a table cell instead of filling it
## 1.1.1 ## 1.1.1
Plugin API 1.15.0. Plugin API 1.15.0.
+22 -4
View File
@@ -1,6 +1,6 @@
# Termix plugin guide # Termix plugin guide
SDK 1.1.1 | Plugin API 1.15.0 SDK 1.1.2 | Plugin API 1.15.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed. `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -782,9 +782,27 @@ exports with props is `ui.api.txt` in the Termix repo
`StatTile` or `MetricCard`, and use `TileFrame` for a titled section. Cards drop their own box inside `StatTile` or `MetricCard`, and use `TileFrame` for a titled section. Cards drop their own box inside
these. Forms keep `PANEL.body` padding inside their section. An `InlineView` form made of `SectionCard`s these. Forms keep `PANEL.body` padding inside their section. An `InlineView` form made of `SectionCard`s
takes `stacked`. takes `stacked`.
- Page views go in `PanelShell`'s `tabs` band as a `TabStrip`. `Segmented` is for filters and view modes. - Page views go in `PanelShell`'s `tabs` band as a `TabStrip`. `Segmented` is for filters and view modes and
A page with many sections uses `SideNav` on the left. Put the refresh button in the header with keeps its natural width, never stretched across the pane. A page with many sections uses `SideNav` on the
`RefreshButton`, and the docs link with the `docs` prop. left. Put the refresh button in the header with `RefreshButton`, and the docs link with the `docs` prop.
- Toolbars: search, filters and buttons go in `PanelShell`'s `toolbar` slot, or in a `ToolbarBand` for a second
row. Both make every control in the row 32px, so a `size="sm"` button lines up with a search box. Use
`ToolbarSelect` for a small select there, never a native `<select>`. Don't build your own row with its own
padding.
- Padding: loose content in a page body (a hint, a single field) takes `PANEL.body`. A `SectionCard` body uses
`PANEL.section` for its fields.
- Headers have no background. Label a group with `GroupHeading`, never a row with `bg-muted`.
- Sub views: a detail or edit view opens as an `InlineView`, which has the one back button. Anywhere else use
`BackButton` in `PanelShell`'s `leading` slot. Never make a smaller back button.
- Save and Add always sit pinned at the bottom: pass `FormFooter` to `InlineView` or `PanelShell` as `footer`.
Inside a tab body, wrap the form in `<PanelShell chrome={false} footer={...}>`.
- Loading: `LoadingState` for anything still loading (`compact` in cards and rows). A spinner on its own is
only for a button or an activity like "working".
- Config and log editors fill the rest of the view: `TextAreaField fill`, or `min-h-0 flex-1` on a log, never a
fixed number of rows or a `max-h-[60vh]`.
- Host row dots: report a problem with `registerHostBadge({ useDot })` instead of drawing a dot. The layout
merges every plugin's dots into one.
- `termix-plugin validate` lists drift from these rules under `ui`. They are warnings, so fix what applies.
- States and data: `StatusBadge` and `StatusDot` (pass a state like "running" and the color follows), - States and data: `StatusBadge` and `StatusDot` (pass a state like "running" and the color follows),
`InfoGrid` for label and value pairs, `DataTable` for a plain table, `Pager` and `usePaged` under long lists. `InfoGrid` for label and value pairs, `DataTable` for a plain table, `Pager` and `usePaged` under long lists.
- Tile pages use `TileGrid` with `TileFrame` around each tile and `TilePicker` beside it while editing. Slots are - Tile pages use `TileGrid` with `TileFrame` around each tile and `TilePicker` beside it while editing. Slots are
@@ -51,6 +51,9 @@ export async function validate({ cwd }) {
for (const warning of docsWarnings(cwd, raw)) { for (const warning of docsWarnings(cwd, raw)) {
console.warn(` warn ${warning}`); console.warn(` warn ${warning}`);
} }
for (const warning of uiWarnings(cwd)) {
console.warn(` ui ${warning}`);
}
if (problems.length > 0) { if (problems.length > 0) {
for (const problem of problems) console.error(` ${problem}`); for (const problem of problems) console.error(` ${problem}`);
@@ -152,6 +155,92 @@ function walkSources(dir, out = []) {
return out; return out;
} }
/** Whether line i sits inside a still open Button within the lines before it. */
function insideButton(lines, i, reach = 12) {
const before = lines.slice(Math.max(0, i - reach), i + 1).join("\n");
const open = before.lastIndexOf("<Button");
const openLower = before.lastIndexOf("<button");
const start = Math.max(open, openLower);
if (start < 0) return false;
return !/<\/(Button|button)>/.test(before.slice(start));
}
const UI_RULES = [
{
test: (line) => /animate-spin|<Loader2\b/.test(line),
// Spinners inside a button, and activity spinners like "working", are fine.
skip: (lines, i) =>
insideButton(lines, i) ||
!/loading|Loading/.test(lines.slice(Math.max(0, i - 4), i + 4).join(" ")),
message: "raw spinner while loading, use LoadingState",
},
{
test: (line) => /<(ArrowLeft|ChevronLeft)\b/.test(line),
// Only real back buttons, not previous page or month arrows.
skip: (lines, i) =>
!insideButton(lines, i, 6) ||
!/back|onBack|close\b/i.test(
lines.slice(Math.max(0, i - 6), i + 4).join(" "),
),
message:
"hand made back button, use BackButton in PanelShell leading or InlineView",
},
{
test: (line) => /<select\b/.test(line),
message: "native select, use Select or SelectField",
},
{
test: (line) => /\[&>button\]:flex-1/.test(line),
message: "stretched Segmented, keep filters at their natural width",
},
{
test: (line) =>
/(^|[\s"'`])bg-(muted|card|accent)(\/\d+)?(?=[\s"'`]|$)/.test(line) &&
/\bborder-b\b/.test(line) &&
/(uppercase|GroupHeading|py-1\.5)/.test(line),
message: "filled section header, headers have no background",
},
{
test: (line) => /max-h-\[\d+vh\]/.test(line),
message: "viewer capped by screen height, let it fill the view",
},
{
test: (line) => /<FormFooter\b/.test(line),
skip: (lines, i) =>
/footer\s*=/i.test(lines.slice(Math.max(0, i - 4), i + 1).join(" ")),
message:
"FormFooter in the body, pass it to an InlineView or PanelShell footer (PanelShell chrome={false} inside a tab) so it stays pinned",
},
{
test: (line) =>
/\{t\(["'][\w.]*[Ll]oading["']\)\}/.test(line) && !/label=/.test(line),
message: "plain loading text, use LoadingState",
},
];
/**
* Drift from the shared UI kit in src/frontend: raw spinners, hand made back
* buttons, native selects, stretched filters, filled headers and footers that
* scroll away. Warnings only, since some cases are on purpose.
*/
export function uiWarnings(cwd) {
const warnings = [];
for (const file of walkSources(path.join(cwd, "src", "frontend"))) {
if (!file.endsWith(".tsx")) continue;
const lines = fs.readFileSync(file, "utf8").split(/\r?\n/);
const rel = path.relative(cwd, file).replace(/\\/g, "/");
lines.forEach((line, i) => {
if (/^\s*(\/\/|\*|import\b)/.test(line)) return;
for (const rule of UI_RULES) {
if (rule.test(line) && !(rule.skip && rule.skip(lines, i))) {
warnings.push(`${rel}:${i + 1} ${rule.message}`);
}
}
});
}
return warnings;
}
/** /**
* Docs gaps that do not block a release: no docs/index.md, or a process.env * Docs gaps that do not block a release: no docs/index.md, or a process.env
* read the manifest does not list in env. * read the manifest does not list in env.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@termix-ssh/plugin-sdk", "name": "@termix-ssh/plugin-sdk",
"version": "1.1.1", "version": "1.1.2",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
+24 -3
View File
@@ -656,7 +656,7 @@ export interface HostProtocolContribution {
quickConnect?: { showDomain?: boolean }; quickConnect?: { showDomain?: boolean };
} }
export interface HostBadgeContribution { export interface HostRowContribution {
id: string; id: string;
when: (host: PluginHostRecord) => boolean; when: (host: PluginHostRecord) => boolean;
component: ComponentType<{ host: PluginHostRecord }>; component: ComponentType<{ host: PluginHostRecord }>;
@@ -664,11 +664,32 @@ export interface HostBadgeContribution {
order?: number; order?: number;
} }
/** A problem shown as the host row's status dot. */
export interface HostDot {
tone: "bad" | "warn";
label: string;
}
export interface HostBadgeContribution {
id: string;
when: (host: PluginHostRecord) => boolean;
/** Drawn as is on the row. Leave out for a badge that is only a dot. */
component?: ComponentType<{ host: PluginHostRecord }>;
/**
* A hook that returns a problem or null. Every plugin's dots merge into one
* dot on the row, with the worst tone and each label in its tooltip, so two
* plugins reporting the same host never draw two dots. Core 26.11.1+.
*/
useDot?: (host: PluginHostRecord) => HostDot | null;
/** Lower comes first. Ties sort by id. */
order?: number;
}
/** The dot or stripe that shows a host's status on its row. */ /** The dot or stripe that shows a host's status on its row. */
export type HostStatusIndicatorContribution = HostBadgeContribution; export type HostStatusIndicatorContribution = HostRowContribution;
/** A control in the host editor's toolbar. */ /** A control in the host editor's toolbar. */
export type HostEditorToolbarItemContribution = HostBadgeContribution; export type HostEditorToolbarItemContribution = HostRowContribution;
export interface HostSectionProps { export interface HostSectionProps {
host: PluginHostRecord; host: PluginHostRecord;
+6 -2
View File
@@ -149,6 +149,7 @@ HostDefaultsContextValue (type): HostDefaultsContextValue
HostDefaultsLevel (type): HostDefaultsLevel HostDefaultsLevel (type): HostDefaultsLevel
HostDefaultsTarget (type): HostDefaultsTarget HostDefaultsTarget (type): HostDefaultsTarget
HostDensity (type): HostDensity HostDensity (type): HostDensity
HostDotGroup (value): ({ host, badges, className, }: { host: Host; badges: HostBadgeDef[]; className?: string; }) => Element
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
HostFeatureFieldsProps (type): HostFeatureFieldsProps HostFeatureFieldsProps (type): HostFeatureFieldsProps
HostFolder (type): HostFolder HostFolder (type): HostFolder
@@ -210,7 +211,7 @@ NoticePanel (value): ({ icon: Icon, title, children, action, tone, className, }:
NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, max, disabled, className, }: { label: string; value: number; onChange: (value: number) => void; placeholder?: string; hint?: ReactNode; error?: string; min?: number; max?: number; disabled?: boolean; className?: string; }) => Element NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, max, disabled, className, }: { label: string; value: number; onChange: (value: number) => void; placeholder?: string; hint?: ReactNode; error?: string; min?: number; max?: number; disabled?: boolean; className?: string; }) => Element
OPEN_PLUGINS_EVENT (value): "termix:open-plugins" OPEN_PLUGINS_EVENT (value): "termix:open-plugins"
OpenTabRecord (type): OpenTabRecord OpenTabRecord (type): OpenTabRecord
PANEL (value): { readonly body: "p-2.5"; readonly gap: "gap-2"; readonly band: "px-3 py-2"; readonly cardHead: "px-3 py-2.5"; readonly header: "h-12.5"; } PANEL (value): { readonly body: "p-3"; readonly section: "flex flex-col gap-3 py-3"; readonly gap: "gap-2"; readonly band: "px-3 py-2"; readonly cardHead: "px-3 py-2.5"; readonly header: "h-12.5"; }
PRESETS (value): Record<"simple" | "balanced" | "advanced", UiAreaPreferences> PRESETS (value): Record<"simple" | "balanced" | "advanced", UiAreaPreferences>
PROMPT_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" PROMPT_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
@@ -309,6 +310,7 @@ SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }:
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element
TILE_COLUMNS (value): 12 TILE_COLUMNS (value): 12
TOOLBAR_FIT (value): "[&_[data-slot=button][data-size=sm]]:h-8 [&_[data-slot=button][data-size=xs]]:h-8 [&_[data-slot=button][data-size=icon-sm]]:size-8 [&_[data-slot=button][data-size=icon-xs]]:size-8 [&_[data-slot=input]]:h-8 [&_[data-slot=select-trigger]]:h-8"
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
Tab (type): Tab Tab (type): Tab
TabShellCallbacks (type): TabShellCallbacks TabShellCallbacks (type): TabShellCallbacks
@@ -318,7 +320,7 @@ TabStrip (value): ({ tabs, activeTab, onTabChange, isActive, variant, trailing,
TabStripItem (type): TabStripItem TabStripItem (type): TabStripItem
TabType (type): TabType TabType (type): TabType
TagInput (value): ({ tags, input, placeholder, onTagsChange, onInputChange, }: { tags: string[]; input: string; placeholder: string; onTagsChange: (tags: string[]) => void; onInputChange: (value: string) => void; }) => Element TagInput (value): ({ tags, input, placeholder, onTagsChange, onInputChange, }: { tags: string[]; input: string; placeholder: string; onTagsChange: (tags: string[]) => void; onInputChange: (value: string) => void; }) => Element
TextAreaField (value): ({ label, value, onChange, placeholder, rows, hint, error, aside, mono, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; hint?: ReactNode; error?: string; aside?: ReactNode; mono?: boolean; disabled?: boolean; className?: string; }) => Element TextAreaField (value): ({ label, value, onChange, placeholder, rows, hint, error, aside, mono, disabled, fill, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; hint?: ReactNode; error?: string; aside?: ReactNode; mono?: boolean; disabled?: boolean; fill?: boolean; className?: string; }) => Element
TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, disabled, mono, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; aside?: ReactNode; disabled?: boolean; mono?: boolean; className?: string; }) => Element TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, disabled, mono, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; aside?: ReactNode; disabled?: boolean; mono?: boolean; className?: string; }) => Element
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>> Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
TextareaProps (type): TextareaProps TextareaProps (type): TextareaProps
@@ -335,6 +337,8 @@ TileSlot (type): TileSlot
TileStack (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element TileStack (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
TileWall (value): ({ size, minWidth, gridClassName, className, children, }: { size?: GridSize; minWidth?: number; gridClassName?: string; className?: string; children: ReactNode; }) => Element TileWall (value): ({ size, minWidth, gridClassName, className, children, }: { size?: GridSize; minWidth?: number; gridClassName?: string; className?: string; children: ReactNode; }) => Element
TlsStatus (type): TlsStatus TlsStatus (type): TlsStatus
ToolbarBand (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
ToolbarSelect (value): <T extends string>({ value, onChange, options, label, disabled, className, }: { value: T; onChange: (value: NoInfer<T>) => void; options: { value: NoInfer<T>; label: string; }[]; label: string; disabled?: boolean; className?: string; }) => Element
ToolsTab (type): ToolsTab ToolsTab (type): ToolsTab
Tooltip (value): ({ ...props }: TooltipProps) => Element Tooltip (value): ({ ...props }: TooltipProps) => Element
TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipContentProps & RefAttributes<HTMLDivElement>) => Element TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipContentProps & RefAttributes<HTMLDivElement>) => Element
+4
View File
@@ -20,6 +20,10 @@ const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
const ALLOWED = [ const ALLOWED = [
"SeamlessTileContext", "SeamlessTileContext",
"ToolbarBand",
"ToolbarSelect",
"HostDotGroup",
"TOOLBAR_FIT",
"TileEditBar", "TileEditBar",
"TileStack", "TileStack",
"TileColumn", "TileColumn",
+5 -1
View File
@@ -253,6 +253,7 @@ export function TextAreaField({
aside, aside,
mono, mono,
disabled, disabled,
fill,
className, className,
}: { }: {
label: string; label: string;
@@ -265,6 +266,8 @@ export function TextAreaField({
aside?: ReactNode; aside?: ReactNode;
mono?: boolean; mono?: boolean;
disabled?: boolean; disabled?: boolean;
/** Takes the rest of the height, for config and log editors. */
fill?: boolean;
className?: string; className?: string;
}) { }) {
return ( return (
@@ -273,7 +276,7 @@ export function TextAreaField({
hint={hint} hint={hint}
error={error} error={error}
aside={aside} aside={aside}
className={className} className={cn(fill && "min-h-0 flex-1", className)}
> >
<textarea <textarea
rows={rows} rows={rows}
@@ -284,6 +287,7 @@ export function TextAreaField({
className={cn( className={cn(
"w-full resize-none border border-input bg-background px-3 py-2 text-xs text-foreground transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30", "w-full resize-none border border-input bg-background px-3 py-2 text-xs text-foreground transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30",
mono && "font-mono text-[10px]", mono && "font-mono text-[10px]",
fill && "min-h-40 flex-1",
)} )}
/> />
</Field> </Field>
+2 -4
View File
@@ -220,7 +220,7 @@ export function ListBadge({
return ( return (
<span <span
className={cn( className={cn(
"flex shrink-0 items-center gap-0.5 border px-1 py-px text-[9px] uppercase leading-none tracking-wider [&_svg]:size-2.5", "inline-flex w-fit shrink-0 items-center gap-0.5 border px-1 py-px text-[9px] uppercase leading-none tracking-wider [&_svg]:size-2.5",
tone === "brand" && tone === "brand" &&
"border-accent-brand/30 bg-accent-brand/10 text-accent-brand", "border-accent-brand/30 bg-accent-brand/10 text-accent-brand",
tone === "warning" && "border-warning/30 bg-warning/10 text-warning", tone === "warning" && "border-warning/30 bg-warning/10 text-warning",
@@ -428,7 +428,6 @@ export function FormFooter({
{onDelete && ( {onDelete && (
<Button <Button
variant="ghost" variant="ghost"
size="sm"
onClick={onDelete} onClick={onDelete}
disabled={saving} disabled={saving}
className="text-destructive hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/10" className="text-destructive hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/10"
@@ -444,7 +443,7 @@ export function FormFooter({
))} ))}
</div> </div>
{onCancel && ( {onCancel && (
<Button variant="ghost" size="sm" onClick={onCancel} disabled={saving}> <Button variant="ghost" onClick={onCancel} disabled={saving}>
{cancelLabel ?? t("common.cancel")} {cancelLabel ?? t("common.cancel")}
</Button> </Button>
)} )}
@@ -454,7 +453,6 @@ export function FormFooter({
type={saveType} type={saveType}
form={form} form={form}
variant="outline" variant="outline"
size="sm"
onClick={onSave} onClick={onSave}
disabled={disabled || saving} disabled={disabled || saving}
className="border-accent-brand/40 px-6 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10" className="border-accent-brand/40 px-6 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
+2
View File
@@ -19,6 +19,7 @@ export function LoadingState({
return ( return (
<div <div
role="status" role="status"
data-slot="loading-state"
className={cn( className={cn(
"flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground", "flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground",
className, className,
@@ -32,6 +33,7 @@ export function LoadingState({
return ( return (
<div <div
role="status" role="status"
data-slot="loading-state"
className={cn( className={cn(
"flex flex-1 flex-col items-center justify-center gap-2 px-4 py-12 text-xs text-muted-foreground", "flex flex-1 flex-col items-center justify-center gap-2 px-4 py-12 text-xs text-muted-foreground",
className, className,
+1 -1
View File
@@ -102,7 +102,7 @@ export function ManagerSearch({
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? t("managerCard.filter")} placeholder={placeholder ?? t("managerCard.filter")}
className="h-7 w-full border border-border bg-background pl-7 pr-2 text-xs outline-none focus:ring-1 focus:ring-ring" className="h-8 w-full border border-border bg-background pl-7 pr-2 text-xs outline-none focus:ring-1 focus:ring-ring"
/> />
</div> </div>
{count != null && ( {count != null && (
+89 -9
View File
@@ -16,6 +16,7 @@ import { cn } from "@/lib/utils";
import { Separator } from "@/components/separator"; import { Separator } from "@/components/separator";
import { DocsLink } from "@/components/docs-link"; import { DocsLink } from "@/components/docs-link";
import { useInSeamlessTile } from "@/components/tile-grid/context"; import { useInSeamlessTile } from "@/components/tile-grid/context";
import { FooterClosed } from "@/components/tile-grid/edge";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -26,13 +27,81 @@ import {
/** One spacing rhythm for every panel and tab. */ /** One spacing rhythm for every panel and tab. */
export const PANEL = { export const PANEL = {
body: "p-2.5", body: "p-3",
section: "flex flex-col gap-3 py-3",
gap: "gap-2", gap: "gap-2",
band: "px-3 py-2", band: "px-3 py-2",
cardHead: "px-3 py-2.5", cardHead: "px-3 py-2.5",
header: "h-12.5", header: "h-12.5",
} as const; } as const;
/** Makes every control in a toolbar row the same 32px height. */
export const TOOLBAR_FIT =
"[&_[data-slot=button][data-size=sm]]:h-8 [&_[data-slot=button][data-size=xs]]:h-8 [&_[data-slot=button][data-size=icon-sm]]:size-8 [&_[data-slot=button][data-size=icon-xs]]:size-8 [&_[data-slot=input]]:h-8 [&_[data-slot=select-trigger]]:h-8";
/** A small unlabelled select for toolbar rows, as tall as the other controls. */
export function ToolbarSelect<T extends string>({
value,
onChange,
options,
label,
disabled,
className,
}: {
value: T;
onChange: (value: NoInfer<T>) => void;
options: { value: NoInfer<T>; label: string }[];
/** Read by screen readers, since the select has no visible label. */
label: string;
disabled?: boolean;
className?: string;
}) {
return (
<Select
value={value}
onValueChange={(next) => onChange(next as T)}
disabled={disabled}
>
<SelectTrigger
aria-label={label}
className={cn("h-8 w-auto text-xs", className)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/** A toolbar row for controls outside the PanelShell toolbar slot. */
export function ToolbarBand({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<div
data-slot="toolbar-band"
className={cn(
"flex shrink-0 flex-wrap items-center gap-2 border-b border-border",
PANEL.band,
TOOLBAR_FIT,
className,
)}
>
{children}
</div>
);
}
export type PanelViewMode = "grid" | "list"; export type PanelViewMode = "grid" | "list";
export type PanelDensity = "comfortable" | "compact"; export type PanelDensity = "comfortable" | "compact";
@@ -112,18 +181,20 @@ export function PanelShell({
className, className,
)} )}
> >
{children} <FooterClosed closed={!!footer}>{children}</FooterClosed>
</div> </div>
); );
const bands = ( const bands = (
<> <>
{tabs && ( {tabs && (
<div className="shrink-0 border-b border-border px-1">{tabs}</div> <div className={`shrink-0 border-b border-border px-1 ${TOOLBAR_FIT}`}>
{tabs}
</div>
)} )}
{toolbar && ( {toolbar && (
<div <div
className={`flex shrink-0 items-center gap-2 border-b border-border ${PANEL.band}`} className={`flex shrink-0 items-center gap-2 border-b border-border ${PANEL.band} ${TOOLBAR_FIT}`}
> >
{toolbar} {toolbar}
</div> </div>
@@ -132,7 +203,9 @@ export function PanelShell({
); );
const foot = footer && ( const foot = footer && (
<div className="flex shrink-0 items-center gap-2 border-t border-border px-3 py-2"> <div
className={`flex shrink-0 items-center gap-2 border-t border-border px-3 py-2 ${TOOLBAR_FIT}`}
>
{footer} {footer}
</div> </div>
); );
@@ -375,7 +448,11 @@ export function Segmented<T extends string>({
return ( return (
<div <div
role="radiogroup" role="radiogroup"
className={cn("flex items-center border border-border", className)} data-slot="segmented"
className={cn(
"flex w-fit shrink-0 items-center border border-border",
className,
)}
> >
{options.map((option, i) => { {options.map((option, i) => {
const active = option.value === value; const active = option.value === value;
@@ -388,7 +465,7 @@ export function Segmented<T extends string>({
title={option.title ?? option.label} title={option.title ?? option.label}
onClick={() => onChange(option.value)} onClick={() => onChange(option.value)}
className={cn( className={cn(
"flex h-[30px] items-center justify-center gap-1.5 px-2.5 text-[11px] font-medium transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring", "flex h-[calc(2rem-2px)] items-center justify-center gap-1.5 px-2.5 text-[11px] font-medium transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
i > 0 && "border-l border-border", i > 0 && "border-l border-border",
active active
? "bg-accent-brand/10 text-accent-brand" ? "bg-accent-brand/10 text-accent-brand"
@@ -428,7 +505,10 @@ export function ViewToggle({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const toggle = ( const toggle = (
<div className="flex items-center border border-border"> <div
data-slot="view-toggle"
className="flex items-center border border-border"
>
<ToggleButton <ToggleButton
active={view === "grid"} active={view === "grid"}
onClick={() => onView("grid")} onClick={() => onView("grid")}
@@ -513,7 +593,7 @@ function ToggleButton({
aria-label={title} aria-label={title}
aria-pressed={active} aria-pressed={active}
className={cn( className={cn(
"flex size-[30px] items-center justify-center transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring", "flex size-[calc(2rem-2px)] items-center justify-center transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
bordered && "border-l border-border", bordered && "border-l border-border",
active active
? "bg-accent-brand/10 text-accent-brand" ? "bg-accent-brand/10 text-accent-brand"
+1 -1
View File
@@ -50,7 +50,7 @@ const SNAP = 8;
const SCROLL_EDGE = 48; const SCROLL_EDGE = 48;
const styles = ` const styles = `
.tg-grid { position: relative; } .tg-grid { position: relative; overflow-x: clip; }
.tg-tile { position: absolute; left: 0; top: 0; min-width: 0; display: flex; flex-direction: column; } .tg-tile { position: absolute; left: 0; top: 0; min-width: 0; display: flex; flex-direction: column; }
.tg-grid[data-animate="true"] .tg-tile:not([data-active]) { transition: transform 160ms ease, width 160ms ease, height 160ms ease; } .tg-grid[data-animate="true"] .tg-tile:not([data-active]) { transition: transform 160ms ease, width 160ms ease, height 160ms ease; }
.tg-tile[data-active] { z-index: 40; } .tg-tile[data-active] { z-index: 40; }
+1 -1
View File
@@ -21,7 +21,7 @@ export function TileStack({
{Children.toArray(children).map((section, index) => ( {Children.toArray(children).map((section, index) => (
<div <div
key={keyOf(section, index)} key={keyOf(section, index)}
className="min-w-0 shrink-0 border-b border-border" className="min-w-0 shrink-0 border-b border-border [&>[data-slot=toolbar-band]]:border-b-0"
> >
{section} {section}
</div> </div>
+22 -2
View File
@@ -28,6 +28,23 @@ export function EdgeOwned({ children }: { children: ReactNode }) {
); );
} }
/** True in a body with a pinned footer, whose top line already closes it. */
const FooterClosedContext = createContext(false);
export function FooterClosed({
closed,
children,
}: {
closed: boolean;
children: ReactNode;
}) {
return (
<FooterClosedContext.Provider value={closed}>
{children}
</FooterClosedContext.Provider>
);
}
/** /**
* Whether the element's end reaches the bottom of the visible scroll area, * Whether the element's end reaches the bottom of the visible scroll area,
* where a closing line would sit on the screen edge and only add noise. * where a closing line would sit on the screen edge and only add noise.
@@ -65,7 +82,7 @@ function useReachesBottom(ref: React.RefObject<HTMLElement | null>) {
* Wraps tiles whose last row draws a line under itself. The line stays when * Wraps tiles whose last row draws a line under itself. The line stays when
* there is room below and is clipped when the tiles reach the bottom of the * there is room below and is clipped when the tiles reach the bottom of the
* screen, the same rule the tile grid uses. Inside a stack section the * screen, the same rule the tile grid uses. Inside a stack section the
* section owns the line, so it is always clipped. * section owns the line, so it is always clipped, and so does a pinned footer.
*/ */
export function BottomEdge({ export function BottomEdge({
className, className,
@@ -77,11 +94,14 @@ export function BottomEdge({
children: ReactNode; children: ReactNode;
}) { }) {
const owned = useContext(EdgeOwnedContext); const owned = useContext(EdgeOwnedContext);
const closed = useContext(FooterClosedContext);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const reaches = useReachesBottom(ref); const reaches = useReachesBottom(ref);
return ( return (
<div ref={ref} className={cn("min-w-0 overflow-clip", className)}> <div ref={ref} className={cn("min-w-0 overflow-clip", className)}>
<div className={cn((owned || reaches) && "-mb-px", innerClassName)}> <div
className={cn((owned || closed || reaches) && "-mb-px", innerClassName)}
>
{children} {children}
</div> </div>
</div> </div>
+1 -1
View File
@@ -261,7 +261,7 @@ export function RepairView({
</div> </div>
<div className="flex flex-col border border-border"> <div className="flex flex-col border border-border">
<div className="flex items-center justify-between gap-2 border-b border-border bg-muted/30 px-3 py-2"> <div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<span className="truncate text-xs text-foreground"> <span className="truncate text-xs text-foreground">
{name} {name}
{status.pluginId && ( {status.pluginId && (
+6 -6
View File
@@ -1,6 +1,7 @@
import { CircleSlash } from "lucide-react"; import { CircleSlash } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { LoadingState } from "@/components/loading-state";
import { usePermissions } from "@/hooks/use-permissions"; import { usePermissions } from "@/hooks/use-permissions";
import { pluginName } from "@/lib/plugin-i18n"; import { pluginName } from "@/lib/plugin-i18n";
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins"; import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
@@ -32,12 +33,11 @@ export function PluginViewPlaceholder({
if (status === "loading") { if (status === "loading") {
return ( return (
<div <LoadingState
className="flex h-full w-full items-center justify-center" compact={compact}
aria-label={t("plugins.runtime.loading", { name })} label={t("plugins.runtime.loading", { name })}
> className={compact ? undefined : "h-full w-full"}
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" /> />
</div>
); );
} }
+7 -1
View File
@@ -26,6 +26,7 @@ import {
import { import {
registerHostAction, registerHostAction,
registerHostBadge, registerHostBadge,
hostDotReporter,
registerHostEditorToolbarItem, registerHostEditorToolbarItem,
registerHostStatusIndicator, registerHostStatusIndicator,
registerHostContextMenuItem, registerHostContextMenuItem,
@@ -398,7 +399,12 @@ export function createPluginApp(
id: badge.id, id: badge.id,
pluginId, pluginId,
when: hostCallback(badge.when) as never, when: hostCallback(badge.when) as never,
component: scoped(badge.component) as never, component: badge.component
? (scoped(badge.component) as never)
: undefined,
dot: badge.useDot
? (scoped(hostDotReporter(badge.useDot)) as never)
: undefined,
order: badge.order, order: badge.order,
}), }),
); );
+4
View File
@@ -323,6 +323,9 @@ export {
PanelShell, PanelShell,
RefreshButton, RefreshButton,
Segmented, Segmented,
TOOLBAR_FIT,
ToolbarBand,
ToolbarSelect,
ViewToggle, ViewToggle,
type GridSize, type GridSize,
type PanelDensity, type PanelDensity,
@@ -834,6 +837,7 @@ export {
useHostEditorToolbarItems, useHostEditorToolbarItems,
useHostStatusIndicators, useHostStatusIndicators,
} from "@/sidebar/host-contributions"; } from "@/sidebar/host-contributions";
export { HostDotGroup } from "@/sidebar/HostDotGroup";
export { useEditorDefaultKeys } from "@/sidebar/host-defaults/catalog"; export { useEditorDefaultKeys } from "@/sidebar/host-defaults/catalog";
export { export {
enabledHostProtocols, enabledHostProtocols,
+1
View File
@@ -65,6 +65,7 @@ export function TabStage({ className }: { className?: string }) {
elements) by TabPortals. Hidden while a split is active. */} elements) by TabPortals. Hidden while a split is active. */}
<div <div
ref={store.setNormalView} ref={store.setNormalView}
data-slot="tab-stage"
className="absolute inset-0" className="absolute inset-0"
style={{ display: activeSplit ? "none" : undefined }} style={{ display: activeSplit ? "none" : undefined }}
/> />
+90
View File
@@ -0,0 +1,90 @@
import { useCallback, useMemo, useState } from "react";
import { StatusDot } from "@/components/status";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/tooltip";
import type { Host } from "@/types/ui-types";
import type { HostBadgeDef, HostDot } from "./host-contributions";
function DotSlot({
badge,
host,
onDot,
}: {
badge: HostBadgeDef;
host: Host;
onDot: (id: string, dot: HostDot | null) => void;
}) {
const Reporter = badge.dot!;
const report = useCallback(
(dot: HostDot | null) => onDot(badge.id, dot),
[badge.id, onDot],
);
return <Reporter host={host} report={report} />;
}
/**
* The one status dot on a host row. Every badge that reports a dot feeds it,
* so the worst tone wins and the tooltip lists each reason.
*/
export function HostDotGroup({
host,
badges,
className,
}: {
host: Host;
badges: HostBadgeDef[];
className?: string;
}) {
const [dots, setDots] = useState<Record<string, HostDot | null>>({});
const onDot = useCallback((id: string, dot: HostDot | null) => {
setDots((current) => {
const before = current[id];
if (before?.tone === dot?.tone && before?.label === dot?.label)
return current;
return { ...current, [id]: dot };
});
}, []);
const sources = badges.filter((badge) => badge.dot);
const active = useMemo(
() =>
sources
.map((badge) => dots[badge.id])
.filter((dot): dot is HostDot => !!dot),
[sources, dots],
);
const tone = active.some((dot) => dot.tone === "bad") ? "bad" : "warn";
const lines = [
...new Set(active.map((dot) => dot.label).filter(Boolean)),
].join("\n");
return (
<>
{sources.map((badge) => (
<DotSlot key={badge.id} badge={badge} host={host} onDot={onDot} />
))}
{active.length > 0 && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span
data-testid="host-dot"
className="flex shrink-0 items-center"
>
<StatusDot tone={tone} className={className} />
</span>
</TooltipTrigger>
{lines && (
<TooltipContent side="right" className="whitespace-pre-line">
{lines}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
</>
);
}
+33 -2
View File
@@ -1,4 +1,4 @@
import type { ComponentType } from "react"; import { useEffect, type ComponentType } from "react";
import type { Host } from "@/types/ui-types"; import type { Host } from "@/types/ui-types";
import { byOrderThenId, createRegistry } from "@/lib/registry"; import { byOrderThenId, createRegistry } from "@/lib/registry";
import type { TabShellCallbacks } from "@/shell/tab-registry"; import type { TabShellCallbacks } from "@/shell/tab-registry";
@@ -37,11 +37,26 @@ export interface HostActionDef {
}[]; }[];
} }
/** A problem a plugin wants shown as the host row's one status dot. */
export interface HostDot {
tone: "bad" | "warn";
label: string;
}
/** Calls a plugin's dot hook and passes the answer up to the dot group. */
export type HostDotReporter = ComponentType<{
host: Host;
report: (dot: HostDot | null) => void;
}>;
export interface HostBadgeDef { export interface HostBadgeDef {
id: string; id: string;
pluginId?: string; pluginId?: string;
when: (host: Host) => boolean; when: (host: Host) => boolean;
component: ComponentType<{ host: Host }>; /** Drawn as is. Leave out when the badge is only a dot. */
component?: ComponentType<{ host: Host }>;
/** Merged with every other dot into one dot on the row. */
dot?: HostDotReporter;
order?: number; order?: number;
} }
@@ -114,6 +129,22 @@ export function defaultConnectAction(
)[0]; )[0];
} }
/** Turns a plugin's dot hook into a component the dot group can mount. */
export function hostDotReporter(
useDot: (host: Host) => HostDot | null,
): HostDotReporter {
return function HostDotReport({ host, report }) {
const dot = useDot(host);
const tone = dot?.tone;
const label = dot?.label ?? "";
useEffect(() => {
report(tone ? { tone, label } : null);
}, [tone, label, report]);
useEffect(() => () => report(null), [report]);
return null;
};
}
export function hostBadgesFor(all: HostBadgeDef[], host: Host): HostBadgeDef[] { export function hostBadgesFor(all: HostBadgeDef[], host: Host): HostBadgeDef[] {
return all.filter((badge) => safeWhen(badge.when, host)); return all.filter((badge) => safeWhen(badge.when, host));
} }
@@ -10,9 +10,12 @@ import {
PanelSearch, PanelSearch,
PanelShell, PanelShell,
Segmented, Segmented,
ToolbarBand,
ViewToggle, ViewToggle,
gridSizeStyle, gridSizeStyle,
} from "@/components/panel-layout"; } from "@/components/panel-layout";
import { TileColumn } from "@/components/tile-grid/TileColumn";
import { TileStack } from "@/components/tile-grid/TileStack";
afterEach(cleanup); afterEach(cleanup);
@@ -148,3 +151,61 @@ describe("PanelShell", () => {
expect(screen.getByText("footer")).toBeTruthy(); expect(screen.getByText("footer")).toBeTruthy();
}); });
}); });
describe("toolbar sizing", () => {
it("fits small controls in the toolbar and in ToolbarBand", () => {
render(
<>
<PanelShell title="X" toolbar={<span>tools</span>}>
body
</PanelShell>
<ToolbarBand>band</ToolbarBand>
</>,
);
const band = screen.getByText("band");
expect(band.className).toContain(
"[&_[data-slot=button][data-size=sm]]:h-8",
);
expect(screen.getByText("tools").parentElement!.className).toContain(
"[&_[data-slot=input]]:h-8",
);
});
it("keeps Segmented at its natural width", () => {
render(
<Segmented<"a" | "b">
value="a"
onChange={() => {}}
options={[
{ value: "a", label: "A" },
{ value: "b", label: "B" },
]}
/>,
);
expect(screen.getByRole("radiogroup").className).toContain("w-fit");
});
it("clips the last tile line when a footer is pinned", () => {
const { container } = render(
<PanelShell title="Form" footer={<span>save</span>}>
<TileColumn>
<div>field</div>
</TileColumn>
</PanelShell>,
);
expect(container.querySelector(".-mb-px")).toBeTruthy();
});
});
describe("ToolbarBand in a tile stack", () => {
it("leaves the line under it to the stack", () => {
render(
<TileStack>
<ToolbarBand>inside</ToolbarBand>
</TileStack>,
);
expect(screen.getByText("inside").parentElement!.className).toContain(
"[&>[data-slot=toolbar-band]]:border-b-0",
);
});
});
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { HostDotGroup } from "@/sidebar/HostDotGroup";
import {
hostDotReporter,
type HostBadgeDef,
type HostDot,
} from "@/sidebar/host-contributions";
import type { Host } from "@/types/ui-types";
afterEach(cleanup);
const host = { id: 1, name: "web" } as unknown as Host;
function badge(id: string, dot: HostDot | null): HostBadgeDef {
return { id, when: () => true, dot: hostDotReporter(() => dot) };
}
describe("HostDotGroup", () => {
it("draws one dot with the worst tone for many reports", () => {
render(
<HostDotGroup
host={host}
badges={[
badge("uptime", { tone: "bad", label: "1 monitor down" }),
badge("ops", { tone: "warn", label: "Disk: 91%" }),
]}
/>,
);
const dots = screen.getAllByTestId("host-dot");
expect(dots).toHaveLength(1);
expect(dots[0].firstElementChild!.className).toContain("bg-destructive");
});
it("draws nothing when no badge reports", () => {
render(<HostDotGroup host={host} badges={[badge("uptime", null)]} />);
expect(screen.queryByTestId("host-dot")).toBeNull();
});
});