diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index effea181e..2dcd2e5f0 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -6195,6 +6195,304 @@ select.input { margin-left: 4px; } +/* ResourceRow — unified expandable row anatomy used by the Manage page so + installed models and backends share the same visual grammar as the Install + gallery (icon, name, description, badges, expandable detail panel). */ +.resource-row { transition: background var(--duration-fast) var(--ease-default); } +.resource-row.is-dimmed { opacity: 0.55; transition: opacity 0.2s; } +.resource-row.is-expanded { background: var(--color-bg-tertiary); } + +.resource-row__chevron-cell { width: 30px; } +.resource-row__icon-cell { width: 64px; } + +.resource-row__icon { + width: 48px; + height: 48px; + border-radius: var(--radius-md); + border: 1px solid var(--color-border-subtle); + background: var(--color-bg-primary); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; +} +.resource-row__icon > img { + width: 100%; + height: 100%; + object-fit: cover; +} +.resource-row__icon > i { + font-size: 1.25rem; + color: var(--color-accent); +} + +.resource-row__detail-row > .resource-row__detail-cell { + padding: 0; + background: var(--color-bg-primary); + border-top: 1px solid var(--color-border-subtle); +} +.resource-row__detail { + padding: var(--spacing-md) var(--spacing-lg); +} +.resource-row__detail h4 { + font-size: var(--text-xs); + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text-muted); + margin: 0 0 var(--spacing-sm) 0; + display: flex; + align-items: center; + gap: var(--spacing-xs); +} +.resource-row__detail h4 i { color: var(--color-accent); } +.resource-row__detail-grid { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px var(--spacing-md); +} +.resource-row__detail-grid dt { + color: var(--color-text-muted); + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.06em; + align-self: start; + padding-top: 2px; +} +.resource-row__detail-grid dd { + margin: 0; + font-size: var(--text-sm); + color: var(--color-text-primary); + min-width: 0; + word-break: break-word; +} +.resource-row__detail-md { + color: var(--color-text-secondary); + line-height: 1.6; + font-size: var(--text-sm); +} +.resource-row__detail-md p:first-child { margin-top: 0; } +.resource-row__detail-md p:last-child { margin-bottom: 0; } + +/* Description line directly under the row name — Install gallery already + does this; Manage rows used to show only the bare name. */ +.resource-row__desc { + display: block; + font-size: var(--text-xs); + color: var(--color-text-muted); + max-width: 42ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-top: 2px; +} + +/* ResourceActions — split lifecycle vs destructive with a thin divider so + the trash icon doesn't sit at the same eye-weight as the routine buttons. + Used now only for the rare row whose actions can't collapse into the + kebab menu (e.g. a "Protected" badge on system backends). */ +.resource-actions { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + justify-content: flex-end; +} +.resource-actions__group { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); +} +.resource-actions__divider { + width: 1px; + height: 20px; + background: var(--color-border-subtle); + margin: 0 var(--spacing-xs); + flex-shrink: 0; +} + +/* ActionMenu — kebab trigger + popover menu. Borrows claudemaster's + restrained pattern: button reads as a quiet ellipsis at rest, lights up + on row hover, and the menu items hold typography-first weight (icon + + label, no fills until hover). The trigger stays at low opacity until the + user reaches for the row, so dense tables don't read as control panels. */ +.action-menu__trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + font-size: var(--text-sm); + opacity: 0.45; + transition: + opacity var(--duration-fast) var(--ease-default), + color var(--duration-fast) var(--ease-default), + background var(--duration-fast) var(--ease-default), + border-color var(--duration-fast) var(--ease-default); +} +.resource-row:hover .action-menu__trigger, +.action-menu__trigger:focus-visible, +.action-menu__trigger.is-open { + opacity: 1; +} +.action-menu__trigger:hover, +.action-menu__trigger.is-open { + background: var(--color-bg-tertiary); + border-color: var(--color-border-subtle); + color: var(--color-text-primary); +} +.action-menu__trigger:focus-visible { + outline: 2px solid var(--color-border-focus); + outline-offset: 2px; +} +.action-menu__trigger--compact { + width: 24px; + height: 24px; + font-size: var(--text-xs); +} + +.action-menu { + display: flex; + flex-direction: column; + min-width: 200px; + padding: 4px; + outline: none; +} +.action-menu__item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: 8px 10px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-primary); + font-family: inherit; + font-size: var(--text-sm); + font-weight: 500; + text-align: left; + cursor: pointer; + width: 100%; + transition: background var(--duration-fast) var(--ease-default), + color var(--duration-fast) var(--ease-default); +} +.action-menu__item.is-active, +.action-menu__item:hover:not(:disabled) { + background: var(--color-bg-tertiary); +} +.action-menu__item:disabled { + cursor: not-allowed; + opacity: 0.45; +} +.action-menu__icon { + width: 14px; + text-align: center; + color: var(--color-text-muted); + font-size: var(--text-xs); + flex-shrink: 0; +} +.action-menu__item.is-active .action-menu__icon, +.action-menu__item:hover:not(:disabled) .action-menu__icon { + color: var(--color-text-primary); +} +.action-menu__label { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.action-menu__shortcut { + font-family: var(--font-mono); + font-size: 10px; + color: var(--color-text-muted); + letter-spacing: 0.04em; + flex-shrink: 0; +} +.action-menu__item.is-danger { color: var(--color-error); } +.action-menu__item.is-danger .action-menu__icon { color: var(--color-error); } +.action-menu__item.is-danger:hover:not(:disabled), +.action-menu__item.is-danger.is-active { + background: var(--color-error-light); + color: var(--color-error); +} +.action-menu__item.is-danger:hover:not(:disabled) .action-menu__icon, +.action-menu__item.is-danger.is-active .action-menu__icon { + color: var(--color-error); +} +.action-menu__divider { + height: 1px; + background: var(--color-border-subtle); + margin: 4px 2px; +} +.action-menu__badge { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: 8px 10px; + font-size: var(--text-xs); + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text-muted); +} +.action-menu__badge > i { color: var(--color-text-muted); font-size: var(--text-xs); width: 14px; text-align: center; } + +/* Pulse modifier — subtle ring on the Pin button when a model is pinned, so + pinning is visible at a glance without a second icon next to the name. */ +.btn--pulse { + animation: btnPulseWarning 1.6s ease-in-out infinite; +} +@keyframes btnPulseWarning { + 0%, 100% { box-shadow: 0 0 0 0 rgba(235, 203, 139, 0); } + 50% { box-shadow: 0 0 0 4px rgba(235, 203, 139, 0.18); } +} + +/* StatCard clickable variant — the Manage summary cards double as shortcuts + to the relevant tab + filter, so the card needs a real button affordance. */ +.stat-card[data-clickable="true"] { + cursor: pointer; + user-select: none; +} +.stat-card[data-clickable="true"]:hover { + border-color: var(--stat-accent, var(--color-border)); + transform: translateY(-1px); +} +.stat-card[data-clickable="true"]:focus-visible { + outline: 2px solid var(--color-border-focus); + outline-offset: 2px; +} + +/* Manage summary marker — same .stat-grid layout. Top margin separates the + cards from the System Resources card above (otherwise they sit too close + to the RAM bar) and bottom margin tightens the gap to the tabs below. */ +.manage-summary { + margin-top: var(--spacing-xl); + margin-bottom: var(--spacing-lg); +} + +/* Screen-reader-only label, used for table headers whose visual cell needs + no label (kebab-only Actions column, toggle-only Enabled column). The + header still announces correctly to assistive tech without making the + table feel like it's labelling sparse columns twice. */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + /* Reduced motion accessibility */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { diff --git a/core/http/react-ui/src/components/ActionMenu.jsx b/core/http/react-ui/src/components/ActionMenu.jsx new file mode 100644 index 000000000..5c58ecd78 --- /dev/null +++ b/core/http/react-ui/src/components/ActionMenu.jsx @@ -0,0 +1,141 @@ +import { useRef, useState, useEffect, useCallback } from 'react' +import Popover from './Popover' + +// ActionMenu renders a kebab (three-dot) button that opens a popover with a +// list of row actions. Replaces the inline cluster of icon buttons that made +// dense tables feel like a control panel — actions stay out of the way until +// the user reaches for them, the way Linear/Vercel/Notion handle row menus. +// +// Items shape: +// { key, icon?, label, onClick, danger?, disabled?, hidden?, shortcut? } +// { divider: true } // visual separator +// { type: 'badge', icon?, label } // non-interactive badge row +// +// Hidden items are filtered out so callers can write conditional menus +// inline (`{ key: 'stop', visible: isRunning, ... }` style) without ternaries. +// +// Keyboard: +// ArrowUp / ArrowDown — move highlight (skipping dividers + badges) +// Enter / Space — activate +// Escape — close, return focus to trigger +export default function ActionMenu({ items, ariaLabel = 'Actions', triggerLabel, compact = false }) { + const triggerRef = useRef(null) + const [open, setOpen] = useState(false) + const [activeIdx, setActiveIdx] = useState(-1) + + const interactive = (Array.isArray(items) ? items : []).filter(it => it && !it.divider && it.type !== 'badge' && !it.hidden) + const visible = (Array.isArray(items) ? items : []).filter(it => it && !it.hidden) + + const close = useCallback(() => { + setOpen(false) + setActiveIdx(-1) + }, []) + + // Move highlight to the first interactive item when opening, so keyboard + // users land somewhere meaningful instead of having to arrow into the menu. + useEffect(() => { + if (open && activeIdx === -1 && interactive.length > 0) { + setActiveIdx(0) + } + }, [open, activeIdx, interactive.length]) + + const handleTriggerKeyDown = (e) => { + if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + e.stopPropagation() + setOpen(true) + } + } + + const handleMenuKeyDown = (e) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + setActiveIdx(i => Math.min(interactive.length - 1, (i < 0 ? -1 : i) + 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setActiveIdx(i => Math.max(0, (i < 0 ? interactive.length : i) - 1)) + } else if (e.key === 'Home') { + e.preventDefault() + setActiveIdx(0) + } else if (e.key === 'End') { + e.preventDefault() + setActiveIdx(interactive.length - 1) + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + const item = interactive[activeIdx] + if (item && !item.disabled) { + close() + item.onClick?.() + } + } + } + + if (interactive.length === 0 && !visible.some(it => it.type === 'badge')) { + return null + } + + return ( + <> + + +
{ if (el && open) el.focus() }} + > + {visible.map((item, i) => { + if (item.divider) { + return
+ } + if (item.type === 'badge') { + return ( +
+ {item.icon &&