From d3b127c2ee7cf6de4fad3ac733c4062fd3a10d93 Mon Sep 17 00:00:00 2001 From: Irozuku Date: Mon, 17 Aug 2026 09:24:14 -0400 Subject: [PATCH 1/2] feat: gate explorer and converter tool cards on credentials and downloads Add download and credential gating to the ToolList/ToolGrid explorer and converter cards, mirroring the model rows in the models sidebar. A card is blocked when a required credential is not authenticated or a required download is still pending, and clicking it resolves the blocker (opens the credentials dialog or starts the download) instead of opening the config. Emit requires_download/download_size_bytes in BaseConverter and BaseExplorer metadata so the frontend can detect downloadable explorers and converters, and extend the metadata tests accordingly. --- DashAI/back/converters/base_converter.py | 2 + DashAI/back/exploration/base_explorer.py | 2 + .../components/notebooks/tool/ToolGrid.jsx | 59 ++++++++-- .../notebooks/tool/ToolGridItem.jsx | 109 +++++++++++++----- .../components/notebooks/tool/ToolList.jsx | 59 ++++++++-- .../notebooks/tool/ToolListItem.jsx | 96 ++++++++++----- .../components/notebooks/tool/useToolGate.js | 56 +++++++++ .../test_base_converter_metadata.py | 28 +++++ .../test_base_explorer_metadata.py | 35 ++++++ 9 files changed, 375 insertions(+), 71 deletions(-) create mode 100644 DashAI/front/src/components/notebooks/tool/useToolGate.js diff --git a/DashAI/back/converters/base_converter.py b/DashAI/back/converters/base_converter.py index a8a534034..6a7408c29 100644 --- a/DashAI/back/converters/base_converter.py +++ b/DashAI/back/converters/base_converter.py @@ -70,6 +70,8 @@ def get_metadata(cls) -> Dict[str, Any]: meta["category"] = cls.CATEGORY if cls.CATEGORY else "Other" meta["icon"] = cls.ICON if cls.ICON else Icon.Extension.value meta["color"] = cls.COLOR if cls.COLOR else "rgb(255, 255, 255)" + meta["requires_download"] = bool(getattr(cls, "REQUIRES_DOWNLOAD", False)) + meta["download_size_bytes"] = getattr(cls, "DOWNLOAD_SIZE_BYTES", None) meta["supervised"] = cls.SUPERVISED meta["changes_row_count"] = cls.CHANGES_ROW_COUNT meta["n_components_features_bounded"] = getattr( diff --git a/DashAI/back/exploration/base_explorer.py b/DashAI/back/exploration/base_explorer.py index 65520cd5e..fac016eb9 100644 --- a/DashAI/back/exploration/base_explorer.py +++ b/DashAI/back/exploration/base_explorer.py @@ -89,6 +89,8 @@ def get_metadata(cls) -> Dict[str, Any]: meta["category"] = cls.CATEGORY if cls.CATEGORY else "Other" meta["icon"] = cls.ICON if cls.ICON else Icon.Extension.value meta["color"] = cls.COLOR if cls.COLOR else "rgb(255, 255, 255)" + meta["requires_download"] = bool(getattr(cls, "REQUIRES_DOWNLOAD", False)) + meta["download_size_bytes"] = getattr(cls, "DOWNLOAD_SIZE_BYTES", None) if meta.get("input_cardinality") is None: meta["input_cardinality"] = {"min": 1} diff --git a/DashAI/front/src/components/notebooks/tool/ToolGrid.jsx b/DashAI/front/src/components/notebooks/tool/ToolGrid.jsx index dbbadcb14..bb18cc4cd 100644 --- a/DashAI/front/src/components/notebooks/tool/ToolGrid.jsx +++ b/DashAI/front/src/components/notebooks/tool/ToolGrid.jsx @@ -6,14 +6,30 @@ import { useTourContext } from "../../tour/TourProvider"; import { groupByCategory, sortCategories } from "./toolCategories"; import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material/styles"; +import { useSnackbar } from "notistack"; import { useExplorersAndConverters } from "../context/ExplorersAndConvertersContext"; +import { startComponentDownload } from "../../models/model/ComponentDownloadControl"; +import CredentialsDialog from "../../credentials/CredentialsDialog"; +import { useToolGate } from "./useToolGate"; + +function ResolveDrop({ tool, onUse, onDownload, onNeedsCredentials }) { + const gate = useToolGate(tool); + useEffect(() => { + gate.resolve({ onUse, onDownload, onNeedsCredentials }); + // Resolve once when the dropped tool changes; re-resolving on every state + // change could restart a download or reopen the dialog. + }, [tool?.name]); + return null; +} export default function ToolGrid({ tools, notebook, FormComponent }) { const [open, setOpen] = useState(false); const [selectedTool, setSelectedTool] = useState(null); + const [credentialsDialogOpen, setCredentialsDialogOpen] = useState(false); const tourContext = useTourContext(); const { t } = useTranslation(["datasets", "common"]); const theme = useTheme(); + const { enqueueSnackbar } = useSnackbar(); const { pendingDropTool, setPendingDropTool } = useExplorersAndConverters(); const grouped = useMemo(() => groupByCategory(tools), [tools]); @@ -22,7 +38,7 @@ export default function ToolGrid({ tools, notebook, FormComponent }) { [grouped], ); - const handleToolClick = (tool) => { + const handleUseTool = (tool) => { setSelectedTool(tool); setOpen(true); @@ -41,14 +57,23 @@ export default function ToolGrid({ tools, notebook, FormComponent }) { } }; + const handleDownloadTool = (tool) => { + startComponentDownload({ component: tool, enqueueSnackbar, t }); + }; + + const handleNeedsCredentials = () => { + setCredentialsDialogOpen(true); + }; + + const droppedTool = useMemo( + () => tools.find((tool) => tool.name === pendingDropTool?.name), + [pendingDropTool, tools], + ); + useEffect(() => { - if (!pendingDropTool) return; - const match = tools.find((t) => t.name === pendingDropTool.name); - if (match) { - handleToolClick(match); - setPendingDropTool(null); - } - }, [pendingDropTool, tools]); + if (!droppedTool) return; + setPendingDropTool(null); + }, [droppedTool, setPendingDropTool]); if (!tools || tools.length === 0) { return ( @@ -114,7 +139,9 @@ export default function ToolGrid({ tools, notebook, FormComponent }) { key={tool.name} tool={tool} disabled={tool.disabled} - onClick={() => handleToolClick(tool)} + onUse={() => handleUseTool(tool)} + onDownload={() => handleDownloadTool(tool)} + onNeedsCredentials={handleNeedsCredentials} /> ))} @@ -122,6 +149,15 @@ export default function ToolGrid({ tools, notebook, FormComponent }) { ); })} + {droppedTool && ( + handleUseTool(droppedTool)} + onDownload={() => handleDownloadTool(droppedTool)} + onNeedsCredentials={handleNeedsCredentials} + /> + )} + {selectedTool && ( )} + + setCredentialsDialogOpen(false)} + /> ); } diff --git a/DashAI/front/src/components/notebooks/tool/ToolGridItem.jsx b/DashAI/front/src/components/notebooks/tool/ToolGridItem.jsx index 4a71ffdb0..4cfad82c7 100644 --- a/DashAI/front/src/components/notebooks/tool/ToolGridItem.jsx +++ b/DashAI/front/src/components/notebooks/tool/ToolGridItem.jsx @@ -1,20 +1,34 @@ import React, { useState } from "react"; -import { Box, Typography, Chip, Tooltip } from "@mui/material"; +import { Box, Typography, Tooltip, Stack } from "@mui/material"; +import { VpnKeyOutlined as KeyIcon } from "@mui/icons-material"; import HoverToolInfo from "./HoverToolInfo"; import api from "../../../api/api"; import { CategoryIcon } from "./CategoryIcon"; import { useTranslation } from "react-i18next"; import { useTheme } from "@mui/material/styles"; import { setCustomDragImage } from "../../../utils/dragImage"; +import ModelDownloadStatusIcon from "../../models/model/ModelDownloadStatusIcon"; +import { useToolGate } from "./useToolGate"; -export default function ToolGridItem({ tool, disabled, onClick }) { +export default function ToolGridItem({ + tool, + disabled, + onUse, + onDownload, + onNeedsCredentials, +}) { const [anchorEl, setAnchorEl] = useState(null); const [hoveredTool, setHoveredTool] = useState(null); - const { t } = useTranslation(["common"]); + const { t } = useTranslation(["common", "credentials"]); const theme = useTheme(); + const gate = useToolGate({ ...tool, disabled }); + + const handleClick = () => + gate.resolve({ onUse, onDownload, onNeedsCredentials }); + const handleMouseEnter = (event, tool) => { - if (!disabled) { + if (!gate.blocked) { setAnchorEl(event.currentTarget); setHoveredTool(tool); } @@ -25,10 +39,28 @@ export default function ToolGridItem({ tool, disabled, onClick }) { setHoveredTool(null); }; + const action = + gate.locked || gate.requiresDownload ? ( + + {gate.locked && ( + + + + )} + {gate.requiresDownload && ( + + )} + + ) : null; + return ( <> { e.dataTransfer.setData( "application/x-dashai-tool", @@ -67,30 +99,36 @@ export default function ToolGridItem({ tool, disabled, onClick }) { } onMouseEnter={(e) => handleMouseEnter(e, tool)} onMouseLeave={handleMouseLeave} - onClick={disabled ? null : onClick} + onClick={handleClick} sx={{ position: "relative", - bgcolor: disabled + bgcolor: gate.blocked ? theme.palette.ui.disabled : theme.palette.ui.box, border: `1px solid ${theme.palette.ui.border}`, borderRadius: 1.5, overflow: "hidden", - cursor: disabled ? "not-allowed" : "grab", + cursor: gate.blocked + ? gate.gated + ? "pointer" + : "not-allowed" + : "grab", transition: "all 0.2s", - opacity: disabled ? 0.5 : 1, - filter: disabled ? "grayscale(0.6)" : "none", + opacity: gate.blocked ? 0.5 : 1, + filter: gate.blocked ? "grayscale(0.6)" : "none", "&:hover": { - bgcolor: disabled + bgcolor: gate.blocked ? theme.palette.ui.disabled : theme.palette.action.hover, - borderColor: disabled + borderColor: gate.blocked ? theme.palette.ui.border : tool.metadata.color, - transform: disabled ? "none" : "translateY(-4px)", - boxShadow: disabled ? "none" : `0 8px 16px rgba(0, 0, 0, 0.2)`, + transform: gate.blocked ? "none" : "translateY(-4px)", + boxShadow: gate.blocked + ? "none" + : `0 8px 16px rgba(0, 0, 0, 0.2)`, }, - "&::after": disabled + "&::after": gate.blocked ? { content: '""', position: "absolute", @@ -109,11 +147,13 @@ export default function ToolGridItem({ tool, disabled, onClick }) { sx={{ width: "100%", height: 100, - bgcolor: disabled + bgcolor: gate.blocked ? theme.palette.ui.disabled : theme.palette.ui.border, borderBottom: `1px solid ${ - disabled ? theme.palette.ui.disabled : theme.palette.ui.border + gate.blocked + ? theme.palette.ui.disabled + : theme.palette.ui.border }`, }} > @@ -124,7 +164,7 @@ export default function ToolGridItem({ tool, disabled, onClick }) { width: "100%", height: "100%", objectFit: "cover", - opacity: disabled ? 0.4 : 1, + opacity: gate.blocked ? 0.4 : 1, }} /> @@ -142,10 +182,10 @@ export default function ToolGridItem({ tool, disabled, onClick }) { height: 28, p: 4, borderRadius: 0.75, - bgcolor: disabled + bgcolor: gate.blocked ? theme.palette.ui.disabled : theme.palette.ui.border, - color: disabled + color: gate.blocked ? theme.palette.text.disabled : theme.palette.text.primary, flexShrink: 0, @@ -154,17 +194,32 @@ export default function ToolGridItem({ tool, disabled, onClick }) { + + {action && ( + + {action} + + )} {/* Title */} - {!disabled && ( + {!gate.blocked && ( { + gate.resolve({ onUse, onDownload, onNeedsCredentials }); + // Resolve once when the dropped tool changes; re-resolving on every state + // change could restart a download or reopen the dialog. + }, [tool?.name]); + return null; +} export default function ToolList({ tools, notebook, FormComponent }) { const theme = useTheme(); const [open, setOpen] = useState(false); const [selectedTool, setSelectedTool] = useState(null); + const [credentialsDialogOpen, setCredentialsDialogOpen] = useState(false); const tourContext = useTourContext(); const { t } = useTranslation(["datasets", "common"]); + const { enqueueSnackbar } = useSnackbar(); const { pendingDropTool, setPendingDropTool } = useExplorersAndConverters(); const grouped = useMemo(() => groupByCategory(tools), [tools]); @@ -30,7 +46,7 @@ export default function ToolList({ tools, notebook, FormComponent }) { [grouped], ); - const handleToolClick = (tool) => { + const handleUseTool = (tool) => { setSelectedTool(tool); setOpen(true); if (tourContext && tourContext.run) { @@ -40,14 +56,23 @@ export default function ToolList({ tools, notebook, FormComponent }) { } }; + const handleDownloadTool = (tool) => { + startComponentDownload({ component: tool, enqueueSnackbar, t }); + }; + + const handleNeedsCredentials = () => { + setCredentialsDialogOpen(true); + }; + + const droppedTool = useMemo( + () => tools.find((tool) => tool.name === pendingDropTool?.name), + [pendingDropTool, tools], + ); + useEffect(() => { - if (!pendingDropTool) return; - const match = tools.find((t) => t.name === pendingDropTool.name); - if (match) { - handleToolClick(match); - setPendingDropTool(null); - } - }, [pendingDropTool, tools]); + if (!droppedTool) return; + setPendingDropTool(null); + }, [droppedTool, setPendingDropTool]); if (!tools || tools.length === 0) { return ( @@ -129,7 +154,9 @@ export default function ToolList({ tools, notebook, FormComponent }) { key={tool.name} tool={tool} disabled={tool.disabled} - onClick={() => handleToolClick(tool)} + onUse={() => handleUseTool(tool)} + onDownload={() => handleDownloadTool(tool)} + onNeedsCredentials={handleNeedsCredentials} /> ))} @@ -138,6 +165,15 @@ export default function ToolList({ tools, notebook, FormComponent }) { ); })} + {droppedTool && ( + handleUseTool(droppedTool)} + onDownload={() => handleDownloadTool(droppedTool)} + onNeedsCredentials={handleNeedsCredentials} + /> + )} + {selectedTool && ( )} + + setCredentialsDialogOpen(false)} + /> ); } diff --git a/DashAI/front/src/components/notebooks/tool/ToolListItem.jsx b/DashAI/front/src/components/notebooks/tool/ToolListItem.jsx index 0232cd470..612be1227 100644 --- a/DashAI/front/src/components/notebooks/tool/ToolListItem.jsx +++ b/DashAI/front/src/components/notebooks/tool/ToolListItem.jsx @@ -1,25 +1,35 @@ import React, { useState } from "react"; -import { Box, Typography, Chip, Tooltip } from "@mui/material"; +import { Box, Typography, Tooltip, Stack } from "@mui/material"; +import { VpnKeyOutlined as KeyIcon } from "@mui/icons-material"; import { useTheme } from "@mui/material/styles"; import HoverToolInfo from "./HoverToolInfo"; import api from "../../../api/api"; import { CategoryIcon } from "./CategoryIcon"; import { useTranslation } from "react-i18next"; import { setCustomDragImage } from "../../../utils/dragImage"; +import ModelDownloadStatusIcon from "../../models/model/ModelDownloadStatusIcon"; +import { useToolGate } from "./useToolGate"; export default function ToolListItem({ tool, disabled = false, - onClick, + onUse, + onDownload, + onNeedsCredentials, ...props }) { const theme = useTheme(); const [anchorEl, setAnchorEl] = useState(null); const [hoveredTool, setHoveredTool] = useState(null); - const { t } = useTranslation(["common"]); + const { t } = useTranslation(["common", "credentials"]); + + const gate = useToolGate({ ...tool, disabled }); + + const handleClick = () => + gate.resolve({ onUse, onDownload, onNeedsCredentials }); const handleMouseEnter = (event, tool) => { - if (!disabled) { + if (!gate.blocked) { setAnchorEl(event.currentTarget); setHoveredTool(tool); } @@ -43,10 +53,28 @@ export default function ToolListItem({ setHoveredTool(null); }; + const action = + gate.locked || gate.requiresDownload ? ( + + {gate.locked && ( + + + + )} + {gate.requiresDownload && ( + + )} + + ) : null; + return ( <> { e.dataTransfer.setData( "application/x-dashai-tool", @@ -87,32 +115,36 @@ export default function ToolListItem({ } onMouseEnter={(e) => handleMouseEnter(e, tool)} onMouseLeave={handleMouseLeave} - onClick={disabled ? null : onClick} + onClick={handleClick} sx={{ display: "flex", alignItems: "center", gap: 6, p: 6, - bgcolor: disabled + bgcolor: gate.blocked ? theme.palette.ui.disabled : theme.palette.ui.box, border: `1px solid ${theme.palette.ui.border}`, borderRadius: 1, - cursor: disabled ? "not-allowed" : "grab", + cursor: gate.blocked + ? gate.gated + ? "pointer" + : "not-allowed" + : "grab", transition: "all 0.2s", - opacity: disabled ? 0.5 : 1, - filter: disabled ? "grayscale(0.6)" : "none", + opacity: gate.blocked ? 0.5 : 1, + filter: gate.blocked ? "grayscale(0.6)" : "none", position: "relative", "&:hover": { - bgcolor: disabled + bgcolor: gate.blocked ? theme.palette.ui.disabled : theme.palette.action.hover, - borderColor: disabled + borderColor: gate.blocked ? theme.palette.ui.border : tool.metadata.color, - transform: disabled ? "none" : "translateX(4px)", + transform: gate.blocked ? "none" : "translateX(4px)", }, - "&::after": disabled + "&::after": gate.blocked ? { content: '""', position: "absolute", @@ -134,10 +166,10 @@ export default function ToolListItem({ width: 36, height: 36, borderRadius: 1, - bgcolor: disabled + bgcolor: gate.blocked ? theme.palette.ui.disabled : theme.palette.ui.border, - color: disabled + color: gate.blocked ? theme.palette.text.disabled : theme.palette.text.primary, flexShrink: 0, @@ -146,7 +178,7 @@ export default function ToolListItem({ @@ -164,7 +196,7 @@ export default function ToolListItem({ + {/* Download/credential status (e.g. a locked key or download icon) */} + {action && ( + + {action} + + )} + {/* Preview Thumbnail */} - {!disabled && ( + {!gate.blocked && ( { + const requiresDownload = Boolean(tool?.metadata?.requires_download); + const { downloaded, downloading } = useComponentDownloadState( + tool || { name: "" }, + ); + const { statuses, loaded } = useCredentialStatuses(); + const { locked, requiredPlatforms } = getComponentCredentialState( + tool || {}, + statuses, + loaded, + ); + const ready = !locked && (!requiresDownload || (downloaded && !downloading)); + const blocked = Boolean(tool?.disabled) || !ready; + const gated = locked || (requiresDownload && !(downloaded && !downloading)); + + const resolve = ({ onUse, onDownload, onNeedsCredentials }) => { + if (downloading) return; + if (locked) { + onNeedsCredentials?.(); + return; + } + if (requiresDownload && !(downloaded && !downloading)) { + onDownload?.(); + return; + } + if (tool?.disabled) return; + onUse?.(); + }; + + return { + requiresDownload, + downloaded, + downloading, + locked, + requiredPlatforms, + ready, + blocked, + gated, + resolve, + }; +}; diff --git a/tests/back/converters/test_base_converter_metadata.py b/tests/back/converters/test_base_converter_metadata.py index d3c4ecafe..3ac5f1307 100644 --- a/tests/back/converters/test_base_converter_metadata.py +++ b/tests/back/converters/test_base_converter_metadata.py @@ -21,6 +21,22 @@ def transform(self, x, y=None): return x +class _DownloadableConverter(BaseConverter): + SCHEMA = None + metadata = {} + REQUIRES_DOWNLOAD = True + DOWNLOAD_SIZE_BYTES = 1234 + + def get_output_type(self, column_name=None): + return None + + def fit(self, x, y=None): + return self + + def transform(self, x, y=None): + return x + + class _StarDtypeConverter(BaseConverter): SCHEMA = None metadata = {"allowed_types": [], "allowed_dtypes": ["*"]} @@ -92,6 +108,18 @@ def test_get_metadata_none_metadata_produces_empty_lists(): assert "restricted_dtypes" not in meta +def test_get_metadata_plain_converter_not_downloadable(): + meta = _FloatIntConverter.get_metadata() + assert meta["requires_download"] is False + assert meta["download_size_bytes"] is None + + +def test_get_metadata_downloadable_converter_metadata(): + meta = _DownloadableConverter.get_metadata() + assert meta["requires_download"] is True + assert meta["download_size_bytes"] == 1234 + + def test_get_metadata_categorical_text_serialized_correctly(): class _CatTextConverter(BaseConverter): SCHEMA = None diff --git a/tests/back/exploration/test_base_explorer_metadata.py b/tests/back/exploration/test_base_explorer_metadata.py index 9eebb5901..ddc4a733c 100644 --- a/tests/back/exploration/test_base_explorer_metadata.py +++ b/tests/back/exploration/test_base_explorer_metadata.py @@ -22,6 +22,27 @@ def get_results(self, exploration_path, options): return _StubExplorer +def _make_downloadable_explorer(): + """Return a minimal concrete BaseExplorer that requires a download.""" + + class _DownloadableExplorer(BaseExplorer): + SCHEMA = BaseExplorerSchema + metadata = {} + REQUIRES_DOWNLOAD = True + DOWNLOAD_SIZE_BYTES = 5678 + + def launch_exploration(self, dataset, explorer_info): + return None + + def save_notebook(self, notebook_info, explorer_info, save_path, result): + return "" + + def get_results(self, exploration_path, options): + return {} + + return _DownloadableExplorer + + # --- get_metadata tests --- @@ -72,6 +93,20 @@ def get_results(self, exploration_path, options): assert "restricted_dtypes" not in meta +def test_get_metadata_plain_explorer_not_downloadable(): + cls = _make_explorer() + meta = cls.get_metadata() + assert meta["requires_download"] is False + assert meta["download_size_bytes"] is None + + +def test_get_metadata_downloadable_explorer_metadata(): + cls = _make_downloadable_explorer() + meta = cls.get_metadata() + assert meta["requires_download"] is True + assert meta["download_size_bytes"] == 5678 + + def test_get_metadata_does_not_mutate_class_attribute(): cls = _make_explorer(allowed_types=[Float], allowed_dtypes=[]) original_metadata = dict(cls.metadata) From 0cdb04a184e0e8bf5c809754ad747d546b3a7282 Mon Sep 17 00:00:00 2001 From: Irozuku Date: Mon, 17 Aug 2026 11:31:36 -0400 Subject: [PATCH 2/2] fix: keep tool card status icons in full color Card level opacity/grayscale grouped the download and credential icons with the dimmed content, so a gated card greyed its own affordances. Dim the preview, icon and text individually and lift the status icons above the hatch overlay. Hover state now keys off `disabled` (dataset mismatch) instead of the full gate, so credential locked and not yet downloaded tools still show their description popover and hover animation. --- .../notebooks/tool/ToolGridItem.jsx | 32 ++-- .../notebooks/tool/ToolListItem.jsx | 161 ++++++++++-------- 2 files changed, 109 insertions(+), 84 deletions(-) diff --git a/DashAI/front/src/components/notebooks/tool/ToolGridItem.jsx b/DashAI/front/src/components/notebooks/tool/ToolGridItem.jsx index 4cfad82c7..b3cf46917 100644 --- a/DashAI/front/src/components/notebooks/tool/ToolGridItem.jsx +++ b/DashAI/front/src/components/notebooks/tool/ToolGridItem.jsx @@ -28,7 +28,7 @@ export default function ToolGridItem({ gate.resolve({ onUse, onDownload, onNeedsCredentials }); const handleMouseEnter = (event, tool) => { - if (!gate.blocked) { + if (!disabled) { setAnchorEl(event.currentTarget); setHoveredTool(tool); } @@ -60,7 +60,7 @@ export default function ToolGridItem({ return ( <> - {/* Preview Image */} + {/* Preview Image — dimmed when blocked; the download/credential icons + below are kept out of every dimmed subtree so they keep full + color. */} {action} @@ -224,6 +228,7 @@ export default function ToolGridItem({ : theme.palette.text.primary, fontWeight: 500, mb: 1, + opacity: gate.blocked ? 0.5 : 1, overflow: "hidden", textOverflow: "ellipsis", display: "-webkit-box", @@ -243,6 +248,7 @@ export default function ToolGridItem({ color: gate.blocked ? theme.palette.text.disabled : theme.palette.text.primary, + opacity: gate.blocked ? 0.5 : 1, }} > {tool.metadata.category ?? t("common:other")} @@ -250,7 +256,7 @@ export default function ToolGridItem({ - {!gate.blocked && ( + {!disabled && ( { - if (!gate.blocked) { + if (!disabled) { setAnchorEl(event.currentTarget); setHoveredTool(tool); } @@ -72,9 +72,9 @@ export default function ToolListItem({ ) : null; return ( - <> + - {/* Icon */} + {/* Content (tool icon, text) dimmed when the card is blocked; the + download/credential icons below stay outside it so they keep full + color. */} - - - - {/* Content */} - + {/* Icon */} - + + + {/* Content */} + + - {tool.display_name || tool.name} - - - - + {tool.display_name || tool.name} + + + - {tool.metadata.category ?? t("common:other")} - + + {tool.metadata.category ?? t("common:other")} + + - {/* Download/credential status (e.g. a locked key or download icon) */} + {/* Download/credential status — left of the thumbnail, full color */} {action && ( {action} @@ -278,13 +296,14 @@ export default function ToolListItem({ - {!gate.blocked && ( + + {!disabled && ( )} - + ); }