fix(app): architecture + UI/UX review fixes

Multi-agent arch/UX review pass. Architecture: real HTTP 404 on not-found,
breadcrumb links by slug not lowercased title, CMS pages surface their
plugins, shared status taxonomy (src/lib/statuses.ts) consumed by all three
frontend consumers, data-driven status filter, typed emdash collections
(src/emdash-collections.d.ts), removed unused @astrojs/react + react deps and
dead pnpm block, tsconfig extends Astro strict preset, dev deps pruned from
the runtime image. UI/UX: fixed StatusBadge WCAG-AA contrast, labelled the
search/filter controls, canonical URL + BreadcrumbList JSON-LD + og:type,
human status labels, filtered result count + recoverable empty state,
auto-submit filters, mobile overflow fixes, skip-to-content, :focus-visible.

Commit the npm lockfile so the Dockerfile's `npm ci` path engages.
astro check: 0 errors / 0 warnings / 0 hints.
This commit is contained in:
Oleks
2026-06-02 03:24:52 +03:00
parent 4beb58ff48
commit bfc6a65638
18 changed files with 10794 additions and 87 deletions
+3
View File
@@ -90,3 +90,6 @@ cd app && npm run typecheck
- Don't change branch promotion semantics (fast-forward only across - Don't change branch promotion semantics (fast-forward only across
`develop``staging``production`). Mirroring emdash-kotkanagrilli's `develop``staging``production`). Mirroring emdash-kotkanagrilli's
flow is intentional. flow is intentional.
- Don't commit the dev SQLite DB. `data.db` / `data.db-shm` / `data.db-wal`
are dev artifacts (gitignored + dockerignored); they must never live in
the source tree. Regenerate via `npm run bootstrap` (`emdash init`).
+1
View File
@@ -15,6 +15,7 @@ WORKDIR /app
COPY app/ ./ COPY app/ ./
RUN rm -f data.db data.db-shm data.db-wal && rm -rf uploads RUN rm -f data.db data.db-shm data.db-wal && rm -rf uploads
RUN npm run build RUN npm run build
RUN npm prune --omit=dev
FROM node:22-bookworm-slim AS runtime FROM node:22-bookworm-slim AS runtime
WORKDIR /app WORKDIR /app
-2
View File
@@ -1,5 +1,4 @@
import node from "@astrojs/node"; import node from "@astrojs/node";
import react from "@astrojs/react";
import { defineConfig, memoryCache } from "astro/config"; import { defineConfig, memoryCache } from "astro/config";
import emdash, { local } from "emdash/astro"; import emdash, { local } from "emdash/astro";
import { sqlite } from "emdash/db"; import { sqlite } from "emdash/db";
@@ -27,7 +26,6 @@ export default defineConfig({
responsiveStyles: true, responsiveStyles: true,
}, },
integrations: [ integrations: [
react(),
emdash({ emdash({
database: sqlite({ url: `file:${stateDir}/data.db` }), database: sqlite({ url: `file:${stateDir}/data.db` }),
storage: local({ storage: local({
+10590
View File
File diff suppressed because it is too large Load Diff
+1 -10
View File
@@ -17,20 +17,11 @@
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "^10.0.5", "@astrojs/node": "^10.0.5",
"@astrojs/react": "^5.0.0",
"astro": "^6.3.0", "astro": "^6.3.0",
"better-sqlite3": "^12.8.0", "better-sqlite3": "^12.8.0",
"emdash": "^0.10.0", "emdash": "^0.10.0"
"react": "19.2.4",
"react-dom": "19.2.4"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "^0.9.7" "@astrojs/check": "^0.9.7"
},
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3",
"esbuild"
]
} }
} }
+4 -11
View File
@@ -1,18 +1,11 @@
--- ---
import { STATUS_LABELS, STATUSES } from "../lib/statuses";
interface Props { interface Props {
status: string | null | undefined; status: string | null | undefined;
} }
const { status } = Astro.props; const { status } = Astro.props;
const value = (status ?? "proposed").toLowerCase(); const value = (status ?? "proposed").toLowerCase();
const labels: Record<string, string> = { const label = STATUS_LABELS[value] ?? value;
port: "Port", const desc = STATUSES.find((s) => s.value === value)?.desc;
"built-in": "Built-in",
drop: "Drop",
saas: "SaaS",
gated: "Gated",
done: "Done",
proposed: "Proposed",
};
const label = labels[value] ?? value;
--- ---
<span class={`badge badge--${value}`}>{label}</span> <span class={`badge badge--${value}`} title={desc}><span class="sr-only">Status: </span>{label}</span>
+3 -11
View File
@@ -1,17 +1,9 @@
--- ---
import StatusBadge from "./StatusBadge.astro"; import StatusBadge from "./StatusBadge.astro";
const items = [ import { STATUSES } from "../lib/statuses";
{ status: "port", desc: "must be reimplemented on the target CMS" },
{ status: "built-in", desc: "covered by target CMS core / framework" },
{ status: "saas", desc: "replaced by an external service" },
{ status: "drop", desc: "not needed on the target CMS" },
{ status: "gated", desc: "fate depends on an unresolved decision" },
{ status: "done", desc: "ported and shipped" },
{ status: "proposed", desc: "submitted for cataloging" },
];
--- ---
<div class="legend"> <div class="legend">
{items.map((i) => ( {STATUSES.map((i) => (
<span class="item"><StatusBadge status={i.status} /> {i.desc}</span> <span class="item"><StatusBadge status={i.value} /> {i.desc}</span>
))} ))}
</div> </div>
+42
View File
@@ -0,0 +1,42 @@
// Types for the content collections defined in seed/seed.json.
//
// emdash's getEmDashCollection/getEmDashEntry resolve their entry `.data`
// shape from `InferCollectionData<T>`, which reads `EmDashCollections[T]` if
// this interface is augmented and otherwise falls back to
// `Record<string, unknown>`. Augmenting it here gives every page typed
// `entry.data`. Keep these field shapes in sync with seed/seed.json.
import "emdash";
// Fields emdash adds to every entry's `data` regardless of the seed schema.
// `id` is the database ULID (entry.id is the slug); used for API calls and
// cross-collection refs.
interface EmDashSystemFields {
id: string;
}
declare module "emdash" {
interface EmDashCollections {
cmses: EmDashSystemFields & {
title: string;
website?: string | null;
description?: string | null;
};
plugins: EmDashSystemFields & {
title: string;
purpose?: string | null;
source_cms: string;
target_cms?: string | null;
category?: string | null;
status: string;
source_repo_url?: string | null;
target_repo_url?: string | null;
notes?: import("emdash").PortableTextBlock[] | null;
};
pages: EmDashSystemFields & {
title: string;
content?: import("emdash").PortableTextBlock[] | null;
excerpt?: string | null;
};
}
}
+10 -4
View File
@@ -8,9 +8,10 @@ interface Props {
title: string; title: string;
description?: string | null; description?: string | null;
content?: { collection: string; id: string; slug?: string | null }; content?: { collection: string; id: string; slug?: string | null };
breadcrumbs?: { name: string; url: string }[];
} }
const { title, description, content } = Astro.props; const { title, description, content, breadcrumbs } = Astro.props;
const settings = await getSiteSettings(); const settings = await getSiteSettings();
const siteTitle = settings?.title ?? "CMS plugins catalog"; const siteTitle = settings?.title ?? "CMS plugins catalog";
const siteTagline = settings?.tagline ?? "WordPress → Emdash plugin parity catalog"; const siteTagline = settings?.tagline ?? "WordPress → Emdash plugin parity catalog";
@@ -18,15 +19,20 @@ const siteTagline = settings?.tagline ?? "WordPress → Emdash plugin parity cat
const fullTitle = title === siteTitle ? siteTitle : `${title} — ${siteTitle}`; const fullTitle = title === siteTitle ? siteTitle : `${title} — ${siteTitle}`;
const pageDescription = description ?? siteTagline; const pageDescription = description ?? siteTagline;
const canonicalBase = Astro.site ?? new URL(Astro.url.origin);
const canonical = new URL(Astro.url.pathname, canonicalBase).href;
const pageCtx = createPublicPageContext({ const pageCtx = createPublicPageContext({
Astro, Astro,
kind: content ? "content" : "custom", kind: content ? "content" : "custom",
pageType: "website", pageType: content ? "article" : "website",
title: fullTitle, title: fullTitle,
pageTitle: title, pageTitle: title,
description: pageDescription, description: pageDescription,
content, content,
siteName: siteTitle, siteName: siteTitle,
canonical,
breadcrumbs,
}); });
--- ---
@@ -36,10 +42,10 @@ const pageCtx = createPublicPageContext({
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{fullTitle}</title> <title>{fullTitle}</title>
<meta name="description" content={pageDescription} />
<EmDashHead page={pageCtx} /> <EmDashHead page={pageCtx} />
</head> </head>
<body> <body>
<a href="#main" class="skip-link">Skip to content</a>
<EmDashBodyStart page={pageCtx} /> <EmDashBodyStart page={pageCtx} />
<header class="site"> <header class="site">
<div class="container row"> <div class="container row">
@@ -51,7 +57,7 @@ const pageCtx = createPublicPageContext({
</nav> </nav>
</div> </div>
</header> </header>
<main> <main id="main">
<div class="container"> <div class="container">
<slot /> <slot />
</div> </div>
+19
View File
@@ -0,0 +1,19 @@
export interface StatusDef {
value: string;
label: string;
desc: string;
}
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" },
];
export const STATUS_LABELS: Record<string, string> = Object.fromEntries(
STATUSES.map((s) => [s.value, s.label]),
);
+1
View File
@@ -1,5 +1,6 @@
--- ---
import Base from "../layouts/Base.astro"; import Base from "../layouts/Base.astro";
Astro.response.status = 404;
--- ---
<Base title="Not found"> <Base title="Not found">
<h1>Not found</h1> <h1>Not found</h1>
+14 -5
View File
@@ -6,15 +6,24 @@ import Base from "../layouts/Base.astro";
export const prerender = false; export const prerender = false;
const slug = decodeSlug(Astro.params.slug); const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.redirect("/404"); const { entry, cacheHint } = slug
? await getEmDashEntry("pages", slug)
const { entry, cacheHint } = await getEmDashEntry("pages", slug); : { entry: null, cacheHint: undefined };
if (!entry) return Astro.redirect("/404"); if (!slug || !entry) {
Astro.cache.set(cacheHint); Astro.response.status = 404;
}
if (cacheHint) Astro.cache.set(cacheHint);
--- ---
{!entry ? (
<Base title="Not found">
<h1>Not found</h1>
<p>The page you're looking for doesn't exist. <a href="/">Back to the catalog.</a></p>
</Base>
) : (
<Base title={entry.data.title} description={entry.data.excerpt ?? undefined} content={{ collection: "pages", id: entry.data.id, slug: entry.id }}> <Base title={entry.data.title} description={entry.data.excerpt ?? undefined} content={{ collection: "pages", id: entry.data.id, slug: entry.id }}>
<article class="detail"> <article class="detail">
<h1>{entry.data.title}</h1> <h1>{entry.data.title}</h1>
{entry.data.content && <div class="notes"><PortableText value={entry.data.content} /></div>} {entry.data.content && <div class="notes"><PortableText value={entry.data.content} /></div>}
</article> </article>
</Base> </Base>
)}
+35 -15
View File
@@ -6,31 +6,51 @@ import PluginCard from "../../components/PluginCard.astro";
export const prerender = false; export const prerender = false;
const slug = decodeSlug(Astro.params.slug); const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.redirect("/404"); const { entry: cms, cacheHint } = slug
? await getEmDashEntry("cmses", slug)
: { entry: null, cacheHint: undefined };
if (!slug || !cms) {
Astro.response.status = 404;
}
if (cacheHint) Astro.cache.set(cacheHint);
const { entry: cms, cacheHint } = await getEmDashEntry("cmses", slug); const PLUGIN_FETCH_CAP = 10000;
if (!cms) return Astro.redirect("/404"); const { entries: all } = cms
Astro.cache.set(cacheHint); ? await getEmDashCollection("plugins", { orderBy: { title: "asc" }, limit: PLUGIN_FETCH_CAP })
: { entries: [] };
const { entries: all } = await getEmDashCollection("plugins", { if (all.length >= PLUGIN_FETCH_CAP) console.warn("[cms] plugin fetch hit cap", PLUGIN_FETCH_CAP, "- counts/lists may be truncated");
orderBy: { title: "asc" }, const cmsName = cms?.data.title as string;
limit: 9999, const fromHere = all.filter((p) => p.data.source_cms === cmsName);
}); const targetingHere = all.filter((p) => p.data.target_cms === cmsName);
const cmsName = cms.data.title as string;
const plugins = all.filter((p) => p.data.source_cms === cmsName);
--- ---
<Base title={cmsName} description={cms.data.description ?? undefined}> {!cms ? (
<Base title="Not found">
<h1>Not found</h1>
<p>That CMS doesn't exist. <a href="/">Back to the catalog.</a></p>
</Base>
) : (
<Base title={cmsName} description={cms.data.description ?? undefined} breadcrumbs={[{ name: "By CMS", url: "/cms" }, { name: cmsName, url: Astro.url.pathname }]}>
<p class="crumbs"><a href="/cms">By CMS</a></p> <p class="crumbs"><a href="/cms">By CMS</a></p>
<h1>{cmsName}</h1> <h1>{cmsName}</h1>
{cms.data.website && <p><a href={cms.data.website}>{cms.data.website}</a></p>} {cms.data.website && <p><a href={cms.data.website}>{cms.data.website}</a></p>}
{cms.data.description && <p>{cms.data.description}</p>} {cms.data.description && <p>{cms.data.description}</p>}
<h2 style="margin-top:2rem">Plugins ({plugins.length})</h2> <h2 class="section-gap">Plugins from this CMS ({fromHere.length})</h2>
{plugins.length === 0 ? ( {fromHere.length === 0 ? (
<p class="empty">No plugins cataloged for this CMS yet.</p> <p class="empty">No plugins cataloged for this CMS yet.</p>
) : ( ) : (
<ul class="plugin-grid"> <ul class="plugin-grid">
{plugins.map((entry) => <PluginCard entry={entry} />)} {fromHere.map((entry) => <PluginCard entry={entry} />)}
</ul> </ul>
)} )}
{targetingHere.length > 0 && (
<>
<h2 class="section-gap">Plugins targeting this CMS ({targetingHere.length})</h2>
<ul class="plugin-grid">
{targetingHere.map((entry) => <PluginCard entry={entry} />)}
</ul>
</>
)}
</Base> </Base>
)}
+7 -2
View File
@@ -9,11 +9,16 @@ const { entries: cmses, cacheHint } = await getEmDashCollection("cmses", {
}); });
Astro.cache.set(cacheHint); Astro.cache.set(cacheHint);
const { entries: plugins } = await getEmDashCollection("plugins", { limit: 9999 }); 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>(); const countBySource = new Map<string, number>();
const countByTarget = new Map<string, number>();
for (const p of plugins) { for (const p of plugins) {
const s = p.data.source_cms; const s = p.data.source_cms;
if (s) countBySource.set(s, (countBySource.get(s) ?? 0) + 1); if (s) countBySource.set(s, (countBySource.get(s) ?? 0) + 1);
const t = p.data.target_cms;
if (t) countByTarget.set(t, (countByTarget.get(t) ?? 0) + 1);
} }
--- ---
<Base title="By CMS" description="Browse plugins grouped by source CMS."> <Base title="By CMS" description="Browse plugins grouped by source CMS.">
@@ -22,7 +27,7 @@ for (const p of plugins) {
{cmses.map((c) => ( {cmses.map((c) => (
<li class="plugin-card"> <li class="plugin-card">
<h3><a href={`/cms/${c.id}`}>{c.data.title}</a></h3> <h3><a href={`/cms/${c.id}`}>{c.data.title}</a></h3>
<p class="meta">{countBySource.get(c.data.title) ?? 0} plugins</p> <p class="meta">{countBySource.get(c.data.title) ?? 0} from · {countByTarget.get(c.data.title) ?? 0} targeting</p>
{c.data.description && <p>{c.data.description}</p>} {c.data.description && <p>{c.data.description}</p>}
</li> </li>
))} ))}
+9 -7
View File
@@ -3,6 +3,7 @@ import { getEmDashCollection } from "emdash";
import Base from "../layouts/Base.astro"; import Base from "../layouts/Base.astro";
import PluginCard from "../components/PluginCard.astro"; import PluginCard from "../components/PluginCard.astro";
import StatusLegend from "../components/StatusLegend.astro"; import StatusLegend from "../components/StatusLegend.astro";
import { STATUSES } from "../lib/statuses";
export const prerender = false; export const prerender = false;
@@ -17,7 +18,8 @@ const statusFilter = url.searchParams.get("status") ?? "";
const sourceFilter = url.searchParams.get("source") ?? ""; const sourceFilter = url.searchParams.get("source") ?? "";
const sources = Array.from(new Set(plugins.map((p) => p.data.source_cms).filter(Boolean))).sort(); const sources = Array.from(new Set(plugins.map((p) => p.data.source_cms).filter(Boolean))).sort();
const statuses = ["port", "built-in", "saas", "drop", "gated", "done", "proposed"]; const present = new Set(plugins.map((p) => p.data.status).filter(Boolean));
const statuses = STATUSES.filter((s) => present.has(s.value));
const filtered = plugins.filter((p) => { const filtered = plugins.filter((p) => {
const d = p.data; const d = p.data;
@@ -29,22 +31,22 @@ const filtered = plugins.filter((p) => {
--- ---
<Base title="Plugins" description="Browse cataloged CMS plugins and their migration status."> <Base title="Plugins" description="Browse cataloged CMS plugins and their migration status.">
<h1>Plugins</h1> <h1>Plugins</h1>
<p>{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} entries cataloged. Filter by status, source CMS, or search by name.`}</p>
<StatusLegend /> <StatusLegend />
<form class="toolbar" method="get"> <form class="toolbar" method="get">
<input type="search" name="q" placeholder="Search…" value={q} /> <input type="search" name="q" placeholder="Search…" value={q} aria-label="Search plugins by name" />
<select name="source"> <select name="source" aria-label="Filter by source CMS" onchange="this.form.submit()">
<option value="">All source CMSes</option> <option value="">All source CMSes</option>
{sources.map((s) => ( {sources.map((s) => (
<option value={s} selected={s === sourceFilter}>{s}</option> <option value={s} selected={s === sourceFilter}>{s}</option>
))} ))}
</select> </select>
<select name="status"> <select name="status" aria-label="Filter by status" onchange="this.form.submit()">
<option value="">All statuses</option> <option value="">All statuses</option>
{statuses.map((s) => ( {statuses.map((s) => (
<option value={s} selected={s === statusFilter}>{s}</option> <option value={s.value} selected={s.value === statusFilter}>{s.label}</option>
))} ))}
</select> </select>
<button type="submit">Apply</button> <button type="submit">Apply</button>
@@ -52,7 +54,7 @@ const filtered = plugins.filter((p) => {
</form> </form>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<p class="empty">No plugins match the current filters.</p> <p class="empty">No plugins match {q ? `“${q}”` : "the current filters"}. <a href="/">Clear filters</a></p>
) : ( ) : (
<ul class="plugin-grid"> <ul class="plugin-grid">
{filtered.map((entry) => <PluginCard entry={entry} />)} {filtered.map((entry) => <PluginCard entry={entry} />)}
+28 -10
View File
@@ -1,5 +1,5 @@
--- ---
import { getEmDashEntry, decodeSlug } from "emdash"; import { getEmDashCollection, getEmDashEntry, decodeSlug } from "emdash";
import { PortableText } from "emdash/ui"; import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro"; import Base from "../../layouts/Base.astro";
import StatusBadge from "../../components/StatusBadge.astro"; import StatusBadge from "../../components/StatusBadge.astro";
@@ -7,27 +7,44 @@ import StatusBadge from "../../components/StatusBadge.astro";
export const prerender = false; export const prerender = false;
const slug = decodeSlug(Astro.params.slug); const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.redirect("/404"); const { entry, cacheHint } = slug
? await getEmDashEntry("plugins", slug)
: { entry: null, cacheHint: undefined };
if (!slug || !entry) {
Astro.response.status = 404;
}
if (cacheHint) Astro.cache.set(cacheHint);
const { entry, cacheHint } = await getEmDashEntry("plugins", slug); const d = entry?.data;
if (!entry) return Astro.redirect("/404");
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;
--- ---
<Base title={d.title} description={d.purpose ?? undefined} content={{ collection: "plugins", id: entry.data.id, slug: entry.id }}> {!entry || !d ? (
<Base title="Not found">
<h1>Not found</h1>
<p>That plugin doesn't exist. <a href="/">Back to the catalog.</a></p>
</Base>
) : (
<Base
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 }]}
>
<article class="detail"> <article class="detail">
<header> <header>
<p class="crumbs"> <p class="crumbs">
<a href="/">Plugins</a> <a href="/">Plugins</a>
{d.source_cms && (<> · <a href={`/cms/${d.source_cms.toLowerCase()}`}>{d.source_cms}</a></>)} {d.source_cms && (<> · {sourceCmsSlug ? <a href={`/cms/${sourceCmsSlug}`}>{d.source_cms}</a> : <span>{d.source_cms}</span>}</>)}
</p> </p>
<h1>{d.title}</h1> <h1>{d.title}</h1>
<p> <p>
<StatusBadge status={d.status} /> <StatusBadge status={d.status} />
{d.source_cms && <span style="margin-left:.75rem;color:var(--c-muted)">{d.source_cms}{d.target_cms ? ` → ${d.target_cms}` : ""}</span>} {d.source_cms && <span class="source-target">{d.source_cms}{d.target_cms ? ` → ${d.target_cms}` : ""}</span>}
</p> </p>
{d.purpose && <p style="font-size:1.05rem">{d.purpose}</p>} {d.purpose && <p class="lead">{d.purpose}</p>}
</header> </header>
<dl> <dl>
@@ -46,3 +63,4 @@ const d = entry.data;
)} )}
</article> </article>
</Base> </Base>
)}
+26 -9
View File
@@ -10,9 +10,9 @@
/* status palette */ /* status palette */
--c-status-port: #2563eb; --c-status-port: #2563eb;
--c-status-builtin: #16a34a; --c-status-builtin: #15803d;
--c-status-drop: #6b7280; --c-status-drop: #4b5563;
--c-status-saas: #d97706; --c-status-saas: #b45309;
--c-status-gated: #b91c1c; --c-status-gated: #b91c1c;
--c-status-done: #047857; --c-status-done: #047857;
--c-status-proposed: #7c3aed; --c-status-proposed: #7c3aed;
@@ -23,8 +23,10 @@
--fs-h2: clamp(1.4rem, 3vw, 1.9rem); --fs-h2: clamp(1.4rem, 3vw, 1.9rem);
--fs-h3: 1.25rem; --fs-h3: 1.25rem;
--fs-h4: 1.1rem; --fs-h4: 1.1rem;
--fs-card-title: 1.05rem;
--radius: 6px; --radius: 6px;
color-scheme: light;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -49,6 +51,13 @@ h4 { font-size: var(--fs-h4); }
a { color: var(--c-link); text-decoration: none; } a { color: var(--c-link); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
:where(a, button, input, select, [tabindex]):focus-visible { outline: 2px solid var(--c-link); outline-offset: 2px; }
.skip-link { position: absolute; left: -9999px; top: 0; background: var(--c-link); color: #fff; padding: 0.5rem 0.9rem; border-radius: var(--radius); z-index: 10; }
.skip-link:focus { left: 0.5rem; top: 0.5rem; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.container { .container {
max-width: 1100px; max-width: 1100px;
margin: 0 auto; margin: 0 auto;
@@ -110,12 +119,15 @@ footer.site {
border-radius: var(--radius); border-radius: var(--radius);
background: white; background: white;
} }
.toolbar input[type="search"] { min-width: 240px; } .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:hover { filter: brightness(1.08); }
.toolbar a { font: inherit; padding: 0.4rem 0.6rem; border-radius: var(--radius); }
.plugin-grid { .plugin-grid {
list-style: none; padding: 0; margin: 0; list-style: none; padding: 0; margin: 0;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr));
gap: 1rem; gap: 1rem;
} }
.plugin-card { .plugin-card {
@@ -124,18 +136,23 @@ footer.site {
padding: 1rem 1.1rem; padding: 1rem 1.1rem;
background: var(--c-bg); background: var(--c-bg);
display: flex; flex-direction: column; gap: 0.5rem; display: flex; flex-direction: column; gap: 0.5rem;
transition: border-color .15s, box-shadow .15s;
} }
.plugin-card h3 { margin: 0; font-size: 1.05rem; } .plugin-card:hover { border-color: var(--c-link); box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.plugin-card h3 { margin: 0; font-size: var(--fs-card-title); }
.plugin-card h3 a { color: var(--c-heading); } .plugin-card h3 a { color: var(--c-heading); }
.plugin-card .meta { font-size: 0.78rem; color: var(--c-muted); display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; } .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); } .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; }
/* detail page */ /* detail page */
.detail header { border-bottom: 1px solid var(--c-border); padding-bottom: 1rem; margin-bottom: 1.5rem; } .detail header { border-bottom: 1px solid var(--c-border); padding-bottom: 1rem; margin-bottom: 1.5rem; }
.detail .crumbs { color: var(--c-muted); font-size: var(--fs-sm); margin-bottom: 0.25rem; } .detail .crumbs { color: var(--c-muted); font-size: var(--fs-sm); margin-bottom: 0.25rem; }
.detail dl { display: grid; grid-template-columns: max-content 1fr; gap: 0.4rem 1rem; margin: 0 0 1.5rem; } .detail dl { display: grid; grid-template-columns: max-content 1fr; gap: 0.4rem 1rem; margin: 0 0 1.5rem; }
.detail dt { color: var(--c-muted); font-size: var(--fs-sm); } .detail dt { color: var(--c-muted); font-size: var(--fs-sm); }
.detail dd { margin: 0; } .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); }
.section-gap { margin-top: 2rem; }
.detail .notes { line-height: 1.65; } .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; } .detail .notes pre { background: var(--c-bg-alt); padding: 0.6rem 0.8rem; border-radius: var(--radius); overflow-x: auto; }
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"extends": "astro/tsconfigs/base", "extends": "astro/tsconfigs/strict",
"compilerOptions": { "compilerOptions": {
"types": ["node"] "types": ["node"]
}, },