diff --git a/STARTER.md b/STARTER.md index 4f2bbad..03e5dee 100644 --- a/STARTER.md +++ b/STARTER.md @@ -29,8 +29,14 @@ 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 +The instance preview uses the familiar cockpit shell: an instance-owned +repository name and `/ Cockpit` home link, a grey **IDLE** button (awaiting work), +a horizontally scrollable organ icon bar, and a selected-board toolbar. The shell +stays visible while the board content scrolls. **Reload** refreshes the selected +configured board or local view; **New tab** opens that same destination. IDLE is a +starter-shell placeholder, not an aggregated health verdict. + +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. diff --git a/scripts/instance_navigation.js b/scripts/instance_navigation.js index edc4f57..7111b7f 100644 --- a/scripts/instance_navigation.js +++ b/scripts/instance_navigation.js @@ -1,12 +1,20 @@ -// The shell keeps its navigation while selecting an instance-owned destination. +// Instance-owned cockpit shell. Selecting/reloading a view never activates an organ. (() => { 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')]; + const heading = document.getElementById('cockpit-heading'); + const status = document.getElementById('cockpit-status'); + const external = document.getElementById('cockpit-external'); + const scroller = nav.querySelector('.nav-scroll'); + function selectedView() { return views.find(v => '#' + v.id === location.hash); } + function hint() { + nav.querySelector('.nav-hint').hidden = scroller.scrollWidth <= scroller.clientWidth; + } function select(focus = false) { - const view = views.find(v => '#' + v.id === location.hash); + const view = selectedView(); overview.hidden = !!view; for (const panel of views) panel.hidden = panel !== view; const selected = view ? '#' + view.id : '#scientist'; @@ -15,28 +23,40 @@ else link.removeAttribute('aria-current'); } const frame = view && view.querySelector('iframe'); + heading.textContent = view ? view.querySelector('h1').textContent : 'Scientist'; + status.textContent = view ? view.querySelector('.organ-state').textContent : 'Overview'; + const local = new URL(location.href); local.hash = selected; + external.href = frame ? frame.dataset.boardSrc : local.href; + external.setAttribute('aria-label', `Open ${heading.textContent} in a new tab`); 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); + const target = frame ? heading : (view || overview).querySelector('h1'); + if (target) { target.setAttribute('tabindex', '-1'); target.focus({preventScroll: true}); } + (view || overview).scrollTop = 0; } nav.querySelector('[aria-current="page"]').scrollIntoView({block: 'nearest', inline: 'nearest'}); + hint(); } history.scrollRestoration = 'manual'; window.addEventListener('hashchange', () => select(true)); window.addEventListener('popstate', () => select(true)); - nav.addEventListener('click', event => { + document.querySelector('.instance-shell-header').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. + event.stopPropagation(); // Shared section-link handling must not scroll the shell. if (link.hash !== location.hash) history.pushState(null, '', link.hash); select(true); }); + document.getElementById('cockpit-reload').addEventListener('click', () => { + const view = selectedView(), frame = view && view.querySelector('iframe'); + if (frame) frame.contentWindow.location.replace(frame.dataset.boardSrc); + else location.reload(); + }); + window.addEventListener('resize', hint); select(); })(); diff --git a/scripts/instance_navigation.py b/scripts/instance_navigation.py index 431f9b8..0099ef7 100644 --- a/scripts/instance_navigation.py +++ b/scripts/instance_navigation.py @@ -55,28 +55,65 @@ def render(instance): f'') else: content = inactive_content(organ, pending=state == 'setup') - panels.append(f'') + identity = escape(instance.get('repo') or instance.get('name') or 'Scientist') nav = ('') + + ''.join(links) + '') + nav = (f'
' + f'{identity} / Cockpit' + '' + f'
{nav}
' + '

Scientist

' + '

Overview

' + '' + '' + '↗New tab
') 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} +body.instance-cockpit{max-width:none;width:100%;height:100vh;height:100dvh;padding:0;margin:0;display:flex;flex-direction:column;overflow:hidden;--shell-bg:#fff;--shell-alt:#f6f6fa;--shell-text:#1c1e26;--shell-soft:#555a6e;--shell-line:#e4e4ee;--shell-brand:#7c4dff;--shell-grey:#9095a8} +@media(prefers-color-scheme:dark){body.instance-cockpit{--shell-bg:#14151a;--shell-alt:#1b1d24;--shell-text:#e8e8ef;--shell-soft:#a0a4b8;--shell-line:#2a2d38;--shell-brand:#9d7aff;--shell-grey:#6b7085}} +.instance-shell-header,.cockpit-toolbar{flex:none;background:var(--shell-bg);color:var(--shell-text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif} +.instance-shell-header{padding-top:env(safe-area-inset-top)} +.cockpit-topbar{border-bottom:1px solid var(--shell-line)} +.cockpit-wrap{max-width:1160px;margin:0 auto;padding:0 1rem;min-height:3.25rem;display:flex;align-items:center;justify-content:space-between;gap:.5rem;box-sizing:border-box} +.cockpit-home{color:var(--shell-text);font-size:.95rem;font-weight:750;text-decoration:none;min-height:44px;display:flex;align-items:center;gap:.3em;min-width:0;overflow-wrap:anywhere} +.cockpit-home span{color:var(--shell-soft);font-weight:600;white-space:nowrap} +.cockpit-idle{border:1px solid var(--shell-line);border-radius:8px;background:var(--shell-alt);color:var(--shell-soft);min-height:44px;padding:.35rem .65rem;font-family:inherit;font-size:.78rem;font-weight:700;letter-spacing:.04em;white-space:nowrap;opacity:.7} +.cockpit-idle span{color:var(--shell-grey);margin-right:.35em} +.instance-nav{background:var(--shell-alt);border-bottom:1px solid var(--shell-line)} +.instance-nav .nav-scroll{max-width:1160px;margin:auto;overflow-x:scroll;overscroll-behavior-x:contain;scrollbar-width:thin;scrollbar-color:var(--shell-grey) var(--shell-alt);padding:.45rem .5rem;box-sizing:border-box} +.instance-nav .nav-scroll::-webkit-scrollbar{height:8px} +.instance-nav .nav-scroll::-webkit-scrollbar-track{background:var(--shell-alt)} +.instance-nav .nav-scroll::-webkit-scrollbar-thumb{background:var(--shell-grey);border-radius:4px} .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 .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(--shell-soft);font-size:.8rem;font-weight:650;text-decoration:none;box-sizing:border-box} +.instance-nav .board-link:hover{background:var(--shell-bg);color:var(--shell-text)} +.instance-nav .board-link[aria-current=page]{background:var(--shell-bg);color:var(--shell-brand);border-color:var(--shell-brand)} .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-nav .nav-hint{text-align:right;color:var(--shell-soft);font-size:.7rem;padding:0 .75rem .2rem;margin:0} +.instance-cockpit :focus-visible{outline:3px solid var(--shell-brand);outline-offset:3px} +.cockpit-toolbar{display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding:.3rem max(.65rem,env(safe-area-inset-left));border-bottom:1px solid var(--shell-line)} +.cockpit-toolbar h1{font-size:1rem;margin:0;line-height:1.5} +.cockpit-toolbar p{font-size:.75rem;color:var(--shell-soft);margin:0} +.cockpit-actions{display:flex;gap:.35rem;flex:none} +.cockpit-action{display:inline-flex;align-items:center;justify-content:center;gap:.35rem;min-width:44px;min-height:44px;padding:.35rem .6rem;border:1px solid var(--shell-line);border-radius:8px;background:var(--shell-bg);color:var(--shell-text);font:inherit;font-size:.8rem;cursor:pointer;text-decoration:none;box-sizing:border-box} +.cockpit-action:hover{border-color:var(--shell-brand);text-decoration:none} +.instance-stage{flex:1;min-height:0;min-width:0;display:flex;overflow:hidden;padding-bottom:env(safe-area-inset-bottom)} +.instance-stage>#scientist,.instance-stage>.instance-view{width:100%;max-width:var(--board-max);margin:0 auto;overflow-y:auto;box-sizing:border-box;padding:1rem var(--board-gutter) 4rem;scroll-margin:0} +.instance-stage>[hidden]{display:none!important} .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)} +.instance-stage>.embedded-board{max-width:none;padding:0;overflow:hidden;display:flex} +.embedded-board>h1,.embedded-board>p{display:none} +.instance-view iframe{display:block;flex:1;width:100%;height:100%;min-width:0;border:0;background:var(--bg)} +@media(max-width:420px){.cockpit-wrap{padding-inline:.65rem}.instance-nav .board-link{min-width:70px}.cockpit-action span{display:none}} """ diff --git a/scripts/organism_board.py b/scripts/organism_board.py index 9cae8e4..890596d 100644 --- a/scripts/organism_board.py +++ b/scripts/organism_board.py @@ -358,7 +358,7 @@ def _render_html(snapshot: dict) -> str: return f""" {_html.escape(name)} Dashboard -{navigation}{'
' if instance is not None else ""}{banner}{panel} +{navigation}{'
' if instance is not None else ""}{'
' 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 ''} @@ -366,8 +366,8 @@ def _render_html(snapshot: dict) -> str: {catalogue} {footer} -{'
' if instance is not None else ""}{views} -{'' if instance is not None else ""} +{'
' if instance is not None else ""}{views}{'' if instance is not None else ""} +{'' if instance is not None else ""} """ diff --git a/tests/navigation_browser.cjs b/tests/navigation_browser.cjs index 1e331ae..0bbd2ad 100644 --- a/tests/navigation_browser.cjs +++ b/tests/navigation_browser.cjs @@ -16,7 +16,7 @@ const assert = require('node:assert/strict'); 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'})}); + browser=await chromium.launch({headless:true,ignoreDefaultArgs:['--hide-scrollbars'],...(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']}); @@ -27,6 +27,18 @@ const assert = require('node:assert/strict'); await page.goto(base+variant+'.html'); assert.equal(await page.locator('.instance-nav a').count(),15); assert.equal(await page.locator('#scientist').isVisible(),true); + assert.equal((await page.locator('.cockpit-home').innerText()).replace(/\s+/g,' '),'MyScientist / Cockpit'); + assert.equal(await page.locator('.cockpit-idle').isDisabled(),true); + assert.equal(await page.locator('#cockpit-heading').textContent(),'Scientist'); + assert(await page.locator('.nav-scroll').evaluate(el=>el.scrollWidth>el.clientWidth)); + assert.equal(await page.locator('.nav-hint').isVisible(),true); + assert.equal(await page.locator('.nav-scroll').evaluate(el=>getComputedStyle(el).overflowX),'scroll'); + const initialToolbar=await page.locator('.cockpit-toolbar').boundingBox(); + await page.locator('#scientist').evaluate(el=>el.scrollTop=500); + assert.equal((await page.locator('.cockpit-toolbar').boundingBox()).y,initialToolbar.y); + await page.locator('#scientist').evaluate(el=>el.scrollTop=0); + const [popup]=await Promise.all([page.waitForEvent('popup'),page.locator('#cockpit-external').click()]); + await popup.waitForLoadState();assert(popup.url().endsWith('#scientist'));await popup.close(); 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. @@ -34,11 +46,15 @@ const assert = require('node:assert/strict'); 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('#cockpit-heading').textContent(),'Brain'); + assert((await page.locator('#cockpit-external').getAttribute('href')).endsWith('#organ-brain')); 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); + await Promise.all([page.waitForEvent('domcontentloaded'),page.locator('#cockpit-reload').click()]); + assert.equal(await page.locator('#organ-brain').isVisible(),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(); @@ -52,7 +68,7 @@ const assert = require('node:assert/strict'); 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'); + assert(await page.evaluate(()=>document.querySelector('#scientist .hero').getBoundingClientRect().top >= document.querySelector('.cockpit-toolbar').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'); @@ -65,6 +81,10 @@ const assert = require('node:assert/strict'); 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.equal(await page.locator('#cockpit-external').getAttribute('href'),'https://example.invalid/plans/'); + const previousLoads=requests.filter(url=>url==='https://example.invalid/plans/').length; + await Promise.all([page.waitForResponse(r=>r.url()==='https://example.invalid/plans/'),page.locator('#cockpit-reload').click()]); + assert.equal(requests.filter(url=>url==='https://example.invalid/plans/').length,previousLoads+1); 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); @@ -77,8 +97,10 @@ const assert = require('node:assert/strict'); 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 page.goto(base+'lifecycle.html#organ-mind'); + assert.equal(await page.locator('#organ-mind a').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.'); + console.log('PASS: 30 browser cases, cockpit shell/scrollbar/reload/new-tab, 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 index 52e3c96..fe583a4 100644 --- a/tests/test_instance_navigation.py +++ b/tests/test_instance_navigation.py @@ -61,3 +61,21 @@ def test_collected_connected_and_paused_boards_remain_in_navigation(monkeypatch) assert 'href="#organ-mind"' in page assert 'href="#organ-heart"' in page assert page.count('', 'available': [], 'setup': [], 'boards': []}) + assert 'Mira & <Lab> / Cockpit' in nav + assert 'disabled title="Awaiting work"' in nav + assert '>● IDLE' in nav + assert nav.index('cockpit-topbar') < nav.index('instance-nav') < nav.index('cockpit-toolbar') + assert 'id="cockpit-reload"' in nav + assert 'id="cockpit-external" href="#scientist" target="_blank"' in nav + assert 'pyautolabs.github.io' not in nav + + +def test_unprofiled_dashboard_does_not_gain_cockpit_shell(): + page = ob.render({'owner': 'Example', 'boards': [], 'generated': None}, 'html') + assert 'class="instance-cockpit"' not in page + assert 'class="instance-shell-header"' not in page + assert 'id="cockpit-reload"' not in page