diff --git a/css/focusMode.css b/css/focusMode.css new file mode 100644 index 000000000..4307f99ed --- /dev/null +++ b/css/focusMode.css @@ -0,0 +1,266 @@ +/* ─── Toolbar indicator pill ─────────────────────────────────────────────── */ + +#focus-mode-indicator { + position: absolute; + /* centered horizontally over the tab bar area */ + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + z-index: 10; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.82em; + font-weight: 700; + padding: 5px 16px; + border-radius: 20px; + white-space: nowrap; + background: #ffffff; + color: #111111; + border: none; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.22); + cursor: pointer; + user-select: none; + pointer-events: auto; + letter-spacing: 0.01em; + transition: box-shadow 0.15s, background 0.15s; +} + +#focus-mode-indicator:hover { + box-shadow: 0 3px 12px rgba(0, 0, 0, 0.3); + background: #f5f5f5; +} + +#focus-mode-indicator.is-off-track { + background: #ffffff; + color: #c0392b; +} + +.dark-theme #focus-mode-indicator { + background: #2a2a2a; + color: #ffffff; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); +} + +.dark-theme #focus-mode-indicator:hover { + background: #383838; +} + +.dark-theme #focus-mode-indicator.is-off-track { + color: #ff6b6b; +} + +/* ─── Goal input modal ──────────────────────────────────────────────────── */ + +#focus-mode-goal-modal { + max-width: 520px; +} + +.focus-modal-hint { + font-size: 0.88em; + opacity: 0.65; + margin: -0.5em 0 1em 0; +} + +.focus-goal-input { + display: block; + width: 100%; + box-sizing: border-box; + padding: 0.6em 0.8em; + font-size: 1em; + border: 1px solid rgba(0, 0, 0, 0.18); + border-radius: 6px; + background: transparent; + color: inherit; + outline: none; + margin-bottom: 1.2em; +} + +.focus-goal-input:focus { + border-color: #007aff; + box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.2); +} + +.dark-mode .focus-goal-input { + border-color: rgba(255, 255, 255, 0.2); +} + +.dark-mode .focus-goal-input:focus { + border-color: #0a84ff; + box-shadow: 0 0 0 2px rgba(10, 132, 255, 0.25); +} + +.focus-modal-actions { + display: flex; + justify-content: space-between; + align-items: center; +} + +.focus-btn-primary { + background: #007aff; + color: #fff; + border: none; + border-radius: 6px; + padding: 0.5em 1.2em; + font-size: 0.95em; + font-weight: 600; + cursor: pointer; +} + +.focus-btn-primary:hover { + background: #0066d6; +} + +.dark-mode .focus-btn-primary { + background: #0a84ff; +} + +.focus-btn-secondary { + background: transparent; + color: inherit; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 6px; + padding: 0.5em 1.1em; + font-size: 0.88em; + cursor: pointer; + opacity: 0.75; +} + +.focus-btn-secondary:hover { + opacity: 1; + background: rgba(0, 0, 0, 0.06); +} + +.dark-mode .focus-btn-secondary { + border-color: rgba(255, 255, 255, 0.2); +} + +.dark-mode .focus-btn-secondary:hover { + background: rgba(255, 255, 255, 0.08); +} + +/* ─── Drift alert bar ───────────────────────────────────────────────────── */ + +#focus-mode-alert { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 100; + display: flex; + align-items: center; + gap: 0.75em; + padding: 0.65em 1.1em; + background: #fff3cd; + border-top: 1px solid #f0c36d; + color: #5a4a00; + font-size: 0.88em; + animation: focus-alert-slide-in 0.25s ease-out; +} + +@keyframes focus-alert-slide-in { + from { transform: translateY(100%); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.dark-mode #focus-mode-alert { + background: #3a2e00; + border-color: #6a5200; + color: #f0d080; +} + +.focus-alert-icon { + font-size: 1.1em; + flex-shrink: 0; +} + +#focus-mode-alert > span:nth-child(2) { + flex: 1; +} + +.focus-alert-actions { + display: flex; + gap: 0.5em; + flex-shrink: 0; +} + +.focus-alert-actions button { + padding: 0.3em 0.8em; + border-radius: 5px; + border: 1px solid rgba(0, 0, 0, 0.2); + background: rgba(0, 0, 0, 0.06); + color: inherit; + font-size: 0.9em; + cursor: pointer; + font-weight: 500; +} + +.focus-alert-actions button:first-child { + background: #007aff; + color: #fff; + border-color: transparent; +} + +.focus-alert-actions button:first-child:hover { + background: #0066d6; +} + +.focus-alert-actions button:last-child:hover { + background: rgba(0, 0, 0, 0.12); +} + +.dark-mode .focus-alert-actions button:first-child { + background: #0a84ff; +} + +/* ─── Session summary modal ─────────────────────────────────────────────── */ + +#focus-mode-summary { + max-width: 420px; +} + +.focus-summary-grid { + display: flex; + flex-direction: column; + gap: 0.6em; + margin-top: 0.5em; +} + +.focus-summary-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5em 0.75em; + border-radius: 6px; + background: rgba(0, 0, 0, 0.04); +} + +.dark-mode .focus-summary-row { + background: rgba(255, 255, 255, 0.06); +} + +.focus-summary-label { + font-size: 0.9em; + opacity: 0.75; +} + +.focus-summary-value { + font-weight: 700; + font-size: 0.95em; +} + +.focus-summary-value.focus-on-topic { + color: #1a8a3a; +} + +.dark-mode .focus-summary-value.focus-on-topic { + color: #4cd964; +} + +.focus-summary-value.focus-off-topic { + color: #c0392b; +} + +.dark-mode .focus-summary-value.focus-off-topic { + color: #ff6b6b; +} diff --git a/index.html b/index.html index d0e97bc17..1b47cb3c3 100644 --- a/index.html +++ b/index.html @@ -140,6 +140,7 @@
+ + + + + + + + + + diff --git a/js/default.js b/js/default.js index acacba620..4729227b8 100644 --- a/js/default.js +++ b/js/default.js @@ -184,5 +184,8 @@ require('searchbar/developmentModeNotification.js').initialize() require('searchbar/shortcutButtons.js').initialize() require('searchbar/calculatorPlugin.js').initialize() +require('focusMode.js').initialize() + // once everything's loaded, start the session require('sessionRestore.js').restore() +require('focusMode.js').onSessionRestoreComplete() diff --git a/js/focusMode.js b/js/focusMode.js index 7a4f171b4..67ae953d6 100644 --- a/js/focusMode.js +++ b/js/focusMode.js @@ -1,20 +1,424 @@ -var isFocusMode = false +var settings = require('util/settings/settings.js') +var webviews = require('webviews.js') -ipc.on('enterFocusMode', function () { - isFocusMode = true - document.body.classList.add('is-focus-mode') -}) +/* ─── Stop words ─────────────────────────────────────────────────────────── */ -ipc.on('exitFocusMode', function () { - isFocusMode = false - document.body.classList.remove('is-focus-mode') -}) +var STOP_WORDS = new Set([ + 'a', 'an', 'the', 'is', 'are', 'was', 'for', 'to', 'of', 'and', 'or', + 'in', 'on', 'at', 'with', 'it', 'this', 'that', 'by', 'from', 'be', + 'as', 'how', 'what', 'about', 'i', 'my', 'me' +]) -module.exports = { - enabled: function () { - return isFocusMode - }, - warn: function () { - ipc.invoke('showFocusModeDialog2') +/* ─── Tokenizer ──────────────────────────────────────────────────────────── */ + +function tokenize (text) { + if (!text) return [] + var words = text.toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .split(/\s+/) + .filter(function (w) { return w.length > 1 && !STOP_WORDS.has(w) }) + + var bigrams = [] + for (var i = 0; i < words.length - 1; i++) { + bigrams.push(words[i] + ' ' + words[i + 1]) + } + return words.concat(bigrams) +} + +/* ─── Relevance scorer ───────────────────────────────────────────────────── */ + +function scoreRelevance (signals, goalTokens) { + var combined = signals.join(' ').toLowerCase() + var score = 0 + goalTokens.forEach(function (token) { + if (combined.indexOf(token) !== -1) score++ + }) + return score +} + +/* ─── Session state ──────────────────────────────────────────────────────── */ + +var state = { + enabled: false, + goal: '', + goalTokens: [], + sessionStart: null, + consecutiveOffTopic: 0, + lastAlertTime: 0, + offTopicSeconds: 0, + offTopicStartTime: null, + currentPageOnTopic: true, + driftAlerts: 0, + samplingInterval: null, + timerInterval: null, + windowFocused: true, + sessionRestoreComplete: false +} + +/* ─── UI element references ──────────────────────────────────────────────── */ + +var ui = {} + +function getUIElements () { + ui.indicator = document.getElementById('focus-mode-indicator') + ui.goalModal = document.getElementById('focus-mode-goal-modal') + ui.goalInput = document.getElementById('focus-mode-goal-input') + ui.alertBar = document.getElementById('focus-mode-alert') + ui.alertGoal = document.getElementById('focus-alert-goal') + ui.summaryModal = document.getElementById('focus-mode-summary') + ui.summaryTotal = document.getElementById('focus-summary-total') + ui.summaryOnTopic = document.getElementById('focus-summary-ontopic') + ui.summaryOffTopic = document.getElementById('focus-summary-offtopic') + ui.summaryAlerts = document.getElementById('focus-summary-alerts') +} + +/* ─── Indicator ──────────────────────────────────────────────────────────── */ + +function currentOffTopicSeconds () { + var extra = 0 + if (state.offTopicStartTime && state.windowFocused) { + extra = Math.round((Date.now() - state.offTopicStartTime) / 1000) + } + return state.offTopicSeconds + extra +} + +function updateIndicator () { + if (!ui.indicator) return + if (!state.enabled) { + ui.indicator.hidden = true + return + } + ui.indicator.hidden = false + var mins = Math.floor(currentOffTopicSeconds() / 60) + if (mins > 0) { + ui.indicator.textContent = mins + ' min off-track' + ui.indicator.classList.add('is-off-track') + } else { + ui.indicator.textContent = 'On track' + ui.indicator.classList.remove('is-off-track') + } +} + +/* ─── Time formatter ─────────────────────────────────────────────────────── */ + +function formatTime (seconds) { + var m = Math.floor(seconds / 60) + var s = seconds % 60 + if (m > 0) return m + 'm ' + s + 's' + return s + 's' +} + +/* ─── Modal helpers ──────────────────────────────────────────────────────── */ + +function getModalMode () { + return require('modalMode.js') +} + +function showGoalModal () { + if (!ui.goalModal) return + webviews.requestPlaceholder('focusMode') + ui.goalModal.hidden = false + // pre-fill with current goal so the user can see/update it + ui.goalInput.value = state.goal || localStorage.getItem('focusModeGoal') || '' + ui.goalInput.focus() + ui.goalInput.select() +} + +function hideGoalModal () { + if (!ui.goalModal) return + ui.goalModal.hidden = true + webviews.hidePlaceholder('focusMode') +} + +function showSummaryModal (totalSec, onSec, offSec, alerts) { + if (!ui.summaryModal) return + ui.summaryTotal.textContent = formatTime(totalSec) + ui.summaryOnTopic.textContent = formatTime(onSec) + ui.summaryOffTopic.textContent = formatTime(offSec) + ui.summaryAlerts.textContent = alerts + + webviews.requestPlaceholder('focusMode') + ui.summaryModal.hidden = false + getModalMode().toggle(true, { + onDismiss: function () { + ui.summaryModal.hidden = true + webviews.hidePlaceholder('focusMode') + getModalMode().toggle(false) + } + }) +} + +/* ─── Alert bar ──────────────────────────────────────────────────────────── */ + +var alertAutoHideTimer = null + +function showAlert () { + if (!ui.alertBar) return + if (ui.alertGoal) ui.alertGoal.textContent = state.goal + ui.alertBar.hidden = false + + if (alertAutoHideTimer) clearTimeout(alertAutoHideTimer) + alertAutoHideTimer = setTimeout(function () { + if (ui.alertBar) ui.alertBar.hidden = true + }, 10000) +} + +/* ─── Relevance check ────────────────────────────────────────────────────── */ + +function checkRelevance (url, title, meta) { + if (!state.enabled || state.goalTokens.length === 0) return + + var signals = [url || '', title || '', meta || ''] + var score = scoreRelevance(signals, state.goalTokens) + var isOnTopic = score >= 1 + + if (isOnTopic) { + if (state.offTopicStartTime) { + state.offTopicSeconds += Math.round((Date.now() - state.offTopicStartTime) / 1000) + state.offTopicStartTime = null + } + state.consecutiveOffTopic = 0 + state.currentPageOnTopic = true + } else { + if (!state.offTopicStartTime && state.windowFocused) { + state.offTopicStartTime = Date.now() + } + state.consecutiveOffTopic++ + state.currentPageOnTopic = false + + if (state.consecutiveOffTopic >= 2) { + var now = Date.now() + if (now - state.lastAlertTime > 3 * 60 * 1000) { + showAlert() + state.lastAlertTime = now + state.driftAlerts++ + } + state.consecutiveOffTopic = 0 + } + } + + updateIndicator() +} + +/* ─── Page content sampler (every 45 s) ─────────────────────────────────── */ + +function sampleCurrentPage () { + if (!state.enabled) return + var tabId = tabs.getSelected() + if (!tabId) return + var tab = tabs.get(tabId) + if (!tab || !tab.url) return + if (tab.url.indexOf('min://') === 0 || tab.url.indexOf('about:') === 0) return + + var code = [ + '(function(){', + ' var m = document.querySelector(\'meta[name="description"]\');', + ' return {', + ' title: document.title || "",', + ' meta: m ? (m.getAttribute("content") || "") : "",', + ' url: window.location.href || ""', + ' };', + '})()' + ].join('\n') + + webviews.callAsync(tabId, 'executeJavaScript', [code], function (err, result) { + if (!err && result) { + checkRelevance(result.url, result.title, result.meta) + } + }) +} + +/* ─── Session lifecycle ──────────────────────────────────────────────────── */ + +function startSession (goal) { + state.goal = goal + state.goalTokens = tokenize(goal) + state.sessionStart = Date.now() + state.consecutiveOffTopic = 0 + state.lastAlertTime = 0 + state.offTopicSeconds = 0 + state.offTopicStartTime = null + state.currentPageOnTopic = true + state.driftAlerts = 0 + + localStorage.setItem('focusModeGoal', goal) + + if (state.samplingInterval) clearInterval(state.samplingInterval) + state.samplingInterval = setInterval(sampleCurrentPage, 45000) + + if (state.timerInterval) clearInterval(state.timerInterval) + state.timerInterval = setInterval(updateIndicator, 5000) + + sampleCurrentPage() + updateIndicator() +} + +function endSession () { + if (state.samplingInterval) { clearInterval(state.samplingInterval); state.samplingInterval = null } + if (state.timerInterval) { clearInterval(state.timerInterval); state.timerInterval = null } + + if (state.offTopicStartTime) { + state.offTopicSeconds += Math.round((Date.now() - state.offTopicStartTime) / 1000) + state.offTopicStartTime = null + } + + var totalSec = state.sessionStart ? Math.round((Date.now() - state.sessionStart) / 1000) : 0 + var offSec = state.offTopicSeconds + var onSec = Math.max(0, totalSec - offSec) + + showSummaryModal(totalSec, onSec, offSec, state.driftAlerts) + + state.enabled = false + state.sessionStart = null + state.goal = '' + state.goalTokens = [] + localStorage.removeItem('focusModeGoal') + updateIndicator() +} + +/* ─── Enable / disable ───────────────────────────────────────────────────── */ + +function enable () { + state.enabled = true + updateIndicator() +} + +function disable () { + if (state.sessionStart) { + endSession() + } else { + state.enabled = false + updateIndicator() } } + +/* ─── Window blur / focus ────────────────────────────────────────────────── */ + +function onWindowBlur () { + state.windowFocused = false + if (state.offTopicStartTime) { + state.offTopicSeconds += Math.round((Date.now() - state.offTopicStartTime) / 1000) + state.offTopicStartTime = null + } +} + +function onWindowFocus () { + state.windowFocused = true + if (state.enabled && !state.currentPageOnTopic && !state.offTopicStartTime) { + state.offTopicStartTime = Date.now() + } +} + +/* ─── Initialize ─────────────────────────────────────────────────────────── */ + +function initialize () { + getUIElements() + + ipc.on('blur', onWindowBlur) + ipc.on('focus', onWindowFocus) + + // Show goal modal on every new blank tab while Focus Mode is ON + tasks.on('tab-added', function (tabId, tab) { + if (!state.enabled) return + if (!state.sessionRestoreComplete) return // don't show during startup restore + if (tab && tab.url) return // skip tabs opened with a URL (link/bookmark) + // small delay so the tab UI settles before the modal appears + setTimeout(function () { + if (state.enabled) showGoalModal() + }, 150) + }) + + // Intercept tab URL changes on the active tab + tasks.on('tab-updated', function (id, key) { + if (!state.enabled) return + if (key === 'url' && id === tabs.getSelected()) { + var tab = tabs.get(id) + if (!tab || !tab.url) return + var url = tab.url + if (url.indexOf('min://') === 0 || url.indexOf('about:') === 0) return + checkRelevance(url, tab.title || '', '') + } + }) + + // Goal modal — submit button + document.getElementById('focus-goal-submit').addEventListener('click', function () { + var goal = ui.goalInput.value.trim() + if (!goal) return + hideGoalModal() + startSession(goal) + }) + + // Goal modal — Enter key + ui.goalInput.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + var goal = this.value.trim() + if (!goal) return + hideGoalModal() + startSession(goal) + } else if (e.key === 'Escape') { + hideGoalModal() + } + }) + + // Goal modal — Dismiss (×) + document.getElementById('focus-goal-dismiss').addEventListener('click', function () { + hideGoalModal() + }) + + // Goal modal — Open Preferences + document.getElementById('focus-goal-open-prefs').addEventListener('click', function () { + hideGoalModal() + webviews.update(tabs.getSelected(), 'min://app/pages/settings/index.html') + }) + + // Alert bar — Ignore + document.getElementById('focus-alert-ignore').addEventListener('click', function () { + if (ui.alertBar) ui.alertBar.hidden = true + }) + + // Alert bar — Refocus (open a new blank tab) + document.getElementById('focus-alert-refocus').addEventListener('click', function () { + if (ui.alertBar) ui.alertBar.hidden = true + require('browserUI.js').addTab() + }) + + // Summary close button + document.getElementById('focus-summary-close').addEventListener('click', function () { + if (ui.summaryModal) ui.summaryModal.hidden = true + webviews.hidePlaceholder('focusMode') + getModalMode().toggle(false) + }) + + // React to settings toggle (from settings page) + var _initialLoad = true + settings.listen('focusModeEnabled', function (value) { + if (_initialLoad) { + _initialLoad = false + if (value === true) enable() + return + } + if (value === true && !state.enabled) { + enable() + } else if (!value && state.enabled) { + disable() + } + }) + + updateIndicator() +} + +/* ─── Public API ─────────────────────────────────────────────────────────── */ + +module.exports = { + initialize: initialize, + enabled: function () { return state.enabled }, + warn: function () { ipc.invoke('showFocusModeDialog2') }, + enable: enable, + disable: disable, + startSession: startSession, + endSession: endSession, + tokenize: tokenize, + scoreRelevance: scoreRelevance, + checkRelevance: checkRelevance, + onSessionRestoreComplete: function () { state.sessionRestoreComplete = true } +} diff --git a/pages/settings/index.html b/pages/settings/index.html index d8e6d629c..83a878ee1 100644 --- a/pages/settings/index.html +++ b/pages/settings/index.html @@ -234,6 +234,19 @@

+
+

Focus Mode

+ +
+ + +
+ Tracks your browsing against a session goal and alerts you when you drift off-topic. + All tracking is local — nothing leaves your device. +
+
+
+

diff --git a/pages/settings/settings.js b/pages/settings/settings.js index 64005305f..b2e014f63 100644 --- a/pages/settings/settings.js +++ b/pages/settings/settings.js @@ -449,6 +449,18 @@ searchEngineInput.addEventListener('input', function (e) { settings.set('searchEngine', { url: this.value }) }) +/* focus mode setting */ + +var focusModeCheckbox = document.getElementById('checkbox-focus-mode') + +settings.get('focusModeEnabled', function (value) { + focusModeCheckbox.checked = !!value +}) + +focusModeCheckbox.addEventListener('change', function () { + settings.set('focusModeEnabled', this.checked) +}) + /* key map settings */ settings.get('keyMap', function (keyMapSettings) { diff --git a/scripts/buildBrowserStyles.js b/scripts/buildBrowserStyles.js index ded9b3145..740acbc87 100644 --- a/scripts/buildBrowserStyles.js +++ b/scripts/buildBrowserStyles.js @@ -20,7 +20,8 @@ const modules = [ 'css/passwordManager.css', 'css/passwordCapture.css', 'css/passwordViewer.css', - 'node_modules/dragula/dist/dragula.min.css' + 'node_modules/dragula/dist/dragula.min.css', + 'css/focusMode.css' ] function buildBrowserStyles () {