1
0

WIP Feedback

This commit is contained in:
2026-03-25 18:32:58 +01:00
parent 4e97b5214e
commit 0566d9db10
10 changed files with 315 additions and 80 deletions

View File

@@ -5,7 +5,7 @@ set -euo pipefail
#
# Usage: ./run-screenshots.sh
#
# Output: screenshots/*.png (gitignored)
# Output: screenshots/{mobile,desktop}/{manual-nickelmenu,manual-patches,connected-nickelmenu,connected-patches,edge-cases}/*.png (gitignored)
cd "$(dirname "$0")"

View File

@@ -6,12 +6,13 @@
* Run: ./run-screenshots.sh
*/
import { test, expect } from '@playwright/test';
import { injectMockDevice } from './helpers/mock-device.js';
import { injectMockDevice, overrideFirmwareURLs } from './helpers/mock-device.js';
import { hasFirmwareZip } from './helpers/assets.js';
const shot = async (page, name, testInfo) => {
const shot = async (page, folder, name, testInfo) => {
const project = testInfo.project.name;
await page.waitForTimeout(200);
await page.screenshot({ path: `screenshots/${project}/${name}.png`, fullPage: true });
await page.screenshot({ path: `screenshots/${project}/${folder}/${name}.png`, fullPage: true });
};
/** Dismiss the mobile warning modal if it's open. */
@@ -23,143 +24,280 @@ const dismissMobileModal = async (page) => {
}
};
test('capture all steps', async ({ page }, testInfo) => {
// ============================================================
// 1. Manual NickelMenu flow
// ============================================================
test('manual nickelmenu', async ({ page }, testInfo) => {
const dir = 'manual-nickelmenu';
const isMobile = testInfo.project.name === 'mobile';
// 1. Connect step (with mobile modal if applicable)
await page.goto('/');
if (isMobile) {
// Capture the mobile warning modal
await expect(page.locator('#mobile-dialog')).toBeVisible();
await page.screenshot({ path: `screenshots/mobile/00-mobile-warning.png` });
await page.click('#btn-mobile-continue');
await expect(page.locator('#mobile-dialog')).not.toBeVisible();
}
await expect(page.locator('#step-connect')).not.toBeHidden();
await injectMockDevice(page);
await shot(page, '01-connect', testInfo);
// 2. Connection instructions
await page.click('#btn-connect');
await expect(page.locator('#step-connect-instructions')).not.toBeHidden();
await shot(page, '02-connect-instructions', testInfo);
// 2b. Connection instructions with disclaimer open
await page.click('details.banner--accent summary');
await page.waitForTimeout(100);
await shot(page, '03-connect-instructions-disclaimer', testInfo);
// 3. Device detected
await page.click('#btn-connect-ready');
await expect(page.locator('#step-device')).not.toBeHidden();
await shot(page, '04-device', testInfo);
// 4. Mode selection
await page.click('#btn-device-next');
// Click "Build downloadable archive" to enter manual mode
await page.click('#btn-manual');
await expect(page.locator('#step-mode')).not.toBeHidden();
await shot(page, '05-mode-selection', testInfo);
await shot(page, dir, '01-mode-selection', testInfo);
// 5a. NickelMenu config
// Select NickelMenu config
await page.click('input[name="mode"][value="nickelmenu"]');
await page.click('#btn-mode-next');
await expect(page.locator('#step-nickelmenu')).not.toBeHidden();
await shot(page, '06-nickelmenu-config', testInfo);
await shot(page, dir, '02-nickelmenu-config', testInfo);
// 5b. NickelMenu features (preset)
// Preset → features
await page.click('input[value="preset"]');
await page.click('#btn-nm-next');
await expect(page.locator('#step-nm-features')).not.toBeHidden();
await shot(page, '07-nickelmenu-features', testInfo);
await shot(page, dir, '03-nickelmenu-features', testInfo);
// 5c. NickelMenu review
// Features → review (only download button in manual mode)
await page.click('#btn-nm-features-next');
await expect(page.locator('#step-nm-review')).not.toBeHidden();
await shot(page, '08-nickelmenu-review', testInfo);
await shot(page, dir, '04-nickelmenu-review', testInfo);
// Go back to mode and try patches path
await page.click('#btn-nm-review-back');
await page.click('#btn-nm-features-back');
await page.click('#btn-nm-back');
// Download → done
await page.click('#btn-nm-download');
const nmDone = page.locator('#step-nm-done');
await expect(nmDone).not.toBeHidden();
await shot(page, dir, '05-nickelmenu-done', testInfo);
});
// ============================================================
// 2. Manual Patches flow
// ============================================================
test('manual patches', async ({ page }, testInfo) => {
test.skip(!hasFirmwareZip(), 'Firmware zip not available');
const dir = 'manual-patches';
const isMobile = testInfo.project.name === 'mobile';
await page.goto('/');
await injectMockDevice(page);
await page.waitForFunction(() => !!window.FIRMWARE_DOWNLOADS);
await overrideFirmwareURLs(page);
if (isMobile) {
await page.click('#btn-mobile-continue');
await expect(page.locator('#mobile-dialog')).not.toBeVisible();
}
// Click "Build downloadable archive" to enter manual mode
await page.click('#btn-manual');
await expect(page.locator('#step-mode')).not.toBeHidden();
await shot(page, dir, '01-mode-selection', testInfo);
// Select Patches → version selection
await page.click('input[name="mode"][value="patches"]');
await page.click('#btn-mode-next');
await expect(page.locator('#step-patches')).not.toBeHidden();
await shot(page, '09-patches-config', testInfo);
await expect(page.locator('#step-manual-version')).not.toBeHidden();
await shot(page, dir, '02-version-selection', testInfo);
// 6b. Expand a patch section and select a patch
// Select firmware version and model
await page.selectOption('#manual-version', { index: 1 });
await expect(page.locator('#manual-model')).not.toBeHidden();
await page.selectOption('#manual-model', { index: 1 });
await page.click('#btn-manual-confirm');
// Patches config
await expect(page.locator('#step-patches')).not.toBeHidden();
await shot(page, dir, '03-patches-config', testInfo);
// Expand section and select a patch
const section = page.locator('.patch-file-section').first();
await section.locator('summary').click();
const patchLabel = section.locator('label').filter({ has: page.locator('.patch-name:not(.patch-name-none)') }).first();
await patchLabel.locator('input').check();
await shot(page, '10-patches-selected', testInfo);
await shot(page, dir, '04-patches-selected', testInfo);
// Review & build
await page.click('#btn-patches-next');
await expect(page.locator('#step-firmware')).not.toBeHidden();
await shot(page, dir, '05-build', testInfo);
// Build
await page.click('#btn-build');
const stepDone = page.locator('#step-done');
await expect(stepDone).not.toBeHidden({ timeout: 60_000 });
await shot(page, dir, '06-patches-done', testInfo);
// Download
await page.click('#btn-download');
await expect(stepDone.locator('#download-instructions')).toBeVisible();
await shot(page, dir, '07-patches-done-download', testInfo);
});
test('nickelmenu done with feedback', async ({ page }, testInfo) => {
// Enable analytics so the feedback widget appears
await page.addInitScript(() => { window.__ANALYTICS_ENABLED = true; });
// ============================================================
// 3. Connected NickelMenu flow
// ============================================================
test('connected nickelmenu', async ({ page }, testInfo) => {
const dir = 'connected-nickelmenu';
const isMobile = testInfo.project.name === 'mobile';
await page.goto('/');
await dismissMobileModal(page);
if (isMobile) {
await expect(page.locator('#mobile-dialog')).toBeVisible();
await page.screenshot({ path: `screenshots/mobile/${dir}/00-mobile-warning.png` });
await page.click('#btn-mobile-continue');
}
await expect(page.locator('#step-connect')).not.toBeHidden();
await injectMockDevice(page);
await shot(page, dir, '01-connect', testInfo);
// Connect device → mode selection → NickelMenu
// Connection instructions
await page.click('#btn-connect');
await expect(page.locator('#step-connect-instructions')).not.toBeHidden();
await shot(page, dir, '02-connect-instructions', testInfo);
// Device detected
await page.click('#btn-connect-ready');
await expect(page.locator('#step-device')).not.toBeHidden();
await shot(page, dir, '03-device', testInfo);
// Mode selection
await page.click('#btn-device-next');
await expect(page.locator('#step-mode')).not.toBeHidden();
await shot(page, dir, '04-mode-selection', testInfo);
// NickelMenu config
await page.click('input[name="mode"][value="nickelmenu"]');
await page.click('#btn-mode-next');
await expect(page.locator('#step-nickelmenu')).not.toBeHidden();
await shot(page, dir, '05-nickelmenu-config', testInfo);
// NickelMenu-only → review → write to device
await page.click('input[value="nickelmenu-only"]');
// Preset → features
await page.click('input[value="preset"]');
await page.click('#btn-nm-next');
await expect(page.locator('#step-nm-review')).not.toBeHidden();
await page.click('#btn-nm-write');
await expect(page.locator('#step-nm-features')).not.toBeHidden();
await shot(page, dir, '06-nickelmenu-features', testInfo);
// Wait for done step
// Features → review
await page.click('#btn-nm-features-next');
await expect(page.locator('#step-nm-review')).not.toBeHidden();
await shot(page, dir, '07-nickelmenu-review', testInfo);
// Write to device → done
await page.click('#btn-nm-write');
const nmDone = page.locator('#step-nm-done');
await expect(nmDone).not.toBeHidden();
await expect(nmDone.locator('.feedback')).toBeVisible();
await shot(page, '15-done-feedback', testInfo);
await shot(page, dir, '08-nickelmenu-done', testInfo);
});
// Click thumbs up and capture the thank-you state
await nmDone.locator('.feedback-btn[data-vote="up"]').click();
await expect(nmDone.locator('.feedback-thanks')).toBeVisible();
await shot(page, '16-done-feedback-voted', testInfo);
// ============================================================
// 4. Connected Patches flow
// ============================================================
test('connected patches', async ({ page }, testInfo) => {
test.skip(!hasFirmwareZip(), 'Firmware zip not available');
const dir = 'connected-patches';
const isMobile = testInfo.project.name === 'mobile';
await page.goto('/');
await injectMockDevice(page);
await page.waitForFunction(() => !!window.FIRMWARE_DOWNLOADS);
await overrideFirmwareURLs(page);
if (isMobile) {
await page.click('#btn-mobile-continue');
await expect(page.locator('#mobile-dialog')).not.toBeVisible();
}
await expect(page.locator('#step-connect')).not.toBeHidden();
await shot(page, dir, '01-connect', testInfo);
// Connection instructions
await page.click('#btn-connect');
await expect(page.locator('#step-connect-instructions')).not.toBeHidden();
await shot(page, dir, '02-connect-instructions', testInfo);
// Device detected
await page.click('#btn-connect-ready');
await expect(page.locator('#step-device')).not.toBeHidden();
await shot(page, dir, '03-device', testInfo);
// Mode selection
await page.click('#btn-device-next');
await expect(page.locator('#step-mode')).not.toBeHidden();
await shot(page, dir, '04-mode-selection', testInfo);
// Patches config
await page.click('input[name="mode"][value="patches"]');
await page.click('#btn-mode-next');
await expect(page.locator('#step-patches')).not.toBeHidden();
await shot(page, dir, '05-patches-config', testInfo);
// Expand section and select a patch
const section = page.locator('.patch-file-section').first();
await section.locator('summary').click();
const patchLabel = section.locator('label').filter({ has: page.locator('.patch-name:not(.patch-name-none)') }).first();
await patchLabel.locator('input').check();
await shot(page, dir, '06-patches-selected', testInfo);
// Review & build
await page.click('#btn-patches-next');
await expect(page.locator('#step-firmware')).not.toBeHidden();
await shot(page, dir, '07-build', testInfo);
// Build → done
await page.click('#btn-build');
const stepDone = page.locator('#step-done');
await expect(stepDone).not.toBeHidden({ timeout: 60_000 });
await shot(page, dir, '08-patches-done', testInfo);
// Download
await page.click('#btn-download');
await expect(stepDone.locator('#download-instructions')).toBeVisible();
await shot(page, dir, '09-patches-done-download', testInfo);
});
// ============================================================
// 5. Edge cases
// ============================================================
test('unsupported browser', async ({ page }, testInfo) => {
const dir = 'edge-cases';
await page.addInitScript(() => { delete window.showDirectoryPicker; });
await page.goto('/');
await dismissMobileModal(page);
await expect(page.locator('#connect-unsupported-hint')).toBeVisible();
await shot(page, dir, 'unsupported-browser', testInfo);
});
test('incompatible firmware', async ({ page }, testInfo) => {
const dir = 'edge-cases';
await page.goto('/');
await dismissMobileModal(page);
await injectMockDevice(page, { firmware: '5.0.0' });
await page.click('#btn-connect');
await page.click('#btn-connect-ready');
await expect(page.locator('#step-device')).not.toBeHidden();
await shot(page, '11-device-incompatible', testInfo);
await shot(page, dir, 'incompatible-firmware', testInfo);
});
test('unknown model', async ({ page }, testInfo) => {
const dir = 'edge-cases';
await page.goto('/');
await dismissMobileModal(page);
await injectMockDevice(page, { serial: 'X9990A0000000' });
await page.click('#btn-connect');
await page.click('#btn-connect-ready');
await expect(page.locator('#step-device')).not.toBeHidden();
await shot(page, '12-device-unknown', testInfo);
});
test('unsupported browser', async ({ page }, testInfo) => {
await page.addInitScript(() => { delete window.showDirectoryPicker; });
await page.goto('/');
await dismissMobileModal(page);
await expect(page.locator('#connect-unsupported-hint')).toBeVisible();
await shot(page, '13-connect-unsupported', testInfo);
await shot(page, dir, 'unknown-model', testInfo);
});
test('disclaimer dialog', async ({ page }, testInfo) => {
const dir = 'edge-cases';
await page.goto('/');
await dismissMobileModal(page);
await page.click('#btn-how-it-works');
await expect(page.locator('#how-it-works-dialog')).toBeVisible();
await page.waitForTimeout(200);
await page.screenshot({ path: `screenshots/${testInfo.project.name}/14-disclaimer-dialog.png` });
await page.screenshot({ path: `screenshots/${testInfo.project.name}/${dir}/disclaimer-dialog.png` });
});

View File

@@ -0,0 +1,41 @@
.feedback {
display: flex;
align-items: center;
gap: 0.75rem;
}
.feedback-text {
font-size: 0.88rem;
}
.feedback-buttons {
display: flex;
gap: 0.35rem;
margin-left: auto;
}
.feedback-btn {
font-size: 1rem;
padding: 0.2rem 0.55rem;
background: transparent;
border: 1px solid var(--info-border);
border-radius: 999px;
cursor: pointer;
transition: all 0.15s ease;
color: inherit;
line-height: 1.3;
}
.feedback-btn:hover {
background: rgba(255, 255, 255, 0.5);
}
.feedback-btn--selected {
background: rgba(255, 255, 255, 0.5);
border-color: var(--info-text);
}
.feedback-thanks {
font-size: 0.85rem;
font-weight: 600;
}

View File

@@ -69,7 +69,7 @@ select + .fallback-hint {
}
.restart-hint {
margin-top: 1.5rem;
margin-top: 1rem;
font-size: 0.78rem;
color: var(--text-muted);
}

View File

@@ -11,6 +11,7 @@
@import './components/info-card.css';
@import './components/step-nav.css';
@import './components/modal.css';
@import './components/feedback.css';
@import './layout/hero.css';
@import './layout/steps.css';
@import './layout/footer.css';

View File

@@ -305,7 +305,15 @@
<strong>Safely eject your Kobo and let it reboot.</strong> Please be patient, NickelMenu will be automatically removed during the reboot.
A "glitchy" horizontal line may briefly appear on screen after restarting — this is normal, as NickelMenu removes itself.
</p>
<p class="restart-hint">You can always restart the entire flow by reloading the page.</p>
<p class="restart-hint">You can always restart the entire flow by reloading the page, if you want to try again for another configuration or undo the changes that were made.</p>
<div class="banner banner--info feedback" hidden>
<span class="feedback-text">Did you find the wizard easy to use?</span>
<span class="feedback-thanks" hidden>Thank you for your feedback!</span>
<span class="feedback-buttons">
<button class="feedback-btn" data-vote="up" type="button">&#x1F44D;</button>
<button class="feedback-btn" data-vote="down" type="button">&#x1F44E;</button>
</span>
</div>
</section>
<!-- Step 2 (patches path): Configure patches -->
@@ -378,7 +386,15 @@
<li>The device will reboot and apply the patches automatically.</li>
</ol>
</div>
<p class="restart-hint">You can always restart the entire flow by reloading the page.</p>
<p class="restart-hint">You can always restart the entire flow by reloading the page, if you want to try again for another configuration or undo the changes that were made.</p>
<div class="banner banner--info feedback" hidden>
<span class="feedback-text">Did you find the wizard easy to use?</span>
<span class="feedback-thanks" hidden>Thank you for your feedback!</span>
<span class="feedback-buttons">
<button class="feedback-btn" data-vote="up" type="button">&#x1F44D;</button>
<button class="feedback-btn" data-vote="down" type="button">&#x1F44E;</button>
</span>
</div>
</section>
<!-- Error state -->

View File

@@ -97,6 +97,33 @@ export async function fetchOrThrow(url, errorPrefix = 'Fetch failed') {
return resp;
}
/**
* Wire up a .feedback banner inside a container element.
* Shows text + vote buttons; clicking one replaces all with a thank-you message.
* @param {HTMLElement} container - element containing the .feedback widget
* @param {function} onVote - callback receiving 'up' or 'down'
*/
export function setupFeedback(container, onVote) {
const widget = container.querySelector('.feedback');
if (!widget) return;
widget.hidden = false;
const text = widget.querySelector('.feedback-text');
const buttons = widget.querySelectorAll('.feedback-btn');
const thanks = widget.querySelector('.feedback-thanks');
text.hidden = false;
thanks.hidden = true;
buttons.forEach((btn) => {
btn.hidden = false;
btn.disabled = false;
btn.addEventListener('click', () => {
text.hidden = true;
buttons.forEach((b) => { b.hidden = true; });
thanks.hidden = false;
onVote(btn.dataset.vote);
}, { once: true });
});
}
/**
* Trigger a browser download of in-memory data.
* Creates a temporary object URL, clicks a hidden <a>, then revokes it.

View File

@@ -13,11 +13,11 @@
* `resetNickelMenuState`.
*/
import { $, $q, $qa, triggerDownload, renderNmCheckboxList, populateList } from '../dom.js';
import { $, $q, $qa, triggerDownload, renderNmCheckboxList, populateList, setupFeedback } from '../dom.js';
import { showStep, setNavStep } from '../nav.js';
import { ALL_FEATURES } from '../../nickelmenu/installer.js';
import { TL } from '../strings.js';
import { track } from '../analytics.js';
import { isEnabled as analyticsEnabled, track } from '../analytics.js';
export function initNickelMenu(state) {
@@ -366,6 +366,12 @@ export function initNickelMenu(state) {
track('flow-end', { result: 'nm-download' });
}
if (analyticsEnabled()) {
setupFeedback(stepNmDone, (vote) => {
track('feedback', { vote });
});
}
setNavStep(5);
showStep(stepNmDone);
}

View File

@@ -14,11 +14,11 @@
* `updatePatchCount`, and `configureFirmwareStep`.
*/
import { $, formatMB, triggerDownload, populateList } from '../dom.js';
import { $, formatMB, triggerDownload, populateList, setupFeedback } from '../dom.js';
import { showStep, setNavLabels, setNavStep } from '../nav.js';
import { KoboModels } from '../services/kobo-device.js';
import { TL } from '../strings.js';
import { track } from '../analytics.js';
import { isEnabled as analyticsEnabled, track } from '../analytics.js';
import JSZip from 'jszip';
export function initPatchesFlow(state) {
@@ -249,6 +249,12 @@ export function initPatchesFlow(state) {
downloadInstructions.hidden = true;
existingTgzWarning.hidden = true;
if (analyticsEnabled()) {
setupFeedback(stepDone, (vote) => {
track('feedback', { vote });
});
}
setNavStep(5);
showStep(stepDone);

View File

@@ -17,8 +17,8 @@ export const TL = {
STATUS: {
DEVICE_RECOGNIZED: 'Your device has been recognized. You can continue to the next step!',
NM_REMOVED_ON_REBOOT: 'NickelMenu will be removed on next reboot.',
NM_INSTALLED: 'NickelMenu has been installed on your Kobo.',
NM_DOWNLOAD_READY: 'Your NickelMenu package is ready to download.',
NM_INSTALLED: 'NickelMenu has been prepared for your Kobo. To complete the installation, follow the instructions below.',
NM_DOWNLOAD_READY: 'Your NickelMenu package is ready to download. After downloading, a list of installation steps will be displayed.',
NM_WILL_BE_REMOVED: 'NickelMenu will be updated and marked for removal. It will uninstall itself when your Kobo reboots.',
NM_WILL_BE_INSTALLED: 'The following will be installed on your Kobo:',
NM_NICKEL_ROOT_TGZ: 'NickelMenu (KoboRoot.tgz)',