diff --git a/static/index.html b/static/index.html
index 901b42510..3f07f3308 100644
--- a/static/index.html
+++ b/static/index.html
@@ -2671,7 +2671,7 @@
-
+
diff --git a/static/js/memory.js b/static/js/memory.js
index fe8f7a978..e5d6ffff0 100644
--- a/static/js/memory.js
+++ b/static/js/memory.js
@@ -272,7 +272,7 @@ export function openMemoryModal(tabName = 'browse') {
if (tab) {
tab.click();
} else if (target === 'skills') {
- import('./skills.js?v=20260909kebabconsistency1').then(m => {
+ import('./skills.js?v=20261028skillheight1').then(m => {
if (m.loadSkills) m.loadSkills(true);
else if (m.default?.loadSkills) m.default.loadSkills(true);
});
@@ -1613,7 +1613,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
// Lazy-load skills tab (cascade=true → play the domino-in entrance)
if (target === 'skills') {
- import('./skills.js?v=20260909kebabconsistency1').then(m => { if (m.loadSkills) m.loadSkills(true); else if (m.default?.loadSkills) m.default.loadSkills(true); });
+ import('./skills.js?v=20261028skillheight1').then(m => { if (m.loadSkills) m.loadSkills(true); else if (m.default?.loadSkills) m.default.loadSkills(true); });
}
});
});
diff --git a/static/js/skills.js b/static/js/skills.js
index 413e68f37..81dcebc2c 100644
--- a/static/js/skills.js
+++ b/static/js/skills.js
@@ -1288,6 +1288,9 @@ function _toggleSkillEdit(card, name) {
ta.className = 'skill-md-editor';
ta.spellcheck = false;
ta.value = (card._md != null ? card._md : (pre ? pre.textContent : '')) || '';
+ // Let the source set the intrinsic height; the card's existing flex/max
+ // height keeps long files bounded and the Save footer reachable.
+ ta.rows = Math.max(ta.rows, ta.value.split(/\r\n|\r|\n/).length);
ta.addEventListener('click', (e) => e.stopPropagation());
if (pre) pre.style.display = 'none';
preview.insertBefore(ta, preview.querySelector('.doclib-card-expanded-actions'));
diff --git a/static/sw.js b/static/sw.js
index 44cf2e2a1..5345be92b 100644
--- a/static/sw.js
+++ b/static/sw.js
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
-const CACHE_NAME = 'odysseus-v659-notes-reference';
+const CACHE_NAME = 'odysseus-v660-skill-source-height';
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
// gives offline math fallback glyphs instead of proper typesetting.
@@ -68,7 +68,7 @@ const PRECACHE = [
'/static/js/dragSort.js',
'/static/js/sessions.js',
'/static/js/memory.js',
- '/static/js/skills.js?v=20260909kebabconsistency1',
+ '/static/js/skills.js?v=20261028skillheight1',
'/static/js/skillsMetrics.js?v=20260908autonomousskills1',
'/static/js/tourHints.js',
'/static/js/fileHandler.js?v=20260909mobileattachmentedit1',
diff --git a/tests/test_skill_editor_height_browser.py b/tests/test_skill_editor_height_browser.py
new file mode 100644
index 000000000..87d419ebe
--- /dev/null
+++ b/tests/test_skill_editor_height_browser.py
@@ -0,0 +1,76 @@
+"""Raw skill source needs a usable editor height without hiding its footer."""
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+
+from bs4 import BeautifulSoup
+import pytest
+
+
+@pytest.mark.parametrize("width", [1440, 390])
+@pytest.mark.parametrize("lines", [8, 40, 400])
+def test_skill_editor_source_height(tmp_path, width, lines):
+ if not shutil.which("node"):
+ pytest.skip("node not installed")
+ repo = Path(__file__).resolve().parents[1]
+ index = BeautifulSoup((repo / "static/index.html").read_text(), "html.parser")
+ modal = index.select_one("#memory-modal")
+ modal["class"] = [value for value in modal.get("class", []) if value != "hidden"]
+ for panel in modal.select("[data-memory-panel]"):
+ classes = set(panel.get("class", []))
+ if panel["data-memory-panel"] == "skills":
+ classes.discard("hidden")
+ else:
+ classes.add("hidden")
+ panel["class"] = sorted(classes)
+ styles = "".join(str(node) for node in index.select('link[rel="stylesheet"]'))
+ document = tmp_path / "skills.html"
+ document.write_text(styles + str(modal) + str(index.select_one("#toast")), encoding="utf-8")
+ script = r"""
+const fs=require('node:fs'),assert=require('node:assert/strict');
+let chromium;
+try{({chromium}=require('playwright'));}catch(e){if(e.code==='MODULE_NOT_FOUND')process.exit(77);throw e;}
+(async()=>{
+ const browser=await chromium.launch({headless:true});
+ try{
+ const origin=process.env.ODYSSEUS_TEST_STATIC_ORIGIN,lines=Number(process.argv[3]);
+ const page=await browser.newPage({viewport:{width:Number(process.argv[2]),height:900}});page.setDefaultTimeout(5000);
+ let source=Array.from({length:lines},(_,i)=>'Line '+(i+1)+' of synthetic skill source').join('\n'),posts=0;
+ const errors=[];page.on('pageerror',e=>errors.push(e.message));
+ await page.route(origin+'/api/**',async route=>{
+ const request=route.request(),path=new URL(request.url()).pathname;let data={};
+ if(path==='/api/skills')data={skills:[{name:'fixture-height',description:'Source height fixture',status:'draft',source:'user',confidence:0.9}]};
+ else if(path.endsWith('/markdown')){
+ if(request.method()==='POST'){posts++;source=request.postDataJSON().markdown;data={ok:true};}else data={markdown:source};
+ }else if(path.endsWith('/audit-all/status'))data={status:'none'};
+ await route.fulfill({contentType:'application/json',body:JSON.stringify(data)});
+ });
+ await page.goto(origin+'/static/js/storage.js');await page.setContent(fs.readFileSync(process.argv[1],'utf8'));
+ await page.evaluate(async()=>{const skills=await import('/static/js/skills.js');await skills.loadSkills();});
+ const card=page.locator('.skill-card[data-skill-name="fixture-height"]');await card.locator('.skill-card-name').click();
+ await card.getByRole('button',{name:'Edit',exact:true}).click();const editor=card.locator('.skill-md-editor');await editor.waitFor({state:'visible'});
+ const geometry=await editor.evaluate(el=>{
+ const box=el.getBoundingClientRect(),lineHeight=parseFloat(getComputedStyle(el).lineHeight);
+ const modal=el.closest('.modal-content').getBoundingClientRect();
+ return {height:box.height,visibleLines:el.clientHeight/lineHeight,modalBottom:modal.bottom,viewportHeight:innerHeight};
+ });
+ assert(geometry.visibleLines>=Math.min(lines,8),JSON.stringify(geometry));
+ const save=card.getByRole('button',{name:'Save',exact:true}),bounds=await save.boundingBox();
+ assert(bounds.y>=0&&bounds.y+bounds.height<=geometry.viewportHeight,JSON.stringify({geometry,bounds}));
+ assert(geometry.modalBottom<=geometry.viewportHeight+1,JSON.stringify(geometry));
+ assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);
+ await editor.fill(source+'\nSaved final line');await save.click();await page.waitForFunction(()=>!document.querySelector('.skill-md-editor'));
+ assert.equal(posts,1);assert(source.endsWith('Saved final line'));assert.deepEqual(errors,[]);
+ console.log(JSON.stringify({width:process.argv[2],lines,geometry,posts}));
+ }finally{await browser.close();}
+})().catch(e=>{console.error(e);process.exitCode=1;});
+"""
+ result = subprocess.run(["node", "-e", script, str(document), str(width), str(lines)],
+ cwd=repo, env=os.environ.copy(), capture_output=True,
+ text=True, timeout=45)
+ if result.returncode == 77:
+ pytest.skip("playwright not installed")
+ assert result.returncode == 0, result.stdout + result.stderr
+ assert json.loads(result.stdout)["posts"] == 1