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.
@@ -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 — served by the warm pref cache (no disk hit
888
- // within the TTL), so even boundary renders stay off the filesystem.
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
- // Inside the TTL a pref rewrite is not yet visible and costs no read…
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.match(statusCalls.at(-1)[1], /1✦$/, 'the cached pink pref keeps the line visible')
905
- assert.equal(prefReads, 0, 'the TTL serves the stale-but-pink answer without I/O')
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
- // …after invalidation (TTL expiry in production) the next render re-reads.
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, 1, 'invalidation re-reads the pref exactly once')
911
- assert.equal(statusCalls.at(-1)[1], undefined, 'the non-pink pref hides the line')
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)`)
@@ -5,12 +5,12 @@
5
5
  "colors": {
6
6
  "autoAccept": "#8F6BAC",
7
7
  "bashBorder": "#C05C7E",
8
- "claude": "#D5517F",
8
+ "claude": "rgb(222,110,150)",
9
9
  "toolNameMutate": "#8A6A00",
10
10
  "toolNameExec": "#0E7A8A",
11
- "claudeShimmer": "#E879A0",
12
- "claudeBlue_FOR_SYSTEM_SPINNER": "#D5517F",
13
- "claudeBlueShimmer_FOR_SYSTEM_SPINNER": "#E879A0",
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",
@@ -5,12 +5,12 @@
5
5
  "colors": {
6
6
  "autoAccept": "#B598D9",
7
7
  "bashBorder": "#E08CA8",
8
- "claude": "#F27BA6",
8
+ "claude": "rgb(242,123,166)",
9
9
  "toolNameMutate": "#E5C07B",
10
10
  "toolNameExec": "#72C4CF",
11
- "claudeShimmer": "#F8AFC6",
12
- "claudeBlue_FOR_SYSTEM_SPINNER": "#F27BA6",
13
- "claudeBlueShimmer_FOR_SYSTEM_SPINNER": "#F8AFC6",
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",