• feat(self-modification): add dynamic Cordis plugin runtime and UI
This commit is contained in:
@@ -42,6 +42,21 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
/** Theme token dictionary: --dsw-alias-* overrides keyed by variable name. */
|
||||
export type ThemeTokens = Record<string, string>
|
||||
|
||||
/**
|
||||
* One override-layer token value: both palette modes are mandatory (repeat
|
||||
* the same value when the token is scheme-invariant) so an override never
|
||||
* goes illegible when the user switches to the other scheme.
|
||||
*/
|
||||
export interface ThemeTokenModes {
|
||||
/** Value applied while the light base palette is active. */
|
||||
light: string
|
||||
/** Value applied while the dark base palette is active. */
|
||||
dark: string
|
||||
}
|
||||
|
||||
/** Override-layer dictionary: token names to per-mode value pairs. */
|
||||
export type ThemeTokenOverrides = Record<string, ThemeTokenModes>
|
||||
|
||||
/** One selectable theme: id, dark/light semantics, and alias-token overrides. */
|
||||
export interface ThemeDefinition {
|
||||
/** Theme id (the setTheme argument for concrete themes). */
|
||||
@@ -59,7 +74,11 @@ export interface ThemeDefinition {
|
||||
export interface ThemeSnapshot {
|
||||
/** The persisted preference (may be `system`). */
|
||||
preference: ThemePreference
|
||||
/** The resolved active theme (`system` resolved via prefers-color-scheme). */
|
||||
/**
|
||||
* The resolved active theme (`system` resolved via prefers-color-scheme)
|
||||
* with override layers folded into its tokens (seq order, later layers win
|
||||
* per-token; each value picked for the active color scheme).
|
||||
*/
|
||||
active: ThemeDefinition
|
||||
/** Registered themes in registration order. */
|
||||
themes: readonly ThemeDefinition[]
|
||||
@@ -67,6 +86,20 @@ export interface ThemeSnapshot {
|
||||
revision: number
|
||||
}
|
||||
|
||||
/** One theme token exposed to pre-definition Cordis inspection. */
|
||||
export interface ThemeTokenInspection {
|
||||
/** Token name accepted by {@link ThemeService.overrideTokens}. */
|
||||
name: string
|
||||
/** Intended visual role. */
|
||||
description: string
|
||||
/** CSS value category. */
|
||||
valueType: string
|
||||
/** Whether override layers must supply both palette modes. */
|
||||
requiresLightAndDark: boolean
|
||||
/** CSS custom property consumed by UI styles. */
|
||||
cssVariable?: string
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
theme: ThemeRuntime
|
||||
@@ -87,11 +120,29 @@ const BUILTIN_THEMES: readonly ThemeDefinition[] = Object.freeze([
|
||||
Object.freeze({ id: 'dark', colorScheme: 'dark' as const, tokens: Object.freeze({}) }),
|
||||
])
|
||||
|
||||
const BUILTIN_INSPECT_TOKENS: readonly ThemeTokenInspection[] = Object.freeze([
|
||||
{ name: '--dsw-alias-bg-base', description: 'Application base background.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-bg-base' },
|
||||
{ name: '--dsw-alias-bg-layer-1', description: 'Primary raised surface background.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-bg-layer-1' },
|
||||
{ name: '--dsw-alias-bg-layer-2', description: 'Secondary nested surface background.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-bg-layer-2' },
|
||||
{ name: '--dsw-alias-bg-overlay', description: 'Overlay and popover background.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-bg-overlay' },
|
||||
{ name: '--dsw-alias-border-l1', description: 'Primary subtle border.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-border-l1' },
|
||||
{ name: '--dsw-alias-border-l2', description: 'Secondary stronger border.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-border-l2' },
|
||||
{ name: '--dsw-alias-brand-primary', description: 'Primary brand accent.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-brand-primary' },
|
||||
{ name: '--dsw-alias-label-primary', description: 'Primary text color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-label-primary' },
|
||||
{ name: '--dsw-alias-label-secondary', description: 'Secondary text color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-label-secondary' },
|
||||
{ name: '--dsw-alias-state-error-primary', description: 'Primary error state color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-state-error-primary' },
|
||||
{ name: '--dsw-alias-state-success-primary', description: 'Primary success state color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-state-success-primary' },
|
||||
{ name: '--dsw-alias-state-warn-primary', description: 'Primary warning state color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-state-warn-primary' },
|
||||
{ name: '--dsw-specific-sidebar-fill', description: 'Sidebar column and title-row background.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-specific-sidebar-fill' },
|
||||
])
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* overrides. Reads go through {@link getTheme}; preference writes only
|
||||
* through {@link setTheme}; continuous sync only through the `theme/change`
|
||||
* event. {@link overrideTokens} stacks partial token layers over the active
|
||||
* theme without touching the registry.
|
||||
* 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`.
|
||||
@@ -104,6 +155,9 @@ export class ThemeRuntime {
|
||||
private revision = 0
|
||||
private snapshot: ThemeSnapshot
|
||||
private readonly media: MediaQueryList | undefined
|
||||
/** Override layers by source; seq (monotonic) is the stacking order. */
|
||||
private readonly overrides = new Map<string, { seq: number; tokens: ThemeTokenOverrides }>()
|
||||
private overrideSeq = 0
|
||||
|
||||
/**
|
||||
* @param ctx - owning context (change events are emitted on it; the
|
||||
@@ -140,6 +194,25 @@ export class ThemeRuntime {
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the current token directory without reading DOM or computed styles.
|
||||
* @returns stable JSON-safe token descriptions, including registered and override-only names.
|
||||
*/
|
||||
exportInspectTokens(): ThemeTokenInspection[] {
|
||||
const tokens = new Map(BUILTIN_INSPECT_TOKENS.map(token => [token.name, token]))
|
||||
for (const theme of this.themes) {
|
||||
for (const name of Object.keys(theme.tokens)) {
|
||||
if (!tokens.has(name)) tokens.set(name, dynamicToken(name))
|
||||
}
|
||||
}
|
||||
for (const layer of this.overrides.values()) {
|
||||
for (const name of Object.keys(layer.tokens)) {
|
||||
if (!tokens.has(name)) tokens.set(name, dynamicToken(name))
|
||||
}
|
||||
}
|
||||
return [...tokens.values()].map(token => ({ ...token })).sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the theme preference — the only user preference write entry.
|
||||
* Built-in preferences are written through the settings scope and every
|
||||
@@ -189,6 +262,33 @@ export class ThemeRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stack a token override layer on top of the active theme — the token-level
|
||||
* analogue of slot shading: the base theme stays untouched, layers compose
|
||||
* in seq order with later layers winning per-token, and removing a layer
|
||||
* restores whatever it covered. Calling again with the same source replaces
|
||||
* that source's whole layer and restacks it on top (effect re-registration
|
||||
* semantics). Emits `theme/change` with the recomposed snapshot.
|
||||
* @param source - layer identity; one layer per source (dynamic packages
|
||||
* pass their package id — the façade pins it, so it also names the layer's
|
||||
* origin for inspection).
|
||||
* @param tokens - token-name → `{ light, dark }` value pairs. Validated at
|
||||
* runtime (model-authored callers reach this boundary with untyped JS);
|
||||
* a bare string value throws a teaching error.
|
||||
* @returns disposer removing exactly the layer this call created; a no-op
|
||||
* once the source has re-overridden (the newer layer is not torn down).
|
||||
*/
|
||||
overrideTokens(source: string, tokens: ThemeTokenOverrides): () => void {
|
||||
const layer = { seq: this.overrideSeq++, tokens: validateOverrides(source, tokens) }
|
||||
this.overrides.set(source, layer)
|
||||
this.publish()
|
||||
return () => {
|
||||
if (this.overrides.get(source) !== layer) return
|
||||
this.overrides.delete(source)
|
||||
this.publish()
|
||||
}
|
||||
}
|
||||
|
||||
private buildSnapshot(): ThemeSnapshot {
|
||||
const resolvedId = this.preference === 'system'
|
||||
? (this.media?.matches === true ? 'dark' : 'light')
|
||||
@@ -200,12 +300,29 @@ export class ThemeRuntime {
|
||||
if (active === undefined) throw new Error(`theme registry lost "${resolvedId}"`)
|
||||
return Object.freeze({
|
||||
preference: this.preference,
|
||||
active,
|
||||
active: this.composeActive(active),
|
||||
themes: Object.freeze([...this.themes]),
|
||||
revision: this.revision,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the override layers into the active definition: seq order, later
|
||||
* layers win per-token, each value picked for the active color scheme (the
|
||||
* presenter consumes the composed snapshot and needs no override awareness).
|
||||
* Without layers the registered definition passes through by identity.
|
||||
*/
|
||||
private composeActive(active: ThemeDefinition): ThemeDefinition {
|
||||
if (this.overrides.size === 0) return active
|
||||
const tokens: ThemeTokens = { ...active.tokens }
|
||||
for (const layer of [...this.overrides.values()].sort((a, b) => a.seq - b.seq)) {
|
||||
for (const [name, modes] of Object.entries(layer.tokens)) {
|
||||
tokens[name] = modes[active.colorScheme]
|
||||
}
|
||||
}
|
||||
return Object.freeze({ ...active, tokens: Object.freeze(tokens) })
|
||||
}
|
||||
|
||||
private publish(): void {
|
||||
this.revision += 1
|
||||
this.snapshot = this.buildSnapshot()
|
||||
@@ -213,6 +330,44 @@ export class ThemeRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime shape check for one override layer (model-authored callers pass
|
||||
* untyped JS through the dynamic-package façade, so the static type cannot
|
||||
* enforce the pair shape there). Returns a defensive per-token copy so later
|
||||
* caller mutation cannot reach the stored layer.
|
||||
*/
|
||||
function validateOverrides(source: string, tokens: ThemeTokenOverrides): ThemeTokenOverrides {
|
||||
const validated: ThemeTokenOverrides = {}
|
||||
for (const [name, value] of Object.entries<unknown>(tokens)) {
|
||||
if (typeof value === 'string') {
|
||||
throw new TypeError(
|
||||
`theme override "${name}" from "${source}" is a bare string — pass { light: ${JSON.stringify(value)}, dark: ${JSON.stringify(value)} } `
|
||||
+ '(repeat the value when it is the same in both palettes); a single value goes illegible when the user switches color scheme',
|
||||
)
|
||||
}
|
||||
if (typeof value !== 'object' || value === null
|
||||
|| typeof (value as { light?: unknown }).light !== 'string'
|
||||
|| typeof (value as { dark?: unknown }).dark !== 'string') {
|
||||
throw new TypeError(
|
||||
`theme override "${name}" from "${source}" must map to a { light, dark } pair of strings — one value per color scheme`,
|
||||
)
|
||||
}
|
||||
const modes = value as ThemeTokenModes
|
||||
validated[name] = { light: modes.light, dark: modes.dark }
|
||||
}
|
||||
return validated
|
||||
}
|
||||
|
||||
function dynamicToken(name: string): ThemeTokenInspection {
|
||||
return {
|
||||
name,
|
||||
description: 'Theme token registered by the current Client composition.',
|
||||
valueType: 'CSS value',
|
||||
requiresLightAndDark: true,
|
||||
...(name.startsWith('--') ? { cssVariable: name } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Required services: settings transport plus slots/locale for the Appearance
|
||||
* row. `remote` carries the forwarded settings invalidation that
|
||||
|
||||
Reference in New Issue
Block a user