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..efdf4d84ff 100644 --- a/apps/desktop/src/features/plugins/presentation.tsx +++ b/apps/desktop/src/features/plugins/presentation.tsx @@ -203,32 +203,46 @@ 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 ( {showDot ? ( @@ -236,9 +250,7 @@ export function RatingChip({ ) : null} ); } diff --git a/apps/desktop/src/styles/plugins.css b/apps/desktop/src/styles/plugins.css index 2d0b64a4af..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 @@ -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; @@ -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; diff --git a/apps/desktop/test/plugin-reviews-surface.test.mjs b/apps/desktop/test/plugin-reviews-surface.test.mjs index 21555c0617..f716eddf29 100644 --- a/apps/desktop/test/plugin-reviews-surface.test.mjs +++ b/apps/desktop/test/plugin-reviews-surface.test.mjs @@ -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, / 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, / { @@ -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/); diff --git a/apps/desktop/test/plugins-page-style.test.mjs b/apps/desktop/test/plugins-page-style.test.mjs index 311352e551..bad5cdeca6 100644 --- a/apps/desktop/test/plugins-page-style.test.mjs +++ b/apps/desktop/test/plugins-page-style.test.mjs @@ -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 diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index d9466328aa..44660ddc62 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -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`) diff --git a/docs/spec/07-plugins/07-plugin-marketplace.md b/docs/spec/07-plugins/07-plugin-marketplace.md index 731c058044..246c1e78b8 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 +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). diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index da0a758657..df65e61726 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.** | +| 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) @@ -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. 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..d58bca8c0e 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,15 @@ Extensions - 风险描述(安全说明标注) - 安装按钮 +市场卡片在满宽下固定为两栏网格(`repeat(2, minmax(0, 1fr))`,不是 `auto-fill`), +并按页面容器自身宽度塌成单栏(`@container plugins-page (max-width: 560px)`),而不是 +按视口宽度,因此宽窗口里的窄面板同样会塌成单栏(D671)。每张卡片的页脚包含评分芯片、 +轮换的评论行和安装动作:评论行每六秒在插件中心选定的评论间轮换(悬停或聚焦时暂停, +并遵循减动效偏好),插件没有评论时读取固定的本地化"暂无评论"占位而不是收起该槽位。 +芯片只显示一位小数的平均值——绝不显示评论数——摘要为零评分的市场卡片固定显示 0.0; +只有插件中心完全没有返回摘要时芯片才隐藏,因此服务不可用仍然不会被误读为零评分 +(D671)。 + 市场卡呈现字母组合字形,而不是获取 `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 50bb3987ad..4b0c0bdb7c 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 或执行迁移。** | +| D671 | 市场两栏卡片网格与页脚评论行 | **市场卡片在满宽下固定渲染为两栏(`repeat(2, minmax(0, 1fr))`),并按页面容器自身宽度塌成单栏(`@container plugins-page (max-width: 560px)`,不是视口查询)。卡片页脚每六秒轮换插件中心选定的评论行(悬停/聚焦暂停,遵循减动效偏好),无评论时显示固定的本地化"暂无评论"占位。评分芯片只显示平均值、不再显示评论数;摘要为零评分的市场卡片固定显示 0.0,仅在完全没有摘要时隐藏芯片。已安装列表行与详情页不变。** | ## C. 提供商和模型覆盖决策 (0.3.4) @@ -5528,3 +5529,18 @@ Markdown 源码,不是 `text/html` 负载;对禁用行内 HTML 的外部编 `apps/desktop/test/composer-deep-file-search-user-path.test.mjs` 覆盖。见 `03-runtime/01-ipc-protocol.md` §13c、`04-ux/08-component-spec.md` §11.8 与 E2E-COMPOSER-deep-file-mentions。 + +## 2026-10-19 —— 市场两栏卡片网格与页脚评论行(D671) + +- 市场卡片网格在满宽下固定渲染为两栏(`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 9be23e8711..e6d61ea6ba 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -2327,13 +2327,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 ed9da2a67d..1bb38cc713 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -2348,13 +2348,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 f5b0e4233d..3ae8670abf 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -2327,13 +2327,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 7bcb322025..4fb09f5ffa 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -2327,13 +2327,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 3acb0bf81c..8443b81e8e 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -2344,13 +2344,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 f4c4d842e5..612f0ce5c7 100644 --- a/packages/i18n/src/locales/pt-BR/index.ts +++ b/packages/i18n/src/locales/pt-BR/index.ts @@ -2293,13 +2293,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 fd807b1955..2ce435ae8d 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -2338,13 +2338,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 968c3b4d8d..41858238cc 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -2322,13 +2322,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 998ecc3819..24196257b3 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -2322,13 +2322,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.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 acd68526e2..1f2dc97f2e 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"); @@ -612,6 +612,119 @@ 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(); + // 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"), + "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}`, + ); + + // 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"; + }, + pluginReviewsBatchProbe: async () => { step("batch:init"); await i18n.init({ lng: "en", resources: { en: { translation: en } } }); @@ -670,7 +783,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 +803,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(