From 2839c981842c37c65bd606e408eb753d76ffa99f Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Tue, 22 Sep 2026 09:47:29 +0800 Subject: [PATCH 1/7] fix(ui): even out the global search pills around their chevrons The group count pill and the drilldown back pill both read lopsided, and the padding was not the cause. A lucide chevron inks only x 9..15 of its 24 viewBox, so the square Icon box keeps roughly 34% of its width empty on each side -- 4.8px at the 14px sm box under the count, 5.1px at the 15px box in the back button. Whichever end of a pill a chevron meets therefore reads about 5px wider than the padding declares, no matter how even that padding is. Computed from the compiled stylesheets and the glyph paths: the count pill's visible insets were 4.5px leading against 8.8px trailing, and the back pill's were 9.1px leading against 6.5px trailing. The back button also carried an asymmetric 3px 6px 3px 4px, which the chevron slack then amplified. Cancel the chevron box slack only on the side that meets the pill's inline padding, via margin-inline-end on the trailing count chevron and margin-inline-start on the leading back chevron, and make the back button's padding symmetric. The slack is one local $chevron-box-slack constant with the glyph geometry documented next to it, because it tracks the rendered chevron size rather than the spacing scale. The count pill keeps --openbitfun-space-1 padding: its hover pill cannot bleed left, since the results viewport in the modal host has no inline-start padding to bleed into and would clip the leading radius. The inner text-to-chevron gap stays untouched. It is a flex gap rather than an inset, and after the edge fix it no longer reads as one. Verified with the compiled stylesheet output for both selectors and with pnpm run check:web:appearance (typography audit, appearance contracts, theme color audit, theme visual contract), which reports the same pre-existing warnings as before. No test pins these two declarations yet. --- .../app/global-search/GlobalSearchRoot.scss | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/web-ui/src/app/global-search/GlobalSearchRoot.scss b/src/web-ui/src/app/global-search/GlobalSearchRoot.scss index 283eff7224..aec5d12ff1 100644 --- a/src/web-ui/src/app/global-search/GlobalSearchRoot.scss +++ b/src/web-ui/src/app/global-search/GlobalSearchRoot.scss @@ -1,6 +1,15 @@ @use '../styles/nav-panel-font-scope.scss' as nav-font; @use '../styles/workspace-shell-surfaces' as shell-surfaces; +/* + * A lucide chevron inks only the middle of its square box -- the path spans + * x 9..15 in a 24 viewBox, so roughly 34% of the box stays empty on each side + * (about 5px at the 14-15px chevrons this surface renders). A pill whose + * chevron meets its inline padding has to cancel that slack, otherwise the + * chevron end reads wider than the text end even though the padding is even. + */ +$chevron-box-slack: 5px; + .global-search-dialog { // The results viewport absorbs short windows and longer result sets. inline-size: var(--openbitfun-layout-search-dialog-width); @@ -136,10 +145,14 @@ * viewport no inline-start padding above 980px, so a leading negative * margin crosses the clip line and chops the pill's leading radius. */ - padding: 3px 6px 3px 4px; + padding: 3px 6px; border-radius: 7px; font-size: var(--openbitfun-type-label-sm-font-size); font-weight: var(--openbitfun-type-label-sm-font-weight); + + > [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 { From 9dd78c2c43376ac17c5277c3dccdb6f166ecbba0 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Tue, 22 Sep 2026 10:13:08 +0800 Subject: [PATCH 2/7] fix(terminal): match the shell font to code surfaces and fix its CJK fallback The shell panel rendered noticeably larger than the code surfaces beside it and showed Simplified Chinese in the platform's generic monospace face (SimSun on Windows), even though the interface text around it looks like a modern sans. Two separate causes: - `font.family.mono` names no CJK family, unlike `sans` and `control`, which already carry 'PingFang SC', 'Hiragino Sans GB' and 'Microsoft YaHei UI'. Chromium therefore fell through to the generic `monospace` CJK face. The CJK families are now appended after the Latin monospace families and before the generic keyword, which is the only correct position: a proportional CJK face placed earlier in the list also supplies Latin glyphs and would take code rendering off the monospace grid. The apple-system profile mirrors the stack, and the generated data-migrator stylesheet and MiniApp first-paint projection are regenerated from the token. - The interactive terminal took `font.size.base` (14px), while the read-only terminal preview inside chat and every other code surface take the `xs` step (12px). It now reads `font.size.xs` and re-reads it on `font:after-change`, so the panel follows the global font size preference like the other renderers that cannot consume a CSS custom property. An explicit `options.fontSize` from a caller still wins over both. The chat preview keeps its static `xs` token, so the two agree at the default preference. Verified with pnpm --dir design-system/packages/design-tokens test (37 passed), pnpm --dir src/web-ui run test:run src/tools/terminal (44 passed), pnpm run type-check:web, eslint on the changed component, pnpm run data-migrator:theme:check, and pnpm run check:web:appearance (typography audit, appearance contracts, theme color audit, theme visual contract), which reports only the same pre-existing warnings. No test pins the terminal font size yet. --- .../design-tokens/src/system.tokens.json | 2 +- .../ui/generated/design-system.css | 2 +- .../generated/default_appearance_style.html | 2 +- src/web-ui/src/font-profiles/apple-system.css | 2 +- .../tools/terminal/components/Terminal.tsx | 22 +++++++++++++++++-- 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/design-system/packages/design-tokens/src/system.tokens.json b/design-system/packages/design-tokens/src/system.tokens.json index 0a11a07ea5..2114987b62 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": { diff --git a/src/apps/data-migrator/ui/generated/design-system.css b/src/apps/data-migrator/ui/generated/design-system.css index 143d5a8ac3..5085a35359 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; 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/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/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(), }; From 8ffd17a83c90291b7c90dc359f3f180ccf342a20 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Tue, 22 Sep 2026 11:04:46 +0800 Subject: [PATCH 3/7] fix(ui): add a content-sized inline width to menus A floating action menu has no shared column to align with, so the default menu width left a wide gap between short labels and their trailing content. Menu now takes an opt-in inlineSize="content" that shrink-wraps the widest row inside the shared menu bounds, capped by overlay.menu.inlineSize and floored by a new overlay.menu.minInlineSize token. The default stays fixed so anchored pickers keep matching their control. --- .../design-tokens/src/system.tokens.json | 4 ++++ .../design-tokens/tests/contract.test.mjs | 5 +++++ design-system/packages/ui/README.md | 6 ++++++ .../ui/src/components/Menu/Menu.meta.ts | 2 ++ .../ui/src/components/Menu/Menu.module.css | 7 +++++++ .../packages/ui/src/components/Menu/Menu.tsx | 6 ++++++ .../ui/src/components/Menu/MenuPopover.tsx | 6 +++--- design-system/packages/ui/tests/menu.test.mjs | 21 +++++++++++++++++++ .../ui/generated/design-system.css | 1 + 9 files changed, 55 insertions(+), 3 deletions(-) diff --git a/design-system/packages/design-tokens/src/system.tokens.json b/design-system/packages/design-tokens/src/system.tokens.json index 2114987b62..d615f51208 100644 --- a/design-system/packages/design-tokens/src/system.tokens.json +++ b/design-system/packages/design-tokens/src/system.tokens.json @@ -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/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 5085a35359..fdc099faba 100644 --- a/src/apps/data-migrator/ui/generated/design-system.css +++ b/src/apps/data-migrator/ui/generated/design-system.css @@ -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); From 612845183a455f32a47bb2df852ea8a0625adcfb Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Tue, 22 Sep 2026 11:04:50 +0800 Subject: [PATCH 4/7] fix(ui): size menu shortcut hints with the meta role ActionItem .shortcut shared only its display rules with .metadata, so a text hint inherited the surface body size and rendered larger than the 13px label next to it. It now carries the meta typography role, matching SearchField. --- .../ui/src/components/ActionItem/ActionItem.module.css | 10 ++++++++++ 1 file changed, 10 insertions(+) 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; } From f3c30a787f60b56c09b43d9ac6eb21644d8d5af3 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Tue, 22 Sep 2026 11:04:53 +0800 Subject: [PATCH 5/7] fix(ui): draw the select-all menu row with a solid glyph BoxSelect is an alias of SquareDashed, so select all read as a dashed placeholder box in the terminal and editor menus. --- .../context-menu-system/components/ContextMenuRenderer.tsx | 2 ++ .../shared/context-menu-system/providers/EditorMenuProvider.ts | 1 + .../context-menu-system/providers/TerminalMenuProvider.ts | 1 + 3 files changed, 4 insertions(+) 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/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', { From 28982918cadaf002781e6d474ca29f9aedde8258 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Tue, 22 Sep 2026 11:04:56 +0800 Subject: [PATCH 6/7] fix(ui): size context menus to their own rows Right-click menus now shrink-wrap their labels, shortcut column, and submenu chevrons instead of reserving the default menu width. --- .../components/ui/ContextMenu.test.tsx | 51 +++++++++++++++++++ .../components/ui/ContextMenu.tsx | 4 +- 2 files changed, 54 insertions(+), 1 deletion(-) 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; From d1476dff4a10859a9e5f891f2a2e223356e7242e Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Tue, 22 Sep 2026 11:04:59 +0800 Subject: [PATCH 7/7] fix(nav): size the sidebar action menus to their rows Applies the shared content-sized menu to the settings, theme, session, workspace, session filter, and assistant menus, and drops the private min-width that the shared floor now owns. The session filter menu measures its own width instead of assuming the default. --- src/web-ui/src/app/components/NavPanel/NavPanel.scss | 1 - .../components/AppearanceQuickSwitchMenuItem.tsx | 1 + .../NavPanel/components/AssistantSessionCreateMenu.tsx | 1 + .../NavPanel/components/PersistentFooterActions.tsx | 1 + .../NavPanel/components/WorkspaceSessionFilterMenu.tsx | 9 ++++++--- .../NavPanel/sections/sessions/SessionsSection.tsx | 1 + .../sections/workspaces/WorkspaceAcpSessionSubmenu.tsx | 1 + .../NavPanel/sections/workspaces/WorkspaceItem.tsx | 2 ++ 8 files changed, 13 insertions(+), 4 deletions(-) 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 = () => { { = ({ = ({ = ({