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''
f'{name} {status}
{content} ')
+ identity = escape(instance.get('repo') or instance.get('name') or 'Scientist')
nav = (' ')
+ + ''.join(links) + '
Scroll for more boards →
')
+ nav = (f''
+ '')
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}
Publication snapshot. Open an organ for its latest evidence.
{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('