Consolidate listing pages with unified grid cards and modal system (#2101)

* Prototype extension details modal

- Add detail popup modal for extension cards with full metadata and gallery
- Implement image gallery with thumbnail strip and main image selection
- Add modal styling and positioning in global.css
- Connect card click handlers to open modal with extension data

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix accessibility issues with modal focus restoration

- Add missing listing-cards-page class to agents.astro page root
- Pass focusable button element to openCardDetailsModal instead of article
- Fixes focus restoration for keyboard users when closing modal
- Applied fix across all listing pages (agents, instructions, hooks, plugins, skills, workflows)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address remaining PR review feedback

- Fix extension modal ARIA state by setting aria-current to "true" and removing it when inactive
- Use focusable .resource-preview as modal trigger for extension thumbnail/click/keyboard paths
- Extract shared multi-select helpers into pages/select-utils.ts and reuse across instructions/hooks/plugins/workflows
- Remove unused card-model.ts to avoid dead/overlapping type definitions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Aaron Powell
2026-06-24 09:47:39 +10:00
committed by GitHub
parent ec8cb2a8ae
commit 8cdeb2d2ed
25 changed files with 1777 additions and 737 deletions
+68 -65
View File
@@ -1,4 +1,5 @@
import { escapeHtml, getGitHubUrl, getLastUpdatedHtml } from "../utils";
import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render";
export interface RenderableExtension {
id: string;
@@ -17,10 +18,16 @@ export interface RenderableExtension {
path?: string | null;
type?: string | null;
} | null;
gallery?: {
path?: string | null;
type?: string | null;
} | null;
gallery?:
| {
path?: string | null;
type?: string | null;
}
| Array<{
path?: string | null;
type?: string | null;
}>
| null;
} | null;
imageUrl?: string | null;
assetPath?: string | null;
@@ -48,12 +55,10 @@ export function sortExtensions<T extends RenderableExtension>(
export function renderExtensionsHtml(items: RenderableExtension[]): string {
if (items.length === 0) {
return `
<div class="empty-state">
<h3>No extensions found</h3>
<p>No canvas extensions are available right now.</p>
</div>
`;
return renderEmptyStateHtml(
"No extensions found",
"No canvas extensions are available right now."
);
}
return items
@@ -69,62 +74,60 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string {
const sourceUrl =
item.sourceUrl || (item.path ? getGitHubUrl(item.path) : "");
return `
<article id="${escapeHtml(item.id)}" class="resource-item" role="listitem">
<div class="resource-preview">
${
item.imageUrl
? `<button type="button" class="resource-thumbnail-btn" data-preview-url="${escapeHtml(item.imageUrl)}" data-preview-alt="${escapeHtml(item.name)} preview" aria-label="Open ${escapeHtml(item.name)} preview">
<img class="resource-thumbnail" src="${escapeHtml(item.imageUrl)}" alt="${escapeHtml(item.name)} preview" loading="lazy" />
</button>`
: `<div class="resource-thumbnail resource-thumbnail-placeholder" aria-hidden="true">Canvas</div>`
}
<div class="resource-info">
<div class="resource-title">${escapeHtml(item.name)}</div>
<div class="resource-description">${escapeHtml(
item.description || "Canvas extension"
)}</div>
<div class="resource-keywords">
${
item.keywords && item.keywords.length > 0
? item.keywords
.map(
(kw) =>
`<span class="keyword-tag">${escapeHtml(kw)}</span>`
)
.join("")
: ""
}
</div>
<div class="resource-meta">
${
item.external
? '<span class="resource-tag">External</span>'
: ""
}
${getLastUpdatedHtml(item.lastUpdated)}
</div>
</div>
</div>
<div class="resource-actions">
<button
class="btn btn-primary btn-small copy-install-url-btn"
data-install-url="${escapeHtml(installUrl)}"
title="Copy install URL"
${installUrl ? "" : "disabled"}
>
Install
</button>
${
sourceUrl
? `<a href="${escapeHtml(
sourceUrl
)}" class="btn btn-secondary btn-small" target="_blank" rel="noopener noreferrer" title="View source">Source</a>`
: ""
}
</div>
</article>
const previewMediaHtml = item.imageUrl
? `<div class="resource-thumbnail-btn" data-extension-id="${escapeHtml(item.id)}" aria-hidden="true">
<img class="resource-thumbnail" src="${escapeHtml(item.imageUrl)}" alt="${escapeHtml(item.name)} preview" loading="lazy" />
</div>`
: `<div class="resource-thumbnail resource-thumbnail-placeholder" aria-hidden="true">Canvas</div>`;
const infoExtraHtml = `
<div class="resource-keywords">
${
item.keywords && item.keywords.length > 0
? item.keywords
.map((kw) => `<span class="keyword-tag">${escapeHtml(kw)}</span>`)
.join("")
: ""
}
</div>
`;
const metaHtml = `
${item.external ? '<span class="resource-tag">External</span>' : ""}
${getLastUpdatedHtml(item.lastUpdated)}
`;
const actionsHtml = `
<button
class="btn btn-primary btn-small copy-install-url-btn"
data-install-url="${escapeHtml(installUrl)}"
title="Copy install URL"
${installUrl ? "" : "disabled"}
>
Install
</button>
${
sourceUrl
? `<a href="${escapeHtml(
sourceUrl
)}" class="btn btn-secondary btn-small" target="_blank" rel="noopener noreferrer" title="View source">Source</a>`
: ""
}
`;
return renderSharedCardHtml({
title: item.name,
description: item.description || "Canvas extension",
previewMediaHtml,
infoExtraHtml,
metaHtml,
actionsHtml,
tabIndex: 0,
articleAttributes: {
id: item.id,
"data-extension-id": item.id,
},
});
})
.join("");
}