Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`, `verify-translation-pairing --write` for the touched bilingual pairs, `gen-doc-graphs`, and one typert snapshot whose ids embed character offsets. `pnpm run rescope-vendor --check` verifies the result. Renames nine vendored packages (cordis, cosmokit, schemastery and the six @cordisjs plugins) and every reference that resolves them: manifest names and dependency keys, module specifiers including declare-module merges, cordis.yml plugin names, tsconfig paths, every Markdown fence, and `docs/` prose. Directory names, upstream versions, and dependency ranges are unchanged, so vendor/README.md still reads as an upstream snapshot; its manifest table gains an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed at each fork's origin. The tutorial tier follows the rename end to end: its yaml fences named plugins the Loader can no longer resolve, its `ts ignore-check` fences disagreed with the compiled fences beside them, and its prose quoted both. The contracts that told readers to keep upstream names — the root convention and the vendoring cookbook's tree comment and manifest invariant — now say to rescope instead. Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle purity gate now names the vendored libraries a browser bundle inlines, and the files where a bare `cordis` is an agent-preset id keep that product data.
255 lines
9.9 KiB
TypeScript
255 lines
9.9 KiB
TypeScript
/**
|
|
* Browser theme registry over the `--dsw-*` token stylesheets. The service
|
|
* owns the live theme preference (light/dark/system), resolves `system` through
|
|
* `prefers-color-scheme`, and publishes immutable snapshots; it never touches
|
|
* the DOM — ui-layout's presenter consumes the resolved snapshot. The Host
|
|
* settings scope loads and stores the preference in the user-settings
|
|
* document. The plugin also registers the Appearance preference row into the
|
|
* settings General section — the theme feature owns its own settings surface.
|
|
*/
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
|
import {
|
|
bindSettingsScope, type ClientContext, type SettingsScope,
|
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
|
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
|
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
import type { AppearanceRowInjected } from './AppearanceRow.tsx'
|
|
import { AppearanceRow } from './AppearanceRow.tsx'
|
|
import { createAppearanceRowStore } from './settings-store.ts'
|
|
import { en, zh, type ThemeKey } from './locales.ts'
|
|
import {
|
|
DEFAULT_PREFERENCE, isThemePreference, THEME_PREFERENCE_FIELD, THEME_SETTINGS_NAMESPACE,
|
|
type ThemePreference, type ThemeSettings,
|
|
} from '../theme-settings.ts'
|
|
|
|
export type { AppearanceRowComponentProps, AppearanceRowInjected } from './AppearanceRow.tsx'
|
|
export type { AppearanceRowState } from './settings-store.ts'
|
|
export type { ThemeKey } from './locales.ts'
|
|
export type { ThemePreference, ThemeSettings } from '../theme-settings.ts'
|
|
|
|
/** Namespace owning this feature's settings-row copy. */
|
|
export const SETTINGS_NS = 'settings.theme'
|
|
|
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
interface LocaleNamespaceMap {
|
|
/** The Appearance settings row's copy. */
|
|
'settings.theme': ThemeKey
|
|
}
|
|
}
|
|
|
|
/** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */
|
|
export type ThemeTokens = Record<string, string>
|
|
|
|
/** One selectable theme: id, dark/light semantics, and alias-token overrides. */
|
|
export interface ThemeDefinition {
|
|
/** Theme id (the setTheme argument for concrete themes). */
|
|
id: string
|
|
/**
|
|
* Which base palette this theme builds on. The presenter switches
|
|
* `body[data-ds-dark-theme]` from this field — never from the id.
|
|
*/
|
|
colorScheme: 'light' | 'dark'
|
|
/** Alias-layer overrides applied as inline CSS variables over the base palette. */
|
|
tokens: ThemeTokens
|
|
}
|
|
|
|
/** Immutable theme state published on every change. */
|
|
export interface ThemeSnapshot {
|
|
/** The persisted preference (may be `system`). */
|
|
preference: ThemePreference
|
|
/** The resolved active theme (`system` resolved via prefers-color-scheme). */
|
|
active: ThemeDefinition
|
|
/** Registered themes in registration order. */
|
|
themes: readonly ThemeDefinition[]
|
|
/** Monotonic change counter (registry or active changes). */
|
|
revision: number
|
|
}
|
|
|
|
declare module '@deepseek-ai/cordis' {
|
|
interface Context {
|
|
theme: ThemeService
|
|
}
|
|
interface Events {
|
|
/**
|
|
* Theme state changed (preference switched, registry updated, or the OS
|
|
* color scheme changed while the preference is `system`).
|
|
* @param snapshot - Current immutable theme snapshot.
|
|
* @mode emit
|
|
*/
|
|
'theme/change'(snapshot: ThemeSnapshot): void
|
|
}
|
|
}
|
|
|
|
const BUILTIN_THEMES: readonly ThemeDefinition[] = Object.freeze([
|
|
Object.freeze({ id: 'light', colorScheme: 'light' as const, tokens: Object.freeze({}) }),
|
|
Object.freeze({ id: 'dark', colorScheme: 'dark' as const, tokens: Object.freeze({}) }),
|
|
])
|
|
|
|
/**
|
|
* Theme registry and preference owner. `light`/`dark` are built in (the base
|
|
* stylesheets carry both palettes); third-party themes register alias-layer
|
|
* overrides. Reads go through {@link getTheme}; writes only through
|
|
* {@link setTheme}; continuous sync only through the `theme/change` event.
|
|
* The service holds the `prefers-color-scheme` media query (environment
|
|
* sensing, not presentation) and re-emits when the OS scheme flips while the
|
|
* preference is `system`.
|
|
*/
|
|
export class ThemeService {
|
|
private readonly ctx: Context
|
|
private readonly host: SettingsScope<ThemeSettings>
|
|
private themes: ThemeDefinition[] = [...BUILTIN_THEMES]
|
|
private preference: ThemePreference
|
|
private revision = 0
|
|
private snapshot: ThemeSnapshot
|
|
private readonly media: MediaQueryList | undefined
|
|
|
|
/**
|
|
* @param ctx - owning context (change events are emitted on it; the
|
|
* media-query and scope listeners are released through ctx.effect on dispose).
|
|
* @param host - durable preference scope owned by the same plugin.
|
|
*/
|
|
constructor(ctx: Context, host: SettingsScope<ThemeSettings>) {
|
|
this.ctx = ctx
|
|
this.host = host
|
|
this.preference = DEFAULT_PREFERENCE
|
|
// Non-browser runs (node e2e booting the client tree) have no matchMedia.
|
|
this.media = typeof matchMedia === 'undefined' ? undefined : matchMedia('(prefers-color-scheme: dark)')
|
|
this.snapshot = this.buildSnapshot()
|
|
if (this.media !== undefined) {
|
|
const media = this.media
|
|
const onChange = (): void => {
|
|
if (this.preference !== 'system') return
|
|
this.publish()
|
|
}
|
|
ctx.effect(() => {
|
|
media.addEventListener('change', onChange)
|
|
return () => { media.removeEventListener('change', onChange) }
|
|
}, 'ui-theme: prefers-color-scheme listener')
|
|
}
|
|
ctx.effect(() => host.subscribe(() => { this.adopt() }), 'ui-theme: settings scope adoption')
|
|
this.adopt()
|
|
}
|
|
|
|
/**
|
|
* Read the current immutable theme snapshot.
|
|
* @returns the current snapshot (stable reference until the next change).
|
|
*/
|
|
getTheme(): ThemeSnapshot {
|
|
return this.snapshot
|
|
}
|
|
|
|
/**
|
|
* Switch the theme preference — the only user preference write entry.
|
|
* Built-in preferences are written through the settings scope and every
|
|
* accepted value emits `theme/change`.
|
|
* @param id - a registered theme id or `system`; unknown ids throw.
|
|
*/
|
|
setTheme(id: string): void {
|
|
if (id !== 'system' && !this.themes.some(t => t.id === id)) {
|
|
throw new Error(`theme "${id}" is not registered`)
|
|
}
|
|
if (this.preference === id) return
|
|
this.preference = id as ThemePreference
|
|
if (isThemePreference(id)) void this.host.set(THEME_PREFERENCE_FIELD, id)
|
|
this.publish()
|
|
}
|
|
|
|
/** Adopt the scope's accepted durable preference without writing it back. */
|
|
private adopt(): void {
|
|
const section = this.host.getSnapshot().value
|
|
if (section === undefined || this.preference === section.preference) return
|
|
this.preference = section.preference
|
|
this.publish()
|
|
}
|
|
|
|
/**
|
|
* Register a theme. Duplicate id throws (single occupant per id; the
|
|
* built-in pair counts; `system` is a preference, not a registrable id).
|
|
* @param definition - theme id, colorScheme, and alias-token overrides.
|
|
* @returns disposer. Disposing the theme backing the active preference
|
|
* resets the preference to the default so the UI never keeps tokens of an
|
|
* unregistered theme.
|
|
*/
|
|
register(definition: ThemeDefinition): () => void {
|
|
if (definition.id === 'system') throw new Error('"system" is a preference, not a registrable theme id')
|
|
if (this.themes.some(t => t.id === definition.id)) {
|
|
throw new Error(`theme "${definition.id}" is already registered`)
|
|
}
|
|
this.themes = [...this.themes, definition]
|
|
this.publish()
|
|
return () => {
|
|
if (!this.themes.some(t => t.id === definition.id)) return
|
|
this.themes = this.themes.filter(t => t.id !== definition.id)
|
|
if (this.preference === definition.id) {
|
|
this.preference = DEFAULT_PREFERENCE
|
|
}
|
|
this.publish()
|
|
}
|
|
}
|
|
|
|
private buildSnapshot(): ThemeSnapshot {
|
|
const resolvedId = this.preference === 'system'
|
|
? (this.media?.matches === true ? 'dark' : 'light')
|
|
: this.preference
|
|
// Both built-ins always exist; a registered preference id resolves or has
|
|
// been reset by its disposer, so the lookup cannot miss.
|
|
const active = this.themes.find(t => t.id === resolvedId)
|
|
/* v8 ignore next 2 -- needs a registry without light/dark, which register()/dispose() cannot produce */
|
|
if (active === undefined) throw new Error(`theme registry lost "${resolvedId}"`)
|
|
return Object.freeze({
|
|
preference: this.preference,
|
|
active,
|
|
themes: Object.freeze([...this.themes]),
|
|
revision: this.revision,
|
|
})
|
|
}
|
|
|
|
private publish(): void {
|
|
this.revision += 1
|
|
this.snapshot = this.buildSnapshot()
|
|
this.ctx.emit('theme/change', this.snapshot)
|
|
}
|
|
}
|
|
|
|
/** Required services: settings transport plus slots/locale for the Appearance row. */
|
|
export const inject = ['slots', 'locale', 'connection']
|
|
|
|
/**
|
|
* Client plugin body: provide the theme service and register the
|
|
* feature-owned Appearance preference row into the General section's item
|
|
* slot (a feature owns its settings surface).
|
|
* @param ctx - client cordis context.
|
|
*/
|
|
export function apply(ctx: ClientContext): void {
|
|
const host = bindSettingsScope<ThemeSettings>(ctx, { namespace: THEME_SETTINGS_NAMESPACE })
|
|
const theme = new ThemeService(ctx, host)
|
|
ctx.provide('theme', theme)
|
|
|
|
ctx.effect(() => ctx.locale.register(SETTINGS_NS, { zh, en }), 'ui-theme: settings row dictionaries')
|
|
|
|
const store = createAppearanceRowStore()
|
|
let bound: BoundActions<typeof store> | undefined
|
|
const sync = (snapshot: ThemeSnapshot): void => {
|
|
bound?.sync(snapshot.preference, snapshot.revision)
|
|
}
|
|
ctx.on('theme/change', sync)
|
|
const injected = (actions: BoundActions<typeof store>): AppearanceRowInjected => {
|
|
bound = actions
|
|
// Re-sync from the getter so no event is lost between registration and
|
|
// first render (the store's revision guard drops stale duplicates).
|
|
sync(theme.getTheme())
|
|
return {
|
|
setTheme: (id) => { theme.setTheme(id) },
|
|
}
|
|
}
|
|
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
|
name: 'settings.general.item',
|
|
id: 'appearance',
|
|
order: 10,
|
|
store,
|
|
locale: SETTINGS_NS,
|
|
inject: injected,
|
|
}, AppearanceRow))
|
|
}
|