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:
Generated
+10
-11
@@ -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
@@ -22,6 +22,7 @@
|
||||
"emdash": "^0.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.7"
|
||||
"@astrojs/check": "^0.9.7",
|
||||
"@types/node": "^22"
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user