[eric] marketplace: the packages tab wears the app's own tokens, a collection is one wide row, and the grid fills the width

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012G8kyALnPjsA7aJFmMBq3R
This commit is contained in:
ciregenz
2026-09-02 17:10:26 -07:00
co-authored by Claude Fable 5.1
parent 8afec84986
commit 7d96489f26
4 changed files with 141 additions and 123 deletions
@@ -103,12 +103,14 @@ interface Props {
sortOptions: PickerOption[];
sortValue: string;
onSort: (v: string) => void;
// Optional left-hand content for the controls row; without it that band is empty with two pills pushed to the far right.
leading?: React.ReactNode;
}
// The Directory's search row + chip/filter row, shared by both tabs (same chrome on claude.ai).
const DirectoryFilterBar: React.FC<Props> = ({
searchPlaceholder, query, onQuery,
filterSections, filterSelected, onToggleFilter, sortOptions, sortValue, onSort,
filterSections, filterSelected, onToggleFilter, sortOptions, sortValue, onSort, leading,
}) => {
const c = useClaudeTokens();
return (
@@ -135,7 +137,8 @@ const DirectoryFilterBar: React.FC<Props> = ({
},
}}
/>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ minWidth: 0 }}>{leading}</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<FilterPill sections={filterSections} selected={filterSelected} onToggle={onToggleFilter} />
<SortPill options={sortOptions} value={sortValue} onChange={onSort} />
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Alert from '@mui/material/Alert';
import Snackbar from '@mui/material/Snackbar';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
@@ -122,6 +123,20 @@ const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void
}
};
const sectionLabel = (label: string, count?: number): React.ReactElement | null => {
if (!label) return null;
return (
<Stack direction="row" spacing={0.75} alignItems="baseline" sx={{ mb: 1 }}>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 600, color: c.text.tertiary, letterSpacing: '0.01em' }}>
{label}
</Typography>
{typeof count === 'number' && (
<Typography sx={{ fontSize: '0.75rem', color: c.text.ghost }}>{count}</Typography>
)}
</Stack>
);
};
const body = (): React.ReactElement => {
if (loading && !loaded) {
return (
@@ -140,11 +155,9 @@ const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void
return (
<>
{visibleBundles.length > 0 && (
<Box sx={{ mb: 2.5 }}>
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 600, color: c.text.tertiary, mb: 1 }}>
Collections
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.75 }}>
<Box sx={{ mb: 3 }}>
{sectionLabel('Collections')}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{visibleBundles.map((bundle) => (
<PackageBundleCard
key={bundle.id}
@@ -156,7 +169,16 @@ const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void
</Box>
</Box>
)}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.75, alignContent: 'start' }}>
{visible.length > 0 && sectionLabel(visibleBundles.length > 0 ? 'All packages' : '', visible.length)}
<Box
sx={{
display: 'grid',
// Fills the width at any card size, so an odd count leaves one gap rather than half a row.
gridTemplateColumns: 'repeat(auto-fill, minmax(272px, 1fr))',
gap: 1.25,
alignContent: 'start',
}}
>
{visible.map((listing) => (
<PackageCard
key={listing.id}
@@ -186,6 +208,13 @@ const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void
]}
sortValue={sort}
onSort={setSort}
leading={
loaded ? (
<Typography sx={{ fontSize: '0.8125rem', color: c.text.muted }}>
{visible.length + visibleBundles.length} {visible.length + visibleBundles.length === 1 ? 'result' : 'results'}
</Typography>
) : null
}
/>
{source === 'cache' && (
<Typography sx={{ fontSize: '0.8125rem', color: c.text.muted }}>
@@ -2,11 +2,11 @@ import React from 'react';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import Inventory2Icon from '@mui/icons-material/Inventory2';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import ExtensionIcon from '@mui/icons-material/Extension';
import Inventory2Icon from '@mui/icons-material/Inventory2Outlined';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { KIND_LABELS, type Listing } from './catalog';
import { type Listing } from './catalog';
interface Props {
bundle: Listing;
@@ -14,60 +14,58 @@ interface Props {
onOpen: () => void;
}
// Leads with a stacked-icon motif of its first few members and a count, so a bundle reads as a collection at a glance rather than as another single package.
// A collection is one wide row, not a card in the package grid: it groups the things below it, and a
// half-width card with empty space beside it reads as a layout that broke rather than a section.
export default function PackageBundleCard({ bundle, members, onOpen }: Props) {
const c = useClaudeTokens();
const preview = members.slice(0, 4);
const kinds = Array.from(new Set(members.map((m) => KIND_LABELS[m.kind] || m.kind).filter(Boolean)));
const names = members.map((m) => m.title).filter(Boolean);
return (
<Box
onClick={onOpen}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.75,
cursor: 'pointer',
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: 3,
p: 2.5,
border: `${c.border.width} solid ${c.border.subtle}`,
borderRadius: `${c.radius.lg}px`,
px: 2,
py: 1.75,
transition: c.transition,
position: 'relative',
overflow: 'hidden',
'&:hover': {
borderColor: c.accent.primary,
boxShadow: c.shadow.md,
transform: 'translateY(-2px)',
},
'&:hover': { borderColor: c.border.strong, boxShadow: c.shadow.sm },
}}
>
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 1.5 }}>
<Box
sx={{
width: 48,
height: 48,
borderRadius: 2.5,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: `${c.accent.primary}1A`,
overflow: 'hidden',
}}
>
{bundle.icon_url ? (
<Box component="img" src={bundle.icon_url} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<Inventory2Icon sx={{ fontSize: 24, color: c.accent.primary }} />
)}
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: `${c.radius.md}px`,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: c.bg.secondary,
overflow: 'hidden',
}}
>
{bundle.icon_url ? (
<Box component="img" src={bundle.icon_url} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<Inventory2Icon sx={{ fontSize: 20, color: c.text.tertiary }} />
)}
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Stack direction="row" spacing={1} alignItems="baseline" sx={{ minWidth: 0 }}>
<Typography
sx={{
fontSize: '1.05rem',
fontWeight: 660,
fontSize: '0.9375rem',
fontWeight: 650,
color: c.text.primary,
letterSpacing: '-0.01em',
letterSpacing: '-0.006em',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
@@ -75,38 +73,35 @@ export default function PackageBundleCard({ bundle, members, onOpen }: Props) {
>
{bundle.title}
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.accent.primary, fontWeight: 600 }}>
Bundle · {members.length} {members.length === 1 ? 'package' : 'packages'}
<Typography sx={{ fontSize: '0.75rem', color: c.text.muted, flexShrink: 0 }}>
{members.length} {members.length === 1 ? 'package' : 'packages'}
</Typography>
</Box>
</Stack>
</Stack>
<Typography
sx={{
fontSize: '0.8125rem',
color: c.text.tertiary,
lineHeight: 1.5,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{bundle.description || names.join(', ') || 'A curated collection of packages.'}
</Typography>
</Box>
<Typography
sx={{
fontSize: '0.875rem',
color: c.text.secondary,
lineHeight: 1.5,
mb: 1.75,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
minHeight: '2.6em',
}}
>
{bundle.description || 'A curated collection of packages.'}
</Typography>
<Stack direction="row" spacing={-0.75} sx={{ mt: 'auto', alignItems: 'center' }}>
<Stack direction="row" sx={{ alignItems: 'center', flexShrink: 0 }}>
{preview.map((m, i) => (
<Box
key={m.id}
title={m.title}
sx={{
width: 30,
height: 30,
borderRadius: 1.5,
width: 28,
height: 28,
borderRadius: `${c.radius.sm}px`,
flexShrink: 0,
ml: i === 0 ? 0 : '-8px',
ml: i === 0 ? 0 : '-7px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -119,29 +114,18 @@ export default function PackageBundleCard({ bundle, members, onOpen }: Props) {
{m.icon_url ? (
<Box component="img" src={m.icon_url} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<ExtensionIcon sx={{ fontSize: 15, color: c.text.muted }} />
<ExtensionIcon sx={{ fontSize: 14, color: c.text.muted }} />
)}
</Box>
))}
{members.length > preview.length && (
<Typography sx={{ ml: 1, fontSize: '0.78rem', color: c.text.muted }}>
+{members.length - preview.length} more
<Typography sx={{ ml: 1, fontSize: '0.75rem', color: c.text.muted }}>
+{members.length - preview.length}
</Typography>
)}
{kinds.length > 0 && (
<Chip
label={kinds.slice(0, 2).join(' · ')}
size="small"
sx={{
ml: 'auto',
height: 22,
fontSize: '0.72rem',
bgcolor: c.bg.secondary,
color: c.text.tertiary,
}}
/>
)}
</Stack>
<ChevronRightIcon sx={{ fontSize: 18, color: c.text.ghost, flexShrink: 0 }} />
</Box>
);
}
@@ -2,7 +2,6 @@ import React from 'react';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import ExtensionIcon from '@mui/icons-material/Extension';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { parseTags, KIND_LABELS, type Listing } from './catalog';
@@ -25,44 +24,41 @@ export default function PackageCard({ listing, onOpen, onTag }: Props) {
flexDirection: 'column',
cursor: 'pointer',
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: 3,
p: 2.5,
border: `${c.border.width} solid ${c.border.subtle}`,
borderRadius: `${c.radius.lg}px`,
p: 2,
transition: c.transition,
'&:hover': {
borderColor: c.border.medium,
boxShadow: c.shadow.md,
transform: 'translateY(-2px)',
},
// No lift on hover: every other surface in this app answers with the border and a hairline shadow, and a card that jumps reads cheap next to them.
'&:hover': { borderColor: c.border.strong, boxShadow: c.shadow.sm },
}}
>
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 1.5 }}>
<Stack direction="row" spacing={1.25} alignItems="center" sx={{ mb: 1.25 }}>
<Box
sx={{
width: 44,
height: 44,
borderRadius: 2,
width: 40,
height: 40,
borderRadius: `${c.radius.md}px`,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: `${c.accent.primary}14`,
bgcolor: c.bg.secondary,
overflow: 'hidden',
}}
>
{listing.icon_url ? (
<Box component="img" src={listing.icon_url} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<ExtensionIcon sx={{ fontSize: 22, color: c.accent.primary }} />
<ExtensionIcon sx={{ fontSize: 20, color: c.text.tertiary }} />
)}
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
sx={{
fontSize: '1rem',
fontWeight: 620,
fontSize: '0.9375rem',
fontWeight: 650,
color: c.text.primary,
letterSpacing: '-0.01em',
letterSpacing: '-0.006em',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
@@ -70,47 +66,53 @@ export default function PackageCard({ listing, onOpen, onTag }: Props) {
>
{listing.title}
</Typography>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted }}>
<Typography sx={{ fontSize: '0.75rem', color: c.text.muted, mt: 0.1 }}>
{KIND_LABELS[listing.kind] || listing.kind || 'Package'}
{listing.version ? ` · v${listing.version}` : ''}
{listing.author ? ` · ${listing.author}` : ''}
</Typography>
</Box>
</Stack>
<Typography
sx={{
fontSize: '0.875rem',
color: c.text.secondary,
lineHeight: 1.5,
mb: 1.75,
fontSize: '0.8438rem',
color: c.text.tertiary,
lineHeight: 1.55,
mb: 1.5,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
// Fixed two lines so every card in a row ends its body at the same baseline.
minHeight: '2.6em',
}}
>
{listing.description || 'No description provided.'}
</Typography>
<Stack direction="row" spacing={0.75} sx={{ mt: 'auto', flexWrap: 'wrap', gap: 0.75 }}>
<Stack direction="row" sx={{ mt: 'auto', flexWrap: 'wrap', gap: 0.75 }}>
{tags.map((t) => (
<Chip
<Box
key={t}
label={`#${t}`}
size="small"
onClick={(e: React.MouseEvent<HTMLDivElement>) => {
role="button"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onTag(t);
}}
sx={{
height: 22,
fontSize: '0.72rem',
bgcolor: c.bg.secondary,
color: c.text.tertiary,
'&:hover': { color: c.text.primary },
px: 0.9,
py: 0.2,
borderRadius: `${c.radius.sm}px`,
border: `${c.border.width} solid ${c.border.subtle}`,
fontSize: '0.7188rem',
color: c.text.muted,
transition: c.transition,
'&:hover': { color: c.text.secondary, borderColor: c.border.medium },
}}
/>
>
{t}
</Box>
))}
</Stack>
</Box>