Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/features/plugins/MarketplacePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export function MarketplacePanel({
summary={reviewSummaries[item.id]}
locale={locale}
showDot={false}
showZero
/>
<ReviewTicker ticker={reviewTickers[item.id]} />
{installed && !upgradable ? (
Expand Down
11 changes: 10 additions & 1 deletion apps/desktop/src/features/plugins/ReviewTicker.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { PluginReviewTicker } from "@pi-desktop/shared";

/** How long one review line stays on screen before the next one fades in. */
Expand All @@ -13,6 +14,7 @@ const ADVANCE_MS = 6000;
* asked for reduced motion sees the first row only.
*/
export function ReviewTicker({ ticker }: { ticker?: PluginReviewTicker }) {
const { t } = useTranslation();
const rows = ticker?.rows ?? [];
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
Expand All @@ -39,7 +41,14 @@ export function ReviewTicker({ ticker }: { ticker?: PluginReviewTicker }) {
return () => window.clearInterval(handle);
}, [paused, reducedMotion, rows.length]);

if (rows.length === 0) return null;
// No reviews yet: show a fixed placeholder line instead of collapsing the slot.
if (rows.length === 0) {
return (
<span className="plugins-card-review is-empty">
{t("plugins.reviews.tickerEmpty")}
</span>
);
}
const row = rows[index] ?? rows[0];

return (
Expand Down
40 changes: 26 additions & 14 deletions apps/desktop/src/features/plugins/presentation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,42 +203,54 @@ export function AgentExtensionDetails({ status }: { status: PluginAgentExtension
}

/**
* The compact rating readout cards carry in their metadata line: one star,
* the average, and how many ratings it is worth. Nothing renders when the
* centre has no summary for the plugin — an unavailable service must not read
* as a zero rating.
* The compact rating readout cards carry. The count is not shown beside the
* average (the review line is where reviews live). Nothing renders without a
* summary at all — an unanswered or unreachable centre must never read as a
* zero rating, whether the read is still in flight or has failed. A caller
* that passes `showZero` keeps the chip visible only for the summary the centre
* actually returned with no ratings, reading a fixed 0.0.
*/
export function RatingChip({
summary,
locale,
showDot = true,
showZero = false,
}: {
summary: PluginReviewSummary | undefined;
locale: string;
/** The card footer separates rating from review text with its own rule. */
showDot?: boolean;
/** Render a fixed 0.0 when the centre reports a summary with no ratings. */
showZero?: boolean;
}) {
const { t } = useTranslation();
const avg = summary ? formatRatingAvg(summary.ratingAvg, locale) : null;
if (!summary || !avg || summary.ratingCount <= 0) return null;
if (!summary) return null;
const avg = formatRatingAvg(summary.ratingAvg, locale);
const rated = !!avg && summary.ratingCount > 0;
if (!rated && !showZero) return null;
const text = rated
? t("plugins.reviews.ratingChip", { avg })
: formatRatingAvg(0, locale);
return (
<span
className="plugins-rating"
role="img"
aria-label={t("plugins.reviews.ratingSummaryAria", {
avg,
count: summary.ratingCount,
})}
role={rated ? "img" : undefined}
aria-label={
rated
? t("plugins.reviews.ratingSummaryAria", {
avg,
count: summary.ratingCount,
})
: undefined
}
>
{showDot ? (
<span className="plugins-dot" aria-hidden>
·
</span>
) : null}
<IconStar size={11} style={{ fill: "currentColor" }} aria-hidden="true" />
<span className="plugins-rating-text">
{t("plugins.reviews.ratingChip", { avg, count: summary.ratingCount })}
</span>
<span className="plugins-rating-text">{text}</span>
</span>
);
}
Expand Down
24 changes: 23 additions & 1 deletion apps/desktop/src/styles/plugins.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
display: flex;
flex-direction: column;
gap: 14px;
/* Container query context for the cards below: the grid tracks the page
pane, not the window, so a narrow content area with a wide window still
collapses to one column. */
container-type: inline-size;
container-name: plugins-page;
}

/* The page header and segmented row form one quiet tool surface; air, not a
Expand Down Expand Up @@ -996,12 +1001,23 @@
color: var(--ds-text-primary);
}

/* Two columns at full width (was auto-fill, which grew to three or more on
wide windows). The query is on the page container, not the viewport: below
560px of pane width two columns would put each card under the ~272px the old
layout already treated as too tight, so the grid collapses to one column
even while the window itself is wide. */
.plugins-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(272px, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}

@container plugins-page (max-width: 560px) {
.plugins-card-grid {
grid-template-columns: 1fr;
}
}

/* Marketplace cards are tiles too: tint at rest, lifted by shadow on hover. */
.plugins-card {
display: flex;
Expand Down Expand Up @@ -1170,6 +1186,12 @@
background: var(--ds-border-subtle);
}

/* Fixed "no reviews yet" placeholder: not a ticker line, so no fade and a
quieter, italic voice. */
.plugins-card-review.is-empty {
font-style: italic;
}

.plugins-card-review-text {
min-width: 0;
overflow: hidden;
Expand Down
22 changes: 20 additions & 2 deletions apps/desktop/test/plugin-reviews-surface.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,25 @@ test("an installed card grows one call to action: update, then open panel", () =
test("installed and marketplace cards carry the rating chip in their metadata", () => {
assert.match(presentation, /export function RatingChip/);
assert.match(presentation, /formatRatingAvg/);
assert.match(presentation, /summary\.ratingCount <= 0/);
assert.match(marketPanel, /<RatingChip[\s\S]*?summary=\{reviewSummaries\[item\.id\]\}/);
// No summary, no chip — an in-flight or failed read never reads as 0.0.
assert.match(presentation, /if \(!summary\) return null;/);
assert.match(presentation, /summary\.ratingCount > 0/);
// The count never rides beside the average on the chip: the review line is
// where reviews live (marketplace cards whose summary reports no ratings read
// a fixed 0.0 via showZero; other callers keep that chip hidden).
assert.match(marketPanel, /<RatingChip[\s\S]*?summary=\{reviewSummaries\[item\.id\]\}[\s\S]*?showZero/);
assert.match(installedPanel, /<RatingChip[\s\S]*?summary=\{reviewSummaries\[plugin\.id\]\}/);
assert.doesNotMatch(installedPanel, /showZero/);
// A chip lives inside the existing metadata line, so the card grows no row.
assert.match(marketPanel, /plugins-card-meta[\s\S]*?<RatingChip/);
assert.match(installedPanel, /plugins-row-meta[\s\S]*?<RatingChip/);
// Locale catalogue: the chip text is the average alone, in every locale.
for (const locale of ["de", "en", "es", "fr", "ko", "pt-BR", "tr", "zh-CN", "zh-TW"]) {
const catalogue = read(`../../../packages/i18n/src/locales/${locale}/index.ts`);
assert.match(catalogue, /ratingChip: "\{\{avg\}\}",/);
assert.doesNotMatch(catalogue, /ratingChip: "[^"]*\{\{count\}\}/);
assert.match(catalogue, /tickerEmpty: /);
}
});

test("the installed card shows the trust glyph only from installed metadata", () => {
Expand Down Expand Up @@ -206,6 +219,11 @@ test("the review drawer keeps a real-Chromium user-path fixture", () => {
assert.match(harness, /pluginReviewsDrawerProbe/);
assert.match(harness, /pluginReviewsStaleSummaryProbe/);
assert.match(harness, /pluginReviewsUnavailableSummaryProbe/);
// The card footer contract: a zero-rating summary reads 0.0, an unanswered
// card keeps no chip, and the review line holds its slot either way.
assert.match(harness, /pluginReviewsZeroRatingProbe/);
assert.match(fixture, /pluginReviewsZeroRatingProbe/);
assert.match(fixture, /plugins-card-review\.is-empty/);
assert.match(harness, /pluginReviewsBatchProbe/);
assert.match(harness, /pluginReviewsNarrowDrawerProbe/);
assert.match(harness, /setContentSize/);
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/test/plugins-page-style.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,30 @@ test("plugins page styles tier permission risk with semantic tokens", () => {
assert.match(section, /@media \(prefers-reduced-motion: reduce\)/);
});

// The marketplace card grid is exactly two columns at full width (a fixed
// count, not auto-fill, which grew to three or more on wide windows) and falls
// back to one column from the page container's own width — the pane can be
// narrow while the window is wide, so a viewport query would miss it.
test("marketplace cards sit in a fixed two-column grid with a narrow fallback", () => {
const section = pluginsSection(stylesSource);

assert.match(
section,
/\.plugins-card-grid\s*\{[^}]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\)/,
);
assert.doesNotMatch(section, /\.plugins-card-grid\s*\{[^}]*auto-fill/);
// The fallback needs a container to query: the page owns the context.
assert.match(
section,
/\.plugins-page\s*\{[^}]*container-type:\s*inline-size;[^}]*container-name:\s*plugins-page;/,
);
assert.match(
section,
/@container plugins-page \(max-width: 560px\)\s*\{\s*\.plugins-card-grid\s*\{[^}]*grid-template-columns:\s*1fr/,
);
assert.doesNotMatch(section, /@media \(max-width: 400px\)\s*\{\s*\.plugins-card-grid/);
});

// The installed list is an auto-filling grid of soft tile cards: the gap does
// the work a hairline used to, `min(340px, 100%)` stops a column from
// overflowing a container narrower than one card, and nothing clips so a
Expand Down
2 changes: 1 addition & 1 deletion docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -3623,7 +3623,7 @@ identify the platform validation still needed.

- **Preconditions**: Extensions reachable with at least one installed and one marketplace plugin the centre knows, plus a second run against a centre that has no review API.
- **Steps**: 1) Open Extensions → Installed and, without visiting Marketplace, confirm a published plugin's card shows a rating chip only when the centre returned a summary. 2) Open a marketplace card's detail sheet and read the ratings summary between Permissions and Versions; press "See all reviews". 3) In the drawer, filter by sentiment and switch between newest and most helpful; page with Load more. 4) Inspect the composer: it is disabled, and its one action is the link to the plugin centre page. 5) Press Escape once (only the drawer closes) and again (the sheet closes). 6) Reopen the sheet and confirm the drawer left its scroll position untouched. 7) Repeat against the centre without review support and read the summary.
- **Expected**: Installed and marketplace ids are batched into one summary read that never requires a Marketplace refresh; an unavailable centre shows no fabricated zeros, and the detail summary says ratings are unavailable — while the review entry stays reachable and the drawer retries its own list, so its disabled composer and its web link (official fallback until the host reports `reviewUrl`) are never lost; a successful summary read replaces the ratings for the ids just requested, so a plugin the centre stops serving loses its stale rating chip; the chip rides the card's existing metadata line and adds no row; the drawer covers the mounted detail sheet, keeps its scroll position, closes on the first Escape only, returns focus to its opener (Escape claims the key with preventDefault/stopPropagation; the covered sheet is inert and Tab is trapped inside the drawer), appends pages deduplicated by review id, and drops an answer for a filter the user already left; a review's version reads as user-reported, never as an installed version; the composer accepts no input, and the only write path is the real link opened through `browserOpenExternal` in the system browser on a primary or middle click (right-click stays with the platform menu), with a failed open reported as a toast; no sign-in, vote, report, or review-writing UI exists in any locale.
- **Expected**: Installed and marketplace ids are batched into one summary read that never requires a Marketplace refresh; an unavailable centre shows no fabricated zeros, and the detail summary says ratings are unavailable — while the review entry stays reachable and the drawer retries its own list, so its disabled composer and its web link (official fallback until the host reports `reviewUrl`) are never lost; a successful summary read replaces the ratings for the ids just requested, so a plugin the centre stops serving loses its stale rating chip; the chip rides the card's existing metadata line and adds no row; the marketplace chip shows the average without a rating count, and a zero-rating marketplace card reads a fixed 0.0 while its footer review line rotates the centre's rows (fixed localized "no reviews yet" when none exist); the drawer covers the mounted detail sheet, keeps its scroll position, closes on the first Escape only, returns focus to its opener (Escape claims the key with preventDefault/stopPropagation; the covered sheet is inert and Tab is trapped inside the drawer), appends pages deduplicated by review id, and drops an answer for a filter the user already left; a review's version reads as user-reported, never as an installed version; the composer accepts no input, and the only write path is the real link opened through `browserOpenExternal` in the system browser on a primary or middle click (right-click stays with the platform menu), with a failed open reported as a toast; no sign-in, vote, report, or review-writing UI exists in any locale.
- **Specs linked**: `07-plugins/07-plugin-marketplace.md` §7, `07-plugins/14-plugin-roadmap.md`
- **Acceptance**: G (Extensions page) + Security
- **Status**: Automated fixture (`node scripts/e2e-plugin-reviews-drawer.mjs`: three probes — full path with card chip, section order, paging/dedup, filter stale-drop, error+retry, link href/middle-click-only/failure toast, Tab trap + inert, Escape/focus/scroll restoration; stale-summary replacement; unavailable-summary drawer entry + retry + fallback link) plus unit-covered (`plugin-reviews-logic.test.mjs`, `plugin-reviews-surface.test.mjs`)
Expand Down
14 changes: 14 additions & 0 deletions docs/spec/07-plugins/07-plugin-marketplace.md
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,20 @@ direct labels and actions without explanatory paragraphs. Explanations remain
where they help a decision — permission review, detail sheets, editors, and
error states.

Marketplace cards sit in a fixed two-column grid at full width
(`repeat(2, minmax(0, 1fr))`, not auto-fill), falling back to a single column
from the page container's own width (`@container plugins-page (max-width:
560px)`) rather than the viewport, so a narrow pane in a wide window still
collapses (D671). Each card's footer carries
the rating chip, one rotating review line, and the install action: the review
line cycles through the centre-selected rows every six seconds (pausing on
hover or focus, and respecting reduced motion), and when the plugin has no
reviews it reads a fixed localized "no reviews yet" placeholder instead of
collapsing. The chip shows the one-decimal average alone — never the rating
count — and a marketplace card whose summary reports no ratings reads a fixed
0.0; a chip stays hidden only when the centre provides no summary at all, so
an unavailable service never reads as a zero rating (D671).

Marketplace cards render a monogram glyph rather than fetching `iconUrl`; the
renderer performs no remote image loads (D169).

Expand Down
23 changes: 23 additions & 0 deletions docs/spec/08-meta/decisions-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ This log freezes previously open questions into concrete decisions.
| D021 | First-run onboarding | **Inline checklist (not modal wizard)** |
| D022 | Local telemetry | **Local logs only in MVP (no remote telemetry)** |
| D664 | Sidebar running activity defaults | **When unset, the master and Count badge resolve on while Activity rail resolves off. Explicit saved values remain authoritative. The master continues gating both affordances; turning it off does not rewrite either child preference. No schema or migration change.** |
| D671 | Marketplace two-column card grid and footer review line | **Marketplace cards render in a fixed two-column grid (`repeat(2, minmax(0, 1fr))`), collapsing to one column from the page container's own width (`@container plugins-page (max-width: 560px)`, not a viewport query). The card footer rotates the centre-selected review rows every six seconds (hover/focus pause, reduced-motion aware) and shows a fixed localized "no reviews yet" line when none exist. The rating chip shows the average without the count, and a marketplace card with a zero-rating summary reads 0.0; the chip hides only when no summary exists. Installed rows and the detail sheet are unchanged.** |


## C. Provider & model coverage decisions (0.3.4)
Expand Down Expand Up @@ -7918,3 +7919,25 @@ must keep splitting are covered by `markdown-blocks.test.mjs`.
and closed payloads. See `04-ux/08-component-spec.md`,
`04-ux/09-interaction-patterns.md`, and
E2E-CHAT-turn-process-and-thinking-display.

## 2026-10-19 — Marketplace two-column card grid and footer review line (D671)

- The marketplace card grid renders exactly two columns at full width
(`repeat(2, minmax(0, 1fr))`, replacing `auto-fill`, which grew to three or
more columns on wide windows) and falls back to a single column when the
container cannot hold two cards.
- Each card footer keeps the rating chip beside a rotating review line: the
line cycles through the centre-selected rows every six seconds, pauses on
hover or focus, and respects reduced motion. When the plugin has no reviews,
the footer shows a fixed localized "no reviews yet" placeholder instead of
collapsing the slot.
- The rating chip shows the one-decimal average alone — the rating count is
removed from the chip in every locale. A marketplace card whose summary
reports zero ratings reads a fixed 0.0; the chip stays hidden only when the
centre provides no summary at all, so an unavailable service still never
reads as a zero rating. Installed-row chips and the detail sheet summary are
unchanged.
- Covered by `apps/desktop/test/plugin-reviews-surface.test.mjs`,
`apps/desktop/test/plugins-page-style.test.mjs`,
`packages/i18n/test/catalogs.test.mjs`, and the reviews-drawer E2E fixture.
See `07-plugins/07-plugin-marketplace.md` §7.
9 changes: 9 additions & 0 deletions docs/zh-CN/spec/07-plugins/07-plugin-marketplace.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,15 @@ Extensions
- 风险描述(安全说明标注)
- 安装按钮

市场卡片在满宽下固定为两栏网格(`repeat(2, minmax(0, 1fr))`,不是 `auto-fill`),
并按页面容器自身宽度塌成单栏(`@container plugins-page (max-width: 560px)`),而不是
按视口宽度,因此宽窗口里的窄面板同样会塌成单栏(D671)。每张卡片的页脚包含评分芯片、
轮换的评论行和安装动作:评论行每六秒在插件中心选定的评论间轮换(悬停或聚焦时暂停,
并遵循减动效偏好),插件没有评论时读取固定的本地化"暂无评论"占位而不是收起该槽位。
芯片只显示一位小数的平均值——绝不显示评论数——摘要为零评分的市场卡片固定显示 0.0;
只有插件中心完全没有返回摘要时芯片才隐藏,因此服务不可用仍然不会被误读为零评分
(D671)。

市场卡呈现字母组合字形,而不是获取 `iconUrl`;的
渲染器不执行远程图像加载(D169)。

Expand Down
Loading
Loading