dsh-tui-theme 0.6.1 → 0.7.1
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.
- package/README.md +23 -13
- package/cordis.patch.yml +2 -0
- package/docs/decisions/2026-09-12-pink-day-claude-family-lightening.md +49 -0
- package/lib/types/autoTheme.d.ts +3 -0
- package/lib/types/autoTheme.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +19 -2
- package/lib/types/ornament.d.ts +38 -0
- package/lib/types/ornament.d.ts.map +1 -0
- package/lib/types/ornament.js +88 -0
- package/lib/types/settingsSection.d.ts +9 -4
- package/lib/types/settingsSection.d.ts.map +1 -1
- package/lib/types/settingsSection.js +88 -7
- package/lib/types/shadowCleanup.d.ts +39 -0
- package/lib/types/shadowCleanup.d.ts.map +1 -0
- package/lib/types/shadowCleanup.js +122 -0
- package/lib/types/statusLine.d.ts +27 -11
- package/lib/types/statusLine.d.ts.map +1 -1
- package/lib/types/statusLine.js +181 -43
- package/lib/types/statusView.d.ts +82 -0
- package/lib/types/statusView.d.ts.map +1 -0
- package/lib/types/statusView.js +126 -0
- package/lib/types/themeAssets.d.ts +4 -0
- package/lib/types/themeAssets.d.ts.map +1 -1
- package/lib/types/themeAssets.js +8 -3
- package/package.json +42 -42
- package/scripts/expected-settings-contract.mjs +21 -1
- package/scripts/headless-order-test.mjs +33 -10
- package/scripts/runtime-themes-headless.mjs +160 -23
- package/scripts/validate-themes-against-host.mjs +33 -6
- package/scripts/verify-package.mjs +16 -7
- package/scripts/verify.mjs +377 -9
- package/themes/pink-day.json +4 -4
- package/themes/pink-night.json +4 -4
|
@@ -100,8 +100,18 @@ assert.deepEqual(
|
|
|
100
100
|
[...allKeys].sort(),
|
|
101
101
|
'compiled theme keys must match the checked-out host source',
|
|
102
102
|
)
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
// The absolute key floor from the 96-key era does not survive the 0.10.1
|
|
104
|
+
// semantic refactor (73 canonical keys); per-key coverage below is the real
|
|
105
|
+
// guarantee, so the count is reported instead of pinned to a hardcoded floor.
|
|
106
|
+
console.log(`* host Theme key count: ${allKeys.length} (coverage asserted per key)`)
|
|
107
|
+
|
|
108
|
+
// The color keys the host's /settings screen paints with (screens/Settings.tsx
|
|
109
|
+
// at the pinned baseline: promptBorder card frame, permission card title,
|
|
110
|
+
// selectionBg focused row, suggestion/error/success/inactive/subtle row text
|
|
111
|
+
// and checkbox chips, warning badges). Maintained by hand because the screen
|
|
112
|
+
// has no exported key manifest; revisit this list whenever a dsh-TUI release
|
|
113
|
+
// adds a Theme key to that screen — the per-key coverage assertion below then
|
|
114
|
+
// fails loudly instead of shipping an unreadable settings card.
|
|
105
115
|
const settingsKeys = [
|
|
106
116
|
'promptBorder',
|
|
107
117
|
'selectionBg',
|
|
@@ -185,7 +195,19 @@ const cases = [
|
|
|
185
195
|
['pink-night', 'success', '#55303E', 3.0],
|
|
186
196
|
['pink-night', 'inactive', '#55303E', 3.0],
|
|
187
197
|
['pink-day', 'text', '#F6F3ED', 4.5],
|
|
188
|
-
|
|
198
|
+
// claude drives the 5-row header display font and bold+underlined markdown
|
|
199
|
+
// headings; the sakura-day identity deliberately goes one notch lighter
|
|
200
|
+
// (#DE6E96), so the large-text floor here is 2.5 instead of 3.0. Body-text
|
|
201
|
+
// readability stays guarded by the 4.5 `text` case above.
|
|
202
|
+
['pink-day', 'claude', '#F6F3ED', 2.5],
|
|
203
|
+
// The DEEPSEEK pixel word's gradient end (claudeBlue_FOR_SYSTEM_SPINNER,
|
|
204
|
+
// 0.10.1 `activity`) was lightened one notch with the same sakura-day
|
|
205
|
+
// family move (#DE6E96 -> #E879A0), so the gradient reads as a fade. The
|
|
206
|
+
// measured 2.47 is accepted for this decorative gradient end and guarded
|
|
207
|
+
// here at 2.4 — the floor exists to catch further lightening, not to
|
|
208
|
+
// re-litigate the accepted 0.03 gap below the 2.5 large-text floor.
|
|
209
|
+
// Decision record: docs/decisions/2026-09-12-pink-day-claude-family-lightening.md
|
|
210
|
+
['pink-day', 'claudeBlue_FOR_SYSTEM_SPINNER', '#F6F3ED', 2.4],
|
|
189
211
|
['pink-day', 'inactive', '#F6F3ED', 2.5],
|
|
190
212
|
['pink-day', 'success', '#F6F3ED', 3.0],
|
|
191
213
|
['pink-day', 'success', '#F3D7E0', 2.5],
|
|
@@ -196,12 +218,17 @@ const cases = [
|
|
|
196
218
|
// ratio is assertable here — the host renders whatever the user's terminal
|
|
197
219
|
// palette defines. Its settings keys are still covered by the per-theme
|
|
198
220
|
// key-coverage assertions above.
|
|
221
|
+
// Hosts >= 0.10.1 resolve built themes to canonical keys, so era keys read
|
|
222
|
+
// their canonical names there; pre-refactor hosts keep the era keys.
|
|
223
|
+
const semanticKeys = 'accent' in built['pink-night']
|
|
224
|
+
const semanticAliases = { claude: 'accent', claudeBlue_FOR_SYSTEM_SPINNER: 'activity' }
|
|
199
225
|
for (const [theme, key, background, minimum] of cases) {
|
|
200
|
-
const
|
|
226
|
+
const probeKey = (semanticKeys && semanticAliases[key]) || key
|
|
227
|
+
const foregroundRgb = parseColor(built[theme][probeKey])
|
|
201
228
|
const backgroundRgb = parseColor(background)
|
|
202
|
-
assert.notEqual(foregroundRgb, undefined, `${theme}.${
|
|
229
|
+
assert.notEqual(foregroundRgb, undefined, `${theme}.${probeKey} must be parseable`)
|
|
203
230
|
const ratio = contrast(foregroundRgb, backgroundRgb)
|
|
204
|
-
assert.ok(ratio >= minimum, `${theme}.${
|
|
231
|
+
assert.ok(ratio >= minimum, `${theme}.${probeKey} contrast ${ratio.toFixed(2)} >= ${minimum}`)
|
|
205
232
|
}
|
|
206
233
|
|
|
207
234
|
console.log(`OK host theme validation: ${Object.keys(built).length} themes, ${allKeys.length - 1} keys each, settings colors covered`)
|
|
@@ -7,9 +7,14 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
8
|
import assert from 'node:assert/strict'
|
|
9
9
|
|
|
10
|
+
// This repo deliberately ships no package-lock.json: the pinned host tarball
|
|
11
|
+
// bundles @dsh-std/* packages whose manifests still declare workspace:*, and
|
|
12
|
+
// npm replays a committed lockfile faithfully — every install (npm ci too)
|
|
13
|
+
// dies with EUNSUPPORTEDPROTOCOL. Removing the lockfile makes npm skip the
|
|
14
|
+
// bundled directories instead. The peer/dev consistency checks below therefore
|
|
15
|
+
// read package.json only; CI's contract job guards the deletion (REVIEW.md G1).
|
|
10
16
|
const pluginRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
11
17
|
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
18
|
const packCommand = process.platform === 'win32'
|
|
14
19
|
? { file: 'cmd.exe', args: ['/d', '/s', '/c', 'npm pack --dry-run --json --ignore-scripts'] }
|
|
15
20
|
: { file: 'npm', args: ['pack', '--dry-run', '--json', '--ignore-scripts'] }
|
|
@@ -44,6 +49,12 @@ for (const required of [
|
|
|
44
49
|
'lib/types/runtimeThemes.d.ts',
|
|
45
50
|
'lib/types/toast.js',
|
|
46
51
|
'lib/types/toast.d.ts',
|
|
52
|
+
'lib/types/statusView.js',
|
|
53
|
+
'lib/types/statusView.d.ts',
|
|
54
|
+
'lib/types/ornament.js',
|
|
55
|
+
'lib/types/ornament.d.ts',
|
|
56
|
+
'lib/types/shadowCleanup.js',
|
|
57
|
+
'lib/types/shadowCleanup.d.ts',
|
|
47
58
|
'themes/pink-night.json',
|
|
48
59
|
'themes/pink-day.json',
|
|
49
60
|
'themes/pink-ansi.json',
|
|
@@ -84,12 +95,10 @@ for (const name of Object.keys(packageJson.peerDependencies)) {
|
|
|
84
95
|
assert.equal(devAccepted, true, `${name} dev pin must be accepted by its peer range`)
|
|
85
96
|
}
|
|
86
97
|
assert.equal(packageJson.dependencies?.['@deepseek-ai/schemastery'], undefined)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
assert.deepEqual(rootLock.peerDependencies, packageJson.peerDependencies)
|
|
92
|
-
assert.equal(rootLock.dependencies, undefined)
|
|
98
|
+
// The lockfile's root metadata is deliberately not compared here: this script
|
|
99
|
+
// also ships inside the published tarball (package.json `files`), and npm never
|
|
100
|
+
// packs a lockfile, so the check could not hold for a consumer. The repo-level
|
|
101
|
+
// lockfile invariant lives in the CI contract job instead (REVIEW.md G1).
|
|
93
102
|
assert.equal(existsSync(new URL('../lib/types/index.js', import.meta.url)), true)
|
|
94
103
|
|
|
95
104
|
console.log(`✓ package manifest: ${packed.name}@${packed.version}, ${files.size} files`)
|
package/scripts/verify.mjs
CHANGED
|
@@ -64,12 +64,14 @@ const applyAndSettle = async (ctx, config) => {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/** A stub Cordis-like context; every seam optional and recorded. */
|
|
67
|
-
function makeStubCtx({ status, sections, settingsService, themes, toast, deferThemes = false, deferToast = false } = {}) {
|
|
67
|
+
function makeStubCtx({ status, sections, settingsService, themes, toast, dialogs, deferThemes = false, deferToast = false, deferDialogs = false } = {}) {
|
|
68
68
|
const record = { handlers: new Map(), disposers: [], statusCalls: [], sectionsCalls: [], registerCalls: [], watchers: [], themeRegisters: [], warnings: [], infos: [] }
|
|
69
69
|
let availableThemes = themes
|
|
70
70
|
let availableToast = toast
|
|
71
|
+
let availableDialogs = dialogs
|
|
71
72
|
const deferredThemeCallbacks = []
|
|
72
73
|
const deferredToastCallbacks = []
|
|
74
|
+
const deferredDialogsCallbacks = []
|
|
73
75
|
const logger = { info: msg => record.infos.push(String(msg)), warn: msg => record.warnings.push(String(msg)), error: () => {} }
|
|
74
76
|
const base = {
|
|
75
77
|
logger,
|
|
@@ -78,6 +80,7 @@ function makeStubCtx({ status, sections, settingsService, themes, toast, deferTh
|
|
|
78
80
|
if (serviceName === 'tuiSettingsSections') return sections
|
|
79
81
|
if (serviceName === 'tuiThemes') return availableThemes
|
|
80
82
|
if (serviceName === 'tuiToast') return availableToast
|
|
83
|
+
if (serviceName === 'tuiDialogs') return availableDialogs
|
|
81
84
|
return undefined
|
|
82
85
|
},
|
|
83
86
|
on(event, handler) {
|
|
@@ -99,6 +102,7 @@ function makeStubCtx({ status, sections, settingsService, themes, toast, deferTh
|
|
|
99
102
|
tuiSettingsSections: sections,
|
|
100
103
|
tuiThemes: availableThemes,
|
|
101
104
|
tuiToast: availableToast,
|
|
105
|
+
tuiDialogs: availableDialogs,
|
|
102
106
|
}
|
|
103
107
|
if (deferThemes && availableThemes === undefined && deps.includes('tuiThemes')) {
|
|
104
108
|
deferredThemeCallbacks.push(callback)
|
|
@@ -108,6 +112,10 @@ function makeStubCtx({ status, sections, settingsService, themes, toast, deferTh
|
|
|
108
112
|
deferredToastCallbacks.push(callback)
|
|
109
113
|
return
|
|
110
114
|
}
|
|
115
|
+
if (deferDialogs && availableDialogs === undefined && deps.includes('tuiDialogs')) {
|
|
116
|
+
deferredDialogsCallbacks.push(callback)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
111
119
|
if (deps.every(dep => services[dep] !== undefined)) {
|
|
112
120
|
const props = Object.fromEntries(deps.map(dep => [dep, services[dep]]))
|
|
113
121
|
callback({ ...base, ...props })
|
|
@@ -122,10 +130,29 @@ function makeStubCtx({ status, sections, settingsService, themes, toast, deferTh
|
|
|
122
130
|
availableToast = service
|
|
123
131
|
for (const callback of deferredToastCallbacks.splice(0)) callback({ ...base, tuiToast: service })
|
|
124
132
|
}
|
|
133
|
+
record.activateDialogs = service => {
|
|
134
|
+
availableDialogs = service
|
|
135
|
+
for (const callback of deferredDialogsCallbacks.splice(0)) callback({ ...base, tuiDialogs: service })
|
|
136
|
+
}
|
|
125
137
|
return { ctx: base, record }
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
const fakeStatus = calls => ({ set(key, text) { calls.push([key, text]); return () => {} } })
|
|
141
|
+
/**
|
|
142
|
+
* A 0.10.1+ status service: registerView present. `refuse` simulates the
|
|
143
|
+
* host rejecting the registration (returns undefined, warning invisible);
|
|
144
|
+
* `throws` simulates a hostile service whose registration throws — the
|
|
145
|
+
* plugin must warn and fall back, never propagate.
|
|
146
|
+
*/
|
|
147
|
+
const fakeRichStatus = (calls, viewCalls, { refuse = false, throws = false } = {}) => ({
|
|
148
|
+
set(key, text) { calls.push([key, text]); return () => {} },
|
|
149
|
+
registerView(descriptor, identity) {
|
|
150
|
+
viewCalls.push([descriptor, identity])
|
|
151
|
+
if (throws) throw new Error('status view registry unavailable')
|
|
152
|
+
if (refuse) return undefined
|
|
153
|
+
return () => {}
|
|
154
|
+
},
|
|
155
|
+
})
|
|
129
156
|
const fakeSections = calls => ({ register(section) { calls.push(section); return () => {} } })
|
|
130
157
|
const fakeThemes = (record, { throws = false } = {}) => ({
|
|
131
158
|
register(descriptor, identity) {
|
|
@@ -155,6 +182,18 @@ const fakeToast = (deliveries, { dropFirst = 0 } = {}) => {
|
|
|
155
182
|
},
|
|
156
183
|
}
|
|
157
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* A tuiDialogs stub: records confirm requests, answers them from the test
|
|
187
|
+
* via the returned queue (each entry resolves one dialog).
|
|
188
|
+
*/
|
|
189
|
+
const fakeDialogs = (requests, answers) => ({
|
|
190
|
+
confirm(owner, request) {
|
|
191
|
+
requests.push({ owner, request })
|
|
192
|
+
return new Promise(resolve => {
|
|
193
|
+
answers.push(resolve)
|
|
194
|
+
})
|
|
195
|
+
},
|
|
196
|
+
})
|
|
158
197
|
|
|
159
198
|
const emit = (record, event, ...args) => {
|
|
160
199
|
for (const handler of record.handlers.get(event) ?? []) handler(...args)
|
|
@@ -611,6 +650,31 @@ const emit = (record, event, ...args) => {
|
|
|
611
650
|
console.log('✓ follow logging: baseline docs stay quiet, real toggles still log')
|
|
612
651
|
}
|
|
613
652
|
|
|
653
|
+
// ── 12c. unreadable bundled source: the directory-level failure stays precise ──
|
|
654
|
+
{
|
|
655
|
+
const targetDir = join(sandboxHome, 'source-fail-target')
|
|
656
|
+
const sourceDir = join(pluginRoot, 'themes')
|
|
657
|
+
const originalReaddir = builtinFs.readdirSync
|
|
658
|
+
try {
|
|
659
|
+
builtinFs.readdirSync = (path, ...rest) => {
|
|
660
|
+
if (String(path) === sourceDir) throw new Error('EACCES: source unreadable')
|
|
661
|
+
return originalReaddir(path, ...rest)
|
|
662
|
+
}
|
|
663
|
+
syncBuiltinESMExports()
|
|
664
|
+
const result = installBundledThemes(targetDir, sourceDir)
|
|
665
|
+
assert.deepEqual(result.failed, [], 'no per-file failure exists when nothing was attempted')
|
|
666
|
+
assert.match(
|
|
667
|
+
result.sourceError ?? '',
|
|
668
|
+
/could not read bundled themes from .*themes.*EACCES/,
|
|
669
|
+
'the directory-level error names the source and the cause',
|
|
670
|
+
)
|
|
671
|
+
} finally {
|
|
672
|
+
builtinFs.readdirSync = originalReaddir
|
|
673
|
+
syncBuiltinESMExports()
|
|
674
|
+
}
|
|
675
|
+
console.log('✓ unreadable bundled source: precise sourceError, no bogus per-file failure')
|
|
676
|
+
}
|
|
677
|
+
|
|
614
678
|
// ── 12. status injection: session handlers die with tuiStatus activation ────
|
|
615
679
|
{
|
|
616
680
|
const outerHandlers = new Map()
|
|
@@ -884,8 +948,9 @@ const emit = (record, event, ...args) => {
|
|
|
884
948
|
assert.equal(statusCalls.length, baselineCalls, 'firehose events render nothing')
|
|
885
949
|
assert.equal(prefReads, 0, 'firehose events never read the theme pref')
|
|
886
950
|
|
|
887
|
-
// A turn boundary renders —
|
|
888
|
-
//
|
|
951
|
+
// A turn boundary renders — the mtime gate costs one stat (not counted
|
|
952
|
+
// here), and the warm cache answers without a read, so even boundary
|
|
953
|
+
// renders stay off the read path.
|
|
889
954
|
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
890
955
|
assert.equal(statusCalls.length, baselineCalls + 1, 'a turn boundary renders')
|
|
891
956
|
assert.equal(prefReads, 0, 'the warm cache serves the boundary render')
|
|
@@ -897,18 +962,28 @@ const emit = (record, event, ...args) => {
|
|
|
897
962
|
assert.match(statusCalls.at(-1)[1], /0✦$/)
|
|
898
963
|
assert.equal(prefReads, 0)
|
|
899
964
|
|
|
900
|
-
//
|
|
965
|
+
// A pref rewrite (the host's /theme switch) is seen at the very next
|
|
966
|
+
// render: the stat notices the moved mtime and pays exactly one re-read,
|
|
967
|
+
// and the non-pink pref hides the line — no waiting out the TTL (the
|
|
968
|
+
// stale window used to show as wrong rich-view colors).
|
|
901
969
|
writeFileSync(themePrefPath, JSON.stringify({ theme: 'dark' }, null, 2))
|
|
902
970
|
emit(record, 'session/event', nextSession, { type: 'turn/end' })
|
|
903
971
|
assert.equal(statusCalls.length, baselineCalls + 3)
|
|
904
|
-
assert.
|
|
905
|
-
assert.equal(prefReads,
|
|
972
|
+
assert.equal(statusCalls.at(-1)[1], undefined, 'the pref rewrite is seen at the next render')
|
|
973
|
+
assert.equal(prefReads, 1, 'the moved mtime costs exactly one re-read')
|
|
974
|
+
|
|
975
|
+
// An unchanged pref keeps the cache warm: the render reruns, but the
|
|
976
|
+
// hidden line's text is unchanged so set() is deduplicated away, and
|
|
977
|
+
// no read happens.
|
|
978
|
+
emit(record, 'session/event', nextSession, { type: 'turn/end' })
|
|
979
|
+
assert.equal(statusCalls.length, baselineCalls + 3, 'an unchanged line does not rewrite the store')
|
|
980
|
+
assert.equal(prefReads, 1, 'the unchanged mtime serves the cache')
|
|
906
981
|
|
|
907
|
-
//
|
|
982
|
+
// Test-only invalidation (the TTL-expiry stand-in) re-reads too.
|
|
908
983
|
invalidateThemePrefCacheForTests()
|
|
909
984
|
emit(record, 'session/event', nextSession, { type: 'turn/end' })
|
|
910
|
-
assert.equal(prefReads,
|
|
911
|
-
assert.equal(statusCalls.at(-1)[1], undefined, 'the non-pink pref
|
|
985
|
+
assert.equal(prefReads, 2, 'invalidation re-reads the pref exactly once')
|
|
986
|
+
assert.equal(statusCalls.at(-1)[1], undefined, 'the non-pink pref keeps the line hidden')
|
|
912
987
|
} finally {
|
|
913
988
|
builtinFs.readFileSync = originalRead
|
|
914
989
|
syncBuiltinESMExports()
|
|
@@ -917,5 +992,298 @@ const emit = (record, event, ...args) => {
|
|
|
917
992
|
console.log('✓ hot path: firehose events render nothing and never read the pref; boundaries use the cache')
|
|
918
993
|
}
|
|
919
994
|
|
|
995
|
+
// ── 15. settings panel UX: groups, ornament drafts, follow format ───────────
|
|
996
|
+
{
|
|
997
|
+
const dataDir = join(sandboxHome, '.dsh-tui')
|
|
998
|
+
rmSync(dataDir, { recursive: true, force: true })
|
|
999
|
+
mkdirSync(dataDir, { recursive: true })
|
|
1000
|
+
writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
|
|
1001
|
+
invalidateThemePrefCacheForTests()
|
|
1002
|
+
const statusCalls = []
|
|
1003
|
+
const sectionsCalls = []
|
|
1004
|
+
const settingsRecord = { registerCalls: [], watchers: [] }
|
|
1005
|
+
const { ctx, record } = makeStubCtx({
|
|
1006
|
+
status: fakeStatus(statusCalls),
|
|
1007
|
+
sections: fakeSections(sectionsCalls),
|
|
1008
|
+
settingsService: fakeSettingsService(settingsRecord, {}),
|
|
1009
|
+
})
|
|
1010
|
+
await applyAndSettle(ctx)
|
|
1011
|
+
const section = sectionsCalls[0]
|
|
1012
|
+
const fieldByPath = path =>
|
|
1013
|
+
section.fields.find(field => JSON.stringify(field.path) === JSON.stringify(path))
|
|
1014
|
+
|
|
1015
|
+
// Navigation groups: two subpages, every field assigned.
|
|
1016
|
+
assert.deepEqual(
|
|
1017
|
+
section.groups.map(group => group.id).sort(),
|
|
1018
|
+
['follow', 'status-line'],
|
|
1019
|
+
)
|
|
1020
|
+
assert.equal(fieldByPath(['followSystem']).group, 'follow')
|
|
1021
|
+
for (const path of [['showGlyph'], ['statusGlyph'], ['showClock'], ['showTurns'], ['statusSeparator'], ['statusScope']]) {
|
|
1022
|
+
assert.equal(fieldByPath(path).group, 'status-line')
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Ornament draft gate: 1–2 display cells, control chars refused, empty
|
|
1026
|
+
// resets to the built-in default, unset displays the effective value.
|
|
1027
|
+
const glyph = fieldByPath(['statusGlyph'])
|
|
1028
|
+
assert.equal(glyph.kind, 'text')
|
|
1029
|
+
assert.deepEqual(glyph.parse(''), { kind: 'clear' })
|
|
1030
|
+
assert.deepEqual(glyph.parse('❀'), { kind: 'set', value: '❀' })
|
|
1031
|
+
assert.deepEqual(glyph.parse('樱'), { kind: 'set', value: '樱' }, 'a 2-cell CJK char is allowed')
|
|
1032
|
+
assert.equal(glyph.parse('abc'), undefined, '3 cells are rejected')
|
|
1033
|
+
assert.equal(glyph.parse('a\u0007b'), undefined, 'control characters are rejected')
|
|
1034
|
+
assert.deepEqual(glyph.parse(' \u00A0 '), { kind: 'clear' }, 'whitespace-only drafts reset to the default')
|
|
1035
|
+
assert.equal(glyph.format(undefined), '✿', 'unset shows the effective default')
|
|
1036
|
+
assert.equal(glyph.format('❀'), '❀')
|
|
1037
|
+
const separator = fieldByPath(['statusSeparator'])
|
|
1038
|
+
assert.equal(separator.kind, 'text')
|
|
1039
|
+
assert.deepEqual(separator.parse('✦'), { kind: 'set', value: '✦' })
|
|
1040
|
+
assert.equal(separator.format(undefined), '·')
|
|
1041
|
+
|
|
1042
|
+
// followSystem format surfaces the cached follow state the startup would
|
|
1043
|
+
// consult — the "toggled on, nothing happened" reason, on the surface.
|
|
1044
|
+
const follow = fieldByPath(['followSystem'])
|
|
1045
|
+
assert.equal(follow.format(undefined), 'off', 'the cordis layer default is off')
|
|
1046
|
+
assert.equal(follow.format(false), 'off')
|
|
1047
|
+
assert.equal(follow.format(true), 'on(无缓存,启动时不动)')
|
|
1048
|
+
writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: Date.UTC(2026, 8, 12) }))
|
|
1049
|
+
assert.match(follow.format(true), /^on(缓存: light · \d{4}-\d{2}-\d{2})$/)
|
|
1050
|
+
writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: false }))
|
|
1051
|
+
assert.equal(follow.format(true), 'on(缓存: dark)', 'a cache without a timestamp omits the date')
|
|
1052
|
+
|
|
1053
|
+
// A committed ornament edit lands live on the scalar line.
|
|
1054
|
+
for (const watcher of settingsRecord.watchers) {
|
|
1055
|
+
watcher({ statusGlyph: '❀', statusSeparator: '~' })
|
|
1056
|
+
}
|
|
1057
|
+
emit(record, 'session/event', { id: 'u1' }, { type: 'turn/end' })
|
|
1058
|
+
assert.match(statusCalls.at(-1)[1], /^❀ ~ \d{2}:\d{2} ~ 1✦$/)
|
|
1059
|
+
|
|
1060
|
+
// Hand-edited config layers bypass the draft gate; the render side
|
|
1061
|
+
// sanitizes anyway (control chars stripped, capped at 2 cells).
|
|
1062
|
+
for (const watcher of settingsRecord.watchers) {
|
|
1063
|
+
watcher({ statusGlyph: 'x\u0007yz', statusSeparator: '~' })
|
|
1064
|
+
}
|
|
1065
|
+
emit(record, 'session/event', { id: 'u1' }, { type: 'turn/end' })
|
|
1066
|
+
assert.match(statusCalls.at(-1)[1], /^xy ~ /, 'control chars stripped and the rest capped at 2 cells')
|
|
1067
|
+
console.log('✓ settings panel: two groups, ornament drafts gated, follow format shows the cache state')
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// ── 16. rich status view: themed one-row view on registerView-capable hosts ─
|
|
1071
|
+
{
|
|
1072
|
+
const themePrefPath = join(sandboxHome, '.dsh-tui', 'theme.json')
|
|
1073
|
+
writeFileSync(themePrefPath, JSON.stringify({ theme: 'pink-night' }, null, 2))
|
|
1074
|
+
invalidateThemePrefCacheForTests()
|
|
1075
|
+
const statusCalls = []
|
|
1076
|
+
const viewCalls = []
|
|
1077
|
+
const settingsRecord = { registerCalls: [], watchers: [] }
|
|
1078
|
+
const { ctx, record } = makeStubCtx({
|
|
1079
|
+
status: fakeRichStatus(statusCalls, viewCalls),
|
|
1080
|
+
sections: fakeSections([]),
|
|
1081
|
+
settingsService: fakeSettingsService(settingsRecord, {}),
|
|
1082
|
+
})
|
|
1083
|
+
await applyAndSettle(ctx)
|
|
1084
|
+
|
|
1085
|
+
assert.equal(viewCalls.length, 1, 'the rich view is registered exactly once')
|
|
1086
|
+
assert.equal(statusCalls.length, 0, 'the rich path never writes scalar text')
|
|
1087
|
+
const [descriptor, identity] = viewCalls[0]
|
|
1088
|
+
assert.equal(descriptor.key, 'dsh-tui-theme', 'the rich view keeps the shared contribution key')
|
|
1089
|
+
assert.equal(descriptor.maxRows, 1)
|
|
1090
|
+
assert.equal(typeof descriptor.component, 'function')
|
|
1091
|
+
assert.equal(identity?.tuiStatus !== undefined, true, 'identity is the inject-scoped context')
|
|
1092
|
+
|
|
1093
|
+
// Render the component against a minimal fake of the host kit: the element
|
|
1094
|
+
// tree must map the snapshot to themed Text cells.
|
|
1095
|
+
const renderComponent = () =>
|
|
1096
|
+
descriptor.component({
|
|
1097
|
+
React: {
|
|
1098
|
+
createElement: (type, props, ...children) => ({ type, props, children: children.length === 1 && Array.isArray(children[0]) ? children[0] : children }),
|
|
1099
|
+
useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
|
|
1100
|
+
},
|
|
1101
|
+
ui: { Box: 'box', Text: 'text' },
|
|
1102
|
+
})
|
|
1103
|
+
const element = renderComponent()
|
|
1104
|
+
assert.equal(element.type, 'box', 'one row Box')
|
|
1105
|
+
assert.equal(element.children[0].type, 'text')
|
|
1106
|
+
assert.equal(element.children[0].props.color, 'rgb(242,123,166)', 'the glyph uses the brand key of the active palette')
|
|
1107
|
+
assert.equal(element.children[0].children[0], '✿')
|
|
1108
|
+
assert.equal(element.children[1].props.color, '#77646D', 'the separator uses the subtle key')
|
|
1109
|
+
assert.match(element.children[2].children[0], /^\d{2}:\d{2}$/)
|
|
1110
|
+
assert.equal(element.children[2].props.color, '#C4B0B9', 'the clock uses the bottom-bar inactiveShimmer key')
|
|
1111
|
+
|
|
1112
|
+
// Turn boundaries push through the store and land in the next render.
|
|
1113
|
+
const session = { id: 'r1' }
|
|
1114
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
1115
|
+
const withTurns = renderComponent()
|
|
1116
|
+
assert.equal(withTurns.children.at(-1).children[0], '1✦')
|
|
1117
|
+
assert.equal(withTurns.children.at(-1).props.color, '#C4B0B9')
|
|
1118
|
+
|
|
1119
|
+
// A /theme switch (the host rewrites the pref) lands at the very next
|
|
1120
|
+
// render: rewrite the pref to pink-day and the palette moves with it,
|
|
1121
|
+
// without any TTL wait.
|
|
1122
|
+
writeFileSync(themePrefPath, JSON.stringify({ theme: 'pink-day' }, null, 2))
|
|
1123
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
1124
|
+
const dayView = renderComponent()
|
|
1125
|
+
assert.equal(dayView.children[0].props.color, 'rgb(222,110,150)', 'the glyph follows the new palette')
|
|
1126
|
+
assert.equal(dayView.children[2].props.color, '#9E6E82', 'the clock follows the new palette immediately')
|
|
1127
|
+
|
|
1128
|
+
// Toggles fold into the snapshot: all off renders nothing (no scalar
|
|
1129
|
+
// fallback either — the rich view just shows nothing).
|
|
1130
|
+
for (const watcher of settingsRecord.watchers) {
|
|
1131
|
+
watcher({ showGlyph: false, showClock: false, showTurns: false })
|
|
1132
|
+
}
|
|
1133
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
1134
|
+
assert.equal(renderComponent(), null, 'all toggles off render nothing')
|
|
1135
|
+
|
|
1136
|
+
// Master switch off: registered but never visible.
|
|
1137
|
+
const quietCalls = []
|
|
1138
|
+
const quietViews = []
|
|
1139
|
+
const quietCtx = makeStubCtx({ status: fakeRichStatus(quietCalls, quietViews) })
|
|
1140
|
+
await applyAndSettle(quietCtx.ctx, { statusEnabled: false })
|
|
1141
|
+
assert.equal(quietViews.length, 1, 'the view is still registered')
|
|
1142
|
+
assert.equal(
|
|
1143
|
+
quietViews[0][0].component({
|
|
1144
|
+
React: {
|
|
1145
|
+
createElement: (type, props, ...children) => ({ type, props, children: children.length === 1 && Array.isArray(children[0]) ? children[0] : children }),
|
|
1146
|
+
useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
|
|
1147
|
+
},
|
|
1148
|
+
ui: { Box: 'box', Text: 'text' },
|
|
1149
|
+
}),
|
|
1150
|
+
null,
|
|
1151
|
+
'statusEnabled=false renders nothing',
|
|
1152
|
+
)
|
|
1153
|
+
console.log('✓ rich status view: themed cells from the palette, mutual exclusion from set()')
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// ── 17. refused rich registration falls back to the scalar path ─────────────
|
|
1157
|
+
{
|
|
1158
|
+
const statusCalls = []
|
|
1159
|
+
const viewCalls = []
|
|
1160
|
+
const { ctx } = makeStubCtx({ status: fakeRichStatus(statusCalls, viewCalls, { refuse: true }) })
|
|
1161
|
+
await applyAndSettle(ctx)
|
|
1162
|
+
assert.equal(viewCalls.length, 1, 'the registration was attempted')
|
|
1163
|
+
assert.equal(statusCalls.length > 0, true, 'a refused registration (undefined) falls back to set()')
|
|
1164
|
+
console.log('✓ refused rich registration: the scalar path keeps the line alive')
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// ── 17b. hostile rich registration throws: warn, fall back, never propagate ─
|
|
1168
|
+
{
|
|
1169
|
+
const statusCalls = []
|
|
1170
|
+
const viewCalls = []
|
|
1171
|
+
const { ctx, record } = makeStubCtx({ status: fakeRichStatus(statusCalls, viewCalls, { throws: true }) })
|
|
1172
|
+
// applyAndSettle resolving at all is the non-propagation half of the
|
|
1173
|
+
// invariant: a synchronous throw escaping the inject callback would land here.
|
|
1174
|
+
await applyAndSettle(ctx)
|
|
1175
|
+
assert.equal(viewCalls.length, 1, 'the registration was attempted')
|
|
1176
|
+
assert.equal(statusCalls.length > 0, true, 'a throwing registration falls back to set()')
|
|
1177
|
+
assert.equal(
|
|
1178
|
+
record.warnings.some(msg => msg.includes('rich status view registration failed')),
|
|
1179
|
+
true,
|
|
1180
|
+
'the failure is warned, never propagated',
|
|
1181
|
+
)
|
|
1182
|
+
assert.equal(
|
|
1183
|
+
(record.handlers.get('session/event') ?? []).length >= 1,
|
|
1184
|
+
true,
|
|
1185
|
+
'the session wiring survived the throw',
|
|
1186
|
+
)
|
|
1187
|
+
console.log('✓ hostile rich registration: warn + scalar fallback, session wiring intact')
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// ── 18. shadow cleanup dialog: one confirm, byte-checked deletion ───────────
|
|
1191
|
+
{
|
|
1192
|
+
const seedShadow = () => {
|
|
1193
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
1194
|
+
mkdirSync(sandboxThemes, { recursive: true })
|
|
1195
|
+
for (const theme of ['pink-night', 'pink-day', 'pink-ansi']) {
|
|
1196
|
+
writeFileSync(join(sandboxThemes, `${theme}.json`), readFileSync(join(pluginRoot, 'themes', `${theme}.json`), 'utf8'))
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// 18a. Dialog answered with "delete": the byte-identical copies are
|
|
1201
|
+
// removed and the result is toasted.
|
|
1202
|
+
seedShadow()
|
|
1203
|
+
const requests = []
|
|
1204
|
+
const answers = []
|
|
1205
|
+
const cleanDeliveries = []
|
|
1206
|
+
const cleanContext = makeStubCtx({
|
|
1207
|
+
deferThemes: true,
|
|
1208
|
+
toast: fakeToast(cleanDeliveries),
|
|
1209
|
+
dialogs: fakeDialogs(requests, answers),
|
|
1210
|
+
})
|
|
1211
|
+
apply(cleanContext.ctx)
|
|
1212
|
+
cleanContext.record.activateThemes(fakeThemes(cleanContext.record))
|
|
1213
|
+
await settle()
|
|
1214
|
+
assert.equal(requests.length, 1, 'exactly one confirm dialog is offered')
|
|
1215
|
+
assert.equal(requests[0].owner?.tuiDialogs !== undefined, true, 'the owner is the inject-scoped context')
|
|
1216
|
+
for (const file of ['pink-night.json', 'pink-day.json', 'pink-ansi.json']) {
|
|
1217
|
+
assert.ok(requests[0].request.message.includes(file), `the dialog names ${file}`)
|
|
1218
|
+
}
|
|
1219
|
+
assert.ok(requests[0].request.title.includes('旧主题文件'))
|
|
1220
|
+
answers[0](true)
|
|
1221
|
+
await settle()
|
|
1222
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-night.json')), false, 'confirmation removes the shadowing copy')
|
|
1223
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-day.json')), false, 'confirmation removes the shadowing copy')
|
|
1224
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-ansi.json')), false, 'confirmation removes the shadowing copy')
|
|
1225
|
+
const cleanToast = cleanDeliveries.find(delivery => delivery[0].includes('已清理'))
|
|
1226
|
+
assert.ok(cleanToast, 'the cleanup result is toasted')
|
|
1227
|
+
assert.equal(cleanToast[1], 'success')
|
|
1228
|
+
|
|
1229
|
+
// 18b. Declining keeps the previous behavior exactly: files stay, no
|
|
1230
|
+
// cleanup toast.
|
|
1231
|
+
seedShadow()
|
|
1232
|
+
const declineRequests = []
|
|
1233
|
+
const declineAnswers = []
|
|
1234
|
+
const declineDeliveries = []
|
|
1235
|
+
const declineContext = makeStubCtx({
|
|
1236
|
+
deferThemes: true,
|
|
1237
|
+
toast: fakeToast(declineDeliveries),
|
|
1238
|
+
dialogs: fakeDialogs(declineRequests, declineAnswers),
|
|
1239
|
+
})
|
|
1240
|
+
apply(declineContext.ctx)
|
|
1241
|
+
declineContext.record.activateThemes(fakeThemes(declineContext.record))
|
|
1242
|
+
await settle()
|
|
1243
|
+
assert.equal(declineRequests.length, 1, 'the dialog is offered again for the new activation')
|
|
1244
|
+
declineAnswers[0](false)
|
|
1245
|
+
await settle()
|
|
1246
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-night.json')), true, 'declining keeps the file')
|
|
1247
|
+
assert.equal(
|
|
1248
|
+
declineDeliveries.some(delivery => delivery[0].includes('已清理')),
|
|
1249
|
+
false,
|
|
1250
|
+
'no cleanup toast without a confirmation',
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
// 18c. The dialogs seam arriving late still asks exactly once; a file the
|
|
1254
|
+
// user edited between detection and confirmation is never deleted.
|
|
1255
|
+
seedShadow()
|
|
1256
|
+
writeFileSync(join(sandboxThemes, 'pink-day.json'), '{ "name": "pink-day", "colors": { "text": "#123456" } }')
|
|
1257
|
+
const lateRequests = []
|
|
1258
|
+
const lateAnswers = []
|
|
1259
|
+
const lateContext = makeStubCtx({ deferThemes: true, deferDialogs: true, toast: fakeToast([]) })
|
|
1260
|
+
apply(lateContext.ctx)
|
|
1261
|
+
lateContext.record.activateThemes(fakeThemes(lateContext.record))
|
|
1262
|
+
await settle()
|
|
1263
|
+
assert.equal(lateRequests.length, 0, 'no dialog before the seam arrives')
|
|
1264
|
+
lateContext.record.activateDialogs(fakeDialogs(lateRequests, lateAnswers))
|
|
1265
|
+
await settle()
|
|
1266
|
+
assert.equal(lateRequests.length, 1, 'the parked offer fires when the seam arrives')
|
|
1267
|
+
lateAnswers[0](true)
|
|
1268
|
+
await settle()
|
|
1269
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-night.json')), false, 'the still-identical file is removed')
|
|
1270
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-day.json')), true, 'the edited file survives the byte check')
|
|
1271
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-ansi.json')), false)
|
|
1272
|
+
|
|
1273
|
+
// 18d. A hostile dialogs service must not take the activation down.
|
|
1274
|
+
seedShadow()
|
|
1275
|
+
const hostileContext = makeStubCtx({
|
|
1276
|
+
deferThemes: true,
|
|
1277
|
+
dialogs: { confirm() { throw new Error('dialog machinery broken') } },
|
|
1278
|
+
})
|
|
1279
|
+
apply(hostileContext.ctx)
|
|
1280
|
+
hostileContext.record.activateThemes(fakeThemes(hostileContext.record))
|
|
1281
|
+
await settle()
|
|
1282
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-night.json')), true, 'a failed dialog deletes nothing')
|
|
1283
|
+
|
|
1284
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
1285
|
+
console.log('✓ shadow cleanup dialog: offered once, byte-checked deletion, decline/late/hostile all safe')
|
|
1286
|
+
}
|
|
1287
|
+
|
|
920
1288
|
console.log('\nAll plugin verifications passed.')
|
|
921
1289
|
console.log(`(sandbox used: ${sandboxHome} — the real home was never touched)`)
|
package/themes/pink-day.json
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
"colors": {
|
|
6
6
|
"autoAccept": "#8F6BAC",
|
|
7
7
|
"bashBorder": "#C05C7E",
|
|
8
|
-
"claude": "
|
|
8
|
+
"claude": "rgb(222,110,150)",
|
|
9
9
|
"toolNameMutate": "#8A6A00",
|
|
10
10
|
"toolNameExec": "#0E7A8A",
|
|
11
|
-
"claudeShimmer": "
|
|
12
|
-
"claudeBlue_FOR_SYSTEM_SPINNER": "
|
|
13
|
-
"claudeBlueShimmer_FOR_SYSTEM_SPINNER": "
|
|
11
|
+
"claudeShimmer": "rgb(242,160,191)",
|
|
12
|
+
"claudeBlue_FOR_SYSTEM_SPINNER": "rgb(232,121,160)",
|
|
13
|
+
"claudeBlueShimmer_FOR_SYSTEM_SPINNER": "rgb(242,160,191)",
|
|
14
14
|
"permission": "#D5517F",
|
|
15
15
|
"permissionShimmer": "#E879A0",
|
|
16
16
|
"planMode": "#43916B",
|
package/themes/pink-night.json
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
"colors": {
|
|
6
6
|
"autoAccept": "#B598D9",
|
|
7
7
|
"bashBorder": "#E08CA8",
|
|
8
|
-
"claude": "
|
|
8
|
+
"claude": "rgb(242,123,166)",
|
|
9
9
|
"toolNameMutate": "#E5C07B",
|
|
10
10
|
"toolNameExec": "#72C4CF",
|
|
11
|
-
"claudeShimmer": "
|
|
12
|
-
"claudeBlue_FOR_SYSTEM_SPINNER": "
|
|
13
|
-
"claudeBlueShimmer_FOR_SYSTEM_SPINNER": "
|
|
11
|
+
"claudeShimmer": "rgb(248,175,198)",
|
|
12
|
+
"claudeBlue_FOR_SYSTEM_SPINNER": "rgb(242,123,166)",
|
|
13
|
+
"claudeBlueShimmer_FOR_SYSTEM_SPINNER": "rgb(248,175,198)",
|
|
14
14
|
"permission": "#F2A0BF",
|
|
15
15
|
"permissionShimmer": "#F8C1D6",
|
|
16
16
|
"planMode": "#7FB596",
|