diff --git a/discord-mini-tabs-extension/MANUAL_TESTS.md b/discord-mini-tabs-extension/MANUAL_TESTS.md
new file mode 100644
index 000000000..fb207ec27
--- /dev/null
+++ b/discord-mini-tabs-extension/MANUAL_TESTS.md
@@ -0,0 +1,71 @@
+# Manual Tests
+
+## Load Unpacked
+
+1. Open `chrome://extensions`.
+2. Enable Developer mode.
+3. Load the `C:\Users\RGB\rtk\.worktrees\discord-mini-tabs-extension\discord-mini-tabs-extension` directory.
+4. Confirm Chrome shows no manifest or service worker registration errors.
+
+## Shortcut Management
+
+1. Add a text shortcut with `https://discord.com/channels/123456789012345678/987654321098765432`.
+2. Confirm the shortcut appears in the Text list.
+3. Edit the shortcut name.
+4. Search for the new name.
+5. Delete the shortcut.
+6. Confirm the list updates without reopening the popup.
+
+## Save Current Discord Channel
+
+1. Open a real Discord channel in a normal Chrome tab.
+2. Open the extension popup.
+3. Click Save current.
+4. Confirm the URL and suggested name populate the form.
+5. Save the shortcut.
+
+## Mini Window
+
+1. Open a saved text shortcut.
+2. Confirm one Chrome popup window opens.
+3. Send a Discord chat message in that window.
+4. Open another shortcut.
+5. Confirm the same popup window is reused.
+6. Resize the popup window.
+7. Close and reopen a shortcut.
+8. Confirm the remembered size is used.
+
+## Voice URL
+
+1. Save a Discord voice channel URL as type Voice.
+2. Open it from the Voice list.
+3. Confirm Discord web displays the voice channel UI.
+4. Join or leave voice manually through Discord web.
+
+## Window Controls
+
+1. Click Focus and confirm the mini window comes forward.
+2. Click Close and confirm the mini window closes.
+3. Click Reset position and confirm the next open lets Chrome choose a valid position with the saved size.
+
+## Settings
+
+1. Set width to `420`, height to `900`, and zoom to `90`.
+2. Open a shortcut.
+3. Confirm the mini window size and Discord tab zoom are applied.
+4. Change width to `500` and zoom to `100`.
+5. Confirm the existing mini window updates.
+
+## Stability Smoke Test
+
+1. Switch between several text and voice shortcuts repeatedly.
+2. Confirm only one Discord mini window remains open.
+3. Close the mini window manually.
+4. Open a shortcut again.
+5. Confirm the extension recovers without errors.
+
+## Service Worker Errors
+
+1. Open `chrome://extensions`.
+2. Open the service worker inspection link for Discord Mini Tabs.
+3. Confirm there are no uncaught exceptions after opening the popup, adding a shortcut, opening a shortcut, focusing, closing, and changing settings.
diff --git a/discord-mini-tabs-extension/README.md b/discord-mini-tabs-extension/README.md
new file mode 100644
index 000000000..c9a30f067
--- /dev/null
+++ b/discord-mini-tabs-extension/README.md
@@ -0,0 +1,22 @@
+# Discord Mini Tabs
+
+A Chrome Manifest V3 extension that opens saved Discord text and voice channel URLs in one reusable Chrome popup window.
+
+## Development
+
+Run pure logic tests:
+
+```bash
+npm test
+```
+
+Load in Chrome:
+
+1. Open `chrome://extensions`.
+2. Enable Developer mode.
+3. Click Load unpacked.
+4. Select the `discord-mini-tabs-extension` directory.
+
+## Scope
+
+Discord runs as the official Discord web app in a real Chrome popup window. This extension does not embed Discord, inject content scripts, read Discord messages, or inspect Discord voice internals.
diff --git a/discord-mini-tabs-extension/manifest.json b/discord-mini-tabs-extension/manifest.json
new file mode 100644
index 000000000..b39ad8d3d
--- /dev/null
+++ b/discord-mini-tabs-extension/manifest.json
@@ -0,0 +1,16 @@
+{
+ "manifest_version": 3,
+ "name": "Discord Mini Tabs",
+ "version": "0.1.0",
+ "description": "Open saved Discord text and voice channels in one reusable mini Chrome popup window.",
+ "permissions": ["storage", "tabs", "windows", "activeTab"],
+ "host_permissions": ["https://discord.com/*"],
+ "background": {
+ "service_worker": "src/background/service-worker.js",
+ "type": "module"
+ },
+ "action": {
+ "default_title": "Discord Mini Tabs",
+ "default_popup": "src/popup/popup.html"
+ }
+}
diff --git a/discord-mini-tabs-extension/package.json b/discord-mini-tabs-extension/package.json
new file mode 100644
index 000000000..e70d2a296
--- /dev/null
+++ b/discord-mini-tabs-extension/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "discord-mini-tabs-extension",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "test": "node --test"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+}
diff --git a/discord-mini-tabs-extension/src/background/service-worker-core.js b/discord-mini-tabs-extension/src/background/service-worker-core.js
new file mode 100644
index 000000000..b143047d7
--- /dev/null
+++ b/discord-mini-tabs-extension/src/background/service-worker-core.js
@@ -0,0 +1,183 @@
+import { MESSAGE_TYPES } from "../shared/constants.js";
+
+function getSuggestedName(title) {
+ const cleanedTitle = String(title ?? "")
+ .replace(/\s*(?:-|[|])\s*Discord$/i, "")
+ .trim();
+ return cleanedTitle || "Discord channel";
+}
+
+export function createServiceWorkerController({
+ chromeApi,
+ getExtensionState,
+ setShortcuts,
+ setWindowState,
+ createShortcut,
+ deleteShortcut,
+ findShortcut,
+ updateShortcut,
+ validateDiscordChannelUrl,
+ closeMiniWindow,
+ focusMiniWindow,
+ openShortcutInMiniWindow,
+ resetMiniWindowPosition,
+ saveBoundsFromWindow,
+ updateMiniWindowSettings,
+ setTimeoutFn,
+ clearTimeoutFn,
+ debounceMs = 400
+}) {
+ const boundsSaveTimers = new Map();
+ const boundsSaveTokens = new Map();
+ let shortcutMutationQueue = Promise.resolve();
+
+ function enqueueShortcutMutation(operation) {
+ const next = shortcutMutationQueue.catch(() => undefined).then(operation);
+ shortcutMutationQueue = next.catch(() => undefined);
+ return next;
+ }
+
+ async function handleCreateShortcut(payload) {
+ const state = await getExtensionState();
+ const shortcut = createShortcut(payload);
+ const shortcuts = await setShortcuts([...state.shortcuts, shortcut]);
+ return { shortcut, shortcuts };
+ }
+
+ async function handleUpdateShortcut(payload) {
+ const state = await getExtensionState();
+ const existing = findShortcut(state.shortcuts, payload.id);
+ if (!existing) {
+ throw new Error("Shortcut not found.");
+ }
+
+ const updated = updateShortcut(existing, payload);
+ const shortcuts = await setShortcuts(
+ state.shortcuts.map((shortcut) => (shortcut.id === updated.id ? updated : shortcut))
+ );
+ return { shortcut: updated, shortcuts };
+ }
+
+ async function handleDeleteShortcut(payload) {
+ const state = await getExtensionState();
+ const shortcuts = await setShortcuts(deleteShortcut(state.shortcuts, payload.id));
+ return { shortcuts };
+ }
+
+ async function handleOpenShortcut(payload) {
+ const state = await getExtensionState();
+ const shortcut = findShortcut(state.shortcuts, payload.id);
+ if (!shortcut) {
+ throw new Error("Shortcut not found.");
+ }
+
+ const windowState = await openShortcutInMiniWindow({ chromeApi, shortcut });
+ return { windowState };
+ }
+
+ async function handleReadActiveDiscordTab() {
+ const tabs = await chromeApi.tabs.query({ active: true, currentWindow: true });
+ const activeTab = tabs[0];
+ const result = validateDiscordChannelUrl(activeTab?.url ?? "");
+ if (!result.ok) {
+ throw new Error("The active tab is not a supported Discord channel URL.");
+ }
+
+ return {
+ url: result.url,
+ title: activeTab.title ?? "",
+ suggestedName: getSuggestedName(activeTab.title)
+ };
+ }
+
+ async function handleMessage(message) {
+ const payload = message?.payload ?? {};
+
+ switch (message?.type) {
+ case MESSAGE_TYPES.GET_STATE:
+ return getExtensionState();
+ case MESSAGE_TYPES.CREATE_SHORTCUT:
+ return enqueueShortcutMutation(() => handleCreateShortcut(payload));
+ case MESSAGE_TYPES.UPDATE_SHORTCUT:
+ return enqueueShortcutMutation(() => handleUpdateShortcut(payload));
+ case MESSAGE_TYPES.DELETE_SHORTCUT:
+ return enqueueShortcutMutation(() => handleDeleteShortcut(payload));
+ case MESSAGE_TYPES.OPEN_SHORTCUT:
+ return handleOpenShortcut(payload);
+ case MESSAGE_TYPES.READ_ACTIVE_DISCORD_TAB:
+ return handleReadActiveDiscordTab();
+ case MESSAGE_TYPES.UPDATE_WINDOW_SETTINGS:
+ return {
+ windowState: await updateMiniWindowSettings({
+ chromeApi,
+ bounds: payload.bounds,
+ zoom: payload.zoom
+ })
+ };
+ case MESSAGE_TYPES.FOCUS_WINDOW:
+ return { windowId: await focusMiniWindow({ chromeApi }) };
+ case MESSAGE_TYPES.CLOSE_WINDOW:
+ return { windowState: await closeMiniWindow({ chromeApi }) };
+ case MESSAGE_TYPES.RESET_POSITION:
+ return { windowState: await resetMiniWindowPosition({ chromeApi }) };
+ default:
+ throw new Error("Unsupported message type.");
+ }
+ }
+
+ async function handleWindowRemoved(windowId) {
+ try {
+ const state = await getExtensionState();
+ if (state.windowState.windowId === windowId) {
+ await setWindowState({ ...state.windowState, windowId: null, tabId: null });
+ }
+ } catch {
+ // Background window events must not surface unhandled storage failures.
+ }
+ }
+
+ function handleBoundsChanged(changedWindow) {
+ if (!changedWindow || changedWindow.type !== "popup" || !Number.isInteger(changedWindow.id)) {
+ return Promise.resolve();
+ }
+
+ const windowId = changedWindow.id;
+ const token = Symbol("bounds-save");
+ boundsSaveTokens.set(windowId, token);
+ clearTimeoutFn(boundsSaveTimers.get(windowId));
+ const timerId = setTimeoutFn(async () => {
+ if (boundsSaveTimers.get(windowId) === timerId) {
+ boundsSaveTimers.delete(windowId);
+ }
+ if (boundsSaveTokens.get(windowId) !== token) {
+ return;
+ }
+
+ try {
+ const state = await getExtensionState();
+ if (boundsSaveTokens.get(windowId) !== token) {
+ return;
+ }
+
+ await saveBoundsFromWindow(changedWindow, {
+ expectedWindowId: state.windowState.windowId,
+ shouldSave: () => boundsSaveTokens.get(windowId) === token
+ });
+ } catch {
+ // Background window events must not surface unhandled storage failures.
+ } finally {
+ if (boundsSaveTokens.get(windowId) === token) {
+ boundsSaveTokens.delete(windowId);
+ }
+ }
+ }, debounceMs);
+ boundsSaveTimers.set(windowId, timerId);
+ return Promise.resolve();
+ }
+
+ return {
+ handleMessage,
+ handleWindowRemoved,
+ handleBoundsChanged
+ };
+}
diff --git a/discord-mini-tabs-extension/src/background/service-worker.js b/discord-mini-tabs-extension/src/background/service-worker.js
new file mode 100644
index 000000000..b1e48c5c6
--- /dev/null
+++ b/discord-mini-tabs-extension/src/background/service-worker.js
@@ -0,0 +1,53 @@
+import { createServiceWorkerController } from "./service-worker-core.js";
+import { createShortcut, deleteShortcut, findShortcut, updateShortcut } from "../shared/shortcuts.js";
+import { validateDiscordChannelUrl } from "../shared/url.js";
+import { getExtensionState, setShortcuts, setWindowState } from "./storage.js";
+import {
+ closeMiniWindow,
+ focusMiniWindow,
+ openShortcutInMiniWindow,
+ resetMiniWindowPosition,
+ saveBoundsFromWindow,
+ updateMiniWindowSettings
+} from "./window-manager.js";
+
+function errorMessage(error) {
+ return error instanceof Error ? error.message : "Unknown extension error.";
+}
+
+const controller = createServiceWorkerController({
+ chromeApi: chrome,
+ getExtensionState,
+ setShortcuts,
+ setWindowState,
+ createShortcut,
+ deleteShortcut,
+ findShortcut,
+ updateShortcut,
+ validateDiscordChannelUrl,
+ closeMiniWindow,
+ focusMiniWindow,
+ openShortcutInMiniWindow,
+ resetMiniWindowPosition,
+ saveBoundsFromWindow,
+ updateMiniWindowSettings,
+ setTimeoutFn: setTimeout,
+ clearTimeoutFn: clearTimeout,
+ debounceMs: 400
+});
+
+chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
+ controller
+ .handleMessage(message)
+ .then((data) => sendResponse({ ok: true, data }))
+ .catch((error) => sendResponse({ ok: false, error: errorMessage(error) }));
+ return true;
+});
+
+chrome.windows.onRemoved.addListener((windowId) => {
+ controller.handleWindowRemoved(windowId);
+});
+
+chrome.windows.onBoundsChanged.addListener((changedWindow) => {
+ controller.handleBoundsChanged(changedWindow);
+});
diff --git a/discord-mini-tabs-extension/src/background/storage.js b/discord-mini-tabs-extension/src/background/storage.js
new file mode 100644
index 000000000..290c22031
--- /dev/null
+++ b/discord-mini-tabs-extension/src/background/storage.js
@@ -0,0 +1,39 @@
+import { STORAGE_KEYS } from "../shared/constants.js";
+import { createDefaultWindowState, normalizeWindowState } from "../shared/settings.js";
+import { normalizeShortcutList } from "../shared/shortcuts.js";
+
+const DEFAULT_STATE = {
+ [STORAGE_KEYS.SHORTCUTS]: [],
+ [STORAGE_KEYS.WINDOW_STATE]: createDefaultWindowState()
+};
+
+function getDefaultStorageArea() {
+ return chrome.storage.local;
+}
+
+export async function getExtensionState(storageArea = getDefaultStorageArea()) {
+ const data = await storageArea.get(DEFAULT_STATE);
+ return {
+ shortcuts: normalizeShortcutList(data[STORAGE_KEYS.SHORTCUTS]),
+ windowState: normalizeWindowState(data[STORAGE_KEYS.WINDOW_STATE])
+ };
+}
+
+export async function setShortcuts(shortcuts, storageArea = getDefaultStorageArea()) {
+ const normalized = normalizeShortcutList(shortcuts);
+ await storageArea.set({ [STORAGE_KEYS.SHORTCUTS]: normalized });
+ return normalized;
+}
+
+export async function setWindowState(windowState, storageArea = getDefaultStorageArea()) {
+ const normalized = normalizeWindowState(windowState);
+ await storageArea.set({ [STORAGE_KEYS.WINDOW_STATE]: normalized });
+ return normalized;
+}
+
+export async function updateWindowState(updater, storageArea = getDefaultStorageArea()) {
+ const state = await getExtensionState(storageArea);
+ const nextWindowState = normalizeWindowState(updater(state.windowState));
+ await setWindowState(nextWindowState, storageArea);
+ return nextWindowState;
+}
diff --git a/discord-mini-tabs-extension/src/background/window-manager.js b/discord-mini-tabs-extension/src/background/window-manager.js
new file mode 100644
index 000000000..a86e1209d
--- /dev/null
+++ b/discord-mini-tabs-extension/src/background/window-manager.js
@@ -0,0 +1,271 @@
+import { DEFAULT_BOUNDS } from "../shared/constants.js";
+import { clampBounds, normalizeWindowState } from "../shared/settings.js";
+import { getExtensionState, setWindowState, updateWindowState } from "./storage.js";
+
+let windowMutationQueue = Promise.resolve();
+
+function getDefaultChromeApi() {
+ if (typeof chrome === "undefined") {
+ throw new Error("Chrome API is unavailable.");
+ }
+ return chrome;
+}
+
+function enqueueWindowMutation(operation) {
+ const next = windowMutationQueue.catch(() => undefined).then(operation);
+ windowMutationQueue = next.catch(() => undefined);
+ return next;
+}
+
+function boundsToCreateData(bounds, url) {
+ const createData = {
+ url,
+ type: "popup",
+ focused: true,
+ width: bounds.width,
+ height: bounds.height
+ };
+
+ if (Number.isInteger(bounds.left)) createData.left = bounds.left;
+ if (Number.isInteger(bounds.top)) createData.top = bounds.top;
+
+ return createData;
+}
+
+async function getWindowWithTab(chromeApi, windowState) {
+ if (!Number.isInteger(windowState.windowId)) {
+ return null;
+ }
+
+ try {
+ const currentWindow = await chromeApi.windows.get(windowState.windowId, { populate: true });
+ if (currentWindow.type !== "popup") {
+ return null;
+ }
+
+ const existingTab =
+ currentWindow.tabs?.find((tab) => tab.id === windowState.tabId) ??
+ currentWindow.tabs?.[0] ??
+ null;
+ if (!Number.isInteger(existingTab?.id)) {
+ return null;
+ }
+
+ return { window: currentWindow, tab: existingTab };
+ } catch {
+ return null;
+ }
+}
+
+async function applyZoom(chromeApi, tabId, zoom) {
+ try {
+ await chromeApi.tabs.setZoom(tabId, zoom);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export async function openShortcutInMiniWindow({
+ chromeApi = getDefaultChromeApi(),
+ storageArea,
+ shortcut
+}) {
+ return enqueueWindowMutation(() =>
+ openShortcutInMiniWindowUnlocked({ chromeApi, storageArea, shortcut })
+ );
+}
+
+async function openShortcutInMiniWindowUnlocked({ chromeApi, storageArea, shortcut }) {
+ const state = await getExtensionState(storageArea);
+ const windowState = normalizeWindowState(state.windowState);
+ const existing = await getWindowWithTab(chromeApi, windowState);
+
+ if (existing) {
+ await chromeApi.windows.update(existing.window.id, { focused: true });
+ const tab = await chromeApi.tabs.update(existing.tab.id, {
+ url: shortcut.url,
+ active: true
+ });
+ const tabId = Number.isInteger(tab?.id) ? tab.id : existing.tab.id;
+ await applyZoom(chromeApi, tabId, windowState.zoom);
+
+ return setWindowState(
+ {
+ ...windowState,
+ windowId: existing.window.id,
+ tabId,
+ lastShortcutId: shortcut.id
+ },
+ storageArea
+ );
+ }
+
+ const bounds = clampBounds(windowState.bounds ?? DEFAULT_BOUNDS);
+ const createdWindow = await chromeApi.windows.create(boundsToCreateData(bounds, shortcut.url));
+ const createdTab = createdWindow?.tabs?.[0] ?? null;
+ if (!Number.isInteger(createdWindow?.id) || !Number.isInteger(createdTab?.id)) {
+ throw new Error("Chrome did not return a usable Discord mini window.");
+ }
+
+ await applyZoom(chromeApi, createdTab.id, windowState.zoom);
+
+ return setWindowState(
+ {
+ ...windowState,
+ windowId: createdWindow.id,
+ tabId: createdTab.id,
+ bounds,
+ lastShortcutId: shortcut.id
+ },
+ storageArea
+ );
+}
+
+export async function focusMiniWindow({ chromeApi = getDefaultChromeApi(), storageArea }) {
+ return enqueueWindowMutation(() => focusMiniWindowUnlocked({ chromeApi, storageArea }));
+}
+
+async function focusMiniWindowUnlocked({ chromeApi, storageArea }) {
+ const state = await getExtensionState(storageArea);
+ const existing = await getWindowWithTab(chromeApi, state.windowState);
+ if (!existing) {
+ await setWindowState({ ...state.windowState, windowId: null, tabId: null }, storageArea);
+ return null;
+ }
+
+ await chromeApi.windows.update(existing.window.id, { focused: true });
+ return existing.window.id;
+}
+
+export async function closeMiniWindow({ chromeApi = getDefaultChromeApi(), storageArea }) {
+ return enqueueWindowMutation(() => closeMiniWindowUnlocked({ chromeApi, storageArea }));
+}
+
+async function closeMiniWindowUnlocked({ chromeApi, storageArea }) {
+ const state = await getExtensionState(storageArea);
+ if (Number.isInteger(state.windowState.windowId)) {
+ try {
+ await chromeApi.windows.remove(state.windowState.windowId);
+ } catch {
+ // The window may already be closed. Clearing stored ids is still correct.
+ }
+ }
+
+ return setWindowState({ ...state.windowState, windowId: null, tabId: null }, storageArea);
+}
+
+export async function resetMiniWindowPosition({
+ chromeApi = getDefaultChromeApi(),
+ storageArea
+}) {
+ return enqueueWindowMutation(() =>
+ resetMiniWindowPositionUnlocked({ chromeApi, storageArea })
+ );
+}
+
+async function resetMiniWindowPositionUnlocked({ chromeApi, storageArea }) {
+ const state = await updateWindowState(
+ (windowState) => ({
+ ...windowState,
+ bounds: { ...windowState.bounds, left: null, top: null }
+ }),
+ storageArea
+ );
+
+ if (Number.isInteger(state.windowId)) {
+ try {
+ await chromeApi.windows.update(state.windowId, {
+ width: state.bounds.width,
+ height: state.bounds.height
+ });
+ } catch {
+ return setWindowState({ ...state, windowId: null, tabId: null }, storageArea);
+ }
+ }
+
+ return state;
+}
+
+export async function updateMiniWindowSettings({
+ chromeApi = getDefaultChromeApi(),
+ storageArea,
+ bounds,
+ zoom
+}) {
+ return enqueueWindowMutation(() =>
+ updateMiniWindowSettingsUnlocked({ chromeApi, storageArea, bounds, zoom })
+ );
+}
+
+async function updateMiniWindowSettingsUnlocked({ chromeApi, storageArea, bounds, zoom }) {
+ const state = await updateWindowState(
+ (windowState) => ({
+ ...windowState,
+ bounds: clampBounds({ ...windowState.bounds, ...bounds }),
+ zoom: zoom ?? windowState.zoom
+ }),
+ storageArea
+ );
+
+ if (Number.isInteger(state.windowId)) {
+ try {
+ const updateInfo = {
+ width: state.bounds.width,
+ height: state.bounds.height
+ };
+ if (Number.isInteger(state.bounds.left)) updateInfo.left = state.bounds.left;
+ if (Number.isInteger(state.bounds.top)) updateInfo.top = state.bounds.top;
+
+ await chromeApi.windows.update(state.windowId, updateInfo);
+ if (Number.isInteger(state.tabId)) {
+ await applyZoom(chromeApi, state.tabId, state.zoom);
+ }
+ } catch {
+ return setWindowState({ ...state, windowId: null, tabId: null }, storageArea);
+ }
+ }
+
+ return state;
+}
+
+export async function saveBoundsFromWindow(
+ window,
+ { storageArea, expectedWindowId, shouldSave = () => true }
+) {
+ return enqueueWindowMutation(() =>
+ saveBoundsFromWindowUnlocked(window, { storageArea, expectedWindowId, shouldSave })
+ );
+}
+
+async function saveBoundsFromWindowUnlocked(
+ window,
+ { storageArea, expectedWindowId, shouldSave = () => true }
+) {
+ if (!window || window.type !== "popup" || window.id !== expectedWindowId) {
+ return null;
+ }
+
+ const state = await getExtensionState(storageArea);
+ const windowState = normalizeWindowState(state.windowState);
+ if (windowState.windowId !== window.id) {
+ return null;
+ }
+
+ if (!(await shouldSave())) {
+ return null;
+ }
+
+ return setWindowState(
+ {
+ ...windowState,
+ bounds: clampBounds({
+ left: window.left,
+ top: window.top,
+ width: window.width,
+ height: window.height
+ })
+ },
+ storageArea
+ );
+}
diff --git a/discord-mini-tabs-extension/src/popup/popup.css b/discord-mini-tabs-extension/src/popup/popup.css
new file mode 100644
index 000000000..2770e369a
--- /dev/null
+++ b/discord-mini-tabs-extension/src/popup/popup.css
@@ -0,0 +1,232 @@
+:root {
+ color-scheme: dark;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ background: #121318;
+ color: #f3f4f8;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ width: 380px;
+ min-height: 560px;
+ background: #121318;
+ color: #f3f4f8;
+}
+
+button,
+input,
+select {
+ font: inherit;
+}
+
+button {
+ min-height: 34px;
+ border: 1px solid #363946;
+ border-radius: 6px;
+ background: #20222b;
+ color: #f3f4f8;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+button:hover {
+ border-color: #5865f2;
+}
+
+button.primary {
+ border-color: #5865f2;
+ background: #5865f2;
+ color: #ffffff;
+ font-weight: 700;
+}
+
+input,
+select {
+ width: 100%;
+ min-width: 0;
+ min-height: 34px;
+ border: 1px solid #363946;
+ border-radius: 6px;
+ padding: 7px 9px;
+ background: #171922;
+ color: #f3f4f8;
+}
+
+input::placeholder {
+ color: #858b9b;
+}
+
+label {
+ display: grid;
+ gap: 5px;
+ min-width: 0;
+ color: #c4c8d4;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.shell {
+ display: grid;
+ gap: 10px;
+ padding: 12px;
+}
+
+.topbar {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 10px;
+ align-items: center;
+}
+
+h1 {
+ margin: 0;
+ font-size: 17px;
+ line-height: 1.2;
+}
+
+p {
+ margin: 0;
+}
+
+#windowMeta {
+ margin-top: 3px;
+ color: #aeb3c2;
+ font-size: 12px;
+}
+
+.feedback {
+ border: 1px solid #6b3a3a;
+ border-radius: 6px;
+ padding: 8px 10px;
+ background: #2a171b;
+ color: #ffb8bd;
+ font-size: 12px;
+ line-height: 1.35;
+ overflow-wrap: anywhere;
+}
+
+.feedback[hidden] {
+ display: none;
+}
+
+.panel {
+ border: 1px solid #2d303a;
+ border-radius: 8px;
+ background: #191b23;
+ padding: 10px;
+}
+
+.controls {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1.35fr;
+ gap: 8px;
+}
+
+.shortcuts {
+ display: grid;
+ gap: 9px;
+}
+
+.segmented {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 6px;
+}
+
+.segmented button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+}
+
+.segmented button.active {
+ border-color: #5865f2;
+ background: #2f344f;
+}
+
+.segmented span {
+ min-width: 20px;
+ border-radius: 999px;
+ padding: 1px 6px;
+ background: #11131a;
+ color: #d9dcff;
+ font-size: 11px;
+}
+
+.shortcut-list {
+ display: grid;
+ gap: 8px;
+ min-height: 58px;
+ max-height: 190px;
+ overflow-y: auto;
+}
+
+.shortcut-card {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px;
+ align-items: start;
+ border: 1px solid #30333e;
+ border-radius: 8px;
+ padding: 9px;
+ background: #20222b;
+}
+
+.shortcut-name {
+ color: #ffffff;
+ font-size: 13px;
+ font-weight: 800;
+ line-height: 1.25;
+ overflow-wrap: anywhere;
+}
+
+.shortcut-url {
+ margin-top: 4px;
+ color: #aeb3c2;
+ font-size: 12px;
+ line-height: 1.3;
+ overflow-wrap: anywhere;
+}
+
+.shortcut-actions {
+ display: grid;
+ grid-template-columns: repeat(3, 32px);
+ gap: 5px;
+}
+
+.shortcut-actions button {
+ width: 32px;
+ min-height: 30px;
+ padding: 0;
+}
+
+.empty {
+ display: grid;
+ min-height: 58px;
+ place-items: center;
+ color: #858b9b;
+ font-size: 12px;
+}
+
+.form {
+ display: grid;
+ gap: 9px;
+}
+
+.form-actions {
+ display: grid;
+ grid-template-columns: 1fr 1.3fr;
+ gap: 8px;
+}
+
+.settings {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr auto;
+ gap: 8px;
+ align-items: end;
+}
diff --git a/discord-mini-tabs-extension/src/popup/popup.html b/discord-mini-tabs-extension/src/popup/popup.html
new file mode 100644
index 000000000..b9597897e
--- /dev/null
+++ b/discord-mini-tabs-extension/src/popup/popup.html
@@ -0,0 +1,84 @@
+
+
+
+
+
+ Discord Mini Tabs
+
+
+
+
+
+
+
Discord Mini Tabs
+
Closed | 420 x 900 | 90%
+
+ Save current
+
+
+
+
+
+ Focus
+ Close
+ Reset position
+
+
+
+
+
+
+
+
+
+
+
diff --git a/discord-mini-tabs-extension/src/popup/popup.js b/discord-mini-tabs-extension/src/popup/popup.js
new file mode 100644
index 000000000..5307a9678
--- /dev/null
+++ b/discord-mini-tabs-extension/src/popup/popup.js
@@ -0,0 +1,311 @@
+import {
+ DEFAULT_BOUNDS,
+ DEFAULT_ZOOM,
+ MESSAGE_TYPES,
+ SHORTCUT_TYPES
+} from "../shared/constants.js";
+import { compactDiscordUrl } from "../shared/url.js";
+import { buildPopupModel } from "./view-model.js";
+
+const CONTROL_IDS = {
+ windowMeta: "windowMeta",
+ saveCurrentButton: "saveCurrentButton",
+ feedback: "feedback",
+ focusButton: "focusButton",
+ closeButton: "closeButton",
+ resetButton: "resetButton",
+ searchInput: "searchInput",
+ textTab: "textTab",
+ voiceTab: "voiceTab",
+ textCount: "textCount",
+ voiceCount: "voiceCount",
+ shortcutList: "shortcutList",
+ shortcutForm: "shortcutForm",
+ formTitle: "formTitle",
+ editingId: "editingId",
+ shortcutName: "shortcutName",
+ shortcutUrl: "shortcutUrl",
+ shortcutType: "shortcutType",
+ cancelEditButton: "cancelEditButton",
+ settingsForm: "settingsForm",
+ widthInput: "widthInput",
+ heightInput: "heightInput",
+ zoomInput: "zoomInput"
+};
+
+function toNumber(value, fallback) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : fallback;
+}
+
+function toZoomPercent(zoom) {
+ return Math.round(Number(zoom ?? DEFAULT_ZOOM) * 100);
+}
+
+function getSafeWindowState(windowState) {
+ const bounds = windowState?.bounds ?? {};
+ const width = Number(bounds.width);
+ const height = Number(bounds.height);
+ return {
+ ...windowState,
+ bounds: {
+ ...DEFAULT_BOUNDS,
+ ...bounds,
+ width: Number.isFinite(width) ? width : DEFAULT_BOUNDS.width,
+ height: Number.isFinite(height) ? height : DEFAULT_BOUNDS.height
+ },
+ zoom: Number.isFinite(Number(windowState?.zoom)) ? Number(windowState.zoom) : DEFAULT_ZOOM
+ };
+}
+
+function getRequiredElement(documentRef, id) {
+ const element = documentRef.getElementById(id);
+ if (!element) {
+ throw new Error(`Missing popup element: ${id}`);
+ }
+ return element;
+}
+
+export function createPopupApp({ document, chromeApi }) {
+ const elements = Object.fromEntries(
+ Object.entries(CONTROL_IDS).map(([key, id]) => [key, getRequiredElement(document, id)])
+ );
+ const state = {
+ shortcuts: [],
+ windowState: getSafeWindowState(null),
+ query: "",
+ activeType: SHORTCUT_TYPES.TEXT
+ };
+
+ async function sendMessage(type, payload = {}) {
+ const response = await chromeApi.runtime.sendMessage({ type, payload });
+ if (!response?.ok) {
+ throw new Error(response?.error || "Popup request failed.");
+ }
+ return response.data;
+ }
+
+ function showError(error) {
+ elements.feedback.textContent = error?.message || String(error || "Something went wrong.");
+ elements.feedback.hidden = false;
+ }
+
+ function clearFeedback() {
+ elements.feedback.textContent = "";
+ elements.feedback.hidden = true;
+ }
+
+ function setText(element, value) {
+ element.textContent = String(value);
+ }
+
+ function setActiveType(type) {
+ state.activeType = type === SHORTCUT_TYPES.VOICE ? SHORTCUT_TYPES.VOICE : SHORTCUT_TYPES.TEXT;
+ if (!elements.editingId.value.trim()) {
+ elements.shortcutType.value = state.activeType;
+ }
+ render();
+ }
+
+ function clearShortcutForm() {
+ elements.formTitle.textContent = "Add shortcut";
+ elements.editingId.value = "";
+ elements.shortcutName.value = "";
+ elements.shortcutUrl.value = "";
+ elements.shortcutType.value = state.activeType;
+ }
+
+ function populateShortcutForm(shortcut) {
+ elements.formTitle.textContent = "Edit shortcut";
+ elements.editingId.value = shortcut.id;
+ elements.shortcutName.value = shortcut.name;
+ elements.shortcutUrl.value = shortcut.url;
+ elements.shortcutType.value = shortcut.type;
+ }
+
+ function createButton(label, title, handler) {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.textContent = label;
+ button.setAttribute("title", title);
+ button.addEventListener("click", handler);
+ return button;
+ }
+
+ function renderShortcut(shortcut) {
+ const card = document.createElement("article");
+ card.className = "shortcut-card";
+
+ const content = document.createElement("div");
+ const name = document.createElement("div");
+ name.className = "shortcut-name";
+ name.textContent = shortcut.name;
+
+ const url = document.createElement("div");
+ url.className = "shortcut-url";
+ url.textContent = compactDiscordUrl(shortcut.url) || shortcut.url;
+
+ content.append(name, url);
+
+ const actions = document.createElement("div");
+ actions.className = "shortcut-actions";
+ actions.append(
+ createButton("O", "Open", () => runAction(async () => {
+ await sendMessage(MESSAGE_TYPES.OPEN_SHORTCUT, { id: shortcut.id });
+ await refresh();
+ })),
+ createButton("E", "Edit", () => {
+ clearFeedback();
+ populateShortcutForm(shortcut);
+ }),
+ createButton("D", "Delete", () => runAction(async () => {
+ await sendMessage(MESSAGE_TYPES.DELETE_SHORTCUT, { id: shortcut.id });
+ await refresh();
+ }))
+ );
+
+ card.append(content, actions);
+ return card;
+ }
+
+ function renderEmpty() {
+ const empty = document.createElement("div");
+ empty.className = "empty";
+ empty.textContent = "No shortcuts";
+ return empty;
+ }
+
+ function render() {
+ const model = buildPopupModel({
+ shortcuts: state.shortcuts,
+ query: state.query,
+ activeType: state.activeType,
+ windowState: state.windowState
+ });
+
+ setText(elements.windowMeta, `${model.status} | ${model.boundsLabel} | ${model.zoomLabel}`);
+ setText(elements.textCount, model.textCount);
+ setText(elements.voiceCount, model.voiceCount);
+ elements.textTab.classList.toggle("active", model.activeType === SHORTCUT_TYPES.TEXT);
+ elements.voiceTab.classList.toggle("active", model.activeType === SHORTCUT_TYPES.VOICE);
+ const shortcutNodes = model.activeShortcuts.map(renderShortcut);
+ elements.shortcutList.replaceChildren(...(shortcutNodes.length ? shortcutNodes : [renderEmpty()]));
+ }
+
+ function renderSettingsInputs() {
+ const bounds = state.windowState.bounds;
+ elements.widthInput.value = String(bounds.width);
+ elements.heightInput.value = String(bounds.height);
+ elements.zoomInput.value = String(toZoomPercent(state.windowState.zoom));
+ }
+
+ async function refresh() {
+ const data = await sendMessage(MESSAGE_TYPES.GET_STATE);
+ state.shortcuts = Array.isArray(data?.shortcuts) ? data.shortcuts : [];
+ state.windowState = getSafeWindowState(data?.windowState);
+ renderSettingsInputs();
+ render();
+ }
+
+ async function runAction(action) {
+ try {
+ clearFeedback();
+ await action();
+ } catch (error) {
+ showError(error);
+ }
+ }
+
+ function bindEvents() {
+ elements.searchInput.addEventListener("input", () => {
+ state.query = elements.searchInput.value;
+ render();
+ });
+ elements.textTab.addEventListener("click", () => setActiveType(SHORTCUT_TYPES.TEXT));
+ elements.voiceTab.addEventListener("click", () => setActiveType(SHORTCUT_TYPES.VOICE));
+ elements.cancelEditButton.addEventListener("click", () => {
+ clearFeedback();
+ clearShortcutForm();
+ });
+
+ elements.focusButton.addEventListener("click", () => runAction(async () => {
+ await sendMessage(MESSAGE_TYPES.FOCUS_WINDOW);
+ await refresh();
+ }));
+ elements.closeButton.addEventListener("click", () => runAction(async () => {
+ await sendMessage(MESSAGE_TYPES.CLOSE_WINDOW);
+ await refresh();
+ }));
+ elements.resetButton.addEventListener("click", () => runAction(async () => {
+ await sendMessage(MESSAGE_TYPES.RESET_POSITION);
+ await refresh();
+ }));
+
+ elements.saveCurrentButton.addEventListener("click", () => runAction(async () => {
+ const data = await sendMessage(MESSAGE_TYPES.READ_ACTIVE_DISCORD_TAB);
+ clearShortcutForm();
+ elements.shortcutName.value = data?.suggestedName ?? "";
+ elements.shortcutUrl.value = data?.url ?? "";
+ elements.shortcutType.value = state.activeType;
+ }));
+
+ elements.shortcutForm.addEventListener("submit", (event) => runAction(async () => {
+ event.preventDefault();
+ const id = elements.editingId.value.trim();
+ const payload = {
+ name: elements.shortcutName.value.trim(),
+ url: elements.shortcutUrl.value.trim(),
+ type: elements.shortcutType.value === SHORTCUT_TYPES.VOICE
+ ? SHORTCUT_TYPES.VOICE
+ : SHORTCUT_TYPES.TEXT
+ };
+ if (id) {
+ await sendMessage(MESSAGE_TYPES.UPDATE_SHORTCUT, { id, ...payload });
+ } else {
+ await sendMessage(MESSAGE_TYPES.CREATE_SHORTCUT, payload);
+ }
+ state.activeType = payload.type;
+ clearShortcutForm();
+ await refresh();
+ }));
+
+ elements.settingsForm.addEventListener("submit", (event) => runAction(async () => {
+ event.preventDefault();
+ const payload = {
+ bounds: {
+ width: toNumber(elements.widthInput.value, DEFAULT_BOUNDS.width),
+ height: toNumber(elements.heightInput.value, DEFAULT_BOUNDS.height)
+ },
+ zoom: toNumber(elements.zoomInput.value, toZoomPercent(DEFAULT_ZOOM)) / 100
+ };
+ await sendMessage(MESSAGE_TYPES.UPDATE_WINDOW_SETTINGS, payload);
+ await refresh();
+ }));
+ }
+
+ async function init() {
+ bindEvents();
+ clearShortcutForm();
+ await runAction(refresh);
+ }
+
+ return {
+ init,
+ refresh,
+ sendMessage
+ };
+}
+
+if (globalThis.document && globalThis.chrome?.runtime?.sendMessage) {
+ const app = createPopupApp({
+ document: globalThis.document,
+ chromeApi: globalThis.chrome
+ });
+ if (globalThis.document.readyState === "loading") {
+ globalThis.document.addEventListener("DOMContentLoaded", () => {
+ app.init();
+ });
+ } else {
+ app.init();
+ }
+}
diff --git a/discord-mini-tabs-extension/src/popup/view-model.js b/discord-mini-tabs-extension/src/popup/view-model.js
new file mode 100644
index 000000000..507f6c273
--- /dev/null
+++ b/discord-mini-tabs-extension/src/popup/view-model.js
@@ -0,0 +1,30 @@
+import { DEFAULT_BOUNDS, DEFAULT_ZOOM, SHORTCUT_TYPES } from "../shared/constants.js";
+import { filterShortcuts, splitShortcutsByType } from "../shared/shortcuts.js";
+
+export function formatWindowStatus(windowState) {
+ return Number.isInteger(windowState?.windowId) ? "Open" : "Closed";
+}
+
+export function formatZoomPercent(zoom) {
+ return `${Math.round(Number(zoom) * 100)}%`;
+}
+
+export function buildPopupModel({ shortcuts, query, activeType, windowState }) {
+ const filteredShortcuts = filterShortcuts(shortcuts, query);
+ const groupedShortcuts = splitShortcutsByType(filteredShortcuts);
+ const normalizedActiveType =
+ activeType === SHORTCUT_TYPES.VOICE ? SHORTCUT_TYPES.VOICE : SHORTCUT_TYPES.TEXT;
+ const bounds = windowState?.bounds ?? {};
+ const width = bounds.width ?? DEFAULT_BOUNDS.width;
+ const height = bounds.height ?? DEFAULT_BOUNDS.height;
+
+ return {
+ status: formatWindowStatus(windowState),
+ zoomLabel: formatZoomPercent(windowState?.zoom ?? DEFAULT_ZOOM),
+ boundsLabel: `${width} x ${height}`,
+ activeType: normalizedActiveType,
+ activeShortcuts: groupedShortcuts[normalizedActiveType],
+ textCount: groupedShortcuts.text.length,
+ voiceCount: groupedShortcuts.voice.length
+ };
+}
diff --git a/discord-mini-tabs-extension/src/shared/constants.js b/discord-mini-tabs-extension/src/shared/constants.js
new file mode 100644
index 000000000..3a3e87585
--- /dev/null
+++ b/discord-mini-tabs-extension/src/shared/constants.js
@@ -0,0 +1,43 @@
+export const STORAGE_KEYS = {
+ SHORTCUTS: "shortcuts",
+ WINDOW_STATE: "windowState"
+};
+
+export const SHORTCUT_TYPES = {
+ TEXT: "text",
+ VOICE: "voice"
+};
+
+export const DEFAULT_BOUNDS = {
+ left: null,
+ top: null,
+ width: 420,
+ height: 900
+};
+
+export const BOUNDS_LIMITS = {
+ minWidth: 320,
+ minHeight: 480,
+ maxWidth: 1200,
+ maxHeight: 1400
+};
+
+export const DEFAULT_ZOOM = 0.9;
+export const MIN_ZOOM = 0.67;
+export const MAX_ZOOM = 1.25;
+
+export const DISCORD_HOST = "discord.com";
+export const DISCORD_CHANNEL_PREFIX = "/channels/";
+
+export const MESSAGE_TYPES = {
+ GET_STATE: "GET_STATE",
+ CREATE_SHORTCUT: "CREATE_SHORTCUT",
+ UPDATE_SHORTCUT: "UPDATE_SHORTCUT",
+ DELETE_SHORTCUT: "DELETE_SHORTCUT",
+ OPEN_SHORTCUT: "OPEN_SHORTCUT",
+ READ_ACTIVE_DISCORD_TAB: "READ_ACTIVE_DISCORD_TAB",
+ UPDATE_WINDOW_SETTINGS: "UPDATE_WINDOW_SETTINGS",
+ FOCUS_WINDOW: "FOCUS_WINDOW",
+ CLOSE_WINDOW: "CLOSE_WINDOW",
+ RESET_POSITION: "RESET_POSITION"
+};
diff --git a/discord-mini-tabs-extension/src/shared/settings.js b/discord-mini-tabs-extension/src/shared/settings.js
new file mode 100644
index 000000000..4ec7c98e0
--- /dev/null
+++ b/discord-mini-tabs-extension/src/shared/settings.js
@@ -0,0 +1,84 @@
+import {
+ BOUNDS_LIMITS,
+ DEFAULT_BOUNDS,
+ DEFAULT_ZOOM,
+ MAX_ZOOM,
+ MIN_ZOOM
+} from "./constants.js";
+
+function clampNumber(value, min, max, fallback) {
+ const number = Number(value);
+ if (!Number.isFinite(number)) {
+ return fallback;
+ }
+ return Math.min(max, Math.max(min, Math.round(number)));
+}
+
+function normalizeNullableCoordinate(value) {
+ if (value === null || value === undefined) {
+ return null;
+ }
+
+ const number = Number(value);
+ return Number.isFinite(number) ? Math.round(number) : null;
+}
+
+function normalizeObject(value) {
+ return value && typeof value === "object" ? value : {};
+}
+
+export function clampBounds(bounds = {}) {
+ const source = normalizeObject(bounds);
+
+ return {
+ left: normalizeNullableCoordinate(source.left),
+ top: normalizeNullableCoordinate(source.top),
+ width: clampNumber(
+ source.width,
+ BOUNDS_LIMITS.minWidth,
+ BOUNDS_LIMITS.maxWidth,
+ DEFAULT_BOUNDS.width
+ ),
+ height: clampNumber(
+ source.height,
+ BOUNDS_LIMITS.minHeight,
+ BOUNDS_LIMITS.maxHeight,
+ DEFAULT_BOUNDS.height
+ )
+ };
+}
+
+export function clampZoom(value) {
+ const number = Number(value);
+ if (!Number.isFinite(number)) {
+ return DEFAULT_ZOOM;
+ }
+ return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number(number.toFixed(2))));
+}
+
+export function createDefaultWindowState() {
+ return {
+ windowId: null,
+ tabId: null,
+ bounds: { ...DEFAULT_BOUNDS },
+ zoom: DEFAULT_ZOOM,
+ lastShortcutId: null
+ };
+}
+
+export function normalizeWindowState(input = {}) {
+ const defaults = createDefaultWindowState();
+ const source = normalizeObject(input);
+ const bounds = normalizeObject(source.bounds);
+
+ return {
+ windowId: Number.isInteger(source.windowId) ? source.windowId : null,
+ tabId: Number.isInteger(source.tabId) ? source.tabId : null,
+ bounds: clampBounds({ ...defaults.bounds, ...bounds }),
+ zoom: clampZoom(source.zoom ?? defaults.zoom),
+ lastShortcutId:
+ typeof source.lastShortcutId === "string" && source.lastShortcutId.length > 0
+ ? source.lastShortcutId
+ : null
+ };
+}
diff --git a/discord-mini-tabs-extension/src/shared/shortcuts.js b/discord-mini-tabs-extension/src/shared/shortcuts.js
new file mode 100644
index 000000000..95da41157
--- /dev/null
+++ b/discord-mini-tabs-extension/src/shared/shortcuts.js
@@ -0,0 +1,105 @@
+import { SHORTCUT_TYPES } from "./constants.js";
+import { normalizeDiscordChannelUrl, validateDiscordChannelUrl } from "./url.js";
+
+const VALID_TYPES = new Set([SHORTCUT_TYPES.TEXT, SHORTCUT_TYPES.VOICE]);
+
+function defaultIdFactory() {
+ if (globalThis.crypto?.randomUUID) {
+ return globalThis.crypto.randomUUID();
+ }
+ return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+}
+
+function defaultNow() {
+ return new Date().toISOString();
+}
+
+function normalizeName(name) {
+ const value = String(name ?? "").trim();
+ if (value.length === 0) {
+ throw new Error("Shortcut name is required.");
+ }
+ return value;
+}
+
+function normalizeType(type) {
+ if (!VALID_TYPES.has(type)) {
+ throw new Error("Shortcut type must be text or voice.");
+ }
+ return type;
+}
+
+function isShortcutLike(shortcut) {
+ return (
+ shortcut !== null &&
+ typeof shortcut === "object" &&
+ typeof shortcut.id === "string" &&
+ typeof shortcut.name === "string" &&
+ typeof shortcut.type === "string" &&
+ typeof shortcut.url === "string" &&
+ VALID_TYPES.has(shortcut.type) &&
+ validateDiscordChannelUrl(shortcut.url).ok
+ );
+}
+
+export function normalizeShortcutList(shortcuts) {
+ if (!Array.isArray(shortcuts)) {
+ return [];
+ }
+ return shortcuts.filter(isShortcutLike);
+}
+
+export function createShortcut(input, options = {}) {
+ const idFactory = options.idFactory ?? defaultIdFactory;
+ const now = options.now ?? defaultNow;
+ const timestamp = now();
+
+ return {
+ id: idFactory(),
+ name: normalizeName(input.name),
+ type: normalizeType(input.type),
+ url: normalizeDiscordChannelUrl(input.url),
+ createdAt: timestamp,
+ updatedAt: timestamp
+ };
+}
+
+export function updateShortcut(existing, input) {
+ const now = input.now ?? defaultNow;
+ return {
+ ...existing,
+ name: normalizeName(input.name ?? existing.name),
+ type: normalizeType(input.type ?? existing.type),
+ url: normalizeDiscordChannelUrl(input.url ?? existing.url),
+ updatedAt: now()
+ };
+}
+
+export function deleteShortcut(shortcuts, id) {
+ return normalizeShortcutList(shortcuts).filter((shortcut) => shortcut.id !== id);
+}
+
+export function findShortcut(shortcuts, id) {
+ return normalizeShortcutList(shortcuts).find((shortcut) => shortcut.id === id) ?? null;
+}
+
+export function filterShortcuts(shortcuts, query) {
+ const normalizedShortcuts = normalizeShortcutList(shortcuts);
+ const normalizedQuery = String(query ?? "").trim().toLowerCase();
+ if (!normalizedQuery) {
+ return normalizedShortcuts;
+ }
+
+ return normalizedShortcuts.filter((shortcut) => {
+ const haystack = `${shortcut.name} ${shortcut.url}`.toLowerCase();
+ return haystack.includes(normalizedQuery);
+ });
+}
+
+export function splitShortcutsByType(shortcuts) {
+ const normalizedShortcuts = normalizeShortcutList(shortcuts);
+ return {
+ text: normalizedShortcuts.filter((shortcut) => shortcut.type === SHORTCUT_TYPES.TEXT),
+ voice: normalizedShortcuts.filter((shortcut) => shortcut.type === SHORTCUT_TYPES.VOICE)
+ };
+}
diff --git a/discord-mini-tabs-extension/src/shared/url.js b/discord-mini-tabs-extension/src/shared/url.js
new file mode 100644
index 000000000..777d90518
--- /dev/null
+++ b/discord-mini-tabs-extension/src/shared/url.js
@@ -0,0 +1,81 @@
+import { DISCORD_CHANNEL_PREFIX, DISCORD_HOST } from "./constants.js";
+
+function parseUrl(input) {
+ if (typeof input !== "string" || input.trim().length === 0) {
+ return null;
+ }
+
+ try {
+ return new URL(input.trim());
+ } catch {
+ return null;
+ }
+}
+
+function isSnowflake(value) {
+ return /^\d+$/.test(value);
+}
+
+function getChannelParts(url) {
+ const parts = url.pathname.split("/").filter(Boolean);
+ if (parts.length !== 3 || parts[0] !== "channels") {
+ return null;
+ }
+
+ const [, guildOrMe, channelId] = parts;
+ if (!guildOrMe || !channelId) {
+ return null;
+ }
+
+ if (!isSnowflake(channelId) || (guildOrMe !== "@me" && !isSnowflake(guildOrMe))) {
+ return null;
+ }
+
+ return { guildOrMe, channelId };
+}
+
+export function validateDiscordChannelUrl(input) {
+ const url = parseUrl(input);
+ if (!url) {
+ return { ok: false, error: "Enter a valid Discord channel URL." };
+ }
+
+ if (url.protocol !== "https:" || url.hostname !== DISCORD_HOST) {
+ return { ok: false, error: "Only https://discord.com channel URLs are supported." };
+ }
+
+ if (!url.pathname.startsWith(DISCORD_CHANNEL_PREFIX)) {
+ return { ok: false, error: "The URL must point to discord.com/channels/..." };
+ }
+
+ const parts = getChannelParts(url);
+ if (!parts) {
+ return { ok: false, error: "The Discord URL must include a server or DM id and channel id." };
+ }
+
+ const normalized = `https://${DISCORD_HOST}/channels/${parts.guildOrMe}/${parts.channelId}`;
+ return {
+ ok: true,
+ url: normalized,
+ guildOrMe: parts.guildOrMe,
+ channelId: parts.channelId,
+ scope: parts.guildOrMe === "@me" ? "dm" : "server"
+ };
+}
+
+export function normalizeDiscordChannelUrl(input) {
+ const result = validateDiscordChannelUrl(input);
+ if (!result.ok) {
+ throw new Error(result.error);
+ }
+ return result.url;
+}
+
+export function compactDiscordUrl(input) {
+ const result = validateDiscordChannelUrl(input);
+ if (!result.ok) {
+ return "";
+ }
+ const guildLabel = result.guildOrMe === "@me" ? "DM" : result.guildOrMe;
+ return `${guildLabel} / ${result.channelId}`;
+}
diff --git a/discord-mini-tabs-extension/test/popup-controller.test.js b/discord-mini-tabs-extension/test/popup-controller.test.js
new file mode 100644
index 000000000..20f7d6b66
--- /dev/null
+++ b/discord-mini-tabs-extension/test/popup-controller.test.js
@@ -0,0 +1,373 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import { createPopupApp } from "../src/popup/popup.js";
+import { MESSAGE_TYPES, SHORTCUT_TYPES } from "../src/shared/constants.js";
+
+const textUrl = "https://discord.com/channels/123456789012345678/987654321098765432";
+const voiceUrl = "https://discord.com/channels/223456789012345678/887654321098765432";
+
+class FakeClassList {
+ constructor() {
+ this.values = new Set();
+ }
+
+ add(value) {
+ this.values.add(value);
+ }
+
+ remove(value) {
+ this.values.delete(value);
+ }
+
+ toggle(value, force) {
+ if (force) {
+ this.add(value);
+ } else {
+ this.remove(value);
+ }
+ }
+
+ contains(value) {
+ return this.values.has(value);
+ }
+}
+
+class FakeElement {
+ constructor(tagName = "div") {
+ this.tagName = tagName.toUpperCase();
+ this.children = [];
+ this.listeners = new Map();
+ this.classList = new FakeClassList();
+ this.dataset = {};
+ this.attributes = new Map();
+ this.hidden = false;
+ this.value = "";
+ this.type = "";
+ this.id = "";
+ this._textContent = "";
+ }
+
+ get textContent() {
+ return `${this._textContent}${this.children.map((child) => child.textContent).join("")}`;
+ }
+
+ set textContent(value) {
+ this._textContent = String(value ?? "");
+ this.children = [];
+ }
+
+ append(...children) {
+ this.children.push(...children);
+ }
+
+ replaceChildren(...children) {
+ this._textContent = "";
+ this.children = children;
+ }
+
+ setAttribute(name, value) {
+ this.attributes.set(name, String(value));
+ }
+
+ addEventListener(type, handler) {
+ const handlers = this.listeners.get(type) ?? [];
+ handlers.push(handler);
+ this.listeners.set(type, handlers);
+ }
+
+ async dispatchEvent(type) {
+ const event = { preventDefault() {}, target: this };
+ const handlers = this.listeners.get(type) ?? [];
+ await Promise.all(handlers.map((handler) => handler(event)));
+ }
+
+ findByText(text) {
+ if (this._textContent === text) {
+ return this;
+ }
+ for (const child of this.children) {
+ const match = child.findByText(text);
+ if (match) {
+ return match;
+ }
+ }
+ return null;
+ }
+}
+
+class FakeDocument {
+ constructor(ids) {
+ this.elements = new Map(ids.map((id) => [id, new FakeElement()]));
+ for (const [id, element] of this.elements) {
+ element.id = id;
+ }
+ }
+
+ getElementById(id) {
+ return this.elements.get(id) ?? null;
+ }
+
+ createElement(tagName) {
+ return new FakeElement(tagName);
+ }
+}
+
+function createPopupDocument() {
+ return new FakeDocument([
+ "windowMeta",
+ "saveCurrentButton",
+ "feedback",
+ "focusButton",
+ "closeButton",
+ "resetButton",
+ "searchInput",
+ "textTab",
+ "voiceTab",
+ "textCount",
+ "voiceCount",
+ "shortcutList",
+ "shortcutForm",
+ "formTitle",
+ "editingId",
+ "shortcutName",
+ "shortcutUrl",
+ "shortcutType",
+ "cancelEditButton",
+ "settingsForm",
+ "widthInput",
+ "heightInput",
+ "zoomInput"
+ ]);
+}
+
+function createChromeApi(respond) {
+ const messages = [];
+ return {
+ messages,
+ runtime: {
+ async sendMessage(message) {
+ messages.push(message);
+ return respond(message);
+ }
+ }
+ };
+}
+
+test("popup markup exposes resetButton id contract", async () => {
+ const html = await readFile(new URL("../src/popup/popup.html", import.meta.url), "utf8");
+
+ assert.match(html, /id="resetButton"/);
+ assert.match(html, /id="formTitle"/);
+ assert.doesNotMatch(html, /resetPositionButton/);
+});
+
+test("initializes from service worker state and renders popup controls", async () => {
+ const document = createPopupDocument();
+ const chromeApi = createChromeApi(async () => ({
+ ok: true,
+ data: {
+ shortcuts: [{ id: "text-1", name: "Dev Chat", type: SHORTCUT_TYPES.TEXT, url: textUrl }],
+ windowState: {
+ windowId: 12,
+ bounds: { width: 420, height: 900 },
+ zoom: 0.9
+ }
+ }
+ }));
+
+ const app = createPopupApp({ document, chromeApi });
+ await app.init();
+
+ assert.deepEqual(chromeApi.messages, [{ type: MESSAGE_TYPES.GET_STATE, payload: {} }]);
+ assert.equal(document.getElementById("windowMeta").textContent, "Open | 420 x 900 | 90%");
+ assert.equal(document.getElementById("textCount").textContent, "1");
+ assert.equal(document.getElementById("voiceCount").textContent, "0");
+ assert.equal(document.getElementById("formTitle").textContent, "Add shortcut");
+ assert.equal(document.getElementById("widthInput").value, "420");
+ assert.equal(document.getElementById("heightInput").value, "900");
+ assert.equal(document.getElementById("zoomInput").value, "90");
+ assert.match(document.getElementById("shortcutList").textContent, /Dev Chat/);
+ assert.match(document.getElementById("shortcutList").textContent, /123456789012345678 \/ 987654321098765432/);
+});
+
+test("submits exponent-style window settings as bounds and decimal zoom then refreshes", async () => {
+ const document = createPopupDocument();
+ const chromeApi = createChromeApi(async (message) => {
+ if (message.type === MESSAGE_TYPES.UPDATE_WINDOW_SETTINGS) {
+ return { ok: true, data: null };
+ }
+ return {
+ ok: true,
+ data: {
+ shortcuts: [],
+ windowState: {
+ windowId: null,
+ bounds: { width: 420, height: 900 },
+ zoom: 0.9
+ }
+ }
+ };
+ });
+
+ const app = createPopupApp({ document, chromeApi });
+ await app.init();
+
+ document.getElementById("widthInput").value = "5e2";
+ document.getElementById("heightInput").value = "8e2";
+ document.getElementById("zoomInput").value = "1e2";
+ await document.getElementById("settingsForm").dispatchEvent("submit");
+
+ assert.deepEqual(chromeApi.messages, [
+ { type: MESSAGE_TYPES.GET_STATE, payload: {} },
+ {
+ type: MESSAGE_TYPES.UPDATE_WINDOW_SETTINGS,
+ payload: { bounds: { width: 500, height: 800 }, zoom: 1 }
+ },
+ { type: MESSAGE_TYPES.GET_STATE, payload: {} }
+ ]);
+});
+
+test("switching tabs while editing does not change the edited shortcut type", async () => {
+ const document = createPopupDocument();
+ const chromeApi = createChromeApi(async (message) => {
+ if (message.type === MESSAGE_TYPES.UPDATE_SHORTCUT) {
+ return { ok: true, data: null };
+ }
+ return {
+ ok: true,
+ data: {
+ shortcuts: [
+ { id: "voice-1", name: "Voice Room", type: SHORTCUT_TYPES.VOICE, url: voiceUrl }
+ ],
+ windowState: {
+ windowId: null,
+ bounds: { width: 420, height: 900 },
+ zoom: 0.9
+ }
+ }
+ };
+ });
+
+ const app = createPopupApp({ document, chromeApi });
+ await app.init();
+
+ document.getElementById("voiceTab").dispatchEvent("click");
+ document.getElementById("editingId").value = "voice-1";
+ document.getElementById("shortcutName").value = "Voice Room";
+ document.getElementById("shortcutUrl").value = voiceUrl;
+ document.getElementById("shortcutType").value = SHORTCUT_TYPES.VOICE;
+
+ await document.getElementById("textTab").dispatchEvent("click");
+ await document.getElementById("shortcutForm").dispatchEvent("submit");
+
+ assert.deepEqual(chromeApi.messages[1], {
+ type: MESSAGE_TYPES.UPDATE_SHORTCUT,
+ payload: {
+ id: "voice-1",
+ name: "Voice Room",
+ url: voiceUrl,
+ type: SHORTCUT_TYPES.VOICE
+ }
+ });
+});
+
+test("edit action updates form title and cancel restores add title", async () => {
+ const document = createPopupDocument();
+ const chromeApi = createChromeApi(async () => ({
+ ok: true,
+ data: {
+ shortcuts: [{ id: "text-1", name: "Dev Chat", type: SHORTCUT_TYPES.TEXT, url: textUrl }],
+ windowState: {
+ windowId: null,
+ bounds: { width: 420, height: 900 },
+ zoom: 0.9
+ }
+ }
+ }));
+
+ const app = createPopupApp({ document, chromeApi });
+ await app.init();
+
+ const editButton = document.getElementById("shortcutList").findByText("E");
+ assert.ok(editButton);
+ await editButton.dispatchEvent("click");
+
+ assert.equal(document.getElementById("formTitle").textContent, "Edit shortcut");
+ assert.equal(document.getElementById("editingId").value, "text-1");
+
+ await document.getElementById("cancelEditButton").dispatchEvent("click");
+
+ assert.equal(document.getElementById("formTitle").textContent, "Add shortcut");
+ assert.equal(document.getElementById("editingId").value, "");
+});
+
+test("save current resets edit mode title before populating active tab", async () => {
+ const activeUrl = "https://discord.com/channels/323456789012345678/787654321098765432";
+ const document = createPopupDocument();
+ const chromeApi = createChromeApi(async (message) => {
+ if (message.type === MESSAGE_TYPES.READ_ACTIVE_DISCORD_TAB) {
+ return {
+ ok: true,
+ data: {
+ suggestedName: "Active Channel",
+ url: activeUrl
+ }
+ };
+ }
+ return {
+ ok: true,
+ data: {
+ shortcuts: [{ id: "text-1", name: "Dev Chat", type: SHORTCUT_TYPES.TEXT, url: textUrl }],
+ windowState: {
+ windowId: null,
+ bounds: { width: 420, height: 900 },
+ zoom: 0.9
+ }
+ }
+ };
+ });
+
+ const app = createPopupApp({ document, chromeApi });
+ await app.init();
+
+ const editButton = document.getElementById("shortcutList").findByText("E");
+ assert.ok(editButton);
+ await editButton.dispatchEvent("click");
+ assert.equal(document.getElementById("formTitle").textContent, "Edit shortcut");
+
+ await document.getElementById("saveCurrentButton").dispatchEvent("click");
+
+ assert.equal(document.getElementById("editingId").value, "");
+ assert.equal(document.getElementById("shortcutName").value, "Active Channel");
+ assert.equal(document.getElementById("shortcutUrl").value, activeUrl);
+ assert.equal(document.getElementById("formTitle").textContent, "Add shortcut");
+});
+
+test("search render does not overwrite unsaved settings values", async () => {
+ const document = createPopupDocument();
+ const chromeApi = createChromeApi(async () => ({
+ ok: true,
+ data: {
+ shortcuts: [{ id: "text-1", name: "Dev Chat", type: SHORTCUT_TYPES.TEXT, url: textUrl }],
+ windowState: {
+ windowId: 12,
+ bounds: { width: 420, height: 900 },
+ zoom: 0.9
+ }
+ }
+ }));
+
+ const app = createPopupApp({ document, chromeApi });
+ await app.init();
+
+ document.getElementById("widthInput").value = "777";
+ document.getElementById("heightInput").value = "888";
+ document.getElementById("zoomInput").value = "101";
+ document.getElementById("searchInput").value = "dev";
+ await document.getElementById("searchInput").dispatchEvent("input");
+
+ assert.equal(document.getElementById("widthInput").value, "777");
+ assert.equal(document.getElementById("heightInput").value, "888");
+ assert.equal(document.getElementById("zoomInput").value, "101");
+});
diff --git a/discord-mini-tabs-extension/test/service-worker-core.test.js b/discord-mini-tabs-extension/test/service-worker-core.test.js
new file mode 100644
index 000000000..cbe07fb9d
--- /dev/null
+++ b/discord-mini-tabs-extension/test/service-worker-core.test.js
@@ -0,0 +1,401 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { createServiceWorkerController } from "../src/background/service-worker-core.js";
+import { getExtensionState, setShortcuts, setWindowState } from "../src/background/storage.js";
+import { saveBoundsFromWindow } from "../src/background/window-manager.js";
+import { MESSAGE_TYPES } from "../src/shared/constants.js";
+import { createShortcut, deleteShortcut, findShortcut, updateShortcut } from "../src/shared/shortcuts.js";
+import { validateDiscordChannelUrl } from "../src/shared/url.js";
+
+const firstUrl = "https://discord.com/channels/123456789012345678/987654321098765432";
+const secondUrl = "https://discord.com/channels/223456789012345678/887654321098765432";
+
+function createFakeStorage(initial = {}) {
+ const data = { ...initial };
+ return {
+ data,
+ async get(defaults) {
+ return { ...defaults, ...data };
+ },
+ async set(values) {
+ Object.assign(data, values);
+ }
+ };
+}
+
+function createDeferred() {
+ let resolve;
+ const promise = new Promise((promiseResolve) => {
+ resolve = promiseResolve;
+ });
+ return { promise, resolve };
+}
+
+async function flushMicrotasks() {
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+function createController(storage, overrides = {}) {
+ return createServiceWorkerController({
+ chromeApi: {
+ tabs: {
+ async query() {
+ return [];
+ }
+ }
+ },
+ getExtensionState: () => getExtensionState(storage),
+ setShortcuts: (shortcuts) => setShortcuts(shortcuts, storage),
+ setWindowState: (windowState) => setWindowState(windowState, storage),
+ createShortcut,
+ deleteShortcut,
+ findShortcut,
+ updateShortcut,
+ validateDiscordChannelUrl,
+ openShortcutInMiniWindow: async ({ shortcut }) => ({ lastShortcutId: shortcut.id }),
+ focusMiniWindow: async () => 1,
+ closeMiniWindow: async () => ({}),
+ resetMiniWindowPosition: async () => ({}),
+ updateMiniWindowSettings: async ({ bounds, zoom }) => ({ bounds, zoom }),
+ saveBoundsFromWindow: (window, options) =>
+ saveBoundsFromWindow(window, { storageArea: storage, ...options }),
+ ...overrides
+ });
+}
+
+test("serializes concurrent shortcut creates and preserves both shortcuts", async () => {
+ const storage = createFakeStorage();
+ const controller = createController(storage);
+
+ await Promise.all([
+ controller.handleMessage({
+ type: MESSAGE_TYPES.CREATE_SHORTCUT,
+ payload: { name: "First", type: "text", url: firstUrl }
+ }),
+ controller.handleMessage({
+ type: MESSAGE_TYPES.CREATE_SHORTCUT,
+ payload: { name: "Second", type: "text", url: secondUrl }
+ })
+ ]);
+
+ assert.equal(storage.data.shortcuts.length, 2);
+ assert.deepEqual(
+ storage.data.shortcuts.map((shortcut) => shortcut.name).sort(),
+ ["First", "Second"]
+ );
+});
+
+test("ignores unrelated window bounds events without cancelling a pending mini save", async () => {
+ const storage = createFakeStorage({
+ windowState: {
+ windowId: 1,
+ tabId: 10,
+ bounds: { left: 1, top: 2, width: 420, height: 900 },
+ zoom: 0.9,
+ lastShortcutId: null
+ }
+ });
+ const scheduled = [];
+ let nextTimerId = 1;
+ const controller = createController(storage, {
+ setTimeoutFn(callback) {
+ const timer = { id: nextTimerId++, callback, cancelled: false };
+ scheduled.push(timer);
+ return timer.id;
+ },
+ clearTimeoutFn(timerId) {
+ const timer = scheduled.find((item) => item.id === timerId);
+ if (timer) {
+ timer.cancelled = true;
+ }
+ },
+ debounceMs: 400
+ });
+
+ await controller.handleBoundsChanged({
+ id: 1,
+ type: "popup",
+ left: 50,
+ top: 60,
+ width: 640,
+ height: 820
+ });
+ await controller.handleBoundsChanged({
+ id: 2,
+ type: "normal",
+ left: 500,
+ top: 600,
+ width: 700,
+ height: 800
+ });
+
+ await Promise.all(scheduled.filter((timer) => !timer.cancelled).map((timer) => timer.callback()));
+
+ assert.deepEqual(storage.data.windowState.bounds, {
+ left: 50,
+ top: 60,
+ width: 640,
+ height: 820
+ });
+});
+
+test("preserves mini bounds event order when storage reads resolve out of order", async () => {
+ const storage = createFakeStorage({
+ windowState: {
+ windowId: 1,
+ tabId: 10,
+ bounds: { left: 1, top: 2, width: 420, height: 900 },
+ zoom: 0.9,
+ lastShortcutId: null
+ }
+ });
+ const scheduled = [];
+ const readDeferrals = [];
+ let nextTimerId = 1;
+ let deferReads = true;
+ const readState = () => ({
+ shortcuts: storage.data.shortcuts ?? [],
+ windowState: storage.data.windowState
+ });
+ const controller = createController(storage, {
+ getExtensionState() {
+ if (!deferReads) {
+ return Promise.resolve(readState());
+ }
+
+ const deferred = createDeferred();
+ readDeferrals.push(deferred);
+ return deferred.promise;
+ },
+ setTimeoutFn(callback) {
+ const timer = { id: nextTimerId++, callback, cancelled: false };
+ scheduled.push(timer);
+ return timer.id;
+ },
+ clearTimeoutFn(timerId) {
+ const timer = scheduled.find((item) => item.id === timerId);
+ if (timer) {
+ timer.cancelled = true;
+ }
+ },
+ debounceMs: 400
+ });
+
+ const firstEvent = controller.handleBoundsChanged({
+ id: 1,
+ type: "popup",
+ left: 10,
+ top: 60,
+ width: 640,
+ height: 820
+ });
+ const secondEvent = controller.handleBoundsChanged({
+ id: 1,
+ type: "popup",
+ left: 20,
+ top: 70,
+ width: 650,
+ height: 830
+ });
+ await Promise.resolve();
+ deferReads = false;
+
+ if (readDeferrals.length >= 2) {
+ readDeferrals[1].resolve(readState());
+ await secondEvent;
+ readDeferrals[0].resolve(readState());
+ await firstEvent;
+ } else {
+ await Promise.all([firstEvent, secondEvent]);
+ }
+
+ await Promise.all(scheduled.filter((timer) => !timer.cancelled).map((timer) => timer.callback()));
+
+ assert.equal(storage.data.windowState.bounds.left, 20);
+});
+
+test("unrelated popup bounds events do not cancel pending mini save", async () => {
+ const storage = createFakeStorage({
+ windowState: {
+ windowId: 1,
+ tabId: 10,
+ bounds: { left: 1, top: 2, width: 420, height: 900 },
+ zoom: 0.9,
+ lastShortcutId: "s1"
+ }
+ });
+ const scheduled = [];
+ let nextTimerId = 1;
+ const controller = createController(storage, {
+ setTimeoutFn(callback) {
+ const timer = { id: nextTimerId++, callback, cancelled: false };
+ scheduled.push(timer);
+ return timer.id;
+ },
+ clearTimeoutFn(timerId) {
+ const timer = scheduled.find((item) => item.id === timerId);
+ if (timer) {
+ timer.cancelled = true;
+ }
+ },
+ debounceMs: 400
+ });
+
+ await controller.handleBoundsChanged({
+ id: 1,
+ type: "popup",
+ left: 20,
+ top: 30,
+ width: 500,
+ height: 700
+ });
+ await controller.handleBoundsChanged({
+ id: 2,
+ type: "popup",
+ left: 99,
+ top: 99,
+ width: 600,
+ height: 800
+ });
+
+ await Promise.all(scheduled.filter((timer) => !timer.cancelled).map((timer) => timer.callback()));
+
+ assert.equal(storage.data.windowState.bounds.left, 20);
+ assert.equal(storage.data.windowState.bounds.top, 30);
+});
+
+test("ignores stale in-flight bounds callbacks for the same window", async () => {
+ const storage = createFakeStorage({
+ windowState: {
+ windowId: 1,
+ tabId: 10,
+ bounds: { left: 1, top: 2, width: 420, height: 900 },
+ zoom: 0.9,
+ lastShortcutId: "s1"
+ }
+ });
+ const scheduled = [];
+ const readDeferrals = [];
+ let nextTimerId = 1;
+ const readState = () => ({
+ shortcuts: storage.data.shortcuts ?? [],
+ windowState: storage.data.windowState
+ });
+ const controller = createController(storage, {
+ getExtensionState() {
+ const deferred = createDeferred();
+ readDeferrals.push(deferred);
+ return deferred.promise;
+ },
+ setTimeoutFn(callback) {
+ const timer = { id: nextTimerId++, callback, cancelled: false };
+ scheduled.push(timer);
+ return timer.id;
+ },
+ clearTimeoutFn(timerId) {
+ const timer = scheduled.find((item) => item.id === timerId);
+ if (timer) {
+ timer.cancelled = true;
+ }
+ },
+ debounceMs: 400
+ });
+
+ await controller.handleBoundsChanged({
+ id: 1,
+ type: "popup",
+ left: 10,
+ top: 30,
+ width: 500,
+ height: 700
+ });
+ const firstCallback = scheduled.at(-1).callback();
+ await flushMicrotasks();
+
+ await controller.handleBoundsChanged({
+ id: 1,
+ type: "popup",
+ left: 20,
+ top: 40,
+ width: 510,
+ height: 710
+ });
+ const secondCallback = scheduled.at(-1).callback();
+ await flushMicrotasks();
+
+ readDeferrals[1].resolve(readState());
+ await secondCallback;
+ readDeferrals[0].resolve(readState());
+ await firstCallback;
+
+ assert.equal(storage.data.windowState.bounds.left, 20);
+ assert.equal(storage.data.windowState.bounds.top, 40);
+});
+
+test("ignores stale bounds callbacks that are already inside save", async () => {
+ const storage = createFakeStorage({
+ windowState: {
+ windowId: 1,
+ tabId: 10,
+ bounds: { left: 1, top: 2, width: 420, height: 900 },
+ zoom: 0.9,
+ lastShortcutId: "s1"
+ }
+ });
+ const scheduled = [];
+ const saveDeferrals = [];
+ let nextTimerId = 1;
+ const controller = createController(storage, {
+ saveBoundsFromWindow(window, options) {
+ const deferred = createDeferred();
+ saveDeferrals.push({ deferred, window, options });
+ return deferred.promise.then(() =>
+ saveBoundsFromWindow(window, { storageArea: storage, ...options })
+ );
+ },
+ setTimeoutFn(callback) {
+ const timer = { id: nextTimerId++, callback, cancelled: false };
+ scheduled.push(timer);
+ return timer.id;
+ },
+ clearTimeoutFn(timerId) {
+ const timer = scheduled.find((item) => item.id === timerId);
+ if (timer) {
+ timer.cancelled = true;
+ }
+ },
+ debounceMs: 400
+ });
+
+ await controller.handleBoundsChanged({
+ id: 1,
+ type: "popup",
+ left: 10,
+ top: 30,
+ width: 500,
+ height: 700
+ });
+ const firstCallback = scheduled.at(-1).callback();
+ await flushMicrotasks();
+
+ await controller.handleBoundsChanged({
+ id: 1,
+ type: "popup",
+ left: 20,
+ top: 40,
+ width: 510,
+ height: 710
+ });
+ const secondCallback = scheduled.at(-1).callback();
+ await flushMicrotasks();
+
+ saveDeferrals[1].deferred.resolve();
+ await secondCallback;
+ saveDeferrals[0].deferred.resolve();
+ await firstCallback;
+
+ assert.equal(storage.data.windowState.bounds.left, 20);
+ assert.equal(storage.data.windowState.bounds.top, 40);
+});
diff --git a/discord-mini-tabs-extension/test/settings.test.js b/discord-mini-tabs-extension/test/settings.test.js
new file mode 100644
index 000000000..a2d71d295
--- /dev/null
+++ b/discord-mini-tabs-extension/test/settings.test.js
@@ -0,0 +1,82 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ clampBounds,
+ clampZoom,
+ createDefaultWindowState,
+ normalizeWindowState
+} from "../src/shared/settings.js";
+
+test("creates default window state", () => {
+ assert.deepEqual(createDefaultWindowState(), {
+ windowId: null,
+ tabId: null,
+ bounds: {
+ left: null,
+ top: null,
+ width: 420,
+ height: 900
+ },
+ zoom: 0.9,
+ lastShortcutId: null
+ });
+});
+
+test("clamps bounds to supported range", () => {
+ assert.deepEqual(clampBounds({ left: 20, top: 30, width: 100, height: 2000 }), {
+ left: 20,
+ top: 30,
+ width: 320,
+ height: 1400
+ });
+});
+
+test("keeps null left and top when unset", () => {
+ assert.deepEqual(clampBounds({ width: 500, height: 700 }), {
+ left: null,
+ top: null,
+ width: 500,
+ height: 700
+ });
+});
+
+test("clampBounds handles null input", () => {
+ assert.deepEqual(clampBounds(null), {
+ left: null,
+ top: null,
+ width: 420,
+ height: 900
+ });
+});
+
+test("clamps zoom to supported range", () => {
+ assert.equal(clampZoom(0.2), 0.67);
+ assert.equal(clampZoom(2), 1.25);
+ assert.equal(clampZoom(0.9), 0.9);
+});
+
+test("normalizes partial window state", () => {
+ const state = normalizeWindowState({
+ windowId: 10,
+ bounds: { width: 640 },
+ zoom: 1.5,
+ lastShortcutId: "abc"
+ });
+
+ assert.deepEqual(state, {
+ windowId: 10,
+ tabId: null,
+ bounds: {
+ left: null,
+ top: null,
+ width: 640,
+ height: 900
+ },
+ zoom: 1.25,
+ lastShortcutId: "abc"
+ });
+});
+
+test("normalizeWindowState handles null input", () => {
+ assert.deepEqual(normalizeWindowState(null), createDefaultWindowState());
+});
diff --git a/discord-mini-tabs-extension/test/shortcuts.test.js b/discord-mini-tabs-extension/test/shortcuts.test.js
new file mode 100644
index 000000000..50571ab94
--- /dev/null
+++ b/discord-mini-tabs-extension/test/shortcuts.test.js
@@ -0,0 +1,121 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ createShortcut,
+ deleteShortcut,
+ filterShortcuts,
+ findShortcut,
+ splitShortcutsByType,
+ updateShortcut
+} from "../src/shared/shortcuts.js";
+
+const fixedNow = () => "2026-05-21T00:00:00.000Z";
+const fixedId = () => "shortcut-1";
+const textUrl = "https://discord.com/channels/123456789012345678/987654321098765432";
+const voiceUrl = "https://discord.com/channels/223457890234567890/887650987650987650";
+
+test("creates normalized text shortcut", () => {
+ const shortcut = createShortcut(
+ {
+ name: " dev chat ",
+ type: "text",
+ url: `${textUrl}/?jump=999`
+ },
+ { idFactory: fixedId, now: fixedNow }
+ );
+
+ assert.deepEqual(shortcut, {
+ id: "shortcut-1",
+ name: "dev chat",
+ type: "text",
+ url: textUrl,
+ createdAt: "2026-05-21T00:00:00.000Z",
+ updatedAt: "2026-05-21T00:00:00.000Z"
+ });
+});
+
+test("rejects empty shortcut name", () => {
+ assert.throws(
+ () => createShortcut({ name: " ", type: "text", url: textUrl }),
+ /name/
+ );
+});
+
+test("updates shortcut while preserving id and createdAt", () => {
+ const original = createShortcut(
+ { name: "dev chat", type: "text", url: textUrl },
+ { idFactory: fixedId, now: fixedNow }
+ );
+ const updated = updateShortcut(original, {
+ name: "team call",
+ type: "voice",
+ url: voiceUrl,
+ now: () => "2026-05-21T01:00:00.000Z"
+ });
+
+ assert.equal(updated.id, "shortcut-1");
+ assert.equal(updated.createdAt, "2026-05-21T00:00:00.000Z");
+ assert.equal(updated.updatedAt, "2026-05-21T01:00:00.000Z");
+ assert.equal(updated.name, "team call");
+ assert.equal(updated.type, "voice");
+ assert.equal(updated.url, voiceUrl);
+});
+
+test("deletes shortcut by id", () => {
+ const shortcuts = [
+ { id: "a", name: "A", type: "text", url: textUrl },
+ { id: "b", name: "B", type: "voice", url: voiceUrl }
+ ];
+ assert.deepEqual(deleteShortcut(shortcuts, "a").map((item) => item.id), ["b"]);
+});
+
+test("finds shortcuts by id and returns null when missing", () => {
+ const shortcuts = [
+ { id: "a", name: "Dev Chat", type: "text", url: textUrl },
+ { id: "b", name: "Team Call", type: "voice", url: voiceUrl }
+ ];
+
+ assert.equal(findShortcut(shortcuts, "b")?.name, "Team Call");
+ assert.equal(findShortcut(shortcuts, "missing"), null);
+});
+
+test("filters shortcuts by name and url", () => {
+ const shortcuts = [
+ { id: "a", name: "Dev Chat", type: "text", url: textUrl },
+ { id: "b", name: "Team Call", type: "voice", url: voiceUrl }
+ ];
+ assert.deepEqual(filterShortcuts(shortcuts, "team").map((item) => item.id), ["b"]);
+ assert.deepEqual(filterShortcuts(shortcuts, "123456").map((item) => item.id), ["a"]);
+ assert.deepEqual(filterShortcuts(shortcuts, "voice").map((item) => item.id), []);
+});
+
+test("splits shortcuts by type", () => {
+ const result = splitShortcutsByType([
+ { id: "a", name: "Dev Chat", type: "text", url: textUrl },
+ { id: "b", name: "Team Call", type: "voice", url: voiceUrl }
+ ]);
+
+ assert.deepEqual(result.text.map((item) => item.id), ["a"]);
+ assert.deepEqual(result.voice.map((item) => item.id), ["b"]);
+});
+
+test("list helpers tolerate malformed shortcut lists", () => {
+ assert.deepEqual(deleteShortcut(null, "a"), []);
+ assert.equal(findShortcut(null, "a"), null);
+ assert.deepEqual(filterShortcuts(null, "dev"), []);
+ assert.deepEqual(splitShortcutsByType(null), { text: [], voice: [] });
+});
+
+test("list helpers skip malformed shortcut items", () => {
+ const shortcuts = [
+ null,
+ { id: "a", name: "Dev Chat", type: "text", url: textUrl },
+ { id: "b", name: "Team Call", type: "voice", url: voiceUrl },
+ { id: "broken", name: null, type: "text", url: null }
+ ];
+
+ assert.deepEqual(deleteShortcut(shortcuts, "a").map((item) => item.id), ["b"]);
+ assert.equal(findShortcut(shortcuts, "b")?.name, "Team Call");
+ assert.deepEqual(filterShortcuts(shortcuts, "TEAM").map((item) => item.id), ["b"]);
+ assert.deepEqual(splitShortcutsByType(shortcuts).text.map((item) => item.id), ["a"]);
+});
diff --git a/discord-mini-tabs-extension/test/storage.test.js b/discord-mini-tabs-extension/test/storage.test.js
new file mode 100644
index 000000000..b6bc05781
--- /dev/null
+++ b/discord-mini-tabs-extension/test/storage.test.js
@@ -0,0 +1,101 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ getExtensionState,
+ setShortcuts,
+ setWindowState,
+ updateWindowState
+} from "../src/background/storage.js";
+
+const textUrl = "https://discord.com/channels/123456789012345678/987654321098765432";
+const voiceUrl = "https://discord.com/channels/223456789012345678/887654321098765432";
+
+function createFakeStorage(initial = {}) {
+ const data = { ...initial };
+ return {
+ data,
+ async get(defaults) {
+ return { ...defaults, ...data };
+ },
+ async set(values) {
+ Object.assign(data, values);
+ }
+ };
+}
+
+test("returns normalized default state", async () => {
+ const storage = createFakeStorage();
+ const state = await getExtensionState(storage);
+
+ assert.deepEqual(state.shortcuts, []);
+ assert.equal(state.windowState.bounds.width, 420);
+ assert.equal(state.windowState.zoom, 0.9);
+});
+
+test("saves shortcuts", async () => {
+ const storage = createFakeStorage();
+ await setShortcuts([{ id: "a", name: "Dev", type: "text", url: textUrl }], storage);
+ assert.equal(storage.data.shortcuts.length, 1);
+});
+
+test("filters malformed persisted shortcuts on read", async () => {
+ const valid = { id: "a", name: "Dev", type: "text", url: textUrl };
+ const storage = createFakeStorage({
+ shortcuts: [
+ valid,
+ null,
+ { id: "broken", name: null, type: "text", url: textUrl },
+ { id: "bad-type", name: "Bad", type: "bad", url: textUrl },
+ { id: "bad-url", name: "Bad URL", type: "voice", url: null },
+ { id: "b", name: "Voice", type: "voice", url: voiceUrl }
+ ]
+ });
+
+ const state = await getExtensionState(storage);
+
+ assert.deepEqual(state.shortcuts.map((shortcut) => shortcut.id), ["a", "b"]);
+});
+
+test("filters malformed shortcuts on write", async () => {
+ const storage = createFakeStorage();
+ const saved = await setShortcuts(
+ [
+ { id: "a", name: "Dev", type: "text", url: textUrl },
+ null,
+ { id: "broken", name: "Broken", type: "text", url: null }
+ ],
+ storage
+ );
+
+ assert.deepEqual(saved.map((shortcut) => shortcut.id), ["a"]);
+ assert.deepEqual(storage.data.shortcuts.map((shortcut) => shortcut.id), ["a"]);
+});
+
+test("normalizes saved window state", async () => {
+ const storage = createFakeStorage();
+ await setWindowState({ bounds: { width: 100, height: 2000 }, zoom: 2 }, storage);
+ assert.equal(storage.data.windowState.bounds.width, 320);
+ assert.equal(storage.data.windowState.bounds.height, 1400);
+ assert.equal(storage.data.windowState.zoom, 1.25);
+});
+
+test("updates window state from previous value", async () => {
+ const storage = createFakeStorage({
+ windowState: {
+ windowId: 5,
+ tabId: 6,
+ bounds: { left: null, top: null, width: 420, height: 900 },
+ zoom: 0.9,
+ lastShortcutId: null
+ }
+ });
+
+ const updated = await updateWindowState((state) => ({
+ ...state,
+ bounds: { ...state.bounds, width: 640 }
+ }), storage);
+
+ assert.equal(updated.windowId, 5);
+ assert.equal(updated.bounds.width, 640);
+ assert.equal(storage.data.windowState.bounds.width, 640);
+});
diff --git a/discord-mini-tabs-extension/test/url.test.js b/discord-mini-tabs-extension/test/url.test.js
new file mode 100644
index 000000000..6a4b86d25
--- /dev/null
+++ b/discord-mini-tabs-extension/test/url.test.js
@@ -0,0 +1,61 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ compactDiscordUrl,
+ normalizeDiscordChannelUrl,
+ validateDiscordChannelUrl
+} from "../src/shared/url.js";
+
+test("accepts server channel Discord URLs", () => {
+ const result = validateDiscordChannelUrl("https://discord.com/channels/123/456");
+ assert.equal(result.ok, true);
+ assert.equal(result.url, "https://discord.com/channels/123/456");
+ assert.equal(result.scope, "server");
+});
+
+test("accepts direct message Discord URLs", () => {
+ const result = validateDiscordChannelUrl("https://discord.com/channels/@me/456");
+ assert.equal(result.ok, true);
+ assert.equal(result.url, "https://discord.com/channels/@me/456");
+ assert.equal(result.scope, "dm");
+});
+
+test("normalizes trailing slash and search params", () => {
+ assert.equal(
+ normalizeDiscordChannelUrl("https://discord.com/channels/123/456/?jump=999"),
+ "https://discord.com/channels/123/456"
+ );
+});
+
+test("rejects non-discord hosts", () => {
+ const result = validateDiscordChannelUrl("https://example.com/channels/123/456");
+ assert.equal(result.ok, false);
+ assert.match(result.error, /discord\.com/);
+});
+
+test("rejects non-channel Discord URLs", () => {
+ const result = validateDiscordChannelUrl("https://discord.com/app");
+ assert.equal(result.ok, false);
+ assert.match(result.error, /channels/);
+});
+
+test("rejects non-numeric server and channel ids", () => {
+ assert.equal(validateDiscordChannelUrl("https://discord.com/channels/foo/456").ok, false);
+ assert.equal(validateDiscordChannelUrl("https://discord.com/channels/123/bar").ok, false);
+ assert.equal(validateDiscordChannelUrl("https://discord.com/channels/@me/not-a-snowflake").ok, false);
+});
+
+test("rejects non-https discord URLs", () => {
+ const result = validateDiscordChannelUrl("http://discord.com/channels/123/456");
+ assert.equal(result.ok, false);
+});
+
+test("rejects discord host spoofing", () => {
+ const result = validateDiscordChannelUrl("https://discord.com.evil.test/channels/123/456");
+ assert.equal(result.ok, false);
+});
+
+test("returns compact display labels", () => {
+ assert.equal(compactDiscordUrl("https://discord.com/channels/123/456"), "123 / 456");
+ assert.equal(compactDiscordUrl("https://discord.com/channels/@me/456"), "DM / 456");
+});
diff --git a/discord-mini-tabs-extension/test/view-model.test.js b/discord-mini-tabs-extension/test/view-model.test.js
new file mode 100644
index 000000000..df0c2229a
--- /dev/null
+++ b/discord-mini-tabs-extension/test/view-model.test.js
@@ -0,0 +1,77 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ buildPopupModel,
+ formatWindowStatus,
+ formatZoomPercent
+} from "../src/popup/view-model.js";
+
+const textUrl = "https://discord.com/channels/123456789012345678/987654321098765432";
+const voiceUrl = "https://discord.com/channels/223456789012345678/887654321098765432";
+
+const shortcuts = [
+ { id: "a", name: "Dev Chat", type: "text", url: textUrl },
+ { id: "b", name: "Team Call", type: "voice", url: voiceUrl }
+];
+
+test("formats mini window status", () => {
+ assert.equal(formatWindowStatus({ windowId: null }), "Closed");
+ assert.equal(formatWindowStatus({ windowId: 10 }), "Open");
+});
+
+test("formats zoom as rounded percent", () => {
+ assert.equal(formatZoomPercent(0.9), "90%");
+ assert.equal(formatZoomPercent(1), "100%");
+});
+
+test("builds popup model grouped by active text shortcuts", () => {
+ const model = buildPopupModel({
+ shortcuts,
+ query: "",
+ activeType: "text",
+ windowState: {
+ windowId: 1,
+ tabId: 2,
+ bounds: { left: null, top: null, width: 420, height: 900 },
+ zoom: 0.9,
+ lastShortcutId: "a"
+ }
+ });
+
+ assert.equal(model.status, "Open");
+ assert.equal(model.zoomLabel, "90%");
+ assert.equal(model.activeShortcuts.length, 1);
+ assert.equal(model.activeShortcuts[0].id, "a");
+ assert.equal(model.textCount, 1);
+ assert.equal(model.voiceCount, 1);
+});
+
+test("filters popup shortcuts before applying active type", () => {
+ const model = buildPopupModel({
+ shortcuts,
+ query: "team",
+ activeType: "voice",
+ windowState: {
+ windowId: null,
+ zoom: 1,
+ bounds: { width: 420, height: 900 }
+ }
+ });
+
+ assert.equal(model.activeShortcuts.length, 1);
+ assert.equal(model.activeShortcuts[0].id, "b");
+});
+
+test("defaults popup zoom label when window state has no zoom", () => {
+ const model = buildPopupModel({
+ shortcuts,
+ query: "",
+ activeType: "text",
+ windowState: {
+ windowId: null,
+ bounds: { width: 420, height: 900 }
+ }
+ });
+
+ assert.equal(model.zoomLabel, "90%");
+});
diff --git a/discord-mini-tabs-extension/test/window-manager.test.js b/discord-mini-tabs-extension/test/window-manager.test.js
new file mode 100644
index 000000000..cfe856994
--- /dev/null
+++ b/discord-mini-tabs-extension/test/window-manager.test.js
@@ -0,0 +1,413 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ closeMiniWindow,
+ focusMiniWindow,
+ openShortcutInMiniWindow,
+ resetMiniWindowPosition,
+ saveBoundsFromWindow,
+ updateMiniWindowSettings
+} from "../src/background/window-manager.js";
+
+function createFakeStorage(initial = {}) {
+ const data = { ...initial };
+ return {
+ data,
+ async get(defaults) {
+ return { ...defaults, ...data };
+ },
+ async set(values) {
+ Object.assign(data, values);
+ }
+ };
+}
+
+function createFakeChrome({
+ failSetZoom = false,
+ failWindowUpdate = false,
+ tabsUpdateReturnsUndefined = false,
+ windowsCreateReturnsUndefined = false
+} = {}) {
+ const calls = [];
+ const windowsById = new Map();
+ const tabsById = new Map();
+ let nextWindowId = 100;
+ let nextTabId = 200;
+
+ return {
+ calls,
+ windowsById,
+ tabsById,
+ windows: {
+ async create(createData) {
+ calls.push(["windows.create", createData]);
+ if (windowsCreateReturnsUndefined) {
+ return undefined;
+ }
+
+ const windowId = nextWindowId++;
+ const tabId = nextTabId++;
+ const tab = { id: tabId, windowId, url: createData.url };
+ const window = {
+ id: windowId,
+ type: createData.type,
+ focused: true,
+ left: createData.left,
+ top: createData.top,
+ width: createData.width,
+ height: createData.height,
+ tabs: [tab]
+ };
+ windowsById.set(windowId, window);
+ tabsById.set(tabId, tab);
+ return window;
+ },
+ async get(windowId, options) {
+ calls.push(["windows.get", windowId, options]);
+ const window = windowsById.get(windowId);
+ if (!window) throw new Error("No window");
+ return options?.populate ? window : { ...window, tabs: undefined };
+ },
+ async update(windowId, updateInfo) {
+ calls.push(["windows.update", windowId, updateInfo]);
+ if (failWindowUpdate) throw new Error("Window update failed");
+ const window = windowsById.get(windowId);
+ if (!window) throw new Error("No window");
+ Object.assign(window, updateInfo);
+ return window;
+ },
+ async remove(windowId) {
+ calls.push(["windows.remove", windowId]);
+ windowsById.delete(windowId);
+ }
+ },
+ tabs: {
+ async update(tabId, updateInfo) {
+ calls.push(["tabs.update", tabId, updateInfo]);
+ const tab = tabsById.get(tabId);
+ if (!tab) throw new Error("No tab");
+ Object.assign(tab, updateInfo);
+ if (tabsUpdateReturnsUndefined) {
+ return undefined;
+ }
+
+ return tab;
+ },
+ async setZoom(tabId, zoom) {
+ calls.push(["tabs.setZoom", tabId, zoom]);
+ if (failSetZoom) throw new Error("Zoom failed");
+ }
+ }
+ };
+}
+
+function callsNamed(chromeApi, name) {
+ return chromeApi.calls.filter((call) => call[0] === name);
+}
+
+function createDeferred() {
+ let resolve;
+ let reject;
+ const promise = new Promise((promiseResolve, promiseReject) => {
+ resolve = promiseResolve;
+ reject = promiseReject;
+ });
+ return { promise, resolve, reject };
+}
+
+function deferWindowCreate(chromeApi) {
+ const started = createDeferred();
+ const release = createDeferred();
+ const createWindow = chromeApi.windows.create;
+ chromeApi.windows.create = async (createData) => {
+ started.resolve();
+ await release.promise;
+ return createWindow(createData);
+ };
+ return { started, release };
+}
+
+const shortcut = {
+ id: "s1",
+ name: "Dev Chat",
+ type: "text",
+ url: "https://discord.com/channels/123456789012345678/987654321098765432"
+};
+
+const nextShortcut = {
+ ...shortcut,
+ id: "s2",
+ url: "https://discord.com/channels/223456789012345678/887654321098765432"
+};
+
+test("creates a popup window when no valid window exists", async () => {
+ const chromeApi = createFakeChrome({ failSetZoom: true });
+ const storage = createFakeStorage({
+ windowState: {
+ windowId: 999,
+ tabId: 998
+ }
+ });
+
+ const result = await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut });
+
+ assert.equal(result.windowId, 100);
+ assert.equal(result.tabId, 200);
+ assert.equal(storage.data.windowState.windowId, 100);
+ assert.equal(storage.data.windowState.tabId, 200);
+ assert.equal(storage.data.windowState.lastShortcutId, "s1");
+ assert.equal(callsNamed(chromeApi, "windows.create").length, 1);
+ assert.equal(callsNamed(chromeApi, "windows.create")[0][1].type, "popup");
+ assert.equal(callsNamed(chromeApi, "windows.create")[0][1].width, 420);
+ assert.equal(callsNamed(chromeApi, "windows.create")[0][1].height, 900);
+ assert.equal(callsNamed(chromeApi, "tabs.setZoom").length, 1);
+});
+
+test("reuses existing mini window and tab", async () => {
+ const chromeApi = createFakeChrome();
+ const storage = createFakeStorage();
+ await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut });
+
+ const result = await openShortcutInMiniWindow({
+ chromeApi,
+ storageArea: storage,
+ shortcut: nextShortcut
+ });
+
+ assert.equal(result.windowId, 100);
+ assert.equal(result.tabId, 200);
+ assert.equal(callsNamed(chromeApi, "windows.create").length, 1);
+ assert.ok(
+ chromeApi.calls.some(
+ (call) => call[0] === "windows.update" && call[1] === 100 && call[2].focused === true
+ )
+ );
+ assert.ok(
+ chromeApi.calls.some(
+ (call) => call[0] === "tabs.update" && call[1] === 200 && call[2].url === nextShortcut.url
+ )
+ );
+ assert.equal(storage.data.windowState.lastShortcutId, "s2");
+});
+
+test("serializes concurrent open calls so only one popup is created", async () => {
+ const chromeApi = createFakeChrome();
+ const storage = createFakeStorage();
+
+ const [first, second] = await Promise.all([
+ openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut }),
+ openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut: nextShortcut })
+ ]);
+
+ assert.equal(callsNamed(chromeApi, "windows.create").length, 1);
+ assert.equal(first.windowId, 100);
+ assert.equal(second.windowId, 100);
+ assert.equal(storage.data.windowState.windowId, 100);
+ assert.equal(storage.data.windowState.tabId, 200);
+ assert.equal(storage.data.windowState.lastShortcutId, "s2");
+});
+
+test("serializes close after in-flight open so close wins", async () => {
+ const chromeApi = createFakeChrome();
+ const storage = createFakeStorage();
+ const deferredCreate = deferWindowCreate(chromeApi);
+
+ const openPromise = openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut });
+ await deferredCreate.started.promise;
+ const closePromise = closeMiniWindow({ chromeApi, storageArea: storage });
+
+ deferredCreate.release.resolve();
+ await Promise.all([openPromise, closePromise]);
+
+ assert.equal(storage.data.windowState.windowId, null);
+ assert.equal(storage.data.windowState.tabId, null);
+ assert.equal(chromeApi.windowsById.has(100), false);
+});
+
+test("serializes settings after in-flight open so settings win", async () => {
+ const chromeApi = createFakeChrome();
+ const storage = createFakeStorage();
+ const deferredCreate = deferWindowCreate(chromeApi);
+
+ const openPromise = openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut });
+ await deferredCreate.started.promise;
+ const settingsPromise = updateMiniWindowSettings({
+ chromeApi,
+ storageArea: storage,
+ bounds: { width: 640, height: 820 },
+ zoom: 1
+ });
+
+ deferredCreate.release.resolve();
+ await Promise.all([openPromise, settingsPromise]);
+
+ assert.equal(storage.data.windowState.windowId, 100);
+ assert.equal(storage.data.windowState.bounds.width, 640);
+ assert.equal(storage.data.windowState.bounds.height, 820);
+ assert.equal(storage.data.windowState.zoom, 1);
+ assert.equal(chromeApi.windowsById.get(100).width, 640);
+ assert.equal(chromeApi.windowsById.get(100).height, 820);
+});
+
+test("focuses existing window", async () => {
+ const chromeApi = createFakeChrome();
+ const storage = createFakeStorage();
+ await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut });
+ chromeApi.windowsById.get(100).focused = false;
+
+ await focusMiniWindow({ chromeApi, storageArea: storage });
+
+ assert.equal(chromeApi.windowsById.get(100).focused, true);
+ assert.ok(
+ chromeApi.calls.some(
+ (call) => call[0] === "windows.update" && call[1] === 100 && call[2].focused === true
+ )
+ );
+});
+
+test("closes mini window and clears ids", async () => {
+ const chromeApi = createFakeChrome();
+ const storage = createFakeStorage();
+ await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut });
+
+ await closeMiniWindow({ chromeApi, storageArea: storage });
+
+ assert.equal(callsNamed(chromeApi, "windows.remove").length, 1);
+ assert.equal(chromeApi.windowsById.has(100), false);
+ assert.equal(storage.data.windowState.windowId, null);
+ assert.equal(storage.data.windowState.tabId, null);
+});
+
+test("resets position but keeps size", async () => {
+ const chromeApi = createFakeChrome();
+ const storage = createFakeStorage();
+ await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut });
+ await updateMiniWindowSettings({
+ chromeApi,
+ storageArea: storage,
+ bounds: { left: 50, top: 60, width: 640, height: 820 },
+ zoom: 1.1
+ });
+
+ const state = await resetMiniWindowPosition({ chromeApi, storageArea: storage });
+
+ assert.equal(state.bounds.left, null);
+ assert.equal(state.bounds.top, null);
+ assert.equal(state.bounds.width, 640);
+ assert.equal(state.bounds.height, 820);
+ const lastWindowUpdate = callsNamed(chromeApi, "windows.update").at(-1);
+ assert.deepEqual(lastWindowUpdate[2], { width: 640, height: 820 });
+});
+
+test("returns cleared state when reset position detects stale window", async () => {
+ const chromeApi = createFakeChrome({ failWindowUpdate: true });
+ const storage = createFakeStorage();
+ await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut });
+
+ const state = await resetMiniWindowPosition({ chromeApi, storageArea: storage });
+
+ assert.equal(state.windowId, null);
+ assert.equal(state.tabId, null);
+ assert.equal(storage.data.windowState.windowId, null);
+});
+
+test("returns cleared state when settings update detects stale window", async () => {
+ const chromeApi = createFakeChrome({ failWindowUpdate: true });
+ const storage = createFakeStorage();
+ await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut });
+
+ const state = await updateMiniWindowSettings({
+ chromeApi,
+ storageArea: storage,
+ bounds: { width: 640, height: 820 },
+ zoom: 1
+ });
+
+ assert.equal(state.windowId, null);
+ assert.equal(state.tabId, null);
+ assert.equal(storage.data.windowState.windowId, null);
+});
+
+test("saves bounds from popup windows only", async () => {
+ const storage = createFakeStorage({
+ windowState: {
+ windowId: 1,
+ tabId: 2,
+ bounds: { left: 1, top: 2, width: 420, height: 900 },
+ zoom: 0.9,
+ lastShortcutId: "s1"
+ }
+ });
+
+ const ignoredNormalWindow = await saveBoundsFromWindow(
+ { id: 1, type: "normal", left: 10, top: 20, width: 500, height: 700 },
+ { storageArea: storage, expectedWindowId: 1 }
+ );
+ const ignoredWrongWindow = await saveBoundsFromWindow(
+ { id: 2, type: "popup", left: 10, top: 20, width: 500, height: 700 },
+ { storageArea: storage, expectedWindowId: 1 }
+ );
+ const saved = await saveBoundsFromWindow(
+ { id: 1, type: "popup", left: 10, top: 20, width: 500, height: 700 },
+ { storageArea: storage, expectedWindowId: 1 }
+ );
+
+ assert.equal(ignoredNormalWindow, null);
+ assert.equal(ignoredWrongWindow, null);
+ assert.equal(saved.bounds.left, 10);
+ assert.equal(saved.bounds.top, 20);
+ assert.equal(saved.bounds.width, 500);
+ assert.equal(saved.bounds.height, 700);
+ assert.equal(storage.data.windowState.bounds.left, 10);
+});
+
+test("does not save bounds for a stale expected window id", async () => {
+ const storage = createFakeStorage({
+ windowState: {
+ windowId: 2,
+ tabId: 20,
+ bounds: { left: 1, top: 2, width: 420, height: 900 },
+ zoom: 0.9,
+ lastShortcutId: "s2"
+ }
+ });
+
+ const result = await saveBoundsFromWindow(
+ { id: 1, type: "popup", left: 10, top: 20, width: 500, height: 700 },
+ { storageArea: storage, expectedWindowId: 1 }
+ );
+
+ assert.equal(result, null);
+ assert.equal(storage.data.windowState.windowId, 2);
+ assert.deepEqual(storage.data.windowState.bounds, {
+ left: 1,
+ top: 2,
+ width: 420,
+ height: 900
+ });
+});
+
+test("falls back to existing tab id when tabs.update returns undefined", async () => {
+ const chromeApi = createFakeChrome({ tabsUpdateReturnsUndefined: true });
+ const storage = createFakeStorage();
+ await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut });
+
+ const result = await openShortcutInMiniWindow({
+ chromeApi,
+ storageArea: storage,
+ shortcut: nextShortcut
+ });
+
+ assert.equal(result.tabId, 200);
+ assert.equal(storage.data.windowState.tabId, 200);
+});
+
+test("throws custom error when chrome creates no usable window", async () => {
+ const chromeApi = createFakeChrome({ windowsCreateReturnsUndefined: true });
+ const storage = createFakeStorage();
+
+ await assert.rejects(
+ openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut }),
+ /usable Discord mini window/
+ );
+});
diff --git a/docs/superpowers/plans/2026-05-21-discord-mini-tabs-extension-implementation.md b/docs/superpowers/plans/2026-05-21-discord-mini-tabs-extension-implementation.md
new file mode 100644
index 000000000..f3da35b48
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-21-discord-mini-tabs-extension-implementation.md
@@ -0,0 +1,2358 @@
+# Discord Mini Tabs Extension Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build a lightweight Chrome MV3 extension that opens saved Discord text and voice channel URLs in one reusable, configurable Chrome popup window.
+
+**Architecture:** The extension is a standalone, dependency-light project under `discord-mini-tabs-extension/`. The popup renders shortcut/search/settings UI, while the MV3 service worker owns all Chrome API work for windows, tabs, zoom, and local storage. Discord runs only as the official Discord web app in a real Chrome popup window.
+
+**Tech Stack:** Plain JavaScript ES modules, Chrome Manifest V3, `chrome.storage.local`, `chrome.windows`, `chrome.tabs`, popup HTML/CSS, and Node's built-in `node:test` runner for pure logic tests.
+
+---
+
+## Implementation Decisions
+
+- Use plain JavaScript ES modules and no build step.
+- Use `node --test` for shared logic tests.
+- Support only `https://discord.com/channels/...` in the first release.
+- Store shortcuts and settings locally with `chrome.storage.local`.
+- Do not add content scripts.
+- Do not inspect or manipulate Discord DOM.
+- Do not add import/export of shortcuts in the first release.
+
+## File Structure
+
+- `discord-mini-tabs-extension/package.json`: local scripts and module mode.
+- `discord-mini-tabs-extension/manifest.json`: Chrome MV3 extension manifest.
+- `discord-mini-tabs-extension/README.md`: load-unpacked and usage notes.
+- `discord-mini-tabs-extension/src/shared/constants.js`: storage keys, default bounds, min/max clamps, message types.
+- `discord-mini-tabs-extension/src/shared/url.js`: Discord URL validation and formatting.
+- `discord-mini-tabs-extension/src/shared/settings.js`: bounds and zoom normalization.
+- `discord-mini-tabs-extension/src/shared/shortcuts.js`: shortcut creation, mutation, grouping, and search.
+- `discord-mini-tabs-extension/src/background/storage.js`: promise-based storage helpers.
+- `discord-mini-tabs-extension/src/background/window-manager.js`: create/focus/update/close/reset mini Discord window.
+- `discord-mini-tabs-extension/src/background/service-worker.js`: Chrome runtime message routing and window event listeners.
+- `discord-mini-tabs-extension/src/popup/view-model.js`: pure UI view model helpers.
+- `discord-mini-tabs-extension/src/popup/popup.html`: popup markup.
+- `discord-mini-tabs-extension/src/popup/popup.css`: popup styling.
+- `discord-mini-tabs-extension/src/popup/popup.js`: popup controller and DOM event handling.
+- `discord-mini-tabs-extension/test/*.test.js`: Node tests for pure modules and injected Chrome API fakes.
+- `discord-mini-tabs-extension/MANUAL_TESTS.md`: Chrome load-unpacked and stability smoke checklist.
+
+## Task 1: Scaffold The Standalone Extension Shell
+
+**Files:**
+- Create: `discord-mini-tabs-extension/package.json`
+- Create: `discord-mini-tabs-extension/manifest.json`
+- Create: `discord-mini-tabs-extension/README.md`
+- Create: `discord-mini-tabs-extension/src/background/service-worker.js`
+- Create: `discord-mini-tabs-extension/src/popup/popup.html`
+- Create: `discord-mini-tabs-extension/src/popup/popup.css`
+- Create: `discord-mini-tabs-extension/src/popup/popup.js`
+
+- [ ] **Step 1: Create the directory tree**
+
+Run:
+
+```powershell
+New-Item -ItemType Directory -Force `
+ 'discord-mini-tabs-extension/src/background', `
+ 'discord-mini-tabs-extension/src/popup', `
+ 'discord-mini-tabs-extension/src/shared', `
+ 'discord-mini-tabs-extension/test'
+```
+
+Expected: command exits 0 and the four directories exist.
+
+- [ ] **Step 2: Create the project metadata**
+
+Create `discord-mini-tabs-extension/package.json`:
+
+```json
+{
+ "name": "discord-mini-tabs-extension",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "test": "node --test"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+}
+```
+
+Create `discord-mini-tabs-extension/manifest.json`:
+
+```json
+{
+ "manifest_version": 3,
+ "name": "Discord Mini Tabs",
+ "version": "0.1.0",
+ "description": "Open saved Discord text and voice channels in one reusable mini Chrome popup window.",
+ "permissions": ["storage", "tabs", "windows", "activeTab"],
+ "host_permissions": ["https://discord.com/*"],
+ "background": {
+ "service_worker": "src/background/service-worker.js",
+ "type": "module"
+ },
+ "action": {
+ "default_title": "Discord Mini Tabs",
+ "default_popup": "src/popup/popup.html"
+ }
+}
+```
+
+- [ ] **Step 3: Create the initial README**
+
+Create `discord-mini-tabs-extension/README.md`:
+
+```markdown
+# Discord Mini Tabs
+
+A Chrome Manifest V3 extension that opens saved Discord text and voice channel URLs in one reusable Chrome popup window.
+
+## Development
+
+Run pure logic tests:
+
+```bash
+npm test
+```
+
+Load in Chrome:
+
+1. Open `chrome://extensions`.
+2. Enable Developer mode.
+3. Click Load unpacked.
+4. Select the `discord-mini-tabs-extension` directory.
+
+## Scope
+
+Discord runs as the official Discord web app in a real Chrome popup window. This extension does not embed Discord, inject content scripts, read Discord messages, or inspect Discord voice internals.
+```
+
+- [ ] **Step 4: Add temporary valid extension entry files**
+
+Create `discord-mini-tabs-extension/src/background/service-worker.js`:
+
+```js
+chrome.runtime.onInstalled.addListener(() => {
+ console.info("Discord Mini Tabs installed");
+});
+```
+
+Create `discord-mini-tabs-extension/src/popup/popup.html`:
+
+```html
+
+
+
+
+
+ Discord Mini Tabs
+
+
+
+
+ Discord Mini Tabs
+ Extension shell ready.
+
+
+
+
+```
+
+Create `discord-mini-tabs-extension/src/popup/popup.css`:
+
+```css
+:root {
+ color-scheme: dark;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+body {
+ margin: 0;
+ width: 360px;
+ background: #17181c;
+ color: #f3f4f8;
+}
+
+.shell {
+ padding: 16px;
+}
+
+h1 {
+ margin: 0 0 8px;
+ font-size: 18px;
+ font-weight: 700;
+}
+
+p {
+ margin: 0;
+ color: #aeb3c2;
+}
+```
+
+Create `discord-mini-tabs-extension/src/popup/popup.js`:
+
+```js
+console.info("Discord Mini Tabs popup loaded");
+```
+
+- [ ] **Step 5: Run the initial test command**
+
+Run:
+
+```powershell
+cd discord-mini-tabs-extension
+npm test
+```
+
+Expected: `node --test` exits 0 with no failing tests.
+
+- [ ] **Step 6: Commit the scaffold**
+
+```powershell
+git add discord-mini-tabs-extension
+git commit -m "feat: scaffold discord mini tabs extension"
+```
+
+## Task 2: Add Shared URL And Settings Logic With Tests
+
+**Files:**
+- Create: `discord-mini-tabs-extension/test/url.test.js`
+- Create: `discord-mini-tabs-extension/test/settings.test.js`
+- Create: `discord-mini-tabs-extension/src/shared/constants.js`
+- Create: `discord-mini-tabs-extension/src/shared/url.js`
+- Create: `discord-mini-tabs-extension/src/shared/settings.js`
+
+- [ ] **Step 1: Write failing URL tests**
+
+Create `discord-mini-tabs-extension/test/url.test.js`:
+
+```js
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ compactDiscordUrl,
+ normalizeDiscordChannelUrl,
+ validateDiscordChannelUrl
+} from "../src/shared/url.js";
+
+test("accepts server channel Discord URLs", () => {
+ const result = validateDiscordChannelUrl("https://discord.com/channels/123/456");
+ assert.equal(result.ok, true);
+ assert.equal(result.url, "https://discord.com/channels/123/456");
+ assert.equal(result.scope, "server");
+});
+
+test("accepts direct message Discord URLs", () => {
+ const result = validateDiscordChannelUrl("https://discord.com/channels/@me/456");
+ assert.equal(result.ok, true);
+ assert.equal(result.url, "https://discord.com/channels/@me/456");
+ assert.equal(result.scope, "dm");
+});
+
+test("normalizes trailing slash and search params", () => {
+ assert.equal(
+ normalizeDiscordChannelUrl("https://discord.com/channels/123/456/?jump=999"),
+ "https://discord.com/channels/123/456"
+ );
+});
+
+test("rejects non-discord hosts", () => {
+ const result = validateDiscordChannelUrl("https://example.com/channels/123/456");
+ assert.equal(result.ok, false);
+ assert.match(result.error, /discord\.com/);
+});
+
+test("rejects non-channel Discord URLs", () => {
+ const result = validateDiscordChannelUrl("https://discord.com/app");
+ assert.equal(result.ok, false);
+ assert.match(result.error, /channels/);
+});
+
+test("returns compact display labels", () => {
+ assert.equal(compactDiscordUrl("https://discord.com/channels/123/456"), "123 / 456");
+ assert.equal(compactDiscordUrl("https://discord.com/channels/@me/456"), "DM / 456");
+});
+```
+
+- [ ] **Step 2: Write failing settings tests**
+
+Create `discord-mini-tabs-extension/test/settings.test.js`:
+
+```js
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ clampBounds,
+ clampZoom,
+ createDefaultWindowState,
+ normalizeWindowState
+} from "../src/shared/settings.js";
+
+test("creates default window state", () => {
+ assert.deepEqual(createDefaultWindowState(), {
+ windowId: null,
+ tabId: null,
+ bounds: {
+ left: null,
+ top: null,
+ width: 420,
+ height: 900
+ },
+ zoom: 0.9,
+ lastShortcutId: null
+ });
+});
+
+test("clamps bounds to supported range", () => {
+ assert.deepEqual(clampBounds({ left: 20, top: 30, width: 100, height: 2000 }), {
+ left: 20,
+ top: 30,
+ width: 320,
+ height: 1400
+ });
+});
+
+test("keeps null left and top when unset", () => {
+ assert.deepEqual(clampBounds({ width: 500, height: 700 }), {
+ left: null,
+ top: null,
+ width: 500,
+ height: 700
+ });
+});
+
+test("clamps zoom to supported range", () => {
+ assert.equal(clampZoom(0.2), 0.67);
+ assert.equal(clampZoom(2), 1.25);
+ assert.equal(clampZoom(0.9), 0.9);
+});
+
+test("normalizes partial window state", () => {
+ const state = normalizeWindowState({
+ windowId: 10,
+ bounds: { width: 640 },
+ zoom: 1.5,
+ lastShortcutId: "abc"
+ });
+
+ assert.deepEqual(state, {
+ windowId: 10,
+ tabId: null,
+ bounds: {
+ left: null,
+ top: null,
+ width: 640,
+ height: 900
+ },
+ zoom: 1.25,
+ lastShortcutId: "abc"
+ });
+});
+```
+
+- [ ] **Step 3: Run tests and verify the expected failure**
+
+Run:
+
+```powershell
+cd discord-mini-tabs-extension
+npm test
+```
+
+Expected: FAIL because `src/shared/url.js` and `src/shared/settings.js` do not exist yet.
+
+- [ ] **Step 4: Implement shared constants**
+
+Create `discord-mini-tabs-extension/src/shared/constants.js`:
+
+```js
+export const STORAGE_KEYS = {
+ SHORTCUTS: "shortcuts",
+ WINDOW_STATE: "windowState"
+};
+
+export const SHORTCUT_TYPES = {
+ TEXT: "text",
+ VOICE: "voice"
+};
+
+export const DEFAULT_BOUNDS = {
+ left: null,
+ top: null,
+ width: 420,
+ height: 900
+};
+
+export const BOUNDS_LIMITS = {
+ minWidth: 320,
+ minHeight: 480,
+ maxWidth: 1200,
+ maxHeight: 1400
+};
+
+export const DEFAULT_ZOOM = 0.9;
+export const MIN_ZOOM = 0.67;
+export const MAX_ZOOM = 1.25;
+
+export const DISCORD_HOST = "discord.com";
+export const DISCORD_CHANNEL_PREFIX = "/channels/";
+
+export const MESSAGE_TYPES = {
+ GET_STATE: "GET_STATE",
+ CREATE_SHORTCUT: "CREATE_SHORTCUT",
+ UPDATE_SHORTCUT: "UPDATE_SHORTCUT",
+ DELETE_SHORTCUT: "DELETE_SHORTCUT",
+ OPEN_SHORTCUT: "OPEN_SHORTCUT",
+ READ_ACTIVE_DISCORD_TAB: "READ_ACTIVE_DISCORD_TAB",
+ UPDATE_WINDOW_SETTINGS: "UPDATE_WINDOW_SETTINGS",
+ FOCUS_WINDOW: "FOCUS_WINDOW",
+ CLOSE_WINDOW: "CLOSE_WINDOW",
+ RESET_POSITION: "RESET_POSITION"
+};
+```
+
+- [ ] **Step 5: Implement URL validation**
+
+Create `discord-mini-tabs-extension/src/shared/url.js`:
+
+```js
+import { DISCORD_CHANNEL_PREFIX, DISCORD_HOST } from "./constants.js";
+
+function parseUrl(input) {
+ if (typeof input !== "string" || input.trim().length === 0) {
+ return null;
+ }
+
+ try {
+ return new URL(input.trim());
+ } catch {
+ return null;
+ }
+}
+
+function getChannelParts(url) {
+ const parts = url.pathname.split("/").filter(Boolean);
+ if (parts.length !== 3 || parts[0] !== "channels") {
+ return null;
+ }
+
+ const [, guildOrMe, channelId] = parts;
+ if (!guildOrMe || !channelId) {
+ return null;
+ }
+
+ return { guildOrMe, channelId };
+}
+
+export function validateDiscordChannelUrl(input) {
+ const url = parseUrl(input);
+ if (!url) {
+ return { ok: false, error: "Enter a valid Discord channel URL." };
+ }
+
+ if (url.protocol !== "https:" || url.hostname !== DISCORD_HOST) {
+ return { ok: false, error: "Only https://discord.com channel URLs are supported." };
+ }
+
+ if (!url.pathname.startsWith(DISCORD_CHANNEL_PREFIX)) {
+ return { ok: false, error: "The URL must point to discord.com/channels/..." };
+ }
+
+ const parts = getChannelParts(url);
+ if (!parts) {
+ return { ok: false, error: "The Discord URL must include a server or DM id and channel id." };
+ }
+
+ const normalized = `https://${DISCORD_HOST}/channels/${parts.guildOrMe}/${parts.channelId}`;
+ return {
+ ok: true,
+ url: normalized,
+ guildOrMe: parts.guildOrMe,
+ channelId: parts.channelId,
+ scope: parts.guildOrMe === "@me" ? "dm" : "server"
+ };
+}
+
+export function normalizeDiscordChannelUrl(input) {
+ const result = validateDiscordChannelUrl(input);
+ if (!result.ok) {
+ throw new Error(result.error);
+ }
+ return result.url;
+}
+
+export function compactDiscordUrl(input) {
+ const result = validateDiscordChannelUrl(input);
+ if (!result.ok) {
+ return "";
+ }
+ const guildLabel = result.guildOrMe === "@me" ? "DM" : result.guildOrMe;
+ return `${guildLabel} / ${result.channelId}`;
+}
+```
+
+- [ ] **Step 6: Implement settings normalization**
+
+Create `discord-mini-tabs-extension/src/shared/settings.js`:
+
+```js
+import {
+ BOUNDS_LIMITS,
+ DEFAULT_BOUNDS,
+ DEFAULT_ZOOM,
+ MAX_ZOOM,
+ MIN_ZOOM
+} from "./constants.js";
+
+function clampNumber(value, min, max, fallback) {
+ const number = Number(value);
+ if (!Number.isFinite(number)) {
+ return fallback;
+ }
+ return Math.min(max, Math.max(min, Math.round(number)));
+}
+
+function normalizeNullableCoordinate(value) {
+ const number = Number(value);
+ return Number.isFinite(number) ? Math.round(number) : null;
+}
+
+export function clampBounds(bounds = {}) {
+ return {
+ left: normalizeNullableCoordinate(bounds.left),
+ top: normalizeNullableCoordinate(bounds.top),
+ width: clampNumber(
+ bounds.width,
+ BOUNDS_LIMITS.minWidth,
+ BOUNDS_LIMITS.maxWidth,
+ DEFAULT_BOUNDS.width
+ ),
+ height: clampNumber(
+ bounds.height,
+ BOUNDS_LIMITS.minHeight,
+ BOUNDS_LIMITS.maxHeight,
+ DEFAULT_BOUNDS.height
+ )
+ };
+}
+
+export function clampZoom(value) {
+ const number = Number(value);
+ if (!Number.isFinite(number)) {
+ return DEFAULT_ZOOM;
+ }
+ return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number(number.toFixed(2))));
+}
+
+export function createDefaultWindowState() {
+ return {
+ windowId: null,
+ tabId: null,
+ bounds: { ...DEFAULT_BOUNDS },
+ zoom: DEFAULT_ZOOM,
+ lastShortcutId: null
+ };
+}
+
+export function normalizeWindowState(input = {}) {
+ const defaults = createDefaultWindowState();
+ return {
+ windowId: Number.isInteger(input.windowId) ? input.windowId : null,
+ tabId: Number.isInteger(input.tabId) ? input.tabId : null,
+ bounds: clampBounds({ ...defaults.bounds, ...input.bounds }),
+ zoom: clampZoom(input.zoom ?? defaults.zoom),
+ lastShortcutId:
+ typeof input.lastShortcutId === "string" && input.lastShortcutId.length > 0
+ ? input.lastShortcutId
+ : null
+ };
+}
+```
+
+- [ ] **Step 7: Run tests and verify they pass**
+
+Run:
+
+```powershell
+cd discord-mini-tabs-extension
+npm test
+```
+
+Expected: PASS for `url.test.js` and `settings.test.js`.
+
+- [ ] **Step 8: Commit shared logic**
+
+```powershell
+git add discord-mini-tabs-extension
+git commit -m "feat: add discord url and settings logic"
+```
+
+## Task 3: Add Shortcut Creation, Editing, Deletion, Grouping, And Search
+
+**Files:**
+- Create: `discord-mini-tabs-extension/test/shortcuts.test.js`
+- Create: `discord-mini-tabs-extension/src/shared/shortcuts.js`
+
+- [ ] **Step 1: Write failing shortcut tests**
+
+Create `discord-mini-tabs-extension/test/shortcuts.test.js`:
+
+```js
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ createShortcut,
+ deleteShortcut,
+ filterShortcuts,
+ splitShortcutsByType,
+ updateShortcut
+} from "../src/shared/shortcuts.js";
+
+const fixedNow = () => "2026-05-21T00:00:00.000Z";
+const fixedId = () => "shortcut-1";
+
+test("creates normalized text shortcut", () => {
+ const shortcut = createShortcut(
+ {
+ name: " dev chat ",
+ type: "text",
+ url: "https://discord.com/channels/123/456/?jump=1"
+ },
+ { idFactory: fixedId, now: fixedNow }
+ );
+
+ assert.deepEqual(shortcut, {
+ id: "shortcut-1",
+ name: "dev chat",
+ type: "text",
+ url: "https://discord.com/channels/123/456",
+ createdAt: "2026-05-21T00:00:00.000Z",
+ updatedAt: "2026-05-21T00:00:00.000Z"
+ });
+});
+
+test("rejects empty shortcut name", () => {
+ assert.throws(
+ () => createShortcut({ name: " ", type: "text", url: "https://discord.com/channels/123/456" }),
+ /name/
+ );
+});
+
+test("updates shortcut while preserving id and createdAt", () => {
+ const original = createShortcut(
+ { name: "dev chat", type: "text", url: "https://discord.com/channels/123/456" },
+ { idFactory: fixedId, now: fixedNow }
+ );
+ const updated = updateShortcut(original, {
+ name: "team call",
+ type: "voice",
+ url: "https://discord.com/channels/789/111",
+ now: () => "2026-05-21T01:00:00.000Z"
+ });
+
+ assert.equal(updated.id, "shortcut-1");
+ assert.equal(updated.createdAt, "2026-05-21T00:00:00.000Z");
+ assert.equal(updated.updatedAt, "2026-05-21T01:00:00.000Z");
+ assert.equal(updated.type, "voice");
+ assert.equal(updated.url, "https://discord.com/channels/789/111");
+});
+
+test("deletes shortcut by id", () => {
+ const shortcuts = [
+ { id: "a", name: "A", type: "text", url: "https://discord.com/channels/1/2" },
+ { id: "b", name: "B", type: "voice", url: "https://discord.com/channels/3/4" }
+ ];
+ assert.deepEqual(deleteShortcut(shortcuts, "a").map((item) => item.id), ["b"]);
+});
+
+test("filters shortcuts by name and url", () => {
+ const shortcuts = [
+ { id: "a", name: "Dev Chat", type: "text", url: "https://discord.com/channels/1/2" },
+ { id: "b", name: "Team Call", type: "voice", url: "https://discord.com/channels/3/4" }
+ ];
+ assert.deepEqual(filterShortcuts(shortcuts, "team").map((item) => item.id), ["b"]);
+ assert.deepEqual(filterShortcuts(shortcuts, "channels/1").map((item) => item.id), ["a"]);
+});
+
+test("splits shortcuts by type", () => {
+ const result = splitShortcutsByType([
+ { id: "a", name: "Dev Chat", type: "text", url: "https://discord.com/channels/1/2" },
+ { id: "b", name: "Team Call", type: "voice", url: "https://discord.com/channels/3/4" }
+ ]);
+
+ assert.deepEqual(result.text.map((item) => item.id), ["a"]);
+ assert.deepEqual(result.voice.map((item) => item.id), ["b"]);
+});
+```
+
+- [ ] **Step 2: Run tests and verify the expected failure**
+
+Run:
+
+```powershell
+cd discord-mini-tabs-extension
+npm test
+```
+
+Expected: FAIL because `src/shared/shortcuts.js` does not exist.
+
+- [ ] **Step 3: Implement shortcut helpers**
+
+Create `discord-mini-tabs-extension/src/shared/shortcuts.js`:
+
+```js
+import { SHORTCUT_TYPES } from "./constants.js";
+import { normalizeDiscordChannelUrl } from "./url.js";
+
+const VALID_TYPES = new Set([SHORTCUT_TYPES.TEXT, SHORTCUT_TYPES.VOICE]);
+
+function defaultIdFactory() {
+ if (globalThis.crypto?.randomUUID) {
+ return globalThis.crypto.randomUUID();
+ }
+ return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+}
+
+function defaultNow() {
+ return new Date().toISOString();
+}
+
+function normalizeName(name) {
+ const value = String(name ?? "").trim();
+ if (value.length === 0) {
+ throw new Error("Shortcut name is required.");
+ }
+ return value;
+}
+
+function normalizeType(type) {
+ if (!VALID_TYPES.has(type)) {
+ throw new Error("Shortcut type must be text or voice.");
+ }
+ return type;
+}
+
+export function createShortcut(input, options = {}) {
+ const idFactory = options.idFactory ?? defaultIdFactory;
+ const now = options.now ?? defaultNow;
+ const timestamp = now();
+
+ return {
+ id: idFactory(),
+ name: normalizeName(input.name),
+ type: normalizeType(input.type),
+ url: normalizeDiscordChannelUrl(input.url),
+ createdAt: timestamp,
+ updatedAt: timestamp
+ };
+}
+
+export function updateShortcut(existing, input) {
+ const now = input.now ?? defaultNow;
+ return {
+ ...existing,
+ name: normalizeName(input.name ?? existing.name),
+ type: normalizeType(input.type ?? existing.type),
+ url: normalizeDiscordChannelUrl(input.url ?? existing.url),
+ updatedAt: now()
+ };
+}
+
+export function deleteShortcut(shortcuts, id) {
+ return shortcuts.filter((shortcut) => shortcut.id !== id);
+}
+
+export function findShortcut(shortcuts, id) {
+ return shortcuts.find((shortcut) => shortcut.id === id) ?? null;
+}
+
+export function filterShortcuts(shortcuts, query) {
+ const normalizedQuery = String(query ?? "").trim().toLowerCase();
+ if (!normalizedQuery) {
+ return shortcuts;
+ }
+
+ return shortcuts.filter((shortcut) => {
+ const haystack = `${shortcut.name} ${shortcut.type} ${shortcut.url}`.toLowerCase();
+ return haystack.includes(normalizedQuery);
+ });
+}
+
+export function splitShortcutsByType(shortcuts) {
+ return {
+ text: shortcuts.filter((shortcut) => shortcut.type === SHORTCUT_TYPES.TEXT),
+ voice: shortcuts.filter((shortcut) => shortcut.type === SHORTCUT_TYPES.VOICE)
+ };
+}
+```
+
+- [ ] **Step 4: Run tests and verify they pass**
+
+Run:
+
+```powershell
+cd discord-mini-tabs-extension
+npm test
+```
+
+Expected: PASS for URL, settings, and shortcut tests.
+
+- [ ] **Step 5: Commit shortcut logic**
+
+```powershell
+git add discord-mini-tabs-extension
+git commit -m "feat: add shortcut management logic"
+```
+
+## Task 4: Add Storage Helpers With Tests
+
+**Files:**
+- Create: `discord-mini-tabs-extension/test/storage.test.js`
+- Create: `discord-mini-tabs-extension/src/background/storage.js`
+
+- [ ] **Step 1: Write failing storage tests**
+
+Create `discord-mini-tabs-extension/test/storage.test.js`:
+
+```js
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ getExtensionState,
+ setShortcuts,
+ setWindowState,
+ updateWindowState
+} from "../src/background/storage.js";
+
+function createFakeStorage(initial = {}) {
+ const data = { ...initial };
+ return {
+ data,
+ async get(defaults) {
+ return { ...defaults, ...data };
+ },
+ async set(values) {
+ Object.assign(data, values);
+ }
+ };
+}
+
+test("returns normalized default state", async () => {
+ const storage = createFakeStorage();
+ const state = await getExtensionState(storage);
+
+ assert.deepEqual(state.shortcuts, []);
+ assert.equal(state.windowState.bounds.width, 420);
+ assert.equal(state.windowState.zoom, 0.9);
+});
+
+test("saves shortcuts", async () => {
+ const storage = createFakeStorage();
+ await setShortcuts([{ id: "a", name: "Dev", type: "text", url: "https://discord.com/channels/1/2" }], storage);
+ assert.equal(storage.data.shortcuts.length, 1);
+});
+
+test("normalizes saved window state", async () => {
+ const storage = createFakeStorage();
+ await setWindowState({ bounds: { width: 100, height: 2000 }, zoom: 2 }, storage);
+ assert.equal(storage.data.windowState.bounds.width, 320);
+ assert.equal(storage.data.windowState.bounds.height, 1400);
+ assert.equal(storage.data.windowState.zoom, 1.25);
+});
+
+test("updates window state from previous value", async () => {
+ const storage = createFakeStorage({
+ windowState: {
+ windowId: 5,
+ tabId: 6,
+ bounds: { left: null, top: null, width: 420, height: 900 },
+ zoom: 0.9,
+ lastShortcutId: null
+ }
+ });
+
+ const updated = await updateWindowState((state) => ({
+ ...state,
+ bounds: { ...state.bounds, width: 640 }
+ }), storage);
+
+ assert.equal(updated.windowId, 5);
+ assert.equal(updated.bounds.width, 640);
+ assert.equal(storage.data.windowState.bounds.width, 640);
+});
+```
+
+- [ ] **Step 2: Run tests and verify the expected failure**
+
+Run:
+
+```powershell
+cd discord-mini-tabs-extension
+npm test
+```
+
+Expected: FAIL because `src/background/storage.js` does not exist.
+
+- [ ] **Step 3: Implement storage helpers**
+
+Create `discord-mini-tabs-extension/src/background/storage.js`:
+
+```js
+import { STORAGE_KEYS } from "../shared/constants.js";
+import { createDefaultWindowState, normalizeWindowState } from "../shared/settings.js";
+
+const DEFAULT_STATE = {
+ [STORAGE_KEYS.SHORTCUTS]: [],
+ [STORAGE_KEYS.WINDOW_STATE]: createDefaultWindowState()
+};
+
+function normalizeShortcuts(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function getDefaultStorageArea() {
+ return chrome.storage.local;
+}
+
+export async function getExtensionState(storageArea = getDefaultStorageArea()) {
+ const data = await storageArea.get(DEFAULT_STATE);
+ return {
+ shortcuts: normalizeShortcuts(data[STORAGE_KEYS.SHORTCUTS]),
+ windowState: normalizeWindowState(data[STORAGE_KEYS.WINDOW_STATE])
+ };
+}
+
+export async function setShortcuts(shortcuts, storageArea = getDefaultStorageArea()) {
+ const normalized = normalizeShortcuts(shortcuts);
+ await storageArea.set({ [STORAGE_KEYS.SHORTCUTS]: normalized });
+ return normalized;
+}
+
+export async function setWindowState(windowState, storageArea = getDefaultStorageArea()) {
+ const normalized = normalizeWindowState(windowState);
+ await storageArea.set({ [STORAGE_KEYS.WINDOW_STATE]: normalized });
+ return normalized;
+}
+
+export async function updateWindowState(updater, storageArea = getDefaultStorageArea()) {
+ const state = await getExtensionState(storageArea);
+ const nextWindowState = normalizeWindowState(updater(state.windowState));
+ await setWindowState(nextWindowState, storageArea);
+ return nextWindowState;
+}
+```
+
+- [ ] **Step 4: Run tests and verify they pass**
+
+Run:
+
+```powershell
+cd discord-mini-tabs-extension
+npm test
+```
+
+Expected: PASS for URL, settings, shortcuts, and storage tests.
+
+- [ ] **Step 5: Commit storage helpers**
+
+```powershell
+git add discord-mini-tabs-extension
+git commit -m "feat: add extension storage helpers"
+```
+
+## Task 5: Add Window Manager With Chrome API Fakes
+
+**Files:**
+- Create: `discord-mini-tabs-extension/test/window-manager.test.js`
+- Create: `discord-mini-tabs-extension/src/background/window-manager.js`
+
+- [ ] **Step 1: Write failing window manager tests**
+
+Create `discord-mini-tabs-extension/test/window-manager.test.js`:
+
+```js
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ closeMiniWindow,
+ focusMiniWindow,
+ openShortcutInMiniWindow,
+ resetMiniWindowPosition,
+ saveBoundsFromWindow
+} from "../src/background/window-manager.js";
+
+function createFakeStorage(initial = {}) {
+ const data = { ...initial };
+ return {
+ data,
+ async get(defaults) {
+ return { ...defaults, ...data };
+ },
+ async set(values) {
+ Object.assign(data, values);
+ }
+ };
+}
+
+function createFakeChrome() {
+ const calls = [];
+ const windowsById = new Map();
+ const tabsById = new Map();
+ let nextWindowId = 100;
+ let nextTabId = 200;
+
+ return {
+ calls,
+ windowsById,
+ tabsById,
+ windows: {
+ async create(createData) {
+ calls.push(["windows.create", createData]);
+ const windowId = nextWindowId++;
+ const tabId = nextTabId++;
+ const tab = { id: tabId, windowId, url: createData.url };
+ const window = {
+ id: windowId,
+ type: createData.type,
+ focused: true,
+ left: createData.left,
+ top: createData.top,
+ width: createData.width,
+ height: createData.height,
+ tabs: [tab]
+ };
+ windowsById.set(windowId, window);
+ tabsById.set(tabId, tab);
+ return window;
+ },
+ async get(windowId, options) {
+ calls.push(["windows.get", windowId, options]);
+ const window = windowsById.get(windowId);
+ if (!window) throw new Error("No window");
+ return options?.populate ? window : { ...window, tabs: undefined };
+ },
+ async update(windowId, updateInfo) {
+ calls.push(["windows.update", windowId, updateInfo]);
+ const window = windowsById.get(windowId);
+ if (!window) throw new Error("No window");
+ Object.assign(window, updateInfo);
+ return window;
+ },
+ async remove(windowId) {
+ calls.push(["windows.remove", windowId]);
+ windowsById.delete(windowId);
+ }
+ },
+ tabs: {
+ async update(tabId, updateInfo) {
+ calls.push(["tabs.update", tabId, updateInfo]);
+ const tab = tabsById.get(tabId);
+ if (!tab) throw new Error("No tab");
+ Object.assign(tab, updateInfo);
+ return tab;
+ },
+ async setZoom(tabId, zoom) {
+ calls.push(["tabs.setZoom", tabId, zoom]);
+ }
+ }
+ };
+}
+
+const shortcut = {
+ id: "s1",
+ name: "Dev Chat",
+ type: "text",
+ url: "https://discord.com/channels/1/2"
+};
+
+test("creates a popup window when no valid window exists", async () => {
+ const chromeApi = createFakeChrome();
+ const storage = createFakeStorage();
+
+ const result = await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut });
+
+ assert.equal(result.windowId, 100);
+ assert.equal(result.tabId, 200);
+ assert.equal(storage.data.windowState.windowId, 100);
+ assert.equal(storage.data.windowState.tabId, 200);
+ assert.equal(storage.data.windowState.lastShortcutId, "s1");
+ assert.deepEqual(chromeApi.calls[0][0], "windows.create");
+ assert.equal(chromeApi.calls[0][1].type, "popup");
+ assert.equal(chromeApi.calls[0][1].width, 420);
+ assert.equal(chromeApi.calls[0][1].height, 900);
+});
+
+test("reuses existing mini window and tab", async () => {
+ const chromeApi = createFakeChrome();
+ const storage = createFakeStorage();
+ await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut });
+
+ const nextShortcut = { ...shortcut, id: "s2", url: "https://discord.com/channels/3/4" };
+ const result = await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut: nextShortcut });
+
+ assert.equal(result.windowId, 100);
+ assert.equal(result.tabId, 200);
+ assert.ok(chromeApi.calls.some((call) => call[0] === "tabs.update" && call[2].url === nextShortcut.url));
+ assert.equal(storage.data.windowState.lastShortcutId, "s2");
+});
+
+test("focuses existing window", async () => {
+ const chromeApi = createFakeChrome();
+ const storage = createFakeStorage();
+ await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut });
+
+ await focusMiniWindow({ chromeApi, storageArea: storage });
+
+ assert.ok(chromeApi.calls.some((call) => call[0] === "windows.update" && call[2].focused === true));
+});
+
+test("closes mini window and clears ids", async () => {
+ const chromeApi = createFakeChrome();
+ const storage = createFakeStorage();
+ await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut });
+
+ await closeMiniWindow({ chromeApi, storageArea: storage });
+
+ assert.equal(storage.data.windowState.windowId, null);
+ assert.equal(storage.data.windowState.tabId, null);
+});
+
+test("resets position but keeps size", async () => {
+ const chromeApi = createFakeChrome();
+ const storage = createFakeStorage();
+ await openShortcutInMiniWindow({ chromeApi, storageArea: storage, shortcut });
+
+ const state = await resetMiniWindowPosition({ chromeApi, storageArea: storage });
+
+ assert.equal(state.bounds.left, null);
+ assert.equal(state.bounds.top, null);
+ assert.equal(state.bounds.width, 420);
+});
+
+test("saves bounds from popup windows only", async () => {
+ const storage = createFakeStorage();
+ const saved = await saveBoundsFromWindow(
+ { id: 1, type: "popup", left: 10, top: 20, width: 500, height: 700 },
+ { storageArea: storage, expectedWindowId: 1 }
+ );
+
+ assert.equal(saved.bounds.left, 10);
+ assert.equal(saved.bounds.width, 500);
+});
+```
+
+- [ ] **Step 2: Run tests and verify the expected failure**
+
+Run:
+
+```powershell
+cd discord-mini-tabs-extension
+npm test
+```
+
+Expected: FAIL because `src/background/window-manager.js` does not exist.
+
+- [ ] **Step 3: Implement the window manager**
+
+Create `discord-mini-tabs-extension/src/background/window-manager.js`:
+
+```js
+import { DEFAULT_BOUNDS } from "../shared/constants.js";
+import { clampBounds, normalizeWindowState } from "../shared/settings.js";
+import { getExtensionState, setWindowState, updateWindowState } from "./storage.js";
+
+function getDefaultChromeApi() {
+ return chrome;
+}
+
+function boundsToCreateData(bounds, url) {
+ const createData = {
+ url,
+ type: "popup",
+ focused: true,
+ width: bounds.width,
+ height: bounds.height
+ };
+
+ if (Number.isInteger(bounds.left)) createData.left = bounds.left;
+ if (Number.isInteger(bounds.top)) createData.top = bounds.top;
+
+ return createData;
+}
+
+async function getWindowWithTab(chromeApi, windowState) {
+ if (!Number.isInteger(windowState.windowId)) {
+ return null;
+ }
+
+ try {
+ const currentWindow = await chromeApi.windows.get(windowState.windowId, { populate: true });
+ const existingTab = currentWindow.tabs?.find((tab) => tab.id === windowState.tabId) ?? currentWindow.tabs?.[0] ?? null;
+ if (!existingTab?.id) {
+ return null;
+ }
+ return { window: currentWindow, tab: existingTab };
+ } catch {
+ return null;
+ }
+}
+
+async function applyZoom(chromeApi, tabId, zoom) {
+ try {
+ await chromeApi.tabs.setZoom(tabId, zoom);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export async function openShortcutInMiniWindow({
+ chromeApi = getDefaultChromeApi(),
+ storageArea,
+ shortcut
+}) {
+ const state = await getExtensionState(storageArea);
+ const windowState = normalizeWindowState(state.windowState);
+ const existing = await getWindowWithTab(chromeApi, windowState);
+
+ if (existing) {
+ await chromeApi.windows.update(existing.window.id, { focused: true });
+ const tab = await chromeApi.tabs.update(existing.tab.id, { url: shortcut.url, active: true });
+ await applyZoom(chromeApi, existing.tab.id, windowState.zoom);
+ const nextState = await setWindowState(
+ {
+ ...windowState,
+ windowId: existing.window.id,
+ tabId: tab.id ?? existing.tab.id,
+ lastShortcutId: shortcut.id
+ },
+ storageArea
+ );
+ return nextState;
+ }
+
+ const bounds = clampBounds(windowState.bounds ?? DEFAULT_BOUNDS);
+ const createdWindow = await chromeApi.windows.create(boundsToCreateData(bounds, shortcut.url));
+ const createdTab = createdWindow.tabs?.[0] ?? null;
+ if (!createdWindow.id || !createdTab?.id) {
+ throw new Error("Chrome did not return a usable Discord mini window.");
+ }
+
+ await applyZoom(chromeApi, createdTab.id, windowState.zoom);
+ return setWindowState(
+ {
+ ...windowState,
+ windowId: createdWindow.id,
+ tabId: createdTab.id,
+ bounds,
+ lastShortcutId: shortcut.id
+ },
+ storageArea
+ );
+}
+
+export async function focusMiniWindow({ chromeApi = getDefaultChromeApi(), storageArea }) {
+ const state = await getExtensionState(storageArea);
+ const existing = await getWindowWithTab(chromeApi, state.windowState);
+ if (!existing) {
+ await setWindowState({ ...state.windowState, windowId: null, tabId: null }, storageArea);
+ return null;
+ }
+
+ await chromeApi.windows.update(existing.window.id, { focused: true });
+ return existing.window.id;
+}
+
+export async function closeMiniWindow({ chromeApi = getDefaultChromeApi(), storageArea }) {
+ const state = await getExtensionState(storageArea);
+ if (Number.isInteger(state.windowState.windowId)) {
+ try {
+ await chromeApi.windows.remove(state.windowState.windowId);
+ } catch {
+ // The window may already be closed. Clearing stored ids is still correct.
+ }
+ }
+
+ return setWindowState({ ...state.windowState, windowId: null, tabId: null }, storageArea);
+}
+
+export async function resetMiniWindowPosition({ chromeApi = getDefaultChromeApi(), storageArea }) {
+ const state = await updateWindowState(
+ (windowState) => ({
+ ...windowState,
+ bounds: { ...windowState.bounds, left: null, top: null }
+ }),
+ storageArea
+ );
+
+ if (Number.isInteger(state.windowId)) {
+ try {
+ await chromeApi.windows.update(state.windowId, {
+ width: state.bounds.width,
+ height: state.bounds.height
+ });
+ } catch {
+ await setWindowState({ ...state, windowId: null, tabId: null }, storageArea);
+ }
+ }
+
+ return state;
+}
+
+export async function updateMiniWindowSettings({ chromeApi = getDefaultChromeApi(), storageArea, bounds, zoom }) {
+ const state = await updateWindowState(
+ (windowState) => ({
+ ...windowState,
+ bounds: clampBounds({ ...windowState.bounds, ...bounds }),
+ zoom
+ }),
+ storageArea
+ );
+
+ if (Number.isInteger(state.windowId)) {
+ try {
+ const updateInfo = { width: state.bounds.width, height: state.bounds.height };
+ if (Number.isInteger(state.bounds.left)) updateInfo.left = state.bounds.left;
+ if (Number.isInteger(state.bounds.top)) updateInfo.top = state.bounds.top;
+ await chromeApi.windows.update(state.windowId, updateInfo);
+ if (Number.isInteger(state.tabId)) {
+ await applyZoom(chromeApi, state.tabId, state.zoom);
+ }
+ } catch {
+ await setWindowState({ ...state, windowId: null, tabId: null }, storageArea);
+ }
+ }
+
+ return state;
+}
+
+export async function saveBoundsFromWindow(window, { storageArea, expectedWindowId }) {
+ if (!window || window.type !== "popup" || window.id !== expectedWindowId) {
+ return null;
+ }
+
+ return updateWindowState(
+ (windowState) => ({
+ ...windowState,
+ bounds: clampBounds({
+ left: window.left,
+ top: window.top,
+ width: window.width,
+ height: window.height
+ })
+ }),
+ storageArea
+ );
+}
+```
+
+- [ ] **Step 4: Run tests and verify they pass**
+
+Run:
+
+```powershell
+cd discord-mini-tabs-extension
+npm test
+```
+
+Expected: PASS for all tests, including `window-manager.test.js`.
+
+- [ ] **Step 5: Commit window manager**
+
+```powershell
+git add discord-mini-tabs-extension
+git commit -m "feat: manage reusable discord mini window"
+```
+
+## Task 6: Replace The Service Worker With Runtime Message Routing
+
+**Files:**
+- Modify: `discord-mini-tabs-extension/src/background/service-worker.js`
+
+- [ ] **Step 1: Replace the service worker implementation**
+
+Modify `discord-mini-tabs-extension/src/background/service-worker.js`:
+
+```js
+import { MESSAGE_TYPES } from "../shared/constants.js";
+import { createShortcut, deleteShortcut, findShortcut, updateShortcut } from "../shared/shortcuts.js";
+import { validateDiscordChannelUrl } from "../shared/url.js";
+import { getExtensionState, setShortcuts, setWindowState } from "./storage.js";
+import {
+ closeMiniWindow,
+ focusMiniWindow,
+ openShortcutInMiniWindow,
+ resetMiniWindowPosition,
+ saveBoundsFromWindow,
+ updateMiniWindowSettings
+} from "./window-manager.js";
+
+let boundsSaveTimer = null;
+
+async function handleCreateShortcut(payload) {
+ const state = await getExtensionState();
+ const shortcut = createShortcut(payload);
+ const shortcuts = await setShortcuts([...state.shortcuts, shortcut]);
+ return { shortcut, shortcuts };
+}
+
+async function handleUpdateShortcut(payload) {
+ const state = await getExtensionState();
+ const existing = findShortcut(state.shortcuts, payload.id);
+ if (!existing) {
+ throw new Error("Shortcut not found.");
+ }
+
+ const updated = updateShortcut(existing, payload);
+ const shortcuts = await setShortcuts(
+ state.shortcuts.map((shortcut) => (shortcut.id === updated.id ? updated : shortcut))
+ );
+ return { shortcut: updated, shortcuts };
+}
+
+async function handleDeleteShortcut(payload) {
+ const state = await getExtensionState();
+ const shortcuts = await setShortcuts(deleteShortcut(state.shortcuts, payload.id));
+ return { shortcuts };
+}
+
+async function handleOpenShortcut(payload) {
+ const state = await getExtensionState();
+ const shortcut = findShortcut(state.shortcuts, payload.id);
+ if (!shortcut) {
+ throw new Error("Shortcut not found.");
+ }
+
+ const windowState = await openShortcutInMiniWindow({ shortcut });
+ return { windowState };
+}
+
+async function handleReadActiveDiscordTab() {
+ const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
+ const activeTab = tabs[0];
+ const result = validateDiscordChannelUrl(activeTab?.url ?? "");
+ if (!result.ok) {
+ throw new Error("The active tab is not a supported Discord channel URL.");
+ }
+
+ return {
+ url: result.url,
+ title: activeTab.title ?? "",
+ suggestedName: activeTab.title?.replace(/\s+-\s+Discord$/, "").trim() || "Discord channel"
+ };
+}
+
+async function handleMessage(message) {
+ const payload = message?.payload ?? {};
+
+ switch (message?.type) {
+ case MESSAGE_TYPES.GET_STATE:
+ return getExtensionState();
+ case MESSAGE_TYPES.CREATE_SHORTCUT:
+ return handleCreateShortcut(payload);
+ case MESSAGE_TYPES.UPDATE_SHORTCUT:
+ return handleUpdateShortcut(payload);
+ case MESSAGE_TYPES.DELETE_SHORTCUT:
+ return handleDeleteShortcut(payload);
+ case MESSAGE_TYPES.OPEN_SHORTCUT:
+ return handleOpenShortcut(payload);
+ case MESSAGE_TYPES.READ_ACTIVE_DISCORD_TAB:
+ return handleReadActiveDiscordTab();
+ case MESSAGE_TYPES.UPDATE_WINDOW_SETTINGS:
+ return {
+ windowState: await updateMiniWindowSettings({
+ bounds: payload.bounds,
+ zoom: payload.zoom
+ })
+ };
+ case MESSAGE_TYPES.FOCUS_WINDOW:
+ return { windowId: await focusMiniWindow({}) };
+ case MESSAGE_TYPES.CLOSE_WINDOW:
+ return { windowState: await closeMiniWindow({}) };
+ case MESSAGE_TYPES.RESET_POSITION:
+ return { windowState: await resetMiniWindowPosition({}) };
+ default:
+ throw new Error("Unsupported message type.");
+ }
+}
+
+chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
+ handleMessage(message)
+ .then((data) => sendResponse({ ok: true, data }))
+ .catch((error) => sendResponse({ ok: false, error: error.message }));
+ return true;
+});
+
+chrome.windows.onRemoved.addListener(async (windowId) => {
+ const state = await getExtensionState();
+ if (state.windowState.windowId === windowId) {
+ await setWindowState({ ...state.windowState, windowId: null, tabId: null });
+ }
+});
+
+chrome.windows.onBoundsChanged.addListener((changedWindow) => {
+ clearTimeout(boundsSaveTimer);
+ boundsSaveTimer = setTimeout(async () => {
+ const state = await getExtensionState();
+ await saveBoundsFromWindow(changedWindow, {
+ expectedWindowId: state.windowState.windowId
+ });
+ }, 400);
+});
+```
+
+- [ ] **Step 2: Run tests**
+
+Run:
+
+```powershell
+cd discord-mini-tabs-extension
+npm test
+```
+
+Expected: PASS. The service worker itself is exercised manually in later tasks because it depends on live Chrome extension APIs.
+
+- [ ] **Step 3: Commit service worker routing**
+
+```powershell
+git add discord-mini-tabs-extension/src/background/service-worker.js
+git commit -m "feat: route extension runtime messages"
+```
+
+## Task 7: Add Popup View Model Tests
+
+**Files:**
+- Create: `discord-mini-tabs-extension/test/view-model.test.js`
+- Create: `discord-mini-tabs-extension/src/popup/view-model.js`
+
+- [ ] **Step 1: Write failing view model tests**
+
+Create `discord-mini-tabs-extension/test/view-model.test.js`:
+
+```js
+import test from "node:test";
+import assert from "node:assert/strict";
+import { buildPopupModel, formatWindowStatus, formatZoomPercent } from "../src/popup/view-model.js";
+
+const shortcuts = [
+ { id: "a", name: "Dev Chat", type: "text", url: "https://discord.com/channels/1/2" },
+ { id: "b", name: "Team Call", type: "voice", url: "https://discord.com/channels/3/4" }
+];
+
+test("formats window status", () => {
+ assert.equal(formatWindowStatus({ windowId: null }), "Closed");
+ assert.equal(formatWindowStatus({ windowId: 10 }), "Open");
+});
+
+test("formats zoom percent", () => {
+ assert.equal(formatZoomPercent(0.9), "90%");
+ assert.equal(formatZoomPercent(1), "100%");
+});
+
+test("builds grouped popup model", () => {
+ const model = buildPopupModel({
+ shortcuts,
+ query: "",
+ activeType: "text",
+ windowState: {
+ windowId: 1,
+ tabId: 2,
+ bounds: { left: null, top: null, width: 420, height: 900 },
+ zoom: 0.9,
+ lastShortcutId: "a"
+ }
+ });
+
+ assert.equal(model.status, "Open");
+ assert.equal(model.zoomLabel, "90%");
+ assert.equal(model.activeShortcuts.length, 1);
+ assert.equal(model.activeShortcuts[0].id, "a");
+ assert.equal(model.textCount, 1);
+ assert.equal(model.voiceCount, 1);
+});
+
+test("applies search before active type", () => {
+ const model = buildPopupModel({
+ shortcuts,
+ query: "team",
+ activeType: "voice",
+ windowState: { windowId: null, zoom: 1, bounds: { width: 420, height: 900 } }
+ });
+
+ assert.equal(model.activeShortcuts.length, 1);
+ assert.equal(model.activeShortcuts[0].id, "b");
+});
+```
+
+- [ ] **Step 2: Run tests and verify the expected failure**
+
+Run:
+
+```powershell
+cd discord-mini-tabs-extension
+npm test
+```
+
+Expected: FAIL because `src/popup/view-model.js` does not exist.
+
+- [ ] **Step 3: Implement popup view model**
+
+Create `discord-mini-tabs-extension/src/popup/view-model.js`:
+
+```js
+import { SHORTCUT_TYPES } from "../shared/constants.js";
+import { filterShortcuts, splitShortcutsByType } from "../shared/shortcuts.js";
+
+export function formatWindowStatus(windowState) {
+ return Number.isInteger(windowState?.windowId) ? "Open" : "Closed";
+}
+
+export function formatZoomPercent(zoom) {
+ return `${Math.round(Number(zoom) * 100)}%`;
+}
+
+export function buildPopupModel({ shortcuts, query, activeType, windowState }) {
+ const filtered = filterShortcuts(shortcuts, query);
+ const grouped = splitShortcutsByType(filtered);
+ const normalizedType = activeType === SHORTCUT_TYPES.VOICE ? SHORTCUT_TYPES.VOICE : SHORTCUT_TYPES.TEXT;
+
+ return {
+ status: formatWindowStatus(windowState),
+ zoomLabel: formatZoomPercent(windowState?.zoom ?? 0.9),
+ boundsLabel: `${windowState?.bounds?.width ?? 420} x ${windowState?.bounds?.height ?? 900}`,
+ activeType: normalizedType,
+ activeShortcuts: grouped[normalizedType],
+ textCount: grouped.text.length,
+ voiceCount: grouped.voice.length
+ };
+}
+```
+
+- [ ] **Step 4: Run tests and verify they pass**
+
+Run:
+
+```powershell
+cd discord-mini-tabs-extension
+npm test
+```
+
+Expected: PASS for all tests, including `view-model.test.js`.
+
+- [ ] **Step 5: Commit view model**
+
+```powershell
+git add discord-mini-tabs-extension
+git commit -m "feat: add popup view model"
+```
+
+## Task 8: Build The Popup UI And Controller
+
+**Files:**
+- Modify: `discord-mini-tabs-extension/src/popup/popup.html`
+- Modify: `discord-mini-tabs-extension/src/popup/popup.css`
+- Modify: `discord-mini-tabs-extension/src/popup/popup.js`
+
+- [ ] **Step 1: Replace popup markup**
+
+Modify `discord-mini-tabs-extension/src/popup/popup.html`:
+
+```html
+
+
+
+
+
+ Discord Mini Tabs
+
+
+
+
+
+
+
Discord Mini Tabs
+
Closed · 420 x 900 · 90%
+
+ Save current
+
+
+
+
+
+ Focus
+ Close
+ Reset position
+
+
+
+ Search
+
+
+
+
+ Text 0
+ Voice 0
+
+
+
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: Replace popup styling**
+
+Modify `discord-mini-tabs-extension/src/popup/popup.css`:
+
+```css
+:root {
+ color-scheme: dark;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ background: #17181c;
+ color: #f4f5f8;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ width: 380px;
+ background: #17181c;
+}
+
+button,
+input,
+select {
+ font: inherit;
+}
+
+button {
+ border: 0;
+ border-radius: 6px;
+ background: #5865f2;
+ color: #ffffff;
+ cursor: pointer;
+ min-height: 32px;
+ padding: 0 10px;
+}
+
+button:hover {
+ background: #6974f5;
+}
+
+button.secondary,
+.controls button,
+.form-actions button:first-child {
+ background: #2a2d36;
+ color: #f4f5f8;
+}
+
+.shell {
+ display: grid;
+ gap: 12px;
+ padding: 14px;
+}
+
+.topbar {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+h1,
+h2,
+p {
+ margin: 0;
+}
+
+h1 {
+ font-size: 18px;
+ line-height: 1.2;
+}
+
+h2 {
+ font-size: 13px;
+ color: #c9ced9;
+}
+
+p,
+.meta,
+.shortcut-url,
+.field span {
+ color: #aeb3c2;
+ font-size: 12px;
+}
+
+.feedback {
+ border-radius: 6px;
+ background: #2d2330;
+ color: #ffd6e7;
+ padding: 8px 10px;
+ font-size: 12px;
+}
+
+.controls,
+.form-actions {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.field {
+ display: grid;
+ gap: 5px;
+}
+
+input,
+select {
+ width: 100%;
+ border: 1px solid #363a46;
+ border-radius: 6px;
+ background: #101116;
+ color: #f4f5f8;
+ min-height: 34px;
+ padding: 7px 9px;
+}
+
+.segments {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 6px;
+}
+
+.segment {
+ background: #242731;
+ color: #c9ced9;
+}
+
+.segment.active {
+ background: #5865f2;
+ color: #ffffff;
+}
+
+.shortcut-list {
+ display: grid;
+ gap: 8px;
+ max-height: 230px;
+ overflow: auto;
+}
+
+.shortcut-card {
+ display: grid;
+ gap: 8px;
+ border: 1px solid #303442;
+ border-radius: 8px;
+ background: #1f222b;
+ padding: 10px;
+}
+
+.shortcut-main {
+ min-width: 0;
+}
+
+.shortcut-name {
+ font-size: 14px;
+ font-weight: 700;
+ overflow-wrap: anywhere;
+}
+
+.shortcut-url {
+ margin-top: 2px;
+ overflow-wrap: anywhere;
+}
+
+.shortcut-actions {
+ display: flex;
+ gap: 6px;
+}
+
+.shortcut-actions button {
+ flex: 1;
+}
+
+.empty {
+ border: 1px dashed #3b4050;
+ border-radius: 8px;
+ color: #aeb3c2;
+ padding: 18px;
+ text-align: center;
+}
+
+.panel {
+ display: grid;
+ gap: 10px;
+ border-top: 1px solid #2b2f3a;
+ padding-top: 12px;
+}
+
+.settings-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 8px;
+}
+```
+
+- [ ] **Step 3: Replace popup controller**
+
+Modify `discord-mini-tabs-extension/src/popup/popup.js`:
+
+```js
+import { MESSAGE_TYPES, SHORTCUT_TYPES } from "../shared/constants.js";
+import { compactDiscordUrl } from "../shared/url.js";
+import { buildPopupModel } from "./view-model.js";
+
+const elements = {
+ windowMeta: document.querySelector("#windowMeta"),
+ feedback: document.querySelector("#feedback"),
+ saveCurrentButton: document.querySelector("#saveCurrentButton"),
+ focusButton: document.querySelector("#focusButton"),
+ closeButton: document.querySelector("#closeButton"),
+ resetButton: document.querySelector("#resetButton"),
+ searchInput: document.querySelector("#searchInput"),
+ textTab: document.querySelector("#textTab"),
+ voiceTab: document.querySelector("#voiceTab"),
+ textCount: document.querySelector("#textCount"),
+ voiceCount: document.querySelector("#voiceCount"),
+ shortcutList: document.querySelector("#shortcutList"),
+ shortcutForm: document.querySelector("#shortcutForm"),
+ formTitle: document.querySelector("#formTitle"),
+ editingId: document.querySelector("#editingId"),
+ shortcutName: document.querySelector("#shortcutName"),
+ shortcutUrl: document.querySelector("#shortcutUrl"),
+ shortcutType: document.querySelector("#shortcutType"),
+ cancelEditButton: document.querySelector("#cancelEditButton"),
+ settingsForm: document.querySelector("#settingsForm"),
+ widthInput: document.querySelector("#widthInput"),
+ heightInput: document.querySelector("#heightInput"),
+ zoomInput: document.querySelector("#zoomInput")
+};
+
+const state = {
+ shortcuts: [],
+ windowState: null,
+ activeType: SHORTCUT_TYPES.TEXT,
+ query: ""
+};
+
+async function sendMessage(type, payload = {}) {
+ const response = await chrome.runtime.sendMessage({ type, payload });
+ if (!response?.ok) {
+ throw new Error(response?.error ?? "Extension request failed.");
+ }
+ return response.data;
+}
+
+function showFeedback(message) {
+ elements.feedback.textContent = message;
+ elements.feedback.hidden = false;
+}
+
+function clearFeedback() {
+ elements.feedback.textContent = "";
+ elements.feedback.hidden = true;
+}
+
+function setFormMode(shortcut = null) {
+ elements.editingId.value = shortcut?.id ?? "";
+ elements.shortcutName.value = shortcut?.name ?? "";
+ elements.shortcutUrl.value = shortcut?.url ?? "";
+ elements.shortcutType.value = shortcut?.type ?? state.activeType;
+ elements.formTitle.textContent = shortcut ? "Edit shortcut" : "Add shortcut";
+ elements.cancelEditButton.hidden = !shortcut;
+}
+
+function renderShortcuts(model) {
+ elements.shortcutList.replaceChildren();
+
+ if (model.activeShortcuts.length === 0) {
+ const empty = document.createElement("div");
+ empty.className = "empty";
+ empty.textContent = "No shortcuts match this view.";
+ elements.shortcutList.append(empty);
+ return;
+ }
+
+ for (const shortcut of model.activeShortcuts) {
+ const card = document.createElement("article");
+ card.className = "shortcut-card";
+
+ const main = document.createElement("div");
+ main.className = "shortcut-main";
+
+ const name = document.createElement("div");
+ name.className = "shortcut-name";
+ name.textContent = shortcut.name;
+
+ const url = document.createElement("div");
+ url.className = "shortcut-url";
+ url.textContent = compactDiscordUrl(shortcut.url);
+
+ const actions = document.createElement("div");
+ actions.className = "shortcut-actions";
+
+ const openButton = document.createElement("button");
+ openButton.type = "button";
+ openButton.textContent = "Open";
+ openButton.addEventListener("click", () => openShortcut(shortcut.id));
+
+ const editButton = document.createElement("button");
+ editButton.type = "button";
+ editButton.className = "secondary";
+ editButton.textContent = "Edit";
+ editButton.addEventListener("click", () => setFormMode(shortcut));
+
+ const deleteButton = document.createElement("button");
+ deleteButton.type = "button";
+ deleteButton.className = "secondary";
+ deleteButton.textContent = "Delete";
+ deleteButton.addEventListener("click", () => deleteShortcut(shortcut.id));
+
+ main.append(name, url);
+ actions.append(openButton, editButton, deleteButton);
+ card.append(main, actions);
+ elements.shortcutList.append(card);
+ }
+}
+
+function render() {
+ const model = buildPopupModel({
+ shortcuts: state.shortcuts,
+ query: state.query,
+ activeType: state.activeType,
+ windowState: state.windowState
+ });
+
+ elements.windowMeta.textContent = `${model.status} · ${model.boundsLabel} · ${model.zoomLabel}`;
+ elements.textCount.textContent = String(model.textCount);
+ elements.voiceCount.textContent = String(model.voiceCount);
+ elements.textTab.classList.toggle("active", model.activeType === SHORTCUT_TYPES.TEXT);
+ elements.voiceTab.classList.toggle("active", model.activeType === SHORTCUT_TYPES.VOICE);
+ elements.widthInput.value = state.windowState?.bounds?.width ?? 420;
+ elements.heightInput.value = state.windowState?.bounds?.height ?? 900;
+ elements.zoomInput.value = Math.round((state.windowState?.zoom ?? 0.9) * 100);
+
+ renderShortcuts(model);
+}
+
+async function refresh() {
+ const data = await sendMessage(MESSAGE_TYPES.GET_STATE);
+ state.shortcuts = data.shortcuts;
+ state.windowState = data.windowState;
+ render();
+}
+
+async function runAction(action) {
+ clearFeedback();
+ try {
+ await action();
+ await refresh();
+ } catch (error) {
+ showFeedback(error.message);
+ }
+}
+
+async function openShortcut(id) {
+ await runAction(() => sendMessage(MESSAGE_TYPES.OPEN_SHORTCUT, { id }));
+}
+
+async function deleteShortcut(id) {
+ await runAction(() => sendMessage(MESSAGE_TYPES.DELETE_SHORTCUT, { id }));
+}
+
+elements.searchInput.addEventListener("input", () => {
+ state.query = elements.searchInput.value;
+ render();
+});
+
+elements.textTab.addEventListener("click", () => {
+ state.activeType = SHORTCUT_TYPES.TEXT;
+ render();
+});
+
+elements.voiceTab.addEventListener("click", () => {
+ state.activeType = SHORTCUT_TYPES.VOICE;
+ render();
+});
+
+elements.shortcutForm.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ const payload = {
+ id: elements.editingId.value || undefined,
+ name: elements.shortcutName.value,
+ url: elements.shortcutUrl.value,
+ type: elements.shortcutType.value
+ };
+
+ await runAction(async () => {
+ if (payload.id) {
+ await sendMessage(MESSAGE_TYPES.UPDATE_SHORTCUT, payload);
+ } else {
+ await sendMessage(MESSAGE_TYPES.CREATE_SHORTCUT, payload);
+ }
+ setFormMode(null);
+ });
+});
+
+elements.cancelEditButton.addEventListener("click", () => setFormMode(null));
+
+elements.saveCurrentButton.addEventListener("click", async () => {
+ await runAction(async () => {
+ const active = await sendMessage(MESSAGE_TYPES.READ_ACTIVE_DISCORD_TAB);
+ setFormMode({
+ name: active.suggestedName,
+ url: active.url,
+ type: state.activeType
+ });
+ });
+});
+
+elements.focusButton.addEventListener("click", () => runAction(() => sendMessage(MESSAGE_TYPES.FOCUS_WINDOW)));
+elements.closeButton.addEventListener("click", () => runAction(() => sendMessage(MESSAGE_TYPES.CLOSE_WINDOW)));
+elements.resetButton.addEventListener("click", () => runAction(() => sendMessage(MESSAGE_TYPES.RESET_POSITION)));
+
+elements.settingsForm.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ await runAction(() =>
+ sendMessage(MESSAGE_TYPES.UPDATE_WINDOW_SETTINGS, {
+ bounds: {
+ width: Number(elements.widthInput.value),
+ height: Number(elements.heightInput.value)
+ },
+ zoom: Number(elements.zoomInput.value) / 100
+ })
+ );
+});
+
+refresh().catch((error) => showFeedback(error.message));
+```
+
+- [ ] **Step 4: Run tests**
+
+Run:
+
+```powershell
+cd discord-mini-tabs-extension
+npm test
+```
+
+Expected: PASS for all tests.
+
+- [ ] **Step 5: Manually load the extension shell in Chrome**
+
+Run Chrome manually:
+
+```text
+chrome://extensions
+```
+
+Enable Developer mode, click Load unpacked, and select:
+
+```text
+C:\Users\RGB\rtk\discord-mini-tabs-extension
+```
+
+Expected: Chrome accepts the extension with no manifest error. Opening the extension action shows the popup UI.
+
+- [ ] **Step 6: Commit popup UI**
+
+```powershell
+git add discord-mini-tabs-extension
+git commit -m "feat: build discord shortcut popup"
+```
+
+## Task 9: Add Manual Verification Checklist And Run Final Checks
+
+**Files:**
+- Create: `discord-mini-tabs-extension/MANUAL_TESTS.md`
+
+- [ ] **Step 1: Create manual verification checklist**
+
+Create `discord-mini-tabs-extension/MANUAL_TESTS.md`:
+
+```markdown
+# Manual Tests
+
+## Load Unpacked
+
+1. Open `chrome://extensions`.
+2. Enable Developer mode.
+3. Load the `discord-mini-tabs-extension` directory.
+4. Confirm Chrome shows no manifest or service worker registration errors.
+
+## Shortcut Management
+
+1. Add a text shortcut with `https://discord.com/channels/123/456`.
+2. Confirm the shortcut appears in the Text list.
+3. Edit the shortcut name.
+4. Search for the new name.
+5. Delete the shortcut.
+6. Confirm the list updates without reopening the popup.
+
+## Save Current Discord Channel
+
+1. Open a real Discord channel in a normal Chrome tab.
+2. Open the extension popup.
+3. Click Save current.
+4. Confirm the URL and suggested name populate the form.
+5. Save the shortcut.
+
+## Mini Window
+
+1. Open a saved text shortcut.
+2. Confirm one Chrome popup window opens.
+3. Send a Discord chat message in that window.
+4. Open another shortcut.
+5. Confirm the same popup window is reused.
+6. Resize the popup window.
+7. Close and reopen a shortcut.
+8. Confirm the remembered size is used.
+
+## Voice URL
+
+1. Save a Discord voice channel URL as type Voice.
+2. Open it from the Voice list.
+3. Confirm Discord web displays the voice channel UI.
+4. Join or leave voice manually through Discord web.
+
+## Window Controls
+
+1. Click Focus and confirm the mini window comes forward.
+2. Click Close and confirm the mini window closes.
+3. Click Reset position and confirm the next open lets Chrome choose a valid position with the saved size.
+
+## Settings
+
+1. Set width to `420`, height to `900`, and zoom to `90`.
+2. Open a shortcut.
+3. Confirm the mini window size and Discord tab zoom are applied.
+4. Change width to `500` and zoom to `100`.
+5. Confirm the existing mini window updates.
+
+## Stability Smoke Test
+
+1. Switch between several text and voice shortcuts repeatedly.
+2. Confirm only one Discord mini window remains open.
+3. Close the mini window manually.
+4. Open a shortcut again.
+5. Confirm the extension recovers without errors.
+```
+
+- [ ] **Step 2: Run logic tests**
+
+Run:
+
+```powershell
+cd discord-mini-tabs-extension
+npm test
+```
+
+Expected: PASS for every `node:test` file.
+
+- [ ] **Step 3: Inspect service worker errors manually**
+
+In Chrome:
+
+```text
+chrome://extensions
+```
+
+Open the service worker inspection link for Discord Mini Tabs.
+
+Expected: no uncaught exceptions after opening popup, adding a shortcut, opening a shortcut, focusing, closing, and changing settings.
+
+- [ ] **Step 4: Confirm git status only contains intended files**
+
+Run:
+
+```powershell
+git status --short -- discord-mini-tabs-extension
+```
+
+Expected: only `discord-mini-tabs-extension` files from this plan appear.
+
+- [ ] **Step 5: Commit verification docs**
+
+```powershell
+git add discord-mini-tabs-extension/MANUAL_TESTS.md
+git commit -m "docs: add discord mini tabs manual tests"
+```
+
+## Final Verification Gate
+
+Before claiming the implementation is complete, run:
+
+```powershell
+cd discord-mini-tabs-extension
+npm test
+```
+
+Expected: all Node tests pass.
+
+Then complete the manual checklist in `discord-mini-tabs-extension/MANUAL_TESTS.md` using Chrome load-unpacked mode. The implementation is not complete until both the automated tests and the manual Chrome checks pass.
+
+## Spec Coverage Review
+
+- One reusable Discord popup window: Task 5 and Task 6.
+- Default `420x900`, configurable size, remembered bounds: Task 2, Task 5, Task 8.
+- Adjustable zoom defaulting to `90%`: Task 2, Task 5, Task 8.
+- Text/Voice groups and search: Task 3, Task 7, Task 8.
+- Manual URL entry: Task 8.
+- Save current Discord channel: Task 6 and Task 8.
+- Local storage: Task 4.
+- Minimal permissions and MV3 manifest: Task 1.
+- No Discord iframe, no content script, no DOM scraping: Task 1 manifest and Task 6 architecture.
+- Error recovery for missing window/tab and invalid URLs: Task 2, Task 5, Task 6.
+- Automated logic tests and manual stability checks: Tasks 2 through 9.
diff --git a/docs/superpowers/specs/2026-05-21-discord-mini-tabs-extension-design.md b/docs/superpowers/specs/2026-05-21-discord-mini-tabs-extension-design.md
new file mode 100644
index 000000000..0b67f52db
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-21-discord-mini-tabs-extension-design.md
@@ -0,0 +1,256 @@
+# Discord Mini Tabs Chrome Extension Design
+
+Date: 2026-05-21
+Status: Approved for implementation planning
+
+## Summary
+
+Build a Chrome Manifest V3 extension that opens Discord web channels in a small, reusable Chrome popup window. The extension popup acts as a lightweight control panel for saved Discord text and voice channel shortcuts. Discord itself runs as the official Discord web app in a real Chrome window, so chat, voice UI, login, audio, and permissions remain handled by Discord and Chrome.
+
+The default mini window size is 420x900. Users can change width, height, zoom, and window position. The extension remembers manual resize and move changes for future opens.
+
+## Goals
+
+- Open Discord text and voice channel URLs in one dedicated Chrome popup window.
+- Let users chat and interact normally inside Discord web.
+- Let users save shortcuts manually or from the current active Discord tab.
+- Group shortcuts by Text and Voice, with search and compact window controls.
+- Keep performance stable by avoiding embedded Discord, content-script DOM scraping, and multiple Discord instances.
+- Store all shortcuts and settings locally with `chrome.storage.local`.
+
+## Non-Goals
+
+- Do not clone or reimplement Discord UI.
+- Do not embed Discord in an iframe or extension page.
+- Do not auto-click Join Voice, mute, deafen, or other Discord controls.
+- Do not read Discord messages, inspect Discord DOM, or scrape internal voice state.
+- Do not provide real bitrate telemetry. Any voice/audio status shown by the popup is limited to Chrome-level window or tab state if available.
+- Do not sync settings across devices in the first version.
+
+## Architecture
+
+The extension has three main parts:
+
+- `manifest.json`: Manifest V3 metadata, permissions, host permissions, popup entry, and service worker.
+- Popup UI: user-facing control panel for shortcuts, search, window controls, and settings.
+- Background service worker: owns Chrome API interactions for windows, tabs, zoom, and storage.
+
+The background service worker stores and manages:
+
+- `windowId`: the current Discord mini popup window id, if it still exists.
+- `tabId`: the Discord tab inside the popup window, if it still exists.
+- saved shortcuts.
+- mini window bounds: `left`, `top`, `width`, `height`.
+- default and current zoom.
+- last opened shortcut id.
+
+When the user opens a shortcut, the service worker validates the saved URL, checks whether the stored `windowId` and `tabId` are still valid, and then either:
+
+- creates a new Chrome popup window with the Discord URL, or
+- focuses the existing popup window and updates its existing tab URL.
+
+The extension should use one Discord mini window at a time. This keeps memory, CPU, and WebRTC/audio surface lower than opening one Discord instance per channel.
+
+## Permissions
+
+Use the minimum permissions needed for the agreed behavior:
+
+- `storage`: save shortcuts, settings, bounds, zoom, and last opened shortcut.
+- `tabs`: read/update tab state and set zoom.
+- `windows`: create, focus, close, and track the mini popup window.
+- `activeTab`: support saving the current Discord channel from the active tab.
+- host permissions for `https://discord.com/*`.
+
+The initial version targets stable Discord web URLs:
+
+```text
+https://discord.com/channels/{serverId}/{channelId}
+https://discord.com/channels/@me/{channelId}
+```
+
+`https://canary.discord.com/*` and `https://ptb.discord.com/*` can be added later if needed, but are not required for the first version.
+
+## Popup UI
+
+The popup is a compact command surface, not a Discord renderer.
+
+Primary sections:
+
+- Header with extension name and mini window status: `Closed`, `Open`, or `Focused` when known.
+- Search input for shortcut name, server label, channel label, or URL.
+- Segmented control for `Text` and `Voice`.
+- Shortcut list grouped by type.
+- Mini window controls: `Focus`, `Close`, `Reset position`.
+- Settings controls: `Width`, `Height`, `Zoom`, and `Apply`.
+- Add shortcut controls:
+ - manual URL input and display name.
+ - `Save current Discord channel` action.
+
+Each shortcut item should show:
+
+- display name.
+- type: `text` or `voice`.
+- a shortened URL or channel identifier.
+- actions: `Open`, `Edit`, `Delete`.
+
+The popup should remain fast and simple. It should not load remote assets, render Discord previews, or maintain a long-running connection.
+
+## Data Model
+
+Storage key shape:
+
+```json
+{
+ "shortcuts": [
+ {
+ "id": "uuid-or-stable-random-id",
+ "name": "dev-chat",
+ "type": "text",
+ "url": "https://discord.com/channels/123456789012345678/987654321098765432",
+ "createdAt": "2026-05-21T00:00:00.000Z",
+ "updatedAt": "2026-05-21T00:00:00.000Z"
+ }
+ ],
+ "windowState": {
+ "windowId": 123,
+ "tabId": 456,
+ "bounds": {
+ "left": 1200,
+ "top": 80,
+ "width": 420,
+ "height": 900
+ },
+ "zoom": 0.9,
+ "lastShortcutId": "uuid-or-stable-random-id"
+ }
+}
+```
+
+The defaults are:
+
+- width: `420`
+- height: `900`
+- zoom: `0.9`
+- position: near the right edge of the primary display when Chrome allows it.
+
+Bounds and zoom should be clamped to reasonable values before saving or applying:
+
+- minimum width: `320`
+- minimum height: `480`
+- maximum width: `1200`
+- maximum height: `1400`
+- zoom range: `0.67` to `1.25`
+
+## User Flows
+
+### Open Shortcut
+
+1. User clicks `Open` on a saved Text or Voice shortcut.
+2. Popup sends `OPEN_SHORTCUT` to the service worker.
+3. Service worker validates the URL.
+4. Service worker resolves existing mini window and tab state.
+5. If the mini window is alive, focus it and update the tab URL.
+6. If the mini window is missing or invalid, create a Chrome popup window using the saved bounds.
+7. Apply saved zoom to the Discord tab.
+8. Store current `windowId`, `tabId`, and `lastShortcutId`.
+
+### Save Current Discord Channel
+
+1. User opens a Discord channel in a normal Chrome tab.
+2. User opens the extension popup and clicks `Save current Discord channel`.
+3. Popup asks the service worker to inspect the active tab.
+4. Service worker accepts only `discord.com/channels/...` URLs.
+5. Popup proposes a display name from tab title when possible.
+6. User chooses Text or Voice and saves.
+
+The extension does not need to infer whether a URL is truly text or voice. The user selects the type, and both types are opened through the same Discord URL mechanism.
+
+### Resize Or Move Mini Window
+
+1. User manually resizes or moves the Chrome popup window.
+2. Background receives `chrome.windows.onBoundsChanged`.
+3. Background debounces storage writes.
+4. New bounds are saved to `chrome.storage.local`.
+5. Next open uses the remembered size and position.
+
+### Update Size Or Zoom
+
+1. User changes width, height, or zoom in popup settings.
+2. Popup sends `UPDATE_WINDOW_SETTINGS`.
+3. Service worker stores the settings.
+4. If the mini window exists, service worker applies bounds with `chrome.windows.update`.
+5. If the Discord tab exists, service worker applies zoom with `chrome.tabs.setZoom`.
+
+## Error Handling
+
+- If stored `windowId` no longer exists, clear it and create a new mini window on the next open.
+- If stored `tabId` no longer exists, locate the Discord tab in the mini window or create/update a tab as needed.
+- If the mini window is closed manually, clear window state when Chrome reports removal.
+- If a saved URL is invalid, block open and show a short error in the popup.
+- If `Save current Discord channel` is used outside Discord, show that the active tab is not a Discord channel.
+- If Chrome rejects requested bounds because of display limits, fall back to default bounds and let Chrome choose a valid position.
+- If zoom cannot be applied, keep the window usable and show a non-blocking popup error.
+
+## Performance And Stability
+
+The design favors stability over deep Discord integration:
+
+- One Discord web instance at a time.
+- No content script injection into Discord.
+- No DOM polling.
+- No background network requests.
+- No audio/WebRTC handling by the extension.
+- Debounced bounds persistence to avoid excessive storage writes.
+- Small local data model stored in `chrome.storage.local`.
+
+Discord web remains responsible for:
+
+- login session.
+- chat rendering and sending.
+- voice channel UI.
+- microphone/audio permissions.
+- connection quality and reconnect behavior.
+
+## Testing Plan
+
+### Logic Tests
+
+- Validate accepted Discord channel URLs.
+- Reject malformed URLs and non-Discord hosts.
+- Filter shortcuts by search query.
+- Clamp width, height, and zoom settings.
+- Recover from missing `windowId` or `tabId`.
+
+### Manual Chrome Extension Tests
+
+- Load the extension unpacked in Chrome.
+- Add a shortcut manually.
+- Save the current Discord channel from an active Discord tab.
+- Open a text shortcut and send a test message in Discord web.
+- Open a voice shortcut and verify Discord web handles voice UI.
+- Switch between shortcuts and confirm only one mini window is reused.
+- Resize and move the mini window, close it, then reopen and verify remembered bounds.
+- Change zoom and verify it applies to the Discord tab.
+- Use Focus, Close, and Reset position.
+- Reload the extension and verify stored shortcuts/settings remain.
+
+### Stability Smoke Test
+
+- Open the mini window and leave it running for an extended session.
+- Switch between several text and voice shortcuts repeatedly.
+- Confirm the extension does not open duplicate Discord windows.
+- Confirm closing the mini window and reopening recovers cleanly.
+- Confirm invalid shortcuts do not crash the popup or service worker.
+
+## Implementation Scope
+
+The first implementation should create a standalone extension codebase in a dedicated directory rather than modifying the existing Rust CLI project. A suitable directory name is `discord-mini-tabs-extension`.
+
+The implementation should be plain, dependency-light TypeScript or JavaScript unless a build step is intentionally chosen during implementation planning. Given the stability goal, the default recommendation is a small MV3 extension with minimal tooling and no runtime framework.
+
+## Open Follow-Ups For Implementation Planning
+
+- Choose plain JavaScript versus TypeScript with a small build step.
+- Decide exact popup visual style and icon set.
+- Decide whether to support `canary.discord.com` and `ptb.discord.com` in the first release.
+- Decide whether to include import/export of shortcuts in the first release.