diff --git a/design-system/packages/design-tokens/src/system.tokens.json b/design-system/packages/design-tokens/src/system.tokens.json index 0a11a07ea5..d615f51208 100644 --- a/design-system/packages/design-tokens/src/system.tokens.json +++ b/design-system/packages/design-tokens/src/system.tokens.json @@ -33,7 +33,7 @@ }, "mono": { "$type": "fontFamily", - "$value": "'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, 'Cascadia Mono', 'Cascadia Code', Consolas, 'Liberation Mono', 'Courier New', monospace" + "$value": "'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, 'Cascadia Mono', 'Cascadia Code', Consolas, 'Liberation Mono', 'Courier New', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', monospace" } }, "size": { @@ -859,6 +859,10 @@ "menu": { "$type": "dimension", "inlineSize": { "$value": "220px" }, + "minInlineSize": { + "$description": "Lower bound for content-sized menus; overlay.menu.inlineSize stays their upper bound.", + "$value": "160px" + }, "maxBlockSize": { "$value": "480px" }, "surfacePadding": { "$value": "{space.2}" }, "surfaceRadius": { "$value": "{radius.xl}" }, diff --git a/design-system/packages/design-tokens/tests/contract.test.mjs b/design-system/packages/design-tokens/tests/contract.test.mjs index 39e45fa767..852b0b0335 100644 --- a/design-system/packages/design-tokens/tests/contract.test.mjs +++ b/design-system/packages/design-tokens/tests/contract.test.mjs @@ -242,6 +242,11 @@ test("Menu tokens preserve the compact grouped surface contract", async () => { const systemDocument = await readSource("system.tokens.json"); assert.equal(tokens["overlay.menu.inlineSize"], "220px"); + assert.equal(tokens["overlay.menu.minInlineSize"], "160px"); + assert.ok( + Number.parseFloat(tokens["overlay.menu.minInlineSize"]) < Number.parseFloat(tokens["overlay.menu.inlineSize"]), + "content-sized menus need a minimum strictly below the fixed width", + ); assert.equal(tokens["overlay.menu.maxBlockSize"], "480px"); assert.equal(tokens["overlay.menu.headingHeight"], "24px"); assert.equal(tokens["overlay.menu.itemHeight"], "30px"); diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index 9ecc164156..a29c4c7e97 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -598,6 +598,12 @@ Product code owns positioning and viewport limits, and must not patch private list/section-items/group-options gaps. A deliberate density variation belongs on the owning surface via `--openbitfun-overlay-menu-row-gap`. +Menus use `overlay.menu.inlineSize` by default. `Menu` / `MenuPopover` accept +`inlineSize="content"` for short, product-owned surfaces such as a context menu: +the surface then hugs its widest row, stays at or above +`overlay.menu.minInlineSize`, and never exceeds the fixed token. Long menus that +share a column with the same triggering control keep the fixed width. + ActionItem hover and pressed surfaces use the semantic neutral hover fill; pressed text remains semibold. Menu and navigation captions consume the final caption color directly, avoiding a second opacity multiplier. The nested-menu diff --git a/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css b/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css index c8a0ae4aeb..e16f645395 100644 --- a/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css +++ b/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css @@ -126,6 +126,16 @@ white-space: nowrap; } + /* A text shortcut hint is secondary text, so it must not inherit the surface body size. */ + .shortcut { + font-family: var(--openbitfun-type-meta-font-family); + font-size: var(--openbitfun-type-meta-font-size); + font-weight: var(--openbitfun-type-meta-font-weight); + line-height: var(--openbitfun-type-meta-line-height); + letter-spacing: var(--openbitfun-type-meta-letter-spacing); + white-space: nowrap; + } + .root[data-disabled="true"] .metadata { color: inherit; } diff --git a/design-system/packages/ui/src/components/Menu/Menu.meta.ts b/design-system/packages/ui/src/components/Menu/Menu.meta.ts index acab5b27cc..b41ddb718d 100644 --- a/design-system/packages/ui/src/components/Menu/Menu.meta.ts +++ b/design-system/packages/ui/src/components/Menu/Menu.meta.ts @@ -10,6 +10,7 @@ export const menuMeta = { { name: "MenuList.children", type: "ReactNode (row stack inside custom scroll or animation wrappers)" }, { defaultValue: "false", name: "autoFocusFirstItem", type: "boolean" }, { defaultValue: "auto", name: "scrollbarVisibility", type: "auto | always | hidden" }, + { defaultValue: "fixed", name: "inlineSize", type: "fixed | content" }, { name: "MenuPopover.items", type: "readonly MenuEntry[]" }, { name: "MenuPopover.open / onClose", type: "boolean / () => void" }, { name: "MenuPopover.anchorRef / position", type: "RefObject / { x: number; y: number }" }, @@ -29,6 +30,7 @@ export const menuMeta = { "color.selection.surface", "color.focus.ring", "overlay.menu.inlineSize", + "overlay.menu.minInlineSize", "overlay.menu.maxBlockSize", "overlay.menu.surfacePadding", "overlay.menu.surfaceRadius", diff --git a/design-system/packages/ui/src/components/Menu/Menu.module.css b/design-system/packages/ui/src/components/Menu/Menu.module.css index e3f3fa3c18..0bb894c074 100644 --- a/design-system/packages/ui/src/components/Menu/Menu.module.css +++ b/design-system/packages/ui/src/components/Menu/Menu.module.css @@ -23,6 +23,13 @@ box-shadow: var(--openbitfun-shadow-menu); } + /* Content-driven menus hug their widest row; the fixed token stays the upper bound. */ + .root[data-openbitfun-inline-size="content"] { + inline-size: max-content; + min-inline-size: var(--openbitfun-overlay-menu-min-inline-size); + max-inline-size: min(var(--openbitfun-overlay-menu-inline-size), 100%); + } + .viewport { flex: 1 1 auto; min-inline-size: 0; diff --git a/design-system/packages/ui/src/components/Menu/Menu.tsx b/design-system/packages/ui/src/components/Menu/Menu.tsx index 801741c3ea..e088fab6c0 100644 --- a/design-system/packages/ui/src/components/Menu/Menu.tsx +++ b/design-system/packages/ui/src/components/Menu/Menu.tsx @@ -21,10 +21,14 @@ import styles from "./Menu.module.css"; export type MenuItemRole = "menuitem" | "menuitemcheckbox" | "menuitemradio"; +/** `fixed` keeps the menu width token; `content` fits the rows between the menu minimum and that token. */ +export type MenuInlineSize = "fixed" | "content"; + export interface MenuProps extends Omit, "autoFocus" | "role"> { autoFocusFirstItem?: boolean; children: ReactNode; + inlineSize?: MenuInlineSize; scrollbarVisibility?: ScrollbarVisibility; } @@ -105,6 +109,7 @@ export const Menu = forwardRef(function Menu({ autoFocusFirstItem = false, children, className, + inlineSize = "fixed", onFocusCapture, onKeyDown, scrollbarVisibility = "auto", @@ -210,6 +215,7 @@ export const Menu = forwardRef(function Menu({ {...props} className={classNames(styles.root, className)} data-openbitfun-component="menu" + data-openbitfun-inline-size={inlineSize} onFocusCapture={handleFocusCapture} onKeyDown={handleKeyDown} ref={setRootRef} diff --git a/design-system/packages/ui/src/components/Menu/MenuPopover.tsx b/design-system/packages/ui/src/components/Menu/MenuPopover.tsx index 445a476a27..593d65a8c5 100644 --- a/design-system/packages/ui/src/components/Menu/MenuPopover.tsx +++ b/design-system/packages/ui/src/components/Menu/MenuPopover.tsx @@ -121,7 +121,7 @@ interface MenuLevelProps extends Omit { parts?: MenuPopoverParts; } -function MenuLevel({ items, open, phase, treeId, onClose, onBack, anchorRef, position, placement, menuRef: externalRef, autoFocusFirstItem, className, style, parts, ...props }: MenuLevelProps) { +function MenuLevel({ items, open, phase, treeId, onClose, onBack, anchorRef, position, placement, menuRef: externalRef, autoFocusFirstItem, className, inlineSize, style, parts, ...props }: MenuLevelProps) { const MenuSurface = parts?.root ?? Menu; const Item = parts?.item ?? MenuItem; const Separator = parts?.separator ?? MenuSeparator; @@ -197,12 +197,12 @@ function MenuLevel({ items, open, phase, treeId, onClose, onBack, anchorRef, pos return () => doc?.removeEventListener("keydown", keyboard, true); }); - const submenu = activeEntry ? { intent.closeNow(); submenuAnchor.current?.focus(); }} anchorRef={submenuAnchor} placement="right" autoFocusFirstItem={keyboardOpen.current} onPointerEnter={intent.keepOpen} onPointerLeave={intent.requestClose} /> : null; return <> - { (menuRef as { current: HTMLDivElement | null }).current = node; }} className={classNames(styles.popup, className)} autoFocusFirstItem={open && autoFocusFirstItem && Boolean(layout)} tabIndex={-1} + { (menuRef as { current: HTMLDivElement | null }).current = node; }} className={classNames(styles.popup, className)} inlineSize={inlineSize} autoFocusFirstItem={open && autoFocusFirstItem && Boolean(layout)} tabIndex={-1} style={{ ...layout?.style, ...style, visibility: layout ? undefined : "hidden" }} data-openbitfun-native-webview-occlusion data-openbitfun-menu-tree={treeId} data-placement={layout?.placement ?? placement} data-state={phase} aria-hidden={!open || undefined} {...(!open ? { inert: "" } : {})} onContextMenu={event => event.preventDefault()}> {items.map(item => item.separator ? : {item.icon} : undefined} shortcut={item.shortcut ? {item.shortcut} : undefined} tone={item.tone} role={item.role} checked={item.checked} diff --git a/design-system/packages/ui/tests/menu.test.mjs b/design-system/packages/ui/tests/menu.test.mjs index 0a52d77df8..96af478037 100644 --- a/design-system/packages/ui/tests/menu.test.mjs +++ b/design-system/packages/ui/tests/menu.test.mjs @@ -143,3 +143,24 @@ test("Menu keeps equal item insets while its scrollbar stays on the surface edge ); assert.doesNotMatch(styles, /scrollbar-gutter:\s*stable/); }); + +test("content-sized menus hug their widest row inside the shared menu bounds", async () => { + const styles = await readFile(new URL("../src/components/Menu/Menu.module.css", import.meta.url), "utf8"); + const popover = await readFile(new URL("../src/components/Menu/MenuPopover.tsx", import.meta.url), "utf8"); + const contentRule = styles.match(/\.root\[data-openbitfun-inline-size="content"\]\s*\{[^}]*\}/)?.[0] ?? ""; + + assert.notEqual(contentRule, ""); + assert.match(contentRule, /inline-size:\s*max-content/); + assert.match(contentRule, /min-inline-size:\s*var\(--openbitfun-overlay-menu-min-inline-size\)/); + assert.match(contentRule, /max-inline-size:\s*min\(var\(--openbitfun-overlay-menu-inline-size\), 100%\)/); + // The default surface keeps the fixed token instead of hugging content. + assert.match(styles, /\.root\s*\{[^}]*inline-size:\s*var\(--openbitfun-overlay-menu-inline-size\)/); + + const contentMarkup = renderToStaticMarkup(createElement(Menu, { inlineSize: "content" }, createElement(MenuItem, null, "Paste"))); + const defaultMarkup = renderToStaticMarkup(createElement(Menu, null, createElement(MenuItem, null, "Paste"))); + assert.match(contentMarkup, /data-openbitfun-inline-size="content"/); + assert.match(defaultMarkup, /data-openbitfun-inline-size="fixed"/); + // Submenus are separate surfaces and must inherit the requested sizing mode. + assert.match(popover, /items=\{activeEntry\.submenu!\}[^>]*inlineSize=\{inlineSize\}/); +}); + diff --git a/src/apps/data-migrator/ui/generated/design-system.css b/src/apps/data-migrator/ui/generated/design-system.css index 143d5a8ac3..fdc099faba 100644 --- a/src/apps/data-migrator/ui/generated/design-system.css +++ b/src/apps/data-migrator/ui/generated/design-system.css @@ -163,7 +163,7 @@ --openbitfun-focus-offset: 2px; --openbitfun-focus-width: 2px; --openbitfun-font-family-control: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI Variable Text', 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; - --openbitfun-font-family-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, 'Cascadia Mono', 'Cascadia Code', Consolas, 'Liberation Mono', 'Courier New', monospace; + --openbitfun-font-family-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, 'Cascadia Mono', 'Cascadia Code', Consolas, 'Liberation Mono', 'Courier New', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', monospace; --openbitfun-font-family-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; --openbitfun-font-size-2xl: 18px; --openbitfun-font-size-2xl-plus: 20px; @@ -388,6 +388,7 @@ --openbitfun-overlay-menu-item-padding-inline: var(--openbitfun-space-2); --openbitfun-overlay-menu-item-radius: var(--openbitfun-radius-base); --openbitfun-overlay-menu-max-block-size: 480px; + --openbitfun-overlay-menu-min-inline-size: 160px; --openbitfun-overlay-menu-row-gap: 2px; --openbitfun-overlay-menu-scrollbar-gap: 2px; --openbitfun-overlay-menu-section-gap: var(--openbitfun-space-2); diff --git a/src/crates/contracts/product-domains/src/miniapp/generated/default_appearance_style.html b/src/crates/contracts/product-domains/src/miniapp/generated/default_appearance_style.html index 03452a2010..ea56074d51 100644 --- a/src/crates/contracts/product-domains/src/miniapp/generated/default_appearance_style.html +++ b/src/crates/contracts/product-domains/src/miniapp/generated/default_appearance_style.html @@ -5,7 +5,7 @@ --openbitfun-radius: 8px; --openbitfun-radius-lg: 12px; --openbitfun-font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; - --openbitfun-font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, 'Cascadia Mono', 'Cascadia Code', Consolas, 'Liberation Mono', 'Courier New', monospace; + --openbitfun-font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, 'Cascadia Mono', 'Cascadia Code', Consolas, 'Liberation Mono', 'Courier New', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei UI', 'Microsoft YaHei', monospace; --openbitfun-bg: #0e0e10; --openbitfun-bg-secondary: #1c1c1f; --openbitfun-bg-tertiary: #0e0e10; diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 5ec24f7f9c..e88a238e57 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -1570,7 +1570,6 @@ $_section-header-height: 22px; /* Positioning-only overrides; the design-system Menu owns the surface look. */ .openbitfun-nav-panel__footer-menu { position: fixed; - min-width: 148px; max-width: calc(100vw - 16px); max-height: calc(100vh - 16px); z-index: var(--openbitfun-layer-popover); diff --git a/src/web-ui/src/app/components/NavPanel/components/AppearanceQuickSwitchMenuItem.tsx b/src/web-ui/src/app/components/NavPanel/components/AppearanceQuickSwitchMenuItem.tsx index abff59a642..969e7244e3 100644 --- a/src/web-ui/src/app/components/NavPanel/components/AppearanceQuickSwitchMenuItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/AppearanceQuickSwitchMenuItem.tsx @@ -237,6 +237,7 @@ const AppearanceQuickSwitchMenuItem: React.FC = ({ { { diff --git a/src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx b/src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx index 9a5d7702c4..0904229423 100644 --- a/src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx @@ -74,14 +74,15 @@ const WorkspaceSessionFilterMenu: React.FC = () => { const anchor = buttonRef.current?.getBoundingClientRect(); if (!anchor) return; const measuredHeight = menuRef.current?.offsetHeight ?? 422; + const menuWidth = menuRef.current?.offsetWidth || MAIN_MENU_WIDTH; const preferredRight = anchor.right + MENU_GAP; - const canOpenRight = preferredRight + MAIN_MENU_WIDTH <= window.innerWidth - VIEWPORT_PADDING; + const canOpenRight = preferredRight + menuWidth <= window.innerWidth - VIEWPORT_PADDING; setMenuPosition({ top: clamp(anchor.top - 6, VIEWPORT_PADDING, window.innerHeight - measuredHeight - VIEWPORT_PADDING), left: clamp( - canOpenRight ? preferredRight : anchor.left - MENU_GAP - MAIN_MENU_WIDTH, + canOpenRight ? preferredRight : anchor.left - MENU_GAP - menuWidth, VIEWPORT_PADDING, - window.innerWidth - MAIN_MENU_WIDTH - VIEWPORT_PADDING, + window.innerWidth - menuWidth - VIEWPORT_PADDING, ), }); }, []); @@ -235,6 +236,7 @@ const WorkspaceSessionFilterMenu: React.FC = () => { { = ({ = ({ = ({ [data-openbitfun-component='icon'] { + margin-inline-start: -$chevron-box-slack; + } } &__detail-heading { @@ -592,14 +605,23 @@ font-weight: var(--openbitfun-type-body-sm-font-weight); } + /* + * The hover pill paints its own background, and the results viewport has no + * inline-start padding to bleed a negative optical offset into, so the pill + * keeps real inline padding and its count sits inside the group title. + */ .global-search__group-drilldown { min-height: 0; margin: 0; - padding: 0; + padding: 0 var(--openbitfun-space-1); gap: var(--openbitfun-space-1); color: inherit; font-size: inherit; font-weight: inherit; + + > [data-openbitfun-component='icon'] { + margin-inline-end: -$chevron-box-slack; + } } .global-search__group-items { diff --git a/src/web-ui/src/font-profiles/apple-system.css b/src/web-ui/src/font-profiles/apple-system.css index 2c40149851..acb888649c 100644 --- a/src/web-ui/src/font-profiles/apple-system.css +++ b/src/web-ui/src/font-profiles/apple-system.css @@ -2,6 +2,6 @@ :where([data-openbitfun-design-system-root]) { --openbitfun-font-family-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif; --openbitfun-font-family-control: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif; - --openbitfun-font-family-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, monospace; + --openbitfun-font-family-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, "PingFang SC", "Hiragino Sans GB", monospace; } } diff --git a/src/web-ui/src/shared/context-menu-system/components/ContextMenuRenderer.tsx b/src/web-ui/src/shared/context-menu-system/components/ContextMenuRenderer.tsx index b185b1f95c..4c6cdf6516 100644 --- a/src/web-ui/src/shared/context-menu-system/components/ContextMenuRenderer.tsx +++ b/src/web-ui/src/shared/context-menu-system/components/ContextMenuRenderer.tsx @@ -16,6 +16,7 @@ import { Navigation, Scissors, Square, + SquareCheckBig, type LucideIcon, } from 'lucide-react'; @@ -59,6 +60,7 @@ const CONTEXT_MENU_ICONS = { List, Navigation, Scissors, + SelectAll: SquareCheckBig, Square, } satisfies Record; diff --git a/src/web-ui/src/shared/context-menu-system/components/ui/ContextMenu.test.tsx b/src/web-ui/src/shared/context-menu-system/components/ui/ContextMenu.test.tsx index 89d121cc57..d53f56c3a2 100644 --- a/src/web-ui/src/shared/context-menu-system/components/ui/ContextMenu.test.tsx +++ b/src/web-ui/src/shared/context-menu-system/components/ui/ContextMenu.test.tsx @@ -1,4 +1,6 @@ import React, { act } from 'react'; +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; import { createRoot, type Root } from 'react-dom/client'; import { JSDOM } from 'jsdom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -8,6 +10,27 @@ import type { ContextMenuItem } from './types'; import { ContextMenuRenderer } from '../ContextMenuRenderer'; import { useContextMenuStore } from '../../store/ContextMenuStore'; +const contextMenuSourceRoot = path.resolve(__dirname, '../..'); + +function contextMenuSourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) return contextMenuSourceFiles(file); + return /\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name) ? [file] : []; + }); +} + +/** Icon names are strings until the renderer maps them, so every referenced name must resolve. */ +function referencedIconNames(): string[] { + const names = new Set(); + for (const file of contextMenuSourceFiles(contextMenuSourceRoot)) { + for (const match of readFileSync(file, 'utf8').matchAll(/\bicon:\s*'([^']+)'/g)) { + names.add(match[1]); + } + } + return Array.from(names).sort(); +} + vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ error: vi.fn() }), })); @@ -267,6 +290,13 @@ describe('ContextMenu presence', () => { expect(document.querySelector('[data-openbitfun-product-part="item"][data-openbitfun-state="submenu-active"]')?.getAttribute('aria-expanded')).toBe('true'); }); + it('sizes the surface to its own rows within the shared menu bounds', () => { + act(() => root.render(, shortcut: 'Ctrl+Shift+C' }, + ]} />)); + expect(document.querySelector('[data-openbitfun-component="menu"]')?.getAttribute('data-openbitfun-inline-size')).toBe('content'); + }); + it('resolves the file explorer terminal icon and forwards layout classes through product slots', () => { useContextMenuStore.setState({ visible: true, @@ -313,4 +343,25 @@ describe('ContextMenu presence', () => { expect(document.querySelector('[data-menu-id="configure"] [data-openbitfun-name="gear"]')).not.toBeNull(); expect(document.querySelector('[data-menu-id="remove"] [data-openbitfun-name="delete"]')).not.toBeNull(); }); + + it('resolves every icon name referenced by menu providers and commands', () => { + const names = referencedIconNames(); + expect(names).toContain('SelectAll'); + expect(names.length).toBeGreaterThan(10); + + useContextMenuStore.setState({ + visible: true, + position: { x: 20, y: 20 }, + items: names.map(name => ({ id: `icon-${name}`, label: name, icon: name })), + }); + + act(() => root.render()); + + for (const name of names) { + const item = document.querySelector(`[data-menu-id="icon-${name}"]`); + expect(item, `unresolved menu item for icon ${name}`).not.toBeNull(); + expect(item?.querySelector('svg'), `unresolved icon ${name}`).not.toBeNull(); + expect(item?.querySelector('i'), `untranslated icon name ${name}`).toBeNull(); + } + }); }); diff --git a/src/web-ui/src/shared/context-menu-system/components/ui/ContextMenu.tsx b/src/web-ui/src/shared/context-menu-system/components/ui/ContextMenu.tsx index 4faea58a94..b36b786d41 100644 --- a/src/web-ui/src/shared/context-menu-system/components/ui/ContextMenu.tsx +++ b/src/web-ui/src/shared/context-menu-system/components/ui/ContextMenu.tsx @@ -41,7 +41,9 @@ export const ContextMenu: React.FC = ({ items, position, visib }, }); - return ; + // Context menus size to their own rows: there is no shared column to align with, and the + // default menu width leaves a wide gap between short labels and their shortcuts. + return ; }; export default ContextMenu; diff --git a/src/web-ui/src/shared/context-menu-system/providers/EditorMenuProvider.ts b/src/web-ui/src/shared/context-menu-system/providers/EditorMenuProvider.ts index 4d930417fd..f4a24a90d4 100644 --- a/src/web-ui/src/shared/context-menu-system/providers/EditorMenuProvider.ts +++ b/src/web-ui/src/shared/context-menu-system/providers/EditorMenuProvider.ts @@ -153,6 +153,7 @@ export class EditorMenuProvider implements IMenuProvider { items.push({ id: 'editor-select-all', label: i18nService.t('common:actions.selectAll'), + icon: 'SelectAll', shortcut: 'Ctrl+A', command: 'select-all', onClick: async (ctx) => { diff --git a/src/web-ui/src/shared/context-menu-system/providers/TerminalMenuProvider.ts b/src/web-ui/src/shared/context-menu-system/providers/TerminalMenuProvider.ts index 46e452c441..70536c378d 100644 --- a/src/web-ui/src/shared/context-menu-system/providers/TerminalMenuProvider.ts +++ b/src/web-ui/src/shared/context-menu-system/providers/TerminalMenuProvider.ts @@ -63,6 +63,7 @@ export class TerminalMenuProvider implements IMenuProvider { items.push({ id: 'terminal-select-all', label: i18nService.t('common:actions.selectAll'), + icon: 'SelectAll', shortcut: 'Ctrl+Shift+A', onClick: () => { globalEventBus.emit('terminal:select-all', { diff --git a/src/web-ui/src/tools/terminal/components/Terminal.tsx b/src/web-ui/src/tools/terminal/components/Terminal.tsx index 20796db6fc..27f2976d2c 100644 --- a/src/web-ui/src/tools/terminal/components/Terminal.tsx +++ b/src/web-ui/src/tools/terminal/components/Terminal.tsx @@ -20,9 +20,10 @@ import { sendDebugProbe } from '@/shared/utils/debugProbe'; import { nowMs } from '@/shared/utils/timing'; import { getTypographyTokenNumber, - getTypographyTokenPx, getTypographyTokenValue, + readActiveTypographyTokenPx, } from '@/infrastructure/design-system/typographyRuntime'; +import { fontPreferenceService } from '@/infrastructure/font-preference'; import '@xterm/xterm/css/xterm.css'; import './Terminal.scss'; @@ -174,8 +175,19 @@ function normalizePasteDecision( return decision; } +/** + * The interactive terminal shares the `xs` code step with chat code blocks so a + * shell panel never renders larger than the code beside it, and it follows the + * global font size preference because xterm takes a pixel size instead of a CSS + * custom property. + */ +const TERMINAL_FONT_SIZE_TOKEN = 'font.size.xs' as const; + +function readTerminalFontSize(): number { + return readActiveTypographyTokenPx(TERMINAL_FONT_SIZE_TOKEN); +} + const DEFAULT_OPTIONS: TerminalOptions = { - fontSize: getTypographyTokenPx('font.size.base'), fontFamily: getTypographyTokenValue('font.family.mono'), lineHeight: getTypographyTokenNumber('lineHeight.tight'), minimumContrastRatio: DEFAULT_XTERM_MINIMUM_CONTRAST_RATIO, @@ -229,11 +241,17 @@ const Terminal = forwardRef(({ // _keyPressHandled, to avoid duplicates in the safety net. const keyPressHandledRef = useRef(false); const [isReady, setIsReady] = useState(false); + const [fontSize, setFontSize] = useState(readTerminalFontSize); + useEffect(() => { + const syncFontSize = () => setFontSize(readTerminalFontSize()); + return fontPreferenceService.on('font:after-change', syncFontSize); + }, []); // Merge options. Appearance is resolved at render time so that the // initial XTerm instance is created with the correct background color and avoids // the black-background flash that occurs when a light theme is active. const mergedOptions = { ...DEFAULT_OPTIONS, + fontSize, ...options, theme: getInitialXtermColors(), };