DSH / Atlas
2026-08-06implementedbug-fix

onboarding takeover chrome moves into the step

首次使用引导的接管界面框架移入步骤自身

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; DeepSeekOnboar

Source compatibility: Chinese title uses a compatible non-canonical header; Chinese status uses a compatible non-canonical header

English

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.client.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-general/tests/settings-root.client.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-settings-models) pass unchanged — the mask selector and geometry pins survive because the stylesheet moved verbatim.

中文

问题

设置外壳在 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 逐字迁移——并在自身挂载生命周期内保持 #rootinert。两个步骤组件只把各自的可见分支包进该原语;既有的 null 分支由此在构造上不绘制、不阻塞任何内容,因为界面框架已是同一次渲染决策的一部分。

SettingsRoot 的协调器原样保留(有序账本投影、每次挂载一个步骤、本地完成集合、stepIdcompleteopenSection 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(此前在不透明图层背后处于 inert 状态)。对真正未配置的用户,接管层比从前晚一个 join 往返出现——但一出现就带着内容,而不是先露出空白展示层再填充。

未来若有步骤注册后不把可见内容包进 OnboardingSurface,会无遮罩地裸渲染在应用之上;slot 约定的 JSDoc 已把包裹写为注册方的义务。

测试

packages/client/ui-primitives/tests/onboarding-surface.client.spec.tsx 钉住原语行为:包裹内容的 body portal、遮罩/展示层类名存在、#rootinert 恰好持续挂载生命周期,以及无 #root 的组合。packages/client/ui-settings-general/tests/settings-root.client.spec.tsx 钉住反转后的外壳约定:已挂载步骤什么都不渲染时,无接管界面框架、无 inert。apps/web/tests/onboarding-deepseek-config.e2e.ts 新增本缺陷的整装回归钉:已配置世界刷新页面,同时在浏览器网络边界扣住所有 settings.describe 响应——把步骤的判定窗口从 loopback 下不可见拉宽到数百毫秒,这正是断言保持非空洞的关键——页内 8ms 采样器证明接管界面框架从未挂载、#root 从未变为 inert。该文件的既有场景与步骤 spec(ui-settings-generalui-settings-models)原样通过——样式表逐字迁移,遮罩选择器与几何钉子得以幸存。