From cbe0701fcdd6676e43e84bf1c463417c1d9aad2b Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Wed, 7 Oct 2026 12:05:21 +0100 Subject: [PATCH] feat: put slogan before navigation and collapse dashboard sections --- agents/conductors/cortex/_cortex.py | 3 +- agents/conductors/intake/_intake.py | 5 +- board/_board.py | 3 +- board/_theme.py | 205 ++++++++++++++++++++++++++++ docs/board-navigation.md | 42 +++++- tests/board_sections_browser.cjs | 53 +++++++ tests/test_board.py | 2 +- tests/test_board_sections.py | 72 ++++++++++ tests/test_cortex_conductor.py | 2 +- tests/test_intake_dashboard.py | 11 +- 10 files changed, 385 insertions(+), 13 deletions(-) create mode 100644 tests/board_sections_browser.cjs create mode 100644 tests/test_board_sections.py diff --git a/agents/conductors/cortex/_cortex.py b/agents/conductors/cortex/_cortex.py index e631712..78b7b3e 100644 --- a/agents/conductors/cortex/_cortex.py +++ b/agents/conductors/cortex/_cortex.py @@ -70,6 +70,7 @@ # this page is visibly the same family as the Mind's and the Brain's. sys.path.insert(0, str(BRAIN_HOME / "board")) from _theme import ( # noqa: E402 + section_layout, JS as _THEME_JS, boards_footer, css as _theme_css, hero, orchestration_panel, pills, stats, normalize_refresh_stamp, ) @@ -721,7 +722,7 @@ def render_dashboard_html(c: dict) -> str: H.append(footer) H += [f"", f"", "", ""] - return "\n".join(H) + "\n" + return section_layout("\n".join(H) + "\n") # The two lines that change on a re-render without the page changing: the diff --git a/agents/conductors/intake/_intake.py b/agents/conductors/intake/_intake.py index 6a569d5..17633af 100755 --- a/agents/conductors/intake/_intake.py +++ b/agents/conductors/intake/_intake.py @@ -62,6 +62,7 @@ # page and the Brain board are visibly the same family (board/_theme.py). sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "board")) from _theme import ( # noqa: E402 + section_layout, JS as _THEME_JS, boards_footer, css as _theme_css, hero, orchestration_panel, pills, stats, normalize_refresh_stamp, ) @@ -3053,7 +3054,9 @@ def h2(title, src): if footer: H.append(footer) H += [f"", "", ""] - return "\n".join(H) + "\n" + return section_layout("\n".join(H) + "\n", { + "start-here": {"count": len({r["path"] for r in high[:PICK_LIST_MAX] + quick[:PICK_LIST_MAX]})} + }) def _dashboard_body(page: str) -> str: diff --git a/board/_board.py b/board/_board.py index 90019b1..f5470a9 100755 --- a/board/_board.py +++ b/board/_board.py @@ -52,6 +52,7 @@ # this page and the Mind dashboard are visibly the same family. sys.path.insert(0, str(Path(__file__).resolve().parent)) from _theme import ( # noqa: E402 + section_layout, JS as _THEME_JS, boards_footer, css as _theme_css, hero, orchestration_panel, pills, stats, portable_prompt, ) # The organ cockpit feed contract (state.json) — the validator every organ @@ -1964,7 +1965,7 @@ def community_row(e, note, tone): if footer: H.append(footer) H += [f"", "", ""] - return "\n".join(H) + "\n" + return section_layout("\n".join(H) + "\n") def render_json(data): diff --git a/board/_theme.py b/board/_theme.py index f1b0fef..1609247 100644 --- a/board/_theme.py +++ b/board/_theme.py @@ -573,6 +573,23 @@ def mark(key): .board-nav-card:focus-visible{outline:3px solid var(--accent);outline-offset:3px} .board-nav-count{font-size:1.8rem;font-weight:700;line-height:1.15;color:var(--accent)} .board-nav-label{font-weight:650}.board-nav-context{font-size:.8rem;color:var(--muted)} + +/* Shared major-section disclosures; owner-provided summaries stay visible. */ +details.board-section{margin:1rem 0;border:1px solid var(--line);border-radius:10px;padding:0} +.board-section>summary{display:flex;align-items:center;gap:.65rem;flex-wrap:wrap;padding:1rem;cursor:pointer;list-style:none;min-height:48px} +.board-section>summary::-webkit-details-marker{display:none} +.board-section>summary::before{content:"▸";color:var(--accent);flex:none} +.board-section[open]>summary::before{content:"▾"} +.board-section>summary>h2{display:inline;margin:0;padding:0;border:0;flex:1;min-width:0;font-size:1.15rem} +.board-section>summary>h2::after{display:none} +.board-section>summary:focus-visible{outline:3px solid var(--accent);outline-offset:3px} +.board-section-body{padding:0 1rem 1rem;min-width:0} +.section-badge{font-size:.8rem;border:1px solid currentColor;border-radius:999px;padding:.15rem .55rem;white-space:normal} +.section-status-red{color:#b42318}.section-status-yellow{color:#8a5700}.section-status-green{color:#18733c} +.section-status-stale,.section-status-unknown{color:var(--muted)} +@media(prefers-color-scheme:dark){.section-status-red{color:#ff9188}.section-status-yellow{color:#eac15c}.section-status-green{color:#7bd49c}} +@media print{.board-section-body{display:block!important}.board-section>summary{break-after:avoid}} + /* --- sections ---------------------------------------------------------- */ h2{font-size:1.1rem;margin:2.1rem 0 .3rem;padding:0 0 .35rem;font-weight:650; position:relative;color:var(--accent);border-bottom:2px solid var(--edge)} @@ -1215,3 +1232,191 @@ def portable_prompt(payload): if not match: return payload return f"Use the {match[1].replace('_', '-')} skill." + payload[match.end():] + + +def section_layout(page, summaries=None): + """Compose an owner's rendered board into the shared disclosure layout. + + Preserve the owner's HTML verbatim, including IDs and copy payloads. Only + major heading groups are wrapped; existing details and card headings are + left alone. Optional summaries map section IDs to owner-supplied count, + status and label fields. This helper never infers health from prose. + """ + from html.parser import HTMLParser + + class Tree(HTMLParser): + def __init__(self, source): + super().__init__(convert_charrefs=False) + self.source = source + self.lines = [0] + self.lines.extend(m.end() for m in _re.finditer("\n", source)) + self.nodes = [] + self.stack = [] + self.feed(source) + + def source_offset(self): + line, column = self.getpos() + return self.lines[line - 1] + column + + def handle_starttag(self, tag, attrs): + start = self.source_offset() + node = dict(tag=tag, attrs=dict(attrs), start=start, + inner=start + len(self.get_starttag_text()), + end=None, close=None, parent=self.stack[-1] if self.stack else None, + children=[]) + if self.stack: + self.stack[-1]['children'].append(node) + self.nodes.append(node) + if tag in {'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', + 'link', 'meta', 'param', 'source', 'track', 'wbr'}: + node['end'] = node['close'] = node['inner'] + else: + self.stack.append(node) + + def handle_startendtag(self, tag, attrs): + self.handle_starttag(tag, attrs) + if self.stack and self.stack[-1]['start'] == self.source_offset(): + node = self.stack.pop() + node['end'] = node['close'] = node['inner'] + + def handle_endtag(self, tag): + for index in range(len(self.stack) - 1, -1, -1): + if self.stack[index]['tag'] == tag: + for node in self.stack[index:]: + node['close'] = self.source_offset() + node['end'] = self.source.find('>', self.source_offset()) + 1 + del self.stack[index:] + break + + tree = Tree(page) + summaries = summaries or {} + + def classes(node): + return (node['attrs'].get('class') or '').split() + + def ancestors(node): + node = node['parent'] + while node: + yield node + node = node['parent'] + + def headings(node): + return [n for n in tree.nodes if n['tag'] == 'h2' + and node['start'] <= n['start'] < (node['end'] or len(page))] + + edits = [] + nav = next((n for n in tree.nodes if 'board-nav' in classes(n)), None) + panel = next((n for n in tree.nodes if 'orchestration-panel' in classes(n)), None) + if nav and panel and nav['end'] and panel['end'] and nav['end'] <= panel['start']: + edits += [(nav['start'], nav['start'], page[panel['start']:panel['end']] + '\n'), + (panel['start'], panel['end'], '')] + + # Counts remain owner-supplied: reuse the values on the owner's nav cards. + nav_counts = {} + if nav: + for link in tree.nodes: + href = link['attrs'].get('href') or '' + if 'board-nav-card' not in classes(link) or not href.startswith('#'): + continue + count = next((n for n in tree.nodes if 'board-nav-count' in classes(n) + and link['start'] < n['start'] < link['end']), None) + if count: + label = next((n for n in tree.nodes if 'board-nav-label' in classes(n) + and link['start'] < n['start'] < link['end']), None) + nav_counts.setdefault(href[1:], []).append(( + _html.unescape(page[count['inner']:count['close']]), + _html.unescape(page[label['inner']:label['close']]) if label else '')) + + covered_until = -1 + for heading in (n for n in tree.nodes if n['tag'] == 'h2' and n['end']): + lineage = list(ancestors(heading)) + if heading['start'] < covered_until or any( + n['tag'] in {'details', 'nav', 'header', 'aside', 'article', 'table'} + or set(classes(n)) & {'orchestration-panel', 'card', 'file'} for n in lineage + ): + continue + # A section/group wrapper belongs to its heading; a body/main is shared. + scope = heading + for parent in lineage: + if parent['tag'] in {'body', 'main', 'html'} or len(headings(parent)) != 1: + break + scope = parent + if not scope['end'] or not scope['parent']: + continue + start, end = scope['start'], scope['end'] + ident = heading['attrs'].get('id') or scope['attrs'].get('id') + if scope is heading: + siblings = scope['parent']['children'] + pos = next(i for i, n in enumerate(siblings) if n is scope) + if pos and siblings[pos - 1]['tag'] == 'a': + previous = siblings[pos - 1] + if previous['attrs'].get('id') and not page[previous['inner']:previous['close']].strip(): + start = previous['start'] + ident = ident or previous['attrs']['id'] + end = scope['parent']['close'] or end + for sibling in siblings[pos + 1:]: + if (sibling['tag'] in {'h2', 'footer', 'script', 'nav'} or headings(sibling) + or set(classes(sibling)) & {'boards-footer', 'boards'}): + end = sibling['start'] + # Leave a preceding empty anchor with the next heading. + idx = next(i for i, n in enumerate(siblings) if n is sibling) + prev = siblings[idx - 1] + if prev['tag'] == 'a' and prev['attrs'].get('id'): + end = prev['start'] + break + info = summaries.get(ident, {}) + if not info and ident in nav_counts and ']*>', '', page[heading['inner']:heading['close']])).lower() + values = nav_counts[ident] + info = {'count': ' · '.join( + count if len(values) == 1 and label.lower() in heading_text else f'{count} {label}' + for count, label in values)} + badges = '' + if info.get('count') is not None: + badges += '' + _html.escape(str(info['count'])) + '' + status = info.get('status') + if status is not None: + tone = status if status in {'red', 'yellow', 'green', 'stale', 'unknown'} else 'unknown' + badges += (f'' + + _html.escape(str(info.get('label') or status)) + '') + title = page[heading['start']:heading['end']] + content = page[start:heading['start']] + page[heading['end']:end] + replacement = ('
' + title + badges + + '
' + content + '
') + edits.append((start, end, replacement)) + covered_until = end + for start, end, replacement in sorted(edits, key=lambda edit: (edit[0], edit[1]), reverse=True): + page = page[:start] + replacement + page[end:] + return page + + +SECTION_JS = r""" +(function(){ + if(typeof window==='undefined') return; + function reveal(){ + var id; + try { id=decodeURIComponent(location.hash.slice(1)); } catch(e) { return; } + var target=id && document.getElementById(id); + if(!target) return; + for(var p=target;p;p=p.parentElement) if(p.tagName==='DETAILS') p.open=true; + requestAnimationFrame(function(){target.scrollIntoView({block:'start'});}); + } + window.addEventListener('hashchange',reveal); + document.addEventListener('click',function(event){ + var link=event.target.closest('a[href]'); + if(link && link.hash && link.origin===location.origin && link.pathname===location.pathname) + setTimeout(reveal,0); + }); + if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',reveal); + else reveal(); + var printed=[]; + window.addEventListener('beforeprint',function(){ + printed=Array.from(document.querySelectorAll('details:not([open])')); + printed.forEach(function(node){node.open=true;}); + }); + window.addEventListener('afterprint',function(){ + printed.forEach(function(node){node.open=false;}); printed=[]; + }); +})(); +""" +JS += SECTION_JS diff --git a/docs/board-navigation.md b/docs/board-navigation.md index da8c4e8..20a73e3 100644 --- a/docs/board-navigation.md +++ b/docs/board-navigation.md @@ -1,7 +1,7 @@ # Shared board navigation -Every board uses the same top order: organ logo/banner, section navigation -cards, freshness/context, then its content. The cards are real links, not +Every board uses the same top order: organ logo/banner, slogan/orchestration +panel (including its freshness controls), section navigation cards, then its content. The cards are real links, not JavaScript-only hidden tabs. Counts are optional; an unknown count is never silently rendered as zero. Each owner supplies meaningful destinations and keeps ownership of collection, health, freshness and action semantics. @@ -11,7 +11,9 @@ keeps ownership of collection, health, freshness and action semantics. `board/_theme.py::hero(key, kind, lede_html='', navigation=())` retains its existing positional API. The optional keyword inserts `navigation_cards(items, label='Board sections')` directly after the masthead -and before the description. Existing consumers without navigation are unchanged. +and before the description. `section_layout(page, summaries=None)` composes the +finished owner HTML into the standard order, moving the orchestration panel above +these cards. Existing `hero` callers retain their positional API. Each item is a mapping with `href` and `label`, plus optional `count` and `context`. Text is escaped. Destinations accept local anchors, relative links @@ -25,6 +27,34 @@ copy card CSS into another repository or use the accent to imply health. Keep existing section anchors; only render links whose targets exist. Multiple counts may link to one meaningful summary, as Ears' follow-through cards do. +## Collapsible major sections + +Every owner passes its completed HTML through `section_layout`. The shared +adapter wraps major H2 groups in native `details`/`summary`, initially closed. +The original headings, stable anchors, content and copy payloads are retained; +existing disclosures and headings inside cards, tables and the orchestration +panel are not wrapped again. The adapter preserves the original HTML bytes +inside each group rather than serializing or interpreting domain content. +A section wrapper with one heading stays together; unwrapped heading groups end +at the next major heading, section, navigation, footer or script. Nested detail +controls retain their own state. The slogan panel itself stays visible. + +Headers reuse optional counts already supplied by the owner on navigation cards. +An owner can additionally pass `summaries={section_id: {count, status, label}}`. +Zero remains visible; absent counts remain absent. Status and label are escaped, +and statuses have visible text as well as colour. The theme never aggregates +health or derives counts from prose. Existing descriptive heading summaries stay +visible. Mind's Start Here count is the number of unique tasks actually shown in +its two recommendation lists. Heart's Observed checks uses its own check states: +red, then yellow, then stale evidence, then unknown; green requires observed +passing evidence. This is independent of release readiness. + +The shared JavaScript reveals ancestor disclosures on initial fragments, hash +changes and repeated link clicks. Navigation remains real anchor links. Native +summaries work by keyboard without JavaScript; JavaScript additionally handles +deep-link reveal and opening all content for printing, restoring closed states +afterwards. Other sections remain independently expandable. + ## Renderer ownership and rollout The authoritative membership is `config/policy.yaml` → `board.boards`. @@ -58,15 +88,15 @@ handler. Do not hide evidence age or change feeds to make the layout look fresh. ## Validation Renderer checks cover safe links, escaped labels, optional/zero/unknown counts, -header ordering and live anchor destinations. Browser validation covers 390, +header ordering, section boundaries, zero/unknown summaries and live anchor destinations. Browser validation covers 390, 768, 820, 1024 and 1440px in light/dark, long labels, keyboard navigation and count-free cards. Whole-page overflow and existing copy/disclosure behavior remain part of each consumer's validation; a shared CSS change alone is not proof of adoption. -The core implementation passed 1186 Brain tests and a strict Sphinx build. +The previous navigation-only rollout passed 1186 Brain tests and a strict Sphinx build. Thirty Chromium cases exercised the three owner renderers at five widths in -light/dark: cards immediately followed the banner, every target existed, every +light/dark: cards immediately followed the banner in that earlier layout, every target existed, every card fit the viewport with a touch-sized target, keyboard activation navigated to the section, and no whole-page overflow occurred. Screenshots below use synthetic test data, not current operational state. diff --git a/tests/board_sections_browser.cjs b/tests/board_sections_browser.cjs new file mode 100644 index 0000000..0fd0d55 --- /dev/null +++ b/tests/board_sections_browser.cjs @@ -0,0 +1,53 @@ +// NODE_PATH= node tests/board_sections_browser.cjs +const {chromium}=require('playwright'); +const fs=require('fs'),path=require('path'),assert=require('assert/strict'); +(async()=>{ + const folder=path.resolve(process.argv[2]),out=path.resolve(process.argv[3]);fs.mkdirSync(out,{recursive:true});const browser=await chromium.launch(); + const results=[]; + try{ + for(const file of fs.readdirSync(folder).filter(f=>f.endsWith('.html')&&!f.startsWith('.'))){ + const page=await browser.newPage();await page.addInitScript(()=>{Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copiedText=text}}})});const errors=[];page.on('pageerror',e=>errors.push(e.message)); + for(const colorScheme of ['light','dark'])for(const width of [390,768,820,1024,1440]){ + await page.setViewportSize({width,height:1000});await page.emulateMedia({colorScheme}); + await page.goto('file://'+path.join(folder,file)); + const structure=await page.evaluate(()=>{ + const nav=document.querySelector('.board-nav'),panel=document.querySelector('.orchestration-panel'); + const sections=[...document.querySelectorAll('details.board-section')]; + return {nav:!!nav,panel:!!panel,order:!nav||!panel||!!(panel.compareDocumentPosition(nav)&Node.DOCUMENT_POSITION_FOLLOWING),sections:sections.length,open:sections.filter(x=>x.open).length,overflow:document.documentElement.scrollWidth>innerWidth+1}; + }); + assert(structure.order,`${file}: panel order`);assert(!structure.open,`${file}: initially collapsed`); + assert(!structure.overflow,`${file}: ${width} ${colorScheme} overflow`); + const copy=page.locator('.orchestration-copy').first(); + if(await copy.count()){await copy.click();assert(await page.evaluate(()=>typeof window.copiedText==='string'&&window.copiedText.length>0),`${file}: copy prompt`);} + if(width===390&&colorScheme==='light')await page.screenshot({path:path.join(out,file+'.png'),fullPage:false}); + const links=await page.locator('.board-nav a[href^="#"]').evaluateAll(nodes=>nodes.map(n=>n.getAttribute('href'))); + for(const hash of links){ + const exists=await page.evaluate(h=>!!document.getElementById(decodeURIComponent(h.slice(1))),hash); + assert(exists,`${file}: missing ${hash}`); + await page.locator(`.board-nav a[href="${hash}"]`).first().click();await page.waitForTimeout(30); + assert(await page.evaluate(h=>{let t=document.getElementById(decodeURIComponent(h.slice(1)));for(let p=t;p;p=p.parentElement)if(p.tagName==='DETAILS'&&!p.open)return false;return true},hash),`${file}: unopened ${hash}`); + } + // Direct deep links and repeated navigation must reopen a closed parent. + if(links.length){ + const hash=links[0];await page.goto('file://'+path.join(folder,file)+hash);await page.waitForTimeout(30); + await page.evaluate(()=>document.querySelectorAll('details.board-section').forEach(d=>d.open=false)); + await page.locator(`.board-nav a[href="${hash}"]`).first().click();await page.waitForTimeout(30); + assert(await page.evaluate(h=>{let t=document.getElementById(decodeURIComponent(h.slice(1)));for(let p=t;p;p=p.parentElement)if(p.tagName==='DETAILS'&&!p.open)return false;return true},hash),`${file}: repeated anchor`); + } + const deep=await page.locator('details.board-section .board-section-body [id]').evaluateAll(nodes=>nodes.find(n=>n.closest('details:not(.board-section)'))?.id); + if(deep){ + await page.goto('file://'+path.join(folder,file)+'#'+encodeURIComponent(deep));await page.waitForTimeout(30); + assert(await page.evaluate(id=>{for(let p=document.getElementById(id);p;p=p.parentElement)if(p.tagName==='DETAILS'&&!p.open)return false;return true},deep),`${file}: deep link`); + } + const summary=page.locator('details.board-section>summary').first(); + if(await summary.count()){ + await summary.evaluate(s=>s.parentElement.open=false);await summary.focus();await page.keyboard.press('Enter'); + assert(await summary.evaluate(s=>s.parentElement.open),`${file}: keyboard`); + } + results.push({file,width,colorScheme,sections:structure.sections}); + } + assert.equal(errors.length,0,`${file}: ${errors.join('; ')}`);await page.close(); + } + fs.writeFileSync(path.join(out,'browser-results.json'),JSON.stringify(results,null,2));console.log(`${results.length} browser cases passed`); + }finally{await browser.close()} +})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/test_board.py b/tests/test_board.py index 131c18b..5cecd62 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -562,7 +562,7 @@ def test_the_header_strip_counts_every_section_that_can_ask_something(tmp_path): for label in ("Overnight red", "Blockers", "Awaiting", "In flight", "Open issues"): assert f'{label}' in page - assert '