diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ecb5111..db261a1 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -33,7 +33,9 @@ Adding a service to the catalog is a one-line change in [`catalog.js`](catalog.j
- `category` must be one of the values in `CATEGORIES`
- `color` is only used for the generated letter avatar shown before the service reports its own favicon
-`npm test` enforces all four of those. No icon file is needed — Panebox picks up the real favicon from the service itself.
+`npm test` enforces all four of those. No icon file is needed: run `npm run brand-icons` and, if simple-icons carries that brand, its official mark is inlined into `icons.js` automatically. Otherwise the service gets a letter avatar until the site reports its own favicon.
+
+`icons.js` is generated — edit `tools/make-brand-icons.js`, never `icons.js` itself.
## Project layout
diff --git a/README.md b/README.md
index ae291f5..4f883d9 100644
--- a/README.md
+++ b/README.md
@@ -73,7 +73,8 @@ Download the build for your platform from [Releases](https://github.com/viny4/pa
Builds are **not code-signed**, because signing certificates cost money this project doesn't have. That means:
-- **macOS** will say the app "cannot be opened because the developer cannot be verified". Right-click the app → Open → Open, or run `xattr -dr com.apple.quarantine /Applications/Panebox.app`.
+- **macOS** will say the app "cannot be opened because the developer cannot be verified". Right-click the app → Open → Open.
+ - If it instead says **"Panebox is damaged and can't be opened"**, that is Gatekeeper rejecting a quarantined app, not a bad download. Run `xattr -dr com.apple.quarantine /Applications/Panebox.app` and open it again.
- **Windows** will show a SmartScreen warning. Click "More info" → "Run anyway".
If that trade-off isn't acceptable to you, build from source — it takes two commands.
@@ -169,6 +170,10 @@ Panebox exists because [Rambox](https://rambox.app) went closed-source and [Fran
- **[ElectronIM](https://github.com/manusa/electronim)** (Apache-2.0) — minimal and privacy-focused
- **[Hamsket](https://github.com/TheGoddessInari/hamsket)** (GPL-3.0) — a fork of the last open-source Rambox
+## Credits
+
+Brand marks come from [simple-icons](https://github.com/simple-icons/simple-icons) (CC0-1.0), inlined into `icons.js` at build time so the app ships no icon dependency and never fetches an icon over the network. Services without an official mark use a generated letter avatar until the site reports its own favicon.
+
## License
[MIT](LICENSE)
diff --git a/catalog.js b/catalog.js
index d4ff023..35aac55 100644
--- a/catalog.js
+++ b/catalog.js
@@ -89,5 +89,54 @@
return SERVICES.find((s) => s.key === key) || null;
}
- return { SERVICES, CATEGORIES, DEFAULT_KEYS, byKey };
+ const bareHost = (value) => {
+ try {
+ return new URL(value).hostname.replace(/^www\./i, '').toLowerCase();
+ } catch {
+ return '';
+ }
+ };
+
+ const barePath = (value) => {
+ try {
+ return new URL(value).pathname.replace(/\/+$/, '') || '/';
+ } catch {
+ return '/';
+ }
+ };
+
+ /**
+ * Finds a catalog entry for a URL, so one typed by hand still gets the
+ * official brand mark instead of a letter avatar.
+ *
+ * The path matters, not just the host: Gmail and Google Chat both live on
+ * mail.google.com, separated only by /chat. Host-only matching handed every
+ * Gmail URL the Google Chat icon.
+ */
+ function byHost(url) {
+ const host = bareHost(url);
+ if (!host) return null;
+ const path = barePath(url);
+
+ const sameHost = SERVICES.filter((s) => bareHost(s.url) === host);
+ if (sameHost.length) {
+ // Most specific path that the URL actually sits under wins; '/' matches
+ // anything and acts as the fallback for that host.
+ const scored = sameHost
+ .map((s) => ({ service: s, prefix: barePath(s.url) }))
+ .filter((c) => c.prefix === '/' || path === c.prefix || path.startsWith(`${c.prefix}/`))
+ .sort((a, b) => b.prefix.length - a.prefix.length);
+ if (scored.length) return scored[0].service;
+ }
+
+ // Fall back to a subdomain relationship: app.slack.com -> Slack.
+ return (
+ SERVICES.find((s) => {
+ const known = bareHost(s.url);
+ return known && (host.endsWith(`.${known}`) || known.endsWith(`.${host}`));
+ }) || null
+ );
+ }
+
+ return { SERVICES, CATEGORIES, DEFAULT_KEYS, byKey, byHost };
});
diff --git a/icons.js b/icons.js
index 2d80e11..6b07df2 100644
--- a/icons.js
+++ b/icons.js
@@ -1,55 +1,126 @@
/**
- * Brand marks for the services we have accurate paths for. Everything else
- * falls back to a generated letter avatar in the service's brand colour, and is
- * upgraded to the real favicon once the service reports one.
+ * Brand marks for the service catalog.
*
- * We never call a third-party favicon service — that would leak the list of
- * every app a user has added.
+ * GENERATED FILE — do not edit by hand. Run `npm run brand-icons` to rebuild
+ * from tools/make-brand-icons.js.
+ *
+ * Paths come from simple-icons (CC0-1.0), inlined at build time so the app
+ * ships no icon dependency and never requests an icon over the network.
+ * Services without an official mark fall back to a generated letter avatar,
+ * and are upgraded to the site's own favicon once it loads.
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.ICONS = factory();
})(typeof self !== 'undefined' ? self : this, function () {
const BRAND = {
- linkedin:
- '',
- instagram:
- '',
- x: '',
+ chatgpt:
+ "",
+ claude:
+ "",
+ gemini:
+ "",
+ perplexity:
+ "",
+ grok:
+ "",
+ copilot:
+ "",
+ mistral:
+ "",
+ deepseek:
+ "",
+ huggingface:
+ "",
+ napkin:
+ "",
whatsapp:
- '',
- youtube:
- '',
- youtubemusic:
- '',
- github:
- '',
- gitlab:
- '',
- reddit:
- '',
+ "",
+ telegram:
+ "",
slack:
- '',
+ "",
discord:
- '',
- telegram:
- '',
+ "",
+ messenger:
+ "",
+ signal:
+ "",
+ googlechat:
+ "",
+ element:
+ "",
gmail:
- '',
- chatgpt:
- '',
+ "",
+ protonmail:
+ "",
+ gcalendar:
+ "",
+ linkedin:
+ "",
+ x:
+ "",
+ instagram:
+ "",
+ facebook:
+ "",
+ reddit:
+ "",
+ bluesky:
+ "",
+ mastodon:
+ "",
+ threads:
+ "",
+ notion:
+ "",
+ linear:
+ "",
+ trello:
+ "",
+ asana:
+ "",
+ jira:
+ "",
+ obsidian:
+ "",
+ gdrive:
+ "",
+ figma:
+ "",
+ github:
+ "",
+ gitlab:
+ "",
+ vercel:
+ "",
+ cloudflare:
+ "",
+ supabase:
+ "",
+ stackoverflow:
+ "",
+ youtube:
+ "",
+ spotify:
+ "",
+ twitch:
+ "",
+ youtubemusic:
+ "",
};
/** Deterministic, network-free fallback: initials on the brand colour. */
function letterAvatar(name, color) {
- const initials = String(name || '?')
- .replace(/[^\p{L}\p{N} ]/gu, '')
- .trim()
- .split(/\s+/)
- .slice(0, 2)
- .map((w) => w[0])
- .join('')
- .toUpperCase() || '?';
+ const initials =
+ String(name || '?')
+ .replace(/[^\p{L}\p{N} ]/gu, '')
+ .trim()
+ .split(/\s+/)
+ .slice(0, 2)
+ .map((w) => w[0])
+ .join('')
+ .toUpperCase() || '?';
const bg = color && color !== '#ffffff' ? color : '#4b5563';
return { initials, background: bg };
diff --git a/index.html b/index.html
index a1574a1..7d4539c 100644
--- a/index.html
+++ b/index.html
@@ -63,6 +63,9 @@
+
@@ -149,7 +152,7 @@
Settings
-
Drag services in the sidebar to reorder them.
+
Drag the ⠿ handle to reorder, or drag icons in the sidebar.
@@ -313,9 +316,11 @@
Choose what to share
+
+
diff --git a/lib/store.js b/lib/store.js
index 6f3aea8..27521a8 100644
--- a/lib/store.js
+++ b/lib/store.js
@@ -44,6 +44,7 @@ function defaultConfig() {
workspaces: [{ id: 'ws-all', name: 'All', appIds: null }],
activeWorkspace: 'ws-all',
todos: [],
+ splitIds: [],
window: null,
settings: {
theme: 'system',
@@ -59,6 +60,7 @@ function defaultConfig() {
spellcheckLanguages: ['en-US'],
// The only network request Panebox makes on its own behalf.
checkForUpdates: true,
+ sidebarHidden: false,
},
};
}
@@ -84,9 +86,36 @@ function createStore(filePath) {
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
// Merge over defaults so upgrades pick up newly added settings keys.
data = deepMerge(defaultConfig(), parsed);
+
+ // Keep the last known-good copy. Losing a service list is annoying;
+ // losing it silently, with no way back, is worse.
+ try {
+ fs.copyFileSync(filePath, `${filePath}.backup`);
+ } catch {
+ /* a missing backup must never stop the app starting */
+ }
}
} catch (err) {
- console.error('Could not read config, starting fresh:', err.message);
+ // Do NOT fall through to defaults and then overwrite the file — that turns
+ // one bad read into permanent data loss. Preserve the original first.
+ const quarantined = `${filePath}.corrupt-${Date.now()}`;
+ try {
+ fs.renameSync(filePath, quarantined);
+ console.error(`Config unreadable (${err.message}); kept a copy at ${quarantined}`);
+ } catch {
+ console.error('Config unreadable and could not be preserved:', err.message);
+ }
+
+ // Prefer the last good backup over starting from scratch.
+ try {
+ const backup = `${filePath}.backup`;
+ if (fs.existsSync(backup)) {
+ data = deepMerge(defaultConfig(), JSON.parse(fs.readFileSync(backup, 'utf8')));
+ console.error('Recovered configuration from backup.');
+ }
+ } catch {
+ console.error('Backup was unreadable too; starting fresh.');
+ }
}
let writeTimer = null;
diff --git a/lib/urls.js b/lib/urls.js
index 009c58e..bd99e19 100644
--- a/lib/urls.js
+++ b/lib/urls.js
@@ -79,4 +79,58 @@ function isAuthUrl(rawUrl) {
return pathMatches || hasOauthParams;
}
-module.exports = { isAuthUrl, AUTH_HOSTS };
+/**
+ * Turns whatever a user typed into a URL we are willing to load, or null.
+ *
+ * Prefixing "https://" onto anything is too permissive on its own: "not a url"
+ * becomes "https://not a url", which parses, gets percent-encoded, and lands a
+ * permanently broken service in the sidebar named "Not%20a%20url". Likewise
+ * "file:///etc/passwd" becomes "https://file:///etc/passwd" — harmless, but
+ * still a dead entry the user has to work out how to remove.
+ */
+function normalizeServiceUrl(raw) {
+ const input = String(raw == null ? '' : raw).trim();
+ if (!input) return null;
+
+ // An explicit scheme that isn't http(s) is a refusal, not something to prefix.
+ // "localhost:3000" is host:port, not a scheme, so a colon followed by digits
+ // does not count — self-hosting a service is a legitimate thing to do here.
+ const scheme = input.match(/^([a-z][a-z0-9+.-]*):(?!\d)/i);
+ if (scheme && !/^https?$/i.test(scheme[1])) return null;
+
+ let url;
+ try {
+ url = new URL(/^https?:\/\//i.test(input) ? input : `https://${input}`);
+ } catch {
+ return null;
+ }
+
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
+ if (!url.hostname) return null;
+
+ const isIpv6 = /^\[[0-9a-f:]+\]$/i.test(url.host);
+ if (!isIpv6 && !/^[a-z0-9.-]+$/i.test(url.hostname)) return null; // spaces, %20, junk
+ if (url.hostname.startsWith('.') || url.hostname.endsWith('.')) return null;
+
+ // Bare words aren't hosts. localhost and IP literals are.
+ const isIpv4 = /^\d{1,3}(\.\d{1,3}){3}$/.test(url.hostname);
+ if (!url.hostname.includes('.') && url.hostname !== 'localhost' && !isIpv6) return null;
+ if (!isIpv4 && !isIpv6 && url.hostname !== 'localhost' && !/\.[a-z]{2,}$/i.test(url.hostname)) {
+ return null;
+ }
+
+ return url;
+}
+
+/** A readable default name from a URL: "https://www.notion.so/x" -> "Notion". */
+function nameFromUrl(url) {
+ const host = url.hostname.replace(/^www\./i, '');
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || url.host.startsWith('[')) return url.host;
+ const label = host.split('.')[0];
+ return label.charAt(0).toUpperCase() + label.slice(1);
+}
+
+const API = { isAuthUrl, AUTH_HOSTS, normalizeServiceUrl, nameFromUrl };
+
+if (typeof module === 'object' && module.exports) module.exports = API;
+else if (typeof self !== 'undefined') self.URLS = API;
diff --git a/main.js b/main.js
index d7814ae..7340fc1 100644
--- a/main.js
+++ b/main.js
@@ -450,6 +450,8 @@ function buildAppMenu() {
{ label: 'Next Service', accelerator: 'CmdOrCtrl+Tab', click: () => send('pb:cycle', 1) },
{ label: 'Previous Service', accelerator: 'CmdOrCtrl+Shift+Tab', click: () => send('pb:cycle', -1) },
{ type: 'separator' },
+ { label: 'Toggle Sidebar', accelerator: 'CmdOrCtrl+B', click: () => send('pb:toggle-sidebar') },
+ { label: 'Split View', accelerator: 'CmdOrCtrl+Shift+S', click: () => send('pb:toggle-split') },
{ label: 'Toggle Todo Panel', accelerator: 'CmdOrCtrl+T', click: () => send('pb:toggle-todo') },
{ label: 'Task Manager', accelerator: 'CmdOrCtrl+Shift+M', click: () => send('pb:open-taskmanager') },
{ type: 'separator' },
diff --git a/package-lock.json b/package-lock.json
index db8dee3..679d77d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,19 +1,20 @@
{
"name": "panebox",
- "version": "2.1.0",
+ "version": "2.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "panebox",
- "version": "2.1.0",
+ "version": "2.1.1",
"license": "MIT",
"dependencies": {
"electron-updater": "^6.8.9"
},
"devDependencies": {
"electron": "^43.2.0",
- "electron-builder": "^25.1.8"
+ "electron-builder": "^25.1.8",
+ "simple-icons": "^16.27.1"
}
},
"node_modules/@develar/schema-utils": {
@@ -3961,6 +3962,26 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/simple-icons": {
+ "version": "16.27.1",
+ "resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.27.1.tgz",
+ "integrity": "sha512-slZF8iKxkv7Lb9SF3L1AcIl5iYLNvDrKKm8yV69cG66XvxJ12SX+bYTgxRwCY4bXSmklGXoXaKEyVmEypOCeqw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/simple-icons"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/simple-icons"
+ }
+ ],
+ "license": "CC0-1.0",
+ "engines": {
+ "node": ">=0.12.18"
+ }
+ },
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
diff --git a/package.json b/package.json
index 05ee072..297d275 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "panebox",
"productName": "Panebox",
- "version": "2.1.0",
+ "version": "2.1.1",
"description": "A small, private, open-source desktop deck for your web apps — AI tools, chat, mail and more in one window.",
"main": "main.js",
"license": "MIT",
@@ -36,11 +36,13 @@
"dist:linux": "electron-builder --linux",
"pack": "electron-builder --dir",
"test": "node --test \"test/**/*.test.js\"",
- "icons:check": "node tools/make-icons.js --check"
+ "icons:check": "node tools/make-icons.js --check",
+ "brand-icons": "node tools/make-brand-icons.js"
},
"devDependencies": {
"electron": "^43.2.0",
- "electron-builder": "^25.1.8"
+ "electron-builder": "^25.1.8",
+ "simple-icons": "^16.27.1"
},
"build": {
"appId": "io.github.panebox",
@@ -130,7 +132,8 @@
"repo": "panebox",
"releaseType": "draft"
}
- ]
+ ],
+ "afterPack": "./tools/after-pack.js"
},
"dependencies": {
"electron-updater": "^6.8.9"
diff --git a/preload.js b/preload.js
index e662a78..eddbc74 100644
--- a/preload.js
+++ b/preload.js
@@ -74,6 +74,8 @@ contextBridge.exposeInMainWorld('panebox', {
onOpenAdd: on('pb:open-add'),
onOpenFind: on('pb:open-find'),
onOpenTaskManager: on('pb:open-taskmanager'),
+ onToggleSidebar: on('pb:toggle-sidebar'),
+ onToggleSplit: on('pb:toggle-split'),
onToggleTodo: on('pb:toggle-todo'),
onReloadActive: on('pb:reload-active'),
onCycle: on('pb:cycle'),
diff --git a/renderer.js b/renderer.js
index eda666a..9672e7c 100644
--- a/renderer.js
+++ b/renderer.js
@@ -18,6 +18,10 @@ const state = {
config: null,
activeId: null,
webviews: new Map(), // appId ->
+ panes: new Map(), // appId -> wrapper element holding the webview
+ split: false, // grid mode
+ splitIds: new Set(), // which services are in the grid — chosen, not implied
+ focusedId: null, // in grid mode, the pane spotlighted at full size
ready: new Set(), // appIds whose webview has emitted dom-ready
tabs: new Map(), // appId -> { tab, badgeEl }
badges: new Map(), // appId -> number (-1 means "dot")
@@ -149,8 +153,8 @@ function renderSidebar() {
for (const app of visible) {
const wrapper = document.createElement('div');
wrapper.className = 'app-tab-wrapper';
- wrapper.draggable = true;
wrapper.dataset.appId = app.id;
+ wrapper.title = 'Drag to reorder';
const tab = document.createElement('div');
tab.className = 'app-tab' + (app.id === state.activeId ? ' active' : '');
@@ -175,55 +179,407 @@ function renderSidebar() {
}
});
+ if (state.split) {
+ tab.classList.toggle('in-split', state.splitIds.has(app.id));
+ }
+
const tooltip = document.createElement('div');
tooltip.className = 'app-tooltip';
- tooltip.textContent = app.name;
+ tooltip.textContent = state.split
+ ? `${state.splitIds.has(app.id) ? 'Remove from' : 'Add to'} split — ${app.name}`
+ : app.name;
- tab.addEventListener('click', () => switchTab(app.id));
+ tab.addEventListener('click', () => {
+ if (state.split) toggleInSplit(app.id);
+ else switchTab(app.id);
+ });
tab.addEventListener('contextmenu', (e) => {
e.preventDefault();
openServiceModal(app.id);
});
- wrapper.addEventListener('dragstart', (e) => {
- state.dragSourceId = app.id;
+ wrapper.addEventListener('pointerdown', (e) => beginReorder(e, wrapper)); // sidebar
+
+ wrapper.append(tab, del, tooltip);
+ list.appendChild(wrapper);
+ state.tabs.set(app.id, { tab, badgeEl: badge });
+ }
+
+ // The add button lives with the services, not stranded in the footer — it is
+ // where you look when you want another one.
+ const addWrap = document.createElement('div');
+ addWrap.className = 'app-tab-wrapper';
+
+ const addTile = document.createElement('button');
+ addTile.className = 'app-add-tile';
+ addTile.setAttribute('aria-label', 'Add a service');
+ addTile.innerHTML =
+ '';
+ addTile.addEventListener('click', () => {
+ renderCatalog();
+ openModal('add-modal');
+ });
+
+ const addTip = document.createElement('div');
+ addTip.className = 'app-tooltip';
+ const ws = activeWorkspace();
+ addTip.textContent = ws && ws.appIds ? `Add to ${ws.name}` : 'Add a service';
+
+ addWrap.append(addTile, addTip);
+ list.appendChild(addWrap);
+
+ refreshBadges();
+}
+
+/**
+ * Reordering, driven by pointer events rather than HTML5 drag-and-drop.
+ *
+ * The native API looked fine in tests and did nothing in practice — synthetic
+ * DragEvents dispatch straight to the node and prove very little. Pointer
+ * events behave the same way in a test as under a real mouse, and they let the
+ * tab move under the cursor instead of only showing a drop line.
+ *
+ * A short movement threshold keeps an ordinary click on the tab working.
+ */
+function beginReorder(event, wrapper, listEl, commit, itemSelector) {
+ if (event.button !== 0) return;
+
+ const list = listEl || $('app-list');
+ const selector = itemSelector || '.app-tab-wrapper[data-app-id]';
+ const onCommit = commit || commitSidebarOrder;
+ const startY = event.clientY;
+ let dragging = false;
+
+ const sidebarRight = $('sidebar').getBoundingClientRect().right;
+ const appId = wrapper.dataset.appId;
+ const canDropIntoGrid = state.split && appId && !state.splitIds.has(appId);
+ let overGrid = false;
+
+ const onMove = (e) => {
+ if (!dragging) {
+ if (Math.abs(e.clientY - startY) < 6 && Math.abs(e.clientX - startX) < 6) return;
+ dragging = true;
wrapper.classList.add('dragging');
- e.dataTransfer.effectAllowed = 'move';
- // Firefox/Chromium need data set for the drag to start at all.
- e.dataTransfer.setData('text/plain', app.id);
+ document.body.classList.add('reordering');
+ }
+
+ // Dragged out of the sidebar and into the grid: that means "add a pane",
+ // not "reorder".
+ if (canDropIntoGrid) {
+ overGrid = e.clientX > sidebarRight + 12;
+ $('view-container').classList.toggle('drop-target', overGrid);
+ if (overGrid) return; // don't shuffle the sidebar while aiming at the grid
+ }
+
+ const others = [...list.querySelectorAll(selector)].filter((n) => n !== wrapper);
+ const before = others.find((n) => {
+ const r = n.getBoundingClientRect();
+ return e.clientY < r.top + r.height / 2;
});
- wrapper.addEventListener('dragend', () => {
- wrapper.classList.remove('dragging');
- document.querySelectorAll('.drag-over').forEach((n) => n.classList.remove('drag-over'));
- state.dragSourceId = null;
+
+ if (before) {
+ list.insertBefore(wrapper, before);
+ } else {
+ // past the last service, but always above the add tile
+ const addTile = list.querySelector('.app-add-tile');
+ list.insertBefore(wrapper, addTile ? addTile.parentElement : null);
+ }
+ };
+
+ const onUp = () => {
+ document.removeEventListener('pointermove', onMove);
+ document.removeEventListener('pointerup', onUp);
+ $('view-container').classList.remove('drop-target');
+ if (!dragging) return;
+ wrapper.classList.remove('dragging');
+ document.body.classList.remove('reordering');
+
+ if (overGrid && canDropIntoGrid) {
+ renderSidebar(); // undo any shuffling that happened on the way out
+ toggleInSplit(appId);
+ return;
+ }
+ onCommit();
+ };
+
+ document.addEventListener('pointermove', onMove);
+ document.addEventListener('pointerup', onUp);
+}
+
+/**
+ * Writes the on-screen order back to config.
+ *
+ * Only the services visible in the current workspace are on screen, so the
+ * others must keep their positions — we refill just the visible slots.
+ */
+function commitSidebarOrder() {
+ const order = [...$('app-list').querySelectorAll('.app-tab-wrapper[data-app-id]')].map(
+ (n) => n.dataset.appId,
+ );
+ const visible = new Set(order);
+ const reordered = order.map((id) => appById(id)).filter(Boolean);
+
+ let next = 0;
+ state.config.apps = apps().map((app) =>
+ visible.has(app.id) ? reordered[next++] || app : app,
+ );
+ saveApps();
+}
+
+// ------------------------------------------------------------------ grid mode
+
+/**
+ * Columns for n panes, following the shape video-call grids settle on: keep
+ * tiles as close to square as the space allows rather than stretching them.
+ */
+function gridColumns(count) {
+ if (count <= 1) return 1;
+ if (count <= 4) return 2;
+ if (count <= 9) return 3;
+ if (count <= 16) return 4;
+ return 5;
+}
+
+/** Sessions are never touched here — panes only show and hide. */
+function setSplit(on) {
+ state.split = !!on;
+ if (!state.split) state.focusedId = null;
+
+ const container = $('view-container');
+ container.classList.toggle('split', state.split);
+ $('btn-split').classList.toggle('on', state.split);
+
+ if (!state.split) {
+ const adder = container.querySelector('.pane-add');
+ if (adder) adder.classList.remove('active');
+ for (const [id, pane] of state.panes) {
+ pane.classList.remove('focused', 'thumb');
+ pane.classList.toggle('active', id === state.activeId);
+ }
+ container.style.removeProperty('--grid-cols');
+ return;
+ }
+
+ // Drop anything that has since been removed, then seed if this is the first
+ // time: the service you are on, plus the next couple from the workspace, so
+ // the grid opens with something in it rather than a single lonely pane.
+ state.splitIds = new Set([...state.splitIds].filter((id) => appById(id)));
+ if (state.splitIds.size < 2) {
+ const seed = [state.activeId, ...visibleApps().map((a) => a.id)].filter(Boolean);
+ for (const id of seed) {
+ if (state.splitIds.size >= 2) break;
+ state.splitIds.add(id);
+ }
+ }
+
+ renderSplit();
+}
+
+/** Wakes the chosen services, shows only those panes, and sizes the grid. */
+function renderSplit() {
+ const container = $('view-container');
+ const ids = [...state.splitIds].slice(0, 12);
+
+ for (const id of ids) {
+ const app = appById(id);
+ if (!app) continue;
+ if (!state.webviews.has(id)) createWebview(app);
+ state.lastActive.set(id, Date.now());
+ }
+
+ const shown = new Set(ids);
+ for (const [id, pane] of state.panes) {
+ pane.classList.toggle('active', shown.has(id));
+ }
+
+ // A "+" tile so services can be added from inside the grid, not only from
+ // the sidebar.
+ let adder = container.querySelector('.pane-add');
+ if (!adder) {
+ adder = document.createElement('button');
+ adder.className = 'pane pane-add';
+ adder.innerHTML =
+ 'Add a pane';
+ adder.addEventListener('click', (e) => {
+ e.stopPropagation();
+ openSplitMenu(adder);
});
- wrapper.addEventListener('dragover', (e) => {
- e.preventDefault();
- if (state.dragSourceId && state.dragSourceId !== app.id) wrapper.classList.add('drag-over');
+ container.appendChild(adder);
+ }
+ container.appendChild(adder); // keep it last
+ adder.classList.toggle('active', shown.size < 12 && !state.focusedId);
+
+ // Sized from the panes alone. Counting the add tile as a cell left two
+ // services sharing a row with a large empty square beside them.
+ if (state.focusedId && !shown.has(state.focusedId)) state.focusedId = null;
+ container.style.setProperty('--grid-cols', gridColumns(shown.size));
+ applySplitFocus();
+ renderSidebar();
+ saveSplit();
+}
+
+/**
+ * Picker for the "+" tile: everything not already on screen, plus a shortcut to
+ * drop the whole current group in at once. Both routes matter — sometimes you
+ * want two things side by side, sometimes the entire group as a dashboard.
+ */
+function openSplitMenu(anchor) {
+ const menu = $('split-menu');
+ menu.textContent = '';
+
+ const ws = activeWorkspace();
+ const groupIds = visibleApps().map((a) => a.id);
+ const missingFromGroup = groupIds.filter((id) => !state.splitIds.has(id));
+
+ if (missingFromGroup.length > 1) {
+ const all = document.createElement('button');
+ all.className = 'popover-manage';
+ all.textContent = `Add all ${missingFromGroup.length} from "${ws ? ws.name : 'All'}"`;
+ all.addEventListener('click', () => {
+ for (const id of missingFromGroup) {
+ if (state.splitIds.size >= 12) break;
+ state.splitIds.add(id);
+ }
+ menu.hidden = true;
+ renderSplit();
});
- wrapper.addEventListener('dragleave', () => wrapper.classList.remove('drag-over'));
- wrapper.addEventListener('drop', (e) => {
- e.preventDefault();
- wrapper.classList.remove('drag-over');
- reorderApps(state.dragSourceId, app.id);
+ menu.appendChild(all);
+
+ const sep = document.createElement('div');
+ sep.className = 'popover-sep';
+ menu.appendChild(sep);
+ }
+
+ const candidates = apps().filter((a) => !state.splitIds.has(a.id));
+ if (!candidates.length) {
+ const empty = document.createElement('div');
+ empty.className = 'empty-state';
+ empty.style.fontSize = '12px';
+ empty.textContent = 'Every service is already on screen.';
+ menu.appendChild(empty);
+ }
+
+ for (const app of candidates) {
+ const row = document.createElement('button');
+ row.className = 'popover-item';
+ const icon = iconNode(app);
+ icon.classList.add('popover-icon');
+ row.append(icon, document.createTextNode(app.name));
+ row.addEventListener('click', () => {
+ menu.hidden = true;
+ toggleInSplit(app.id);
});
+ menu.appendChild(row);
+ }
- wrapper.append(tab, del, tooltip);
- list.appendChild(wrapper);
- state.tabs.set(app.id, { tab, badgeEl: badge });
+ const rect = anchor.getBoundingClientRect();
+ menu.style.left = `${Math.min(rect.left, window.innerWidth - 250)}px`;
+ menu.style.top = `${Math.min(rect.top + 8, window.innerHeight - 320)}px`;
+ menu.hidden = false;
+}
+
+function saveSplit() {
+ window.panebox.config.setKey('splitIds', [...state.splitIds]);
+}
+
+/**
+ * Adds or removes one service from the grid.
+ *
+ * This is what makes the grid a choice rather than a consequence of the
+ * workspace: two services side by side is the common case, not nine.
+ */
+function toggleInSplit(appId) {
+ if (state.splitIds.has(appId)) {
+ if (state.splitIds.size <= 1) return setSplit(false); // last pane closes the grid
+ state.splitIds.delete(appId);
+ if (state.focusedId === appId) state.focusedId = null;
+ } else {
+ if (state.splitIds.size >= 12) return;
+ state.splitIds.add(appId);
}
+ renderSplit();
+}
- refreshBadges();
+function setSplitFocus(appId) {
+ if (!state.split) return;
+ state.focusedId = appId;
+ applySplitFocus();
+}
+
+function applySplitFocus() {
+ const container = $('view-container');
+ const focused = state.focusedId;
+ container.classList.toggle('has-focus', !!focused);
+
+ let thumbs = 0;
+ for (const [id, pane] of state.panes) {
+ const isThumb = !!focused && id !== focused && pane.classList.contains('active');
+ pane.classList.toggle('focused', id === focused);
+ pane.classList.toggle('thumb', isThumb);
+ if (isThumb) thumbs++;
+ }
+
+ // The thumbnail strip needs one column per thumbnail; the focused pane spans
+ // all of them on the row above.
+ container.style.setProperty('--thumb-count', Math.max(thumbs, 1));
+}
+
+function toggleSplit() {
+ setSplit(!state.split);
}
-function reorderApps(sourceId, targetId) {
- if (!sourceId || sourceId === targetId) return;
- const list = apps();
- const from = list.findIndex((a) => a.id === sourceId);
- const to = list.findIndex((a) => a.id === targetId);
- if (from < 0 || to < 0) return;
- const [moved] = list.splice(from, 1);
- list.splice(to, 0, moved);
+// -------------------------------------------------------------- icons (page)
+
+/**
+ * Looks for a high-resolution logo inside the page.
+ *
+ * The favicon a page advertises first is typically 16x16 and looks like mush in
+ * the sidebar. Sites almost always ship something better — an apple-touch-icon
+ * at 180px, or manifest icons at 192/512 — so prefer the largest we can find.
+ * Everything is read from the page itself; nothing is asked of a third party.
+ */
+async function findBestIcon(app, wv) {
+ const script = `(async () => {
+ const out = [];
+ // An SVG scales to any size, so it beats every bitmap regardless of what
+ // dimensions the page declares.
+ const push = (href, size) => {
+ if (!href) return;
+ const abs = new URL(href, location.href).href;
+ out.push({ href: abs, size: /\\.svg(\\?|#|$)/i.test(abs) ? 1024 : size });
+ };
+
+ for (const link of document.querySelectorAll('link[rel~="icon"], link[rel~="apple-touch-icon"], link[rel~="apple-touch-icon-precomposed"], link[rel~="shortcut"]')) {
+ const declared = parseInt((link.getAttribute('sizes') || '').split(/[x\\s]/)[0], 10);
+ const isApple = (link.getAttribute('rel') || '').includes('apple');
+ push(link.getAttribute('href'), Number.isFinite(declared) ? declared : (isApple ? 180 : 32));
+ }
+
+ const manifest = document.querySelector('link[rel="manifest"]');
+ if (manifest) {
+ try {
+ const res = await fetch(new URL(manifest.getAttribute('href'), location.href), { credentials: 'omit' });
+ const data = await res.json();
+ for (const icon of data.icons || []) {
+ const declared = parseInt(String(icon.sizes || '').split(/[x\\s]/)[0], 10);
+ push(icon.src, Number.isFinite(declared) ? declared : 64);
+ }
+ } catch (e) { /* no manifest, or blocked — the link tags are enough */ }
+ }
+
+ out.sort((a, b) => b.size - a.size);
+ return out[0] && out[0].size >= 48 ? out[0] : null;
+ })()`;
+
+ const best = await withWebview(app.id, (view) => view.executeJavaScript(script, false), null);
+ if (!best || !best.href || best.href === app.favicon) return;
+
+ app.favicon = best.href;
+ app.faviconHiRes = true;
saveApps();
renderSidebar();
}
@@ -245,6 +601,7 @@ function createWebview(app) {
console.warn(`[${app.name}] custom JS failed:`, err && err.message);
});
if (app.id === state.activeId) syncUrl();
+ if (!app.serviceKey) findBestIcon(app, wv);
});
const syncUrl = () => {
@@ -253,8 +610,20 @@ function createWebview(app) {
$('url-input').value = url;
$('url-lock').classList.toggle('insecure', !/^https:/i.test(url));
};
- wv.addEventListener('did-navigate', syncUrl);
- wv.addEventListener('did-navigate-in-page', syncUrl);
+ wv.addEventListener('did-navigate', () => {
+ syncUrl();
+ refreshNavState();
+ });
+ wv.addEventListener('did-navigate-in-page', () => {
+ syncUrl();
+ refreshNavState();
+ });
+
+ wv.addEventListener('did-start-loading', () => setLoading(app.id, true));
+ wv.addEventListener('did-stop-loading', () => {
+ setLoading(app.id, false);
+ refreshNavState();
+ });
wv.addEventListener('page-title-updated', (e) => {
// A service that reports exact counts via navigator.setAppBadge is always
@@ -265,7 +634,9 @@ function createWebview(app) {
wv.addEventListener('page-favicon-updated', (e) => {
const icon = e.favicons && e.favicons[0];
- if (!icon || icon === app.favicon) return;
+ // Only a starting point: the default favicon is usually 16x16 and looks
+ // muddy at 26px. dom-ready then hunts for something sharper.
+ if (!icon || icon === app.favicon || app.faviconHiRes) return;
app.favicon = icon;
saveApps();
renderSidebar();
@@ -281,7 +652,73 @@ function createWebview(app) {
console.warn(`[${app.name}] failed to load: ${e.errorDescription} (${e.validatedURL})`);
});
- $('view-container').appendChild(wv);
+ // The webview lives inside a pane wrapper so grid mode can give it a header.
+ // Built at creation, never moved afterwards: relocating a in the
+ // DOM tears down its process and reloads the page.
+ const pane = document.createElement('div');
+ pane.className = 'pane';
+ pane.dataset.appId = app.id;
+
+ const head = document.createElement('div');
+ head.className = 'pane-head';
+
+ const label = document.createElement('span');
+ label.className = 'pane-name';
+ label.textContent = app.name;
+
+ const focusBtn = document.createElement('button');
+ focusBtn.className = 'pane-btn';
+ focusBtn.title = 'Expand';
+ focusBtn.textContent = '⤢';
+ focusBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ setSplitFocus(state.focusedId === app.id ? null : app.id);
+ });
+
+ const openBtn = document.createElement('button');
+ openBtn.className = 'pane-btn';
+ openBtn.title = 'Open on its own';
+ openBtn.textContent = '↗';
+ openBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ setSplit(false);
+ switchTab(app.id);
+ });
+
+ const closeBtn = document.createElement('button');
+ closeBtn.className = 'pane-btn';
+ closeBtn.title = 'Remove from split';
+ closeBtn.textContent = '✕';
+ closeBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ toggleInSplit(app.id);
+ });
+
+ head.append(label, focusBtn, openBtn, closeBtn);
+ // Double-click expands. A third click inside the same burst opens the
+ // service on its own — the natural "keep going" gesture.
+ let clicks = 0;
+ let clickTimer = null;
+ head.addEventListener('click', (e) => {
+ if (e.target.closest('.pane-btn')) return;
+ clicks++;
+ clearTimeout(clickTimer);
+ if (clicks === 2) {
+ setSplitFocus(state.focusedId === app.id ? null : app.id);
+ } else if (clicks >= 3) {
+ clicks = 0;
+ setSplit(false);
+ switchTab(app.id);
+ return;
+ }
+ clickTimer = setTimeout(() => {
+ clicks = 0;
+ }, 450);
+ });
+
+ pane.append(head, wv);
+ $('view-container').appendChild(pane);
+ state.panes.set(app.id, pane);
state.webviews.set(app.id, wv);
state.lastActive.set(app.id, Date.now());
return wv;
@@ -290,7 +727,10 @@ function createWebview(app) {
function destroyWebview(appId) {
const wv = state.webviews.get(appId);
if (!wv) return;
- wv.remove();
+ const pane = state.panes.get(appId);
+ if (pane) pane.remove();
+ else wv.remove();
+ state.panes.delete(appId);
state.webviews.delete(appId);
state.ready.delete(appId);
state.badges.delete(appId);
@@ -308,8 +748,9 @@ function switchTab(appId) {
if (!state.webviews.has(appId)) createWebview(app);
- for (const [id, view] of state.webviews) {
- view.classList.toggle('active', id === appId);
+ if (state.split) setSplit(false);
+ for (const [id, pane] of state.panes) {
+ pane.classList.toggle('active', id === appId);
}
for (const [id, { tab }] of state.tabs) {
tab.classList.toggle('active', id === appId);
@@ -319,6 +760,8 @@ function switchTab(appId) {
$('url-input').value = withWebview(appId, (view) => view.getURL()) || app.url;
$('btn-mute').classList.toggle('off', !!app.muted);
+ setLoading(appId, withWebview(appId, (wv) => wv.isLoading(), false));
+ refreshNavState();
closeFind();
}
@@ -334,6 +777,27 @@ function cycle(delta) {
const parseBadgeFromTitle = (title) => window.BADGE.parseBadgeFromTitle(title);
+/**
+ * Keeps the toolbar honest.
+ *
+ * Back and forward used to look clickable even with no history, and reload gave
+ * no feedback at all — so a working button was indistinguishable from a broken
+ * one. Now they disable when there is nowhere to go, and reload spins while the
+ * page is actually loading.
+ */
+function refreshNavState() {
+ const canBack = withWebview(state.activeId, (wv) => wv.canGoBack(), false);
+ const canFwd = withWebview(state.activeId, (wv) => wv.canGoForward(), false);
+ $('btn-back').disabled = !canBack;
+ $('btn-forward').disabled = !canFwd;
+}
+
+function setLoading(appId, loading) {
+ if (appId !== state.activeId) return;
+ $('btn-reload').classList.toggle('loading', loading);
+ $('btn-reload').title = loading ? 'Stop loading' : 'Reload (Cmd/Ctrl+R)';
+}
+
function setBadge(appId, count, source = 'title') {
const raw = Number(count);
const value = Number.isFinite(raw) ? raw : 0;
@@ -447,6 +911,7 @@ function hibernationTick() {
for (const [appId] of state.webviews) {
if (appId === state.activeId) continue;
+ if (state.split && state.panes.get(appId)?.classList.contains('active')) continue;
const app = appById(appId);
if (!app || app.hibernate === false) continue;
const last = state.lastActive.get(appId) || now;
@@ -461,28 +926,35 @@ function hibernationTick() {
// ---------------------------------------------------------------- add app
function addApp({ name, url, serviceKey, color }) {
- let finalName = (name || '').trim();
- let finalUrl = (url || '').trim();
- if (!finalUrl) return null;
- if (!/^https?:\/\//i.test(finalUrl)) finalUrl = `https://${finalUrl}`;
-
- try {
- const parsed = new URL(finalUrl);
- if (!finalName) {
- const host = parsed.hostname.replace(/^www\./, '');
- finalName = host.split('.')[0].replace(/^./, (c) => c.toUpperCase());
- }
- } catch {
- alert('That does not look like a valid URL.');
+ const parsed = window.URLS.normalizeServiceUrl(url);
+ if (!parsed) {
+ alert(
+ `"${String(url).trim()}" is not a web address Panebox can open.\n\n` +
+ 'Try something like notion.so or https://example.com/app.',
+ );
return null;
}
+ let finalName = (name || '').trim() || window.URLS.nameFromUrl(parsed);
+ const finalUrl = parsed.href;
+
+ // A second copy of the same service is a feature (two accounts), but two
+ // rows reading "Gemini" is not — number them so they can be told apart.
+ const sameName = apps().filter(
+ (a) => a.name === finalName || a.name.startsWith(`${finalName} `),
+ ).length;
+ if (sameName > 0) finalName = `${finalName} ${sameName + 1}`;
+
+ // A hand-typed URL that happens to be a service we know should still get the
+ // official mark rather than a letter avatar.
+ const known = serviceKey ? null : window.CATALOG.byHost(finalUrl);
+
const app = {
id: `app-${Date.now()}-${Math.floor(Math.random() * 10000)}`,
name: finalName,
url: finalUrl,
- serviceKey: serviceKey || null,
- color: color || null,
+ serviceKey: serviceKey || (known && known.key) || null,
+ color: color || (known && known.color) || null,
favicon: null,
session: 'isolated',
notifications: true,
@@ -532,6 +1004,9 @@ function removeApp(appId) {
let catalogCategory = 'AI';
function renderCatalog() {
+ const ws = activeWorkspace();
+ $('add-target').textContent = ws && ws.appIds ? `Adding to "${ws.name}"` : '';
+
const query = $('catalog-search').value.trim().toLowerCase();
const tabs = $('category-tabs');
const grid = $('catalog-grid');
@@ -606,9 +1081,21 @@ function renderManageList() {
for (const app of apps()) {
const row = document.createElement('div');
row.className = 'manage-app-row';
+ row.dataset.appId = app.id;
const info = document.createElement('div');
info.className = 'manage-app-info';
+
+ // Reordering belongs where the list is, not only in the sidebar.
+ const grip = document.createElement('span');
+ grip.className = 'row-grip';
+ grip.title = 'Drag to reorder';
+ grip.textContent = '⠿';
+ grip.addEventListener('pointerdown', (e) =>
+ beginReorder(e, row, $('manage-app-list'), commitManageOrder, '.manage-app-row[data-app-id]'),
+ );
+ info.appendChild(grip);
+
info.appendChild(iconNode(app));
const details = document.createElement('div');
@@ -647,6 +1134,19 @@ function renderManageList() {
}
}
+/** The settings list shows every service, so its order is the whole order. */
+function commitManageOrder() {
+ const order = [...$('manage-app-list').querySelectorAll('.manage-app-row[data-app-id]')].map(
+ (n) => n.dataset.appId,
+ );
+ const reordered = order.map((id) => appById(id)).filter(Boolean);
+ if (reordered.length === apps().length) {
+ state.config.apps = reordered;
+ saveApps();
+ renderSidebar();
+ }
+}
+
function renderWorkspaceList() {
const list = $('workspace-list');
list.textContent = '';
@@ -962,6 +1462,9 @@ function saveServiceModal() {
if (state.activeId === app.id) switchTab(app.id);
}
+ const pane = state.panes.get(app.id);
+ if (pane) pane.querySelector('.pane-name').textContent = app.name;
+
renderSidebar();
renderManageList();
closeModal('service-modal');
@@ -1248,6 +1751,17 @@ function updateDndButton() {
$('btn-dnd').classList.toggle('on', !!settings().dnd);
}
+function applySidebarVisibility(hidden) {
+ document.body.classList.toggle('sidebar-hidden', !!hidden);
+}
+
+function toggleSidebar() {
+ const next = !document.body.classList.contains('sidebar-hidden');
+ applySidebarVisibility(next);
+ settings().sidebarHidden = next;
+ window.panebox.config.setKey('settings.sidebarHidden', next);
+}
+
function applyTheme(isDark) {
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
}
@@ -1303,6 +1817,8 @@ async function init() {
migrateLegacy();
applyTheme(await window.panebox.theme.isDark());
+ applySidebarVisibility(settings().sidebarHidden);
+ state.splitIds = new Set((state.config.splitIds || []).filter((id) => appById(id)));
fillSettingsForm();
updateDndButton();
bindSettingsControls();
@@ -1336,7 +1852,7 @@ document.addEventListener('DOMContentLoaded', () => {
}),
);
$('btn-reload').addEventListener('click', () =>
- withWebview(state.activeId, (wv) => wv.reload()),
+ withWebview(state.activeId, (wv) => (wv.isLoading() ? wv.stop() : wv.reload())),
);
$('btn-mute').addEventListener('click', () => {
const app = appById(state.activeId);
@@ -1353,6 +1869,7 @@ document.addEventListener('DOMContentLoaded', () => {
updateDndButton();
$('set-dnd').checked = next;
});
+ $('btn-split').addEventListener('click', toggleSplit);
$('btn-find').addEventListener('click', openFind);
$('btn-todo').addEventListener('click', () => {
$('todo-panel').hidden = !$('todo-panel').hidden;
@@ -1383,10 +1900,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
// --- sidebar ---
- $('btn-add-app').addEventListener('click', () => {
- renderCatalog();
- openModal('add-modal');
- });
$('btn-settings').addEventListener('click', () => {
fillSettingsForm();
renderManageList();
@@ -1399,8 +1912,10 @@ document.addEventListener('DOMContentLoaded', () => {
});
// Clicks inside the popover must not dismiss it — you type a name in there.
$('workspace-menu').addEventListener('click', (e) => e.stopPropagation());
+ $('split-menu').addEventListener('click', (e) => e.stopPropagation());
document.addEventListener('click', () => {
$('workspace-menu').hidden = true;
+ $('split-menu').hidden = true;
});
// --- add modal ---
@@ -1475,6 +1990,13 @@ document.addEventListener('DOMContentLoaded', () => {
// --- keyboard ---
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
+ if (!$('workspace-menu').hidden || !$('split-menu').hidden) {
+ $('workspace-menu').hidden = true;
+ $('split-menu').hidden = true;
+ return;
+ }
+ if (state.focusedId) return setSplitFocus(null);
+ if (state.split) return setSplit(false);
if (!$('find-bar').hidden) return closeFind();
document.querySelectorAll('.modal-overlay.active').forEach((m) => {
if (m.id !== 'screen-modal') closeModal(m.id);
@@ -1483,7 +2005,13 @@ document.addEventListener('DOMContentLoaded', () => {
}
if (!(e.metaKey || e.ctrlKey)) return;
- if (e.shiftKey && e.key.toLowerCase() === 'r') {
+ if (e.shiftKey && e.key.toLowerCase() === 's') {
+ e.preventDefault();
+ toggleSplit();
+ } else if (e.key.toLowerCase() === 'b' && !e.shiftKey) {
+ e.preventDefault();
+ toggleSidebar();
+ } else if (e.shiftKey && e.key.toLowerCase() === 'r') {
e.preventDefault();
window.panebox.window.relaunch();
} else if (e.key >= '1' && e.key <= '9') {
@@ -1550,6 +2078,8 @@ document.addEventListener('DOMContentLoaded', () => {
});
window.panebox.menu.onOpenFind(openFind);
window.panebox.menu.onOpenTaskManager(startTaskManager);
+ window.panebox.menu.onToggleSidebar(toggleSidebar);
+ window.panebox.menu.onToggleSplit(toggleSplit);
window.panebox.menu.onToggleTodo(() => {
$('todo-panel').hidden = !$('todo-panel').hidden;
});
diff --git a/styles.css b/styles.css
index 0804e36..ca3c96a 100644
--- a/styles.css
+++ b/styles.css
@@ -56,9 +56,15 @@ button { font-family: inherit; }
#app-container { display: flex; width: 100vw; height: 100vh; position: relative; }
-/* macOS traffic-light drag strip. */
+/* macOS traffic-light drag strip.
+ Width is deliberately limited to the sidebar. It used to span the whole
+ window at z-index 101, which put an invisible drag region on top of every
+ toolbar button — clicks landed on the strip and the buttons never fired,
+ while Cmd+R still worked because menu accelerators bypass the DOM.
+ The toolbar is draggable in its own right (#topbar sets -webkit-app-region:
+ drag, with no-drag on the control groups), so nothing is lost. */
.drag-handle {
- position: absolute; top: 0; left: 0; right: 0; height: 44px;
+ position: absolute; top: 0; left: 0; width: 76px; height: 44px;
-webkit-app-region: drag; z-index: 101; pointer-events: none;
}
body.platform-darwin .drag-handle { pointer-events: auto; }
@@ -556,3 +562,173 @@ webview.active { visibility: visible; }
}
.modal-sub { display: block; font-size: 11.5px; color: var(--text-dim); margin-top: 2px; }
+
+/* ------------------------------------------------- toolbar feedback state */
+
+/* A button that cannot do anything should not look like it can. Back and
+ forward with no history used to read as broken. */
+.nav-btn:disabled {
+ opacity: 0.3;
+ cursor: default;
+ pointer-events: none;
+}
+
+/* Reloading a page that looks identical gives no signal, so spin the icon for
+ as long as the load actually takes. */
+#btn-reload.loading svg {
+ animation: pb-spin 0.7s linear infinite;
+ color: var(--accent);
+}
+@keyframes pb-spin {
+ to { transform: rotate(360deg); }
+}
+
+/* ------------------------------------------------------- collapsible sidebar */
+
+/* Cmd/Ctrl+B, same as an editor. Width animates so the workspace grows into
+ the space rather than snapping. */
+#sidebar {
+ transition: width .16s ease, opacity .16s ease;
+ overflow: hidden;
+}
+body.sidebar-hidden #sidebar {
+ width: 0;
+ opacity: 0;
+ border-right-color: transparent;
+ pointer-events: none;
+}
+/* The traffic lights need their drag strip even with the sidebar collapsed. */
+body.sidebar-hidden.platform-darwin #topbar { padding-left: 84px; }
+
+/* While reordering, the tab follows the cursor and nothing else reacts. */
+.app-tab-wrapper.dragging { opacity: .55; transform: scale(.94); }
+.app-tab-wrapper.dragging .app-tooltip { display: none; }
+body.reordering { cursor: grabbing; }
+body.reordering .app-tab,
+body.reordering .app-tab-wrapper { cursor: grabbing; }
+body.reordering webview { pointer-events: none; }
+
+/* Reorder handle in the settings service list. */
+.row-grip {
+ cursor: grab; color: var(--text-dim); font-size: 15px; line-height: 1;
+ padding: 2px 4px; margin-right: 2px; user-select: none; flex-shrink: 0;
+}
+.row-grip:hover { color: var(--text); }
+.manage-app-row.dragging { opacity: .5; background: var(--bg-hover); }
+
+/* ------------------------------------------------------------- split view */
+
+/* Each webview lives in a pane so grid mode can give it a header. In single
+ mode the pane is invisible chrome filling the workspace. */
+.pane {
+ position: absolute; inset: 0;
+ display: flex; flex-direction: column;
+ visibility: hidden; min-width: 0; min-height: 0;
+}
+.pane.active { visibility: visible; }
+.pane webview {
+ /* The original rule pinned every webview to position:absolute inset:0, which
+ inside a pane means it covers its own header — visible only once the page
+ paints, so the header appeared to exist and then vanish. */
+ position: relative; inset: auto;
+ flex: 1; min-height: 0; width: 100%; height: auto;
+ border: none; visibility: visible;
+}
+.pane-head { display: none; }
+
+/* Grid mode: tiles sized like a video call, squarish and evenly divided. */
+#view-container.split {
+ display: grid;
+ grid-template-columns: repeat(var(--grid-cols, 2), minmax(0, 1fr));
+ grid-auto-rows: minmax(0, 1fr);
+ gap: 8px; padding: 8px;
+}
+#view-container.split .pane {
+ position: relative; inset: auto;
+ border: 1px solid var(--border); border-radius: var(--radius-md);
+ overflow: hidden; background: var(--bg-panel);
+ transition: box-shadow .15s ease, border-color .15s ease;
+}
+#view-container.split .pane:not(.active) { display: none; }
+#view-container.split .pane:hover { border-color: var(--accent); }
+
+#view-container.split .pane-head {
+ display: flex; align-items: center; gap: 6px;
+ padding: 5px 6px 5px 10px; flex-shrink: 0;
+ background: var(--bg-sidebar); border-bottom: 1px solid var(--border);
+ font-size: 11.5px; color: var(--text-muted); cursor: default;
+}
+.pane-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.pane-btn {
+ width: 22px; height: 22px; flex-shrink: 0;
+ background: none; border: none; border-radius: 5px;
+ color: var(--text-dim); font-size: 12px; cursor: pointer; line-height: 1;
+}
+.pane-btn:hover { background: var(--bg-hover); color: var(--text); }
+
+/* Spotlight: one pane takes the room, the rest become a strip underneath —
+ the same shape a call takes when someone shares their screen.
+ Two explicit rows: the focused pane spans every column of the first, and the
+ thumbnails auto-place across the second. A flex column was wrong here — the
+ thumbnails stacked and squeezed the focused pane down to a couple of pixels. */
+#view-container.split.has-focus {
+ display: grid;
+ grid-template-columns: repeat(var(--thumb-count, 1), minmax(0, 1fr));
+ grid-template-rows: minmax(0, 1fr) 132px;
+}
+#view-container.split.has-focus .pane.focused {
+ grid-row: 1; grid-column: 1 / -1;
+ border-color: var(--accent); box-shadow: var(--glow);
+}
+#view-container.split.has-focus .pane.thumb { grid-row: 2; }
+#view-container.split.has-focus .pane.thumb .pane-name { font-size: 11px; }
+
+/* Which services are currently in the grid. */
+.app-tab.in-split { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
+.app-tab.in-split::after {
+ content: ''; position: absolute; bottom: -3px; left: 50%; transform: translateX(-50%);
+ width: 14px; height: 3px; border-radius: 2px; background: var(--accent);
+}
+
+/* "+" control. Floats over the grid rather than taking a cell — as a cell it
+ left a large empty square next to two panes. */
+#view-container .pane-add,
+.pane-add {
+ display: none; position: absolute; right: 14px; bottom: 14px; z-index: 20;
+ align-items: center; gap: 7px; padding: 8px 14px;
+ background: var(--bg-panel); border: 1px solid var(--border);
+ border-radius: 999px; color: var(--text-muted);
+ cursor: pointer; font-family: inherit; font-size: 12px;
+ box-shadow: var(--shadow); transition: all .18s ease;
+}
+.pane-add svg { width: 15px; height: 15px; }
+#view-container.split .pane-add.active {
+ display: flex; position: absolute;
+ right: 14px; bottom: 14px; left: auto; top: auto;
+ width: auto; height: auto; flex: 0 0 auto;
+ border-style: solid; background: var(--bg-panel);
+}
+#view-container.split .pane-add:hover { border-color: var(--accent); color: var(--accent); }
+#view-container.split.has-focus .pane-add { display: none; }
+
+/* Dragging a sidebar icon over the grid. */
+#view-container.drop-target { outline: 2px dashed var(--accent); outline-offset: -6px; }
+#view-container.drop-target::after {
+ content: 'Drop to add a pane';
+ position: absolute; left: 50%; top: 16px; transform: translateX(-50%);
+ padding: 6px 14px; border-radius: 999px; z-index: 30;
+ background: var(--accent); color: #fff; font-size: 12px; font-weight: 600;
+ pointer-events: none;
+}
+
+.popover-item {
+ display: flex; align-items: center; gap: 9px; width: 100%;
+ padding: 7px 10px; background: none; border: none; border-radius: 6px;
+ color: var(--text); font-size: 12.5px; font-family: inherit;
+ cursor: pointer; text-align: left;
+}
+.popover-item:hover { background: var(--bg-hover); }
+.popover-icon, .popover-item .avatar {
+ width: 18px; height: 18px; flex-shrink: 0; border-radius: 4px; font-size: 8px;
+}
+#split-menu { max-height: 320px; overflow-y: auto; }
diff --git a/test/dom-contract.test.js b/test/dom-contract.test.js
new file mode 100644
index 0000000..60d181a
--- /dev/null
+++ b/test/dom-contract.test.js
@@ -0,0 +1,96 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert');
+const fs = require('node:fs');
+const path = require('node:path');
+
+/**
+ * Guards the contract between renderer.js and index.html.
+ *
+ * `$('some-id')` returns null when the element is missing, and calling
+ * `.addEventListener` on null throws — which aborts the rest of the
+ * DOMContentLoaded handler and silently kills every listener registered after
+ * it. That shipped once: index.html dropped a button while renderer.js kept
+ * wiring it, and the workspace dropdown, settings, task manager, modals and
+ * keyboard shortcuts all stopped working with no visible error.
+ *
+ * It is a one-line mistake with an enormous blast radius, so it gets a test.
+ */
+
+const root = path.join(__dirname, '..');
+const renderer = fs.readFileSync(path.join(root, 'renderer.js'), 'utf8');
+const html = fs.readFileSync(path.join(root, 'index.html'), 'utf8');
+
+/** Every id="..." declared in index.html. */
+function declaredIds() {
+ const ids = new Set();
+ for (const m of html.matchAll(/\bid="([^"]+)"/g)) ids.add(m[1]);
+ return ids;
+}
+
+/** Every literal $('...') lookup in renderer.js, with its line number. */
+function referencedIds() {
+ const refs = [];
+ renderer.split('\n').forEach((line, i) => {
+ for (const m of line.matchAll(/\$\('([^']+)'\)/g)) {
+ refs.push({ id: m[1], line: i + 1 });
+ }
+ });
+ return refs;
+}
+
+test('index.html declares every element renderer.js looks up', () => {
+ const declared = declaredIds();
+ const missing = referencedIds().filter((r) => !declared.has(r.id));
+
+ assert.deepStrictEqual(
+ missing,
+ [],
+ `renderer.js references ids that index.html does not define:\n` +
+ missing.map((m) => ` line ${m.line}: $('${m.id}')`).join('\n') +
+ `\n\nThis throws at runtime and silently disables every listener wired after it.`,
+ );
+});
+
+test('renderer.js does not reference the removed footer add button', () => {
+ // Specific regression: the button moved into the service list.
+ assert.ok(
+ !renderer.includes('btn-add-app'),
+ 'btn-add-app was replaced by the inline .app-add-tile',
+ );
+});
+
+test('the inline add tile is wired up', () => {
+ assert.ok(renderer.includes('app-add-tile'), 'renderer.js must create the add tile');
+ assert.ok(
+ fs.readFileSync(path.join(root, 'styles.css'), 'utf8').includes('.app-add-tile'),
+ 'styles.css must style the add tile',
+ );
+});
+
+test('every script index.html loads actually exists', () => {
+ for (const m of html.matchAll(/