feat(web): add versioned first-run welcome
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md
|
||||
README.md: 9388e9dd3a984bfcebc85b6b1a35bcce4b9b116e
|
||||
README.zh.md: 57c91ac5dd0bcc0a3e5e029359bf6c3a2be58ec7
|
||||
README.md: 02d8f0e5fdc169d3a45f59d7b42d873943df2b52
|
||||
README.zh.md: 465d57847588e9ccbccc9d9067099773de63c0d0
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
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.section` (one page per feature), and `settings.onboarding` (feature-owned overlays on the empty Hero). The shell ships no copy and reads no locale state — all text arrives from registrants (ui-settings-general owns chrome and General; features own their sections, rows, and onboarding overlays).
|
||||
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.section` (one page per feature), and `settings.onboarding` (ordered feature-owned steps on the empty Hero). The shell ships no copy and reads no locale state — all text arrives from registrants (ui-settings-general owns chrome, General, and the product welcome step; features own their sections, rows, and conditional onboarding steps).
|
||||
|
||||
The shell supplies onboarding registrants only two navigation facts: whether the session surface is the empty Hero and an `openSection(id)` callback that opens the panel on a registered section. Registrants own capability readiness, dismissal, copy, and mutations; the shell therefore does not become a second configuration fact source.
|
||||
The shell projects the onboarding ledger into ascending order and mounts exactly one step at a time. 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 two independently registered dialogs cannot stack and the shell does not become a second configuration fact source.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、覆盖在空白 Hero 之上的浮层)。外壳不自带文案,也不读取 locale 状态:所有文本都来自注册方(ui-settings-general 拥有界面框架和「通用」分区;各功能拥有各自的分区、行和首次使用浮层)。
|
||||
设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在空白 Hero 上的有序步骤)。外壳不自带文案,也不读取 locale 状态:所有文本都来自注册方(ui-settings-general 拥有界面框架、「通用」分区和产品欢迎步骤;各功能拥有各自的分区、行和条件式首次使用引导步骤)。
|
||||
|
||||
外壳只向首次使用注册方提供两个导航事实:当前会话界面是否为空白 Hero,以及一个 `openSection(id)` 回调;后者会打开设置面板并切换到已注册的指定分区。能力就绪状态、浮层关闭、文案和变更操作均由注册方持有,因此外壳不会成为第二个配置事实来源。
|
||||
外壳将首次使用引导记录按升序投影,并且每次只挂载一个步骤。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前步骤后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案和变更操作均由注册方持有,因此两个独立注册的对话框无法堆叠,外壳也不会成为第二个配置事实来源。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* names resolve to that content (trigger: its own text; dialog:
|
||||
* 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 slot receives the sessions-derived empty-Hero fact and a
|
||||
* private callback that opens one registered section.
|
||||
* the onboarding coordinator mounts exactly one ordered registrant while the
|
||||
* sessions-derived empty-Hero fact is active.
|
||||
*/
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -95,9 +95,10 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
|
||||
* @returns the settings shell element tree.
|
||||
*/
|
||||
export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
const { wide, useSections, useSessions, renderSlot } = props
|
||||
const { wide, useSections, useOnboardingSteps, useSessions, renderSlot } = props
|
||||
const [open, setOpen] = useState(false)
|
||||
const [activeId, setActiveId] = useState<string | undefined>(undefined)
|
||||
const [completedOnboarding, setCompletedOnboarding] = useState<ReadonlySet<string>>(() => new Set())
|
||||
const close = useCallback(() => {
|
||||
setOpen(false)
|
||||
setActiveId(undefined)
|
||||
@@ -111,9 +112,25 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
// freshly localized text on locale change, and the trigger/header/close
|
||||
// seats re-render through their own outlets' subscriptions.
|
||||
const rows = useSections(s => s)
|
||||
const onboardingSteps = useOnboardingSteps(s => s)
|
||||
const onboardingActive = useSessions(state =>
|
||||
state.phase === 'ready'
|
||||
&& (state.current === undefined || state.byId[state.current]?.blank === true))
|
||||
const onboardingStep = onboardingActive
|
||||
? onboardingSteps.find(step => !completedOnboarding.has(step.id))
|
||||
: undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (onboardingActive) return
|
||||
setCompletedOnboarding(new Set())
|
||||
}, [onboardingActive])
|
||||
|
||||
const completeOnboardingStep = useCallback((id: string) => {
|
||||
setCompletedOnboarding((previous) => {
|
||||
if (previous.has(id)) return previous
|
||||
return new Set([...previous, id])
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -135,7 +152,11 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
{renderSlot('settings.onboarding', { active: onboardingActive, openSection })}
|
||||
{onboardingStep !== undefined && renderSlot('settings.onboarding', {
|
||||
stepId: onboardingStep.id,
|
||||
complete: () => { completeOnboardingStep(onboardingStep.id) },
|
||||
openSection,
|
||||
}, { only: onboardingStep.id })}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,10 +48,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
*/
|
||||
'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps }
|
||||
/**
|
||||
* Root-scoped onboarding overlays contributed by settings features. The
|
||||
* shell supplies whether the current navigation state is the empty Hero
|
||||
* and a private callback that opens one settings section; registrants own
|
||||
* readiness, copy, and dialog behavior.
|
||||
* 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.
|
||||
*/
|
||||
'settings.onboarding': { kind: 'list'; scope: 'root'; owner: SettingsOnboardingOwnerProps }
|
||||
}
|
||||
@@ -79,10 +79,12 @@ export interface SettingsSectionOwnerProps {
|
||||
children?: never
|
||||
}
|
||||
|
||||
/** Owner share of a settings-backed onboarding overlay. */
|
||||
/** Owner share of the currently active settings-backed onboarding step. */
|
||||
export interface SettingsOnboardingOwnerProps {
|
||||
/** Whether the current UI is in its empty Hero/onboarding state. */
|
||||
active: boolean
|
||||
/** Stable id of the step currently selected by the coordinator. */
|
||||
stepId: string
|
||||
/** Complete or skip this step and transfer ownership to the next entry. */
|
||||
complete: () => void
|
||||
/** Open the settings panel directly on one registered section. */
|
||||
openSection: (id: string) => void
|
||||
}
|
||||
@@ -94,6 +96,12 @@ export interface SettingsSectionRow {
|
||||
label: string
|
||||
}
|
||||
|
||||
/** One ordered onboarding step projected from a slot registration. */
|
||||
export interface SettingsOnboardingStep {
|
||||
id: string
|
||||
order: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrant-private injected share of the settings shell (assembled in
|
||||
* apply): the ledger's nav-row projection as a hooks-compartment source —
|
||||
@@ -103,6 +111,8 @@ export type SettingsRootInjected = {
|
||||
hooks: {
|
||||
/** settings.section ledger projected into ordered nav rows. */
|
||||
sections: HostObservable<readonly SettingsSectionRow[]>
|
||||
/** settings.onboarding ledger projected into coordinator order. */
|
||||
onboardingSteps: HostObservable<readonly SettingsOnboardingStep[]>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,15 @@
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SettingsRootInjected, SettingsSectionRow } from './contract/slots.ts'
|
||||
import type {
|
||||
SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow,
|
||||
} from './contract/slots.ts'
|
||||
import { SettingsRoot } from './SettingsRoot.tsx'
|
||||
|
||||
export type {
|
||||
SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected,
|
||||
SettingsOnboardingOwnerProps, SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps,
|
||||
SettingsOnboardingOwnerProps, SettingsOnboardingStep, SettingsSectionOwnerProps,
|
||||
SettingsSectionRow, SettingsTriggerOwnerProps,
|
||||
} from './contract/slots.ts'
|
||||
|
||||
/**
|
||||
@@ -35,6 +38,8 @@ export function apply(ctx: ClientContext): void {
|
||||
// getSnapshot returns the cached rows until the ledger version moves).
|
||||
let rowsVersion = -1
|
||||
let rows: readonly SettingsSectionRow[] = []
|
||||
let onboardingVersion = -1
|
||||
let onboardingSteps: readonly SettingsOnboardingStep[] = []
|
||||
const injected = (): SettingsRootInjected => ({
|
||||
hooks: {
|
||||
sections: {
|
||||
@@ -55,6 +60,23 @@ export function apply(ctx: ClientContext): void {
|
||||
},
|
||||
subscribe: listener => ctx.slots.subscribe('settings.section', listener),
|
||||
},
|
||||
onboardingSteps: {
|
||||
getSnapshot: () => {
|
||||
const version = ctx.slots.getVersion('settings.onboarding')
|
||||
if (version !== onboardingVersion) {
|
||||
onboardingVersion = version
|
||||
onboardingSteps = ctx.slots.entries('settings.onboarding')
|
||||
.map(e => ({
|
||||
/* v8 ignore next -- list-slot registration requires id */
|
||||
id: e.options.id ?? '',
|
||||
order: e.options.order ?? 0,
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
return onboardingSteps
|
||||
},
|
||||
subscribe: listener => ctx.slots.subscribe('settings.onboarding', listener),
|
||||
},
|
||||
},
|
||||
})
|
||||
ctx.effect(() => {
|
||||
|
||||
@@ -83,6 +83,29 @@ describe('ui-settings apply', () => {
|
||||
off()
|
||||
})
|
||||
|
||||
it('projects onboarding entries into stable coordinator order', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const { onboardingSteps } = injectedOf(b.slots).hooks
|
||||
b.slots.register({ name: 'settings.onboarding', id: 'credential', order: 0 } as never, () => null)
|
||||
b.slots.register({ name: 'settings.onboarding', id: 'welcome', order: -100 } as never, () => null)
|
||||
b.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null)
|
||||
const steps = onboardingSteps.getSnapshot()
|
||||
expect(steps).toEqual([
|
||||
{ id: 'welcome', order: -100 },
|
||||
{ id: 'credential', order: 0 },
|
||||
{ id: 'default-order', order: 0 },
|
||||
])
|
||||
expect(onboardingSteps.getSnapshot()).toBe(steps)
|
||||
const listener = vi.fn()
|
||||
const off = onboardingSteps.subscribe(listener)
|
||||
b.slots.register({ name: 'settings.onboarding', id: 'later', order: 10 } as never, () => null)
|
||||
await Promise.resolve()
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
off()
|
||||
})
|
||||
|
||||
it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => {
|
||||
const b = await bench()
|
||||
const redeclare = declare(b.slots)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
|
||||
afterEach(cleanup)
|
||||
|
||||
type Row = { id: string; order: number; label: string }
|
||||
type Step = { id: string; order: number }
|
||||
|
||||
/** Slot-content stand-ins: the shell renders whatever the seats contribute. */
|
||||
const SEAT_CONTENT: Record<string, string> = {
|
||||
@@ -23,7 +24,11 @@ function mount({
|
||||
{ id: 'general', order: 0, label: 'General' },
|
||||
{ id: 'models', order: 10, label: 'Models' },
|
||||
],
|
||||
}: { wide?: boolean; onboardingActive?: boolean; rows?: Row[] } = {}) {
|
||||
steps = [
|
||||
{ id: 'welcome', order: -100 },
|
||||
{ id: 'credential', order: 0 },
|
||||
],
|
||||
}: { wide?: boolean; onboardingActive?: boolean; rows?: Row[]; steps?: Step[] } = {}) {
|
||||
// Mutable row source standing in for the bound useSections hook; bump()
|
||||
// plays a ledger change through the same observable contract.
|
||||
let current = rows
|
||||
@@ -46,6 +51,7 @@ function mount({
|
||||
useSessions,
|
||||
useWorkspaces: unusedHook,
|
||||
wide,
|
||||
useOnboardingSteps: select => select(steps),
|
||||
useSections: (select) => {
|
||||
const [, force] = useState(0)
|
||||
useEffect(() => {
|
||||
@@ -164,20 +170,30 @@ describe('SettingsPanel navigation', () => {
|
||||
expect(screen.queryByTestId('section-general')).toBeNull()
|
||||
})
|
||||
|
||||
it('hands Hero readiness and a direct section opener to onboarding registrants', () => {
|
||||
it('mounts onboarding steps in order and transfers ownership only on completion', () => {
|
||||
const { renderSlot } = mount()
|
||||
const onboardingCall = renderSlot.mock.calls.find(call => call[0] === 'settings.onboarding')
|
||||
expect(onboardingCall?.[1]).toMatchObject({ active: true })
|
||||
const first = renderSlot.mock.calls.find(call => call[0] === 'settings.onboarding')
|
||||
expect(first?.[1]).toMatchObject({ stepId: 'welcome' })
|
||||
expect(first?.[2]).toEqual({ only: 'welcome' })
|
||||
act(() => {
|
||||
(onboardingCall?.[1] as { openSection: (id: string) => void }).openSection('models')
|
||||
(first?.[1] as { complete: () => void }).complete()
|
||||
;(first?.[1] as { complete: () => void }).complete()
|
||||
})
|
||||
const onboardingCalls = renderSlot.mock.calls.filter(call => call[0] === 'settings.onboarding')
|
||||
const second = onboardingCalls.at(-1)
|
||||
expect(second?.[1]).toMatchObject({ stepId: 'credential' })
|
||||
expect(second?.[2]).toEqual({ only: 'credential' })
|
||||
|
||||
act(() => {
|
||||
(second?.[1] as { openSection: (id: string) => void }).openSection('models')
|
||||
})
|
||||
expect(screen.getByRole('dialog')).toBeTruthy()
|
||||
expect(screen.getByTestId('section-models')).toBeTruthy()
|
||||
|
||||
cleanup()
|
||||
const active = mount({ onboardingActive: false }).renderSlot.mock.calls
|
||||
.find(call => call[0] === 'settings.onboarding')
|
||||
expect(active?.[1]).toMatchObject({ active: false })
|
||||
const inactive = mount({ onboardingActive: false }).renderSlot.mock.calls
|
||||
.filter(call => call[0] === 'settings.onboarding')
|
||||
expect(inactive).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('falls back to the first row when the active entry unregisters', () => {
|
||||
|
||||
Reference in New Issue
Block a user