feat(web): add versioned first-run welcome

This commit is contained in:
NI0317
2026-07-30 22:17:56 +08:00
parent b6130b178d
commit bef8db3add
49 changed files with 1096 additions and 115 deletions

View File

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

View File

@@ -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[]>
}
}

View File

@@ -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(() => {