codex-endpoint-switcher 1.0.1 → 1.1.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.
@@ -11,6 +11,7 @@ const endpointStorePath = path.join(codexRoot, "endpoint-presets.json");
11
11
  const endpointStatePath = path.join(codexRoot, "endpoint-switcher-state.json");
12
12
  const proxyPort = 3187;
13
13
  const proxyBaseUrl = `http://127.0.0.1:${proxyPort}/`;
14
+ const syncCodePrefix = "ces1:";
14
15
 
15
16
  function escapeRegExp(value) {
16
17
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -79,6 +80,123 @@ function validateEndpointPayload(payload) {
79
80
  return { note, url, key };
80
81
  }
81
82
 
83
+ function normalizeTimestamp(value, fallbackValue) {
84
+ const raw = String(value || "").trim();
85
+ if (!raw) {
86
+ return fallbackValue;
87
+ }
88
+
89
+ const parsed = new Date(raw);
90
+ if (Number.isNaN(parsed.getTime())) {
91
+ return fallbackValue;
92
+ }
93
+
94
+ return parsed.toISOString();
95
+ }
96
+
97
+ function encodeSyncCode(payload) {
98
+ return `${syncCodePrefix}${Buffer.from(JSON.stringify(payload), "utf8").toString("base64url")}`;
99
+ }
100
+
101
+ function decodeSyncCode(syncCode) {
102
+ const input = String(syncCode || "").trim();
103
+ if (!input) {
104
+ throw new Error("同步码不能为空。");
105
+ }
106
+
107
+ if (input.startsWith("{")) {
108
+ return JSON.parse(input);
109
+ }
110
+
111
+ if (input.startsWith(syncCodePrefix)) {
112
+ const encoded = input.slice(syncCodePrefix.length).trim();
113
+ if (!encoded) {
114
+ throw new Error("同步码格式不正确。");
115
+ }
116
+
117
+ return JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
118
+ }
119
+
120
+ throw new Error("同步码格式不正确。请使用系统生成的同步码,或粘贴 JSON 内容。");
121
+ }
122
+
123
+ function normalizeSyncPayload(payload) {
124
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
125
+ throw new Error("同步数据格式不正确。");
126
+ }
127
+
128
+ const schemaVersion = Number(payload.schemaVersion || payload.version || 0);
129
+ if (schemaVersion !== 1) {
130
+ throw new Error("当前只支持导入 v1 同步数据。");
131
+ }
132
+
133
+ const rawEndpoints = Array.isArray(payload.endpoints) ? payload.endpoints : [];
134
+ if (rawEndpoints.length === 0) {
135
+ throw new Error("同步数据里没有可导入的连接。");
136
+ }
137
+
138
+ const usedIds = new Set();
139
+ const endpoints = rawEndpoints.map((item) => {
140
+ const normalized = validateEndpointPayload(item);
141
+ const fallbackTimestamp = new Date().toISOString();
142
+ let id = String(item.id || "").trim();
143
+
144
+ if (!id || usedIds.has(id)) {
145
+ id = randomUUID();
146
+ }
147
+
148
+ usedIds.add(id);
149
+
150
+ return {
151
+ id,
152
+ note: normalized.note,
153
+ url: normalized.url,
154
+ key: normalized.key,
155
+ createdAt: normalizeTimestamp(item.createdAt, fallbackTimestamp),
156
+ updatedAt: normalizeTimestamp(item.updatedAt, fallbackTimestamp),
157
+ };
158
+ });
159
+
160
+ return {
161
+ schemaVersion,
162
+ exportedAt: normalizeTimestamp(payload.exportedAt, new Date().toISOString()),
163
+ activeEndpointId: String(payload.activeEndpointId || "").trim(),
164
+ endpoints,
165
+ };
166
+ }
167
+
168
+ function buildSyncPayload(endpoints, activeEndpointId) {
169
+ return {
170
+ schemaVersion: 1,
171
+ exportedAt: new Date().toISOString(),
172
+ source: "codex-endpoint-switcher",
173
+ activeEndpointId: String(activeEndpointId || "").trim(),
174
+ endpoints: endpoints.map((endpoint) => ({
175
+ id: endpoint.id,
176
+ note: endpoint.note,
177
+ url: endpoint.url,
178
+ key: endpoint.key,
179
+ createdAt: endpoint.createdAt,
180
+ updatedAt: endpoint.updatedAt,
181
+ })),
182
+ };
183
+ }
184
+
185
+ function findEndpointMatchIndex(endpoints, importedEndpoint) {
186
+ const byId = endpoints.findIndex((item) => item.id === importedEndpoint.id);
187
+ if (byId !== -1) {
188
+ return byId;
189
+ }
190
+
191
+ return endpoints.findIndex(
192
+ (item) => item.note === importedEndpoint.note && item.url === importedEndpoint.url,
193
+ );
194
+ }
195
+
196
+ function resolveImportedEndpointId(endpoints, importedId) {
197
+ return endpoints.find((item) => item.id === importedId)?.id || "";
198
+ }
199
+
82
200
  async function ensureDirectory(targetPath) {
83
201
  await fs.mkdir(targetPath, { recursive: true });
84
202
  }
@@ -302,6 +420,26 @@ async function backupCurrentState() {
302
420
  };
303
421
  }
304
422
 
423
+ async function backupEndpointData() {
424
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
425
+ const endpointStoreBackupPath = path.join(
426
+ backupDir,
427
+ `endpoint-presets.json.sync-${timestamp}.bak`,
428
+ );
429
+ const endpointStateBackupPath = path.join(
430
+ backupDir,
431
+ `endpoint-switcher-state.json.sync-${timestamp}.bak`,
432
+ );
433
+
434
+ await fs.copyFile(endpointStorePath, endpointStoreBackupPath);
435
+ await fs.copyFile(endpointStatePath, endpointStateBackupPath);
436
+
437
+ return {
438
+ endpointStoreBackupPath,
439
+ endpointStateBackupPath,
440
+ };
441
+ }
442
+
305
443
  function buildEndpointResponse(endpoint, activeId) {
306
444
  return {
307
445
  id: endpoint.id,
@@ -582,6 +720,114 @@ async function enableProxyMode() {
582
720
  };
583
721
  }
584
722
 
723
+ /**
724
+ * 导出当前保存的连接为可跨机器粘贴传输的同步码。
725
+ */
726
+ async function exportSyncPackage() {
727
+ const context = await resolveActiveEndpointContext();
728
+ const payload = buildSyncPayload(context.endpoints, context.activeEndpointId);
729
+
730
+ return {
731
+ syncCode: encodeSyncCode(payload),
732
+ exportedAt: payload.exportedAt,
733
+ endpointCount: payload.endpoints.length,
734
+ activeEndpointId: payload.activeEndpointId,
735
+ endpoints: context.endpoints.map((item) => ({
736
+ id: item.id,
737
+ note: item.note,
738
+ url: item.url,
739
+ maskedKey: maskKey(item.key),
740
+ isActive: item.id === context.activeEndpointId,
741
+ })),
742
+ };
743
+ }
744
+
745
+ /**
746
+ * 导入同步码,支持合并现有连接或直接覆盖本机连接列表。
747
+ */
748
+ async function importSyncPackage(syncCode, mode = "merge") {
749
+ const safeMode = String(mode || "merge").trim().toLowerCase();
750
+ if (!["merge", "replace"].includes(safeMode)) {
751
+ throw new Error("导入模式不正确,只支持 merge 或 replace。");
752
+ }
753
+
754
+ const importedPayload = normalizeSyncPayload(decodeSyncCode(syncCode));
755
+ const [existingEndpoints, existingState] = await Promise.all([
756
+ readEndpointStore(),
757
+ readEndpointState(),
758
+ ]);
759
+
760
+ const workingEndpoints = safeMode === "replace" ? [] : existingEndpoints.slice();
761
+ const importIdMapping = new Map();
762
+ let addedCount = 0;
763
+ let updatedCount = 0;
764
+
765
+ for (const importedEndpoint of importedPayload.endpoints) {
766
+ const matchIndex = findEndpointMatchIndex(workingEndpoints, importedEndpoint);
767
+
768
+ if (matchIndex !== -1) {
769
+ const currentEndpoint = workingEndpoints[matchIndex];
770
+ workingEndpoints[matchIndex] = {
771
+ ...currentEndpoint,
772
+ note: importedEndpoint.note,
773
+ url: importedEndpoint.url,
774
+ key: importedEndpoint.key,
775
+ createdAt: currentEndpoint.createdAt || importedEndpoint.createdAt,
776
+ updatedAt: importedEndpoint.updatedAt,
777
+ };
778
+ importIdMapping.set(importedEndpoint.id, workingEndpoints[matchIndex].id);
779
+ updatedCount += 1;
780
+ continue;
781
+ }
782
+
783
+ let nextId = importedEndpoint.id;
784
+ while (resolveImportedEndpointId(workingEndpoints, nextId)) {
785
+ nextId = randomUUID();
786
+ }
787
+
788
+ const nextEndpoint = {
789
+ ...importedEndpoint,
790
+ id: nextId,
791
+ };
792
+
793
+ workingEndpoints.push(nextEndpoint);
794
+ importIdMapping.set(importedEndpoint.id, nextEndpoint.id);
795
+ addedCount += 1;
796
+ }
797
+
798
+ const currentActiveStillExists = workingEndpoints.some(
799
+ (item) => item.id === existingState.activeEndpointId,
800
+ );
801
+ const importedActiveId = importIdMapping.get(importedPayload.activeEndpointId) || "";
802
+ const nextActiveId = currentActiveStillExists
803
+ ? existingState.activeEndpointId
804
+ : importedActiveId || (workingEndpoints[0] ? workingEndpoints[0].id : "");
805
+ const activeEndpoint = workingEndpoints.find((item) => item.id === nextActiveId) || null;
806
+
807
+ const backupPaths = await backupEndpointData();
808
+ await writeEndpointStore(workingEndpoints);
809
+ await writeEndpointState({
810
+ activeEndpointId: nextActiveId,
811
+ proxyModeEnabled: existingState.proxyModeEnabled,
812
+ });
813
+
814
+ return {
815
+ ...backupPaths,
816
+ mode: safeMode,
817
+ importedCount: importedPayload.endpoints.length,
818
+ addedCount,
819
+ updatedCount,
820
+ totalEndpoints: workingEndpoints.length,
821
+ activeEndpointId: nextActiveId,
822
+ activeEndpointNote: activeEndpoint ? activeEndpoint.note : "",
823
+ importedActiveApplied: Boolean(importedActiveId && importedActiveId === nextActiveId),
824
+ message:
825
+ safeMode === "merge"
826
+ ? "已合并导入同步数据。当前连接不会被强制切换,除非原当前连接已不存在。"
827
+ : "已覆盖本机连接列表。若原当前连接已不存在,则会切到同步数据里的当前连接。",
828
+ };
829
+ }
830
+
585
831
  function getManagedPaths() {
586
832
  return {
587
833
  codexRoot,
@@ -616,9 +862,11 @@ module.exports = {
616
862
  createEndpoint,
617
863
  deleteEndpoint,
618
864
  enableProxyMode,
865
+ exportSyncPackage,
619
866
  getCurrentEndpointSummary,
620
867
  getManagedPaths,
621
868
  getProxyTarget,
869
+ importSyncPackage,
622
870
  listEndpoints,
623
871
  proxyBaseUrl,
624
872
  proxyPort,
@@ -122,6 +122,77 @@
122
122
  <button id="clearFormButton" type="button" class="ghost-button">清空表单</button>
123
123
  </div>
124
124
  </form>
125
+
126
+ <div class="stack-form stack-form-alt">
127
+ <div>
128
+ <p class="panel-kicker">Cloud Sync</p>
129
+ <h3>账号配置同步</h3>
130
+ </div>
131
+ <label class="field">
132
+ <span>同步服务器</span>
133
+ <input
134
+ id="cloudServerUrl"
135
+ type="text"
136
+ placeholder="例如:http://127.0.0.1:3190"
137
+ autocomplete="off"
138
+ />
139
+ </label>
140
+ <label class="field">
141
+ <span>账号</span>
142
+ <input
143
+ id="cloudUsername"
144
+ type="text"
145
+ placeholder="例如:yourname"
146
+ autocomplete="off"
147
+ />
148
+ </label>
149
+ <label class="field">
150
+ <span>密码</span>
151
+ <input
152
+ id="cloudPassword"
153
+ type="password"
154
+ placeholder="输入同步账号密码"
155
+ autocomplete="off"
156
+ />
157
+ </label>
158
+ <p class="field-hint">
159
+ 账号空间里保存的是你的连接配置。推送是把本机连接上传到账号,拉取是把账号里的连接同步回本机。
160
+ </p>
161
+ <div class="sync-status-grid">
162
+ <article class="sync-status-card">
163
+ <span class="metric-label">登录状态</span>
164
+ <strong id="cloudAuthStatus">未登录</strong>
165
+ </article>
166
+ <article class="sync-status-card">
167
+ <span class="metric-label">服务器</span>
168
+ <strong id="cloudRemoteStatus">未设置</strong>
169
+ </article>
170
+ <article class="sync-status-card sync-status-card-wide">
171
+ <span class="metric-label">同步记录</span>
172
+ <strong id="cloudLastSyncStatus">还没有推送或拉取记录</strong>
173
+ </article>
174
+ </div>
175
+ <div class="action-row">
176
+ <button id="cloudRegisterButton" type="button" class="secondary-button">
177
+ 注册账号
178
+ </button>
179
+ <button id="cloudLoginButton" type="button" class="primary-button">
180
+ 登录账号
181
+ </button>
182
+ <button id="cloudLogoutButton" type="button" class="ghost-button">退出登录</button>
183
+ </div>
184
+ <div class="action-row">
185
+ <button id="cloudPushButton" type="button" class="secondary-button">
186
+ 推送当前连接
187
+ </button>
188
+ <button id="cloudPullMergeButton" type="button" class="primary-button">
189
+ 合并拉取
190
+ </button>
191
+ <button id="cloudPullReplaceButton" type="button" class="danger-button">
192
+ 覆盖拉取
193
+ </button>
194
+ </div>
195
+ </div>
125
196
  </section>
126
197
 
127
198
  <section class="panel profiles-panel">
@@ -2,6 +2,7 @@ const state = {
2
2
  current: null,
3
3
  endpoints: [],
4
4
  paths: null,
5
+ cloud: null,
5
6
  };
6
7
 
7
8
  function createWebBridge() {
@@ -56,6 +57,37 @@ function createWebBridge() {
56
57
  method: "POST",
57
58
  });
58
59
  },
60
+ getCloudSyncStatus() {
61
+ return request("/api/cloud/status");
62
+ },
63
+ registerCloudAccount(payload) {
64
+ return request("/api/cloud/register", {
65
+ method: "POST",
66
+ body: JSON.stringify(payload),
67
+ });
68
+ },
69
+ loginCloudAccount(payload) {
70
+ return request("/api/cloud/login", {
71
+ method: "POST",
72
+ body: JSON.stringify(payload),
73
+ });
74
+ },
75
+ logoutCloudAccount() {
76
+ return request("/api/cloud/logout", {
77
+ method: "POST",
78
+ });
79
+ },
80
+ pushCloudConfig() {
81
+ return request("/api/cloud/push", {
82
+ method: "POST",
83
+ });
84
+ },
85
+ pullCloudConfig(payload) {
86
+ return request("/api/cloud/pull", {
87
+ method: "POST",
88
+ body: JSON.stringify(payload),
89
+ });
90
+ },
59
91
  getPaths() {
60
92
  return request("/api/paths");
61
93
  },
@@ -96,6 +128,19 @@ function setStatus(message, type = "info") {
96
128
  statusBox.className = `status-box ${type}`;
97
129
  }
98
130
 
131
+ function syncFieldValue(selector, value) {
132
+ const input = $(selector);
133
+ if (!input) {
134
+ return;
135
+ }
136
+
137
+ if (document.activeElement === input && String(input.value || "").trim()) {
138
+ return;
139
+ }
140
+
141
+ input.value = value || "";
142
+ }
143
+
99
144
  function resetForm() {
100
145
  $("#endpointId").value = "";
101
146
  $("#endpointNote").value = "";
@@ -140,6 +185,49 @@ function renderCurrent() {
140
185
  : "当前未匹配到已保存连接";
141
186
  }
142
187
 
188
+ function renderCloudStatus() {
189
+ const cloud = state.cloud;
190
+ if (!cloud) {
191
+ return;
192
+ }
193
+
194
+ syncFieldValue("#cloudServerUrl", cloud.serverUrl);
195
+ syncFieldValue("#cloudUsername", cloud.username);
196
+
197
+ const authText = !cloud.serverUrl
198
+ ? "未配置同步服务器"
199
+ : cloud.loggedIn
200
+ ? `已登录:${cloud.remoteUser || cloud.username}`
201
+ : cloud.hasToken
202
+ ? "本地已保存登录态,但远端校验未通过"
203
+ : "未登录";
204
+
205
+ const remoteText = cloud.serverUrl
206
+ ? cloud.lastError
207
+ ? `${cloud.serverUrl} · ${cloud.lastError}`
208
+ : cloud.serverUrl
209
+ : "未设置";
210
+
211
+ const syncStatusParts = [];
212
+ if (cloud.lastPushAt) {
213
+ syncStatusParts.push(`最近推送:${formatDateTime(cloud.lastPushAt)}`);
214
+ }
215
+ if (cloud.lastPullAt) {
216
+ syncStatusParts.push(`最近拉取:${formatDateTime(cloud.lastPullAt)}`);
217
+ }
218
+ if (!syncStatusParts.length) {
219
+ syncStatusParts.push("还没有推送或拉取记录");
220
+ }
221
+
222
+ $("#cloudAuthStatus").textContent = authText;
223
+ $("#cloudRemoteStatus").textContent = remoteText;
224
+ $("#cloudLastSyncStatus").textContent = syncStatusParts.join(" / ");
225
+ $("#cloudLogoutButton").disabled = !cloud.hasToken;
226
+ $("#cloudPushButton").disabled = !cloud.loggedIn;
227
+ $("#cloudPullMergeButton").disabled = !cloud.loggedIn;
228
+ $("#cloudPullReplaceButton").disabled = !cloud.loggedIn;
229
+ }
230
+
143
231
  function createEndpointCard(endpoint) {
144
232
  const card = document.createElement("article");
145
233
  card.className = `profile-card ${endpoint.isActive ? "active" : ""}`;
@@ -230,18 +318,21 @@ function renderEndpoints() {
230
318
 
231
319
  async function refreshDashboard(showMessage = true) {
232
320
  try {
233
- const [current, endpoints, paths] = await Promise.all([
321
+ const [current, endpoints, paths, cloud] = await Promise.all([
234
322
  bridge.getCurrentConfig(),
235
323
  bridge.listEndpoints(),
236
324
  bridge.getPaths(),
325
+ bridge.getCloudSyncStatus(),
237
326
  ]);
238
327
 
239
328
  state.current = current;
240
329
  state.endpoints = endpoints;
241
330
  state.paths = paths;
331
+ state.cloud = cloud;
242
332
 
243
333
  renderCurrent();
244
334
  renderEndpoints();
335
+ renderCloudStatus();
245
336
 
246
337
  if (showMessage) {
247
338
  setStatus("已刷新当前连接状态。", "success");
@@ -289,6 +380,91 @@ async function handleEnableProxyMode() {
289
380
  }
290
381
  }
291
382
 
383
+ function getCloudFormPayload() {
384
+ return {
385
+ serverUrl: $("#cloudServerUrl").value.trim(),
386
+ username: $("#cloudUsername").value.trim(),
387
+ password: $("#cloudPassword").value,
388
+ };
389
+ }
390
+
391
+ async function handleCloudRegister() {
392
+ try {
393
+ setStatus("正在注册同步账号 ...", "info");
394
+ const payload = getCloudFormPayload();
395
+ const result = await bridge.registerCloudAccount(payload);
396
+ $("#cloudPassword").value = "";
397
+ await refreshDashboard(false);
398
+ setStatus(result.message || "账号注册成功。", "success");
399
+ } catch (error) {
400
+ setStatus(`注册失败:${error.message}`, "error");
401
+ }
402
+ }
403
+
404
+ async function handleCloudLogin() {
405
+ try {
406
+ setStatus("正在登录同步账号 ...", "info");
407
+ const payload = getCloudFormPayload();
408
+ const result = await bridge.loginCloudAccount(payload);
409
+ $("#cloudPassword").value = "";
410
+ await refreshDashboard(false);
411
+ setStatus(result.message || "账号登录成功。", "success");
412
+ } catch (error) {
413
+ setStatus(`登录失败:${error.message}`, "error");
414
+ }
415
+ }
416
+
417
+ async function handleCloudLogout() {
418
+ try {
419
+ setStatus("正在退出同步账号 ...", "info");
420
+ const result = await bridge.logoutCloudAccount();
421
+ await refreshDashboard(false);
422
+ setStatus(result.message || "已退出同步账号。", "success");
423
+ } catch (error) {
424
+ setStatus(`退出失败:${error.message}`, "error");
425
+ }
426
+ }
427
+
428
+ async function handleCloudPush() {
429
+ try {
430
+ setStatus("正在把当前连接推送到账号空间 ...", "info");
431
+ const result = await bridge.pushCloudConfig();
432
+ await refreshDashboard(false);
433
+ setStatus(
434
+ result.message || `已推送 ${result.endpointCount || 0} 条连接到账号空间。`,
435
+ "success",
436
+ );
437
+ } catch (error) {
438
+ setStatus(`推送失败:${error.message}`, "error");
439
+ }
440
+ }
441
+
442
+ async function handleCloudPull(mode) {
443
+ if (mode === "replace") {
444
+ const confirmed = window.confirm(
445
+ "覆盖拉取会直接替换本机保存的连接列表。拉取前会自动备份,确定继续吗?",
446
+ );
447
+ if (!confirmed) {
448
+ return;
449
+ }
450
+ }
451
+
452
+ try {
453
+ setStatus(mode === "merge" ? "正在从账号空间合并拉取 ..." : "正在从账号空间覆盖拉取 ...", "info");
454
+ const result = await bridge.pullCloudConfig({ mode });
455
+ await refreshDashboard(false);
456
+ const activeHint = result.activeEndpointNote
457
+ ? `当前生效连接记录:${result.activeEndpointNote}。`
458
+ : "当前没有匹配到生效连接记录。";
459
+ setStatus(
460
+ `${result.message || "拉取完成。"} 新增 ${result.addedCount} 条,更新 ${result.updatedCount} 条。${activeHint}`,
461
+ "success",
462
+ );
463
+ } catch (error) {
464
+ setStatus(`拉取失败:${error.message}`, "error");
465
+ }
466
+ }
467
+
292
468
  async function handleSubmitEndpoint(event) {
293
469
  event.preventDefault();
294
470
  const id = $("#endpointId").value.trim();
@@ -358,6 +534,16 @@ function bindEvents() {
358
534
  setStatus("已清空表单。", "info");
359
535
  });
360
536
  $("#enableProxyModeButton").addEventListener("click", handleEnableProxyMode);
537
+ $("#cloudRegisterButton").addEventListener("click", handleCloudRegister);
538
+ $("#cloudLoginButton").addEventListener("click", handleCloudLogin);
539
+ $("#cloudLogoutButton").addEventListener("click", handleCloudLogout);
540
+ $("#cloudPushButton").addEventListener("click", handleCloudPush);
541
+ $("#cloudPullMergeButton").addEventListener("click", () => {
542
+ handleCloudPull("merge");
543
+ });
544
+ $("#cloudPullReplaceButton").addEventListener("click", () => {
545
+ handleCloudPull("replace");
546
+ });
361
547
  $("#openCodexRootButton").addEventListener("click", () => {
362
548
  if (state.paths?.codexRoot) {
363
549
  handleOpenPath(state.paths.codexRoot, " Codex 目录");
@@ -33,7 +33,8 @@ body {
33
33
  }
34
34
 
35
35
  button,
36
- input {
36
+ input,
37
+ textarea {
37
38
  font: inherit;
38
39
  }
39
40
 
@@ -259,7 +260,8 @@ code {
259
260
 
260
261
  input[type="text"],
261
262
  input[type="password"],
262
- input[type="file"] {
263
+ input[type="file"],
264
+ textarea {
263
265
  width: 100%;
264
266
  min-height: 46px;
265
267
  padding: 10px 14px;
@@ -269,6 +271,12 @@ input[type="file"] {
269
271
  color: var(--text);
270
272
  }
271
273
 
274
+ textarea {
275
+ min-height: 168px;
276
+ resize: vertical;
277
+ line-height: 1.6;
278
+ }
279
+
272
280
  input[readonly] {
273
281
  color: var(--muted);
274
282
  background: rgba(249, 244, 238, 0.95);
@@ -300,6 +308,29 @@ input[readonly] {
300
308
  gap: 10px;
301
309
  }
302
310
 
311
+ .sync-status-grid {
312
+ display: grid;
313
+ grid-template-columns: repeat(2, minmax(0, 1fr));
314
+ gap: 10px;
315
+ }
316
+
317
+ .sync-status-card {
318
+ padding: 14px;
319
+ border-radius: 14px;
320
+ background: rgba(255, 255, 255, 0.72);
321
+ border: 1px solid rgba(47, 36, 30, 0.08);
322
+ }
323
+
324
+ .sync-status-card strong {
325
+ display: block;
326
+ margin-top: 8px;
327
+ word-break: break-all;
328
+ }
329
+
330
+ .sync-status-card-wide {
331
+ grid-column: span 2;
332
+ }
333
+
303
334
  .metric-actions {
304
335
  margin-top: 12px;
305
336
  }
@@ -508,4 +539,12 @@ input[readonly] {
508
539
  .hero-side {
509
540
  min-height: 180px;
510
541
  }
542
+
543
+ .sync-status-grid {
544
+ grid-template-columns: 1fr;
545
+ }
546
+
547
+ .sync-status-card-wide {
548
+ grid-column: auto;
549
+ }
511
550
  }