diff --git a/desktop/electron/e2e/annotation.spec.ts b/desktop/electron/e2e/annotation.spec.ts index 4d09936f..581d1cf1 100644 --- a/desktop/electron/e2e/annotation.spec.ts +++ b/desktop/electron/e2e/annotation.spec.ts @@ -149,8 +149,10 @@ test.afterAll(async () => { test('D2: drag → target row → companion chip → postAgentInput carries the image', async () => { test.setTimeout(90_000); - // Connect through the real ConnectPanel (auto-opened on boot): the probe + - // bind hit the mock hub; the token lands in the throwaway safeStorage file. + // Connect through the real status-bar entry and ConnectPanel: disconnected + // launch stays non-modal, then the probe + bind hit the mock hub and the + // token lands in the throwaway safeStorage file. + await page.getByRole('button', { name: 'Connect', exact: true }).click(); const connect = page.locator('.connect'); await expect(connect).toBeVisible({ timeout: 20_000 }); await connect.locator('input').nth(1).fill(`http://127.0.0.1:${String(hubPort)}`); diff --git a/desktop/electron/e2e/app.spec.ts b/desktop/electron/e2e/app.spec.ts index d6944e6b..b048115b 100644 --- a/desktop/electron/e2e/app.spec.ts +++ b/desktop/electron/e2e/app.spec.ts @@ -351,7 +351,7 @@ test('author: the New ▾ menu creates a document and the workspace pane folds', await expect(page.locator('.read-tabstrip .read-tabitem').last()).toBeVisible(); // Fold the pane via its header chevron → the tree is gone and a slim edge // button takes its place; clicking that restores the pane. - await page.locator('.author-nav .author-nav-head .author-nav-icon').first().click(); + await page.getByRole('button', { name: 'Hide the workspace pane' }).click(); await expect(page.locator('.author-nav')).toHaveCount(0); await page.locator('.author-nav-show').click(); await expect(page.locator('.author-nav')).toBeVisible(); diff --git a/desktop/electron/electron-builder.yml b/desktop/electron/electron-builder.yml index 6fed7f6f..7ca04fd3 100644 --- a/desktop/electron/electron-builder.yml +++ b/desktop/electron/electron-builder.yml @@ -24,6 +24,12 @@ copyright: © TermiPod contributors # before this step; here we only pack the already-correct .node. npmRebuild: false +# npm 11 currently installs node-pty's macOS `spawn-helper` without its execute +# bit. The addon launches that helper with posix_spawnp; a packaged app therefore +# fails every local shell with `posix_spawnp failed` unless packaging restores +# mode 0755 before signing. +afterPack: scripts/after-pack.cjs + directories: output: release buildResources: build @@ -38,6 +44,10 @@ files: - '!tsconfig.json' - '!esbuild.mjs' - '!electron-builder.yml' + - '!scripts/**/*' + # Local packaging output may already exist from an earlier build. Never feed + # it back into app.asar (which recursively balloons the next package). + - '!release/**/*' # build/ holds the deb maintainer scripts (buildResources) — packaging # inputs, not runtime payload; keep them out of the asar. - '!build/**/*' diff --git a/desktop/electron/scripts/after-pack.cjs b/desktop/electron/scripts/after-pack.cjs new file mode 100644 index 00000000..acc65fb4 --- /dev/null +++ b/desktop/electron/scripts/after-pack.cjs @@ -0,0 +1,37 @@ +const fs = require('node:fs/promises'); +const path = require('node:path'); + +/** + * Restore executable permissions on node-pty's macOS launcher before the app is + * signed. npm 11 may unpack the published helper as 0644; node-pty invokes it + * with posix_spawnp, which then fails even though the binary exists. + */ +exports.default = async function afterPack(context) { + if (context.electronPlatformName !== 'darwin') return; + + const resources = path.join( + context.appOutDir, + `${context.packager.appInfo.productFilename}.app`, + 'Contents', + 'Resources', + 'app.asar.unpacked', + 'node_modules', + 'node-pty', + 'prebuilds', + ); + + let fixed = 0; + for (const arch of ['darwin-arm64', 'darwin-x64']) { + const helper = path.join(resources, arch, 'spawn-helper'); + try { + await fs.chmod(helper, 0o755); + fixed += 1; + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + } + + if (fixed === 0) { + throw new Error(`node-pty spawn-helper not found under ${resources}`); + } +}; diff --git a/desktop/electron/src/ipc/menu.ts b/desktop/electron/src/ipc/menu.ts index 0dfb13a8..e84f99d8 100644 --- a/desktop/electron/src/ipc/menu.ts +++ b/desktop/electron/src/ipc/menu.ts @@ -14,12 +14,14 @@ /// - a rendered figure `` (mermaid/vega/echarts) → the renderer rasterizes /// it to a PNG `data:` URL and passes it as `imageData`, which we write to /// the clipboard as a `nativeImage`. -import { clipboard, Menu, nativeImage, type MenuItemConstructorOptions } from 'electron'; +import { clipboard, Menu, nativeImage, shell, type MenuItemConstructorOptions } from 'electron'; import type { Handler } from './dispatch'; +import { isSafeExternal } from './platform'; export const menuHandlers: Record = { /// Pop a native context menu at the cursor. `editable` widens it to the full - /// edit set; `hasSelection` gates cut/copy; an image target adds "Copy image". + /// edit set; `hasSelection` gates cut/copy; `selectAll` keeps document-frame + /// whitespace useful; an image target adds "Copy image". /// Paste is offered only when the OS clipboard actually holds text (checked /// here, in the main process, so the renderer needn't touch the async /// clipboard-read permission path). @@ -28,12 +30,23 @@ export const menuHandlers: Record = { const wc = ctx.win.webContents; const editable = args.editable === true; const hasSelection = args.hasSelection === true; + const linkUrl = typeof args.linkUrl === 'string' && isSafeExternal(args.linkUrl) ? args.linkUrl : ''; + const openLinkLabel = typeof args.openLinkLabel === 'string' && args.openLinkLabel !== '' ? args.openLinkLabel : 'Open link in browser'; + const copyLinkLabel = typeof args.copyLinkLabel === 'string' && args.copyLinkLabel !== '' ? args.copyLinkLabel : 'Copy link address'; const imageLabel = typeof args.imageLabel === 'string' && args.imageLabel !== '' ? args.imageLabel : 'Copy image'; const template: MenuItemConstructorOptions[] = []; + if (linkUrl !== '') { + template.push( + { label: openLinkLabel, click: () => void shell.openExternal(linkUrl) }, + { label: copyLinkLabel, click: () => clipboard.writeText(linkUrl) }, + ); + } + // Image "Copy image" leads the menu when present. if (typeof args.imageData === 'string' && args.imageData !== '') { const data = args.imageData; + if (template.length > 0) template.push({ type: 'separator' }); template.push({ label: imageLabel, click: () => { @@ -44,6 +57,7 @@ export const menuHandlers: Record = { } else if (args.image === true && typeof args.x === 'number' && typeof args.y === 'number') { const x = Math.round(args.x); const y = Math.round(args.y); + if (template.length > 0) template.push({ type: 'separator' }); template.push({ label: imageLabel, click: () => wc.copyImageAt(x, y) }); } @@ -56,9 +70,10 @@ export const menuHandlers: Record = { { type: 'separator' }, { role: 'selectAll' }, ); - } else if (hasSelection) { + } else if (hasSelection || args.selectAll === true) { if (template.length > 0) template.push({ type: 'separator' }); - template.push({ role: 'copy' }, { type: 'separator' }, { role: 'selectAll' }); + if (hasSelection) template.push({ role: 'copy' }, { type: 'separator' }); + template.push({ role: 'selectAll' }); } if (template.length === 0) return; Menu.buildFromTemplate(template).popup({ window: ctx.win }); diff --git a/desktop/electron/src/main.ts b/desktop/electron/src/main.ts index 9ddf5d63..f9880048 100644 --- a/desktop/electron/src/main.ts +++ b/desktop/electron/src/main.ts @@ -10,7 +10,15 @@ /// M1.1 wires the shell + the platform-helper and migration command families; /// the hub transport goes renderer-direct (plan §7 rows 1–2), keychain / files / /// dialogs / draw.io land in later M1 slices. -import { app, BrowserWindow, ipcMain, session, shell } from 'electron'; +import { + app, + BrowserWindow, + ipcMain, + Menu, + session, + shell, + type MenuItemConstructorOptions, +} from 'electron'; import path from 'node:path'; import './schemes'; // registers privileged app:// + drawio:// before app ready import { APP_ORIGIN, registerAppScheme } from './appscheme'; @@ -36,7 +44,7 @@ import './uihighlight_host'; // Coworking H: importing the module registers the navigate (desktop_open) // provider. Its IPC reply handler is registered separately, in ipc/dispatch. import './desktopopen_host'; -import { initEvents } from './events'; +import { emit, initEvents } from './events'; // The frontend build. In dev (`electron .` from desktop/electron) it resolves to // desktop/dist; in a packaged app electron-builder ships it as an `extraResource` @@ -56,6 +64,72 @@ const ICON = path.join(__dirname, '..', 'assets', 'icon.png'); let mainWindow: BrowserWindow | null = null; +type ShellCommand = 'settings' | 'toggle-navigation'; + +function sendShellCommand(action: ShellCommand): void { + const win = mainWindow; + if (win === null || win.webContents.isDestroyed()) return; + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); + emit(win.webContents, 'shell:command', { action }); +} + +function installApplicationMenu(): void { + // `app.name` comes from package.json (`termipod-electron`, or the distinct + // review-build lock name), not the product name macOS shows. The packaged + // executable is the stable display name and keeps these native labels correct + // for both TermiPod and side-by-side review builds. + const displayName = app.isPackaged ? path.basename(process.execPath) : 'TermiPod'; + const appMenu: MenuItemConstructorOptions = { + label: displayName, + submenu: [ + { label: `About ${displayName}`, click: () => app.showAboutPanel() }, + { type: 'separator' }, + { + label: 'Settings…', + accelerator: 'CommandOrControl+,', + click: () => sendShellCommand('settings'), + }, + { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, + { label: `Hide ${displayName}`, accelerator: 'Command+H', click: () => app.hide() }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { label: `Quit ${displayName}`, accelerator: 'Command+Q', click: () => app.quit() }, + ], + }; + const template: MenuItemConstructorOptions[] = [ + ...(process.platform === 'darwin' ? [appMenu] : []), + { role: 'fileMenu' }, + { role: 'editMenu' }, + { + label: 'View', + submenu: [ + { + label: 'Toggle Navigation', + accelerator: 'CommandOrControl+Shift+B', + click: () => sendShellCommand('toggle-navigation'), + }, + { type: 'separator' }, + { role: 'reload' }, + { role: 'forceReload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' }, + ], + }, + { role: 'windowMenu' }, + ]; + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} + function createWindow(): void { const win = new BrowserWindow({ width: 1280, @@ -63,6 +137,15 @@ function createWindow(): void { minWidth: 900, minHeight: 600, title: 'TermiPod — Desktop Workbench', + // On macOS the native title row duplicated the app's own persistent chrome + // and cost ~52px on every workbench. hiddenInset keeps the standard traffic + // lights/window behaviour while allowing the app surface to fill that row. + ...(process.platform === 'darwin' + ? { + titleBarStyle: 'hiddenInset' as const, + trafficLightPosition: { x: 14, y: 14 }, + } + : {}), icon: ICON, backgroundColor: '#0b0b10', show: false, // paint-free first frame; reveal on ready-to-show @@ -96,6 +179,11 @@ function createWindow(): void { if (!url.startsWith(APP_ORIGIN)) e.preventDefault(); }); win.once('ready-to-show', () => win.show()); + const publishFullScreen = (): void => { + emit(win.webContents, 'shell:fullscreen', { fullScreen: win.isFullScreen() }); + }; + win.on('enter-full-screen', publishFullScreen); + win.on('leave-full-screen', publishFullScreen); win.on('closed', () => { if (mainWindow === win) mainWindow = null; setShellWindow(null); @@ -139,6 +227,7 @@ if (!app.requestSingleInstanceLock()) { ); } initEvents(); + installApplicationMenu(); registerAppScheme(session.defaultSession, DIST); registerDrawioScheme(session.defaultSession); // defaultSession ONLY: webview guests run in isolated partitions, so diff --git a/desktop/electron/src/webtab.ts b/desktop/electron/src/webtab.ts index e10cd8b6..1ce48521 100644 --- a/desktop/electron/src/webtab.ts +++ b/desktop/electron/src/webtab.ts @@ -90,6 +90,9 @@ async function applyProxy(proxy: string | null): Promise { // defaults below cover the pre-push window (a guest can't exist before the // renderer has mounted and pushed) and the unit-test path. const guestMenuLabels: Record = { + back: 'Back', + forward: 'Forward', + reload: 'Reload', openLink: 'Open link in browser', copyLink: 'Copy link address', copyImage: 'Copy image', @@ -104,6 +107,15 @@ const guestMenuLabels: Record = { /// a guest, so target `wc` directly). function runGuestMenuAction(wc: Electron.WebContents, action: GuestMenuAction, params: Electron.ContextMenuParams): void { switch (action) { + case 'back': + if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); + break; + case 'forward': + if (wc.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); + break; + case 'reload': + wc.reload(); + break; case 'openLink': if (isSafeExternal(params.linkURL)) void shell.openExternal(params.linkURL); break; @@ -134,6 +146,8 @@ function runGuestMenuAction(wc: Electron.WebContents, action: GuestMenuAction, p /// window so the menu is anchored correctly across multiple windows. function popupGuestContextMenu(wc: Electron.WebContents, params: Electron.ContextMenuParams): void { const items = buildGuestMenuTemplate({ + canGoBack: wc.navigationHistory.canGoBack(), + canGoForward: wc.navigationHistory.canGoForward(), linkURL: isSafeExternal(params.linkURL) ? params.linkURL : '', isImage: params.mediaType === 'image' && params.hasImageContents, isEditable: params.isEditable, diff --git a/desktop/electron/src/webtab_policy.test.ts b/desktop/electron/src/webtab_policy.test.ts index c53f72bb..0d16af16 100644 --- a/desktop/electron/src/webtab_policy.test.ts +++ b/desktop/electron/src/webtab_policy.test.ts @@ -106,10 +106,12 @@ test('isLoopbackHttpUrl: direct predicate spot-checks', () => { // ── Guest context-menu template ────────────────────────────────────────────── // This is why kimiweb/webtab now HAVE a right-click menu at all: the guest's // `context-menu` is handled main-side (webtab.ts), building a native menu from -// this descriptor. The blank base = "nothing under the cursor" (which must -// yield NO menu, not an empty one). +// this descriptor. The blank base = "nothing under the cursor"; navigation +// still keeps that menu useful, like a regular browser. const NONE: GuestMenuContext = { + canGoBack: false, + canGoForward: false, linkURL: '', isImage: false, isEditable: false, @@ -122,13 +124,18 @@ const NONE: GuestMenuContext = { const actions = (items: ReturnType): string[] => items.map((it) => (it === 'separator' ? '|' : it.action)); -test('guest menu: nothing useful under the cursor ⇒ no menu', () => { - assert.deepEqual(buildGuestMenuTemplate(NONE), []); +test('guest menu: whitespace still offers browser navigation', () => { + const items = buildGuestMenuTemplate(NONE); + assert.deepEqual(actions(items), ['back', 'forward', 'reload']); + const byAction = Object.fromEntries(items.filter((it) => it !== 'separator').map((it) => [it.action, it.enabled])); + assert.equal(byAction.back, false); + assert.equal(byAction.forward, false); + assert.equal(byAction.reload, true); }); test('guest menu: a plain text selection offers copy + select-all', () => { const items = buildGuestMenuTemplate({ ...NONE, selectionText: 'hello' }); - assert.deepEqual(actions(items), ['copy', '|', 'selectAll']); + assert.deepEqual(actions(items), ['back', 'forward', 'reload', '|', 'copy', '|', 'selectAll']); }); test('guest menu: an editable field offers the full edit set, enabled per editFlags', () => { @@ -141,7 +148,7 @@ test('guest menu: an editable field offers the full edit set, enabled per editFl canPaste: true, canSelectAll: true, }); - assert.deepEqual(actions(items), ['cut', 'copy', 'paste', '|', 'selectAll']); + assert.deepEqual(actions(items), ['back', 'forward', 'reload', '|', 'cut', 'copy', 'paste', '|', 'selectAll']); // editFlags drive enabled state (an empty clipboard ⇒ paste disabled, no // selection ⇒ cut/copy disabled) — Chromium's own accounting. const empty = buildGuestMenuTemplate({ ...NONE, isEditable: true }); @@ -159,9 +166,13 @@ test('guest menu: a link leads, then image, then text (browser ordering)', () => isImage: true, selectionText: 'sel', }); - assert.deepEqual(actions(items), ['openLink', 'copyLink', '|', 'copyImage', '|', 'copy', '|', 'selectAll']); + assert.deepEqual(actions(items), [ + 'back', 'forward', 'reload', '|', 'openLink', 'copyLink', '|', 'copyImage', '|', 'copy', '|', 'selectAll', + ]); }); test('guest menu: an image alone offers copy image only', () => { - assert.deepEqual(actions(buildGuestMenuTemplate({ ...NONE, isImage: true })), ['copyImage']); + assert.deepEqual(actions(buildGuestMenuTemplate({ ...NONE, isImage: true })), [ + 'back', 'forward', 'reload', '|', 'copyImage', + ]); }); diff --git a/desktop/electron/src/webtab_policy.ts b/desktop/electron/src/webtab_policy.ts index 3071e038..bffa34a7 100644 --- a/desktop/electron/src/webtab_policy.ts +++ b/desktop/electron/src/webtab_policy.ts @@ -92,7 +92,17 @@ export function partitionPolicy(partition: string): PartitionPolicy | null { // descriptor, whose item set / ordering / enabled-state is exercised by // webtab_policy.test.ts without booting Electron. -export type GuestMenuAction = 'openLink' | 'copyLink' | 'copyImage' | 'cut' | 'copy' | 'paste' | 'selectAll'; +export type GuestMenuAction = + | 'back' + | 'forward' + | 'reload' + | 'openLink' + | 'copyLink' + | 'copyImage' + | 'cut' + | 'copy' + | 'paste' + | 'selectAll'; export type GuestMenuItem = { action: GuestMenuAction; enabled: boolean } | 'separator'; @@ -100,6 +110,8 @@ export type GuestMenuItem = { action: GuestMenuAction; enabled: boolean } | 'sep /// `ContextMenuParams`. `linkURL` is already emptied by the caller when the URL /// isn't a safe external, so this pure logic needn't know the scheme rules. export interface GuestMenuContext { + canGoBack: boolean; + canGoForward: boolean; linkURL: string; isImage: boolean; isEditable: boolean; @@ -110,16 +122,21 @@ export interface GuestMenuContext { canSelectAll: boolean; } -/// Build the ordered guest context-menu descriptor. Empty ⇒ no menu (nothing -/// useful to offer — e.g. a right-click on non-editable whitespace with no -/// selection, link, or image). Ordering mirrors a browser's: link, image, text. +/// Build the ordered guest context-menu descriptor. Navigation is always present +/// so right-clicking page whitespace still behaves like a browser; contextual +/// link, image and edit actions follow it. export function buildGuestMenuTemplate(ctx: GuestMenuContext): GuestMenuItem[] { - const out: GuestMenuItem[] = []; + const out: GuestMenuItem[] = [ + { action: 'back', enabled: ctx.canGoBack }, + { action: 'forward', enabled: ctx.canGoForward }, + { action: 'reload', enabled: true }, + ]; const sep = (): void => { if (out.length > 0) out.push('separator'); }; if (ctx.linkURL !== '') { + sep(); out.push({ action: 'openLink', enabled: true }, { action: 'copyLink', enabled: true }); } if (ctx.isImage) { diff --git a/desktop/src/i18n/index.ts b/desktop/src/i18n/index.ts index dd29c940..1a606d05 100644 --- a/desktop/src/i18n/index.ts +++ b/desktop/src/i18n/index.ts @@ -1316,6 +1316,8 @@ const en: Dict = { 'read.modeLibrary': 'Library', 'read.modeDiscover': 'Discover', + 'read.modeBrowser': 'Browser', + 'read.libraryActions': 'Library actions', 'read.importZotero': 'Import Zotero', 'read.importHint': 'Import a Zotero library (zotero.sqlite) — parsed on-device, nothing uploaded.', 'read.importing': 'Importing…', @@ -1572,6 +1574,7 @@ const en: Dict = { 'read.colType': 'Type', 'read.browserBack': 'Back', 'read.browserForward': 'Forward', + 'read.browserNavigation': 'Document navigation', 'read.browserReload': 'Reload', 'read.openInWindow': 'Open in browser window (loads any site)', 'read.browserAddressPlaceholder': 'Enter a URL or address', @@ -1589,6 +1592,8 @@ const en: Dict = { 'read.showDetails': 'Show details', 'author.docTabs': 'Open documents', + 'author.tabsScrollBack': 'Scroll to earlier documents', + 'author.tabsScrollForward': 'Scroll to later documents', 'author.words_one': '{n} word', 'author.words_other': '{n} words', 'author.placeholder': 'Write in Markdown — $math$ and ```code``` supported…', @@ -1640,6 +1645,7 @@ const en: Dict = { 'author.navOpenFolder': 'Open folder…', 'author.navCloseFolder': 'Close folder', 'author.navRefresh': 'Refresh', + 'author.navMoreActions': 'Workspace actions', 'author.filterFiles': 'Filter files…', 'author.noFilterMatch': 'No files match the filter.', 'author.navSync': 'Sync workspace…', @@ -1741,6 +1747,7 @@ const en: Dict = { 'companion.localNoFolder': 'No workspace folder open — the agent runs in the default directory. Open a folder to scope it to your workspace.', 'annotate.ask': 'Ask agent — select a region to share', + 'annotate.enableHint': 'Enable UI context sharing in Settings → Assistant to annotate', 'annotate.chip': 'Annotate', 'annotate.title': 'Annotation', 'annotate.hint': 'Drag to select a region · Esc to cancel', @@ -3443,6 +3450,8 @@ const zh: Dict = { 'read.modeLibrary': '文库', 'read.modeDiscover': '发现', + 'read.modeBrowser': '浏览器', + 'read.libraryActions': '文库操作', 'read.importZotero': '导入 Zotero', 'read.importHint': '导入 Zotero 文库(zotero.sqlite)— 在本地解析,不会上传任何内容。', 'read.importing': '导入中…', @@ -3694,6 +3703,7 @@ const zh: Dict = { 'read.colType': '类型', 'read.browserBack': '后退', 'read.browserForward': '前进', + 'read.browserNavigation': '文档导航', 'read.browserReload': '重新加载', 'read.openInWindow': '在浏览器窗口中打开(可加载任何网站)', 'read.browserAddressPlaceholder': '输入网址或地址', @@ -3711,6 +3721,8 @@ const zh: Dict = { 'read.showDetails': '显示详情', 'author.docTabs': '打开的文档', + 'author.tabsScrollBack': '滚动到较早的文档', + 'author.tabsScrollForward': '滚动到较后的文档', 'author.words': '{n} 词', 'author.placeholder': '用 Markdown 撰写 — 支持 $数学$ 与 ```代码```…', 'author.empty': '暂无可预览内容。', @@ -3761,6 +3773,7 @@ const zh: Dict = { 'author.navOpenFolder': '打开文件夹…', 'author.navCloseFolder': '关闭文件夹', 'author.navRefresh': '刷新', + 'author.navMoreActions': '工作区操作', 'author.filterFiles': '筛选文件…', 'author.noFilterMatch': '没有匹配筛选的文件。', 'author.navSync': '同步工作区…', @@ -3861,6 +3874,7 @@ const zh: Dict = { 'companion.localNoFolder': '未打开工作区文件夹——智能体将在默认目录中运行。打开文件夹以将其限定到你的工作区。', 'annotate.ask': '询问智能体 — 框选区域共享', + 'annotate.enableHint': '请在“设置 → 助手”中启用界面上下文共享后再标注', 'annotate.chip': '标注', 'annotate.title': '标注', 'annotate.hint': '拖拽框选区域 · Esc 取消', diff --git a/desktop/src/nativeContextMenu.ts b/desktop/src/nativeContextMenu.ts index bc998759..8f87fc2d 100644 --- a/desktop/src/nativeContextMenu.ts +++ b/desktop/src/nativeContextMenu.ts @@ -26,6 +26,9 @@ import { svgElementToPngDataUrl } from './ui/rasterizeSvg'; /// and re-pushed on every language change (the labels are cached main-side). function syncGuestMenuLabels(): void { void invoke('webtab_set_menu_labels', { + back: tStatic('read.browserBack'), + forward: tStatic('read.browserForward'), + reload: tStatic('read.browserReload'), openLink: tStatic('ctx.openLink'), copyLink: tStatic('ctx.copyLink'), copyImage: tStatic('common.copyImage'), diff --git a/desktop/src/styles/partials/01-base-shell.css b/desktop/src/styles/partials/01-base-shell.css index 50d8a13e..0cc03c0b 100644 --- a/desktop/src/styles/partials/01-base-shell.css +++ b/desktop/src/styles/partials/01-base-shell.css @@ -4,39 +4,54 @@ * --font-size-*) generated from design-tokens/tokens.json (WS1). Dark-first, * matching the mobile app's backgroundDark palette. */ -/* Semantic theme layer — the elevated design language (2026-07). Kept - * desktop-local (does NOT mutate the shared design-tokens/tokens.json, which the - * mobile Flutter build mirrors): refined cool blue-biased neutrals with a real - * elevation scale, focus rings, motion, and a disciplined accent. Switched by - * ; dark is the default. */ +/* Semantic theme layer — desktop-local (does NOT mutate the shared + * design-tokens/tokens.json mirrored by Flutter). Warm monochrome graphite is + * the default product language: hierarchy comes from tonal zoning, typography + * and spacing, while colour is reserved for semantic status. */ :root { color-scheme: dark; - --pane-gap: 1px; + /* Pane separation is drawn by ResizeHandle itself. A layout gap beside that + divider produced a second parallel line (and a dark gutter in light mode). */ + --pane-gap: 0px; /* Surface ladder — a tight near-black scale (Linear/Radix-grounded); 1px * hairlines carry separation, so the steps stay subtle and clean, not muddy. */ - --bg: #08090c; /* app ground */ - --canvas: #0d0f13; /* navigator + dock rails */ - --surface: #131519; /* cards, panels, popovers */ - --raised: #191c22; /* buttons, hover targets, inset controls */ - --hover: #20242b; /* row / interactive hover fill */ - --input: #0b0c10; - --border: #23262d; /* hairline (Radix step 6/7) */ - --border-strong: #31363f; /* interactive / focus border (step 8) */ - --text: #f4f6f8; /* high-contrast ink */ - --text-secondary: #a7aeba; - --text-muted: #8b939f; /* WCAG AA: 4.5:1+ on --surface (was #6a727f ≈3.8:1) */ - /* accent — brand cyan, reserved for focus / selection / live data / one CTA */ - --accent: var(--color-primary); - --accent-strong: var(--color-primary-dark); - --accent-ink: #00252b; + --bg: #171717; /* focused content canvas */ + --canvas: #242424; /* navigation / inspector panes + persistent chrome */ + --surface: #1f1f1f; /* cards and nested panel surfaces */ + --raised: #2f2f2f; /* controls and genuinely raised regions */ + --hover: #333333; /* interactive hover / selected neutral fill */ + --input: #212121; + --border: #303030; /* low-contrast hairline between tonal zones */ + --border-strong: #4a4a4a; /* interactive / focus border */ + --text: #ececec; /* high-contrast ink */ + --text-secondary: #b4b4b4; + --text-muted: #8b8b8b; + /* ChatGPT-like default: monochrome interaction emphasis. Accent colours may + * be user-customisable later, but the shipped visual language does not depend + * on one. Semantic green/amber/red remain independent below. */ + --accent: #ececec; + --accent-strong: #d4d4d4; + --accent-ink: #171717; /* accent used as TEXT — on dark surfaces the bright accent already clears AA, * so it's the accent itself; the light theme overrides it to a darker teal * (see below) because the light accent as text is only ~2.9:1 on white. Route * accent-coloured text through this, NOT --accent (which is tuned for fills, * borders and focus rings). */ - --accent-text: var(--accent); + --accent-text: #f4f4f4; --accent-tint: color-mix(in srgb, var(--accent) 12%, transparent); --accent-line: color-mix(in srgb, var(--accent) 42%, transparent); + /* Soft-glass chrome — translucency is intentionally limited to persistent + * navigation, toolbar and overlay surfaces. Reading/editing canvases stay + * opaque so depth never competes with content. Half-pixel rules render as + * true hairlines on Retina displays instead of boxed panel borders. */ + --hairline: 0.5px; + --glass-pane: color-mix(in srgb, var(--canvas) 92%, transparent); + --glass-surface: color-mix(in srgb, var(--surface) 90%, transparent); + --glass-control: color-mix(in srgb, var(--raised) 88%, transparent); + --glass-rim: color-mix(in srgb, var(--text) 10%, transparent); + --glass-highlight: color-mix(in srgb, var(--text) 6%, transparent); + --fullscreen-nav: #292929; + --glass-filter: blur(20px) saturate(1.08); /* semantic state — distinct from the accent hue */ --ok: var(--color-success); --warn: var(--color-warning); @@ -99,6 +114,14 @@ --track-tight: -0.018em; --track-snug: -0.011em; --track-body: -0.006em; + /* Control geometry. One compact ladder replaces per-component heights and + * makes toolbars, dialogs and dense workbench chrome feel related. */ + --control-sm: 28px; + --control-md: 32px; + --control-lg: 40px; + --radius-control: 6px; + --radius-card: 10px; + --radius-overlay: 14px; /* Phantom-token aliases (#318). These names were referenced across the sheet * through an inline var() fallback but never actually defined — so the fallback * was silently load-bearing (and inconsistent: one fell back to a raw #fff). @@ -127,27 +150,29 @@ } :root[data-theme='light'] { color-scheme: light; - --bg: #f7f8fa; - --canvas: #eef0f3; + --bg: #ffffff; + --canvas: #f4f4f4; --surface: #ffffff; - --raised: #ffffff; - --hover: #e4e9f0; /* was #eef1f5 ≈1.1:1 on white — nearly invisible */ - --input: #f6f7f9; - --border: #e6e9ee; - --border-strong: #d4d9e1; - --text: #0f141c; - --text-secondary: #586173; - --text-muted: #6b7280; /* WCAG AA: 4.66:1 on --bg (was #8a93a3 ≈2.91:1) */ - --accent: var(--color-primary-dark); - --accent-strong: var(--color-primary-dark); - /* Dark ink on the solid accent fill — white was only 3.39:1 on the light - * accent; dark ink is ≈5:1 (mirrors the dark theme's dark-on-accent ink). */ - --accent-ink: #00252b; - /* Accent-as-text darkened for AA: the light accent (#009AA8) is ~2.9:1 on - * white; this teal is ~5:1, so links/labels/active tabs stay readable. */ - --accent-text: #007a8a; + --raised: #f4f4f4; + --hover: #e8e8e8; + --input: #f7f7f7; + --border: #e3e3e3; + --border-strong: #c7c7c7; + --text: #212121; + --text-secondary: #5d5d5d; + --text-muted: #737373; + --accent: #2f2f2f; + --accent-strong: #171717; + --accent-ink: #ffffff; + --accent-text: #212121; --accent-tint: color-mix(in srgb, var(--accent) 10%, transparent); --accent-line: color-mix(in srgb, var(--accent) 40%, transparent); + --glass-pane: color-mix(in srgb, var(--canvas) 88%, transparent); + --glass-surface: color-mix(in srgb, var(--surface) 88%, transparent); + --glass-control: color-mix(in srgb, var(--raised) 80%, transparent); + --glass-rim: color-mix(in srgb, var(--text) 12%, transparent); + --glass-highlight: color-mix(in srgb, var(--surface) 78%, transparent); + --fullscreen-nav: #eaeae8; --ok: var(--color-success-on-light); --warn: var(--color-on-warning-container-light); --danger: var(--color-error); @@ -169,6 +194,30 @@ * { box-sizing: border-box; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--text-muted) 42%, transparent) transparent; +} +/* ChatGPT-style overlay scrollbars: transparent track and a narrow, quiet thumb + that gains contrast only on hover. Individual widgets may still override the + physical width when their layout engine reserves a fixed gutter (xterm). */ +*::-webkit-scrollbar { + width: 8px; + height: 8px; +} +*::-webkit-scrollbar-track, +*::-webkit-scrollbar-corner { + background: transparent; +} +*::-webkit-scrollbar-thumb { + min-height: 28px; + background: color-mix(in srgb, var(--text-muted) 42%, transparent); + border: 2px solid transparent; + border-radius: var(--radius-stadium); + background-clip: padding-box; +} +*::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--text-secondary) 68%, transparent); + background-clip: padding-box; } html, @@ -176,6 +225,10 @@ body, #root { height: 100%; margin: 0; + /* The desktop shell owns scrolling inside its bounded panes. Letting the + document itself scroll exposes a second scrollbar at the window edge when + native titlebar insets or floating chrome extend by even a fraction. */ + overflow: hidden; } body { @@ -236,6 +289,56 @@ pre { min-height: 0; } +/* Electron macOS uses BrowserWindow.hiddenInset: content replaces the redundant + native title row while the real traffic lights remain. Reserve their compact + corner only inside the activity rail; the active workbench toolbar occupies + the rest of that same top band. App-region rules preserve native dragging + without swallowing clicks on toolbar controls. */ +.shell-macos .activity-bar { + padding-top: 46px; + position: relative; + border-right-color: transparent; + box-shadow: none; + -webkit-app-region: drag; +} +.shell-macos.is-fullscreen .activity-bar { + padding-top: var(--spacing-s8); +} +.shell.is-fullscreen .activity-bar { + background: var(--fullscreen-nav); +} +.shell-macos .activity-bar::after { + content: ''; + position: absolute; + top: 46px; + right: 0; + bottom: 0; + width: var(--hairline); + background: var(--glass-rim); + box-shadow: 1px 0 0 var(--glass-highlight); + pointer-events: none; +} +.shell-macos .fleet-toolbar, +.shell-macos .surface-head, +.shell-macos .settings-cats, +.shell-macos .settings-content-title, +.shell-macos .term-nav, +.shell-macos .term-head { + -webkit-app-region: drag; +} +.shell-macos .term-nav-actions, +.shell-macos .term-nav-list { + -webkit-app-region: no-drag; +} +.shell-macos button, +.shell-macos input, +.shell-macos select, +.shell-macos textarea, +.shell-macos [role='button'], +.shell-macos [role='tab'] { + -webkit-app-region: no-drag; +} + /* The main middle band: the activity-bar rail + the active job surface. Grows to fill between the titlebar and the terminal dock / status bar. */ .workbench-row { @@ -243,6 +346,21 @@ pre { min-height: 0; display: flex; } +/* With the rail hidden, macOS traffic lights still own the leading corner. + Ordinary page headers share that title band, so shift their first content + past the native controls. Settings and Terminal instead reserve a compact + draggable top inset in their own left panes. */ +.shell-macos.rail-hidden:not(.is-fullscreen) .fleet-toolbar, +.shell-macos.rail-hidden:not(.is-fullscreen) .surface-head { + padding-left: 84px; +} +.shell-macos.rail-hidden:not(.is-fullscreen) .settings-cats, +.shell-macos.rail-hidden:not(.is-fullscreen) .term-panel.surface .term-nav:not(.folded) { + padding-top: 46px; +} +.shell-macos.rail-hidden:not(.is-fullscreen) .term-panel.surface .term-nav.folded ~ .term-main .term-head { + padding-left: 84px; +} .workbench-main { flex: 1; min-width: 0; @@ -315,17 +433,25 @@ pre { display: flex; align-items: center; gap: var(--spacing-s4); + min-height: 42px; padding: var(--spacing-s4) var(--spacing-s12); - background: var(--canvas); - border-bottom: 1px solid var(--border); + background: var(--glass-pane); + border-bottom: var(--hairline) solid var(--glass-rim); + -webkit-backdrop-filter: var(--glass-filter); + backdrop-filter: var(--glass-filter); + box-shadow: inset 0 1px 0 var(--glass-highlight); } .fleet-toolbar-label { - font-size: var(--font-size-label); - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--text-muted); + font-size: var(--font-size-body-small); + letter-spacing: var(--track-snug); + color: var(--text); font-weight: 600; } +.fleet-toolbar button { + min-height: var(--control-sm); + padding: 4px 9px; + font-size: var(--font-size-body-small); +} .fleet-toolbar-sep { width: 1px; height: 16px; @@ -342,8 +468,10 @@ pre { align-items: center; gap: var(--spacing-s16); padding: 0 var(--spacing-s16); - background: linear-gradient(180deg, var(--canvas), var(--surface)); - border-bottom: 1px solid var(--border); + background: var(--glass-pane); + border-bottom: var(--hairline) solid var(--glass-rim); + -webkit-backdrop-filter: var(--glass-filter); + backdrop-filter: var(--glass-filter); } .titlebar { @@ -354,9 +482,13 @@ pre { .statusbar { height: 28px; - border-top: 1px solid var(--border); + position: relative; + z-index: var(--z-nav); + overflow: visible; + border-top: var(--hairline) solid var(--glass-rim); border-bottom: none; - background: linear-gradient(180deg, var(--surface), var(--canvas)); + background: var(--glass-pane); + box-shadow: inset 0 1px 0 var(--glass-highlight); font-size: var(--font-size-caption); font-variant-numeric: tabular-nums; color: var(--text-secondary); @@ -376,7 +508,19 @@ pre { } .statusbar-term:hover, .statusbar-term.active { - color: var(--accent-text); + color: var(--text); +} +.statusbar-term.active { + background: var(--hover); +} +.statusbar-count { + color: var(--text-secondary); +} +.statusbar-count.attention { + color: var(--warn); +} +.statusbar-hosts.quiet { + color: var(--text-muted); } /* Background-sync indicator (Author workspace + Read/Zotero library jobs). Shown @@ -417,7 +561,7 @@ pre { display: flex; gap: var(--pane-gap); min-height: 0; - background: var(--border); + background: var(--bg); } .shell-body > .navigator { flex: 0 0 auto; /* width set inline by MissionLayout (resizable) */ @@ -466,24 +610,64 @@ pre { white-space: nowrap; } -/* Nav fold toggle (left of the Fleet/Projects toolbar). */ -.nav-fold-btn { +/* One visual language for pane visibility throughout the workbench: a compact + 28px ghost control with the sidebar glyph. Direction is conveyed by mirroring + the glyph for right-hand panes, not by changing button shape or weight. */ +.pane-toggle { + flex: 0 0 auto; + width: 28px; + height: 28px; + min-height: 28px; display: inline-flex; align-items: center; justify-content: center; - padding: 4px; - color: var(--text-secondary); + padding: 0; + color: var(--text-muted); background: transparent; - border: 1px solid transparent; - border-radius: var(--radius-sm); + border: var(--hairline) solid transparent; + border-radius: var(--radius-control); + cursor: pointer; } -.nav-fold-btn:hover { +.pane-toggle:hover { color: var(--text); - background: var(--surface); + background: var(--hover); +} +.nav-fold-btn { + color: var(--text-secondary); } .nav-fold-btn.active { color: var(--accent-text); } +.pane-control-row { + display: flex; + align-items: center; + gap: var(--spacing-s8); +} +.pane-control-row > .pane-toggle:last-child { + margin-left: auto; +} + +/* Fleet and Projects keep their collapse control inside the pane it affects. + The scrolling content receives enough top-right breathing room that the + control never overlaps the first segmented row. */ +.mission-nav { + position: relative; + overflow: hidden; +} +.mission-nav-scroll { + height: 100%; + min-height: 0; + overflow: auto; +} +.mission-nav-fold { + position: absolute; + top: var(--spacing-s8); + right: var(--spacing-s8); + z-index: var(--z-nav); +} +.mission-nav .nav-subtabs { + margin-right: 44px; +} /* Agents/Hosts and Projects/Workspaces subtab bar at the top of a nav. */ .nav-subtabs { @@ -503,43 +687,63 @@ pre { } .region-header { + min-height: 36px; padding: var(--spacing-s8) var(--spacing-s12); - font-size: var(--font-size-caption); - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--text-muted); + font-size: var(--font-size-body-small); + font-weight: 550; + letter-spacing: var(--track-snug); + color: var(--text-secondary); position: sticky; top: 0; - background: var(--canvas); - border-bottom: 1px solid var(--border); + background: var(--glass-pane); + border-bottom: var(--hairline) solid var(--glass-rim); + -webkit-backdrop-filter: var(--glass-filter); + backdrop-filter: var(--glass-filter); + box-shadow: inset 0 1px 0 var(--glass-highlight); } .navigator, .dock { - background: var(--canvas); + background: var(--glass-pane); + -webkit-backdrop-filter: var(--glass-filter); + backdrop-filter: var(--glass-filter); } -/* Foldable region header (attention dock): title on the left, fold toggle on - the right. */ -.region-header.foldable { +/* Empty states are intentionally quiet and composed: a small semantic glyph, + one useful line, and no decorative card competing with real content. */ +.empty-state { + min-height: 160px; display: flex; + flex-direction: column; align-items: center; - justify-content: space-between; + justify-content: center; gap: var(--spacing-s8); + padding: var(--spacing-s24); + color: var(--text-muted); + text-align: center; + font-size: var(--font-size-body-small); } -.dock-fold-btn { +.empty-state-icon { + width: 32px; + height: 32px; display: inline-flex; align-items: center; justify-content: center; - padding: 2px; - color: var(--text-muted); - background: transparent; - border: 1px solid transparent; - border-radius: var(--radius-sm); + border-radius: var(--radius-stadium); + color: var(--text-secondary); + background: var(--hover); } -.dock-fold-btn:hover { - color: var(--text); - background: var(--surface); +.dock .empty-state { + min-height: 120px; +} + +/* Foldable region header (attention dock): title on the left, fold toggle on + the right. */ +.region-header.foldable { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-s8); } /* Collapsed dock: a thin vertical rail that re-opens the attention panel; its label reads bottom-to-top so the narrow rail stays readable. */ @@ -551,9 +755,9 @@ pre { gap: var(--spacing-s8); width: 30px; padding: var(--spacing-s8) 0; - background: var(--canvas); + background: var(--glass-pane); border: none; - border-left: 1px solid var(--border); + border-left: var(--hairline) solid var(--glass-rim); border-radius: 0; color: var(--text-muted); cursor: pointer; diff --git a/desktop/src/styles/partials/02-job-surfaces.css b/desktop/src/styles/partials/02-job-surfaces.css index 22743c0e..d12de3f2 100644 --- a/desktop/src/styles/partials/02-job-surfaces.css +++ b/desktop/src/styles/partials/02-job-surfaces.css @@ -4,25 +4,31 @@ .activity-bar { flex: 0 0 auto; - width: 68px; + width: 72px; display: flex; flex-direction: column; gap: var(--spacing-s2); - padding: var(--spacing-s8) var(--spacing-s4); - background: var(--canvas); - border-right: 1px solid var(--border); + padding: var(--spacing-s8) var(--spacing-s6); + background: var(--glass-pane); + border-right: var(--hairline) solid var(--glass-rim); + box-shadow: inset -1px 0 0 var(--glass-highlight); + /* Do not apply backdrop-filter here. It creates a stacking context that traps + the profile popover under the adjacent workbench pane. The translucent + surface still reads as glass; blur belongs on the floating menu itself. */ /* Visible (not auto) so the hub-switcher dropdown at the top can escape the narrow rail rather than being clipped; the job list owns the scroll. */ overflow: visible; } .activity-tab { + position: relative; display: flex; flex-direction: column; align-items: center; gap: 2px; - padding: var(--spacing-s8) var(--spacing-s2); - border: 1px solid transparent; - border-radius: var(--radius-sm); + min-height: 52px; + padding: 7px var(--spacing-s2); + border: 0; + border-radius: var(--radius-control); background: transparent; color: var(--text-secondary); cursor: pointer; @@ -33,9 +39,18 @@ color: var(--text); } .activity-tab.active { - background: var(--accent-tint); - color: var(--accent-text); - border-color: var(--accent-line); + background: var(--hover); + color: var(--text); +} +.activity-tab.active::before { + content: ''; + position: absolute; + left: -6px; + top: 14px; + width: 2px; + height: 24px; + border-radius: var(--radius-stadium); + background: var(--accent); } /* The job pinned as the split's secondary pane (S2). `position: relative` is on the modifier, not `.activity-tab`, so an unsplit rail keeps its old stacking. */ @@ -55,61 +70,19 @@ display: flex; align-items: center; justify-content: center; - height: 24px; + height: 22px; } .job-icon { display: block; } -/* Activity-bar hub slot (top) — the hub identity / connection chrome (profile - switcher, or offline + connect), relocated here from the bottom status bar so - the hub you're driving leads the top-left. The job list grows to fill and owns - the scroll, pinning the Settings tab to the bottom (the VS Code gear idiom). */ -.activity-hub { - flex: 0 0 auto; - display: flex; - flex-direction: column; - align-items: stretch; - gap: var(--spacing-s4); - margin-bottom: var(--spacing-s4); - padding-bottom: var(--spacing-s8); - border-bottom: 1px solid var(--border); -} -.activity-brand-mark { - display: flex; - align-items: center; - justify-content: center; - height: 30px; - font-weight: 700; - letter-spacing: 0.02em; - color: var(--accent-text); - font-size: var(--font-size-body); -} -/* Compact the relocated hub chrome to fit the 68px rail: full-width controls, - caption type, truncated profile name; the dropdown escapes rightward. */ -.activity-hub .profile-switcher { - width: 100%; -} -.activity-hub .switcher-pill { - width: 100%; - justify-content: space-between; - padding: 3px var(--spacing-s8); - font-size: var(--font-size-caption); - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} -.activity-hub .switcher-menu { - left: 0; - top: calc(100% + 4px); -} -.activity-hub .pill.offline { - justify-content: center; - font-size: var(--font-size-label); +.hub-status-dot { + width: 6px; + height: 6px; + border-radius: var(--radius-stadium); + background: var(--warn); } -.activity-hub button.primary { - width: 100%; - padding: 3px var(--spacing-s8); - font-size: var(--font-size-caption); +.hub-status-dot.online { + background: var(--success); } .activity-jobs { flex: 1 1 auto; @@ -129,21 +102,59 @@ align-items: center; gap: var(--spacing-s12); } +.statusbar-hub-cluster, +.statusbar-context, +.statusbar-hub { + min-width: 0; + display: inline-flex; + align-items: center; +} +.statusbar-hub-cluster { + gap: var(--spacing-s8); +} +.statusbar-hub { + gap: var(--spacing-s4); +} +.statusbar-hub .profile-switcher { + min-width: 0; + max-width: 140px; +} +.statusbar-hub .switcher-pill { + min-height: 22px; + max-width: 140px; + padding: 1px var(--spacing-s4); + color: var(--text-secondary); + font: inherit; + background: transparent; + border-color: transparent; +} +.statusbar-hub .switcher-pill:hover, +.statusbar-hub .switcher-pill[aria-expanded='true'] { + color: var(--text); + background: var(--hover); +} +.statusbar-hub-connect { + min-height: 22px; + display: inline-flex; + align-items: center; + gap: var(--spacing-s4); + padding: 1px var(--spacing-s4); + color: var(--text-secondary); + font: inherit; + background: transparent; + border-color: transparent; +} +.statusbar-hub-connect:hover { + color: var(--text); + background: var(--hover); +} .statusbar-palette { font-variant-numeric: tabular-nums; font-size: var(--font-size-caption); + min-height: 22px; padding: 1px 7px; -} -/* The status bar's own controls stay compact so the bar keeps its 28px height. */ -.statusbar .profile-switcher .switcher-pill { - padding: 1px 8px; - font-size: var(--font-size-caption); -} -/* Profile dropdown opens upward from the bottom bar. */ -.statusbar .switcher-menu { - top: auto; - bottom: 100%; - margin-bottom: 4px; + color: var(--text-muted); + background: transparent; } /* Shared inline icon (ui/Icon.tsx): baseline-aligned with adjacent text, never shrinks in a flex row, and a hair of optical spacing when it precedes a label. */ @@ -182,8 +193,9 @@ button > .ui-icon:not(:only-child):first-child, margin-right: 0.35em; } .activity-label { - font-size: var(--font-size-label); - letter-spacing: 0.02em; + font-size: var(--font-size-caption); + font-weight: 500; + letter-spacing: -0.005em; } /* --- shared job-surface chrome --- */ @@ -201,8 +213,11 @@ button > .ui-icon:not(:only-child):first-child, align-items: center; gap: var(--spacing-s12); padding: var(--spacing-s8) var(--spacing-s16); - background: var(--canvas); - border-bottom: 1px solid var(--border); + background: var(--glass-pane); + border-bottom: var(--hairline) solid var(--glass-rim); + box-shadow: inset 0 1px 0 var(--glass-highlight); + /* A filter here makes this header a containing/stacking context and causes + Author's fixed scrim + New menu to paint below the document tab strip. */ } .surface-icon { display: flex; @@ -216,12 +231,12 @@ button > .ui-icon:not(:only-child):first-child, display: flex; align-items: center; gap: var(--spacing-s8); - font-weight: 600; + font-weight: 550; letter-spacing: -0.01em; } .surface-tag { font-size: var(--font-size-label); - font-weight: 700; + font-weight: 650; color: var(--accent-text); background: var(--accent-tint); border-radius: var(--radius-xs); @@ -245,6 +260,44 @@ button > .ui-icon:not(:only-child):first-child, white-space: nowrap; } +/* Read carries the widest action set in the workbench. Keep it on the same + visual rhythm as Author's compact document controls: one 44px row, quiet + utility actions, and no second subtitle line consuming permanent space. */ +.surface-read .surface-head { + min-height: 44px; + gap: var(--spacing-s8); + padding: 6px var(--spacing-s12); +} +.surface-read .surface-icon .job-icon { + width: 18px; + height: 18px; +} +.surface-read .surface-titles { + display: flex; + align-items: center; +} +.surface-read .surface-title { + font-size: var(--font-size-body-small); +} +.surface-read .surface-hint { + display: none; +} +.surface-read .surface-head button { + min-height: var(--control-sm); + padding: 4px var(--spacing-s8); + font-size: var(--font-size-caption); +} +.surface-read .surface-head .import-btn { + color: var(--text-secondary); + background: transparent; + border-color: transparent; +} +.surface-read .surface-head .import-btn:hover { + color: var(--text); + background: var(--hover); + border-color: transparent; +} + /* honest placeholder for jobs whose primary EMBED hasn't shipped (J4) */ .surface-placeholder { max-width: 640px; @@ -378,30 +431,39 @@ button > .ui-icon:not(:only-child):first-child, border: 1px solid color-mix(in srgb, var(--danger) 40%, var(--border)); border-radius: 6px; } +/* Header dropdowns render through ui/PopoverMenu.tsx so pane overflow and + draggable title regions cannot clip or swallow them. */ +.popover-menu-backdrop { + position: fixed; + inset: 0; + z-index: var(--z-menu); + background: transparent; + -webkit-app-region: no-drag; +} +.popover-menu { + z-index: calc(var(--z-menu) + 1); + overflow-y: auto; + overscroll-behavior: contain; + -webkit-app-region: no-drag; +} + /* The New-figure spec dropdown. */ .author-figbtn { position: relative; display: inline-flex; } -.author-figmenu-scrim { - position: fixed; - inset: 0; - z-index: 40; -} .author-figmenu { - position: absolute; - top: 100%; - left: 0; - margin-top: 4px; - z-index: 41; - min-width: 140px; + z-index: calc(var(--z-menu) + 1); + min-width: 220px; display: flex; flex-direction: column; - padding: 4px; - background: var(--surface); - border: 1px solid var(--border); - border-radius: 8px; - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.25); + padding: var(--spacing-s4); + background: var(--glass-surface); + border: var(--hairline) solid var(--glass-rim); + border-radius: var(--radius-card); + box-shadow: var(--sh-3), inset 0 1px 0 var(--glass-highlight); + -webkit-backdrop-filter: blur(24px) saturate(1.1); + backdrop-filter: blur(24px) saturate(1.1); } .author-figmenu-item { display: flex; @@ -417,8 +479,8 @@ button > .ui-icon:not(:only-child):first-child, cursor: pointer; } .author-figmenu-item:hover { - background: var(--surface-sunken, var(--bg)); - color: var(--accent-text); + background: var(--hover); + color: var(--text); } /* Category header inside the New ▾ menu (Write / Data / Draw / Figure). */ .author-figmenu-group { @@ -1025,7 +1087,6 @@ button > .ui-icon:not(:only-child):first-child, min-width: 0; display: flex; flex-direction: column; - border-right: 1px solid var(--border); background: var(--canvas); overflow: hidden; } @@ -1503,22 +1564,14 @@ button > .ui-icon:not(:only-child):first-child, position: relative; display: inline-flex; } -.inspect-menu-scrim { - position: fixed; - inset: 0; - z-index: var(--z-overlay); -} .inspect-menu { - position: absolute; - top: calc(100% + 4px); - right: 0; min-width: 200px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); box-shadow: var(--sh-3); padding: var(--spacing-s4); - z-index: var(--z-menu); + z-index: calc(var(--z-menu) + 1); } .inspect-menu-item { display: flex; @@ -1536,6 +1589,20 @@ button > .ui-icon:not(:only-child):first-child, } .inspect-menu-item:hover { background: var(--hover); + color: var(--text); +} +.surface-read .surface-head .read-actions-trigger { + width: var(--control-sm); + min-width: var(--control-sm); + justify-content: center; + padding-inline: 0; + margin-right: 0; +} +.read-head-menu { + width: 236px; +} +.read-head-menu .inspect-menu-item.attn { + color: var(--warning); } .inspect-modal-backdrop { position: fixed; diff --git a/desktop/src/styles/partials/03-pdf.css b/desktop/src/styles/partials/03-pdf.css index f10b6543..1a366229 100644 --- a/desktop/src/styles/partials/03-pdf.css +++ b/desktop/src/styles/partials/03-pdf.css @@ -9,24 +9,74 @@ /* A draggable divider between two panes (rail | center | inspector). */ .resize-handle { flex: 0 0 auto; - width: 6px; - background: var(--border); + width: 1px; + position: relative; + z-index: 2; + background: transparent; cursor: col-resize; touch-action: none; } -.resize-handle:hover { - background: var(--accent); +.resize-handle::before { + content: ''; + position: absolute; + inset: 0 -4px; + cursor: col-resize; +} +.resize-handle::after { + content: ''; + position: absolute; + inset: 0 auto 0 50%; + width: var(--hairline); + transform: translateX(-50%); + background: var(--glass-rim); + pointer-events: none; + transition: background 0.12s var(--ease), width 0.12s var(--ease); +} +.resize-handle:hover, +.resize-handle:focus-visible { + background: transparent; + outline: none; +} +.resize-handle:hover::after, +.resize-handle:focus-visible::after { + width: 1px; + background: var(--border-strong); } /* Horizontal divider between two stacked panes (drags vertically). */ .resize-handle-v { flex: 0 0 auto; - height: 6px; - background: var(--border); + height: 1px; + position: relative; + z-index: 2; + background: transparent; cursor: row-resize; touch-action: none; } -.resize-handle-v:hover { - background: var(--accent); +.resize-handle-v::before { + content: ''; + position: absolute; + inset: -4px 0; + cursor: row-resize; +} +.resize-handle-v::after { + content: ''; + position: absolute; + inset: 50% 0 auto; + height: var(--hairline); + transform: translateY(-50%); + background: var(--glass-rim); + pointer-events: none; + transition: background 0.12s var(--ease), height 0.12s var(--ease); +} +.resize-handle-v:hover, +.resize-handle-v:focus-visible { + background: transparent; + outline: none; +} +.resize-handle-v:hover::after, +.resize-handle-v:focus-visible::after { + height: 1px; + background: var(--border-strong); } .read-center { flex: 1; @@ -49,24 +99,10 @@ } /* Collapsible rail / inspector. A thin always-visible strip (`read-pane-expand`) - re-opens a collapsed panel; a `read-fold` chevron inside each panel collapses - it. Widths + fold state persist in localStorage. */ + re-opens a collapsed panel. Fold controls remain in normal header flow so they + cannot collide with content or actions. Widths + fold state persist. */ .read-fold { - padding: 0 var(--spacing-s6, 6px); - color: var(--text-muted); - background: none; - border: none; - cursor: pointer; - font-size: 1rem; - line-height: 1; -} -.read-fold:hover { - color: var(--text); -} -.read-rail-head { - display: flex; - justify-content: flex-end; - padding: var(--spacing-s4) var(--spacing-s4) 0; + flex: 0 0 auto; } .read-pane-expand { flex: 0 0 auto; @@ -228,7 +264,6 @@ .pdfjs-toc { flex: 0 0 auto; /* width is set inline (resizable, persisted); do NOT add .scroll — its flex:1 would override this and defeat the inline width. */ - border-right: 1px solid var(--border); background: var(--surface); display: flex; flex-direction: column; @@ -393,35 +428,62 @@ box-shadow: 0 0 0 1px color-mix(in srgb, #ff8a00 60%, transparent); } .textLayer ::selection { - background: color-mix(in srgb, var(--accent) 32%, transparent); + /* The PDF canvas is always white. The monochrome app accent became a pale + grey wash here, barely distinguishable from the paper; use the system-like + blue already in the design-token palette at enough opacity to preserve + black glyph contrast while making the selected range unmistakable. */ + background: color-mix(in srgb, var(--color-terminal-blue) 48%, transparent); } /* Transparent hit-targets over in-PDF links; clicks route to the in-app browser tab (never the OS browser / never a whole-app navigation). */ -.pdfjs-link { +.surface-read .pdfjs-link { position: absolute; + appearance: none; + min-width: 0; + min-height: 0; + margin: 0; border: none; + border-radius: 0; /* Discoverable via a thin UNDERLINE only at rest — NOT a filled tint. A persistent background fill blanketed link-dense pages (a references list or table of contents where nearly every line is a link) into a wall of colour - that obscured the text. The underline marks a link without covering it; the - tint fill appears on hover, when a single link is the focus. External links - use the accent hue. */ + that obscured the text. The underline marks a link without covering it and + becomes slightly stronger on hover. External links use the accent hue. */ background: transparent; box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--accent) 40%, transparent); + /* This is a semantic overlay, not a glass control. The global button rule is + loaded later and its backdrop blur otherwise smears the PDF glyphs under + every link rectangle into an opaque-looking grey bar. */ + -webkit-backdrop-filter: none; + backdrop-filter: none; + filter: none; + transform: none; + transition: box-shadow 0.13s var(--ease); + opacity: 1; cursor: pointer; padding: 0; + line-height: 0; z-index: 2; /* above the text layer so link clicks win over text selection */ } -.pdfjs-link:hover { - background: color-mix(in srgb, var(--accent) 22%, transparent); +.surface-read .pdfjs-link:hover { + /* PDF annotations are often split into narrow rectangles around punctuation + and wrapped URLs. Filling those rectangles creates floating pills over the + document, so hover feedback stays on the baseline too. */ + background: transparent; + box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--accent) 72%, transparent); +} +.surface-read .pdfjs-link:focus-visible { + outline: 2px solid color-mix(in srgb, var(--text) 78%, transparent); + outline-offset: 1px; } /* Internal links (refs / figures / ToC jumps) get a distinct green underline so they read as in-document navigation, not an external link. */ -.pdfjs-link.internal { +.surface-read .pdfjs-link.internal { box-shadow: inset 0 -1px 0 color-mix(in srgb, #22c55e 45%, transparent); } -.pdfjs-link.internal:hover { - background: color-mix(in srgb, #22c55e 22%, transparent); +.surface-read .pdfjs-link.internal:hover { + background: transparent; + box-shadow: inset 0 -2px 0 color-mix(in srgb, #22c55e 72%, transparent); } .pdfjs-find { display: flex; @@ -875,8 +937,9 @@ min-width: 0; min-height: 0; display: flex; - border-left: 1px solid var(--border); - background: var(--bg); + background: var(--glass-pane); + -webkit-backdrop-filter: var(--glass-filter); + backdrop-filter: var(--glass-filter); } .reader-side > * { flex: 1; @@ -964,12 +1027,16 @@ /* Reader / browser tab strip — sits above the tab content; the "Library" pseudo tab returns to the list. */ .read-tabstrip { + flex: 0 0 36px; display: flex; align-items: stretch; gap: 2px; - padding: var(--spacing-s4) var(--spacing-s8) 0; - border-bottom: 1px solid var(--border); - background: var(--surface); + padding: 4px var(--spacing-s8) 0; + border-bottom: var(--hairline) solid var(--glass-rim); + background: var(--glass-surface); + -webkit-backdrop-filter: var(--glass-filter); + backdrop-filter: var(--glass-filter); + box-shadow: inset 0 1px 0 var(--glass-highlight); overflow-x: auto; } .read-tabitem { @@ -982,11 +1049,12 @@ color: var(--text-secondary); background: transparent; white-space: nowrap; + font-size: var(--font-size-body-small); } .read-tabitem.active { color: var(--text); background: var(--bg); - border-color: var(--border); + border-color: var(--glass-rim); } .read-tabitem-label, .read-tabstrip > .read-tabitem { diff --git a/desktop/src/styles/partials/04-library-nav.css b/desktop/src/styles/partials/04-library-nav.css index df58c224..33396644 100644 --- a/desktop/src/styles/partials/04-library-nav.css +++ b/desktop/src/styles/partials/04-library-nav.css @@ -4,11 +4,32 @@ (Zotero-style). See .read-rail-pane + .resize-handle-v. */ .read-rail { flex: 0 0 auto; - background: var(--canvas); + position: relative; + background: var(--glass-pane); + -webkit-backdrop-filter: var(--glass-filter); + backdrop-filter: var(--glass-filter); overflow: hidden; display: flex; flex-direction: column; } +/* The first collection row doubles as the pane header: title/count flex on the + left and the standard collapse control owns the right edge. */ +.read-rail-header { + display: flex; + align-items: center; + gap: var(--spacing-s4); + padding-right: var(--spacing-s8); +} +.read-rail-header.active { + background: var(--accent-tint); +} +.read-rail-header .read-rail-all { + flex: 1 1 auto; + min-width: 0; +} +.read-rail-header .read-rail-all:hover { + background: var(--hover); +} /* One scroll pane (collections OR tags). A fixed-height pane (collections, when tags are present) carries an inline `height`; `.grow` fills the remaining rail height (the tags pane, and the collections pane when there are no tags). */ @@ -195,7 +216,9 @@ .ref-inspector-empty { min-width: 0; min-height: 0; - background: var(--bg); + background: var(--glass-pane); + -webkit-backdrop-filter: var(--glass-filter); + backdrop-filter: var(--glass-filter); } .ref-inspector { display: flex; @@ -208,8 +231,36 @@ align-items: center; gap: var(--spacing-s2); padding: var(--spacing-s4) var(--spacing-s8); - border-bottom: 1px solid var(--border); - background: var(--canvas); + border-bottom: var(--hairline) solid var(--glass-rim); + background: var(--glass-pane); + box-shadow: inset 0 1px 0 var(--glass-highlight); + overflow: hidden; +} +.ref-tab-strip { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + gap: var(--spacing-s2); + overflow-x: auto; + overscroll-behavior-inline: contain; + scrollbar-width: none; +} +.ref-tab-strip::-webkit-scrollbar { + display: none; +} +.ref-tab-nav { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: var(--spacing-s2); +} +.ref-tab-actions { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: var(--spacing-s4); + white-space: nowrap; } .ref-tab { padding: var(--spacing-s4) var(--spacing-s12); @@ -238,7 +289,8 @@ font-size: var(--font-size-label); font-weight: 600; cursor: pointer; - margin-right: var(--spacing-s8); + margin-right: 0; + white-space: nowrap; } .ref-pdf-btn:hover { background: var(--accent); @@ -365,7 +417,7 @@ font-size: var(--font-size-label); font-weight: 600; cursor: pointer; - margin-right: var(--spacing-s8); + margin-right: 0; } .ref-scrape-btn:hover:not(:disabled) { border-color: var(--accent); @@ -1029,4 +1081,4 @@ button.small, } .ir-stat.err { color: var(--danger); -} \ No newline at end of file +} diff --git a/desktop/src/styles/partials/05-transcript-boards.css b/desktop/src/styles/partials/05-transcript-boards.css index cd4cd821..da7dc3a4 100644 --- a/desktop/src/styles/partials/05-transcript-boards.css +++ b/desktop/src/styles/partials/05-transcript-boards.css @@ -1356,17 +1356,17 @@ .card { background: var(--surface); border: 1px solid var(--border); - border-radius: var(--radius-md); + border-radius: var(--radius-card); padding: var(--spacing-s12); display: flex; flex-direction: column; gap: var(--spacing-s8); box-shadow: var(--sh-1); - transition: box-shadow 0.13s var(--ease), border-color 0.13s var(--ease); + transition: border-color 0.13s var(--ease), background 0.13s var(--ease); } .card:hover { - box-shadow: var(--sh-2); border-color: var(--border-strong); + background: color-mix(in srgb, var(--surface) 94%, var(--text) 2%); } .card-head { display: flex; @@ -1448,7 +1448,7 @@ opens as a modal. */ .kanban-split { display: flex; - gap: var(--spacing-s12); + gap: 0; align-items: flex-start; min-width: 0; } @@ -1464,7 +1464,6 @@ top: 0; max-height: calc(100vh - 190px); overflow-y: auto; - border-left: 1px solid var(--border); padding-left: var(--spacing-s12); min-width: 0; } diff --git a/desktop/src/styles/partials/06-settings-terminal.css b/desktop/src/styles/partials/06-settings-terminal.css index 941d7572..b1177d66 100644 --- a/desktop/src/styles/partials/06-settings-terminal.css +++ b/desktop/src/styles/partials/06-settings-terminal.css @@ -575,24 +575,24 @@ button { font: inherit; color: var(--text); - background: var(--raised); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: var(--spacing-s8) var(--spacing-s12); + background: var(--glass-control); + border: var(--hairline) solid var(--glass-rim); + min-height: var(--control-md); + border-radius: var(--radius-control); + padding: 6px var(--spacing-s12); cursor: pointer; box-shadow: var(--sh-1); + -webkit-backdrop-filter: blur(10px) saturate(1.05); + backdrop-filter: blur(10px) saturate(1.05); transition: background 0.13s var(--ease), border-color 0.13s var(--ease), - box-shadow 0.13s var(--ease), transform 0.13s var(--ease); + color 0.13s var(--ease), box-shadow 0.13s var(--ease); } button:hover { background: var(--hover); border-color: var(--border-strong); - transform: translateY(-1px); - box-shadow: var(--sh-2); } button:active { - transform: translateY(0); - box-shadow: var(--sh-1); + background: color-mix(in srgb, var(--hover) 76%, var(--text) 4%); } button:disabled { opacity: 0.5; @@ -601,14 +601,24 @@ button:disabled { box-shadow: var(--sh-1); } button.primary { - background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 90%, white 10%), var(--accent-strong)); + background: var(--accent); color: var(--accent-ink); border-color: var(--accent-strong); font-weight: 600; } button.primary:hover { + background: var(--accent-strong); border-color: var(--accent-strong); - box-shadow: var(--sh-2), 0 0 18px var(--accent-line); +} +button.ghost { + color: var(--text-secondary); + background: transparent; + border-color: transparent; +} +button.ghost:hover { + color: var(--text); + background: var(--hover); + border-color: transparent; } input, @@ -618,8 +628,9 @@ textarea { color: var(--text); background: var(--input); border: 1px solid var(--border); - border-radius: var(--radius-sm); - padding: var(--spacing-s8); + min-height: var(--control-md); + border-radius: var(--radius-control); + padding: 6px var(--spacing-s8); transition: border-color 0.15s var(--ease), box-shadow 0.15s var(--ease); } input:focus, @@ -663,9 +674,12 @@ th { flex-direction: column; gap: var(--spacing-s12); padding: var(--spacing-s24); - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-md); + background: var(--glass-surface); + border: var(--hairline) solid var(--glass-rim); + border-radius: var(--radius-overlay); + -webkit-backdrop-filter: blur(24px) saturate(1.1); + backdrop-filter: blur(24px) saturate(1.1); + box-shadow: inset 0 1px 0 var(--glass-highlight); } /* The Modal dialog div carries .connect (#313); the
inside keeps submit semantics but melts into the card's flex layout (its children become the @@ -677,6 +691,13 @@ th { display: flex; align-items: center; gap: var(--spacing-s8); + /* ConnectPanel is portaled to
, outside `.shell-macos`; mark its own + title row explicitly so a hidden-inset macOS window remains draggable while + the connection modal covers the underlying page. */ + -webkit-app-region: drag; +} +.connect-head button { + -webkit-app-region: no-drag; } .connect-head h2 { margin: 0; diff --git a/desktop/src/styles/partials/07-panels-vault-misc.css b/desktop/src/styles/partials/07-panels-vault-misc.css index eca77a6e..332c0568 100644 --- a/desktop/src/styles/partials/07-panels-vault-misc.css +++ b/desktop/src/styles/partials/07-panels-vault-misc.css @@ -350,7 +350,6 @@ button.search-result-link:hover { .sessions-list { flex: 0 0 300px; overflow: auto; - border-right: 1px solid var(--border); display: flex; flex-direction: column; } @@ -397,6 +396,9 @@ button.search-result-link:hover { cursor: nwse-resize; } .session-item { + width: 100%; + min-width: 0; + box-sizing: border-box; display: flex; flex-direction: column; align-items: flex-start; @@ -406,7 +408,7 @@ button.search-result-link:hover { border: none; border-bottom: 1px solid color-mix(in srgb, var(--border) 45%, transparent); cursor: pointer; - padding: var(--spacing-s8); + padding: var(--spacing-s6) var(--spacing-s8); color: var(--text); } .session-item.active { @@ -452,6 +454,10 @@ button.search-result-link:hover { font-family: inherit; text-align: left; } +.sessions-group { + width: 100%; + min-width: 0; +} .sessions-group-head .sessions-group-label { flex: 1; min-width: 0; @@ -596,19 +602,30 @@ button.search-result-link:hover { } .switcher-pill { cursor: pointer; - border: 1px solid var(--border); - background: var(--input); + border: var(--hairline) solid var(--glass-rim); + background: var(--glass-control); +} +.switcher-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .switcher-menu { position: absolute; top: calc(100% + 4px); left: 0; - z-index: var(--z-overlay); - min-width: 260px; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.28); + z-index: var(--z-menu); + width: 240px; + max-width: calc(100vw - 96px); + max-height: calc(100vh - 48px); + overflow-y: auto; + background: var(--glass-surface); + border: var(--hairline) solid var(--glass-rim); + border-radius: var(--radius-card); + box-shadow: var(--sh-3), inset 0 1px 0 var(--glass-highlight); + -webkit-backdrop-filter: blur(24px) saturate(1.1); + backdrop-filter: blur(24px) saturate(1.1); padding: var(--spacing-s4); display: flex; flex-direction: column; @@ -616,16 +633,17 @@ button.search-result-link:hover { } .switcher-item { display: flex; + flex-wrap: wrap; align-items: center; gap: var(--spacing-s4); border-radius: var(--radius-sm); padding: 2px 4px; } .switcher-item.active { - background: color-mix(in srgb, var(--color-primary) 14%, transparent); + background: var(--hover); } .switcher-pick { - flex: 1; + flex: 1 0 100%; min-width: 0; display: flex; flex-direction: column; @@ -641,7 +659,19 @@ button.search-result-link:hover { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - max-width: 200px; + max-width: 100%; +} +.switcher-item-actions { + display: inline-flex; + align-items: center; + gap: var(--spacing-s12); + padding: 0 var(--spacing-s4) var(--spacing-s4); +} +.switcher-menu-status { + position: fixed; + top: auto; + right: auto; + z-index: var(--z-menu); } .switcher-add { margin-top: var(--spacing-s4); @@ -807,8 +837,8 @@ button.search-result-link:hover { * context and paints *above* an auto backdrop regardless of tree order, so * that row bleeds through the modal and steals its wheel/pointer events. */ z-index: var(--z-menu); - background: rgba(6, 8, 12, 0.55); - backdrop-filter: blur(3px) saturate(0.9); + background: color-mix(in srgb, var(--bg) 74%, transparent); + backdrop-filter: blur(5px) saturate(0.85); display: flex; justify-content: center; align-items: flex-start; @@ -829,19 +859,23 @@ button.search-result-link:hover { .palette { width: 560px; max-width: 90vw; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-md); + background: var(--glass-surface); + border: var(--hairline) solid var(--glass-rim); + border-radius: var(--radius-overlay); overflow: hidden; + -webkit-backdrop-filter: blur(24px) saturate(1.1); + backdrop-filter: blur(24px) saturate(1.1); + box-shadow: inset 0 1px 0 var(--glass-highlight); } .palette input { width: 100%; border: none; - border-bottom: 1px solid var(--border); + border-bottom: var(--hairline) solid var(--glass-rim); border-radius: 0; padding: var(--spacing-s12) var(--spacing-s16); } .palette-item { + position: relative; display: flex; align-items: center; gap: var(--spacing-s8); @@ -859,7 +893,17 @@ button.search-result-link:hover { font-variant-numeric: tabular-nums; } .palette-item.active { - background: color-mix(in srgb, var(--accent) 18%, transparent); + background: var(--hover); +} +.palette-item.active::before { + content: ''; + position: absolute; + left: 5px; + top: 8px; + bottom: 8px; + width: 2px; + border-radius: var(--radius-stadium); + background: var(--accent); } /* ---- criteria create + deliverable send-back (Phase 5 parity) ---- */ diff --git a/desktop/src/styles/partials/08-plan-terminal-dock.css b/desktop/src/styles/partials/08-plan-terminal-dock.css index 538e2488..c1308419 100644 --- a/desktop/src/styles/partials/08-plan-terminal-dock.css +++ b/desktop/src/styles/partials/08-plan-terminal-dock.css @@ -417,7 +417,7 @@ } /* Left nav (surface mode): saved SSH connections/hosts. Width is set inline (resizable + persisted); `.folded` collapses it entirely (a reveal chevron in - the head brings it back). Border-right is the seat for the ResizeHandle. */ + the head brings it back). The adjacent ResizeHandle owns the sole divider. */ .term-nav { flex: 0 0 auto; width: 210px; @@ -427,7 +427,6 @@ min-height: 0; overflow-y: auto; padding: var(--spacing-s8); - border-right: 1px solid var(--border); background: var(--canvas); } .term-nav.folded { @@ -444,23 +443,6 @@ color: var(--text-muted); font-weight: 600; } -.term-nav-fold, -.term-nav-reveal { - display: flex; - align-items: center; - justify-content: center; - padding: 2px; - background: none; - border: none; - color: var(--text-muted); - cursor: pointer; - border-radius: var(--radius-sm); -} -.term-nav-fold:hover, -.term-nav-reveal:hover { - background: var(--hover); - color: var(--text); -} .term-nav-reveal { flex: 0 0 auto; margin-right: var(--spacing-s4); @@ -479,12 +461,21 @@ .term-nav-new { flex: 1; min-width: 0; + height: 32px; + min-height: 32px; + padding: var(--spacing-s4) var(--spacing-s8); justify-content: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .term-nav-import { flex: 0 0 auto; + width: 32px; + height: 32px; + min-height: 32px; justify-content: center; - padding: 5px 7px; + padding: 0; } .term-nav-notice { padding: 2px 4px; diff --git a/desktop/src/styles/partials/09-canvas-tail.css b/desktop/src/styles/partials/09-canvas-tail.css index 27228508..7c23952e 100644 --- a/desktop/src/styles/partials/09-canvas-tail.css +++ b/desktop/src/styles/partials/09-canvas-tail.css @@ -473,14 +473,12 @@ display: flex; flex-direction: column; min-height: 0; - border-right: 1px solid var(--border); background: var(--surface-sunken, var(--surface)); } -/* Right-docked variant (the Author editor's Obsidian-style outline): the border - moves to the left edge, and the collapsed show-button sits at the right. */ +/* Right-docked variant (the Author editor's Obsidian-style outline). The shared + ResizeHandle draws the boundary; the rail must not add a parallel border. */ .mdreader-outline.side-right { - border-right: none; - border-left: 1px solid var(--border); + border: none; } .mdreader-outline-show.side-right { align-self: flex-start; @@ -625,6 +623,7 @@ } .doc-zoom-btn { width: 24px; + min-height: 24px; padding: 0; } .doc-zoom-pct { @@ -662,6 +661,12 @@ .att-html-wrap { display: flex; } +.att-html-nav { + position: absolute; + top: var(--spacing-s8); + left: var(--spacing-s12); + z-index: 5; +} /* Info-tab attachment summary card (type / location / preview). */ .ref-attach-info { display: flex; @@ -815,7 +820,6 @@ flex: 0 0 auto; /* width is inline (resizable); never shrink below it */ width: 240px; overflow: auto; - border-right: 1px solid var(--border); padding: 6px; display: flex; flex-direction: column; @@ -1006,6 +1010,175 @@ display: flex; flex-direction: column; } +/* Author's document strip is denser than the Reader's mixed-content tabs, but + filenames must remain recognizable. Fixed-width tabs scroll instead of + collapsing into icon/close-button noise; edge controls make that overflow + discoverable without leaving a permanent scrollbar in the workbench chrome. */ +.author-tabs-shell { + flex: 0 0 38px; + min-width: 0; + display: flex; + align-items: stretch; + border-bottom: var(--hairline) solid var(--glass-rim); + background: var(--glass-surface); + -webkit-backdrop-filter: var(--glass-filter); + backdrop-filter: var(--glass-filter); + box-shadow: inset 0 1px 0 var(--glass-highlight); +} +.author-tabs-shell .author-doc-tabs { + flex: 1; + min-width: 0; + gap: 3px; + padding: 5px 7px 0; + border-bottom: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + scroll-behavior: smooth; + overscroll-behavior-inline: contain; +} +.author-doc-tabs::-webkit-scrollbar { + display: none; +} +.author-doc-tabs .read-tabitem { + position: relative; + flex: 0 0 clamp(148px, 16vw, 208px); + min-width: 0; + height: 32px; + max-width: none; + border-color: transparent; + border-radius: 7px 7px 0 0; + transition: color 120ms var(--ease), background 120ms var(--ease), border-color 120ms var(--ease); +} +.author-doc-tabs .read-tabitem::before { + content: ''; + position: absolute; + z-index: 1; + top: 0; + left: 10px; + right: 10px; + height: 2px; + border-radius: 0 0 2px 2px; + background: var(--accent); + opacity: 0; + transform: scaleX(0.55); + transition: opacity 120ms var(--ease), transform 120ms var(--ease); +} +.author-doc-tabs .read-tabitem:hover:not(.active) { + color: var(--text); + background: var(--hover); +} +.author-doc-tabs .read-tabitem.active { + background: var(--bg); + border-color: var(--border); +} +.author-doc-tabs .read-tabitem.active::before { + opacity: 1; + transform: scaleX(1); +} +.author-doc-tabs .read-tabitem-label { + flex: 1; + gap: 7px; + min-width: 0; + padding: 0 4px 0 10px; + letter-spacing: -0.005em; +} +.author-doc-tabs .read-tabitem-kind { + color: var(--text-muted); + transition: color 120ms var(--ease); +} +.author-doc-tabs .active .read-tabitem-kind { + color: var(--accent-text); +} +.author-tab-title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.author-tab-dirty { + flex: 0 0 6px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--warn); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--warn) 13%, transparent); +} +.author-doc-tabs .read-tabitem-badge { + flex: none; + margin-left: 0; + padding: 0 4px; + line-height: 16px; + color: var(--text-secondary); + background: var(--raised); +} +.author-doc-tabs .read-tabitem-agent { + flex: 0 0 20px; + width: 20px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + margin: 0 1px 0 0; + padding: 0; + border-radius: 5px; +} +.author-doc-tabs .read-tabitem-agent:hover { + background: var(--accent-strong); +} +.author-doc-tabs .read-tabitem-x { + flex: 0 0 22px; + width: 22px; + height: 22px; + margin-right: 4px; + padding: 0; + border-radius: 5px; + opacity: 0; + transition: opacity 120ms var(--ease), color 120ms var(--ease), background 120ms var(--ease); +} +.author-doc-tabs .read-tabitem:hover .read-tabitem-x, +.author-doc-tabs .read-tabitem.active .read-tabitem-x, +.author-doc-tabs .read-tabitem-x:focus-visible, +.author-doc-tabs .read-tabitem-x.danger { + opacity: 0.72; +} +.author-doc-tabs .read-tabitem-x:hover { + color: var(--text); + background: var(--hover); + opacity: 1; +} +.author-doc-tabs .read-tabitem-x.danger { + color: var(--danger); + background: color-mix(in srgb, var(--danger) 12%, transparent); +} +.author-tab-scroll { + flex: 0 0 28px; + width: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + color: var(--text-secondary); + background: var(--surface); + border: 0; + cursor: pointer; +} +.author-tab-scroll.before { + border-right: 1px solid var(--border); +} +.author-tab-scroll.after { + border-left: 1px solid var(--border); +} +.author-tab-scroll:hover:not(:disabled) { + color: var(--text); + background: var(--hover); +} +.author-tab-scroll:disabled { + color: var(--text-muted); + opacity: 0.3; + cursor: default; +} .author-nav-col { flex: none; min-height: 0; @@ -1019,8 +1192,9 @@ flex-direction: column; gap: 6px; padding: 8px 6px; - border-right: 1px solid var(--border); - background: var(--surface); + background: var(--glass-pane); + -webkit-backdrop-filter: var(--glass-filter); + backdrop-filter: var(--glass-filter); } .author-nav-sec { display: flex; @@ -1039,8 +1213,6 @@ flex-shrink: 0; } .author-nav-head { - display: flex; - align-items: center; gap: 4px; padding: 2px 6px 4px; font-size: 10.5px; @@ -1052,6 +1224,62 @@ .author-nav-head .spacer { flex: 1; } +.author-nav-head.has-folder { + font-size: var(--font-size-caption); + letter-spacing: -0.005em; + text-transform: none; + color: var(--text-secondary); +} +.author-nav-folder-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.author-nav-overflow { + color: var(--text-secondary); +} +.author-nav-actions-backdrop { + position: fixed; + inset: 0; + z-index: var(--z-menu); + background: transparent; +} +.author-nav-actions-menu { + position: fixed; + z-index: calc(var(--z-menu) + 1); + width: 196px; + padding: var(--spacing-s4); + background: var(--glass-surface); + border: var(--hairline) solid var(--glass-rim); + border-radius: var(--radius-card); + box-shadow: var(--shadow-menu); + -webkit-backdrop-filter: var(--glass-filter); + backdrop-filter: var(--glass-filter); +} +.author-nav-actions-menu button { + width: 100%; + min-height: 32px; + display: flex; + align-items: center; + gap: var(--spacing-s8); + padding: var(--spacing-s4) var(--spacing-s8); + color: var(--text-secondary); + background: transparent; + border: 0; + border-radius: var(--radius-sm); + text-align: left; + font-size: var(--font-size-body-small); +} +.author-nav-actions-menu button:hover:not(:disabled), +.author-nav-actions-menu button:focus-visible { + color: var(--text); + background: var(--hover); + outline: none; +} +.author-nav-actions-menu button:disabled { + opacity: 0.4; +} .author-nav-icon { padding: 1px 4px; border-radius: 4px; @@ -1078,13 +1306,6 @@ .author-nav-empty { padding: 2px 6px; } -.author-nav-root { - padding: 2px 6px 4px; - color: var(--text-secondary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} .author-nav-item { display: flex; align-items: center; diff --git a/desktop/src/surfaces/AdminCockpit.tsx b/desktop/src/surfaces/AdminCockpit.tsx index 9d84b4be..95a80b75 100644 --- a/desktop/src/surfaces/AdminCockpit.tsx +++ b/desktop/src/surfaces/AdminCockpit.tsx @@ -6,6 +6,7 @@ import { useSession } from '../state/session'; import { ConfirmButton } from '../ui/ConfirmButton'; import { useConfirm } from '../ui/ConfirmModal'; import { Modal } from '../ui/Modal'; +import { EnvProfilesManager } from './EnvProfilesManager'; function msg(err: unknown): string { return err instanceof Error ? err.message : String(err); @@ -618,7 +619,7 @@ function FamiliesTab(): JSX.Element { ); } -type AdminTab = 'team' | 'hosts' | 'agents' | 'teams' | 'templates' | 'families' | 'upkeep'; +type AdminTab = 'team' | 'hosts' | 'agents' | 'teams' | 'environments' | 'templates' | 'families' | 'upkeep'; /// WS7 — Team governance + operator Admin cockpit as an overlay. Team tab /// (members + editable policy); Hosts/Agents admin tabs with confirmed @@ -632,6 +633,7 @@ export function AdminCockpit({ onClose }: { onClose: () => void }): JSX.Element { v: 'hosts', label: t('admin.hosts') }, { v: 'agents', label: t('admin.agents') }, { v: 'teams', label: t('admin.teams') }, + { v: 'environments', label: t('settings.catEnvProfiles') }, { v: 'templates', label: t('admin.templates') }, { v: 'families', label: t('admin.engines') }, { v: 'upkeep', label: t('admin.upkeep') }, @@ -652,6 +654,7 @@ export function AdminCockpit({ onClose }: { onClose: () => void }): JSX.Element {tab === 'hosts' && } {tab === 'agents' && } {tab === 'teams' && } + {tab === 'environments' && } {tab === 'templates' && } {tab === 'families' && } {tab === 'upkeep' && } diff --git a/desktop/src/surfaces/AttentionDock.tsx b/desktop/src/surfaces/AttentionDock.tsx index f577395e..5a11915d 100644 --- a/desktop/src/surfaces/AttentionDock.tsx +++ b/desktop/src/surfaces/AttentionDock.tsx @@ -4,6 +4,7 @@ import { useAttention } from '../hub/queries'; import { obj, str, type Entity } from '../hub/types'; import { useT } from '../i18n'; import { useSession } from '../state/session'; +import { Icon } from '../ui/Icon'; function msg(err: unknown): string { return err instanceof Error ? err.message : String(err); @@ -202,7 +203,14 @@ export function AttentionDock(): JSX.Element { if (query.isLoading) return
{t('att.loading')}
; if (query.isError) return
{msg(query.error)}
; - if (items.length === 0) return
{t('att.empty')}
; + if (items.length === 0) { + return ( +
+ + {t('att.empty')} +
+ ); + } return (
diff --git a/desktop/src/surfaces/AuditConsole.tsx b/desktop/src/surfaces/AuditConsole.tsx index 64a56d6d..a948299b 100644 --- a/desktop/src/surfaces/AuditConsole.tsx +++ b/desktop/src/surfaces/AuditConsole.tsx @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { str, type Entity } from '../hub/types'; import { useT } from '../i18n'; import { useSession } from '../state/session'; +import { Icon } from '../ui/Icon'; function field(row: Entity, keys: string[]): string { for (const k of keys) { @@ -30,6 +31,15 @@ export function AuditConsole(): JSX.Element { } const rows = query.data ?? []; + if (rows.length === 0) { + return ( +
+ + {t('audit.empty')} +
+ ); + } + return (
@@ -50,11 +60,6 @@ export function AuditConsole(): JSX.Element { ))} - {rows.length === 0 && ( - - - - )}
{field(row, ['target', 'summary', 'ref', 'target_id'])}
{t('audit.empty')}
diff --git a/desktop/src/surfaces/AuthorNav.tsx b/desktop/src/surfaces/AuthorNav.tsx index 7c7409dd..4179ff71 100644 --- a/desktop/src/surfaces/AuthorNav.tsx +++ b/desktop/src/surfaces/AuthorNav.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useMemo, useState, type MouseEvent as ReactMouseEvent } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'; +import { createPortal } from 'react-dom'; import { invoke } from '../bridge'; import { useT } from '../i18n'; import { Icon, type IconName } from '../ui/Icon'; @@ -131,11 +132,44 @@ export function AuthorNav({ onFold }: { onFold?: () => void }): JSX.Element { const [filter, setFilter] = useState(''); const [showSync, setShowSync] = useState(false); const [dropActive, setDropActive] = useState(false); + const [actionsOpen, setActionsOpen] = useState(false); + const [actionsPos, setActionsPos] = useState<{ left: number; top: number } | null>(null); + const actionsButtonRef = useRef(null); + const actionsMenuRef = useRef(null); // On-disk file-tree right-click menu + its two-step delete confirm. const [fileMenu, setFileMenu] = useState(null); const [fileConfirmDelete, setFileConfirmDelete] = useState(false); const tauri = isShell(); + const placeActions = useCallback((): void => { + const button = actionsButtonRef.current; + if (button === null) return; + const rect = button.getBoundingClientRect(); + const width = 196; + setActionsPos({ + left: Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8)), + top: Math.min(rect.bottom + 4, window.innerHeight - 148), + }); + }, []); + + useEffect(() => { + if (!actionsOpen) return; + placeActions(); + actionsMenuRef.current?.querySelector('button:not(:disabled)')?.focus(); + const reposition = (): void => placeActions(); + window.addEventListener('resize', reposition); + window.addEventListener('scroll', reposition, true); + return () => { + window.removeEventListener('resize', reposition); + window.removeEventListener('scroll', reposition, true); + }; + }, [actionsOpen, placeActions]); + + function runAction(action: () => void): void { + setActionsOpen(false); + action(); + } + // Path → open-marker for every open, file-linked document, so a workspace row // whose path matches renders emphasized (dirty ● / active highlight) like its // tab. Drafts (no filePath) aren't in the tree, so they don't appear here. @@ -370,46 +404,70 @@ export function AuthorNav({ onFold }: { onFold?: () => void }): JSX.Element { setFileMenu({ path: folder, dir: true, root: true, x: e.clientX, y: e.clientY }); }} > -
- {onFold !== undefined && ( - - )} - {t('author.navFiles')} +
+ + {folder !== null ? baseName(folder) : t('author.navFiles')} + - {folder !== null && ( - - )} - {tauri && folder !== null && ( - - )} - {tauri && ( - + {onFold !== undefined && ( + )} - {folder !== null && ( - + {actionsOpen && actionsPos !== null && createPortal( + <> +
setActionsOpen(false)} /> +
{ + if (e.key === 'Escape') { + e.preventDefault(); + setActionsOpen(false); + actionsButtonRef.current?.focus(); + } + }} + > + + + + +
+ , + document.body, )}
{!tauri &&
{t('author.navDesktopOnly')}
} {tauri && folder === null &&
{t('author.navPickHint')}
} - {folder !== null && ( -
- {baseName(folder)} -
- )} {folder !== null && ( // Reuses the Inspect tree's filter-input styling (generic token-based // input) rather than duplicating a near-identical rule. diff --git a/desktop/src/surfaces/AuthorSurface.tsx b/desktop/src/surfaces/AuthorSurface.tsx index 2b8beea2..b64fd1ce 100644 --- a/desktop/src/surfaces/AuthorSurface.tsx +++ b/desktop/src/surfaces/AuthorSurface.tsx @@ -44,6 +44,7 @@ const MarkdownEditor = lazy(() => import('../ui/MarkdownEditor').then((m) => ({ const WysiwygEditor = lazy(() => import('../ui/WysiwygEditor').then((m) => ({ default: m.WysiwygEditor }))); import { ResizeHandle } from '../ui/ResizeHandle'; import { WorkbenchSurface } from '../ui/WorkbenchSurface'; +import { PopoverMenu } from '../ui/PopoverMenu'; const clamp = (n: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, n)); function loadW(key: string, fallback: number): number { @@ -316,10 +317,84 @@ export function AuthorSurface(): JSX.Element { // The categorized "New ▾" dropdown — one menu for every document kind (Write / // Data / Draw / Figure), the figure rows driven by the `FIGURES` registry. const [newMenu, setNewMenu] = useState(false); + const newMenuAnchorRef = useRef(null); + const tabStripRef = useRef(null); + const [tabOverflow, setTabOverflow] = useState({ overflowing: false, before: false, after: false }); const active = docs.find((d) => d.id === activeId); const tauri = isShell(); + // Document tabs keep a useful, stable width instead of shrinking into a row + // of indistinguishable file icons. Track the scroll edges so compact previous / + // next controls appear only when the strip genuinely overflows. + const measureTabOverflow = useCallback((): void => { + const el = tabStripRef.current; + if (el === null) return; + const max = Math.max(0, el.scrollWidth - el.clientWidth); + // `scrollIntoView({ inline: 'nearest' })` may stop at the strip's 7px + // inline padding rather than literal zero; treat that optical gutter as an + // edge so a control never looks actionable when every tab is already shown. + const edgeSlack = 8; + const next = { + overflowing: max > 1, + before: el.scrollLeft > edgeSlack, + after: el.scrollLeft < max - edgeSlack, + }; + setTabOverflow((prev) => + prev.overflowing === next.overflowing && prev.before === next.before && prev.after === next.after ? prev : next, + ); + }, []); + + useEffect(() => { + const el = tabStripRef.current; + if (el === null) return; + const frame = window.requestAnimationFrame(measureTabOverflow); + const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measureTabOverflow) : null; + observer?.observe(el); + return () => { + window.cancelAnimationFrame(frame); + observer?.disconnect(); + }; + }, [docs.length, measureTabOverflow]); + + // Opening or selecting a document should never leave its tab hidden beyond + // an overflow edge. `nearest` moves the strip only as far as necessary. + useEffect(() => { + const el = tabStripRef.current; + if (el === null || activeId === null) return; + const frame = window.requestAnimationFrame(() => { + const tab = Array.from(el.children).find((child) => (child as HTMLElement).dataset.docId === activeId); + tab?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + measureTabOverflow(); + }); + return () => window.cancelAnimationFrame(frame); + }, [activeId, docs.length, measureTabOverflow]); + + function scrollTabs(direction: -1 | 1): void { + const el = tabStripRef.current; + if (el === null) return; + el.scrollBy({ left: direction * Math.max(180, el.clientWidth * 0.72), behavior: 'smooth' }); + } + + function moveTabFocus(e: React.KeyboardEvent): void { + const target = e.target as HTMLElement; + if (target.getAttribute('role') !== 'tab' || docs.length === 0) return; + const current = docs.findIndex((d) => d.id === activeId); + let next = current < 0 ? 0 : current; + if (e.key === 'ArrowRight') next = (next + 1) % docs.length; + else if (e.key === 'ArrowLeft') next = (next - 1 + docs.length) % docs.length; + else if (e.key === 'Home') next = 0; + else if (e.key === 'End') next = docs.length - 1; + else return; + e.preventDefault(); + setActive(docs[next].id); + window.requestAnimationFrame(() => { + const strip = tabStripRef.current; + const tab = strip === null ? undefined : Array.from(strip.children).find((child) => (child as HTMLElement).dataset.docId === docs[next].id); + tab?.querySelector('[role="tab"]')?.focus(); + }); + } + // The dock companion's context provider (the unified assistant dock, D2.2 — // state/companionContext.ts): the active document. Register on mount + // active-document change, unregister on unmount / no active document (the @@ -492,6 +567,8 @@ export function AuthorSurface(): JSX.Element { if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === 's') { e.preventDefault(); void saveRef.current(); + } else if (e.key === 'Escape') { + setNewMenu(false); } } window.addEventListener('keydown', onKey); @@ -555,7 +632,7 @@ export function AuthorSurface(): JSX.Element { job="author" actions={ <> -
+
{/* Primary = new Document (the common case); the caret opens the categorized menu. */} - {newMenu && ( - <> -
setNewMenu(false)} /> -
+ setNewMenu(false)} + className="author-figmenu" + ariaLabel={t('author.newMenu')} + >
{t('author.newGroupWrite')}
void createDoc('markdown')} close={() => setNewMenu(false)} />
{t('author.newGroupData')}
@@ -596,9 +676,7 @@ export function AuthorSurface(): JSX.Element { close={() => setNewMenu(false)} /> ))} -
- - )} +
{tauri && ( <> @@ -641,70 +719,111 @@ export function AuthorSurface(): JSX.Element { )}
{docs.length > 0 && ( -
- {docs.map((d) => { - const draft = d.filePath === undefined; - const openTab = (e: { clientX: number; clientY: number; preventDefault: () => void }): void => { - const items: MenuItem[] = [{ label: t('author.navRename'), onClick: () => void renameDoc(d.id) }]; - if (draft && folder !== null) { - items.push({ label: t('author.navSaveToWorkspace'), onClick: () => void saveDraftToWorkspace(d.id) }); - } - if (!draft && tauri) { - const p = d.filePath as string; - items.push({ label: t('author.fReveal'), onClick: () => revealPath(p) }); - } - items.push({ label: t('author.navClose'), danger: true, onClick: () => closeTab(d.id) }); - openTabMenu(e, items); - }; - return ( - - + )} +
+ {docs.map((d) => { + const draft = d.filePath === undefined; + const title = d.title !== '' ? d.title : t('author.untitled'); + const edit = latestAgentEdit(agentEdits, d.id); + const editTitle = edit === undefined ? '' : agentEditTitle(edit, agentEditCount(agentEdits, d.id), t); + const openTab = (e: { clientX: number; clientY: number; preventDefault: () => void }): void => { + const items: MenuItem[] = [{ label: t('author.navRename'), onClick: () => void renameDoc(d.id) }]; + if (draft && folder !== null) { + items.push({ label: t('author.navSaveToWorkspace'), onClick: () => void saveDraftToWorkspace(d.id) }); + } + if (!draft && tauri) { + const p = d.filePath as string; + items.push({ label: t('author.fReveal'), onClick: () => revealPath(p) }); + } + items.push({ label: t('author.navClose'), danger: true, onClick: () => closeTab(d.id) }); + openTabMenu(e, items); + }; + return ( + - - {d.dirty === true ? '● ' : ''} - {d.title !== '' ? d.title : t('author.untitled')} - {draft && {t('author.navDraft')}} - - {/* B6: attribution + one-click revert. Outside the tab button - (a button cannot nest one) so it is reachable without - switching to the tab — reverting a write on a document you - are not looking at is the common case when an agent works - across several. */} - {latestAgentEdit(agentEdits, d.id) !== undefined && ( - )} - - - ); - })} + {/* B6: attribution + one-click revert. Outside the tab button + (a button cannot nest one) so it is reachable without + switching to the tab — reverting a write on a document you + are not looking at is the common case when an agent works + across several. */} + {edit !== undefined && ( + + )} + + + ); + })} +
+ {tabOverflow.overflowing && ( + + )}
)} {active !== undefined ? ( diff --git a/desktop/src/surfaces/DebugSurface.tsx b/desktop/src/surfaces/DebugSurface.tsx index f9fbdff1..4ec3d6e7 100644 --- a/desktop/src/surfaces/DebugSurface.tsx +++ b/desktop/src/surfaces/DebugSurface.tsx @@ -29,6 +29,7 @@ import { InspectOpenDialog, type OpenMode, type PickResult, type PinRoot } from import { InspectTree } from './InspectTree'; import { InspectRepoAddDialog } from './InspectRepoAdd'; import { RepoPickDialog } from './InspectForgePick'; +import { PopoverMenu } from '../ui/PopoverMenu'; // CodeMirror 6 + its search/language-data deps ride a lazy chunk (never the boot // bundle — plan §7 bundle discipline), loaded the first time a code tab renders. @@ -945,6 +946,8 @@ export function DebugSurface(): JSX.Element { const [notFound, setNotFound] = useState(null); const [menu, setMenu] = useState(false); const [cmpMenu, setCmpMenu] = useState(false); + const openMenuAnchorRef = useRef(null); + const compareMenuAnchorRef = useRef(null); const [dialog, setDialog] = useState(null); const [repoDialog, setRepoDialog] = useState(false); // The Compare menu's repo picker (#460) — resolve + browse a GitHub/HF repo @@ -1112,14 +1115,17 @@ export function DebugSurface(): JSX.Element { -
+
- {menu && ( - <> -
setMenu(false)} /> -
+ setMenu(false)} + className="inspect-menu" + ariaLabel={t('inspect.open')} + > {isShell() && ( -
- - )} +
{canCompare && ( -
+
- {cmpMenu && ( - <> -
(setCmpMenu(false), setCmpBase(null))} /> -
+ { + setCmpMenu(false); + setCmpBase(null); + }} + className="inspect-menu" + ariaLabel={t('inspect.compare')} + > {otherTabs.length > 0 &&
{t('inspect.compareWithTab')}
} {otherTabs.map((tb) => ( -
- - )} +
)} diff --git a/desktop/src/surfaces/InspectTree.tsx b/desktop/src/surfaces/InspectTree.tsx index 04d836fa..01bad302 100644 --- a/desktop/src/surfaces/InspectTree.tsx +++ b/desktop/src/surfaces/InspectTree.tsx @@ -517,7 +517,7 @@ export function InspectTree({ )} -
diff --git a/desktop/src/surfaces/ReadSurface.tsx b/desktop/src/surfaces/ReadSurface.tsx index 841cd0e4..fdc6af1f 100644 --- a/desktop/src/surfaces/ReadSurface.tsx +++ b/desktop/src/surfaces/ReadSurface.tsx @@ -39,7 +39,7 @@ import { type ScrapePatch, type ScrapeSeed, } from '../discovery'; -import { hostOf, isShell, revealPath } from '../platform'; +import { hostOf, isShell, openExternal, revealPath } from '../platform'; import { useCompanionContext } from '../state/companionContext'; import { BrowserView } from './BrowserView'; import { Markdown } from '../ui/Markdown'; @@ -63,6 +63,7 @@ import { ResizeHandle, VResizeHandle } from '../ui/ResizeHandle'; import { useContextMenu } from '../ui/ContextMenu'; import { WebdavModal } from '../ui/WebdavModal'; import { WorkbenchSurface } from '../ui/WorkbenchSurface'; +import { PopoverMenu } from '../ui/PopoverMenu'; const clamp = (n: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, n)); @@ -375,8 +376,15 @@ function TextDoc({ text }: { text: string }): JSX.Element { // and whenever the level changes — a reflow, not a scaled bitmap. If the frame is // unexpectedly cross-origin (contentDocument throws), fall back to element `zoom`. function HtmlDoc({ url, title }: { url: string; title: string }): JSX.Element { + const t = useT(); const zoom = useDocZoom('html'); + const openLink = useOpenLink(); const frameRef = useRef(null); + const historyRef = useRef<{ entries: string[]; index: number }>({ entries: [], index: -1 }); + const detachRef = useRef<() => void>(() => undefined); + const [canBack, setCanBack] = useState(false); + const [canForward, setCanForward] = useState(false); + const apply = (): void => { const f = frameRef.current; if (f === null) return; @@ -392,12 +400,135 @@ function HtmlDoc({ url, title }: { url: string; title: string }): JSX.Element { } (f.style as CSSStyleDeclaration & { zoom: string }).zoom = String(zoom.zoom); }; + + const recordLocation = (): void => { + const win = frameRef.current?.contentWindow; + if (win === null || win === undefined) return; + let href = ''; + try { + href = win.location.href; + } catch { + return; // a cross-origin navigation cannot be governed by this viewer + } + const h = historyRef.current; + if (h.entries[h.index] !== href) { + if (h.index > 0 && h.entries[h.index - 1] === href) h.index -= 1; + else if (h.index + 1 < h.entries.length && h.entries[h.index + 1] === href) h.index += 1; + else { + h.entries = [...h.entries.slice(0, h.index + 1), href]; + h.index = h.entries.length - 1; + } + } + setCanBack(h.index > 0); + setCanForward(h.index >= 0 && h.index < h.entries.length - 1); + }; + + const onFrameLoad = (): void => { + apply(); + detachRef.current(); + const frame = frameRef.current; + let doc: Document | null = null; + let win: Window | null = null; + try { + doc = frame?.contentDocument ?? null; + win = frame?.contentWindow ?? null; + } catch { + return; + } + if (doc === null || win === null) return; + + const onHistory = (): void => recordLocation(); + const onClick = (e: MouseEvent): void => { + const anchor = (e.target as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null; + if (anchor === null) return; + const href = anchor.href; + if (!/^https?:\/\//i.test(href) && !/^mailto:/i.test(href)) return; + // Network links belong in Read's dedicated Browser mode, not inside the + // local attachment iframe. Mail links retain the OS handler. + e.preventDefault(); + if (/^https?:\/\//i.test(href)) openLink(href); + else openExternal(href); + }; + const onContextMenu = (e: MouseEvent): void => { + if (!isShell() || e.defaultPrevented) return; + const target = e.target as Element | null; + const editable = target?.closest?.('input, textarea, [contenteditable=""], [contenteditable="true"]') != null; + const selected = (win.getSelection()?.toString().trim() ?? '') !== ''; + const image = target?.closest?.('img') != null; + const anchor = target?.closest?.('a[href]') as HTMLAnchorElement | null; + let x: number | undefined; + let y: number | undefined; + if (image && frame !== null) { + const rect = frame.getBoundingClientRect(); + x = rect.left + e.clientX; + y = rect.top + e.clientY; + } + e.preventDefault(); + void invoke('menu_show_context', { + editable, + hasSelection: selected, + selectAll: true, + image, + x, + y, + linkUrl: anchor?.href ?? '', + openLinkLabel: tStatic('ctx.openLink'), + copyLinkLabel: tStatic('ctx.copyLink'), + imageLabel: tStatic('common.copyImage'), + }).catch(() => undefined); + }; + + doc.addEventListener('click', onClick, true); + doc.addEventListener('contextmenu', onContextMenu); + win.addEventListener('hashchange', onHistory); + win.addEventListener('popstate', onHistory); + detachRef.current = () => { + doc?.removeEventListener('click', onClick, true); + doc?.removeEventListener('contextmenu', onContextMenu); + win?.removeEventListener('hashchange', onHistory); + win?.removeEventListener('popstate', onHistory); + }; + recordLocation(); + }; + // Re-apply whenever the level changes (the frame keeps its content across zooms). useEffect(apply, [zoom.zoom, url]); + useEffect(() => { + historyRef.current = { entries: [], index: -1 }; + setCanBack(false); + setCanForward(false); + }, [url]); + useEffect(() => () => detachRef.current(), []); + return (
+
+ + + +
-