Merge remote-tracking branch 'origin/master' into dshw/pr-2423
# Conflicts: # packages/client/ui-sidebar/src/client/SidebarRoot.tsx
This commit is contained in:
@@ -22,7 +22,7 @@ const COPY: Record<string, string> = {
|
||||
/** Empty global standard-kit hooks (the row reads neither). */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
function emptyWorkspaces() {
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
* projection into the row store, and HMR collapse recovery. */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SettingsScopeService } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import type { AppearanceRowInjected, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import type { AppearanceRowInjected, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-settings.ts'
|
||||
import { AppearanceRow } from '../src/client/AppearanceRow.tsx'
|
||||
import type { createAppearanceRowStore } from '../src/client/settings-store.ts'
|
||||
@@ -27,8 +27,8 @@ function deferred<T>() {
|
||||
|
||||
async function bench(isLoopback = true) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const locale = new LocaleService(ctx)
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
const locale = new LocaleRuntime(ctx)
|
||||
ctx.provide('locale', locale)
|
||||
let preference = 'system'
|
||||
const namespace = () => ({
|
||||
@@ -56,15 +56,15 @@ async function bench(isLoopback = true) {
|
||||
ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never)
|
||||
// The settings transport and the forwarded-event port the plugin injects.
|
||||
new TestRemote(ctx)
|
||||
await ctx.plugin(SettingsScopeService).await()
|
||||
await ctx.plugin(SettingsScopeBinder).await()
|
||||
return {
|
||||
ctx, slots: ctx.get('slots') as SlotsService, locale, describe, mutate,
|
||||
ctx, slots: ctx.get('slots') as SlotRegistry, locale, describe, mutate,
|
||||
setHostPreference: (next: string) => { preference = next },
|
||||
}
|
||||
}
|
||||
|
||||
/** Stand in for the settings shell: declare the General item slot from root. */
|
||||
function declareItems(slots: SlotsService): () => void {
|
||||
function declareItems(slots: SlotRegistry): () => void {
|
||||
return slots.register(
|
||||
{ name: 'root', children: { [SLOT]: { kind: 'list', scope: 'root' } } } as never,
|
||||
() => null,
|
||||
@@ -73,7 +73,7 @@ function declareItems(slots: SlotsService): () => void {
|
||||
|
||||
/** Mirror the framework's inject choreography: bake a real instance from the
|
||||
* declared handle and hand its actions to the entry's inject factory. */
|
||||
function faceOf(slots: SlotsService) {
|
||||
function faceOf(slots: SlotRegistry) {
|
||||
const entry = slots.entries(SLOT).find(e => e.component === AppearanceRow)!
|
||||
const handle = entry.store as ReturnType<typeof createAppearanceRowStore>
|
||||
const instance = handle.create()
|
||||
@@ -109,7 +109,7 @@ describe('ui-theme apply', () => {
|
||||
const b = await bench()
|
||||
declareItems(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const theme = b.ctx.get('theme') as ThemeService
|
||||
const theme = b.ctx.get('theme') as ThemeRuntime
|
||||
// An event ahead of any inject hits the unbound-actions arm.
|
||||
theme.setTheme('dark')
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('ui-theme apply', () => {
|
||||
b.setHostPreference('dark')
|
||||
declareItems(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const theme = b.ctx.get('theme') as ThemeService
|
||||
const theme = b.ctx.get('theme') as ThemeRuntime
|
||||
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
|
||||
b.ctx.remote.$dispatch('settings/document-updated', ['unrelated', 0])
|
||||
expect(b.describe).toHaveBeenCalledOnce()
|
||||
@@ -144,7 +144,7 @@ describe('ui-theme apply', () => {
|
||||
const remote = await bench(false)
|
||||
declareItems(remote.slots)
|
||||
await remote.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const remoteTheme = remote.ctx.get('theme') as ThemeService
|
||||
const remoteTheme = remote.ctx.get('theme') as ThemeRuntime
|
||||
remoteTheme.setTheme('dark')
|
||||
await Promise.resolve()
|
||||
expect(remote.describe).not.toHaveBeenCalled()
|
||||
@@ -159,7 +159,7 @@ describe('ui-theme apply', () => {
|
||||
b.describe.mockImplementationOnce(() => pending.promise)
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const theme = b.ctx.get('theme') as ThemeService
|
||||
const theme = b.ctx.get('theme') as ThemeRuntime
|
||||
expect(theme.getTheme().preference).toBe('system')
|
||||
pending.resolve(await describe())
|
||||
await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') })
|
||||
@@ -170,7 +170,7 @@ describe('ui-theme apply', () => {
|
||||
const b = await bench()
|
||||
b.setHostPreference('sepia')
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const theme = b.ctx.get('theme') as ThemeService
|
||||
const theme = b.ctx.get('theme') as ThemeRuntime
|
||||
await vi.waitFor(() => { expect(b.describe).toHaveBeenCalledOnce() })
|
||||
expect(theme.getTheme().preference).toBe('system')
|
||||
})
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import type { WebServer } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { SettingsProvider, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, apply,
|
||||
} from '@deepseek-ai/dsh-client-ui-theme'
|
||||
|
||||
class MemorySettings extends Settings {
|
||||
class MemorySettings extends SettingsProvider {
|
||||
readonly writable = true
|
||||
protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
|
||||
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
|
||||
@@ -34,12 +34,12 @@ describe('ui-theme host', () => {
|
||||
await ctx.plugin(MemorySettings).await()
|
||||
let transform: ((html: string) => string) | undefined
|
||||
let disposed = false
|
||||
ctx.provide('httpServer', {
|
||||
ctx.provide('webServer', {
|
||||
tapIndex: (next: (html: string) => string) => {
|
||||
transform = next
|
||||
return () => { disposed = true }
|
||||
},
|
||||
} as HttpServerService)
|
||||
} as WebServer)
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await fiber.await()
|
||||
expect(transform?.('<body></body>')).toContain('const preference = "system"')
|
||||
@@ -53,12 +53,12 @@ describe('ui-theme host', () => {
|
||||
it('uses the system preference when only an HTTP server exists', async () => {
|
||||
const ctx = new Context()
|
||||
let transform: ((html: string) => string) | undefined
|
||||
ctx.provide('httpServer', {
|
||||
ctx.provide('webServer', {
|
||||
tapIndex: (next: (html: string) => string) => {
|
||||
transform = next
|
||||
return () => undefined
|
||||
},
|
||||
} as HttpServerService)
|
||||
} as WebServer)
|
||||
await ctx.plugin({ apply }).await()
|
||||
expect(transform?.('<body></body>')).toContain('const preference = "system"')
|
||||
})
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-theme'
|
||||
import { apply as clientApply, inject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { apply as clientApply, inject, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import * as ThemeInvariant from '@deepseek-ai/dsh-client-ui-theme/invariant'
|
||||
import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('registers under the package name with an empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(InvariantRegistry, { enabled: true })
|
||||
await expect(ctx.plugin(ThemeInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('invariant companion', () => {
|
||||
// copy, hence the slots + locale edges.
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope'])
|
||||
const ctx = new Context()
|
||||
new SlotsService(ctx)
|
||||
new SlotRegistry(ctx)
|
||||
ctx.provide('connection', {
|
||||
api: { settings: { describe: () => Promise.resolve({
|
||||
rpcId: 'theme-invariant' as never,
|
||||
@@ -39,6 +39,6 @@ describe('invariant companion', () => {
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
await ctx.plugin({ inject: localeInject, apply: localeApply }).await()
|
||||
await ctx.plugin({ inject, apply: clientApply }).await()
|
||||
expect(ctx.get('theme')).toBeInstanceOf(ThemeService)
|
||||
expect(ctx.get('theme')).toBeInstanceOf(ThemeRuntime)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,22 +2,26 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { ThemeSettings, ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import type {
|
||||
ThemeSettings,
|
||||
ThemeSnapshot,
|
||||
ThemeTokenOverrides,
|
||||
} from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
|
||||
const make = (host = stubSettingsScope<ThemeSettings>()): {
|
||||
ctx: Context
|
||||
theme: ThemeService
|
||||
theme: ThemeRuntime
|
||||
events: ThemeSnapshot[]
|
||||
host: StubSettingsScope<ThemeSettings>
|
||||
} => {
|
||||
const ctx = new Context()
|
||||
const events: ThemeSnapshot[] = []
|
||||
ctx.on('theme/change', (snapshot) => { events.push(snapshot) })
|
||||
return { ctx, theme: new ThemeService(ctx, host.scope), events, host }
|
||||
return { ctx, theme: new ThemeRuntime(ctx, host.scope), events, host }
|
||||
}
|
||||
|
||||
describe('ThemeService', () => {
|
||||
describe('ThemeRuntime', () => {
|
||||
it('defaults to the system preference resolved against prefers-color-scheme', () => {
|
||||
const { theme } = make()
|
||||
const snapshot = theme.getTheme()
|
||||
@@ -103,6 +107,91 @@ describe('ThemeService', () => {
|
||||
expect(events.map(e => e.revision)).toEqual([1, 2, 3, 4])
|
||||
})
|
||||
|
||||
it('stacks reversible token overrides in call order and selects the active palette value', () => {
|
||||
const { theme } = make()
|
||||
const firstTokens: ThemeTokenOverrides = {
|
||||
'--shared': { light: 'first-light', dark: 'first-dark' },
|
||||
'--first': { light: 'first-only-light', dark: 'first-only-dark' },
|
||||
}
|
||||
const disposeFirst = theme.overrideTokens('first', firstTokens)
|
||||
firstTokens['--shared']!.light = 'mutated-after-call'
|
||||
const disposeSecond = theme.overrideTokens('second', {
|
||||
'--shared': { light: 'second-light', dark: 'second-dark' },
|
||||
})
|
||||
|
||||
expect(theme.getTheme().active.tokens).toMatchObject({
|
||||
'--first': 'first-only-light',
|
||||
'--shared': 'second-light',
|
||||
})
|
||||
theme.setTheme('dark')
|
||||
expect(theme.getTheme().active.tokens).toMatchObject({
|
||||
'--first': 'first-only-dark',
|
||||
'--shared': 'second-dark',
|
||||
})
|
||||
|
||||
disposeSecond()
|
||||
expect(theme.getTheme().active.tokens['--shared']).toBe('first-dark')
|
||||
disposeFirst()
|
||||
expect(theme.getTheme().active.tokens['--shared']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('replacing one source leaves its stale disposer harmless', () => {
|
||||
const { theme, events } = make()
|
||||
const stale = theme.overrideTokens('package', {
|
||||
'--old': { light: 'old-light', dark: 'old-dark' },
|
||||
})
|
||||
const current = theme.overrideTokens('package', {
|
||||
'--new': { light: 'new-light', dark: 'new-dark' },
|
||||
})
|
||||
stale()
|
||||
expect(theme.getTheme().active.tokens).toEqual({ '--new': 'new-light' })
|
||||
current()
|
||||
current()
|
||||
expect(theme.getTheme().active.tokens).toEqual({})
|
||||
expect(events).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('exports sorted built-in, registered, and override-only token descriptions as copies', () => {
|
||||
const { theme } = make()
|
||||
theme.register({
|
||||
id: 'custom',
|
||||
colorScheme: 'light',
|
||||
tokens: {
|
||||
'--dsw-alias-bg-base': 'duplicate-built-in',
|
||||
'--registered': 'registered',
|
||||
},
|
||||
})
|
||||
theme.overrideTokens('package', {
|
||||
'--registered': { light: 'duplicate-registered', dark: 'duplicate-registered' },
|
||||
semanticAccent: { light: 'pink', dark: 'red' },
|
||||
})
|
||||
|
||||
const tokens = theme.exportInspectTokens()
|
||||
expect(tokens.map(token => token.name)).toEqual([...tokens.map(token => token.name)].sort())
|
||||
expect(tokens.find(token => token.name === '--registered')).toMatchObject({
|
||||
valueType: 'CSS value',
|
||||
cssVariable: '--registered',
|
||||
})
|
||||
const semantic = tokens.find(token => token.name === 'semanticAccent')
|
||||
expect(semantic).toMatchObject({ valueType: 'CSS value' })
|
||||
expect(semantic).not.toHaveProperty('cssVariable')
|
||||
expect(tokens.filter(token => token.name === '--dsw-alias-bg-base')).toHaveLength(1)
|
||||
|
||||
tokens[0]!.description = 'caller mutation'
|
||||
expect(theme.exportInspectTokens()[0]!.description).not.toBe('caller mutation')
|
||||
})
|
||||
|
||||
it('rejects every malformed token override value with a teaching error', () => {
|
||||
const { theme } = make()
|
||||
const override = (value: unknown): void => {
|
||||
theme.overrideTokens('package', { '--bad': value } as unknown as ThemeTokenOverrides)
|
||||
}
|
||||
expect(() => { override('red') }).toThrow(/bare string.*light.*dark/)
|
||||
for (const value of [1, null, {}, { light: 1, dark: 'dark' }, { light: 'light' }]) {
|
||||
expect(() => { override(value) }).toThrow(/must map to a \{ light, dark \} pair/)
|
||||
}
|
||||
})
|
||||
|
||||
it('context dispose releases the scope subscription', async () => {
|
||||
const { ctx, host } = make()
|
||||
expect(host.listenerCount()).toBe(1)
|
||||
|
||||
Reference in New Issue
Block a user