website: plugins page spacing, featured section, media with video, and viewer (#6254)

* website: match left and right edge padding on ranked plugin rows

The rank sat right-aligned in a fixed 16px column and the row had 8px of
padding, so a single digit started ~15.6px from the row edge while the
install count ended 8px from it. The rank now sits at the row's padding,
which is 16px on both sides, and the list's negative margin matches so
the rank still lines up with the section heading. Tabular figures keep
the tile column aligned without the fixed width.

* website: give plugins section headings room above their content

Section headings sat 16px above their cards while sections sat 56px
apart, so a heading read as closer to the section above it. A shared
PluginSection now owns the header-to-content gap (32px), used by the
index sections and by the browse results under the sort tabs.

* website: center the Build your own plugin card

The heading, description, and buttons were left-aligned in a full-width
card, leaving most of it empty. Center them and keep the description at
its readable measure.

* website: order What's new by first-listing date

newestFirst and addedAgo read submittedAt. The registry publishes the
first-listing date as publishedAt, so What's new now sorts and dates by
it. The fixture's publishedAt values become first-listing dates.

* website: plugin media with video tiles and a click-to-view viewer

The registry's published screenshots field becomes media and may hold
mp4 and webm videos. Cards and link previews use the first image, the
thumbnail route serves only image entries, and the detail gallery shows
videos as tiles alongside images. Any tile opens one viewer. An image
fits the viewport but never exceeds its natural size. A video plays with
controls and scales to the largest size that fits, small sources included.

The daemon resolver now validates only the ID and artifact it installs.

* server: drop the unreleased pluginRegistryEnabled config key

The key only existed in unreleased betas and nothing reads it. The
config schema is strict, so a config that still sets it now fails
validation and names the key.

* website: Featured section on the plugins index

The registry index gains a required featured list of plugin IDs. The
index shows those plugins above What's new, in the registry's order,
skipping IDs that are not listed, and hides the section when none are.
The index sections now share one container for their spacing.
This commit is contained in:
Mohamed Boudra
2026-10-06 20:53:34 +01:00
committed by GitHub
parent 05637a7f05
commit 3c478e5967
23 changed files with 610 additions and 222 deletions
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { pluginMediaKind } from "./plugin-registry.js";
describe("plugin media kind", () => {
it.each([
["https://cdn.example.com/a/preview.png", "image"],
["https://cdn.example.com/a/preview.JPG", "image"],
["https://cdn.example.com/a/preview.jpeg", "image"],
["https://cdn.example.com/a/preview.webp", "image"],
["https://cdn.example.com/a/preview.gif", "image"],
["https://cdn.example.com/a/demo.mp4", "video"],
["https://cdn.example.com/a/demo.WebM", "video"],
["https://cdn.example.com/a/demo.mp4?raw=true#t=2", "video"],
["https://cdn.example.com/mp4/preview.png?format=mp4", "image"],
] as const)("reads %s as %s from its path extension", (url, kind) => {
expect(pluginMediaKind(url)).toBe(kind);
});
});
+11 -1
View File
@@ -39,13 +39,21 @@ export const PublishedPluginSchema = z.object({
repository: z.object({ url: z.string().url(), commit: z.string().optional() }),
artifact: PluginRegistryArtifactSchema,
icon: z.string().url().optional(),
screenshots: z.array(z.string().url()),
/** Images and videos in display order; see `pluginMediaKind`. */
media: z.array(z.string().url()),
submittedAt: z.string(),
reviewedAt: z.string(),
updatedAt: z.string(),
publishedAt: z.string(),
installs: z.number().int().nonnegative().optional(),
});
const VIDEO_EXTENSIONS = new Set(["mp4", "webm"]);
/** Whether a registry media entry is a video or an image, from its path extension. */
export function pluginMediaKind(url: string): "image" | "video" {
const extension = new URL(url).pathname.split(".").pop()?.toLowerCase() ?? "";
return VIDEO_EXTENSIONS.has(extension) ? "video" : "image";
}
export const PublishedPluginDetailSchema = PublishedPluginSchema.extend({ readme: z.string() });
export const PluginRegistryIndexSchema = z.object({
schemaVersion: z.literal(1),
@@ -54,6 +62,8 @@ export const PluginRegistryIndexSchema = z.object({
z.object({ slug: z.string(), label: z.string(), description: z.string().optional() }),
),
plugins: z.array(PublishedPluginSchema),
/** Plugin IDs the maintainer features, in display order. An ID may name a plugin that is not listed. */
featured: z.array(z.string()),
generatedAt: z.string(),
});
export type PluginRegistryIdentity = z.infer<typeof PluginRegistryIdentitySchema>;
+2 -9
View File
@@ -205,19 +205,12 @@ test("loads private plugin registry settings through the configuration boundary"
const home = await mkdtemp(path.join(os.tmpdir(), "paseo-registry-config-"));
try {
const pluginRegistries = { "plugins.example.test": { authorization: "Bearer fixture" } };
await writeFile(
path.join(home, "config.json"),
JSON.stringify({ pluginRegistries, pluginRegistryEnabled: false }),
);
await writeFile(path.join(home, "config.json"), JSON.stringify({ pluginRegistries }));
const config = loadConfig(home, {
env: {
PASEO_PLUGIN_REGISTRY: "https://plugins.example.test/internal",
PASEO_PLUGIN_REGISTRY_ENABLED: "false",
},
env: { PASEO_PLUGIN_REGISTRY: "https://plugins.example.test/internal" },
});
expect(config.pluginRegistryUrl).toBe("https://plugins.example.test/internal");
expect(config.pluginRegistries).toEqual(pluginRegistries);
expect(config).not.toHaveProperty("pluginRegistryEnabled");
} finally {
await rm(home, { recursive: true, force: true });
}
@@ -308,8 +308,6 @@ export const PersistedConfigSchema = z
providers: ProvidersSchema.optional(),
pluginRegistries: PluginRegistriesSchema.optional(),
// COMPAT(plugin-registry-gate): added in v0.11.0; accept the ignored startup key until 2027-04-06.
pluginRegistryEnabled: z.boolean().optional(),
pluginsEnabled: z.boolean().optional(),
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
worktrees: WorktreesConfigSchema.optional(),
@@ -494,7 +494,7 @@ describe("PluginService", () => {
commit: stdout.trim(),
pluginPath: ".",
},
screenshots: [],
media: [],
submittedAt: "2026-10-03",
reviewedAt: "2026-10-03",
updatedAt: "2026-10-03",
@@ -433,7 +433,7 @@ describe("registry plugin sources", () => {
commit,
pluginPath: "packages/example",
},
screenshots: [],
media: [],
submittedAt: "2026-10-03",
reviewedAt: "2026-10-03",
updatedAt: "2026-10-03",
@@ -516,20 +516,10 @@ it("installs and updates only the npm artifacts pinned by the plugin registry",
const server = createServer((request, response) => {
intents.push(request.headers["x-paseo-install"]);
response.end(
// Install reads only the ID and artifact, so the document omits the directory's display fields.
JSON.stringify({
id: "acme/example",
name: "Example",
description: "Test",
categories: [],
author: { github: "acme" },
repository: { url: "https://github.com/acme/example" },
artifact: { kind: "npm", package: "paseo-fixture-plugin", ...pin },
screenshots: [],
submittedAt: "2026-10-03",
reviewedAt: "2026-10-03",
updatedAt: "2026-10-03",
publishedAt: "2026-10-03",
readme: "# Example",
}),
);
});
@@ -1,9 +1,17 @@
import {
PublishedPluginDetailSchema,
PluginRegistryArtifactSchema,
PluginRegistryIdSchema,
type PluginRegistries,
type PluginRegistryIdentity,
} from "@getpaseo/protocol/plugin-registry";
import type { PluginUpdateTarget } from "@getpaseo/protocol/messages";
import { z } from "zod";
// Install reads only these fields; the rest of a published plugin is the directory's display data.
const RegistryInstallDocumentSchema = z.object({
id: PluginRegistryIdSchema,
artifact: PluginRegistryArtifactSchema,
});
export interface RegistryOptions {
defaultUrl?: string;
@@ -49,7 +57,7 @@ export async function resolveRegistryPlugin(
}
if (!response.ok)
throw new Error(`Registry ${base.host} returned ${response.status} for ${identity.id}`);
const plugin = PublishedPluginDetailSchema.parse(await response.json());
const plugin = RegistryInstallDocumentSchema.parse(await response.json());
if (plugin.id !== identity.id) throw new Error("Registry returned a different plugin ID");
const artifact = plugin.artifact;
const target: PluginUpdateTarget =
+1 -1
View File
@@ -18,7 +18,7 @@
"github": "security",
"name": "</script><script>globalThis.__overviewExecuted=1</script><img src=x onerror=\"globalThis.__overviewExecuted=1\">"
},
"screenshots": [
"media": [
"data:image/svg+xml,<svg onload=\"globalThis.__overviewExecuted=1\"/>",
"http://example.com/image.png"
],
+97 -1
View File
@@ -312,7 +312,7 @@ test.describe("registry fixture layout", () => {
page,
}) => {
const plugin = registry.plugins.find((entry) => entry.id === "alhassanaraouf/base2tone")!;
const source = plugin.screenshots[0];
const source = plugin.media[0];
await openPlugins(page);
await expectThumbnailCard(
page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Base2Tone/ }),
@@ -328,6 +328,85 @@ test.describe("registry fixture layout", () => {
).toHaveAttribute("src", source);
});
test("shows images and videos as gallery tiles in registry order and opens each in the viewer", async ({
page,
}) => {
const plugin = registry.plugins.find((entry) => entry.id === "omercnet/dracula")!;
const [firstImage, video] = plugin.media;
await page.goto(`/plugins/${plugin.id}`);
const tiles = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ });
await expect(tiles).toHaveCount(3);
const layout = await tiles.evaluateAll(galleryTiles);
expect(layout.map((tile) => tile.href)).toEqual(plugin.media);
expect(new Set(layout.map((tile) => tile.size)).size).toBe(1);
const preview = tiles.nth(1).locator("video");
await expect(preview).toHaveAttribute("preload", "metadata");
await expect(preview).not.toHaveAttribute("controls");
await expect(preview).not.toHaveAttribute("autoplay");
expect(await preview.evaluate((element: HTMLVideoElement) => element.muted)).toBe(true);
const viewer = page.getByRole("dialog");
await tiles.nth(1).click();
const playing = viewer.getByLabel("Dracula video 2");
await expect(playing).toHaveAttribute("src", video);
await expect(playing).toHaveAttribute("controls", "");
await expect.poll(() => playing.evaluate(isPaused)).toBe(false);
// The fixture video is 320x240, so it has to scale up to fill the viewer.
await expect.poll(() => playing.evaluate(viewerFill)).toBeCloseTo(1, 2);
await page.keyboard.press("Escape");
await expect(viewer).toBeHidden();
await expect(page.locator("dialog video")).toHaveCount(0);
await tiles.first().click();
await expect(viewer.getByRole("img", { name: "Dracula screenshot 1" })).toHaveAttribute(
"src",
firstImage,
);
await viewer.getByRole("button", { name: "Close" }).click();
await expect(viewer).toBeHidden();
expect(page.context().pages()).toHaveLength(1);
const newTab = page.context().waitForEvent("page");
await tiles.first().click({ modifiers: ["ControlOrMeta"] });
await expect(await newTab).toHaveURL(firstImage);
await expect(viewer).toBeHidden();
});
test("keeps a video that fails to load visible in the viewer", async ({ page }) => {
const plugin = registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!;
await page.route(plugin.media[0], (route) => route.abort());
await page.goto(`/plugins/${plugin.id}`);
await page.getByRole("link", { name: "launchd Jobs video 1" }).click();
const video = page.getByRole("dialog").getByLabel("launchd Jobs video 1");
await expect(video).toBeVisible();
await expect(video).toHaveCSS("opacity", "1");
});
test("shows the plugin tile on cards when its media has no image", async ({ page }) => {
await page.goto("/plugins/all");
const card = page.getByRole("main").getByRole("link", { name: /launchd Jobs/ });
await expect(card.locator("img, video")).toHaveCount(0);
await card.click();
await expect(
page.getByRole("link", { name: "launchd Jobs video 1" }).locator("video"),
).toHaveAttribute(
"src",
registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!.media[0],
);
});
test("features listed plugins above What's new in the registry's order", async ({ page }) => {
await openPlugins(page);
await expect(page.getByRole("heading", { level: 2 }).first()).toHaveText("Featured");
const featured = page.getByRole("region", { name: "Featured" });
await expect(featured).toContainText("Hand-picked by the maintainer.");
await expect(featured.getByRole("link", { name: /Added/ })).toHaveText([
/Dracula/,
/Herald/,
/launchd Jobs/,
]);
});
test("lists the nine categories in order with counts, and the newest plugins first", async ({
page,
}) => {
@@ -343,6 +422,23 @@ test.describe("registry fixture layout", () => {
});
});
function galleryTiles(links: Element[]) {
return links.map((link) => ({
href: link.getAttribute("href"),
size: `${link.clientWidth}x${link.clientHeight}`,
}));
}
/** The share of the viewer's room the media fills along its limiting axis; 1 fills it. */
function viewerFill(media: HTMLElement) {
const box = media.getBoundingClientRect();
return Math.max(box.width / (window.innerWidth * 0.9), box.height / (window.innerHeight * 0.85));
}
function isPaused(video: HTMLVideoElement) {
return video.paused;
}
async function expectThumbnailCard(card: Locator, source: string, id: string) {
// Card screenshots are decorative and hidden from the accessibility tree.
const image = card.locator("img").first();
+44 -37
View File
@@ -5,6 +5,12 @@
"url": "https://plugins.paseo.sh"
},
"generatedAt": "2026-10-04T15:55:47.882Z",
"featured": [
"omercnet/dracula",
"gpambrozio/herald",
"acme/not-published",
"gpambrozio/launchd-jobs"
],
"categories": [
{
"slug": "daemon-management",
@@ -74,12 +80,12 @@
"name": "alhassanaraouf",
"github": "alhassanaraouf"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/alhassanaraouf/paseo-themes/main/packages/paseo-base2tone-theme/preview.png"
],
"submittedAt": "2026-09-24T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-24T17:12:05.467Z",
"publishedAt": "2026-09-24T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Base2Tone\n\n## Behavior\n\nBase2Tone theme for Paseo, ported from the Obsidian theme deathau/Base2Tone-For-Obsidian.md."
@@ -106,13 +112,13 @@
"name": "Tom Lundy",
"github": "dorasto"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/sayr-plguin-1.png",
"https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/paseo-plugin-2.png"
],
"submittedAt": "2026-09-22T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-22T10:39:28.935Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Sayr\n\n## Behavior\n\nA paseo plugin for viewing and managing your Sayr tasks"
@@ -139,13 +145,13 @@
"name": "gpambrozio",
"github": "gpambrozio"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/screenshot.png",
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/detail-panel.png"
],
"submittedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-10-01T17:46:20.500Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# GitHub Board\n\n## Behavior\n\nPaseo plugin: a GitHub sidebar board of your issues, draft and open pull requests, and discussions"
@@ -172,13 +178,13 @@
"name": "gpambrozio",
"github": "gpambrozio"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/panel.png",
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/timeline-card.png"
],
"submittedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-27T19:03:31.943Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Herald\n\n## Behavior\n\nPaseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel"
@@ -205,12 +211,12 @@
"name": "gpambrozio",
"github": "gpambrozio"
},
"screenshots": [
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/launchd-jobs/docs/screenshot.png"
"media": [
"https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.mp4"
],
"submittedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-22T17:56:41.061Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# launchd Jobs\n\n## Behavior\n\nPaseo plugin: schedule shell commands on your Mac through launchd, from a sidebar panel"
@@ -237,12 +243,12 @@
"name": "gpambrozio",
"github": "gpambrozio"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/model-pricing/docs/screenshot.png"
],
"submittedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-22T18:03:14.979Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Model Pricing\n\n## Behavior\n\nPaseo plugin: a sidebar table of what every model costs, across Anthropic, OpenAI, Fireworks AI, Ollama Cloud and OpenRouter"
@@ -269,10 +275,10 @@
"name": "gpambrozio",
"github": "gpambrozio"
},
"screenshots": [],
"media": [],
"submittedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-10-01T22:25:58.192Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Skills\n\n## Behavior\n\nPaseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it"
@@ -298,13 +304,13 @@
"name": "hungcuong9125",
"github": "hungcuong9125"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-provider-cli.png",
"https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-direct-api.png"
],
"submittedAt": "2026-09-22T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-30T02:57:56.473Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# PromptKit\n\n## Behavior\n\nPromptKit - rewrite and transform prompts directly from the Paseo composer."
@@ -331,13 +337,13 @@
"name": "Omer Cohen",
"github": "omercnet"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-roster.png",
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-settings.png"
],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-10-03T09:36:35.639Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Agent Monitor\n\n## Behavior\n\nA Paseo plugin that provides a host-wide agent triage roster."
@@ -364,13 +370,13 @@
"name": "Omer Cohen",
"github": "omercnet"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-wide.png",
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-compact.png"
],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-10-03T09:37:29.149Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Beads\n\n## Behavior\n\nA dependency-aware Beads work queue for each Paseo workspace."
@@ -397,13 +403,14 @@
"name": "Omer Cohen",
"github": "omercnet"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot.png",
"https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.webm",
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot-alucard.png"
],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-10-03T09:37:18.185Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Dracula\n\n## Behavior\n\nDracula Classic and Alucard Classic themes for Paseo."
@@ -430,13 +437,13 @@
"name": "Omer Cohen",
"github": "omercnet"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-behind.png",
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-settings.png"
],
"submittedAt": "2026-09-18T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-10-03T09:36:06.720Z",
"publishedAt": "2026-09-18T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Fresh Worktrees\n\n## Behavior\n\nFast-forwards clean local base branches before Paseo creates branch-off worktrees"
@@ -463,13 +470,13 @@
"name": "Omer Cohen",
"github": "omercnet"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/plugin-manager.png",
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/workspace-settings.png"
],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-10-03T09:36:36.267Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# OMP\n\n## Behavior\n\nPaseo integration for OMP, including its direct provider and workspace tooling."
@@ -496,10 +503,10 @@
"name": "Omer Cohen",
"github": "omercnet"
},
"screenshots": [],
"media": [],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-10-03T09:37:04.327Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# PR Radar\n\n## Behavior\n\nA viewer-aware delivery queue for pull requests linked to Paseo workspaces."
@@ -526,13 +533,13 @@
"name": "Omer Cohen",
"github": "omercnet"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-wide.png",
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-compact.png"
],
"submittedAt": "2026-09-18T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-10-03T09:38:48.289Z",
"publishedAt": "2026-09-18T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Shared Browser\n\n## Behavior\n\nOne real Chromium session per Paseo workspace, shared live across every connected client."
@@ -559,10 +566,10 @@
"name": "Omer Cohen",
"github": "omercnet"
},
"screenshots": [],
"media": [],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-10-03T09:36:15.719Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Tell Agent\n\n## Behavior\n\nSend messages to agents in other workspaces on the same Paseo host."
@@ -588,12 +595,12 @@
"name": "Tom Gringauz",
"github": "tomgrin10"
},
"screenshots": [
"media": [
"https://raw.githubusercontent.com/tomgrin10/paseo-defer/main/docs/defer-popover.png"
],
"submittedAt": "2026-09-22T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-27T09:59:16.553Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Defer\n\n## Behavior\n\nDefer messages to Paseo coding agents until a chosen time or usage-window reset."
@@ -619,10 +626,10 @@
"name": "Tom Gringauz",
"github": "tomgrin10"
},
"screenshots": [],
"media": [],
"submittedAt": "2026-09-22T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-23T10:36:50.112Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Graphite\n\n## Behavior\n\nWorkspace-native Graphite stack status and PR action radar for Paseo."
+35 -32
View File
@@ -5,6 +5,7 @@ import type { InstallCounts } from "./installs";
import { type BrowseQuery, browseHref } from "./links";
import { PLUGIN_GRID_CLASS, PluginCard } from "./plugin-card";
import { PluginSearch } from "./plugin-search";
import { PluginSection } from "./section";
import {
addedAgo,
CATEGORIES,
@@ -72,36 +73,38 @@ export function BrowsePage({
</a>
)}
</div>
<SortTabs query={query} />
{results.length === 0 && query.q && (
<div className="mt-8 rounded-xl border border-white/10 bg-white/[0.03] px-4 py-10 text-center">
<p className="text-sm text-muted-foreground">No plugins match.</p>
<a
href={clearHref}
className="mt-2 inline-block text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"
>
Clear filters
</a>
</div>
)}
{results.length === 0 && !query.q && (
<p className="mt-8 text-sm text-muted-foreground">No plugins in this category yet.</p>
)}
{results.length > 0 && (
<div className={`mt-8 ${PLUGIN_GRID_CLASS}`}>
{results.map((plugin) =>
query.sort === "new" ? (
<PluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
) : (
<PluginCard
key={plugin.id}
plugin={plugin}
installs={installs[plugin.id]?.[query.window] ?? 0}
/>
),
)}
</div>
)}
<PluginSection>
<SortTabs query={query} />
{results.length === 0 && query.q && (
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-4 py-10 text-center">
<p className="text-sm text-muted-foreground">No plugins match.</p>
<a
href={clearHref}
className="mt-2 inline-block text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground"
>
Clear filters
</a>
</div>
)}
{results.length === 0 && !query.q && (
<p className="text-sm text-muted-foreground">No plugins in this category yet.</p>
)}
{results.length > 0 && (
<div className={PLUGIN_GRID_CLASS}>
{results.map((plugin) =>
query.sort === "new" ? (
<PluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
) : (
<PluginCard
key={plugin.id}
plugin={plugin}
installs={installs[plugin.id]?.[query.window] ?? 0}
/>
),
)}
</div>
)}
</PluginSection>
</div>
</div>
</SiteShell>
@@ -186,7 +189,7 @@ function SortTabs({ query }: { query: BrowseQuery }) {
<WindowSwitch current={query.window} hrefs={windowHrefs} />
);
return (
<>
<div>
<div className="flex items-baseline justify-between gap-6 border-b border-white/10">
<div className="flex gap-6">
<a
@@ -207,6 +210,6 @@ function SortTabs({ query }: { query: BrowseQuery }) {
{windowSwitch && <div className="hidden pb-2 sm:block">{windowSwitch}</div>}
</div>
{windowSwitch && <div className="mt-4 sm:hidden">{windowSwitch}</div>}
</>
</div>
);
}
@@ -26,16 +26,16 @@ export function ContributeSection() {
return (
<section
aria-labelledby="build-your-own"
className="mt-20 rounded-xl border border-white/10 bg-white/[0.03] px-6 py-8 sm:px-8"
className="mt-20 rounded-xl border border-white/10 bg-white/[0.03] px-6 py-8 text-center sm:px-8"
>
<h2 id="build-your-own" className="text-lg font-medium">
Build your own plugin
</h2>
<p className="mt-2 max-w-lg text-sm text-muted-foreground">
<p className="mx-auto mt-2 max-w-lg text-sm text-muted-foreground">
Plugins add themes, workspace panels, slash commands, and agent hooks to Paseo. Write one
with the plugin docs, then submit it to get it listed here.
</p>
<div className="mt-6 flex flex-wrap gap-3">
<div className="mt-6 flex flex-wrap justify-center gap-3">
<a href={DOCS_URL} className={PRIMARY_BUTTON_CLASS}>
Read the docs
</a>
+7 -2
View File
@@ -11,14 +11,19 @@ import {
function registryBase(): string {
return (env as RegistryEnvironment).PLUGINS_REGISTRY_URL ?? "https://getpaseo.github.io/plugins";
}
/** Listed plugins in index order, their install counts per window, and the server's clock. */
/** Listed plugins in index order, featured plugin IDs, install counts per window, and the server's clock. */
export const getRegistry = createServerFn({ method: "GET" }).handler(async () => {
const context = getWebsiteCacheContext();
const [index, installs] = await Promise.all([
loadRegistryIndex(registryBase(), context),
loadInstallCounts(registryBase(), context),
]);
return { plugins: index.plugins, installs, now: new Date().toISOString() };
return {
plugins: index.plugins,
featured: index.featured,
installs,
now: new Date().toISOString(),
};
});
export const getRegistryPlugin = createServerFn({ method: "GET" })
.validator((id: string) => id)
@@ -0,0 +1,158 @@
import { pluginOverviewPolicy, pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import { pluginMediaKind } from "@getpaseo/protocol/plugin-registry";
import { Play, X } from "lucide-react";
import {
type CSSProperties,
type MouseEvent,
type SyntheticEvent,
useCallback,
useMemo,
useRef,
useState,
} from "react";
const TILE_CLASS =
"relative block aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%] md:w-[calc(50%-0.375rem)]";
// The viewer's room: media never exceeds this box, which leaves a margin around it.
const VIEW_WIDTH = "90vw";
const VIEW_HEIGHT = "85vh";
const VIEW_CLASS = "max-h-[85vh] max-w-[90vw] rounded-lg object-contain";
interface MediaItem {
url: string;
kind: "image" | "video";
/** Image alt text, or the video's accessible name. */
label: string;
}
/** Plugin media in registry order. Each tile opens the viewer; without JavaScript it links to the file. */
export function MediaGallery({ name, media }: { name: string; media: string[] }) {
const dialog = useRef<HTMLDialogElement>(null);
const [viewing, setViewing] = useState<MediaItem | null>(null);
const open = useCallback((item: MediaItem) => {
setViewing(item);
dialog.current?.showModal();
}, []);
const close = useCallback(() => dialog.current?.close(), []);
// Unmounting the viewed media stops a playing video.
const handleClose = useCallback(() => setViewing(null), []);
const closeOnBackdrop = useCallback((event: MouseEvent<HTMLDialogElement>) => {
if (event.target === event.currentTarget) event.currentTarget.close();
}, []);
if (media.length === 0) return null;
return (
<>
<div className="-mx-6 mt-10 flex gap-3 overflow-x-auto px-6 md:mx-0 md:px-0">
{media.map((url, index) => {
const source = pluginOverviewUrl(url);
if (!source) return <p key={url}>{url}</p>;
const kind = pluginMediaKind(source);
const label = `${name} ${kind === "video" ? "video" : "screenshot"} ${index + 1}`;
return <MediaTile key={url} url={source} kind={kind} label={label} onOpen={open} />;
})}
</div>
<dialog
ref={dialog}
aria-label={viewing?.label}
onClose={handleClose}
onClick={closeOnBackdrop}
className="m-auto overflow-visible bg-transparent p-0 backdrop:bg-black/80"
>
{viewing?.kind === "image" && (
<img src={viewing.url} alt={viewing.label} className={VIEW_CLASS} />
)}
{viewing?.kind === "video" && <ViewerVideo url={viewing.url} label={viewing.label} />}
<button
type="button"
aria-label="Close"
onClick={close}
className="fixed right-4 top-4 rounded-full bg-black/60 p-2 text-white transition-colors hover:bg-black/80"
>
<X className="h-5 w-5" />
</button>
</dialog>
</>
);
}
/** Fills the viewer's room at the video's aspect ratio, scaling small sources up. */
function ViewerVideo({ url, label }: { url: string; label: string }) {
const [aspectRatio, setAspectRatio] = useState<number | null>(null);
const readAspectRatio = useCallback((event: SyntheticEvent<HTMLVideoElement>) => {
const { videoWidth, videoHeight } = event.currentTarget;
setAspectRatio(videoWidth / videoHeight);
}, []);
const style = useMemo<CSSProperties | undefined>(
() =>
aspectRatio === null
? undefined
: { aspectRatio, width: `min(${VIEW_WIDTH}, ${VIEW_HEIGHT} * ${aspectRatio})` },
[aspectRatio],
);
return (
<video
src={url}
aria-label={label}
controls
autoPlay
playsInline
onLoadedMetadata={readAspectRatio}
style={style}
className={VIEW_CLASS}
/>
);
}
function MediaTile({
url,
kind,
label,
onOpen,
}: MediaItem & { onOpen: (item: MediaItem) => void }) {
const handleClick = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
// Modified clicks keep the link's own behavior, such as opening the file in a new tab.
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
onOpen({ url, kind, label });
},
[onOpen, url, kind, label],
);
return (
<a
href={url}
{...pluginOverviewPolicy.link}
aria-label={kind === "video" ? label : undefined}
onClick={handleClick}
className={TILE_CLASS}
>
{kind === "image" ? (
<img
src={url}
alt={label}
loading="lazy"
className="h-full w-full object-cover object-top"
/>
) : (
<>
<video
src={url}
preload="metadata"
muted
playsInline
className="h-full w-full object-cover object-top"
/>
<span
aria-hidden
className="absolute inset-0 flex items-center justify-center bg-black/10"
>
<span className="rounded-full bg-black/60 p-3 text-white">
<Play className="h-5 w-5 fill-current" />
</span>
</span>
</>
)}
</a>
);
}
@@ -112,7 +112,7 @@ describe("registry images", () => {
"./icon.png",
"//example.com/x",
])("does not load %s in cards or icons", (url) => {
const plugin = { ...registry.plugins[0], icon: url, screenshots: [url] } as Plugin;
const plugin = { ...registry.plugins[0], icon: url, media: [url] } as Plugin;
for (const component of [
createElement(PluginTile, { plugin, size: "sm" }),
createElement(PluginCard, { plugin }),
@@ -129,7 +129,7 @@ it("uses the first HTTPS screenshot when an earlier screenshot is rejected", ()
const plugin = {
...registry.plugins[0],
icon: undefined,
screenshots: ["data:image/png;base64,AAAA", source],
media: ["data:image/png;base64,AAAA", source],
} as Plugin;
const nodes = elements(renderToStaticMarkup(createElement(PluginCard, { plugin })));
expect(
+5 -8
View File
@@ -1,16 +1,15 @@
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import { Download } from "lucide-react";
import { pluginCardScreenshot } from "./thumbnails";
import { pluginHref } from "./links";
import { PluginTile } from "./plugin-tile";
import { formatInstalls, getCategory, type Plugin } from "./registry";
import { firstMediaImage, formatInstalls, getCategory, type Plugin } from "./registry";
export const PLUGIN_GRID_CLASS =
"grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4";
/** First allowed screenshot, or the plugin tile on a quiet backdrop when there is none. */
/** First allowed image, or the plugin tile on a quiet backdrop when there is none. */
function PluginShot({ plugin }: { plugin: Plugin }) {
const url = plugin.screenshots.find(pluginOverviewUrl);
const url = firstMediaImage(plugin);
return (
<div
aria-hidden
@@ -101,11 +100,9 @@ export function PluginRankRow({
return (
<a
href={pluginHref(plugin.id)}
className="flex items-center gap-3 rounded-lg px-2 py-2.5 transition-colors hover:bg-white/[0.04]"
className="flex items-center gap-3 rounded-lg px-4 py-2.5 transition-colors hover:bg-white/[0.04]"
>
<span className="w-4 text-right text-sm tabular-nums text-extra-muted-foreground">
{rank}
</span>
<span className="text-sm tabular-nums text-extra-muted-foreground">{rank}</span>
<PluginTile plugin={plugin} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-white">{plugin.name}</p>
+45 -3
View File
@@ -2,6 +2,8 @@ import { createHash } from "node:crypto";
import { createServer } from "node:http";
import { describe, expect, it } from "vitest";
import {
addedAgo,
featuredPlugins,
formatInstalls,
installCommand,
mostInstalled,
@@ -26,7 +28,7 @@ const plugin: Plugin = {
resolved: "https://registry.npmjs.org/example.tgz",
integrity: "sha512-YWJj",
},
screenshots: [],
media: [],
submittedAt: "2026-10-03",
reviewedAt: "2026-10-03",
updatedAt: "2026-10-03",
@@ -40,6 +42,7 @@ describe("plugin registry", () => {
registry: { name: "Internal", url: "https://example.test" },
categories: [],
plugins: [plugin],
featured: [plugin.id],
generatedAt: "2026-10-03",
};
const detail = { ...plugin, readme: "# Example" };
@@ -60,8 +63,46 @@ describe("plugin registry", () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("orders by submission date and by installs in a window, keeping index order for ties", () => {
const older = { ...plugin, id: "acme/older", submittedAt: "2026-09-01" };
it("features listed plugins once each, in the registry's order, dropping IDs that are not listed", () => {
const listed = [
{ ...plugin, id: "acme/first" },
{ ...plugin, id: "acme/second" },
{ ...plugin, id: "acme/third" },
];
const featured = ["acme/third", "acme/not-published", "acme/first", "acme/third"];
expect(featuredPlugins(listed, featured).map((p) => p.id)).toEqual([
"acme/third",
"acme/first",
]);
expect(featuredPlugins(listed, ["acme/not-published"])).toEqual([]);
});
it("lists what's new by first-listing date, not submission or update dates", () => {
const listedFirst = {
...plugin,
id: "acme/listed-first",
submittedAt: "2026-09-20",
reviewedAt: "2026-09-21",
updatedAt: "2026-10-02",
publishedAt: "2026-09-02",
};
const listedLater = {
...plugin,
id: "acme/listed-later",
submittedAt: "2026-09-01",
reviewedAt: "2026-09-01",
updatedAt: "2026-09-01",
publishedAt: "2026-09-30",
};
expect(newestFirst([listedFirst, listedLater]).map((p) => p.id)).toEqual([
"acme/listed-later",
"acme/listed-first",
]);
const now = "2026-10-03T12:00:00.000Z";
expect(addedAgo(listedLater, now)).toBe("3d ago");
expect(addedAgo(listedFirst, now)).toBe("4w ago");
});
it("orders by listing date and by installs in a window, keeping index order for ties", () => {
const older = { ...plugin, id: "acme/older", publishedAt: "2026-09-01" };
const twin = { ...plugin, id: "acme/twin" };
expect(newestFirst([older, plugin, twin]).map((p) => p.id)).toEqual([
"acme/example",
@@ -155,6 +196,7 @@ describe("plugin registry", () => {
registry: { name: "Internal", url: base },
categories: [],
plugins: [plugin],
featured: [],
generatedAt: "2026-10-03",
},
}),
+15 -5
View File
@@ -1,5 +1,6 @@
import type { z } from "zod";
import type { PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry";
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import { pluginMediaKind, type PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry";
import { CATEGORIES, type Category, type CategorySlug } from "./categories";
import type { InstallCounts, InstallWindow } from "./installs";
export { CATEGORIES, type Category, type CategorySlug };
@@ -15,6 +16,10 @@ const DAY_MS = 24 * 60 * 60 * 1000;
export function getCategory(slug: string): Category | null {
return CATEGORIES.find((category) => category.slug === slug) ?? null;
}
/** The plugin's first HTTPS image, for cards and link previews; videos never stand in for one. */
export function firstMediaImage(plugin: Plugin): string | undefined {
return plugin.media.find((url) => pluginOverviewUrl(url) && pluginMediaKind(url) === "image");
}
export function getPluginsInCategory(plugins: Plugin[], slug: string): Plugin[] {
return plugins.filter((plugin) => plugin.categories.includes(slug));
}
@@ -72,9 +77,14 @@ export function searchPlugins(plugins: Plugin[], term: string): Plugin[] {
.includes(needle),
);
}
/** Newest submissions first; equal dates keep index order. */
/** The featured IDs that name a listed plugin, in the registry's order, each once. */
export function featuredPlugins(plugins: Plugin[], featured: string[]): Plugin[] {
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
return [...new Set(featured)].flatMap((id) => byId.get(id) ?? []);
}
/** Most recently listed first; equal dates keep index order. */
export function newestFirst(plugins: Plugin[]): Plugin[] {
return [...plugins].sort((a, b) => b.submittedAt.localeCompare(a.submittedAt));
return [...plugins].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
}
/** Most installs in the window first; equal counts keep index order. */
export function mostInstalled(
@@ -85,9 +95,9 @@ export function mostInstalled(
const count = (plugin: Plugin) => installs[plugin.id]?.[window] ?? 0;
return [...plugins].sort((a, b) => count(b) - count(a));
}
/** How long ago the registry accepted the plugin, relative to the loader's clock. */
/** How long ago the plugin was first listed, relative to the loader's clock. */
export function addedAgo(plugin: Plugin, now: string): string {
const days = Math.floor((Date.parse(now) - Date.parse(plugin.submittedAt)) / DAY_MS);
const days = Math.floor((Date.parse(now) - Date.parse(plugin.publishedAt)) / DAY_MS);
if (days <= 0) return "today";
if (days === 1) return "yesterday";
if (days < 14) return `${days}d ago`;
+35
View File
@@ -0,0 +1,35 @@
import type { ReactNode } from "react";
/** A block of a plugins page: its header, then its content one fixed gap below. */
export function PluginSection({
id,
labelledBy,
className = "",
children,
}: {
id?: string;
labelledBy?: string;
className?: string;
children: ReactNode;
}) {
return (
<section id={id} aria-labelledby={labelledBy} className={`flex flex-col gap-8 ${className}`}>
{children}
</section>
);
}
/** A section's title with its trailing controls on one baseline. */
export function PluginSectionHeader({ children }: { children: ReactNode }) {
return (
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">{children}</div>
);
}
export function PluginSectionTitle({ id, children }: { id: string; children: ReactNode }) {
return (
<h2 id={id} className="text-lg font-medium">
{children}
</h2>
);
}
@@ -6,6 +6,7 @@ import { NewPluginCard, PluginCard } from "./plugin-card";
import type { Plugin } from "./registry";
const source = "https://cdn.jsdelivr.net/npm/paseo-example@1.2.3/preview.png";
const video = "https://cdn.jsdelivr.net/npm/paseo-example@1.2.3/demo.mp4";
const plugin: Plugin = {
id: "acme/example",
name: "Example",
@@ -20,7 +21,7 @@ const plugin: Plugin = {
resolved: "https://registry.npmjs.org/example.tgz",
integrity: "sha512-YWJj",
},
screenshots: [source],
media: [source],
submittedAt: "2026-10-03",
reviewedAt: "2026-10-03",
updatedAt: "2026-10-03",
@@ -29,10 +30,27 @@ const plugin: Plugin = {
};
describe("plugin card thumbnails", () => {
it("uses the first image in the plugin's media, skipping a leading video", () => {
const html = renderToStaticMarkup(
createElement(PluginCard, { plugin: { ...plugin, media: [video, source] } }),
);
expect(html).toContain(`/plugins/thumb/592/${encodeURIComponent(source)}?`);
expect(html).not.toContain(encodeURIComponent(video));
});
it("shows the plugin tile when the media has no image", () => {
const html = renderToStaticMarkup(
createElement(PluginCard, { plugin: { ...plugin, media: [video] } }),
);
expect(html).not.toContain("<img");
expect(html).not.toContain("<video");
expect(html).toContain(">E</div>");
});
it("keeps a screenshot on an unsupported host visible at its original URL", () => {
const screenshot = "https://example.com/preview.png";
const html = renderToStaticMarkup(
createElement(PluginCard, { plugin: { ...plugin, screenshots: [screenshot] } }),
createElement(PluginCard, { plugin: { ...plugin, media: [screenshot] } }),
);
expect(html).toContain(`src="${screenshot}"`);
expect(html).not.toContain("/plugins/thumb/");
@@ -118,7 +136,7 @@ describe("thumbnail route", () => {
const upstream = imageFetch(imageResponse("thumbnail"));
const response = await handlePluginThumbnailRequest(
thumbnailRequest(screenshot, width, plugin.id, accept),
[{ ...plugin, screenshots: [screenshot] }],
[{ ...plugin, media: [screenshot] }],
upstream.fetchImage,
);
expect(upstream.calls).toEqual([
@@ -170,6 +188,7 @@ describe("thumbnail route", () => {
404,
],
["removed plugin", thumbnailRequest(), [], 404],
["listed video", thumbnailRequest(video), [{ ...plugin, media: [video, source] }], 404],
["malformed encoding", new Request("https://paseo.sh/plugins/thumb/592/%ZZ"), [plugin], 400],
["malformed URL", thumbnailRequest("not a URL"), [plugin], 400],
...[
@@ -183,7 +202,7 @@ describe("thumbnail route", () => {
[
"disallowed registered source",
thumbnailRequest(url),
[{ ...plugin, screenshots: [url] }],
[{ ...plugin, media: [url] }],
400,
] satisfies [string, Request, Plugin[], number],
),
@@ -225,7 +244,7 @@ describe("thumbnail route", () => {
);
const response = await handlePluginThumbnailRequest(
request,
[{ ...plugin, screenshots: [screenshot] }],
[{ ...plugin, media: [screenshot] }],
upstream.fetchImage,
);
expect(response.status).toBe(200);
+3 -2
View File
@@ -1,3 +1,4 @@
import { pluginMediaKind } from "@getpaseo/protocol/plugin-registry";
import type { Plugin } from "./registry";
// The single-column grid reaches 591px just below sm (640px minus 48px padding).
@@ -17,7 +18,7 @@ function pluginThumbnailUrl(id: string, source: string, scale: 1 | 2): string {
return `/plugins/thumb/${CARD_WIDTH * scale}/${encodeURIComponent(source)}?plugin=${encodeURIComponent(id)}`;
}
/** Only registry-listed screenshots are eligible for transformation or original fallback. */
/** Only images listed in the plugin's media are eligible for transformation or original fallback. */
export async function handlePluginThumbnailRequest(
request: Request,
plugins: Plugin[],
@@ -38,7 +39,7 @@ export async function handlePluginThumbnailRequest(
if (!source) return new Response("Invalid thumbnail source", { status: 400 });
const plugin = plugins.find((entry) => entry.id === url.searchParams.get("plugin"));
if (!plugin?.screenshots.includes(source))
if (!plugin?.media.includes(source) || pluginMediaKind(source) !== "image")
return new Response("Screenshot not found", { status: 404 });
const format = negotiatedFormat(request.headers.get("accept") ?? "");
@@ -2,10 +2,10 @@ import { createFileRoute, notFound } from "@tanstack/react-router";
import { ExternalLink } from "lucide-react";
import { CodeBlock } from "~/components/code-block";
import { PluginOverview, PluginContentLink } from "~/plugins/overview";
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import { SiteShell } from "~/components/site-shell";
import { pageMeta } from "~/meta";
import {
firstMediaImage,
formatInstalls,
getAuthor,
getCategory,
@@ -18,6 +18,7 @@ import {
} from "~/plugins";
import { AuthorLink } from "~/plugins/author-link";
import { categoryHref } from "~/plugins/links";
import { MediaGallery } from "~/plugins/media-gallery";
import { PluginsNotFound } from "~/plugins/not-found";
import "~/styles.css";
@@ -35,7 +36,7 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({
loaderData?.plugin ? `${loaderData.plugin.name} – Paseo plugin` : "Plugin not found – Paseo",
loaderData?.plugin?.description ?? "Plugin not found.",
`/plugins/${params.owner}/${params.slug}`,
loaderData?.plugin.screenshots.find(pluginOverviewUrl),
loaderData?.plugin && firstMediaImage(loaderData.plugin),
),
component: PluginPage,
notFoundComponent: () => (
@@ -88,28 +89,7 @@ function PluginPage() {
)}
</div>
{plugin.screenshots.length > 0 && (
<div className="-mx-6 mt-10 flex gap-3 overflow-x-auto px-6 md:mx-0 md:px-0">
{plugin.screenshots.map((url, index) =>
pluginOverviewUrl(url) ? (
<PluginContentLink
key={url}
href={url}
className="aspect-video w-[85%] flex-shrink-0 overflow-hidden rounded-xl border border-white/10 bg-white/[0.03] sm:w-[60%] md:w-[calc(50%-0.375rem)]"
>
<img
src={url}
alt={`${plugin.name} screenshot ${index + 1}`}
loading="lazy"
className="h-full w-full object-cover object-top"
/>
</PluginContentLink>
) : (
<p key={url}>{url}</p>
),
)}
</div>
)}
<MediaGallery name={plugin.name} media={plugin.media} />
<div className="mt-10 border-t border-white/10 pt-10">
<PluginOverview>{readmeBody(plugin.readme)}</PluginOverview>
+88 -70
View File
@@ -19,6 +19,7 @@ import {
addedAgo,
CATEGORIES,
type CategorySlug,
featuredPlugins,
getCategory,
getPluginsInCategory,
getRegistry,
@@ -40,6 +41,7 @@ import {
} from "~/plugins/links";
import { NewPluginCard, PluginRankRow } from "~/plugins/plugin-card";
import { PluginSearch } from "~/plugins/plugin-search";
import { PluginSection, PluginSectionHeader, PluginSectionTitle } from "~/plugins/section";
import { WindowSwitch } from "~/plugins/window-switch";
import "~/styles.css";
@@ -59,6 +61,9 @@ const CATEGORY_ICONS: Record<CategorySlug, LucideIcon> = {
};
const SUBMIT_CLASS = "text-sm text-muted-foreground transition-colors hover:text-foreground";
// What's new and Featured cards: a swipeable row on phones, a grid from `sm` up.
const CARD_ROW_CLASS =
"-mx-6 flex gap-4 overflow-x-auto px-6 pb-1 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 lg:grid-cols-4";
const SEE_ALL_CLASS =
"inline-flex items-center gap-0.5 text-sm text-muted-foreground transition-colors hover:text-foreground";
@@ -91,8 +96,9 @@ export const Route = createFileRoute("/plugins/")({
});
function PluginsPage() {
const { plugins, installs, now } = Route.useLoaderData();
const { plugins, featured: featuredIds, installs, now } = Route.useLoaderData();
const window = Route.useSearch().window ?? DEFAULT_WINDOW;
const featured = featuredPlugins(plugins, featuredIds);
const newest = newestFirst(plugins).slice(0, NEW_COUNT);
const top = mostInstalled(plugins, installs, window).slice(0, TOP_COUNT);
const windowHrefs = useMemo(
@@ -122,77 +128,89 @@ function PluginsPage() {
</div>
</div>
<section aria-labelledby="whats-new" className="mt-10">
<div className="mb-4 flex items-baseline justify-between gap-4">
<h2 id="whats-new" className="text-lg font-medium">
What’s new
</h2>
<a href={browseHref({ sort: "new", window: DEFAULT_WINDOW })} className={SEE_ALL_CLASS}>
See all
<ChevronRight className="h-3.5 w-3.5" />
</a>
</div>
<div className="-mx-6 flex gap-4 overflow-x-auto px-6 pb-1 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 lg:grid-cols-4">
{newest.map((plugin) => (
<NewPluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
))}
</div>
</section>
<div className="mt-10 flex flex-col gap-14">
{featured.length > 0 && (
<PluginSection labelledBy="featured">
<div>
<PluginSectionTitle id="featured">Featured</PluginSectionTitle>
<p className="mt-1 text-sm text-muted-foreground">Hand-picked by the maintainer.</p>
</div>
<div className={CARD_ROW_CLASS}>
{featured.map((plugin) => (
<NewPluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
))}
</div>
</PluginSection>
)}
<section aria-labelledby="categories" className="mt-14">
<h2 id="categories" className="mb-4 text-lg font-medium">
Categories
</h2>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{CATEGORIES.map((category) => {
const Icon = CATEGORY_ICONS[category.slug];
return (
<a
key={category.slug}
href={categoryHref(category.slug)}
className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-3.5 py-3.5 transition-colors hover:border-white/20 hover:bg-white/[0.05] sm:px-4"
>
<Icon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 text-sm leading-tight text-white">
{category.label}
</span>
<span className="text-xs tabular-nums text-extra-muted-foreground">
{getPluginsInCategory(plugins, category.slug).length}
</span>
</a>
);
})}
</div>
</section>
<section
id="most-installed"
aria-labelledby="most-installed-title"
className="mt-14 scroll-mt-8"
>
<div className="mb-4 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">
<h2 id="most-installed-title" className="text-lg font-medium">
<a
href={browseHref({ sort: "installs", window })}
className="group inline-flex items-center gap-1"
>
Most installed
<ChevronRight className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
<PluginSection labelledBy="whats-new">
<PluginSectionHeader>
<PluginSectionTitle id="whats-new">What’s new</PluginSectionTitle>
<a href={browseHref({ sort: "new", window: DEFAULT_WINDOW })} className={SEE_ALL_CLASS}>
See all
<ChevronRight className="h-3.5 w-3.5" />
</a>
</h2>
<WindowSwitch current={window} hrefs={windowHrefs} />
</div>
<div className="-mx-2 grid gap-x-8 md:grid-cols-2">
{top.map((plugin, index) => (
<PluginRankRow
key={plugin.id}
plugin={plugin}
rank={index + 1}
installs={installs[plugin.id]?.[window] ?? 0}
/>
))}
</div>
</section>
</PluginSectionHeader>
<div className={CARD_ROW_CLASS}>
{newest.map((plugin) => (
<NewPluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
))}
</div>
</PluginSection>
<PluginSection labelledBy="categories">
<PluginSectionTitle id="categories">Categories</PluginSectionTitle>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{CATEGORIES.map((category) => {
const Icon = CATEGORY_ICONS[category.slug];
return (
<a
key={category.slug}
href={categoryHref(category.slug)}
className="flex items-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-3.5 py-3.5 transition-colors hover:border-white/20 hover:bg-white/[0.05] sm:px-4"
>
<Icon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 text-sm leading-tight text-white">
{category.label}
</span>
<span className="text-xs tabular-nums text-extra-muted-foreground">
{getPluginsInCategory(plugins, category.slug).length}
</span>
</a>
);
})}
</div>
</PluginSection>
<PluginSection
id="most-installed"
labelledBy="most-installed-title"
className="scroll-mt-8"
>
<PluginSectionHeader>
<PluginSectionTitle id="most-installed-title">
<a
href={browseHref({ sort: "installs", window })}
className="group inline-flex items-center gap-1"
>
Most installed
<ChevronRight className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
</a>
</PluginSectionTitle>
<WindowSwitch current={window} hrefs={windowHrefs} />
</PluginSectionHeader>
<div className="-mx-4 grid gap-x-8 md:grid-cols-2">
{top.map((plugin, index) => (
<PluginRankRow
key={plugin.id}
plugin={plugin}
rank={index + 1}
installs={installs[plugin.id]?.[window] ?? 0}
/>
))}
</div>
</PluginSection>
</div>
<ContributeSection />
</SiteShell>