From e7720b56f3acc314ca0a6d5745acb45fb6cda19b Mon Sep 17 00:00:00 2001 From: v_wyxgwang Date: Mon, 19 Oct 2026 15:31:16 +0800 Subject: [PATCH 1/5] feat(plugins): two-column marketplace grid and footer review ticker line Marketplace cards rendered in an auto-fill grid that grew to three or more columns on wide windows, their rating chip repeated the review count next to the average, and a plugin with no reviews collapsed its footer review slot entirely. The card footer is the review surface, so it now reads as one: a fixed two-column grid, a rotating review line that keeps a localized "no reviews yet" placeholder when empty, and a chip that shows the average alone, reading a fixed 0.0 when the centre answered with no ratings while an unavailable centre still hides the chip so service absence never reads as a zero rating. Installed-row chips and the detail sheet summary are unchanged. Locales drop {{count}} from ratingChip and gain tickerEmpty; the reviews-drawer E2E fixture and the source-contract tests pin the new shapes, and the marketplace spec, decisions log (D665), and zh-CN mirrors follow. --- .../src/features/plugins/MarketplacePanel.tsx | 1 + .../src/features/plugins/ReviewTicker.tsx | 11 ++++++- .../src/features/plugins/presentation.tsx | 33 ++++++++++++------- apps/desktop/src/styles/plugins.css | 16 ++++++++- .../test/plugin-reviews-surface.test.mjs | 13 +++++++- apps/desktop/test/plugins-page-style.test.mjs | 17 ++++++++++ docs/spec/06-delivery/04-e2e-test-plan.md | 2 +- docs/spec/07-plugins/07-plugin-marketplace.md | 14 ++++++++ docs/spec/08-meta/decisions-log.md | 23 +++++++++++++ .../spec/07-plugins/07-plugin-marketplace.md | 10 ++++++ docs/zh-CN/spec/08-meta/decisions-log.md | 16 +++++++++ packages/i18n/src/locales/de/index.ts | 3 +- packages/i18n/src/locales/en/index.ts | 3 +- packages/i18n/src/locales/es/index.ts | 3 +- packages/i18n/src/locales/fr/index.ts | 3 +- packages/i18n/src/locales/ko/index.ts | 3 +- packages/i18n/src/locales/pt-BR/index.ts | 3 +- packages/i18n/src/locales/tr/index.ts | 3 +- packages/i18n/src/locales/zh-CN/index.ts | 3 +- packages/i18n/src/locales/zh-TW/index.ts | 3 +- scripts/e2e/plugin-reviews-drawer.tsx | 8 +++-- 21 files changed, 164 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/features/plugins/MarketplacePanel.tsx b/apps/desktop/src/features/plugins/MarketplacePanel.tsx index 38657dae8d..f21f397bc3 100644 --- a/apps/desktop/src/features/plugins/MarketplacePanel.tsx +++ b/apps/desktop/src/features/plugins/MarketplacePanel.tsx @@ -174,6 +174,7 @@ export function MarketplacePanel({ summary={reviewSummaries[item.id]} locale={locale} showDot={false} + showZero /> {installed && !upgradable ? ( diff --git a/apps/desktop/src/features/plugins/ReviewTicker.tsx b/apps/desktop/src/features/plugins/ReviewTicker.tsx index 28e36ef137..636f07bd3f 100644 --- a/apps/desktop/src/features/plugins/ReviewTicker.tsx +++ b/apps/desktop/src/features/plugins/ReviewTicker.tsx @@ -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. */ @@ -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); @@ -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 ( + + {t("plugins.reviews.tickerEmpty")} + + ); + } const row = rows[index] ?? rows[0]; return ( diff --git a/apps/desktop/src/features/plugins/presentation.tsx b/apps/desktop/src/features/plugins/presentation.tsx index 6ac96732d5..7ef20ba85e 100644 --- a/apps/desktop/src/features/plugins/presentation.tsx +++ b/apps/desktop/src/features/plugins/presentation.tsx @@ -203,8 +203,10 @@ 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 + * The compact rating readout cards carry. The count is not shown beside the + * average (the review line is where reviews live), and a caller that passes + * `showZero` keeps the chip visible when the centre answered with no ratings, + * reading a fixed 0.0 instead. Without `showZero` nothing renders when the * centre has no summary for the plugin — an unavailable service must not read * as a zero rating. */ @@ -212,23 +214,34 @@ 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 no ratings. */ + showZero?: boolean; }) { const { t } = useTranslation(); const avg = summary ? formatRatingAvg(summary.ratingAvg, locale) : null; - if (!summary || !avg || summary.ratingCount <= 0) return null; + const unrated = !summary || !avg || summary.ratingCount <= 0; + if (unrated && !showZero) return null; + const text = unrated + ? formatRatingAvg(0, locale) + : t("plugins.reviews.ratingChip", { avg }); return ( {showDot ? ( @@ -236,9 +249,7 @@ export function RatingChip({ ) : null} ); } diff --git a/apps/desktop/src/styles/plugins.css b/apps/desktop/src/styles/plugins.css index 2d0b64a4af..e10d57c787 100644 --- a/apps/desktop/src/styles/plugins.css +++ b/apps/desktop/src/styles/plugins.css @@ -996,12 +996,20 @@ color: var(--ds-text-primary); } +/* Two columns at full width (was auto-fill, which grew to three or more on + wide windows); a container narrower than one card falls back to one. */ .plugins-card-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(272px, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +@media (max-width: 400px) { + .plugins-card-grid { + grid-template-columns: 1fr; + } +} + /* Marketplace cards are tiles too: tint at rest, lifted by shadow on hover. */ .plugins-card { display: flex; @@ -1170,6 +1178,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; diff --git a/apps/desktop/test/plugin-reviews-surface.test.mjs b/apps/desktop/test/plugin-reviews-surface.test.mjs index 21555c0617..9bd2475aeb 100644 --- a/apps/desktop/test/plugin-reviews-surface.test.mjs +++ b/apps/desktop/test/plugin-reviews-surface.test.mjs @@ -53,11 +53,22 @@ 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, / { diff --git a/apps/desktop/test/plugins-page-style.test.mjs b/apps/desktop/test/plugins-page-style.test.mjs index 311352e551..a06018691c 100644 --- a/apps/desktop/test/plugins-page-style.test.mjs +++ b/apps/desktop/test/plugins-page-style.test.mjs @@ -79,6 +79,23 @@ 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 when the container cannot hold two cards. +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/); + assert.match( + section, + /@media \(max-width: 400px\)\s*\{\s*\.plugins-card-grid\s*\{[^}]*grid-template-columns:\s*1fr/, + ); +}); + // 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 diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index fc3b8c2607..f1ae3bf5d8 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -3505,7 +3505,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`) diff --git a/docs/spec/07-plugins/07-plugin-marketplace.md b/docs/spec/07-plugins/07-plugin-marketplace.md index 731c058044..c15cd59d8d 100644 --- a/docs/spec/07-plugins/07-plugin-marketplace.md +++ b/docs/spec/07-plugins/07-plugin-marketplace.md @@ -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 +when the container cannot hold two cards (D665). 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 (D665). + +Marketplace cards render a monogram glyph rather than fetching `iconUrl`; the +renderer performs no remote image loads (D169). Marketplace cards render a monogram glyph rather than fetching `iconUrl`; the renderer performs no remote image loads (D169). diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index 72603777f1..e76cd3e187 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -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.** | +| D665 | Marketplace two-column card grid and footer review line | **Marketplace cards render in a fixed two-column grid (`repeat(2, minmax(0, 1fr))`, single column below 400px). 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) @@ -7778,3 +7779,25 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. default. Settings reads apply these defaults through renderer API normalization; no persisted schema or migration change is required. See `04-ux/01-ui-ia.md`, `04-ux/06-settings-ia.md`, and E2E-266. + +## 2026-10-19 — Marketplace two-column card grid and footer review line (D665) + +- 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. diff --git a/docs/zh-CN/spec/07-plugins/07-plugin-marketplace.md b/docs/zh-CN/spec/07-plugins/07-plugin-marketplace.md index ecea410e75..12189a3c16 100644 --- a/docs/zh-CN/spec/07-plugins/07-plugin-marketplace.md +++ b/docs/zh-CN/spec/07-plugins/07-plugin-marketplace.md @@ -431,6 +431,16 @@ Extensions - 风险描述(安全说明标注) - 安装按钮 +市场卡片在满宽下固定为两栏网格(`repeat(2, minmax(0, 1fr))`,不是 `auto-fill`), +容器放不下两张卡片时回退为单栏(D665)。每张卡片的页脚包含评分芯片、轮换的评论 +行和安装动作:评论行每六秒在插件中心选定的评论间轮换(悬停或聚焦时暂停,并遵循 +减动效偏好),插件没有评论时读取固定的本地化"暂无评论"占位而不是收起该槽位。芯片 +只显示一位小数的平均值——绝不显示评论数——摘要为零评分的市场卡片固定显示 0.0; +只有插件中心完全没有返回摘要时芯片才隐藏,因此服务不可用仍然不会被误读为零评分 +(D665)。 + +市场卡呈现字母组合字形,而不是获取 `iconUrl`;的 +渲染器不执行远程图像加载(D169)。 市场卡呈现字母组合字形,而不是获取 `iconUrl`;的 渲染器不执行远程图像加载(D169)。 diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md index fbc570ab86..1fbd97e6e5 100644 --- a/docs/zh-CN/spec/08-meta/decisions-log.md +++ b/docs/zh-CN/spec/08-meta/decisions-log.md @@ -74,6 +74,7 @@ | D021 | 首次运行新手引导 | **内联清单(不是模式向导)** | | D022 | 本地遥测 | **仅在 MVP 中记录本地日志(无远程遥测)** | | D664 | 侧边栏运行活动默认值 | **未设置时,总开关和数量徽标默认开启,活动流光默认关闭。已保存的值优先生效。总开关仍控制两种提示;关闭总开关不会改写任一子开关偏好。不更改 schema 或执行迁移。** | +| D665 | 市场两栏卡片网格与页脚评论行 | **市场卡片在满宽下固定渲染为两栏(`repeat(2, minmax(0, 1fr))`,400px 以下单栏)。卡片页脚每六秒轮换插件中心选定的评论行(悬停/聚焦暂停,遵循减动效偏好),无评论时显示固定的本地化"暂无评论"占位。评分芯片只显示平均值、不再显示评论数;摘要为零评分的市场卡片固定显示 0.0,仅在完全没有摘要时隐藏芯片。已安装列表行与详情页不变。** | ## C. 提供商和模型覆盖决策 (0.3.4) @@ -5488,3 +5489,18 @@ Markdown 源码,不是 `text/html` 负载;对禁用行内 HTML 的外部编 - 数量徽标保留运行状态的可发现性,同时避免动画轨道成为默认界面。不需要更改持久化 schema 或迁移。设置读取通过渲染层 API 规范化应用这些默认值。见 `04-ux/01-ui-ia.md`、`04-ux/06-settings-ia.md` 与 E2E-266。 + +## 2026-10-19 —— 市场两栏卡片网格与页脚评论行(D665) + +- 市场卡片网格在满宽下固定渲染为两栏(`repeat(2, minmax(0, 1fr))`,取代会在宽窗口 + 长到三栏及以上的 `auto-fill`),容器放不下两张卡片时回退为单栏。 +- 卡片页脚保留评分芯片并轮换评论行:评论行每六秒在插件中心选定的评论间轮换,悬停 + 或聚焦时暂停,并遵循系统减动效偏好。插件没有评论时,页脚显示固定的本地化"暂无 + 评论"占位,而不是收起该槽位。 +- 评分芯片只显示一位小数的平均值——所有语言都不再显示评论数。摘要为零评分的市场 + 卡片固定显示 0.0;只有插件中心完全没有返回摘要时芯片才隐藏,服务不可用仍然不会 + 被误读为零评分。已安装列表行的芯片与详情页摘要不变。 +- 由 `apps/desktop/test/plugin-reviews-surface.test.mjs`、 + `apps/desktop/test/plugins-page-style.test.mjs`、 + `packages/i18n/test/catalogs.test.mjs` 与评论抽屉 E2E 夹具覆盖。见 + `07-plugins/07-plugin-marketplace.md` §7。 diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 0fa00141cb..2bf12b76ea 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -2303,13 +2303,14 @@ sklm: { "installCopyFailed": "Installationsdetails konnten nicht kopiert werden", "contributeGuide": "So tragen Sie bei", reviews: { - ratingChip: "{{avg}} ({{count}})", + ratingChip: "{{avg}}", ratingSummaryAria_one: "Mit {{avg}} von 5 aus 1 Bewertung bewertet", ratingSummaryAria_other: "Mit {{avg}} von 5 aus {{count}} Bewertungen bewertet", summaryTitle: "Bewertungen", count_one: "1 Bewertung", count_other: "{{count}} Bewertungen", empty: "Noch keine Bewertungen.", + tickerEmpty: "Noch keine Bewertungen", unavailable: "Bewertungen sind für diese Quelle nicht verfügbar.", loading: "Bewertungen werden geladen…", loadFailed: "Bewertungen konnten nicht geladen werden.", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index 7b16fca985..40bdd488e9 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -2324,13 +2324,14 @@ importConfirm: "Imported extensions run inside the agent process with the same a installCopyFailed: "Couldn't copy the install details", contributeGuide: "How to contribute", reviews: { - ratingChip: "{{avg}} ({{count}})", + ratingChip: "{{avg}}", ratingSummaryAria_one: "Rated {{avg}} out of 5 from 1 rating", ratingSummaryAria_other: "Rated {{avg}} out of 5 from {{count}} ratings", summaryTitle: "Ratings & reviews", count_one: "1 rating", count_other: "{{count}} ratings", empty: "No reviews yet.", + tickerEmpty: "No reviews yet", unavailable: "Ratings aren't available from this source.", loading: "Loading reviews…", loadFailed: "Couldn't load reviews.", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index 205a5595ad..448208fe72 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -2303,13 +2303,14 @@ sklm: { "installCopyFailed": "No se pudieron copiar los detalles", "contributeGuide": "Cómo contribuir", reviews: { - ratingChip: "{{avg}} ({{count}})", + ratingChip: "{{avg}}", ratingSummaryAria_one: "Valorado con {{avg}} de 5 a partir de 1 valoración", ratingSummaryAria_other: "Valorado con {{avg}} de 5 a partir de {{count}} valoraciones", summaryTitle: "Valoraciones y reseñas", count_one: "1 valoración", count_other: "{{count}} valoraciones", empty: "Todavía no hay reseñas.", + tickerEmpty: "Aún no hay reseñas", unavailable: "Las valoraciones no están disponibles para esta fuente.", loading: "Cargando reseñas…", loadFailed: "No se pudieron cargar las reseñas.", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index f015a56042..4caa77f2df 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -2303,13 +2303,14 @@ sklm: { "installCopyFailed": "Impossible de copier les détails", "contributeGuide": "Comment contribuer", reviews: { - ratingChip: "{{avg}} ({{count}})", + ratingChip: "{{avg}}", ratingSummaryAria_one: "Noté {{avg}} sur 5 d'après 1 avis", ratingSummaryAria_other: "Noté {{avg}} sur 5 d'après {{count}} avis", summaryTitle: "Notes et avis", count_one: "1 avis", count_other: "{{count}} avis", empty: "Aucun avis pour le moment.", + tickerEmpty: "Aucun avis pour le moment", unavailable: "Les notes ne sont pas disponibles pour cette source.", loading: "Chargement des avis…", loadFailed: "Impossible de charger les avis.", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index 5c5b1723f9..647e3e533a 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -2320,13 +2320,14 @@ importConfirm: "가져온 확장은 에이전트 프로세스 안에서 에이 installCopyFailed: "설치 세부 정보를 복사하지 못했습니다", contributeGuide: "기여 방법", reviews: { - ratingChip: "{{avg}} ({{count}})", + ratingChip: "{{avg}}", ratingSummaryAria_one: "평가 1개 기준 5점 만점에 {{avg}}점", ratingSummaryAria_other: "평가 {{count}}개 기준 5점 만점에 {{avg}}점", summaryTitle: "평점 및 리뷰", count_one: "평가 1개", count_other: "평가 {{count}}개", empty: "아직 리뷰가 없습니다.", + tickerEmpty: "아직 리뷰가 없습니다", unavailable: "이 소스에서는 평점을 사용할 수 없습니다.", loading: "리뷰 불러오는 중…", loadFailed: "리뷰를 불러오지 못했습니다.", diff --git a/packages/i18n/src/locales/pt-BR/index.ts b/packages/i18n/src/locales/pt-BR/index.ts index 48036f4236..c2d3e606ae 100644 --- a/packages/i18n/src/locales/pt-BR/index.ts +++ b/packages/i18n/src/locales/pt-BR/index.ts @@ -2269,13 +2269,14 @@ export const ptBR = { installCopyFailed: "Não foi possível copiar os detalhes da instalação", contributeGuide: "Como contribuir", reviews: { - ratingChip: "{{avg}} ({{count}})", + ratingChip: "{{avg}}", ratingSummaryAria_one: "Avaliado em {{avg}} de 5 com base em 1 avaliação", ratingSummaryAria_other: "Avaliado em {{avg}} de 5 com base em {{count}} avaliações", summaryTitle: "Notas e avaliações", count_one: "1 avaliação", count_other: "{{count}} avaliações", empty: "Ainda não há avaliações.", + tickerEmpty: "Ainda não há avaliações", unavailable: "As notas não estão disponíveis para esta fonte.", loading: "Carregando avaliações…", loadFailed: "Não foi possível carregar as avaliações.", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index 10d7331cff..86e599f68e 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -2314,13 +2314,14 @@ importConfirm: "İçe aktarılan uzantılar ajan sürecinde, ajanın kendi araç installCopyFailed: "Kurulum ayrıntıları kopyalanamadı", contributeGuide: "Nasıl katkıda bulunulur", reviews: { - ratingChip: "{{avg}} ({{count}})", + ratingChip: "{{avg}}", ratingSummaryAria_one: "1 değerlendirme ile 5 üzerinden {{avg}}", ratingSummaryAria_other: "{{count}} değerlendirme ile 5 üzerinden {{avg}}", summaryTitle: "Puanlar ve değerlendirmeler", count_one: "1 değerlendirme", count_other: "{{count}} değerlendirme", empty: "Henüz değerlendirme yok.", + tickerEmpty: "Henüz değerlendirme yok", unavailable: "Puanlar bu kaynak için kullanılamıyor.", loading: "Değerlendirmeler yükleniyor…", loadFailed: "Değerlendirmeler yüklenemedi.", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index b193bfda29..ec37abb9a2 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -2298,13 +2298,14 @@ sklm: { installCopyFailed: "复制安装详情失败", contributeGuide: "贡献指南", reviews: { - ratingChip: "{{avg}}({{count}})", + ratingChip: "{{avg}}", ratingSummaryAria_one: "{{avg}} 分(满分 5 分,来自 1 条评分)", ratingSummaryAria_other: "{{avg}} 分(满分 5 分,来自 {{count}} 条评分)", summaryTitle: "评分与评价", count_one: "1 条评分", count_other: "{{count}} 条评分", empty: "还没有评价。", + tickerEmpty: "暂无评论", unavailable: "当前来源不提供评分。", loading: "正在加载评价…", loadFailed: "评价加载失败。", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index a954bec613..a895cc656c 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -2298,13 +2298,14 @@ sklm: { installCopyFailed: "複製安裝詳情失敗", contributeGuide: "貢獻指南", reviews: { - ratingChip: "{{avg}}({{count}})", + ratingChip: "{{avg}}", ratingSummaryAria_one: "{{avg}} 分(滿分 5 分,來自 1 則評分)", ratingSummaryAria_other: "{{avg}} 分(滿分 5 分,來自 {{count}} 則評分)", summaryTitle: "評分與評價", count_one: "1 則評分", count_other: "{{count}} 則評分", empty: "還沒有評價。", + tickerEmpty: "暫無評論", unavailable: "目前來源不提供評分。", loading: "正在載入評價…", loadFailed: "評價載入失敗。", diff --git a/scripts/e2e/plugin-reviews-drawer.tsx b/scripts/e2e/plugin-reviews-drawer.tsx index acd68526e2..13e4e1e1fd 100644 --- a/scripts/e2e/plugin-reviews-drawer.tsx +++ b/scripts/e2e/plugin-reviews-drawer.tsx @@ -205,7 +205,7 @@ Object.assign(globalThis, { chip.getAttribute("aria-label") === "Rated 4.5 out of 5 from 3 ratings", `rating chip aria-label: ${chip.getAttribute("aria-label")}`, ); - check(chip.textContent?.includes("4.5 (3)") === true, `rating chip text: ${chip.textContent}`); + check(chip.textContent?.includes("4.5") === true && !chip.textContent?.includes("(3)"), `rating chip text: ${chip.textContent}`); find(".plugins-card-hit").click(); await settle(() => !!maybe(".plugins-sheet"), "detail sheet"); @@ -670,7 +670,8 @@ Object.assign(globalThis, { ); await settle(() => !!maybe(".plugins-rating"), "batch probe: fresh chip"); check( - find(".plugins-rating").textContent?.includes("4.5 (3)") === true, + find(".plugins-rating").textContent?.includes("4.5") === true && + !find(".plugins-rating").textContent?.includes("(3)"), `fresh chip text: ${find(".plugins-rating").textContent}`, ); check( @@ -689,7 +690,8 @@ Object.assign(globalThis, { // stays and the stale rating never appears on the visible card. check(!!maybe(".plugins-rating"), "fresh chip lost after the stale answer landed"); check( - find(".plugins-rating").textContent?.includes("4.5 (3)") === true, + find(".plugins-rating").textContent?.includes("4.5") === true && + !find(".plugins-rating").textContent?.includes("(3)"), "fresh chip changed after the stale answer landed", ); check( From dd51fd7c571ffc2bc5abb9ce8e5b1c1360531990 Mon Sep 17 00:00:00 2001 From: v_wyxgwang Date: Mon, 19 Oct 2026 15:56:54 +0800 Subject: [PATCH 2/5] fix(plugins): keep the rating chip off an unanswered centre The marketplace chip treated "no summary yet" and "a summary with zero ratings" as one state, so every card flashed a 0.0 while the summaries batch was still in flight and a plugin the centre stopped serving kept a fabricated rating. The E2E probes caught it: the stale-answer probe saw no batch ordering, and the unavailable probe found a chip on a card the centre never answered. A summary that is absent now renders nothing, whatever showZero says; only a summary the centre actually returned with ratingCount 0 reads the fixed 0.0. The new zero-rating probe drives the whole footer contract in real Chromium: a rated card keeps the average alone, a zero-rating card reads 0.0, an unanswered card keeps no chip, and the review line holds its slot with the centre's rows or the empty placeholder. --- .../src/features/plugins/presentation.tsx | 33 +++---- .../test/plugin-reviews-surface.test.mjs | 13 ++- scripts/e2e-plugin-reviews-drawer.mjs | 2 +- scripts/e2e/plugin-reviews-drawer.tsx | 96 +++++++++++++++++++ 4 files changed, 124 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/features/plugins/presentation.tsx b/apps/desktop/src/features/plugins/presentation.tsx index 7ef20ba85e..efdf4d84ff 100644 --- a/apps/desktop/src/features/plugins/presentation.tsx +++ b/apps/desktop/src/features/plugins/presentation.tsx @@ -204,11 +204,11 @@ export function AgentExtensionDetails({ status }: { status: PluginAgentExtension /** * The compact rating readout cards carry. The count is not shown beside the - * average (the review line is where reviews live), and a caller that passes - * `showZero` keeps the chip visible when the centre answered with no ratings, - * reading a fixed 0.0 instead. Without `showZero` nothing renders when the - * centre has no summary for the plugin — an unavailable service must not read - * as a zero rating. + * 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, @@ -220,27 +220,28 @@ export function RatingChip({ 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 no ratings. */ + /** 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; - const unrated = !summary || !avg || summary.ratingCount <= 0; - if (unrated && !showZero) return null; - const text = unrated - ? formatRatingAvg(0, locale) - : t("plugins.reviews.ratingChip", { avg }); + 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 ( {showDot ? ( diff --git a/apps/desktop/test/plugin-reviews-surface.test.mjs b/apps/desktop/test/plugin-reviews-surface.test.mjs index 9bd2475aeb..f716eddf29 100644 --- a/apps/desktop/test/plugin-reviews-surface.test.mjs +++ b/apps/desktop/test/plugin-reviews-surface.test.mjs @@ -52,10 +52,12 @@ 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/); + // 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 zero-ratings cards read a fixed 0.0 via - // showZero; other callers keep the summary-absent chip hidden). + // 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, / { 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/); diff --git a/scripts/e2e-plugin-reviews-drawer.mjs b/scripts/e2e-plugin-reviews-drawer.mjs index db46e83ca6..32ab0f1569 100644 --- a/scripts/e2e-plugin-reviews-drawer.mjs +++ b/scripts/e2e-plugin-reviews-drawer.mjs @@ -86,7 +86,7 @@ app.whenReady().then(async()=>{ try { const win=new BrowserWindow({show:false,useContentSize:true,width:900,height:720,webPreferences:{backgroundThrottling:false,sandbox:true,contextIsolation:true}}); await win.loadFile(require('node:path').join(__dirname,'index.html')); - const probes=['pluginReviewsDrawerProbe','pluginReviewsStaleSummaryProbe','pluginReviewsUnavailableSummaryProbe','pluginReviewsBatchProbe','pluginReviewsNarrowDrawerProbe']; +const probes=['pluginReviewsDrawerProbe','pluginReviewsStaleSummaryProbe','pluginReviewsUnavailableSummaryProbe','pluginReviewsZeroRatingProbe','pluginReviewsBatchProbe','pluginReviewsNarrowDrawerProbe']; let failed=false; for(const name of probes){ // The narrow probe runs against a phone-sized viewport; everything else diff --git a/scripts/e2e/plugin-reviews-drawer.tsx b/scripts/e2e/plugin-reviews-drawer.tsx index 13e4e1e1fd..92019fe713 100644 --- a/scripts/e2e/plugin-reviews-drawer.tsx +++ b/scripts/e2e/plugin-reviews-drawer.tsx @@ -612,6 +612,102 @@ Object.assign(globalThis, { return "PASS: at a narrow viewport the drawer stays inside it with no horizontal overflow; filters, sort, close and the write link are all usable; the body scrolls and the composer stays pinned"; }, + // A summary the centre returned with no ratings reads a fixed 0.0 on the + // card, a rated summary keeps the average alone, and a card the centre never + // answered keeps no chip at all — an unknown rating must never read as zero. + // The footer review line also holds its slot: the centre's rows when there + // are any, a fixed placeholder when there are none. + pluginReviewsZeroRatingProbe: async () => { + step("zero:init"); + await i18n.init({ lng: "en", resources: { en: { translation: en } } }); + const ratedPlugin = { ...plugin, id: "rated.plugin", name: "Rated Plugin" }; + const zeroPlugin = { ...plugin, id: "zero.plugin", name: "Zero Plugin" }; + const silentPlugin = { ...plugin, id: "silent.plugin", name: "Silent Plugin" }; + Object.assign(api, { + ...pageMocks(), + marketSearch: async () => ({ plugins: [ratedPlugin, zeroPlugin, silentPlugin] }), + marketReviewSummaries: async () => ({ + "rated.plugin": { ...summary, pluginId: "rated.plugin" }, + "zero.plugin": { + ...summary, + pluginId: "zero.plugin", + ratingAvg: 0, + ratingCount: 0, + ratingDist: [0, 0, 0, 0, 0], + positiveCount: 0, + neutralCount: 0, + negativeCount: 0, + }, + }), + marketReviewTickers: async () => ({ + "rated.plugin": { + pluginId: "rated.plugin", + rows: [ + { + id: "ticker-1", + authorName: "Ada", + ratingHalfStars: 9, + body: "Rotates on the card", + helpfulCount: 2, + createdAt: "2026-10-01T00:00:00.000Z", + }, + ], + }, + }), + }); + + usePluginBrowseState.setState({ tab: "market", query: "" }); + step("zero:render"); + const { container, root } = mountProbe(); + await settle(() => texts(".plugins-card-name").length === 3, "zero probe: three cards"); + await settle( + () => !!cardByName("Rated Plugin").querySelector(".plugins-rating"), + "zero probe: rated chip", + ); + + const ratedChip = cardByName("Rated Plugin").querySelector(".plugins-rating"); + check( + ratedChip?.getAttribute("aria-label") === "Rated 4.5 out of 5 from 3 ratings", + `zero probe: rated aria-label: ${ratedChip?.getAttribute("aria-label")}`, + ); + check( + ratedChip?.textContent?.includes("4.5") === true && + !ratedChip?.textContent?.includes("(3)"), + `zero probe: rated chip text: ${ratedChip?.textContent}`, + ); + + await settle( + () => !!cardByName("Zero Plugin").querySelector(".plugins-rating"), + "zero probe: zero-rating chip", + ); + const zeroChip = cardByName("Zero Plugin").querySelector(".plugins-rating"); + check( + zeroChip?.textContent?.trim() === "0.0", + `zero probe: zero-rating chip text: ${zeroChip?.textContent}`, + ); + check( + cardByName("Silent Plugin").querySelector(".plugins-rating") === null, + "zero probe: a card the centre never answered rendered a zero rating", + ); + + check( + cardByName("Rated Plugin") + .querySelector(".plugins-card-review") + ?.textContent?.includes("Rotates on the card") === true, + `zero probe: review line: ${cardByName("Rated Plugin").querySelector(".plugins-card-review")?.textContent}`, + ); + check( + cardByName("Silent Plugin") + .querySelector(".plugins-card-review.is-empty") + ?.textContent?.includes("No reviews yet") === true, + `zero probe: empty review line: ${cardByName("Silent Plugin").querySelector(".plugins-card-review")?.textContent}`, + ); + + root.unmount(); + container.remove(); + return "PASS: a zero-rating summary reads 0.0 without a count, an unanswered card keeps no chip, and the footer review line rotates the centre's rows or names the empty state"; + }, + pluginReviewsBatchProbe: async () => { step("batch:init"); await i18n.init({ lng: "en", resources: { en: { translation: en } } }); From e185fc1a4dc8b2ba28965248c3dda3e04def64a4 Mon Sep 17 00:00:00 2001 From: v_wyxgwang Date: Tue, 20 Oct 2026 11:10:41 +0800 Subject: [PATCH 3/5] fix(plugins): query the marketplace grid against the page pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one-column fallback used a viewport media query, so a narrow content pane inside a wide window kept two cramped columns — each card right at the ~272px the old auto-fill layout already treated as too tight. The page root now owns the query context (`container-type: inline-size`, `container-name: plugins-page`) and the grid collapses below 560px of pane width, the threshold where two columns would drop under that floor. Container queries are already the pattern in this stylesheet's neighbours (composer, settings, messages), so this tracks the pane with the mechanism the page already expects. The zero-rating probe now measures both ends in real Chromium: a 900px pane resolves two tracks and a 500px pane resolves one, with the window still wide. --- apps/desktop/src/styles/plugins.css | 12 ++++++++++-- apps/desktop/test/plugins-page-style.test.mjs | 13 ++++++++++--- docs/spec/07-plugins/07-plugin-marketplace.md | 4 +++- docs/spec/08-meta/decisions-log.md | 2 +- .../spec/07-plugins/07-plugin-marketplace.md | 9 +++++---- docs/zh-CN/spec/08-meta/decisions-log.md | 2 +- scripts/e2e/plugin-reviews-drawer.tsx | 17 +++++++++++++++++ 7 files changed, 47 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/styles/plugins.css b/apps/desktop/src/styles/plugins.css index e10d57c787..97c61f2e7c 100644 --- a/apps/desktop/src/styles/plugins.css +++ b/apps/desktop/src/styles/plugins.css @@ -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 @@ -997,14 +1002,17 @@ } /* Two columns at full width (was auto-fill, which grew to three or more on - wide windows); a container narrower than one card falls back to one. */ + 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(2, minmax(0, 1fr)); gap: 12px; } -@media (max-width: 400px) { +@container plugins-page (max-width: 560px) { .plugins-card-grid { grid-template-columns: 1fr; } diff --git a/apps/desktop/test/plugins-page-style.test.mjs b/apps/desktop/test/plugins-page-style.test.mjs index a06018691c..bad5cdeca6 100644 --- a/apps/desktop/test/plugins-page-style.test.mjs +++ b/apps/desktop/test/plugins-page-style.test.mjs @@ -80,8 +80,9 @@ test("plugins page styles tier permission risk with semantic tokens", () => { }); // 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 when the container cannot hold two cards. +// 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); @@ -90,10 +91,16 @@ test("marketplace cards sit in a fixed two-column grid with a narrow fallback", /\.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, - /@media \(max-width: 400px\)\s*\{\s*\.plugins-card-grid\s*\{[^}]*grid-template-columns:\s*1fr/, + /\.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 diff --git a/docs/spec/07-plugins/07-plugin-marketplace.md b/docs/spec/07-plugins/07-plugin-marketplace.md index c15cd59d8d..19fcea7cc4 100644 --- a/docs/spec/07-plugins/07-plugin-marketplace.md +++ b/docs/spec/07-plugins/07-plugin-marketplace.md @@ -583,7 +583,9 @@ 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 -when the container cannot hold two cards (D665). Each card's footer carries +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 (D665). 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 diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index e76cd3e187..7611ad9413 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -72,7 +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.** | -| D665 | Marketplace two-column card grid and footer review line | **Marketplace cards render in a fixed two-column grid (`repeat(2, minmax(0, 1fr))`, single column below 400px). 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.** | +| D665 | 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) diff --git a/docs/zh-CN/spec/07-plugins/07-plugin-marketplace.md b/docs/zh-CN/spec/07-plugins/07-plugin-marketplace.md index 12189a3c16..6fba27313b 100644 --- a/docs/zh-CN/spec/07-plugins/07-plugin-marketplace.md +++ b/docs/zh-CN/spec/07-plugins/07-plugin-marketplace.md @@ -432,10 +432,11 @@ Extensions - 安装按钮 市场卡片在满宽下固定为两栏网格(`repeat(2, minmax(0, 1fr))`,不是 `auto-fill`), -容器放不下两张卡片时回退为单栏(D665)。每张卡片的页脚包含评分芯片、轮换的评论 -行和安装动作:评论行每六秒在插件中心选定的评论间轮换(悬停或聚焦时暂停,并遵循 -减动效偏好),插件没有评论时读取固定的本地化"暂无评论"占位而不是收起该槽位。芯片 -只显示一位小数的平均值——绝不显示评论数——摘要为零评分的市场卡片固定显示 0.0; +并按页面容器自身宽度塌成单栏(`@container plugins-page (max-width: 560px)`),而不是 +按视口宽度,因此宽窗口里的窄面板同样会塌成单栏(D665)。每张卡片的页脚包含评分芯片、 +轮换的评论行和安装动作:评论行每六秒在插件中心选定的评论间轮换(悬停或聚焦时暂停, +并遵循减动效偏好),插件没有评论时读取固定的本地化"暂无评论"占位而不是收起该槽位。 +芯片只显示一位小数的平均值——绝不显示评论数——摘要为零评分的市场卡片固定显示 0.0; 只有插件中心完全没有返回摘要时芯片才隐藏,因此服务不可用仍然不会被误读为零评分 (D665)。 diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md index 1fbd97e6e5..6d3d0246c1 100644 --- a/docs/zh-CN/spec/08-meta/decisions-log.md +++ b/docs/zh-CN/spec/08-meta/decisions-log.md @@ -74,7 +74,7 @@ | D021 | 首次运行新手引导 | **内联清单(不是模式向导)** | | D022 | 本地遥测 | **仅在 MVP 中记录本地日志(无远程遥测)** | | D664 | 侧边栏运行活动默认值 | **未设置时,总开关和数量徽标默认开启,活动流光默认关闭。已保存的值优先生效。总开关仍控制两种提示;关闭总开关不会改写任一子开关偏好。不更改 schema 或执行迁移。** | -| D665 | 市场两栏卡片网格与页脚评论行 | **市场卡片在满宽下固定渲染为两栏(`repeat(2, minmax(0, 1fr))`,400px 以下单栏)。卡片页脚每六秒轮换插件中心选定的评论行(悬停/聚焦暂停,遵循减动效偏好),无评论时显示固定的本地化"暂无评论"占位。评分芯片只显示平均值、不再显示评论数;摘要为零评分的市场卡片固定显示 0.0,仅在完全没有摘要时隐藏芯片。已安装列表行与详情页不变。** | +| D665 | 市场两栏卡片网格与页脚评论行 | **市场卡片在满宽下固定渲染为两栏(`repeat(2, minmax(0, 1fr))`),并按页面容器自身宽度塌成单栏(`@container plugins-page (max-width: 560px)`,不是视口查询)。卡片页脚每六秒轮换插件中心选定的评论行(悬停/聚焦暂停,遵循减动效偏好),无评论时显示固定的本地化"暂无评论"占位。评分芯片只显示平均值、不再显示评论数;摘要为零评分的市场卡片固定显示 0.0,仅在完全没有摘要时隐藏芯片。已安装列表行与详情页不变。** | ## C. 提供商和模型覆盖决策 (0.3.4) diff --git a/scripts/e2e/plugin-reviews-drawer.tsx b/scripts/e2e/plugin-reviews-drawer.tsx index 92019fe713..1f2dc97f2e 100644 --- a/scripts/e2e/plugin-reviews-drawer.tsx +++ b/scripts/e2e/plugin-reviews-drawer.tsx @@ -659,6 +659,9 @@ Object.assign(globalThis, { usePluginBrowseState.setState({ tab: "market", query: "" }); step("zero:render"); const { container, root } = mountProbe(); + // The shipping page root owns the container query; the probe's mount point + // stands in for it so the grid's fallback can be measured. + container.className = "plugins-page"; await settle(() => texts(".plugins-card-name").length === 3, "zero probe: three cards"); await settle( () => !!cardByName("Rated Plugin").querySelector(".plugins-rating"), @@ -703,6 +706,20 @@ Object.assign(globalThis, { `zero probe: empty review line: ${cardByName("Silent Plugin").querySelector(".plugins-card-review")?.textContent}`, ); + // The fallback tracks the pane, not the window: with the window still wide, + // a 500px page container must collapse the grid to one column, and a 900px + // one must restore the pair. + const page = document.querySelector(".plugins-page"); + const grid = document.querySelector(".plugins-card-grid"); + check(!!page && !!grid, "zero probe: page container or grid missing"); + const columns = () => + getComputedStyle(grid!).gridTemplateColumns.split(" ").filter(Boolean).length; + page!.style.width = "900px"; + check(columns() === 2, `zero probe: wide pane columns: ${columns()}`); + page!.style.width = "500px"; + check(columns() === 1, `zero probe: narrow pane columns: ${columns()}`); + page!.style.width = ""; + root.unmount(); container.remove(); return "PASS: a zero-rating summary reads 0.0 without a count, an unanswered card keeps no chip, and the footer review line rotates the centre's rows or names the empty state"; From 9f445315ba40f6445b6e5a831716ae183bb67a73 Mon Sep 17 00:00:00 2001 From: v_wyxgwang Date: Tue, 20 Oct 2026 11:35:34 +0800 Subject: [PATCH 4/5] fix(ci): run the client-certificate E2E under a virtual display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Run provider optional client certificate E2E` was the only Electron step in this job invoked without `xvfb-run`. The job installs the `xvfb` package but never starts a resident display server, so the probe opened no window and died on `Missing X server or $DISPLAY`, failing the whole trusted extensions job — on main as well as on every branch built on it. The step now runs under `xvfb-run --auto-servernum` like its three neighbours. The probe itself was already green: it passes locally, and the only thing missing in CI was the display the other Electron steps get. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57b85e956b..57e59157bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,7 +160,7 @@ jobs: run: pnpm --filter @pi-desktop/desktop build - name: Run provider optional client certificate E2E - run: pnpm test:e2e:provider-client-certificate + run: xvfb-run --auto-servernum pnpm test:e2e:provider-client-certificate - name: Run browser capture/resize E2E run: xvfb-run --auto-servernum sh -c 'openbox >/dev/null 2>&1 & exec node scripts/e2e-browser-capture-resize.mjs' From 49a390f053bf8d8aa7d99a44871c4b9b2d5db219 Mon Sep 17 00:00:00 2001 From: v_wyxgwang Date: Tue, 20 Oct 2026 12:08:44 +0800 Subject: [PATCH 5/5] fix(ci): give the client-certificate probe a display and a hard timeout The step was the only Electron run in this job without `xvfb-run`, so it died immediately on `Missing X server or $DISPLAY`. Wrapping it the same way as its neighbours gets it past that, but the first attempt then hung: Electron started, never reached the probe's own output, and the job sat for thirty minutes until it was cancelled, because the runner script's sixty-second fallback sent only SIGTERM and waited for a `close` that never came. The step now starts openbox inside the virtual display like the browser-capture step does, and the runner escalates SIGTERM to SIGKILL after five seconds and reports the hang as a failure, so a probe that cannot start can no longer hold a CI job open. --- .github/workflows/ci.yml | 2 +- scripts/e2e-provider-client-certificate.mjs | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57e59157bd..4853d7daf9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,7 +160,7 @@ jobs: run: pnpm --filter @pi-desktop/desktop build - name: Run provider optional client certificate E2E - run: xvfb-run --auto-servernum pnpm test:e2e:provider-client-certificate + run: xvfb-run --auto-servernum sh -c 'openbox >/dev/null 2>&1 & exec pnpm test:e2e:provider-client-certificate' - name: Run browser capture/resize E2E run: xvfb-run --auto-servernum sh -c 'openbox >/dev/null 2>&1 & exec node scripts/e2e-browser-capture-resize.mjs' diff --git a/scripts/e2e-provider-client-certificate.mjs b/scripts/e2e-provider-client-certificate.mjs index 2876c74792..ae66a551b0 100644 --- a/scripts/e2e-provider-client-certificate.mjs +++ b/scripts/e2e-provider-client-certificate.mjs @@ -29,12 +29,20 @@ try { output += chunk; process.stdout.write(chunk); }); - const timer = setTimeout(() => child.kill(), 60_000); + // A hung probe must fail the step quickly: SIGTERM first, then SIGKILL, so a + // child that ignores the polite signal can never hold the CI job open. + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + setTimeout(() => child.kill("SIGKILL"), 5_000).unref(); + }, 120_000); try { const code = await new Promise((resolve, reject) => { child.once("error", reject); child.once("close", resolve); }); + assert.equal(timedOut, false, `provider client certificate E2E hung and was killed:\n${output}`); assert.equal(code, 0, output); assert.match(output, /provider client certificate E2E passed/); } finally { clearTimeout(timer); }