fix(ui): move the onboarding takeover chrome into the step
The settings shell painted the onboarding overlay (opaque stage, mask, #root inert) the moment a step was registered and not locally completed, while every step still had to load its private join before deciding whether to show — rendering null could not suppress the shell-owned chrome. Every reload on the hero therefore flashed a full-screen opaque layer (white in the light palette) for one settings/credential RPC round-trip after the session list turned ready. The chrome now belongs to the step: a new zero-cordis OnboardingSurface primitive (ui-primitives) renders the body-portaled overlay/mask/stage verbatim from the former SettingsRoot stylesheet and holds #root inert for exactly its own lifetime. WelcomeNotice and DeepSeekOnboardingDialog wrap only their visible branch in it, so their existing null branches paint and block nothing by construction. SettingsRoot keeps the coordinator unchanged but renders the elected step bare, and the settings.onboarding contract now names the surface wrap as the registrant's obligation. The onboarding e2e gains a held-join reload scenario pinning that a configured world never mounts the takeover chrome or inerts the app.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
|
||||
README.md: 385730c94831d2fd4af83f9eca0f55941551c796
|
||||
README.zh.md: b8a75dbffc6549f6294dfda5988c67d6569386c9
|
||||
README.md: 7571cb48424b650a1aaa5222b33a3ee14faa69b4
|
||||
README.zh.md: fa0c3f24023ec8c1eb77553bfe191801b6698687
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, DiffBlock, ReadBlock, SearchBlock, and WebBlock. Contract: api-contracts v3 §8.
|
||||
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the OnboardingSurface first-run takeover (body-portaled mask + opaque stage that holds `#root` inert for exactly its own lifetime), the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, DiffBlock, ReadBlock, SearchBlock, and WebBlock. Contract: api-contracts v3 §8.
|
||||
|
||||
## Hover cards
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock、DiffBlock、ReadBlock、SearchBlock,以及 WebBlock。契约:api-contracts v3 §8。
|
||||
纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、OnboardingSurface 首次使用接管层(portal 到 body 的遮罩加不透明展示层,在自身生命周期内保持 `#root` 为 `inert`)、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock、DiffBlock、ReadBlock、SearchBlock,以及 WebBlock。契约:api-contracts v3 §8。
|
||||
|
||||
## 悬浮卡片
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/* First-run stage: keep the product top bar visible, then let onboarding own
|
||||
the complete workspace instead of presenting another settings modal. */
|
||||
.onboardingOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
/* Mask */
|
||||
.onboardingMask {
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
top: 80px;
|
||||
bottom: 0px;
|
||||
background: rgba(0, 0, 0, 0.24);
|
||||
/* Mask-blur */
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.onboardingStage {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
34
packages/client/ui-primitives/src/OnboardingSurface.tsx
Normal file
34
packages/client/ui-primitives/src/OnboardingSurface.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
// OnboardingSurface: the full-viewport first-run takeover an onboarding step
|
||||
// wraps its visible content in. The overlay portals to this document's body
|
||||
// (the Modal precedent: ancestor stacking contexts cannot leave sticky page
|
||||
// controls above the mask), and the surface holds `#root` inert for exactly
|
||||
// its own lifetime — a step that renders null paints nothing and blocks
|
||||
// nothing, so "should onboarding show right now" stays a plain render
|
||||
// decision inside the step component.
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import css from './OnboardingSurface.module.css'
|
||||
|
||||
/**
|
||||
* Render the onboarding takeover chrome (mask + opaque stage) around one
|
||||
* step's content and keep the application root inert while mounted.
|
||||
* @param props.children - the step's page content, centered on the stage.
|
||||
* @returns the body-portaled overlay tree.
|
||||
*/
|
||||
export function OnboardingSurface({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
const appRoot = document.getElementById('root')
|
||||
if (appRoot === null) return
|
||||
appRoot.inert = true
|
||||
return () => { appRoot.inert = false }
|
||||
}, [])
|
||||
|
||||
return createPortal((
|
||||
<div className={css.onboardingOverlay} role="presentation">
|
||||
<div className={css.onboardingMask} aria-hidden="true" />
|
||||
<div className={css.onboardingStage}>{children}</div>
|
||||
</div>
|
||||
), document.body)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx'
|
||||
export { useAnchoredMaxHeight } from './useAnchoredMaxHeight.ts'
|
||||
export { HoverCard } from './HoverCard.tsx'
|
||||
export { Modal } from './Modal.tsx'
|
||||
export { OnboardingSurface } from './OnboardingSurface.tsx'
|
||||
export { RiskConfirmation } from './RiskConfirmation.tsx'
|
||||
export type { RiskConfirmationProps } from './RiskConfirmation.tsx'
|
||||
export { ConnectionBanner } from './ConnectionBanner.tsx'
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { OnboardingSurface } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
let appRoot: HTMLDivElement
|
||||
|
||||
beforeEach(() => {
|
||||
appRoot = document.createElement('div')
|
||||
appRoot.id = 'root'
|
||||
document.body.appendChild(appRoot)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
appRoot.remove()
|
||||
})
|
||||
|
||||
describe('OnboardingSurface', () => {
|
||||
it('portals the overlay chrome to document.body around its content', () => {
|
||||
const view = render(<OnboardingSurface><p>step content</p></OnboardingSurface>)
|
||||
// Portaled: the overlay is a body child, not inside the render container.
|
||||
expect(view.container.querySelector('[class*="onboardingOverlay"]')).toBeNull()
|
||||
const overlay = document.body.querySelector('[class*="onboardingOverlay"]')
|
||||
expect(overlay).not.toBeNull()
|
||||
// The onboarding e2e pins the mask by class substring; the stage carries
|
||||
// the content.
|
||||
expect(overlay!.querySelector('[class*="onboardingMask"]')).not.toBeNull()
|
||||
const stage = overlay!.querySelector('[class*="onboardingStage"]')
|
||||
expect(stage).not.toBeNull()
|
||||
expect(stage!.textContent).toBe('step content')
|
||||
})
|
||||
|
||||
it('holds #root inert for exactly its own lifetime', () => {
|
||||
const view = render(<OnboardingSurface>x</OnboardingSurface>)
|
||||
expect(appRoot.inert).toBe(true)
|
||||
view.unmount()
|
||||
expect(appRoot.inert).toBe(false)
|
||||
})
|
||||
|
||||
it('renders without an #root element (compositions that mount elsewhere)', () => {
|
||||
appRoot.remove()
|
||||
const view = render(<OnboardingSurface>x</OnboardingSurface>)
|
||||
expect(document.body.querySelector('[class*="onboardingStage"]')!.textContent).toBe('x')
|
||||
view.unmount()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user