From 90f8108a3d00f38b28d4db956458a2a20dbfa9ce Mon Sep 17 00:00:00 2001 From: GCWing Date: Tue, 8 Sep 2026 22:13:13 +0800 Subject: [PATCH] feat(ui): refine search, startup, and scrollbar behavior --- design-system/packages/ui/README.md | 31 ++- .../ConfirmDialog/ConfirmDialog.module.css | 2 - .../src/components/Dialog/Dialog.module.css | 2 - .../ScrollArea/ScrollArea.module.css | 39 ---- .../src/components/ScrollArea/ScrollArea.tsx | 1 + .../SearchField/SearchField.meta.ts | 3 +- .../SearchField/SearchField.module.css | 39 +++- .../src/components/Tooltip/Tooltip.module.css | 13 -- design-system/packages/ui/src/flow-chat.ts | 1 + .../composer/ChatComposerQueue.module.css | 10 - design-system/packages/ui/src/index.ts | 1 + .../packages/ui/src/styles/scrollbars.css | 83 +++++++++ .../packages/ui/tests/scroll-area.test.mjs | 16 +- .../packages/ui/tests/search-field.test.mjs | 17 +- docs/architecture/i18n.md | 8 + scripts/generate-i18n-contract.mjs | 59 ++++++ scripts/i18n-contract.test.mjs | 1 + src/apps/desktop/src/appearance.rs | 33 ---- src/web-ui/index.html | 176 ++++++++++++------ .../AgentCompanionDesktopPet.scss | 10 - .../GalleryLayout/GalleryLayout.scss | 13 -- .../components/SplashScreen/SplashScreen.scss | 14 ++ .../mission-control/MissionControl.scss | 17 -- src/web-ui/src/app/layout/AppLayout.scss | 3 - .../src/app/layout/FloatingMiniChat.scss | 5 +- src/web-ui/src/app/scenes/SceneViewport.scss | 11 +- .../miniapps/views/MiniAppGalleryView.scss | 1 - .../miniapps/views/MiniAppMarketView.scss | 1 - .../app/scenes/my-agent/InsightsScene.scss | 8 - .../profile/views/AssistantDefaultsPage.scss | 4 - .../app/scenes/profile/views/NurseryView.scss | 36 ---- .../src/app/scenes/welcome/WelcomeScene.scss | 25 ++- .../src/app/scenes/welcome/WelcomeScene.tsx | 32 ++-- .../src/app/startup/startupPreload.test.ts | 78 +++++++- src/web-ui/src/app/styles/index.scss | 1 - src/web-ui/src/app/styles/scrollbar.test.ts | 85 +++++---- .../src/app/styles/utilities/scrollbar.css | 75 -------- .../src/flow_chat/components/ChatInput.scss | 25 --- .../src/flow_chat/components/ChatInput.tsx | 5 +- .../ChatInputWorkspaceStripLayout.test.ts | 3 +- .../components/InlineDiffPreview.scss | 18 -- .../TaskDetailPanel/TaskDetailPanel.scss | 15 -- .../BackgroundCommandOutputPanel.scss | 1 - .../components/modern/ExploreRegion.scss | 17 -- .../modern/FLOWCHAT_VERIFICATION.md | 19 +- .../modern/FLOWCHAT_VIEWPORT_REGISTER.md | 11 ++ .../modern/FLOWCHAT_VIRTUALIZATION.md | 11 ++ .../components/modern/FlowChatContext.tsx | 5 +- .../modern/ModernFlowChatContainer.tsx | 11 +- .../modern/SessionFileModificationsBar.scss | 19 -- .../components/modern/SessionFilesBadge.scss | 19 -- .../modern/VirtualItemRenderer.appearance.ts | 2 +- .../modern/VirtualItemRenderer.scss | 48 +++-- .../components/modern/VirtualItemRenderer.tsx | 36 ++-- ...rtualMessageList.session-boundary.test.tsx | 126 +++++++++++++ .../components/modern/VirtualMessageList.tsx | 114 +++++++++--- .../modern/flowChatSearchDom.test.ts | 40 +++- .../components/modern/flowChatSearchDom.ts | 72 +++++-- .../modern/flowChatSearchPresentation.test.ts | 88 +++++++++ .../modern/flowChatSearchPresentation.ts | 118 ++++++++++++ .../modern/useFlowChatSearch.test.ts | 54 ++++++ .../components/modern/useFlowChatSearch.ts | 28 +-- .../modern/useFlowChatSearchPresentation.ts | 76 ++++++++ .../components/toolbar-mode/ToolbarMode.scss | 15 -- .../toolbar-mode/ToolbarModeContext.ts | 4 +- .../toolbarWindowGeometry.test.ts | 6 + .../config/components/ModelSettingsPage.scss | 4 - src/web-ui/src/locales/en-US/common.json | 1 + src/web-ui/src/locales/zh-CN/common.json | 1 + src/web-ui/src/locales/zh-TW/common.json | 1 + .../styles/FeatureModal.scss | 9 - .../src/tools/editor/config/defaults.ts | 2 +- .../git/components/BranchQuickSwitch.scss | 2 - .../components/GitGraphView/GitGraphView.scss | 1 - 74 files changed, 1328 insertions(+), 653 deletions(-) create mode 100644 design-system/packages/ui/src/styles/scrollbars.css delete mode 100644 src/web-ui/src/app/styles/utilities/scrollbar.css create mode 100644 src/web-ui/src/flow_chat/components/modern/flowChatSearchPresentation.test.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/flowChatSearchPresentation.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/useFlowChatSearchPresentation.ts diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index 85d2a75a4e..24e6ed19e4 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -35,6 +35,27 @@ text buttons keep those hit targets while omitting the visible pill background and radius. Use native hover, pressed, focus, disabled, and loading behavior in addition to Design Lab's state specimens. +## Native scrollbars + +`styles.css` owns scrollbar presentation inside `ThemeRoot` (or +`data-openbitfun-design-system-root`) and standalone `ScrollArea` viewports. +Native file trees, virtualized transcripts, navigation, menus, and dialogs use +the same policy without wrappers, scroll listeners, or timers. + +On mouse/trackpad surfaces, the thumb appears while its own viewport is hovered +or contains visible keyboard focus. Leaving the viewport hides it; scrolling +from streaming output does not reveal an unattended panel. Touch surfaces retain +visible native thumbs, and forced colors retains system accessibility colors. +Tracks stay transparent. Only color changes, so hover never changes viewport +width, overflow, or scrollbar gutters. + +`ScrollArea` keeps `scrollbarVisibility="auto"` as the default. `always` keeps +the thumb visible and reserves a scrolling track; `hidden` deliberately hides +the native scrollbar while preserving scrolling. `Menu`, `Listbox`, and +`NavigationPanelBody` forward the same contract. Product styles own layout and +`scrollbar-gutter`, not local scrollbar colors or show/hide handlers. Monaco +and terminal renderers keep their own scrollbar APIs. + ## Text overflow @@ -275,14 +296,18 @@ Escape or selection restores the trigger, and Tab continues from its position in the form. Search, typed values, and multiple selection remain component-owned. `SearchField variant="embedded"` removes its standalone pill surface for these compositions; its container must supply padding, height, and visible focus -treatment. The default SearchField appearance is unchanged. +treatment. Standalone SearchField pills use a subtle neutral border, increasing +to the default neutral border on hover and focus while preserving validation +and forced-color states. This search-specific treatment does not change Input. `SearchField variant="panel"` provides a joined frosted surface with a rounded input row and an optional `footer` slot for result status and actions. It reuses the same input node when switching from the default pill, preserves input-row height, and provides a divider, metadata typography, and a single focus outline. -The surface uses semantic tint and blur tokens, with an opaque fallback for -unsupported blur or reduced transparency. Callers own the query, localized +The whole panel combines the raised semantic surface at 80% opacity with the +medium backdrop blur and overlay shadow; its input and footer remain transparent. +The panel uses the same quiet focus border, with an opaque fallback for +unsupported blur, reduced transparency, or high contrast. Callers own the query, localized counts, navigation callbacks, and disabled action states; use `IconButton` for the actions. The panel stays in normal flow by default. A toolbar that needs downward expansion without reflow should reserve the input height and position diff --git a/design-system/packages/ui/src/components/ConfirmDialog/ConfirmDialog.module.css b/design-system/packages/ui/src/components/ConfirmDialog/ConfirmDialog.module.css index a63e3b9891..16eb628a5f 100644 --- a/design-system/packages/ui/src/components/ConfirmDialog/ConfirmDialog.module.css +++ b/design-system/packages/ui/src/components/ConfirmDialog/ConfirmDialog.module.css @@ -79,8 +79,6 @@ border-radius: var(--openbitfun-layout-confirm-dialog-preview-radius); color: var(--openbitfun-color-content-primary); background: var(--openbitfun-color-surface-tertiary); - scrollbar-color: var(--openbitfun-color-scrollbar-thumb) transparent; - scrollbar-width: thin; } .preview pre { diff --git a/design-system/packages/ui/src/components/Dialog/Dialog.module.css b/design-system/packages/ui/src/components/Dialog/Dialog.module.css index 109dd7ebe1..d894c88754 100644 --- a/design-system/packages/ui/src/components/Dialog/Dialog.module.css +++ b/design-system/packages/ui/src/components/Dialog/Dialog.module.css @@ -127,8 +127,6 @@ flex: 1 1 auto; flex-direction: column; overflow: auto; - scrollbar-color: var(--openbitfun-color-scrollbar-thumb) transparent; - scrollbar-width: thin; } .body[data-inset="standard"] { diff --git a/design-system/packages/ui/src/components/ScrollArea/ScrollArea.module.css b/design-system/packages/ui/src/components/ScrollArea/ScrollArea.module.css index 57bda1c343..6578ad73b7 100644 --- a/design-system/packages/ui/src/components/ScrollArea/ScrollArea.module.css +++ b/design-system/packages/ui/src/components/ScrollArea/ScrollArea.module.css @@ -3,8 +3,6 @@ min-inline-size: 0; min-block-size: 0; overscroll-behavior: contain; - scrollbar-color: var(--openbitfun-color-scrollbar-thumb) transparent; - scrollbar-width: thin; } .root[data-openbitfun-orientation="vertical"] { @@ -32,41 +30,4 @@ .root[data-openbitfun-orientation="both"][data-openbitfun-scrollbar-visibility="always"] { overflow: scroll; } - - .root::-webkit-scrollbar { - inline-size: var(--openbitfun-scrollbar-width); - block-size: var(--openbitfun-scrollbar-width); - } - - .root::-webkit-scrollbar-track { - background: transparent; - } - - .root::-webkit-scrollbar-thumb { - border-radius: var(--openbitfun-scrollbar-radius); - background: var(--openbitfun-color-scrollbar-thumb); - } - - .root::-webkit-scrollbar-thumb:hover { - background: var(--openbitfun-color-scrollbar-thumb-hover); - } - - .root[data-openbitfun-scrollbar-visibility="hidden"] { - scrollbar-width: none; - } - - .root[data-openbitfun-scrollbar-visibility="hidden"]::-webkit-scrollbar { - display: none; - } - - @media (forced-colors: active) { - .root { - scrollbar-color: CanvasText transparent; - } - - .root::-webkit-scrollbar-thumb, - .root::-webkit-scrollbar-thumb:hover { - background: CanvasText; - } - } } diff --git a/design-system/packages/ui/src/components/ScrollArea/ScrollArea.tsx b/design-system/packages/ui/src/components/ScrollArea/ScrollArea.tsx index b4906016e6..8aa53babec 100644 --- a/design-system/packages/ui/src/components/ScrollArea/ScrollArea.tsx +++ b/design-system/packages/ui/src/components/ScrollArea/ScrollArea.tsx @@ -9,6 +9,7 @@ export interface ScrollAreaProps extends HTMLAttributes { "data-openbitfun-component"?: string; "data-openbitfun-part"?: string; orientation?: ScrollAreaOrientation; + /** Auto reveals on viewport hover or keyboard focus; touch remains visible. */ scrollbarVisibility?: ScrollbarVisibility; } diff --git a/design-system/packages/ui/src/components/SearchField/SearchField.meta.ts b/design-system/packages/ui/src/components/SearchField/SearchField.meta.ts index 204dc36abe..bc1c280ada 100644 --- a/design-system/packages/ui/src/components/SearchField/SearchField.meta.ts +++ b/design-system/packages/ui/src/components/SearchField/SearchField.meta.ts @@ -28,8 +28,8 @@ export const searchFieldMeta = { "color.content.muted", "color.content.disabled", "color.surface.raised", - "color.surface.subtle", "color.border.subtle", + "color.border.default", "color.field.background", "color.field.backgroundHover", "color.field.border", @@ -44,5 +44,6 @@ export const searchFieldMeta = { "radius.pill", "radius.lg", "effect.blur.medium", + "shadow.overlay", ], } as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/components/SearchField/SearchField.module.css b/design-system/packages/ui/src/components/SearchField/SearchField.module.css index d70d9a7b47..ba7b939939 100644 --- a/design-system/packages/ui/src/components/SearchField/SearchField.module.css +++ b/design-system/packages/ui/src/components/SearchField/SearchField.module.css @@ -9,6 +9,15 @@ border-radius: var(--openbitfun-radius-pill); } + /* Search owns a quiet focus treatment without changing general-purpose Input. */ + .root[data-variant="default"] .field:where(:not([data-invalid="true"])) { + border-color: var(--openbitfun-color-border-subtle); + } + + .root[data-variant="default"] .field:where(:not([data-invalid="true"], [data-disabled="true"])):is(:hover, :focus-within) { + border-color: var(--openbitfun-color-border-default); + } + .root .clear { /* The clear action is already composited inside Input's field surface. */ --_icon-button-background: transparent; @@ -35,10 +44,11 @@ outline-offset: calc(-1 * var(--openbitfun-border-width-default)); color: var(--openbitfun-color-content-primary); background: var(--openbitfun-color-surface-raised); + box-shadow: var(--openbitfun-shadow-overlay); } .root[data-variant="panel"]:focus-within { - outline-color: var(--openbitfun-color-field-border-focus); + outline-color: var(--openbitfun-color-border-default); } .root[data-variant="panel"]:has(.field[data-invalid="true"]) { @@ -73,13 +83,20 @@ @supports (backdrop-filter: none) or (-webkit-backdrop-filter: none) { .root[data-variant="panel"] { - background: var(--openbitfun-color-surface-subtle); + /* Surface tokens may be opaque; composite the whole panel before blurring. */ + background: color-mix(in srgb, var(--openbitfun-color-surface-raised) 80%, transparent); -webkit-backdrop-filter: var(--openbitfun-effect-blur-medium); backdrop-filter: var(--openbitfun-effect-blur-medium); } } - @media (prefers-reduced-transparency: reduce) { + :where(:global([data-contrast="high"])) .root[data-variant="panel"] { + background: var(--openbitfun-color-surface-raised); + -webkit-backdrop-filter: none; + backdrop-filter: none; + } + + @media (prefers-reduced-transparency: reduce), (prefers-contrast: more) { .root[data-variant="panel"] { background: var(--openbitfun-color-surface-raised); -webkit-backdrop-filter: none; @@ -88,14 +105,26 @@ } @media (forced-colors: active) { - .root[data-variant="panel"] { + .root[data-variant="default"] .field, + .root[data-variant="default"] .field:is(:hover, :focus-within) { + border-color: ButtonText; + } + + .root[data-variant="default"] .field:focus-within { + border-color: Highlight; + } + + .root[data-variant="panel"], + .root[data-variant="panel"]:has(.field[data-invalid="true"]) { outline-color: ButtonText; background: Canvas; + box-shadow: none; -webkit-backdrop-filter: none; backdrop-filter: none; } - .root[data-variant="panel"]:focus-within { + .root[data-variant="panel"]:focus-within, + .root[data-variant="panel"]:has(.field[data-invalid="true"]):focus-within { outline-color: Highlight; } } diff --git a/design-system/packages/ui/src/components/Tooltip/Tooltip.module.css b/design-system/packages/ui/src/components/Tooltip/Tooltip.module.css index c7d4f529a0..08bac88f9b 100644 --- a/design-system/packages/ui/src/components/Tooltip/Tooltip.module.css +++ b/design-system/packages/ui/src/components/Tooltip/Tooltip.module.css @@ -54,19 +54,6 @@ -webkit-backdrop-filter: var(--openbitfun-effect-blur-base); } - .content::-webkit-scrollbar { - inline-size: 4px; - } - - .content::-webkit-scrollbar-track { - background: transparent; - } - - .content::-webkit-scrollbar-thumb { - background: var(--openbitfun-color-border-default); - border-radius: 2px; - } - .arrow { position: absolute; z-index: 0; diff --git a/design-system/packages/ui/src/flow-chat.ts b/design-system/packages/ui/src/flow-chat.ts index fd818b9301..a06f2e2527 100644 --- a/design-system/packages/ui/src/flow-chat.ts +++ b/design-system/packages/ui/src/flow-chat.ts @@ -1,3 +1,4 @@ import "./styles/layers.css"; +import "./styles/scrollbars.css"; export * from "./flow-chat/index"; diff --git a/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.module.css b/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.module.css index 767526161a..bf80bc5d95 100644 --- a/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.module.css +++ b/design-system/packages/ui/src/flow-chat/composer/ChatComposerQueue.module.css @@ -70,19 +70,9 @@ overflow-y: auto; overscroll-behavior-y: contain; padding: 0; - scrollbar-width: thin; list-style: none; } - .list::-webkit-scrollbar { - inline-size: var(--openbitfun-space-1); - } - - .list::-webkit-scrollbar-thumb { - border-radius: var(--openbitfun-radius-pill); - background: var(--openbitfun-color-action-neutral-border); - } - .item { box-sizing: border-box; display: grid; diff --git a/design-system/packages/ui/src/index.ts b/design-system/packages/ui/src/index.ts index 63bf962270..60d59ade52 100644 --- a/design-system/packages/ui/src/index.ts +++ b/design-system/packages/ui/src/index.ts @@ -1,4 +1,5 @@ import "./styles/layers.css"; +import "./styles/scrollbars.css"; export { DesignSystemProvider, diff --git a/design-system/packages/ui/src/styles/scrollbars.css b/design-system/packages/ui/src/styles/scrollbars.css new file mode 100644 index 0000000000..c356663a95 --- /dev/null +++ b/design-system/packages/ui/src/styles/scrollbars.css @@ -0,0 +1,83 @@ +/* + * Native scrollbar presentation belongs to the design system. Apply it to + * ThemeRoot descendants and standalone ScrollArea viewports, including portals + * under a document-level design-system root. Each element gets its own color: + * an ancestor's hover must never reveal an idle nested viewport's scrollbar. + * + * Only the paint changes on interaction. Width, overflow, and gutter geometry + * stay fixed so virtualized lists do not remeasure when the pointer moves. + */ +@layer openbitfun.base { + :where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility])::-webkit-scrollbar { + width: var(--openbitfun-scrollbar-width); + height: var(--openbitfun-scrollbar-width); + background: transparent; + } + + /* OS settings can expose the root, track pieces, buttons, and corner. */ + :where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility])::-webkit-scrollbar-track, + :where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility])::-webkit-scrollbar-track-piece, + :where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility])::-webkit-scrollbar-corner, + :where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility])::-webkit-scrollbar-button, + :where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility])::-webkit-resizer { + background: transparent; + } + + :where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility])::-webkit-scrollbar-thumb { + background: var(--openbitfun-color-scrollbar-thumb); + border-radius: var(--openbitfun-scrollbar-radius); + } + + :where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility])::-webkit-scrollbar-thumb:hover { + background: var(--openbitfun-color-scrollbar-thumb-hover); + } + + /* + * Keep the standard width/color pair together. Safari 18 supports width but + * not color; setting thin there disables its transparent WebKit scrollbar. + */ + @supports (scrollbar-color: transparent transparent) { + :where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility]) { + scrollbar-width: thin; + scrollbar-color: var(--openbitfun-color-scrollbar-thumb) transparent; + } + } + + /* + * Hide only for a primary mouse/trackpad. Touch keeps a discoverable native + * thumb, and forced colors keeps the operating system's accessibility cues. + * Keyboard focus reveals its viewport without latching ordinary mouse focus. + * Scroll events (including streaming output) do not reveal an idle viewport. + */ + @media (hover: hover) and (pointer: fine) and (forced-colors: none) { + :where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility]):where(:not(:hover, :focus-visible, :has(:focus-visible), [data-openbitfun-scrollbar-visibility="always"]))::-webkit-scrollbar-thumb { + background: transparent; + } + + @supports (scrollbar-color: transparent transparent) { + :where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility]):where(:not(:hover, :focus-visible, :has(:focus-visible), [data-openbitfun-scrollbar-visibility="always"])) { + scrollbar-color: transparent transparent; + } + } + } + + /* Explicit hidden viewports keep scrolling and their existing layout. */ + [data-openbitfun-scrollbar-visibility="hidden"] { + scrollbar-width: none; + } + + [data-openbitfun-scrollbar-visibility="hidden"]::-webkit-scrollbar { + display: none; + } + + @media (forced-colors: active) { + :where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility]) { + scrollbar-color: auto; + } + + :where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility])::-webkit-scrollbar-thumb, + :where([data-openbitfun-design-system-root], [data-openbitfun-design-system-root] *, [data-openbitfun-scrollbar-visibility])::-webkit-scrollbar-thumb:hover { + background: CanvasText; + } + } +} diff --git a/design-system/packages/ui/tests/scroll-area.test.mjs b/design-system/packages/ui/tests/scroll-area.test.mjs index 2b11547358..d2bed70319 100644 --- a/design-system/packages/ui/tests/scroll-area.test.mjs +++ b/design-system/packages/ui/tests/scroll-area.test.mjs @@ -52,10 +52,16 @@ test("ScrollArea styling uses public scrollbar tokens and preserves native scrol assert.match(styles, /overflow-y: auto/); assert.match(styles, /overflow-x: auto/); assert.match(styles, /overflow: scroll/); - assert.match(styles, /--openbitfun-scrollbar-width/); - assert.match(styles, /--openbitfun-scrollbar-radius/); - assert.match(styles, /--openbitfun-color-scrollbar-thumb/); - assert.match(styles, /--openbitfun-color-scrollbar-thumb-hover/); - assert.match(styles, /scrollbar-width: none/); + assert.doesNotMatch(styles, /scrollbar-color:|::-webkit-scrollbar/); + + // The published stylesheet must carry the shared policy for both ordinary + // native scroll containers and ScrollArea, including standalone consumers. + const publishedStyles = await readFile(new URL("../dist/styles.css", import.meta.url), "utf8"); + assert.match(publishedStyles, /--openbitfun-scrollbar-width/); + assert.match(publishedStyles, /--openbitfun-scrollbar-radius/); + assert.match(publishedStyles, /--openbitfun-color-scrollbar-thumb/); + assert.match(publishedStyles, /--openbitfun-color-scrollbar-thumb-hover/); + assert.match(publishedStyles, /\[data-openbitfun-scrollbar-visibility\]/); + assert.match(publishedStyles, /scrollbar-width:\s*none/); assert.doesNotMatch(styles, /#[0-9a-f]{3,8}/i); }); diff --git a/design-system/packages/ui/tests/search-field.test.mjs b/design-system/packages/ui/tests/search-field.test.mjs index 445c9e0f54..ed8bd0dcac 100644 --- a/design-system/packages/ui/tests/search-field.test.mjs +++ b/design-system/packages/ui/tests/search-field.test.mjs @@ -94,7 +94,7 @@ test("SearchField panel uses canonical frosted tokens with an opaque reduced-tra "utf8", ); assert.match(styles, /border-radius: var\(--openbitfun-radius-lg\)/); - assert.match(styles, /@supports[^}]+background: var\(--openbitfun-color-surface-subtle\)/s); + assert.match(styles, /@supports[^}]+background: color-mix\(in srgb, var\(--openbitfun-color-surface-raised\) 80%, transparent\)/s); assert.match(styles, /backdrop-filter: var\(--openbitfun-effect-blur-medium\)/); assert.match(styles, /@media \(prefers-reduced-transparency: reduce\)[^}]+background: var\(--openbitfun-color-surface-raised\)[^}]+backdrop-filter: none/s); }); @@ -140,7 +140,7 @@ test("SearchField owns pill composition while reusing Input behavior", async () assert.match(styles, /--openbitfun-type-meta-font-size/); }); -test("SearchField shares Input's single-border focus presentation", async () => { +test("SearchField owns a quiet single-border focus without changing Input's focus contract", async () => { const [styles, inputStyles] = await Promise.all([ readFile(new URL("../src/components/SearchField/SearchField.module.css", import.meta.url), "utf8"), readFile(new URL("../src/components/Input/Input.module.css", import.meta.url), "utf8"), @@ -149,7 +149,18 @@ test("SearchField shares Input's single-border focus presentation", async () => /\.field:focus-within\s*\{([^}]+)\}/, )?.[1]; - assert.doesNotMatch(styles, /border-color:|\.field[^{}]*:focus-within\s*\{/); + const searchFocusRule = styles.match( + /\.root\[data-variant="default"\] \.field:where\(:not\(\[data-invalid="true"\], \[data-disabled="true"\]\)\):is\(:hover, :focus-within\)\s*\{([^}]+)\}/, + )?.[1]; + const panelFocusRule = styles.match( + /\.root\[data-variant="panel"\]:focus-within\s*\{([^}]+)\}/, + )?.[1]; + + assert.ok(searchFocusRule); + assert.match(searchFocusRule, /border-color: var\(--openbitfun-color-border-default\)/); + assert.doesNotMatch(searchFocusRule, /box-shadow|border-width|outline/); + assert.ok(panelFocusRule); + assert.match(panelFocusRule, /outline-color: var\(--openbitfun-color-border-default\)/); assert.ok(focusRule); assert.match(focusRule, /border-color: var\(--openbitfun-color-field-border-focus\)/); assert.match(focusRule, /box-shadow: none/); diff --git a/docs/architecture/i18n.md b/docs/architecture/i18n.md index 0db10598c7..5faac45da7 100644 --- a/docs/architecture/i18n.md +++ b/docs/architecture/i18n.md @@ -30,6 +30,7 @@ pnpm run i18n:generate Generated outputs: - `src/web-ui/src/infrastructure/i18n/presets/generatedLocaleContract.ts` +- The marked startup i18n block in `src/web-ui/index.html` - `src/mobile-web/src/i18n/generatedLocaleContract.ts` - `OpenBitFun-Installer/src/i18n/generatedLocaleContract.ts` - `src/crates/assembly/core/src/service/i18n/generated_locale_contract.rs` @@ -38,6 +39,13 @@ Generated outputs: Do not edit generated files manually. +The pre-React desktop splash reads a small, synchronous projection of Web UI +`common.loading` and `common.window` strings from the generated block in +`index.html`. The desktop host injects the saved locale; locale aliases and +default selection come from the shared contract. Run `pnpm run i18n:generate` +after changing these startup strings. The projection avoids loading full +catalogs or making an extra request before first paint. + ## Resource Ownership | Surface | Resource owner | Loading rule | diff --git a/scripts/generate-i18n-contract.mjs b/scripts/generate-i18n-contract.mjs index 359ba5acbb..8153549ded 100644 --- a/scripts/generate-i18n-contract.mjs +++ b/scripts/generate-i18n-contract.mjs @@ -11,6 +11,10 @@ const outputs = [ path: path.join(root, 'src', 'web-ui', 'src', 'infrastructure', 'i18n', 'presets', 'generatedLocaleContract.ts'), generate: generateWebLocaleContract, }, + { + path: path.join(root, 'src', 'web-ui', 'index.html'), + generate: generateWebStartupI18n, + }, { path: path.join(root, 'src', 'mobile-web', 'src', 'i18n', 'generatedLocaleContract.ts'), generate: generateMobileLocaleContract, @@ -34,6 +38,14 @@ const outputs = [ ]; const RELAY_HOMEPAGE_SHARED_TERM_KEYS = ['features.remoteControl']; +const WEB_STARTUP_MESSAGE_KEYS = { + loadingApp: 'loading.app', + minimize: 'window.minimize', + maximize: 'window.maximize', + restore: 'window.restore', + close: 'window.close', + petLoading: 'loading.companion', +}; function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); @@ -293,6 +305,53 @@ ${contract.locales.map((locale) => { `; } +function generateWebStartupI18n(contract) { + const locales = orderedLocales(contract, 'web-ui'); + const manifest = { + schemaVersion: 1, + defaultLocale: contract.surfaceDefaults['web-ui'], + locales: locales.map((locale) => { + const resourcePath = path.join(root, 'src', 'web-ui', 'src', 'locales', locale.id, 'common.json'); + const resource = readJson(resourcePath); + const messages = Object.fromEntries( + Object.entries(WEB_STARTUP_MESSAGE_KEYS).map(([messageKey, resourceKey]) => { + const value = getNestedSharedTerm(resource, resourceKey); + assert( + typeof value === 'string' && value.trim().length > 0, + `web startup i18n ${locale.id}:${resourceKey} must be a non-empty string`, + ); + return [messageKey, value]; + }), + ); + + return { + id: locale.id, + aliases: locale.aliases, + messages, + }; + }), + }; + + const html = fs.readFileSync(path.join(root, 'src', 'web-ui', 'index.html'), 'utf8'); + const startMarker = ''; + const endMarker = ''; + const start = html.indexOf(startMarker); + const end = html.indexOf(endMarker, start); + assert(start >= 0 && end > start, 'Web UI startup i18n markers must exist in index.html'); + + // Keep first-paint copy synchronous and safe inside an HTML script element. + const json = JSON.stringify(manifest, null, 2).replace(/', + ' ', + ` ${endMarker}`, + ].join('\n'); + return html.slice(0, start) + projection + html.slice(end + endMarker.length); +} + function generateMobileLocaleContract(contract, sharedTermsByLocale) { const locales = orderedLocales(contract, 'mobile-web'); const defaultLanguage = contract.surfaceDefaults['mobile-web']; diff --git a/scripts/i18n-contract.test.mjs b/scripts/i18n-contract.test.mjs index 3f17995862..93fbaf1d02 100644 --- a/scripts/i18n-contract.test.mjs +++ b/scripts/i18n-contract.test.mjs @@ -12,6 +12,7 @@ const contractPath = path.join(root, 'src', 'shared', 'i18n', 'contract', 'local const sharedTermsDir = path.join(root, 'src', 'shared', 'i18n', 'resources', 'shared'); const expectedGeneratedFiles = [ 'src/web-ui/src/infrastructure/i18n/presets/generatedLocaleContract.ts', + 'src/web-ui/index.html', 'src/mobile-web/src/i18n/generatedLocaleContract.ts', 'OpenBitFun-Installer/src/i18n/generatedLocaleContract.ts', 'src/crates/assembly/core/src/service/i18n/generated_locale_contract.rs', diff --git a/src/apps/desktop/src/appearance.rs b/src/apps/desktop/src/appearance.rs index dea2bd840d..1833aa1f99 100644 --- a/src/apps/desktop/src/appearance.rs +++ b/src/apps/desktop/src/appearance.rs @@ -307,36 +307,6 @@ impl AppearanceConfig { appearance_id } - fn startup_messages_json(locale: &str) -> String { - let messages = match locale { - "en-US" | "en" => serde_json::json!({ - "loadingApp": "Starting OpenBitFun...", - "minimize": "Minimize", - "maximize": "Maximize", - "restore": "Restore", - "close": "Close", - "petLoading": "Loading companion..." - }), - "zh-TW" | "zh-Hant-TW" => serde_json::json!({ - "loadingApp": "正在啟動 OpenBitFun...", - "minimize": "最小化", - "maximize": "最大化", - "restore": "還原", - "close": "關閉", - "petLoading": "正在載入助手..." - }), - _ => serde_json::json!({ - "loadingApp": "正在启动 OpenBitFun...", - "minimize": "最小化", - "maximize": "最大化", - "restore": "还原", - "close": "关闭", - "petLoading": "正在加载助手..." - }), - }; - messages.to_string() - } - fn generate_init_script( &self, startup_trace_id: &str, @@ -347,7 +317,6 @@ impl AppearanceConfig { let startup_locale = &bootstrap_config.locale; let startup_locale_json = serde_json::to_string(&startup_locale).unwrap_or_else(|_| "\"zh-CN\"".to_string()); - let startup_messages_json = Self::startup_messages_json(startup_locale); let show_startup_window_controls = !cfg!(target_os = "macos"); let startup_trace_id_json = serde_json::to_string(startup_trace_id) .unwrap_or_else(|_| "\"desktop-unknown\"".to_string()); @@ -386,7 +355,6 @@ impl AppearanceConfig { window.__OPENBITFUN_PERF_TRACE_ENABLED__ = {perf_trace_enabled}; window.__OPENBITFUN_BOOTSTRAP_LOG_LEVEL__ = {bootstrap_log_level_json}; window.__OPENBITFUN_BOOTSTRAP_LOCALE__ = {startup_locale_json}; - window.__OPENBITFUN_BOOTSTRAP_MESSAGES__ = {startup_messages_json}; window.__OPENBITFUN_SHOW_STARTUP_WINDOW_CONTROLS__ = {show_startup_window_controls}; window.__OPENBITFUN_BOOTSTRAP_APPEARANCE_ID__ = {bootstrap_appearance_id_json}; window.__OPENBITFUN_BOOTSTRAP_APPEARANCE_SELECTION__ = {bootstrap_appearance_selection_json}; @@ -446,7 +414,6 @@ impl AppearanceConfig { perf_trace_enabled = perf_trace_enabled, bootstrap_log_level_json = bootstrap_log_level_json, startup_locale_json = startup_locale_json, - startup_messages_json = startup_messages_json, show_startup_window_controls = show_startup_window_controls, bootstrap_keybindings_assignment = bootstrap_keybindings_assignment, bootstrap_workspace_startup_state_assignment = diff --git a/src/web-ui/index.html b/src/web-ui/index.html index 8440366c71..788d2fde67 100644 --- a/src/web-ui/index.html +++ b/src/web-ui/index.html @@ -7,47 +7,87 @@ OpenBitFun - AI Code Assistant + + + +