Merge pull request #1812 from deepseek-harness/fix/onboarding-step-owned-chrome

fix(ui): move the onboarding takeover chrome into the step
This commit is contained in:
imccyu
2026-08-06 21:44:41 +08:00
committed by GitHub
21 changed files with 342 additions and 106 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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 .agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md
2026-08-06-onboarding-step-owned-takeover-chrome.md: 4b3bbbc03c4494359297ae6e54bcc9a74c387e80
2026-08-06-onboarding-step-owned-takeover-chrome.zh.md: 548285d285939325a26df3b8d70c43fe952813a9

View File

@@ -0,0 +1,35 @@
# Agent Note: onboarding takeover chrome moves into the step
Status: implemented
English | [中文](2026-08-06-onboarding-step-owned-takeover-chrome.zh.md)
## Problem
The settings shell mounted the onboarding takeover chrome — a body-portaled overlay with an opaque `--dsw-alias-bg-layer-1` stage, a blur mask, and `#root` set inert — the moment a `settings.onboarding` step was registered and not yet locally completed. Every step decides whether it actually needs to show by loading a private fact first (WelcomeNotice: the acknowledgement bit through its settings join; DeepSeekOnboardingDialog: credential readiness through the Models join) and renders `null` while that fact is in flight. Rendering `null` could not suppress the chrome, because the opaque stage was painted by the shell around the slot outlet, not by the step.
On every reload while the hero (blank or no session) was current, the sessions list turning `ready` therefore popped a full-screen opaque layer — white in the light palette — and blocked all interaction for exactly one credential/settings RPC round-trip, after which the already-configured steps self-completed and the layer vanished. Users saw the app flash white each refresh the moment the workspace/session lists landed.
## Decision
The takeover chrome belongs to the step, not the shell. A new zero-cordis primitive, `OnboardingSurface` (ui-primitives), renders the body-portaled overlay/mask/stage — CSS class names and geometry moved verbatim from `SettingsRoot.module.css` — and holds `#root` inert for exactly its own mount lifetime. Both step components wrap only their **visible** branch in it; their existing `null` branches now paint and block nothing by construction, because the chrome is part of the same render decision.
`SettingsRoot` keeps the coordinator exactly as it was (ordered ledger projection, one mounted step, local completed set, `stepId`/`complete`/`openSection` currency) but renders the elected step bare — no portal, no stage, no inert effect. The `settings.onboarding` slot contract now states that registrants own the surface wrap and must render `null` while their private facts are undecided.
## Alternatives considered
**Register steps conditionally (ledger as the has-content signal).** Register the entry only after the private join resolves to "needs intervention". Architecturally clean (publish at the commit point) but a larger change: the join load must move from the dialogs into each plugin's apply, and registration/disposal becomes reactive plumbing in two packages. Rejected as oversized for the defect.
**Convert `settings.onboarding` to a chain with an externalized completed-set store.** The composer-takeover pattern; prototyped and reverted. Selectors can only judge owner props, so the private readiness facts still had to be resolved inside the components — the chain bought routing generality the two current steps do not need, at the cost of a contract change across three packages.
**Detect empty slot output at the render site.** `renderSlot` returns an outlet element unconditionally, so the owner cannot branch on a step's `null`; probing rendered DOM emptiness needs a commit-then-retract dance whose dynamic transitions lose the pre-paint guarantee.
## Consequences
While a step is mounted but undecided, the application stays visible and interactive: `#root` is no longer inert during the decision window (previously it was inert behind an opaque layer). For a genuinely unconfigured user the takeover now appears one join round-trip later than before — but with its content already present, instead of an empty stage that fills in.
A future step that registers without wrapping its visible content in `OnboardingSurface` renders bare over the app with no mask; the slot contract JSDoc names the wrap as the registrant's obligation.
## Testing
`packages/client/ui-primitives/tests/onboarding-surface.spec.tsx` pins the primitive: body portal around the content, mask/stage class presence, `#root` inert held for exactly the mount lifetime, and the no-`#root` composition. `packages/client/ui-settings/tests/settings-root.spec.tsx` pins the inverted shell contract: no takeover chrome and no inert while a mounted step renders nothing. `apps/web/tests/onboarding-deepseek-config.e2e.ts` gains the defect's assembled regression pin: a configured world reloads while every `settings.describe` response is held open at the browser's network boundary — widening the steps' deciding window from loopback-invisible to hundreds of milliseconds, which is what keeps the assertions non-vacuous — and an 8 ms in-page sampler proves the takeover chrome never mounts and `#root` never turns inert. The file's existing scenarios and the step specs (`ui-settings-general`, `ui-models`) pass unchanged — the mask selector and geometry pins survive because the stylesheet moved verbatim.

View File

@@ -0,0 +1,35 @@
# Agent Note首次使用引导的接管界面框架移入步骤自身
状态:已实现
[English](2026-08-06-onboarding-step-owned-takeover-chrome.md) | 中文
## 问题
设置外壳在 `settings.onboarding` 有已注册且本地未完成的步骤时就立即挂出首次使用引导的接管界面框架——portal 到 body 的浮层,带不透明的 `--dsw-alias-bg-layer-1` 展示层、模糊遮罩,并把 `#root` 置为 `inert`。而每个步骤都要先加载私有事实才能判定自己是否需要出场WelcomeNotice经其设置 join 读取确认位DeepSeekOnboardingDialog经 Models join 读取凭据就绪状态),判定期间渲染 `null`。渲染 `null` 无法抑制界面框架,因为不透明展示层是外壳画在 slot outlet 外面的,不属于步骤。
于是每次在 hero空白或无会话状态下刷新页面会话列表一变 `ready` 就弹出整屏不透明层——亮色主题下是白色——并阻断全部交互,时长恰好等于一次凭据/设置 RPC 往返;之后已配置好的步骤自我完成,图层消失。用户看到的就是每次刷新在 workspace/会话列表落地的瞬间闪一下白屏。
## 决定
接管界面框架属于步骤,不属于外壳。新增零 cordis 原语 `OnboardingSurface`ui-primitives渲染 portal 到 body 的浮层遮罩展示层——CSS 类名与几何从 `SettingsRoot.module.css` 逐字迁移——并在自身挂载生命周期内保持 `#root``inert`。两个步骤组件只把各自的**可见**分支包进该原语;既有的 `null` 分支由此在构造上不绘制、不阻塞任何内容,因为界面框架已是同一次渲染决策的一部分。
`SettingsRoot` 的协调器原样保留(有序账本投影、每次挂载一个步骤、本地完成集合、`stepId``complete``openSection` currency但对当选步骤裸渲染——不再有 portal、展示层和 inert 效果。`settings.onboarding` 的 slot 契约现在写明:注册方持有外层包裹,且在私有事实未决时必须渲染 `null`
## 曾考虑的替代方案
**条件注册(账本即有内容信号)。** 私有 join 解析出「需要介入」后才注册条目。架构上干净(在 commit point 发布但改动更大join 的加载必须从对话框上移到各插件的 apply注册销毁在两个包里都变成响应式接线。对本缺陷而言过重否决。
**把 `settings.onboarding` 改成 chain 并把完成集合外置为 store。** composer takeover 的版型做过原型后回退。selector 只能判定 owner props私有就绪事实仍然只能在组件内部解析——chain 买来的是当前两个步骤并不需要的路由通用性,代价却是跨三个包的契约变更。
**在渲染点探测 slot 输出为空。** `renderSlot` 无条件返回 outlet 元素owner 无法据步骤的 `null` 分支;探测已渲染 DOM 是否为空需要先提交再撤回的手法,其动态翻转会失去 paint 前的保证。
## 后果
步骤已挂载但尚未判定期间,应用保持可见且可交互:判定窗口内 `#root` 不再是 `inert`(此前是在不透明图层背后被置灰)。对真正未配置的用户,接管层比从前晚一个 join 往返出现——但一出现就带着内容,而不是先露出空白展示层再填充。
未来若有步骤注册后不把可见内容包进 `OnboardingSurface`会无遮罩地裸渲染在应用之上slot 契约的 JSDoc 已把包裹写为注册方的义务。
## 测试
`packages/client/ui-primitives/tests/onboarding-surface.spec.tsx` 钉住原语行为:内容外的 body portal、遮罩展示层类名存在、`#root``inert` 恰好持续挂载生命周期,以及无 `#root` 的组合。`packages/client/ui-settings/tests/settings-root.spec.tsx` 钉住反转后的外壳契约:已挂载步骤什么都不渲染时,无接管界面框架、无 inert。`apps/web/tests/onboarding-deepseek-config.e2e.ts` 新增本缺陷的整装回归钉:已配置世界刷新页面,同时在浏览器网络边界扣住所有 `settings.describe` 响应——把步骤的判定窗口从 loopback 下不可见拉宽到数百毫秒,这正是断言保持非空洞的关键——页内 8ms 采样器证明接管界面框架从未挂载、`#root` 从未变为 inert。该文件的既有场景与步骤 spec`ui-settings-general``ui-models`)原样通过——样式表逐字迁移,遮罩选择器与几何钉子得以幸存。

View File

@@ -161,6 +161,58 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('never paints the takeover chrome on a configured reload, even with the settings join held open', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-configured-reload'))
// Regression pin for the reload white flash: both steps are satisfied
// (welcome acknowledged, credential configured), yet each must LOAD its
// private join before it can decide not to show. The chrome lives inside
// the step (OnboardingSurface), so the deciding window paints and blocks
// nothing. Holding settings.describe widens that window from loopback
// RTT scale to a deterministic hundreds of milliseconds, removing all
// timing dependence from the sampler assertions below.
//
// The sampler init script persists across this shared page's later
// navigations (init scripts re-run per navigation); that stays harmless
// because no later scenario in this file legitimately shows the
// takeover, and only this test reads __takeoverSightings.
await page.addInitScript(() => {
const sightings: string[] = []
;(window as unknown as { __takeoverSightings: string[] }).__takeoverSightings = sightings
setInterval(() => {
if (document.querySelector('[class*="onboardingStage"], [class*="onboardingMask"]') !== null) {
sightings.push('chrome')
}
if (document.getElementById('root')?.inert === true) sightings.push('inert')
}, 8)
})
// EVERY settings.describe issued before the release is held — not just
// the first — so the pin cannot silently collapse back to loopback
// timing if a second boot-time consumer of the join ever appears.
let released = false
const heldRoutes: Array<() => void> = []
const releaseDescribe = (): void => {
released = true
for (const resolve of heldRoutes.splice(0)) resolve()
}
await page.route('**/api/settings.describe', async (route) => {
if (!released) await new Promise<void>((resolve) => { heldRoutes.push(resolve) })
await route.continue()
})
const warningsBefore = tripwire.warnings.length
await page.reload({ waitUntil: 'commit' })
await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
// The app is painted and interactive while the steps are still deciding.
await page.waitForTimeout(600)
releaseDescribe()
await page.waitForTimeout(400)
await page.unroute('**/api/settings.describe')
acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
expect(await page.evaluate(() =>
(window as unknown as { __takeoverSightings: string[] }).__takeoverSightings)).toEqual([])
expect(await page.locator('[class*="onboardingStage"]').count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('configures arbitrary DeepSeek models and prompts after the selected model is removed', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-models'))
// Opened here rather than inherited: the credential test reloads the page

View File

@@ -597,6 +597,22 @@
"@deepseek-ai/dsh-client-ui-theme"
]
},
"packages/client/ui-settings": {
"entry": [
"tests/**/*.spec.ts",
"tests/**/*.spec.tsx"
],
"project": [
"src/**/*.ts",
"src/**/*.tsx",
"tests/**/*.ts",
"tests/**/*.tsx"
],
"ignoreDependencies": [
"@types/react-dom",
"react-dom"
]
},
"apps/web": {
"entry": [
"tests/**/*.e2e.ts",

View File

@@ -7,7 +7,7 @@
import { useEffect, useRef } from 'react'
import type { ReactNode } from 'react'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { BrandWordmark, Button } from '@deepseek-ai/dsh-client-ui-primitives'
import { BrandWordmark, Button, OnboardingSurface } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
import { deepSeekReadiness } from './store.ts'
@@ -66,6 +66,9 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
openSection('models')
}
// Null covers the still-deciding and nothing-to-do states alike: the
// takeover chrome below is part of THIS render, so declining paints and
// blocks nothing while the shared join is in flight.
switch (readiness.kind) {
case 'loading':
case 'adapter-absent':
@@ -80,25 +83,27 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
}
return (
<section className={styles['page']} role="region" aria-labelledby="deepseek-onboarding-title">
<div className={styles['brand']} aria-hidden="true"><BrandWordmark size={24} /></div>
<h2
ref={titleRef}
id="deepseek-onboarding-title"
className={styles['title']}
tabIndex={-1}
>
{t('onboardingTitle')}
</h2>
<p className={styles['description']}>{t('onboardingDescription')}</p>
<div className={styles['actions']}>
<Button variant="ghost" className={styles['later']} onClick={complete}>
{t('onboardingLater')}
</Button>
<Button variant="primary" className={styles['primary']} onClick={openModels}>
{t('onboardingGoToSettings')}
</Button>
</div>
</section>
<OnboardingSurface>
<section className={styles['page']} role="region" aria-labelledby="deepseek-onboarding-title">
<div className={styles['brand']} aria-hidden="true"><BrandWordmark size={24} /></div>
<h2
ref={titleRef}
id="deepseek-onboarding-title"
className={styles['title']}
tabIndex={-1}
>
{t('onboardingTitle')}
</h2>
<p className={styles['description']}>{t('onboardingDescription')}</p>
<div className={styles['actions']}>
<Button variant="ghost" className={styles['later']} onClick={complete}>
{t('onboardingLater')}
</Button>
<Button variant="primary" className={styles['primary']} onClick={openModels}>
{t('onboardingGoToSettings')}
</Button>
</div>
</section>
</OnboardingSurface>
)
}

View File

@@ -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

View File

@@ -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

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
纯 React 原子组件(零 cordisStateDot、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 原子组件(零 cordisStateDot、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。
## 悬浮卡片

View File

@@ -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);
}

View 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)
}

View File

@@ -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'

View File

@@ -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()
})
})

View File

@@ -3,7 +3,7 @@
import { useCallback, useEffect, useRef } from 'react'
import type { ReactNode } from 'react'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { BrandWordmark, Button } from '@deepseek-ai/dsh-client-ui-primitives'
import { BrandWordmark, Button, OnboardingSurface } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { WelcomeNoticeState, WelcomeNoticeStore } from './welcome-store.ts'
import css from './WelcomeNotice.module.css'
@@ -55,6 +55,9 @@ export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
if (state.status === 'ready' && !state.acknowledged) titleRef.current?.focus()
}, [state.acknowledged, state.status])
// Null while the acknowledgement fact is still loading (or already given):
// the takeover chrome below is part of THIS render, so deciding not to
// show paints and blocks nothing.
if (state.status === 'idle' || state.status === 'loading' || state.acknowledged) return null
const acknowledge = async (): Promise<void> => {
@@ -62,25 +65,27 @@ export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
}
return (
<section className={css.page} role="region" aria-labelledby="welcome-notice-title">
<div className={css.brand} aria-hidden="true"><BrandWordmark size={24} /></div>
<h2 ref={titleRef} id="welcome-notice-title" className={css.title} tabIndex={-1}>{t('welcome.title')}</h2>
<p className={css.opening}>{t('welcome.paragraph.0')}</p>
<blockquote className={css.reflection}>{t('welcome.paragraph.1')}</blockquote>
<p className={css.feedback}>
{emphasizedFeedback(t('welcome.paragraph.2'), t('welcome.feedbackEmphasis'))}
</p>
{state.error === null ? null : <p className={css.error} role="alert">{t('welcome.error')}</p>}
<div className={css.footer}>
<Button
variant="primary"
className={css.primary}
disabled={state.status === 'saving'}
onClick={() => { void acknowledge() }}
>
{t('welcome.continue')}
</Button>
</div>
</section>
<OnboardingSurface>
<section className={css.page} role="region" aria-labelledby="welcome-notice-title">
<div className={css.brand} aria-hidden="true"><BrandWordmark size={24} /></div>
<h2 ref={titleRef} id="welcome-notice-title" className={css.title} tabIndex={-1}>{t('welcome.title')}</h2>
<p className={css.opening}>{t('welcome.paragraph.0')}</p>
<blockquote className={css.reflection}>{t('welcome.paragraph.1')}</blockquote>
<p className={css.feedback}>
{emphasizedFeedback(t('welcome.paragraph.2'), t('welcome.feedbackEmphasis'))}
</p>
{state.error === null ? null : <p className={css.error} role="alert">{t('welcome.error')}</p>}
<div className={css.footer}>
<Button
variant="primary"
className={css.primary}
disabled={state.status === 'saving'}
onClick={() => { void acknowledge() }}
>
{t('welcome.continue')}
</Button>
</div>
</section>
</OnboardingSurface>
)
}

View File

@@ -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-settings/README.md
README.md: de78d599b7833179339ceeb680fbd665b056bd83
README.zh.md: 8ae3bdf34f59ca03e4796c354df739aa9fe29bd9
README.md: 785f0417f00ec8eb1f8c9273b4d81f8ca5ca1810
README.zh.md: 8e7bd7325b78416345985ee25a56a5eb8b382478

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages in a full-viewport stage). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome, General, and the product notice; features own their actions, sections, rows, and conditional onboarding pages). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency).
The shell projects the onboarding ledger into ascending order and mounts exactly one page at a time in a body-level stage while marking the underlying app root inert. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, and mutations, so independently registered flows cannot stack and the shell does not become a second configuration fact source.
The shell projects the onboarding ledger into ascending order and mounts exactly one page at a time; the takeover chrome (body-level stage, mask, app-root `inert`) belongs to the step itself through ui-primitives' `OnboardingSurface`, so a mounted step still resolving its private facts renders null and neither paints nor blocks anything — the shell shows no empty stage while a step decides. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, mutations, and the surface wrap, so independently registered flows cannot stack and the shell does not become a second configuration fact source.
## Model Experience

View File

@@ -4,7 +4,7 @@
设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot`settings.trigger``settings.header``settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)和 `settings.onboarding`由各功能持有、显示在全视口展示层中的有序页面。外壳不自带文案所有文本都来自注册方ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的操作、分区、行和条件式首次使用引导页面)。导航 label 可以是跟随语言的 thunk因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。
外壳将首次使用引导记录按升序投影,在 body 层级的展示层中每次只挂载一个页面,同时将下层应用根节点标记为 `inert`。当前注册方会收到该条目的 id、`complete()``openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案变更操作均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。
外壳将首次使用引导记录按升序投影,每次只挂载一个页面接管界面框架body 层级的展示层、遮罩、应用根节点 `inert`)经 ui-primitives 的 `OnboardingSurface` 由步骤自身持有,因此已挂载但仍在判定私有事实的步骤渲染 null 时不绘制也不阻塞任何内容——步骤判定期间外壳不会露出空白展示层。当前注册方会收到该条目的 id、`complete()``openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案变更操作以及页面的外层包裹均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。
## 模型体验

View File

@@ -219,33 +219,3 @@
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* 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);
}

View File

@@ -7,10 +7,11 @@
* aria-labelledby the title node; close: visually-hidden slot text). Modal
* open state and the active section id are component-local viewing state;
* the onboarding coordinator mounts exactly one ordered registrant while the
* sessions-derived empty-Hero fact is active.
* sessions-derived empty-Hero fact is active — the takeover chrome
* (OnboardingSurface) belongs to the step, so a mounted-but-deciding step
* paints nothing here.
*/
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
@@ -134,14 +135,6 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
})
}, [])
useEffect(() => {
if (onboardingStep === undefined) return
const appRoot = document.getElementById('root')
if (appRoot === null) return
appRoot.inert = true
return () => { appRoot.inert = false }
}, [onboardingStep])
return (
<>
<button
@@ -162,18 +155,15 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
onClose={close}
/>
)}
{onboardingStep !== undefined && createPortal((
<div className={css.onboardingOverlay} role="presentation">
<div className={css.onboardingMask} aria-hidden="true" />
<div className={css.onboardingStage}>
{renderSlot('settings.onboarding', {
stepId: onboardingStep.id,
complete: () => { completeOnboardingStep(onboardingStep.id) },
openSection,
}, { only: onboardingStep.id })}
</div>
</div>
), document.body)}
{/* The takeover chrome (OnboardingSurface: mask, opaque stage, `#root`
inert) lives inside the step component, wrapped around its visible
content — a step still deciding (private facts loading) renders
null, so nothing paints or blocks while it decides. */}
{onboardingStep !== undefined && renderSlot('settings.onboarding', {
stepId: onboardingStep.id,
complete: () => { completeOnboardingStep(onboardingStep.id) },
openSection,
}, { only: onboardingStep.id })}
</>
)
}

View File

@@ -57,7 +57,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* Root-scoped onboarding steps contributed by settings features. The
* shell mounts one ordered step at a time; the active registrant either
* completes itself or keeps ownership until the user completes its sole
* path. Registrants own readiness, copy, and dialog behavior.
* path. Registrants own readiness, copy, dialog behavior, AND the
* takeover chrome: a step wraps its visible content in the
* OnboardingSurface primitive (mask, opaque stage, `#root` inert) and
* renders null while its private facts are still loading — the shell
* paints no chrome of its own, so a mounted-but-deciding step shows and
* blocks nothing (the reload white-flash fix; a bare unwrapped step
* would render without mask or stage).
*/
'settings.onboarding': { kind: 'list'; scope: 'root'; owner: SettingsOnboardingOwnerProps }
}

View File

@@ -204,14 +204,19 @@ describe('SettingsPanel navigation', () => {
expect(inactive).toHaveLength(0)
})
it('makes the underlying application inert while onboarding owns the viewport', () => {
it('paints no takeover chrome of its own around the mounted step', () => {
// The chrome (mask, opaque stage, #root inert) belongs to the step via
// the OnboardingSurface primitive — a mounted-but-deciding step that
// renders null must show and block nothing (the reload white-flash fix;
// onboarding-surface.spec.tsx pins the primitive's half).
const appRoot = document.createElement('div')
appRoot.id = 'root'
document.body.append(appRoot)
const { view } = mount()
expect(appRoot.inert).toBe(true)
expect(view.container.querySelector('[class*="onboarding"]')).toBeNull()
expect(document.body.querySelector('[class*="onboarding"]')).toBeNull()
expect(appRoot.inert).not.toBe(true)
view.unmount()
expect(appRoot.inert).toBe(false)
appRoot.remove()
})