dsh-tui-theme 0.6.0 → 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.
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Rich status view (dsh-TUI >= 0.10.1 seam: `tuiStatus.registerView`).
3
+ *
4
+ * A compact one-row host React component that renders the blossom line with
5
+ * colors taken from the active pink palette — the one thing the scalar
6
+ * `tuiStatus.set` path can never do (the host renders set() text uncolored +
7
+ * terminal dim).
8
+ *
9
+ * Data channel: the component subscribes to a tiny plugin-side external
10
+ * store via the host React's useSyncExternalStore, exactly the "owns its
11
+ * live data via an external store" pattern the host seam documents. The
12
+ * plugin pushes scalar-only snapshots (turn boundaries, the clock tick,
13
+ * settings edits); the component maps one snapshot to themed Text cells and
14
+ * never touches the filesystem, timers, or events itself — it runs on the
15
+ * host's render thread and must stay a pure function of its snapshot.
16
+ *
17
+ * Host kit rules honored here: the component is created with the host React
18
+ * instance handed in via props (single-React rule — the plugin never imports
19
+ * react), and `Box`/`Text` receive only layout/color props (no focus,
20
+ * keyboard, wheel, or ref props; no pointer handlers either — the line is
21
+ * pure display).
22
+ * @module dsh-tui-theme/statusView
23
+ */
24
+ export const NO_COLORS = Object.freeze({
25
+ glyph: undefined,
26
+ text: undefined,
27
+ separator: undefined,
28
+ });
29
+ function sameSnapshot(a, b) {
30
+ return (a.visible === b.visible &&
31
+ a.glyph === b.glyph &&
32
+ a.clock === b.clock &&
33
+ a.turns === b.turns &&
34
+ a.separator === b.separator &&
35
+ a.colors.glyph === b.colors.glyph &&
36
+ a.colors.text === b.colors.text &&
37
+ a.colors.separator === b.colors.separator);
38
+ }
39
+ export function createStatusStore() {
40
+ const listeners = new Set();
41
+ let current = Object.freeze({
42
+ visible: false,
43
+ glyph: undefined,
44
+ clock: undefined,
45
+ turns: undefined,
46
+ separator: '·',
47
+ colors: NO_COLORS,
48
+ });
49
+ return {
50
+ getSnapshot: () => current,
51
+ subscribe(listener) {
52
+ listeners.add(listener);
53
+ return () => {
54
+ listeners.delete(listener);
55
+ };
56
+ },
57
+ push(next) {
58
+ if (sameSnapshot(current, next))
59
+ return;
60
+ current = Object.freeze({
61
+ visible: next.visible,
62
+ glyph: next.glyph,
63
+ clock: next.clock,
64
+ turns: next.turns,
65
+ separator: next.separator,
66
+ colors: Object.freeze({
67
+ glyph: next.colors.glyph,
68
+ text: next.colors.text,
69
+ separator: next.colors.separator,
70
+ }),
71
+ });
72
+ for (const listener of [...listeners]) {
73
+ try {
74
+ listener();
75
+ }
76
+ catch {
77
+ // One broken listener must not starve the others.
78
+ }
79
+ }
80
+ },
81
+ clear() {
82
+ listeners.clear();
83
+ },
84
+ };
85
+ }
86
+ /**
87
+ * Build the rich view component bound to one store. The returned function is
88
+ * a host React component: it receives `{ React, ui }` from the host on every
89
+ * render and maps the current snapshot to a one-row themed Box.
90
+ */
91
+ export function createStatusViewComponent(store) {
92
+ return props => {
93
+ const { React, ui } = props;
94
+ const snapshot = React.useSyncExternalStore(store.subscribe, store.getSnapshot);
95
+ if (!snapshot.visible)
96
+ return null;
97
+ const cells = [];
98
+ if (snapshot.glyph !== undefined) {
99
+ cells.push({ text: snapshot.glyph, color: snapshot.colors.glyph });
100
+ }
101
+ if (snapshot.clock !== undefined) {
102
+ cells.push({ text: snapshot.clock, color: snapshot.colors.text });
103
+ }
104
+ if (snapshot.turns !== undefined) {
105
+ cells.push({ text: snapshot.turns, color: snapshot.colors.text });
106
+ }
107
+ if (cells.length === 0)
108
+ return null;
109
+ const children = [];
110
+ for (const [index, cell] of cells.entries()) {
111
+ if (index > 0) {
112
+ children.push(React.createElement(ui.Text, { key: `sep-${index}`, color: snapshot.colors.separator }, ` ${snapshot.separator} `));
113
+ }
114
+ children.push(React.createElement(ui.Text, { key: `cell-${index}`, color: cell.color }, cell.text));
115
+ }
116
+ return React.createElement(ui.Box, { flexDirection: 'row' }, children);
117
+ };
118
+ }
119
+ /**
120
+ * The registration descriptor for the rich view. The caller passes the same
121
+ * contribution key the scalar path uses, so the effect-ledger resource id
122
+ * and the headless order-test pin remain stable across both paths.
123
+ */
124
+ export function statusViewDescriptor(key, component) {
125
+ return { key, maxRows: 1, component };
126
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tui-theme",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Sakura-pink themes for dsh-TUI with optional cached background follow (pink-day/pink-night), a blossom status line, and a /settings section. No shortcuts, no commands.",
5
5
  "type": "module",
6
6
  "main": "lib/types/index.js",
@@ -58,57 +58,57 @@
58
58
  },
59
59
  "peerDependencies": {
60
60
  "@deepseek-ai/cordis": "^4.0.1",
61
- "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2",
62
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2",
61
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || 0.1.3-alpha.2 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.2-alpha.3 || 0.1.3-alpha.2 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1",
62
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || 0.1.3-alpha.2 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.2-alpha.3 || 0.1.3-alpha.2 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1",
63
63
  "@deepseek-ai/schemastery": "^3.18.1"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@deepseek-ai/cordis": "^4.0.1",
67
- "@deepseek-ai/dsh-session": "0.1.2-alpha.2",
68
- "@deepseek-ai/dsh-settings": "0.1.2-alpha.2",
67
+ "@deepseek-ai/dsh-session": "0.1.2-alpha.3",
68
+ "@deepseek-ai/dsh-settings": "0.1.2-alpha.3",
69
69
  "@deepseek-ai/schemastery": "^3.18.1",
70
- "@deepseek-harness-tui/dsh-tui": "0.10.0-beta.4",
70
+ "@deepseek-harness-tui/dsh-tui": "0.10.1",
71
71
  "@types/node": "^22.0.0",
72
72
  "tsx": "^4.23.12",
73
73
  "typescript": "^6.0.3",
74
- "@deepseek-ai/dsh-llm": "0.1.2-alpha.2",
75
- "@deepseek-ai/dsh-agent": "0.1.2-alpha.2",
76
- "@deepseek-ai/dsh-user-questions": "0.1.2-alpha.2",
77
- "@deepseek-ai/dsh-user-approval": "0.1.2-alpha.2",
78
- "@deepseek-ai/dsh-commands": "0.1.2-alpha.2",
79
- "@deepseek-ai/dsh-atomic-write": "0.1.2-alpha.2",
80
- "@deepseek-ai/dsh-tool-ask-user": "0.1.2-alpha.2",
81
- "@deepseek-ai/dsh-skill": "0.1.2-alpha.2",
82
- "@deepseek-ai/dsh-agent-instructions": "0.1.2-alpha.2",
83
- "@deepseek-ai/dsh-tools": "0.1.2-alpha.2",
84
- "@deepseek-ai/dsh-system-prompt": "0.1.2-alpha.2",
85
- "@deepseek-ai/dsh-invariants": "0.1.2-alpha.2"
74
+ "@deepseek-ai/dsh-llm": "0.1.2-alpha.3",
75
+ "@deepseek-ai/dsh-agent": "0.1.2-alpha.3",
76
+ "@deepseek-ai/dsh-user-questions": "0.1.2-alpha.3",
77
+ "@deepseek-ai/dsh-user-approval": "0.1.2-alpha.3",
78
+ "@deepseek-ai/dsh-commands": "0.1.2-alpha.3",
79
+ "@deepseek-ai/dsh-atomic-write": "0.1.2-alpha.3",
80
+ "@deepseek-ai/dsh-tool-ask-user": "0.1.2-alpha.3",
81
+ "@deepseek-ai/dsh-skill": "0.1.2-alpha.3",
82
+ "@deepseek-ai/dsh-agent-instructions": "0.1.2-alpha.3",
83
+ "@deepseek-ai/dsh-tools": "0.1.2-alpha.3",
84
+ "@deepseek-ai/dsh-system-prompt": "0.1.2-alpha.3",
85
+ "@deepseek-ai/dsh-invariants": "0.1.2-alpha.3"
86
86
  },
87
87
  "overrides": {
88
- "@deepseek-ai/dsh-session": "0.1.2-alpha.2",
89
- "@deepseek-ai/dsh-settings": "0.1.2-alpha.2",
90
- "@deepseek-ai/dsh-llm": "0.1.2-alpha.2",
91
- "@deepseek-ai/dsh-agent": "0.1.2-alpha.2",
92
- "@deepseek-ai/dsh-user-questions": "0.1.2-alpha.2",
93
- "@deepseek-ai/dsh-user-approval": "0.1.2-alpha.2",
94
- "@deepseek-ai/dsh-commands": "0.1.2-alpha.2",
95
- "@deepseek-ai/dsh-atomic-write": "0.1.2-alpha.2",
96
- "@deepseek-ai/dsh-tool-ask-user": "0.1.2-alpha.2",
97
- "@deepseek-ai/dsh-skill": "0.1.2-alpha.2",
98
- "@deepseek-ai/dsh-agent-instructions": "0.1.2-alpha.2",
99
- "@deepseek-ai/dsh-tools": "0.1.2-alpha.2",
100
- "@deepseek-ai/dsh-system-prompt": "0.1.2-alpha.2",
101
- "@deepseek-ai/dsh-invariants": "0.1.2-alpha.2",
102
- "@deepseek-ai/dsh-scope": "0.1.2-alpha.2",
103
- "@deepseek-ai/dsh-attachment": "0.1.2-alpha.2",
104
- "@deepseek-ai/dsh-brand": "0.1.2-alpha.2",
105
- "@deepseek-ai/dsh-typert-protocol": "0.1.2-alpha.2",
106
- "@deepseek-ai/dsh-timeout": "0.1.2-alpha.2",
107
- "@deepseek-ai/dsh-session-projection": "0.1.2-alpha.2",
108
- "@deepseek-ai/dsh-code-runtime": "0.1.2-alpha.2",
109
- "@deepseek-ai/dsh-fs": "0.1.2-alpha.2",
110
- "@deepseek-ai/dsh-home-paths": "0.1.2-alpha.2",
111
- "@deepseek-ai/dsh-sandbox": "0.1.2-alpha.2"
88
+ "@deepseek-ai/dsh-session": "0.1.2-alpha.3",
89
+ "@deepseek-ai/dsh-settings": "0.1.2-alpha.3",
90
+ "@deepseek-ai/dsh-llm": "0.1.2-alpha.3",
91
+ "@deepseek-ai/dsh-agent": "0.1.2-alpha.3",
92
+ "@deepseek-ai/dsh-user-questions": "0.1.2-alpha.3",
93
+ "@deepseek-ai/dsh-user-approval": "0.1.2-alpha.3",
94
+ "@deepseek-ai/dsh-commands": "0.1.2-alpha.3",
95
+ "@deepseek-ai/dsh-atomic-write": "0.1.2-alpha.3",
96
+ "@deepseek-ai/dsh-tool-ask-user": "0.1.2-alpha.3",
97
+ "@deepseek-ai/dsh-skill": "0.1.2-alpha.3",
98
+ "@deepseek-ai/dsh-agent-instructions": "0.1.2-alpha.3",
99
+ "@deepseek-ai/dsh-tools": "0.1.2-alpha.3",
100
+ "@deepseek-ai/dsh-system-prompt": "0.1.2-alpha.3",
101
+ "@deepseek-ai/dsh-invariants": "0.1.2-alpha.3",
102
+ "@deepseek-ai/dsh-scope": "0.1.2-alpha.3",
103
+ "@deepseek-ai/dsh-attachment": "0.1.2-alpha.3",
104
+ "@deepseek-ai/dsh-brand": "0.1.2-alpha.3",
105
+ "@deepseek-ai/dsh-typert-protocol": "0.1.2-alpha.3",
106
+ "@deepseek-ai/dsh-timeout": "0.1.2-alpha.3",
107
+ "@deepseek-ai/dsh-session-projection": "0.1.2-alpha.3",
108
+ "@deepseek-ai/dsh-code-runtime": "0.1.2-alpha.3",
109
+ "@deepseek-ai/dsh-fs": "0.1.2-alpha.3",
110
+ "@deepseek-ai/dsh-home-paths": "0.1.2-alpha.3",
111
+ "@deepseek-ai/dsh-sandbox": "0.1.2-alpha.3"
112
112
  },
113
113
  "allowScripts": {
114
114
  "esbuild@0.28.2": true
@@ -25,13 +25,18 @@ export const SETTINGS_FIELDS = [
25
25
  [['showClock'], 'boolean'],
26
26
  [['showTurns'], 'boolean'],
27
27
  [['statusScope'], 'select'],
28
+ [['statusGlyph'], 'text'],
29
+ [['statusSeparator'], 'text'],
28
30
  ]
29
31
 
30
32
  /** The values the statusScope select must expose, looked up by field path. */
31
33
  export const STATUS_SCOPE_FIELD_PATH = ['statusScope']
32
34
  export const STATUS_SCOPE_OPTIONS = ['pink-only', 'all-themes']
33
35
 
34
- /** Assert a section object (ns + fields) matches the contract. */
36
+ /** Navigation group ids; every field must name one of them. */
37
+ export const SETTINGS_GROUPS = ['follow', 'status-line']
38
+
39
+ /** Assert a section object (ns + groups + fields) matches the contract. */
35
40
  export function assertSettingsContract(assert, section) {
36
41
  assert.equal(section.ns, SETTINGS_NAMESPACE, 'settings namespace must match')
37
42
  const actual = section.fields.map(field => [field.path, field.kind])
@@ -50,4 +55,19 @@ export function assertSettingsContract(assert, section) {
50
55
  STATUS_SCOPE_OPTIONS,
51
56
  'status scope must expose both supported select values',
52
57
  )
58
+ assert.deepEqual(
59
+ (section.groups ?? []).map(group => group.id).sort(),
60
+ [...SETTINGS_GROUPS].sort(),
61
+ 'settings groups must remain compatible with the declared navigation',
62
+ )
63
+ for (const field of section.fields) {
64
+ assert.equal(
65
+ typeof field.group, 'string',
66
+ `field ${JSON.stringify(field.path)} must declare its navigation group`,
67
+ )
68
+ assert.equal(
69
+ SETTINGS_GROUPS.includes(field.group), true,
70
+ `field ${JSON.stringify(field.path)} names an unknown group`,
71
+ )
72
+ }
53
73
  }
@@ -102,30 +102,35 @@ const collectBinds = () => {
102
102
  .filter(entry => entry.resource?.id === STATUS_CONTRIBUTION_KEY)
103
103
  : []
104
104
  }
105
+ // The plugin renders its contribution through the rich view path on hosts
106
+ // with tuiStatus.registerView (0.10.1+) and through the scalar set() path on
107
+ // older ones — the key is the same, only the store half differs.
105
108
  const readState = () => {
106
109
  const runtime = app.get('tuiStatus')
107
110
  const settingsRuntime = app.get('tuiSettingsSections')
108
111
  const settingsHost = settingsSectionsModule.getHostSettingsSections(settingsRuntime)
112
+ const store = statusModule.getHostStatusStore(runtime)
109
113
  return {
110
114
  pinkBinds: collectBinds(),
111
- snapshot: statusModule.getHostStatusStore(runtime)?.getSnapshot(),
115
+ snapshot: store?.getSnapshot(),
116
+ viewSnapshot: typeof store?.getViewSnapshot === 'function' ? store.getViewSnapshot() : [],
112
117
  settingsSection: settingsHost?.list().find(section => section.ns === SETTINGS_NAMESPACE),
113
118
  }
114
119
  }
115
120
 
116
121
  let state = readState()
117
122
  const deadline = Date.now() + READY_TIMEOUT_MS
118
- while (
119
- (state.pinkBinds.length === 0 ||
120
- !state.snapshot?.some?.(entry => entry.key === STATUS_CONTRIBUTION_KEY) ||
121
- state.settingsSection === undefined) &&
122
- Date.now() < deadline
123
- ) {
123
+ const contributionVisible = s =>
124
+ s.pinkBinds.length > 0 &&
125
+ (s.snapshot?.some?.(entry => entry.key === STATUS_CONTRIBUTION_KEY) === true ||
126
+ s.viewSnapshot?.some?.(entry => entry.key === STATUS_CONTRIBUTION_KEY) === true) &&
127
+ s.settingsSection !== undefined
128
+ while (!contributionVisible(state) && Date.now() < deadline) {
124
129
  await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
125
130
  state = readState()
126
131
  }
127
132
 
128
- const { pinkBinds, snapshot, settingsSection } = state
133
+ const { pinkBinds, snapshot, viewSnapshot, settingsSection } = state
129
134
  assert.equal(
130
135
  STATUS_CONTRIBUTION_KEY,
131
136
  SETTINGS_NAMESPACE,
@@ -133,7 +138,8 @@ assert.equal(
133
138
  )
134
139
  assert.ok(pinkBinds.length > 0, `plugin must bind through the late status service within ${READY_TIMEOUT_MS}ms`)
135
140
  assert.ok(
136
- snapshot?.some?.(entry => entry.key === STATUS_CONTRIBUTION_KEY),
141
+ snapshot?.some?.(entry => entry.key === STATUS_CONTRIBUTION_KEY) ||
142
+ viewSnapshot?.some?.(entry => entry.key === STATUS_CONTRIBUTION_KEY),
137
143
  `status store must contain the plugin contribution within ${READY_TIMEOUT_MS}ms`,
138
144
  )
139
145
  assert.ok(
@@ -141,4 +147,21 @@ assert.ok(
141
147
  `plugin must register its /settings section through the late settings service within ${READY_TIMEOUT_MS}ms`,
142
148
  )
143
149
  assertSettingsContract(assert, settingsSection)
144
- console.log(`OK headless order: ${pinkBinds.length} ledger bind(s), status contribution, settings section`)
150
+ if (typeof statusModule.getHostStatusStore(app.get('tuiStatus'))?.getViewSnapshot === 'function') {
151
+ const view = viewSnapshot.find(entry => entry.key === STATUS_CONTRIBUTION_KEY)
152
+ assert.ok(view, 'a registerView-capable host must carry the contribution as a rich view')
153
+ assert.equal(view.maxRows, 1, 'the rich status view requests exactly one row')
154
+ assert.equal(typeof view.component, 'function', 'the rich status view carries a component')
155
+ assert.equal(
156
+ snapshot.some(entry => entry.key === STATUS_CONTRIBUTION_KEY),
157
+ false,
158
+ 'the two render paths are mutually exclusive: the rich path contributes no scalar text',
159
+ )
160
+ console.log('OK headless order: rich view registered (maxRows 1), ledger bind, settings section')
161
+ } else {
162
+ assert.ok(
163
+ snapshot?.some?.(entry => entry.key === STATUS_CONTRIBUTION_KEY),
164
+ 'a set()-only host must carry the contribution as scalar text',
165
+ )
166
+ console.log(`OK headless order: ${pinkBinds.length} ledger bind(s), status contribution, settings section`)
167
+ }
@@ -37,36 +37,69 @@ const pluginHost = await import(pathToFileURL(join(adapter, 'plugin-host.js')).h
37
37
  const extensions = await import(pathToFileURL(join(adapter, 'extensions.js')).href)
38
38
  const themesModule = await import(pathToFileURL(join(adapter, 'themes.js')).href)
39
39
  const themeModule = await import(pathToFileURL(join(adapter, '..', 'theme.js')).href)
40
- const testUtils = await import(pathToFileURL(join(adapter, '..', 'test-utils.js')).href)
40
+ // dsh-TUI >= 0.10.1 dropped test-utils.js from the npm package; fall back to a
41
+ // manual plugin mount (same shape as headless-order-test.mjs). The admitted
42
+ // path stays first so release-ledger bookkeeping stays asserted where the
43
+ // admission helpers exist.
44
+ const testUtils = existsSync(join(adapter, '..', 'test-utils.js'))
45
+ ? await import(pathToFileURL(join(adapter, '..', 'test-utils.js')).href)
46
+ : undefined
47
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
48
+ const mountPlugin = async app => {
49
+ if (testUtils === undefined) {
50
+ return { context: app, fiber: app.fiber, manual: true }
51
+ }
52
+ const manifest = testUtils.testManifest({ id: SETTINGS_NAMESPACE })
53
+ const admitted = await testUtils.mountAdmitted(app, SETTINGS_NAMESPACE, manifest)
54
+ return { context: admitted.context, fiber: admitted.fiber, manual: false }
55
+ }
41
56
  const pink = await import(pathToFileURL(join(pluginRoot, 'lib', 'types', 'index.js')).href)
42
57
  const { SETTINGS_NAMESPACE } = await import(pathToFileURL(join(pluginRoot, 'scripts', 'expected-settings-contract.mjs')).href)
43
58
 
44
59
  const app = new Context()
45
60
  await app.plugin(pluginHost.default ?? pluginHost)
46
- const manifest = testUtils.testManifest({ id: SETTINGS_NAMESPACE })
47
- const admitted = await testUtils.mountAdmitted(app, SETTINGS_NAMESPACE, manifest)
48
- await admitted.context.plugin(pink)
61
+ const mount = await mountPlugin(app)
62
+ if (mount.manual) console.log('* mountAdmitted unavailable on this host; using manual plugin mount')
63
+ await mount.context.plugin(pink)
49
64
  await app.plugin(extensions.default ?? extensions)
50
65
 
51
66
  const host = themesModule.getHostThemes(app.get('tuiThemes'))
52
67
  assert.ok(host, 'runtime theme host must be mounted')
53
68
  const deadline = Date.now() + 5_000
54
69
  while (host.getSnapshot().length !== 3 && Date.now() < deadline) {
55
- await testUtils.sleep(25)
70
+ await sleep(25)
56
71
  }
57
72
  const snapshot = host.getSnapshot()
58
73
  assert.deepEqual(snapshot.map(entry => entry.name).sort(), ['pink-ansi', 'pink-day', 'pink-night'])
59
74
 
75
+ // dsh-TUI >= 0.10.1 semantic-rename sentinel: the host normalizes legacy keys
76
+ // at admission (aliases map to canonical names, retired keys are dropped),
77
+ // while pre-0.10.1 hosts snapshot the descriptor colors verbatim.
78
+ const semanticKeys = 'accent' in themeModule.getTheme('dark')
60
79
  for (const entry of snapshot) {
61
80
  const expected = JSON.parse(readFileSync(join(pluginRoot, 'themes', `${entry.name}.json`), 'utf8'))
62
81
  assert.equal(entry.displayName, expected.displayName)
63
82
  assert.equal(entry.base, expected.base)
64
- assert.deepEqual(entry.colors, expected.colors)
65
- assert.deepEqual(host.resolve(entry.name), { ...themeModule.getTheme(entry.base), ...expected.colors })
66
- assert.equal(themeModule.getTheme(entry.name).claude, expected.colors.claude)
83
+ if (semanticKeys) {
84
+ assert.equal(entry.colors.accent, expected.colors.claude)
85
+ assert.equal(entry.colors.accentShimmer, expected.colors.claudeShimmer)
86
+ assert.equal(entry.colors.activity, expected.colors.claudeBlue_FOR_SYSTEM_SPINNER)
87
+ assert.equal(entry.colors.activityShimmer, expected.colors.claudeBlueShimmer_FOR_SYSTEM_SPINNER)
88
+ assert.equal(entry.colors.mascotBody, expected.colors.clawd_body)
89
+ assert.equal(entry.colors.inputBackground, expected.colors.clawd_background)
90
+ assert.equal(entry.colors.userPromptLabel, expected.colors.briefLabelYou)
91
+ assert.equal(entry.colors.text, expected.colors.text)
92
+ assert.equal('rainbow_red' in entry.colors, false, 'retired keys are dropped at admission')
93
+ assert.equal(host.resolve(entry.name).accent, expected.colors.claude)
94
+ assert.equal(themeModule.getTheme(entry.name).accent, expected.colors.claude)
95
+ } else {
96
+ assert.deepEqual(entry.colors, expected.colors)
97
+ assert.deepEqual(host.resolve(entry.name), { ...themeModule.getTheme(entry.base), ...expected.colors })
98
+ assert.equal(themeModule.getTheme(entry.name).claude, expected.colors.claude)
99
+ }
67
100
  }
68
101
  assert.equal(existsSync(staticThemes), false, 'runtime registration must not create static theme files')
69
- await testUtils.sleep(1_700)
102
+ await sleep(1_700)
70
103
  assert.equal(existsSync(staticThemes), false, 'runtime confirmation must leave no static fallback files')
71
104
 
72
105
  const ledgerPath = join(dataDir, 'effect-ledger.jsonl')
@@ -74,11 +107,13 @@ const readLedger = () => (existsSync(ledgerPath) ? readFileSync(ledgerPath, 'utf
74
107
  const themeCreates = readLedger().filter(entry => entry.resource?.kind === 'theme' && entry.operation === 'create')
75
108
  assert.deepEqual(themeCreates.map(entry => entry.resource.id).sort(), ['pink-ansi', 'pink-day', 'pink-night'])
76
109
 
77
- await admitted.fiber.dispose()
78
- await testUtils.sleep(50)
110
+ await mount.fiber.dispose()
111
+ await sleep(50)
79
112
  assert.deepEqual(host.getSnapshot(), [], 'disposing the admitted plugin must release runtime themes')
80
- const themeReleases = readLedger().filter(entry => entry.resource?.kind === 'theme' && entry.operation === 'release')
81
- assert.deepEqual(themeReleases.map(entry => entry.resource.id).sort(), ['pink-ansi', 'pink-day', 'pink-night'])
113
+ if (!mount.manual) {
114
+ const themeReleases = readLedger().filter(entry => entry.resource?.kind === 'theme' && entry.operation === 'release')
115
+ assert.deepEqual(themeReleases.map(entry => entry.resource.id).sort(), ['pink-ansi', 'pink-day', 'pink-night'])
116
+ }
82
117
  assert.deepEqual(themeModule.getTheme('pink-night'), themeModule.getTheme('dark'), 'disposed runtime theme no longer resolves')
83
118
  console.log('OK runtime themes: 3 registered, no static files, ledger create/release, disposal clean')
84
119
 
@@ -106,23 +141,50 @@ for (const theme of ['pink-night', 'pink-day', 'pink-ansi']) {
106
141
  const deliveries = []
107
142
  const app2 = new Context()
108
143
  await app2.plugin(pluginHost.default ?? pluginHost)
109
- const admitted2 = await testUtils.mountAdmitted(app2, SETTINGS_NAMESPACE, manifest)
110
- await admitted2.context.plugin(pink)
144
+ const mount2 = await mountPlugin(app2)
145
+ await mount2.context.plugin(pink)
111
146
  await app2.plugin(extensions.default ?? extensions)
112
147
  toastModule.getHostToastStore(app2.get('tuiToast'))?.setSink(delivery => deliveries.push(delivery))
113
148
 
114
149
  const toastDeadline = Date.now() + 8_000
115
150
  while (deliveries.length === 0 && Date.now() < toastDeadline) {
116
- await testUtils.sleep(25)
151
+ await sleep(25)
117
152
  }
118
153
  assert.ok(deliveries.length >= 1, 'the shadow-hint toast must reach the host sink (retry included)')
119
154
  assert.equal(deliveries[0].color, undefined, 'the shadow hint is neutral')
120
155
  for (const file of ['pink-night.json', 'pink-day.json', 'pink-ansi.json']) {
121
156
  assert.ok(deliveries[0].text.includes(file), `hint must name ${file}`)
122
157
  }
123
- await admitted2.fiber.dispose()
124
- await testUtils.sleep(50)
125
- console.log('OK toast: shadow hint delivered through the real tuiToast seam')
158
+
159
+ // The same shadow situation additionally offers the one-shot guided cleanup
160
+ // dialog through the real tuiDialogs seam (host-managed confirm panel).
161
+ // Headless has no chat screen: the request parks in the dialog store, which
162
+ // is exactly the early-boot timing the plugin relies on — the dialog becomes
163
+ // visible as soon as the chat screen drains the store.
164
+ const dialogsModule = await import(pathToFileURL(join(adapter, 'dialogs.js')).href)
165
+ const dialogStore = dialogsModule.getHostDialogStore(app2.get('tuiDialogs'))
166
+ assert.ok(dialogStore, 'the real host must expose the dialog store')
167
+ const dialogDeadline = Date.now() + 5_000
168
+ while (dialogStore.getSnapshot() === null && Date.now() < dialogDeadline) {
169
+ await sleep(25)
170
+ }
171
+ const dialogSnapshot = dialogStore.getSnapshot()
172
+ assert.ok(dialogSnapshot, 'the shadow situation must offer the cleanup dialog')
173
+ assert.equal(dialogSnapshot.kind, 'confirm')
174
+ assert.ok(dialogSnapshot.title.includes('旧主题文件'), 'the dialog title names the cleanup')
175
+ for (const file of ['pink-night.json', 'pink-day.json', 'pink-ansi.json']) {
176
+ assert.ok((dialogSnapshot.message ?? '').includes(file), `the dialog message must name ${file}`)
177
+ }
178
+ assert.equal(existsSync(join(dataDir2, 'themes', 'pink-night.json')), true, 'an unanswered dialog deletes nothing')
179
+ await mount2.fiber.dispose()
180
+ await sleep(50)
181
+ assert.equal(dialogStore.getSnapshot(), null, 'disposing the activation settles the parked dialog')
182
+ assert.equal(
183
+ existsSync(join(dataDir2, 'themes', 'pink-night.json')),
184
+ true,
185
+ 'a cancelled dialog must not delete the shadow files',
186
+ )
187
+ console.log('OK toast: shadow hint delivered through the real tuiToast seam; cleanup dialog offered and settled')
126
188
 
127
189
  // ── Phase 3: apply-time toasts reach the sink through the seam-late retry ───
128
190
  // On a real 0.10 host the tuiToast service arrives with the extensions row,
@@ -157,15 +219,15 @@ const fakeSettings = {
157
219
  const deliveries3 = []
158
220
  const app3 = new Context()
159
221
  await app3.plugin(pluginHost.default ?? pluginHost)
160
- const admitted3 = await testUtils.mountAdmitted(app3, SETTINGS_NAMESPACE, manifest)
222
+ const mount3 = await mountPlugin(app3)
161
223
  app3.provide('settings', fakeSettings)
162
- await admitted3.context.plugin(pink)
224
+ await mount3.context.plugin(pink)
163
225
  await app3.plugin(extensions.default ?? extensions)
164
226
  toastModule.getHostToastStore(app3.get('tuiToast'))?.setSink(delivery => deliveries3.push(delivery))
165
227
 
166
228
  const applyToastDeadline = Date.now() + 8_000
167
229
  while (deliveries3.length < 2 && Date.now() < applyToastDeadline) {
168
- await testUtils.sleep(25)
230
+ await sleep(25)
169
231
  }
170
232
  const healToast = deliveries3.find(delivery => delivery.text.includes('已修复'))
171
233
  assert.ok(healToast, 'the self-heal warning fired during apply must reach the host sink')
@@ -176,5 +238,5 @@ assert.ok(followToast, 'the boot-follow result fired at settings time must reach
176
238
  assert.equal(followToast.color, 'success', 'the boot-follow toast is a success')
177
239
  assert.ok(followToast.text.includes('pink-day') && followToast.text.includes('reload'), 'the follow toast names the new theme and the reload hint')
178
240
  assert.equal(JSON.parse(readFileSync(join(dataDir3, 'theme.json'), 'utf8')).theme, 'pink-day', 'the follow pref write still happened')
179
- await admitted3.fiber.dispose()
241
+ await mount3.fiber.dispose()
180
242
  console.log('OK toast phase 3: apply-time self-heal and boot-follow toasts delivered on the real host')
@@ -100,7 +100,10 @@ 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')
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)`)
104
107
 
105
108
  const settingsKeys = [
106
109
  'promptBorder',
@@ -185,7 +188,19 @@ const cases = [
185
188
  ['pink-night', 'success', '#55303E', 3.0],
186
189
  ['pink-night', 'inactive', '#55303E', 3.0],
187
190
  ['pink-day', 'text', '#F6F3ED', 4.5],
188
- ['pink-day', 'claude', '#F6F3ED', 3.0],
191
+ // claude drives the 5-row header display font and bold+underlined markdown
192
+ // headings; the sakura-day identity deliberately goes one notch lighter
193
+ // (#DE6E96), so the large-text floor here is 2.5 instead of 3.0. Body-text
194
+ // readability stays guarded by the 4.5 `text` case above.
195
+ ['pink-day', 'claude', '#F6F3ED', 2.5],
196
+ // The DEEPSEEK pixel word's gradient end (claudeBlue_FOR_SYSTEM_SPINNER,
197
+ // 0.10.1 `activity`) was lightened one notch with the same sakura-day
198
+ // family move (#DE6E96 -> #E879A0), so the gradient reads as a fade. The
199
+ // measured 2.47 is accepted for this decorative gradient end and guarded
200
+ // here at 2.4 — the floor exists to catch further lightening, not to
201
+ // re-litigate the accepted 0.03 gap below the 2.5 large-text floor.
202
+ // Decision record: docs/decisions/2026-09-12-pink-day-claude-family-lightening.md
203
+ ['pink-day', 'claudeBlue_FOR_SYSTEM_SPINNER', '#F6F3ED', 2.4],
189
204
  ['pink-day', 'inactive', '#F6F3ED', 2.5],
190
205
  ['pink-day', 'success', '#F6F3ED', 3.0],
191
206
  ['pink-day', 'success', '#F3D7E0', 2.5],
@@ -196,12 +211,17 @@ const cases = [
196
211
  // ratio is assertable here — the host renders whatever the user's terminal
197
212
  // palette defines. Its settings keys are still covered by the per-theme
198
213
  // key-coverage assertions above.
214
+ // Hosts >= 0.10.1 resolve built themes to canonical keys, so era keys read
215
+ // their canonical names there; pre-refactor hosts keep the era keys.
216
+ const semanticKeys = 'accent' in built['pink-night']
217
+ const semanticAliases = { claude: 'accent', claudeBlue_FOR_SYSTEM_SPINNER: 'activity' }
199
218
  for (const [theme, key, background, minimum] of cases) {
200
- const foregroundRgb = parseColor(built[theme][key])
219
+ const probeKey = (semanticKeys && semanticAliases[key]) || key
220
+ const foregroundRgb = parseColor(built[theme][probeKey])
201
221
  const backgroundRgb = parseColor(background)
202
- assert.notEqual(foregroundRgb, undefined, `${theme}.${key} must be parseable`)
222
+ assert.notEqual(foregroundRgb, undefined, `${theme}.${probeKey} must be parseable`)
203
223
  const ratio = contrast(foregroundRgb, backgroundRgb)
204
- assert.ok(ratio >= minimum, `${theme}.${key} contrast ${ratio.toFixed(2)} >= ${minimum}`)
224
+ assert.ok(ratio >= minimum, `${theme}.${probeKey} contrast ${ratio.toFixed(2)} >= ${minimum}`)
205
225
  }
206
226
 
207
227
  console.log(`OK host theme validation: ${Object.keys(built).length} themes, ${allKeys.length - 1} keys each, settings colors covered`)
@@ -44,6 +44,12 @@ for (const required of [
44
44
  'lib/types/runtimeThemes.d.ts',
45
45
  'lib/types/toast.js',
46
46
  'lib/types/toast.d.ts',
47
+ 'lib/types/statusView.js',
48
+ 'lib/types/statusView.d.ts',
49
+ 'lib/types/ornament.js',
50
+ 'lib/types/ornament.d.ts',
51
+ 'lib/types/shadowCleanup.js',
52
+ 'lib/types/shadowCleanup.d.ts',
47
53
  'themes/pink-night.json',
48
54
  'themes/pink-day.json',
49
55
  'themes/pink-ansi.json',