Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ All notable changes to MeMesh are documented here.
its own **Project** tab. Old `?tab=` deep links and stored tab choices
migrate to the surface that absorbed them, so a bookmarked
`?tab=Browse` opens Memories instead of nothing.
- **One notice at a time.** The Doctor, Onboarding and Insights banners
could stack three deep above the nav. They now share a priority slot —
Doctor (broken install) > Onboarding (empty library) > Insights
(pending proposals) — showing exactly one; the next in line surfaces
the moment the winner is dismissed or its condition clears.

## [4.6.0] — 2026-08-16

Expand Down
4 changes: 2 additions & 2 deletions dashboard/dist/index.html

Large diffs are not rendered by default.

15 changes: 12 additions & 3 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,18 @@ export function App() {
return (
<div class="shell">
<Header health={health} error={error} />
<DoctorBanner />
<InsightsBanner currentTab={tab} onNavigateToInsights={() => setTab('Home')} />
<OnboardingBanner health={health} />
{/* The notice slot: one banner at a time. Each banner self-decides
eligibility (ineligible = no DOM), and DOM order IS the priority —
Doctor (broken install) > Onboarding (empty library) > Insights
(pending proposals). The stylesheet shows only the slot's first
rendered child; the rest wait in the tree for the winner to clear
(dismissal or the condition resolving). Three banners could
previously stack into a wall above the nav. */}
<div class="notice-slot">
<DoctorBanner />
<OnboardingBanner health={health} />
<InsightsBanner currentTab={tab} onNavigateToInsights={() => setTab('Home')} />
</div>
<TabNav tabs={tabLabels} active={tab} onSelect={(k) => setTab(k as Tab)} />
{/* Each panel is the tabpanel for its TabNav tab: id + role +
aria-labelledby wire the roving-tablist relationship (see TabNav). */}
Expand Down
10 changes: 10 additions & 0 deletions dashboard/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,16 @@ body {
}
.theme-btn:hover { border-color: var(--text-3); }

/* ---- Notice slot ---- */
/* One notice at a time. The banners inside self-decide eligibility (an
ineligible banner renders no DOM node), so the slot's FIRST rendered
child is the highest-priority applicable notice — App.tsx orders them
Doctor > Onboarding > Insights. display:none keeps the losers out of
the accessibility tree as well as the viewport; they surface the
moment the winner clears. tests/dashboard/notice-slot.test.tsx pins
both this rule and the DOM order. */
.notice-slot > * ~ * { display: none; }

/* Nav */
.nav {
display: flex;
Expand Down
117 changes: 117 additions & 0 deletions tests/dashboard/notice-slot.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// @vitest-environment happy-dom
//
// The notice slot: at most ONE banner interrupts at a time, by priority
// Doctor > Onboarding > Insights. The mechanism is split across two
// places, so this file pins both halves:
//
// 1. App.tsx renders the three banners inside `.notice-slot` in priority
// order — each banner self-decides eligibility and renders no DOM
// when ineligible, so document order IS the priority order.
// 2. global.css hides every slot child after the first rendered one.
// happy-dom does not compute stylesheet cascade, so the rule is
// pinned at the source: delete or loosen it and this file goes red
// even though every DOM assertion would still pass.
//
// All network is stubbed — nothing here touches ~/.memesh or any config.

import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
import { render, waitFor } from '@testing-library/preact';
import { readFileSync } from 'fs';
import { t } from '../../dashboard/src/lib/i18n';
import { App } from '../../dashboard/src/App';

function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}

/** Every banner eligible at once: doctor FAILs, the library is empty, and
* two dream proposals wait. (Insights additionally needs the active tab
* to not be Home — pinned via the stored tab below.) */
function stubAllBannersEligible() {
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/v1/doctor')) {
return jsonResponse({
success: true,
data: {
status: 'FAIL',
checks: [{ id: 'db', label: 'Database', status: 'fail', summary: 'db unreadable' }],
},
});
}
if (url.includes('/v1/dream/proposals')) {
return jsonResponse({ success: true, data: [{ id: 1, status: 'pending' }, { id: 2, status: 'pending' }] });
}
if (url.includes('/v1/health')) {
return jsonResponse({ success: true, data: { status: 'ok', version: 't', entity_count: 0 } });
}
if (url.includes('/v1/config')) {
return jsonResponse({ success: true, data: {} });
}
return jsonResponse({ success: true, data: [] });
});
}

describe('the notice slot shows one banner at a time, by priority', () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
// Insights self-suppresses on Home (its content lives there); park the
// app on Memories so all three banners are eligible simultaneously.
localStorage.setItem('memesh.tab', 'Memories');
});
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
sessionStorage.clear();
});

it('renders all eligible banners inside the slot in Doctor > Onboarding > Insights order', async () => {
stubAllBannersEligible();
const { container } = render(<App />);

const slot = container.querySelector('.notice-slot');
expect(slot, 'App must render the notice slot').not.toBeNull();

await waitFor(() => {
// All three eligible banners have landed in the slot.
expect(slot!.children.length).toBe(3);
});

// Document order is the priority order — the stylesheet shows only the
// first child, so getting this order wrong silently changes which
// notice the user sees.
const [first, second, third] = [...slot!.children];
expect(first.textContent).toContain('db unreadable');
expect(second.textContent).toContain(t('onboarding.title'));
expect(third.textContent).toContain(
t('banner.pendingInsights', { n: 2, s: 's' }),
);
});

it('the next notice in line takes the slot when the winner is not eligible', async () => {
stubAllBannersEligible();
// The doctor banner's dismissal signature matches the stubbed failing
// check, so the highest-priority notice is out of the running from the
// first render — Onboarding must be the slot's first child.
localStorage.setItem('memesh.doctorBanner.dismissedSig', 'db:fail::');
const { container } = render(<App />);

const slot = container.querySelector('.notice-slot')!;
await waitFor(() => {
expect(slot.children.length).toBe(2);
});
expect(slot.children[0].textContent).toContain(t('onboarding.title'));
});

it('the stylesheet hides every slot child after the first', () => {
const css = readFileSync('dashboard/src/styles/global.css', 'utf8');
// The one-notice rule: any .notice-slot child with a preceding sibling
// is display:none. Whitespace-tolerant, but the selector and the
// declaration must both survive.
expect(css).toMatch(/\.notice-slot\s*>\s*\*\s*~\s*\*\s*\{\s*display:\s*none;?\s*\}/);
});
});
Loading