From d18d6b059ae9d2d8cb54577f5903d27dc211675b Mon Sep 17 00:00:00 2001 From: Wenhao Date: Tue, 20 Oct 2026 23:52:24 +0800 Subject: [PATCH 01/45] chore: ignore local verification artifacts --- .gitignore | 1 + tests/e2e/extension-harness.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f7102e1..51872b7 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ test-screenshot-*.png debug-screenshot.png screenshot*.png test-results/ +artifacts/ # Local planning artifacts task_plan.md diff --git a/tests/e2e/extension-harness.js b/tests/e2e/extension-harness.js index f940bd6..40ba0b4 100644 --- a/tests/e2e/extension-harness.js +++ b/tests/e2e/extension-harness.js @@ -8,7 +8,7 @@ import { getBrowserLaunchOptions } from './browser-launch-options.js'; export const REPO_EXTENSION_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const COPY_EXCLUDED_SEGMENTS = ['.git', 'node_modules', 'test-results', 'playwright-report', 'dist']; +const COPY_EXCLUDED_SEGMENTS = ['.git', 'node_modules', 'test-results', 'playwright-report', 'dist', 'artifacts']; /** * Copies the extension and lets the caller patch its manifest, e.g. to map From 1cd6e406998e5d0e052c883b08aa942f046889b6 Mon Sep 17 00:00:00 2001 From: Wenhao Date: Tue, 20 Oct 2026 23:52:24 +0800 Subject: [PATCH 02/45] feat: ask engaged users to rate Panelize on the Chrome Web Store Count successful Send All actions in chrome.storage.local. After 20 sends the comparison page shows a banner linking to the store reviews page. Rate and Don't ask again stop the prompt; Later snoozes it for 50 sends. --- _locales/de/messages.json | 12 +++ _locales/en/messages.json | 12 +++ _locales/es/messages.json | 12 +++ _locales/fr/messages.json | 12 +++ _locales/it/messages.json | 12 +++ _locales/ja/messages.json | 12 +++ _locales/ko/messages.json | 12 +++ _locales/ru/messages.json | 12 +++ _locales/zh_CN/messages.json | 12 +++ _locales/zh_TW/messages.json | 12 +++ modules/rating-prompt.js | 129 ++++++++++++++++++++++++ multi-panel/multi-panel.css | 88 +++++++++++++++++ multi-panel/multi-panel.html | 11 +++ multi-panel/multi-panel.js | 64 +++++++++++- tests/e2e/rating-prompt.e2e.test.js | 104 +++++++++++++++++++ tests/rating-prompt.test.js | 148 ++++++++++++++++++++++++++++ 16 files changed, 663 insertions(+), 1 deletion(-) create mode 100644 modules/rating-prompt.js create mode 100644 tests/e2e/rating-prompt.e2e.test.js create mode 100644 tests/rating-prompt.test.js diff --git a/_locales/de/messages.json b/_locales/de/messages.json index aec50c0..1082cb6 100644 --- a/_locales/de/messages.json +++ b/_locales/de/messages.json @@ -827,5 +827,17 @@ "content": "$1" } } + }, + "ratingPromptMessage": { + "message": "Gefällt dir Panelize? Bewerte es im Chrome Web Store" + }, + "ratingPromptRate": { + "message": "Bewerten" + }, + "ratingPromptLater": { + "message": "Später" + }, + "ratingPromptNever": { + "message": "Nicht mehr fragen" } } diff --git a/_locales/en/messages.json b/_locales/en/messages.json index f11a19f..61f6475 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -844,5 +844,17 @@ "content": "$1" } } + }, + "ratingPromptMessage": { + "message": "Enjoying Panelize? Rate it on the Chrome Web Store" + }, + "ratingPromptRate": { + "message": "Rate" + }, + "ratingPromptLater": { + "message": "Later" + }, + "ratingPromptNever": { + "message": "Don't ask again" } } diff --git a/_locales/es/messages.json b/_locales/es/messages.json index e858b20..2dec30b 100644 --- a/_locales/es/messages.json +++ b/_locales/es/messages.json @@ -827,5 +827,17 @@ "content": "$1" } } + }, + "ratingPromptMessage": { + "message": "¿Te gusta Panelize? Valóralo en Chrome Web Store" + }, + "ratingPromptRate": { + "message": "Valorar" + }, + "ratingPromptLater": { + "message": "Más tarde" + }, + "ratingPromptNever": { + "message": "No volver a preguntar" } } diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index 651f1d4..473085d 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -827,5 +827,17 @@ "content": "$1" } } + }, + "ratingPromptMessage": { + "message": "Vous aimez Panelize ? Notez-le sur le Chrome Web Store" + }, + "ratingPromptRate": { + "message": "Noter" + }, + "ratingPromptLater": { + "message": "Plus tard" + }, + "ratingPromptNever": { + "message": "Ne plus demander" } } diff --git a/_locales/it/messages.json b/_locales/it/messages.json index bf0442d..6ce0a53 100644 --- a/_locales/it/messages.json +++ b/_locales/it/messages.json @@ -827,5 +827,17 @@ "content": "$1" } } + }, + "ratingPromptMessage": { + "message": "Ti piace Panelize? Valutalo sul Chrome Web Store" + }, + "ratingPromptRate": { + "message": "Valuta" + }, + "ratingPromptLater": { + "message": "Più tardi" + }, + "ratingPromptNever": { + "message": "Non chiedere più" } } diff --git a/_locales/ja/messages.json b/_locales/ja/messages.json index f5d2526..455a86e 100644 --- a/_locales/ja/messages.json +++ b/_locales/ja/messages.json @@ -827,5 +827,17 @@ "content": "$1" } } + }, + "ratingPromptMessage": { + "message": "Panelize を気に入っていただけましたか?Chrome ウェブストアで評価をお願いします" + }, + "ratingPromptRate": { + "message": "評価する" + }, + "ratingPromptLater": { + "message": "後で" + }, + "ratingPromptNever": { + "message": "今後表示しない" } } diff --git a/_locales/ko/messages.json b/_locales/ko/messages.json index 9347a2a..124bae1 100644 --- a/_locales/ko/messages.json +++ b/_locales/ko/messages.json @@ -827,5 +827,17 @@ "content": "$1" } } + }, + "ratingPromptMessage": { + "message": "Panelize가 마음에 드시나요? Chrome 웹 스토어에서 평가해 주세요" + }, + "ratingPromptRate": { + "message": "평가하기" + }, + "ratingPromptLater": { + "message": "나중에" + }, + "ratingPromptNever": { + "message": "다시 묻지 않기" } } diff --git a/_locales/ru/messages.json b/_locales/ru/messages.json index 02e0feb..6b76485 100644 --- a/_locales/ru/messages.json +++ b/_locales/ru/messages.json @@ -827,5 +827,17 @@ "content": "$1" } } + }, + "ratingPromptMessage": { + "message": "Нравится Panelize? Оцените его в Интернет-магазине Chrome" + }, + "ratingPromptRate": { + "message": "Оценить" + }, + "ratingPromptLater": { + "message": "Позже" + }, + "ratingPromptNever": { + "message": "Больше не спрашивать" } } diff --git a/_locales/zh_CN/messages.json b/_locales/zh_CN/messages.json index b6801d4..ffbe7ae 100644 --- a/_locales/zh_CN/messages.json +++ b/_locales/zh_CN/messages.json @@ -823,5 +823,17 @@ "content": "$1" } } + }, + "ratingPromptMessage": { + "message": "喜欢 Panelize 吗?欢迎在 Chrome 应用商店给它评个分" + }, + "ratingPromptRate": { + "message": "去评分" + }, + "ratingPromptLater": { + "message": "以后再说" + }, + "ratingPromptNever": { + "message": "不再询问" } } diff --git a/_locales/zh_TW/messages.json b/_locales/zh_TW/messages.json index 39e3004..6df538f 100644 --- a/_locales/zh_TW/messages.json +++ b/_locales/zh_TW/messages.json @@ -823,5 +823,17 @@ "content": "$1" } } + }, + "ratingPromptMessage": { + "message": "喜歡 Panelize 嗎?歡迎到 Chrome 線上應用程式商店為它評分" + }, + "ratingPromptRate": { + "message": "去評分" + }, + "ratingPromptLater": { + "message": "稍後再說" + }, + "ratingPromptNever": { + "message": "不要再詢問" } } diff --git a/modules/rating-prompt.js b/modules/rating-prompt.js new file mode 100644 index 0000000..b5e2d13 --- /dev/null +++ b/modules/rating-prompt.js @@ -0,0 +1,129 @@ +// Store rating prompt: counts successful "Send All" actions locally and decides +// when the comparison page should ask the user to rate Panelize. Nothing leaves +// the device; the state lives in chrome.storage.local. + +export const RATING_PROMPT_STORAGE_KEY = 'ratingPrompt'; + +/** Successful sends before the banner first appears. */ +export const RATING_PROMPT_FIRST_THRESHOLD = 20; + +/** Further successful sends before the banner returns after "Later". */ +export const RATING_PROMPT_LATER_INTERVAL = 50; + +export const RATING_PROMPT_REVIEW_URL = + 'https://chromewebstore.google.com/detail/panelize/iokalaafkmjffolodkkgbbccmofbglii/reviews'; + +export const RATING_PROMPT_ACTIONS = Object.freeze({ + RATE: 'rate', + LATER: 'later', + NEVER: 'never' +}); + +/** + * @typedef {object} RatingPromptState + * @property {number} sendCount - Successful "Send All" actions so far. + * @property {number} nextPromptAt - sendCount at which the banner shows. + * @property {boolean} dismissed - True once the user rated or opted out. + */ + +/** @returns {RatingPromptState} */ +export function createInitialRatingPromptState() { + return { + sendCount: 0, + nextPromptAt: RATING_PROMPT_FIRST_THRESHOLD, + dismissed: false + }; +} + +function toCount(value, fallback) { + return Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback; +} + +/** + * Repairs a stored value so missing or corrupt fields fall back to defaults. + * @param {unknown} value + * @returns {RatingPromptState} + */ +export function normalizeRatingPromptState(value) { + const initial = createInitialRatingPromptState(); + if (!value || typeof value !== 'object') { + return initial; + } + return { + sendCount: toCount(value.sendCount, initial.sendCount), + nextPromptAt: toCount(value.nextPromptAt, initial.nextPromptAt), + dismissed: value.dismissed === true + }; +} + +/** + * @param {RatingPromptState} state + * @returns {RatingPromptState} + */ +export function recordSuccessfulSend(state) { + const current = normalizeRatingPromptState(state); + return { ...current, sendCount: current.sendCount + 1 }; +} + +/** + * @param {RatingPromptState} state + * @returns {boolean} + */ +export function shouldShowRatingPrompt(state) { + const current = normalizeRatingPromptState(state); + return !current.dismissed && current.sendCount >= current.nextPromptAt; +} + +/** + * Applies a banner button. "Rate" and "Don't ask again" stop the prompt for + * good; "Later" pushes it back by RATING_PROMPT_LATER_INTERVAL sends. + * @param {RatingPromptState} state + * @param {string} action - One of RATING_PROMPT_ACTIONS. + * @returns {RatingPromptState} + */ +export function applyRatingPromptAction(state, action) { + const current = normalizeRatingPromptState(state); + switch (action) { + case RATING_PROMPT_ACTIONS.RATE: + case RATING_PROMPT_ACTIONS.NEVER: + return { ...current, dismissed: true }; + case RATING_PROMPT_ACTIONS.LATER: + return { ...current, nextPromptAt: current.sendCount + RATING_PROMPT_LATER_INTERVAL }; + default: + throw new Error(`Unknown rating prompt action: ${action}`); + } +} + +/** + * @param {chrome.storage.StorageArea} [storage] + * @returns {Promise} + */ +export async function loadRatingPromptState(storage = chrome.storage.local) { + const result = await storage.get(RATING_PROMPT_STORAGE_KEY); + return normalizeRatingPromptState(result?.[RATING_PROMPT_STORAGE_KEY]); +} + +async function updateRatingPromptState(storage, update) { + const next = update(await loadRatingPromptState(storage)); + await storage.set({ [RATING_PROMPT_STORAGE_KEY]: next }); + return next; +} + +/** + * Counts one successful "Send All" and returns the new state. + * @param {chrome.storage.StorageArea} [storage] + * @returns {Promise} + */ +export function countSuccessfulSend(storage = chrome.storage.local) { + return updateRatingPromptState(storage, recordSuccessfulSend); +} + +/** + * Stores the user's answer to the banner and returns the new state. + * @param {string} action - One of RATING_PROMPT_ACTIONS. + * @param {chrome.storage.StorageArea} [storage] + * @returns {Promise} + */ +export function saveRatingPromptAction(action, storage = chrome.storage.local) { + return updateRatingPromptState(storage, (state) => applyRatingPromptAction(state, action)); +} diff --git a/multi-panel/multi-panel.css b/multi-panel/multi-panel.css index 8ea458e..a37364f 100644 --- a/multi-panel/multi-panel.css +++ b/multi-panel/multi-panel.css @@ -620,6 +620,94 @@ body { display: none; } +/* ===== Store Rating Banner ===== */ +.rating-banner { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 16px; + background: #e3f2fd; + border-top: 1px solid #bbdefb; + color: #333; + font-size: 13px; + flex-shrink: 0; +} + +.rating-banner[hidden] { + display: none; +} + +.rating-banner-icon { + color: #1976d2; + font-size: 18px; +} + +.rating-banner-text { + flex: 1; + min-width: 0; +} + +.rating-banner-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.rating-banner-btn { + padding: 4px 12px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: #1976d2; + font-size: 13px; + cursor: pointer; + transition: background 0.2s; +} + +.rating-banner-btn:hover { + background: rgba(25, 118, 210, 0.08); +} + +.rating-banner-btn.primary { + background: #1976d2; + color: #fff; +} + +.rating-banner-btn.primary:hover { + background: #1565c0; +} + +[data-theme="dark"] .rating-banner { + background: #1a3a5a; + border-top-color: #444; + color: #e0e0e0; +} + +[data-theme="dark"] .rating-banner-icon, +[data-theme="dark"] .rating-banner-btn { + color: #64b5f6; +} + +[data-theme="dark"] .rating-banner-btn:hover { + background: rgba(100, 181, 246, 0.12); +} + +[data-theme="dark"] .rating-banner-btn.primary { + background: #64b5f6; + color: #1e1e1e; +} + +@media (max-width: 640px) { + .rating-banner { + flex-wrap: wrap; + } + + .rating-banner-actions { + width: 100%; + justify-content: flex-end; + } +} + /* ===== Input Bar ===== */ #input-bar { background: #fff; diff --git a/multi-panel/multi-panel.html b/multi-panel/multi-panel.html index 4ec2796..1acdd63 100644 --- a/multi-panel/multi-panel.html +++ b/multi-panel/multi-panel.html @@ -85,6 +85,17 @@ + + +