From ba97db9881d784ecf01fe8ac974f73a1e34b63a9 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Tue, 13 Oct 2026 21:01:21 +0100 Subject: [PATCH] Add persistent organ navigation to Scientist instance previews --- STARTER.md | 13 +++++ scripts/instance_navigation.js | 42 ++++++++++++++++ scripts/instance_navigation.py | 82 ++++++++++++++++++++++++++++++ scripts/organism_board.py | 28 +++++------ tests/navigation_browser.cjs | 84 +++++++++++++++++++++++++++++++ tests/test_instance_navigation.py | 63 +++++++++++++++++++++++ 6 files changed, 296 insertions(+), 16 deletions(-) create mode 100644 scripts/instance_navigation.js create mode 100644 scripts/instance_navigation.py create mode 100644 tests/navigation_browser.cjs create mode 100644 tests/test_instance_navigation.py diff --git a/STARTER.md b/STARTER.md index e329935..4f2bbad 100644 --- a/STARTER.md +++ b/STARTER.md @@ -27,6 +27,19 @@ The example GitHub identity and `example.invalid` address are placeholders, not repositories or a deployed service. This example body map is a fixture, not a complete Mind installation. +## Navigate before connecting organs + +The instance preview includes the familiar Scientist/organ icon bar above the +overview. Scientist returns to the overview; each connected organ opens its +configured board inside the page, with an external-link fallback. Paused organs +remain navigable without resuming their feed observation. + +An unconnected organ opens its existing Dashboards description and setup prompt, +with a **Copy setup prompt** button. Setup-incomplete organs use the same resume +prompt as their overview card. These views share their text with Dashboards; +selecting one does not connect an organ or request its feed. Links such as +`#organ-brain` can be bookmarked, and Back/Forward switch the selected view. + ## Initialize a user-owned configuration directory If you already have a canonical Mind `repos.yaml`, the offline initializer saves diff --git a/scripts/instance_navigation.js b/scripts/instance_navigation.js new file mode 100644 index 0000000..edc4f57 --- /dev/null +++ b/scripts/instance_navigation.js @@ -0,0 +1,42 @@ +// The shell keeps its navigation while selecting an instance-owned destination. +(() => { + const nav = document.querySelector('.instance-nav'); + if (!nav) return; + const overview = document.getElementById('scientist'); + const views = [...document.querySelectorAll('.instance-view')]; + const links = [...nav.querySelectorAll('a')]; + function select(focus = false) { + const view = views.find(v => '#' + v.id === location.hash); + overview.hidden = !!view; + for (const panel of views) panel.hidden = panel !== view; + const selected = view ? '#' + view.id : '#scientist'; + for (const link of links) { + if (link.hash === selected) link.setAttribute('aria-current', 'page'); + else link.removeAttribute('aria-current'); + } + const frame = view && view.querySelector('iframe'); + if (frame && !frame.dataset.loaded) { + // Replace the child entry so Back/Forward belongs to the shell routes. + frame.contentWindow.location.replace(frame.dataset.boardSrc); + frame.dataset.loaded = 'true'; + } + if (focus) { + const heading = view ? view.querySelector('h1') : overview.querySelector('h1'); + if (heading) { heading.setAttribute('tabindex', '-1'); heading.focus({preventScroll: true}); } + window.scrollTo(0, 0); + } + nav.querySelector('[aria-current="page"]').scrollIntoView({block: 'nearest', inline: 'nearest'}); + } + history.scrollRestoration = 'manual'; + window.addEventListener('hashchange', () => select(true)); + window.addEventListener('popstate', () => select(true)); + nav.addEventListener('click', event => { + const link = event.target.closest('a'); + if (!link || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + event.preventDefault(); + event.stopPropagation(); // The shared section-link handler must not scroll behind the shell. + if (link.hash !== location.hash) history.pushState(null, '', link.hash); + select(true); + }); + select(); +})(); diff --git a/scripts/instance_navigation.py b/scripts/instance_navigation.py new file mode 100644 index 0000000..431f9b8 --- /dev/null +++ b/scripts/instance_navigation.py @@ -0,0 +1,82 @@ +"""Instance-owned cockpit navigation; optional organs never gain implicit URLs.""" +from html import escape + +# Match the existing cockpit's icon/label order (including Scientist first). +# This is presentation only: membership and destinations come from the profile. +ICONS = { + 'scientist': '๐Ÿ”ญ', 'brain': '๐Ÿง ', 'mind': '๐Ÿ’ญ', 'cortex': '๐Ÿ”ฌ', + 'broca': '๐Ÿ—ฃ๏ธ', 'memory': '๐Ÿ“š', 'eyes': '๐Ÿ‘๏ธ', 'ears': '๐Ÿ‘‚', + 'heart': 'โค๏ธ', 'hands': '๐Ÿ› ๏ธ', 'pulse': 'โฑ๏ธ', 'insight': '๐Ÿ’ก', + 'dna': '๐Ÿงฌ', 'nerves': 'โšก', 'gut': 'โ™ป๏ธ', +} + + +def inactive_content(organ, *, pending=False): + """One verbatim description/prompt for both the overview and organ page.""" + key = organ['key'] + if pending: + prompt = (f"Resume setup for {organ['name']} in my existing Mind task. " + "Read my profile and canonical map first. Validate the owner setup and published feed, " + "then preview connect_organ.py before applying. Preserve existing records.") + label = f'Resume {key} setup' + else: + prompt = (f"Help me add {organ['name']} ({organ['role']}) to this Scientist. " + "Read my existing profile, canonical map and Mind task. Confirm the purpose, repository " + "identity and public board location; preview organ_lifecycle.py setup before applying. " + "Keep it setup incomplete until connect_organ.py validates its published feed.") + label = f'Add {key}' + note = '

Not connected. No feed requested.

' if pending else '' + return (f'{note}

{escape(organ["role"])}

' + f'' + f'') + + +def render(instance): + """Return navigation and local destinations without contacting any board.""" + entries = {o['key']: (o, state) for state in ('boards', 'setup', 'available') + for o in instance.get(state, [])} + links = ['
  • ' + 'Scientist
  • '] + panels = [] + for key in dict.fromkeys([*ICONS, *entries]): + if key not in entries: + continue + organ, state = entries[key] + name = escape(organ['name']) + status = ('Paused' if organ.get('paused') else 'Connected') if state == 'boards' else ( + 'Setup incomplete' if state == 'setup' else 'Available to add') + links.append(f'
  • ' + f'' + f'{name}
  • ') + if state == 'boards': + url = escape(organ['url'], quote=True) + content = (f'

    Open {name} in a new tab โ†’

    ' + f'') + else: + content = inactive_content(organ, pending=state == 'setup') + panels.append(f'') + nav = ('') + return nav, ''.join(panels) + + +CSS = """ +.instance-nav{position:sticky;top:0;z-index:20;background:var(--bg);border-bottom:1px solid var(--line);margin-bottom:1rem} +.instance-nav .nav-scroll{overflow-x:auto;overscroll-behavior-x:contain;scrollbar-width:thin;padding:.45rem .5rem} +.instance-nav ul{display:flex;gap:.3rem;list-style:none;width:max-content;margin:0 auto;padding:0} +.instance-nav .board-link{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.15rem;min-width:76px;min-height:64px;padding:.4rem .65rem;border:1px solid transparent;border-radius:12px;color:var(--muted);font-size:.8rem;font-weight:650;text-decoration:none;box-sizing:border-box} +.instance-nav .board-link:hover{background:var(--panel,var(--bg));color:var(--fg)} +.instance-nav .board-link[aria-current=page]{color:var(--accent);border-color:var(--accent)} +.instance-nav .nav-icon{font-size:1.55rem;line-height:1.25} +.instance-nav :focus-visible,.instance-view :focus-visible{outline:3px solid var(--accent);outline-offset:3px} +.instance-view[hidden],#scientist[hidden]{display:none!important} +.instance-view,#scientist{scroll-margin-top:7rem} +.instance-view h1{font-size:1.8rem;margin-bottom:.3rem} +.organ-state{color:var(--muted)} +.instance-view textarea{width:100%;box-sizing:border-box;min-height:10rem;font:inherit;padding:.75rem;background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:6px;resize:vertical} +button.organ-setup-copy{width:auto;height:auto;min-height:44px;padding:.5rem .8rem;margin-top:.5rem;font:inherit} +.instance-view iframe{display:block;width:100%;height:75vh;height:75dvh;min-height:360px;border:1px solid var(--line);background:var(--bg)} +""" diff --git a/scripts/organism_board.py b/scripts/organism_board.py index 0b009d7..9cae8e4 100644 --- a/scripts/organism_board.py +++ b/scripts/organism_board.py @@ -332,27 +332,21 @@ def _render_html(snapshot: dict) -> str:
    Published status
    {head}
    {_html.escape(note)}Open {organ_name} โ†’
    """) catalogue = "" + navigation = views = navigation_css = navigation_script = "" if instance is not None: + from instance_navigation import inactive_content, render as render_navigation, CSS + navigation, views = render_navigation(dict(instance, boards=snapshot.get("boards", []))) + navigation_css = CSS + navigation_script = (HOME / "scripts" / "instance_navigation.js").read_text(encoding="utf-8") for pending in instance.get("setup", []): key = pending["key"] - prompt_text = (f"Resume setup for {pending['name']} in my existing Mind task. " - "Read my profile and canonical map first. Validate the owner setup and published feed, " - "then preview connect_organ.py before applying. Preserve existing records.") rows.append(f'
    ' f'{_html.escape(pending["name"])} ยท Setup incomplete' - f'

    Not connected. No feed requested.

    ' - f'

    {_html.escape(pending["role"])}

    ') + f'
    {inactive_content(pending, pending=True)}
    ') entries = [] for available in instance.get("available", []): - key = available["key"] - prompt_text = (f"Help me add {available['name']} ({available['role']}) to this Scientist. " - "Read my existing profile, canonical map and Mind task. Confirm the purpose, repository " - "identity and public board location; preview organ_lifecycle.py setup before applying. " - "Keep it setup incomplete until connect_organ.py validates its published feed.") entries.append(f'
    {_html.escape(available["name"])} ยท Available to add' - f'

    {_html.escape(available["role"])}

    ') + f'{inactive_content(available)}') if entries: catalogue = '
    Available organs' + ''.join(entries) + '
    ' data = json.dumps(snapshot, ensure_ascii=False).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026") @@ -363,8 +357,8 @@ def _render_html(snapshot: dict) -> str: # whole grid inside section_layout's extra collapsed H2 group. return f""" -{_html.escape(name)} Dashboard -{banner}{panel} +{_html.escape(name)} Dashboard +{navigation}{'
    ' if instance is not None else ""}{banner}{panel} {projects_html}

    Dashboards

    {'

    No organs connected. Add a connection when your project needs it.

    ' if instance is not None and not snapshot.get("boards") else ''} @@ -372,8 +366,10 @@ def _render_html(snapshot: dict) -> str: {catalogue} {footer} +{'
    ' if instance is not None else ""}{views} +{'' if instance is not None else ""} -""" +""" def badge_endpoint(snapshot: dict) -> dict: diff --git a/tests/navigation_browser.cjs b/tests/navigation_browser.cjs new file mode 100644 index 0000000..1e331ae --- /dev/null +++ b/tests/navigation_browser.cjs @@ -0,0 +1,84 @@ +// NODE_PATH= node tests/navigation_browser.cjs +// Fixtures: CLI-rendered empty.html, connected.html and examples/lifecycle as lifecycle.html. +const {chromium} = require('playwright'); +const fs = require('node:fs'); +const path = require('node:path'); +const http = require('node:http'); +const assert = require('node:assert/strict'); +(async () => { + const fixtures = path.resolve(process.argv[2]), evidence = path.resolve(process.argv[3]); + fs.mkdirSync(evidence,{recursive:true}); + const server = http.createServer((req,res)=>{ + const name = new URL(req.url,'http://localhost').pathname.slice(1); + if (!['empty.html','connected.html','lifecycle.html'].includes(name)) {res.writeHead(404);res.end();return;} + res.setHeader('Content-Type','text/html; charset=utf-8');res.end(fs.readFileSync(path.join(fixtures,name))); + }); + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); + let browser; + try { + browser=await chromium.launch({headless:true,...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? {executablePath:process.env.PLAYWRIGHT_EXECUTABLE_PATH} : {channel:'chromium'})}); + const base=`http://127.0.0.1:${server.address().port}/`, results=[]; + for(const width of [390,768,820,1024,1440]) for(const colorScheme of ['light','dark']) { + const ctx=await browser.newContext({viewport:{width,height:1000},colorScheme,permissions:['clipboard-read','clipboard-write']}); + const requests=[]; + await ctx.route('https://**/*',route=>{requests.push(route.request().url());return route.fulfill({status:200,contentType:'text/html',body:'

    Configured board

    '});}); + const page=await ctx.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message)); + for(const variant of ['empty','connected']) { + await page.goto(base+variant+'.html'); + assert.equal(await page.locator('.instance-nav a').count(),15); + assert.equal(await page.locator('#scientist').isVisible(),true); + await page.locator('.organ-catalogue > summary').click(); + const original=await page.locator('.organ-catalogue textarea[aria-label="Add brain"]').inputValue(); + // Native keyboard link activation is the same navigation route as a pointer. + await page.locator('.instance-nav a[href="#organ-brain"]').focus();await page.keyboard.press('Enter'); + await page.waitForFunction(()=>!document.getElementById('organ-brain').hidden); + assert.equal(await page.locator('#scientist').isVisible(),false); + assert.equal(await page.locator('#organ-brain textarea').inputValue(),original); + assert.equal(await page.locator('#organ-brain h1').evaluate(el=>el===document.activeElement),true); + await page.locator('#organ-brain button.copy').click(); + assert.equal(await page.evaluate(()=>navigator.clipboard.readText()),original); + assert.equal(await page.locator('.instance-nav a[aria-current="page"]').getAttribute('href'),'#organ-brain'); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true); + if(variant==='connected') await page.screenshot({path:path.join(evidence,`brain-${width}-${colorScheme}.png`),fullPage:true}); + for(const href of await page.locator('.instance-nav a').evaluateAll(as=>as.map(a=>a.getAttribute('href')))) { + await page.locator(`.instance-nav a[href="${href}"]`).click(); + await page.waitForFunction(href=>document.querySelector('.instance-nav [aria-current="page"]').getAttribute('href')===href,href); + } + await page.goto(base+variant+'.html#organ-brain'); + assert.equal(await page.locator('#organ-brain').isVisible(),true); + await page.locator('.instance-nav a[href="#organ-mind"]').click(); + await page.goBack();await page.waitForFunction(()=>!document.getElementById('organ-brain').hidden); + await page.goForward();await page.waitForFunction(()=>!document.getElementById('organ-mind').hidden); + await page.locator('.instance-nav a[href="#scientist"]').click(); + await page.waitForFunction(()=>!document.getElementById('scientist').hidden); + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + assert(await page.evaluate(()=>document.querySelector('#scientist .hero').getBoundingClientRect().top >= document.querySelector('.instance-nav').getBoundingClientRect().bottom),'navigation must not cover the overview banner'); + if(variant==='connected') await page.screenshot({path:path.join(evidence,`overview-${width}-${colorScheme}.png`),fullPage:true}); + } + assert.deepEqual(requests,[],'empty and disconnected project must not request any external URL'); + await page.goto(base+'lifecycle.html');await page.waitForFunction(()=>!refreshing); + assert(requests.every(url=>url.startsWith('https://example.invalid/checks/state.json'))); + await page.locator('.instance-nav a[href="#organ-broca"]').click(); + assert.equal(await page.locator('#organ-broca textarea').inputValue(),await page.locator('#setup-broca textarea').inputValue()); + await page.locator('.instance-nav a[href="#organ-brain"]').click(); + await page.locator('.instance-nav a[href="#organ-mind"]').click(); + await page.frameLocator('#organ-mind iframe').locator('h1').waitFor(); + assert.equal(await page.locator('#organ-mind .organ-state').textContent(),'Paused'); + assert(requests.includes('https://example.invalid/plans/')); + assert(!requests.some(url=>url.startsWith('https://example.invalid/plans/state.json'))); + await page.goBack();await page.waitForFunction(()=>!document.getElementById('organ-brain').hidden); + await page.goForward();await page.waitForFunction(()=>!document.getElementById('organ-mind').hidden); + await page.locator('.instance-nav a[href="#organ-heart"]').click(); + await page.frameLocator('#organ-heart iframe').locator('h1').waitFor(); + assert(requests.includes('https://example.invalid/checks/')); + assert.deepEqual(errors,[]); + results.push({width,colorScheme,variants:3,passed:true});await ctx.close(); + } + const ctx=await browser.newContext({javaScriptEnabled:false}); + const page=await ctx.newPage();await page.goto(base+'empty.html#organ-brain'); + assert.equal(await page.locator('#organ-brain textarea').isVisible(),true); + await ctx.close(); + fs.writeFileSync(path.join(evidence,'validation.json'),JSON.stringify(results,null,2)); + console.log('PASS: 30 browser cases, keyboard/copy/history, inactive network isolation, connected/paused routing, no-JS text.'); + } finally {if(browser)await browser.close();await new Promise(resolve=>server.close(resolve));} +})().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/tests/test_instance_navigation.py b/tests/test_instance_navigation.py new file mode 100644 index 0000000..52e3c96 --- /dev/null +++ b/tests/test_instance_navigation.py @@ -0,0 +1,63 @@ +"""Navigation preserves instance isolation and the existing onboarding wording.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts')) +from instance_navigation import inactive_content, render +import organism_board as ob + + +def test_content_is_verbatim_for_available_and_pending(): + organ = {'key': 'brain', 'name': 'Brain', 'role': 'Orchestration'} + expected = ('Help me add Brain (Orchestration) to this Scientist. ' + 'Read my existing profile, canonical map and Mind task. Confirm the purpose, repository ' + 'identity and public board location; preview organ_lifecycle.py setup before applying. ' + 'Keep it setup incomplete until connect_organ.py validates its published feed.') + assert f'>{expected}' in inactive_content(organ) + pending = ('Resume setup for Brain in my existing Mind task. ' + 'Read my profile and canonical map first. Validate the owner setup and published feed, ' + 'then preview connect_organ.py before applying. Preserve existing records.') + assert f'>{pending}' in inactive_content(organ, pending=True) + + +def test_navigation_uses_only_explicit_connections_and_cockpit_order(): + instance = {'available': [{'key': 'brain', 'name': 'Brain', 'role': 'Orchestration'}, + {'key': 'broca', 'name': 'Broca', 'role': 'Assistants'}], + 'setup': [{'key': 'heart', 'name': 'Heart', 'role': 'Tests', + 'url': 'https://pending.invalid/'}], + 'boards': [{'key': 'mind', 'name': 'Mind', 'url': 'https://owner.invalid/plans/', + 'paused': True}]} + nav, views = render(instance) + assert nav.index('#scientist') < nav.index('#organ-brain') < nav.index('#organ-mind') < nav.index('#organ-broca') + assert 'data-board-src="https://owner.invalid/plans/"' in views + assert views.count('Paused

    ' in views + assert 'pending.invalid' not in views + assert 'pyautolabs.github.io' not in nav + views + assert 'src="https://owner.invalid/plans/"' not in views.replace('data-board-src=', '') + + +def test_overview_and_inactive_page_share_exact_content(): + organ = {'key': 'brain', 'name': 'Brain & ', 'role': 'Coordination & '} + instance = {'name': 'Test', 'source_url': 'https://github.com/Example/Scientist', 'boards': [], + 'available': [organ], 'setup': []} + snapshot = {'instance': instance, 'boards': [], 'generated': '2026-10-13T12:00:00Z'} + page = ob.render(snapshot, 'html') + assert page.count(inactive_content(organ)) == 2 + assert 'Brain & <example>' in page + assert '' not in page + assert page.index('class="instance-nav"') < page.index('
    ') + + +def test_collected_connected_and_paused_boards_remain_in_navigation(monkeypatch): + instance = {'name': 'Test', 'source_url': 'https://github.com/Example/Scientist', + 'available': [], 'setup': [], 'boards': [ + {'key': 'mind', 'name': 'Mind', 'repo': 'Plans', + 'url': 'https://example.invalid/plans/', 'paused': True}, + {'key': 'heart', 'name': 'Heart', 'repo': 'Checks', + 'url': 'https://example.invalid/checks/'}]} + monkeypatch.setattr(ob, '_get', lambda *_: '{}') + page = ob.render(ob.collect(instance=instance), 'html') + assert 'href="#organ-mind"' in page + assert 'href="#organ-heart"' in page + assert page.count('