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 // 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"). // 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']; const WPNAV_ORDER=['Issue','In Progress','Issued','QC','Scheduled','Draft','Closed'];
// ── drawer behaviour ───────────────────────────────────────────────────────── // The panel's ESSENTIAL layout ships with this script rather than living only in
// Hover the edge handle to open, move the pointer away to close. Pinning keeps it // wp-creation-styles.css. The two files are cached independently, so a browser can
// open and shifts the form across; the pin is remembered. Touch devices have no // run new markup against an old stylesheet — and without these rules the panel is
// hover, so tapping the handle opens it and it stays until you pick something, // not merely unstyled, it's broken: its title, filter and buttons drop into the
// tap outside, or press Escape. // middle of the form as loose widgets (which is exactly what happened once).
let _navCloseTimer = null; // 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(){ function injectWpNavCriticalCss(){
clearTimeout(_navCloseTimer); if(document.getElementById('wp-nav-critical')) return;
document.body.classList.add('wp-nav-open'); const st = document.createElement('style');
const h = document.getElementById('wp-nav-handle'); st.id = 'wp-nav-critical';
if(h) h.setAttribute('aria-expanded', 'true'); st.textContent = WP_NAV_CRITICAL_CSS;
} // First in <head> so the real stylesheet (loaded later) still wins on every
function closeWpNav(force){ // property it defines — this is a floor, not an override.
clearTimeout(_navCloseTimer); const head = document.head || document.documentElement;
if(navPinned() && !force) return; // pinned stays put unless explicitly closed head.insertBefore(st, head.firstChild);
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');
} }
// ── 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(){ function toggleWpNav(){
if(document.body.classList.contains('wp-nav-open') || navPinned()) closeWpNav(true); const collapsed = document.body.classList.toggle('wp-nav-collapsed');
else openWpNav(); try{ localStorage.setItem('wp_nav_collapsed', collapsed ? '1' : ''); }catch(e){}
} const btn = document.getElementById('wp-nav-toggle');
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');
if(btn){ if(btn){
btn.classList.toggle('is-on', !!on); btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
btn.title = on ? 'Unpin (let it hide again)' : 'Keep this list open'; btn.title = collapsed ? 'Expand the panel' : 'Collapse the panel';
} }
try{ localStorage.setItem('wp_nav_pinned', on ? '1' : ''); }catch(e){} closeWpNavMore();
positionSectionNav(); // pinned changes nothing about --rail-top, but keep them in step track(collapsed ? 'wp_nav_collapsed' : 'wp_nav_expanded');
} }
function toggleWpNavPin(){
const on = !navPinned(); // Kept so older callers (and the suite shell) don't break.
setNavPinned(on); function openWpNav(){ document.body.classList.remove('wp-nav-collapsed'); }
if(on) openWpNav(); else closeWpNav(true); function closeWpNav(){ document.body.classList.add('wp-nav-collapsed'); }
track(on ? 'wp_nav_pinned' : 'wp_nav_unpinned');
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(){ function initWpNavDrawer(){
const nav = document.getElementById('wp-nav'); const nav = document.getElementById('wp-nav');
const handle = document.getElementById('wp-nav-handle'); if(!nav || nav.dataset.bound) return;
if(!nav || !handle || nav.dataset.bound) return;
nav.dataset.bound = '1'; nav.dataset.bound = '1';
injectWpNavCriticalCss();
// Hover in / out. A short close delay stops the drawer snapping shut while the try{ if(localStorage.getItem('wp_nav_collapsed')) document.body.classList.add('wp-nav-collapsed'); }catch(e){}
// pointer crosses the gap between handle and panel. const btn = document.getElementById('wp-nav-toggle');
const armClose = () => { if(btn){
clearTimeout(_navCloseTimer); const collapsed = document.body.classList.contains('wp-nav-collapsed');
_navCloseTimer = setTimeout(() => closeWpNav(false), 350); btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
}; btn.title = collapsed ? 'Expand the panel' : 'Collapse the panel';
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){}
} }
function renderWpNav(){ function renderWpNav(){
const list=document.getElementById('wp-nav-list'); if(!list) return; const list = document.getElementById('wp-nav-list');
const cnt=document.getElementById('wp-nav-count'); if(!list) return;
if(cnt) cnt.textContent = savedPackages.length ? '('+savedPackages.length+')' : ''; const q = ((document.getElementById('wp-nav-search') || {}).value || '').trim().toLowerCase();
const badge=document.getElementById('wp-nav-handle-count'); const me = myUserId();
if(badge) badge.textContent = savedPackages.length;
const q=((document.getElementById('wp-nav-search')||{}).value||'').trim().toLowerCase();
// Keep the original index — edit/view act on savedPackages by position. // Keep the original index — edit/view act on savedPackages by position.
const rows=savedPackages.map((p,i)=>({p:p,i:i})).filter(r=>{ const all = savedPackages.map((p, i) => ({p, i}));
if(!q) return true; const mine = all.filter(r => r.p.assigneeId && r.p.assigneeId === me);
const p=r.p; const needs = all.filter(r => r.p.status === 'Issue' || wpReleaseBlocked(r.p));
return [p.number,p.subject,p.type,p.location,p.system].some(v=>String(v||'').toLowerCase().includes(q));
}); 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){ 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; return;
} }
const groups={};
rows.forEach(r=>{ const s=r.p.status||'Draft'; (groups[s]=groups[s]||[]).push(r); }); const groups = {};
const keys=Object.keys(groups).sort((a,b)=>{ rows.forEach(r => { const st = r.p.status || 'Draft'; (groups[st] = groups[st] || []).push(r); });
const ia=WPNAV_ORDER.indexOf(a), ib=WPNAV_ORDER.indexOf(b); const keys = Object.keys(groups).sort((a, b) => {
return (ia<0?99:ia)-(ib<0?99:ib); 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; list.innerHTML = keys.map(k => {
return '<div class="wp-nav-group">'+esc(label)+' · '+groups[k].length+'</div>'+groups[k].map(r=>{ const heading = k === 'Issue' ? 'Issue (Hold)' : k;
const p=r.p, open=(p.constraints||[]).filter(c=>c.status==='open').length; return '<div class="wp-nav-group">' + esc(heading) + ' · ' + groups[k].length + '</div>' +
// Waiting on an unclosed predecessor is 'not ready' too, not just open constraints. groups[k].map(r => {
const waiting=(p.predecessors||[]).map(id=>wpById(id)).filter(x=>x&&x.status!=='Closed').length; const p = r.p;
const dot = p.status==='Issue' ? 'hold' : ((open===0 && !waiting) ? 'ok' : 'open'); const open = (p.constraints || []).filter(c => c.status === 'open').length;
const state = p.status==='Issue' ? 'on hold' const waiting = wpWaitingOn(p).length;
: (open ? open+' open' : (waiting ? 'waits on '+waiting : 'ready')); const dot = p.status === 'Issue' ? 'hold' : ((open === 0 && !waiting) ? 'ok' : 'open');
const active = (editingId && p.id===editingId) ? ' active' : ''; const state = p.status === 'Issue' ? 'on hold'
return '<button type="button" class="wp-nav-item'+active+'" onclick="wpNavOpen('+r.i+')" title="'+esc((p.number||'')+' — '+(p.subject||''))+'">'+ : (open ? open + ' open' : (waiting ? 'waits on ' + waiting : 'ready'));
'<span class="wp-nav-num">'+esc(p.number||'(unnumbered)')+'</span>'+ const active = (editingId && p.id === editingId) ? ' active' : '';
'<span class="wp-nav-subj">'+esc(p.subject||'untitled')+'</span>'+ const title = (p.number || '') + ' — ' + (p.subject || '') + ' · ' + (p.status || '') + ' · ' + state;
'<span class="wp-nav-meta"><span class="wp-nav-dot '+dot+'"></span>'+esc(state)+ return '<button type="button" class="wp-nav-item' + active + '" onclick="wpNavOpen(' + r.i + ')"' +
(p.type?' · '+esc(p.type):'')+'</span></button>'; ' title="' + esc(title) + '">' +
}).join(''); '<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(''); }).join('');
} }
function wpNavOpen(i){ function wpNavOpen(i){
const p=savedPackages[i]; if(!p) return; const p=savedPackages[i]; if(!p) return;
closeWpNav(false); // get out of the way once you've chosen (unless pinned)
editingId=p.id; editingId=p.id;
loadPackageIntoForm(p); // ends in showForm(), so this also leaves the dashboard / package view loadPackageIntoForm(p); // ends in showForm(), so this also leaves the dashboard / package view
renderWpNav(); renderWpNav();

View File

@@ -47,24 +47,62 @@
<div class="wp-layout"> <div class="wp-layout">
<!-- WP NAVIGATOR — auto-hiding drawer. The handle is always visible; hover or tap <!-- WORK PACKAGE NAVIGATOR
it to slide the list in over the form, or pin it to keep it open. --> A persistent side panel (not a hover drawer): collapse toggle, a primary action,
<button class="wp-nav-handle" id="wp-nav-handle" onclick="toggleWpNav()" icon nav, then the project's packages as rows with colour-coded initial badges.
title="Work packages on this project" aria-label="Show work packages" aria-expanded="false"> Collapsing leaves a narrow icon rail so you can still see and switch packages. -->
Work Packages <span class="wp-nav-handle-count" id="wp-nav-handle-count">0</span>
</button>
<aside class="wp-nav" id="wp-nav" aria-label="Work packages"> <aside class="wp-nav" id="wp-nav" aria-label="Work packages">
<div class="wp-nav-head"> <div class="wp-nav-top">
<div class="wp-nav-title">Work Packages <span class="wp-nav-count" id="wp-nav-count"></span></div> <button class="wp-nav-toggle" id="wp-nav-toggle" onclick="toggleWpNav()"
<button class="wp-nav-btn" id="wp-nav-pin" onclick="toggleWpNavPin()" title="Keep this list open" aria-label="Pin list open">📌</button> title="Collapse the panel" aria-label="Collapse the panel" aria-expanded="true">
<button class="wp-nav-btn" onclick="closeWpNav(true)" title="Close list" aria-label="Close list"></button> <svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true">
<rect x="2.5" y="3.5" width="15" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/>
<line x1="7.5" y1="3.5" x2="7.5" y2="16.5" stroke="currentColor" stroke-width="1.4"/>
<path class="wp-nav-toggle-arrow" d="M14 10 H10 M11.6 8.2 L9.8 10 L11.6 11.8"
fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="wp-nav-primary">
<button class="wp-nav-cta" onclick="newPackage()" title="Start a new work package">
<span class="wp-nav-cta-plus" aria-hidden="true">+</span><span class="wp-nav-cta-label">New work package</span>
</button>
<button class="wp-nav-cta-more" id="wp-nav-more-btn" onclick="toggleWpNavMore(event)"
title="More actions" aria-label="More actions" aria-haspopup="true" aria-expanded="false"></button>
<div class="wp-nav-menu" id="wp-nav-more" hidden>
<button type="button" onclick="wpNavAction('duplicate')">Duplicate this package</button>
<button type="button" onclick="wpNavAction('split')">Split by discipline</button>
<button type="button" onclick="wpNavAction('export')">Export all (JSON)</button>
</div>
</div>
<nav class="wp-nav-links" aria-label="Views">
<button type="button" class="wp-nav-link" data-view="mine" onclick="setWpNavView('mine')" title="Packages you own">
<span class="wp-nav-ico" aria-hidden="true"></span><span class="wp-nav-link-label">My packages</span>
<span class="wp-nav-link-n" id="wp-nav-n-mine"></span>
</button>
<button type="button" class="wp-nav-link is-current" data-view="all" onclick="setWpNavView('all')" title="Every package on this project">
<span class="wp-nav-ico" aria-hidden="true"></span><span class="wp-nav-link-label">All packages</span>
<span class="wp-nav-link-n" id="wp-nav-n-all"></span>
</button>
<button type="button" class="wp-nav-link" data-view="open" onclick="setWpNavView('open')" title="Not release-ready yet">
<span class="wp-nav-ico" aria-hidden="true"></span><span class="wp-nav-link-label">Needs attention</span>
<span class="wp-nav-link-n" id="wp-nav-n-open"></span>
</button>
<button type="button" class="wp-nav-link" onclick="showDashboard()" title="Status and gating across the project">
<span class="wp-nav-ico" aria-hidden="true"></span><span class="wp-nav-link-label">Dashboard</span>
</button>
</nav>
<div class="wp-nav-sect">
<span class="wp-nav-sect-label" id="wp-nav-sect-label">Work packages</span>
<span class="wp-nav-count" id="wp-nav-count"></span>
</div>
<div class="wp-nav-filter">
<input type="search" class="wp-nav-search" id="wp-nav-search" placeholder="Filter packages…" oninput="renderWpNav()">
</div> </div>
<input type="search" class="wp-nav-search" id="wp-nav-search" placeholder="Filter by number, subject, type…" oninput="renderWpNav()">
<div class="wp-nav-list" id="wp-nav-list"></div> <div class="wp-nav-list" id="wp-nav-list"></div>
<div class="wp-nav-foot">
<button class="add-btn" onclick="newPackage()">+ New</button>
<button class="add-btn" onclick="showDashboard()">📊 Dashboard</button>
</div>
</aside> </aside>
<div class="main"> <div class="main">

View File

@@ -105,7 +105,8 @@
width away from the form — which matters most when this page is embedded in the width away from the form — which matters most when this page is embedded in the
suite's tab and every pixel is shared with the app chrome. */ suite's tab and every pixel is shared with the app chrome. */
.wp-layout { display: block; width: 100%; margin: 0; } .wp-layout { display: block; width: 100%; margin: 0; }
.main { min-width: 0; max-width: none; margin: 0; padding: 22px 28px 72px var(--gutter,34px); .main { min-width: 0; max-width: none; margin: 0;
padding: 22px 28px 72px calc(var(--nav-w,288px) + 28px);
transition: padding-left .18s ease; } transition: padding-left .18s ease; }
.section { display: none; } .section { display: none; }
@@ -438,7 +439,7 @@
border-radius:var(--radius); padding:7px 10px; font-size:11px; } border-radius:var(--radius); padding:7px 10px; font-size:11px; }
/* ── CREATION TOOL ───────────────────────────────────────────────── */ /* ── CREATION TOOL ───────────────────────────────────────────────── */
.ctx-bar { max-width:none; margin:0; padding:12px 28px 12px var(--gutter,34px); display:flex; align-items:center; gap:20px; .ctx-bar { max-width:none; margin:0; padding:12px 28px 12px calc(var(--nav-w,288px) + 28px); display:flex; align-items:center; gap:20px;
border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; } border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; }
.ctx-empty { color:var(--text-muted); font-size:13px; } .ctx-empty { color:var(--text-muted); font-size:13px; }
.ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; } .ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; }
@@ -474,7 +475,7 @@
/* ── WORK PACKAGE FORM ───────────────────────────────────────────── */ /* ── WORK PACKAGE FORM ───────────────────────────────────────────── */
.sop-hint { color:var(--accent) !important; } .sop-hint { color:var(--accent) !important; }
.release-banner { max-width:none; margin:0; padding:0 28px 0 var(--gutter,34px); } .release-banner { max-width:none; margin:0; padding:0 28px 0 calc(var(--nav-w,288px) + 28px); }
.release-banner .rb-inner { margin-top:14px; border-radius:var(--radius); padding:11px 16px; font-size:13px; font-weight:600; .release-banner .rb-inner { margin-top:14px; border-radius:var(--radius); padding:11px 16px; font-size:13px; font-weight:600;
display:flex; align-items:center; gap:10px; } display:flex; align-items:center; gap:10px; }
.rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid #b6e3c6; } .rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid #b6e3c6; }
@@ -582,7 +583,7 @@
/* Section nav (jump chips) */ /* Section nav (jump chips) */
.section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px; .section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px;
padding:8px 12px 8px var(--gutter,34px); background:rgba(255,255,255,.94); backdrop-filter:blur(4px); padding:8px 12px 8px calc(var(--nav-w,288px) + 28px); background:rgba(255,255,255,.94); backdrop-filter:blur(4px);
border-bottom:1px solid var(--border); box-shadow:0 1px 4px rgba(20,30,50,.06); border-bottom:1px solid var(--border); box-shadow:0 1px 4px rgba(20,30,50,.06);
transition:transform .22s ease; } transition:transform .22s ease; }
.section-nav-bar:empty{ display:none; } .section-nav-bar:empty{ display:none; }
@@ -649,108 +650,164 @@
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim); font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);
border:1px solid #ffc4c4; white-space:nowrap; vertical-align:middle; } border:1px solid #ffc4c4; white-space:nowrap; vertical-align:middle; }
/* ── WP NAVIGATOR — auto-hiding drawer ───────────────────────────────────── /* -- WORK PACKAGE NAVIGATOR ------------------------------------------------
Was a fixed 262px column, which (a) stole width from the form and (b) vanished A persistent side panel in the spirit of MS Planner: collapse toggle, one
entirely under 1100px — so embedded in the suite tab it was never visible at primary action, icon nav, then the packages as rows with colour-coded initial
all. Now it slides in over the content from a slim edge handle: hover (or tap) badges and a highlighted current row. It sits IN the layout (the form shifts
the handle to open, move away to close, or pin it open if you'd rather it stay. across) rather than hovering over the content, and collapses to a 56px icon
Nothing is taken from the form unless you pin it. */ rail so you can still see and switch packages with it closed. */
.wp-nav { .wp-nav {
position: fixed; position: fixed;
top: var(--rail-top, 0px); top: var(--rail-top, 48px);
left: 0; left: 0;
bottom: 0; bottom: 0;
width: 300px; width: var(--nav-w, 288px);
max-width: 86vw;
z-index: 120; z-index: 120;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--surface); background: #fbfbfc;
border-right: 1px solid var(--border-strong); border-right: 1px solid var(--border);
box-shadow: 6px 0 22px rgba(20,30,50,.16); overflow: hidden;
transform: translateX(-100%); transition: width .16s ease;
transition: transform .18s ease;
} }
body.wp-nav-open .wp-nav, body { --nav-w: 288px; }
body.wp-nav-pinned .wp-nav { transform: none; } body.wp-nav-collapsed { --nav-w: 56px; }
/* Pinned: no shadow (it's part of the layout now) and the form shifts over. */
body.wp-nav-pinned .wp-nav { box-shadow: none; }
/* The always-visible edge handle. Vertical so it costs ~26px of width. */ /* -- collapse toggle -- */
.wp-nav-handle { .wp-nav-top { display: flex; align-items: center; padding: 8px 10px 2px; }
position: fixed; .wp-nav-toggle {
top: calc(var(--rail-top, 0px) + 14px); display: inline-flex; align-items: center; justify-content: center;
left: 0; width: 34px; height: 34px; padding: 0;
z-index: 119; background: transparent; border: 1px solid transparent; border-radius: 5px;
display: flex; color: var(--text-muted); cursor: pointer;
align-items: center;
gap: 6px;
padding: 12px 5px;
writing-mode: vertical-rl;
background: var(--surface);
color: var(--text-muted);
border: 1px solid var(--border-strong);
border-left: 0;
border-radius: 0 5px 5px 0;
box-shadow: 2px 0 8px rgba(20,30,50,.10);
font: inherit;
font-size: 11px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
cursor: pointer;
} }
.wp-nav-handle:hover { color: var(--accent); border-color: var(--accent); } .wp-nav-toggle:hover { background: #eef0f3; color: var(--text); }
.wp-nav-handle .wp-nav-handle-count { /* Arrow flips to point right when the panel is closed. */
writing-mode: horizontal-tb; font-size: 10px; font-weight: 700; letter-spacing: 0; body.wp-nav-collapsed .wp-nav-toggle-arrow { transform: rotate(180deg); transform-origin: 11px 10px; }
background: var(--accent-dim); color: var(--accent); border-radius: 8px; padding: 0 5px;
/* -- primary action -- */
.wp-nav-primary { position: relative; display: flex; gap: 2px; padding: 6px 10px 12px; }
.wp-nav-cta {
flex: 1 1 auto; min-width: 0;
display: inline-flex; align-items: center; justify-content: flex-start; gap: 9px;
height: 40px; padding: 0 14px;
background: var(--accent); color: #fff;
border: 0; border-radius: 6px 0 0 6px;
font: inherit; font-size: 14px; font-weight: 600;
cursor: pointer; white-space: nowrap;
} }
body.wp-nav-open .wp-nav-handle, .wp-nav-cta:hover { background: #0353e9; }
body.wp-nav-pinned .wp-nav-handle { display: none; } .wp-nav-cta-plus { font-size: 17px; font-weight: 400; line-height: 1; }
.wp-nav-cta-more {
flex: 0 0 auto; width: 30px; height: 40px;
background: var(--accent); color: #fff; border: 0; border-left: 1px solid rgba(255,255,255,.28);
border-radius: 0 6px 6px 0; font: inherit; font-size: 12px; cursor: pointer;
}
.wp-nav-cta-more:hover { background: #0353e9; }
.wp-nav-menu {
position: absolute; top: calc(100% - 6px); left: 10px; right: 10px; z-index: 10;
background: var(--surface); border: 1px solid var(--border-strong); border-radius: 6px;
box-shadow: 0 10px 26px rgba(20,30,50,.18); padding: 5px 0;
}
.wp-nav-menu[hidden] { display: none; }
.wp-nav-menu button {
display: block; width: 100%; text-align: left; background: none; border: 0;
padding: 8px 12px; font: inherit; font-size: 13px; color: var(--text); cursor: pointer;
}
.wp-nav-menu button:hover { background: var(--surface2); }
.wp-nav-head { display:flex; align-items:center; gap:8px; padding:12px 12px 8px; } /* -- icon nav -- */
.wp-nav-title { font-size:12px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--text-muted); } .wp-nav-links { padding: 2px 8px 10px; display: flex; flex-direction: column; gap: 1px; }
.wp-nav-count { color:var(--text-dim); font-weight:600; letter-spacing:0; } .wp-nav-link {
.wp-nav-btn { background:transparent; border:1px solid var(--border); border-radius:4px; display: flex; align-items: center; gap: 12px;
color:var(--text-muted); cursor:pointer; font-size:13px; line-height:1; padding:3px 7px; } width: 100%; padding: 9px 10px;
.wp-nav-btn:hover { border-color:var(--accent); color:var(--accent); } background: none; border: 0; border-radius: 6px;
.wp-nav-btn.is-on { border-color:var(--accent); color:var(--accent); background:var(--accent-dim); } font: inherit; font-size: 14px; color: var(--text);
.wp-nav-head .wp-nav-btn:first-of-type { margin-left:auto; } cursor: pointer; text-align: left; white-space: nowrap;
.wp-nav-search { margin:0 12px 10px; padding:6px 9px; font-size:12px; font-family:inherit; }
border:1px solid var(--border); border-radius:4px; background:var(--bg); color:var(--text); } .wp-nav-link:hover { background: #eef0f3; }
.wp-nav-search:focus { outline:none; border-color:var(--accent); } .wp-nav-link.is-current { background: #e8eaed; font-weight: 600; }
.wp-nav-list { flex:1 1 auto; overflow-y:auto; padding:0 8px 8px; } .wp-nav-ico { flex: 0 0 20px; width: 20px; text-align: center; font-size: 15px; color: var(--text-muted); }
.wp-nav-group { font-size:10px; font-weight:700; letter-spacing:.09em; text-transform:uppercase; .wp-nav-link-label { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; }
color:var(--text-dim); padding:10px 6px 5px; } .wp-nav-link-n { flex: 0 0 auto; font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
.wp-nav-item { display:block; width:100%; text-align:left; background:transparent; border:0;
border-left:3px solid transparent; border-radius:4px; padding:6px 8px; cursor:pointer;
font-family:inherit; color:var(--text); }
.wp-nav-item:hover { background:var(--surface2); }
.wp-nav-item.active { background:var(--accent-dim); border-left-color:var(--accent); }
.wp-nav-num { display:block; font-family:var(--mono); font-size:11px; font-weight:600; color:var(--accent); }
.wp-nav-subj { display:block; font-size:12px; color:var(--text-muted); line-height:1.35;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.wp-nav-meta { display:flex; align-items:center; gap:6px; margin-top:3px; font-size:10px; color:var(--text-dim); }
.wp-nav-dot { width:7px; height:7px; border-radius:50%; background:var(--text-dim); flex:0 0 auto; }
.wp-nav-dot.ok { background:var(--accent-green); }
.wp-nav-dot.open { background:var(--accent-amber); }
.wp-nav-dot.hold { background:var(--red); }
.wp-nav-empty { padding:12px 8px; font-size:12px; color:var(--text-dim); }
.wp-nav-foot { border-top:1px solid var(--border); padding:9px 12px; display:flex; gap:8px; flex-wrap:wrap; }
/* Keep the drawer's footer clear of the fixed save bar. */
body.has-sticky-save .wp-nav { bottom: 54px; }
/* Pinned: the page chrome shifts with the form so nothing hides behind the panel. */
body.wp-nav-pinned { --gutter: 328px; }
body.wp-nav-pinned .sticky-save { left: 300px; }
/* Narrow screens: never pin (there isn't the width) — overlay only. */ /* -- section header + filter -- */
@media (max-width: 900px) { .wp-nav-sect {
body.wp-nav-pinned { --gutter: 34px; } /* no room to pin — overlay only */ display: flex; align-items: baseline; gap: 6px;
.wp-nav { width: 290px; } padding: 6px 18px 4px; border-top: 1px solid var(--border);
}
.wp-nav-sect-label { font-size: 12px; color: var(--text-muted); }
.wp-nav-count { font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
.wp-nav-filter { padding: 4px 12px 8px; }
.wp-nav-search {
width: 100%; padding: 7px 10px; font: inherit; font-size: 13px;
border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text);
}
.wp-nav-search:focus { outline: none; border-color: var(--accent); }
/* -- package rows -- */
.wp-nav-list { flex: 1 1 auto; overflow-y: auto; overflow-x: hidden; padding: 0 8px 14px; }
.wp-nav-group {
font-size: 11px; font-weight: 600; letter-spacing: .02em;
color: var(--text-dim); padding: 12px 10px 4px;
}
.wp-nav-item {
position: relative;
display: flex; align-items: center; gap: 11px;
width: 100%; padding: 7px 10px; margin-bottom: 1px;
background: none; border: 0; border-radius: 6px;
font: inherit; color: var(--text); text-align: left; cursor: pointer;
}
.wp-nav-item:hover { background: #eef0f3; }
.wp-nav-item.active { background: #e8eaed; }
/* Left accent bar on the current package, like Planner's selected plan. */
.wp-nav-item.active::before {
content: ''; position: absolute; left: 0; top: 6px; bottom: 6px;
width: 3px; border-radius: 2px; background: var(--accent);
}
.wp-nav-badge {
flex: 0 0 28px; width: 28px; height: 28px; border-radius: 5px;
display: inline-flex; align-items: center; justify-content: center;
font-family: var(--sans); font-size: 11px; font-weight: 700; letter-spacing: .02em;
color: #fff; text-transform: uppercase;
}
.wp-nav-body { min-width: 0; flex: 1 1 auto; }
.wp-nav-num { display: block; font-size: 13.5px; font-weight: 600; color: var(--text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wp-nav-subj { display: block; font-size: 12px; color: var(--text-muted); line-height: 1.35;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wp-nav-state { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 5px;
font-size: 10.5px; color: var(--text-dim); white-space: nowrap; }
.wp-nav-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-dim); flex: 0 0 auto; }
.wp-nav-dot.ok { background: var(--accent-green); }
.wp-nav-dot.open { background: var(--accent-amber); }
.wp-nav-dot.hold { background: var(--red); }
.wp-nav-empty { padding: 14px 10px; font-size: 12.5px; color: var(--text-dim); }
/* -- collapsed rail: badges and icons only -- */
body.wp-nav-collapsed .wp-nav-cta-label,
body.wp-nav-collapsed .wp-nav-cta-more,
body.wp-nav-collapsed .wp-nav-link-label,
body.wp-nav-collapsed .wp-nav-link-n,
body.wp-nav-collapsed .wp-nav-sect,
body.wp-nav-collapsed .wp-nav-filter,
body.wp-nav-collapsed .wp-nav-body,
body.wp-nav-collapsed .wp-nav-state,
body.wp-nav-collapsed .wp-nav-group { display: none; }
body.wp-nav-collapsed .wp-nav-primary { padding: 6px 10px 10px; }
body.wp-nav-collapsed .wp-nav-cta { justify-content: center; padding: 0; border-radius: 6px; }
body.wp-nav-collapsed .wp-nav-link { justify-content: center; padding: 9px 0; }
body.wp-nav-collapsed .wp-nav-item { justify-content: center; padding: 6px 0; }
body.wp-nav-collapsed .wp-nav-list { padding: 6px 6px 14px; }
/* Narrow screens: keep the rail collapsed-width so the form still has room. */
@media (max-width: 860px) {
body { --nav-w: 56px; }
body:not(.wp-nav-collapsed) .wp-nav { width: 288px; box-shadow: 6px 0 22px rgba(20,30,50,.16); }
} }
/* Sticky save bar */ /* Sticky save bar */
.sticky-save{ position:fixed; left:0; right:0; bottom:0; z-index:40; display:flex; align-items:center; .sticky-save{ position:fixed; left:var(--nav-w,288px); right:0; bottom:0; z-index:40; display:flex; align-items:center;
justify-content:space-between; gap:14px; padding:10px 20px; background:#fff; justify-content:space-between; gap:14px; padding:10px 20px; background:#fff;
border-top:1px solid var(--border-strong); box-shadow:0 -2px 10px rgba(20,30,50,.08); } border-top:1px solid var(--border-strong); box-shadow:0 -2px 10px rgba(20,30,50,.08); }
.sticky-save .sticky-status{ font-size:13px; font-weight:600; } .sticky-save .sticky-status{ font-size:13px; font-weight:600; }