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(/