Files
sow-nodebb-theme/docs/superpowers/plans/2026-07-17-topbar-unread-drawer.md
T
archvillainette 004be9b038 Topbar unread drawer (#28)
Adds an Unread dropdown to the topbar utilities (before notifications), per docs/superpowers/plans/2026-07-17-topbar-unread-drawer.md.

- New partial `templates/partials/header/unread-drawer.tpl`: toggle with live `component="unread/count"` badge (core socket updates), server-rendered empty/footer rows.
- `public/client.js`: on dropdown open, fetches `GET /api/unread` and injects up to 10 topic rows (titles/categories via textContent only); fetch failure falls back to the empty state.
- Mobile burger drawer gets an Unread row with the same badge.
- SCSS: unread dropdown joins the shared dropdown width rule; flex row styles for topic items.

Deviations from the plan's example code, found by live verification against the dev forum:
- Plan's `api.get('/unread')` hits the v3 write API and 404s → replaced with a fetch of the `/api/unread` read route.
- NodeBB 4 has no global `window.require`, so runtime translation of the static `[[unread:...]]` strings never ran → those strings are now server-rendered in the template (also removes the plan's translate-over-innerHTML step, which would have re-parsed untrusted titles as HTML).

Verified: full plain-node test suite passes; Playwright against the dev container passed all checks (badge count, item ordering before notifications, dropdown topic links + navigation, /unread footer link, mobile drawer badge at 390px).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #28
Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
2026-07-17 16:43:12 +00:00

12 KiB
Raw Blame History

Topbar Unread Drawer Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add an "Unread" dropdown to the topbar utilities (next to notifications) with a live unread-topic count badge and a fetched list of unread topics.

Architecture: A new template partial imported by topbar.tpl gives the button + badge (core's component="unread/count" socket updates make the badge live for free). A small addition to public/client.js fetches /api/unread on dropdown open and renders topic links with plain DOM building. SCSS reuses the existing .wg-topbar__dropdown vocabulary.

Tech Stack: NodeBB templates (benchpress .tpl), jQuery/Bootstrap 5 dropdown events, NodeBB client api module, SCSS. Tests are plain-Node assert scripts in tests/ (run with node tests/<file>.test.js).

Global Constraints

  • NixOS host: no global installs; run tests with plain node.
  • One branch, one PR: all commits go on the existing feat/topbar-unread-drawer branch.
  • Verification against the dev container in ../sow-nodebb (make dev, make dev-build), forum at http://localhost:4567. Theme edits need make dev-build before they show up.
  • Titles from the API must be inserted with textContent (never HTML strings) — trust boundary.

Files:

  • Create: templates/partials/header/unread-drawer.tpl
  • Modify: templates/partials/header/topbar.tpl (logged-in utility list, before the notifications <li>; and the drawer-actions block)
  • Test: tests/global-topbar-contract.test.js (append assertions)

Interfaces:

  • Produces: <li component="unread" ...> containing a dropdown toggle and <ul ... data-wg-unread-menu>; Task 2's JS binds to .wg-topbar [component="unread"] and renders into [data-wg-unread-menu].

  • Step 1: Write the failing test — append to tests/global-topbar-contract.test.js:

const unreadDrawer = read('templates/partials/header/unread-drawer.tpl');
assertIncludes(
	topbar,
	'<!-- IMPORT partials/header/unread-drawer.tpl -->',
	'Topbar should mount the unread drawer before notifications'
);
assert(
	topbar.indexOf('partials/header/unread-drawer.tpl') < topbar.indexOf('partials/sidebar/notifications.tpl'),
	'Unread drawer should sit before the notifications item'
);
assertIncludes(
	unreadDrawer,
	'component="unread/count"',
	'Unread toggle should carry the live core count badge'
);
assertIncludes(
	unreadDrawer,
	'data-wg-unread-menu',
	'Unread dropdown menu should expose the JS mount hook'
);
assertMatches(
	topbar,
	/href="\{relative_path\}\/unread"[^>]*>[\s\S]*?component="unread\/count"/,
	'Mobile drawer actions should include an Unread link with count badge'
);
  • Step 2: Run test to verify it fails

Run: node tests/global-topbar-contract.test.js Expected: FAIL with templates/partials/header/unread-drawer.tpl should exist

  • Step 3: Create templates/partials/header/unread-drawer.tpl
<a class="nav-link dropdown-toggle d-flex gap-2 align-items-center" href="#" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" aria-label="[[unread:title]]">
	<i class="fa fa-fw fa-inbox" aria-hidden="true"></i>
	<span component="unread/count" class="badge rounded-1 bg-primary {{{ if !unreadCount.topic }}}hidden{{{ end }}}">{unreadCount.topic}</span>
</a>
<ul class="dropdown-menu wg-topbar__dropdown unread-dropdown p-1 shadow" role="menu" data-wg-unread-menu>
	<li class="dropdown-item disabled"><i class="fa fa-fw fa-spinner fa-spin" aria-hidden="true"></i> [[unread:title]]</li>
</ul>
  • Step 4: Mount it in topbar.tpl — insert before the notifications <li> (line 40):
				<li component="unread" class="nav-item unread dropdown" title="[[unread:title]]">
					<!-- IMPORT partials/header/unread-drawer.tpl -->
				</li>

And in the drawer-actions block, insert before the notifications <a> (line 112):

				<a href="{relative_path}/unread"><i class="fa fa-fw fa-inbox" aria-hidden="true"></i><span>[[unread:title]]</span><span component="unread/count" class="badge rounded-1 bg-primary {{{ if !unreadCount.topic }}}hidden{{{ end }}}">{unreadCount.topic}</span></a>
  • Step 5: Run test to verify it passes

Run: node tests/global-topbar-contract.test.js Expected: exits 0, no output

  • Step 6: Commit
git add templates/partials/header/unread-drawer.tpl templates/partials/header/topbar.tpl tests/global-topbar-contract.test.js
git commit -m "feat: add unread drawer button to topbar"

Task 2: Client JS — fetch and render unread topics on open

Files:

  • Modify: public/client.js (inside initWestgateTopbar(), next to the existing shown.bs.dropdown.westgateTopbar handler)
  • Test: tests/unread-drawer-client.test.js (new)

Interfaces:

  • Consumes: [component="unread"] / [data-wg-unread-menu] markup from Task 1; NodeBB client api.get('/unread', {}, cb) whose payload has topics: [{ title, slug, category: { name } }, ...].

  • Produces: window.westgateTheme.renderUnreadMenu(menuEl, topics) (exported for the test): clears menuEl, appends up to 10 <li><a class="dropdown-item" href="<relative_path>/topic/<slug>"> items with title text and a category <small>, then a footer link to /unread. Empty list → single disabled item [[unread:no-unread-topics]].

  • Step 1: Write the failing test — create tests/unread-drawer-client.test.js:

'use strict';

const assert = require('assert');
const fs = require('fs');
const path = require('path');

const client = fs.readFileSync(path.join(__dirname, '..', 'public', 'client.js'), 'utf8');

assert(
	client.includes("'show.bs.dropdown.westgateUnread'"),
	'client.js should fetch unread topics when the unread dropdown opens'
);
assert(
	client.includes("api.get('/unread'"),
	'client.js should load the unread list from the /unread API'
);
assert(
	client.includes('renderUnreadMenu'),
	'client.js should expose the unread menu renderer'
);
assert(
	/textContent/.test(client) && !/innerHTML\s*=[^=]*title/.test(client),
	'Topic titles must be inserted via textContent, never innerHTML'
);
  • Step 2: Run test to verify it fails

Run: node tests/unread-drawer-client.test.js Expected: FAIL with "client.js should fetch unread topics when the unread dropdown opens"

  • Step 3: Implement. In public/client.js, add above initWestgateTopbar():
	function renderUnreadMenu(menuEl, topics) {
		if (!menuEl) {
			return;
		}

		menuEl.textContent = '';
		const relativePath = (window.config && window.config.relative_path) || '';

		function addItem(node) {
			const li = document.createElement('li');
			li.appendChild(node);
			menuEl.appendChild(li);
		}

		const list = (topics || []).slice(0, 10);
		if (!list.length) {
			const empty = document.createElement('span');
			empty.className = 'dropdown-item disabled';
			empty.textContent = '[[unread:no-unread-topics]]';
			addItem(empty);
		}

		list.forEach((topic) => {
			const link = document.createElement('a');
			link.className = 'dropdown-item wg-unread-item text-truncate';
			link.href = `${relativePath}/topic/${topic.slug}`;
			link.textContent = topic.title;
			if (topic.category && topic.category.name) {
				const category = document.createElement('small');
				category.className = 'wg-unread-item__category';
				category.textContent = topic.category.name;
				link.appendChild(category);
			}
			addItem(link);
		});

		const divider = document.createElement('hr');
		divider.className = 'dropdown-divider';
		addItem(divider);

		const seeAll = document.createElement('a');
		seeAll.className = 'dropdown-item wg-unread-item--all';
		seeAll.href = `${relativePath}/unread`;
		seeAll.textContent = '[[unread:title]]';
		addItem(seeAll);

		if (window.require) {
			window.require(['translator'], function (translator) {
				translator.translate(menuEl.innerHTML, (translated) => {
					menuEl.innerHTML = translated;
				});
			});
		}
	}

And inside initWestgateTopbar(), next to the existing search-focus handler:

		$(document)
			.off('show.bs.dropdown.westgateUnread')
			.on('show.bs.dropdown.westgateUnread', '.wg-topbar [component="unread"]', function () {
				const menuEl = this.querySelector('[data-wg-unread-menu]');
				require(['api'], function (api) {
					api.get('/unread', {}, function (err, data) {
						if (err) {
							renderUnreadMenu(menuEl, []);
							return;
						}
						renderUnreadMenu(menuEl, data && data.topics);
					});
				});
			});

And with the other exports near the bottom:

	window.westgateTheme.renderUnreadMenu = renderUnreadMenu;
  • Step 4: Run tests to verify they pass

Run: node tests/unread-drawer-client.test.js && node tests/global-topbar-contract.test.js Expected: exits 0, no output

  • Step 5: Commit
git add public/client.js tests/unread-drawer-client.test.js
git commit -m "feat: load unread topics into topbar drawer on open"

Task 3: SCSS for unread rows

Files:

  • Modify: scss/westgate/_topbar.scss (append after the .notifications-dropdown width block, ~line 177)
  • Test: tests/global-topbar-contract.test.js (append)

Interfaces:

  • Consumes: .unread-dropdown, .wg-unread-item, .wg-unread-item__category class names from Tasks 12.

  • Step 1: Write the failing test — append to tests/global-topbar-contract.test.js:

assertIncludes(
	stylesheet,
	'.unread-dropdown',
	'Topbar styles should cover the unread dropdown'
);
  • Step 2: Run test to verify it fails

Run: node tests/global-topbar-contract.test.js Expected: FAIL with "Topbar styles should cover the unread dropdown"

  • Step 3: Implement — in scss/westgate/_topbar.scss, extend the existing width rule so it reads:
.wg-topbar .notifications-dropdown,
.wg-topbar .chats-dropdown,
.wg-topbar .drafts-dropdown,
.wg-topbar .unread-dropdown {
  width: min(24rem, calc(100vw - 1.5rem));
}

and append:

.wg-topbar .wg-unread-item {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
}

.wg-topbar .wg-unread-item__category {
  margin-left: auto;
  flex-shrink: 0;
  opacity: 0.7;
}
  • Step 4: Run test to verify it passes

Run: node tests/global-topbar-contract.test.js Expected: exits 0, no output

  • Step 5: Commit
git add scss/westgate/_topbar.scss tests/global-topbar-contract.test.js
git commit -m "feat: style topbar unread dropdown rows"

Task 4: Verify in the sow-nodebb dev container (Playwright)

Files: none (verification only)

Interfaces:

  • Consumes: the running dev forum at http://localhost:4567 (../sow-nodebb).

  • Step 1: Build and start the dev forum. In ../sow-nodebb: confirm .env has PLUGIN_PATH=../sow-nodebb-theme, PLUGIN_ID=nodebb-theme-westgate, EXTRA_PLUGINS=nodebb-plugin-westgate-wiki, then:

make dev
make dev-build

Expected: NodeBB build succeeds; http://localhost:4567 responds.

  • Step 2: Playwright checks (playwright-skill / MCP browser tools) against http://localhost:4567, logged in with the admin creds from ../sow-nodebb/.env:
    • The topbar utility list shows the unread item (li[component="unread"]) immediately before the notifications item.
    • With unread topics present (create a topic from a second account or incognito post), the badge [component="unread/count"] is visible with a number.
    • Clicking the toggle opens the dropdown and lists unread topic links; clicking one lands on that topic.
    • The dropdown footer link lands on /unread.
    • At mobile width (~390px), the burger drawer shows the Unread row with badge.

Expected: all checks pass; screenshot the open dropdown for the PR.

  • Step 3: Run the full local test suite
for t in tests/*.test.js; do node "$t" || exit 1; done

Expected: exits 0.

  • Step 4: Commit any fixes made during verification, then push and open the PR on the existing feat/topbar-unread-drawer branch.