dsh-tui-theme 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * End-to-end order test with the installed dsh-TUI services. Composes this
3
+ * plugin before the extension services to exercise late service injection.
4
+ *
5
+ * DSH_TUI_ADAPTER_DIR must point at dsh-TUI's lib/types/dsh-adapter directory.
6
+ * Script-side floor: the adapter must be a built dsh-TUI >= 0.9.0 — the
7
+ * settings-sections module and its getHostSettingsSections probe landed there.
8
+ * Set DSH_TUI_EXPECTED_VERSION only when an explicit release baseline needs
9
+ * to be pinned; ordinary development verifies the supplied host as-is.
10
+ */
11
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import { fileURLToPath, pathToFileURL } from 'node:url'
15
+ import { createRequire } from 'node:module'
16
+ import assert from 'node:assert/strict'
17
+ import { assertSettingsContract, SETTINGS_NAMESPACE } from './expected-settings-contract.mjs'
18
+
19
+ // The tuiStatus contribution key and the effect-ledger resource id. Deliberately
20
+ // a separate constant from SETTINGS_NAMESPACE: they hold the same value today
21
+ // (see src/pluginId.ts), but this assertion pins that equality while the status
22
+ // snapshot / ledger filters below must never silently track the namespace.
23
+ const STATUS_CONTRIBUTION_KEY = SETTINGS_NAMESPACE
24
+
25
+ const pluginRoot = fileURLToPath(new URL('..', import.meta.url))
26
+ const adapter = process.env.DSH_TUI_ADAPTER_DIR
27
+ if (adapter === undefined || adapter === '') {
28
+ throw new Error('DSH_TUI_ADAPTER_DIR must point at dsh-TUI lib/types/dsh-adapter for this host integration test.')
29
+ }
30
+
31
+ if (!existsSync(join(adapter, 'extensions.js'))) {
32
+ throw new Error(`dsh-TUI adapter not found at ${adapter}`)
33
+ }
34
+
35
+ const hostPackagePath = join(adapter, '..', '..', '..', 'package.json')
36
+ if (!existsSync(hostPackagePath)) {
37
+ throw new Error(`dsh-TUI package metadata not found above adapter at ${adapter}`)
38
+ }
39
+ const hostPackage = JSON.parse(readFileSync(hostPackagePath, 'utf8'))
40
+ assert.match(hostPackage.version, /^\d+\.\d+\.\d+(?:[-+].+)?$/u, 'host adapter must declare a version')
41
+ const expectedVersion = process.env.DSH_TUI_EXPECTED_VERSION
42
+ if (expectedVersion !== undefined && expectedVersion !== '') {
43
+ assert.equal(hostPackage.version, expectedVersion, `expected dsh-TUI adapter ${expectedVersion}, received ${hostPackage.version}`)
44
+ } else {
45
+ console.log(`* host adapter ${hostPackage.version} (no explicit version pin)`)
46
+ }
47
+
48
+ const sandbox = mkdtempSync(join(tmpdir(), 'pink-order-'))
49
+ process.env.USERPROFILE = sandbox
50
+ process.env.HOME = sandbox
51
+
52
+ const dataDir = join(sandbox, '.dsh-tui')
53
+ mkdirSync(dataDir, { recursive: true })
54
+ writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
55
+
56
+ // Guard the script-side floor explicitly: settings-sections.js first shipped
57
+ // in dsh-TUI 0.9.0, so a bare ERR_MODULE_NOT_FOUND from a dynamic import
58
+ // would hide the real reason a host tree is too old for this test.
59
+ const settingsSectionsPath = join(adapter, 'settings-sections.js')
60
+ if (!existsSync(settingsSectionsPath)) {
61
+ throw new Error(
62
+ `dsh-TUI adapter at ${adapter} has no settings-sections.js; this integration test needs a host >= 0.9.0`,
63
+ )
64
+ }
65
+
66
+ const req = createRequire(join(adapter, 'extensions.js'))
67
+ const { Context } = await import(pathToFileURL(req.resolve('@deepseek-ai/cordis')).href)
68
+ const extensions = await import(pathToFileURL(join(adapter, 'extensions.js')).href)
69
+ const ledgerModule = await import(pathToFileURL(join(adapter, 'effect-ledger.js')).href)
70
+ const statusModule = await import(pathToFileURL(join(adapter, 'status.js')).href)
71
+ const settingsSectionsModule = await import(pathToFileURL(settingsSectionsPath).href)
72
+ const pink = await import(pathToFileURL(join(pluginRoot, 'lib', 'types', 'index.js')).href)
73
+
74
+ const app = new Context()
75
+ await app.plugin(ledgerModule.default)
76
+ await app.plugin(pink)
77
+ await app.plugin(extensions.default ?? extensions)
78
+ await app.plugin(settingsSectionsModule.default ?? settingsSectionsModule)
79
+
80
+ // The late injections resolve asynchronously; poll for every observable
81
+ // outcome instead of sleeping a fixed wall-clock delay.
82
+ const READY_TIMEOUT_MS = 5_000
83
+ const POLL_INTERVAL_MS = 25
84
+ const collectBinds = () => {
85
+ const ledger = join(dataDir, 'effect-ledger.jsonl')
86
+ return existsSync(ledger)
87
+ ? readFileSync(ledger, 'utf8').trim().split('\n')
88
+ .filter(Boolean)
89
+ .map(line => JSON.parse(line))
90
+ .filter(entry => entry.resource?.id === STATUS_CONTRIBUTION_KEY)
91
+ : []
92
+ }
93
+ const readState = () => {
94
+ const runtime = app.get('tuiStatus')
95
+ const settingsRuntime = app.get('tuiSettingsSections')
96
+ const settingsHost = settingsSectionsModule.getHostSettingsSections(settingsRuntime)
97
+ return {
98
+ pinkBinds: collectBinds(),
99
+ snapshot: statusModule.getHostStatusStore(runtime)?.getSnapshot(),
100
+ settingsSection: settingsHost?.list().find(section => section.ns === SETTINGS_NAMESPACE),
101
+ }
102
+ }
103
+
104
+ let state = readState()
105
+ const deadline = Date.now() + READY_TIMEOUT_MS
106
+ while (
107
+ (state.pinkBinds.length === 0 ||
108
+ !state.snapshot?.some?.(entry => entry.key === STATUS_CONTRIBUTION_KEY) ||
109
+ state.settingsSection === undefined) &&
110
+ Date.now() < deadline
111
+ ) {
112
+ await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
113
+ state = readState()
114
+ }
115
+
116
+ const { pinkBinds, snapshot, settingsSection } = state
117
+ assert.equal(
118
+ STATUS_CONTRIBUTION_KEY,
119
+ SETTINGS_NAMESPACE,
120
+ 'the status contribution key must stay equal to the settings namespace (src/pluginId.ts)',
121
+ )
122
+ assert.ok(pinkBinds.length > 0, `plugin must bind through the late status service within ${READY_TIMEOUT_MS}ms`)
123
+ assert.ok(
124
+ snapshot?.some?.(entry => entry.key === STATUS_CONTRIBUTION_KEY),
125
+ `status store must contain the plugin contribution within ${READY_TIMEOUT_MS}ms`,
126
+ )
127
+ assert.ok(
128
+ settingsSection,
129
+ `plugin must register its /settings section through the late settings service within ${READY_TIMEOUT_MS}ms`,
130
+ )
131
+ assertSettingsContract(assert, settingsSection)
132
+ console.log(`OK headless order: ${pinkBinds.length} ledger bind(s), status contribution, settings section`)
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Validate bundled themes against matching dsh-TUI source and runtime modules.
3
+ * DSH_TUI_SOURCE_ROOT must point at a dsh-TUI source checkout.
4
+ * DSH_TUI_ADAPTER_DIR must point at the matching lib/types/dsh-adapter directory.
5
+ * Set DSH_TUI_EXPECTED_VERSION only when an explicit release baseline needs
6
+ * to be pinned; ordinary development verifies the supplied host as-is.
7
+ *
8
+ * Run with: node --import tsx/esm scripts/validate-themes-against-host.mjs
9
+ */
10
+ import { existsSync, mkdtempSync, readFileSync, readdirSync } from 'node:fs'
11
+ import { tmpdir } from 'node:os'
12
+ import { join, resolve } from 'node:path'
13
+ import { fileURLToPath, pathToFileURL } from 'node:url'
14
+ import assert from 'node:assert/strict'
15
+ import ts from 'typescript'
16
+
17
+ const pluginRoot = fileURLToPath(new URL('..', import.meta.url))
18
+ const sourceRoot = process.env.DSH_TUI_SOURCE_ROOT
19
+ if (sourceRoot === undefined || sourceRoot === '') {
20
+ throw new Error('DSH_TUI_SOURCE_ROOT must point at a dsh-TUI source checkout for this host theme validation.')
21
+ }
22
+ const hostRoot = resolve(sourceRoot)
23
+ const customThemePath = join(hostRoot, 'src', 'customTheme.ts')
24
+ const themePath = join(hostRoot, 'src', 'theme.ts')
25
+
26
+ if (!existsSync(customThemePath) || !existsSync(themePath)) {
27
+ throw new Error(`dsh-TUI sources not found at ${hostRoot}`)
28
+ }
29
+
30
+ const hostPackagePath = join(hostRoot, 'package.json')
31
+ if (!existsSync(hostPackagePath)) {
32
+ throw new Error(`dsh-TUI package metadata not found at ${hostPackagePath}`)
33
+ }
34
+ const hostPackage = JSON.parse(readFileSync(hostPackagePath, 'utf8'))
35
+ assert.match(hostPackage.version, /^\d+\.\d+\.\d+(?:[-+].+)?$/u, 'host source must declare a version')
36
+
37
+ const adapter = process.env.DSH_TUI_ADAPTER_DIR
38
+ if (adapter === undefined || adapter === '') {
39
+ throw new Error('DSH_TUI_ADAPTER_DIR must point at dsh-TUI lib/types/dsh-adapter for this host theme validation.')
40
+ }
41
+ const adapterRoot = resolve(adapter)
42
+ const runtimeThemePath = join(adapterRoot, '..', 'theme.js')
43
+ const runtimeCustomThemePath = join(adapterRoot, '..', 'customTheme.js')
44
+ if (!existsSync(runtimeThemePath) || !existsSync(runtimeCustomThemePath)) {
45
+ throw new Error(`dsh-TUI compiled theme modules not found above adapter at ${adapterRoot}`)
46
+ }
47
+ const adapterPackagePath = join(adapterRoot, '..', '..', '..', 'package.json')
48
+ if (!existsSync(adapterPackagePath)) {
49
+ throw new Error(`dsh-TUI package metadata not found above adapter at ${adapterRoot}`)
50
+ }
51
+ const adapterPackage = JSON.parse(readFileSync(adapterPackagePath, 'utf8'))
52
+ assert.equal(
53
+ adapterPackage.version,
54
+ hostPackage.version,
55
+ `source ${hostPackage.version} and adapter ${adapterPackage.version} must be the same dsh-TUI version`,
56
+ )
57
+ const expectedVersion = process.env.DSH_TUI_EXPECTED_VERSION
58
+ if (expectedVersion !== undefined && expectedVersion !== '') {
59
+ assert.equal(hostPackage.version, expectedVersion, `expected dsh-TUI ${expectedVersion}, received ${hostPackage.version}`)
60
+ } else {
61
+ console.log(`* host source and adapter ${hostPackage.version} (no explicit version pin)`)
62
+ }
63
+
64
+ const sandboxHome = mkdtempSync(join(tmpdir(), 'pink-theme-host-validate-'))
65
+ process.env.USERPROFILE = sandboxHome
66
+ process.env.HOME = sandboxHome
67
+
68
+ const themesDir = join(pluginRoot, 'themes')
69
+ const { parseCustomTheme, buildTheme } = await import(pathToFileURL(runtimeCustomThemePath).href)
70
+ const { getTheme, isLightThemeActive, registerCustomThemeResolver } = await import(
71
+ pathToFileURL(runtimeThemePath).href,
72
+ )
73
+
74
+ function readThemeKeysFromSource(path) {
75
+ const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true)
76
+ const declaration = source.statements.find(
77
+ statement => ts.isTypeAliasDeclaration(statement) && statement.name.text === 'Theme',
78
+ )
79
+ assert.ok(declaration && ts.isTypeLiteralNode(declaration.type), 'host Theme must remain a type literal')
80
+ const keys = declaration.type.members.flatMap(member => {
81
+ if (!ts.isPropertySignature(member) || member.name === undefined) return []
82
+ if (ts.isIdentifier(member.name) || ts.isStringLiteral(member.name)) return [member.name.text]
83
+ return []
84
+ })
85
+ assert.ok(keys.length > 0, 'host Theme must declare at least one color key')
86
+ return keys
87
+ }
88
+
89
+ const allKeys = readThemeKeysFromSource(themePath)
90
+ assert.deepEqual(
91
+ [...Object.keys(getTheme('dark'))].sort(),
92
+ [...allKeys].sort(),
93
+ 'compiled theme keys must match the checked-out host source',
94
+ )
95
+ assert.ok(allKeys.length >= 90, 'host Theme key count drifted; re-check coverage')
96
+
97
+ const settingsKeys = [
98
+ 'promptBorder',
99
+ 'selectionBg',
100
+ 'permission',
101
+ 'suggestion',
102
+ 'success',
103
+ 'inactive',
104
+ 'subtle',
105
+ 'warning',
106
+ 'error',
107
+ ]
108
+
109
+ function parseColor(value) {
110
+ if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
111
+ const n = Number.parseInt(value.slice(1), 16)
112
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
113
+ }
114
+ const match = /^rgb\((\d+),(\d+),(\d+)\)$/.exec(value)
115
+ return match === null ? undefined : [Number(match[1]), Number(match[2]), Number(match[3])]
116
+ }
117
+
118
+ function luminance(color) {
119
+ const channel = value => {
120
+ const normalized = value / 255
121
+ return normalized <= 0.03928
122
+ ? normalized / 12.92
123
+ : ((normalized + 0.055) / 1.055) ** 2.4
124
+ }
125
+ return 0.2126 * channel(color[0]) + 0.7152 * channel(color[1]) + 0.0722 * channel(color[2])
126
+ }
127
+
128
+ function contrast(foreground, background) {
129
+ const [high, low] = [luminance(foreground), luminance(background)].sort((a, b) => b - a)
130
+ return (high + 0.05) / (low + 0.05)
131
+ }
132
+
133
+ const expectedLight = { 'pink-night': false, 'pink-day': true, 'pink-ansi': false }
134
+ const built = {}
135
+ registerCustomThemeResolver(name => built[name])
136
+
137
+ for (const file of readdirSync(themesDir).filter(name => name.endsWith('.json'))) {
138
+ const warnings = []
139
+ const originalWarn = console.warn
140
+ console.warn = message => warnings.push(String(message))
141
+ let spec
142
+ try {
143
+ spec = parseCustomTheme(readFileSync(join(themesDir, file), 'utf8'), file)
144
+ } finally {
145
+ console.warn = originalWarn
146
+ }
147
+
148
+ assert.notEqual(spec, undefined, `${file} must parse`)
149
+ assert.deepEqual(warnings, [], `${file} must produce zero host warnings`)
150
+ const missing = allKeys.filter(key => key !== 'userMessageBackground' && !(key in spec.colors))
151
+ assert.deepEqual(missing, [], `${file} must cover every Theme key`)
152
+ const missingSettingsKeys = settingsKeys.filter(key => !(key in spec.colors))
153
+ assert.deepEqual(
154
+ missingSettingsKeys,
155
+ [],
156
+ `${file} must cover every color used by the dsh-TUI settings cards and checkbox chips`,
157
+ )
158
+
159
+ const theme = buildTheme(spec)
160
+ built[spec.name] = theme
161
+ assert.equal(isLightThemeActive(spec.name), expectedLight[spec.name], `${spec.name} identity`)
162
+ }
163
+
164
+ for (const [name, light] of Object.entries(expectedLight)) {
165
+ assert.equal(isLightThemeActive(name), light, `${name} identity via resolver`)
166
+ }
167
+
168
+ // Backgrounds: the /settings section renders inside the session screen, so
169
+ // unfocused rows sit on the theme's terminal background while a focused row
170
+ // gets selectionBg (screens/Settings.tsx CardRow). success/inactive are the
171
+ // checkbox chip colors there ([✓] vs [ ]).
172
+ const cases = [
173
+ ['pink-night', 'text', '#1E1E1E', 4.5],
174
+ ['pink-night', 'claude', '#1E1E1E', 3.0],
175
+ ['pink-night', 'inactive', '#1E1E1E', 2.5],
176
+ ['pink-night', 'success', '#1E1E1E', 4.5],
177
+ ['pink-night', 'success', '#55303E', 3.0],
178
+ ['pink-night', 'inactive', '#55303E', 3.0],
179
+ ['pink-day', 'text', '#F6F3ED', 4.5],
180
+ ['pink-day', 'claude', '#F6F3ED', 3.0],
181
+ ['pink-day', 'inactive', '#F6F3ED', 2.5],
182
+ ['pink-day', 'success', '#F6F3ED', 3.0],
183
+ ['pink-day', 'success', '#F3D7E0', 2.5],
184
+ ['pink-day', 'inactive', '#F3D7E0', 2.5],
185
+ ]
186
+ // pink-ansi is intentionally absent from the contrast cases: every one of its
187
+ // colors is an `ansi:` palette token with no pinned RGB value, so no numeric
188
+ // ratio is assertable here — the host renders whatever the user's terminal
189
+ // palette defines. Its settings keys are still covered by the per-theme
190
+ // key-coverage assertions above.
191
+ for (const [theme, key, background, minimum] of cases) {
192
+ const foregroundRgb = parseColor(built[theme][key])
193
+ const backgroundRgb = parseColor(background)
194
+ assert.notEqual(foregroundRgb, undefined, `${theme}.${key} must be parseable`)
195
+ const ratio = contrast(foregroundRgb, backgroundRgb)
196
+ assert.ok(ratio >= minimum, `${theme}.${key} contrast ${ratio.toFixed(2)} >= ${minimum}`)
197
+ }
198
+
199
+ console.log(`OK host theme validation: ${Object.keys(built).length} themes, ${allKeys.length - 1} keys each, settings colors covered`)
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Release artifact contract: inspect npm's dry-run manifest without creating
3
+ * a tarball, then ensure consumers receive every advertised runtime asset.
4
+ */
5
+ import { execFileSync } from 'node:child_process'
6
+ import { existsSync, readFileSync } from 'node:fs'
7
+ import { fileURLToPath } from 'node:url'
8
+ import assert from 'node:assert/strict'
9
+
10
+ const pluginRoot = fileURLToPath(new URL('..', import.meta.url))
11
+ const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
12
+ const lockfile = JSON.parse(readFileSync(new URL('../package-lock.json', import.meta.url), 'utf8'))
13
+ const packCommand = process.platform === 'win32'
14
+ ? { file: 'cmd.exe', args: ['/d', '/s', '/c', 'npm pack --dry-run --json --ignore-scripts'] }
15
+ : { file: 'npm', args: ['pack', '--dry-run', '--json', '--ignore-scripts'] }
16
+ const manifest = JSON.parse(execFileSync(packCommand.file, packCommand.args, {
17
+ cwd: pluginRoot,
18
+ encoding: 'utf8',
19
+ }))
20
+ const packed = manifest[0]
21
+ assert.ok(packed, 'npm pack must report one package')
22
+ assert.equal(packed.name, 'dsh-tui-theme')
23
+ assert.equal(packed.version, packageJson.version)
24
+
25
+ const files = new Set(packed.files.map(entry => entry.path))
26
+ for (const required of [
27
+ 'package.json',
28
+ 'README.md',
29
+ 'LICENSE',
30
+ 'cordis.patch.yml',
31
+ 'lib/types/index.js',
32
+ 'lib/types/index.d.ts',
33
+ 'lib/types/autoTheme.js',
34
+ 'lib/types/autoTheme.d.ts',
35
+ 'lib/types/pluginId.js',
36
+ 'lib/types/pluginId.d.ts',
37
+ 'lib/types/settingsSection.js',
38
+ 'lib/types/settingsSection.d.ts',
39
+ 'lib/types/statusLine.js',
40
+ 'lib/types/statusLine.d.ts',
41
+ 'lib/types/themeAssets.js',
42
+ 'lib/types/themeAssets.d.ts',
43
+ 'themes/pink-night.json',
44
+ 'themes/pink-day.json',
45
+ 'themes/pink-ansi.json',
46
+ 'docs/screenshots/pink-day.png',
47
+ 'docs/screenshots/pink-night.png',
48
+ 'docs/screenshots/settings.png',
49
+ 'scripts/verify.mjs',
50
+ 'scripts/verify-package.mjs',
51
+ 'scripts/headless-order-test.mjs',
52
+ 'scripts/validate-themes-against-host.mjs',
53
+ 'scripts/expected-settings-contract.mjs',
54
+ ]) {
55
+ assert.ok(files.has(required), `published package must include ${required}`)
56
+ }
57
+
58
+ const exportedPaths = [packageJson.main, packageJson.types, packageJson.dsh.bundle.patch]
59
+ for (const entry of Object.values(packageJson.exports)) {
60
+ if (typeof entry === 'string') exportedPaths.push(entry)
61
+ else if (entry !== null && typeof entry === 'object') exportedPaths.push(...Object.values(entry))
62
+ }
63
+ for (const entry of exportedPaths) {
64
+ assert.equal(typeof entry, 'string')
65
+ assert.ok(files.has(entry.replace(/^\.\//, '')), `published package must include declared entry ${entry}`)
66
+ }
67
+
68
+ for (const name of Object.keys(packageJson.peerDependencies)) {
69
+ if (!name.startsWith('@deepseek-ai/')) continue
70
+ assert.equal(packageJson.dependencies?.[name], undefined, `${name} must not be a runtime dependency`)
71
+ assert.equal(packageJson.devDependencies?.[name], packageJson.peerDependencies[name], `${name} peer and dev ranges must match`)
72
+ }
73
+ assert.equal(packageJson.dependencies?.['@deepseek-ai/schemastery'], undefined)
74
+ const rootLock = lockfile.packages?.['']
75
+ assert.ok(rootLock, 'lockfile must have root metadata')
76
+ assert.equal(lockfile.version, packageJson.version)
77
+ assert.deepEqual(rootLock.devDependencies, packageJson.devDependencies)
78
+ assert.deepEqual(rootLock.peerDependencies, packageJson.peerDependencies)
79
+ assert.equal(rootLock.dependencies, undefined)
80
+ assert.equal(existsSync(new URL('../lib/types/index.js', import.meta.url)), true)
81
+
82
+ console.log(`✓ package manifest: ${packed.name}@${packed.version}, ${files.size} files`)