diff --git a/src/background/index.ts b/src/background/index.ts index d15eba4..dfac677 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -73,20 +73,52 @@ let cachedScreenshot: Blob | null = null; // ─── Plan 01-09 badge palette + notification constants ─────────────── // Project naming standard: SCREAMING_SNAKE for true constants. These // drive the operator-facing badge state machine + notification flow. -const BADGE_REC_COLOR = '#00C853'; // material green -const BADGE_OFF_COLOR = '#D32F2F'; // material red -const BADGE_ERROR_COLOR = '#F9A825'; // material amber-700 +// +// Plan 01-12 Wave 4: BADGE_REC_COLOR updated from #00C853 (material +// green) to #b2543d (= --mks-madder-600 per D-04 loom palette per +// RESEARCH §10 Open Question A7). OFF + ERROR colors retained as +// engineering choices (no loom-palette token for material-red / +// material-amber-700 equivalents); document choice inline. +// +// Plan 01-12 Wave 4: BADGE_*_TITLE constants kept as FALLBACKS for the +// chrome.i18n.getMessage reads at the setBadgeState call sites. Unit +// tests that don't stub chrome.i18n degrade to these literals rather +// than empty strings. +const BADGE_REC_COLOR = '#b2543d'; // --mks-madder-600 per D-04 loom palette (Plan 01-12) +const BADGE_OFF_COLOR = '#D32F2F'; // material red (no loom-palette token for OFF) +const BADGE_ERROR_COLOR = '#F9A825'; // material amber-700 (no loom-palette token for ERR) const BADGE_REC_TEXT = 'REC'; const BADGE_OFF_TEXT = ''; const BADGE_ERROR_TEXT = 'ERR'; -const BADGE_REC_TITLE = 'Recording — last 30 s buffered. Click to save.'; -const BADGE_OFF_TITLE = 'Not recording. Click to start.'; -const BADGE_ERROR_TITLE = 'Recording error. Click to try again.'; +const BADGE_REC_TITLE_FALLBACK = 'Recording — last 30 s buffered. Click to save.'; +const BADGE_OFF_TITLE_FALLBACK = 'Not recording. Click to start.'; +const BADGE_ERROR_TITLE_FALLBACK = 'Recording error. Click to try again.'; const NOTIFICATION_ICON_PATH = 'icons/icon128.png'; const NOTIFICATION_STARTUP_PREFIX = 'mokosh-startup-'; const NOTIFICATION_RECOVERY_PREFIX = 'mokosh-recovery-'; const POPUP_HTML_PATH = 'src/popup/index.html'; +// Plan 01-12 Wave 4: operator-facing copy fallbacks for the notification +// title (extName) + the two notification messages. Same `|| fallback` +// pattern as the popup — unit-test contexts without chrome.i18n stub +// degrade to these literals. +const NOTIF_EXTNAME_FALLBACK = 'Mokosh'; +const NOTIF_STARTUP_FALLBACK = 'Recording started. Click here to start a recording.'; +const NOTIF_RECOVERY_FALLBACK = 'Recording stopped. Click here to start a new session.'; + +/** + * Safe wrapper around chrome.i18n.getMessage with explicit fallback. + * Returns the fallback when chrome.i18n is undefined (unit-test contexts + * without a stub) OR when the key is missing in the resolved locale. + * + * chrome.i18n.getMessage is SYNCHRONOUS per Chrome docs — no Promise. + * Returns '' for missing keys, hence the explicit length check. + */ +function i18nMessage(key: string, fallback: string): string { + const text = chrome?.i18n?.getMessage?.(key) ?? ''; + return text.length > 0 ? text : fallback; +} + // ─── Plan 01-09 badge state machine + mode helpers ─────────────────── // 3-state machine: REC (during recording), OFF (idle), ERROR (after // RECORDING_ERROR). Each setBadgeState call is best-effort: chrome.action @@ -101,15 +133,15 @@ function setBadgeState(state: BadgeState): void { if (state === 'REC') { text = BADGE_REC_TEXT; color = BADGE_REC_COLOR; - title = BADGE_REC_TITLE; + title = i18nMessage('tooltipRecPrefix', BADGE_REC_TITLE_FALLBACK); } else if (state === 'OFF') { text = BADGE_OFF_TEXT; color = BADGE_OFF_COLOR; - title = BADGE_OFF_TITLE; + title = i18nMessage('tooltipOff', BADGE_OFF_TITLE_FALLBACK); } else { text = BADGE_ERROR_TEXT; color = BADGE_ERROR_COLOR; - title = BADGE_ERROR_TITLE; + title = i18nMessage('tooltipErr', BADGE_ERROR_TITLE_FALLBACK); } try { chrome.action.setBadgeText({ text }); } catch (e) { logger.warn('setBadgeText failed:', e); } try { chrome.action.setBadgeBackgroundColor({ color }); } catch (e) { logger.warn('setBadgeBackgroundColor failed:', e); } @@ -822,8 +854,8 @@ chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) => chrome.notifications.create(recoveryId, { type: 'basic', iconUrl: chrome.runtime.getURL(NOTIFICATION_ICON_PATH), - title: 'Mokosh stopped', - message: 'Recording stopped. Click here to start a new session.', + title: i18nMessage('extName', NOTIF_EXTNAME_FALLBACK), + message: i18nMessage('notifRecovery', NOTIF_RECOVERY_FALLBACK), priority: 1, }); } catch (e) { @@ -924,8 +956,8 @@ try { chrome.notifications.create(notificationId, { type: 'basic', iconUrl: chrome.runtime.getURL(NOTIFICATION_ICON_PATH), - title: 'Mokosh ready', - message: 'Click here to start recording your session.', + title: i18nMessage('extName', NOTIF_EXTNAME_FALLBACK), + message: i18nMessage('notifStartup', NOTIF_STARTUP_FALLBACK), priority: 1, }); } catch (e) { diff --git a/src/popup/index.html b/src/popup/index.html index 99fe8de..4b5d520 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -3,19 +3,25 @@ - AI Call Recorder + + Mokosh — Session Capture
-

- Последние 30 сек видео + 10 мин лога -

+

- \ No newline at end of file + diff --git a/src/popup/index.ts b/src/popup/index.ts index 34ecb58..c007b7b 100644 --- a/src/popup/index.ts +++ b/src/popup/index.ts @@ -1,5 +1,12 @@ import type { PopupState } from '../shared/types'; +// Plan 01-12 Wave 4: operator-facing copy now flows from +// chrome.i18n.getMessage() reads against _locales/{en,ru}/messages.json. +// A `|| ` literal is kept at each call site so unit-test contexts +// without a chrome.i18n stub degrade to readable Russian-ish text rather +// than empty strings (Chrome's chrome.i18n.getMessage returns '' for +// unknown keys per RESEARCH Pitfall 4). + // Элементы UI const saveButton = document.getElementById('saveButton') as HTMLButtonElement; const statusMessage = document.getElementById('statusMessage') as HTMLDivElement; @@ -26,6 +33,29 @@ function log(...args: unknown[]) { console.log('[Popup]', ...args); } +/** + * Safe wrapper around chrome.i18n.getMessage with explicit fallback. + * Returns the fallback when chrome.i18n is undefined (unit-test contexts + * without a stub) OR when the key is missing in the resolved locale. + */ +function i18n(key: string, fallback: string): string { + const text = chrome?.i18n?.getMessage?.(key) ?? ''; + return text.length > 0 ? text : fallback; +} + +/** + * Populate every element carrying a data-mks-key attribute with the + * resolved i18n string. Called once at init() for static-copy elements + * (info-text, etc.); dynamic copy (button text) is set per-state in + * updateUI(). + */ +function populateMksKeys(): void { + document.querySelectorAll('[data-mks-key]').forEach((el) => { + const key = el.dataset.mksKey; + if (key) el.textContent = i18n(key, el.textContent ?? ''); + }); +} + // Обновление UI function updateUI() { log('Updating UI:', popupState); @@ -35,19 +65,19 @@ function updateUI() { switch (popupState.status) { case 'idle': - buttonText.textContent = 'Сохранить отчёт об ошибке'; + buttonText.textContent = i18n('popupSaveCta', 'Сохранить отчёт об ошибке'); saveButton.className = 'save-button'; saveButton.disabled = !popupState.hasPermissions; break; case 'saving': - buttonText.textContent = 'Сохраняю...'; + buttonText.textContent = i18n('popupSaving', 'Сохраняю...'); saveButton.className = 'save-button saving'; saveButton.disabled = true; break; case 'done': - buttonText.textContent = 'Готово! ✓'; + buttonText.textContent = i18n('popupSaveDoneShort', 'Готово! ✓'); saveButton.className = 'save-button done'; saveButton.disabled = true; break; @@ -59,7 +89,7 @@ function updateUI() { // still attempt SAVE). The empty-state copy points operators back to // the toolbar icon for starting a NEW session. if (!popupState.hasPermissions && popupState.status === 'idle') { - statusMessage.textContent = 'Откройте запись через иконку расширения'; + statusMessage.textContent = i18n('popupEmptyState', 'Откройте запись через иконку расширения'); statusMessage.className = 'status-message error'; } else { statusMessage.textContent = ''; @@ -88,7 +118,7 @@ async function saveArchive() { if (response.success) { popupState.status = 'done'; - statusMessage.textContent = 'Архив успешно сохранён!'; + statusMessage.textContent = i18n('popupSaveDone', 'Архив успешно сохранён!'); statusMessage.className = 'status-message success'; // Возвращаемся в idle через 3 секунды @@ -118,6 +148,9 @@ function init() { // Привязка событий saveButton.addEventListener('click', saveArchive); + // Populate data-mks-key static elements (info-text etc.) on init. + populateMksKeys(); + // Plan 01-09: SAVE-only popup — no permission round-trip on open. The // popup is only reachable when recording is active (background SW sets // setPopup to this html in REC/ERROR modes; OFF mode uses setPopup('') @@ -129,4 +162,4 @@ function init() { } // Запуск при загрузке -document.addEventListener('DOMContentLoaded', init); \ No newline at end of file +document.addEventListener('DOMContentLoaded', init); diff --git a/src/popup/style.css b/src/popup/style.css index a7d4399..02f0656 100644 --- a/src/popup/style.css +++ b/src/popup/style.css @@ -1,3 +1,11 @@ +/* Mokosh popup — D-04 loom palette via canonical tokens.css. + Plan 01-12 Wave 4 Task 1: all hex literals removed; every color reads + from var(--mks-*). Imports the canonical token system from + src/shared/tokens.css (which @font-face's the self-hosted Lora + IBM + Plex faces — MV3 CSP self-host enforced). */ + +@import "../shared/tokens.css"; + * { margin: 0; padding: 0; @@ -5,81 +13,85 @@ } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; - background: #f8f9fa; - min-width: 320px; - padding: 16px; + font-family: var(--mks-font-ui); + background: var(--mks-surface); + color: var(--mks-fg-1); + min-width: var(--mks-popup-w); + padding: var(--mks-space-4); } .container { display: flex; flex-direction: column; align-items: center; - gap: 12px; + gap: var(--mks-space-3); } .save-button { width: 100%; - padding: 14px 20px; - font-size: 14px; - font-weight: 600; + padding: var(--mks-space-3) var(--mks-space-5); + font-family: var(--mks-font-ui); + font-size: var(--mks-text-sm); + font-weight: var(--mks-weight-semibold); border: none; - border-radius: 8px; - background: #2563eb; - color: white; + border-radius: var(--mks-radius-lg); + background: var(--mks-rec); + color: var(--mks-fg-inverse); cursor: pointer; - transition: all 0.2s ease; - box-shadow: 0 2px 4px rgba(37, 99, 235, 0.2); + transition: all var(--mks-dur-base) var(--mks-ease-out); + box-shadow: var(--mks-shadow-1); } .save-button:hover:not(:disabled) { - background: #1d4ed8; - box-shadow: 0 4px 8px rgba(37, 99, 235, 0.3); + background: var(--mks-madder-700); + box-shadow: var(--mks-shadow-2); transform: translateY(-1px); } .save-button:active:not(:disabled) { transform: translateY(0); - box-shadow: 0 2px 4px rgba(37, 99, 235, 0.2); + box-shadow: var(--mks-shadow-1); } .save-button:disabled { - background: #94a3b8; + background: var(--mks-fg-disabled); cursor: not-allowed; box-shadow: none; opacity: 0.6; } .save-button.saving { - background: #0891b2; + background: var(--mks-amber-600); cursor: wait; } .save-button.done { - background: #059669; + background: var(--mks-moss-600); } .info-text { - font-size: 12px; - color: #64748b; + font-family: var(--mks-font-ui); + font-size: var(--mks-text-xs); + color: var(--mks-fg-2); text-align: center; } .status-message { - font-size: 13px; - font-weight: 500; + font-family: var(--mks-font-ui); + font-size: var(--mks-text-sm); + font-weight: var(--mks-weight-medium); min-height: 20px; text-align: center; } .status-message.error { - color: #dc2626; + color: var(--mks-error); } .status-message.success { - color: #059669; + color: var(--mks-success); } .status-message.info { - color: #0891b2; -} \ No newline at end of file + color: var(--mks-warning); +} diff --git a/tests/background/badge-state-machine.test.ts b/tests/background/badge-state-machine.test.ts index 31698e2..3664caf 100644 --- a/tests/background/badge-state-machine.test.ts +++ b/tests/background/badge-state-machine.test.ts @@ -185,21 +185,27 @@ describe('Plan 01-09 Task 3: badge state machine REC/OFF/ERROR contract', () => // setBadgeText was called with text='REC'. expect(badgeTextCallsFor(stub, 'REC').length).toBeGreaterThanOrEqual(1); - // setBadgeBackgroundColor was called with a green-ish color. - const greenCalls = stub.action.setBadgeBackgroundColor.mock.calls.filter( + // Plan 01-12 Wave 4: BADGE_REC_COLOR migrated from #00C853 (material + // green) to #b2543d (--mks-madder-600 per D-04 loom palette). The + // assertion below is updated lockstep. + const recColorCalls = stub.action.setBadgeBackgroundColor.mock.calls.filter( (args: unknown[]) => { const opts = args[0] as { color?: unknown }; const color = typeof opts === 'object' && opts !== null ? opts.color : undefined; - return typeof color === 'string' && /^#00[Cc]853$/.test(color); + return typeof color === 'string' && /^#b2543d$/i.test(color); }, ); - expect(greenCalls.length).toBeGreaterThanOrEqual(1); - // setTitle was called with the recording title. + expect(recColorCalls.length).toBeGreaterThanOrEqual(1); + // setTitle was called with the recording title. Plan 01-12 Wave 4: + // setBadgeState now reads chrome.i18n.getMessage('tooltipRecPrefix') + // with fallback 'Recording — last 30 s buffered. Click to save.'. + // Tests without a chrome.i18n stub see the fallback, which still + // matches /Recording/i — assertion unchanged below. const titleCalls = stub.action.setTitle.mock.calls.filter( (args: unknown[]) => { const opts = args[0] as { title?: unknown }; const title = typeof opts === 'object' && opts !== null ? opts.title : undefined; - return typeof title === 'string' && /Recording/i.test(title); + return typeof title === 'string' && /Recording|recording/i.test(title); }, ); expect(titleCalls.length).toBeGreaterThanOrEqual(1); diff --git a/tests/background/onstartup-notification.test.ts b/tests/background/onstartup-notification.test.ts index 6860c16..65a6115 100644 --- a/tests/background/onstartup-notification.test.ts +++ b/tests/background/onstartup-notification.test.ts @@ -150,9 +150,22 @@ describe('Plan 01-09 Task 3: chrome.runtime.onStartup notification contract', () type?: unknown; title?: unknown; message?: unknown; }; expect(opts.type).toBe('basic'); - expect(opts.title).toBe('Mokosh ready'); + // Plan 01-12 Wave 4: notification title migrated from literal + // 'Mokosh ready' to chrome.i18n.getMessage('extName') with fallback + // 'Mokosh' (NOTIF_EXTNAME_FALLBACK). Tests without a chrome.i18n + // stub observe the fallback. Assert by substring match so the + // contract survives both the fallback ('Mokosh') and the resolved + // EN locale value ('Mokosh — Session Capture'). + expect(typeof opts.title).toBe('string'); + expect(/Mokosh/i.test(String(opts.title))).toBe(true); expect(typeof opts.message).toBe('string'); - expect(/Click/i.test(String(opts.message))).toBe(true); + // Plan 01-12 Wave 4: notification message migrated from literal + // 'Click here to start recording your session.' to + // chrome.i18n.getMessage('notifStartup') with fallback + // 'Recording started. Click here to start a recording.'. Tests + // without a chrome.i18n stub observe the fallback; both contain + // 'recording' (case-insensitive). + expect(/recording|recor|click/i.test(String(opts.message))).toBe(true); }); // ────────────────────────────────────────────────────────────────────── diff --git a/tests/background/toolbar-action.test.ts b/tests/background/toolbar-action.test.ts index 6f369d1..56c84bd 100644 --- a/tests/background/toolbar-action.test.ts +++ b/tests/background/toolbar-action.test.ts @@ -295,6 +295,7 @@ interface DocumentStub { addEventListener: (ev: string, cb: () => void) => void; _docListeners: Map void>>; querySelector: (sel: string) => ElementStub | null; + querySelectorAll: (sel: string) => ElementStub[]; } function buildDocumentStub(): { @@ -332,6 +333,12 @@ function buildDocumentStub(): { }, _docListeners: docListeners, querySelector: () => null, + // Plan 01-12 Wave 4: populateMksKeys() in src/popup/index.ts uses + // querySelectorAll('[data-mks-key]') to populate i18n copy on init. + // The toolbar-action test loads the popup without a real DOM, so + // returning an empty array safely is the right answer (no data-mks-key + // elements exist in the stubbed doc; populate-loop is a no-op). + querySelectorAll: () => [], }; return { doc, saveBtn, statusMsg, buttonText }; }