From c207ed1ed851bd060fea3b2f7643ffefc475a632 Mon Sep 17 00:00:00 2001 From: red person Date: Wed, 28 Oct 2026 06:54:12 +0300 Subject: [PATCH] fix(skills): size the raw editor from its source lines --- static/index.html | 2 +- static/js/memory.js | 4 +- static/js/skills.js | 3 + static/sw.js | 4 +- tests/test_skill_editor_height_browser.py | 76 +++++++++++++++++++++++ 5 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 tests/test_skill_editor_height_browser.py 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