The set mixed colour emoji with dingbats, and the same glyph read as two things partly BECAUSE emoji render as per-platform artwork. The system chosen: monochrome text-presentation glyphs - the suite is classic-script vanilla HTML with no bundler, so an SVG sprite or icon font is a new asset pipeline, while text glyphs render through the same font stack as the words beside them. The enforceable form of "renders identically on Windows, macOS and a tablet": no emoji-range codepoint and no U+FE0F selector anywhere in UI source, swept by the probe on every run. Converted: green-check/red-cross emoji in the admin and users consoles to checkmark/cross, no-entry to circled-slash (blocked/on hold), the lock to the pencil already meaning "edit with a logged reason" on sign-offs, the star to the diamond, the folder to the reference marker, the side nav's lightning to the gear, and the WATCH glyph (U+231A - emoji-presentation BY DEFAULT per Unicode) to a text-presentation clock face. Dropped where the label already carried the meaning: lightning on Save & view, the camera on Add photo, the page/frame pictograms on file rows (the filename is the label). Stale help copy fixed while its emoji left: it still described the pre-T9.4 "Load sample" and the pre-T7.10 "Usage Logs" locations. The meaning-to-icon mapping is in docs/reference/tokens.md - one meaning per glyph, one glyph per meaning, both directions asserted from the document itself; the probe also sweeps every page for glyphs not in the approved set, so an unmapped icon cannot creep in. Verification (each probe run alone): NEW tests/icon_check.py 5/5. Regressions: frame_check 38/38, files_check 36/36, a11y_check 22/22, cards_check 44/44. Items: S6 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
279 lines
14 KiB
JavaScript
279 lines
14 KiB
JavaScript
/* Global app navigation drawer for the Work Package Suite.
|
|
|
|
The suite grew page by page and the only way between them was the browser's back
|
|
button or the home page. This is the one place that lists everywhere you can go —
|
|
a ☰ button in the app bar opening an off-canvas drawer.
|
|
|
|
ROLE GATING: the drawer only offers what the signed-in account can actually reach.
|
|
The Admin Console is admins-only, so it appears for admins only; the User Directory
|
|
is readable by everyone (that's the point of a directory), so it always appears.
|
|
Every destination re-checks server-side — this is navigation, not a permission.
|
|
|
|
PROJECT CONTEXT: links that open a project-scoped page carry the active ?project=
|
|
so the drawer doesn't silently drop the job you were looking at.
|
|
|
|
Add it to a page with:
|
|
<link rel="stylesheet" href="wp-sidenav.css">
|
|
<script src="wp-sidenav.js"></script>
|
|
after auth-guard.js. It mounts itself into whichever top bar the page has.
|
|
It used to skip iframes, because the embedded WP creator lived inside a page
|
|
that already had a drawer; B7/T7.1 dissolved that frame. */
|
|
(function () {
|
|
'use strict';
|
|
|
|
// ── the map ────────────────────────────────────────────────────────────────
|
|
// `match` is what marks a link current; `project` means "carry ?project=".
|
|
// `show` is an optional gate, evaluated once the user is known.
|
|
var LINKS = [
|
|
{ section: 'Work' },
|
|
{ href: 'index.html', match: /(^|\/)(index\.html)?$/, icon: '⌂', label: 'Home',
|
|
sub: 'Projects & what\'s next' },
|
|
{ href: 'work-package-suite.html?tab=sop', match: /work-package-suite\.html/, icon: '⚙',
|
|
label: 'SOP Configuration', sub: 'The project baseline', project: true, tab: 'sop' },
|
|
// B7/T7.1: both of these were tabs of the suite page, opened by swapping an
|
|
// iframe, so `match` had to be null - one URL could not tell them apart. The
|
|
// creator is its own document now, so they have real addresses and the drawer
|
|
// can mark which one you are on.
|
|
{ href: 'wp-creation-index.html', match: /wp-creation-index\.html/, icon: '▤',
|
|
label: 'Work Package Creator', sub: 'Build and edit IWPs', project: true, tab: 'wp' },
|
|
{ href: 'wp-creation-index.html?view=dashboard', match: null, icon: '▦',
|
|
label: 'Dashboard', sub: 'Status & release gates', project: true, tab: 'dashboard' },
|
|
{ href: 'field.html', match: /(^|\/)field\.html$/, icon: '⚒', label: 'Field View',
|
|
sub: 'Update packages on site', project: true },
|
|
{ section: 'People' },
|
|
{ href: 'users.html', match: /(^|\/)users\.html$/, icon: '☺', label: 'User Directory',
|
|
sub: 'Who\'s on the project' },
|
|
{ href: 'admin.html', match: /(^|\/)admin\.html$/, icon: '⚙', label: 'Admin Console',
|
|
sub: 'Settings & diagnostics',
|
|
show: function () { return typeof window.wpIsAdmin === 'function' && window.wpIsAdmin(); } },
|
|
// Account actions, inherited from the flat user menu that used to sit in the app
|
|
// bar (T2.2). Everything else that menu offered — Admin, Users, Sign out — the
|
|
// drawer already had; these two were its only unique contents, so they moved here
|
|
// rather than being lost with it. `action` items render as buttons, not links.
|
|
{ section: 'Account' },
|
|
{ action: 'wpPreferences', icon: '◷', label: 'Language & time',
|
|
sub: 'Dates, numbers and time zone' },
|
|
{ action: 'wpChangePassword', icon: '⚿', label: 'Password', sub: 'Change your password' },
|
|
];
|
|
|
|
function esc(v) {
|
|
return String(v == null ? '' : v)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
function isDark(node) {
|
|
try {
|
|
var m = (getComputedStyle(node).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
|
|
if (!m) return true;
|
|
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
|
|
} catch (e) { return true; }
|
|
}
|
|
|
|
function activeProjectId() {
|
|
try {
|
|
var q = new URLSearchParams(location.search).get('project');
|
|
if (q) return q;
|
|
return (window.ProjectData && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
|
|
} catch (e) { return ''; }
|
|
}
|
|
|
|
// The suite page reads ?tab= and ?project=; keeping the current project on the link
|
|
// is the difference between "open the dashboard" and "open the dashboard, then pick
|
|
// the job again".
|
|
function hrefFor(item) {
|
|
if (!item.project) return item.href;
|
|
var pid = activeProjectId();
|
|
if (!pid) return item.href;
|
|
var sep = item.href.indexOf('?') >= 0 ? '&' : '?';
|
|
return item.href + sep + 'project=' + encodeURIComponent(pid);
|
|
}
|
|
|
|
// Current-page marking. The three suite tabs share one file, so they're told apart
|
|
// by ?tab= (defaulting to sop, which is what work-package-suite.html itself does).
|
|
function isCurrent(item) {
|
|
var path = location.pathname;
|
|
if (item.tab) {
|
|
if (!/work-package-suite\.html$/.test(path)) return false;
|
|
var tab = '';
|
|
try { tab = new URLSearchParams(location.search).get('tab') || 'sop'; } catch (e) { tab = 'sop'; }
|
|
return tab === item.tab;
|
|
}
|
|
return !!(item.match && item.match.test(path));
|
|
}
|
|
|
|
// ── build ──────────────────────────────────────────────────────────────────
|
|
var drawer, scrim, btn, lastFocus = null;
|
|
|
|
function buildDrawer(user) {
|
|
scrim = document.createElement('div');
|
|
scrim.className = 'wp-navscrim';
|
|
scrim.hidden = true;
|
|
scrim.addEventListener('click', close);
|
|
|
|
drawer = document.createElement('nav');
|
|
drawer.className = 'wp-sidenav';
|
|
drawer.id = 'wp-sidenav';
|
|
drawer.setAttribute('aria-label', 'Suite navigation');
|
|
drawer.setAttribute('aria-hidden', 'true');
|
|
|
|
var rows = '';
|
|
LINKS.forEach(function (item) {
|
|
if (item.section) { rows += '<div class="wp-sidenav-sect">' + esc(item.section) + '</div>'; return; }
|
|
if (item.show && !item.show()) return;
|
|
var inner =
|
|
'<span class="wp-sidenav-ico" aria-hidden="true">' + esc(item.icon) + '</span>' +
|
|
'<span class="wp-sidenav-label">' + esc(item.label) +
|
|
(item.sub ? '<small>' + esc(item.sub) + '</small>' : '') + '</span>';
|
|
// An action opens a dialog on the current page rather than going anywhere, so
|
|
// it is a button. Never a <div> with a click handler — CLAUDE.md, and the
|
|
// drawer is keyboard-navigable precisely because everything in it is focusable.
|
|
if (item.action) {
|
|
rows += '<button type="button" class="wp-sidenav-link" data-action="' +
|
|
esc(item.action) + '">' + inner + '</button>';
|
|
return;
|
|
}
|
|
rows += '<a class="wp-sidenav-link' + (isCurrent(item) ? ' is-current' : '') + '" href="' +
|
|
esc(hrefFor(item)) + '"' + (isCurrent(item) ? ' aria-current="page"' : '') + '>' +
|
|
inner + '</a>';
|
|
});
|
|
|
|
var who = user ? (user.full_name || user.username || '') : '';
|
|
drawer.innerHTML =
|
|
'<div class="wp-sidenav-head">' +
|
|
'<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>' +
|
|
'<span class="wp-sidenav-title">Work Package Suite<span>Prime Controls</span></span>' +
|
|
'<button type="button" class="wp-sidenav-close" title="Close" aria-label="Close navigation">✕</button>' +
|
|
'</div>' +
|
|
// The active project in full, wrapped rather than truncated. Below 1024px the
|
|
// app bar shows the project NUMBER alone (B2), so this is where the whole name
|
|
// is always readable. It is also the only place it is guaranteed to fit.
|
|
'<div class="wp-sidenav-proj" id="wp-sidenav-proj"></div>' +
|
|
'<div class="wp-sidenav-body">' + rows + '</div>' +
|
|
'<div class="wp-sidenav-foot">' +
|
|
(who ? '<div class="wp-sidenav-who">Signed in as<strong>' + esc(who) + '</strong></div>' : '') +
|
|
'<button type="button" class="wp-sidenav-link" id="wp-sidenav-signout">' +
|
|
'<span class="wp-sidenav-ico" aria-hidden="true">⏻</span>' +
|
|
'<span class="wp-sidenav-label">Sign out</span></button>' +
|
|
'</div>';
|
|
|
|
drawer.querySelector('.wp-sidenav-close').addEventListener('click', close);
|
|
drawer.querySelector('#wp-sidenav-signout').addEventListener('click', function () {
|
|
if (typeof window.wpLogout === 'function') window.wpLogout();
|
|
});
|
|
// Close first, then act: these open a dialog, and leaving the drawer over it
|
|
// would put a scrim between the user and the thing they just asked for. The
|
|
// handler is looked up at click time because wp-format.js may still be parsing
|
|
// when the drawer is built — the flat menu had the same note.
|
|
Array.prototype.forEach.call(drawer.querySelectorAll('[data-action]'), function (el) {
|
|
el.addEventListener('click', function () {
|
|
var fn = window[el.getAttribute('data-action')];
|
|
close();
|
|
if (typeof fn === 'function') fn();
|
|
});
|
|
});
|
|
document.body.appendChild(scrim);
|
|
document.body.appendChild(drawer);
|
|
paintProject();
|
|
// Selecting a project on the launcher does not reload, so subscribe rather than
|
|
// paint once — the same single source T1.1 established.
|
|
try {
|
|
if (window.ProjectData && ProjectData.onActiveChange) {
|
|
ProjectData.onActiveChange(paintProject);
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
// Full name, never abbreviated. Absent rather than empty when no project is active,
|
|
// so the drawer does not carry a stray blank band.
|
|
function paintProject() {
|
|
var box = document.getElementById('wp-sidenav-proj');
|
|
if (!box) return;
|
|
var p = null;
|
|
try { p = (window.ProjectData && ProjectData.getActive && ProjectData.getActive()) || null; } catch (e) {}
|
|
if (!p || !(p.name || p.number)) { box.innerHTML = ''; box.hidden = true; return; }
|
|
box.hidden = false;
|
|
box.innerHTML = '<span class="wp-sidenav-proj-k">Project</span>' +
|
|
'<strong>' + esc(p.name || '(unnamed)') + '</strong>' +
|
|
(p.number ? '<span class="wp-sidenav-proj-n">' + esc(p.number) + '</span>' : '');
|
|
}
|
|
|
|
function focusables() {
|
|
return drawer ? drawer.querySelectorAll('a[href], button:not([disabled])') : [];
|
|
}
|
|
|
|
function open() {
|
|
if (!drawer) return;
|
|
lastFocus = document.activeElement;
|
|
scrim.hidden = false;
|
|
// Two frames: the element has to be laid out un-transitioned before the class
|
|
// that animates it lands, or it simply appears.
|
|
requestAnimationFrame(function () {
|
|
scrim.classList.add('is-open');
|
|
drawer.classList.add('is-open');
|
|
});
|
|
drawer.setAttribute('aria-hidden', 'false');
|
|
btn.setAttribute('aria-expanded', 'true');
|
|
var f = focusables();
|
|
if (f.length) f[0].focus();
|
|
}
|
|
|
|
function close() {
|
|
if (!drawer) return;
|
|
drawer.classList.remove('is-open');
|
|
scrim.classList.remove('is-open');
|
|
drawer.setAttribute('aria-hidden', 'true');
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
// Keep the scrim in the tree until the slide-out finishes, or the panel snaps.
|
|
setTimeout(function () { if (!drawer.classList.contains('is-open')) scrim.hidden = true; }, 220);
|
|
if (lastFocus && lastFocus.focus) lastFocus.focus();
|
|
}
|
|
|
|
function isOpen() { return !!(drawer && drawer.classList.contains('is-open')); }
|
|
|
|
// Escape closes; Tab cycles inside the drawer while it's open, so focus can't walk
|
|
// off into the page behind the scrim.
|
|
document.addEventListener('keydown', function (e) {
|
|
if (!isOpen()) return;
|
|
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
|
|
if (e.key !== 'Tab') return;
|
|
var f = focusables();
|
|
if (!f.length) return;
|
|
var first = f[0], last = f[f.length - 1];
|
|
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
});
|
|
|
|
// ── mount ──────────────────────────────────────────────────────────────────
|
|
// The button goes at the START of the bar, before the brand: that is where a menu
|
|
// affordance is looked for, and it keeps clear of the project switcher and search
|
|
// that wp-chrome.js inserts into the middle of the same bar.
|
|
function mount() {
|
|
if (document.getElementById('wp-sidenav')) return;
|
|
var host = document.querySelector('.wp-appbar') || document.querySelector('.header');
|
|
if (!host) return;
|
|
|
|
btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'wp-navbtn';
|
|
btn.id = 'wp-navbtn';
|
|
btn.title = 'Menu';
|
|
btn.setAttribute('aria-label', 'Open navigation');
|
|
btn.setAttribute('aria-haspopup', 'true');
|
|
btn.setAttribute('aria-expanded', 'false');
|
|
btn.setAttribute('aria-controls', 'wp-sidenav');
|
|
if (!isDark(host)) btn.setAttribute('data-bar', 'light');
|
|
btn.innerHTML = '<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true">' +
|
|
'<path d="M3 5.5h14M3 10h14M3 14.5h14" fill="none" stroke="currentColor" ' +
|
|
'stroke-width="1.6" stroke-linecap="round"/></svg>';
|
|
btn.addEventListener('click', function () { if (isOpen()) close(); else open(); });
|
|
|
|
host.insertBefore(btn, host.firstChild);
|
|
buildDrawer(window.WP_USER);
|
|
}
|
|
|
|
// Wait for the auth guard: the gated links depend on the signed-in role, and an
|
|
// unauthenticated page is about to redirect anyway.
|
|
if (window.WP_USER) mount();
|
|
else document.addEventListener('wp-auth-ready', mount);
|
|
})();
|