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.
@@ -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
- assert.ok(allKeys.length >= 90, 'host Theme key count drifted; re-check coverage')
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
- ['pink-day', 'claude', '#F6F3ED', 3.0],
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 foregroundRgb = parseColor(built[theme][key])
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}.${key} must be parseable`)
229
+ assert.notEqual(foregroundRgb, undefined, `${theme}.${probeKey} must be parseable`)
203
230
  const ratio = contrast(foregroundRgb, backgroundRgb)
204
- assert.ok(ratio >= minimum, `${theme}.${key} contrast ${ratio.toFixed(2)} >= ${minimum}`)
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
- const rootLock = lockfile.packages?.['']
88
- assert.ok(rootLock, 'lockfile must have root metadata')
89
- assert.equal(lockfile.version, packageJson.version)
90
- assert.deepEqual(rootLock.devDependencies, packageJson.devDependencies)
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`)
@@ -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 — served by the warm pref cache (no disk hit
888
- // within the TTL), so even boundary renders stay off the filesystem.
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
- // Inside the TTL a pref rewrite is not yet visible and costs no read…
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.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')
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
- // …after invalidation (TTL expiry in production) the next render re-reads.
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, 1, 'invalidation re-reads the pref exactly once')
911
- assert.equal(statusCalls.at(-1)[1], undefined, 'the non-pink pref hides the line')
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)`)
@@ -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",