From f747839c59cb4dad1d198bb8f356f638ba90fed8 Mon Sep 17 00:00:00 2001
From: kbx <6354698+KenjiBaheux@users.noreply.github.com>
Date: Sun, 25 Jan 2026 10:09:40 +0900
Subject: [PATCH 1/2] feat(ui): improve model downloader onboarding
- Redesign requirements list with dynamic system checks (Flag, Storage).
- Add indeterminate progress bar state to fix "0% freeze" perception.
- Implement hover-to-copy interaction for chrome://flags.
- Reorder requirements to minimize premature abandonment.
---
.npmrc | 16 +
package-lock.json | 41 +--
package.json | 6 +-
src/shared/components/model-downloader.ts | 344 +++++++++++++---------
4 files changed, 243 insertions(+), 164 deletions(-)
create mode 100644 .npmrc
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..040d722
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,16 @@
+# Force exact versions in package.json (no ^ or ~)
+# This prevents a supply chain attack from auto-installing
+# a compromised minor/patch version during a fresh 'npm install'.
+save-exact=true
+
+# Prevent execution of arbitrary 'post-install' scripts
+# Many malicious packages use these scripts to steal environment variables/keys.
+ignore-scripts=true
+
+# Ensure we only use the official registry
+# This mitigates 'Dependency Confusion' where a private package
+# name is mimicked on the public registry.
+registry=https://registry.npmjs.org/
+
+# Lock the lockfile version to ensure cross-platform consistency
+lockfile-version=3
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 5eaa09f..3866b9a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,7 +11,7 @@
"@webcomponents/webcomponentsjs": "^2.8.0"
},
"devDependencies": {
- "@crxjs/vite-plugin": "^2.0.3",
+ "@crxjs/vite-plugin": "2.0.3",
"@types/chrome": "^0.1.29",
"@types/dom-chromium-ai": "^0.0.10",
"@types/node": "^24.9.2",
@@ -19,7 +19,7 @@
"eslint": "^9.39.1",
"globals": "^15.15.0",
"husky": "^9.1.7",
- "pino": "^10.1.0",
+ "pino": "^10.1.1",
"prettier": "^3.6.2",
"puppeteer-core": "^24.27.0",
"typescript": "~5.8.3",
@@ -31,9 +31,9 @@
}
},
"node_modules/@crxjs/vite-plugin": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/@crxjs/vite-plugin/-/vite-plugin-2.2.0.tgz",
- "integrity": "sha512-HpT1GLbUQy42nlpN4sGzFgulacBraMM778s8Q+oPo4cb26DwO9tTwdndlvAS8fe6vEProFXvbdt37objp/0IQA==",
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@crxjs/vite-plugin/-/vite-plugin-2.0.3.tgz",
+ "integrity": "sha512-OfAzERiwBlLg7u/+aRExJVYlxx95IaYOSEMJpMzXcKqWPNBMlA1ppwC2nelHTIvF5LWxn5CEp6zqlHjLH9QGtg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3567,32 +3567,32 @@
}
},
"node_modules/pino": {
- "version": "10.1.0",
- "resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz",
- "integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==",
+ "version": "10.3.0",
+ "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz",
+ "integrity": "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
- "pino-abstract-transport": "^2.0.0",
+ "pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
- "thread-stream": "^3.0.0"
+ "thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
- "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
+ "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4208,13 +4208,16 @@
}
},
"node_modules/thread-stream": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
- "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
+ "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
"dev": true,
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
+ },
+ "engines": {
+ "node": ">=20"
}
},
"node_modules/tinybench": {
@@ -4383,9 +4386,9 @@
}
},
"node_modules/undici": {
- "version": "7.16.0",
- "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz",
- "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==",
+ "version": "7.19.1",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.19.1.tgz",
+ "integrity": "sha512-Gpq0iNm5M6cQWlyHQv9MV+uOj1jWk7LpkoE5vSp/7zjb4zMdAcUD+VL5y0nH4p9EbUklq00eVIIX/XcDHzu5xg==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/package.json b/package.json
index 7bad068..33c7768 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,7 @@
"prepare": "husky"
},
"devDependencies": {
- "@crxjs/vite-plugin": "^2.0.3",
+ "@crxjs/vite-plugin": "2.0.3",
"@types/chrome": "^0.1.29",
"@types/dom-chromium-ai": "^0.0.10",
"@types/node": "^24.9.2",
@@ -27,7 +27,7 @@
"eslint": "^9.39.1",
"globals": "^15.15.0",
"husky": "^9.1.7",
- "pino": "^10.1.0",
+ "pino": "^10.1.1",
"prettier": "^3.6.2",
"puppeteer-core": "^24.27.0",
"typescript": "~5.8.3",
@@ -40,4 +40,4 @@
"dependencies": {
"@webcomponents/webcomponentsjs": "^2.8.0"
}
-}
+}
\ No newline at end of file
diff --git a/src/shared/components/model-downloader.ts b/src/shared/components/model-downloader.ts
index 4b0723e..ed86a90 100644
--- a/src/shared/components/model-downloader.ts
+++ b/src/shared/components/model-downloader.ts
@@ -1,8 +1,7 @@
import { createModelDownloader, type DownloadProgress } from '../../services/model-downloader.ts';
-import { STORAGE_DEFAULTS, STORAGE_KEYS } from '../constants.ts';
+import { STORAGE_KEYS } from '../constants.ts';
const PROOFREADER_FLAG_URL = 'chrome://flags/#proofreader-api-for-gemini-nano';
-const PROOFREADER_FLAG_INSTRUCTIONS = `Enable "Proofreader API for Gemini Nano" on ${PROOFREADER_FLAG_URL}`;
export class ModelDownloaderComponent extends HTMLElement {
private shadow: ShadowRoot;
@@ -12,12 +11,13 @@ export class ModelDownloaderComponent extends HTMLElement {
private elements = {
container: null as HTMLDivElement | null,
+ requirements: null as HTMLDivElement | null,
status: null as HTMLDivElement | null,
button: null as HTMLButtonElement | null,
progress: null as HTMLProgressElement | null,
progressText: null as HTMLDivElement | null,
error: null as HTMLDivElement | null,
- };
+ }
constructor() {
super();
@@ -25,10 +25,11 @@ export class ModelDownloaderComponent extends HTMLElement {
}
connectedCallback() {
- this.render();
+ this.render().then(() => {
this.attachEventListeners();
this.checkInitialState();
- }
+ });
+}
disconnectedCallback() {
this.cleanup.forEach((fn) => fn());
@@ -39,69 +40,55 @@ export class ModelDownloaderComponent extends HTMLElement {
}
}
- private async checkInitialState() {
- try {
- const { [STORAGE_KEYS.MODEL_DOWNLOADED]: modelDownloaded } = await chrome.storage.local.get({
- [STORAGE_KEYS.MODEL_DOWNLOADED]: STORAGE_DEFAULTS[STORAGE_KEYS.MODEL_DOWNLOADED],
- });
- const hasDownloadedModel = Boolean(modelDownloaded);
-
- if (!('Proofreader' in window)) {
- const messageParts = [
- 'Proofreader API not found. This extension requires Chrome 141+ with the Built-in AI Proofreader API enabled.',
- `${PROOFREADER_FLAG_INSTRUCTIONS}.`,
- ];
+private async checkInitialState() {
+ try {
+ // 1. Check if the flag is even enabled (window check)
+ if (!('Proofreader' in window)) {
+ this.showError('Built-in AI features are disabled. Please enable the flag below.');
+ this.hideDownloadButton();
+ return;
+ }
- if (hasDownloadedModel) {
- messageParts.push('Downloaded models cannot be used until the flag is turned on.');
- }
+ // 2. Check the specific Proofreader availability status
+ const availability = await this.downloader.checkProofreaderAvailability();
- this.showError(messageParts.join(' '));
- this.hideDownloadButton();
- return;
- }
+ switch (availability) {
+ case 'available':
+ // Model is already on disk
+ await this.markModelAsReady();
+ this.showSuccess();
+ break;
- const proofreaderAvailability = await this.downloader.checkProofreaderAvailability();
- // Check language detector availability to trigger download if needed
- await this.downloader.checkLanguageDetectorAvailability();
-
- if (proofreaderAvailability === 'unavailable') {
- if (hasDownloadedModel) {
- this.showError(
- [
- 'Proofreader API features are disabled in Chrome even though the models are ready.',
- `${PROOFREADER_FLAG_INSTRUCTIONS}.`,
- ].join(' ')
- );
- } else {
- this.showError(
- 'Proofreader API is unavailable on this device. Requirements:\n' +
- '• Chrome 141 or later\n' +
- '• At least 22 GB free storage\n' +
- '• GPU with 4GB+ VRAM\n' +
- '• Enable chrome://flags/#proofreader-api-for-gemini-nano'
- );
- }
- this.hideDownloadButton();
- return;
- }
+ case 'downloadable':
+ // Requirements met, but model is missing
+ this.hideError();
+ this.showDownloadButton();
+ break;
- if (proofreaderAvailability === 'available') {
- await chrome.storage.local.set({
- [STORAGE_KEYS.MODEL_DOWNLOADED]: true,
- [STORAGE_KEYS.PROOFREADER_READY]: true,
- [STORAGE_KEYS.MODEL_AVAILABILITY]: 'available',
- });
- this.showSuccess();
- return;
- }
+ case 'downloading':
+ // Already in progress (perhaps from a previous session)
+ this.showProgress();
+ break;
- this.hideError();
- this.showDownloadButton();
- } catch (error) {
- this.showError(`${(error as Error).message}`);
+ case 'unavailable':
+ default:
+ // System doesn't meet requirements (GPU, Storage, etc.)
+ this.showError('Your system does not meet the hardware requirements for Gemini Nano.');
+ this.hideDownloadButton();
+ break;
}
+ } catch (err) {
+ this.showError(`Initialization failed: ${(err as Error).message}`);
}
+}
+
+private async markModelAsReady() {
+ await chrome.storage.local.set({
+ [STORAGE_KEYS.MODEL_DOWNLOADED]: true,
+ [STORAGE_KEYS.PROOFREADER_READY]: true,
+ [STORAGE_KEYS.MODEL_AVAILABILITY]: 'available',
+ });
+}
private async handleDownload() {
if (!this.elements.button) return;
@@ -145,21 +132,34 @@ export class ModelDownloaderComponent extends HTMLElement {
private updateProgress(progress: DownloadProgress) {
if (!this.elements.progress || !this.elements.progressText) return;
- this.elements.progress.value = progress.progress;
+ if (progress.progress <= 0 || progress.state === 'checking' || progress.state === 'extracting') {
+ this.elements.progress.removeAttribute('value');
+ } else {
+ this.elements.progress.value = progress.progress;
+ }
const percent = Math.floor(progress.progress * 100);
- const modelLabel =
- progress.modelType === 'language-detector' ? 'Language Detection' : 'Proofreader';
- let text = `${modelLabel}: ${percent}%`;
-
- if (progress.state === 'downloading' && progress.bytesDownloaded && progress.totalBytes) {
- const downloaded = this.formatBytes(progress.bytesDownloaded);
- const total = this.formatBytes(progress.totalBytes);
- text = `${modelLabel}: ${downloaded} / ${total} (${percent}%)`;
- } else if (progress.state === 'extracting') {
- text = `Extracting ${modelLabel.toLowerCase()} model...`;
- } else if (progress.state === 'checking') {
- text = `Checking ${modelLabel.toLowerCase()} availability...`;
+ const modelLabel = progress.modelType === 'language-detector' ? 'Language Detection' : 'Proofreader';
+
+ let text = '';
+ switch (progress.state) {
+ case 'checking':
+ text = `Initializing ${modelLabel.toLowerCase()}...`;
+ break;
+ case 'extracting':
+ text = `Finalizing ${modelLabel.toLowerCase()} setup...`;
+ break;
+ case 'downloading':
+ if (progress.bytesDownloaded && progress.totalBytes) {
+ const downloaded = this.formatBytes(progress.bytesDownloaded);
+ const total = this.formatBytes(progress.totalBytes);
+ text = `Downloading ${modelLabel}: ${downloaded} / ${total} (${percent}%)`;
+ } else {
+ text = `Downloading ${modelLabel}: ${percent}%`;
+ }
+ break;
+ default:
+ text = `Processing...`;
}
this.elements.progressText.textContent = text;
@@ -242,20 +242,26 @@ export class ModelDownloaderComponent extends HTMLElement {
private attachEventListeners() {
if (this.elements.button) {
- const handleClick = () => this.handleDownload();
- this.elements.button.addEventListener('click', handleClick);
- this.cleanup.push(() => this.elements.button?.removeEventListener('click', handleClick));
+ // The handler needs to be bound to 'this' to access class methods
+ const handleButtonClick = () => this.handleDownload();
+ this.elements.button.addEventListener('click', handleButtonClick);
+
+ // Clean up to avoid memory leaks
+ this.cleanup.push(() => this.elements.button?.removeEventListener('click', handleButtonClick));
}
- const unsubscribeProgress = this.downloader.on('download-progress', (progress) => {
+ // Listen to the downloader service for progress updates
+ const unsubscribeProgress = this.downloader.on('state-change', (progress) => {
this.updateProgress(progress);
+
+ if (progress.state === 'ready') {
+ this.showSuccess();
+ } else if (progress.state === 'error') {
+ this.showError(progress.error?.message || 'Download failed');
+ }
});
+
this.cleanup.push(unsubscribeProgress);
-
- const unsubscribeError = this.downloader.on('error', (error) => {
- this.showError(error.message);
- });
- this.cleanup.push(unsubscribeError);
}
private getStyles(): string {
@@ -367,6 +373,26 @@ export class ModelDownloaderComponent extends HTMLElement {
color: #6b7280;
}
+ progress:not([value]) {
+ background-color: #e5e7eb;
+ }
+
+ progress:not([value])::-webkit-progress-bar {
+ background-image: linear-gradient(
+ 90deg,
+ #4f46e5 25%,
+ #818cf8 50%,
+ #4f46e5 75%
+ );
+ background-size: 200% 100%;
+ animation: shimmer 1.5s infinite linear;
+ }
+
+ @keyframes shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+ }
+
.status {
margin-top: 1rem;
padding: 0.75rem;
@@ -392,83 +418,117 @@ export class ModelDownloaderComponent extends HTMLElement {
white-space: pre-line;
text-align: left;
}
- `;
- }
- private render() {
- const container = document.createElement('div');
- container.className = 'container';
+ .requirement-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 8px 0;
+ font-size: 0.85rem;
+ color: #4b5563;
+ line-height: 1.4;
+ }
- const title = document.createElement('h2');
- title.className = 'title';
- title.textContent = 'Welcome to Proofly!';
-
- const description = document.createElement('p');
- description.className = 'description';
- description.innerHTML =
- 'Complete your setup by downloading the AI models to get started with on-device proofreading.
This is a one-time setup.';
-
- const requirements = document.createElement('div');
- requirements.className = 'requirements';
- requirements.innerHTML = `
- Requirements
-
chrome://flags/#proofreader-api-for-gemini-nano
+
+ + Complete your setup by downloading the AI models to get started. This is a one-time setup. +
+