From ea23681e69828d0eae004032576e06736dcc320d Mon Sep 17 00:00:00 2001 From: heznpc Date: Wed, 10 Jun 2026 15:53:50 +0900 Subject: [PATCH 1/3] feat(tutor): viewport-centred lesson grounding + quiz/summarize chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 4a of the learning-companion deepening: - getPageContext now grounds the tutor in what the user is ACTUALLY reading. Long lessons used to send only the first 2,000 chars, so questions about anything past the fold had no grounding. The context now carries a short lesson opening (global context) + the text from the block currently in the viewport, and the heading map (up to 8) marks the current section with "▶". Hard-capped at 2,000 chars total — the privacy policy's "≤2,000 chars of lesson context" disclosure holds regardless of how the pieces combine. Exam-page behavior unchanged. - Two new suggestion chips in all 12 UI languages: "Quiz me on this lesson" and "Summarize this lesson" — the two highest-value learning actions the grounded tutor can serve ("key takeaways" chip kept; the redundant third example rotated out to keep the chip row tight). Gates: 527 unit tests, full E2E 19/19, lint, prettier — green. --- src/content/content.js | 41 +++++++++++++++++++++++++++++---- src/lib/constants.js | 52 +++++++++++++++++++++++++++++++++--------- 2 files changed, 78 insertions(+), 15 deletions(-) diff --git a/src/content/content.js b/src/content/content.js index c8e1243..28446b9 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -791,12 +791,45 @@ if (isExamPage) { return `Certification Exam: ${title}. Page type: exam/assessment. DO NOT help with answers.`; } - const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4')) - .map((h) => h.textContent.trim()) - .slice(0, 5) + // Heading map with the section the user is currently reading marked — + // gives the tutor a table of contents plus "you are here". + const hs = Array.from(document.querySelectorAll('h1, h2, h3, h4')); + let currentIdx = -1; + for (let i = 0; i < hs.length; i++) { + if (hs[i].getBoundingClientRect().top <= 80) currentIdx = i; + } + const headings = hs + .slice(0, 8) + .map((h, i) => (i === currentIdx ? `▶ ${h.textContent.trim()}` : h.textContent.trim())) .join(', '); const lessonBody = document.querySelector('#lesson-main, .lesson-content, .course-content, main'); - const bodyText = lessonBody ? lessonBody.innerText.replace(/\s+/g, ' ').trim().slice(0, 2000) : ''; + // Viewport-centred extract: long lessons used to send only the FIRST + // 2,000 chars, so questions about anything past the fold had no grounding. + // Now: a short lesson opening for global context + the text from the block + // the user is actually looking at. Hard-capped at 2,000 chars total — the + // privacy policy discloses "≤2,000 chars of lesson context" and that cap + // must hold no matter how the pieces combine. + let bodyText = ''; + if (lessonBody) { + const flat = lessonBody.innerText.replace(/\s+/g, ' ').trim(); + const blocks = Array.from(lessonBody.querySelectorAll('p, li, h2, h3, h4, pre, td')); + const vpIdx = blocks.findIndex((el) => el.getBoundingClientRect().bottom > 0); + if (vpIdx > 0) { + let current = ''; + for (let i = Math.max(0, vpIdx - 1); i < blocks.length && current.length < 1500; i++) { + current += blocks[i].innerText.replace(/\s+/g, ' ').trim() + ' '; + } + current = current.trim().slice(0, 1500); + const opening = flat.slice(0, 400); + bodyText = + current && !opening.includes(current.slice(0, 80)) + ? `${opening}\n\n[User is currently viewing:]\n${current}` + : flat.slice(0, 2000); + } else { + bodyText = flat.slice(0, 2000); + } + bodyText = bodyText.slice(0, 2000); + } return `Course: ${title}. Sections: ${headings}${bodyText ? `\n\nLesson content:\n${bodyText}` : ''}`; } diff --git a/src/lib/constants.js b/src/lib/constants.js index 3f1964b..ab36cb9 100644 --- a/src/lib/constants.js +++ b/src/lib/constants.js @@ -422,17 +422,47 @@ const ONBOARDING_LABELS = { }; const EXAMPLE_QUESTIONS = { - en: ['Explain this concept simply', 'What are the key takeaways?', 'Give me a practical example'], - ko: ['이 개념을 쉽게 설명해줘', '핵심 포인트가 뭐야?', '실제 예시를 들어줘'], - ja: ['この概念を簡単に説明して', '重要なポイントは?', '実例を教えて'], - 'zh-CN': ['简单解释一下这个概念', '关键要点是什么?', '给我一个实际例子'], - 'zh-TW': ['簡單解釋一下這個概念', '關鍵要點是什麼?', '給我一個實際例子'], - es: ['Explica este concepto de forma simple', '¿Cuáles son los puntos clave?', 'Dame un ejemplo práctico'], - fr: ['Explique ce concept simplement', 'Quels sont les points clés ?', 'Donne-moi un exemple pratique'], - de: ['Erkläre dieses Konzept einfach', 'Was sind die wichtigsten Punkte?', 'Gib mir ein praktisches Beispiel'], - 'pt-BR': ['Explique este conceito de forma simples', 'Quais são os pontos-chave?', 'Me dê um exemplo prático'], - ru: ['Объясни эту концепцию простыми словами', 'Какие ключевые выводы?', 'Приведи практический пример'], - vi: ['Giải thích khái niệm này một cách đơn giản', 'Những điểm chính là gì?', 'Cho tôi một ví dụ thực tế'], + en: ['Explain this concept simply', 'What are the key takeaways?', 'Quiz me on this lesson', 'Summarize this lesson'], + ko: ['이 개념을 쉽게 설명해줘', '핵심 포인트가 뭐야?', '이 레슨으로 퀴즈 내줘', '이 레슨 요약해줘'], + ja: ['この概念を簡単に説明して', '重要なポイントは?', 'このレッスンでクイズを出して', 'このレッスンを要約して'], + 'zh-CN': ['简单解释一下这个概念', '关键要点是什么?', '就这节课考考我', '总结一下这节课'], + 'zh-TW': ['簡單解釋一下這個概念', '關鍵要點是什麼?', '就這節課考考我', '總結一下這節課'], + es: [ + 'Explica este concepto de forma simple', + '¿Cuáles son los puntos clave?', + 'Hazme un quiz sobre esta lección', + 'Resume esta lección', + ], + fr: [ + 'Explique ce concept simplement', + 'Quels sont les points clés ?', + 'Fais-moi un quiz sur cette leçon', + 'Résume cette leçon', + ], + de: [ + 'Erkläre dieses Konzept einfach', + 'Was sind die wichtigsten Punkte?', + 'Frag mich zu dieser Lektion ab', + 'Fasse diese Lektion zusammen', + ], + 'pt-BR': [ + 'Explique este conceito de forma simples', + 'Quais são os pontos-chave?', + 'Me faça um quiz sobre esta lição', + 'Resuma esta lição', + ], + ru: [ + 'Объясни эту концепцию простыми словами', + 'Какие ключевые выводы?', + 'Проверь меня по этому уроку', + 'Кратко изложи этот урок', + ], + vi: [ + 'Giải thích khái niệm này một cách đơn giản', + 'Những điểm chính là gì?', + 'Đố tôi về bài học này', + 'Tóm tắt bài học này', + ], }; const A11Y_LABELS = { From 93e77ca7d5d977e3b159a062bc973ad4e764ddb4 Mon Sep 17 00:00:00 2001 From: heznpc Date: Wed, 10 Jun 2026 16:02:23 +0900 Subject: [PATCH 2/3] feat(dashboard): local progress panel + fix the sub-panel modules stage-2b missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 4b of the learning-companion deepening, plus a real bug the work surfaced: - FIX: resume.js and text-selection.js still queried sidebar elements via document.getElementById — but the sidebar moved into the shadow root in stage 2b (#190), so the Recent panel had been silently dead (null panel → early return) and select-and-ask couldn't focus the chat input. Both now use sb.$id. (They were the two modules the 62-site migration missed; the E2E suite didn't cover the Recent panel, which is why it went unnoticed.) - NEW: "My progress" dashboard (Tools menu) — a sub-panel aggregating LOCAL data only: lessons visited (sb_recent), bookmarks (sb_bookmarks), flashcard decks + cards mastered (fc_* Leitner boxes, box>=3), and the three most recent lessons as jump-back links (http(s)-guarded). Reads exclusively from chrome.storage.local — nothing leaves the device, consistent with the privacy policy. Labels in all 12 UI languages; dark-mode styles included; all storage-derived values escaped via sb.escapeHtml. - Sub-panel state machine extended (dashboardPanelOpen) across closeSubPanel and the four sibling panels' guards. - E2E: new dashboard.spec.js (panel opens, localized header renders, four stat cards in zero-state) + toggleDashboardPanel/readDashboard ops. Gates: 527 unit tests, full E2E 20/20, lint, prettier — green. --- eslint.config.mjs | 1 + manifest.json | 1 + src/content/bookmarks.js | 2 +- src/content/chat-flashcards.js | 2 +- src/content/chat-history.js | 2 +- src/content/content.css | 47 ++++++++++++++ src/content/dashboard.js | 115 +++++++++++++++++++++++++++++++++ src/content/resume.js | 13 ++-- src/content/sidebar-chat.js | 7 ++ src/content/text-selection.js | 2 +- src/lib/constants.js | 94 +++++++++++++++++++++++++++ tests/e2e/dashboard.spec.js | 52 +++++++++++++++ tests/e2e/helpers/extension.js | 10 +++ 13 files changed, 340 insertions(+), 8 deletions(-) create mode 100644 src/content/dashboard.js create mode 100644 tests/e2e/dashboard.spec.js diff --git a/eslint.config.mjs b/eslint.config.mjs index c4ab17c..f2afb26 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -82,6 +82,7 @@ export default [ FLASHCARD_LABELS: 'readonly', BOOKMARK_LABELS: 'readonly', RESUME_LABELS: 'readonly', + DASHBOARD_LABELS: 'readonly', TOC_LABELS: 'readonly', MENU_LABELS: 'readonly', PDF_EXPORT_LABELS: 'readonly', diff --git a/manifest.json b/manifest.json index 8888d02..cc49235 100644 --- a/manifest.json +++ b/manifest.json @@ -48,6 +48,7 @@ "src/content/chat-flashcards.js", "src/content/bookmarks.js", "src/content/resume.js", + "src/content/dashboard.js", "src/content/reading-aid.js", "src/content/keyboard-shortcuts.js" ], diff --git a/src/content/bookmarks.js b/src/content/bookmarks.js index 20b84f2..4b5c0c5 100644 --- a/src/content/bookmarks.js +++ b/src/content/bookmarks.js @@ -125,7 +125,7 @@ } // Another sub-panel may be open; restore the chat first so savedChatHTML // captures the chat, not the other panel. - if (_state.historyPanelOpen || _state.flashcardPanelOpen || _state.recentPanelOpen) { + if (_state.historyPanelOpen || _state.flashcardPanelOpen || _state.recentPanelOpen || _state.dashboardPanelOpen) { sb._chat.closeSubPanel(); } diff --git a/src/content/chat-flashcards.js b/src/content/chat-flashcards.js index 9b849a4..bf35eb5 100644 --- a/src/content/chat-flashcards.js +++ b/src/content/chat-flashcards.js @@ -96,7 +96,7 @@ } // Close history if open — they share `savedChatHTML`, so closing first // restores the chat panel before we save it again. - if (_state.historyPanelOpen || _state.bookmarksPanelOpen || _state.recentPanelOpen) { + if (_state.historyPanelOpen || _state.bookmarksPanelOpen || _state.recentPanelOpen || _state.dashboardPanelOpen) { sb._chat.closeSubPanel(); } diff --git a/src/content/chat-history.js b/src/content/chat-history.js index 142962d..079a15e 100644 --- a/src/content/chat-history.js +++ b/src/content/chat-history.js @@ -185,7 +185,7 @@ sb._chat.closeSubPanel(); return; } - if (state.flashcardPanelOpen || state.bookmarksPanelOpen || state.recentPanelOpen) { + if (state.flashcardPanelOpen || state.bookmarksPanelOpen || state.recentPanelOpen || state.dashboardPanelOpen) { sb._chat.closeSubPanel(); } diff --git a/src/content/content.css b/src/content/content.css index 409cb65..58fa62f 100644 --- a/src/content/content.css +++ b/src/content/content.css @@ -3008,3 +3008,50 @@ html.si18n-dark .si18n-tp-en { html.si18n-dark .si18n-tp-tr { color: #888; } + +/* ============================================================ + LOCAL PROGRESS DASHBOARD (Tools ▸ My progress) + ============================================================ */ + +.si18n-dash-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + padding: 4px 0 12px; +} + +.si18n-dash-stat { + background: var(--si18n-bg); + border: 1px solid var(--si18n-border); + border-radius: 10px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.si18n-dash-num { + font-size: 18px; + font-weight: 700; + color: var(--si18n-accent); +} + +.si18n-dash-label { + font-size: 11px; + color: var(--si18n-text-secondary); +} + +.si18n-dash-recent-title { + font-size: 12px; + font-weight: 600; + color: var(--si18n-text-secondary); + margin: 6px 0 4px; +} + +html.si18n-dark .si18n-dash-stat { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.14); +} +html.si18n-dark .si18n-dash-num { + color: #aab0e0; +} diff --git a/src/content/dashboard.js b/src/content/dashboard.js new file mode 100644 index 0000000..330d927 --- /dev/null +++ b/src/content/dashboard.js @@ -0,0 +1,115 @@ +/** + * SkillBridge — Local Progress Dashboard + * + * A Tools-menu sub-panel aggregating the learner's LOCAL data only: + * recent lessons (sb_recent), bookmarks (sb_bookmarks), and flashcard + * progress (fc__ Leitner boxes). Everything is read from + * chrome.storage.local — nothing leaves the device, consistent with the + * privacy policy ("local storage only"). + * + * Loaded after sidebar-chat.js (uses sb._chat.state / closeSubPanel) and + * after resume/bookmarks/chat-flashcards (whose stores it reads). + */ + +(function () { + 'use strict'; + + const sb = window._sb; + if (!sb) { + console.warn('[SkillBridge] dashboard: _sb not ready'); + return; + } + const _state = sb._chat.state; + + function collectStats(cb) { + chrome.storage.local.get(null, (all) => { + all = all || {}; + const recent = Array.isArray(all.sb_recent) ? all.sb_recent : []; + const bookmarks = Array.isArray(all.sb_bookmarks) ? all.sb_bookmarks : []; + let decks = 0; + let tracked = 0; + let mastered = 0; + for (const [key, val] of Object.entries(all)) { + if (!key.startsWith('fc_') || !val || typeof val !== 'object') continue; + const boxes = val.boxes && typeof val.boxes === 'object' ? val.boxes : {}; + const terms = Object.keys(boxes); + if (terms.length === 0) continue; + decks++; + tracked += terms.length; + for (const t of terms) if (Number(boxes[t]) >= 3) mastered++; + } + cb({ recent, bookmarks, decks, tracked, mastered }); + }); + } + + function statRow(value, label) { + return ` +
+ ${value} + ${sb.escapeHtml(label)} +
`; + } + + function render(stats) { + const list = sb.$id('si18n-dash-body'); + if (!list) return; + const L = (m) => sb.t(m) || ''; + const recentRows = stats.recent + .slice(0, 3) + .map( + (r) => ` +
+ +
`, + ) + .join(''); + list.innerHTML = ` +
+ ${statRow(stats.recent.length, L(DASHBOARD_LABELS.lessons))} + ${statRow(stats.bookmarks.length, L(DASHBOARD_LABELS.bookmarks))} + ${statRow(stats.decks, L(DASHBOARD_LABELS.decks))} + ${statRow(`${stats.mastered}/${stats.tracked}`, L(DASHBOARD_LABELS.mastered))} +
+ ${stats.recent.length ? `
${sb.escapeHtml(L(RESUME_LABELS.title))}
${recentRows}` : `
${sb.escapeHtml(L(DASHBOARD_LABELS.empty))}
`} + `; + list.querySelectorAll('.si18n-dash-open').forEach((btn) => { + btn.addEventListener('click', () => { + const url = btn.dataset.url; + if (url && /^https?:/i.test(url)) window.location.href = url; + }); + }); + } + + function toggleDashboardPanel() { + const chatPanel = sb.$id('si18n-panel-chat'); + if (!chatPanel) return; + + if (_state.dashboardPanelOpen) { + sb._chat.closeSubPanel(); + return; + } + if (_state.historyPanelOpen || _state.flashcardPanelOpen || _state.bookmarksPanelOpen || _state.recentPanelOpen) { + sb._chat.closeSubPanel(); + } + + _state.dashboardPanelOpen = true; + _state.savedChatHTML = chatPanel.innerHTML; + chatPanel.replaceChildren(); + chatPanel.insertAdjacentHTML( + 'afterbegin', + ` +
+ + ${sb.t(DASHBOARD_LABELS.title)} +
+
+ `, + ); + sb.$id('si18n-dash-back')?.addEventListener('click', () => sb._chat.closeSubPanel()); + collectStats(render); + } + + sb._chat.toggleDashboardPanel = toggleDashboardPanel; +})(); diff --git a/src/content/resume.js b/src/content/resume.js index 4b647f4..fb67613 100644 --- a/src/content/resume.js +++ b/src/content/resume.js @@ -215,14 +215,19 @@ // ============================================================ function toggleRecentPanel() { - const chatPanel = document.getElementById('si18n-panel-chat'); + const chatPanel = sb.$id('si18n-panel-chat'); if (!chatPanel) return; if (_state.recentPanelOpen) { sb._chat.closeSubPanel(); return; } - if (_state.historyPanelOpen || _state.flashcardPanelOpen || _state.bookmarksPanelOpen) { + if ( + _state.historyPanelOpen || + _state.flashcardPanelOpen || + _state.bookmarksPanelOpen || + _state.dashboardPanelOpen + ) { sb._chat.closeSubPanel(); } @@ -241,7 +246,7 @@ `, ); - document.getElementById('si18n-recent-back')?.addEventListener('click', () => sb._chat.closeSubPanel()); + sb.$id('si18n-recent-back')?.addEventListener('click', () => sb._chat.closeSubPanel()); loadRecent(renderList); } @@ -263,7 +268,7 @@ } function renderList() { - const list = document.getElementById('si18n-recent-list'); + const list = sb.$id('si18n-recent-list'); if (!list) return; list.replaceChildren(); list.insertAdjacentHTML('afterbegin', rowsHTML()); diff --git a/src/content/sidebar-chat.js b/src/content/sidebar-chat.js index 04765a6..8afe6eb 100644 --- a/src/content/sidebar-chat.js +++ b/src/content/sidebar-chat.js @@ -23,6 +23,7 @@ flashcardPanelOpen: false, bookmarksPanelOpen: false, recentPanelOpen: false, + dashboardPanelOpen: false, }; const _state = sb._chat.state; @@ -240,6 +241,10 @@