feat(web): present onboarding as a continuous page

This commit is contained in:
NI0317
2026-07-31 00:28:17 +08:00
parent 04f8b30db1
commit 75a0366a52
25 changed files with 475 additions and 228 deletions

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-general/README.md
README.md: 3ae3f58bd00172ed9b547c01a354023c224f6a1c
README.zh.md: ffdaf0e4314daa947a4183e2b23b7b1650de7611
README.md: 0ec2e14bc4f483a23607de7f33ca4c35687c7c4f
README.zh.md: 9ce7136ce9a0bf384bd2b6419d52cd0da5c7dc83

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section (Permission/Tool Call skeleton rows + the `settings.general.item` slot declaration), the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
`src/onboarding-copy.ts` is the single editable owner of the complete Chinese and English notice plus `WELCOME_NOTICE_VERSION`. The Host half registers `ui-onboarding` in the user-settings seam; the browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A different version deliberately presents the notice again. The welcome UI has no close, Escape, mask-click, or secondary path, and none of its copy or acknowledgement enters a Session log or model request.
`src/onboarding-copy.ts` is the single editable owner of the complete Chinese and English notice plus `WELCOME_NOTICE_VERSION`. The Host half registers `ui-onboarding` in the user-settings seam; the browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request.
## Model Experience

View File

@@ -4,7 +4,7 @@
设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区(「权限」/「工具调用」骨架行和 `settings.general.item` slot 声明)、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
`src/onboarding-copy.ts` 是完整中英文通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源。宿主端在 user-settings seam 中注册 `ui-onboarding`;浏览器比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。版本不同时,系统会有意重新显示通知。欢迎界面没有关闭操作、Escape、点击遮罩或次要操作路径,其文案和确认状态均不会进入会话日志或模型请求。
`src/onboarding-copy.ts` 是完整中英文通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源。宿主端在 user-settings seam 中注册 `ui-onboarding`;浏览器比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。版本不同时,系统会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。
## 模型体验

View File

@@ -1,137 +1,161 @@
.overlay {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
padding-top: 80px;
box-sizing: border-box;
}
/* Mask */
.mask {
position: absolute;
left: 0px;
right: 0px;
top: 80px;
bottom: 0px;
background: rgba(0, 0, 0, 0.24);
/* Mask-blur */
backdrop-filter: blur(2px);
}
.dialog {
.page {
position: relative;
z-index: 1;
width: min(600px, calc(100vw - 48px));
max-height: calc(100vh - 128px);
padding: 32px;
width: min(640px, calc(100vw - 64px));
max-height: 100vh;
padding: clamp(64px, 9vh, 104px) 0 40px;
box-sizing: border-box;
overflow-y: auto;
border-radius: 24px;
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-shadow-lv3);
color: var(--dsw-alias-label-primary);
--welcome-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
}
.brand {
display: flex;
align-items: center;
margin-bottom: 42px;
color: var(--dsw-alias-label-primary);
}
.title {
margin: 0;
font-size: 20px;
line-height: 30px;
font-size: 28px;
line-height: 36px;
font-weight: 600;
letter-spacing: -0.01em;
letter-spacing: -0.02em;
outline: none;
}
.lead,
.closing,
.quote,
.feedback p,
.opening,
.status,
.reflection,
.feedback,
.error {
margin: 0;
}
.lead {
margin-top: 12px;
font-size: 16px;
line-height: 25px;
color: var(--dsw-alias-label-secondary);
.opening {
margin-top: 30px;
}
.status {
margin-top: 18px;
}
.reflection {
margin-top: 36px;
padding: 0;
}
.feedback {
margin-top: 20px;
padding: 16px 18px;
border-radius: 14px;
border: 1px solid var(--dsw-alias-border-l1);
background: var(--dsw-alias-bg-module-platform);
font-size: 15px;
line-height: 24px;
margin-top: 30px;
}
.opening,
.status,
.reflection,
.feedback {
font-size: 16px;
line-height: 28px;
color: var(--dsw-alias-label-secondary);
}
.feedback strong {
display: block;
margin-bottom: 4px;
font-weight: 600;
}
.feedback p,
.closing {
color: var(--dsw-alias-label-secondary);
}
.closing {
margin-top: 16px;
font-size: 15px;
line-height: 24px;
}
.quote {
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-secondary);
color: inherit;
font-weight: 500;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--dsw-alias-border-l1);
justify-content: flex-end;
margin-top: 32px;
}
.error {
margin-top: 14px;
font-size: 13px;
line-height: 20px;
margin-top: 20px;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-state-error-primary);
}
.primary {
min-width: 104px;
transition: transform 140ms cubic-bezier(0.23, 1, 0.32, 1);
min-width: 120px;
transition: transform 140ms var(--welcome-ease-out);
}
.primary:active:not(:disabled) {
transform: scale(0.97);
}
.brand,
.title,
.opening,
.status,
.reflection,
.feedback,
.footer {
animation: welcome-enter 280ms var(--welcome-ease-out) both;
}
.title { animation-delay: 40ms; }
.opening { animation-delay: 80ms; }
.status { animation-delay: 120ms; }
.reflection { animation-delay: 160ms; }
.feedback { animation-delay: 200ms; }
.footer { animation-delay: 240ms; }
@keyframes welcome-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.brand,
.title,
.opening,
.status,
.reflection,
.feedback,
.footer {
animation: none;
}
.primary {
transition: none;
}
}
@media (max-width: 560px) {
.dialog {
padding: 24px;
.page {
width: calc(100vw - 40px);
padding-top: 38px;
}
.brand {
margin-bottom: 30px;
}
.opening {
margin-top: 24px;
}
.reflection {
margin-top: 28px;
}
.feedback {
margin-top: 28px;
}
.footer {
align-items: stretch;
flex-direction: column;
gap: 14px;
margin-top: 30px;
}
.primary {

View File

@@ -3,11 +3,24 @@
import { useCallback, useEffect, useRef } from 'react'
import type { ReactNode } from 'react'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
import { BrandWordmark, Button } 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'
function emphasizedFeedback(paragraph: string, emphasis: string): ReactNode {
const index = paragraph.indexOf(emphasis)
/* v8 ignore next -- both locale values derive from one owner object that contains the emphasis */
if (index < 0) return paragraph
return (
<>
{paragraph.slice(0, index)}
<strong>{emphasis}</strong>
{paragraph.slice(index + emphasis.length)}
</>
)
}
/** Registrant-owned dependencies of {@link WelcomeNotice}. */
export interface WelcomeNoticeInjected {
controller: WelcomeNoticeStore
@@ -23,6 +36,7 @@ export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
const { complete, controller, useSnapshot, t } = props
const state = useSnapshot(snapshot => snapshot)
const finished = useRef(false)
const titleRef = useRef<HTMLHeadingElement | null>(null)
const finish = useCallback((): void => {
if (finished.current) return
finished.current = true
@@ -37,6 +51,10 @@ export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
if (state.acknowledged) finish()
}, [finish, state.acknowledged])
useEffect(() => {
if (state.status === 'ready' && !state.acknowledged) titleRef.current?.focus()
}, [state.acknowledged, state.status])
if (state.status === 'idle' || state.status === 'loading' || state.acknowledged) return null
const acknowledge = async (): Promise<void> => {
@@ -44,30 +62,26 @@ export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode {
}
return (
<div className={css.overlay} role="presentation">
<div className={css.mask} aria-hidden="true" />
<section className={css.dialog} role="dialog" aria-modal="true" aria-labelledby="welcome-notice-title">
<h2 id="welcome-notice-title" className={css.title}>{t('welcome.title')}</h2>
<p className={css.lead}>{t('welcome.lead')}</p>
<div className={css.feedback}>
<strong>{t('welcome.feedbackTitle')}</strong>
<p>{t('welcome.feedbackBody')}</p>
</div>
<p className={css.closing}>{t('welcome.closing')}</p>
{state.error === null ? null : <p className={css.error} role="alert">{t('welcome.error')}</p>}
<div className={css.footer}>
<p className={css.quote}>{t('welcome.quote')}</p>
<Button
variant="primary"
className={css.primary}
autoFocus
disabled={state.status === 'saving'}
onClick={() => { void acknowledge() }}
>
{t('welcome.continue')}
</Button>
</div>
</section>
</div>
<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>
<p className={css.status}>{t('welcome.paragraph.1')}</p>
<blockquote className={css.reflection}>{t('welcome.paragraph.2')}</blockquote>
<p className={css.feedback}>
{emphasizedFeedback(t('welcome.paragraph.3'), 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>
)
}

View File

@@ -27,11 +27,11 @@ export const zh: LocaleDict = {
'permission.desc': '选择默认权限模式',
'toolcall.title': '工具调用',
'welcome.title': WELCOME_NOTICE_COPY.zh.title,
'welcome.lead': WELCOME_NOTICE_COPY.zh.lead,
'welcome.feedbackTitle': WELCOME_NOTICE_COPY.zh.feedbackTitle,
'welcome.feedbackBody': WELCOME_NOTICE_COPY.zh.feedbackBody,
'welcome.closing': WELCOME_NOTICE_COPY.zh.closing,
'welcome.quote': WELCOME_NOTICE_COPY.zh.quote,
'welcome.paragraph.0': WELCOME_NOTICE_COPY.zh.paragraphs[0],
'welcome.paragraph.1': WELCOME_NOTICE_COPY.zh.paragraphs[1],
'welcome.paragraph.2': WELCOME_NOTICE_COPY.zh.paragraphs[2],
'welcome.paragraph.3': WELCOME_NOTICE_COPY.zh.paragraphs[3],
'welcome.feedbackEmphasis': WELCOME_NOTICE_COPY.zh.feedbackEmphasis,
'welcome.continue': WELCOME_NOTICE_COPY.zh.continueLabel,
'welcome.error': '暂时无法保存确认状态,请重试。',
}
@@ -47,11 +47,11 @@ export const en: LocaleDict = {
'permission.desc': 'Choose default permission mode',
'toolcall.title': 'Tool Call',
'welcome.title': WELCOME_NOTICE_COPY.en.title,
'welcome.lead': WELCOME_NOTICE_COPY.en.lead,
'welcome.feedbackTitle': WELCOME_NOTICE_COPY.en.feedbackTitle,
'welcome.feedbackBody': WELCOME_NOTICE_COPY.en.feedbackBody,
'welcome.closing': WELCOME_NOTICE_COPY.en.closing,
'welcome.quote': WELCOME_NOTICE_COPY.en.quote,
'welcome.paragraph.0': WELCOME_NOTICE_COPY.en.paragraphs[0],
'welcome.paragraph.1': WELCOME_NOTICE_COPY.en.paragraphs[1],
'welcome.paragraph.2': WELCOME_NOTICE_COPY.en.paragraphs[2],
'welcome.paragraph.3': WELCOME_NOTICE_COPY.en.paragraphs[3],
'welcome.feedbackEmphasis': WELCOME_NOTICE_COPY.en.feedbackEmphasis,
'welcome.continue': WELCOME_NOTICE_COPY.en.continueLabel,
'welcome.error': 'The acknowledgement could not be saved. Please try again.',
}

View File

@@ -8,26 +8,30 @@ export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion'
* Bump only when the notice changes materially and every user should see it
* again. The acknowledgement is compared for exact equality.
*/
export const WELCOME_NOTICE_VERSION = '2026-07-30.2'
export const WELCOME_NOTICE_VERSION = '2026-07-30.3'
/** The complete editable welcome notice in both supported GUI locales. */
export const WELCOME_NOTICE_COPY = {
zh: {
title: '内测声明',
lead: '感谢您试用 DeepSeek Harness。目前仍处于内部测试阶段,部分功能与体验还在持续打磨。',
feedbackTitle: '我们最想听见:失败、困惑和不顺手',
feedbackBody: '如果它没帮到您,甚至给工作添了麻烦,请在企业微信群告诉我们。',
closing: '真实使用中的每一个问题,都可能促使我们重新审视,甚至推翻已有设计。',
quote: '“如切如磋,如琢如磨。”',
paragraphs: [
'感谢您愿意拨冗试用 DeepSeek Harness。',
'目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。',
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
'我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
],
feedbackEmphasis: '如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言',
continueLabel: '继续',
},
en: {
title: 'Internal Testing Notice',
lead: 'Thank you for trying DeepSeek Harness. This version is still in internal testing, and some features and experiences remain under refinement.',
feedbackTitle: 'What we most want to hear: failures, confusion, and friction',
feedbackBody: 'If it did not help—or even made your work harder—please tell us in the company WeChat group.',
closing: 'Every problem found in real use may prompt us to reconsider, or even overturn, an existing design.',
quote: '“As one cuts and files, as one chisels and polishes.”',
paragraphs: [
'Thank you for taking the time to try DeepSeek Harness.',
'This version is still in internal testing. Some features remain unfinished, and parts of the experience may feel rough.',
'“As one cuts and files, as one chisels and polishes.” A product grows through real encounters and candid feedback. Problems you uncover in real use may prompt us to reconsider—or even overturn—our existing designs.',
'We especially want to hear about failures, confusion, and friction. If it did not help you, or even made your work harder, please leave a message in the company WeChat group and tell us about your experience. Every piece of feedback helps us refine it.',
],
feedbackEmphasis: 'If it did not help you, or even made your work harder, please leave a message in the company WeChat group',
continueLabel: 'Continue',
},
} as const

View File

@@ -54,30 +54,22 @@ function mount(version?: string, mutateImpl: () => Promise<unknown> = () => Prom
describe('WelcomeNotice', () => {
it('renders the owner copy with one primary action and no dismissal control', async () => {
const h = mount()
const dialog = await screen.findByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title })
for (const text of [
WELCOME_NOTICE_COPY.zh.title,
WELCOME_NOTICE_COPY.zh.lead,
WELCOME_NOTICE_COPY.zh.feedbackTitle,
WELCOME_NOTICE_COPY.zh.feedbackBody,
WELCOME_NOTICE_COPY.zh.closing,
WELCOME_NOTICE_COPY.zh.quote,
]) {
expect(screen.getByText(text)).toBeTruthy()
}
expect(dialog.textContent?.match(/感谢您试用 DeepSeek Harness/g) ?? []).toHaveLength(1)
const buttons = dialog.querySelectorAll('button')
const page = await screen.findByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
expect(screen.getByText(WELCOME_NOTICE_COPY.zh.title)).toBeTruthy()
for (const text of WELCOME_NOTICE_COPY.zh.paragraphs) expect(page.textContent).toContain(text)
expect(page.textContent?.match(/感谢您愿意拨冗试用 DeepSeek Harness/g) ?? []).toHaveLength(1)
const buttons = page.querySelectorAll('button')
expect(buttons).toHaveLength(1)
expect(screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })).toBeTruthy()
expect(document.activeElement).toBe(screen.getByRole('heading', { name: WELCOME_NOTICE_COPY.zh.title }))
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.click(dialog.parentElement!.firstElementChild!)
expect(h.complete).not.toHaveBeenCalled()
expect(screen.getByRole('dialog')).toBeTruthy()
expect(screen.getByRole('region')).toBeTruthy()
})
it('completes only after the acknowledgement write commits', async () => {
const h = mount()
await screen.findByRole('dialog')
await screen.findByRole('region')
fireEvent.click(screen.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }))
await act(async () => { await Promise.resolve() })
expect(h.mutate).toHaveBeenCalledOnce()
@@ -87,7 +79,7 @@ describe('WelcomeNotice', () => {
it('skips itself when this exact version was already acknowledged', async () => {
const h = mount(WELCOME_NOTICE_VERSION)
await act(async () => { await h.controller.load() })
expect(screen.queryByRole('dialog')).toBeNull()
expect(screen.queryByRole('region')).toBeNull()
expect(h.complete).toHaveBeenCalledOnce()
})
@@ -95,7 +87,7 @@ describe('WelcomeNotice', () => {
let resolveWrite!: (value: unknown) => void
const write = new Promise<unknown>((resolve) => { resolveWrite = resolve })
const h = mount(undefined, () => write)
await screen.findByRole('dialog')
await screen.findByRole('region')
const action = screen.getByRole<HTMLButtonElement>('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel })
fireEvent.click(action)
expect(action.disabled).toBe(true)