@clowder-ai/companion 0.1.0-alpha.13 → 0.1.0-alpha.15

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,171 @@
1
+ const sameValue = (left, right) => Object.is(left, right);
2
+ const canStopCall = field => ['dutyCatProfileId', 'householdReadsAllowed'].includes(field);
3
+
4
+ /**
5
+ * Renderer-side settings state only. The Host remains the single source of
6
+ * persisted values, roster truth, media stopping and plugin lifecycle.
7
+ */
8
+ export class SettingsController {
9
+ constructor({ client, changed = () => {} }) {
10
+ this.client = client;
11
+ this.changed = changed;
12
+ this.readOperation = 0;
13
+ this.writeOperation = 0;
14
+ this.state = { phase: 'idle', page: 'main', saving: false, notice: null, confirmation: null, retry: null };
15
+ }
16
+
17
+ publish(patch = {}) {
18
+ this.state = { ...this.state, ...patch };
19
+ this.changed(this.state);
20
+ }
21
+
22
+ applySettings(reply, patch = {}) {
23
+ if (reply?.kind !== 'settings' || reply.status !== 'available') return false;
24
+ this.publish({
25
+ phase: this.state.saving ? 'saving' : 'ready',
26
+ values: reply.values,
27
+ companions: reply.companions,
28
+ selectedCompanionStatus: reply.selectedCompanionStatus,
29
+ ...patch,
30
+ });
31
+ return true;
32
+ }
33
+
34
+ async load({ preserveSettlement = false } = {}) {
35
+ // A panel reopen is presentation work, not authority to cancel an in-flight
36
+ // write. The write performs its own canonical readback before settlement.
37
+ if (this.state.saving) return false;
38
+ const operation = ++this.readOperation;
39
+ this.publish({ phase: 'loading', ...(preserveSettlement ? {} : { notice: null, retry: null }) });
40
+ try {
41
+ const reply = await this.client.readSettings();
42
+ if (operation !== this.readOperation) return false;
43
+ const clearsUnknown = preserveSettlement && this.state.notice?.kind === 'unconfirmed';
44
+ if (this.applySettings(reply, clearsUnknown ? { notice: null, retry: null } : {})) return true;
45
+ this.publish({ phase: 'unavailable', reason: reply.reason, saving: false });
46
+ } catch (error) {
47
+ if (operation !== this.readOperation) return false;
48
+ this.publish({ phase: 'unavailable', saving: false,
49
+ reason: error?.code === 'invalid_request' ? 'host_upgrade_required' : 'temporarily_unavailable' });
50
+ }
51
+ return false;
52
+ }
53
+
54
+ navigate(page) {
55
+ this.publish({ page, confirmation: null });
56
+ }
57
+
58
+ async requestUpdate(field, value, { confirm = false } = {}) {
59
+ if (this.state.saving || sameValue(this.state.values?.[field], value)) return false;
60
+ if (confirm) {
61
+ this.publish({ page: 'confirm',
62
+ confirmation: { kind: 'setting', field, value, returnPage: this.state.page },
63
+ notice: null, retry: null });
64
+ return true;
65
+ }
66
+ await this.commitUpdate(field, value, this.state.page);
67
+ return true;
68
+ }
69
+
70
+ async readCanonical(operation) {
71
+ try {
72
+ const reply = await this.client.readSettings();
73
+ if (operation !== this.writeOperation || reply?.kind !== 'settings' || reply.status !== 'available') return null;
74
+ return reply;
75
+ } catch { return null; }
76
+ }
77
+
78
+ async commitUpdate(field, value, returnPage = this.state.page) {
79
+ if (this.state.saving) return { callStopped: null };
80
+ const operation = ++this.writeOperation;
81
+ this.publish({ phase: 'saving', saving: true, notice: null, retry: null });
82
+ let receipt;
83
+ try {
84
+ receipt = await this.client.updateSetting(field, value);
85
+ } catch {
86
+ if (operation !== this.writeOperation) return { callStopped: null };
87
+ const canonical = await this.readCanonical(operation);
88
+ if (operation !== this.writeOperation) return { callStopped: null };
89
+ if (canonical) this.applySettings(canonical);
90
+ const saved = canonical && sameValue(canonical.values[field], value);
91
+ const callStopped = canStopCall(field) ? null : false;
92
+ this.publish({ phase: canonical ? 'ready' : this.state.values ? 'ready' : 'unavailable', saving: false,
93
+ page: returnPage, confirmation: null,
94
+ retry: canonical && !saved ? { field, value } : null,
95
+ notice: canonical
96
+ ? { kind: saved ? 'reconciled_saved' : 'reconciled_not_saved', field, callStopped }
97
+ : { kind: 'unconfirmed', field, callStopped } });
98
+ return { callStopped };
99
+ }
100
+ if (operation !== this.writeOperation) return { callStopped: null };
101
+ const canonical = await this.readCanonical(operation);
102
+ if (operation !== this.writeOperation) return { callStopped: null };
103
+ if (canonical) this.applySettings(canonical);
104
+ const callStopped = receipt.callStatus === 'stopped';
105
+ let notice;
106
+ if (receipt.outcome === 'saved') {
107
+ notice = { kind: 'saved', field, callStopped, applies: receipt.applies };
108
+ } else if (receipt.outcome === 'rejected') {
109
+ notice = { kind: 'rejected', field, callStopped, reason: receipt.reason };
110
+ } else if (!canonical) {
111
+ notice = { kind: 'unconfirmed', field, callStopped };
112
+ } else {
113
+ notice = {
114
+ kind: sameValue(canonical.values[field], value) ? 'reconciled_saved' : 'reconciled_not_saved',
115
+ field,
116
+ callStopped,
117
+ };
118
+ }
119
+ const retry = (receipt.outcome === 'rejected' && ['save_failed', 'call_stop_failed'].includes(receipt.reason))
120
+ || (receipt.outcome === 'unconfirmed' && canonical && !sameValue(canonical.values[field], value))
121
+ ? { field, value } : null;
122
+ this.publish({ phase: canonical ? 'ready' : this.state.values ? 'ready' : 'unavailable', saving: false,
123
+ page: returnPage, confirmation: null, notice, retry });
124
+ return { callStopped };
125
+ }
126
+
127
+ async confirm() {
128
+ const confirmation = this.state.confirmation;
129
+ if (!confirmation || this.state.saving) return { callStopped: null };
130
+ if (confirmation.kind === 'setting') {
131
+ return this.commitUpdate(confirmation.field, confirmation.value, confirmation.returnPage);
132
+ }
133
+ const operation = ++this.writeOperation;
134
+ this.publish({ phase: 'saving', saving: true, notice: null });
135
+ try {
136
+ await this.client.disableCompanion();
137
+ if (operation !== this.writeOperation) return { callStopped: null };
138
+ this.publish({ phase: 'disabled', saving: false, confirmation: null });
139
+ return { callStopped: true };
140
+ } catch {
141
+ if (operation !== this.writeOperation) return { callStopped: null };
142
+ this.publish({ phase: this.state.values ? 'ready' : 'unavailable', saving: false,
143
+ page: confirmation.returnPage, confirmation: null, notice: { kind: 'disable_failed' } });
144
+ return { callStopped: null };
145
+ }
146
+ }
147
+
148
+ requestDisable() {
149
+ if (this.state.saving) return;
150
+ this.publish({ page: 'disable', confirmation: { kind: 'disable', returnPage: this.state.page }, notice: null });
151
+ }
152
+
153
+ async retry({ confirm = false } = {}) {
154
+ const retry = this.state.retry;
155
+ if (!retry || this.state.saving) return;
156
+ if (confirm && canStopCall(retry.field)) {
157
+ this.publish({ page: 'confirm',
158
+ confirmation: { kind: 'setting', field: retry.field, value: retry.value, returnPage: this.state.page } });
159
+ return;
160
+ }
161
+ await this.commitUpdate(retry.field, retry.value, this.state.page);
162
+ }
163
+
164
+ async resetPosition() {
165
+ if (this.state.saving) return;
166
+ try {
167
+ await this.client.resetPosition();
168
+ this.publish({ notice: { kind: 'position_reset' } });
169
+ } catch { this.publish({ notice: { kind: 'position_reset_failed' } }); }
170
+ }
171
+ }
@@ -0,0 +1,234 @@
1
+ import { SettingsController } from './settings-controller.mjs';
2
+
3
+ const skinLabels = {
4
+ 'xianxian-codex': '宪宪 v1',
5
+ 'yanyan-codex': '砚砚 v1',
6
+ 'ragdoll-v1': '布偶猫',
7
+ 'yarn-ball': '猫猫球',
8
+ };
9
+ const fieldLabels = {
10
+ dutyCatProfileId: '陪伴者',
11
+ skin: '外观',
12
+ ballSize: '大小',
13
+ behaviorEnabled: '自主活动',
14
+ proactivePolicy: '主动提示',
15
+ personaTone: '人设基调',
16
+ householdReadsAllowed: '资料查询',
17
+ };
18
+
19
+ export class SettingsView {
20
+ constructor({ document, client, close, callActive, onValues,
21
+ expectHostStop = () => false, settleExpectedHostStop = async () => {}, layoutChanged = () => {} }) {
22
+ this.document = document;
23
+ this.$ = id => document.getElementById(id);
24
+ this.close = close;
25
+ this.callActive = callActive;
26
+ this.onValues = onValues;
27
+ this.expectHostStop = expectHostStop;
28
+ this.settleExpectedHostStop = settleExpectedHostStop;
29
+ this.layoutChanged = layoutChanged;
30
+ this.controller = new SettingsController({ client, changed: state => this.render(state) });
31
+ this.bind();
32
+ }
33
+
34
+ bind() {
35
+ this.$('settings-back').onclick = () => {
36
+ if (this.controller.state.page === 'main') this.close();
37
+ else this.controller.navigate('main');
38
+ };
39
+ this.$('settings-partner-open').onclick = () => this.controller.navigate('partner');
40
+ this.$('settings-look-open').onclick = () => this.controller.navigate('look');
41
+ this.$('settings-behavior').onclick = () => void this.controller.requestUpdate(
42
+ 'behaviorEnabled', !this.controller.state.values?.behaviorEnabled,
43
+ );
44
+ this.$('settings-proactive').onclick = () => void this.controller.requestUpdate(
45
+ 'proactivePolicy', this.controller.state.values?.proactivePolicy === 'quiet-badge' ? 'ambient' : 'quiet-badge',
46
+ );
47
+ this.$('settings-documents').onclick = () => {
48
+ const value = !this.controller.state.values?.householdReadsAllowed;
49
+ void this.controller.requestUpdate('householdReadsAllowed', value, { confirm: this.callActive() });
50
+ };
51
+ this.$('settings-tone').onchange = event => {
52
+ const value = event.target.value.replace(/[\r\n]+/gu, ' ').trim();
53
+ event.target.value = value;
54
+ if (!value) { event.target.value = this.controller.state.values?.personaTone ?? ''; return; }
55
+ void this.controller.requestUpdate('personaTone', value);
56
+ };
57
+ this.$('settings-tone').oninput = event => {
58
+ event.target.value = event.target.value.replace(/[\r\n]+/gu, ' ');
59
+ this.$('settings-tone-count').textContent = `${Array.from(event.target.value).length} / 200`;
60
+ };
61
+ this.$('settings-tone').onkeydown = event => {
62
+ if (event.key === 'Enter') event.preventDefault();
63
+ };
64
+ this.$('settings-size').onchange = event => void this.controller.requestUpdate('ballSize', Number(event.target.value));
65
+ for (const id of Object.keys(skinLabels)) {
66
+ this.$(`settings-skin-${id}`).onclick = () => void this.controller.requestUpdate('skin', id);
67
+ }
68
+ this.$('settings-reset').onclick = () => void this.controller.resetPosition();
69
+ this.$('settings-disable-open').onclick = () => this.controller.requestDisable();
70
+ this.$('settings-confirm-cancel').onclick = () => this.controller.navigate(
71
+ this.controller.state.confirmation?.returnPage ?? 'main',
72
+ );
73
+ this.$('settings-confirm-apply').onclick = async () => {
74
+ const expectsStop = this.callActive() && Boolean(this.controller.state.confirmation);
75
+ if (expectsStop) this.expectHostStop();
76
+ const settlement = await this.controller.confirm();
77
+ if (expectsStop) await this.settleExpectedHostStop(settlement?.callStopped ?? null);
78
+ };
79
+ this.$('settings-retry').onclick = () => void this.controller.retry({ confirm: this.callActive() });
80
+ this.$('settings-reread').onclick = () => void this.controller.load();
81
+ this.$('settings-unavailable-retry').onclick = () => void this.controller.load();
82
+ }
83
+
84
+ open() {
85
+ this.controller.navigate('main');
86
+ return this.controller.load({ preserveSettlement: true });
87
+ }
88
+
89
+ setToggle(id, checked) {
90
+ const node = this.$(id);
91
+ node.setAttribute('aria-checked', String(checked));
92
+ node.dataset.checked = String(checked);
93
+ }
94
+
95
+ renderRoster(state) {
96
+ const list = this.$('settings-roster');
97
+ list.replaceChildren();
98
+ for (const companion of state.companions ?? []) {
99
+ const row = this.document.createElement('button');
100
+ row.type = 'button';
101
+ row.className = 'settings-choice';
102
+ row.disabled = !companion.available || state.saving;
103
+ row.dataset.settingControl = 'true';
104
+ row.dataset.selected = String(companion.catProfileId === state.values.dutyCatProfileId);
105
+ const name = this.document.createElement('strong');
106
+ const meta = this.document.createElement('span');
107
+ name.textContent = companion.displayName;
108
+ meta.textContent = companion.available ? '' : '暂不可用';
109
+ row.append(name, meta);
110
+ row.onclick = () => void this.controller.requestUpdate('dutyCatProfileId', companion.catProfileId,
111
+ { confirm: this.callActive() });
112
+ list.append(row);
113
+ }
114
+ }
115
+
116
+ renderValues(state) {
117
+ const values = state.values;
118
+ if (!values) return;
119
+ const selected = state.companions?.find(item => item.catProfileId === values.dutyCatProfileId);
120
+ this.$('settings-partner-value').textContent = selected
121
+ ? `${selected.displayName}${selected.available ? '' : ' · 暂不可用'}`
122
+ : '已保存的陪伴者 · 暂不可用 · 请重新选择';
123
+ this.$('settings-look-value').textContent = skinLabels[values.skin] ?? values.skin;
124
+ this.setToggle('settings-behavior', values.behaviorEnabled);
125
+ this.setToggle('settings-proactive', values.proactivePolicy === 'quiet-badge');
126
+ this.setToggle('settings-documents', values.householdReadsAllowed);
127
+ this.$('settings-size').value = String(values.ballSize);
128
+ this.$('settings-tone').value = values.personaTone;
129
+ this.$('settings-tone-count').textContent = `${Array.from(values.personaTone).length} / 200`;
130
+ for (const id of Object.keys(skinLabels)) {
131
+ this.$(`settings-skin-${id}`).dataset.selected = String(values.skin === id);
132
+ }
133
+ this.renderRoster(state);
134
+ this.onValues(values, state);
135
+ }
136
+
137
+ savedValue(field, state) {
138
+ const value = state.values?.[field];
139
+ if (field === 'skin') return skinLabels[value] ?? value;
140
+ if (field === 'dutyCatProfileId')
141
+ return state.companions?.find(item => item.catProfileId === value)?.displayName ?? '已保存的陪伴者';
142
+ if (field === 'behaviorEnabled' || field === 'householdReadsAllowed') return value ? '开启' : '关闭';
143
+ if (field === 'proactivePolicy') return value === 'quiet-badge' ? '开启' : '关闭';
144
+ if (field === 'ballSize') return `${value}`;
145
+ return value;
146
+ }
147
+
148
+ noticeText(notice, state) {
149
+ if (!notice) return '';
150
+ if (notice.kind === 'position_reset') return '已回到默认位置';
151
+ if (notice.kind === 'position_reset_failed') return '还没能回到默认位置 · 请重试';
152
+ if (notice.kind === 'disable_failed') return '桌面猫猫球还没有停用 · 请重试';
153
+ const label = fieldLabels[notice.field] ?? '设置';
154
+ const stopped = notice.callStopped ? '通话已结束,' : '';
155
+ if (notice.kind === 'saved') return notice.applies === 'next_call'
156
+ ? `${stopped}${label}已保存 · 下次语音通话生效`
157
+ : `${stopped}${label}已保存`;
158
+ if (notice.kind === 'rejected') {
159
+ if (notice.reason === 'selection_unavailable') return `${stopped}${label}暂不可用 · 请重新选择`;
160
+ if (notice.callStopped) return `通话已结束,${label}未更改`;
161
+ const current = this.savedValue(notice.field, state);
162
+ return current === undefined ? `${label}未保存` : `${label}未保存,仍为“${current}”`;
163
+ }
164
+ if (notice.kind === 'reconciled_saved') return `${stopped}重新读取后确认${label}已保存`;
165
+ if (notice.kind === 'reconciled_not_saved') return notice.callStopped
166
+ ? `通话已结束,${label}未更改` : `重新读取后确认${label}未更改`;
167
+ return `${stopped}${label}的保存结果未确认`;
168
+ }
169
+
170
+ renderSettlement(state) {
171
+ for (const id of ['settings-partner-open', 'settings-look-open', 'settings-behavior',
172
+ 'settings-proactive', 'settings-documents', 'settings-tone-field', 'settings-size-field'])
173
+ delete this.$(id).dataset.settlement;
174
+ if (state.notice?.kind !== 'unconfirmed') return;
175
+ const ids = {
176
+ dutyCatProfileId: 'settings-partner-open', skin: 'settings-look-open',
177
+ behaviorEnabled: 'settings-behavior', proactivePolicy: 'settings-proactive',
178
+ householdReadsAllowed: 'settings-documents', personaTone: 'settings-tone-field',
179
+ ballSize: 'settings-size-field',
180
+ };
181
+ const id = ids[state.notice.field];
182
+ if (id) this.$(id).dataset.settlement = 'unconfirmed';
183
+ if (state.notice.field === 'dutyCatProfileId') this.$('settings-partner-value').textContent = '未确认';
184
+ if (state.notice.field === 'skin') this.$('settings-look-value').textContent = '未确认';
185
+ }
186
+
187
+ renderConfirmation(state) {
188
+ const confirmation = state.confirmation;
189
+ if (!confirmation) return;
190
+ if (confirmation.kind === 'disable') {
191
+ this.$('settings-confirm-title').textContent = '停用桌面猫猫球';
192
+ this.$('settings-confirm-copy').textContent = '停用后,桌面上不再显示猫猫球,当前语音通话将结束。设置和聊天记录会保留,可在 Cat Café 的“设置 › 插件”中重新启用。';
193
+ this.$('settings-confirm-apply').textContent = '结束语音并停用';
194
+ return;
195
+ }
196
+ const disablingReads = confirmation.field === 'householdReadsAllowed' && confirmation.value === false;
197
+ this.$('settings-confirm-title').textContent = fieldLabels[confirmation.field];
198
+ this.$('settings-confirm-copy').textContent = confirmation.field === 'dutyCatProfileId'
199
+ ? '更换陪伴者将结束当前语音通话。设置保存后,需要由你重新开始通话。'
200
+ : disablingReads
201
+ ? '关闭后,陪伴者在语音通话中无法查询家里的资料。更改这项需要先结束当前通话,之后可重新开始。'
202
+ : '更改资料查询权限需要先结束当前语音通话,之后可由你重新开始。';
203
+ this.$('settings-confirm-apply').textContent = confirmation.field === 'dutyCatProfileId'
204
+ ? '结束语音并更换' : '结束语音并更改';
205
+ }
206
+
207
+ render(state) {
208
+ const unavailable = state.phase === 'unavailable';
209
+ const disabled = state.phase === 'disabled';
210
+ const requestedPage = disabled ? 'disabled' : unavailable ? 'unavailable' : state.page;
211
+ const page = requestedPage === 'disable' ? 'confirm' : requestedPage;
212
+ for (const name of ['main', 'partner', 'look', 'confirm', 'unavailable', 'disabled']) {
213
+ this.$(`settings-page-${name}`).hidden = name !== page;
214
+ }
215
+ this.$('settings-loading').hidden = state.phase !== 'loading';
216
+ this.$('settings-title').textContent = page === 'partner' ? '陪伴者'
217
+ : page === 'look' ? '外观'
218
+ : ['confirm', 'disable'].includes(page) ? '' : '设置';
219
+ this.$('settings-unavailable-copy').textContent = state.reason === 'host_upgrade_required'
220
+ ? '这个 Host 版本还不支持猫旁设置。原来的设置入口会继续保留。'
221
+ : '设置暂时读不到,请稍后重试。';
222
+ for (const control of this.$('settings').querySelectorAll('[data-setting-control]')) control.disabled = state.saving;
223
+ if (state.values) this.renderValues(state);
224
+ this.renderSettlement(state);
225
+ this.renderConfirmation(state);
226
+ const text = this.noticeText(state.notice, state);
227
+ this.$('settings-notice').hidden = !text;
228
+ this.$('settings-notice').textContent = text;
229
+ this.$('settings-notice').dataset.kind = state.notice?.kind ?? '';
230
+ this.$('settings-retry').hidden = !state.retry || page === 'confirm';
231
+ this.$('settings-reread').hidden = state.notice?.kind !== 'unconfirmed';
232
+ this.layoutChanged();
233
+ }
234
+ }
@@ -1,16 +1,31 @@
1
1
  @import url("./theme.css");
2
+ :root {
3
+ --pet-scale: 1;
4
+ --desktop-paper: #fffbf6;
5
+ --desktop-work: #fcf7f0;
6
+ --desktop-rail: #efe9e3;
7
+ --desktop-ink: #141413;
8
+ --desktop-body: #3d3d3a;
9
+ --desktop-muted: #6f6b63;
10
+ --desktop-line: #e5dfd5;
11
+ --desktop-line-strong: #d8d1c6;
12
+ --desktop-primary: #b05f45;
13
+ --desktop-danger: #b3362c;
14
+ --desktop-success: #3f8552;
15
+ --desktop-warning: #a8721c;
16
+ }
2
17
  * { box-sizing: border-box; }
3
18
  [hidden] { display: none !important; }
4
- body { margin: 0; overflow: hidden; color: var(--cafe-text); background: transparent; font: 14px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; }
19
+ body { margin: 0; overflow: hidden; color: var(--desktop-ink); background: transparent; font: 14px Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; }
5
20
  button, textarea, input { font: inherit; }
6
21
  button { cursor: pointer; color: inherit; }
7
- button:focus-visible, textarea:focus-visible { outline: 2px solid var(--cafe-accent); outline-offset: 3px; }
22
+ button:focus-visible, textarea:focus-visible, input:focus-visible { outline: 2px solid var(--cafe-accent); outline-offset: 3px; }
8
23
  button:disabled { opacity: .45; cursor: default; }
9
24
  .icon-definitions { position: absolute; width: 0; height: 0; overflow: hidden; }
10
25
  .icon { width: 17px; height: 17px; flex-shrink: 0; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
11
26
  #desktop { position: relative; width: 100vw; height: 100vh; }
12
- #anchor { position: absolute; left: 8px; top: 8px; width: 120px; height: 130px; }
13
- #pet { width: 120px; height: 130px; display: block; border: 0; padding: 0; background: url("./skins/xianxian-codex.webp") 0 0 / 960px 1170px; filter: drop-shadow(0 6px 4px #0002); touch-action: none; cursor: grab; }
27
+ #anchor { position: absolute; left: 8px; top: 8px; width: calc(120px * var(--pet-scale)); height: calc(130px * var(--pet-scale)); }
28
+ #pet { width: 120px; height: 130px; display: block; border: 0; padding: 0; background: url("./skins/xianxian-codex.webp") 0 0 / 960px 1170px; filter: drop-shadow(0 6px 4px #0002); touch-action: none; cursor: grab; transform: scale(var(--pet-scale)); transform-origin: left top; }
14
29
  #pet.dragging { cursor: grabbing; }
15
30
  #pet[data-skin="xianxian-codex"] { position: relative; background-image: none; overflow: visible; }
16
31
  #living-sit { position: absolute; left: 0; bottom: 10px; width: 120px; height: 120px; overflow: visible; pointer-events: none; }
@@ -53,9 +68,9 @@ button:disabled { opacity: .45; cursor: default; }
53
68
  #indicators button[data-state="receive-only"] { background: #476dac; }
54
69
  #indicators svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
55
70
  #share-badge { background: #476dac !important; }
56
- #pending-badge { background: #9b6841 !important; font-size: 11px; font-weight: 700; }
71
+ #pending-badge { width: auto !important; min-width: 32px; padding: 0 9px; border-radius: 999px !important; background: #9b6841 !important; font-size: 11px; font-weight: 700; white-space: nowrap; }
57
72
  @keyframes pulse { 50% { opacity: .65; } }
58
- .floating { position: absolute; z-index: 3; border-radius: 13px; border: 1px solid color-mix(in srgb, var(--cafe-border-subtle), transparent 30%); background: color-mix(in srgb, var(--cafe-surface-elevated) 96%, transparent); box-shadow: 0 10px 26px -10px #24272c2e, 0 2px 5px #24272c0a; backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); }
73
+ .floating { position: absolute; z-index: 3; border-radius: 12px; border: 1px solid var(--desktop-line-strong); background: var(--desktop-paper); box-shadow: 0 14px 28px #18171526, 0 2px 5px #1817150f; }
59
74
  .floating { max-height: 500px; overflow: auto; }
60
75
  #bubble { width: 240px; max-height: 80px; overflow: hidden; padding: 8px 11px; pointer-events: none; }
61
76
  #bubble p { margin: 0; font-size: 11px; line-height: 1.45; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
@@ -63,34 +78,134 @@ button:disabled { opacity: .45; cursor: default; }
63
78
  #bubble [data-role="user"] { color: var(--cafe-text-secondary); }
64
79
  .floating button { border: 0; background: transparent; border-radius: 7px; padding: 8px 11px; font-weight: 400; }
65
80
  .floating button:hover:not(:disabled) { background: var(--cafe-surface); }
66
- .floating .primary { background: var(--accent-100); color: var(--accent-900); }
67
- #actions { padding: 5px; width: max-content; }
81
+ .floating .primary { background: var(--desktop-primary); color: white; }
82
+ .floating .primary:hover:not(:disabled) { background: #8f4730; color: white; }
83
+ .floating .destructive { background: var(--desktop-danger); color: white; font-weight: 500; }
84
+ .floating .destructive:hover:not(:disabled) { background: #942c24; }
85
+ .floating .destructive.compact { min-width: 72px; padding: 6px 12px; }
86
+ #actions { padding: 12px; width: 364px; overflow: hidden; }
68
87
  .action-row { display: flex; align-items: center; gap: 4px; }
69
88
  .action-row button { display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 35px; font-size: 12px; white-space: nowrap; }
70
89
  .action-row button .icon { width: 16px; height: 16px; }
71
- #active-actions { border-top: 1px solid var(--cafe-border-subtle); margin-top: 5px; padding-top: 5px; }
72
- #status { max-width: 220px; font-size: 12px; line-height: 1.5; margin: 7px 10px; color: var(--cafe-text-secondary); }
73
- #menu { width: 204px; padding: 5px; }
90
+ #active-actions { border-top: 1px solid var(--desktop-line); margin-top: 9px; padding-top: 9px; }
91
+ #status { font-size: 12px; line-height: 1.45; margin: 8px 0 0; color: var(--desktop-muted); }
92
+ .call-status-line { min-height: 24px; display: flex; align-items: center; gap: 7px; white-space: nowrap; }
93
+ .call-status-line strong { font-size: 13px; font-weight: 600; }
94
+ .call-status-line span:not(.status-dot) { color: var(--desktop-muted); font-size: 12px; }
95
+ .status-dot { width: 8px; height: 8px; flex: none; border-radius: 999px; background: var(--desktop-success); }
96
+ [data-connection-state="recovering"] .status-dot { background: var(--desktop-warning); }
97
+ .call-work { margin: 3px 0 0 15px; color: var(--desktop-muted); font-size: 12px; }
98
+ .call-share-context { display: flex; align-items: center; gap: 7px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--desktop-line); }
99
+ .call-share-context > span, .call-share-line > span { display: grid; gap: 1px; min-width: 0; }
100
+ .call-share-context strong, .call-share-line strong { overflow: hidden; color: var(--desktop-body); font-size: 12px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
101
+ .call-share-context small, .call-share-line small { color: var(--desktop-muted); font-size: 11px; }
102
+ .call-recent { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--desktop-line); }
103
+ .call-recent-label { color: var(--desktop-muted); font-size: 11px; letter-spacing: .05em; }
104
+ .call-recent p { margin: 4px 0 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 12px; line-height: 1.45; }
105
+ .call-recent p[data-role="user"] { color: var(--desktop-muted); }
106
+ .call-actions { justify-content: flex-end; }
107
+ .call-actions #end { min-width: 88px; margin-left: auto; }
108
+ #menu { width: 340px; padding: 8px; }
74
109
  #menu .menu-title { display: block; padding: 7px 11px 6px; color: var(--cafe-text-muted); font-size: 10px; font-weight: 500; letter-spacing: .04em; }
75
110
  #menu button { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 33px; text-align: left; font-size: 12px; line-height: 1.4; }
76
111
  #menu button .icon { color: var(--cafe-text-secondary); width: 16px; height: 16px; }
112
+ #menu .primary .icon { color: inherit; }
77
113
  #menu button:disabled { opacity: .36; }
78
114
  #menu .menu-status { margin-left: auto; color: var(--cafe-text-muted); font-size: 10px; }
79
115
  #menu [aria-pressed="true"] .menu-status { color: var(--accent-700); }
80
116
  #menu .menu-divider { height: 1px; margin: 4px 8px; background: color-mix(in srgb, var(--cafe-border-subtle), transparent 28%); }
81
117
  #menu .quiet-action { color: var(--cafe-text-secondary); }
82
- #chat { width: 320px; display: flex; flex-direction: column; }
118
+ .name-card-actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; padding: 5px 7px 9px; }
119
+ .name-card-actions button { justify-content: center; }
120
+ #menu-status { margin: 2px 10px 8px; color: var(--desktop-muted); font-size: 12px; line-height: 1.45; }
121
+ .call-context { display: grid; grid-template-columns: auto auto 1fr auto; align-items: center; gap: 7px; min-height: 40px; padding: 7px 10px; border-bottom: 1px solid var(--desktop-line); color: var(--desktop-muted); font-size: 12px; }
122
+ .call-context strong { color: var(--desktop-ink); font-size: 13px; }
123
+ .call-context > small { grid-column: 2 / 4; }
124
+ .call-share-line { grid-column: 1 / -1; display: flex; align-items: center; gap: 7px; padding-top: 6px; border-top: 1px solid var(--desktop-line); min-width: 0; }
125
+ .call-context-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
126
+ .call-context-actions button { display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 9px; white-space: nowrap; }
127
+ #menu .call-context { margin: -8px -8px 5px; }
128
+ #chat { width: 400px; max-height: 500px; display: flex; flex-direction: column; overflow: hidden; }
83
129
  #chat header { padding: 7px 9px 7px 15px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--cafe-border-subtle); }
84
130
  #chat header button { font-size: 12px; color: var(--cafe-text-secondary); }
85
- #transcript { height: 145px; overflow: auto; padding: 14px; line-height: 1.7; }
86
- #transcript p { margin: 0 0 10px; overflow-wrap: anywhere; white-space: pre-wrap; }
87
- #transcript p:not(.empty) { width: fit-content; max-width: 92%; padding: 7px 10px; border-radius: 9px; }
88
- #transcript p.user { margin-left: auto; background: var(--accent-50); }
89
- #transcript p.assistant { margin-right: auto; background: var(--cafe-surface); border: 1px solid var(--cafe-border-subtle); }
131
+ #chat-transcript { height: 230px; overflow: auto; padding: 14px; line-height: 1.7; font-size: 13px; }
132
+ #chat-transcript p { margin: 0 0 10px; overflow-wrap: anywhere; white-space: pre-wrap; }
133
+ #chat-transcript p:not(.empty) { width: fit-content; max-width: 92%; padding: 7px 10px; border-radius: 9px; }
134
+ #chat-transcript p.user { margin-left: auto; background: var(--desktop-rail); }
135
+ #chat-transcript p.assistant { margin-right: auto; background: var(--desktop-work); border: 1px solid var(--desktop-line); }
90
136
  .empty { color: var(--cafe-text-muted); font-size: 13px; }
91
137
  #compose { display: flex; align-items: flex-end; padding: 10px; gap: 8px; border-top: 1px solid var(--cafe-border-subtle); }
92
138
  #message { resize: none; border: 0; min-width: 0; flex: 1; background: transparent; padding: 5px; color: inherit; line-height: 1.5; }
93
139
  #chat small { color: var(--cafe-text-muted); font-size: 10px; padding: 0 14px 10px; }
140
+ .call-transcript { width: 420px; height: 210px; display: flex; flex-direction: column; overflow: hidden; }
141
+ .call-transcript[data-expanded="true"] { height: 500px; }
142
+ .transcript-call-context { flex: none; grid-template-columns: auto auto auto auto 1fr auto; padding: 7px 10px; }
143
+ .view-tag { border-radius: 999px; background: var(--desktop-rail); color: var(--desktop-body); padding: 2px 7px; font-size: 11px; }
144
+ #call-transcript-log { min-height: 0; flex: 1 1 auto; overflow-y: auto; padding: 8px 12px 5px; }
145
+ .call-transcript-row { margin: 0 0 7px; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.45; }
146
+ .call-transcript-row.voice.user { color: var(--desktop-muted); font-size: 13px; font-weight: 400; }
147
+ .call-transcript-row.voice.assistant { color: var(--desktop-ink); font-size: 16px; font-weight: 500; line-height: 1.5; }
148
+ .call-transcript-row.typed.user { width: fit-content; max-width: 88%; margin-left: auto; padding: 7px 11px; border-radius: 12px; background: var(--desktop-rail); color: var(--desktop-ink); font-size: 13px; }
149
+ .call-transcript-row[data-live="true"]::after { content: " 实时"; color: #8f4730; font-size: 11px; font-weight: 500; letter-spacing: .05em; }
150
+ .call-transcript:not([data-expanded="true"]) #call-transcript-log { max-height: 112px; }
151
+ .call-transcript:not([data-expanded="true"]) .call-transcript-row.voice.user { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
152
+ .call-transcript:not([data-expanded="true"]) .call-transcript-row.voice.assistant { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; }
153
+ .call-transcript[data-expanded="true"] #call-transcript-log[data-has-more="true"] { mask-image: linear-gradient(to bottom, transparent 0, black 28px); }
154
+ #call-compose { flex: none; display: flex; gap: 8px; padding: 8px 10px 10px; border-top: 1px solid #e5dfd5; }
155
+ #call-message { min-width: 0; flex: 1; height: 32px; border: 1px solid var(--desktop-line-strong); border-radius: 8px; background: var(--desktop-paper); color: var(--desktop-ink); padding: 6px 10px; }
156
+ #call-compose button { height: 32px; padding: 6px 12px; }
157
+ .new-content { position: absolute; right: 12px; bottom: 53px; border: 1px solid #d8d1c6 !important; background: #fffbf6 !important; color: #8f4730; box-shadow: 0 2px 8px #1817151a; font-size: 11px; }
158
+ #call-transcript-status, .call-transcript-foot { flex: none; color: #5f5d57; font-size: 11px; padding: 0 12px 7px; }
159
+ .call-transcript:not([data-expanded="true"]) .call-transcript-foot { display: none; }
160
+ .settings-panel { width: 360px; max-height: 500px; display: flex; flex-direction: column; overflow: hidden; }
161
+ .settings-head { flex: none; min-height: 44px; display: flex; align-items: center; gap: 4px; padding: 5px 8px; border-bottom: 1px solid var(--desktop-line); }
162
+ .settings-head button { width: 28px; padding: 5px; font-size: 20px; line-height: 1; }
163
+ .settings-head strong { font-size: 14px; }
164
+ .settings-loading, .settings-copy, .settings-empty { color: var(--desktop-muted); font-size: 12px; line-height: 1.55; }
165
+ .settings-loading { margin: 0; padding: 10px 16px; border-bottom: 1px solid var(--desktop-line); }
166
+ .settings-page { min-height: 0; overflow-y: auto; }
167
+ .settings-row { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-radius: 0 !important; border-bottom: 1px solid var(--desktop-line) !important; padding: 9px 16px !important; text-align: left; }
168
+ .settings-row > span:first-child, .settings-row strong { color: var(--desktop-ink); font-weight: 400; }
169
+ .settings-row small { display: block; margin-top: 2px; color: var(--desktop-muted); font-size: 11px; font-weight: 400; line-height: 1.35; }
170
+ .settings-value { margin-left: auto; color: var(--desktop-muted); font-size: 12px; }
171
+ .settings-row[data-drilldown] .settings-value::after { content: " ›"; }
172
+ .setting-switch i { position: relative; width: 27px; height: 16px; flex: none; border-radius: 999px; background: var(--desktop-line-strong); }
173
+ .setting-switch i::after { content: ""; position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; border-radius: 50%; background: white; box-shadow: 0 1px 2px #0003; transition: transform .15s; }
174
+ .setting-switch[data-checked="true"] i { background: var(--desktop-ink); }
175
+ .setting-switch[data-checked="true"] i::after { transform: translateX(11px); }
176
+ .setting-switch[data-settlement="unconfirmed"] i { display: none; }
177
+ .setting-switch[data-settlement="unconfirmed"]::after { content: "未确认"; color: var(--desktop-muted); font-size: 12px; }
178
+ .settings-footer { display: flex; justify-content: space-between; padding: 4px 7px; }
179
+ .settings-footer button { color: var(--desktop-muted); font-size: 12px; }
180
+ .settings-copy { margin: 0; padding: 10px 16px; border-bottom: 1px solid var(--desktop-line); }
181
+ .settings-roster { border-bottom: 1px solid var(--desktop-line); }
182
+ .settings-choice { width: 100%; min-height: 48px; display: grid; grid-template-columns: 1fr auto; padding: 7px 16px !important; border-radius: 0 !important; text-align: left; }
183
+ .settings-choice strong, .settings-choice span { grid-column: 1; }
184
+ .settings-choice strong { font-size: 13px; font-weight: 500; }
185
+ .settings-choice span { color: var(--desktop-muted); font-size: 11px; }
186
+ .settings-choice[data-selected="true"]::after { content: "✓"; grid-column: 2; grid-row: 1 / span 2; align-self: center; }
187
+ .settings-field { display: block; padding: 10px 16px; color: var(--desktop-muted); font-size: 11px; }
188
+ .settings-field textarea { width: 100%; margin-top: 6px; resize: none; border: 1px solid var(--desktop-line-strong); border-radius: 8px; background: var(--desktop-paper); color: var(--desktop-ink); padding: 8px 10px; line-height: 1.45; }
189
+ .settings-field > small { display: flex; justify-content: space-between; gap: 8px; margin-top: 5px; }
190
+ .settings-field[data-settlement="unconfirmed"]::after { content: "保存结果未确认"; display: block; margin-top: 5px; color: #8f4730; font-size: 11px; }
191
+ .settings-eyebrow { margin: 0; padding: 10px 16px 0; color: var(--desktop-muted); font-size: 11px; }
192
+ .settings-eyebrow + .settings-copy { padding-top: 3px; }
193
+ .settings-skins { display: grid; grid-template-columns: 1fr 1fr; border-bottom: 1px solid var(--desktop-line); }
194
+ .settings-skins button { min-height: 46px; border-radius: 0; border-bottom: 1px solid var(--desktop-rail); text-align: left; }
195
+ .settings-skins button[data-selected="true"] { background: var(--desktop-work); font-weight: 500; }
196
+ .settings-skins button[data-selected="true"]::after { content: " ✓"; float: right; }
197
+ .settings-size > div { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 8px; margin-top: 8px; }
198
+ .settings-size input { width: 100%; accent-color: var(--desktop-ink); }
199
+ .settings-confirm { padding: 16px; }
200
+ .settings-confirm > strong { display: block; font-size: 14px; }
201
+ .settings-confirm p { color: var(--desktop-body); font-size: 13px; line-height: 1.6; }
202
+ .settings-confirm > div { display: flex; justify-content: flex-end; gap: 8px; }
203
+ .settings-confirm #settings-confirm-apply { border: 1px solid var(--desktop-line-strong); background: var(--desktop-paper); }
204
+ .settings-notice { flex: none; margin: 8px 12px 0; border: 1px solid var(--desktop-line); border-radius: 8px; padding: 8px 10px; color: var(--desktop-muted); font-size: 11px; }
205
+ .settings-notice[data-kind="rejected"], .settings-notice[data-kind="reconciled_not_saved"], .settings-notice[data-kind="disable_failed"] { color: #8f4730; }
206
+ .settings-notice-actions { display: flex; justify-content: flex-end; padding: 0 8px; }
207
+ .settings-notice-actions button { color: var(--desktop-muted); font-size: 11px; }
208
+ .settings-empty { padding: 16px; }
94
209
  .sr-only { position: absolute; width: 1px; height: 1px; clip-path: inset(50%); overflow: hidden; }
95
210
  @media (prefers-reduced-motion: reduce) {
96
211
  #pet, #indicators button, #living-sit [data-layer], #pet[data-action="idle"] #living-sit [data-layer] { animation: none; }
@@ -99,10 +214,9 @@ button:disabled { opacity: .45; cursor: default; }
99
214
  #pet[data-skin="yanyan-codex"] { background-image: url("./skins/yanyan-codex.webp"); }
100
215
  #pet[data-skin="ragdoll-v1"] { background-image: url("./skins/ragdoll-v1.png"); animation: none; background-size: contain; background-repeat: no-repeat; background-position: center; }
101
216
  #pet[data-skin="yarn-ball"] { background-image: url("./skins/yarn-ball.png"); animation: none; background-size: contain; background-repeat: no-repeat; background-position: center; }
102
- #transcript { height: 230px; font-size: 13px; }
103
- #transcript p[data-author]::before { content: attr(data-author); display: block; color: var(--cafe-text-muted); font-size: 10px; margin-bottom: 2px; }
104
- #transcript p[data-companion]::after { content: attr(data-companion) " · " attr(data-companion-live) " · " attr(data-companion-deep); display: block; color: var(--cafe-text-muted); font-size: 9px; line-height: 1.35; margin-top: 4px; }
105
- #transcript p[data-live="true"] { border-left: 2px solid var(--cafe-accent); }
217
+ #chat-transcript p[data-author]::before { content: attr(data-author); display: block; color: var(--cafe-text-muted); font-size: 10px; margin-bottom: 2px; }
218
+ #chat-transcript p[data-companion]::after { content: attr(data-companion) " · " attr(data-companion-live) " · " attr(data-companion-deep); display: block; color: var(--cafe-text-muted); font-size: 9px; line-height: 1.35; margin-top: 4px; }
219
+ #chat-transcript p[data-live="true"] { border-left: 2px solid var(--cafe-accent); }
106
220
  #history { padding: 0; font-size: 10px; color: var(--cafe-text-secondary); }
107
221
  #decisions { width: 320px; min-height: 180px; padding: 8px 12px 12px; display: flex; flex-direction: column; overflow: hidden; }
108
222
  #decisions header { display: flex; align-items: center; justify-content: space-between; flex: none; }