PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/skills.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 16:59:05 UTC 92,837 bytes, 1,941 lines download raw bytes

1// skills.js — Skills tab in the Memory modal.
2//
3// Skills are SKILL.md files (frontmatter + body) under data/skills/.
4// This UI supports: list, search, view (read SKILL.md), edit (replace
5// content), publish/draft toggle, delete, and "run as slash" via the
6// /<skill-name> path.
7
8import uiModule from './ui.js';
9import * as spinnerModule from './spinner.js';
10
11const API = window.location.origin;
12let skills = [];
13let builtinSkills = [];   // read-only agent tool capabilities (TOOL_SECTIONS)
14let loaded = false;
15let _loadPromise = null;
16
17function esc(s) { return uiModule.esc(String(s ?? '')); }
18
19let _pendingFocusSkill = null;
20let _cascadeNext = false;   // set true to play the domino-in entrance on the next render
21
22function _playSkillsCascade(container = document.getElementById('skills-list')) {
23  if (!container || !container.querySelector('.skill-card')) return false;
24  container.classList.remove('doclib-just-opened');
25  void container.offsetWidth;
26  container.classList.add('doclib-just-opened');
27  setTimeout(() => container.classList.remove('doclib-just-opened'), 900);
28  return true;
29}
30
31// Cache of SKILL.md text by skill name, so expanding is instant (no async
32// fetch + content-settle jump). Populated lazily on expand AND eagerly in
33// the background for all visible cards right after render.
34const _mdCache = new Map();
35async function _fetchSkillMarkdown(name) {
36  if (_mdCache.has(name)) return _mdCache.get(name);
37  const res = await fetch(`${API}/api/skills/${encodeURIComponent(name)}/markdown`);
38  if (!res.ok) throw new Error(`HTTP ${res.status}`);
39  const data = await res.json();
40  const md = data.markdown || '';
41  _mdCache.set(name, md);
42  return md;
43}
44// Background-load the markdown for every currently-rendered skill card so it
45// is ready (in the card's <pre> + _mdLoaded) before the user expands it.
46function _preloadVisibleMarkdown() {
47  document.querySelectorAll('#skills-list .skill-card[data-skill-name]').forEach(card => {
48    const name = card.dataset.skillName;
49    if (!name || card._mdLoaded) return;
50    const pre = card.querySelector('.skill-md-pre');
51    const apply = (md) => { if (pre) pre.textContent = md || '(empty)'; card._mdLoaded = true; card._md = md || ''; };
52    if (_mdCache.has(name)) { apply(_mdCache.get(name)); return; }
53    _fetchSkillMarkdown(name).then(apply).catch(() => {});
54  });
55}
56
57// Collapsed skills sections ("user" / "builtin"), persisted so the
58// choice survives reloads. Built-in defaults to collapsed (it's
59// reference info, not the user's own skills).
60const _collapsedSections = (() => {
61  try {
62    const raw = localStorage.getItem('skillsSectionsCollapsed');
63    if (raw) return new Set(JSON.parse(raw));
64  } catch (_) {}
65  return new Set(['builtin']);
66})();
67function _saveCollapsedSections() {
68  try { localStorage.setItem('skillsSectionsCollapsed', JSON.stringify([..._collapsedSections])); } catch (_) {}
69}
70function _applySectionCollapse(container) {
71  if (!container) return;
72  container.querySelectorAll('.skills-section-header').forEach(h => {
73    h.classList.toggle('collapsed', _collapsedSections.has(h.dataset.section));
74  });
75  container.querySelectorAll('.doclib-card[data-skill-section]').forEach(c => {
76    c.classList.toggle('skill-card-section-hidden', _collapsedSections.has(c.dataset.skillSection));
77  });
78}
79
80export async function loadSkills(cascade = false) {
81  // Play the domino-in entrance on this load (set when the tab is opened,
82  // not for the silent re-loads after an edit/delete).
83  if (cascade) _cascadeNext = true;
84  if (cascade && loaded && !_loadPromise && _playSkillsCascade()) {
85    _cascadeNext = false;
86    updateCount();
87    return;
88  }
89  if (_loadPromise) return _loadPromise;
90  _loadPromise = (async () => {
91  try {
92    const res = await fetch(`${API}/api/skills`);
93    const data = await res.json();
94    skills = data.skills || [];
95    _loadSkillApprovalThreshold();
96    // Built-in capabilities are no longer surfaced in the Skills menu.
97    loaded = true;
98    renderSkillsList();
99    updateCount();
100    if (_pendingFocusSkill) {
101      _focusSkillRow(_pendingFocusSkill);
102      _pendingFocusSkill = null;
103    }
104    // If a background audit is running, re-show its progress panel.
105    if (!_auditPoll) {
106      _fetchAuditStatus().then(st => {
107        if (st.status === 'running') _auditAllSkills();
108      }).catch(() => {});
109    }
110  } catch (e) {
111    console.error('Failed to load skills:', e);
112  } finally {
113    _loadPromise = null;
114  }
115  })();
116  return _loadPromise;
117}
118
119function _focusSkillRow(name) {
120  setTimeout(() => {
121    const card = document.querySelector(`.skill-card[data-skill-name="${CSS.escape(name)}"]`);
122    if (!card) return;
123    card.scrollIntoView({ behavior: 'smooth', block: 'center' });
124    card.classList.add('skill-row-flash');
125    setTimeout(() => card.classList.remove('skill-row-flash'), 2000);
126    // Expand it so the linked skill opens to its SKILL.md directly.
127    _expandSkillCard(card, name);
128  }, 200);
129}
130
131// Open the Memory modal → Skills tab → focus a specific skill row.
132// Used by the chat anchor-link delegate ([name](#skill-<name>)).
133export function openSkill(name) {
134  _pendingFocusSkill = name || null;
135  // Open the memory modal if not already open.
136  const memBtn = document.getElementById('tool-memory-btn');
137  if (memBtn) memBtn.click();
138  // Switch to the skills tab (triggers lazy loadSkills()).
139  setTimeout(() => {
140    const tab = document.querySelector('.memory-tab[data-memory-tab="skills"]');
141    if (tab) tab.click();
142    else loadSkills();  // fallback if tab structure differs
143  }, 120);
144}
145
146let _skillsSort = 'confidence';
147let _showDraftsOnly = false;
148let _showPublishedOnly = false;
149let _confMax = null;   // confidence ceiling filter (%, e.g. 90 = show ≤90%); null = off
150let _selectMode = false;
151const _selectedNames = new Set();
152let _skillApprovalThreshold = 0.85;
153
154function updateCount() {
155  const el = document.getElementById('skills-count');
156  if (el) el.textContent = skills.length || '0';
157  const elH = document.getElementById('skills-count-h2');
158  if (elH) elH.textContent = skills.length + ' skill' + (skills.length === 1 ? '' : 's');
159}
160
161function _sortSkills(list) {
162  const arr = list.slice();
163  if (_skillsSort === 'confidence') {
164    arr.sort((a, b) => (b.confidence || 0) - (a.confidence || 0) || (a.name || '').localeCompare(b.name || ''));
165  } else if (_skillsSort === 'uses') {
166    arr.sort((a, b) => (b.uses || 0) - (a.uses || 0) || (a.name || '').localeCompare(b.name || ''));
167  } else if (_skillsSort === 'recent') {
168    arr.sort((a, b) => (b.updated_at || b.created_at || 0) - (a.updated_at || a.created_at || 0));
169  } else {
170    arr.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
171  }
172  return arr;
173}
174
175function _matches(sk, query) {
176  const q = query.toLowerCase();
177  return (
178    (sk.name || '').toLowerCase().includes(q) ||
179    (sk.description || '').toLowerCase().includes(q) ||
180    (sk.when_to_use || sk.problem || '').toLowerCase().includes(q) ||
181    (sk.category || '').toLowerCase().includes(q) ||
182    (sk.tags || []).some(t => (t || '').toLowerCase().includes(q))
183  );
184}
185
186function _statusPill(sk) {
187  const s = sk.status || (sk._legacy ? 'legacy' : 'draft');
188  if (s === 'published') return '<span class="memory-cat-badge skill-status-pill" data-status="published" style="background:color-mix(in srgb, var(--accent, #4ade80) 30%, transparent)">published</span>';
189  if (s === 'draft')     return '<span class="memory-cat-badge skill-status-pill" data-status="draft" style="background:color-mix(in srgb, var(--fg) 14%, transparent)">draft</span>';
190  return `<span class="memory-cat-badge skill-status-pill" data-status="${esc(s)}" style="opacity:0.6">${esc(s)}</span>`;
191}
192
193// Show a "teacher" badge for skills written by the auto-escalation
194// teacher loop. Lets the user tell at-a-glance which procedures were
195// hand-authored vs auto-generated so they can audit (and demote /
196// edit / publish) before trusting them.
197function _sourcePill(sk) {
198  if (sk.source !== 'teacher-escalation') return '';
199  const teacher = sk.teacher_model || 'teacher';
200  return `<span class="memory-cat-badge" title="Created by teacher escalation: ${esc(teacher)}" style="background:color-mix(in srgb, var(--color-warning, #f0ad4e) 22%, transparent);">teacher-created</span>`;
201}
202
203function _modelShortName(model) {
204  return String(model || '').split('/').filter(Boolean).pop() || String(model || '');
205}
206
207function _skillTokens(sk) {
208  return new Set(String([
209    sk.name || '',
210    sk.description || '',
211    sk.when_to_use || '',
212    ...(sk.tags || []),
213  ].join(' ')).toLowerCase()
214    .replace(/-\d+\b/g, '')
215    .split(/[^a-z0-9]+/)
216    .filter(t => t.length > 2 && !['the', 'and', 'with', 'for', 'from', 'using'].includes(t)));
217}
218
219function _skillSimilarity(a, b) {
220  const A = _skillTokens(a), B = _skillTokens(b);
221  if (!A.size || !B.size) return 0;
222  let inter = 0;
223  for (const t of A) if (B.has(t)) inter++;
224  return inter / (A.size + B.size - inter);
225}
226
227function _baseSkillName(name) {
228  return String(name || '').replace(/-\d+$/, '');
229}
230
231function _scoreDuplicateKeeper(sk) {
232  return [
233    (sk.status === 'published') ? 100000 : 0,
234    (sk.uses || 0) * 100,
235    Math.round((sk.confidence || 0) * 100),
236    sk.audit_by_teacher ? -5 : 0,
237    -String(sk.name || '').length / 1000,
238  ].reduce((a, b) => a + b, 0);
239}
240
241function _duplicateMeta(list) {
242  const parent = new Map();
243  const names = list.map(s => s.name || s.id).filter(Boolean);
244  names.forEach(n => parent.set(n, n));
245  const find = (x) => {
246    let p = parent.get(x) || x;
247    while (p !== parent.get(p)) p = parent.get(p);
248    return p;
249  };
250  const unite = (a, b) => {
251    const pa = find(a), pb = find(b);
252    if (pa !== pb) parent.set(pb, pa);
253  };
254  for (let i = 0; i < list.length; i++) {
255    for (let j = i + 1; j < list.length; j++) {
256      const a = list[i], b = list[j];
257      const an = a.name || a.id, bn = b.name || b.id;
258      if (!an || !bn) continue;
259      if (_baseSkillName(an) === _baseSkillName(bn) || _skillSimilarity(a, b) >= 0.38) {
260        unite(an, bn);
261      }
262    }
263  }
264  const groups = new Map();
265  for (const sk of list) {
266    const n = sk.name || sk.id;
267    if (!n) continue;
268    const root = find(n);
269    if (!groups.has(root)) groups.set(root, []);
270    groups.get(root).push(sk);
271  }
272  const meta = new Map();
273  let idx = 1;
274  for (const group of groups.values()) {
275    if (group.length < 2) continue;
276    const sorted = group.slice().sort((a, b) => _scoreDuplicateKeeper(b) - _scoreDuplicateKeeper(a));
277    const keep = sorted[0].name || sorted[0].id;
278    const groupNames = sorted.map(s => s.name || s.id).filter(Boolean);
279    for (const sk of sorted) {
280      const n = sk.name || sk.id;
281      meta.set(n, { group: idx, keep: n === keep, keepName: keep, names: groupNames });
282    }
283    idx++;
284  }
285  return meta;
286}
287
288function _auditModelPills(sk) {
289  const worker = sk.audit_worker_model || '';
290  const teacher = sk.audit_teacher_model || '';
291  let html = '';
292  if (worker) {
293    html += `<span class="memory-cat-badge skill-model-pill skill-model-student" title="Last audited by default audit model: ${esc(worker)}">audit</span>`;
294  }
295  if (sk.audit_by_teacher || teacher) {
296    const title = teacher
297      ? `Teacher rewrote this skill; audit model passed after the rewrite. Teacher: ${teacher}`
298      : 'Teacher rewrote this skill; audit model passed after the rewrite.';
299    html += `<span class="memory-cat-badge skill-model-pill skill-model-teacher" title="${esc(title)}">teacher-fixed</span>`;
300  }
301  return html;
302}
303
304function _necessityKind(sk) {
305  const nec = sk && sk.necessity;
306  if (sk && sk._duplicateGroup) return 'duplicate';
307  if (!nec || nec.necessary !== false) return null;
308  const reason = String(nec.reason || '').toLowerCase();
309  const redundant = (nec.redundant_with || []).filter(Boolean);
310  if (redundant.length || /duplicat|redundan|overlap|same skill|same procedure/.test(reason)) return 'duplicate';
311  if (/trivial|generic|capable assistant|without a saved|not need|unnecessary/.test(reason)) return 'trivial';
312  return 'irrelevant';
313}
314
315function _necessityPill(sk) {
316  const kind = _necessityKind(sk);
317  if (!kind) return '';
318  const nec = sk.necessity || {};
319  const dup = (nec.redundant_with || []).filter(Boolean);
320  const label = kind === 'duplicate' ? (sk._duplicateGroup ? `duplicate #${sk._duplicateGroup}` : 'duplicate')
321    : kind === 'trivial' ? 'generic'
322    : 'possibly-irrelevant';
323  const group = sk._duplicateNames || [];
324  const why = sk._duplicateGroup
325    ? `Duplicate group #${sk._duplicateGroup}. Recommended keep: ${sk._duplicateKeepName}. Group: ${group.join(', ')}`
326    : (nec.reason || 'May not be worth keeping') + (dup.length ? ' | overlaps: ' + dup.join(', ') : '');
327  return `<span class="memory-cat-badge skill-necessity-pill skill-necessity-${kind}" title="${esc(why)}">${label}</span>`;
328}
329
330function _duplicatePriorityPill(sk) {
331  if (!sk._duplicateGroup) return '';
332  if (sk._duplicateKeep) {
333    return `<span class="memory-cat-badge skill-duplicate-keep" title="Best duplicate candidate by published status, uses, confidence, and specificity">recommended</span>`;
334  }
335  return `<span class="memory-cat-badge skill-duplicate-lower" title="Lower-priority duplicate. Suggested keeper: ${esc(sk._duplicateKeepName || '')}">lower-priority</span>`;
336}
337
338// Verified-by-test indicators shown next to the confidence %. A check when a
339// test/audit run passed; a graduation-cap when the teacher model had to
340// rewrite the skill to make it pass. SVG (no Unicode emoji).
341function _auditMarks(sk) {
342  let html = '';
343  if (sk.audit_verdict === 'pass') {
344    html += `<span class="skill-verified" title="Passed an automated test"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>`;
345  }
346  if (sk.audit_by_teacher) {
347    const teacher = sk.audit_teacher_model ? `: ${sk.audit_teacher_model}` : '';
348    html += `<span class="skill-teachermark" title="Teacher rewrote this skill; audit model passed after the rewrite${esc(teacher)}"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 10L12 5 2 10l10 5 10-5z"/><path d="M6 12v5c0 1 3 2 6 2s6-1 6-2v-5"/></svg></span>`;
349  }
350  return html;
351}
352
353// Audit verdict dot — removed at user request. The ✓ check-mark next to the
354// confidence % still indicates a pass. Stub returns empty so the surrounding
355// header HTML still composes without changing other layout.
356function _auditDot(sk) { return ''; }
357
358function _isDraftsFilter() { return !!_showDraftsOnly; }
359
360// Confidence → colour. 90%+ is solidly green, scaling down through
361// yellow/orange to red at 50% and below (hue 120→0 over 90→50).
362function _confColor(conf) {
363  const hue = Math.max(0, Math.min(120, ((conf - 50) / 40) * 120));
364  return `hsl(${Math.round(hue)}, 70%, 42%)`;
365}
366
367// Shared action icons (collapsed kebab menu + expanded footer use the same).
368const _ICON = {
369  del:   '<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>',
370  edit:  '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>',
371  approve: '<polyline points="20 6 9 17 4 12"/>',
372  unpublish: '<path d="M5 12l5 5L20 7"/>',
373  test:  '<polygon points="5 3 19 12 5 21 5 3"/>',
374};
375function _svg(paths, { fill = 'none', size = 13 } = {}) {
376  const stroke = fill === 'currentColor' ? '' : 'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
377  return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" ${stroke} style="vertical-align:-2px;flex-shrink:0;">${paths}</svg>`;
378}
379
380// Kebab dropdown for a collapsed skill card — same actions + icons as the
381// expanded footer (Publish/Unpublish · Edit · Delete).
382function _openSkillMenu(btn, card, sk, name, isPublished) {
383  document.querySelectorAll('.skill-kebab-menu').forEach(m => m.remove());
384  const menu = document.createElement('div');
385  menu.className = 'skill-kebab-menu';
386  const mk = (paths, label, opts, onClick) => {
387    const item = document.createElement('button');
388    item.className = 'skill-kebab-item' + (opts && opts.danger ? ' danger' : '');
389    item.innerHTML = _svg(paths, opts) + `<span>${label}</span>`;
390    item.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); onClick(); });
391    menu.appendChild(item);
392  };
393  if (isPublished) mk(_ICON.unpublish, 'Unpublish', {}, () => _setSkillStatus(name, 'draft'));
394  else mk(_ICON.approve, 'Publish', {}, () => _setSkillStatus(name, 'published'));
395  mk(_ICON.edit, 'Edit', {}, async () => {
396    if (!card.classList.contains('doclib-card-expanded')) await _expandSkillCard(card, name);
397    _toggleSkillEdit(card, name);
398  });
399  mk(_ICON.test, 'Test', {}, () => _testSkill(card, name));
400  // Audit kicks off the bulk audit-all loop (test → judge → fix → retry → demote).
401  // Starts at the top of the list and walks down.
402  mk(_ICON.test, 'Audit', {}, () => _auditAllSkills());
403  mk(_ICON.del, 'Delete', { danger: true }, () => _deleteSkill(name, card));
404
405  // Select — enters bulk-select mode and pre-selects this skill. Same pattern
406  // as the email/documents/brain Select item, with the email bullet icon.
407  const selItem = document.createElement('button');
408  selItem.className = 'skill-kebab-item';
409  selItem.innerHTML = '<span style="display:inline-flex;width:14px;height:14px;align-items:center;justify-content:center;"><span style="font-size:16px;line-height:1;">●</span></span><span>Select</span>';
410  selItem.addEventListener('click', (e) => {
411    e.stopPropagation();
412    menu.remove();
413    if (!_selectMode) _enterSelectMode();
414    _selectedNames.add(name);
415    renderSkillsList();
416  });
417  menu.appendChild(selItem);
418
419  // Mobile-only Cancel — mirrors the email/documents/brain popup pattern.
420  // CSS hides `.dropdown-cancel-mobile` on desktop where outside-click
421  // already dismisses cleanly.
422  const cancelItem = document.createElement('button');
423  cancelItem.className = 'skill-kebab-item dropdown-cancel-mobile';
424  cancelItem.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>Cancel</span>';
425  cancelItem.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); });
426  menu.appendChild(cancelItem);
427
428  document.body.appendChild(menu);
429  const r = btn.getBoundingClientRect();
430  menu.style.top = (r.bottom + 4) + 'px';
431  menu.style.right = Math.max(6, window.innerWidth - r.right) + 'px';
432  // Keep it on-screen (mobile): flip above the button if it would overflow the
433  // bottom, clamp the left edge, and cap the height as a last resort.
434  const mr = menu.getBoundingClientRect();
435  if (mr.bottom > window.innerHeight - 6) {
436    menu.style.top = Math.max(6, r.top - mr.height - 4) + 'px';
437  }
438  if (mr.left < 6) {
439    menu.style.right = Math.max(6, window.innerWidth - 6 - mr.width) + 'px';
440  }
441  const mr2 = menu.getBoundingClientRect();
442  if (mr2.bottom > window.innerHeight - 6) {
443    menu.style.maxHeight = Math.max(80, window.innerHeight - 12 - mr2.top) + 'px';
444    menu.style.overflowY = 'auto';
445  }
446  const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close, true); } };
447  setTimeout(() => document.addEventListener('click', close, true), 0);
448}
449
450// Cards for the agent's built-in tool capabilities (from
451// /api/skills/builtin → TOOL_SECTIONS). Expandable to preview the
452// instruction block; editable with a warning + a revert-to-default
453// button (overrides stored in settings, applied to the prompt).
454function _buildBuiltinCards() {
455  return builtinSkills.map(b => {
456    const card = document.createElement('div');
457    card.className = 'doclib-card skill-card skill-builtin-card';
458    card.dataset.builtinName = b.name;
459
460    const header = document.createElement('div');
461    header.className = 'doclib-card-header skill-card-header';
462    header.innerHTML = `
463      <span class="skill-conf-dot" style="display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--accent, var(--red));flex-shrink:0;margin-right:6px;opacity:0.55;"></span>
464      <div style="flex:1;min-width:0;overflow:hidden;">
465        <div class="doclib-card-title" style="display:flex;align-items:center;gap:6px;min-width:0;">
466          <code style="font-weight:600;font-size:0.9em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex-shrink:1;min-width:0;">${esc(b.name)}</code>
467          <span class="memory-cat-badge" style="background:color-mix(in srgb, var(--fg) 14%, transparent)">built-in</span>
468          ${b.is_overridden ? '<span class="memory-cat-badge" title="You have edited this built-in capability" style="background:color-mix(in srgb, var(--color-warning, #f0ad4e) 30%, transparent);">edited</span>' : ''}
469        </div>
470        ${b.description ? `<div class="doclib-card-session" title="${esc(b.description)}" style="font-size:10px;opacity:0.55;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${esc(b.description)}</div>` : ''}
471      </div>
472      <span class="doclib-card-chevron"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
473    `;
474    card.appendChild(header);
475
476    const preview = document.createElement('div');
477    preview.className = 'doclib-card-preview skill-card-preview';
478    // Warning banner — editing a built-in changes how the assistant uses a native tool.
479    const warn = document.createElement('div');
480    warn.className = 'skill-builtin-warn';
481    warn.innerHTML = '⚠ This is a built-in capability. Editing changes how the assistant is instructed to use this native tool — it can break or alter core behaviour. Use Revert to restore the shipped default.';
482    preview.appendChild(warn);
483    const pre = document.createElement('pre');
484    pre.className = 'skill-md-pre';
485    pre.textContent = '';  // filled on expand
486    preview.appendChild(pre);
487
488    // Footer: Revert (left, only meaningful when overridden) · Edit/Save (right).
489    const actions = document.createElement('div');
490    actions.className = 'doclib-card-expanded-actions';
491
492    const revertBtn = document.createElement('button');
493    revertBtn.className = 'doclib-card-text-btn doclib-card-action-btn doclib-card-text-btn-danger';
494    revertBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>Revert';
495    revertBtn.title = 'Restore the original shipped instructions';
496    revertBtn.addEventListener('click', (e) => { e.stopPropagation(); _revertBuiltin(b.name); });
497
498    const editBtn = document.createElement('button');
499    editBtn.className = 'doclib-card-text-btn doclib-card-action-btn';
500    editBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>Edit';
501    editBtn.addEventListener('click', (e) => { e.stopPropagation(); _toggleBuiltinEdit(card, b.name); });
502
503    const rightGroup = document.createElement('div');
504    rightGroup.className = 'doclib-action-group';
505    const btnRow = document.createElement('div');
506    btnRow.className = 'doclib-action-btn-row';
507    btnRow.appendChild(editBtn);
508    rightGroup.appendChild(btnRow);
509
510    actions.appendChild(revertBtn);
511    actions.appendChild(rightGroup);
512    preview.appendChild(actions);
513    card.appendChild(preview);
514
515    card.addEventListener('click', (e) => {
516      if (e.target.closest('button, input, textarea')) return;
517      _expandBuiltinCard(card, b.name);
518    });
519    return card;
520  });
521}
522
523async function _expandBuiltinCard(card, name) {
524  const grid = card.closest('.doclib-grid');
525  if (card.classList.contains('doclib-card-expanded')) {
526    card.classList.remove('doclib-card-expanded');
527    return;
528  }
529  if (grid) grid.querySelectorAll('.doclib-card-expanded').forEach(c => c.classList.remove('doclib-card-expanded'));
530  card.classList.add('doclib-card-expanded');
531  if (grid) grid.scrollTop = 0;
532  const pre = card.querySelector('.skill-md-pre');
533  if (pre && !card._loaded) {
534    pre.textContent = 'Loading…';
535    try {
536      const res = await fetch(`${API}/api/skills/builtin/${encodeURIComponent(name)}`);
537      if (!res.ok) throw new Error(`HTTP ${res.status}`);
538      const data = await res.json();
539      pre.textContent = data.text || '(empty)';
540      card._loaded = true;
541      card._text = data.text || '';
542      card._default = data.default || '';
543    } catch (e) {
544      pre.textContent = 'Failed to load.';
545    }
546  }
547}
548
549function _toggleBuiltinEdit(card, name) {
550  const preview = card.querySelector('.skill-card-preview');
551  if (!preview) return;
552  if (preview.querySelector('.skill-md-editor')) { _saveBuiltinEdit(card, name); return; }
553  const pre = preview.querySelector('.skill-md-pre');
554  const ta = document.createElement('textarea');
555  ta.className = 'skill-md-editor';
556  ta.spellcheck = false;
557  ta.value = (card._text != null ? card._text : (pre ? pre.textContent : '')) || '';
558  ta.addEventListener('click', (e) => e.stopPropagation());
559  if (pre) pre.style.display = 'none';
560  preview.insertBefore(ta, preview.querySelector('.doclib-card-expanded-actions'));
561  ta.focus();
562  const editBtn = [...preview.querySelectorAll('.doclib-card-action-btn')].find(b => /Edit|Save/.test(b.textContent));
563  if (editBtn) editBtn.innerHTML = '<svg width="11" height="11" viewB
563ox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save';
564}
565
566async function _saveBuiltinEdit(card, name) {
567  const ta = card.querySelector('.skill-md-editor');
568  if (!ta) return;
569  try {
570    const res = await fetch(`${API}/api/skills/builtin/${encodeURIComponent(name)}`, {
571      method: 'PUT',
572      headers: { 'Content-Type': 'application/json' },
573      body: JSON.stringify({ text: ta.value }),
574    });
575    if (!res.ok) throw new Error(`HTTP ${res.status}`);
576    uiModule.showToast('Built-in capability updated');
577    builtinSkills = [];  // force reload of built-in list (refreshes "edited" badge)
578    await loadSkills();
579  } catch (e) { uiModule.showError('Save failed: ' + e.message); }
580}
581
582async function _revertBuiltin(name) {
583  if (!(await uiModule.styledConfirm(`Revert "${name}" to its original built-in instructions?`, { confirmText: 'Revert', danger: true }))) return;
584  try {
585    const res = await fetch(`${API}/api/skills/builtin/${encodeURIComponent(name)}`, { method: 'DELETE' });
586    if (!res.ok) throw new Error(`HTTP ${res.status}`);
587    uiModule.showToast('Reverted to default');
588    builtinSkills = [];
589    await loadSkills();
590  } catch (e) { uiModule.showError('Revert failed: ' + e.message); }
591}
592
593function _getFilteredSkills() {
594  const query = (document.getElementById('skills-search')?.value || '').toLowerCase();
595  let filtered = query ? skills.filter(sk => _matches(sk, query)) : skills;
596  if (_showDraftsOnly) {
597    filtered = filtered.filter(sk => (sk.status || 'draft') !== 'published');
598  }
599  if (_showPublishedOnly) {
600    filtered = filtered.filter(sk => (sk.status || 'draft') === 'published');
601  }
602  if (_confMax != null) {
603    // "≤ X%" — surface the lower-confidence skills that may need review.
604    filtered = filtered.filter(sk => Math.round((sk.confidence || 0) * 100) <= _confMax);
605  }
606  return _sortSkills(filtered);
607}
608
609function renderSkillsList() {
610  const container = document.getElementById('skills-list');
611  if (!container) return;
612  // Re-render rebuilds the cards (none expanded), so clear the expand flag
613  // on the admin-card or it would keep the toolbar hidden with nothing open.
614  container.closest('.admin-card')?.classList.remove('skills-has-expanded');
615
616  const sorted = _getFilteredSkills();
617  // Built-in capabilities show as their own read-only section (skipped when
618  // the user is filtering to drafts, since built-ins aren't drafts).
619  // Skills menu shows the user's own skills only (built-in capabilities
620  // are intentionally not surfaced here).
621  const showBuiltin = false;
622
623  if (!sorted.length && !showBuiltin) {
624    container.innerHTML = `<div style="text-align:center;opacity:0.4;padding:24px 0;font-size:11px;">${loaded ? 'No skills yet, use agent for it to auto extract them.' : 'Loading…'}</div>`;
625    return;
626  }
627
628  // Library-style cards: a compact bar that expands in-place to show the
629  // SKILL.md, with a footer (Delete left; Edit / Run / Approve right).
630  // Reuses the proven .doclib-card / .doclib-card-preview /
631  // .doclib-card-expanded-actions markup so the desktop+mobile expand +
632  // footer behaviour matches the document/chat library exactly.
633  //
634  // #skills-list itself becomes the .doclib-grid (rather than a nested
635  // grid) so the global "hide non-grid children when a card is expanded"
636  // rule (.admin-card:has(.doclib-card-expanded) > *:not(.doclib-grid))
637  // doesn't hide the list container along with everything else.
638  container.classList.add('doclib-grid');
639  const cards = [];
640  const dupeMeta = _duplicateMeta(sorted);
641
642  for (const sk of sorted) {
643    const name = sk.name || sk.id;
644    const dm = dupeMeta.get(name);
645    if (dm) {
646      sk._duplicateGroup = dm.group;
647      sk._duplicateKeep = dm.keep;
648      sk._duplicateKeepName = dm.keepName;
649      sk._duplicateNames = dm.names;
650    } else {
651      delete sk._duplicateGroup;
652      delete sk._duplicateKeep;
653      delete sk._duplicateKeepName;
654      delete sk._duplicateNames;
655    }
656    const conf = Math.round((sk.confidence || 0) * 100);
657    const uses = sk.uses || 0;
658    const isPublished = (sk.status === 'published');
659    const confColor = _confColor(conf);
660
661    const card = document.createElement('div');
662    card.className = 'doclib-card skill-card';
663    card.dataset.skillName = name;
664    card.dataset.skillStatus = sk.status || 'draft';
665
666    const checked = _selectedNames.has(name) ? 'checked' : '';
667    const cbHtml = _selectMode
668      ? `<input type="checkbox" class="memory-select-cb skill-select-cb" data-name="${esc(name)}" ${checked} style="margin-right:6px;flex-shrink:0;cursor:pointer;" />`
669      : '';
670
671    // Collapsed header bar: dot · name (wraps) · [pills (right) · stats · menu].
672    const header = document.createElement('div');
673    header.className = 'doclib-card-header skill-card-header';
674    header.innerHTML = `
675      ${cbHtml}
676      ${_auditDot(sk)}
677      <div class="skill-card-textcol">
678        <code class="skill-card-name">${esc(name)}</code>
679        ${sk.description ? `<div class="skill-card-desc">${esc(sk.description)}</div>` : ''}
680      </div>
681      <div class="skill-card-right">
682        ${_statusPill(sk)}
683        ${_sourcePill(sk)}
684        ${_auditModelPills(sk)}
685        ${_necessityPill(sk)}
686        ${_duplicatePriorityPill(sk)}
687        <span class="skill-stats">${_auditMarks(sk)}<span class="skill-conf" style="color:${confColor};">${conf}%</span> · ${uses}u</span>
688        <span class="skill-chevron-up" title="Collapse"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></span>
689        <button class="skill-kebab-btn" title="Actions" aria-label="Actions"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="12" cy="19" r="1.6"/></svg></button>
690      </div>
691    `;
692    card.appendChild(header);
693
694    // Kebab dropdown (collapsed-bar quick actions: same set + icons as the
695    // expanded footer). Clicking the kebab opens it; it doesn't expand.
696    header.querySelector('.skill-kebab-btn').addEventListener('click', (e) => {
697      e.stopPropagation();
698      _openSkillMenu(e.currentTarget, card, sk, name, isPublished);
699    });
700
701    // Preview (hidden until expanded) — SKILL.md goes here + footer.
702    const preview = document.createElement('div');
703    preview.className = 'doclib-card-preview skill-card-preview';
704    const pre = document.createElement('pre');
705    pre.className = 'skill-md-pre';
706    pre.textContent = '';  // filled on expand
707    preview.appendChild(pre);
708
709    // Footer: Approve/Unpublish on the left, destructive delete on the right.
710    const actions = document.createElement('div');
711    actions.className = 'doclib-card-expanded-actions';
712
713    const delBtn = document.createElement('button');
714    delBtn.className = 'doclib-card-text-btn doclib-card-action-btn doclib-card-text-btn-danger';
715    delBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>Delete';
716    delBtn.addEventListener('click', (e) => { e.stopPropagation(); _deleteSkill(name, card); });
717
718    const editBtn = document.createElement('button');
719    editBtn.className = 'doclib-card-text-btn doclib-card-action-btn';
720    editBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>Edit';
721    editBtn.addEventListener('click', (e) => { e.stopPropagation(); _toggleSkillEdit(card, name); });
722
723    const pubBtn = document.createElement('button');
724    pubBtn.className = 'doclib-card-text-btn doclib-card-action-btn';
725    if (isPublished) {
726      pubBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12l5 5L20 7"/></svg>Unpublish';
727      pubBtn.title = 'Move back to draft';
728      pubBtn.addEventListener('click', (e) => { e.stopPropagation(); _setSkillStatus(name, 'draft'); });
729    } else {
730      pubBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg>Publish';
731      pubBtn.title = 'Publish — appears in the skills index';
732      pubBtn.style.color = 'var(--color-success, #4caf50)';
733      pubBtn.addEventListener('click', (e) => { e.stopPropagation(); _setSkillStatus(name, 'published'); });
734    }
735
736    // Test/audit this one skill — same action that's in the kebab, surfaced in
737    // the footer too so it's not buried under the "⋯" menu.
738    const testBtn = document.createElement('button');
739    testBtn.className = 'doclib-card-text-btn doclib-card-action-btn';
740    testBtn.innerHTML = _svg(_ICON.test, { size: 11 }) + 'Test';
741    testBtn.title = 'Test this skill — run it + AI judge';
742    testBtn.addEventListener('click', (e) => {
743      e.stopPropagation();
744      // Immediate visual feedback: previously the click looked like nothing
745      // happened because _testSkill awaits a status fetch before overwriting
746      // the preview — so users would tap a second time. Mark the button as
747      // pending right away so the first tap is obviously registered.
748      if (testBtn.dataset.busy === '1') return;  // also dedupe rapid double-tap
749      testBtn.dataset.busy = '1';
750      testBtn.disabled = true;
751      const _origHTML = testBtn.innerHTML;
752      testBtn.innerHTML = _svg(_ICON.test, { size: 11 }) + 'Starting…';
753      Promise.resolve(_testSkill(card, name)).finally(() => {
754        // The preview gets overwritten by _testSkill, which removes the
755        // testBtn from the DOM. The cleanup below only matters if the
756        // button still exists (e.g. _testSkill bailed early).
757        if (document.body.contains(testBtn)) {
758          testBtn.disabled = false;
759          testBtn.dataset.busy = '';
760          testBtn.innerHTML = _origHTML;
761        }
762      });
763    });
764
765    const rightGroup = document.createElement('div');
766    rightGroup.className = 'doclib-action-group';
767    const btnRow = document.createElement('div');
768    btnRow.className = 'doclib-action-btn-row';
769    btnRow.appendChild(testBtn);
770    btnRow.appendChild(editBtn);
771    btnRow.appendChild(delBtn);
772    rightGroup.appendChild(btnRow);
773
774    actions.appendChild(pubBtn);
775    actions.appendChild(rightGroup);
776    preview.appendChild(actions);
777    card.appendChild(preview);
778
779    // Click to expand/collapse (unless in select mode → toggle checkbox).
780    card.addEventListener('click', (e) => {
781      if (card._suppressNextClick) { card._suppressNextClick = false; return; }
782      if (e.target.closest('button, input, textarea')) return;
783      if (_selectMode) {
784        const cb = card.querySelector('.skill-select-cb');
785        if (cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); }
786        return;
787      }
788      _expandSkillCard(card, name);
789    });
790
791    // Long-press anywhere on the card opens the kebab dropdown — mirrors the
792    // documents library + brain memory pattern. Skip when touch starts on a
793    // button/input so per-control handlers keep working.
794    {
795      const kebab = header.querySelector('.skill-kebab-btn');
796      let hold = null;
797      let start = null;
798      const _lpCancel = () => { if (hold) { clearTimeout(hold); hold = null; } start = null; };
799      card.addEventListener('pointerdown', (e) => {
800        if (e.target.closest('.skill-kebab-btn, .skill-select-cb, button, input, textarea')) return;
801        start = { x: e.clientX, y: e.clientY };
802        hold = setTimeout(() => {
803          hold = null;
804          card._suppressNextClick = true;
805          setTimeout(() => { card._suppressNextClick = false; }, 400);
806          if (navigator.vibrate) try { navigator.vibrate(15); } catch {}
807          if (kebab) kebab.click();
808        }, 500);
809      });
810      card.addEventListener('pointermove', (e) => {
811        if (!start) return;
812        if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > 10) _lpCancel();
813      });
814      card.addEventListener('pointerup', _lpCancel);
815      card.addEventListener('pointercancel', _lpCancel);
816    }
817
818    cards.push(card);
819  }
820  container.innerHTML = '';
821
822  // Two collapsible sections — "Your skills" and "Built-in". Headers and
823  // cards are all DIRECT children of the grid (cards tagged with
824  // data-skill-section) so the global expand rule — which hides sibling
825  // .doclib-card elements by direct-child selector — keeps working.
826  // Collapse just toggles display on the tagged cards.
827  const _mkSectionHeader = (sectionId, title, count) => {
828    const collapsed = _collapsedSections.has(sectionId);
829    const hdr = document.createElement('div');
830    hdr.className = 'skills-section-label skills-section-header' + (collapsed ? ' collapsed' : '');
831    hdr.dataset.section = sectionId;
832    hdr.innerHTML =
833      `<svg class="skills-section-chevron" width="10" height="10" viewB
833ox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>` +
834      `<span>${esc(title)}</span>` +
835      `<span class="skills-section-count">${count}</span>`;
836    hdr.addEventListener('click', () => {
837      if (_collapsedSections.has(sectionId)) _collapsedSections.delete(sectionId);
838      else _collapsedSections.add(sectionId);
839      _saveCollapsedSections();
840      _applySectionCollapse(container);
841    });
842    return hdr;
843  };
844
845  // "Your skills" section — show the header only when there's also a
846  // built-in section to distinguish from (otherwise it's just the list).
847  if (cards.length) {
848    if (showBuiltin) container.appendChild(_mkSectionHeader('user', 'Your skills', cards.length));
849    cards.forEach(c => { c.dataset.skillSection = 'user'; container.appendChild(c); });
850  }
851
852  // Built-in capabilities — read-only cards (the agent's native tools).
853  if (showBuiltin) {
854    const builtinCards = _buildBuiltinCards();
855    container.appendChild(_mkSectionHeader('builtin', 'Built-in capabilities', builtinCards.length));
856    builtinCards.forEach(c => { c.dataset.skillSection = 'builtin'; container.appendChild(c); });
857  }
858
859  _applySectionCollapse(container);
860
861  // Domino-in cascade when the Skills tab is (re)opened — same sleek
862  // staggered entrance the document/chat library uses (.doclib-just-opened
863  // → section-domino-in on each .doclib-card child). Only consumes the flag
864  // set on tab-open, so search/sort/edit re-renders stay instant.
865  if (_cascadeNext && cards.length) {
866    _cascadeNext = false;
867    _playSkillsCascade(container);
868  }
869
870  // Select-mode checkbox wiring (card-body click is handled in the card's
871  // own click listener above).
872  if (_selectMode) {
873    container.querySelectorAll('.skill-select-cb').forEach(cb => {
874      cb.addEventListener('change', () => {
875        const name = cb.dataset.name;
876        if (cb.checked) _selectedNames.add(name); else _selectedNames.delete(name);
877        const all = document.getElementById('skills-select-all');
878        if (all) {
879          const visible = _getFilteredSkills().map(s => s.name || s.id);
880          all.checked = visible.length > 0 && visible.every(n => _selectedNames.has(n));
881        }
882        _updateBulkBar();
883      });
884    });
885  }
886
887  // Background-load the visible skills' SKILL.md so expanding any of them is
888  // instant (no first-time async fetch → no jump). Deferred so it never
889  // competes with the render/cascade paint.
890  setTimeout(_preloadVisibleMarkdown, 0);
891}
892
893// ---- Card expand / edit / actions ----
894
895// Collapse an expanded skill card: drop the class AND clear the inline
896// heights skills.js pinned on the card/preview/<pre> (otherwise a collapsed
897// card keeps its full expanded height) and detach its resize listener.
898function _collapseSkillCardEl(c) {
899  c.classList.remove('doclib-card-expanded', 'skill-expand-instant');
900  c.style.removeProperty('height');
901  const pv = c.querySelector('.doclib-card-preview');
902  const pr = c.querySelector('.skill-md-pre') || c.querySelector('.skill-md-editor');
903  if (pv) { pv.style.removeProperty('height'); pv.style.removeProperty('flex'); pv.style.removeProperty('max-height'); }
904  if (pr) { pr.style.removeProperty('height'); pr.style.removeProperty('flex'); }
905  if (c._fillH) window.removeEventListener('resize', c._fillH);
906}
907
908async function _expandSkillCard(card, name) {
909  const grid = card.closest('.doclib-grid');
910  const adminCard = card.closest('.admin-card');
911  // Toggle collapse if already open.
912  if (card.classList.contains('doclib-card-expanded')) {
913    _collapseSkillCardEl(card);
914    if (adminCard) adminCard.classList.remove('skills-has-expanded');
915    return;
916  }
917  // Were we already showing another expanded card? If so this is a SWITCH,
918  // not a fresh open — skip the fade-in. The fade reveals the previous card
919  // collapsing behind the new (semi-transparent) one, which read as a jump.
920  const switching = !!(grid && grid.querySelector('.doclib-card-expanded'));
921  // Collapse any other expanded sibling (full cleanup, not just the class).
922  if (grid) grid.querySelectorAll('.doclib-card-expanded').forEach(_collapseSkillCardEl);
923  card.classList.add('doclib-card-expanded');
924  if (switching) card.classList.add('skill-expand-instant');
925  // Explicit class on the admin-card so CSS doesn't depend on :has()
926  // (Firefox mobile builds without :has left the expand at ~50%).
927  if (adminCard) adminCard.classList.add('skills-has-expanded');
928  if (grid) grid.scrollTop = 0;
929
930  // Firefox doesn't treat the absolutely-positioned card's stretched height
931  // (inset:0) or height:100% as DEFINITE, so grid/flex children won't fill.
932  // Pin an explicit px height = the card's already-rendered height. A px
933  // value is unambiguously definite, so the preview + <pre> finally fill.
934  card._fillH = () => {
935    // Reset any prior inline heights so we measure the natural box first
936    // (and so switching desktop<->mobile never leaves stale px values).
937    card.style.removeProperty('height');
938    const preview = card.querySelector('.doclib-card-preview');
939    const header = card.querySelector('.skill-card-header');
940    const pre = card.querySelector('.skill-md-pre') || card.querySelector('.skill-md-editor');
941    if (preview) { preview.style.removeProperty('height'); preview.style.removeProperty('flex'); preview.style.removeProperty('max-height'); }
942    if (pre) { pre.style.removeProperty('height'); pre.style.removeProperty('flex'); }
943
944    // The px-pinning is ONLY for the mobile layout (position:absolute fill,
945    // where Firefox won't propagate a definite height). On desktop the card
946    // expands via normal flex/flow — pinning measured heights there just
947    // under-sizes it. So bail on desktop and let the CSS handle it.
948    if (!window.matchMedia('(max-width: 768px)').matches) return;
949
950    const cardH = card.getBoundingClientRect().height;
951    if (cardH <= 0) return;
952    card.style.setProperty('height', cardH + 'px', 'important');
953    if (!preview) return;
954
955    const px = (el, prop) => parseFloat(getComputedStyle(el)[prop]) || 0;
956    const headerH = header ? header.getBoundingClientRect().height : 0;
957    const cardPad = px(card, 'paddingTop') + px(card, 'paddingBottom');
958    const previewH = Math.max(0, cardH - headerH - cardPad);
959    // Force the preview to an explicit height (flex:none so nothing fights it).
960    // A max-height (~335px, resolved from a % rule) was capping it — clear it.
961    preview.style.setProperty('flex', '0 0 auto', 'important');
962    preview.style.setProperty('max-height', 'none', 'important');
963    preview.style.setProperty('height', previewH + 'px', 'important');
964
965    if (pre) {
966      // Pre = preview height minus its non-pre siblings (footer, warn banner).
967      const prevPad = px(preview, 'paddingTop') + px(preview, 'paddingBottom');
968      let siblings = 0;
969      for (const child of preview.children) {
970        if (child !== pre) siblings += child.getBoundingClientRect().height;
971      }
972      const preH = Math.max(0, previewH - prevPad - siblings);
973      pre.style.setProperty('height', preH + 'px', 'important');
974      pre.style.setProperty('flex', '0 0 auto', 'important');
975    }
976  };
977  // Size SYNCHRONOUSLY (not in rAF) so the pinned heights are in place before
978  // the browser's first paint of the expanded card. Running it a frame later
979  // let the first frame paint at content-height, then snap — the "explosion"
980  // that showed on the first expand (when the SKILL.md was still loading).
981  card._fillH();
982  window.addEventListener('resize', card._fillH);
983
984  const pre = card.querySelector('.skill-md-pre');
985  if (pre && !card._mdLoaded) {
986    // Use the cache when available (the bg preload usually has it already),
987    // so the content is in place synchronously — no async settle/jump.
988    if (_mdCache.has(name)) {
989      const md = _mdCache.get(name);
990      pre.textContent = md || '(empty)';
991      card._mdLoaded = true;
992      card._md = md || '';
993    } else {
994      pre.textContent = 'Loading…';
995      try {
996        const md = await _fetchSkillMarkdown(name);
997        pre.textContent = md || '(empty)';
998        card._mdLoaded = true;
999        card._md = md;
1000      } catch (e) {
1001        pre.textContent = 'Failed to load SKILL.md';
1002      }
1003    }
1004  }
1005}
1006
1007// Swap the read-only <pre> for an editable <textarea> (and back). The
1008// Edit button toggles; a Save button commits via the markdown endpoint.
1009function _toggleSkillEdit(card, name) {
1010  const preview = card.querySelector('.skill-card-preview');
1011  if (!preview) return;
1012  const existing = preview.querySelector('.skill-md-editor');
1013  if (existing) {
1014    // Already editing — treat Edit as Save.
1015    _saveSkillEdit(card, name);
1016    return;
1017  }
1018  const pre = preview.querySelector('.skill-md-pre');
1019  const ta = document.createElement('textarea');
1020  ta.className = 'skill-md-editor';
1021  ta.spellcheck = false;
1022  ta.value = (card._md != null ? card._md : (pre ? pre.textContent : '')) || '';
1023  ta.addEventListener('click', (e) => e.stopPropagation());
1024  if (pre) pre.style.display = 'none';
1025  preview.insertBefore(ta, preview.querySelector('.doclib-card-expanded-actions'));
1026  ta.focus();
1027  // Flip the Edit button label to "Save".
1028  const editBtn = [...preview.querySelectorAll('.doclib-card-action-btn')].find(b => /Edit|Save/.test(b.textContent));
1029  if (editBtn) editBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save';
1030}
1031
1032async function _saveSkillEdit(card, name) {
1033  const preview = card.querySelector('.skill-card-preview');
1034  const ta = preview?.querySelector('.skill-md-editor');
1035  if (!ta) return;
1036  try {
1037    const res = await fetch(`${API}/api/skills/${encodeURIComponent(name)}/markdown`, {
1038      method: 'POST',
1039      headers: { 'Content-Type': 'application/json' },
1040      body: JSON.stringify({ markdown: ta.value }),
1041    });
1042    if (!res.ok) throw new Error(`HTTP ${res.status}`);
1043    // Refresh the cached markdown so the preload/expand show the new text.
1044    _mdCache.set(name, ta.value);
1045    uiModule.showToast('Saved');
1046    await loadSkills();  // re-render (frontmatter changes like name/status may have changed)
1047  } catch (e) {
1048    uiModule.showError('Save failed: ' + e.message);
1049  }
1050}
1051
1052async function _deleteSkill(name, card = null) {
1053  if (!(await uiModule.styledConfirm(`Delete skill "${name}"? This removes the SKILL.md.`, { confirmText: 'Delete', danger: true }))) return;
1054  // Locate the card if the caller didn't hand one over, so we can collapse it
1055  // away gracefully (same fade+shrink as the document library) instead of
1056  // re-rendering the whole list.
1057  if (!card) {
1058    card = [...document.querySelectorAll('.skill-card')]
1059      .find(c => { const n = c.querySelector('.skill-card-name'); return n && n.textContent === name; }) || null;
1060  }
1061  try {
1062    await fetch(`${API}/api/skills/${encodeURIComponent(name)}`, { method: 'DELETE' });
1063    _mdCache.delete(name);
1064    if (card) {
1065      if (card._testPoll) { clearInterval(card._testPoll); card._testPoll = null; }
1066      _setCardRunning(card, false);
1067      card.classList.add('doclib-card-deleting');
1068      card.addEventListener('transitionend', () => card.remove(), { once: true });
1069      setTimeout(() => { if (card.parentElement) card.remove(); }, 400);
1070    } else {
1071      await loadSkills();
1072    }
1073    uiModule.showToast('Skill deleted');
1074  } catch (e) { uiModule.showError('Delete failed: ' + e.message); }
1075}
1076
1077async function _setSkillStatus(name, status) {
1078  try {
1079    await fetch(`${API}/api/skills/${encodeURIComponent(name)}`, {
1080      method: 'PUT',
1081      headers: { 'Content-Type': 'application/json' },
1082      body: JSON.stringify({ status }),
1083    });
1084    await loadSkills();
1085    uiModule.showToast(status === 'published' ? 'Skill approved' : 'Skill moved to draft');
1086  } catch (e) { uiModule.showError('Update failed: ' + e.message); }
1087}
1088
1089// ---- Test a skill (sandbox agent run + AI eval) ----
1090
1091async function _fetchTestStatus(name) {
1092  try {
1093    const r = await fetch(`${API}/api/skills/${encodeURIComponent(name)}/test-status`);
1094    return r.ok ? await r.json() : { status: 'none' };
1095  } catch { return { status: 'none' }; }
1096}
1097
1098function _renderTestLog(logEl, verdictEl, job, card, name) {
1099  if (!logEl) return;
1100  logEl.innerHTML = '';
1101  const add = (txt, cls) => { const d = document.createElement('div'); if (cls) d.className = cls; d.textContent = txt; logEl.appendChild(d); };
1102  for (const ev of (job.log || [])) {
1103    if (ev.type === 'skill_test_start') { add('Task: ' + ev.task, 'skill-test-task'); add('Model: ' + ev.model, 'skill-test-meta'); }
1104    else if (ev.type === 'agent_step') add('— round ' + ev.round + ' —', 'skill-test-round');
1105    else if (ev.type === 'tool_start') add('▸ ' + ev.tool + '  ' + String(ev.command || '').slice(0, 200), 'skill-test-tool');
1106    else if (ev.type === 'tool_output') add(String(ev.output || '').slice(0, 500), 'skill-test-out');
1107    else if (ev.type === 'say') add(ev.text || '', 'skill-test-say');
1108    else if (ev.type === 'evaluating') add('Evaluating run…', 'skill-test-meta');
1109    else if (ev.type === 'error') add('Error: ' + (ev.error || 'run failed'), 'skill-test-err');
1110  }
1111  if (job.status === 'running') add('…running (you can close this — it keeps going)', 'skill-test-meta');
1112  logEl.scrollTop = logEl.scrollHeight;
1113  if (job.status === 'done' && job.verdict) _renderTestVerdict(verdictEl, job.verdict, card, name);
1114  else if (verdictEl) verdictEl.innerHTML = '';
1115}
1116
1117// `force` = start a fresh run even if a finished result already exists (Retry).
1118async function _testSkill(card, name, force = false) {
1119  if (!card.classList.contains('doclib-card-expanded')) await _expandSkillCard(card, name);
1120  const preview = card.querySelector('.skill-card-preview');
1121  if (!preview) return;
1122  preview.innerHTML =
1123    '<div class="skill-test"><div class="skill-test-log"></div>' +
1124    '<div class="skill-test-verdict"></div></div>';
1125  const logEl = preview.querySelector('.skill-test-log');
1126  const verdictEl = preview.querySelector('.skill-test-verdict');
1127  if (card._testPoll) { clearInterval(card._testPoll); card._testPoll = null; }
1128
1129  // Attach to an existing job unless forcing a fresh run.
1130  let job = force ? { status: 'none' } : await _fetchTestStatus(name);
1131
1132  if (job.status === 'none') {
1133    logEl.innerHTML = '<div class="skill-test-meta">Starting test…</div>';
1134    let model = '', endpoint_url = '';
1135    try {
1136      const sm = window.sessionModule;
1137      model = (sm && sm.getCurrentModel && sm.getCurrentModel()) || '';
1138      endpoint_url = (sm && sm.getCurrentEndpointUrl && sm.getCurrentEndpointUrl()) || '';
1139    } catch (_) {}
1140    try {
1141      const res = await fetch(`${API}/api/skills/${encodeURIComponent(name)}/test`, {
1142        method: 'POST', headers: { 'Content-Type': 'application/json' },
1143        body: JSON.stringify({ model, endpoint_url }),
1144      });
1145      if (!res.ok) { logEl.innerHTML = '<div class="skill-test-err">Test failed: HTTP ' + res.status + '</div>'; return; }
1146    } catch (e) { logEl.innerHTML = '<div class="skill-test-err">Test failed: ' + (e.message || e) + '</div>'; return; }
1147    job = await _fetchTestStatus(name);
1148  }
1149
1150  _renderTestLog(logEl, verdictEl, job, card, name);
1151  _setCardRunning(card, job.status === 'running');
1152
1153  if (job.status === 'running') {
1154    card._testPoll = setInterval(async () => {
1155      // Keep polling even if the card is collapsed (the test runs server-side);
1156      // only stop once the card itself is gone from the DOM.
1157      if (!document.body.contains(card)) { clearInterval(card._testPoll); card._testPoll = null; _setCardRunning(card, false); return; }
1158      const s = await _fetchTestStatus(name);
1159      // Update the expanded log only while it's still on screen.
1160      if (document.body.contains(logEl)) _renderTestLog(logEl, verdictEl, s, card, name);
1161      if (s.status !== 'running') {
1162        clearInterval(card._testPoll); card._testPoll = null;
1163        _setCardRunning(card, false);
1164        // If the log isn't visible (card was collapsed), still update the
1165        // header dot/% so the result shows on the folded card.
1166        if (!document.body.contains(logEl) && s.verdict && s.verdict.verdict) {
1167          _applyVerdictToHeader(card, s.verdict.verdict);
1168        }
1169      }
1170    }, 1300);
1171  }
1172}
1173
1174// Show/hide the app-wide whirlpool spinner next to the skill name while a test
1175// is in flight. Works on the collapsed header too, since we inject a real DOM
1176// element rather than a CSS pseudo on a class.
1177function _setCardRunning(card, on) {
1178  if (!card) return;
1179  card.classList.toggle('skill-test-running', !!on);
1180  if (on) {
1181    if (card._testSpinner) return;
1182    const nameEl = card.querySelector('.skill-card-name');
1183    if (!nameEl) return;
1184    const wp = spinnerModule.createWhirlpool(12);
1185    wp.element.style.cssText = 'display:inline-flex;width:12px;height:12px;margin:0 0 0 7px;vertical-align:middle;flex-shrink:0;';
1186    // Append INSIDE the <code> name (inline-flow), not after it. The textcol
1187    // is a flex column, so a sibling-after lands on its own line — putting
1188    // the spinner inside the inline code keeps it on the title row.
1189    nameEl.appendChild(wp.element);
1190    card._testSpinner = wp;
1191  } else if (card._testSpinner) {
1192    try { card._testSpinner.destroy(); } catch (_) {}
1193    if (card._testSpinner.element && card._testSpinner.element.parentElement) {
1194      card._testSpinner.element.remove();
1195    }
1196    card._testSpinner = null;
1197  }
1198}
1199
1200// Reflect a test/audit verdict on the (possibly collapsed) card header without
1201// a full reload: the glowing audit dot, the confidence %, and the pass check.
1202// Works whether the card is expanded or folded so a test that finishes after
1203// you collapse still updates the card.
1204function _applyVerdictToHeader(card, verdict) {
1205  if (!card || !verdict) return;
1206  const dotColor = {
1207    pass: 'var(--color-success, #4ade80)',
1208    needs_work: 'var(--color-warning, #f0ad4e)',
1209    inconclusive: 'var(--color-warning, #f0ad4e)',
1210    fail: 'var(--color-danger, #e06c75)',
1211  }[verdict];
1212  // Audit dot removed at user request — strip any pre-existing one so the
1213  // post-audit live update doesn't leave a stale dot from an old render.
1214  const header = card.querySelector('.skill-card-header');
1215  if (header) {
1216    header.querySelectorAll('.skill-audit-dot').forEach(n => n.remove());
1217  }
1218  const newConf = { pass: 95, needs_work: 60, fail: 40 }[verdict];
1219  const statsEl = card.querySelector('.skill-stats');
1220  if (statsEl && newConf != null) {
1221    const confEl = statsEl.querySelector('.skill-conf');
1222    if (confEl) { confEl.textContent = newConf + '%'; confEl.style.color = _confColor(newConf); }
1223  }
1224  // Fold the verdict into the status (draft / published) pill — colour the
1225  // pill itself and append a tiny check/warn/cross glyph so the audit result
1226  // lives next to the label instead of dangling in the stats row.
1227  const pill = card.querySelector('.skill-status-pill');
1228  if (pill) {
1229    // Inline glyphs for the per-verdict pill — appear next to the "checked"
1230    // label so the verdict reads as a real badge.
1231    const ICON = {
1232      pass: '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="20 6 9 17 4 12"/></svg>',
1233      needs_work: '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><line x1="12" y1="8" x2="12" y2="13"/><line x1="12" y1="17" x2="12" y2="17"/></svg>',
1234      inconclusive: '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><line x1="12" y1="8" x2="12" y2="13"/><line x1="12" y1="17" x2="12" y2="17"/></svg>',
1235      fail: '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
1236    }[verdict];
1237    // Wash the pill's bg + tint the text so a glance at the badge tells you
1238    // pass/needs-work/fail without expanding the card.
1239    const tint = {
1240      pass:       { bg: 'color-mix(in srgb, var(--color-success, #4ade80) 30%, transparent)', fg: 'var(--color-success, #4ade80)' },
1241      needs_work: { bg: 'color-mix(in srgb, var(--color-warning, #f0ad4e) 30%, transparent)', fg: 'var(--color-warning, #f0ad4e)' },
1242      inconclusive: { bg: 'color-mix(in srgb, var(--color-warning, #f0ad4e) 30%, transparent)', fg: 'var(--color-warning, #f0ad4e)' },
1243      fail:       { bg: 'color-mix(in srgb, var(--color-danger, #e06c75) 30%, transparent)',  fg: 'var(--color-danger, #e06c75)' },
1244    }[verdict];
1245    // The status pill (draft / published) keeps its own colours now — the
1246    // verdict lives in a separate "checked" pill that's inserted next to it.
1247    // Remove any prior audit glyph (was previously inserted inside the pill;
1248    // now scrub both the in-pill and sibling locations on every refresh).
1249    pill.querySelectorAll('.skill-pill-verdict').forEach(n => n.remove());
1250    if (pill.parentElement) {
1251      pill.parentElement.querySelectorAll(':scope > .skill-pill-verdict').forEach(n => n.remove());
1252    }
1253    if (ICON) {
1254      // Full "checked" pill badge — sits LEFT of the draft/published pill,
1255      // styled like the other memory-cat-badges so it reads as a real chip.
1256      const span = document.createElement('span');
1257      span.className = 'memory-cat-badge skill-pill-verdict';
1258      span.title = 'Audited: ' + verdict.replace(/_/g, ' ');
1259      span.innerHTML = ICON + '<span>checked</span>';
1260      if (tint) {
1261        span.style.background = tint.bg;
1262        span.style.color = tint.fg;
1263      }
1264      if (pill.parentElement) {
1265        pill.parentElement.insertBefore(span, pill);
1266      } else {
1267        pill.insertAdjacentElement('beforebegin', span);
1268      }
1269    }
1270  }
1271  // Old free-floating .skill-verified check (next to confidence %) is no
1272  // longer added — the pill carries the verdict glyph now. Remove a stale
1273  // one in case the card was rendered by an earlier build.
1274  card.querySelectorAll('.skill-verified').forEach(n => n.remove());
1275}
1276
1277function _renderTestVerdict(el, v, card, name) {
1278  if (!el) return;
1279  const verdict = (v && v.verdict) || 'unknown';
1280  const cls = { pass: 'ok', needs_work: 'warn', fail: 'bad', inconclusive: 'unknown' }[verdict] || 'unknown';
1281  const label = { pass: 'PASS', needs_work: 'NEEDS WORK', fail: 'FAIL', inconclusive: 'INCONCLUSIVE', unknown: 'UNCLEAR' }[verdict] || 'UNCLEAR';
1282  const conf = v && typeof v.confidence === 'number' ? Math.round(v.confidence * 100) + '%' : '';
1283  const issues = Array.isArray(v && v.issues) ? v.issues : [];
1284  // Reflect the skill's current state: if it's already published, the button
1285  // confirms "Approved" (click to unpublish) rather than offering to approve.
1286  const isPub = card && card.dataset && card.dataset.skillStatus === 'published';
1287  const approveLabel = isPub ? 'Approved' : 'Approve';
1288  const approveCls = 'skill-eval-approve' + (isPub ? ' is-approved' : (verdict === 'pass' ? ' suggested' : ''));
1289  const approveTitle = isPub ? 'Already approved — click to unpublish' : 'Publish — appears in the skills index';
1290  el.innerHTML =
1291    '<div class="skill-eval-head"><span class="skill-eval-badge skill-eval-' + cls + '">' + label + (conf ? ' · ' + conf : '') + '</span>' +
1292    '<span class="skill-eval-summary">' + esc((v && v.summary) || '') + '</span></div>' +
1293    (issues.length ? '<ul class="skill-eval-issues">' + issues.map(i => '<li>' + esc(i) + '</li>').join('') + '</ul>' : '') +
1294    '<div class="doclib-card-expanded-actions skill-eval-actions-wrap">' +
1295      '<button class="doclib-card-text-btn doclib-card-action-btn ' + approveCls + '" data-act="approve" title="' + approveTitle + '">' + approveLabel + '</button>' +
1296      '<div class="doclib-action-group"><div class="doclib-action-btn-row">' +
1297        '<button class="doclib-card-text-btn doclib-card-action-btn" data-act="retry" title="Run the test again">Retry</button>' +
1298        '<button class="doclib-card-text-btn doclib-card-action-btn" data-act="copy" title="Copy the run output + verdict">Copy</button>' +
1299        '<button class="doclib-card-text-btn doclib-card-action-btn" data-act="edit">Edit</button>' +
1300        '<button class="doclib-card-text-btn doclib-card-action-btn doclib-card-text-btn-danger" data-act="del">
1300<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button>' +
1301      '</div></div>' +
1302    '</div>';
1303  _applyVerdictToHeader(card, verdict);
1304  el.querySelector('[data-act="approve"]')?.addEventListener('click', async (e) => {
1305    e.stopPropagation();
1306    const nowPub = card.dataset.skillStatus === 'published';
1307    await _setSkillStatus(name, nowPub ? 'draft' : 'published');
1308    // _setSkillStatus reloads the list, but if this card survives, relabel it.
1309    card.dataset.skillStatus = nowPub ? 'draft' : 'published';
1310    const btn = el.querySelector('[data-act="approve"]');
1311    if (btn) {
1312      const pub = card.dataset.skillStatus === 'published';
1313      btn.textContent = pub ? 'Approved' : 'Approve';
1314      btn.title = pub ? 'Already approved — click to unpublish' : 'Publish — appears in the skills index';
1315      btn.classList.toggle('is-approved', pub);
1316      btn.classList.toggle('suggested', !pub && verdict === 'pass');
1317    }
1318  });
1319  el.querySelector('[data-act="del"]')?.addEventListener('click', (e) => { e.stopPropagation(); _deleteSkill(name, card); });
1320  el.querySelector('[data-act="edit"]')?.addEventListener('click', (e) => { e.stopPropagation(); _toggleSkillEdit(card, name); });
1321  el.querySelector('[data-act="retry"]')?.addEventListener('click', (e) => { e.stopPropagation(); _testSkill(card, name, true); });
1322  el.querySelector('[data-act="copy"]')?.addEventListener('click', (e) => {
1323    e.stopPropagation();
1324    const logEl = card.querySelector('.skill-test-log');
1325    const issuesTxt = issues.length ? '\nIssues:\n- ' + issues.join('\n- ') : '';
1326    const text = (logEl ? logEl.innerText.trim() + '\n\n' : '') +
1327      '=== Eval: ' + label + (conf ? ' (' + conf + ')' : '') + ' ===\n' + ((v && v.summary) || '') + issuesTxt;
1328    // Shared helper falls back to execCommand on plain HTTP (navigator.clipboard
1329    // is unavailable in non-secure contexts, which is why the raw call failed).
1330    uiModule.copyToClipboard(text);
1331  });
1332}
1333
1334// ---- Audit all skills (autonomous: test → fix → retry → teacher → flag) ----
1335
1336let _auditPoll = null;
1337let _auditSeenResults = 0;
1338
1339function _confirmAuditSkills(label) {
1340  return new Promise(resolve => {
1341    let overlay = document.getElementById('skills-audit-confirm-overlay');
1342    if (!overlay) {
1343      overlay = document.createElement('div');
1344      overlay.id = 'skills-audit-confirm-overlay';
1345      overlay.className = 'modal';
1346      overlay.innerHTML =
1347        '<div class="modal-content styled-confirm-box">' +
1348          '<div class="modal-header"><h4>Audit Skills</h4></div>' +
1349          '<div class="modal-body">' +
1350            '<p id="skills-audit-confirm-msg"></p>' +
1351            '<label class="memory-bulk-check-all" style="margin-top:10px;display:inline-flex;align-items:center;gap:7px;">' +
1352              '<input type="checkbox" id="skills-audit-skip-audited" checked />' +
1353              '<span>Skip already audited</span>' +
1354            '</label>' +
1355          '</div>' +
1356          '<div class="modal-footer">' +
1357            '<button id="skills-audit-confirm-cancel" class="confirm-btn confirm-btn-secondary">Cancel</button>' +
1358            '<button id="skills-audit-confirm-ok" class="confirm-btn confirm-btn-primary">Audit</button>' +
1359          '</div>' +
1360        '</div>';
1361      document.body.appendChild(overlay);
1362    }
1363
1364    const msg = overlay.querySelector('#skills-audit-confirm-msg');
1365    const skip = overlay.querySelector('#skills-audit-skip-audited');
1366    const okBtn = overlay.querySelector('#skills-audit-confirm-ok');
1367    const cancelBtn = overlay.querySelector('#skills-audit-confirm-cancel');
1368    msg.textContent = `Audit ${label}
1368? Each is tested from top to bottom, then published or moved to draft using your auto-approve confidence threshold.`;
1369    skip.checked = true;
1370    overlay.classList.remove('hidden');
1371    overlay.style.display = '';
1372
1373    function cleanup(result) {
1374      overlay.classList.add('hidden');
1375      overlay.style.display = 'none';
1376      okBtn.removeEventListener('click', onOk);
1377      cancelBtn.removeEventListener('click', onCancel);
1378      overlay.removeEventListener('click', onBackdrop);
1379      document.removeEventListener('keydown', onKey);
1380      resolve(result);
1381    }
1382    function onOk() { cleanup({ ok: true, skipAudited: !!skip.checked }); }
1383    function onCancel() { cleanup({ ok: false, skipAudited: false }); }
1384    function onBackdrop(e) { if (e.target === overlay) onCancel(); }
1385    function onKey(e) {
1386      if (e.key === 'Escape') {
1387        e.preventDefault();
1388        e.stopPropagation();
1389        cleanup({ ok: false, skipAudited: false });
1390      }
1391    }
1392
1393    okBtn.addEventListener('click', onOk);
1394    cancelBtn.addEventListener('click', onCancel);
1395    overlay.addEventListener('click', onBackdrop);
1396    document.addEventListener('keydown', onKey);
1397    okBtn.focus();
1398  });
1399}
1400
1401async function _auditAllSkills(opts = {}) {
1402  const panel = document.getElementById('skills-audit-panel');
1403  if (!panel) return;
1404  // If a run is already going, just (re)attach to it.
1405  let st = await _fetchAuditStatus();
1406  if (st.status !== 'running') {
1407    const explicitNames = Array.isArray(opts.names) ? opts.names.filter(Boolean) : null;
1408    const visibleNames = _getFilteredSkills()
1409      .map(sk => sk.name || sk.id)
1410      .filter(Boolean);
1411    const names = explicitNames || visibleNames;
1412    const label = explicitNames
1413      ? `${names.length} selected ${names.length === 1 ? 'skill' : 'skills'}`
1414      : `${names.length} visible ${names.length === 1 ? 'skill' : 'skills'}`;
1415    if (!names.length) {
1416      uiModule.showToast(explicitNames ? 'No selected skills to audit' : 'No visible skills to audit');
1417      return;
1418    }
1419    const confirmed = await _confirmAuditSkills(label);
1420    if (!confirmed.ok) return;
1421    try {
1422      const r = await fetch(`${API}/api/skills/audit-all`, {
1423        method: 'POST', headers: { 'Content-Type': 'application/json' },
1424        body: JSON.stringify({ scope: explicitNames ? 'selected' : 'all', names, skip_audited: confirmed.skipAudited }),
1425      });
1426      if (!r.ok) { uiModule.showError('Audit failed to start (HTTP ' + r.status + ')'); return; }
1427      st = await _fetchAuditStatus();
1428    } catch (e) { uiModule.showError('Audit failed: ' + (e.message || e)); return; }
1429    _auditSeenResults = 0;
1430  }
1431  panel.classList.remove('hidden');
1432  _auditSeenResults = Math.min(_auditSeenResults, (st.results || []).length);
1433  _renderAuditPanel(panel, st);
1434  _applyAuditResults(st);
1435  _highlightAuditCard(st.status === 'running' ? st.current : null);
1436  if (_auditPoll) clearInterval(_auditPoll);
1437  if (st.status === 'running') {
1438    _auditPoll = setInterval(async () => {
1439      const s = await _fetchAuditStatus();
1440      _renderAuditPanel(panel, s);
1441      _applyAuditResults(s);
1442      _highlightAuditCard(s.status === 'running' ? s.current : null);
1443      if (s.status !== 'running') {
1444        clearInterval(_auditPoll); _auditPoll = null;
1445        _highlightAuditCard(null);
1446        loadSkills();  // refresh statuses (some may have been demoted/edited)
1447      }
1448    }, 1500);
1449  }
1450}
1451
1452function _findSkillCard(name) {
1453  if (!name) return null;
1454  return [...document.querySelectorAll('.skill-card[data-skill-name]')]
1455    .find(c => c.dataset.skillName === name) || null;
1456}
1457
1458function _mergeSkillState(state) {
1459  if (!state || !state.name) return;
1460  const idx = skills.findIndex(s => (s.name || s.id) === state.name);
1461  if (idx >= 0) skills[idx] = { ...skills[idx], ...state };
1462}
1463
1464function _applySkillStateToHeader(card, state, fallbackVerdict) {
1465  if (!card) return;
1466  const verdict = state?.audit_verdict || fallbackVerdict;
1467  if (verdict) _applyVerdictToHeader(card, verdict);
1468  if (state && typeof state.confidence === 'number') {
1469    const conf = Math.round(state.confidence * 100);
1470    const confEl = card.querySelector('.skill-conf');
1471    if (confEl) { confEl.textContent = conf + '%'; confEl.style.color = _confColor(conf); }
1472  }
1473  if (state?.status) {
1474    card.dataset.skillStatus = state.status;
1475    const oldPill = card.querySelector('.skill-status-pill');
1476    if (oldPill) {
1477      const wrap = document.createElement('span');
1478      wrap.innerHTML = _statusPill(state);
1479      const next = wrap.firstElementChild;
1480      if (next) oldPill.replaceWith(next);
1481    }
1482  }
1483  const right = card.querySelector('.skill-card-right');
1484  if (right && state) {
1485    right.querySelectorAll('.skill-model-pill, .skill-necessity-pill').forEach(n => n.remove());
1486    const stats = right.querySelector('.skill-stats');
1487    const wrap = document.createElement('span');
1488    wrap.innerHTML = _auditModelPills(state) + _necessityPill(state);
1489    [...wrap.children].forEach(p => {
1490      if (stats) right.insertBefore(p, stats);
1491      else right.appendChild(p);
1492    });
1493  }
1494}
1495
1496function _applyAuditResults(st) {
1497  const results = st && Array.isArray(st.results) ? st.results : [];
1498  if (!results.length) return;
1499  for (const r of results.slice(_auditSeenResults)) {
1500    const name = r && r.skill;
1501    if (!name) continue;
1502    const state = r.skill_state || null;
1503    _mergeSkillState(state);
1504    const verdict = state?.audit_verdict || r.verdict?.verdict || (r.result === 'flagged' ? 'fail' : null);
1505    _applySkillStateToHeader(_findSkillCard(name), state, verdict);
1506  }
1507  _auditSeenResults = results.length;
1508}
1509
1510// Make the card currently being audited glow, so it's obvious which one the
1511// "Audit now" run is processing. Pass null to clear all highlights.
1512function _highlightAuditCard(name) {
1513  document.querySelectorAll('.skill-card.skill-audit-active')
1514    .forEach(c => { c.classList.remove('skill-audit-active'); _setCardRunning(c, false); });
1515  if (!name) return;
1516  const card = _findSkillCard(name);
1517  if (card) {
1518    card.classList.add('skill-audit-active');
1519    _setCardRunning(card, true);
1520    card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
1521  }
1522}
1523
1524async function _fetchAuditStatus() {
1525  try {
1526    const r = await fetch(`${API}/api/skills/audit-all/status`);
1527    return r.ok ? await r.json() : { status: 'none' };
1528  } catch { return { status: 'none' }; }
1529}
1530
1531function _renderAuditPanel(panel, st) {
1532  if (st.status === 'none') { panel.classList.add('hidden'); panel.innerHTML = ''; return; }
1533  const done = st.done || 0, total = st.total || 0;
1534  const pct = total ? Math.round((done / total) * 100) : 0;
1535  const counts = {};
1536  for (const r of (st.results || [])) counts[r.result] = (counts[r.result] || 0) + 1;
1537  const summary = Object.entries(counts).map(([k, v]) => v + ' ' + k.replace(/_/g, ' ')).join(' · ');
1538  const running = st.status === 'running';
1539  const cancelled = st.status === 'cancelled';
1540  const head = running
1541    ? `Auditing ${done}/${total}${st.current ? ' — ' + esc(st.current) : ''}`
1542    : cancelled
1543      ? `Audit cancelled — ${done}/${total}`
1544    : `Audit complete — ${total} skill${total === 1 ? '' : 's'}`;
1545  panel.innerHTML =
1546    '<div class="skills-audit-head">' +
1547      '<span class="skills-audit-title-wrap" style="display:inline-flex;align-items:center;gap:8px;">' +
1548        '<span class="skills-audit-title">' + head + '</span>' +
1549      '</span>' +
1550      (running
1551        ? '<button class="memory-toolbar-btn" data-act="audit-cancel">Cancel</button>'
1552        : '<button class="memory-toolbar-btn" data-act="audit-close">Close</button>') +
1553    '</div>' +
1554    '<div class="skills-audit-bar"><div class="skills-audit-fill" style="width:' + pct + '%"></div></div>' +
1555    (summary ? '<div class="skills-audit-summary">' + esc(summary) + (st.teacher ? ' · teacher: ' + esc(st.teacher) : '') + '</div>' : '') +
1556    '<div class="skills-audit-log">' + (st.log || []).slice(-40).map(l => '<div>' + esc(l) + '</div>').join('') + '</div>';
1557  // Whirlpool sits next to the title while the audit is actually running.
1558  if (running) {
1559    const titleWrap = panel.querySelector('.skills-audit-title-wrap');
1560    if (titleWrap) {
1561      const wp = spinnerModule.createWhirlpool(12);
1562      wp.element.style.cssText = 'display:inline-flex;width:12px;height:12px;margin:0;vertical-align:middle;flex-shrink:0;';
1563      titleWrap.appendChild(wp.element);
1564    }
1565  }
1566  const cancel = panel.querySelector('[data-act="audit-cancel"]');
1567  if (cancel) cancel.addEventListener('click', async (e) => {
1568    e.stopPropagation();
1569    cancel.disabled = true;
1570    cancel.textContent = 'Cancelling...';
1571    try {
1572      await fetch(`${API}/api/skills/audit-all/cancel`, { method: 'POST', credentials: 'same-origin' });
1573      const s = await _fetchAuditStatus();
1574      _renderAuditPanel(panel, { ...s, status: s.status === 'none' ? 'cancelled' : s.status });
1575      _highlightAuditCard(null);
1576    } catch {
1577      cancel.disabled = false;
1578      cancel.textContent = 'Cancel';
1579    }
1580  });
1581  const close = panel.querySelector('[data-act="audit-close"]');
1582  if (close) close.addEventListener('click', () => { panel.classList.add('hidden'); panel.innerHTML = ''; });
1583  const logEl = panel.querySelector('.skills-audit-log');
1584  if (logEl) logEl.scrollTop = logEl.scrollHeight;
1585}
1586
1587// ---- Select mode / bulk actions ----
1588
1589function _enterSelectMode() {
1590  _selectMode = true;
1591  _selectedNames.clear();
1592  const bar = document.getElementById('skills-bulk-bar');
1593  const btn = document.getElementById('skills-select-btn');
1594  if (bar) bar.classList.remove('hidden');
1595  if (btn) { btn.classList.add('active'); btn.textContent = 'Cancel'; }
1596  _updateBulkBar();
1597  renderSkillsList();
1598}
1599
1600function _exitSelectMode() {
1601  _selectMode = false;
1602  _selectedNames.clear();
1603  const bar = document.getElementById('skills-bulk-bar');
1604  const btn = document.getElementById('skills-select-btn');
1605  const all = document.getElementById('skills-select-all');
1606  if (bar) bar.classList.add('hidden');
1607  if (btn) { btn.classList.remove('active'); btn.textContent = 'Select'; }
1608  if (all) all.checked = false;
1609  renderSkillsList();
1610}
1611
1612function _updateBulkBar() {
1613  const countEl = document.getElementById('skills-selected-count');
1614  const delBtn = document.getElementById('skills-bulk-delete');
1615  const delNonPassingBtn = document.getElementById('skills-bulk-delete-nonpassing');
1616  const pubBtn = document.getElementById('skills-bulk-publish');
1617  const auditBtn = document.getElementById('skills-bulk-audit');
1618  if (countEl) countEl.textContent = `${_selectedNames.size} Selected`;
1619  if (delBtn) delBtn.disabled = _selectedNames.size === 0;
1620  if (auditBtn) auditBtn.disabled = _selectedNames.size === 0;
1621  if (delNonPassingBtn) {
1622    const count = _selectedNonPassingSkills().length;
1623    delNonPassingBtn.disabled = count === 0;
1624    delNonPassingBtn.title = count
1625      ? `Delete ${count} selected non-passing ${count === 1 ? 'skill' : 'skills'}`
1626      : 'No selected non-passing skills';
1627  }
1628  // Approve is only meaningful when at least one selected skill is still a draft.
1629  const anyDraft = [..._selectedNames].some(n => {
1630    const sk = skills.find(s => (s.name || s.id) === n);
1631    return sk && (sk.status || 'draft') !== 'published';
1632  });
1633  if (pubBtn) pubBtn.disabled = !anyDraft;
1634}
1635
1636function _toggleSelectAll() {
1637  const all = document.getElementById('skills-select-all');
1638  if (!all) return;
1639  const visible = _getFilteredSkills().map(s => s.name || s.id);
1640  if (all.checked) visible.forEach(n => _selectedNames.add(n));
1641  else visible.forEach(n => _selectedNames.delete(n));
1642  _updateBulkBar();
1643  renderSkillsList();
1644}
1645
1646async function _bulkDelete() {
1647  if (!_selectedNames.size) return;
1648  const n = _selectedNames.size;
1649  const ok = await uiModule.styledConfirm(
1650    `Delete ${n} ${n === 1 ? 'skill' : 'skills'}? This removes their SKILL.md files.`,
1651    { confirmText: 'Delete', danger: true }
1652  );
1653  if (!ok) return;
1654  let deleted = 0;
1655  const deletedNames = [];
1656  for (const name of _selectedNames) {
1657    try {
1658      const res = await fetch(`${API}/api/skills/${encodeURIComponent(name)}`, { method: 'DELETE' });
1659      if (res.ok) {
1660        deleted++;
1661        deletedNames.push(name);
1662      }
1663    } catch {}
1664  }
1665  for (const name of deletedNames) {
1666    const card = document.querySelector(`.skill-card[data-skill-name="${CSS.escape(name)}"]`);
1667    if (card) card.classList.add('doclib-card-deleting');
1668  }
1669  if (deletedNames.length) await new Promise(resolve => setTimeout(resolve, 320));
1670  _exitSelectMode();
1671  await loadSkills();
1672  uiModule.showToast(`Deleted ${deleted}`);
1673}
1674
1675async function _loadSkillApprovalThreshold() {
1676  try {
1677    const res = await fetch(`${API}/api/prefs`, { credentials: 'same-origin' });
1678    if (!res.ok) return;
1679    const prefs = await res.json();
1680    const raw = prefs.skill_min_confidence ?? prefs.skill_autosave_min_confidence;
1681    const val = Number(raw);
1682    if (Number.isFinite(val)) _skillApprovalThreshold = Math.max(0, Math.min(1, val));
1683  } catch {}
1684}
1685
1686function _selectedNonPassingSkills() {
1687  const selected = new Set(_selectedNames);
1688  return skills.filter(sk => {
1689    const name = sk.name || sk.id;
1690    if (!selected.has(name)) return false;
1691    const conf = Number(sk.confidence || 0);
1692    const necessity = _necessityKind(sk);
1693    if (necessity === 'duplicate' || necessity === 'trivial' || necessity === 'irrelevant') return true;
1694    if ((sk.audit_verdict || '') !== 'pass') return true;
1695    return conf < _skillApprovalThreshold;
1696  });
1697}
1698
1699async function _bulkDeleteNonPassing() {
1700  const targets = _selectedNonPassingSkills();
1701  if (!targets.length) {
1702    uiModule.showToast('No selected non-passing skills');
1703    return;
1704  }
1705  const thresholdPct = Math.round(_skillApprovalThreshold * 100);
1706  const names = targets.map(sk => sk.name || sk.id).filter(Boolean);
1707  const ok = await uiModule.styledConfirm(
1708    `Delete ${names.length} selected non-passing ${names.length === 1 ? 'skill' : 'skills'}? This removes duplicates, generic/irrelevant skills, failed audits, and anything below ${thresholdPct}%.`,
1709    { confirmText: 'Delete non passing', danger: true }
1710  );
1711  if (!ok) return;
1712  let deleted = 0;
1713  const deletedNames = [];
1714  for (const name of names) {
1715    try {
1716      const res = await fetch(`${API}/api/skills/${encodeURIComponent(name)}`, { method: 'DELETE' });
1717      if (res.ok) {
1718        deleted++;
1719        deletedNames.push(name);
1720        _mdCache.delete(name);
1721      }
1722    } catch {}
1723  }
1724  for (const name of deletedNames) {
1725    const card = document.querySelector(`.skill-card[data-skill-name="${CSS.escape(name)}"]`);
1726    if (card) card.classList.add('doclib-card-deleting');
1727  }
1728  if (deletedNames.length) await new Promise(resolve => setTimeout(resolve, 320));
1729  _exitSelectMode();
1730  await loadSkills();
1731  uiModule.showToast(`Deleted ${deleted} non-passing`);
1732}
1733
1734async function _bulkApprove() {
1735  if (!_selectedNames.size) return;
1736  let published = 0;
1737  for (const name of _selectedNames) {
1738    const sk = skills.find(s => (s.name || s.id) === name);
1739    if (sk && sk.status === 'published') continue;
1740    try {
1741      const res = await fetch(`${API}/api/skills/${encodeURIComponent(name)}`, {
1742        method: 'PUT',
1743        headers: { 'Content-Type': 'application/json' },
1744        body: JSON.stringify({ status: 'published' }),
1745      });
1746      if (res.ok) published++;
1747    } catch {}
1748  }
1749  _exitSelectMode();
1750  await loadSkills();
1751  uiModule.showToast(`Published ${published}`);
1752}
1753
1754async function _bulkAudit() {
1755  if (!_selectedNames.size) return;
1756  const selected = new Set(_selectedNames);
1757  const ordered = _getFilteredSkills()
1758    .map(sk => sk.name || sk.id)
1759    .filter(n => selected.has(n));
1760  _exitSelectMode();
1761  await _auditAllSkills({ names: ordered });
1762}
1763
1764async function _showSkillSource(name) {
1765  let md = '';
1766  try {
1767    const res = await fetch(`${API}/api/skills/${encodeURIComponent(name)}/markdown`);
1768    if (!res.ok) throw new Error(`HTTP ${res.status}`);
1769    const data = await res.json();
1770    md = data.markdown || '';
1771  } catch (e) {
1772    uiModule.showError('Failed to load SKILL.md');
1773    return;
1774  }
1775
1776  // Lightweight modal — reuses the .modal CSS the rest of the app uses.
1777  const wrap = document.createElement('div');
1778  wrap.className = 'modal';
1779  wrap.style.display = 'block';
1780  wrap.innerHTML = `
1781    <div class="modal-content" style="max-width:760px;max-height:85vh;display:flex;flex-direction:column">
1782      <div class="modal-header">
1783        <h4>SKILL.md — <code>${esc(name)}</code></h4>
1784        <span style="flex:1"></span>
1785        <button class="memory-toolbar-btn" id="skill-save-btn">Save</button>
1786        <button class="close-btn" id="skill-md-close">✖</button>
1787      </div>
1788      <div class="modal-body" style="display:flex;flex-direction:column;gap:8px">
1789        <textarea id="skill-md-textarea" spellcheck="false" style="flex:1;min-height:50vh;width:100%;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;padding:10px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--fg);box-sizing:border-box"></textarea>
1790        <p class="memory-desc" style="margin:0">Edit the frontmatter and body directly. Save replaces the file via PUT /api/skills/{name}.</p>
1791      </div>
1792    </div>
1793  `;
1794  document.body.appendChild(wrap);
1795  const ta = wrap.querySelector('#skill-md-textarea');
1796  ta.value = md;
1797  wrap.querySelector('#skill-md-close').addEventListener('click', () => wrap.remove());
1798  wrap.addEventListener('click', (e) => { if (e.target === wrap) wrap.remove(); });
1799  wrap.querySelector('#skill-save-btn').addEventListener('click', async () => {
1800    try {
1801      // We use the manage_skills-style edit by going through PUT with a
1802      // single 'content' field. The route doesn't accept that yet — use the
1803      // tool call instead. We have a /api/skills/{name} PUT for fields, but
1804      // a full SKILL.md replace is simpler via the parsed-then-PUT approach
1805      // below: parse client-side by uploading via the tool route.
1806      const res = await fetch(`${API}/api/skills/${encodeURIComponent(name)}/markdown`, {
1807        method: 'POST',
1808        headers: { 'Content-Type': 'application/json' },
1809        body: JSON.stringify({ markdown: ta.value }),
1810      });
1811      if (!res.ok) throw new Error(`HTTP ${res.status}`);
1812      uiModule.showToast('Saved');
1813      wrap.remove();
1814      await loadSkills();
1815    } catch (e) {
1816      uiModule.showError('Save failed: ' + e.message);
1817    }
1818  });
1819}
1820
1821async function importSkillFromUrl() {
1822  const input = document.getElementById('skill-import-url');
1823  const url = (input?.value || '').trim();
1824  if (!url) {
1825    uiModule.showError('Paste a GitHub or skills.sh URL first');
1826    return;
1827  }
1828  const btn = document.getElementById('skill-import-url-btn');
1829  if (btn) btn.disabled = true;
1830  try {
1831    const res = await fetch(`${API}/api/skills/import-from-url`, {
1832      method: 'POST',
1833      headers: { 'Content-Type': 'application/json' },
1834      body: JSON.stringify({ url }),
1835    });
1836    const data = await res.json().catch(() => ({}));
1837    if (!res.ok) throw new Error(data.detail || data.error || `HTTP ${res.status}`);
1838    if (input) input.value = '';
1839    await loadSkills();
1840    const name = data.skill?.name || 'skill';
1841    uiModule.showToast(`Imported ${name} (${data.files || 1} file(s))`);
1842    if (name) openSkill(name);
1843  } catch (err) {
1844    uiModule.showError('Import failed: ' + err.message);
1845  } finally {
1846    if (btn) btn.disabled = false;
1847  }
1848}
1849
1850async function addSkill() {
1851  const name = document.getElementById('new-skill-name')?.value.trim()
1852    || document.getElementById('new-skill-title')?.value.trim();
1853  const description = document.getElementById('new-skill-description')?.value.trim()
1854    || document.getElementById('new-skill-title')?.value.trim();
1855  const whenToUse = document.getElementById('new-skill-when')?.value.trim()
1856    || document.getElementById('new-skill-problem')?.value.trim() || '';
1857  const procedureRaw = document.getElementById('new-skill-procedure')?.value.trim()
1858    || document.getElementById('new-skill-solution')?.value.trim() || '';
1859  const tagsRaw = document.getElementById('new-skill-tags')?.value.trim();
1860  const category = document.getElementById('new-skill-category')?.value.trim() || 'general';
1861
1862  if (!description && !name) {
1863    uiModule.showError('Description (or name) is required');
1864    return;
1865  }
1866  const procedure = procedureRaw
1867    ? procedureRaw.split('\n').map(s => s.replace(/^\s*(?:[-*]|\d+[.)])\s+/, '').trim()).filter(Boolean)
1868    : [];
1869  const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : [];
1870
1871  try {
1872    const res = await fetch(`${API}/api/skills/add`, {
1873      method: 'POST',
1874      headers: { 'Content-Type': 'application/json' },
1875      body: JSON.stringify({
1876        name: name || undefined,
1877        description,
1878        category,
1879        when_to_use: whenToUse,
1880        procedure,
1881        tags,
1882        status: 'draft',
1883      }),
1884    });
1885    if (!res.ok) throw new Error(`HTTP ${res.status}`);
1886    ['new-skill-name', 'new-skill-title', 'new-skill-description', 'new-skill-when',
1887     'new-skill-problem', 'new-skill-procedure', 'new-skill-solution', 'new-skill-tags',
1888     'new-skill-category']
1889      .forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; });
1890    await loadSkills();
1891    uiModule.showToast('Skill added (draft)');
1892  } catch (err) {
1893    uiModule.showError('Failed to add skill: ' + err.message);
1894  }
1895}
1896
1897document.addEventListener('DOMContentLoaded', () => {
1898  document.getElementById('skill-import-url-btn')?.addEventListener('click', importSkillFromUrl);
1899  document.getElementById('skill-import-url')?.addEventListener('keydown', (e) => {
1900    if (e.key === 'Enter') importSkillFromUrl();
1901  });
1902  document.getElementById('add-skill-btn')?.addEventListener('click', addSkill);
1903  document.getElementById('skills-search')?.addEventListener('input', renderSkillsList);
1904  document.getElementById('skills-sort')?.addEventListener('change', (e) => {
1905    // Dropdown holds two optgroups: Sort (sort:<key>) and Filter (filter:<key>).
1906    // Picking a sort option leaves the filter alone, and vice-versa.
1907    const v = e.target.value || '';
1908    if (v.startsWith('sort:')) {
1909      _skillsSort = v.slice(5);
1910    } else if (v.startsWith('filter:')) {
1911      const f = v.slice(7);
1912      if (f === 'all') { _showDraftsOnly = false; _showPublishedOnly = false; _confMax = null; }
1913      else if (f === 'drafts') { _showDraftsOnly = true; _showPublishedOnly = false; _confMax = null; }
1914      else if (f === 'published') { _showPublishedOnly = true; _showDraftsOnly = false; _confMax = null; }
1915      else if (f.startsWith('conf')) { _showDraftsOnly = false; _showPublishedOnly = false; _confMax = parseInt(f.slice(4), 10) || null; }
1916    }
1917    renderSkillsList();
1918  });
1919  document.getElementById('skills-select-btn')?.addEventListener('click', () => {
1920    if (_selectMode) _exitSelectMode(); else _enterSelectMode();
1921  });
1922  document.getElementById('skills-audit-btn')?.addEventListener('click', _auditAllSkills);
1923  document.getElementById('skills-select-all')?.addEventListener('change', _toggleSelectAll);
1924  document.getElementById('skills-bulk-cancel')?.addEventListener('click', _exitSelectMode);
1925  document.getElementById('skills-bulk-audit')?.addEventListener('click', _bulkAudit);
1926  document.getElementById('skills-bulk-delete')?.addEventListener('click', _bulkDelete);
1927  document.getElementById('skills-bulk-delete-nonpassing')?.addEventListener('click', _bulkDeleteNonPassing);
1928  document.getElementById('skills-bulk-publish')?.addEventListener('click', _bulkApprove);
1929  document.getElementById('new-skill-title')?.addEventListener('keydown', (e) => {
1930    if (e.key === 'Enter') addSkill();
1931  });
1932  document.getElementById('new-skill-name')?.addEventListener('keydown', (e) => {
1933    if (e.key === 'Enter') addSkill();
1934  });
1935});
1936
1937export default { loadSkills, openSkill };
1938
1939// Populate the Skills badge on first load so the count is right before the
1940// user clicks into the tab. Cheap fetch — same as the lazy path.
1941document.addEventListener('DOMContentLoaded', () => { loadSkills(); });

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.