mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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`)原样通过——样式表逐字迁移,遮罩选择器与几何钉子得以幸存。
|
||||
@@ -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
|
||||
|
||||
16
knip.json
16
knip.json
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案、变更操作以及页面的外层包裹均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 })}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user