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:
@@ -90,3 +90,6 @@ cd app && npm run typecheck
|
||||
- Don't change branch promotion semantics (fast-forward only across
|
||||
`develop` → `staging` → `production`). Mirroring emdash-kotkanagrilli's
|
||||
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`).
|
||||
|
||||
@@ -15,6 +15,7 @@ WORKDIR /app
|
||||
COPY app/ ./
|
||||
RUN rm -f data.db data.db-shm data.db-wal && rm -rf uploads
|
||||
RUN npm run build
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
FROM node:22-bookworm-slim AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import node from "@astrojs/node";
|
||||
import react from "@astrojs/react";
|
||||
import { defineConfig, memoryCache } from "astro/config";
|
||||
import emdash, { local } from "emdash/astro";
|
||||
import { sqlite } from "emdash/db";
|
||||
@@ -27,7 +26,6 @@ export default defineConfig({
|
||||
responsiveStyles: true,
|
||||
},
|
||||
integrations: [
|
||||
react(),
|
||||
emdash({
|
||||
database: sqlite({ url: `file:${stateDir}/data.db` }),
|
||||
storage: local({
|
||||
|
||||
Generated
+10590
File diff suppressed because it is too large
Load Diff
+1
-10
@@ -17,20 +17,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^10.0.5",
|
||||
"@astrojs/react": "^5.0.0",
|
||||
"astro": "^6.3.0",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"emdash": "^0.10.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"emdash": "^0.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.7"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"better-sqlite3",
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
---
|
||||
import { STATUS_LABELS, STATUSES } from "../lib/statuses";
|
||||
interface Props {
|
||||
status: string | null | undefined;
|
||||
}
|
||||
const { status } = Astro.props;
|
||||
const value = (status ?? "proposed").toLowerCase();
|
||||
const labels: Record<string, string> = {
|
||||
port: "Port",
|
||||
"built-in": "Built-in",
|
||||
drop: "Drop",
|
||||
saas: "SaaS",
|
||||
gated: "Gated",
|
||||
done: "Done",
|
||||
proposed: "Proposed",
|
||||
};
|
||||
const label = labels[value] ?? value;
|
||||
const label = STATUS_LABELS[value] ?? value;
|
||||
const desc = STATUSES.find((s) => s.value === value)?.desc;
|
||||
---
|
||||
<span class={`badge badge--${value}`}>{label}</span>
|
||||
<span class={`badge badge--${value}`} title={desc}><span class="sr-only">Status: </span>{label}</span>
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
---
|
||||
import StatusBadge from "./StatusBadge.astro";
|
||||
const items = [
|
||||
{ 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" },
|
||||
];
|
||||
import { STATUSES } from "../lib/statuses";
|
||||
---
|
||||
<div class="legend">
|
||||
{items.map((i) => (
|
||||
<span class="item"><StatusBadge status={i.status} /> {i.desc}</span>
|
||||
{STATUSES.map((i) => (
|
||||
<span class="item"><StatusBadge status={i.value} /> {i.desc}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Vendored
+42
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,10 @@ interface Props {
|
||||
title: string;
|
||||
description?: 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 siteTitle = settings?.title ?? "CMS plugins 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 pageDescription = description ?? siteTagline;
|
||||
|
||||
const canonicalBase = Astro.site ?? new URL(Astro.url.origin);
|
||||
const canonical = new URL(Astro.url.pathname, canonicalBase).href;
|
||||
|
||||
const pageCtx = createPublicPageContext({
|
||||
Astro,
|
||||
kind: content ? "content" : "custom",
|
||||
pageType: "website",
|
||||
pageType: content ? "article" : "website",
|
||||
title: fullTitle,
|
||||
pageTitle: title,
|
||||
description: pageDescription,
|
||||
content,
|
||||
siteName: siteTitle,
|
||||
canonical,
|
||||
breadcrumbs,
|
||||
});
|
||||
---
|
||||
|
||||
@@ -36,10 +42,10 @@ const pageCtx = createPublicPageContext({
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{fullTitle}</title>
|
||||
<meta name="description" content={pageDescription} />
|
||||
<EmDashHead page={pageCtx} />
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main" class="skip-link">Skip to content</a>
|
||||
<EmDashBodyStart page={pageCtx} />
|
||||
<header class="site">
|
||||
<div class="container row">
|
||||
@@ -51,7 +57,7 @@ const pageCtx = createPublicPageContext({
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<main id="main">
|
||||
<div class="container">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
---
|
||||
import Base from "../layouts/Base.astro";
|
||||
Astro.response.status = 404;
|
||||
---
|
||||
<Base title="Not found">
|
||||
<h1>Not found</h1>
|
||||
|
||||
@@ -6,15 +6,24 @@ import Base from "../layouts/Base.astro";
|
||||
export const prerender = false;
|
||||
|
||||
const slug = decodeSlug(Astro.params.slug);
|
||||
if (!slug) return Astro.redirect("/404");
|
||||
|
||||
const { entry, cacheHint } = await getEmDashEntry("pages", slug);
|
||||
if (!entry) return Astro.redirect("/404");
|
||||
Astro.cache.set(cacheHint);
|
||||
const { entry, cacheHint } = slug
|
||||
? await getEmDashEntry("pages", slug)
|
||||
: { entry: null, cacheHint: undefined };
|
||||
if (!slug || !entry) {
|
||||
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 }}>
|
||||
<article class="detail">
|
||||
<h1>{entry.data.title}</h1>
|
||||
{entry.data.content && <div class="notes"><PortableText value={entry.data.content} /></div>}
|
||||
</article>
|
||||
</Base>
|
||||
)}
|
||||
|
||||
@@ -6,31 +6,51 @@ import PluginCard from "../../components/PluginCard.astro";
|
||||
export const prerender = false;
|
||||
|
||||
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);
|
||||
if (!cms) return Astro.redirect("/404");
|
||||
Astro.cache.set(cacheHint);
|
||||
|
||||
const { entries: all } = await getEmDashCollection("plugins", {
|
||||
orderBy: { title: "asc" },
|
||||
limit: 9999,
|
||||
});
|
||||
const cmsName = cms.data.title as string;
|
||||
const plugins = all.filter((p) => p.data.source_cms === cmsName);
|
||||
const PLUGIN_FETCH_CAP = 10000;
|
||||
const { entries: all } = cms
|
||||
? await getEmDashCollection("plugins", { orderBy: { title: "asc" }, limit: PLUGIN_FETCH_CAP })
|
||||
: { entries: [] };
|
||||
if (all.length >= PLUGIN_FETCH_CAP) console.warn("[cms] plugin fetch hit cap", PLUGIN_FETCH_CAP, "- counts/lists may be truncated");
|
||||
const cmsName = cms?.data.title as string;
|
||||
const fromHere = all.filter((p) => p.data.source_cms === cmsName);
|
||||
const targetingHere = all.filter((p) => p.data.target_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>
|
||||
<h1>{cmsName}</h1>
|
||||
{cms.data.website && <p><a href={cms.data.website}>{cms.data.website}</a></p>}
|
||||
{cms.data.description && <p>{cms.data.description}</p>}
|
||||
|
||||
<h2 style="margin-top:2rem">Plugins ({plugins.length})</h2>
|
||||
{plugins.length === 0 ? (
|
||||
<h2 class="section-gap">Plugins from this CMS ({fromHere.length})</h2>
|
||||
{fromHere.length === 0 ? (
|
||||
<p class="empty">No plugins cataloged for this CMS yet.</p>
|
||||
) : (
|
||||
<ul class="plugin-grid">
|
||||
{plugins.map((entry) => <PluginCard entry={entry} />)}
|
||||
{fromHere.map((entry) => <PluginCard entry={entry} />)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -9,11 +9,16 @@ const { entries: cmses, cacheHint } = await getEmDashCollection("cmses", {
|
||||
});
|
||||
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 countByTarget = new Map<string, number>();
|
||||
for (const p of plugins) {
|
||||
const s = p.data.source_cms;
|
||||
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.">
|
||||
@@ -22,7 +27,7 @@ for (const p of plugins) {
|
||||
{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} 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>}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
@@ -17,7 +18,8 @@ const statusFilter = url.searchParams.get("status") ?? "";
|
||||
const sourceFilter = url.searchParams.get("source") ?? "";
|
||||
|
||||
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 d = p.data;
|
||||
@@ -29,22 +31,22 @@ const filtered = plugins.filter((p) => {
|
||||
---
|
||||
<Base title="Plugins" description="Browse cataloged CMS plugins and their migration status.">
|
||||
<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 />
|
||||
|
||||
<form class="toolbar" method="get">
|
||||
<input type="search" name="q" placeholder="Search…" value={q} />
|
||||
<select name="source">
|
||||
<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()">
|
||||
<option value="">All source CMSes</option>
|
||||
{sources.map((s) => (
|
||||
<option value={s} selected={s === sourceFilter}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<select name="status">
|
||||
<select name="status" aria-label="Filter by status" onchange="this.form.submit()">
|
||||
<option value="">All statuses</option>
|
||||
{statuses.map((s) => (
|
||||
<option value={s} selected={s === statusFilter}>{s}</option>
|
||||
<option value={s.value} selected={s.value === statusFilter}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit">Apply</button>
|
||||
@@ -52,7 +54,7 @@ const filtered = plugins.filter((p) => {
|
||||
</form>
|
||||
|
||||
{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">
|
||||
{filtered.map((entry) => <PluginCard entry={entry} />)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import { getEmDashEntry, decodeSlug } from "emdash";
|
||||
import { getEmDashCollection, getEmDashEntry, decodeSlug } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import StatusBadge from "../../components/StatusBadge.astro";
|
||||
@@ -7,27 +7,44 @@ import StatusBadge from "../../components/StatusBadge.astro";
|
||||
export const prerender = false;
|
||||
|
||||
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);
|
||||
if (!entry) return Astro.redirect("/404");
|
||||
Astro.cache.set(cacheHint);
|
||||
const d = entry?.data;
|
||||
|
||||
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">
|
||||
<header>
|
||||
<p class="crumbs">
|
||||
<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>
|
||||
<h1>{d.title}</h1>
|
||||
<p>
|
||||
<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>
|
||||
{d.purpose && <p style="font-size:1.05rem">{d.purpose}</p>}
|
||||
{d.purpose && <p class="lead">{d.purpose}</p>}
|
||||
</header>
|
||||
|
||||
<dl>
|
||||
@@ -46,3 +63,4 @@ const d = entry.data;
|
||||
)}
|
||||
</article>
|
||||
</Base>
|
||||
)}
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
|
||||
/* status palette */
|
||||
--c-status-port: #2563eb;
|
||||
--c-status-builtin: #16a34a;
|
||||
--c-status-drop: #6b7280;
|
||||
--c-status-saas: #d97706;
|
||||
--c-status-builtin: #15803d;
|
||||
--c-status-drop: #4b5563;
|
||||
--c-status-saas: #b45309;
|
||||
--c-status-gated: #b91c1c;
|
||||
--c-status-done: #047857;
|
||||
--c-status-proposed: #7c3aed;
|
||||
@@ -23,8 +23,10 @@
|
||||
--fs-h2: clamp(1.4rem, 3vw, 1.9rem);
|
||||
--fs-h3: 1.25rem;
|
||||
--fs-h4: 1.1rem;
|
||||
--fs-card-title: 1.05rem;
|
||||
|
||||
--radius: 6px;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@@ -49,6 +51,13 @@ h4 { font-size: var(--fs-h4); }
|
||||
a { color: var(--c-link); text-decoration: none; }
|
||||
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 {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
@@ -110,12 +119,15 @@ footer.site {
|
||||
border-radius: var(--radius);
|
||||
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 {
|
||||
list-style: none; padding: 0; margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.plugin-card {
|
||||
@@ -124,18 +136,23 @@ footer.site {
|
||||
padding: 1rem 1.1rem;
|
||||
background: var(--c-bg);
|
||||
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 .meta { font-size: 0.78rem; 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 .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; }
|
||||
|
||||
/* detail page */
|
||||
.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 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 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 pre { background: var(--c-bg-alt); padding: 0.6rem 0.8rem; border-radius: var(--radius); overflow-x: auto; }
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/base",
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user