initial commit
50
README.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Firefox Focus Mode
|
||||||
|
|
||||||
|
Firefox Focus Mode is a Firefox WebExtension prototype inspired by the iOS and Android Firefox Focus app. It narrows the browsing model down to a single surviving tab, a custom home page with four bookmark tiles, a quick control popup, and a one-tap session wipe.
|
||||||
|
|
||||||
|
## Included
|
||||||
|
|
||||||
|
- Collapses extra tabs and windows back into one active tab
|
||||||
|
- Replaces the Firefox new-tab page with a Focus-style home page
|
||||||
|
- Sets Firefox's homepage/new-window page and new-tab page to the Focus-style home page
|
||||||
|
- Stores and shows up to four bookmarks
|
||||||
|
- Clears browsing data and returns to the home page
|
||||||
|
- Exposes back, forward, refresh, home, trash, settings, and URL entry from the toolbar popup
|
||||||
|
- TODO: Blocks common tracker URLs by default, with optional ad, analytics, and social blocking
|
||||||
|
|
||||||
|
## Suggested profile setup
|
||||||
|
|
||||||
|
Use a secondary Firefox profile for this extension so the single-tab and session-clearing behavior only affects your Focus-style profile.
|
||||||
|
|
||||||
|
## Launcher script
|
||||||
|
|
||||||
|
A companion launcher lives at `./firefox-focus`.
|
||||||
|
|
||||||
|
- Running `firefox-focus` launches Firefox with a dedicated profile at `~/.mozilla/firefox/focus-mode.profile`.
|
||||||
|
- By default, `firefox-focus` launches `firefox-devedition`, not stock Firefox.
|
||||||
|
- Running `firefox-focus example.com` or `firefox-focus "search terms"` opens that target in the Focus profile.
|
||||||
|
- Running `firefox-focus --kiosk example.com` starts the same profile in Firefox kiosk mode.
|
||||||
|
- The launcher writes a packaged add-on file into the profile at `extensions/firefox-focus-mode@hunner.dev.xpi`, writes a `user.js` with privacy-focused defaults, and writes `chrome/userChrome.css` to hide the bookmarks bar and tab strip in that dedicated profile.
|
||||||
|
|
||||||
|
By default `firefox-focus` opens a regular Firefox window for that dedicated profile. That is expected: the extension can replace the new-tab page and add its own controls, but it cannot remove Firefox's native chrome on its own. Use `firefox-focus --kiosk ...` if you want the stricter fullscreen shell.
|
||||||
|
|
||||||
|
If you move this repo later, update `FIREFOX_FOCUS_EXTENSION_DIR` or edit the script so the profile still points at the right checkout.
|
||||||
|
|
||||||
|
Depending on your Firefox build, automatic loading of the local `.xpi` may still be limited by Mozilla's add-on signing rules. If that happens, the profile styling changes from `firefox-focus` will still apply, but the extension itself may still need temporary loading or signing.
|
||||||
|
|
||||||
|
On standard release Firefox builds, this is the expected limitation. The most reliable persistent-install paths are:
|
||||||
|
|
||||||
|
- use a signed `.xpi`
|
||||||
|
- use a Firefox channel intended for extension development, such as Developer Edition or Nightly
|
||||||
|
|
||||||
|
## Important Firefox limits
|
||||||
|
|
||||||
|
- Extensions cannot force all browsing into Firefox private windows. This prototype clears browsing data on browser startup and via the trash action, which gets close to the intended behavior without true permanent private mode.
|
||||||
|
- Extensions cannot remove Firefox's built-in tab strip, address bar, or standard browser buttons. For a stricter kiosk-like shell, pair this extension with a dedicated profile and optional `userChrome.css` tweaks.
|
||||||
|
- Firefox does not let extensions manage other installed extensions with a whitelist, so that part of the spec is intentionally left out.
|
||||||
|
|
||||||
|
## Loading the extension for manual testing
|
||||||
|
|
||||||
|
1. Open `about:debugging#/runtime/this-firefox`.
|
||||||
|
2. Choose `Load Temporary Add-on`.
|
||||||
|
3. Select `manifest.json`.
|
||||||
479
background.js
Normal file
|
|
@ -0,0 +1,479 @@
|
||||||
|
const HOME_URL = browser.runtime.getURL("home.html");
|
||||||
|
const OPTIONS_URL = browser.runtime.getURL("options.html");
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
bookmarks: [
|
||||||
|
{ title: "Mozilla", url: "https://www.mozilla.org/" },
|
||||||
|
{ title: "DuckDuckGo", url: "https://duckduckgo.com/" },
|
||||||
|
{ title: "Wikipedia", url: "https://www.wikipedia.org/" },
|
||||||
|
{ title: "Proton", url: "https://proton.me/" }
|
||||||
|
],
|
||||||
|
blocking: {
|
||||||
|
trackers: true,
|
||||||
|
ads: false,
|
||||||
|
analytics: false,
|
||||||
|
social: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const BLOCKLISTS = {
|
||||||
|
trackers: [
|
||||||
|
"doubleclick.net",
|
||||||
|
"googlesyndication.com",
|
||||||
|
"googletagmanager.com",
|
||||||
|
"googleadservices.com",
|
||||||
|
"taboola.com",
|
||||||
|
"outbrain.com",
|
||||||
|
"scorecardresearch.com",
|
||||||
|
"zedo.com",
|
||||||
|
"criteo.com"
|
||||||
|
],
|
||||||
|
ads: [
|
||||||
|
"ads.",
|
||||||
|
"adservice.",
|
||||||
|
"adserver.",
|
||||||
|
"/ads/",
|
||||||
|
"amazon-adsystem.com",
|
||||||
|
"adnxs.com",
|
||||||
|
"rubiconproject.com",
|
||||||
|
"openx.net"
|
||||||
|
],
|
||||||
|
analytics: [
|
||||||
|
"google-analytics.com",
|
||||||
|
"analytics.",
|
||||||
|
"segment.com",
|
||||||
|
"mixpanel.com",
|
||||||
|
"hotjar.com",
|
||||||
|
"fullstory.com",
|
||||||
|
"amplitude.com",
|
||||||
|
"plausible.io"
|
||||||
|
],
|
||||||
|
social: [
|
||||||
|
"facebook.net",
|
||||||
|
"facebook.com/plugins",
|
||||||
|
"platform.twitter.com",
|
||||||
|
"connect.facebook.net",
|
||||||
|
"platform.linkedin.com",
|
||||||
|
"assets.pinterest.com",
|
||||||
|
"redditstatic.com"
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
let isCollapsingTabs = false;
|
||||||
|
let currentSettings = cloneDefaultSettings();
|
||||||
|
const pendingCollapseTimers = new Map();
|
||||||
|
const pendingCollapseStartedAt = new Map();
|
||||||
|
const NON_MEANINGFUL_COLLAPSE_GRACE_MS = 800;
|
||||||
|
|
||||||
|
function cloneDefaultSettings() {
|
||||||
|
return JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(rawUrl) {
|
||||||
|
if (!rawUrl) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(rawUrl)) {
|
||||||
|
return rawUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawUrl.includes(" ") && !rawUrl.includes(".")) {
|
||||||
|
return `https://duckduckgo.com/?q=${encodeURIComponent(rawUrl)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `https://${rawUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeBookmarks(bookmarks) {
|
||||||
|
const fallback = cloneDefaultSettings().bookmarks;
|
||||||
|
const sanitized = Array.isArray(bookmarks) ? bookmarks.slice(0, 4).map((bookmark, index) => {
|
||||||
|
const safeFallback = fallback[index] || { title: "", url: "" };
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: String(bookmark?.title || safeFallback.title).trim(),
|
||||||
|
url: String(bookmark?.url || safeFallback.url).trim()
|
||||||
|
};
|
||||||
|
}) : [];
|
||||||
|
|
||||||
|
while (sanitized.length < 4) {
|
||||||
|
sanitized.push(fallback[sanitized.length]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
const stored = await browser.storage.local.get("settings");
|
||||||
|
const defaults = cloneDefaultSettings();
|
||||||
|
const settings = {
|
||||||
|
bookmarks: sanitizeBookmarks(stored.settings?.bookmarks || defaults.bookmarks),
|
||||||
|
blocking: {
|
||||||
|
trackers: stored.settings?.blocking?.trackers ?? defaults.blocking.trackers,
|
||||||
|
ads: stored.settings?.blocking?.ads ?? defaults.blocking.ads,
|
||||||
|
analytics: stored.settings?.blocking?.analytics ?? defaults.blocking.analytics,
|
||||||
|
social: stored.settings?.blocking?.social ?? defaults.blocking.social
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
currentSettings = settings;
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings(settings) {
|
||||||
|
currentSettings = {
|
||||||
|
bookmarks: sanitizeBookmarks(settings.bookmarks),
|
||||||
|
blocking: {
|
||||||
|
trackers: Boolean(settings.blocking?.trackers),
|
||||||
|
ads: Boolean(settings.blocking?.ads),
|
||||||
|
analytics: Boolean(settings.blocking?.analytics),
|
||||||
|
social: Boolean(settings.blocking?.social)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await browser.storage.local.set({ settings: currentSettings });
|
||||||
|
return currentSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMeaningfulUrl(url) {
|
||||||
|
if (!url) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ![
|
||||||
|
"about:blank",
|
||||||
|
"about:newtab",
|
||||||
|
HOME_URL
|
||||||
|
].includes(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllNormalTabs() {
|
||||||
|
const windows = await browser.windows.getAll({ populate: true, windowTypes: ["normal"] });
|
||||||
|
return windows
|
||||||
|
.sort((left, right) => left.id - right.id)
|
||||||
|
.flatMap((windowInfo) => (windowInfo.tabs || []).map((tab) => ({ ...tab, __window: windowInfo })));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getKeeperTab(excludedTabId) {
|
||||||
|
const windows = await browser.windows.getAll({ populate: true, windowTypes: ["normal"] });
|
||||||
|
const sortedWindows = windows.sort((left, right) => {
|
||||||
|
if (left.focused && !right.focused) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!left.focused && right.focused) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.id - right.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const windowInfo of sortedWindows) {
|
||||||
|
const activeTab = (windowInfo.tabs || []).find((tab) => tab.active && tab.id !== excludedTabId);
|
||||||
|
if (activeTab) {
|
||||||
|
return activeTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstTab = (windowInfo.tabs || []).find((tab) => tab.id !== excludedTabId);
|
||||||
|
if (firstTab) {
|
||||||
|
return firstTab;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function focusTab(tabId, windowId) {
|
||||||
|
await browser.tabs.update(tabId, { active: true });
|
||||||
|
await browser.windows.update(windowId, { focused: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingCollapse(tabId) {
|
||||||
|
const timerId = pendingCollapseTimers.get(tabId);
|
||||||
|
if (timerId) {
|
||||||
|
clearTimeout(timerId);
|
||||||
|
pendingCollapseTimers.delete(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingCollapseStartedAt.delete(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueCollapse(tabId, delay = 150) {
|
||||||
|
const existingTimerId = pendingCollapseTimers.get(tabId);
|
||||||
|
if (existingTimerId) {
|
||||||
|
clearTimeout(existingTimerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingCollapseStartedAt.has(tabId)) {
|
||||||
|
pendingCollapseStartedAt.set(tabId, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
const timerId = setTimeout(() => {
|
||||||
|
pendingCollapseTimers.delete(tabId);
|
||||||
|
void collapseExtraTab(tabId);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
pendingCollapseTimers.set(tabId, timerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collapseExtraTab(tabId) {
|
||||||
|
const timerId = pendingCollapseTimers.get(tabId);
|
||||||
|
if (timerId) {
|
||||||
|
clearTimeout(timerId);
|
||||||
|
pendingCollapseTimers.delete(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCollapsingTabs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tab = await browser.tabs.get(tabId).catch(() => null);
|
||||||
|
if (!tab) {
|
||||||
|
pendingCollapseStartedAt.delete(tabId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMeaningfulUrl(tab.url) && (tab.pendingUrl || tab.openerTabId)) {
|
||||||
|
queueCollapse(tabId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMeaningfulUrl(tab.url)) {
|
||||||
|
const allTabs = await getAllNormalTabs();
|
||||||
|
if (allTabs.length <= 1) {
|
||||||
|
pendingCollapseStartedAt.delete(tabId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = pendingCollapseStartedAt.get(tabId) || Date.now();
|
||||||
|
if (Date.now() - startedAt < NON_MEANINGFUL_COLLAPSE_GRACE_MS) {
|
||||||
|
queueCollapse(tabId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTabs = await getAllNormalTabs();
|
||||||
|
if (allTabs.length <= 1) {
|
||||||
|
pendingCollapseStartedAt.delete(tabId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keeper = await getKeeperTab(tab.id);
|
||||||
|
if (!keeper) {
|
||||||
|
pendingCollapseStartedAt.delete(tabId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCollapsingTabs = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextUrl = isMeaningfulUrl(tab.url) ? tab.url : HOME_URL;
|
||||||
|
await browser.tabs.update(keeper.id, { url: nextUrl });
|
||||||
|
|
||||||
|
await browser.tabs.remove(tab.id).catch(() => {});
|
||||||
|
await focusTab(keeper.id, keeper.windowId);
|
||||||
|
|
||||||
|
if (tab.windowId !== keeper.windowId) {
|
||||||
|
await browser.windows.remove(tab.windowId).catch(() => {});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pendingCollapseStartedAt.delete(tabId);
|
||||||
|
isCollapsingTabs = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enforceSingleTab() {
|
||||||
|
if (isCollapsingTabs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTabs = await getAllNormalTabs();
|
||||||
|
if (allTabs.length <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keeper = await getKeeperTab();
|
||||||
|
if (!keeper) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCollapsingTabs = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const tab of allTabs) {
|
||||||
|
if (tab.id === keeper.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMeaningfulUrl(tab.url)) {
|
||||||
|
await browser.tabs.update(keeper.id, { url: tab.url });
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.tabs.remove(tab.id).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const windows = await browser.windows.getAll({ windowTypes: ["normal"] });
|
||||||
|
for (const windowInfo of windows) {
|
||||||
|
if (windowInfo.id !== keeper.windowId) {
|
||||||
|
await browser.windows.remove(windowInfo.id).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await focusTab(keeper.id, keeper.windowId);
|
||||||
|
} finally {
|
||||||
|
isCollapsingTabs = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldBlock(details) {
|
||||||
|
if (details.tabId < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = details.url || "";
|
||||||
|
let decodedUrl = url;
|
||||||
|
try {
|
||||||
|
decodedUrl = decodeURIComponent(url);
|
||||||
|
} catch (error) {
|
||||||
|
decodedUrl = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(currentSettings.blocking).some(([category, enabled]) => {
|
||||||
|
if (!enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BLOCKLISTS[category].some((pattern) => decodedUrl.includes(pattern));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearSession(options = {}) {
|
||||||
|
const settings = options.preserveSettings ? await loadSettings() : currentSettings;
|
||||||
|
const redirectHome = options.redirectHome ?? true;
|
||||||
|
|
||||||
|
await browser.browsingData.remove(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
cache: true,
|
||||||
|
cookies: true,
|
||||||
|
downloads: true,
|
||||||
|
formData: true,
|
||||||
|
history: true,
|
||||||
|
indexedDB: true,
|
||||||
|
localStorage: true,
|
||||||
|
serviceWorkers: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (options.preserveSettings) {
|
||||||
|
await browser.storage.local.clear();
|
||||||
|
await browser.storage.local.set({ settings });
|
||||||
|
currentSettings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keeper = await getKeeperTab();
|
||||||
|
if (keeper && (redirectHome || !isMeaningfulUrl(keeper.url))) {
|
||||||
|
await browser.tabs.update(keeper.id, { url: HOME_URL, active: true });
|
||||||
|
await focusTab(keeper.id, keeper.windowId);
|
||||||
|
} else if (!keeper) {
|
||||||
|
await browser.tabs.create({ url: HOME_URL });
|
||||||
|
}
|
||||||
|
|
||||||
|
await enforceSingleTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openInKeeperTab(url) {
|
||||||
|
const normalized = normalizeUrl(url);
|
||||||
|
const keeper = await getKeeperTab();
|
||||||
|
|
||||||
|
if (keeper) {
|
||||||
|
await browser.tabs.update(keeper.id, { url: normalized, active: true });
|
||||||
|
await focusTab(keeper.id, keeper.windowId);
|
||||||
|
} else {
|
||||||
|
await browser.tabs.create({ url: normalized });
|
||||||
|
}
|
||||||
|
|
||||||
|
await enforceSingleTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
browser.runtime.onInstalled.addListener(async () => {
|
||||||
|
await saveSettings(await loadSettings());
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.runtime.onStartup.addListener(async () => {
|
||||||
|
await loadSettings();
|
||||||
|
await clearSession({ preserveSettings: true, redirectHome: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.storage.onChanged.addListener((changes, areaName) => {
|
||||||
|
if (areaName === "local" && changes.settings?.newValue) {
|
||||||
|
currentSettings = changes.settings.newValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.tabs.onCreated.addListener((tab) => {
|
||||||
|
if (tab.id) {
|
||||||
|
queueCollapse(tab.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.tabs.onUpdated.addListener((tabId, changeInfo) => {
|
||||||
|
if (changeInfo.url || changeInfo.status === "complete") {
|
||||||
|
clearPendingCollapse(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changeInfo.url) {
|
||||||
|
void collapseExtraTab(tabId);
|
||||||
|
} else if (changeInfo.status === "complete") {
|
||||||
|
queueCollapse(tabId, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.tabs.onRemoved.addListener((tabId) => {
|
||||||
|
clearPendingCollapse(tabId);
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.windows.onCreated.addListener(() => {
|
||||||
|
void enforceSingleTab();
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.webNavigation.onCreatedNavigationTarget.addListener((details) => {
|
||||||
|
if (details.url) {
|
||||||
|
void openInKeeperTab(details.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.webRequest.onBeforeRequest.addListener(
|
||||||
|
(details) => ({ cancel: shouldBlock(details) }),
|
||||||
|
{ urls: ["<all_urls>"] },
|
||||||
|
["blocking"]
|
||||||
|
);
|
||||||
|
|
||||||
|
browser.runtime.onMessage.addListener((message) => {
|
||||||
|
if (message?.type === "get-settings") {
|
||||||
|
return loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message?.type === "save-settings") {
|
||||||
|
return saveSettings(message.settings || currentSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message?.type === "clear-session") {
|
||||||
|
return clearSession({ preserveSettings: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message?.type === "open-url") {
|
||||||
|
return openInKeeperTab(message.url || HOME_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message?.type === "open-settings") {
|
||||||
|
return browser.tabs.create({ url: OPTIONS_URL });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message?.type === "go-home") {
|
||||||
|
return openInKeeperTab(HOME_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
void loadSettings();
|
||||||
262
firefox-focus
Executable file
|
|
@ -0,0 +1,262 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_name="${0##*/}"
|
||||||
|
firefox_bin="${FIREFOX_BIN:-firefox-devedition}"
|
||||||
|
profile_dir="${FIREFOX_FOCUS_PROFILE_DIR:-$HOME/.mozilla/firefox/focus-mode.profile}"
|
||||||
|
extension_dir="${FIREFOX_FOCUS_EXTENSION_DIR:-$HOME/Documents/git/firefox-focus}"
|
||||||
|
extension_id="firefox-focus-mode@hunner.dev"
|
||||||
|
user_js_path="$profile_dir/user.js"
|
||||||
|
user_chrome_path="$profile_dir/chrome/userChrome.css"
|
||||||
|
extension_xpi="$profile_dir/extensions/$extension_id.xpi"
|
||||||
|
legacy_extension_link="$profile_dir/extensions/$extension_id"
|
||||||
|
kiosk_mode=false
|
||||||
|
|
||||||
|
detect_firefox_version() {
|
||||||
|
"$firefox_bin" --version 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
warn_about_unsigned_install() {
|
||||||
|
local version_output="$1"
|
||||||
|
local firefox_name
|
||||||
|
|
||||||
|
firefox_name="$(basename "$firefox_bin")"
|
||||||
|
|
||||||
|
case "$version_output" in
|
||||||
|
*"Developer Edition"*|*"Nightly"*|*"ESR"*)
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$firefox_name" in
|
||||||
|
firefox-devedition|firefox-developer-edition)
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
cat >&2 <<EOF
|
||||||
|
Note: persistent local add-on install is usually blocked in standard Firefox
|
||||||
|
release builds like "$version_output". The Focus profile styling will still
|
||||||
|
apply, but you may still need "Load Temporary Add-on" in about:debugging unless
|
||||||
|
you use a signed .xpi or a Firefox channel that allows local unsigned installs.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $script_name [-k|--kiosk] [url-or-search] [firefox args...]
|
||||||
|
|
||||||
|
Launch Firefox with the dedicated Focus profile and the local Firefox Focus
|
||||||
|
extension linked into that profile.
|
||||||
|
|
||||||
|
Environment overrides:
|
||||||
|
FIREFOX_BIN Firefox binary to execute (defaults to firefox-devedition)
|
||||||
|
FIREFOX_FOCUS_PROFILE_DIR Dedicated profile directory
|
||||||
|
FIREFOX_FOCUS_EXTENSION_DIR Firefox Focus extension checkout
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
urlencode() {
|
||||||
|
local input="$1"
|
||||||
|
local output=""
|
||||||
|
local i char hex
|
||||||
|
|
||||||
|
for ((i = 0; i < ${#input}; i++)); do
|
||||||
|
char="${input:i:1}"
|
||||||
|
case "$char" in
|
||||||
|
[a-zA-Z0-9.~_-])
|
||||||
|
output+="$char"
|
||||||
|
;;
|
||||||
|
' ')
|
||||||
|
output+='%20'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf -v hex '%%%02X' "'$char"
|
||||||
|
output+="$hex"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '%s\n' "$output"
|
||||||
|
}
|
||||||
|
|
||||||
|
looks_like_url_without_schema() {
|
||||||
|
local target="$1"
|
||||||
|
|
||||||
|
[[ ! "$target" =~ [[:space:]] ]] || return 1
|
||||||
|
[[ "$target" =~ ^localhost([:/?#].*)?$ ]] && return 0
|
||||||
|
[[ "$target" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}([:/?#].*)?$ ]] && return 0
|
||||||
|
[[ "$target" =~ ^([[:alnum:]][[:alnum:]-]*\.)+[[:alpha:]]{2,}([:/?#].*)?$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_target() {
|
||||||
|
local raw_target="$1"
|
||||||
|
|
||||||
|
if [[ -z "$raw_target" ]]; then
|
||||||
|
printf '%s\n' ""
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$raw_target" =~ ^[[:alpha:]][[:alnum:]+.-]*: ]]; then
|
||||||
|
printf '%s\n' "$raw_target"
|
||||||
|
elif looks_like_url_without_schema "$raw_target"; then
|
||||||
|
printf 'https://%s\n' "$raw_target"
|
||||||
|
else
|
||||||
|
printf 'https://search.hunner.dev/search?q=%s\n' "$(urlencode "$raw_target")"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_user_js() {
|
||||||
|
mkdir -p "$profile_dir"
|
||||||
|
|
||||||
|
cat >"$user_js_path" <<EOF
|
||||||
|
user_pref("app.update.auto", false);
|
||||||
|
user_pref("browser.discovery.enabled", false);
|
||||||
|
user_pref("browser.newtabpage.activity-stream.feeds.section.topstories", false);
|
||||||
|
user_pref("browser.newtabpage.activity-stream.feeds.topsites", false);
|
||||||
|
user_pref("browser.newtabpage.activity-stream.showSponsored", false);
|
||||||
|
user_pref("browser.newtabpage.activity-stream.showSponsoredTopSites", false);
|
||||||
|
user_pref("browser.policies.applied", true);
|
||||||
|
user_pref("browser.sessionstore.resume_from_crash", false);
|
||||||
|
user_pref("browser.shell.checkDefaultBrowser", false);
|
||||||
|
user_pref("browser.startup.homepage", "about:newtab");
|
||||||
|
user_pref("browser.startup.page", 1);
|
||||||
|
user_pref("browser.toolbars.bookmarks.visibility", "never");
|
||||||
|
user_pref("browser.tabs.closeWindowWithLastTab", false);
|
||||||
|
user_pref("browser.tabs.warnOnClose", false);
|
||||||
|
user_pref("browser.uiCustomization.state", "{\"placements\":{\"widget-overflow-fixed-list\":[],\"unified-extensions-area\":[],\"nav-bar\":[\"back-button\",\"forward-button\",\"stop-reload-button\",\"urlbar-container\",\"firefox-focus-mode_hunner_dev-browser-action\",\"unified-extensions-button\"],\"toolbar-menubar\":[\"menubar-items\"],\"TabsToolbar\":[\"firefox-view-button\",\"tabbrowser-tabs\",\"new-tab-button\",\"alltabs-button\"],\"vertical-tabs\":[],\"PersonalToolbar\":[]},\"seen\":[\"developer-button\",\"screenshot-button\",\"firefox-focus-mode_hunner_dev-browser-action\"],\"dirtyAreaCache\":[\"nav-bar\",\"vertical-tabs\",\"toolbar-menubar\",\"TabsToolbar\",\"PersonalToolbar\",\"unified-extensions-area\"],\"currentVersion\":23,\"newElementCount\":2}");
|
||||||
|
user_pref("datareporting.healthreport.uploadEnabled", false);
|
||||||
|
user_pref("extensions.autoDisableScopes", 0);
|
||||||
|
user_pref("extensions.enabledScopes", 5);
|
||||||
|
user_pref("extensions.installDistroAddons", true);
|
||||||
|
user_pref("xpinstall.signatures.required", false);
|
||||||
|
user_pref("privacy.clearOnShutdown.cache", true);
|
||||||
|
user_pref("privacy.clearOnShutdown.cookies", true);
|
||||||
|
user_pref("privacy.clearOnShutdown.downloads", true);
|
||||||
|
user_pref("privacy.clearOnShutdown.formdata", true);
|
||||||
|
user_pref("privacy.clearOnShutdown.history", true);
|
||||||
|
user_pref("privacy.clearOnShutdown.offlineApps", true);
|
||||||
|
user_pref("privacy.clearOnShutdown.sessions", true);
|
||||||
|
user_pref("privacy.donottrackheader.enabled", true);
|
||||||
|
user_pref("privacy.sanitize.sanitizeOnShutdown", true);
|
||||||
|
user_pref("privacy.sanitize.timeSpan", 0);
|
||||||
|
user_pref("signon.rememberSignons", false);
|
||||||
|
user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
write_user_chrome() {
|
||||||
|
mkdir -p "$(dirname "$user_chrome_path")"
|
||||||
|
|
||||||
|
cat >"$user_chrome_path" <<'EOF'
|
||||||
|
/* Focus profile chrome trimming */
|
||||||
|
#PersonalToolbar,
|
||||||
|
#TabsToolbar {
|
||||||
|
visibility: collapse !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar-button,
|
||||||
|
#fxa-toolbar-menu-button,
|
||||||
|
#downloads-button {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nav-bar {
|
||||||
|
border-top-width: 0 !important;
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
package_extension() {
|
||||||
|
if [[ ! -f "$extension_dir/manifest.json" ]]; then
|
||||||
|
printf 'Extension manifest not found at %s\n' "$extension_dir/manifest.json" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$profile_dir/extensions"
|
||||||
|
rm -f "$legacy_extension_link"
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$extension_dir"
|
||||||
|
zip -qr "$extension_xpi" \
|
||||||
|
manifest.json \
|
||||||
|
background.js \
|
||||||
|
home.html \
|
||||||
|
home.css \
|
||||||
|
home.js \
|
||||||
|
popup.html \
|
||||||
|
popup.css \
|
||||||
|
popup.js \
|
||||||
|
options.html \
|
||||||
|
options.css \
|
||||||
|
options.js \
|
||||||
|
theme.css \
|
||||||
|
icons
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-k|--kiosk)
|
||||||
|
kiosk_mode=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--)
|
||||||
|
shift
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
-*)
|
||||||
|
printf 'Unknown option: %s\n\n' "$1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
target="${1:-}"
|
||||||
|
if [[ $# -gt 0 ]]; then
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v "$firefox_bin" >/dev/null 2>&1; then
|
||||||
|
printf 'Firefox binary not found: %s\n' "$firefox_bin" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
firefox_version="$(detect_firefox_version)"
|
||||||
|
|
||||||
|
write_user_js
|
||||||
|
write_user_chrome
|
||||||
|
package_extension
|
||||||
|
warn_about_unsigned_install "$firefox_version"
|
||||||
|
|
||||||
|
firefox_args=(
|
||||||
|
--new-instance
|
||||||
|
--profile "$profile_dir"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "$kiosk_mode"; then
|
||||||
|
firefox_args+=(--kiosk)
|
||||||
|
fi
|
||||||
|
|
||||||
|
target_url="$(normalize_target "$target")"
|
||||||
|
|
||||||
|
if [[ -n "$target_url" ]]; then
|
||||||
|
exec "$firefox_bin" \
|
||||||
|
"${firefox_args[@]}" \
|
||||||
|
--new-window "$target_url" \
|
||||||
|
"$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$firefox_bin" \
|
||||||
|
"${firefox_args[@]}" \
|
||||||
|
--new-window \
|
||||||
|
"$@"
|
||||||
124
home.css
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
.shell {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 24px 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(2.8rem, 7vw, 5rem);
|
||||||
|
line-height: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
max-width: 540px;
|
||||||
|
margin: 16px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-form input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmarks {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-height: 132px;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.02));
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
transition: transform 160ms ease, border-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-tile:hover,
|
||||||
|
.bookmark-tile:focus-visible {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(255, 190, 92, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-url {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 36px;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: rgba(132, 19, 34, 0.32);
|
||||||
|
border: 1px solid rgba(255, 126, 126, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-card p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.shell {
|
||||||
|
padding-inline: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-card {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
home.html
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Firefox Focus Mode</title>
|
||||||
|
<link rel="stylesheet" href="theme.css">
|
||||||
|
<link rel="stylesheet" href="home.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="hero">
|
||||||
|
<p class="eyebrow">Privacy-first browsing</p>
|
||||||
|
<h1>Firefox Focus Mode</h1>
|
||||||
|
<p class="subtitle">One tab, a fresh session, and just the essentials.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form id="url-form" class="url-form">
|
||||||
|
<label class="sr-only" for="url-input">Enter a URL or search</label>
|
||||||
|
<input id="url-input" name="url" type="text" placeholder="Enter a URL or search term" autocomplete="off">
|
||||||
|
<button type="submit">Go</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>Bookmarks</h2>
|
||||||
|
<button id="settings-link" class="ghost-button" type="button">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div id="bookmarks" class="bookmarks"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="clear-card">
|
||||||
|
<div>
|
||||||
|
<h2>Erase Session</h2>
|
||||||
|
<p>Delete browsing data, close extra tabs, and return here.</p>
|
||||||
|
</div>
|
||||||
|
<button id="clear-button" class="clear-button" type="button">Trash Everything</button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="home.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
64
home.js
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
function normalizeUrl(rawUrl) {
|
||||||
|
if (!rawUrl) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(rawUrl)) {
|
||||||
|
return rawUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawUrl.includes(" ") && !rawUrl.includes(".")) {
|
||||||
|
return `https://duckduckgo.com/?q=${encodeURIComponent(rawUrl)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `https://${rawUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBookmarkTile(bookmark) {
|
||||||
|
const tile = document.createElement("a");
|
||||||
|
tile.className = "bookmark-tile";
|
||||||
|
tile.href = normalizeUrl(bookmark.url);
|
||||||
|
|
||||||
|
const title = document.createElement("p");
|
||||||
|
title.className = "bookmark-title";
|
||||||
|
title.textContent = bookmark.title || "Untitled";
|
||||||
|
|
||||||
|
const url = document.createElement("p");
|
||||||
|
url.className = "bookmark-url";
|
||||||
|
url.textContent = bookmark.url || "";
|
||||||
|
|
||||||
|
tile.append(title, url);
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
const settings = await browser.runtime.sendMessage({ type: "get-settings" });
|
||||||
|
const container = document.getElementById("bookmarks");
|
||||||
|
container.textContent = "";
|
||||||
|
|
||||||
|
settings.bookmarks.forEach((bookmark) => {
|
||||||
|
container.append(createBookmarkTile(bookmark));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("url-form").addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const input = document.getElementById("url-input");
|
||||||
|
const url = input.value.trim();
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.runtime.sendMessage({ type: "open-url", url });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("settings-link").addEventListener("click", async () => {
|
||||||
|
await browser.runtime.sendMessage({ type: "open-settings" });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("clear-button").addEventListener("click", async () => {
|
||||||
|
await browser.runtime.sendMessage({ type: "clear-session" });
|
||||||
|
});
|
||||||
|
|
||||||
|
void render();
|
||||||
1
icons/focus-16.svg
Normal file
|
After Width: | Height: | Size: 10 KiB |
1
icons/focus-32.svg
Normal file
|
After Width: | Height: | Size: 10 KiB |
1
icons/focus-48.svg
Normal file
|
After Width: | Height: | Size: 10 KiB |
1
icons/focus-96.svg
Normal file
|
After Width: | Height: | Size: 10 KiB |
5
icons/old/focus-16.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||||
|
<rect width="16" height="16" rx="4" fill="#111b28"/>
|
||||||
|
<path d="M8 2.2a5.8 5.8 0 1 0 5.8 5.8A4.8 4.8 0 1 1 8 2.2Z" fill="#f97316"/>
|
||||||
|
<circle cx="9.8" cy="6.2" r="2.2" fill="#ffbe5c"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 255 B |
5
icons/old/focus-32.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="8" fill="#111b28"/>
|
||||||
|
<path d="M16 4.4A11.6 11.6 0 1 0 27.6 16 9.6 9.6 0 1 1 16 4.4Z" fill="#f97316"/>
|
||||||
|
<circle cx="19.6" cy="12.4" r="4.4" fill="#ffbe5c"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 261 B |
5
icons/old/focus-48.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||||
|
<rect width="48" height="48" rx="12" fill="#111b28"/>
|
||||||
|
<path d="M24 6.6A17.4 17.4 0 1 0 41.4 24 14.4 14.4 0 1 1 24 6.6Z" fill="#f97316"/>
|
||||||
|
<circle cx="29.4" cy="18.6" r="6.6" fill="#ffbe5c"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 264 B |
5
icons/old/focus-96.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
|
||||||
|
<rect width="96" height="96" rx="24" fill="#111b28"/>
|
||||||
|
<path d="M48 13.2A34.8 34.8 0 1 0 82.8 48 28.8 28.8 0 1 1 48 13.2Z" fill="#f97316"/>
|
||||||
|
<circle cx="58.8" cy="37.2" r="13.2" fill="#ffbe5c"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 267 B |
52
manifest.json
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
{
|
||||||
|
"manifest_version": 2,
|
||||||
|
"name": "Firefox Focus Mode",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "A simplified, privacy-focused Firefox experience inspired by Firefox Focus.",
|
||||||
|
"homepage_url": "https://github.com/hunner/firefox-focus",
|
||||||
|
"permissions": [
|
||||||
|
"tabs",
|
||||||
|
"storage",
|
||||||
|
"browsingData",
|
||||||
|
"cookies",
|
||||||
|
"webRequest",
|
||||||
|
"webRequestBlocking",
|
||||||
|
"webNavigation",
|
||||||
|
"<all_urls>"
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"scripts": [
|
||||||
|
"background.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browser_action": {
|
||||||
|
"default_title": "Firefox Focus Mode",
|
||||||
|
"default_popup": "popup.html",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "icons/focus-16.svg",
|
||||||
|
"32": "icons/focus-32.svg"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options_ui": {
|
||||||
|
"page": "options.html",
|
||||||
|
"open_in_tab": true,
|
||||||
|
"browser_style": false
|
||||||
|
},
|
||||||
|
"chrome_settings_overrides": {
|
||||||
|
"homepage": "home.html"
|
||||||
|
},
|
||||||
|
"chrome_url_overrides": {
|
||||||
|
"newtab": "home.html"
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"16": "icons/focus-16.svg",
|
||||||
|
"32": "icons/focus-32.svg",
|
||||||
|
"48": "icons/focus-48.svg",
|
||||||
|
"96": "icons/focus-96.svg"
|
||||||
|
},
|
||||||
|
"applications": {
|
||||||
|
"gecko": {
|
||||||
|
"id": "firefox-focus-mode@hunner.dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
options.css
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
.options-shell {
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 44px 24px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-hero {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(2.4rem, 7vw, 4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
max-width: 640px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading h2,
|
||||||
|
.section-heading p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading p {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#save-status {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.bookmark-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
options.html
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Firefox Focus Mode Settings</title>
|
||||||
|
<link rel="stylesheet" href="theme.css">
|
||||||
|
<link rel="stylesheet" href="options.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="options-shell">
|
||||||
|
<section class="options-hero">
|
||||||
|
<p class="eyebrow">Settings</p>
|
||||||
|
<h1>Focus Mode</h1>
|
||||||
|
<p class="subtitle">Choose up to four bookmarks and decide which extra blockers should be enabled.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form id="settings-form">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>Bookmarks</h2>
|
||||||
|
<p>Only four appear on the home page.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bookmark-fields" class="bookmark-fields"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>Blocking</h2>
|
||||||
|
<p>Tracker blocking is on by default. The other categories are optional.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="trackers">
|
||||||
|
<span>Block common trackers</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="ads">
|
||||||
|
<span>Block ads</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="analytics">
|
||||||
|
<span>Block analytics</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" name="social">
|
||||||
|
<span>Block social widgets</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Save Settings</button>
|
||||||
|
<p id="save-status" aria-live="polite"></p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="options.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
64
options.js
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
function buildBookmarkRows(bookmarks) {
|
||||||
|
const fields = document.getElementById("bookmark-fields");
|
||||||
|
fields.textContent = "";
|
||||||
|
|
||||||
|
bookmarks.forEach((bookmark, index) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "bookmark-row";
|
||||||
|
|
||||||
|
const title = document.createElement("input");
|
||||||
|
title.type = "text";
|
||||||
|
title.name = `bookmark-title-${index}`;
|
||||||
|
title.placeholder = `Bookmark ${index + 1} title`;
|
||||||
|
title.value = bookmark.title || "";
|
||||||
|
|
||||||
|
const url = document.createElement("input");
|
||||||
|
url.type = "text";
|
||||||
|
url.name = `bookmark-url-${index}`;
|
||||||
|
url.placeholder = `Bookmark ${index + 1} URL`;
|
||||||
|
url.value = bookmark.url || "";
|
||||||
|
|
||||||
|
row.append(title, url);
|
||||||
|
fields.append(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
const settings = await browser.runtime.sendMessage({ type: "get-settings" });
|
||||||
|
buildBookmarkRows(settings.bookmarks);
|
||||||
|
|
||||||
|
document.querySelector('[name="trackers"]').checked = settings.blocking.trackers;
|
||||||
|
document.querySelector('[name="ads"]').checked = settings.blocking.ads;
|
||||||
|
document.querySelector('[name="analytics"]').checked = settings.blocking.analytics;
|
||||||
|
document.querySelector('[name="social"]').checked = settings.blocking.social;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("settings-form").addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const bookmarks = Array.from({ length: 4 }, (_, index) => ({
|
||||||
|
title: document.querySelector(`[name="bookmark-title-${index}"]`).value.trim(),
|
||||||
|
url: document.querySelector(`[name="bookmark-url-${index}"]`).value.trim()
|
||||||
|
}));
|
||||||
|
|
||||||
|
await browser.runtime.sendMessage({
|
||||||
|
type: "save-settings",
|
||||||
|
settings: {
|
||||||
|
bookmarks,
|
||||||
|
blocking: {
|
||||||
|
trackers: document.querySelector('[name="trackers"]').checked,
|
||||||
|
ads: document.querySelector('[name="ads"]').checked,
|
||||||
|
analytics: document.querySelector('[name="analytics"]').checked,
|
||||||
|
social: document.querySelector('[name="social"]').checked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = document.getElementById("save-status");
|
||||||
|
status.textContent = "Saved";
|
||||||
|
window.setTimeout(() => {
|
||||||
|
status.textContent = "";
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
void render();
|
||||||
25
popup.css
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
body {
|
||||||
|
min-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-shell {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-url-form {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-url-form input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
background: linear-gradient(135deg, #a42038, #d54c56);
|
||||||
|
}
|
||||||
29
popup.html
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Firefox Focus Controls</title>
|
||||||
|
<link rel="stylesheet" href="theme.css">
|
||||||
|
<link rel="stylesheet" href="popup.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="popup-shell">
|
||||||
|
<form id="popup-url-form" class="popup-url-form">
|
||||||
|
<label class="sr-only" for="popup-url-input">Enter a URL or search</label>
|
||||||
|
<input id="popup-url-input" type="text" placeholder="URL or search">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="control-grid">
|
||||||
|
<button data-action="back" type="button">Back</button>
|
||||||
|
<button data-action="forward" type="button">Forward</button>
|
||||||
|
<button data-action="refresh" type="button">Refresh</button>
|
||||||
|
<button data-action="home" type="button">Home</button>
|
||||||
|
<button data-action="trash" type="button" class="danger">Trash</button>
|
||||||
|
<button data-action="settings" type="button">Settings</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
60
popup.js
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
function normalizeUrl(rawUrl) {
|
||||||
|
if (!rawUrl) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(rawUrl)) {
|
||||||
|
return rawUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawUrl.includes(" ") && !rawUrl.includes(".")) {
|
||||||
|
return `https://duckduckgo.com/?q=${encodeURIComponent(rawUrl)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `https://${rawUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getActiveTab() {
|
||||||
|
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("popup-url-form").addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const input = document.getElementById("popup-url-input");
|
||||||
|
const value = input.value.trim();
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.runtime.sendMessage({ type: "open-url", url: normalizeUrl(value) });
|
||||||
|
window.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-action]").forEach((button) => {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
const action = button.dataset.action;
|
||||||
|
const tab = await getActiveTab();
|
||||||
|
|
||||||
|
if (!tab) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "back") {
|
||||||
|
await browser.tabs.goBack(tab.id).catch(() => {});
|
||||||
|
} else if (action === "forward") {
|
||||||
|
await browser.tabs.goForward(tab.id).catch(() => {});
|
||||||
|
} else if (action === "refresh") {
|
||||||
|
await browser.tabs.reload(tab.id);
|
||||||
|
} else if (action === "home") {
|
||||||
|
await browser.runtime.sendMessage({ type: "go-home" });
|
||||||
|
} else if (action === "trash") {
|
||||||
|
await browser.runtime.sendMessage({ type: "clear-session" });
|
||||||
|
} else if (action === "settings") {
|
||||||
|
await browser.runtime.sendMessage({ type: "open-settings" });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
25
spec.md
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Summary
|
||||||
|
|
||||||
|
firefox focus is an ios and android app released by mozilla.
|
||||||
|
|
||||||
|
I want to create a firefox extension to make firefox similar to the "firefox focus" app, which is a simplified, privacy-focused browser experience.
|
||||||
|
|
||||||
|
# Features
|
||||||
|
|
||||||
|
The extension will implement the following features:
|
||||||
|
|
||||||
|
- only one tab ever. no new windows, no new tabs
|
||||||
|
- always incognito. there is no non-private mode. When you close the app, all data is deleted. When you open it again, it's like a fresh install.
|
||||||
|
- only four bookmarks max, and all on the home page. no bookmark manager, no folders, no syncing, no import/export
|
||||||
|
- tracking and script blocking is on by default, and can optionally block ads, analytics, and social scripts and trackers
|
||||||
|
- only five buttons: back, forward, refresh, url bar, trash, and settings. The trash button clears the session and goes to the home page again
|
||||||
|
|
||||||
|
# Implementation
|
||||||
|
|
||||||
|
There is a plugin at ../I-Hate-Tabs---SDI-extension that implements blocking opening new tabs and always opens them in a new window. We can use that to cause new tabs or windows to open in the current window instead.
|
||||||
|
|
||||||
|
There should be a way with an extension to add the four bookmark tiles to the home page, and to make the trash button clear the session and go to the home page again. We can use the extension API to implement these features.
|
||||||
|
|
||||||
|
I still want to have 1password, and maybe a few other extensions so I'm not going to block installing extensions, but maybe there can be a whitelist of allowed extensions that can be installed.
|
||||||
|
|
||||||
|
This extension would not be installed in the main profile, but in a secondary profile that could be launched with a shortcut. This way, the main profile can still be used for regular browsing, and the secondary profile can be used for the firefox focus experience. The extension would be installed in the secondary profile and would only affect that profile.
|
||||||
100
theme.css
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
:root {
|
||||||
|
--bg: #0d1622;
|
||||||
|
--bg-accent: #182739;
|
||||||
|
--surface: rgba(7, 16, 26, 0.7);
|
||||||
|
--text: #f5f1e7;
|
||||||
|
--muted: #afbdcb;
|
||||||
|
--accent: #ffbe5c;
|
||||||
|
--accent-strong: #f97316;
|
||||||
|
--shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(249, 115, 22, 0.22), transparent 28%),
|
||||||
|
radial-gradient(circle at top right, rgba(255, 190, 92, 0.16), transparent 30%),
|
||||||
|
linear-gradient(160deg, var(--bg) 0%, var(--bg-accent) 100%);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-image: linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
|
||||||
|
background-size: 36px 36px;
|
||||||
|
mask-image: radial-gradient(circle at center, black, transparent 78%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 18px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
padding: 15px 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder {
|
||||||
|
color: #8f9dae;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: linear-gradient(135deg, var(--accent-strong), var(--accent));
|
||||||
|
color: #20160d;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover,
|
||||||
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
a:focus-visible {
|
||||||
|
outline: 2px solid rgba(255, 190, 92, 0.9);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-button {
|
||||||
|
background: linear-gradient(135deg, #a42038, #d54c56);
|
||||||
|
color: #fff6f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||