dsh-tui-theme 0.6.1 → 0.7.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.
- package/README.md +17 -12
- package/cordis.patch.yml +2 -0
- package/docs/decisions/2026-09-12-pink-day-claude-family-lightening.md +49 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +16 -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 +175 -41
- 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/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 +85 -23
- package/scripts/validate-themes-against-host.mjs +25 -5
- package/scripts/verify-package.mjs +6 -0
- package/scripts/verify.mjs +352 -9
- package/themes/pink-day.json +4 -4
- package/themes/pink-night.json +4 -4
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)
|
|
@@ -884,8 +923,9 @@ const emit = (record, event, ...args) => {
|
|
|
884
923
|
assert.equal(statusCalls.length, baselineCalls, 'firehose events render nothing')
|
|
885
924
|
assert.equal(prefReads, 0, 'firehose events never read the theme pref')
|
|
886
925
|
|
|
887
|
-
// A turn boundary renders —
|
|
888
|
-
//
|
|
926
|
+
// A turn boundary renders — the mtime gate costs one stat (not counted
|
|
927
|
+
// here), and the warm cache answers without a read, so even boundary
|
|
928
|
+
// renders stay off the read path.
|
|
889
929
|
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
890
930
|
assert.equal(statusCalls.length, baselineCalls + 1, 'a turn boundary renders')
|
|
891
931
|
assert.equal(prefReads, 0, 'the warm cache serves the boundary render')
|
|
@@ -897,18 +937,28 @@ const emit = (record, event, ...args) => {
|
|
|
897
937
|
assert.match(statusCalls.at(-1)[1], /0✦$/)
|
|
898
938
|
assert.equal(prefReads, 0)
|
|
899
939
|
|
|
900
|
-
//
|
|
940
|
+
// A pref rewrite (the host's /theme switch) is seen at the very next
|
|
941
|
+
// render: the stat notices the moved mtime and pays exactly one re-read,
|
|
942
|
+
// and the non-pink pref hides the line — no waiting out the TTL (the
|
|
943
|
+
// stale window used to show as wrong rich-view colors).
|
|
901
944
|
writeFileSync(themePrefPath, JSON.stringify({ theme: 'dark' }, null, 2))
|
|
902
945
|
emit(record, 'session/event', nextSession, { type: 'turn/end' })
|
|
903
946
|
assert.equal(statusCalls.length, baselineCalls + 3)
|
|
904
|
-
assert.
|
|
905
|
-
assert.equal(prefReads,
|
|
947
|
+
assert.equal(statusCalls.at(-1)[1], undefined, 'the pref rewrite is seen at the next render')
|
|
948
|
+
assert.equal(prefReads, 1, 'the moved mtime costs exactly one re-read')
|
|
906
949
|
|
|
907
|
-
//
|
|
950
|
+
// An unchanged pref keeps the cache warm: the render reruns, but the
|
|
951
|
+
// hidden line's text is unchanged so set() is deduplicated away, and
|
|
952
|
+
// no read happens.
|
|
953
|
+
emit(record, 'session/event', nextSession, { type: 'turn/end' })
|
|
954
|
+
assert.equal(statusCalls.length, baselineCalls + 3, 'an unchanged line does not rewrite the store')
|
|
955
|
+
assert.equal(prefReads, 1, 'the unchanged mtime serves the cache')
|
|
956
|
+
|
|
957
|
+
// Test-only invalidation (the TTL-expiry stand-in) re-reads too.
|
|
908
958
|
invalidateThemePrefCacheForTests()
|
|
909
959
|
emit(record, 'session/event', nextSession, { type: 'turn/end' })
|
|
910
|
-
assert.equal(prefReads,
|
|
911
|
-
assert.equal(statusCalls.at(-1)[1], undefined, 'the non-pink pref
|
|
960
|
+
assert.equal(prefReads, 2, 'invalidation re-reads the pref exactly once')
|
|
961
|
+
assert.equal(statusCalls.at(-1)[1], undefined, 'the non-pink pref keeps the line hidden')
|
|
912
962
|
} finally {
|
|
913
963
|
builtinFs.readFileSync = originalRead
|
|
914
964
|
syncBuiltinESMExports()
|
|
@@ -917,5 +967,298 @@ const emit = (record, event, ...args) => {
|
|
|
917
967
|
console.log('✓ hot path: firehose events render nothing and never read the pref; boundaries use the cache')
|
|
918
968
|
}
|
|
919
969
|
|
|
970
|
+
// ── 15. settings panel UX: groups, ornament drafts, follow format ───────────
|
|
971
|
+
{
|
|
972
|
+
const dataDir = join(sandboxHome, '.dsh-tui')
|
|
973
|
+
rmSync(dataDir, { recursive: true, force: true })
|
|
974
|
+
mkdirSync(dataDir, { recursive: true })
|
|
975
|
+
writeFileSync(join(dataDir, 'theme.json'), JSON.stringify({ theme: 'pink-night' }, null, 2))
|
|
976
|
+
invalidateThemePrefCacheForTests()
|
|
977
|
+
const statusCalls = []
|
|
978
|
+
const sectionsCalls = []
|
|
979
|
+
const settingsRecord = { registerCalls: [], watchers: [] }
|
|
980
|
+
const { ctx, record } = makeStubCtx({
|
|
981
|
+
status: fakeStatus(statusCalls),
|
|
982
|
+
sections: fakeSections(sectionsCalls),
|
|
983
|
+
settingsService: fakeSettingsService(settingsRecord, {}),
|
|
984
|
+
})
|
|
985
|
+
await applyAndSettle(ctx)
|
|
986
|
+
const section = sectionsCalls[0]
|
|
987
|
+
const fieldByPath = path =>
|
|
988
|
+
section.fields.find(field => JSON.stringify(field.path) === JSON.stringify(path))
|
|
989
|
+
|
|
990
|
+
// Navigation groups: two subpages, every field assigned.
|
|
991
|
+
assert.deepEqual(
|
|
992
|
+
section.groups.map(group => group.id).sort(),
|
|
993
|
+
['follow', 'status-line'],
|
|
994
|
+
)
|
|
995
|
+
assert.equal(fieldByPath(['followSystem']).group, 'follow')
|
|
996
|
+
for (const path of [['showGlyph'], ['statusGlyph'], ['showClock'], ['showTurns'], ['statusSeparator'], ['statusScope']]) {
|
|
997
|
+
assert.equal(fieldByPath(path).group, 'status-line')
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// Ornament draft gate: 1–2 display cells, control chars refused, empty
|
|
1001
|
+
// resets to the built-in default, unset displays the effective value.
|
|
1002
|
+
const glyph = fieldByPath(['statusGlyph'])
|
|
1003
|
+
assert.equal(glyph.kind, 'text')
|
|
1004
|
+
assert.deepEqual(glyph.parse(''), { kind: 'clear' })
|
|
1005
|
+
assert.deepEqual(glyph.parse('❀'), { kind: 'set', value: '❀' })
|
|
1006
|
+
assert.deepEqual(glyph.parse('樱'), { kind: 'set', value: '樱' }, 'a 2-cell CJK char is allowed')
|
|
1007
|
+
assert.equal(glyph.parse('abc'), undefined, '3 cells are rejected')
|
|
1008
|
+
assert.equal(glyph.parse('a\u0007b'), undefined, 'control characters are rejected')
|
|
1009
|
+
assert.deepEqual(glyph.parse(' \u00A0 '), { kind: 'clear' }, 'whitespace-only drafts reset to the default')
|
|
1010
|
+
assert.equal(glyph.format(undefined), '✿', 'unset shows the effective default')
|
|
1011
|
+
assert.equal(glyph.format('❀'), '❀')
|
|
1012
|
+
const separator = fieldByPath(['statusSeparator'])
|
|
1013
|
+
assert.equal(separator.kind, 'text')
|
|
1014
|
+
assert.deepEqual(separator.parse('✦'), { kind: 'set', value: '✦' })
|
|
1015
|
+
assert.equal(separator.format(undefined), '·')
|
|
1016
|
+
|
|
1017
|
+
// followSystem format surfaces the cached follow state the startup would
|
|
1018
|
+
// consult — the "toggled on, nothing happened" reason, on the surface.
|
|
1019
|
+
const follow = fieldByPath(['followSystem'])
|
|
1020
|
+
assert.equal(follow.format(undefined), 'off', 'the cordis layer default is off')
|
|
1021
|
+
assert.equal(follow.format(false), 'off')
|
|
1022
|
+
assert.equal(follow.format(true), 'on(无缓存,启动时不动)')
|
|
1023
|
+
writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: true, at: Date.UTC(2026, 8, 12) }))
|
|
1024
|
+
assert.match(follow.format(true), /^on(缓存: light · \d{4}-\d{2}-\d{2})$/)
|
|
1025
|
+
writeFileSync(join(dataDir, 'theme-follow.json'), JSON.stringify({ light: false }))
|
|
1026
|
+
assert.equal(follow.format(true), 'on(缓存: dark)', 'a cache without a timestamp omits the date')
|
|
1027
|
+
|
|
1028
|
+
// A committed ornament edit lands live on the scalar line.
|
|
1029
|
+
for (const watcher of settingsRecord.watchers) {
|
|
1030
|
+
watcher({ statusGlyph: '❀', statusSeparator: '~' })
|
|
1031
|
+
}
|
|
1032
|
+
emit(record, 'session/event', { id: 'u1' }, { type: 'turn/end' })
|
|
1033
|
+
assert.match(statusCalls.at(-1)[1], /^❀ ~ \d{2}:\d{2} ~ 1✦$/)
|
|
1034
|
+
|
|
1035
|
+
// Hand-edited config layers bypass the draft gate; the render side
|
|
1036
|
+
// sanitizes anyway (control chars stripped, capped at 2 cells).
|
|
1037
|
+
for (const watcher of settingsRecord.watchers) {
|
|
1038
|
+
watcher({ statusGlyph: 'x\u0007yz', statusSeparator: '~' })
|
|
1039
|
+
}
|
|
1040
|
+
emit(record, 'session/event', { id: 'u1' }, { type: 'turn/end' })
|
|
1041
|
+
assert.match(statusCalls.at(-1)[1], /^xy ~ /, 'control chars stripped and the rest capped at 2 cells')
|
|
1042
|
+
console.log('✓ settings panel: two groups, ornament drafts gated, follow format shows the cache state')
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// ── 16. rich status view: themed one-row view on registerView-capable hosts ─
|
|
1046
|
+
{
|
|
1047
|
+
const themePrefPath = join(sandboxHome, '.dsh-tui', 'theme.json')
|
|
1048
|
+
writeFileSync(themePrefPath, JSON.stringify({ theme: 'pink-night' }, null, 2))
|
|
1049
|
+
invalidateThemePrefCacheForTests()
|
|
1050
|
+
const statusCalls = []
|
|
1051
|
+
const viewCalls = []
|
|
1052
|
+
const settingsRecord = { registerCalls: [], watchers: [] }
|
|
1053
|
+
const { ctx, record } = makeStubCtx({
|
|
1054
|
+
status: fakeRichStatus(statusCalls, viewCalls),
|
|
1055
|
+
sections: fakeSections([]),
|
|
1056
|
+
settingsService: fakeSettingsService(settingsRecord, {}),
|
|
1057
|
+
})
|
|
1058
|
+
await applyAndSettle(ctx)
|
|
1059
|
+
|
|
1060
|
+
assert.equal(viewCalls.length, 1, 'the rich view is registered exactly once')
|
|
1061
|
+
assert.equal(statusCalls.length, 0, 'the rich path never writes scalar text')
|
|
1062
|
+
const [descriptor, identity] = viewCalls[0]
|
|
1063
|
+
assert.equal(descriptor.key, 'dsh-tui-theme', 'the rich view keeps the shared contribution key')
|
|
1064
|
+
assert.equal(descriptor.maxRows, 1)
|
|
1065
|
+
assert.equal(typeof descriptor.component, 'function')
|
|
1066
|
+
assert.equal(identity?.tuiStatus !== undefined, true, 'identity is the inject-scoped context')
|
|
1067
|
+
|
|
1068
|
+
// Render the component against a minimal fake of the host kit: the element
|
|
1069
|
+
// tree must map the snapshot to themed Text cells.
|
|
1070
|
+
const renderComponent = () =>
|
|
1071
|
+
descriptor.component({
|
|
1072
|
+
React: {
|
|
1073
|
+
createElement: (type, props, ...children) => ({ type, props, children: children.length === 1 && Array.isArray(children[0]) ? children[0] : children }),
|
|
1074
|
+
useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
|
|
1075
|
+
},
|
|
1076
|
+
ui: { Box: 'box', Text: 'text' },
|
|
1077
|
+
})
|
|
1078
|
+
const element = renderComponent()
|
|
1079
|
+
assert.equal(element.type, 'box', 'one row Box')
|
|
1080
|
+
assert.equal(element.children[0].type, 'text')
|
|
1081
|
+
assert.equal(element.children[0].props.color, 'rgb(242,123,166)', 'the glyph uses the brand key of the active palette')
|
|
1082
|
+
assert.equal(element.children[0].children[0], '✿')
|
|
1083
|
+
assert.equal(element.children[1].props.color, '#77646D', 'the separator uses the subtle key')
|
|
1084
|
+
assert.match(element.children[2].children[0], /^\d{2}:\d{2}$/)
|
|
1085
|
+
assert.equal(element.children[2].props.color, '#C4B0B9', 'the clock uses the bottom-bar inactiveShimmer key')
|
|
1086
|
+
|
|
1087
|
+
// Turn boundaries push through the store and land in the next render.
|
|
1088
|
+
const session = { id: 'r1' }
|
|
1089
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
1090
|
+
const withTurns = renderComponent()
|
|
1091
|
+
assert.equal(withTurns.children.at(-1).children[0], '1✦')
|
|
1092
|
+
assert.equal(withTurns.children.at(-1).props.color, '#C4B0B9')
|
|
1093
|
+
|
|
1094
|
+
// A /theme switch (the host rewrites the pref) lands at the very next
|
|
1095
|
+
// render: rewrite the pref to pink-day and the palette moves with it,
|
|
1096
|
+
// without any TTL wait.
|
|
1097
|
+
writeFileSync(themePrefPath, JSON.stringify({ theme: 'pink-day' }, null, 2))
|
|
1098
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
1099
|
+
const dayView = renderComponent()
|
|
1100
|
+
assert.equal(dayView.children[0].props.color, 'rgb(222,110,150)', 'the glyph follows the new palette')
|
|
1101
|
+
assert.equal(dayView.children[2].props.color, '#9E6E82', 'the clock follows the new palette immediately')
|
|
1102
|
+
|
|
1103
|
+
// Toggles fold into the snapshot: all off renders nothing (no scalar
|
|
1104
|
+
// fallback either — the rich view just shows nothing).
|
|
1105
|
+
for (const watcher of settingsRecord.watchers) {
|
|
1106
|
+
watcher({ showGlyph: false, showClock: false, showTurns: false })
|
|
1107
|
+
}
|
|
1108
|
+
emit(record, 'session/event', session, { type: 'turn/end' })
|
|
1109
|
+
assert.equal(renderComponent(), null, 'all toggles off render nothing')
|
|
1110
|
+
|
|
1111
|
+
// Master switch off: registered but never visible.
|
|
1112
|
+
const quietCalls = []
|
|
1113
|
+
const quietViews = []
|
|
1114
|
+
const quietCtx = makeStubCtx({ status: fakeRichStatus(quietCalls, quietViews) })
|
|
1115
|
+
await applyAndSettle(quietCtx.ctx, { statusEnabled: false })
|
|
1116
|
+
assert.equal(quietViews.length, 1, 'the view is still registered')
|
|
1117
|
+
assert.equal(
|
|
1118
|
+
quietViews[0][0].component({
|
|
1119
|
+
React: {
|
|
1120
|
+
createElement: (type, props, ...children) => ({ type, props, children: children.length === 1 && Array.isArray(children[0]) ? children[0] : children }),
|
|
1121
|
+
useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
|
|
1122
|
+
},
|
|
1123
|
+
ui: { Box: 'box', Text: 'text' },
|
|
1124
|
+
}),
|
|
1125
|
+
null,
|
|
1126
|
+
'statusEnabled=false renders nothing',
|
|
1127
|
+
)
|
|
1128
|
+
console.log('✓ rich status view: themed cells from the palette, mutual exclusion from set()')
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// ── 17. refused rich registration falls back to the scalar path ─────────────
|
|
1132
|
+
{
|
|
1133
|
+
const statusCalls = []
|
|
1134
|
+
const viewCalls = []
|
|
1135
|
+
const { ctx } = makeStubCtx({ status: fakeRichStatus(statusCalls, viewCalls, { refuse: true }) })
|
|
1136
|
+
await applyAndSettle(ctx)
|
|
1137
|
+
assert.equal(viewCalls.length, 1, 'the registration was attempted')
|
|
1138
|
+
assert.equal(statusCalls.length > 0, true, 'a refused registration (undefined) falls back to set()')
|
|
1139
|
+
console.log('✓ refused rich registration: the scalar path keeps the line alive')
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// ── 17b. hostile rich registration throws: warn, fall back, never propagate ─
|
|
1143
|
+
{
|
|
1144
|
+
const statusCalls = []
|
|
1145
|
+
const viewCalls = []
|
|
1146
|
+
const { ctx, record } = makeStubCtx({ status: fakeRichStatus(statusCalls, viewCalls, { throws: true }) })
|
|
1147
|
+
// applyAndSettle resolving at all is the non-propagation half of the
|
|
1148
|
+
// invariant: a synchronous throw escaping the inject callback would land here.
|
|
1149
|
+
await applyAndSettle(ctx)
|
|
1150
|
+
assert.equal(viewCalls.length, 1, 'the registration was attempted')
|
|
1151
|
+
assert.equal(statusCalls.length > 0, true, 'a throwing registration falls back to set()')
|
|
1152
|
+
assert.equal(
|
|
1153
|
+
record.warnings.some(msg => msg.includes('rich status view registration failed')),
|
|
1154
|
+
true,
|
|
1155
|
+
'the failure is warned, never propagated',
|
|
1156
|
+
)
|
|
1157
|
+
assert.equal(
|
|
1158
|
+
(record.handlers.get('session/event') ?? []).length >= 1,
|
|
1159
|
+
true,
|
|
1160
|
+
'the session wiring survived the throw',
|
|
1161
|
+
)
|
|
1162
|
+
console.log('✓ hostile rich registration: warn + scalar fallback, session wiring intact')
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// ── 18. shadow cleanup dialog: one confirm, byte-checked deletion ───────────
|
|
1166
|
+
{
|
|
1167
|
+
const seedShadow = () => {
|
|
1168
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
1169
|
+
mkdirSync(sandboxThemes, { recursive: true })
|
|
1170
|
+
for (const theme of ['pink-night', 'pink-day', 'pink-ansi']) {
|
|
1171
|
+
writeFileSync(join(sandboxThemes, `${theme}.json`), readFileSync(join(pluginRoot, 'themes', `${theme}.json`), 'utf8'))
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// 18a. Dialog answered with "delete": the byte-identical copies are
|
|
1176
|
+
// removed and the result is toasted.
|
|
1177
|
+
seedShadow()
|
|
1178
|
+
const requests = []
|
|
1179
|
+
const answers = []
|
|
1180
|
+
const cleanDeliveries = []
|
|
1181
|
+
const cleanContext = makeStubCtx({
|
|
1182
|
+
deferThemes: true,
|
|
1183
|
+
toast: fakeToast(cleanDeliveries),
|
|
1184
|
+
dialogs: fakeDialogs(requests, answers),
|
|
1185
|
+
})
|
|
1186
|
+
apply(cleanContext.ctx)
|
|
1187
|
+
cleanContext.record.activateThemes(fakeThemes(cleanContext.record))
|
|
1188
|
+
await settle()
|
|
1189
|
+
assert.equal(requests.length, 1, 'exactly one confirm dialog is offered')
|
|
1190
|
+
assert.equal(requests[0].owner?.tuiDialogs !== undefined, true, 'the owner is the inject-scoped context')
|
|
1191
|
+
for (const file of ['pink-night.json', 'pink-day.json', 'pink-ansi.json']) {
|
|
1192
|
+
assert.ok(requests[0].request.message.includes(file), `the dialog names ${file}`)
|
|
1193
|
+
}
|
|
1194
|
+
assert.ok(requests[0].request.title.includes('旧主题文件'))
|
|
1195
|
+
answers[0](true)
|
|
1196
|
+
await settle()
|
|
1197
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-night.json')), false, 'confirmation removes the shadowing copy')
|
|
1198
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-day.json')), false, 'confirmation removes the shadowing copy')
|
|
1199
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-ansi.json')), false, 'confirmation removes the shadowing copy')
|
|
1200
|
+
const cleanToast = cleanDeliveries.find(delivery => delivery[0].includes('已清理'))
|
|
1201
|
+
assert.ok(cleanToast, 'the cleanup result is toasted')
|
|
1202
|
+
assert.equal(cleanToast[1], 'success')
|
|
1203
|
+
|
|
1204
|
+
// 18b. Declining keeps the previous behavior exactly: files stay, no
|
|
1205
|
+
// cleanup toast.
|
|
1206
|
+
seedShadow()
|
|
1207
|
+
const declineRequests = []
|
|
1208
|
+
const declineAnswers = []
|
|
1209
|
+
const declineDeliveries = []
|
|
1210
|
+
const declineContext = makeStubCtx({
|
|
1211
|
+
deferThemes: true,
|
|
1212
|
+
toast: fakeToast(declineDeliveries),
|
|
1213
|
+
dialogs: fakeDialogs(declineRequests, declineAnswers),
|
|
1214
|
+
})
|
|
1215
|
+
apply(declineContext.ctx)
|
|
1216
|
+
declineContext.record.activateThemes(fakeThemes(declineContext.record))
|
|
1217
|
+
await settle()
|
|
1218
|
+
assert.equal(declineRequests.length, 1, 'the dialog is offered again for the new activation')
|
|
1219
|
+
declineAnswers[0](false)
|
|
1220
|
+
await settle()
|
|
1221
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-night.json')), true, 'declining keeps the file')
|
|
1222
|
+
assert.equal(
|
|
1223
|
+
declineDeliveries.some(delivery => delivery[0].includes('已清理')),
|
|
1224
|
+
false,
|
|
1225
|
+
'no cleanup toast without a confirmation',
|
|
1226
|
+
)
|
|
1227
|
+
|
|
1228
|
+
// 18c. The dialogs seam arriving late still asks exactly once; a file the
|
|
1229
|
+
// user edited between detection and confirmation is never deleted.
|
|
1230
|
+
seedShadow()
|
|
1231
|
+
writeFileSync(join(sandboxThemes, 'pink-day.json'), '{ "name": "pink-day", "colors": { "text": "#123456" } }')
|
|
1232
|
+
const lateRequests = []
|
|
1233
|
+
const lateAnswers = []
|
|
1234
|
+
const lateContext = makeStubCtx({ deferThemes: true, deferDialogs: true, toast: fakeToast([]) })
|
|
1235
|
+
apply(lateContext.ctx)
|
|
1236
|
+
lateContext.record.activateThemes(fakeThemes(lateContext.record))
|
|
1237
|
+
await settle()
|
|
1238
|
+
assert.equal(lateRequests.length, 0, 'no dialog before the seam arrives')
|
|
1239
|
+
lateContext.record.activateDialogs(fakeDialogs(lateRequests, lateAnswers))
|
|
1240
|
+
await settle()
|
|
1241
|
+
assert.equal(lateRequests.length, 1, 'the parked offer fires when the seam arrives')
|
|
1242
|
+
lateAnswers[0](true)
|
|
1243
|
+
await settle()
|
|
1244
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-night.json')), false, 'the still-identical file is removed')
|
|
1245
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-day.json')), true, 'the edited file survives the byte check')
|
|
1246
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-ansi.json')), false)
|
|
1247
|
+
|
|
1248
|
+
// 18d. A hostile dialogs service must not take the activation down.
|
|
1249
|
+
seedShadow()
|
|
1250
|
+
const hostileContext = makeStubCtx({
|
|
1251
|
+
deferThemes: true,
|
|
1252
|
+
dialogs: { confirm() { throw new Error('dialog machinery broken') } },
|
|
1253
|
+
})
|
|
1254
|
+
apply(hostileContext.ctx)
|
|
1255
|
+
hostileContext.record.activateThemes(fakeThemes(hostileContext.record))
|
|
1256
|
+
await settle()
|
|
1257
|
+
assert.equal(existsSync(join(sandboxThemes, 'pink-night.json')), true, 'a failed dialog deletes nothing')
|
|
1258
|
+
|
|
1259
|
+
rmSync(sandboxThemes, { recursive: true, force: true })
|
|
1260
|
+
console.log('✓ shadow cleanup dialog: offered once, byte-checked deletion, decline/late/hostile all safe')
|
|
1261
|
+
}
|
|
1262
|
+
|
|
920
1263
|
console.log('\nAll plugin verifications passed.')
|
|
921
1264
|
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",
|