fix: architecture + UI/UX review fixes

Architecture:
- Cap homepage plugin list at PLUGIN_FETCH_CAP like other pages
- Declare @types/node directly instead of relying on transitive dep
- Single-source status label text (statuses.ts vs seed.json drift)

UI/UX:
- Stop auto-submitting filter selects so keyboard navigation works
- Fix heading hierarchy (add h2) on flat list pages
- Improve homepage title beyond bare "Plugins"
- Make status taxonomy descriptions self-contained
- Render only relevant statuses in the legend, not all 7
- Fix PluginCard "WordPress -> —" for missing target
- Clarify "{n} from / {n} targeting" microcopy
- Use proper count meta markup on CMS list
- Allow header nav row to wrap
- Fix bare CMS URL horizontal overflow
- Add standard line-clamp fallback to cards
- Even out footer stacked paragraph spacing
- Center plugin detail status line (drop margin-left hack)
- Raise toolbar tap targets to 44px
- Surface status badge meaning beyond title attribute
- Include source-CMS breadcrumb step on lookup miss
- Add link into filtered catalog from CMS detail
This commit is contained in:
Oleks
2026-06-02 04:16:58 +03:00
parent bdc43bb1d6
commit 0c2cea8c25
12 changed files with 65 additions and 48 deletions
+10 -11
View File
@@ -14,7 +14,8 @@
"emdash": "^0.10.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.7"
"@astrojs/check": "^0.9.7",
"@types/node": "^22"
}
},
"node_modules/@astrojs/check": {
@@ -3607,13 +3608,12 @@
}
},
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"license": "MIT",
"optional": true,
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
"undici-types": "~6.21.0"
}
},
"node_modules/@types/react": {
@@ -9505,11 +9505,10 @@
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT",
"optional": true
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/unified": {
"version": "11.0.5",
+2 -1
View File
@@ -22,6 +22,7 @@
"emdash": "^0.10.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.7"
"@astrojs/check": "^0.9.7",
"@types/node": "^22"
}
}
+7 -7
View File
@@ -47,13 +47,13 @@
"defaultValue": "proposed",
"searchable": true,
"options": [
{ "value": "port", "label": "Port — reimplement on target" },
{ "value": "built-in", "label": "Built-in — covered by target core" },
{ "value": "saas", "label": "SaaS — replaced by external service" },
{ "value": "drop", "label": "Drop — not needed" },
{ "value": "gated", "label": "Gated — pending decision" },
{ "value": "done", "label": "Done — ported and shipped" },
{ "value": "proposed", "label": "Proposed — newly submitted" }
{ "value": "port", "label": "Port" },
{ "value": "built-in", "label": "Built-in" },
{ "value": "saas", "label": "SaaS" },
{ "value": "drop", "label": "Drop" },
{ "value": "gated", "label": "Gated" },
{ "value": "done", "label": "Done" },
{ "value": "proposed", "label": "Proposed" }
]
},
{ "slug": "source_repo_url", "label": "Source repo URL", "type": "string" },
+1 -1
View File
@@ -20,7 +20,7 @@ const d = entry.data;
<h3><a href={`/plugins/${entry.id}`}>{d.title}</a></h3>
<div class="meta">
<StatusBadge status={d.status} />
{d.source_cms && <span>{d.source_cms} → {d.target_cms ?? ""}</span>}
{d.source_cms && <span>{d.source_cms}{d.target_cms ? `${d.target_cms}` : ""}</span>}
</div>
{d.purpose && <p>{d.purpose}</p>}
</li>
+1 -1
View File
@@ -8,4 +8,4 @@ const value = (status ?? "proposed").toLowerCase();
const label = STATUS_LABELS[value] ?? value;
const desc = STATUSES.find((s) => s.value === value)?.desc;
---
<span class={`badge badge--${value}`} title={desc}><span class="sr-only">Status: </span>{label}</span>
<span class={`badge badge--${value}`} title={desc}><span class="sr-only">Status: {label}{desc ? ` — ${desc}` : ""}. </span>{label}</span>
+5 -1
View File
@@ -1,9 +1,13 @@
---
import StatusBadge from "./StatusBadge.astro";
import { STATUSES } from "../lib/statuses";
interface Props {
items?: typeof STATUSES;
}
const { items = STATUSES } = Astro.props;
---
<div class="legend">
{STATUSES.map((i) => (
{items.map((i) => (
<span class="item"><StatusBadge status={i.value} /> {i.desc}</span>
))}
</div>
+9 -7
View File
@@ -1,3 +1,5 @@
export const PLUGIN_FETCH_CAP = 10000;
export interface StatusDef {
value: string;
label: string;
@@ -5,13 +7,13 @@ export interface StatusDef {
}
export const STATUSES: StatusDef[] = [
{ value: "port", label: "Port", desc: "must be reimplemented on the target CMS" },
{ value: "built-in", label: "Built-in", desc: "covered by target CMS core / framework" },
{ value: "saas", label: "SaaS", desc: "replaced by an external service" },
{ value: "drop", label: "Drop", desc: "not needed on the target CMS" },
{ value: "gated", label: "Gated", desc: "fate depends on an unresolved decision" },
{ value: "done", label: "Done", desc: "ported and shipped" },
{ value: "proposed", label: "Proposed", desc: "submitted for cataloging" },
{ value: "port", label: "Port", desc: "Reimplemented as a new Emdash plugin" },
{ value: "built-in", label: "Built-in", desc: "Already covered by Emdash core" },
{ value: "saas", label: "SaaS", desc: "Replaced by an external hosted service" },
{ value: "drop", label: "Drop", desc: "Not needed after migration" },
{ value: "gated", label: "Gated", desc: "Blocked on an open decision" },
{ value: "done", label: "Done", desc: "Ported and shipped" },
{ value: "proposed", label: "Proposed", desc: "Newly added, not yet classified" },
];
export const STATUS_LABELS: Record<string, string> = Object.fromEntries(
+2 -1
View File
@@ -2,6 +2,7 @@
import { getEmDashCollection, getEmDashEntry, decodeSlug } from "emdash";
import Base from "../../layouts/Base.astro";
import PluginCard from "../../components/PluginCard.astro";
import { PLUGIN_FETCH_CAP } from "../../lib/statuses";
export const prerender = false;
@@ -14,7 +15,6 @@ if (!slug || !cms) {
}
if (cacheHint) Astro.cache.set(cacheHint);
const PLUGIN_FETCH_CAP = 10000;
const { entries: all } = cms
? await getEmDashCollection("plugins", { orderBy: { title: "asc" }, limit: PLUGIN_FETCH_CAP })
: { entries: [] };
@@ -36,6 +36,7 @@ const targetingHere = all.filter((p) => p.data.target_cms === cmsName);
{cms.data.description && <p>{cms.data.description}</p>}
<h2 class="section-gap">Plugins from this CMS ({fromHere.length})</h2>
<p><a href={`/?source=${encodeURIComponent(cmsName)}`}>Filter these in the catalog</a></p>
{fromHere.length === 0 ? (
<p class="empty">No plugins cataloged for this CMS yet.</p>
) : (
+3 -2
View File
@@ -1,6 +1,7 @@
---
import { getEmDashCollection } from "emdash";
import Base from "../../layouts/Base.astro";
import { PLUGIN_FETCH_CAP } from "../../lib/statuses";
export const prerender = false;
@@ -9,7 +10,6 @@ const { entries: cmses, cacheHint } = await getEmDashCollection("cmses", {
});
Astro.cache.set(cacheHint);
const PLUGIN_FETCH_CAP = 10000;
const { entries: plugins } = await getEmDashCollection("plugins", { limit: PLUGIN_FETCH_CAP });
if (plugins.length >= PLUGIN_FETCH_CAP) console.warn("[cms] plugin fetch hit cap", PLUGIN_FETCH_CAP, "- counts/lists may be truncated");
const countBySource = new Map<string, number>();
@@ -23,11 +23,12 @@ for (const p of plugins) {
---
<Base title="By CMS" description="Browse plugins grouped by source CMS.">
<h1>By CMS</h1>
<h2 class="sr-only">CMSes</h2>
<ul class="plugin-grid">
{cmses.map((c) => (
<li class="plugin-card">
<h3><a href={`/cms/${c.id}`}>{c.data.title}</a></h3>
<p class="meta">{countBySource.get(c.data.title) ?? 0} from · {countByTarget.get(c.data.title) ?? 0} targeting</p>
<div class="meta">{countBySource.get(c.data.title) ?? 0} plugins from · {countByTarget.get(c.data.title) ?? 0} targeting</div>
{c.data.description && <p>{c.data.description}</p>}
</li>
))}
+10 -7
View File
@@ -3,13 +3,15 @@ import { getEmDashCollection } from "emdash";
import Base from "../layouts/Base.astro";
import PluginCard from "../components/PluginCard.astro";
import StatusLegend from "../components/StatusLegend.astro";
import { STATUSES } from "../lib/statuses";
import { STATUSES, PLUGIN_FETCH_CAP } from "../lib/statuses";
export const prerender = false;
const { entries: plugins, cacheHint } = await getEmDashCollection("plugins", {
orderBy: { title: "asc" },
limit: PLUGIN_FETCH_CAP,
});
if (plugins.length >= PLUGIN_FETCH_CAP) console.warn("[index] plugin fetch hit cap", PLUGIN_FETCH_CAP, "- list may be truncated");
Astro.cache.set(cacheHint);
const url = new URL(Astro.request.url);
@@ -29,33 +31,34 @@ const filtered = plugins.filter((p) => {
return true;
});
---
<Base title="Plugins" description="Browse cataloged CMS plugins and their migration status.">
<Base title="WordPress → Emdash plugin parity catalog" description="How each WordPress plugin maps onto its Emdash replacement when migrating a site — browse by migration status, source CMS, or name.">
<h1>Plugins</h1>
<p>{(q || statusFilter || sourceFilter) ? `${filtered.length} of ${plugins.length} match.` : `${plugins.length} entries cataloged. Filter by status, source CMS, or search by name.`}</p>
<p>{(q || statusFilter || sourceFilter) ? `${filtered.length} of ${plugins.length} match.` : `${plugins.length} WordPress plugins cataloged with how each maps onto Emdash. Filter by status, source CMS, or search by name.`}</p>
<StatusLegend />
<StatusLegend items={statuses} />
<form class="toolbar" method="get">
<input type="search" name="q" placeholder="Search…" value={q} aria-label="Search plugins by name" />
<select name="source" aria-label="Filter by source CMS" onchange="this.form.submit()">
<select name="source" aria-label="Filter by source CMS">
<option value="">All source CMSes</option>
{sources.map((s) => (
<option value={s} selected={s === sourceFilter}>{s}</option>
))}
</select>
<select name="status" aria-label="Filter by status" onchange="this.form.submit()">
<select name="status" aria-label="Filter by status">
<option value="">All statuses</option>
{statuses.map((s) => (
<option value={s.value} selected={s.value === statusFilter}>{s.label}</option>
))}
</select>
<button type="submit">Apply</button>
{(q || statusFilter || sourceFilter) && <a href="/">Reset</a>}
{(q || statusFilter || sourceFilter) && <a href="/">Clear all</a>}
</form>
{filtered.length === 0 ? (
<p class="empty">No plugins match {q ? `“${q}”` : "the current filters"}. <a href="/">Clear filters</a></p>
) : (
<h2 class="sr-only">Plugin results</h2>
<ul class="plugin-grid">
{filtered.map((entry) => <PluginCard entry={entry} />)}
</ul>
+5 -4
View File
@@ -18,8 +18,9 @@ if (cacheHint) Astro.cache.set(cacheHint);
const d = entry?.data;
const { entries: cmses } = entry ? await getEmDashCollection("cmses", {}) : { entries: [] };
const cmsSlugByTitle = new Map(cmses.map((c) => [c.data.title as string, c.id]));
const sourceCmsSlug = d?.source_cms ? cmsSlugByTitle.get(d.source_cms) : undefined;
const norm = (s: string) => s.trim().toLowerCase();
const cmsSlugByTitle = new Map(cmses.map((c) => [norm(c.data.title as string), c.id]));
const sourceCmsSlug = d?.source_cms ? cmsSlugByTitle.get(norm(d.source_cms)) : undefined;
---
{!entry || !d ? (
<Base title="Not found">
@@ -31,7 +32,7 @@ const sourceCmsSlug = d?.source_cms ? cmsSlugByTitle.get(d.source_cms) : undefin
title={d.title}
description={d.purpose ?? undefined}
content={{ collection: "plugins", id: entry.data.id, slug: entry.id }}
breadcrumbs={[{ name: "Plugins", url: "/" }, ...(sourceCmsSlug ? [{ name: d.source_cms, url: `/cms/${sourceCmsSlug}` }] : []), { name: d.title, url: Astro.url.pathname }]}
breadcrumbs={[{ name: "Plugins", url: "/" }, ...(d.source_cms ? [{ name: d.source_cms, url: sourceCmsSlug ? `/cms/${sourceCmsSlug}` : "/cms" }] : []), { name: d.title, url: Astro.url.pathname }]}
>
<article class="detail">
<header>
@@ -40,7 +41,7 @@ const sourceCmsSlug = d?.source_cms ? cmsSlugByTitle.get(d.source_cms) : undefin
{d.source_cms && (<> · {sourceCmsSlug ? <a href={`/cms/${sourceCmsSlug}`}>{d.source_cms}</a> : <span>{d.source_cms}</span>}</>)}
</p>
<h1>{d.title}</h1>
<p>
<p class="meta">
<StatusBadge status={d.status} />
{d.source_cms && <span class="source-target">{d.source_cms}{d.target_cms ? ` → ${d.target_cms}` : ""}</span>}
</p>
+10 -5
View File
@@ -70,7 +70,7 @@ header.site {
background: var(--c-bg);
}
header.site .row {
display: flex; align-items: center; justify-content: space-between; gap: 1.5rem;
display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.5rem 1.5rem;
}
header.site .brand { font-weight: 700; color: var(--c-heading); }
header.site .brand:hover { text-decoration: none; }
@@ -85,6 +85,8 @@ footer.site {
color: var(--c-muted);
font-size: var(--fs-sm);
}
footer.site .container { display: flex; flex-direction: column; gap: 0.35rem; }
footer.site p { margin: 0; }
/* status badge */
.badge {
@@ -115,14 +117,15 @@ footer.site {
.toolbar input[type="search"], .toolbar select {
font: inherit;
padding: 0.4rem 0.6rem;
min-height: 44px;
border: 1px solid var(--c-border);
border-radius: var(--radius);
background: white;
}
.toolbar input[type="search"] { flex: 1 1 240px; min-width: 0; }
.toolbar button { font: inherit; padding: 0.4rem 0.9rem; border: 1px solid var(--c-border); border-radius: var(--radius); background: var(--c-link); color: #fff; cursor: pointer; }
.toolbar button { font: inherit; padding: 0.4rem 0.9rem; min-height: 44px; border: 1px solid var(--c-border); border-radius: var(--radius); background: var(--c-link); color: #fff; cursor: pointer; }
.toolbar button:hover { filter: brightness(1.08); }
.toolbar a { font: inherit; padding: 0.4rem 0.6rem; border-radius: var(--radius); }
.toolbar a { font: inherit; padding: 0.4rem 0.6rem; min-height: 44px; display: inline-flex; align-items: center; border-radius: var(--radius); }
.plugin-grid {
list-style: none; padding: 0; margin: 0;
@@ -142,7 +145,7 @@ footer.site {
.plugin-card h3 { margin: 0; font-size: var(--fs-card-title); }
.plugin-card h3 a { color: var(--c-heading); }
.plugin-card .meta { font-size: var(--fs-sm); color: var(--c-muted); display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.plugin-card p { margin: 0; font-size: 0.92rem; color: var(--c-body); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.plugin-card p { margin: 0; font-size: 0.92rem; color: var(--c-body); display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
/* detail page */
.detail header { border-bottom: 1px solid var(--c-border); padding-bottom: 1rem; margin-bottom: 1.5rem; }
@@ -151,7 +154,9 @@ footer.site {
.detail dt { color: var(--c-muted); font-size: var(--fs-sm); }
.detail dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.detail .lead { font-size: 1.05rem; }
.detail .source-target { margin-left: .75rem; color: var(--c-muted); }
.detail .meta { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.detail .source-target { color: var(--c-muted); }
main p a { overflow-wrap: anywhere; }
.section-gap { margin-top: 2rem; }
.detail .notes { line-height: 1.65; }
.detail .notes pre { background: var(--c-bg-alt); padding: 0.6rem 0.8rem; border-radius: var(--radius); overflow-x: auto; }