feat(ui-settings): add an About section with company copy and version check

Registers a new `settings.section` (id `about`) in ui-settings-general that
introduces PineSound, names the product build, shows the current version, and
offers a check-updates control (a client-side placeholder comparing against a
known-latest constant pending a real update channel). Adds a 16px info icon to
ui-primitives and wires the section into the settings nav. Tests cover the
registration and the About component; READMEs and translation-pairing hashes
are updated.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-14 17:39:02 +08:00
parent a8ec40ad34
commit 1031b63100
13 changed files with 231 additions and 15 deletions
@@ -856,6 +856,20 @@ export const IconQuestionOutline14 = ({ size = 14, className }: IconProps) => (
</svg>
)
/** ic_ds_info_outline_16: ring + 'i' glyph, used for the About settings section. */
export const IconInfoOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 15.2C4.024 15.2 0.8 11.976 0.8 8C0.8 4.024 4.024 0.8 8 0.8C11.976 0.8 15.2 4.024 15.2 8C15.2 11.976 11.976 15.2 8 15.2ZM8 2.4C4.908 2.4 2.4 4.908 2.4 8C2.4 11.092 4.908 13.6 8 13.6C11.092 13.6 13.6 11.092 13.6 8C13.6 4.908 11.092 2.4 8 2.4Z"
fill="currentColor"
/>
<path d="M7.2 6.8H8.8V11.4H7.2V6.8Z" fill="currentColor" />
<path d="M8 4.9C7.59 4.9 7.25 5.24 7.25 5.65C7.25 6.06 7.59 6.4 8 6.4C8.41 6.4 8.75 6.06 8.75 5.65C8.75 5.24 8.41 4.9 8 4.9Z" fill="currentColor" />
</svg>
)
/** ic_ds_archive_outline_20 (figma extract): lidded box + label slot. The export's
* 0.11px stroke ring around the box contour is dropped — it restates the same
* contour in the same ink, which currentColor already carries. */
@@ -16,8 +16,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('ic_ds_ icon set', () => {
it('exports the full icon set (46 deepsuite + 20 figma extracts + four product glyphs outside those sets)', () => {
expect(iconNames.length).toBe(70)
it('exports the full icon set (46 deepsuite + 20 figma extracts + product glyphs outside those sets)', () => {
expect(iconNames.length).toBe(71)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
@@ -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: c59b77617cfd5848553ba340e97bafe37b7b2a2f
README.zh.md: ea4896284b677a7b430cceb949b53aa6e3a5a242
README.md: 4c3d00b31c4961d2b19afbfa6d5647652a0dbe62
README.zh.md: b1a5c4a58fddda84567095396aa571fd97bbb832
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Settings shell, ownerless copy, and durable product-onboarding namespace. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, projects the `settings.section` ledger into the navigation and the `settings.onboarding` ledger into one mounted step at a time, and registers everything on the Settings pages that belongs to no single feature — the trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, and the `settings` dictionaries. The slot types it renders into belong to ui-settings, the settings domain base; only the shell's own contract types live here, because they reference ui-sidebar's slot type and the base layer must depend on no `ui-*` package. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
Settings shell, ownerless copy, and durable product-onboarding namespace. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, projects the `settings.section` ledger into the navigation and the `settings.onboarding` ledger into one mounted step at a time, and registers everything on the Settings pages that belongs to no single feature — the trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, the About section (PineSound company copy and a client-side version check), and the `settings` dictionaries. The slot types it renders into belong to ui-settings, the settings domain base; only the shell's own contract types live here, because they reference ui-sidebar's slot type and the base layer must depend on no `ui-*` package. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
The shell ships no onboarding copy of its own — all text arrives from registrants. 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 onboarding ledger projects in ascending order and mounts exactly one step at a time. Visible steps own their dialog chrome and app-root `inert` lifecycle; a mounted step still resolving private facts renders null, so nothing paints or blocks while it 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 their visible wrapper, so independently registered flows cannot stack and the shell does not become a second configuration fact source.
@@ -2,7 +2,7 @@
[English](README.md) | 中文
设置外壳、无特定功能归属文案与持久化产品引导 namespace。它以触发控件和模态设置面板占用 `sidebar.settings`,把 `settings.section` 账本投影成导航、把 `settings.onboarding` 账本投影成每次只挂载一个步骤的引导流程,并在设置页面上注册所有不属于单一功能的内容:触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot,以及 `settings` 字典。它渲染进的那些 slot 类型归 ui-settings——设置领域底座——所有;只有外壳自身的契约类型放在这里,因为它们引用 ui-sidebar 的 slot 类型,而底座不得依赖任何 `ui-*` 包。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
设置外壳、无特定功能归属文案与持久化产品引导 namespace。它以触发控件和模态设置面板占用 `sidebar.settings`,把 `settings.section` 账本投影成导航、把 `settings.onboarding` 账本投影成每次只挂载一个步骤的引导流程,并在设置页面上注册所有不属于单一功能的内容:触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot、「关于」分区(PineSound 企业介绍与客户端侧版本检查),以及 `settings` 字典。它渲染进的那些 slot 类型归 ui-settings——设置领域底座——所有;只有外壳自身的契约类型放在这里,因为它们引用 ui-sidebar 的 slot 类型,而底座不得依赖任何 `ui-*` 包。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
外壳不自带引导文案:所有文本都来自注册方。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。首次使用引导记录按升序投影,每次只挂载一个步骤;可见步骤自行持有弹窗框架和应用根节点 `inert` 生命周期。已挂载但仍在判定私有事实的步骤渲染 null,因此判定期间不绘制也不阻塞任何内容。当前注册方会收到该条目的 id、`complete()``openSection(id)` 回调;完成或跳过当前步骤后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案、变更操作以及可见包装均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。
@@ -0,0 +1,72 @@
.section {
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
}
.company {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.companyName {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.tagline {
margin: 0;
font-weight: 500;
}
.intro {
margin: 0.5rem 0 0;
line-height: 1.6;
opacity: 0.85;
}
.product {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid var(--dsh-border);
}
.productName {
margin: 0;
}
.version {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin: 0;
}
.version dt {
opacity: 0.7;
}
.version dd {
margin: 0;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.check {
align-self: flex-start;
}
.check:disabled {
cursor: default;
opacity: 0.6;
}
.status {
margin: 0;
opacity: 0.75;
}
@@ -0,0 +1,66 @@
/**
* The About section: PineSound company introduction and a software-update check.
*
* The update check is a client-side placeholder: it compares the build version
* against a fixed known-latest constant and reports up-to-date, pending a real
* update channel. The two constants are product metadata, not tunables.
*/
import { useState } from 'react'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import css from './AboutSection.module.css'
/** This build's version, mirrored from apps/desktop/package.json. */
const APP_VERSION = '0.1.0-rc.5'
/** Latest version known to this build; the update check compares against it. */
const LATEST_VERSION = APP_VERSION
type UpdateStatus = 'idle' | 'checking' | 'upToDate'
/** Full component props for the About section. */
export type AboutSectionComponentProps =
PropsRuntime<'settings.section'> & PropsLocale<'settings'>
/**
* Render the About section: company copy and a check-updates control.
* @param props - composed slot props (contract/slots.ts).
* @returns the About section element tree.
*/
export function AboutSection({ t }: AboutSectionComponentProps) {
const [status, setStatus] = useState<UpdateStatus>('idle')
const checking = status === 'checking'
const check = (): void => {
if (checking) return
setStatus('checking')
window.setTimeout(() => { setStatus('upToDate') }, 600)
}
return (
<div className={css.section}>
<div className={css.company}>
<h3 className={css.companyName}>{t('about.companyName')}</h3>
<p className={css.tagline}>{t('about.companyTagline')}</p>
<p className={css.intro}>{t('about.companyIntro')}</p>
</div>
<div className={css.product}>
<p className={css.productName}>{t('about.product')}</p>
<dl className={css.version}>
<dt>{t('about.currentVersion')}</dt>
<dd data-version>{APP_VERSION}</dd>
</dl>
<button
type="button"
className={css.check}
disabled={checking}
aria-busy={checking || undefined}
onClick={check}
>
{checking ? t('about.checking') : t('about.checkUpdates')}
</button>
{status === 'upToDate' && !checking
? <p className={css.status} role="status">{t('about.upToDate')} · v{LATEST_VERSION}</p>
: null}
</div>
</div>
)
}
@@ -14,7 +14,7 @@ import { useCallback, useEffect, useId, useRef, useState } from 'react'
import clsx from 'clsx'
import {
IconAgentPresetOutline16, IconCloseOutline16, IconDataOutline16,
IconPersonalizationOutline16, IconSettingsOutline16,
IconInfoOutline16, IconPersonalizationOutline16, IconSettingsOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SettingsRootComponentProps, SettingsSectionRow } from './shell-contract.ts'
import css from './SettingsRoot.module.css'
@@ -24,6 +24,7 @@ function navIcon(id: string) {
if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} />
if (id === 'agent-presets') return <IconAgentPresetOutline16 className={css.navIcon} size={16} />
if (id === 'plugins') return <IconPersonalizationOutline16 className={css.navIcon} size={16} />
if (id === 'about') return <IconInfoOutline16 className={css.navIcon} size={16} />
return <IconSettingsOutline16 className={css.navIcon} size={16} />
}
@@ -3,8 +3,8 @@
* `sidebar.settings` occupant — panel chrome, section navigation, and the
* onboarding stage — and registers everything on the Settings pages that
* belongs to no single feature: the trigger/header chrome content,
* local-document action, General section, and `settings` dictionaries.
* Feature-owned rows and sections stay with their features.
* local-document action, General and About sections, and `settings`
* dictionaries. Feature-owned rows and sections stay with their features.
* Export discipline: packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
@@ -22,6 +22,7 @@ import type {
} from './shell-contract.ts'
import { SettingsRoot } from './SettingsRoot.tsx'
import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
import { AboutSection } from './AboutSection.tsx'
import { GeneralSection } from './GeneralSection.tsx'
import { SettingsDocumentAction } from './SettingsDocumentAction.tsx'
import type { SettingsDocumentActionInjected } from './SettingsDocumentAction.tsx'
@@ -34,6 +35,9 @@ export type {
export type {
GeneralSectionComponentProps,
} from './GeneralSection.tsx'
export type {
AboutSectionComponentProps,
} from './AboutSection.tsx'
export type { SettingsDocumentActionInjected, SettingsDocumentActionProps } from './SettingsDocumentAction.tsx'
export type { SettingsDocumentState } from './settings-document-store.ts'
export { SettingsDocumentStore } from './settings-document-store.ts'
@@ -175,4 +179,11 @@ export function apply(ctx: ClientContext): void {
locale: NS,
children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
}, GeneralSection))
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'about',
order: 100,
label: () => t('about.nav'),
locale: NS,
}, AboutSection))
}
@@ -1,4 +1,4 @@
/** Shell chrome and General-nav dictionaries; feature rows own their copy. */
/** Shell chrome, General-nav, and About-section dictionaries; feature rows own their copy. */
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
@@ -8,6 +8,15 @@ export const zh = {
'openDocument': '打开配置文件',
'openDocument.error': '无法打开配置文件',
'general.nav': '通用设置',
'about.nav': '关于',
'about.companyName': 'PineSound',
'about.companyTagline': 'AI 音频创作平台',
'about.companyIntro': 'PineSound 是一家 AI 音频创作平台,提供 AI 音效、配乐生成、配音、音色克隆与设计,以及云端音频素材搜索与识别,服务广大的音频与视频创作者。',
'about.product': 'DeepSeek Harness Desktop(由 PineSound 基于 deepseek-harness 构建)',
'about.currentVersion': '当前版本',
'about.checkUpdates': '检查更新',
'about.checking': '正在检查更新…',
'about.upToDate': '已是最新版本',
} satisfies Record<string, string>
/** The settings namespace key union. */
@@ -21,4 +30,13 @@ export const en = {
'openDocument': 'Open configuration file',
'openDocument.error': 'Could not open configuration file',
'general.nav': 'General',
'about.nav': 'About',
'about.companyName': 'PineSound',
'about.companyTagline': 'AI audio creation platform',
'about.companyIntro': 'PineSound is an AI audio creation platform offering AI sound-effect and score generation, voiceover, voice cloning and design, plus cloud audio asset search and recognition, serving audio and video creators.',
'about.product': 'DeepSeek Harness Desktop (built by PineSound on the deepseek-harness project)',
'about.currentVersion': 'Current version',
'about.checkUpdates': 'Check for updates',
'about.checking': 'Checking for updates…',
'about.upToDate': 'You are on the latest version',
} satisfies Record<SettingsKey, string>
@@ -6,6 +6,7 @@ import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
import { AboutSection } from '../src/client/AboutSection.tsx'
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
import { GeneralSection } from '../src/client/GeneralSection.tsx'
import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
@@ -73,6 +74,10 @@ function generalEntry(slots: SlotRegistry) {
return slots.entries('settings.section').find(e => e.component === GeneralSection)
}
function aboutEntry(slots: SlotRegistry) {
return slots.entries('settings.section').find(e => e.component === AboutSection)
}
describe('ui-settings-general apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale', 'connection'])
@@ -91,6 +96,9 @@ describe('ui-settings-general apply', () => {
expect(resolveSlotLabel(entry.options.label)).toBe('通用设置')
expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
expect(before.slots.entries('settings.general.item')).toEqual([])
const about = aboutEntry(before.slots)!
expect(about.options).toMatchObject({ id: 'about', order: 100 })
expect(resolveSlotLabel(about.options.label)).toBe('关于')
// The onboarding hole stays declared for feature-owned steps; this plugin
// no longer seats one.
expect(before.slots.entries('settings.onboarding')).toEqual([])
@@ -110,7 +118,7 @@ describe('ui-settings-general apply', () => {
for (const [name, component] of SEATS) {
expect(after.slots.entries(name)[0]!.component).toBe(component)
// The self-inflicted ledger notifications hit the duplicate guard.
expect(after.slots.entries(name)).toHaveLength(1)
expect(after.slots.entries(name)).toHaveLength(name === 'settings.section' ? 2 : 1)
}
await vi.waitFor(() => {
expect(after.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
@@ -142,7 +150,7 @@ describe('ui-settings-general apply', () => {
// subscription), not re-registration.
SEATS.forEach(([name], i) => {
expect(b.slots.getVersion(name)).toBe(zhVersions[i]!)
expect(b.slots.entries(name)).toHaveLength(1)
expect(b.slots.entries(name)).toHaveLength(name === 'settings.section' ? 2 : 1)
})
expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('General')
b.locale.setLocale('zh')
@@ -4,6 +4,8 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx'
import { GeneralSection } from '../src/client/GeneralSection.tsx'
import { AboutSection } from '../src/client/AboutSection.tsx'
import type { AboutSectionComponentProps } from '../src/client/AboutSection.tsx'
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
import type { TriggerContentProps } from '../src/client/chrome.tsx'
import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
@@ -58,6 +60,28 @@ describe('GeneralSection', () => {
})
})
describe('AboutSection', () => {
const aboutProps: AboutSectionComponentProps = { close: vi.fn(), t } as never
it('renders the company copy and the current version', () => {
render(<AboutSection {...aboutProps} />)
expect(screen.getByText('PineSound')).toBeTruthy()
expect(screen.getByText('AI audio creation platform')).toBeTruthy()
expect(screen.getByText(en['about.currentVersion'])).toBeTruthy()
expect(screen.getByText('0.1.0-rc.5')).toBeTruthy()
expect(screen.getByRole('button', { name: en['about.checkUpdates'] })).toBeTruthy()
})
it('checks for updates and reports the latest version', async () => {
render(<AboutSection {...aboutProps} />)
fireEvent.click(screen.getByRole('button', { name: en['about.checkUpdates'] }))
expect(screen.getByText(en['about.checking'])).toBeTruthy()
await waitFor(() => {
expect(screen.getByRole('status').textContent).toContain(en['about.upToDate'])
})
})
})
describe('SettingsDocumentAction', () => {
it('appears only for a file-backed provider and requests its Host-owned document', async () => {
const openDocument = vi.fn(() => Promise.resolve({
@@ -76,10 +76,11 @@ describe('ui-settings apply', () => {
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const { sections } = injectedOf(b.slots).hooks
// This package registers the General section itself; every other section
// arrives from a feature registrant.
// This package registers the General and About sections itself; every
// other section arrives from a feature registrant.
const GENERAL = { id: 'general', order: 0, label: 'general.nav' }
expect(sections.getSnapshot()).toEqual([GENERAL])
const ABOUT = { id: 'about', order: 100, label: 'about.nav' }
expect(sections.getSnapshot()).toEqual([GENERAL, ABOUT])
b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
// No order and no label: both projection defaults apply.
b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
@@ -88,6 +89,7 @@ describe('ui-settings apply', () => {
GENERAL,
{ id: 'a', order: 0, label: '' },
{ id: 'z', order: 20, label: 'Z' },
ABOUT,
])
// Snapshot identity is stable until the ledger moves (uSES contract).
expect(sections.getSnapshot()).toBe(rows)