feat(web): refactor PluginsHomeSection to implement 3-axis faceted filtering

- Replaced the previous tag-based filtering with a new 3-axis model (SURFACE, TYPE, SCENARIO) for enhanced plugin discovery.
- Introduced a new `usePluginFacets` hook to manage the independent selection of facets and their AND-composition.
- Updated the `PluginsHomeSection` component to render facet rows and a Featured chip, improving user interaction and layout.
- Removed legacy categorization logic and associated files, streamlining the codebase.
- Enhanced CSS styles for the new layout and improved visual consistency across the plugins home section.
- Added tests to ensure the correctness of facet extraction and filtering logic.

This update significantly enhances the user experience by providing a more flexible and intuitive way to filter and discover plugins.
This commit is contained in:
pftom 2026-05-12 15:29:59 +08:00
parent c12c816a44
commit 6cccccdc56
9 changed files with 946 additions and 625 deletions

View file

@ -1,28 +1,28 @@
// Plugins discovery section on Home.
//
// Renders a single horizontal pill row driven by *plugin tags* rather
// than a hard-coded "Image / Video / Examples / Other" taxonomy. Each
// plugin's `od.scenario / od.mode / od.surface / od.taskKind / tags`
// fields feed `pluginTags()` (`./plugins-home/scenarioTags.ts`), which
// produces normalised slugs; the section ranks them by count and
// surfaces the top N as filter pills with a "More" overflow expansion.
// Renders a 3-axis faceted filter (SURFACE / TYPE / SCENARIO) over
// the bundled plugin catalog. Each axis is independent and the
// selections compose via AND, so users dial in scope one dimension at
// a time. A small Featured chip sits orthogonal to the facet rows for
// quick access to curator-promoted picks.
//
// "Featured" lives at the front of the row when at least one plugin
// declares `od.featured: true` (otherwise the first scenario plugin
// stands in so the slot stays populated). It is selected by default.
//
// Categorisation, featured-derivation and filtering are factored out
// to `./plugins-home/usePluginCategories.ts` so this file can stay
// focused on layout and so unit tests can drive the hook in isolation.
// Derivation, catalog building and AND-composition live in
// `./plugins-home/facets.ts`; per-axis selection state and the
// Featured override live in `./plugins-home/usePluginFacets.ts`. This
// file owns layout only.
import type { InstalledPluginRecord } from '@open-design/contracts';
import { Icon } from './Icon';
import { PluginCard } from './plugins-home/PluginCard';
import {
usePluginCategories,
type PluginFilterKey,
} from './plugins-home/usePluginCategories';
import type { ScenarioTag } from './plugins-home/scenarioTags';
usePluginFacets,
type FilterMode,
} from './plugins-home/usePluginFacets';
import type {
FacetAxis,
FacetOption,
FacetSelection,
} from './plugins-home/facets';
interface Props {
plugins: InstalledPluginRecord[];
@ -45,168 +45,251 @@ export function PluginsHomeSection({
visiblePlugins,
featuredList,
filtered,
filter,
setFilter,
visibleTags,
overflowTags,
showOverflow,
toggleOverflow,
catalog,
selection,
pickFacet,
clearFacets,
hasActiveFacet,
mode,
setMode,
totalVisible,
} = usePluginCategories({ plugins });
} = usePluginFacets({ plugins });
return (
<section className="plugins-home" data-testid="plugins-home-section">
<header className="plugins-home__head">
<h2 className="plugins-home__title">Plugins</h2>
<div className="plugins-home__heading">
<h2 className="plugins-home__title">Plugins</h2>
<p className="plugins-home__subtitle">
Built-in catalog pick one to load a starter prompt, or type freely above.
</p>
</div>
<span className="plugins-home__count">
{loading ? '…' : `${totalVisible} installed`}
{loading ? '…' : `${filtered.length} of ${totalVisible}`}
</span>
</header>
{loading ? (
<div className="plugins-home__empty">Loading plugins</div>
<div className="plugins-home__empty">Loading catalog</div>
) : visiblePlugins.length === 0 ? (
<div className="plugins-home__empty">
No plugins installed. Install one with{' '}
<code>od plugin install &lt;source&gt;</code>.
Catalog is empty. Bundled plugins ship with Open Design and should appear
here automatically try restarting the daemon if this persists.
</div>
) : (
<>
<FilterRow
filter={filter}
<ModeRow
mode={mode}
featuredCount={featuredList.length}
totalVisible={totalVisible}
visibleTags={visibleTags}
overflowTags={overflowTags}
showOverflow={showOverflow}
onPick={setFilter}
onToggleOverflow={toggleOverflow}
hasActiveFacet={hasActiveFacet}
onModeChange={setMode}
onClearFacets={clearFacets}
/>
<FacetTable
catalog={catalog}
selection={selection}
totalVisible={totalVisible}
onPick={pickFacet}
/>
<div className="plugins-home__grid" role="list">
{filtered.map((p) => (
<PluginCard
key={p.id}
record={p}
isActive={activePluginId === p.id}
isPending={pendingApplyId === p.id}
pendingAny={pendingApplyId !== null}
isFeatured={featuredList.some((f) => f.id === p.id)}
onUse={onUse}
onOpenDetails={onOpenDetails}
/>
))}
</div>
{filtered.length === 0 ? (
<div className="plugins-home__empty plugins-home__empty--filtered">
No plugins match the current filters.{' '}
<button
type="button"
className="plugins-home__linkbtn"
onClick={clearFacets}
>
Clear filters
</button>
</div>
) : (
<div className="plugins-home__grid" role="list">
{filtered.map((p) => (
<PluginCard
key={p.id}
record={p}
isActive={activePluginId === p.id}
isPending={pendingApplyId === p.id}
pendingAny={pendingApplyId !== null}
isFeatured={featuredList.some((f) => f.id === p.id)}
onUse={onUse}
onOpenDetails={onOpenDetails}
/>
))}
</div>
)}
</>
)}
</section>
);
}
interface FilterRowProps {
filter: PluginFilterKey;
interface ModeRowProps {
mode: FilterMode;
featuredCount: number;
totalVisible: number;
visibleTags: ScenarioTag[];
overflowTags: ScenarioTag[];
showOverflow: boolean;
onPick: (key: PluginFilterKey) => void;
onToggleOverflow: () => void;
hasActiveFacet: boolean;
onModeChange: (next: FilterMode) => void;
onClearFacets: () => void;
}
function FilterRow({
filter,
// Tiny strip above the facet table: Featured override + a clear-link
// when at least one facet is active. Kept compact so the SURFACE row
// is what the eye lands on first.
function ModeRow({
mode,
featuredCount,
totalVisible,
visibleTags,
overflowTags,
showOverflow,
onPick,
onToggleOverflow,
}: FilterRowProps) {
// Don't show the row at all when there is nothing meaningful to filter
// (only "All" available + no scenario tags). One pill in isolation is
// visual noise; the original behaviour matches this.
if (featuredCount === 0 && visibleTags.length === 0) return null;
const tagsToRender = showOverflow ? [...visibleTags, ...overflowTags] : visibleTags;
hasActiveFacet,
onModeChange,
onClearFacets,
}: ModeRowProps) {
return (
<div className="plugins-home__filters" role="tablist" aria-label="Plugin categories">
<div className="plugins-home__mode" role="group" aria-label="Plugin mode">
{featuredCount > 0 ? (
<FilterPill
slug="featured"
label="Featured"
count={featuredCount}
active={filter === 'featured'}
variant="featured"
onPick={onPick}
/>
) : null}
<FilterPill
slug="all"
label="All"
count={totalVisible}
active={filter === 'all'}
onPick={onPick}
/>
{tagsToRender.map((tag) => (
<FilterPill
key={tag.slug}
slug={tag.slug}
label={tag.label}
count={tag.count}
active={filter === tag.slug}
onPick={onPick}
/>
))}
{overflowTags.length > 0 ? (
<button
type="button"
className="plugins-home__filter plugins-home__filter--more"
onClick={onToggleOverflow}
data-testid="plugins-home-filter-more"
aria-expanded={showOverflow}
className={[
'plugins-home__chip',
'plugins-home__chip--featured',
mode === 'featured' ? 'is-active' : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => onModeChange(mode === 'featured' ? 'all' : 'featured')}
aria-pressed={mode === 'featured'}
data-testid="plugins-home-chip-featured"
>
<span>{showOverflow ? 'Less' : 'More'}</span>
<span className="plugins-home__filter-count">
{showOverflow ? '' : `+${overflowTags.length}`}
</span>
<Icon name="star" size={11} />
<span>Featured</span>
<span className="plugins-home__chip-count">{featuredCount}</span>
</button>
) : null}
<span className="plugins-home__mode-total">
{totalVisible} in catalog
</span>
{hasActiveFacet ? (
<button
type="button"
className="plugins-home__linkbtn"
onClick={onClearFacets}
data-testid="plugins-home-clear"
>
Clear filters
</button>
) : null}
</div>
);
}
interface FilterPillProps {
slug: PluginFilterKey;
interface FacetTableProps {
catalog: ReturnType<typeof usePluginFacets>['catalog'];
selection: FacetSelection;
totalVisible: number;
onPick: (axis: FacetAxis, slug: string | null) => void;
}
function FacetTable({ catalog, selection, totalVisible, onPick }: FacetTableProps) {
return (
<div className="plugins-home__facets" role="group" aria-label="Plugin filters">
<FacetRow
axis="surface"
label="Surface"
options={catalog.surface}
selectedSlug={selection.surface}
totalVisible={totalVisible}
onPick={onPick}
/>
<FacetRow
axis="type"
label="Type"
options={catalog.type}
selectedSlug={selection.type}
totalVisible={totalVisible}
onPick={onPick}
/>
<FacetRow
axis="scenario"
label="Scenario"
options={catalog.scenario}
selectedSlug={selection.scenario}
totalVisible={totalVisible}
onPick={onPick}
/>
</div>
);
}
interface FacetRowProps {
axis: FacetAxis;
label: string;
options: FacetOption[];
selectedSlug: string | null;
totalVisible: number;
onPick: (axis: FacetAxis, slug: string | null) => void;
}
function FacetRow({ axis, label, options, selectedSlug, totalVisible, onPick }: FacetRowProps) {
if (options.length === 0) return null;
return (
<div className="plugins-home__facet-row" data-testid={`plugins-home-row-${axis}`}>
<span className="plugins-home__facet-label">{label}</span>
<div className="plugins-home__facet-pills" role="tablist" aria-label={`${label} filter`}>
<FacetPill
slug={null}
label="All"
count={totalVisible}
active={selectedSlug === null}
onPick={(slug) => onPick(axis, slug)}
axis={axis}
variant="all"
/>
{options.map((opt) => (
<FacetPill
key={opt.slug}
slug={opt.slug}
label={opt.label}
count={opt.count}
active={selectedSlug === opt.slug}
onPick={(slug) => onPick(axis, slug)}
axis={axis}
/>
))}
</div>
</div>
);
}
interface FacetPillProps {
axis: FacetAxis;
slug: string | null;
label: string;
count: number;
active: boolean;
variant?: 'featured';
onPick: (key: PluginFilterKey) => void;
variant?: 'all';
onPick: (slug: string | null) => void;
}
function FilterPill({ slug, label, count, active, variant, onPick }: FilterPillProps) {
const isFeatured = variant === 'featured';
function FacetPill({ axis, slug, label, count, active, variant, onPick }: FacetPillProps) {
return (
<button
type="button"
role="tab"
aria-selected={active}
className={[
'plugins-home__filter',
'plugins-home__pill',
active ? 'is-active' : '',
isFeatured ? 'plugins-home__filter--featured' : '',
variant === 'all' ? 'plugins-home__pill--all' : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => onPick(slug)}
data-testid={`plugins-home-filter-${slug}`}
data-testid={`plugins-home-pill-${axis}-${slug ?? 'all'}`}
>
{isFeatured ? (
<Icon name="star" size={11} className="plugins-home__filter-icon" />
) : null}
<span>{label}</span>
<span className="plugins-home__filter-count">{count}</span>
<span className="plugins-home__pill-count">{count}</span>
</button>
);
}

View file

@ -0,0 +1,291 @@
// Facet derivation for the Plugins home section.
//
// The home filter row is a 3-axis faceted control modeled on the
// surface / type / scenario taxonomy plugin authors already populate
// in `open-design.json`:
//
// - SURFACE ← od.surface (web / image / video / audio)
// - TYPE ← od.mode (design-system / deck / prototype / …)
// - SCENARIO ← od.scenario (+ a tag whitelist for legacy plugins
// that omit the field)
//
// Centralising the derivation here keeps the categorisation hook pure
// and lets tests assert facet membership without touching React. The
// hook then composes selections across the three axes via AND.
//
// Notes for future maintainers:
// - All axis values are `slugify`d so any fragment of free-form text
// ("Design System", "design_system", "design-system") collapses to
// a single bucket — both at derivation time AND when comparing
// selections.
// - Returning a fixed object shape (instead of `Record<string, …>`)
// keeps the React render path branch-free and mirrors the three
// pill rows the section paints 1:1.
// - Counts in each row reflect the catalog *as a whole*, not the
// post-filter slice. We deliberately avoid recomputing counts
// after a selection because per-axis counts that "go to zero" as
// the user clicks make the row visually noisy and obscure how the
// overall catalog is shaped.
import type { InstalledPluginRecord } from '@open-design/contracts';
export type FacetAxis = 'surface' | 'type' | 'scenario';
export interface PluginFacets {
surface: string[];
type: string[];
scenario: string[];
}
export interface FacetOption {
slug: string;
label: string;
count: number;
}
export interface FacetCatalog {
surface: FacetOption[];
type: FacetOption[];
scenario: FacetOption[];
}
// Tags that bubble up from low-level plugin metadata and never make
// for useful filter chips. Drop them at derivation time so they don't
// pollute the SCENARIO row (which falls back to tags when od.scenario
// is missing).
const NOISE = new Set<string>([
'first-party',
'third-party',
'phase-7',
'phase-1',
'atom',
'bundle',
'scenario',
'plugin',
'untitled',
]);
// Curated SCENARIO vocabulary — a focused set of business / use-case
// tags so the SCENARIO row reads as a domain picker rather than a
// free-form tag cloud. Anything outside this set still surfaces via
// `od.scenario` (the manifest field) which is authored intentionally.
const SCENARIO_TAG_WHITELIST = new Set<string>([
'engineering',
'product',
'design',
'marketing',
'sales',
'finance',
'hr',
'operations',
'education',
'personal',
'general',
'creator',
'healthcare',
'planning',
'legal',
'support',
'developer-tools',
'e-commerce-retail',
'media-consumer',
'productivity-saas',
'creative-artistic',
'professional-corporate',
'design-creative',
'ai-llm',
'social-media-post',
'live',
'live-artifacts',
'orbit',
]);
const SURFACE_LABELS: Record<string, string> = {
web: 'Web',
image: 'Image',
video: 'Video',
audio: 'Audio',
};
const TYPE_LABELS: Record<string, string> = {
'design-system': 'Design system',
deck: 'Deck',
prototype: 'Prototype',
template: 'Template',
example: 'Example',
utility: 'Utility',
image: 'Image',
video: 'Video',
audio: 'Audio',
scenario: 'Scenario',
};
const SCENARIO_LABELS: Record<string, string> = {
engineering: 'Engineering',
product: 'Product',
design: 'Design',
marketing: 'Marketing',
sales: 'Sales',
finance: 'Finance',
hr: 'HR',
operations: 'Operations',
education: 'Education',
personal: 'Personal',
general: 'General',
creator: 'Creator',
healthcare: 'Healthcare',
planning: 'Planning',
legal: 'Legal',
support: 'Support',
'developer-tools': 'Developer tools',
'e-commerce-retail': 'E-commerce',
'media-consumer': 'Media & consumer',
'productivity-saas': 'Productivity',
'creative-artistic': 'Creative',
'professional-corporate': 'Corporate',
'design-creative': 'Design & creative',
'ai-llm': 'AI & LLM',
'social-media-post': 'Social',
live: 'Live',
'live-artifacts': 'Live artifact',
orbit: 'Orbit',
};
function slugify(value: string): string {
return value
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)+/g, '');
}
function humanise(slug: string): string {
return slug
.split('-')
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(' ');
}
function manifestField(record: InstalledPluginRecord, key: string): string | undefined {
const od = (record.manifest?.od ?? {}) as Record<string, unknown>;
const v = od[key];
return typeof v === 'string' ? v : undefined;
}
function manifestTags(record: InstalledPluginRecord): string[] {
const raw = record.manifest?.tags ?? [];
return raw
.map((t) => slugify(String(t)))
.filter((t) => t && !NOISE.has(t));
}
// Per-plugin facet derivation. The result lists each axis as a
// possibly-empty array because:
// - SURFACE may be missing on legacy manifests (we fall back to
// scanning tags for one of the four known surface words).
// - TYPE is single-valued in our manifests but represented as an
// array so the filter compose path can stay uniform.
// - SCENARIO can be multi-valued: `od.scenario` plus any tag in
// SCENARIO_TAG_WHITELIST.
export function extractFacets(record: InstalledPluginRecord): PluginFacets {
const surface = new Set<string>();
const type = new Set<string>();
const scenario = new Set<string>();
const surfaceRaw = manifestField(record, 'surface');
if (surfaceRaw) {
const s = slugify(surfaceRaw);
if (SURFACE_LABELS[s]) surface.add(s);
}
const tags = manifestTags(record);
if (surface.size === 0) {
for (const t of tags) {
if (SURFACE_LABELS[t]) {
surface.add(t);
break;
}
}
}
const modeRaw = manifestField(record, 'mode');
if (modeRaw) {
const m = slugify(modeRaw);
if (m && !NOISE.has(m)) type.add(m);
}
const scenarioRaw = manifestField(record, 'scenario');
if (scenarioRaw) {
const s = slugify(scenarioRaw);
if (s && !NOISE.has(s)) scenario.add(s);
}
for (const t of tags) {
if (SCENARIO_TAG_WHITELIST.has(t)) scenario.add(t);
}
return {
surface: [...surface],
type: [...type],
scenario: [...scenario],
};
}
function labelFor(axis: FacetAxis, slug: string): string {
if (axis === 'surface') return SURFACE_LABELS[slug] ?? humanise(slug);
if (axis === 'type') return TYPE_LABELS[slug] ?? humanise(slug);
return SCENARIO_LABELS[slug] ?? humanise(slug);
}
function buildAxis(
axis: FacetAxis,
plugins: InstalledPluginRecord[],
): FacetOption[] {
const counts = new Map<string, number>();
for (const p of plugins) {
const facets = extractFacets(p);
for (const slug of facets[axis]) {
counts.set(slug, (counts.get(slug) ?? 0) + 1);
}
}
return [...counts.entries()]
.sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]))
.map(([slug, count]) => ({ slug, label: labelFor(axis, slug), count }));
}
export function buildFacetCatalog(plugins: InstalledPluginRecord[]): FacetCatalog {
return {
surface: buildAxis('surface', plugins),
type: buildAxis('type', plugins),
scenario: buildAxis('scenario', plugins),
};
}
// Filter a plugin set by an active selection in each axis. `null` in
// any axis means "no filter on this axis" (the axis row's "All" pill).
// Filters compose via AND across axes.
export interface FacetSelection {
surface: string | null;
type: string | null;
scenario: string | null;
}
export function applyFacetSelection(
plugins: InstalledPluginRecord[],
selection: FacetSelection,
): InstalledPluginRecord[] {
if (!selection.surface && !selection.type && !selection.scenario) return plugins;
return plugins.filter((p) => {
const facets = extractFacets(p);
if (selection.surface && !facets.surface.includes(selection.surface)) return false;
if (selection.type && !facets.type.includes(selection.type)) return false;
if (selection.scenario && !facets.scenario.includes(selection.scenario)) return false;
return true;
});
}
export function isFeaturedPlugin(record: InstalledPluginRecord): boolean {
const od = (record.manifest?.od ?? {}) as Record<string, unknown>;
return od.featured === true;
}

View file

@ -1,182 +0,0 @@
// Tag derivation + labelling for the Plugins home section.
//
// The home filter row is scenario-driven (per the migrate-to-plugins
// design): we no longer hard-code 4 buckets (Image / Video / Examples
// / Design). Instead every plugin emits a normalised set of tags
// (kebab-case slugs) and the section ranks tags by occurrence,
// surfacing the top N as pills with a "More" overflow.
//
// Centralising the derivation here lets the categorisation hook stay
// pure and lets tests assert tag membership without touching React.
import type { InstalledPluginRecord } from '@open-design/contracts';
// Slugs the user only ever sees because they bubble up from low-level
// plugin metadata. They never make for useful filter chips on the home
// page, so we drop them at derivation time. Keep the list small so an
// unexpected tag still reaches the UI for surfacing.
const NOISE_TAGS = new Set([
'first-party',
'third-party',
'phase-7',
'phase-1',
'atom',
'bundle',
'scenario',
'plugin',
]);
// Pretty labels for known scenario slugs. The renderer falls back to
// title-casing the slug for anything missing here, so this map only
// needs to cover the cases where humanise() would produce something
// awkward ("E-Commerce-Retail" instead of "E-commerce & retail").
const TAG_LABELS: Record<string, string> = {
image: 'Image',
video: 'Video',
audio: 'Audio',
'image-template': 'Image template',
'video-template': 'Video template',
example: 'Example',
'design-system': 'Design system',
workflow: 'Workflow',
marketing: 'Marketing',
dashboard: 'Dashboard',
landing: 'Landing',
prototype: 'Prototype',
mobile: 'Mobile',
desktop: 'Desktop',
web: 'Web',
design: 'Design',
engineering: 'Engineering',
product: 'Product',
sales: 'Sales',
finance: 'Finance',
hr: 'HR',
operations: 'Operations',
support: 'Support',
'e-commerce-retail': 'E-commerce',
'developer-tools': 'Developer tools',
'new-generation': 'New generation',
'code-migration': 'Code migration',
'figma-migration': 'Figma migration',
'tune-collab': 'Tune & collab',
};
function slugify(value: string): string {
return value
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)+/g, '');
}
export function labelForTag(slug: string): string {
const known = TAG_LABELS[slug];
if (known) return known;
return slug
.split('-')
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(' ');
}
// Tags the user is most likely to recognise as a "scenario" pill. They
// get a stable position at the start of the pill row regardless of
// raw frequency, so the row never reshuffles when a new plugin lands.
const PINNED_ORDER = [
'workflow',
'example',
'image',
'video',
'design-system',
];
interface ManifestExtras {
featured?: boolean;
surface?: string;
}
function manifestExtras(record: InstalledPluginRecord): ManifestExtras {
const od = (record.manifest?.od ?? {}) as Record<string, unknown>;
return {
featured: od.featured === true,
surface: typeof od.surface === 'string' ? (od.surface as string) : undefined,
};
}
// Derive the set of normalised scenario tags for a single plugin. Two
// invariants the consumer can rely on:
//
// 1. Every multi-stage scenario plugin gets a synthetic `workflow`
// tag so the user can filter scripted pipelines as a group.
// 2. `surface` and `mode` are added when present so type-style
// categorisation (image / video / design-system) still works
// without the renderer hard-coding any of them.
export function pluginTags(record: InstalledPluginRecord): string[] {
const od = (record.manifest?.od ?? {}) as Record<string, unknown>;
const raw: Array<string | undefined> = [];
for (const t of record.manifest?.tags ?? []) raw.push(String(t));
if (typeof od.scenario === 'string') raw.push(od.scenario);
if (typeof od.mode === 'string') raw.push(od.mode);
const surface = manifestExtras(record).surface;
if (surface) raw.push(surface);
if (typeof od.taskKind === 'string') raw.push(od.taskKind);
if (typeof od.platform === 'string') raw.push(od.platform);
const pipelineStages =
od.pipeline && typeof od.pipeline === 'object'
? (od.pipeline as { stages?: unknown[] }).stages
: undefined;
if (Array.isArray(pipelineStages) && pipelineStages.length > 1) {
raw.push('workflow');
}
const seen = new Set<string>();
const out: string[] = [];
for (const t of raw) {
if (!t) continue;
const slug = slugify(t);
if (!slug || NOISE_TAGS.has(slug)) continue;
if (seen.has(slug)) continue;
seen.add(slug);
out.push(slug);
}
return out;
}
export function isFeaturedPlugin(record: InstalledPluginRecord): boolean {
return manifestExtras(record).featured === true;
}
export interface ScenarioTag {
slug: string;
label: string;
count: number;
}
// Sort tags by frequency, with PINNED_ORDER tags floated to the top
// (still hidden when they have zero matches). Returns the catalog the
// pill row renders verbatim.
export function buildTagCatalog(plugins: InstalledPluginRecord[]): ScenarioTag[] {
const counts = new Map<string, number>();
for (const plugin of plugins) {
for (const slug of pluginTags(plugin)) {
counts.set(slug, (counts.get(slug) ?? 0) + 1);
}
}
const pinned = PINNED_ORDER.flatMap((slug) => {
const count = counts.get(slug);
if (!count) return [];
return [{ slug, label: labelForTag(slug), count }];
});
const pinnedSlugs = new Set(PINNED_ORDER);
const rest = Array.from(counts.entries())
.filter(([slug]) => !pinnedSlugs.has(slug))
.sort((a, b) => {
if (a[1] !== b[1]) return b[1] - a[1];
return a[0].localeCompare(b[0]);
})
.map(([slug, count]) => ({ slug, label: labelForTag(slug), count }));
return [...pinned, ...rest];
}

View file

@ -1,112 +0,0 @@
// Pure categorisation hook for the Plugins home section.
//
// Encapsulates the "what tags exist, who's featured, which subset is
// active" computation so the section component can stay focused on
// rendering. Returning derived state from one place also makes the
// behaviour straightforward to unit-test (see
// tests/components/plugins-home-categories.test.ts).
import { useMemo, useState } from 'react';
import type { InstalledPluginRecord } from '@open-design/contracts';
import { buildTagCatalog, isFeaturedPlugin, pluginTags, type ScenarioTag } from './scenarioTags';
// Synthetic filter keys for the always-present pills at the start of
// the row. Real tag slugs follow these in the pill array; the union
// type below lets the component compare-and-render with one variable.
export type PluginFilterKey = 'featured' | 'all' | string;
// How many scenario tags we show as standalone pills before collapsing
// the remainder into a "More" expansion. Sized so the row stays on
// one line for typical (~6 type tags + a handful of scenario tags)
// catalogs while leaving an obvious overflow signal when the catalog
// grows past it.
const DEFAULT_VISIBLE_TAGS = 8;
interface UsePluginCategoriesArgs {
plugins: InstalledPluginRecord[];
visibleTagLimit?: number;
}
export interface UsePluginCategoriesResult {
visiblePlugins: InstalledPluginRecord[];
featuredList: InstalledPluginRecord[];
filtered: InstalledPluginRecord[];
filter: PluginFilterKey;
setFilter: (next: PluginFilterKey | null) => void;
visibleTags: ScenarioTag[];
overflowTags: ScenarioTag[];
showOverflow: boolean;
toggleOverflow: () => void;
totalVisible: number;
}
export function usePluginCategories(
args: UsePluginCategoriesArgs,
): UsePluginCategoriesResult {
const [picked, setPicked] = useState<PluginFilterKey | null>(null);
const [showOverflow, setShowOverflow] = useState(false);
const limit = args.visibleTagLimit ?? DEFAULT_VISIBLE_TAGS;
// Atoms are infrastructure pieces (`code-import`, `patch-edit`) that
// are not user-facing on the home grid; the original section already
// filtered them out and we preserve that contract.
const visiblePlugins = useMemo(
() => args.plugins.filter((p) => p.manifest?.od?.kind !== 'atom'),
[args.plugins],
);
const featuredList = useMemo(() => {
const explicit = visiblePlugins.filter(isFeaturedPlugin);
if (explicit.length > 0) return explicit;
const scenario = visiblePlugins.find((p) => p.manifest?.od?.kind === 'scenario');
return scenario ? [scenario] : [];
}, [visiblePlugins]);
const tagCatalog = useMemo(() => buildTagCatalog(visiblePlugins), [visiblePlugins]);
const visibleTags = useMemo(() => tagCatalog.slice(0, limit), [tagCatalog, limit]);
const overflowTags = useMemo(() => tagCatalog.slice(limit), [tagCatalog, limit]);
// Default to Featured (if any), else "All". The user's explicit
// pick wins as long as it still exists in the catalog after a data
// refresh — picking a slug that vanished simply snaps back to the
// default rather than rendering an empty grid.
const availableFilterKeys = useMemo(() => {
const keys = new Set<PluginFilterKey>(['all']);
if (featuredList.length > 0) keys.add('featured');
for (const tag of tagCatalog) keys.add(tag.slug);
return keys;
}, [tagCatalog, featuredList.length]);
const filter: PluginFilterKey =
picked && availableFilterKeys.has(picked)
? picked
: featuredList.length > 0
? 'featured'
: 'all';
const filtered = useMemo(() => {
if (filter === 'featured') return featuredList;
if (filter === 'all') return visiblePlugins;
return visiblePlugins.filter((p) => pluginTags(p).includes(filter));
}, [filter, featuredList, visiblePlugins]);
function setFilter(next: PluginFilterKey | null): void {
setPicked(next);
}
function toggleOverflow(): void {
setShowOverflow((v) => !v);
}
return {
visiblePlugins,
featuredList,
filtered,
filter,
setFilter,
visibleTags,
overflowTags,
showOverflow,
toggleOverflow,
totalVisible: visiblePlugins.length,
};
}

View file

@ -0,0 +1,106 @@
// Faceted categorisation hook for the Plugins home section.
//
// Replaces the older single-row scenario-tag hook with a 3-axis
// SURFACE / TYPE / SCENARIO model. Filters compose via AND across
// axes (selecting Web + Slides + Marketing shows only plugins that
// match all three). Each axis selection is independent so the user
// can dial scope in / out one dimension at a time.
//
// A small "Featured" toggle sits orthogonally to the facets — when
// active it overrides the facet selection and just shows the
// curator-promoted plugins. We intentionally make Featured override
// rather than AND-compose so a featured pick is never accidentally
// hidden behind a still-selected facet pill.
import { useMemo, useState } from 'react';
import type { InstalledPluginRecord } from '@open-design/contracts';
import {
applyFacetSelection,
buildFacetCatalog,
isFeaturedPlugin,
type FacetAxis,
type FacetCatalog,
type FacetSelection,
} from './facets';
export type FilterMode = 'all' | 'featured';
interface UsePluginFacetsArgs {
plugins: InstalledPluginRecord[];
}
export interface UsePluginFacetsResult {
visiblePlugins: InstalledPluginRecord[];
featuredList: InstalledPluginRecord[];
filtered: InstalledPluginRecord[];
catalog: FacetCatalog;
selection: FacetSelection;
pickFacet: (axis: FacetAxis, slug: string | null) => void;
clearFacets: () => void;
hasActiveFacet: boolean;
mode: FilterMode;
setMode: (next: FilterMode) => void;
totalVisible: number;
}
const EMPTY_SELECTION: FacetSelection = {
surface: null,
type: null,
scenario: null,
};
export function usePluginFacets(args: UsePluginFacetsArgs): UsePluginFacetsResult {
const [mode, setMode] = useState<FilterMode>('all');
const [selection, setSelection] = useState<FacetSelection>(EMPTY_SELECTION);
// Atoms are infrastructure pieces (`code-import`, `patch-edit`) that
// are not user-facing on the home grid; the original section already
// filtered them out and we preserve that contract.
const visiblePlugins = useMemo(
() => args.plugins.filter((p) => p.manifest?.od?.kind !== 'atom'),
[args.plugins],
);
const featuredList = useMemo(
() => visiblePlugins.filter(isFeaturedPlugin),
[visiblePlugins],
);
const catalog = useMemo(() => buildFacetCatalog(visiblePlugins), [visiblePlugins]);
const filtered = useMemo(() => {
if (mode === 'featured') return featuredList;
return applyFacetSelection(visiblePlugins, selection);
}, [mode, featuredList, visiblePlugins, selection]);
function pickFacet(axis: FacetAxis, slug: string | null): void {
if (mode === 'featured') setMode('all');
setSelection((prev) => ({
...prev,
[axis]: prev[axis] === slug ? null : slug,
}));
}
function clearFacets(): void {
setSelection(EMPTY_SELECTION);
}
const hasActiveFacet =
selection.surface !== null ||
selection.type !== null ||
selection.scenario !== null;
return {
visiblePlugins,
featuredList,
filtered,
catalog,
selection,
pickFacet,
clearFacets,
hasActiveFacet,
mode,
setMode,
totalVisible: visiblePlugins.length,
};
}

View file

@ -10,9 +10,21 @@
}
.plugins-home__head {
display: flex;
align-items: baseline;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
gap: 16px;
}
.plugins-home__heading {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.plugins-home__subtitle {
margin: 0;
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
}
.plugins-home__title {
margin: 0;
@ -42,78 +54,167 @@
border-radius: 3px;
}
/* Category filter pills */
.plugins-home__filters {
/* ============================================================
Faceted filter three rows (Surface / Type / Scenario) with a
leading axis label on the left and pills wrapping on the right.
The Featured chip lives in a smaller mode strip above the rows so
it reads as orthogonal to the facet selections it overrides.
============================================================ */
.plugins-home__mode {
display: flex;
gap: 6px;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding-bottom: 2px;
}
.plugins-home__filter {
.plugins-home__mode-total {
font-size: 11.5px;
color: var(--text-faint);
font-variant-numeric: tabular-nums;
}
.plugins-home__chip {
appearance: none;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
gap: 5px;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg-panel);
color: var(--text-muted);
font-size: 12.5px;
font-size: 12px;
cursor: pointer;
transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
}
.plugins-home__filter:hover {
.plugins-home__chip:hover {
border-color: var(--border-strong);
color: var(--text);
}
.plugins-home__filter.is-active {
background: var(--text);
border-color: var(--text);
color: var(--bg-panel);
}
.plugins-home__filter.is-active .plugins-home__filter-count {
color: var(--bg-panel);
opacity: 0.8;
}
.plugins-home__filter-count {
.plugins-home__chip-count {
font-size: 10.5px;
color: var(--text-faint);
font-variant-numeric: tabular-nums;
}
.plugins-home__filter-icon {
flex: 0 0 auto;
margin-right: -1px;
}
/* Featured pill — stands out in the row to flag the curated entry. */
.plugins-home__filter--featured {
.plugins-home__chip--featured {
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
color: var(--accent);
background: var(--accent-tint);
}
.plugins-home__filter--featured:hover {
border-color: var(--accent);
color: var(--accent);
}
.plugins-home__filter--featured.is-active {
.plugins-home__chip--featured.is-active {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.plugins-home__filter--featured.is-active .plugins-home__filter-count {
.plugins-home__chip--featured.is-active .plugins-home__chip-count {
color: white;
opacity: 0.85;
}
/* "More" pill toggles the overflow scenario tags into the pill row
inline. Styled to read as a meta control (dashed border, muted) so
it never competes with a real category for the user's attention. */
.plugins-home__filter--more {
border-style: dashed;
color: var(--text-faint);
.plugins-home__linkbtn {
appearance: none;
background: none;
border: 0;
padding: 0;
margin: 0;
font-size: 11.5px;
color: var(--text-muted);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.plugins-home__filter--more:hover {
.plugins-home__linkbtn:hover {
color: var(--text);
}
.plugins-home__facets {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-subtle);
}
.plugins-home__facet-row {
display: grid;
grid-template-columns: 80px 1fr;
align-items: start;
gap: 12px;
}
.plugins-home__facet-label {
padding-top: 7px;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
font-variant-numeric: tabular-nums;
}
.plugins-home__facet-pills {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.plugins-home__pill {
appearance: none;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg-panel);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
}
.plugins-home__pill:hover {
border-color: var(--border-strong);
color: var(--text);
}
.plugins-home__pill.is-active {
background: var(--text);
border-color: var(--text);
color: var(--bg-panel);
}
.plugins-home__pill.is-active .plugins-home__pill-count {
color: var(--bg-panel);
opacity: 0.8;
}
.plugins-home__pill-count {
font-size: 10.5px;
color: var(--text-faint);
font-variant-numeric: tabular-nums;
}
.plugins-home__pill--all {
background: var(--accent-tint);
border-color: color-mix(in srgb, var(--accent) 30%, var(--border));
color: var(--accent);
}
.plugins-home__pill--all.is-active {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.plugins-home__pill--all.is-active .plugins-home__pill-count {
color: white;
opacity: 0.85;
}
.plugins-home__empty--filtered {
border-style: solid;
background: var(--bg-subtle);
}
@media (max-width: 720px) {
.plugins-home__facet-row {
grid-template-columns: 1fr;
gap: 4px;
}
.plugins-home__facet-label {
padding-top: 0;
}
}
/* Card grid */

View file

@ -0,0 +1,152 @@
// Facet derivation contract for the plugins-home filter rows. The
// home section is now driven by a 3-axis SURFACE / TYPE / SCENARIO
// model rather than a single tag row; these tests lock the per-axis
// extraction + AND composition so the manifest fields the catalog
// depends on don't silently drift.
import { describe, expect, it } from 'vitest';
import type { InstalledPluginRecord } from '@open-design/contracts';
import {
applyFacetSelection,
buildFacetCatalog,
extractFacets,
isFeaturedPlugin,
} from '../../src/components/plugins-home/facets';
function fixture(overrides: {
id: string;
title?: string;
tags?: string[];
od?: Record<string, unknown>;
}): InstalledPluginRecord {
return {
id: overrides.id,
title: overrides.title ?? overrides.id,
version: '0.1.0',
sourceKind: 'bundled',
source: '/tmp',
trust: 'bundled',
capabilitiesGranted: ['prompt:inject'],
manifest: {
name: overrides.id,
version: '0.1.0',
...(overrides.tags ? { tags: overrides.tags } : {}),
...(overrides.od ? { od: overrides.od } : {}),
},
fsPath: '/tmp',
installedAt: 0,
updatedAt: 0,
};
}
describe('extractFacets', () => {
it('reads SURFACE from od.surface', () => {
const f = extractFacets(
fixture({ id: 'a', od: { surface: 'web', mode: 'design-system', scenario: 'design' } }),
);
expect(f.surface).toEqual(['web']);
});
it('falls back to a known surface in tags when od.surface is missing', () => {
const f = extractFacets(
fixture({ id: 'a', tags: ['marketing', 'image', 'untitled'], od: { mode: 'image' } }),
);
expect(f.surface).toEqual(['image']);
});
it('reads TYPE from od.mode and SCENARIO from od.scenario', () => {
const f = extractFacets(
fixture({ id: 'a', od: { surface: 'web', mode: 'design-system', scenario: 'design' } }),
);
expect(f.type).toEqual(['design-system']);
expect(f.scenario).toEqual(['design']);
});
it('augments SCENARIO with whitelisted role tags', () => {
const f = extractFacets(
fixture({
id: 'a',
tags: ['marketing', 'engineering', 'random-noise'],
od: { surface: 'web', mode: 'prototype', scenario: 'product' },
}),
);
expect(f.scenario).toEqual(expect.arrayContaining(['product', 'marketing', 'engineering']));
expect(f.scenario).not.toContain('random-noise');
});
it('drops noise tags from the SCENARIO axis', () => {
const f = extractFacets(
fixture({
id: 'a',
tags: ['first-party', 'phase-7', 'untitled', 'marketing'],
od: { surface: 'web', mode: 'deck' },
}),
);
expect(f.scenario).toEqual(['marketing']);
});
});
describe('buildFacetCatalog', () => {
it('produces three axes sorted by count desc with stable secondary order', () => {
const plugins = [
fixture({ id: 'a', od: { surface: 'web', mode: 'design-system', scenario: 'design' } }),
fixture({ id: 'b', od: { surface: 'web', mode: 'design-system', scenario: 'design' } }),
fixture({ id: 'c', od: { surface: 'image', mode: 'image', scenario: 'marketing' } }),
fixture({ id: 'd', od: { surface: 'video', mode: 'video', scenario: 'marketing' } }),
];
const catalog = buildFacetCatalog(plugins);
expect(catalog.surface.map((o) => o.slug)).toEqual(['web', 'image', 'video']);
expect(catalog.surface[0]).toMatchObject({ slug: 'web', label: 'Web', count: 2 });
expect(catalog.type.map((o) => o.slug)[0]).toBe('design-system');
expect(catalog.scenario.map((o) => o.slug)).toContain('design');
expect(catalog.scenario.map((o) => o.slug)).toContain('marketing');
});
it('humanises unknown slugs in the SCENARIO axis labels', () => {
const catalog = buildFacetCatalog([
fixture({ id: 'a', od: { surface: 'web', mode: 'prototype', scenario: 'mocktail-bar' } }),
]);
expect(catalog.scenario[0]?.label).toBe('Mocktail Bar');
});
});
describe('applyFacetSelection', () => {
const plugins = [
fixture({ id: 'a', od: { surface: 'web', mode: 'design-system', scenario: 'design' } }),
fixture({ id: 'b', od: { surface: 'web', mode: 'prototype', scenario: 'marketing' } }),
fixture({ id: 'c', od: { surface: 'image', mode: 'image', scenario: 'marketing' } }),
fixture({ id: 'd', od: { surface: 'video', mode: 'video', scenario: 'engineering' } }),
];
it('returns everything when no axis is selected', () => {
expect(
applyFacetSelection(plugins, { surface: null, type: null, scenario: null }).map((p) => p.id),
).toEqual(['a', 'b', 'c', 'd']);
});
it('AND-composes selections across axes', () => {
const out = applyFacetSelection(plugins, {
surface: 'web',
type: null,
scenario: 'marketing',
}).map((p) => p.id);
expect(out).toEqual(['b']);
});
it('returns an empty list when no plugin satisfies the selection', () => {
const out = applyFacetSelection(plugins, {
surface: 'video',
type: null,
scenario: 'design',
});
expect(out).toEqual([]);
});
});
describe('isFeaturedPlugin', () => {
it('returns true only for od.featured === true (strict)', () => {
expect(isFeaturedPlugin(fixture({ id: 'a', od: { featured: true } }))).toBe(true);
expect(isFeaturedPlugin(fixture({ id: 'b', od: { featured: 'true' } }))).toBe(false);
expect(isFeaturedPlugin(fixture({ id: 'c' }))).toBe(false);
});
});

View file

@ -1,128 +0,0 @@
// Tag derivation contract for the plugins-home filter row. The home
// section ranks plugins by scenario tag rather than the legacy 4
// surface buckets (image / video / examples / design); these tests
// lock the slug surface so a tag never silently drops out of the
// pill row when the manifest schema evolves.
import { describe, expect, it } from 'vitest';
import type { InstalledPluginRecord } from '@open-design/contracts';
import {
buildTagCatalog,
isFeaturedPlugin,
labelForTag,
pluginTags,
} from '../../src/components/plugins-home/scenarioTags';
function fixture(overrides: {
id: string;
title?: string;
tags?: string[];
od?: Record<string, unknown>;
}): InstalledPluginRecord {
return {
id: overrides.id,
title: overrides.title ?? overrides.id,
version: '0.1.0',
sourceKind: 'bundled',
source: '/tmp',
trust: 'bundled',
capabilitiesGranted: ['prompt:inject'],
manifest: {
name: overrides.id,
version: '0.1.0',
...(overrides.tags ? { tags: overrides.tags } : {}),
...(overrides.od ? { od: overrides.od } : {}),
},
fsPath: '/tmp',
installedAt: 0,
updatedAt: 0,
};
}
describe('pluginTags', () => {
it('emits image / image-template / marketing for a wrapped image template', () => {
const tags = pluginTags(
fixture({
id: 'image-template-foo',
tags: ['image-template', 'first-party', 'image', 'marketing'],
od: { kind: 'scenario', taskKind: 'new-generation', mode: 'image', surface: 'image' },
}),
);
expect(tags).toContain('image');
expect(tags).toContain('image-template');
expect(tags).toContain('marketing');
expect(tags).toContain('new-generation');
expect(tags).not.toContain('first-party');
});
it('adds a synthetic workflow tag for multi-stage scenario plugins', () => {
const tags = pluginTags(
fixture({
id: 'od-code-migration',
tags: ['scenario', 'first-party', 'code-migration'],
od: {
kind: 'scenario',
taskKind: 'code-migration',
pipeline: { stages: [{ id: 'a', atoms: ['x'] }, { id: 'b', atoms: ['y'] }] },
},
}),
);
expect(tags).toContain('workflow');
expect(tags).toContain('code-migration');
expect(tags).not.toContain('scenario');
});
it('drops noise tags but keeps surface / scenario / mode', () => {
const tags = pluginTags(
fixture({
id: 'example-saas-landing',
tags: ['example', 'first-party', 'phase-7'],
od: { kind: 'scenario', mode: 'prototype', scenario: 'marketing', surface: 'web' },
}),
);
expect(tags).toEqual(expect.arrayContaining(['example', 'marketing', 'prototype', 'web']));
expect(tags).not.toContain('first-party');
expect(tags).not.toContain('phase-7');
});
});
describe('buildTagCatalog', () => {
it('pins type tags (image / video / design-system) ahead of free-form tags', () => {
const catalog = buildTagCatalog([
fixture({ id: 'a', tags: ['marketing'], od: { mode: 'image', surface: 'image' } }),
fixture({ id: 'b', tags: ['marketing'], od: { mode: 'image', surface: 'image' } }),
fixture({ id: 'c', tags: ['design-system'], od: { mode: 'design-system', surface: 'web' } }),
]);
const slugs = catalog.map((t) => t.slug);
// 'image' (pinned, count=2) should come before 'marketing' (count=2).
expect(slugs.indexOf('image')).toBeLessThan(slugs.indexOf('marketing'));
});
it('orders by count desc within the non-pinned section', () => {
const catalog = buildTagCatalog([
fixture({ id: 'a', tags: ['marketing'] }),
fixture({ id: 'b', tags: ['marketing'] }),
fixture({ id: 'c', tags: ['dashboard'] }),
]);
const slugs = catalog.map((t) => t.slug);
expect(slugs.indexOf('marketing')).toBeLessThan(slugs.indexOf('dashboard'));
});
});
describe('isFeaturedPlugin', () => {
it('returns true when od.featured === true', () => {
expect(isFeaturedPlugin(fixture({ id: 'a', od: { featured: true } }))).toBe(true);
expect(isFeaturedPlugin(fixture({ id: 'b', od: { featured: 'true' } }))).toBe(false);
expect(isFeaturedPlugin(fixture({ id: 'c' }))).toBe(false);
});
});
describe('labelForTag', () => {
it('uses the known dictionary for canonical slugs', () => {
expect(labelForTag('image')).toBe('Image');
expect(labelForTag('design-system')).toBe('Design system');
});
it('falls back to title-casing unknown slugs', () => {
expect(labelForTag('mocktail-bar')).toBe('Mocktail Bar');
});
});

View file

@ -2,16 +2,17 @@
// Plugins home section — UI contract.
//
// The section is now driven by `usePluginCategories` (tag-based
// scenario chips) rather than the legacy 4-bucket taxonomy. This
// suite locks in:
// The section renders a 3-axis SURFACE / TYPE / SCENARIO faceted
// filter and AND-composes selections across axes. A small Featured
// chip sits orthogonal to the facet rows. This suite locks in:
//
// 1. Featured pill is selected by default when at least one plugin
// declares `od.featured: true`.
// 2. Clicking a scenario tag filters the grid to plugins carrying
// that tag.
// 3. The "More" pill toggles overflow tags into the row when the
// catalog exceeds the visible limit.
// 1. All three facet rows render with axis-specific pills.
// 2. Picking a Surface pill filters the grid to plugins on that
// surface.
// 3. Selections compose via AND across axes (Web + Marketing only
// shows plugins that match both).
// 4. Featured chip overrides the facet selection and only shows
// curator-promoted plugins.
import { describe, expect, it, afterEach, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
@ -59,16 +60,18 @@ afterEach(() => {
vi.restoreAllMocks();
});
describe('PluginsHomeSection (tag-driven)', () => {
it('defaults to the Featured pill when a plugin declares featured', () => {
const plugins = [
makePlugin({ id: 'star', featured: true, mode: 'image', tags: ['image'] }),
makePlugin({ id: 'b', mode: 'image', tags: ['image'] }),
makePlugin({ id: 'c', mode: 'video', tags: ['video'] }),
];
const sample: InstalledPluginRecord[] = [
makePlugin({ id: 'a', surface: 'web', mode: 'design-system', scenario: 'design' }),
makePlugin({ id: 'b', surface: 'web', mode: 'prototype', scenario: 'marketing' }),
makePlugin({ id: 'c', surface: 'image', mode: 'image', scenario: 'marketing' }),
makePlugin({ id: 'd', surface: 'video', mode: 'video', scenario: 'engineering' }),
];
describe('PluginsHomeSection (faceted)', () => {
it('renders the three facet rows and an "All" pill in each', () => {
render(
<PluginsHomeSection
plugins={plugins}
plugins={sample}
loading={false}
activePluginId={null}
pendingApplyId={null}
@ -76,23 +79,18 @@ describe('PluginsHomeSection (tag-driven)', () => {
onOpenDetails={() => {}}
/>,
);
const featured = screen.getByTestId('plugins-home-filter-featured');
expect(featured.getAttribute('aria-selected')).toBe('true');
const grid = screen.getByRole('list');
const items = within(grid).getAllByRole('listitem');
expect(items).toHaveLength(1);
expect(items[0]?.getAttribute('data-plugin-id')).toBe('star');
expect(screen.getByTestId('plugins-home-row-surface')).toBeTruthy();
expect(screen.getByTestId('plugins-home-row-type')).toBeTruthy();
expect(screen.getByTestId('plugins-home-row-scenario')).toBeTruthy();
expect(screen.getByTestId('plugins-home-pill-surface-all')).toBeTruthy();
expect(screen.getByTestId('plugins-home-pill-type-all')).toBeTruthy();
expect(screen.getByTestId('plugins-home-pill-scenario-all')).toBeTruthy();
});
it('filters by a scenario tag when its pill is clicked', () => {
const plugins = [
makePlugin({ id: 'a', mode: 'image', tags: ['image', 'marketing'] }),
makePlugin({ id: 'b', mode: 'video', tags: ['video', 'marketing'] }),
makePlugin({ id: 'c', mode: 'prototype', tags: ['prototype', 'dashboard'] }),
];
it('filters by a Surface pill when clicked', () => {
render(
<PluginsHomeSection
plugins={plugins}
plugins={sample}
loading={false}
activePluginId={null}
pendingApplyId={null}
@ -100,20 +98,33 @@ describe('PluginsHomeSection (tag-driven)', () => {
onOpenDetails={() => {}}
/>,
);
fireEvent.click(screen.getByTestId('plugins-home-filter-marketing'));
fireEvent.click(screen.getByTestId('plugins-home-pill-surface-web'));
const items = within(screen.getByRole('list')).getAllByRole('listitem');
expect(items.map((i) => i.getAttribute('data-plugin-id')).sort()).toEqual(['a', 'b']);
});
it('toggles overflow tags into the pill row when "More" is clicked', () => {
// Build 12 plugins each tagged with a unique slug — sorted
// alphabetically by the catalog so the last one ('zz-late') is
// guaranteed to sit in the overflow tail past the default
// 8-pill visible window.
const prefixes = ['aa', 'ab', 'ac', 'ad', 'ae', 'af', 'ag', 'ah', 'ai', 'aj', 'ak', 'zz-late'];
const plugins = prefixes.map((p) =>
makePlugin({ id: `id-${p}`, tags: [p, 'image'], mode: 'image' }),
it('AND-composes Surface + Scenario selections', () => {
render(
<PluginsHomeSection
plugins={sample}
loading={false}
activePluginId={null}
pendingApplyId={null}
onUse={() => {}}
onOpenDetails={() => {}}
/>,
);
fireEvent.click(screen.getByTestId('plugins-home-pill-surface-web'));
fireEvent.click(screen.getByTestId('plugins-home-pill-scenario-marketing'));
const items = within(screen.getByRole('list')).getAllByRole('listitem');
expect(items.map((i) => i.getAttribute('data-plugin-id'))).toEqual(['b']);
});
it('Featured chip overrides facet selection and shows only featured plugins', () => {
const plugins = [
makePlugin({ id: 'star', surface: 'web', mode: 'design-system', scenario: 'design', featured: true }),
...sample,
];
render(
<PluginsHomeSection
plugins={plugins}
@ -124,10 +135,9 @@ describe('PluginsHomeSection (tag-driven)', () => {
onOpenDetails={() => {}}
/>,
);
const more = screen.getByTestId('plugins-home-filter-more');
expect(more).toBeTruthy();
expect(screen.queryByTestId('plugins-home-filter-zz-late')).toBeNull();
fireEvent.click(more);
expect(screen.getByTestId('plugins-home-filter-zz-late')).toBeTruthy();
fireEvent.click(screen.getByTestId('plugins-home-pill-surface-image'));
fireEvent.click(screen.getByTestId('plugins-home-chip-featured'));
const items = within(screen.getByRole('list')).getAllByRole('listitem');
expect(items.map((i) => i.getAttribute('data-plugin-id'))).toEqual(['star']);
});
});