fix: plugin page clipping and badges, match select and textarea sizes

This commit is contained in:
LukeGus committed 2026-11-04 20:03:15 -06:00
1 parent 2ee31ac9cc
commit a2308c9d71
12 files changed
+58 -28

No files matched your search

+9
View File
@@ -20,6 +20,8 @@
- Host status, import and export, and credential tools now start on for new installs - Host status, import and export, and credential tools now start on for new installs
- Sync now starts on in a new desktop app, so it can link to a server - Sync now starts on in a new desktop app, so it can link to a server
- The first run plugin picker leaves plugins you did not choose off instead of removing them - The first run plugin picker leaves plugins you did not choose off instead of removing them
- Dropdowns and text areas are the same size as the text fields next to them
- A page's own tabs sit above its search bar
### Fixed ### Fixed
@@ -30,6 +32,13 @@
- An installed plugin update older than the copy in a newer image is removed at startup instead of logging an error every boot - An installed plugin update older than the copy in a newer image is removed at startup instead of logging an error every boot
- Canceling a download to the desktop app right as it starts no longer leaves a partial file behind - Canceling a download to the desktop app right as it starts no longer leaves a partial file behind
- Changing two interface settings at once, like resetting the dashboard, now saves both instead of only the last one - Changing two interface settings at once, like resetting the dashboard, now saves both instead of only the last one
- Release notes on a plugin's page are no longer cut off on the left
- The Community badge in Browse is no longer missing its top border
- Official plugin pages no longer show a stray divider before the author
- The version picker on a plugin's page is no longer empty for a plugin installed from a local build
- The plugin video no longer has a second border around it
- The hint at the bottom of Browse and the stored data footer on plugin pages have the same padding as the rest of the page
- Sign in methods on the Security page have a line between them
## 26.11.0 ## 26.11.0
+3 -3
View File
@@ -118,6 +118,9 @@ export function PanelShell({
const bands = ( const bands = (
<> <>
{tabs && (
<div className="shrink-0 border-b border-border px-1">{tabs}</div>
)}
{toolbar && ( {toolbar && (
<div <div
className={`flex shrink-0 items-center gap-2 border-b border-border ${PANEL.band}`} className={`flex shrink-0 items-center gap-2 border-b border-border ${PANEL.band}`}
@@ -125,9 +128,6 @@ export function PanelShell({
{toolbar} {toolbar}
</div> </div>
)} )}
{tabs && (
<div className="shrink-0 border-b border-border px-1">{tabs}</div>
)}
</> </>
); );
+2 -2
View File
@@ -35,7 +35,7 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-none border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-none border bg-transparent px-2.5 py-1 text-xs whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-8 data-[size=sm]:h-7 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}
@@ -105,7 +105,7 @@ function SelectItem({
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-none py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-none py-1.5 pr-8 pl-2 text-xs outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className, className,
)} )}
{...props} {...props}
+4 -4
View File
@@ -154,7 +154,7 @@ function Select2({
aria-expanded={open} aria-expanded={open}
aria-required={required} aria-required={required}
className={cn( className={cn(
"border-input bg-transparent dark:bg-input/30 dark:hover:bg-input/50 text-foreground flex h-9 w-full items-center justify-between gap-2 rounded-none border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "border-input bg-transparent dark:bg-input/30 dark:hover:bg-input/50 text-foreground flex h-8 w-full items-center justify-between gap-2 rounded-none border px-2.5 py-1 text-xs shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
onKeyDown={(event) => { onKeyDown={(event) => {
@@ -189,7 +189,7 @@ function Select2({
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder={t("common.search")} placeholder={t("common.search")}
className="h-9 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground" className="h-8 w-full bg-transparent text-xs outline-none placeholder:text-muted-foreground"
autoFocus autoFocus
/> />
</div> </div>
@@ -198,7 +198,7 @@ function Select2({
className="max-h-64 overflow-y-auto p-1 thin-scrollbar" className="max-h-64 overflow-y-auto p-1 thin-scrollbar"
> >
{filteredOptions.length === 0 ? ( {filteredOptions.length === 0 ? (
<div className="px-2 py-6 text-center text-sm text-muted-foreground"> <div className="px-2 py-6 text-center text-xs text-muted-foreground">
{t("common.noResults")} {t("common.noResults")}
</div> </div>
) : ( ) : (
@@ -222,7 +222,7 @@ function Select2({
aria-selected={isSelected} aria-selected={isSelected}
disabled={option.disabled} disabled={option.disabled}
className={cn( className={cn(
"flex w-full items-center gap-2 rounded-none px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50", "flex w-full items-center gap-2 rounded-none px-2 py-1.5 text-left text-xs outline-none transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50",
isSelected && "bg-muted text-accent-foreground", isSelected && "bg-muted text-accent-foreground",
)} )}
onClick={() => selectValue(option.value)} onClick={() => selectValue(option.value)}
+1 -1
View File
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return ( return (
<textarea <textarea
className={cn( className={cn(
"flex min-h-[80px] w-full rounded-none border border-input bg-transparent dark:bg-input/30 px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] duration-200 outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 disabled:pointer-events-none", "flex min-h-[80px] w-full rounded-none border border-input bg-transparent dark:bg-input/30 px-2.5 py-2 text-xs shadow-xs transition-[color,box-shadow] duration-200 outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 disabled:pointer-events-none",
className, className,
)} )}
ref={ref} ref={ref}
+1 -1
View File
@@ -48,7 +48,7 @@ export function LookSettings({ isAdmin }: { isAdmin: boolean }) {
</p> </p>
{isAdmin && response && ( {isAdmin && response && (
<div className="flex flex-col gap-1.5 border border-border p-2"> <div className="mt-1 flex flex-col gap-1.5 border-t border-border pt-3">
<span className="text-[11px] font-medium text-foreground"> <span className="text-[11px] font-medium text-foreground">
{t("looks.defaultsLabel")} {t("looks.defaultsLabel")}
</span> </span>
+12 -6
View File
@@ -42,6 +42,7 @@ import {
betaToTry, betaToTry,
describeContributions, describeContributions,
formatBytes, formatBytes,
hasSourceBadge,
orderByRisk, orderByRisk,
reportIssueUrl, reportIssueUrl,
requestFeatureUrl, requestFeatureUrl,
@@ -70,13 +71,16 @@ export function PluginDetail({
const running = plugin.status === "running"; const running = plugin.status === "running";
const failed = plugin.status === "failed" || plugin.status === "blocked"; const failed = plugin.status === "failed" || plugin.status === "blocked";
const compatible = plugin.versions.filter((v) => v.compatible); const compatible = plugin.versions.filter((v) => v.compatible);
const [picked, setPicked] = useState<string>( // A local build can be a version the registry never listed.
plugin.version ?? plugin.latestVersion ?? "", const startVersion =
); plugin.version && compatible.some((v) => v.version === plugin.version)
? plugin.version
: (plugin.latestVersion ?? compatible[0]?.version ?? "");
const [picked, setPicked] = useState<string>(startVersion);
useEffect(() => { useEffect(() => {
setPicked(plugin.version ?? plugin.latestVersion ?? ""); setPicked(startVersion);
}, [plugin.version, plugin.latestVersion]); }, [startVersion]);
const contributions = describeContributions(plugin.contributes); const contributions = describeContributions(plugin.contributes);
// Only people who can manage plugins get here, and they see admin settings // Only people who can manage plugins get here, and they see admin settings
@@ -108,10 +112,12 @@ export function PluginDetail({
</span> </span>
<Facts className="text-[11px] text-muted-foreground/70"> <Facts className="text-[11px] text-muted-foreground/70">
{plugin.unverified && <UnverifiedBadge />} {plugin.unverified && <UnverifiedBadge />}
{hasSourceBadge(plugin.source) && (
<SourceBadge <SourceBadge
source={plugin.source} source={plugin.source}
registryName={plugin.registryName} registryName={plugin.registryName}
/> />
)}
{plugin.isBeta && <BetaBadge />} {plugin.isBeta && <BetaBadge />}
{plugin.author && <span>{plugin.author}</span>} {plugin.author && <span>{plugin.author}</span>}
{plugin.version ? ( {plugin.version ? (
@@ -671,7 +677,7 @@ function DataCard({
))} ))}
</div> </div>
)} )}
<div className="flex flex-wrap gap-x-4 gap-y-1 border-t border-border py-2 text-[11px] text-muted-foreground"> <div className="flex flex-wrap gap-x-4 gap-y-1 border-t border-border px-3 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,
+3 -3
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 py-3"> <div className="flex flex-col gap-2 px-3 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 && (
@@ -180,7 +180,7 @@ export function PluginReleaseNotes({ plugin }: { plugin: PluginEntry }) {
) : undefined ) : undefined
} }
> >
<div className="-mx-3 -my-1 md:-mx-4"> <div className="-mx-3 -my-1">
<Release row={latest} installedVersion={plugin.version} /> <Release row={latest} installedVersion={plugin.version} />
{showOlder && older.length > 0 && ( {showOlder && older.length > 0 && (
<div className="max-h-96 divide-y divide-border overflow-y-auto border-t border-border"> <div className="max-h-96 divide-y divide-border overflow-y-auto border-t border-border">
@@ -211,7 +211,7 @@ export function PluginVideo({
if (!src) return null; if (!src) return null;
return ( return (
<div className="aspect-video w-full border border-border bg-black"> <div className="aspect-video w-full bg-black">
<iframe <iframe
src={src} src={src}
title={t("plugins.manager.videoTitle", { name })} title={t("plugins.manager.videoTitle", { name })}
+6 -1
View File
@@ -59,6 +59,7 @@ import { PluginDetail } from "./PluginDetail";
import { import {
categoriesOf, categoriesOf,
describeContributions, describeContributions,
hasSourceBadge,
isSevere, isSevere,
kindsOf, kindsOf,
matchesQuery, matchesQuery,
@@ -1078,10 +1079,12 @@ function InstalledCard({
facts={ facts={
<> <>
{plugin.unverified && <UnverifiedBadge />} {plugin.unverified && <UnverifiedBadge />}
{hasSourceBadge(plugin.source) && (
<SourceBadge <SourceBadge
source={plugin.source} source={plugin.source}
registryName={plugin.registryName} registryName={plugin.registryName}
/> />
)}
{plugin.isBeta && <BetaBadge />} {plugin.isBeta && <BetaBadge />}
{plugin.author && <span>{plugin.author}</span>} {plugin.author && <span>{plugin.author}</span>}
<span>v{plugin.version}</span> <span>v{plugin.version}</span>
@@ -1503,7 +1506,7 @@ function BrowseSection({
}} }}
/> />
<p className="text-[11px] text-muted-foreground"> <p className="px-3 py-2 text-[11px] text-muted-foreground">
{t("plugins.manager.registries.browseHint")}{" "} {t("plugins.manager.registries.browseHint")}{" "}
<DocsLink core="communityRegistry" className="text-[11px]"> <DocsLink core="communityRegistry" className="text-[11px]">
{t("plugins.manager.communitySubmit")} {t("plugins.manager.communitySubmit")}
@@ -1535,10 +1538,12 @@ function BrowseCard({
onOpen={onOpen} onOpen={onOpen}
facts={ facts={
<> <>
{hasSourceBadge(plugin.source) && (
<SourceBadge <SourceBadge
source={plugin.source} source={plugin.source}
registryName={plugin.registryName} registryName={plugin.registryName}
/> />
)}
{plugin.author && <span>{plugin.author}</span>} {plugin.author && <span>{plugin.author}</span>}
{plugin.latestVersion && <span>v{plugin.latestVersion}</span>} {plugin.latestVersion && <span>v{plugin.latestVersion}</span>}
{plugin.category && <span>{plugin.category}</span>} {plugin.category && <span>{plugin.category}</span>}
+10 -5
View File
@@ -1,7 +1,12 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { PluginIcon } from "@/lib/plugin-icon"; import { PluginIcon } from "@/lib/plugin-icon";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { capabilityRisk, formatCount, isSevere } from "./plugin-model"; import {
capabilityRisk,
formatCount,
hasSourceBadge,
isSevere,
} from "./plugin-model";
export function PluginIconBox({ export function PluginIconBox({
name, name,
@@ -80,7 +85,7 @@ export function UnverifiedBadge() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<span <span
className="shrink-0 border border-warning/40 bg-warning/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning" className="inline-block shrink-0 border border-warning/40 bg-warning/10 px-1.5 py-0.5 leading-none text-[10px] font-bold uppercase tracking-wider text-warning"
title={t("plugins.manager.unverifiedHint")} title={t("plugins.manager.unverifiedHint")}
> >
{t("plugins.manager.unverified")} {t("plugins.manager.unverified")}
@@ -97,14 +102,14 @@ export function SourceBadge({
registryName?: string | null; registryName?: string | null;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (source !== "community" && source !== "custom") return null; if (!hasSourceBadge(source)) return null;
const label = const label =
source === "community" source === "community"
? t("plugins.manager.registries.communityBadge") ? t("plugins.manager.registries.communityBadge")
: (registryName ?? t("plugins.manager.registries.customBadge")); : (registryName ?? t("plugins.manager.registries.customBadge"));
return ( return (
<span <span
className="max-w-32 shrink-0 truncate border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground" className="inline-block max-w-32 shrink-0 truncate border border-border bg-muted/40 px-1.5 py-0.5 leading-none align-middle text-[10px] font-bold uppercase tracking-wider text-muted-foreground"
title={t(`plugins.manager.registries.${source}BadgeHint`, { title={t(`plugins.manager.registries.${source}BadgeHint`, {
name: label, name: label,
})} })}
@@ -118,7 +123,7 @@ export function BetaBadge() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<span <span
className="shrink-0 border border-accent-brand/40 bg-accent-brand/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-accent-brand" className="inline-block shrink-0 border border-accent-brand/40 bg-accent-brand/10 px-1.5 py-0.5 leading-none text-[10px] font-bold uppercase tracking-wider text-accent-brand"
title={t("plugins.manager.beta.badgeHint")} title={t("plugins.manager.beta.badgeHint")}
> >
{t("plugins.manager.beta.badge")} {t("plugins.manager.beta.badge")}
+5
View File
@@ -591,3 +591,8 @@ export function youtubeEmbedUrl(videoId: string | undefined): string | null {
? `https://www.youtube-nocookie.com/embed/${videoId}?rel=0` ? `https://www.youtube-nocookie.com/embed/${videoId}?rel=0`
: null; : null;
} }
/** True when SourceBadge draws something, so lists can skip the empty slot. */
export function hasSourceBadge(source: string): boolean {
return source === "community" || source === "custom";
}
+2 -2
View File
@@ -19,13 +19,13 @@ export function AuthEnrollmentSections({
); );
if (sections.length === 0) return null; if (sections.length === 0) return null;
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col divide-y divide-border">
{sections.map((entry) => { {sections.map((entry) => {
const Enrollment = entry.enrollment!; const Enrollment = entry.enrollment!;
return ( return (
<div <div
key={`${entry.pluginId}:${entry.id}`} key={`${entry.pluginId}:${entry.id}`}
className="flex flex-col gap-2" className="flex flex-col gap-2 py-3 first:pt-0"
> >
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"> <span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t(entry.titleKey)} {t(entry.titleKey)}