feat: shared tile look for cards and pages, plus new page pieces in SDK 1.1.0

This commit is contained in:
LukeGus committed 2026-11-04 18:57:18 -06:00
1 parent 1867be3f58
commit 19bdd589f3
41 files changed
+1422 -132

No files matched your search

+2 -1
View File
@@ -12,7 +12,8 @@
### Changed ### Changed
- Plugin cards placed on a tile grid take the dashboard look, with shared lines and no gaps - Plugin cards and pages use the dashboard look everywhere, with shared lines between sections instead of separate boxes with gaps
- The Plugins tab and plugin pages use the same look
- Line charts that cover a few minutes show seconds in their times - Line charts that cover a few minutes show seconds in their times
- Plugins a new release adds to the image start on only when they are recommended. The rest are installed and left off for an admin to turn on - Plugins a new release adds to the image start on only when they are recommended. The rest are installed and left off for an admin to turn on
+9 -1
View File
@@ -1,6 +1,6 @@
# Changelog # Changelog
## 1.0.26 ## 1.1.0
Plugin API 1.15.0. Plugin API 1.15.0.
@@ -9,6 +9,14 @@ Plugin API 1.15.0.
- `/ui` exports `TileGrid`, `TileFrame` and `TilePicker`, the 12 column tile grid the dashboard, the host page and Host Metrics share, plus `appendTile`, `flowTiles`, `removeTile`, `sanitizeTiles` and the `TileSlot` type - `/ui` exports `TileGrid`, `TileFrame` and `TilePicker`, the 12 column tile grid the dashboard, the host page and Host Metrics share, plus `appendTile`, `flowTiles`, `removeTile`, `sanitizeTiles` and the `TileSlot` type
- `registerDashboardCard` takes `entries`, so one card can be placed many times with its own `config`, and `defaultSize` for the grid - `registerDashboardCard` takes `entries`, so one card can be placed many times with its own `config`, and `defaultSize` for the grid
- Dashboard cards get `config`, `setConfig` and `editMode` - Dashboard cards get `config`, `setConfig` and `editMode`
- `/ui` exports `TileWall` (a fixed grid of tiles with shared lines), `TileStack` and `TileColumn` (sections stacked with a line between), `TileEditBar`, and `SeamlessTileContext` with `useInSeamlessTile`
- `/ui` exports `StatTile`, `InfoGrid`, `StatusDot`, `StatusBadge` and `statusTone`, `LoadingState`, `NoticePanel`, `Pager` with `usePaged` and `pageOf`, `DataTable` with `DataTableRow`, `SideNav` with `SideNavItem`, `PanelFrame` and `RefreshButton`
- `InlineView` takes `stacked`, which lays SectionCards out in one column with lines between them
- `GroupHeading` takes `plain`, which drops the rule after the title. Inside a tile layout that is the default
### Changed
- `Card`, `SectionCard`, `MetricCard` and `DataView` use the tile look: no fill, and inside a tile layout no box either. `DataView` grids sit edge to edge with shared lines
## 1.0.25 ## 1.0.25
+18 -5
View File
@@ -1,6 +1,6 @@
# Termix plugin guide # Termix plugin guide
SDK 1.0.26 | Plugin API 1.15.0 SDK 1.1.0 | 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.
@@ -766,15 +766,28 @@ exports with props is `ui.api.txt` in the Termix repo
- Small type: most text 11 to 13px. - Small type: most text 11 to 13px.
- No new modal dialogs. Ask with `useConfirm()`, open views in place with `InlineView`, and use - No new modal dialogs. Ask with `useConfirm()`, open views in place with `InlineView`, and use
`PanePrompt` for small prompts. `PanePrompt` for small prompts.
- Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`, - Every tab or panel sits in `PanelShell` (inside `PanelFrame` when it is a tab). Lists use `PanelList` and
forms end with `FormFooter`, empty lists show `EmptyState`. `ListRow`, add with `AddButton`, forms end with `FormFooter`, empty lists show `EmptyState`, loading shows
`LoadingState`, and "not installed" or "no access" shows `NoticePanel`.
- The tile look: page content sits edge to edge on the page background, with one line between sections and
no gaps, padding or card fill around them. The body of a `PanelShell` has no padding. Stack sections with
`TileStack` (or `TileColumn` when cards sit deeper), put numbers and small cards in a `TileWall` of
`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
takes `stacked`.
- Page views go in `PanelShell`'s `tabs` band as a `TabStrip`. `Segmented` is for filters and view modes.
A page with many sections uses `SideNav` on the left. Put the refresh button in the header with
`RefreshButton`, and the docs link with the `docs` prop.
- 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.
- 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
`{ id, x, y, w, h }` on 12 columns (`h` null for the natural height); save them where your plugin keeps `{ id, x, y, w, h }` on 12 columns (`h` null for the natural height); save them where your plugin keeps
preferences and run saved ones through `sanitizeTiles`. Add with `appendTile`. Needs Termix 26.11.1. preferences and run saved ones through `sanitizeTiles`. Add with `appendTile`. While editing, show
`TileEditBar` over the grid. Needs Termix 26.11.1.
- A dashboard card with `entries` can be placed many times: each entry has a title, a group and a `config` the - A dashboard card with `entries` can be placed many times: each entry has a title, a group and a `config` the
card gets back as `config` (save changes with `setConfig`, keep it small). Operations Center uses this for card gets back as `config` (save changes with `setConfig`, keep it small). Operations Center uses this for
status and action tiles. status and action tiles.
- Card grids use `DataView` with `ViewToggle`. Pass `columns` and `onColumns` to `ViewToggle` and the same - Card grids use `DataView` with `ViewToggle` (the cards share lines like a `TileWall`). Pass `columns` and `onColumns` to `ViewToggle` and the same
value as `size` to `DataView`, so users can pick Auto (as many as fit) or a fixed number of cards per value as `size` to `DataView`, so users can pick Auto (as many as fit) or a fixed number of cards per
row. `usePluginPanelView` keeps the choice as `columns`. Older Termix versions ignore both props. row. `usePluginPanelView` keeps the choice as `columns`. Older Termix versions ignore both props.
- Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends. - Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@termix-ssh/plugin-sdk", "name": "@termix-ssh/plugin-sdk",
"version": "1.0.26", "version": "1.1.0",
"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": {
+28 -3
View File
@@ -80,6 +80,8 @@ DEFAULT_FOLDER_COLOR (value): "#f59145"
DEFAULT_FOLDER_ICON (value): "folder" DEFAULT_FOLDER_ICON (value): "folder"
DashboardCardId (type): DashboardCardId DashboardCardId (type): DashboardCardId
DataColumn (type): DataColumn<T> DataColumn (type): DataColumn<T>
DataTable (value): ({ className, containerClassName, children, ...props }: ClassAttributes<HTMLTableElement> & TableHTMLAttributes<HTMLTableElement> & { containerClassName?: string; }) => Element
DataTableRow (value): ({ active, className, onClick, ...props }: ClassAttributes<HTMLTableRowElement> & HTMLAttributes<HTMLTableRowElement> & { active?: boolean; }) => Element
DataView (value): <T>({ items, view, density, getKey, renderCard, columns, size, minCardWidth, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; size?: GridSize; minCardWidth?: number; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element DataView (value): <T>({ items, view, density, getKey, renderCard, columns, size, minCardWidth, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; size?: GridSize; minCardWidth?: number; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element
DefaultOverrides (type): DefaultOverrides DefaultOverrides (type): DefaultOverrides
DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
@@ -133,7 +135,7 @@ GridColumns (type): GridColumns
GridLayout (type): GridLayout GridLayout (type): GridLayout
GridSize (type): GridSize GridSize (type): GridSize
GridSlot (type): GridSlot GridSlot (type): GridSlot
GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element GroupHeading (value): ({ title, count, action, plain, className, }: { title: string; count?: number; action?: ReactNode; plain?: boolean; className?: string; }) => Element
HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element
Host (type): Host Host (type): Host
HostActionDef (type): HostActionDef HostActionDef (type): HostActionDef
@@ -166,7 +168,9 @@ HostTabEmbed (value): ({ host, type, label, data, isVisible, shell, }: { host: H
HostTrayTrigger (type): HostTrayTrigger HostTrayTrigger (type): HostTrayTrigger
HttpsSettings (type): HttpsSettings HttpsSettings (type): HttpsSettings
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal InfoGrid (value): ({ items, className, }: { items: InfoItem[]; className?: string; }) => Element
InfoItem (type): InfoItem
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, stacked, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; stacked?: boolean; className?: string; children: ReactNode; }) => ReactPortal
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>> Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; } KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
@@ -180,11 +184,12 @@ Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelEle
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
LineChartSeries (type): LineChartSeries LineChartSeries (type): LineChartSeries
LinkedAccountInfo (type): LinkedAccountInfo LinkedAccountInfo (type): LinkedAccountInfo
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "warning" | "muted" | "brand" | "success"; className?: string; children: ReactNode; }) => Element
ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element
ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
ListRowFolder (value): ({ name, icon, count, open, onToggle, stripe, actions, dropActive, onDropItem, emptyText, className, children, }: { name: ReactNode; icon?: ReactNode; count?: number; open: boolean; onToggle: () => void; stripe?: number; actions?: ReactNode; dropActive?: boolean; onDropItem?: () => void; emptyText?: ReactNode; className?: string; children?: ReactNode; }) => Element ListRowFolder (value): ({ name, icon, count, open, onToggle, stripe, actions, dropActive, onDropItem, emptyText, className, children, }: { name: ReactNode; icon?: ReactNode; count?: number; open: boolean; onToggle: () => void; stripe?: number; actions?: ReactNode; dropActive?: boolean; onDropItem?: () => void; emptyText?: ReactNode; className?: string; children?: ReactNode; }) => Element
ListRowTone (type): ListRowTone ListRowTone (type): ListRowTone
LoadingState (value): ({ label, compact, className, }: { label?: string; compact?: boolean; className?: string; }) => Element
LogContext (type): LogContext LogContext (type): LogContext
LogEntry (type): LogEntry LogEntry (type): LogEntry
LookSettings (value): ({ isAdmin }: { isAdmin: boolean; }) => Element LookSettings (value): ({ isAdmin }: { isAdmin: boolean; }) => Element
@@ -201,6 +206,7 @@ MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scr
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
NativeView (value): ({ url, ignoreCert, hidden, fallback, className, style, }: NativeViewProps) => Element NativeView (value): ({ url, ignoreCert, hidden, fallback, className, style, }: NativeViewProps) => Element
NativeViewProps (type): NativeViewProps NativeViewProps (type): NativeViewProps
NoticePanel (value): ({ icon: Icon, title, children, action, tone, className, }: { icon?: LucideIcon; title: ReactNode; children?: ReactNode; action?: ReactNode; tone?: "default" | "destructive" | "warning"; 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 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
@@ -209,9 +215,11 @@ 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"
PROMPT_PRIMARY_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" PROMPT_PRIMARY_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
Pager (value): ({ page, pages, hasNext, onPage, className, }: { page: number; pages?: number; hasNext?: boolean; onPage: (next: number) => void; className?: string; }) => Element
PaletteItemDef (type): PaletteItemDef PaletteItemDef (type): PaletteItemDef
PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, onCancel, role, labelledBy, layer, backgroundColor, className, children, }: { open: boolean; title: ReactNode; titleId?: string; description?: ReactNode; icon?: ReactNode; tone?: "default" | "destructive"; actions?: ReactNode; onCancel?: () => void; role?: "dialog" | "alertdialog"; labelledBy?: string; layer?: "connection" | "pane"; backgroundColor?: string; className?: string; children?: ReactNode; }) => Element PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, onCancel, role, labelledBy, layer, backgroundColor, className, children, }: { open: boolean; title: ReactNode; titleId?: string; description?: ReactNode; icon?: ReactNode; tone?: "default" | "destructive"; actions?: ReactNode; onCancel?: () => void; role?: "dialog" | "alertdialog"; labelledBy?: string; layer?: "connection" | "pane"; backgroundColor?: string; className?: string; children?: ReactNode; }) => Element
PanelDensity (type): PanelDensity PanelDensity (type): PanelDensity
PanelFrame (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element
PanelSearch (value): ({ value, onChange, placeholder, fill, autoFocus, disabled, title, onEnter, inputRef, className, }: { value: string; onChange: (next: string) => void; placeholder?: string; fill?: boolean; autoFocus?: boolean; disabled?: boolean; title?: string; onEnter?: () => void; inputRef?: Ref<HTMLInputElement>; className?: string; }) => Element PanelSearch (value): ({ value, onChange, placeholder, fill, autoFocus, disabled, title, onEnter, inputRef, className, }: { value: string; onChange: (next: string) => void; placeholder?: string; fill?: boolean; autoFocus?: boolean; disabled?: boolean; title?: string; onEnter?: () => void; inputRef?: Ref<HTMLInputElement>; className?: string; }) => Element
PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, tabs, footer, scroll, docs, className, children, }: { chrome?: boolean; icon?: ReactNode; title?: ReactNode; status?: ReactNode; leading?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; tabs?: ReactNode; footer?: ReactNode; scroll?: boolean; docs?: string; className?: string; children: ReactNode; }) => Element PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, tabs, footer, scroll, docs, className, children, }: { chrome?: boolean; icon?: ReactNode; title?: ReactNode; status?: ReactNode; leading?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; tabs?: ReactNode; footer?: ReactNode; scroll?: boolean; docs?: string; className?: string; children: ReactNode; }) => Element
@@ -236,6 +244,7 @@ RadialGaugeProps (type): RadialGaugeProps
RailGroup (type): RailGroup RailGroup (type): RailGroup
RailItemDef (type): RailItemDef RailItemDef (type): RailItemDef
RecentActivityItem (type): RecentActivityItem RecentActivityItem (type): RecentActivityItem
RefreshButton (value): ({ onClick, loading, disabled, label, className, }: { onClick: () => void; loading?: boolean; disabled?: boolean; label?: string; className?: string; }) => Element
ReleaseInfo (type): ReleaseInfo ReleaseInfo (type): ReleaseInfo
Repeater (value): <T>({ label, items, onChange, makeItem, empty, addLabel, hint, renderItem, }: { label: string; items: T[]; onChange: (items: T[]) => void; makeItem: () => T; empty: string; addLabel: string; hint?: ReactNode; renderItem: (item: T, update: (next: T) => void, index: number) => ReactNode; }) => Element Repeater (value): <T>({ label, items, onChange, makeItem, empty, addLabel, hint, renderItem, }: { label: string; items: T[]; onChange: (items: T[]) => void; makeItem: () => T; empty: string; addLabel: string; hint?: ReactNode; renderItem: (item: T, update: (next: T) => void, index: number) => ReactNode; }) => Element
ResolvedHostDefault (type): ResolvedHostDefault ResolvedHostDefault (type): ResolvedHostDefault
@@ -248,6 +257,7 @@ SSHHostWithStatus (type): SSHHostWithStatus
SSH_AUTH_PROTOCOL (value): "ssh" SSH_AUTH_PROTOCOL (value): "ssh"
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element
SeamlessTileContext (value): Context<boolean>
SecretField (value): ({ label, value, onChange, placeholder, hint, error, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; }) => Element SecretField (value): ({ label, value, onChange, placeholder, hint, error, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; }) => Element
SectionCard (value): ({ title, icon, action, children, className, rowId, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; className?: string; rowId?: string; }) => Element SectionCard (value): ({ title, icon, action, children, className, rowId, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; className?: string; rowId?: string; }) => Element
Segmented (value): <T extends string>({ value, onChange, options, className, }: { value: T; onChange: (next: T) => void; options: readonly { value: NoInfer<T>; label: string; icon?: ReactNode; count?: number; title?: string; }[]; className?: string; }) => Element Segmented (value): <T extends string>({ value, onChange, options, className, }: { value: T; onChange: (next: T) => void; options: readonly { value: NoInfer<T>; label: string; icon?: ReactNode; count?: number; title?: string; }[]; className?: string; }) => Element
@@ -277,6 +287,9 @@ SheetHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<
SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
ShellConfirmCapture (value): () => null ShellConfirmCapture (value): () => null
SideNav (value): ({ entries, active, onSelect, footer, label, className, }: { entries: SideNavEntry[]; label?: string; active: string; onSelect: (id: string) => void; footer?: ReactNode; className?: string; }) => Element
SideNavEntry (type): SideNavEntry
SideNavItem (value): ({ entry, active, onClick, }: { entry: SideNavEntry; active: boolean; onClick: () => void; }) => Element
Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
SortKey (type): SortKey SortKey (type): SortKey
@@ -286,6 +299,10 @@ SplitLayoutMenu (value): ({ activeIsSplit, canAddPane, onSplit, onPreset, }: { a
SplitOpenTarget (type): SplitOpenTarget SplitOpenTarget (type): SplitOpenTarget
SplitSummary (type): SplitSummary SplitSummary (type): SplitSummary
StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element
StatTile (value): ({ label, icon, value, unit, percent, hint, tone, actions, onClick, children, className, }: { label: ReactNode; icon?: ReactNode; value: ReactNode; unit?: ReactNode; percent?: number; hint?: ReactNode; tone?: "default" | "ok" | "warn" | "bad"; actions?: ReactNode; onClick?: () => void; children?: ReactNode; className?: string; }) => Element
StatusBadge (value): ({ tone, state, className, children, }: { tone?: StatusTone; state?: string; className?: string; children?: ReactNode; }) => Element
StatusDot (value): ({ tone, state, pulse, className, title, }: { tone?: StatusTone; state?: string; pulse?: boolean; className?: string; title?: string; }) => Element
StatusTone (type): StatusTone
StatusValue (type): StatusValue StatusValue (type): StatusValue
SurfaceKind (type): SurfaceKind SurfaceKind (type): SurfaceKind
SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element
@@ -306,6 +323,8 @@ TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, d
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>> Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
TextareaProps (type): TextareaProps TextareaProps (type): TextareaProps
ThemeId (type): string ThemeId (type): string
TileColumn (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
TileEditBar (value): ({ children, onReset, onAdd, onDone, className, }: { children?: ReactNode; onReset?: () => void; onAdd?: () => void; onDone: () => void; className?: string; }) => Element
TileFrame (value): ({ title, icon, actions, bare, onTitleClick, className, bodyClassName, children, }: { title?: ReactNode; icon?: ReactNode; actions?: ReactNode; bare?: boolean; onTitleClick?: () => void; className?: string; bodyClassName?: string; children: ReactNode; }) => Element TileFrame (value): ({ title, icon, actions, bare, onTitleClick, className, bodyClassName, children, }: { title?: ReactNode; icon?: ReactNode; actions?: ReactNode; bare?: boolean; onTitleClick?: () => void; className?: string; bodyClassName?: string; children: ReactNode; }) => Element
TileGrid (value): ({ slots, editMode, renderTile, tileLabel, onChange, onSettings, gap, stackBelow, minColumns, minHeight, empty, className, }: TileGridProps) => Element TileGrid (value): ({ slots, editMode, renderTile, tileLabel, onChange, onSettings, gap, stackBelow, minColumns, minHeight, empty, className, }: TileGridProps) => Element
TileGridProps (type): TileGridProps TileGridProps (type): TileGridProps
@@ -313,6 +332,8 @@ TileKind (type): TileKind
TilePicker (value): ({ entries, onAdd, onClose, className, }: { entries: TilePickerEntry[]; onAdd: (entry: TilePickerEntry) => void; onClose?: () => void; className?: string; }) => Element TilePicker (value): ({ entries, onAdd, onClose, className, }: { entries: TilePickerEntry[]; onAdd: (entry: TilePickerEntry) => void; onClose?: () => void; className?: string; }) => Element
TilePickerEntry (type): TilePickerEntry TilePickerEntry (type): TilePickerEntry
TileSlot (type): TileSlot TileSlot (type): TileSlot
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
TlsStatus (type): TlsStatus TlsStatus (type): TlsStatus
ToolsTab (type): ToolsTab ToolsTab (type): ToolsTab
Tooltip (value): ({ ...props }: TooltipProps) => Element Tooltip (value): ({ ...props }: TooltipProps) => Element
@@ -503,6 +524,7 @@ normalizeLanguageCode (value): (language?: string) => string
notifySyncChanged (value): () => void notifySyncChanged (value): () => void
openInSplit (value): (target: SplitOpenTarget, open: () => void) => void openInSplit (value): (target: SplitOpenTarget, open: () => void) => void
operationsApi (value): AxiosInstance operationsApi (value): AxiosInstance
pageOf (value): <T>(items: T[], page: number, size: number) => { rows: T[]; page: number; pages: number; }
paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[] paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[]
parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[] parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[]
parseServerTime (value): (ts: string) => Date parseServerTime (value): (ts: string) => Date
@@ -573,6 +595,7 @@ shareHost (value): (hostId: number, shareData: { targets: ShareTarget[]; permiss
shell (value): TabShellCallbacks shell (value): TabShellCallbacks
splitDefaultKey (value): (fullKey: string) => [string, string] splitDefaultKey (value): (fullKey: string) => [string, string]
sshHostToHost (value): (h: SSHHostWithStatus) => Host sshHostToHost (value): (h: SSHHostWithStatus) => Host
statusTone (value): (state: string) => StatusTone
tabIcon (value): (type: TabType) => Element tabIcon (value): (type: TabType) => Element
tabJumpHotkeyKeys (value): () => string[] tabJumpHotkeyKeys (value): () => string[]
takePendingManageRequest (value): () => ManageRequest takePendingManageRequest (value): () => ManageRequest
@@ -618,6 +641,7 @@ useHostProtocols (value): () => HostProtocolDef[]
useHostSections (value): () => HostSectionDef[] useHostSections (value): () => HostSectionDef[]
useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue
useHostStatusIndicators (value): () => HostBadgeDef[] useHostStatusIndicators (value): () => HostBadgeDef[]
useInSeamlessTile (value): () => boolean
useInstalledPlugins (value): () => PluginSummary[] useInstalledPlugins (value): () => PluginSummary[]
useIsDefaultsEditor (value): () => boolean useIsDefaultsEditor (value): () => boolean
useIsMobile (value): () => boolean useIsMobile (value): () => boolean
@@ -625,6 +649,7 @@ useKeybindingActions (value): () => KeybindingActionDef[]
useKeybindingDefaults (value): () => KeybindingDefaultDef[] useKeybindingDefaults (value): () => KeybindingDefaultDef[]
useOptionalConnectionLog (value): () => ConnectionLogContextType useOptionalConnectionLog (value): () => ConnectionLogContextType
usePageVisibleInterval (value): (callback: () => void, intervalMs: number, enabled?: boolean, options?: Options) => void usePageVisibleInterval (value): (callback: () => void, intervalMs: number, enabled?: boolean, options?: Options) => void
usePaged (value): <T>(items: T[], size: number, reset?: unknown) => { setPage: Dispatch<SetStateAction<number>>; rows: T[]; page: number; pages: number; }
usePaletteEntries (value): () => PaletteEntryDef[] usePaletteEntries (value): () => PaletteEntryDef[]
usePaletteGroups (value): () => PaletteGroupDef[] usePaletteGroups (value): () => PaletteGroupDef[]
usePanels (value): () => PanelDef[] usePanels (value): () => PanelDef[]
+25
View File
@@ -19,6 +19,31 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts");
const TSCONFIG = path.join(ROOT, "tsconfig.app.json"); const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
const ALLOWED = [ const ALLOWED = [
"SeamlessTileContext",
"TileEditBar",
"TileStack",
"TileColumn",
"TileWall",
"useInSeamlessTile",
"NoticePanel",
"LoadingState",
"StatusBadge",
"StatusDot",
"statusTone",
"StatusTone",
"StatTile",
"InfoGrid",
"InfoItem",
"Pager",
"pageOf",
"usePaged",
"DataTable",
"DataTableRow",
"SideNav",
"SideNavItem",
"SideNavEntry",
"PanelFrame",
"RefreshButton",
"TileFrame", "TileFrame",
"TileGrid", "TileGrid",
"TilePicker", "TilePicker",
+6 -1
View File
@@ -1,18 +1,23 @@
import * as React from "react"; import * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useInSeamlessTile } from "@/components/tile-grid/context";
function Card({ function Card({
className, className,
size = "default", size = "default",
...props ...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { }: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
// Inside an edge to edge tile layout the layout draws the lines.
const tiled = useInSeamlessTile();
return ( return (
<div <div
data-slot="card" data-slot="card"
data-tile-card=""
data-size={size} data-size={size}
className={cn( className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-none bg-card py-4 text-xs/relaxed text-card-foreground border border-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none", "group/card flex flex-col gap-4 overflow-hidden rounded-none py-4 text-xs/relaxed text-card-foreground has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none",
!tiled && "border border-border",
className, className,
)} )}
{...props} {...props}
+57
View File
@@ -0,0 +1,57 @@
import type React from "react";
import { cn } from "@/lib/utils";
/**
* A plain table in the tile look: sticky header on the page background, a
* line under each row, no outer box. For wide data with real columns; use
* DataView when the same list also has a card view.
*/
export function DataTable({
className,
containerClassName,
children,
...props
}: React.ComponentProps<"table"> & {
/** Classes for the scroll box around the table, like "flex-1". */
containerClassName?: string;
}) {
return (
<div
className={cn(
"min-h-0 min-w-0 overflow-auto thin-scrollbar",
containerClassName,
)}
>
<table
className={cn(
"w-full border-collapse text-xs [&_td]:px-2 [&_td]:py-1.5 [&_th]:px-2 [&_th]:py-2 [&_th]:text-left [&_th]:text-[10px] [&_th]:font-semibold [&_th]:uppercase [&_th]:tracking-widest [&_th]:text-muted-foreground [&_thead]:sticky [&_thead]:top-0 [&_thead]:z-10 [&_thead]:bg-background [&_thead_tr]:border-b [&_tr]:border-b [&_tr]:border-border",
className,
)}
{...props}
>
{children}
</table>
</div>
);
}
/** A body row that highlights on hover, and when active. */
export function DataTableRow({
active = false,
className,
onClick,
...props
}: React.ComponentProps<"tr"> & { active?: boolean }) {
return (
<tr
onClick={onClick}
className={cn(
"transition-colors",
onClick && "cursor-pointer",
active ? "bg-accent-brand/10" : "hover:bg-muted/40",
className,
)}
{...props}
/>
);
}
+17 -15
View File
@@ -1,8 +1,8 @@
import type React from "react"; import type React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { TileWall } from "@/components/tile-grid/TileWall";
import { BottomEdge } from "@/components/tile-grid/edge";
import { import {
PANEL,
gridSizeStyle,
type GridSize, type GridSize,
type PanelDensity, type PanelDensity,
type PanelViewMode, type PanelViewMode,
@@ -91,7 +91,10 @@ function columnVisibility(hideBelow?: "md" | "lg"): string {
return ""; return "";
} }
/** One collection as a card grid or a dense row table. */ /**
* One collection as a card grid or a dense row table, edge to edge with
* shared lines like the rest of the tile look.
*/
export function DataView<T>({ export function DataView<T>({
items, items,
view, view,
@@ -132,21 +135,18 @@ export function DataView<T>({
if (view === "grid") { if (view === "grid") {
return ( return (
<div <TileWall
className={cn( size={size}
"grid", minWidth={minCardWidth ?? 320}
PANEL.gap, gridClassName={size ? undefined : gridClass(columns)}
!size && gridClass(columns), className={className}
className,
)}
style={size ? gridSizeStyle(size, minCardWidth) : undefined}
> >
{items.map((item) => ( {items.map((item) => (
<div key={getKey(item)} className="flex flex-col *:flex-1"> <div key={getKey(item)} className="flex flex-col *:flex-1">
{renderCard(item)} {renderCard(item)}
</div> </div>
))} ))}
</div> </TileWall>
); );
} }
@@ -154,8 +154,9 @@ export function DataView<T>({
const rowHeight = density === "compact" ? "min-h-7" : "min-h-9"; const rowHeight = density === "compact" ? "min-h-7" : "min-h-9";
return ( return (
<BottomEdge>
<div <div
className={cn("@container border border-border bg-card", className)} className={cn("@container", className)}
style={ style={
{ {
"--data-view-cols": templates.base, "--data-view-cols": templates.base,
@@ -168,7 +169,7 @@ export function DataView<T>({
<div <div
className={cn( className={cn(
LIST_GRID_CLASS, LIST_GRID_CLASS,
"sticky top-0 z-10 items-center border-b border-border bg-card text-[10px] font-semibold uppercase tracking-widest text-muted-foreground", "sticky top-0 z-10 items-center border-b border-border bg-background text-[10px] font-semibold uppercase tracking-widest text-muted-foreground",
)} )}
> >
{listColumns.map((column) => ( {listColumns.map((column) => (
@@ -194,7 +195,7 @@ export function DataView<T>({
onClick={onRowClick ? () => onRowClick(item) : undefined} onClick={onRowClick ? () => onRowClick(item) : undefined}
className={cn( className={cn(
LIST_GRID_CLASS, LIST_GRID_CLASS,
"items-center border-b border-border/50 text-xs transition-colors last:border-0", "items-center border-b border-border/50 text-xs transition-colors last:border-border",
rowHeight, rowHeight,
onRowClick && "cursor-pointer", onRowClick && "cursor-pointer",
active ? "bg-accent-brand/10" : "hover:bg-muted/40", active ? "bg-accent-brand/10" : "hover:bg-muted/40",
@@ -217,5 +218,6 @@ export function DataView<T>({
})} })}
</div> </div>
</div> </div>
</BottomEdge>
); );
} }
+41
View File
@@ -0,0 +1,41 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface InfoItem {
label: ReactNode;
value: ReactNode;
/** Monospace value, for ids, paths and addresses. */
mono?: boolean;
}
/** Label and value pairs in two aligned columns. Empty values are skipped. */
export function InfoGrid({
items,
className,
}: {
items: InfoItem[];
className?: string;
}) {
const shown = items.filter(
(item) =>
item.value !== null && item.value !== undefined && item.value !== "",
);
if (shown.length === 0) return null;
return (
<dl
className={cn(
"grid grid-cols-[max-content_minmax(0,1fr)] gap-x-4 gap-y-1 text-xs",
className,
)}
>
{shown.map((item, index) => (
<div key={index} className="contents">
<dt className="text-muted-foreground">{item.label}</dt>
<dd className={cn("min-w-0 break-words", item.mono && "font-mono")}>
{item.value}
</dd>
</div>
))}
</dl>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
/** A spinner with a short line, for a view waiting on its first load. */
export function LoadingState({
label,
compact = false,
className,
}: {
label?: string;
/** One inline line instead of the centered block. */
compact?: boolean;
className?: string;
}) {
const { t } = useTranslation();
const text = label ?? t("common.loading");
if (compact) {
return (
<div
role="status"
className={cn(
"flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground",
className,
)}
>
<Loader2 className="size-3 animate-spin" />
{text}
</div>
);
}
return (
<div
role="status"
className={cn(
"flex flex-1 flex-col items-center justify-center gap-2 px-4 py-12 text-xs text-muted-foreground",
className,
)}
>
<Loader2 className="size-5 animate-spin opacity-60" />
{text}
</div>
);
}
+2 -1
View File
@@ -35,9 +35,10 @@ export function MetricCard({
const tiled = useInSeamlessTile(); const tiled = useInSeamlessTile();
return ( return (
<div <div
data-tile-card=""
className={cn( className={cn(
"flex min-h-0 flex-col overflow-hidden", "flex min-h-0 flex-col overflow-hidden",
!tiled && "border border-border bg-card", !tiled && "border border-border",
)} )}
> >
<div <div
+52
View File
@@ -0,0 +1,52 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* A block that explains why a view has nothing to show yet and what to do,
* like "Samba is not installed" with an Install button. Unlike EmptyState the
* action always shows.
*/
export function NoticePanel({
icon: Icon,
title,
children,
action,
tone = "default",
className,
}: {
icon?: LucideIcon;
title: ReactNode;
children?: ReactNode;
action?: ReactNode;
tone?: "default" | "warning" | "destructive";
className?: string;
}) {
return (
<div
className={cn(
"flex flex-col items-start gap-2 px-4 py-4",
tone === "warning" && "bg-warning/5",
tone === "destructive" && "bg-destructive/5",
className,
)}
>
{Icon && (
<Icon
className={cn(
"size-5 text-muted-foreground",
tone === "warning" && "text-warning",
tone === "destructive" && "text-destructive",
)}
/>
)}
<p className="text-sm font-medium">{title}</p>
{children && (
<div className="text-xs leading-relaxed text-muted-foreground">
{children}
</div>
)}
{action && <div className="mt-1 flex flex-wrap gap-2">{action}</div>}
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
/* eslint-disable react-refresh/only-export-components */
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { cn } from "@/lib/utils";
/** One page of a list, with the page clamped to what exists. */
export function pageOf<T>(
items: T[],
page: number,
size: number,
): { rows: T[]; page: number; pages: number } {
const pages = Math.max(1, Math.ceil(items.length / size));
const at = Math.min(Math.max(0, page), pages - 1);
return { rows: items.slice(at * size, at * size + size), page: at, pages };
}
/** Pages a list in memory. Goes back to the first page when `reset` changes. */
export function usePaged<T>(items: T[], size: number, reset?: unknown) {
const [page, setPage] = useState(0);
useEffect(() => setPage(0), [reset]);
return { ...pageOf(items, page, size), setPage };
}
/** Previous and Next under a list. Hidden when there is only one page. */
export function Pager({
page,
pages,
hasNext,
onPage,
className,
}: {
page: number;
/** Total pages when known; leave out for a list read a page at a time. */
pages?: number;
hasNext?: boolean;
onPage: (next: number) => void;
className?: string;
}) {
const { t } = useTranslation();
const next = hasNext ?? (pages !== undefined && page < pages - 1);
if (page === 0 && !next) return null;
return (
<div
className={cn(
"flex shrink-0 items-center justify-between gap-2 border-t border-border px-2 py-1 text-xs text-muted-foreground",
className,
)}
>
<Button
size="sm"
variant="ghost"
disabled={page === 0}
onClick={() => onPage(page - 1)}
>
{t("panel.pager.previous")}
</Button>
<span>
{pages !== undefined
? t("panel.pager.pageOf", { page: page + 1, pages })
: t("panel.pager.page", { page: page + 1 })}
</span>
<Button
size="sm"
variant="ghost"
disabled={!next}
onClick={() => onPage(page + 1)}
>
{t("panel.pager.next")}
</Button>
</div>
);
}
+64
View File
@@ -6,6 +6,7 @@ import {
Columns3, Columns3,
Grid3X3, Grid3X3,
List, List,
RefreshCw,
Rows3, Rows3,
Search, Search,
X, X,
@@ -14,6 +15,7 @@ import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"; 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 { import {
Select, Select,
SelectContent, SelectContent,
@@ -215,6 +217,59 @@ export function BackButton({
} }
/** Search box sized to match toolbar buttons. Esc clears it. */ /** Search box sized to match toolbar buttons. Esc clears it. */
/** The refresh button panel headers show, spinning while it loads. */
export function RefreshButton({
onClick,
loading = false,
disabled,
label,
className,
}: {
onClick: () => void;
loading?: boolean;
disabled?: boolean;
label?: string;
className?: string;
}) {
const { t } = useTranslation();
const text = label ?? t("common.refresh");
return (
<button
type="button"
onClick={onClick}
disabled={disabled ?? loading}
title={text}
aria-label={text}
className={cn(
"flex size-8 shrink-0 items-center justify-center text-accent-brand transition-colors hover:bg-muted/40 disabled:opacity-60",
className,
)}
>
<RefreshCw className={cn("size-4", loading && "animate-spin")} />
</button>
);
}
/**
* The outer box of a plugin tab: fills the tab and clips, so the PanelShell
* inside can scroll its own body.
*/
export function PanelFrame({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<div className={cn("h-full w-full overflow-hidden", className)}>
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden">
{children}
</div>
</div>
);
}
export function PanelSearch({ export function PanelSearch({
value, value,
onChange, onChange,
@@ -513,13 +568,18 @@ export function GroupHeading({
title, title,
count, count,
action, action,
plain,
className, className,
}: { }: {
title: string; title: string;
count?: number; count?: number;
action?: React.ReactNode; action?: React.ReactNode;
/** No rule after the title. The default inside a tile layout. */
plain?: boolean;
className?: string; className?: string;
}) { }) {
const tiled = useInSeamlessTile();
const noRule = plain ?? tiled;
return ( return (
<div className={cn("flex items-center gap-2", className)}> <div className={cn("flex items-center gap-2", className)}>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"> <span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
@@ -530,7 +590,11 @@ export function GroupHeading({
{count} {count}
</span> </span>
)} )}
{noRule ? (
<div className="flex-1" />
) : (
<div className="h-px flex-1 bg-border" /> <div className="h-px flex-1 bg-border" />
)}
{action} {action}
</div> </div>
); );
+15 -4
View File
@@ -1,5 +1,7 @@
import { DefaultsOnly, HostDefaultBadge } from "@/lib/host-defaults-context"; import { DefaultsOnly, HostDefaultBadge } from "@/lib/host-defaults-context";
import { useState } from "react"; import { useState } from "react";
import { useInSeamlessTile } from "@/components/tile-grid/context";
import { cn } from "@/lib/utils";
import type React from "react"; import type React from "react";
export function SectionCard({ export function SectionCard({
@@ -18,19 +20,28 @@ export function SectionCard({
/** Lets settings search jump to this card. */ /** Lets settings search jump to this card. */
rowId?: string; rowId?: string;
}) { }) {
// Inside an edge to edge tile layout the layout draws the lines.
const tiled = useInSeamlessTile();
return ( return (
<div <div
data-tile-card=""
data-setting-row={rowId} data-setting-row={rowId}
className={`flex flex-col border border-border bg-card overflow-hidden ${className ?? ""}`} className={cn(
"flex flex-col overflow-hidden",
!tiled && "border border-border",
className,
)}
> >
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0"> <div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
<span className="text-muted-foreground">{icon}</span> <span className="flex text-muted-foreground [&_svg]:size-3">
{icon}
</span>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground flex-1"> <span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground flex-1">
{title} {title}
</span> </span>
{action && <div className="ml-auto">{action}</div>} {action && <div className="ml-auto">{action}</div>}
</div> </div>
<div className="px-3 md:px-4 py-1">{children}</div> <div className="px-3 py-1">{children}</div>
</div> </div>
); );
} }
+127
View File
@@ -0,0 +1,127 @@
import type { ComponentType, ReactNode } from "react";
import { ExternalLink } from "lucide-react";
import { cn } from "@/lib/utils";
import { Select2 } from "@/components/select2";
export interface SideNavEntry {
id: string;
label: string;
icon?: ComponentType<{ className?: string }>;
/** A count or short badge after the label. */
badge?: ReactNode;
/** Opens it in a normal tab instead, for split view. */
onPopOut?: () => void;
popOutLabel?: string;
/** Draws a line above this entry. */
divider?: boolean;
}
/**
* The left list of sections a page switches between, like the host page and
* the Operations tab. On a phone it turns into a select over the content.
*/
export function SideNav({
entries,
active,
onSelect,
footer,
label,
className,
}: {
entries: SideNavEntry[];
/** Names the list for screen readers and the phone select. */
label?: string;
active: string;
onSelect: (id: string) => void;
footer?: ReactNode;
className?: string;
}) {
return (
<>
<nav
aria-label={label}
className={cn(
"hidden w-44 shrink-0 flex-col gap-0.5 overflow-auto border-r border-border p-2 thin-scrollbar md:flex",
className,
)}
>
{entries.map((entry) => (
<div key={entry.id} className="contents">
{entry.divider && <div className="my-1 h-px bg-border" />}
<SideNavItem
entry={entry}
active={entry.id === active}
onClick={() => onSelect(entry.id)}
/>
</div>
))}
{footer && <div className="mt-auto pt-2">{footer}</div>}
</nav>
<div className="shrink-0 border-b border-border p-2 md:hidden">
<Select2
value={active}
aria-label={label}
onChange={(event) => onSelect(event.target.value)}
className="h-8 w-full px-2.5 text-xs"
>
{entries.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.label}
</option>
))}
</Select2>
</div>
</>
);
}
export function SideNavItem({
entry,
active,
onClick,
}: {
entry: SideNavEntry;
active: boolean;
onClick: () => void;
}) {
const Icon = entry.icon;
return (
<div
className={cn(
"group flex items-center",
active
? "bg-muted font-medium text-foreground"
: "text-muted-foreground hover:bg-muted/60",
)}
>
<button
type="button"
onClick={onClick}
aria-current={active ? "page" : undefined}
className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left text-xs"
>
{Icon && <Icon className="size-3.5 shrink-0" />}
<span className="truncate">{entry.label}</span>
{entry.badge !== undefined && entry.badge !== null && (
<span className="ml-auto shrink-0 px-1 text-[10px] tabular-nums">
{entry.badge}
</span>
)}
</button>
{entry.onPopOut && (
<button
type="button"
title={entry.popOutLabel}
aria-label={entry.popOutLabel}
onClick={entry.onPopOut}
className={cn(
"mr-1 shrink-0 p-1 text-muted-foreground hover:text-foreground",
!active && "opacity-0 focus:opacity-100 group-hover:opacity-100",
)}
>
<ExternalLink className="size-3" />
</button>
)}
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Meter } from "@/components/meter";
import { TileFrame } from "@/components/tile-grid/TileFrame";
const VALUE_TONE = {
default: "",
ok: "text-green-500",
warn: "text-warning",
bad: "text-destructive",
} as const;
/**
* One number in the tile look: the small title row, a big value, then an
* optional usage bar and a hint line. Put several in a TileWall for a strip.
*/
export function StatTile({
label,
icon,
value,
unit,
percent,
hint,
tone = "default",
actions,
onClick,
children,
className,
}: {
label: ReactNode;
icon?: ReactNode;
value: ReactNode;
unit?: ReactNode;
/** Draws a usage bar under the value. */
percent?: number | null;
hint?: ReactNode;
tone?: keyof typeof VALUE_TONE;
actions?: ReactNode;
onClick?: () => void;
/** Extra lines under the hint. */
children?: ReactNode;
className?: string;
}) {
const body = (
<div className="flex flex-col gap-1.5 px-3 py-2.5">
<div className="flex items-baseline gap-1">
<span
className={cn(
"truncate text-2xl font-bold tabular-nums leading-none",
VALUE_TONE[tone],
)}
>
{value}
</span>
{unit && <span className="text-xs text-muted-foreground">{unit}</span>}
</div>
{percent !== undefined && <Meter percent={percent} />}
{hint && (
<span className="truncate text-[11px] text-muted-foreground">
{hint}
</span>
)}
{children}
</div>
);
return (
<TileFrame
title={label}
icon={icon}
actions={actions}
className={className}
bodyClassName="overflow-hidden"
>
{onClick ? (
<button
type="button"
onClick={onClick}
className="text-left transition-colors hover:bg-muted/40"
>
{body}
</button>
) : (
body
)}
</TileFrame>
);
}
+125
View File
@@ -0,0 +1,125 @@
/* eslint-disable react-refresh/only-export-components */
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { ListBadge } from "@/components/list-kit";
export type StatusTone = "ok" | "warn" | "bad" | "info" | "idle";
const DOT: Record<StatusTone, string> = {
ok: "bg-green-500",
warn: "bg-warning",
bad: "bg-destructive",
info: "bg-accent-brand",
idle: "bg-muted-foreground/50",
};
const BADGE = {
ok: "success",
warn: "warning",
bad: "destructive",
info: "brand",
idle: "muted",
} as const;
// Common state words, so most callers only pass the state.
const WORDS: Record<string, StatusTone> = {
running: "ok",
up: "ok",
online: "ok",
active: "ok",
healthy: "ok",
ready: "ok",
ok: "ok",
success: "ok",
succeeded: "ok",
done: "ok",
completed: "ok",
enabled: "ok",
connected: "ok",
bound: "ok",
compliant: "ok",
pending: "warn",
paused: "warn",
restarting: "warn",
starting: "warn",
stopping: "warn",
waiting: "warn",
degraded: "warn",
warn: "warn",
warning: "warn",
unhealthy: "warn",
drifted: "warn",
pending_approval: "warn",
needs_interaction: "warn",
interrupted: "warn",
update: "info",
created: "idle",
exited: "idle",
stopped: "idle",
inactive: "idle",
disabled: "idle",
offline: "bad",
down: "bad",
dead: "bad",
failed: "bad",
fail: "bad",
error: "bad",
crashloopbackoff: "bad",
};
/** The tone for a state word like "running" or "failed". Unknown words are idle. */
export function statusTone(state: string | null | undefined): StatusTone {
if (!state) return "idle";
const key = state.toLowerCase().replace(/[\s-]+/g, "_");
return WORDS[key] ?? WORDS[key.replace(/_/g, "")] ?? "idle";
}
/** A small round status light. */
export function StatusDot({
tone,
state,
pulse = false,
className,
title,
}: {
tone?: StatusTone;
/** A state word, used when no tone is given. */
state?: string | null;
pulse?: boolean;
className?: string;
title?: string;
}) {
const resolved = tone ?? statusTone(state);
return (
<span
title={title}
aria-hidden={title ? undefined : true}
className={cn(
"inline-block size-2 shrink-0 rounded-full",
DOT[resolved],
pulse && "animate-pulse",
className,
)}
/>
);
}
/** A state as a small colored badge. The text is the state unless children are given. */
export function StatusBadge({
tone,
state,
className,
children,
}: {
tone?: StatusTone;
state?: string | null;
className?: string;
children?: ReactNode;
}) {
const resolved = tone ?? statusTone(state);
return (
<ListBadge tone={BADGE[resolved]} className={className}>
{children ?? state}
</ListBadge>
);
}
@@ -15,6 +15,7 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BackButton, PanelShell } from "@/components/panel-layout"; import { BackButton, PanelShell } from "@/components/panel-layout";
import { TileColumn } from "@/components/tile-grid/TileColumn";
import { import {
PROMPT_BUTTON, PROMPT_BUTTON,
PROMPT_DESTRUCTIVE_BUTTON, PROMPT_DESTRUCTIVE_BUTTON,
@@ -397,6 +398,7 @@ export function InlineView({
onBeforeClose, onBeforeClose,
scroll = true, scroll = true,
bare = false, bare = false,
stacked = false,
className, className,
children, children,
}: { }: {
@@ -416,6 +418,11 @@ export function InlineView({
scroll?: boolean; scroll?: boolean;
/** Draw children edge to edge, for views with their own bands. */ /** Draw children edge to edge, for views with their own bands. */
bare?: boolean; bare?: boolean;
/**
* Cards stacked in one column with a line between them and no padding,
* like a settings page. For forms built from SectionCards.
*/
stacked?: boolean;
className?: string; className?: string;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
@@ -462,6 +469,10 @@ export function InlineView({
> >
{bare ? ( {bare ? (
children children
) : stacked ? (
<TileColumn className={cn("w-full", className)}>
{children}
</TileColumn>
) : ( ) : (
<div <div
className={cn( className={cn(
+1 -1
View File
@@ -96,7 +96,7 @@ export function TabStrip({
<div <div
className={cn( className={cn(
"flex min-w-0 items-center", "flex min-w-0 items-center",
variant === "secondary" && "border-t border-border bg-card", variant === "secondary" && "border-t border-border",
className, className,
)} )}
> >
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import { SeamlessTileContext } from "./context";
import { BottomEdge, EdgeOwned } from "./edge";
/**
* A column of cards that can sit at any depth, like a settings page built
* from several panels. Cards drop their box and the outermost ones draw a
* line under themselves. Use TileStack when the sections are direct children.
*/
export function TileColumn({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return (
<SeamlessTileContext.Provider value={true}>
<BottomEdge
className={className}
innerClassName="flex min-w-0 flex-col [&_[data-tile-card]]:border-x-0 [&_[data-tile-card]]:border-t-0 [&_[data-tile-card]]:border-b [&_[data-tile-card]]:border-border [&_[data-tile-card]_[data-tile-card]]:border-b-0"
>
<EdgeOwned>{children}</EdgeOwned>
</BottomEdge>
</SeamlessTileContext.Provider>
);
}
@@ -0,0 +1,56 @@
import type { ReactNode } from "react";
import { RotateCcw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { cn } from "@/lib/utils";
/**
* The bar over a tile grid while it is being edited: a hint or extra controls
* on the left, then Reset, Add tiles (while the picker is closed) and Done.
*/
export function TileEditBar({
children,
onReset,
onAdd,
onDone,
className,
}: {
children?: ReactNode;
onReset?: () => void;
/** Leave out while the tile picker is open. */
onAdd?: () => void;
onDone: () => void;
className?: string;
}) {
const { t } = useTranslation();
return (
<div
className={cn(
"flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-1.5 text-xs",
className,
)}
>
{children && (
<div className="flex min-w-0 flex-wrap items-center gap-3 text-[11px] text-muted-foreground">
{children}
</div>
)}
<div className="ml-auto flex items-center gap-1">
{onReset && (
<Button size="xs" variant="ghost" className="gap-1" onClick={onReset}>
<RotateCcw className="size-3" />
{t("tileGrid.reset")}
</Button>
)}
{onAdd && (
<Button size="xs" variant="ghost" onClick={onAdd}>
{t("tileGrid.addTiles")}
</Button>
)}
<Button size="xs" onClick={onDone}>
{t("tileGrid.done")}
</Button>
</div>
</div>
);
}
+1 -8
View File
@@ -28,6 +28,7 @@ import {
} from "./layout"; } from "./layout";
import { SeamlessTileContext } from "./context"; import { SeamlessTileContext } from "./context";
import { gridLines } from "./layout"; import { gridLines } from "./layout";
import { scrollParent } from "./edge";
type Edge = "left" | "right" | "bottom" | "corner"; type Edge = "left" | "right" | "bottom" | "corner";
interface Proposal { interface Proposal {
@@ -83,14 +84,6 @@ function propose(base: TileSlot[], id: string, proposal: Proposal) {
return base.map((slot) => (slot.id === id ? { ...slot, ...proposal } : slot)); return base.map((slot) => (slot.id === id ? { ...slot, ...proposal } : slot));
} }
function scrollParent(element: HTMLElement | null) {
for (let node = element?.parentElement; node; node = node.parentElement) {
const { overflowY } = getComputedStyle(node);
if (overflowY === "auto" || overflowY === "scroll") return node;
}
return null;
}
const NO_DRAG = const NO_DRAG =
"button, a, input, select, textarea, label, [role=separator], [contenteditable=true], .tg-handle"; "button, a, input, select, textarea, label, [role=separator], [contenteditable=true], .tg-handle";
+37
View File
@@ -0,0 +1,37 @@
import { Children, isValidElement, type ReactNode } from "react";
import { SeamlessTileContext } from "./context";
import { BottomEdge, EdgeOwned } from "./edge";
/**
* Sections stacked edge to edge with a line under each, the column form of
* the tile look. Cards inside drop their own border. The last line goes away
* when the stack reaches the bottom of the screen.
*/
export function TileStack({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return (
<SeamlessTileContext.Provider value={true}>
<BottomEdge className={className} innerClassName="flex min-w-0 flex-col">
<EdgeOwned>
{Children.toArray(children).map((section, index) => (
<div
key={keyOf(section, index)}
className="min-w-0 shrink-0 border-b border-border"
>
{section}
</div>
))}
</EdgeOwned>
</BottomEdge>
</SeamlessTileContext.Provider>
);
}
function keyOf(node: ReactNode, index: number): string | number {
return isValidElement(node) && node.key !== null ? node.key : index;
}
+53
View File
@@ -0,0 +1,53 @@
import { Children, isValidElement, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { gridSizeStyle, type GridSize } from "@/components/panel-layout";
import { SeamlessTileContext } from "./context";
import { BottomEdge } from "./edge";
/**
* A fixed grid of tiles edge to edge with one line between neighbours, for
* stat strips and card lists. `size` is tiles per row ("auto" fits as many as
* `minWidth` allows). Use TileGrid when people should move and resize tiles.
*/
export function TileWall({
size = "auto",
minWidth = 280,
gridClassName,
className,
children,
}: {
size?: GridSize;
minWidth?: number;
/** Column classes (like "grid-cols-1 md:grid-cols-3") in place of size. */
gridClassName?: string;
className?: string;
children: ReactNode;
}) {
const tiles = Children.toArray(children);
return (
<SeamlessTileContext.Provider value={true}>
{/* The grid sits a pixel wider so the right edge line is cut off. */}
<BottomEdge className={className}>
<div
className={cn("-mr-px grid", gridClassName)}
style={
gridClassName ? undefined : gridSizeStyle(size, minWidth, "0px")
}
>
{tiles.map((tile, index) => (
<div
key={keyOf(tile, index)}
className="flex min-w-0 flex-col border-b border-r border-border *:flex-1"
>
{tile}
</div>
))}
</div>
</BottomEdge>
</SeamlessTileContext.Provider>
);
}
function keyOf(node: ReactNode, index: number): string | number {
return isValidElement(node) && node.key !== null ? node.key : index;
}
+86
View File
@@ -0,0 +1,86 @@
/* eslint-disable react-refresh/only-export-components */
import {
createContext,
useContext,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
export function scrollParent(element: HTMLElement | null) {
for (let node = element?.parentElement; node; node = node.parentElement) {
const { overflowY } = getComputedStyle(node);
if (overflowY === "auto" || overflowY === "scroll") return node;
}
return null;
}
/** True inside a TileStack or TileColumn section, which draws its own line. */
const EdgeOwnedContext = createContext(false);
export function EdgeOwned({ children }: { children: ReactNode }) {
return (
<EdgeOwnedContext.Provider value={true}>
{children}
</EdgeOwnedContext.Provider>
);
}
/**
* 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.
*/
function useReachesBottom(ref: React.RefObject<HTMLElement | null>) {
const [reaches, setReaches] = useState(false);
const reachesRef = useRef(reaches);
reachesRef.current = reaches;
useLayoutEffect(() => {
const element = ref.current;
const scroller = scrollParent(element);
if (!element || !scroller) return;
// Measured without the line itself, so showing it never flips the answer.
const update = () => {
const end =
element.getBoundingClientRect().bottom -
scroller.getBoundingClientRect().top +
scroller.scrollTop -
(reachesRef.current ? 0 : 1);
setReaches(end >= scroller.clientHeight - 0.5);
};
update();
const observer = new ResizeObserver(update);
observer.observe(scroller);
observer.observe(element);
return () => observer.disconnect();
}, [ref]);
return reaches;
}
/**
* 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
* screen, the same rule the tile grid uses. Inside a stack section the
* section owns the line, so it is always clipped.
*/
export function BottomEdge({
className,
innerClassName,
children,
}: {
className?: string;
innerClassName?: string;
children: ReactNode;
}) {
const owned = useContext(EdgeOwnedContext);
const ref = useRef<HTMLDivElement>(null);
const reaches = useReachesBottom(ref);
return (
<div ref={ref} className={cn("min-w-0 overflow-clip", className)}>
<div className={cn((owned || reaches) && "-mb-px", innerClassName)}>
{children}
</div>
</div>
);
}
+5
View File
@@ -1,4 +1,9 @@
export { TileGrid, type TileGridProps } from "./TileGrid"; export { TileGrid, type TileGridProps } from "./TileGrid";
export { TileFrame } from "./TileFrame"; export { TileFrame } from "./TileFrame";
export { TilePicker, type TileKind, type TilePickerEntry } from "./TilePicker"; export { TilePicker, type TileKind, type TilePickerEntry } from "./TilePicker";
export { TileWall } from "./TileWall";
export { TileStack } from "./TileStack";
export { TileColumn } from "./TileColumn";
export { TileEditBar } from "./TileEditBar";
export { SeamlessTileContext, useInSeamlessTile } from "./context";
export * from "./layout"; export * from "./layout";
+7 -3
View File
@@ -10,9 +10,13 @@ export function WidgetTitle({ title, icon, fallback }: WidgetTitleProps) {
const label = title || fallback; const label = title || fallback;
if (!label) return null; if (!label) return null;
return ( return (
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border/50 shrink-0"> <div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
{icon && <span className="text-accent-brand shrink-0">{icon}</span>} {icon && (
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate"> <span className="flex shrink-0 text-muted-foreground [&_svg]:size-3">
{icon}
</span>
)}
<span className="truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{label} {label}
</span> </span>
</div> </div>
+9 -1
View File
@@ -1676,6 +1676,12 @@
"title": "Columns", "title": "Columns",
"auto": "Auto", "auto": "Auto",
"count": "{{count}} wide" "count": "{{count}} wide"
},
"pager": {
"previous": "Previous",
"next": "Next",
"page": "Page {{page}}",
"pageOf": "Page {{page}} of {{pages}}"
} }
}, },
"fields": { "fields": {
@@ -1715,6 +1721,8 @@
"action": "Actions", "action": "Actions",
"resource": "Lists", "resource": "Lists",
"session": "Sessions" "session": "Sessions"
} },
"reset": "Reset",
"done": "Done"
} }
} }
+21
View File
@@ -128,9 +128,15 @@ export {
type GridSlot, type GridSlot,
} from "@/components/card-grid/types"; } from "@/components/card-grid/types";
export { export {
SeamlessTileContext,
TileEditBar,
TileFrame, TileFrame,
TileGrid, TileGrid,
TilePicker, TilePicker,
TileColumn,
TileStack,
TileWall,
useInSeamlessTile,
appendTile, appendTile,
flowTiles, flowTiles,
removeTile, removeTile,
@@ -217,6 +223,19 @@ export { WidgetTitle } from "@/lib/widget-title";
export { runVisibleInterval } from "@/lib/visible-interval"; export { runVisibleInterval } from "@/lib/visible-interval";
export { useIsMobile } from "@/hooks/use-mobile"; export { useIsMobile } from "@/hooks/use-mobile";
export { EmptyState } from "@/components/empty-state"; export { EmptyState } from "@/components/empty-state";
export { NoticePanel } from "@/components/notice-panel";
export { LoadingState } from "@/components/loading-state";
export {
StatusBadge,
StatusDot,
statusTone,
type StatusTone,
} from "@/components/status";
export { StatTile } from "@/components/stat-tile";
export { InfoGrid, type InfoItem } from "@/components/info-grid";
export { Pager, pageOf, usePaged } from "@/components/pager";
export { DataTable, DataTableRow } from "@/components/data-table";
export { SideNav, SideNavItem, type SideNavEntry } from "@/components/side-nav";
export { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog"; export { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog";
export { HostKeyVerificationDialog } from "@/ssh/dialogs/HostKeyVerificationDialog"; export { HostKeyVerificationDialog } from "@/ssh/dialogs/HostKeyVerificationDialog";
@@ -299,8 +318,10 @@ export {
Facts, Facts,
GroupHeading, GroupHeading,
PANEL, PANEL,
PanelFrame,
PanelSearch, PanelSearch,
PanelShell, PanelShell,
RefreshButton,
Segmented, Segmented,
ViewToggle, ViewToggle,
type GridSize, type GridSize,
+32 -36
View File
@@ -17,8 +17,9 @@ import {
TriangleAlert, TriangleAlert,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Card } from "@/components/card"; import { Facts } from "@/components/panel-layout";
import { Facts, PANEL } from "@/components/panel-layout"; import { TileStack } from "@/components/tile-grid/TileStack";
import { TileWall } from "@/components/tile-grid/TileWall";
import { FakeSwitch, SectionCard } from "@/components/section-card"; import { FakeSwitch, SectionCard } from "@/components/section-card";
import { import {
Select, Select,
@@ -52,7 +53,7 @@ import { PluginReleaseNotes, PluginVideo } from "./PluginReleaseNotes";
import type { PluginsManager } from "./use-plugins-manager"; import type { PluginsManager } from "./use-plugins-manager";
const LINK_ROW = const LINK_ROW =
"flex items-center gap-2 border border-border bg-card px-3 py-2.5 text-left text-[11px] text-muted-foreground transition-colors hover:border-accent-brand/40 hover:text-foreground"; "flex w-full items-center gap-2 px-3 py-2.5 text-left text-[11px] text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground";
export function PluginDetail({ export function PluginDetail({
plugin, plugin,
@@ -93,10 +94,8 @@ export function PluginDetail({
const tryableBeta = betaToTry(plugin); const tryableBeta = betaToTry(plugin);
return ( return (
<div <TileStack>
className={`mx-auto flex w-full max-w-5xl flex-col ${PANEL.gap} ${PANEL.body}`} <div className="flex flex-row flex-wrap items-start gap-3 p-3">
>
<Card className="flex-row flex-wrap items-start gap-4 px-4 py-4">
<PluginIconBox <PluginIconBox
name={plugin.icon} name={plugin.icon}
size="lg" size="lg"
@@ -223,10 +222,10 @@ export function PluginDetail({
</Button> </Button>
)} )}
</div> </div>
</Card> </div>
{plugin.installed && failed && plugin.lastError && ( {plugin.installed && failed && plugin.lastError && (
<div className="flex items-start gap-2 border border-destructive/30 bg-destructive/5 px-3 py-2.5"> <div className="flex items-start gap-2 bg-destructive/5 px-3 py-2.5">
<TriangleAlert className="mt-px size-3.5 shrink-0 text-destructive" /> <TriangleAlert className="mt-px size-3.5 shrink-0 text-destructive" />
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs font-medium"> <span className="text-xs font-medium">
@@ -242,7 +241,7 @@ export function PluginDetail({
)} )}
{plugin.status === "server" && ( {plugin.status === "server" && (
<div className="flex items-start gap-2 border border-border bg-muted/30 px-3 py-2.5"> <div className="flex items-start gap-2 bg-muted/30 px-3 py-2.5">
<Server className="mt-px size-3.5 shrink-0 text-muted-foreground" /> <Server className="mt-px size-3.5 shrink-0 text-muted-foreground" />
<span className="text-[11px] leading-snug text-muted-foreground"> <span className="text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.runsOnServerHint")} {t("plugins.manager.runsOnServerHint")}
@@ -250,13 +249,13 @@ export function PluginDetail({
</div> </div>
)} )}
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3"> <div className="grid grid-cols-1 lg:grid-cols-3">
<div className="flex flex-col gap-2 lg:col-span-2"> <TileStack className="-mb-px border-border lg:col-span-2 lg:border-r">
<PluginVideo videoId={plugin.videoId} name={plugin.name} /> <PluginVideo videoId={plugin.videoId} name={plugin.name} />
{plugin.features.length > 0 && ( {plugin.features.length > 0 && (
<SectionCard title={t("plugins.manager.features")} icon={null}> <SectionCard title={t("plugins.manager.features")} icon={null}>
<ul className="grid grid-cols-1 gap-x-4 gap-y-1.5 px-4 py-3 sm:grid-cols-2"> <ul className="grid grid-cols-1 gap-x-4 gap-y-1.5 py-3 sm:grid-cols-2">
{plugin.features.map((feature) => ( {plugin.features.map((feature) => (
<li <li
key={feature} key={feature}
@@ -277,11 +276,11 @@ export function PluginDetail({
key={capability} key={capability}
capability={capability} capability={capability}
showId showId
className="px-4 py-2.5" className="py-2.5"
/> />
))} ))}
{plugin.capabilities.length === 0 && ( {plugin.capabilities.length === 0 && (
<div className="px-4 py-2.5 text-[11px] text-muted-foreground"> <div className="py-2.5 text-[11px] text-muted-foreground">
{t("plugins.manager.consent.none")} {t("plugins.manager.consent.none")}
</div> </div>
)} )}
@@ -292,10 +291,7 @@ export function PluginDetail({
<SectionCard title={t("plugins.manager.envVars")} icon={null}> <SectionCard title={t("plugins.manager.envVars")} icon={null}>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{plugin.env.map((v) => ( {plugin.env.map((v) => (
<div <div key={v.name} className="flex flex-col gap-0.5 py-2.5">
key={v.name}
className="flex flex-col gap-0.5 px-4 py-2.5"
>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<code className="font-mono text-xs font-semibold"> <code className="font-mono text-xs font-semibold">
{v.name} {v.name}
@@ -331,12 +327,12 @@ export function PluginDetail({
onDelete={() => manager.removeData(plugin)} onDelete={() => manager.removeData(plugin)}
/> />
)} )}
</div> </TileStack>
<div className="flex flex-col gap-2"> <TileStack className="-mb-px">
{contributions.length > 0 && ( {contributions.length > 0 && (
<SectionCard title={t("plugins.manager.addsToTermix")} icon={null}> <SectionCard title={t("plugins.manager.addsToTermix")} icon={null}>
<div className="flex flex-col gap-2 px-4 py-3"> <div className="flex flex-col gap-2 py-3">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{contributions.map((c) => ( {contributions.map((c) => (
<span <span
@@ -358,7 +354,7 @@ export function PluginDetail({
{plugin.inRegistry && compatible.length > 0 && ( {plugin.inRegistry && compatible.length > 0 && (
<SectionCard title={t("plugins.manager.version")} icon={null}> <SectionCard title={t("plugins.manager.version")} icon={null}>
<div className="flex flex-col gap-2 px-4 py-3"> <div className="flex flex-col gap-2 py-3">
<span className="text-[11px] leading-snug text-muted-foreground"> <span className="text-[11px] leading-snug text-muted-foreground">
{plugin.installed {plugin.installed
? t("plugins.manager.versionPickHintInstalled") ? t("plugins.manager.versionPickHintInstalled")
@@ -414,7 +410,7 @@ export function PluginDetail({
{plugin.installed && ( {plugin.installed && (
<SectionCard title={t("plugins.manager.updates")} icon={null}> <SectionCard title={t("plugins.manager.updates")} icon={null}>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
<div className="flex items-center justify-between gap-3 px-4 py-3"> <div className="flex items-center justify-between gap-3 py-3">
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs font-medium"> <span className="text-xs font-medium">
{t("plugins.manager.autoUpdate")} {t("plugins.manager.autoUpdate")}
@@ -431,7 +427,7 @@ export function PluginDetail({
} }
/> />
</div> </div>
<div className="flex items-center justify-between gap-3 px-4 py-3"> <div className="flex items-center justify-between gap-3 py-3">
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs font-medium"> <span className="flex items-center gap-1.5 text-xs font-medium">
<FlaskConical className="size-3 text-muted-foreground" /> <FlaskConical className="size-3 text-muted-foreground" />
@@ -455,7 +451,7 @@ export function PluginDetail({
} }
/> />
</div> </div>
<div className="flex items-center justify-between gap-3 px-4 py-3"> <div className="flex items-center justify-between gap-3 py-3">
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs font-medium"> <span className="flex items-center gap-1.5 text-xs font-medium">
<Pin className="size-3 text-muted-foreground" /> <Pin className="size-3 text-muted-foreground" />
@@ -565,9 +561,9 @@ export function PluginDetail({
</span> </span>
</a> </a>
)} )}
</TileStack>
</div> </div>
</div> </TileStack>
</div>
); );
} }
@@ -625,16 +621,16 @@ function DataCard({
} }
> >
{error ? ( {error ? (
<div className="px-4 py-3 text-[11px] text-muted-foreground"> <div className="py-3 text-[11px] text-muted-foreground">
{t("plugins.manager.data.loadFailed")} {t("plugins.manager.data.loadFailed")}
</div> </div>
) : !data ? ( ) : !data ? (
<div className="px-4 py-3 text-[11px] text-muted-foreground"> <div className="py-3 text-[11px] text-muted-foreground">
{t("plugins.manager.loading")} {t("plugins.manager.loading")}
</div> </div>
) : ( ) : (
<div className="-mx-3 -my-1 flex flex-col md:-mx-4"> <div className="-mx-3 -my-1 flex flex-col">
<div className="grid grid-cols-2 gap-px bg-border sm:grid-cols-4"> <TileWall gridClassName="grid-cols-2 sm:grid-cols-4">
<Stat <Stat
label={t("plugins.manager.data.tables")} label={t("plugins.manager.data.tables")}
value={`${data.tables.length}`} value={`${data.tables.length}`}
@@ -655,13 +651,13 @@ function DataCard({
label={t("plugins.manager.data.files")} label={t("plugins.manager.data.files")}
value={formatBytes(data.filesBytes)} value={formatBytes(data.filesBytes)}
/> />
</div> </TileWall>
{data.tables.length > 0 && ( {data.tables.length > 0 && (
<div className="divide-y divide-border border-t border-border"> <div className="divide-y divide-border border-t border-border">
{data.tables.map((table) => ( {data.tables.map((table) => (
<div <div
key={table.name} key={table.name}
className="flex items-center gap-2 px-4 py-1.5" className="flex items-center gap-2 px-3 py-1.5"
> >
<span className="min-w-0 flex-1 truncate font-mono text-[11px]"> <span className="min-w-0 flex-1 truncate font-mono text-[11px]">
{table.name} {table.name}
@@ -675,7 +671,7 @@ function DataCard({
))} ))}
</div> </div>
)} )}
<div className="flex flex-wrap gap-x-4 gap-y-1 border-t border-border px-4 py-2 text-[11px] text-muted-foreground"> <div className="flex flex-wrap gap-x-4 gap-y-1 border-t border-border py-2 text-[11px] text-muted-foreground">
<span> <span>
{t("plugins.manager.data.migrations", { {t("plugins.manager.data.migrations", {
count: data.migrations.length, count: data.migrations.length,
@@ -701,7 +697,7 @@ function Stat({
hint?: string; hint?: string;
}) { }) {
return ( return (
<div className="flex flex-col gap-0.5 bg-card px-4 py-2.5"> <div className="flex flex-col gap-0.5 px-3 py-2.5">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"> <span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{label} {label}
</span> </span>
+1 -1
View File
@@ -62,7 +62,7 @@ function Release({
.filter((group) => group.items.length > 0); .filter((group) => group.items.length > 0);
return ( return (
<div className="flex flex-col gap-2 px-4 py-3"> <div className="flex flex-col gap-2 py-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold">{row.version}</span> <span className="text-xs font-semibold">{row.version}</span>
{row.version === installedVersion && ( {row.version === installedVersion && (
+36 -44
View File
@@ -42,6 +42,8 @@ import {
type PanelViewMode, type PanelViewMode,
} from "@/components/panel-layout"; } from "@/components/panel-layout";
import { DataView, type DataColumn } from "@/components/data-view"; import { DataView, type DataColumn } from "@/components/data-view";
import { StatusDot } from "@/components/status";
import { TileStack } from "@/components/tile-grid/TileStack";
import { InlineView, SurfaceScope } from "@/components/surface/surface-scope"; import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@@ -321,7 +323,7 @@ function Toolbar({
children?: React.ReactNode; children?: React.ReactNode;
}) { }) {
return ( return (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2">
<div className="relative min-w-40 max-w-md flex-1"> <div className="relative min-w-40 max-w-md flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" /> <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input <Input
@@ -346,8 +348,13 @@ function Group({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section className="flex flex-col gap-2.5"> <section className="flex flex-col">
<GroupHeading title={title} count={count} /> <GroupHeading
plain
title={title}
count={count}
className="border-b border-border px-3 py-1.5"
/>
{children} {children}
</section> </section>
); );
@@ -653,7 +660,7 @@ function ActiveFilters({
}); });
} }
return ( return (
<div className="flex min-h-6 flex-wrap items-center gap-1.5"> <div className="flex min-h-6 flex-wrap items-center gap-1.5 border-b border-border px-3 py-1.5">
<span className="text-[11px] text-muted-foreground"> <span className="text-[11px] text-muted-foreground">
{t("plugins.manager.filters.results", { count: shown })} {t("plugins.manager.filters.results", { count: shown })}
</span> </span>
@@ -703,7 +710,7 @@ function Pager({
const per = Number(size); const per = Number(size);
if (total <= Number(PAGE_SIZES[0])) return null; if (total <= Number(PAGE_SIZES[0])) return null;
return ( return (
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-2.5"> <div className="flex flex-wrap items-center gap-2 px-3 py-2">
<span className="text-[11px] tabular-nums text-muted-foreground"> <span className="text-[11px] tabular-nums text-muted-foreground">
{t("plugins.manager.pager.range", { {t("plugins.manager.pager.range", {
from: (page - 1) * per + 1, from: (page - 1) * per + 1,
@@ -895,7 +902,7 @@ function InstalledSection({
rail={<FilterRail rows={searched} facets={facets} onFacets={setFacets} />} rail={<FilterRail rows={searched} facets={facets} onFacets={setFacets} />}
> >
{(filtersButton) => ( {(filtersButton) => (
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}> <div className="flex flex-col">
<Toolbar <Toolbar
query={query} query={query}
onQuery={onQuery} onQuery={onQuery}
@@ -1083,43 +1090,38 @@ function InstalledCard({
} }
/> />
<div className="flex items-center gap-1.5 px-3 pb-2.5">
{failed ? ( {failed ? (
<div className="flex items-center gap-2 border-t border-border bg-destructive/5 px-3 py-2">
<TriangleAlert className="size-3.5 shrink-0 text-destructive" />
<span <span
className="flex-1 truncate text-[11px] text-muted-foreground" className="flex min-w-0 items-center gap-1.5 text-[11px] text-destructive"
title={plugin.lastError ?? undefined} title={plugin.lastError ?? undefined}
> >
<TriangleAlert className="size-3 shrink-0" />
<span className="truncate">
{plugin.status === "blocked" {plugin.status === "blocked"
? t("plugins.manager.status.blocked") ? t("plugins.manager.status.blocked")
: t("plugins.manager.status.failed")} : t("plugins.manager.status.failed")}
</span> </span>
</div> </span>
) : !running ? ( ) : (
<div className="flex items-center gap-2 border-t border-border px-3 py-2"> <span className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="size-1.5 shrink-0 bg-muted-foreground/40" /> <StatusDot tone={running ? "ok" : "idle"} className="size-1.5" />
<span className="text-[11px] text-muted-foreground"> <span className="truncate">
{plugin.status === "server" {!running
? plugin.status === "server"
? t("plugins.manager.status.server") ? t("plugins.manager.status.server")
: plugin.status === "safeMode" : plugin.status === "safeMode"
? t("plugins.manager.status.safeMode") ? t("plugins.manager.status.safeMode")
: t("plugins.manager.status.disabled")} : t("plugins.manager.status.disabled")
</span> : plugin.pinnedVersion
</div>
) : (
<div className="flex items-center gap-2 border-t border-border px-3 py-2">
<span className="size-1.5 shrink-0 bg-green-500" />
<span className="text-[11px] text-muted-foreground">
{plugin.pinnedVersion
? t("plugins.manager.pinnedTo", { ? t("plugins.manager.pinnedTo", {
version: plugin.pinnedVersion, version: plugin.pinnedVersion,
}) })
: t("plugins.manager.status.running")} : t("plugins.manager.status.running")}
</span> </span>
</div> </span>
)} )}
<div className="flex-1" />
<div className="flex items-center gap-1.5 border-t border-border px-3 py-2">
{plugin.updateAvailable && !plugin.pinnedVersion && !failed && ( {plugin.updateAvailable && !plugin.pinnedVersion && !failed && (
<Button <Button
variant="outline" variant="outline"
@@ -1159,11 +1161,7 @@ function InstalledCard({
: t("plugins.manager.enable")} : t("plugins.manager.enable")}
</Button> </Button>
)} )}
<DocsLink <DocsLink href={plugin.docs} variant="icon" className="size-6" />
href={plugin.docs}
variant="icon"
className="ml-auto size-6"
/>
{!plugin.defaultLook && ( {!plugin.defaultLook && (
<Button <Button
variant="ghost" variant="ghost"
@@ -1171,10 +1169,7 @@ function InstalledCard({
disabled={busy || locked} disabled={busy || locked}
title={t("plugins.manager.uninstall")} title={t("plugins.manager.uninstall")}
aria-label={t("plugins.manager.uninstall")} aria-label={t("plugins.manager.uninstall")}
className={cn( className="text-muted-foreground hover:text-destructive"
"text-muted-foreground hover:text-destructive",
!plugin.docs && "ml-auto",
)}
onClick={() => void manager.uninstall(plugin)} onClick={() => void manager.uninstall(plugin)}
> >
<Trash2 className="size-3" /> <Trash2 className="size-3" />
@@ -1442,7 +1437,7 @@ function BrowseSection({
} }
> >
{(filtersButton) => ( {(filtersButton) => (
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}> <div className="flex flex-col">
<Toolbar <Toolbar
query={query} query={query}
onQuery={onQuery} onQuery={onQuery}
@@ -1551,7 +1546,7 @@ function BrowseCard({
</> </>
} }
/> />
<div className="flex items-center gap-2 border-t border-border px-3 py-2"> <div className="flex items-center gap-2 px-3 pb-2.5">
<span className="min-w-0 flex-1 truncate text-[10px] text-muted-foreground"> <span className="min-w-0 flex-1 truncate text-[10px] text-muted-foreground">
{plugin.unsupported && !plugin.installed {plugin.unsupported && !plugin.installed
? "" ? ""
@@ -1646,9 +1641,9 @@ function UpdatesSection({
} }
return ( return (
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}> <TileStack>
{plain.length > 0 && ( {plain.length > 0 && (
<Card className="flex-row items-center gap-3 px-3 py-2.5"> <div className="flex items-center gap-3 px-3 py-2.5">
<span className="flex-1 text-xs text-muted-foreground"> <span className="flex-1 text-xs text-muted-foreground">
{t("plugins.manager.updatesReady", { count: plain.length })} {t("plugins.manager.updatesReady", { count: plain.length })}
{gated.length > 0 && {gated.length > 0 &&
@@ -1664,10 +1659,8 @@ function UpdatesSection({
<Download className="size-3.5" /> <Download className="size-3.5" />
{t("plugins.manager.updateAll")} {t("plugins.manager.updateAll")}
</Button> </Button>
</Card> </div>
)} )}
<div className="flex flex-col gap-3">
{[...gated, ...plain, ...pinned].map((p) => ( {[...gated, ...plain, ...pinned].map((p) => (
<UpdateRow <UpdateRow
key={p.id} key={p.id}
@@ -1676,8 +1669,7 @@ function UpdatesSection({
onOpen={() => onOpen(p.id)} onOpen={() => onOpen(p.id)}
/> />
))} ))}
</div> </TileStack>
</div>
); );
} }
+1 -1
View File
@@ -66,7 +66,7 @@ export function HostPluginSections({
if (plugins.length === 0) return null; if (plugins.length === 0) return null;
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col">
{plugins.map((plugin) => ( {plugins.map((plugin) => (
<HostPluginSection <HostPluginSection
key={plugin.id} key={plugin.id}
+1 -3
View File
@@ -73,9 +73,7 @@ function withTabSuspense(node: React.ReactNode) {
function PanelTabFrame({ children }: { children: React.ReactNode }) { function PanelTabFrame({ children }: { children: React.ReactNode }) {
return ( return (
<div className="flex h-full w-full justify-center overflow-y-auto bg-background"> <div className="flex h-full w-full justify-center overflow-y-auto bg-background">
<div className="flex flex-col flex-1 min-h-0 w-full max-w-5xl"> <div className="flex flex-col flex-1 min-h-0 w-full">{children}</div>
{children}
</div>
</div> </div>
); );
} }
+2 -1
View File
@@ -47,8 +47,9 @@ describe("DataView", () => {
empty={null} empty={null}
/>, />,
); );
const grid = container.firstElementChild as HTMLElement; const grid = container.querySelector(".grid") as HTMLElement;
expect(grid.className).not.toContain("md:grid-cols-2"); expect(grid.className).not.toContain("md:grid-cols-2");
expect(grid.className).not.toContain("gap-");
expect(grid.style.gridTemplateColumns).toContain("/ 3"); expect(grid.style.gridTemplateColumns).toContain("/ 3");
}); });
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
act,
cleanup,
fireEvent,
render,
renderHook,
screen,
} from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { Pager, pageOf, usePaged } from "../../components/pager";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
initReactI18next: { type: "3rdParty", init: () => {} },
}));
afterEach(cleanup);
describe("pageOf", () => {
it("clamps the page to what exists", () => {
const items = [1, 2, 3, 4, 5];
expect(pageOf(items, 9, 2)).toEqual({ rows: [5], page: 2, pages: 3 });
expect(pageOf([], 0, 2)).toEqual({ rows: [], page: 0, pages: 1 });
});
});
describe("usePaged", () => {
it("goes back to the first page when reset changes", () => {
const { result, rerender } = renderHook(
({ reset }) => usePaged([1, 2, 3], 1, reset),
{ initialProps: { reset: "a" } },
);
act(() => result.current.setPage(2));
expect(result.current.rows).toEqual([3]);
rerender({ reset: "b" });
expect(result.current.page).toBe(0);
});
});
describe("Pager", () => {
it("hides with one page", () => {
const { container } = render(
<Pager page={0} pages={1} onPage={() => {}} />,
);
expect(container).toBeEmptyDOMElement();
});
it("moves between pages", () => {
const onPage = vi.fn();
render(<Pager page={0} pages={3} onPage={onPage} />);
expect(screen.getByText("panel.pager.previous")).toBeDisabled();
fireEvent.click(screen.getByText("panel.pager.next"));
expect(onPage).toHaveBeenCalledWith(1);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { StatusBadge, StatusDot, statusTone } from "../../components/status";
afterEach(cleanup);
describe("statusTone", () => {
it("maps common state words", () => {
expect(statusTone("Running")).toBe("ok");
expect(statusTone("CrashLoopBackOff")).toBe("bad");
expect(statusTone("pending approval")).toBe("warn");
expect(statusTone("exited")).toBe("idle");
});
it("treats unknown and empty states as idle", () => {
expect(statusTone("banana")).toBe("idle");
expect(statusTone(null)).toBe("idle");
});
});
describe("StatusBadge", () => {
it("shows the state with its tone", () => {
render(<StatusBadge state="failed" />);
expect(screen.getByText("failed")).toHaveClass("text-destructive");
});
it("lets a tone win over the state", () => {
render(
<StatusBadge state="failed" tone="ok">
Fine
</StatusBadge>,
);
expect(screen.getByText("Fine")).toHaveClass("text-green-500");
});
});
describe("StatusDot", () => {
it("colors by state", () => {
render(<StatusDot state="running" title="up" />);
expect(screen.getByTitle("up")).toHaveClass("bg-green-500");
});
});
@@ -279,7 +279,7 @@ describe("TileGrid", () => {
).container.querySelector(".tg-content > div")!.className; ).container.querySelector(".tg-content > div")!.className;
expect(card()).not.toContain("bg-card"); expect(card()).not.toContain("bg-card");
cleanup(); cleanup();
expect(card(12)).toContain("border border-border bg-card"); expect(card(12)).toContain("border border-border");
}); });
it("stacks cards with no edit controls on narrow screens", () => { it("stacks cards with no edit controls on narrow screens", () => {
@@ -0,0 +1,142 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { TileWall } from "../../../components/tile-grid/TileWall";
import { TileStack } from "../../../components/tile-grid/TileStack";
import { TileColumn } from "../../../components/tile-grid/TileColumn";
import { MetricCard } from "../../../components/metric-card";
import { SectionCard } from "../../../components/section-card";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
initReactI18next: { type: "3rdParty", init: () => {} },
}));
afterEach(cleanup);
const cardOf = (text: string) =>
screen.getByText(text).closest("[data-tile-card]") as HTMLElement;
describe("TileWall", () => {
it("wraps each tile in a cell with the shared lines", () => {
render(
<TileWall size="2">
<span>one</span>
{null}
<span>two</span>
</TileWall>,
);
const cell = screen.getByText("one").parentElement!;
expect(cell).toHaveClass("border-b", "border-r");
expect(cell.parentElement?.children).toHaveLength(2);
});
it("drops the box of cards inside it", () => {
render(
<TileWall>
<MetricCard title="CPU" icon={null}>
x
</MetricCard>
</TileWall>,
);
expect(cardOf("CPU")).not.toHaveClass("border");
});
it("uses column classes in place of size", () => {
render(
<TileWall gridClassName="grid-cols-3">
<span>a</span>
</TileWall>,
);
const grid = screen.getByText("a").parentElement!.parentElement!;
expect(grid).toHaveClass("grid-cols-3");
expect(grid.style.gridTemplateColumns).toBe("");
});
});
describe("bottom line", () => {
const edgeOf = (text: string) =>
screen.getByText(text).closest(".overflow-clip")!.firstElementChild;
it("stays when the tiles do not reach the bottom of the screen", () => {
render(
<TileWall>
<span>alone</span>
</TileWall>,
);
expect(edgeOf("alone")).not.toHaveClass("-mb-px");
});
it("is left to the section inside a stack", () => {
render(
<TileStack>
<TileWall>
<span>nested</span>
</TileWall>
</TileStack>,
);
expect(edgeOf("nested")).toHaveClass("-mb-px");
});
it("is cut off once the tiles reach the bottom of the scroll area", () => {
const rect = vi
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
.mockReturnValue({ top: 0, bottom: 500 } as DOMRect);
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
disconnect() {}
},
);
render(
<div style={{ overflowY: "auto" }}>
<TileWall>
<span>full</span>
</TileWall>
</div>,
);
expect(edgeOf("full")).toHaveClass("-mb-px");
rect.mockRestore();
vi.unstubAllGlobals();
});
});
describe("TileStack", () => {
it("draws a line under each section", () => {
render(
<TileStack>
<span>first</span>
<span>second</span>
</TileStack>,
);
expect(screen.getByText("first").parentElement).toHaveClass("border-b");
expect(screen.getByText("second").parentElement).toHaveClass("border-b");
});
});
describe("cards outside a tile layout", () => {
it("keep their border but no fill", () => {
render(
<SectionCard title="Shell" icon={null}>
body
</SectionCard>,
);
const card = cardOf("Shell");
expect(card).toHaveClass("border");
expect(card).not.toHaveClass("bg-card");
});
it("drop the border inside a TileColumn", () => {
render(
<TileColumn>
<div>
<SectionCard title="Nested" icon={null}>
body
</SectionCard>
</div>
</TileColumn>,
);
expect(cardOf("Nested")).not.toHaveClass("border");
});
});