Refine global topbar integration #7

Merged
archvillainette merged 3 commits from feature/global-topbar-refinements into main 2026-06-26 09:43:50 +00:00
10 changed files with 141 additions and 134 deletions
@@ -0,0 +1,78 @@
# Global Topbar Refinements 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:** Apply the post-review global topbar refinements from `docs/superpowers/specs/2026-06-26-global-topbar-design.md`.
**Architecture:** Keep the implementation inside the existing NodeBB child theme topbar partial, topbar SCSS partial, and theme client bundle. Extend the existing static topbar contract test only for durable behavior and public contracts, without adding a new test harness.
**Tech Stack:** Node.js built-in test runner, NodeBB templates, SCSS.
## Global Constraints
- Keep this as a child theme over `nodebb-theme-harmony`.
- Keep `theme.scss` imports-only.
- Put substantive Westgate-specific topbar CSS under `scss/westgate/`.
- Preserve real NodeBB/Harmony live partials and component hooks for search, notifications, chat, drafts, user menu, and ACP navigation.
- Do not commit secrets or token material.
---
### Task 1: Topbar Contract Refinements
**Files:**
- Modify: `tests/global-topbar-contract.test.js`
- Modify: `templates/partials/header/topbar.tpl`
- Modify: `scss/westgate/_topbar.scss`
- Modify: `public/client.js`
**Interfaces:**
- Consumes: Existing `topbar.tpl` markup, `_topbar.scss` selectors, and `initWestgateTopbar`.
- Produces: A single desktop navigation model, a one-line brand lockup, and opaque topbar menu surfaces.
- [ ] **Step 1: Write the failing test**
Add static contract assertions to `tests/global-topbar-contract.test.js` that check durable behavior rather than incidental wording or implementation details:
```js
assertMatches(
topbar,
/<a\b(?=[^>]*class="[^"]*\bwg-topbar__brand\b[^"]*")(?=[^>]*aria-label="{config\.siteTitle}")/,
'Topbar brand link should use the configured site title as its accessible name'
);
assertMatches(
topbar,
/<span\b(?=[^>]*class="[^"]*\bwg-topbar__brand-name\b[^"]*")[^>]*>{config\.siteTitle}<\/span>/,
'Topbar brand text should render the configured site title'
);
assertExcludes(topbar, 'data-wg-menu', 'Topbar should rely on Bootstrap and NodeBB navigation instead of custom menu triggers');
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test tests/global-topbar-contract.test.js`
Expected: FAIL because the current topbar still hard-codes the brand label and visible brand text.
- [ ] **Step 3: Write minimal implementation**
Change the template so the brand is only the mark plus the configured site title, remove the custom desktop Forums dropdown block, and keep mobile drawer forum entry links. Change `_topbar.scss` so dropdown/drawer surfaces use an opaque panel base, remove custom Forums panel rules, and remove desktop brand ellipsis. Remove now-unused custom topbar panel toggle handling from `public/client.js`.
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test tests/global-topbar-contract.test.js`
Expected: PASS.
- [ ] **Step 5: Run the full available test set**
Run: `node --test tests/*.test.js`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add docs/superpowers/plans/2026-06-26-global-topbar-refinements.md tests/global-topbar-contract.test.js templates/partials/header/topbar.tpl scss/westgate/_topbar.scss public/client.js
git commit -m "fix: refine global topbar integration"
```
@@ -79,6 +79,30 @@ Harmony sidebar buttons should be restyled as topbar controls. They should feel
native to the topbar, not like sidebar markup awkwardly placed in a horizontal native to the topbar, not like sidebar markup awkwardly placed in a horizontal
row. row.
## Post-Review Refinements
Screenshots captured on 2026-06-26 show the topbar working visually, but with
three refinements needed before the topbar feels fully integrated.
Drawer and dropdown menus must be opaque. The current translucent treatment lets
page content show through account, forum, and drawer surfaces, which weakens
readability and makes the controls feel less deliberate. Keep the near-black and
plum surface direction, but use an opaque panel base; decorative highlights,
borders, and shadows may remain subtle.
The brand lockup should be simplified to one identity line: the circular mark
followed by `SHADOWS OVER WESTGATE`. Do not repeat `Shadows Over Westgate` as a
subtitle in the topbar, and do not allow the primary brand name to truncate in
normal desktop layouts. Longer project context such as `An NWN:EE Roleplaying
Experience` belongs on larger page surfaces, not in the dense global chrome.
The custom right-side `Forums` dropdown is redundant when ACP Navigation already
contains a Forums entry. Prefer one seamless navigation model: remove the
separate desktop Forums dropdown and let forum entry points live in ACP
Navigation or in the mobile drawer as direct links. If ACP-managed navigation
needs to drop text bubbles or helper labels to make this feel clean, that is
acceptable.
## Implementation Direction ## Implementation Direction
The production implementation should be theme-first: The production implementation should be theme-first:
@@ -142,6 +166,12 @@ mobile bars should not duplicate those controls.
- Search uses NodeBB search behavior rather than a visual-only input. - Search uses NodeBB search behavior rather than a visual-only input.
- The topbar appears consistently on Custom Pages, wiki routes, and normal forum - The topbar appears consistently on Custom Pages, wiki routes, and normal forum
routes. routes.
- Drawer, dropdown, account, and notification surfaces are opaque enough that
underlying page content does not show through.
- The brand appears once as the mark plus `SHADOWS OVER WESTGATE`; no duplicate
topbar subtitle repeats the site name.
- Desktop navigation has one Forums entry model, not both ACP Navigation Forums
and a separate right-side Forums dropdown.
- The topbar styling follows Westgate theme tokens and lives under - The topbar styling follows Westgate theme tokens and lives under
`scss/westgate/`. `scss/westgate/`.
- The implementation remains a child theme over `nodebb-theme-harmony`. - The implementation remains a child theme over `nodebb-theme-harmony`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

-41
View File
@@ -153,23 +153,6 @@
return document.querySelector('[data-wg-topbar]'); return document.querySelector('[data-wg-topbar]');
} }
function closeWestgateTopbarPanels(topbar, exceptKey) {
if (!topbar || typeof topbar.querySelectorAll !== 'function') {
return;
}
toArray(topbar.querySelectorAll('[data-wg-panel]')).forEach((panel) => {
if (panel.getAttribute('data-wg-panel') !== exceptKey) {
panel.classList.remove('is-open');
}
});
toArray(topbar.querySelectorAll('[data-wg-menu]')).forEach((trigger) => {
if (trigger.getAttribute('data-wg-menu') !== exceptKey) {
trigger.setAttribute('aria-expanded', 'false');
}
});
}
function closeWestgateTopbarDrawer(topbar) { function closeWestgateTopbarDrawer(topbar) {
if (!topbar) { if (!topbar) {
return; return;
@@ -195,22 +178,6 @@
return; return;
} }
const trigger = event.target.closest && event.target.closest('[data-wg-menu]');
if (trigger && currentTopbar.contains(trigger)) {
event.preventDefault();
event.stopPropagation();
const key = trigger.getAttribute('data-wg-menu');
const panel = currentTopbar.querySelector(`[data-wg-panel="${key}"]`);
const shouldOpen = !!panel && !panel.classList.contains('is-open');
closeWestgateTopbarPanels(currentTopbar, shouldOpen ? key : null);
if (panel) {
panel.classList.toggle('is-open', shouldOpen);
}
trigger.setAttribute('aria-expanded', String(shouldOpen));
return;
}
const burger = event.target.closest && event.target.closest('[data-wg-burger]'); const burger = event.target.closest && event.target.closest('[data-wg-burger]');
if (burger && currentTopbar.contains(burger)) { if (burger && currentTopbar.contains(burger)) {
event.preventDefault(); event.preventDefault();
@@ -219,15 +186,9 @@
const isOpen = !currentTopbar.classList.contains('is-drawer-open'); const isOpen = !currentTopbar.classList.contains('is-drawer-open');
currentTopbar.classList.toggle('is-drawer-open', isOpen); currentTopbar.classList.toggle('is-drawer-open', isOpen);
burger.setAttribute('aria-expanded', String(isOpen)); burger.setAttribute('aria-expanded', String(isOpen));
closeWestgateTopbarPanels(currentTopbar, null);
return; return;
} }
if (event.target.closest && event.target.closest('[data-wg-panel]')) {
return;
}
closeWestgateTopbarPanels(currentTopbar, null);
if (!currentTopbar.contains(event.target)) { if (!currentTopbar.contains(event.target)) {
closeWestgateTopbarDrawer(currentTopbar); closeWestgateTopbarDrawer(currentTopbar);
} }
@@ -239,7 +200,6 @@
} }
const currentTopbar = getWestgateTopbar(); const currentTopbar = getWestgateTopbar();
closeWestgateTopbarPanels(currentTopbar, null);
closeWestgateTopbarDrawer(currentTopbar); closeWestgateTopbarDrawer(currentTopbar);
}); });
@@ -263,7 +223,6 @@
$(window).on('action:ajaxify.end', function () { $(window).on('action:ajaxify.end', function () {
wrapWestgateWikiTables(document); wrapWestgateWikiTables(document);
initWestgateTopbar(); initWestgateTopbar();
closeWestgateTopbarPanels(getWestgateTopbar(), null);
closeWestgateTopbarDrawer(getWestgateTopbar()); closeWestgateTopbarDrawer(getWestgateTopbar());
}); });
+14 -75
View File
@@ -1,3 +1,11 @@
$wg-topbar-panel-bg: linear-gradient(
100deg,
#2a1222 0%,
#19161f 23%,
#121017 62%,
#0d0c11 100%
);
.wg-layout-container { .wg-layout-container {
min-height: 100vh; min-height: 100vh;
} }
@@ -17,7 +25,7 @@
.wg-topbar__inner { .wg-topbar__inner {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto; grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: 0.9rem; gap: 0.9rem;
width: min(100%, 1420px); width: min(100%, 1420px);
@@ -60,35 +68,16 @@
font-weight: 700; font-weight: 700;
} }
.wg-topbar__brand-text {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1;
}
.wg-topbar__brand-name { .wg-topbar__brand-name {
max-width: 22ch;
overflow: hidden;
color: var(--wg-text); color: var(--wg-text);
font-family: var(--wg-font-display); font-family: var(--wg-font-display);
font-size: 0.96rem; font-size: 0.96rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-overflow: ellipsis;
text-transform: uppercase; text-transform: uppercase;
white-space: nowrap; white-space: nowrap;
} }
.wg-topbar__brand-subtitle {
margin-top: 0.28rem;
color: var(--wg-gold-soft);
font-family: var(--wg-font-code);
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.wg-topbar__nav { .wg-topbar__nav {
min-width: 0; min-width: 0;
} }
@@ -104,8 +93,7 @@
} }
.wg-topbar .nav-link, .wg-topbar .nav-link,
.wg-topbar__nav-link, .wg-topbar__nav-link {
.wg-topbar__forums-toggle {
min-height: 2.35rem; min-height: 2.35rem;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 6px; border-radius: 6px;
@@ -122,8 +110,6 @@
.wg-topbar .nav-link:focus, .wg-topbar .nav-link:focus,
.wg-topbar__nav-link:hover, .wg-topbar__nav-link:hover,
.wg-topbar__nav-link:focus, .wg-topbar__nav-link:focus,
.wg-topbar__forums-toggle:hover,
.wg-topbar__forums-toggle:focus,
.wg-topbar .nav-item.active > .nav-link, .wg-topbar .nav-item.active > .nav-link,
.wg-topbar .nav-item.active > .navigation-link { .wg-topbar .nav-item.active > .navigation-link {
color: #f3ede4; color: #f3ede4;
@@ -132,16 +118,6 @@
box-shadow: inset 0 1px 0 rgba(255, 244, 221, 0.035); box-shadow: inset 0 1px 0 rgba(255, 244, 221, 0.035);
} }
.wg-topbar__forums {
position: relative;
}
.wg-topbar__forums-toggle {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.wg-topbar__utilities { .wg-topbar__utilities {
justify-self: end; justify-self: end;
} }
@@ -187,8 +163,7 @@
.wg-topbar .notifications-dropdown, .wg-topbar .notifications-dropdown,
.wg-topbar .chats-dropdown, .wg-topbar .chats-dropdown,
.wg-topbar .drafts-dropdown, .wg-topbar .drafts-dropdown,
.wg-topbar .user-dropdown, .wg-topbar .user-dropdown {
.wg-topbar__panel {
min-width: min(22rem, calc(100vw - 1.5rem)); min-width: min(22rem, calc(100vw - 1.5rem));
max-width: calc(100vw - 1.5rem); max-width: calc(100vw - 1.5rem);
border: 1px solid rgba(194, 163, 90, 0.22) !important; border: 1px solid rgba(194, 163, 90, 0.22) !important;
@@ -196,7 +171,7 @@
color: var(--wg-text-soft) !important; color: var(--wg-text-soft) !important;
background: background:
linear-gradient(180deg, rgba(255, 244, 221, 0.028), transparent 36%), linear-gradient(180deg, rgba(255, 244, 221, 0.028), transparent 36%),
var(--wg-velvet-panel) !important; $wg-topbar-panel-bg !important;
box-shadow: box-shadow:
inset 0 1px 0 rgba(255, 244, 221, 0.035), inset 0 1px 0 rgba(255, 244, 221, 0.035),
0 18px 42px rgba(0, 0, 0, 0.42) !important; 0 18px 42px rgba(0, 0, 0, 0.42) !important;
@@ -258,36 +233,6 @@
background: rgba(194, 163, 90, 0.1); background: rgba(194, 163, 90, 0.1);
} }
.wg-topbar__panel {
position: absolute;
top: calc(100% + 0.45rem);
right: 0;
display: none;
padding: 0.42rem;
}
.wg-topbar__panel.is-open {
display: grid;
gap: 0.2rem;
}
.wg-topbar__panel--forums a {
display: flex;
align-items: center;
min-height: 2.25rem;
border-radius: 6px;
padding: 0.35rem 0.55rem;
color: var(--wg-text-soft);
font-size: 0.9rem;
text-decoration: none;
}
.wg-topbar__panel--forums a:hover,
.wg-topbar__panel--forums a:focus {
color: var(--wg-text);
background: rgba(194, 163, 90, 0.08);
}
.wg-topbar__burger { .wg-topbar__burger {
display: none; display: none;
align-items: center; align-items: center;
@@ -310,7 +255,8 @@
display: none; display: none;
border-top: 1px solid rgba(194, 163, 90, 0.12); border-top: 1px solid rgba(194, 163, 90, 0.12);
background: background:
linear-gradient(180deg, rgba(42, 18, 34, 0.98), rgba(12, 10, 15, 0.99)); linear-gradient(180deg, rgba(255, 244, 221, 0.025), transparent 38%),
$wg-topbar-panel-bg;
} }
.wg-topbar.is-drawer-open .wg-topbar__drawer { .wg-topbar.is-drawer-open .wg-topbar__drawer {
@@ -389,12 +335,6 @@
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
} }
.wg-topbar__brand-name {
max-width: 16ch;
}
.wg-topbar__brand-subtitle,
.wg-topbar__forums,
.wg-topbar__utilities { .wg-topbar__utilities {
display: none; display: none;
} }
@@ -411,7 +351,6 @@
} }
.wg-topbar__brand-name { .wg-topbar__brand-name {
max-width: 13ch;
font-size: 0.82rem; font-size: 0.82rem;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
+1 -18
View File
@@ -2,10 +2,7 @@
<div class="wg-topbar__inner"> <div class="wg-topbar__inner">
<a class="wg-topbar__brand" href="{relative_path}/" aria-label="{config.siteTitle}"> <a class="wg-topbar__brand" href="{relative_path}/" aria-label="{config.siteTitle}">
<span class="wg-topbar__brand-mark" aria-hidden="true">W</span> <span class="wg-topbar__brand-mark" aria-hidden="true">W</span>
<span class="wg-topbar__brand-text"> <span class="wg-topbar__brand-name">{config.siteTitle}</span>
<span class="wg-topbar__brand-name">{config.siteTitle}</span>
<span class="wg-topbar__brand-subtitle">Shadows Over Westgate</span>
</span>
</a> </a>
<nav class="wg-topbar__nav" aria-label="[[global:navigation]]"> <nav class="wg-topbar__nav" aria-label="[[global:navigation]]">
@@ -31,20 +28,6 @@
</ul> </ul>
</nav> </nav>
<div class="wg-topbar__forums dropdown">
<button class="wg-topbar__nav-link wg-topbar__forums-toggle" type="button" data-wg-menu="forums" aria-expanded="false" aria-haspopup="true">
<span>Forums</span>
<i class="fa fa-fw fa-chevron-down" aria-hidden="true"></i>
</button>
<div class="wg-topbar__panel wg-topbar__panel--forums" data-wg-panel="forums" role="menu">
<a href="{relative_path}/categories" role="menuitem">[[pages:categories]]</a>
<a href="{relative_path}/recent" role="menuitem">[[recent:title]]</a>
<a href="{relative_path}/unread" role="menuitem">[[unread:title]]</a>
<a href="{relative_path}/popular" role="menuitem">[[pages:popular]]</a>
<a href="{relative_path}/tags" role="menuitem">[[tags:tags]]</a>
</div>
</div>
<div class="wg-topbar__utilities"> <div class="wg-topbar__utilities">
{{{ if config.loggedIn }}} {{{ if config.loggedIn }}}
<ul id="wg-topbar-logged-in-menu" class="wg-topbar__utility-list list-unstyled d-flex align-items-center mb-0"> <ul id="wg-topbar-logged-in-menu" class="wg-topbar__utility-list list-unstyled d-flex align-items-center mb-0">
+18
View File
@@ -29,6 +29,13 @@ function assertExcludes(haystack, needle, message) {
); );
} }
function assertMatches(haystack, pattern, message) {
assert(
pattern.test(haystack),
message || `Expected file to match ${pattern}`
);
}
const header = read('templates/header.tpl'); const header = read('templates/header.tpl');
const footer = read('templates/footer.tpl'); const footer = read('templates/footer.tpl');
const topbar = read('templates/partials/header/topbar.tpl'); const topbar = read('templates/partials/header/topbar.tpl');
@@ -107,6 +114,17 @@ assertIncludes(topbar, '{{{ if allowRegistration }}}', 'Registration should rema
assertIncludes(topbar, '{{{ each navigation }}}', 'Topbar should render ACP Navigation items'); assertIncludes(topbar, '{{{ each navigation }}}', 'Topbar should render ACP Navigation items');
assertIncludes(topbar, '{./textClass}', 'Topbar should honor ACP Navigation text visibility classes'); assertIncludes(topbar, '{./textClass}', 'Topbar should honor ACP Navigation text visibility classes');
assertExcludes(topbar, 'href="{relative_path}/admin"', 'Topbar should not hard-code the administrator link'); assertExcludes(topbar, 'href="{relative_path}/admin"', 'Topbar should not hard-code the administrator link');
assertMatches(
topbar,
/<a\b(?=[^>]*class="[^"]*\bwg-topbar__brand\b[^"]*")(?=[^>]*aria-label="{config\.siteTitle}")/,
'Topbar brand link should use the configured site title as its accessible name'
);
assertMatches(
topbar,
/<span\b(?=[^>]*class="[^"]*\bwg-topbar__brand-name\b[^"]*")[^>]*>{config\.siteTitle}<\/span>/,
'Topbar brand text should render the configured site title'
);
assertExcludes(topbar, 'data-wg-menu', 'Topbar should rely on Bootstrap and NodeBB navigation instead of custom menu triggers');
assertMissing( assertMissing(
'templates/partials/header/brand.tpl', 'templates/partials/header/brand.tpl',