@lemoncat7/dsh-knowledge 1.0.10 → 1.0.11

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.
package/web/app.js CHANGED
@@ -61,6 +61,7 @@ const state = {
61
61
  editor: null,
62
62
  },
63
63
  candidates: [],
64
+ candidateTargets: new Map(),
64
65
  candidateStatus: 'pending',
65
66
  settings: { writebackPolicy: 'conservative', updatedAt: '' },
66
67
  settingsSaving: false,
@@ -93,6 +94,7 @@ function installHostThemeBridge() {
93
94
  }
94
95
  root.dataset.dshHostTheme = 'true'
95
96
  root.style.colorScheme = message.colorScheme
97
+ root.style.setProperty('--dialog-surface', message.colorScheme === 'dark' ? '#1c1c1e' : '#ffffff')
96
98
  document.querySelector('meta[name="color-scheme"]')?.setAttribute('content', message.colorScheme)
97
99
  const background = root.style.getPropertyValue('--bg')
98
100
  if (background) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', background)
@@ -310,7 +312,7 @@ function renderLogin(message = '') {
310
312
 
311
313
  function signOut() {
312
314
  sessionStorage.removeItem(TOKEN_KEY)
313
- Object.assign(state, { token: '', stats: null, overview: null, knowledgeBases: [], mounts: [], resolvedMounts: [], entries: [], documents: [], candidates: [], settings: { writebackPolicy: 'conservative', updatedAt: '' }, tokens: [] })
315
+ Object.assign(state, { token: '', stats: null, overview: null, knowledgeBases: [], mounts: [], resolvedMounts: [], entries: [], documents: [], candidates: [], candidateTargets: new Map(), settings: { writebackPolicy: 'conservative', updatedAt: '' }, tokens: [] })
314
316
  if (AUTH_MODE === 'same-origin') void boot()
315
317
  else renderLogin()
316
318
  }
@@ -543,6 +545,12 @@ async function loadCandidates() {
543
545
  ensureKnowledgeBases(),
544
546
  ])
545
547
  state.candidates = candidates
548
+ const targetIds = [...new Set(candidates.map(candidate => candidate.targetId).filter(Boolean))]
549
+ const targets = await Promise.all(targetIds.map(async id => {
550
+ try { return [id, await api(`entries/${encodeURIComponent(id)}`)] }
551
+ catch { return [id, null] }
552
+ }))
553
+ state.candidateTargets = new Map(targets)
546
554
  if (!state.stats) await refreshStats()
547
555
  }
548
556
 
@@ -756,11 +764,11 @@ function renderKnowledgeBases() {
756
764
  type: 'button', role: 'tab', class: 'tab', 'aria-selected': String(state.knowledgeBaseView === id),
757
765
  onClick: () => { state.knowledgeBaseView = id; renderShell() },
758
766
  }, element('span', {}, label), element('span', { class: 'tab-count' }, count)))),
759
- renderWritebackPolicyControl(),
767
+ actionButton('默认回写模型', () => openGlobalWritebackModelEditor(), 'ghost small'),
760
768
  ),
761
- element('p', {}, state.settings.writebackPolicy === 'conservative'
762
- ? '严谨模式只保留明确或已验证的长期知识,宁可漏记也不堆积噪声。'
763
- : '主动模式会更积极地沉淀可复用内容,仍会排除敏感信息和临时输出。'),
769
+ element('p', {}, state.settings.writebackProvider && state.settings.writebackModel
770
+ ? `未单独指定模型的知识库使用 ${state.settings.writebackProvider} / ${state.settings.writebackModel}。`
771
+ : '未单独指定模型的知识库跟随当前会话模型。'),
764
772
  )
765
773
  return element('div', { class: 'bases-page' },
766
774
  switcher,
@@ -810,41 +818,23 @@ function renderKnowledgeBases() {
810
818
  )
811
819
  }
812
820
 
813
- function renderWritebackPolicyControl() {
814
- const policies = [
815
- ['conservative', '严谨'],
816
- ['proactive', '主动'],
817
- ]
818
- return element('div', {
819
- class: 'writeback-policy-control',
820
- 'aria-label': '全局回写策略',
821
- 'aria-busy': String(state.settingsSaving),
822
- },
823
- element('span', { class: 'writeback-policy-label' }, '回写策略'),
824
- element('div', { class: 'writeback-policy-segments', role: 'radiogroup', 'aria-label': '选择全局回写策略' }, policies.map(([value, label]) => element('button', {
825
- type: 'button', role: 'radio', class: 'writeback-policy-option',
826
- 'aria-checked': String(state.settings.writebackPolicy === value),
827
- disabled: state.settingsSaving,
828
- onClick: () => void updateWritebackPolicy(value),
829
- }, label))))
830
- }
831
-
832
- async function updateWritebackPolicy(writebackPolicy) {
833
- if (state.settingsSaving || state.settings.writebackPolicy === writebackPolicy) return
834
- const previous = state.settings
835
- state.settings = { ...state.settings, writebackPolicy }
836
- state.settingsSaving = true
837
- renderShell()
838
- try {
839
- state.settings = await api('settings', { method: 'PUT', body: { patch: { writebackPolicy } } })
840
- showToast(`全局回写策略已切换为${writebackPolicy === 'conservative' ? '严谨' : '主动'}模式`)
841
- } catch (error) {
842
- state.settings = previous
843
- showToast(friendlyError(error), 'error')
844
- } finally {
845
- state.settingsSaving = false
846
- renderShell()
847
- }
821
+ function openGlobalWritebackModelEditor() {
822
+ const custom = element('input', { type: 'checkbox', checked: Boolean(state.settings.writebackProvider && state.settings.writebackModel) })
823
+ const provider = formField('模型提供方(Provider)', 'input', state.settings.writebackProvider || '', { maxlength: 100 })
824
+ const model = formField('模型名称(Model)', 'input', state.settings.writebackModel || '', { maxlength: 200 })
825
+ const form = element('form', { class: 'form-grid' },
826
+ element('label', { class: 'check-option span-2' }, custom, element('span', {}, element('strong', {}, '使用全局默认模型'), element('small', {}, '关闭后,未单独配置的知识库跟随当前会话模型。'))),
827
+ provider.wrapper, model.wrapper,
828
+ )
829
+ const sync = () => { provider.input.disabled = model.input.disabled = !custom.checked; provider.input.required = model.input.required = custom.checked }
830
+ custom.addEventListener('change', sync); sync()
831
+ openSheet({ title: '默认回写模型', description: '知识库自己的模型优先于这里;这里留空则跟随当前会话。', body: form, primaryLabel: '保存', onPrimary: async () => {
832
+ if (!form.reportValidity()) return false
833
+ state.settings = await api('settings', { method: 'PUT', body: { patch: custom.checked
834
+ ? { writebackProvider: provider.input.value.trim(), writebackModel: model.input.value.trim() }
835
+ : { writebackProvider: null, writebackModel: null } } })
836
+ showToast('默认回写模型已更新。'); renderShell(); return true
837
+ } })
848
838
  }
849
839
 
850
840
  function contextPill(label, value) {
@@ -869,7 +859,8 @@ function renderKnowledgeBaseCard(base) {
869
859
  : element('span', { class: 'tag is-empty' }, '无默认标签'),
870
860
  hiddenTagCount ? element('span', { class: 'tag' }, `+${hiddenTagCount}`) : null),
871
861
  element('div', { class: 'base-card-meta' },
872
- element('span', {}, element('strong', {}, '回写模型'), base.writebackProvider && base.writebackModel ? `${base.writebackProvider} / ${base.writebackModel}` : '跟随当前会话'),
862
+ element('span', {}, element('strong', {}, '回写策略'), base.writebackPolicy === 'proactive' ? '主动' : '严谨'),
863
+ element('span', {}, element('strong', {}, '回写模型'), base.writebackProvider && base.writebackModel ? `${base.writebackProvider} / ${base.writebackModel}` : state.settings.writebackProvider && state.settings.writebackModel ? `默认 · ${state.settings.writebackProvider} / ${state.settings.writebackModel}` : '跟随当前会话'),
873
864
  base.extractionInstructions ? element('span', {}, element('strong', {}, '提取规则'), '已设置') : null,
874
865
  ),
875
866
  element('div', { class: 'base-card-actions' },
@@ -1283,6 +1274,9 @@ function renderCandidates() {
1283
1274
 
1284
1275
  function renderCandidateCard(candidate) {
1285
1276
  const pending = candidate.status === 'pending'
1277
+ const conflictTarget = candidate.action === 'conflict' && candidate.targetId
1278
+ ? state.candidateTargets.get(candidate.targetId)
1279
+ : null
1286
1280
  const targetDocument = candidate.action === 'create'
1287
1281
  ? `新文档“${candidate.draft.title}”`
1288
1282
  : `文档“${candidate.draft.title}”`
@@ -1298,8 +1292,13 @@ function renderCandidateCard(candidate) {
1298
1292
  ),
1299
1293
  badge(STATUS_LABELS[candidate.status], candidate.status === 'approved' ? 'success' : candidate.status === 'rejected' ? 'danger' : 'warning'),
1300
1294
  ),
1301
- element('div', { class: 'candidate-body' },
1302
- element('p', { class: 'candidate-content' }, candidate.draft.body),
1295
+ element('div', { class: `candidate-body${candidate.action === 'conflict' ? ' is-conflict' : ''}` },
1296
+ candidate.action === 'conflict'
1297
+ ? element('div', { class: 'candidate-comparison' },
1298
+ element('section', {}, element('strong', {}, '当前知识'), element('pre', {}, conflictTarget?.body || '目标文档不可用,请刷新后重试。')),
1299
+ element('section', {}, element('strong', {}, '候选内容'), element('pre', {}, candidate.draft.body)),
1300
+ )
1301
+ : element('p', { class: 'candidate-content' }, candidate.draft.body),
1303
1302
  element('div', { class: 'candidate-reason' },
1304
1303
  element('strong', {}, '文档变更'),
1305
1304
  element('span', { class: 'candidate-target' }, `${candidate.action === 'create' ? '创建' : '更新'}${targetDocument}`),
@@ -1310,7 +1309,9 @@ function renderCandidateCard(candidate) {
1310
1309
  pending ? element('div', {},
1311
1310
  actionButton('拒绝', () => reviewCandidate(candidate, 'reject'), 'danger small'),
1312
1311
  actionButton('编辑并通过', () => openEntryEditor(undefined, candidate), 'small'),
1313
- actionButton('通过', () => reviewCandidate(candidate, 'approve'), 'primary small'),
1312
+ candidate.action === 'conflict'
1313
+ ? actionButton('合并追加', () => reviewCandidate(candidate, 'approve', 'merge'), 'primary small')
1314
+ : actionButton('通过', () => reviewCandidate(candidate, 'approve'), 'primary small'),
1314
1315
  ) : null,
1315
1316
  ),
1316
1317
  )
@@ -1399,12 +1400,16 @@ function parseTags(value) {
1399
1400
  }
1400
1401
 
1401
1402
  function openKnowledgeBaseEditor(base) {
1402
- const source = base || { name: '', description: '', defaultTags: [], extractionInstructions: '' }
1403
+ const source = base || { name: '', description: '', defaultTags: [], extractionInstructions: '', writebackPolicy: 'conservative' }
1403
1404
  const form = element('form', { class: 'form-grid' })
1404
1405
  const name = formField('名称', 'input', source.name, { required: true, maxlength: 100, placeholder: '例如:项目规范' })
1405
1406
  const description = formField('回写匹配描述', 'textarea', source.description, { maxlength: 2000, placeholder: '描述什么样的对话才属于这个库。例如:只记录 dsh-knowledge 项目的架构决策和部署规范' })
1406
1407
  const tags = formField('默认标签', 'input', source.defaultTags.join(', '), { placeholder: 'project-rule, backend' })
1407
1408
  const instructions = formField('提取要求', 'textarea', source.extractionInstructions, { maxlength: 4000, placeholder: '例如:只收录已确认、可跨会话复用的项目约定' })
1409
+ const policy = selectField('回写策略', [
1410
+ { value: 'conservative', label: '严谨(高置信度直写)' },
1411
+ { value: 'proactive', label: '主动(更积极沉淀)' },
1412
+ ], source.writebackPolicy || 'conservative')
1408
1413
  const customModel = element('input', { type: 'checkbox', checked: Boolean(source.writebackProvider && source.writebackModel) })
1409
1414
  const provider = formField('模型提供方(Provider)', 'input', source.writebackProvider || '', { maxlength: 100, placeholder: '例如:cli' })
1410
1415
  const model = formField('模型名称(Model)', 'input', source.writebackModel || '', { maxlength: 200, placeholder: '例如:kimi-k2.7-code' })
@@ -1419,7 +1424,7 @@ function openKnowledgeBaseEditor(base) {
1419
1424
  updateModelFields()
1420
1425
  for (const field of [name, description, tags, instructions]) field.wrapper.classList.add('span-2')
1421
1426
  form.append(
1422
- name.wrapper, description.wrapper, tags.wrapper, instructions.wrapper,
1427
+ name.wrapper, description.wrapper, tags.wrapper, instructions.wrapper, policy.wrapper,
1423
1428
  element('label', { class: 'check-option span-2' }, customModel, element('span', {},
1424
1429
  element('strong', {}, '为这个知识库指定回写模型'),
1425
1430
  element('small', {}, '关闭时自动使用当前会话正在使用的模型。'),
@@ -1438,6 +1443,7 @@ function openKnowledgeBaseEditor(base) {
1438
1443
  description: description.input.value.trim(),
1439
1444
  defaultTags: parseTags(tags.input.value),
1440
1445
  extractionInstructions: instructions.input.value.trim(),
1446
+ writebackPolicy: policy.input.value,
1441
1447
  writebackProvider: customModel.checked ? provider.input.value.trim() : '',
1442
1448
  writebackModel: customModel.checked ? model.input.value.trim() : '',
1443
1449
  }
@@ -1710,7 +1716,9 @@ function openEntryEditor(entry, candidate) {
1710
1716
  confidence: Number(confidenceInput.value),
1711
1717
  ...(source.source ? { source: source.source } : {}),
1712
1718
  }
1713
- if (candidate) await api(`candidates/${encodeURIComponent(candidate.id)}/review`, { method: 'POST', body: { decision: 'approve', draft } })
1719
+ if (candidate) await api(`candidates/${encodeURIComponent(candidate.id)}/review`, {
1720
+ method: 'POST', body: { decision: 'approve', draft, ...(candidate.action === 'conflict' ? { resolution: 'merge' } : {}) },
1721
+ })
1714
1722
  else if (entry) await api(`entries/${encodeURIComponent(entry.id)}`, { method: 'PUT', body: { draft } })
1715
1723
  else await api('entries', { method: 'POST', body: { draft } })
1716
1724
  showToast(candidate ? '候选已通过并写入知识库。' : '知识已保存。')
@@ -1733,14 +1741,22 @@ function selectField(label, options, value) {
1733
1741
  return { input, wrapper: element('div', { class: 'field' }, element('label', {}, label), input) }
1734
1742
  }
1735
1743
 
1736
- async function reviewCandidate(candidate, decision) {
1744
+ async function reviewCandidate(candidate, decision, resolution) {
1737
1745
  const approve = decision === 'approve'
1738
1746
  openConfirm({
1739
1747
  title: approve ? '通过这条候选?' : '拒绝这条候选?',
1740
1748
  message: approve ? '通过后内容会立即写入知识库,并参与后续对话召回。' : '拒绝后会保留审核记录,但不会进入知识库。',
1741
1749
  confirmLabel: approve ? '确认通过' : '确认拒绝', danger: !approve,
1742
1750
  onConfirm: async () => {
1743
- await api(`candidates/${encodeURIComponent(candidate.id)}/review`, { method: 'POST', body: { decision } })
1751
+ const reviewed = await api(`candidates/${encodeURIComponent(candidate.id)}/review`, {
1752
+ method: 'POST', body: { decision, ...(resolution ? { resolution } : {}) },
1753
+ })
1754
+ if (approve && reviewed.status === 'pending' && reviewed.action === 'conflict') {
1755
+ showToast('审核期间知识已变化,候选已转为冲突项,请比较后重新确认。')
1756
+ state.stats = null
1757
+ await navigate('candidates')
1758
+ return
1759
+ }
1744
1760
  showToast(approve ? '候选已通过。' : '候选已拒绝。')
1745
1761
  state.stats = null
1746
1762
  await navigate('candidates')
package/web/styles.css CHANGED
@@ -7,6 +7,7 @@
7
7
  --surface-raised: #ffffff;
8
8
  --surface-soft: #f0f0f3;
9
9
  --surface-hover: #f5f5f8;
10
+ --dialog-surface: #ffffff;
10
11
  --text: #1d1d1f;
11
12
  --text-secondary: #4c4c50;
12
13
  --text-tertiary: #6e6e73;
@@ -34,6 +35,7 @@
34
35
  --surface-raised: #242426;
35
36
  --surface-soft: #2c2c2e;
36
37
  --surface-hover: #303033;
38
+ --dialog-surface: #1c1c1e;
37
39
  --text: #f5f5f7;
38
40
  --text-secondary: #c7c7cc;
39
41
  --text-tertiary: #98989d;
@@ -106,7 +108,7 @@ button:disabled { cursor: not-allowed; opacity: .56; }
106
108
  border: 1px solid var(--border);
107
109
  border-radius: 20px;
108
110
  padding: 32px;
109
- background: var(--surface-raised);
111
+ background: var(--dialog-surface);
110
112
  box-shadow: var(--shadow);
111
113
  }
112
114
 
@@ -127,11 +129,11 @@ button:disabled { cursor: not-allowed; opacity: .56; }
127
129
  .login-card > p { margin: 0 0 24px; color: var(--text-secondary); }
128
130
 
129
131
  .field { display: grid; gap: 7px; }
130
- .field + .field { margin-top: 16px; }
131
132
  .field label { color: var(--text-secondary); font-size: 13px; font-weight: 600; }
132
133
  .field-hint { color: var(--text-tertiary); font-size: 12px; }
133
134
 
134
135
  .input, .select, .textarea {
136
+ box-sizing: border-box;
135
137
  width: 100%;
136
138
  border: 1px solid var(--border-strong);
137
139
  border-radius: 9px;
@@ -141,7 +143,7 @@ button:disabled { cursor: not-allowed; opacity: .56; }
141
143
  transition: border-color .15s ease, box-shadow .15s ease;
142
144
  }
143
145
 
144
- .input, .select { min-height: 40px; }
146
+ .input, .select { height: 40px; min-height: 40px; font: inherit; line-height: 20px; }
145
147
  .textarea { min-height: 132px; resize: vertical; }
146
148
  .input::placeholder, .textarea::placeholder { color: var(--text-tertiary); }
147
149
  .input:focus, .select:focus, .textarea:focus { border-color: var(--accent); }
@@ -169,6 +171,7 @@ button:disabled { cursor: not-allowed; opacity: .56; }
169
171
  .button.ghost { border-color: transparent; background: transparent; color: var(--text-secondary); }
170
172
  .button.small { min-height: 32px; padding: 5px 10px; font-size: 12px; }
171
173
  .login-card .button { width: 100%; margin-top: 20px; }
174
+ .login-card .field + .field { margin-top: 16px; }
172
175
 
173
176
  .app-shell { min-height: 100vh; min-height: 100dvh; display: grid; grid-template-columns: var(--sidebar-width) 6px minmax(0, 1fr); }
174
177
 
@@ -392,41 +395,6 @@ button:disabled { cursor: not-allowed; opacity: .56; }
392
395
  .workspace-switcher p { max-width: 56ch; margin: 0; color: var(--text-tertiary); font-size: 12px; text-align: right; }
393
396
  .workspace-tabs { flex: none; }
394
397
  .workspace-tabs .tab { display: inline-flex; align-items: center; gap: 7px; }
395
- .writeback-policy-control {
396
- min-height: 36px;
397
- display: inline-flex;
398
- align-items: center;
399
- gap: 7px;
400
- border-left: 1px solid var(--border);
401
- padding-left: 10px;
402
- }
403
- .writeback-policy-label { color: var(--text-tertiary); font-size: 11px; font-weight: 600; white-space: nowrap; }
404
- .writeback-policy-segments {
405
- display: inline-flex;
406
- gap: 2px;
407
- border: 1px solid var(--border);
408
- border-radius: 9px;
409
- padding: 2px;
410
- background: color-mix(in srgb, var(--surface-soft) 72%, transparent);
411
- }
412
- .writeback-policy-option {
413
- min-width: 48px;
414
- min-height: 30px;
415
- border: 0;
416
- border-radius: 7px;
417
- padding: 5px 10px;
418
- background: transparent;
419
- color: var(--text-tertiary);
420
- font-size: 11px;
421
- font-weight: 620;
422
- transition: background .14s ease, color .14s ease, box-shadow .14s ease;
423
- }
424
- .writeback-policy-option:hover:not(:disabled) { background: var(--surface-hover); color: var(--text); }
425
- .writeback-policy-option[aria-checked="true"] {
426
- background: var(--surface-raised);
427
- color: var(--text);
428
- box-shadow: 0 1px 4px rgb(20 25 35 / 10%);
429
- }
430
398
  .tab-count { min-width: 20px; border-radius: 6px; padding: 1px 6px; background: color-mix(in srgb, var(--text-tertiary) 10%, transparent); color: var(--text-tertiary); font-size: 10px; font-variant-numeric: tabular-nums; }
431
399
  .tab[aria-selected="true"] .tab-count { background: var(--accent-soft); color: var(--accent); }
432
400
  .library-management, .mount-section { min-width: 0; }
@@ -678,6 +646,11 @@ button:disabled { cursor: not-allowed; opacity: .56; }
678
646
  .candidate-header h3 { margin: 8px 0 0; font-size: 15px; }
679
647
  .candidate-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(240px, .55fr); gap: 20px; padding: 0 18px 17px; }
680
648
  .candidate-content { margin: 0; color: var(--text-secondary); white-space: pre-wrap; }
649
+ .candidate-body.is-conflict { grid-template-columns: 1fr; }
650
+ .candidate-comparison { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
651
+ .candidate-comparison section { min-width: 0; border: 1px solid var(--border); border-radius: 9px; padding: 12px; background: var(--surface-soft); }
652
+ .candidate-comparison strong { display: block; margin-bottom: 8px; color: var(--text); font-size: 12px; }
653
+ .candidate-comparison pre { overflow: auto; max-height: 280px; margin: 0; color: var(--text-secondary); font: inherit; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; }
681
654
  .candidate-reason { border-left: 2px solid var(--border-strong); padding-left: 13px; color: var(--text-secondary); font-size: 12px; }
682
655
  .candidate-reason strong { display: block; margin-bottom: 5px; color: var(--text-tertiary); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
683
656
  .candidate-target { display: block; margin-bottom: 13px; border-radius: 7px; padding: 7px 9px; background: var(--accent-soft); color: var(--accent); font-weight: 600; }
@@ -727,7 +700,7 @@ button:disabled { cursor: not-allowed; opacity: .56; }
727
700
  flex-direction: column;
728
701
  border: 1px solid var(--border-strong);
729
702
  border-radius: 18px;
730
- background: var(--surface-raised);
703
+ background: var(--dialog-surface);
731
704
  box-shadow: 0 28px 80px rgb(0 0 0 / 24%);
732
705
  }
733
706
  .dialog.narrow { width: min(100%, 460px); }
@@ -752,7 +725,7 @@ button:disabled { cursor: not-allowed; opacity: .56; }
752
725
  .dialog-header p { margin: 4px 0 0; color: var(--text-tertiary); font-size: 12px; }
753
726
  .dialog-body { overflow: auto; padding: 20px; }
754
727
  .dialog-footer { display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--border); padding: 13px 20px; }
755
- .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
728
+ .form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: 16px; }
756
729
  .form-grid .span-2 { grid-column: 1 / -1; }
757
730
  .check-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
758
731
  .check-option { display: flex; align-items: center; gap: 8px; border: 1px solid var(--border); border-radius: 8px; padding: 9px; }
@@ -805,6 +778,7 @@ button:disabled { cursor: not-allowed; opacity: .56; }
805
778
  }
806
779
 
807
780
  @media (max-width: 760px) {
781
+ .candidate-comparison { grid-template-columns: 1fr; }
808
782
  :root { --sidebar-width: 0px; }
809
783
  .app-shell { display: block; }
810
784
  .app-sidebar-resizer { display: none; }
@@ -890,7 +864,6 @@ button:disabled { cursor: not-allowed; opacity: .56; }
890
864
  .topbar-actions .button.primary::before { content: "+"; font-size: 18px; }
891
865
  .workspace-tabs { width: 100%; }
892
866
  .workspace-tabs .tab { min-width: 0; flex: 1; justify-content: center; padding-inline: 8px; }
893
- .writeback-policy-control { width: 100%; justify-content: space-between; border-left: 0; padding-left: 0; }
894
867
  .document-toolbar-actions { width: 100%; }
895
868
  .document-toolbar-actions .button.primary { margin-left: auto; }
896
869
  .form-grid { grid-template-columns: 1fr; }