Rebuild the work-package side panel in the style of MS Planner

The auto-hiding drawer was the wrong model — a list you navigate by shouldn't appear
and disappear under the pointer, and its vertical text tab read as a stray artifact.
Replaced with a persistent side panel following the Planner reference:

- Collapse toggle at the top (the panel glyph, arrow flips), remembered across visits.
  Collapsed leaves a 56px icon rail where the coloured package badges are still
  clickable, rather than hiding the list entirely.
- One primary action: "+ New work package" with a split caret for Duplicate, Split by
  discipline and Export all.
- Icon nav with counts: My packages (owned by you), All packages, Needs attention
  (on hold or not release-ready), Dashboard. These filter the list below.
- Packages as rows with a colour-coded initial badge, number, subject and readiness
  state, still grouped by status, with a left accent bar on the current package.
  The badge colour is hashed from the WP number, so a package keeps its swatch
  instead of shuffling when another is added or deleted.
- The panel sits IN the layout: the form and the full-width chrome shift beside it
  rather than being overlaid.

Also, the reason it appeared as loose unstyled widgets in the middle of the form: the
panel's markup and its stylesheet are cached independently, so a browser can run new
markup against old CSS. Its essential layout (fixed position, width, the row/badge
flex, the collapsed rules) is now injected by wp-creation-app.js as a floor, inserted
first in <head> so the stylesheet still wins on everything it defines. Same lesson as
the iframe: a component whose CSS-missing state is "broken" rather than "plain" must
carry its own critical layout.

Verified with 25 driven checks in headless Chrome: persistence, the four nav links,
badge colours and text, view filtering, collapse/expand, the split menu, row selection
and highlighting — and, with wp-creation-styles.css removed from the page entirely, the
panel is still a fixed 288px side panel with the form shifted beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 21:47:40 -07:00
parent 40bd19b6cf
commit 917a728399
3 changed files with 388 additions and 200 deletions

View File

@@ -1378,123 +1378,216 @@ function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
// Every saved package on this project, grouped by status, filterable. Clicking a
// row opens it in the form (same path as the Saved table's "edit").
const WPNAV_ORDER=['Issue','In Progress','Issued','QC','Scheduled','Draft','Closed'];
// ── drawer behaviour ─────────────────────────────────────────────────────────
// Hover the edge handle to open, move the pointer away to close. Pinning keeps it
// open and shifts the form across; the pin is remembered. Touch devices have no
// hover, so tapping the handle opens it and it stays until you pick something,
// tap outside, or press Escape.
let _navCloseTimer = null;
// The panel's ESSENTIAL layout ships with this script rather than living only in
// wp-creation-styles.css. The two files are cached independently, so a browser can
// run new markup against an old stylesheet — and without these rules the panel is
// not merely unstyled, it's broken: its title, filter and buttons drop into the
// middle of the form as loose widgets (which is exactly what happened once).
// Injecting the floor here means the panel is always a positioned side panel; the
// stylesheet only refines its appearance.
const WP_NAV_CRITICAL_CSS = `
body{--nav-w:288px;}
body.wp-nav-collapsed{--nav-w:56px;}
.wp-nav{position:fixed;top:var(--rail-top,48px);left:0;bottom:0;width:var(--nav-w);
z-index:120;display:flex;flex-direction:column;overflow:hidden;background:#fbfbfc;
border-right:1px solid #e0e0e0;}
.wp-nav-list{flex:1 1 auto;overflow-y:auto;overflow-x:hidden;}
.wp-nav-item,.wp-nav-link{display:flex;align-items:center;gap:11px;width:100%;
background:none;border:0;text-align:left;cursor:pointer;font:inherit;}
.wp-nav-badge{flex:0 0 28px;width:28px;height:28px;border-radius:5px;color:#fff;
display:inline-flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;}
.wp-nav-body{min-width:0;flex:1 1 auto;}
.wp-nav-num,.wp-nav-subj{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.main{padding-left:calc(var(--nav-w) + 28px);}
body.wp-nav-collapsed .wp-nav-body,body.wp-nav-collapsed .wp-nav-state,
body.wp-nav-collapsed .wp-nav-link-label,body.wp-nav-collapsed .wp-nav-sect,
body.wp-nav-collapsed .wp-nav-filter,body.wp-nav-collapsed .wp-nav-group,
body.wp-nav-collapsed .wp-nav-cta-label,body.wp-nav-collapsed .wp-nav-cta-more{display:none;}
`;
function openWpNav(){
clearTimeout(_navCloseTimer);
document.body.classList.add('wp-nav-open');
const h = document.getElementById('wp-nav-handle');
if(h) h.setAttribute('aria-expanded', 'true');
}
function closeWpNav(force){
clearTimeout(_navCloseTimer);
if(navPinned() && !force) return; // pinned stays put unless explicitly closed
if(force) setNavPinned(false);
document.body.classList.remove('wp-nav-open');
const h = document.getElementById('wp-nav-handle');
if(h) h.setAttribute('aria-expanded', 'false');
function injectWpNavCriticalCss(){
if(document.getElementById('wp-nav-critical')) return;
const st = document.createElement('style');
st.id = 'wp-nav-critical';
st.textContent = WP_NAV_CRITICAL_CSS;
// First in <head> so the real stylesheet (loaded later) still wins on every
// property it defines — this is a floor, not an override.
const head = document.head || document.documentElement;
head.insertBefore(st, head.firstChild);
}
// ── panel behaviour ────────────────────────────────────────────
// The panel is always present. The toggle collapses it to a 56px icon rail (badges
// only) and back; that choice is remembered. No hover-to-open: a list you navigate
// by shouldn't appear and disappear under the pointer.
let wpNavView = 'all'; // all | mine | open
function toggleWpNav(){
if(document.body.classList.contains('wp-nav-open') || navPinned()) closeWpNav(true);
else openWpNav();
}
function navPinned(){ return document.body.classList.contains('wp-nav-pinned'); }
function setNavPinned(on){
document.body.classList.toggle('wp-nav-pinned', !!on);
const btn = document.getElementById('wp-nav-pin');
const collapsed = document.body.classList.toggle('wp-nav-collapsed');
try{ localStorage.setItem('wp_nav_collapsed', collapsed ? '1' : ''); }catch(e){}
const btn = document.getElementById('wp-nav-toggle');
if(btn){
btn.classList.toggle('is-on', !!on);
btn.title = on ? 'Unpin (let it hide again)' : 'Keep this list open';
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
btn.title = collapsed ? 'Expand the panel' : 'Collapse the panel';
}
try{ localStorage.setItem('wp_nav_pinned', on ? '1' : ''); }catch(e){}
positionSectionNav(); // pinned changes nothing about --rail-top, but keep them in step
closeWpNavMore();
track(collapsed ? 'wp_nav_collapsed' : 'wp_nav_expanded');
}
function toggleWpNavPin(){
const on = !navPinned();
setNavPinned(on);
if(on) openWpNav(); else closeWpNav(true);
track(on ? 'wp_nav_pinned' : 'wp_nav_unpinned');
// Kept so older callers (and the suite shell) don't break.
function openWpNav(){ document.body.classList.remove('wp-nav-collapsed'); }
function closeWpNav(){ document.body.classList.add('wp-nav-collapsed'); }
function toggleWpNavMore(ev){
if(ev) ev.stopPropagation();
const m = document.getElementById('wp-nav-more');
const b = document.getElementById('wp-nav-more-btn');
if(!m) return;
const open = m.hidden;
m.hidden = !open;
if(b) b.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function closeWpNavMore(){
const m = document.getElementById('wp-nav-more');
const b = document.getElementById('wp-nav-more-btn');
if(m) m.hidden = true;
if(b) b.setAttribute('aria-expanded', 'false');
}
document.addEventListener('click', e => {
if(!e.target.closest || !e.target.closest('.wp-nav-primary')) closeWpNavMore();
});
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeWpNavMore(); });
function wpNavAction(what){
closeWpNavMore();
if(what === 'duplicate') duplicateWP();
else if(what === 'split') splitByDiscipline();
else if(what === 'export') exportPackages();
}
// The three views are filters over the same list, like Planner's My tasks / My plans.
function setWpNavView(view){
wpNavView = view;
document.querySelectorAll('.wp-nav-link[data-view]').forEach(b => {
b.classList.toggle('is-current', b.getAttribute('data-view') === view);
});
renderWpNav();
track('wp_nav_view', {view});
}
// A stable colour per package so the same one is always the same swatch — the cue
// Planner gets from its plan avatars. Hashed from the WP number, not its index, so
// it doesn't shuffle when a package is added or deleted.
const WP_BADGE_COLORS = ['#0f62fe','#8a3ffc','#007d79','#d02670','#ba4e00',
'#1192e8','#198038','#a56eff','#9f1853','#005d5d'];
function wpBadgeColor(p){
const key = (p.number || p.id || '') + '';
let h = 0;
for(let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 100000;
return WP_BADGE_COLORS[h % WP_BADGE_COLORS.length];
}
// Two characters for the badge: the package's own digits if it has them (WP-25 -> 25),
// otherwise the initials of its type, otherwise WP.
function wpBadgeText(p){
const digits = String(p.number || '').match(/(\d{1,2})(?!.*\d)/);
if(digits) return digits[1];
const t = String(p.type || '').trim();
if(t){
const words = t.split(/\s+/).filter(Boolean);
return (words.length > 1 ? words[0][0] + words[1][0] : t.slice(0, 2));
}
return 'WP';
}
function initWpNavDrawer(){
const nav = document.getElementById('wp-nav');
const handle = document.getElementById('wp-nav-handle');
if(!nav || !handle || nav.dataset.bound) return;
if(!nav || nav.dataset.bound) return;
nav.dataset.bound = '1';
// Hover in / out. A short close delay stops the drawer snapping shut while the
// pointer crosses the gap between handle and panel.
const armClose = () => {
clearTimeout(_navCloseTimer);
_navCloseTimer = setTimeout(() => closeWpNav(false), 350);
};
handle.addEventListener('mouseenter', openWpNav);
handle.addEventListener('mouseleave', armClose);
nav.addEventListener('mouseenter', () => clearTimeout(_navCloseTimer));
nav.addEventListener('mouseleave', armClose);
// Opening from the keyboard should work too.
handle.addEventListener('focus', openWpNav);
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeWpNav(false); });
// Tap/click outside closes it (the touch path, where there's no mouseleave).
document.addEventListener('click', e => {
if(navPinned()) return;
if(!document.body.classList.contains('wp-nav-open')) return;
if(nav.contains(e.target) || handle.contains(e.target)) return;
closeWpNav(false);
});
try{ if(localStorage.getItem('wp_nav_pinned')) setNavPinned(true); }catch(e){}
injectWpNavCriticalCss();
try{ if(localStorage.getItem('wp_nav_collapsed')) document.body.classList.add('wp-nav-collapsed'); }catch(e){}
const btn = document.getElementById('wp-nav-toggle');
if(btn){
const collapsed = document.body.classList.contains('wp-nav-collapsed');
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
btn.title = collapsed ? 'Expand the panel' : 'Collapse the panel';
}
}
function renderWpNav(){
const list=document.getElementById('wp-nav-list'); if(!list) return;
const cnt=document.getElementById('wp-nav-count');
if(cnt) cnt.textContent = savedPackages.length ? '('+savedPackages.length+')' : '';
const badge=document.getElementById('wp-nav-handle-count');
if(badge) badge.textContent = savedPackages.length;
const q=((document.getElementById('wp-nav-search')||{}).value||'').trim().toLowerCase();
const list = document.getElementById('wp-nav-list');
if(!list) return;
const q = ((document.getElementById('wp-nav-search') || {}).value || '').trim().toLowerCase();
const me = myUserId();
// Keep the original index — edit/view act on savedPackages by position.
const rows=savedPackages.map((p,i)=>({p:p,i:i})).filter(r=>{
if(!q) return true;
const p=r.p;
return [p.number,p.subject,p.type,p.location,p.system].some(v=>String(v||'').toLowerCase().includes(q));
});
const all = savedPackages.map((p, i) => ({p, i}));
const mine = all.filter(r => r.p.assigneeId && r.p.assigneeId === me);
const needs = all.filter(r => r.p.status === 'Issue' || wpReleaseBlocked(r.p));
const setN = (id, n) => { const e = document.getElementById(id); if(e) e.textContent = n || ''; };
setN('wp-nav-n-all', all.length);
setN('wp-nav-n-mine', mine.length);
setN('wp-nav-n-open', needs.length);
let rows = wpNavView === 'mine' ? mine : (wpNavView === 'open' ? needs : all);
const label = document.getElementById('wp-nav-sect-label');
if(label) label.textContent = wpNavView === 'mine' ? 'My packages'
: (wpNavView === 'open' ? 'Needs attention' : 'Work packages');
if(q){
rows = rows.filter(r => {
const p = r.p;
return [p.number, p.subject, p.type, p.location, p.system]
.some(v => String(v || '').toLowerCase().includes(q));
});
}
const cnt = document.getElementById('wp-nav-count');
if(cnt) cnt.textContent = rows.length ? '(' + rows.length + ')' : '';
if(!rows.length){
list.innerHTML='<div class="wp-nav-empty">'+(savedPackages.length?'No packages match “'+esc(q)+'”.':'No work packages saved yet. Fill the form and Save Draft.')+'</div>';
list.innerHTML = '<div class="wp-nav-empty">' + (
savedPackages.length
? (q ? 'Nothing matches “' + esc(q) + '”.' : 'No packages in this view.')
: 'No work packages yet. Fill the form and Save Draft.'
) + '</div>';
return;
}
const groups={};
rows.forEach(r=>{ const s=r.p.status||'Draft'; (groups[s]=groups[s]||[]).push(r); });
const keys=Object.keys(groups).sort((a,b)=>{
const ia=WPNAV_ORDER.indexOf(a), ib=WPNAV_ORDER.indexOf(b);
return (ia<0?99:ia)-(ib<0?99:ib);
const groups = {};
rows.forEach(r => { const st = r.p.status || 'Draft'; (groups[st] = groups[st] || []).push(r); });
const keys = Object.keys(groups).sort((a, b) => {
const ia = WPNAV_ORDER.indexOf(a), ib = WPNAV_ORDER.indexOf(b);
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib);
});
list.innerHTML=keys.map(k=>{
const label = k==='Issue' ? 'Issue (Hold)' : k;
return '<div class="wp-nav-group">'+esc(label)+' · '+groups[k].length+'</div>'+groups[k].map(r=>{
const p=r.p, open=(p.constraints||[]).filter(c=>c.status==='open').length;
// Waiting on an unclosed predecessor is 'not ready' too, not just open constraints.
const waiting=(p.predecessors||[]).map(id=>wpById(id)).filter(x=>x&&x.status!=='Closed').length;
const dot = p.status==='Issue' ? 'hold' : ((open===0 && !waiting) ? 'ok' : 'open');
const state = p.status==='Issue' ? 'on hold'
: (open ? open+' open' : (waiting ? 'waits on '+waiting : 'ready'));
const active = (editingId && p.id===editingId) ? ' active' : '';
return '<button type="button" class="wp-nav-item'+active+'" onclick="wpNavOpen('+r.i+')" title="'+esc((p.number||'')+' — '+(p.subject||''))+'">'+
'<span class="wp-nav-num">'+esc(p.number||'(unnumbered)')+'</span>'+
'<span class="wp-nav-subj">'+esc(p.subject||'untitled')+'</span>'+
'<span class="wp-nav-meta"><span class="wp-nav-dot '+dot+'"></span>'+esc(state)+
(p.type?' · '+esc(p.type):'')+'</span></button>';
}).join('');
list.innerHTML = keys.map(k => {
const heading = k === 'Issue' ? 'Issue (Hold)' : k;
return '<div class="wp-nav-group">' + esc(heading) + ' · ' + groups[k].length + '</div>' +
groups[k].map(r => {
const p = r.p;
const open = (p.constraints || []).filter(c => c.status === 'open').length;
const waiting = wpWaitingOn(p).length;
const dot = p.status === 'Issue' ? 'hold' : ((open === 0 && !waiting) ? 'ok' : 'open');
const state = p.status === 'Issue' ? 'on hold'
: (open ? open + ' open' : (waiting ? 'waits on ' + waiting : 'ready'));
const active = (editingId && p.id === editingId) ? ' active' : '';
const title = (p.number || '') + ' — ' + (p.subject || '') + ' · ' + (p.status || '') + ' · ' + state;
return '<button type="button" class="wp-nav-item' + active + '" onclick="wpNavOpen(' + r.i + ')"' +
' title="' + esc(title) + '">' +
'<span class="wp-nav-badge" style="background:' + wpBadgeColor(p) + '">' + esc(wpBadgeText(p)) + '</span>' +
'<span class="wp-nav-body">' +
'<span class="wp-nav-num">' + esc(p.number || '(unnumbered)') + '</span>' +
'<span class="wp-nav-subj">' + esc(p.subject || 'untitled') + '</span>' +
'</span>' +
'<span class="wp-nav-state"><span class="wp-nav-dot ' + dot + '"></span>' + esc(state) + '</span>' +
'</button>';
}).join('');
}).join('');
}
function wpNavOpen(i){
const p=savedPackages[i]; if(!p) return;
closeWpNav(false); // get out of the way once you've chosen (unless pinned)
editingId=p.id;
loadPackageIntoForm(p); // ends in showForm(), so this also leaves the dashboard / package view
renderWpNav();