diff --git a/design-system/packages/design-tokens/README.md b/design-system/packages/design-tokens/README.md index 700b204f4c..017ea60d2c 100644 --- a/design-system/packages/design-tokens/README.md +++ b/design-system/packages/design-tokens/README.md @@ -82,8 +82,8 @@ box in `OverflowText`. The browser includes the selected font's ascent/descent; existing numeric leading roles cannot express those platform-dependent metrics. The outer line box retains any larger leading supplied by its owning control. -`layout.searchDialog` owns the shared Lab/product search composition: 800 × 460 -when space permits, 20px inset and query-to-scope gap, and a 30px query row. +`layout.searchDialog` owns the shared Lab/product search composition: 744 × 460 +when space permits (approximately the golden ratio), 20px inset and query-to-scope gap, and a 30px query row. Only the query shell scopes `control.searchField.height.sm`; general Input and Button sizes retain their defaults. Results scroll within the available viewport. diff --git a/design-system/packages/design-tokens/src/system.tokens.json b/design-system/packages/design-tokens/src/system.tokens.json index 98b522f8c9..51b23ecf87 100644 --- a/design-system/packages/design-tokens/src/system.tokens.json +++ b/design-system/packages/design-tokens/src/system.tokens.json @@ -750,7 +750,7 @@ "layout": { "$type": "dimension", "searchDialog": { - "width": { "$value": "{overlay.dialog.maxInlineSizeXlarge}" }, + "width": { "$value": "744px" }, "height": { "$value": "460px" }, "padding": { "$value": "{space.5}" }, "queryHeight": { "$value": "30px" }, diff --git a/design-system/packages/theme-openbitfun/README.md b/design-system/packages/theme-openbitfun/README.md index d547ca81fd..eb9ef43dac 100644 --- a/design-system/packages/theme-openbitfun/README.md +++ b/design-system/packages/theme-openbitfun/README.md @@ -11,6 +11,12 @@ import { themeModes, themeTokenCatalog, themes } from "@openbitfun/theme-openbit `themeTokenCatalog` exposes the complete public semantic theme contract for visual authoring: colors, elevation shadows, surface filters, and state opacity. UI components consume this semantic contract and never depend on reference colors directly. +`effect.blur.strong` provides 32px blur and 1.3 saturation for translucent floating +panels such as Global Search. The existing 12px `medium` filter leaves detailed +content too distinct at lower surface opacity; its smaller blur remains appropriate +for compact controls. The stronger role lets panels widen their blur radius +independently and retain opaque high-contrast and reduced-transparency fallbacks. + ## Foundational color scales Named numeric scales live below the semantic theme layer. Step numbers increase from light to dark, for example `ref.color.neutral.50`, `ref.color.blue.500`, and `ref.color.red.700`. Semantic tokens map those stable palette values to roles such as `color.content.primary` or `color.status.danger.content` independently in each theme mode. diff --git a/design-system/packages/theme-openbitfun/src/dark.tokens.json b/design-system/packages/theme-openbitfun/src/dark.tokens.json index 5103ac763b..4b44777870 100644 --- a/design-system/packages/theme-openbitfun/src/dark.tokens.json +++ b/design-system/packages/theme-openbitfun/src/dark.tokens.json @@ -327,7 +327,8 @@ "blur": { "subtle": { "$type": "filter", "$value": "blur(4px) saturate(1.05)" }, "base": { "$type": "filter", "$value": "blur(8px) saturate(1.1)" }, - "medium": { "$type": "filter", "$value": "blur(12px) saturate(1.2)" } + "medium": { "$type": "filter", "$value": "blur(12px) saturate(1.2)" }, + "strong": { "$description": "Wide-radius frosting for translucent floating panels over detailed content.", "$type": "filter", "$value": "blur(32px) saturate(1.3)" } } }, "opacity": { diff --git a/design-system/packages/theme-openbitfun/src/light.tokens.json b/design-system/packages/theme-openbitfun/src/light.tokens.json index f5f104d411..ffaddba50d 100644 --- a/design-system/packages/theme-openbitfun/src/light.tokens.json +++ b/design-system/packages/theme-openbitfun/src/light.tokens.json @@ -327,7 +327,8 @@ "blur": { "subtle": { "$type": "filter", "$value": "blur(4px) saturate(1.02)" }, "base": { "$type": "filter", "$value": "blur(8px) saturate(1.05)" }, - "medium": { "$type": "filter", "$value": "blur(12px) saturate(1.2)" } + "medium": { "$type": "filter", "$value": "blur(12px) saturate(1.2)" }, + "strong": { "$description": "Wide-radius frosting for translucent floating panels over detailed content.", "$type": "filter", "$value": "blur(32px) saturate(1.3)" } } }, "opacity": { diff --git a/src/apps/data-tools/ui/generated/design-system.css b/src/apps/data-tools/ui/generated/design-system.css index 61d20f48e5..145d46a2ba 100644 --- a/src/apps/data-tools/ui/generated/design-system.css +++ b/src/apps/data-tools/ui/generated/design-system.css @@ -297,7 +297,7 @@ --openbitfun-layout-search-dialog-query-height: 30px; --openbitfun-layout-search-dialog-results-gap: 30px; --openbitfun-layout-search-dialog-scope-gap: var(--openbitfun-space-5); - --openbitfun-layout-search-dialog-width: var(--openbitfun-overlay-dialog-max-inline-size-xlarge); + --openbitfun-layout-search-dialog-width: 744px; --openbitfun-layout-spinner-matrix-cell-lg: 8px; --openbitfun-layout-spinner-matrix-cell-md: 6px; --openbitfun-layout-spinner-matrix-cell-sm: 4px; @@ -917,6 +917,7 @@ --openbitfun-component-empty-media: color-mix(in srgb, #6a6a6a 35%, #f7f7f7); --openbitfun-effect-blur-base: blur(8px) saturate(1.05); --openbitfun-effect-blur-medium: blur(12px) saturate(1.2); + --openbitfun-effect-blur-strong: blur(32px) saturate(1.3); --openbitfun-effect-blur-subtle: blur(4px) saturate(1.02); --openbitfun-opacity-disabled: 0.55; --openbitfun-opacity-focus: 0.9; @@ -1101,6 +1102,7 @@ --openbitfun-component-empty-media: color-mix(in srgb, #858585 35%, #0e0e10); --openbitfun-effect-blur-base: blur(8px) saturate(1.1); --openbitfun-effect-blur-medium: blur(12px) saturate(1.2); + --openbitfun-effect-blur-strong: blur(32px) saturate(1.3); --openbitfun-effect-blur-subtle: blur(4px) saturate(1.05); --openbitfun-opacity-disabled: 0.6; --openbitfun-opacity-focus: 0.9; diff --git a/src/apps/desktop/src/generated/bootstrap_theme.css b/src/apps/desktop/src/generated/bootstrap_theme.css index c90d4a9ee5..b99a599785 100644 --- a/src/apps/desktop/src/generated/bootstrap_theme.css +++ b/src/apps/desktop/src/generated/bootstrap_theme.css @@ -163,6 +163,7 @@ --openbitfun-component-empty-media: color-mix(in srgb, #858585 35%, #0e0e10); --openbitfun-effect-blur-base: blur(8px) saturate(1.1); --openbitfun-effect-blur-medium: blur(12px) saturate(1.2); + --openbitfun-effect-blur-strong: blur(32px) saturate(1.3); --openbitfun-effect-blur-subtle: blur(4px) saturate(1.05); --openbitfun-opacity-disabled: 0.6; --openbitfun-opacity-focus: 0.9; diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 8d12ecb35c..244a4922eb 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -1157,9 +1157,7 @@ const SessionsSection: React.FC = ({ sourceView ? t('nav.sessions.removeSourceConfirmTitle') : t('nav.sessions.deleteConfirmTitle'), sourceView ? t('nav.sessions.removeSourceConfirmMessage', { name: session ? resolveSessionTitle(session) : t('nav.sessions.untitled'), - }) : t('nav.sessions.deleteConfirmMessage', { - name: session ? resolveSessionTitle(session) : t('nav.sessions.untitled'), - }), + }) : t('nav.sessions.deleteConfirmMessage'), { confirmText: sourceView ? t('nav.sessions.removeSource') : t('nav.sessions.delete') }, ); if (!confirmed) return; diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionActionConfirmation.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionActionConfirmation.test.ts index 9146a3a07f..68700b2682 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionActionConfirmation.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionActionConfirmation.test.ts @@ -26,7 +26,7 @@ describe('session action confirmation policy', () => { expect(source).not.toContain('confirmWarning'); expect(deleteHandler).toContain('await confirmDanger('); expect(deleteHandler).toContain("t('nav.sessions.deleteConfirmTitle')"); - expect(deleteHandler).toContain("t('nav.sessions.deleteConfirmMessage', {"); + expect(deleteHandler).toContain("t('nav.sessions.deleteConfirmMessage')"); expect(deleteHandler).toContain("{ confirmText: sourceView ? t('nav.sessions.removeSource') : t('nav.sessions.delete') }"); expect(deleteHandler.indexOf('await confirmDanger(')).toBeLessThan( deleteHandler.indexOf('await flowChatManager.deleteChatSession(sessionId)'), diff --git a/src/web-ui/src/app/global-search/GlobalSearchContent.test.tsx b/src/web-ui/src/app/global-search/GlobalSearchContent.test.tsx index 5df255791c..1cb606f883 100644 --- a/src/web-ui/src/app/global-search/GlobalSearchContent.test.tsx +++ b/src/web-ui/src/app/global-search/GlobalSearchContent.test.tsx @@ -108,6 +108,49 @@ describe('GlobalSearchContent accessibility', () => { await act(async () => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))); expect(fixtures.activate).toHaveBeenCalledWith(snapshot.items[0].target, expect.anything()); }); + it('renders app and entity search hits as actionable cards while keeping content hits in lists', async () => { + const cardItems: GlobalSearchSnapshot['items'] = [ + snapshot.items[0], + { id: 'setting', providerId: 'capabilities', group: 'settings', title: 'Appearance settings', score: 90, + target: { kind: 'capability', capabilityId: 'setting.appearance' } }, + { id: 'feature', providerId: 'capabilities', group: 'capabilities', title: 'Project features', score: 90, + target: { kind: 'capability', capabilityId: 'feature.projects' } }, + { id: 'workspace', providerId: 'entities', group: 'workspaces', title: 'Project workspace', score: 90, + target: { kind: 'workspace', workspaceId: 'workspace' } }, + { id: 'assistant', providerId: 'entities', group: 'assistants', title: 'Project assistant', score: 90, + target: { kind: 'assistant', workspaceId: 'assistant' } }, + ]; + const contentItems: GlobalSearchSnapshot['items'] = [ + { id: 'message', providerId: 'sessions', group: 'messages', title: 'Message match', score: 90, + target: { kind: 'session', sessionId: 'session', workspaceId: 'workspace', workspacePath: '/project', turnId: 'turn' } }, + { id: 'session', providerId: 'sessions', group: 'sessions', title: 'Session match', score: 90, + target: { kind: 'session', sessionId: 'session', workspaceId: 'workspace', workspacePath: '/project' } }, + { id: 'file', providerId: 'files', group: 'files', title: 'File match', score: 90, + target: { kind: 'file', workspaceId: 'workspace', workspacePath: '/project', filePath: '/project/notes.md', fileName: 'notes.md' } }, + ]; + fixtures.search.mockImplementationOnce((_providers, _request, _signal, onUpdate) => + onUpdate({ ...snapshot, items: [...cardItems, ...contentItems] })); + useGlobalSearchStore.getState().openSearch('project'); + act(() => root.render()); + await act(async () => { await vi.runOnlyPendingTimersAsync(); }); + const dialog = document.querySelector('[data-testid="global-search-dialog"]')!; + + for (const item of cardItems) { + const group = dialog.querySelector(`[data-search-group="${item.group}"]`)!; + expect(group.querySelector('[data-search-layout="cards"]')).not.toBeNull(); + expect(group.querySelector('[data-openbitfun-component="action-card"] button')?.getAttribute('aria-label')).toBe(item.title); + } + for (const item of contentItems) { + const group = dialog.querySelector(`[data-search-group="${item.group}"]`)!; + expect(group.querySelector('[data-search-layout="cards"]')).toBeNull(); + expect(group.querySelector('.global-search__result')?.getAttribute('aria-label')).toBe(item.title); + } + + await act(async () => dialog.querySelector('[data-search-group="settings"] button[aria-label="Appearance settings"]')!.click()); + expect(fixtures.activate).toHaveBeenCalledWith(cardItems[1].target, expect.anything()); + expect(useGlobalSearchStore.getState().open).toBe(false); + }); + it('shows source diagnostics and retries without clearing the query or healthy results', async () => { fixtures.search.mockImplementationOnce((_providers, _request, _signal, onUpdate) => onUpdate(diagnosticSnapshot)); fixtures.search.mockImplementationOnce((_providers, _request, _signal, onUpdate, retry) => diff --git a/src/web-ui/src/app/global-search/GlobalSearchRoot.scss b/src/web-ui/src/app/global-search/GlobalSearchRoot.scss index 9cb1ee5a27..4fdcdf4151 100644 --- a/src/web-ui/src/app/global-search/GlobalSearchRoot.scss +++ b/src/web-ui/src/app/global-search/GlobalSearchRoot.scss @@ -10,7 +10,25 @@ */ $chevron-box-slack: 5px; +.global-search-overlay { + background: transparent; + -webkit-backdrop-filter: none; + backdrop-filter: none; + // An opacity-animated ancestor would isolate the dialog's backdrop filter. + animation: none; +} + .global-search-dialog { + isolation: isolate; + // The material and content clip themselves; the elevation layer stays outside. + overflow: visible; + // Keep ancestor opacity at 1 so the glass layer can sample the page backdrop. + animation-name: global-search-surface-enter; + + &[data-state='exiting'] { + animation-name: global-search-surface-exit; + } + // The results viewport absorbs short windows and longer result sets. inline-size: var(--openbitfun-layout-search-dialog-width); block-size: min( @@ -19,9 +37,83 @@ $chevron-box-slack: 5px; ); } +@keyframes global-search-surface-enter { + from { transform: translateY(var(--openbitfun-motion-distance-sm)) scale(0.98); } + to { transform: none; } +} + +@keyframes global-search-surface-exit { + from { transform: none; } + to { transform: translateY(var(--openbitfun-motion-distance-sm)) scale(0.98); } +} + +.global-search-dialog__material { + display: none; +} + +@supports (backdrop-filter: none) or (-webkit-backdrop-filter: none) { + .global-search-dialog { + // The outer surface owns elevation; only the glass layer is clipped. + background: transparent; + box-shadow: var(--openbitfun-shadow-inner-highlight-hover), var(--openbitfun-shadow-overlay); + } + + .global-search-dialog__material { + display: block; + position: absolute; + z-index: 0; + inset: 0; + border-radius: inherit; + clip-path: inset(0 round var(--openbitfun-overlay-dialog-surface-radius)); + background: color-mix( + in srgb, + color-mix(in srgb, var(--openbitfun-color-surface-raised) 90%, var(--openbitfun-color-content-on-dark)) 58%, + transparent + ); + -webkit-backdrop-filter: var(--openbitfun-effect-blur-strong); + backdrop-filter: var(--openbitfun-effect-blur-strong); + // Retain the glass plane when hover marquees promote content to new layers. + transform: translateZ(0); + pointer-events: none; + } +} + +:where([data-contrast='high']) .global-search-dialog { + background: var(--openbitfun-color-surface-raised); + -webkit-backdrop-filter: none; + backdrop-filter: none; + + .global-search-dialog__material { display: none; } +} + +@media (prefers-reduced-transparency: reduce), (prefers-contrast: more) { + .global-search-dialog { + background: var(--openbitfun-color-surface-raised); + -webkit-backdrop-filter: none; + backdrop-filter: none; + + .global-search-dialog__material { display: none; } + } +} + +@media (forced-colors: active) { + .global-search-dialog { + background: Canvas; + -webkit-backdrop-filter: none; + backdrop-filter: none; + + .global-search-dialog__material { display: none; } + } +} + .global-search-modal-content { + position: relative; + z-index: 1; display: flex; + // Hover animations repaint only the content plane, away from the glass edges. + contain: paint; overflow: hidden; + border-radius: inherit; } .global-search { @@ -840,6 +932,16 @@ $chevron-box-slack: 5px; } .global-search--modal { + container-type: inline-size; + + .global-search__result:hover:not(.is-selected) { + border-color: transparent; + } + + .global-search__action-card:hover:not([data-selected='true']) { + outline-color: transparent; + } + .global-search__query-system-shell { --openbitfun-control-search-field-height-sm: var(--openbitfun-layout-search-dialog-query-height); } @@ -889,6 +991,34 @@ $chevron-box-slack: 5px; } } +// Queried cards use the host's available width; the default overview keeps its layout. +.global-search--modal, +.global-search--embedded { + .global-search__group-items[data-search-layout='cards'] { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--openbitfun-space-3); + } +} + +@container (max-width: 600px) { + .global-search--modal, + .global-search--embedded { + .global-search__group-items[data-search-layout='cards'] { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } +} + +@container (max-width: 420px) { + .global-search--modal, + .global-search--embedded { + .global-search__group-items[data-search-layout='cards'] { + grid-template-columns: minmax(0, 1fr); + } + } +} + .global-search__diagnostics { min-width: 0; margin-right: auto; diff --git a/src/web-ui/src/app/global-search/GlobalSearchRoot.tsx b/src/web-ui/src/app/global-search/GlobalSearchRoot.tsx index 9ffb2fadc7..9cca8d3a60 100644 --- a/src/web-ui/src/app/global-search/GlobalSearchRoot.tsx +++ b/src/web-ui/src/app/global-search/GlobalSearchRoot.tsx @@ -136,7 +136,7 @@ function iconForItem(item: GlobalSearchItem): IconSource { function resultVariant(group: GlobalSearchGroupId): 'action' | 'entity' | 'standard' { if (group === 'actions') return 'action'; - if (group === 'workspaces' || group === 'assistants') return 'entity'; + if (group === 'workspaces' || group === 'assistants' || group === 'settings' || group === 'capabilities') return 'entity'; return 'standard'; } @@ -578,7 +578,11 @@ export const GlobalSearchContent: React.FC = ({ )} -
+
{groupItems.map((item) => { const itemIcon = iconForItem(item); const selected = item.id === activeId; @@ -816,8 +820,10 @@ const GlobalSearchRoot: React.FC = () => { size="xl" aria-label={tCommon('nav.search.dialogLabel')} className="global-search-dialog" + overlayProps={{ className: 'global-search-overlay' }} data-testid="global-search-dialog" > +