T7.2 - F6/D3: the form gets structure - a section rail, one section open

F6 as amended by D3 (Aug 18): one page, persistent side navigation, sections
collapsible, only the current one open by default, plus Expand all. Tabs were
rejected in D3 because they hide sections a first-time author does not know
exist.

What changed:
- The jump-chip strip (#section-nav, span onclick) is gone. In its place a
  <nav> section rail of real <button> entries, aria-current on the current
  section, 44px tap targets, above the form at 390px and beside it at 1440px.
- Every section heading is now a disclosure <button> with aria-expanded and
  aria-controls. One section open at rest; Expand all (aria-pressed) opens
  everything and is remembered per browser.
- Sections are URL-addressable (?section=, T4.2 machinery) and a deep link to
  a collapsed section expands it. Positional-id fallback removed: a card
  without an id gets a console.error and no rail entry, never an invented
  sec-N id that would ride into shareable URLs and move between visits.
- General Information (1,288px on its own) split into #general-card and
  #assign-card (Assignment & Schedule). The split is presentational: both
  cards are the ONE CR-006 section `general` (WP_SECTION_NODES lists both),
  so wp-sections.js and the SOP wizard are untouched. CR-001's adjacency
  (P6 activity beside due date) is preserved and asserted.
- gotoSection() flushes autosave, which the deleted chips used to do.
- secMakeToggle() preserves every element child of a heading - help tips go
  outside the button, everything else inside the label. The first version
  cleared textContent and destroyed #saved-count, which killed boot one line
  short of wpCreatorReady with the page still visibly rendered.
- BL-013 folded in per the task: the T3.4 focus ring on the rebuilt form.
  frame_check reports outline solid 2px on creator inputs.

Height, measured not asserted: 5,399px before; 1,995px at rest at 1440x900.

DONE-WHEN NOT FULLY MET - stated per CLAUDE.md rather than marked complete:
"no single view exceeds roughly two screen heights at rest" reads 2.22
screens (1995/900). The remaining gap is page chrome this wave reworks:
.ctx-bar (67px, T7.4) and .release-banner (45px, T7.5). The criterion was
already amended once (D3, "at rest") and is not being moved again to fit;
tests/form_structure_check.py keeps the check red and it is re-measured at
the end of wave 7. Every other done-when entry passes.

Backlog: BL-001's cause corrected a third time - at rest the overflow is
help.js's .help-tip::after tooltip (481 vs 390), the S8 component T9.5
rebuilds; the tables still overflow only when expanded. Deliberately not
fixed here - a fix would be thrown away with the component at T9.5.

Verification (each probe run alone): form_structure_check 50/51 (the height
check above), sections_check 95/95, generalinfo_check 49/49, frame_check
39/39 regression pass.

Items: F6, D3, BL-013, BL-001 (re-measured)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 09:38:31 -07:00
parent 12d19446d5
commit 755c976841
8 changed files with 1020 additions and 106 deletions

View File

@@ -1398,70 +1398,288 @@ function syncToolTabs(){
// Sticky save bar + section-nav chrome (shown only on the editable form view).
function setFormChrome(on){
const nav=document.getElementById('section-nav'), save=document.getElementById('sticky-save');
if(nav) nav.style.display = on ? '' : 'none';
const rail=document.getElementById('section-rail'), save=document.getElementById('sticky-save');
if(rail) rail.style.display = on ? '' : 'none';
if(save) save.style.display = on ? 'flex' : 'none';
document.body.classList.toggle('has-sticky-save', !!on);
if(on){ buildSectionNav(); updateStickyStatus(); makeCollapsible(); initSectionNavAutoHide(); }
document.body.classList.toggle('has-sec-rail', !!on);
if(on){ initSectionNavAutoHide(); buildSectionRail(); updateStickyStatus(); }
else positionSectionNav();
}
// Make each form card collapsible by clicking its heading (idempotent).
function makeCollapsible(){
document.querySelectorAll('.main > .card').forEach(card=>{
if(card.id==='saved-card') return;
const head=card.querySelector('.section-header, .sub-heading');
if(!head || head.dataset.collapsible) return;
head.dataset.collapsible='1';
head.style.cursor='pointer';
const chev=document.createElement('span'); chev.className='collapse-chev'; chev.textContent='▾';
head.insertBefore(chev, head.firstChild);
head.addEventListener('click', e=>{
if(['INPUT','SELECT','TEXTAREA','BUTTON','A'].includes(e.target.tagName) || e.target.classList.contains('help-tip')) return;
const collapsed=card.classList.toggle('collapsed');
chev.textContent = collapsed ? '▸' : '▾';
// ── SECTION STRUCTURE (F6 / D3) ───────────────────────────────────────────────
// F6 measured this form at 11 cards in one 5,399px scroll, with a strip of jump
// chips standing in for structure. D3 settled the shape, and it is not tabs:
// one page, navigation down the side, sections collapsible, only the current one
// open, plus an `Expand all` for people who would rather scroll straight through.
//
// Tabs were rejected because they hide sections a first-time author does not know
// exist. The uncollapsed long form was rejected because it is the page F6 exists
// to fix. So the height criterion became "at rest" - the state the page loads in -
// which is what D3 amended it to, in writing, rather than quietly failing the old
// one.
//
// Two accessibility defects come out with the old strip, both named in CLAUDE.md:
// - the jump chips were `<span onclick>`; there were two left in the app
// - collapsing was a click listener on a heading `<div>`, so it was mouse-only
// Every control here is a real button. The rail is built FROM THE CARDS, so a
// section added later, or suppressed by a CR-006 toggle, changes the rail without
// anyone remembering to maintain a second list.
const SEC_EXPAND_KEY = 'wp_iwp_expand_all';
let secExpandAll = false;
let secCurrent = '';
let _secSpyBound = false;
// The cards that are sections: rendered, and carrying a heading to name them.
// `hidden` and `display:none` are how CR-006 and applyKindVisibility() suppress
// one, and a table of contents has to lose exactly what the form lost.
function secCards(){
return [...document.querySelectorAll('.main > .card')].filter(card => {
// `Saved work packages` is a list of OTHER packages, sitting at the bottom of
// the form for editing THIS one - and the navigator panel already lists exactly
// the same thing. It is not a section of the package, so it gets no rail entry.
// It is still collapsed by default (secApplyOpenState), because 310px of a
// duplicate list is not what the page should open on.
if(card.id === 'saved-card') return false;
if(card.hidden || card.style.display === 'none') return false;
return !!card.querySelector('.section-title, .sub-heading');
});
}
function secLabel(card){
const h = card.querySelector('.section-title, .sub-heading');
if(!h) return '';
const clone = h.cloneNode(true);
clone.querySelectorAll('.help-tip, .auto-tag, .collapse-chev').forEach(x => x.remove());
return clone.textContent.trim().replace(/\s+/g, ' ');
}
// Everything below the heading, wrapped once so `aria-controls` has a real target
// and collapsing is `hidden` on one element rather than a `:not()` rule listing
// the heading classes. The old CSS did the latter, which meant the disclosure
// state lived nowhere a screen reader could read it.
function secBody(card){
let body = card.querySelector(':scope > .card-body');
if(body) return body;
const head = card.querySelector(':scope > .section-header, :scope > .sub-heading');
if(!head) return null;
body = document.createElement('div');
body.className = 'card-body';
body.id = (card.id || 'sec') + '-body';
const rest = [];
for(let n = head.nextSibling; n; n = n.nextSibling) rest.push(n);
rest.forEach(n => body.appendChild(n));
card.appendChild(body);
return body;
}
// Turn the heading into a disclosure button, in place and once. The label goes
// inside the button; the help tip and the description stay OUTSIDE it, because a
// tooltip trigger nested in a button is two controls in one hit area.
function secMakeToggle(card){
if(card.dataset.secReady) return;
const body = secBody(card);
if(!body) return;
const title = card.querySelector(':scope > .section-header > .section-title')
|| card.querySelector(':scope > .sub-heading');
if(!title) return;
const label = secLabel(card);
// Every ELEMENT in the heading has to survive, not only the text. `saved-card`'s
// heading carries <span id="saved-count">, which renderSavedList() writes to on
// every save - clearing the heading destroyed it, renderSavedList() then threw on
// a null, and boot stopped one line short of setting wpCreatorReady. The page
// still rendered, so it looked like a slow load rather than a crash.
//
// Help tips go OUTSIDE the button: a tooltip trigger nested inside a button is
// two controls sharing one hit area. Everything else goes inside, because it is
// part of the heading's own text - a count, a badge, a tag.
const kids = [...title.children];
const tips = kids.filter(el => el.classList.contains('help-tip'));
const inside = kids.filter(el => !el.classList.contains('help-tip'));
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'card-toggle';
btn.setAttribute('aria-expanded', 'true');
btn.setAttribute('aria-controls', body.id);
btn.innerHTML = '<span class="collapse-chev" aria-hidden="true"></span>'
+ '<span class="card-toggle-label"></span>';
const labelEl = btn.querySelector('.card-toggle-label');
labelEl.textContent = label;
inside.forEach(el => labelEl.appendChild(el));
title.textContent = '';
title.appendChild(btn);
tips.forEach(t => title.appendChild(t));
btn.addEventListener('click', () => {
const open = card.classList.contains('collapsed');
secSetOpen(card, open);
// Opening a section by its own header makes it the one you are in, which the
// rail has to agree with - otherwise the rail marks a section you left.
if(open){ secCurrent = card.id; secMarkRail(); secSyncUrl(); }
});
card.dataset.secReady = '1';
}
function secSetOpen(card, open){
const btn = card.querySelector(':scope .card-toggle');
const body = card.querySelector(':scope > .card-body');
card.classList.toggle('collapsed', !open);
if(btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
if(body) body.hidden = !open;
}
function secIsOpen(card){ return !card.classList.contains('collapsed'); }
// ── the rail ─────────────────────────────────────────────────────────────────
function buildSectionRail(){
const rail = document.getElementById('section-rail');
const list = document.getElementById('sec-rail-list');
if(!rail || !list) return;
let cards = secCards();
// Every section carries a real id in the markup. A positional fallback was here
// and it was a bug waiting to be shipped: the id it invents goes into ?section=
// as a shareable address, and the INDEX moves whenever the set of visible
// sections changes - a CR-006 toggle, the BIM flag, or a card being inserted
// ahead of it. A link somebody sent then opens a different section, silently and
// with no error. Name the card in the markup instead; complain loudly if not.
cards.forEach(card => {
if(!card.id){
// Not a toast: this is a defect in the page, not something a user can act on.
try { console.error('T7.2: a form section has no id; its rail entry cannot be'
+ ' addressed. Give it one in wp-creation-index.html.', card); } catch(e){}
return;
}
secMakeToggle(card);
});
cards = cards.filter(c => c.id);
list.innerHTML = cards.map(card =>
'<li><button type="button" class="sec-rail-item" data-sec="' + esc(card.id) + '">'
+ esc(secLabel(card)) + '</button></li>').join('');
list.querySelectorAll('.sec-rail-item').forEach(b => {
b.addEventListener('click', () => gotoSection(b.dataset.sec));
});
rail.hidden = cards.length < 2;
// Whatever the URL asks for, else the first section. Never "all of them", which
// is the state F6 is about.
const want = (typeof WPUrl !== 'undefined' && WPUrl.get && WPUrl.get('section')) || '';
const target = cards.some(c => c.id === want) ? want : (cards[0] && cards[0].id) || '';
secApplyOpenState(target);
secBindSpy();
}
// One section open, or all of them. Called on every render, so it is also what
// keeps a suppressed section from being left expanded behind the scenes.
function secApplyOpenState(currentId){
const cards = secCards();
secCurrent = currentId || secCurrent || (cards[0] && cards[0].id) || '';
cards.forEach(card => secSetOpen(card, secExpandAll || card.id === secCurrent));
// The saved-package list is not in `cards` - it is not a section - but it is on
// the page and it is 310px tall, so it collapses like everything else. It gets
// its disclosure button here because the rail loop no longer reaches it.
const saved = document.getElementById('saved-card');
if(saved && saved.style.display !== 'none'){
secMakeToggle(saved);
secSetOpen(saved, secExpandAll);
}
secMarkRail();
}
function secMarkRail(){
document.querySelectorAll('.sec-rail-item').forEach(b => {
const on = b.dataset.sec === secCurrent;
b.classList.toggle('is-current', on);
// aria-current="true", not "page": these are places in a page, not pages.
if(on) b.setAttribute('aria-current', 'true');
else b.removeAttribute('aria-current');
});
}
function secSyncUrl(opts){
if(typeof WPUrl === 'undefined') return;
const patch = { section: secCurrent || '' };
(opts && opts.replace ? WPUrl.replace : WPUrl.push).call(WPUrl, patch);
}
// Open a section and go to it. Focus moves to the section's own heading button,
// not just the scroll position: a keyboard user who activates a rail item has to
// land somewhere, and landing nowhere is why jump links are not navigation.
function gotoSection(id, opts){
const card = document.getElementById(id);
if(!card) return false;
// Moving section is a "you have visibly moved on" moment, so flush the draft
// rather than wait out the debounce. This was bound to the old jump chips at
// initAutosave() time, which only worked because the chips were built before it
// ran; it belongs here, where every section change goes through one door -
// rail click, keyboard, deep link and Back alike.
if(typeof WPAutosave !== 'undefined' && WPAutosave.flush) WPAutosave.flush('section');
secApplyOpenState(id);
const btn = card.querySelector('.card-toggle');
if(btn){
btn.focus({preventScroll:true});
card.scrollIntoView({behavior:'smooth', block:'start'});
}
if(!(opts && opts.fromUrl)) secSyncUrl();
return true;
}
function toggleExpandAll(){
setExpandAll(!secExpandAll);
}
function setExpandAll(on){
secExpandAll = !!on;
try { localStorage.setItem(SEC_EXPAND_KEY, secExpandAll ? '1' : '0'); } catch(e){}
const btn = document.getElementById('sec-expand-all');
if(btn){
btn.setAttribute('aria-pressed', secExpandAll ? 'true' : 'false');
btn.textContent = secExpandAll ? 'Collapse all' : 'Expand all';
}
secApplyOpenState(secCurrent);
track(secExpandAll ? 'sections_expand_all' : 'sections_collapse_all');
}
// With one section open the current one is whatever you opened. With Expand all
// on, that answer is wrong the moment you scroll, so the rail follows the form.
function secBindSpy(){
if(_secSpyBound) return;
_secSpyBound = true;
window.addEventListener('scroll', () => {
if(!secExpandAll) return;
const rail = document.getElementById('section-rail');
if(!rail || rail.hidden) return;
const line = (document.querySelector('.header') || {offsetHeight:0}).offsetHeight + 80;
let seen = '';
secCards().forEach(card => {
if(card.getBoundingClientRect().top <= line) seen = card.id;
});
});
}
function buildSectionNav(){
const nav=document.getElementById('section-nav'); if(!nav) return;
const chips=[];
document.querySelectorAll('.main > .card').forEach((card,i)=>{
// `hidden` is how CR-006 suppresses a section; the chip strip is a table of
// contents for the form, so it has to lose the same entries.
if(card.id==='saved-card' || card.style.display==='none' || card.hidden) return;
const h=card.querySelector('.section-title, .sub-heading'); if(!h) return;
const clone=h.cloneNode(true); clone.querySelectorAll('.help-tip').forEach(x=>x.remove());
const label=clone.textContent.trim().replace(/\s+/g,' '); if(!label) return;
if(!card.id) card.id='sec-'+i;
chips.push(`<span class="sec-chip" onclick="document.getElementById('${card.id}').scrollIntoView({behavior:'smooth',block:'start'})">${esc(label)}</span>`);
});
nav.innerHTML=chips.join('');
}
// Keep the section-nav pinned just below the sticky header (so it stays put while
// scrolling instead of hiding behind the header), and let it slide out of the way
// while reading (scroll down), snapping back the moment you scroll up.
function positionSectionNav(){
const nav=document.getElementById('section-nav'), hdr=document.querySelector('.header');
if(nav && hdr) nav.style.top = hdr.offsetHeight + 'px';
// The navigator drawer + its handle hang below the page header. Deliberately NOT
// including the section-nav height: that bar is sticky, so at scroll 0 it sits
// further down the page and the handle would float over the chrome.
document.documentElement.style.setProperty('--rail-top', (hdr?hdr.offsetHeight:0)+'px');
}
let _snLastY=0, _snBound=false;
function initSectionNavAutoHide(){
positionSectionNav();
if(_snBound) return; _snBound=true;
window.addEventListener('resize', positionSectionNav, {passive:true});
window.addEventListener('scroll', ()=>{
const nav=document.getElementById('section-nav');
if(!nav || nav.style.display==='none') return;
const y=window.scrollY||document.documentElement.scrollTop||0;
if(y>_snLastY+4 && y>140) nav.classList.add('nav-hidden'); // scrolling down
else if(y<_snLastY-4) nav.classList.remove('nav-hidden'); // scrolling up
_snLastY=y;
if(seen && seen !== secCurrent){ secCurrent = seen; secMarkRail(); }
}, {passive:true});
}
// The drawer and the comments panel hang below the page chrome, and the chrome is
// taller since T7.1 - app bar, tab strip, toolbar. Measure it rather than assume.
function positionSectionNav(){
const parts = ['.header', '.main-nav', '.wp-toolbar']
.map(sel => document.querySelector(sel))
.filter(Boolean);
const h = parts.reduce((n, el) => n + el.offsetHeight, 0);
document.documentElement.style.setProperty('--rail-top', h + 'px');
const rail = document.getElementById('section-rail');
if(rail) rail.style.setProperty('--sec-rail-top', h + 'px');
}
let _secChromeBound = false;
function initSectionNavAutoHide(){
positionSectionNav();
if(_secChromeBound) return;
_secChromeBound = true;
window.addEventListener('resize', positionSectionNav, {passive:true});
try { setExpandAll(localStorage.getItem(SEC_EXPAND_KEY) === '1'); } catch(e){}
}
function updateStickyStatus(){
const el=document.getElementById('sticky-status'); if(!el) return;
const r=readiness(); const st=getRadio('status');
@@ -1621,7 +1839,10 @@ function wpLocationText(p){
// of its own; CR-004 gives it structured fields in wave 6 and only this line
// changes then.
const WP_SECTION_NODES = {
general: ['#general-card'],
// Two cards, one section. T7.2 split General Information because it was 1,288px
// on its own; the split is presentational and CR-006 still governs both through
// the single `general` id, so wp-sections.js and the SOP wizard are untouched.
general: ['#general-card', '#assign-card'],
location: ['#location-card'],
scope: ['#scope-card'],
assets: ['#asset-card'],
@@ -1682,7 +1903,7 @@ function applySopSections(sections, fields){
});
// The section chips are a table of contents for the form, so they have to lose
// the same entries.
if(typeof buildSectionNav === 'function') buildSectionNav();
if(typeof buildSectionRail === 'function') buildSectionRail();
// A package already on screen is re-rendered, or the view keeps showing a
// section the form no longer has.
if(currentView === 'Package View' && _lastRenderedPkg) renderPackage(_lastRenderedPkg);
@@ -1750,9 +1971,11 @@ const WP_NAV_CRITICAL_CSS = `
/* Modals are hidden by a stylesheet rule; without it their contents render inline
in the middle of the form. Same reasoning as the panel: this is a floor. */
.modal-overlay:not(.open),.cmt-overlay:not(.open){display:none!important;}
/* The jump bar's sticky offset is set inline by JS; give it a sane default so a
stale stylesheet can't park it behind the opaque header. */
.section-nav-bar{position:sticky;top:48px;z-index:30;background:#fff;}
/* F6/D3: the rail must not paint over the form before the real sheet lands,
and a section must never be stuck collapsed if that sheet never arrives.
Same reasoning as the panel above: this is a floor, not an override. */
.sec-rail[hidden]{display:none;}
.card.collapsed > .card-body[hidden]{display:none;}
`;
function injectWpNavCriticalCss(){
@@ -2757,9 +2980,6 @@ function initAutosave(){
// the truth about. The bar is rendered by the app, so mount when it exists.
const mountHost = document.querySelector('#sticky-save .sticky-status') || document.querySelector('#sticky-save');
if(mountHost) WPAutosave.mountIndicator(mountHost);
// Section changes are a "you have visibly moved on" moment, so flush rather than
// wait out the debounce.
document.querySelectorAll('.sec-chip').forEach(c=>c.addEventListener('click', ()=>WPAutosave.flush('section')));
offerDraftRecovery();
}
@@ -2935,6 +3155,12 @@ function bootData(){
document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='');
syncToolTabs();
}
// F6/D3: which section you are in is state, so Back moves between sections
// as well as between packages. fromUrl, because the URL already says this -
// recording it again would make the first Back appear to do nothing.
if(state.section && currentView !== 'Dashboard'){
gotoSection(state.section, {fromUrl:true});
}
});
}
initAutosave();

View File

@@ -99,8 +99,10 @@
<!-- RELEASE READINESS BANNER -->
<div class="release-banner" id="release-banner"></div>
<!-- SECTION NAV (jump links, built from the form cards) -->
<div class="section-nav-bar" id="section-nav"></div>
<!-- F6/D3: the jump-link strip stood here. It was a row of `<span onclick>` chips
that scrolled you somewhere inside a 5,399px page and then told you nothing
about where you had landed. The replacement is a real rail, declared below the
form so it can be a sticky column beside it at desk width. -->
<div class="wp-layout">
@@ -225,7 +227,29 @@
WP_FIELD_NODES addresses. -->
<div class="field" id="field-costCode"><label>Cost code</label><select id="wp_cost"></select><div class="field-hint sop-hint">Acumatica cost codes</div></div>
<div class="field" id="field-acumaticaTask"><label>Acumatica task</label><input type="text" id="wp_wbs" placeholder="Acumatica task no."></div>
<!-- Moved up from the second grid by T7.2. The specification section is
classification, not assignment: it is read-only and filled from the WP
type on the SOP, so it belongs beside Subject and Type. -->
<div class="field"><label>Specification section</label>
<input type="text" id="wp_spec" readonly class="locked-field" placeholder="set on the WP type in the SOP">
<div class="field-hint" id="spec-folder-link"></div></div>
</div>
<div class="field field-grid col1"><div class="field"><label>Description</label><textarea id="wp_desc" rows="2" placeholder="Short summary of the package"></textarea></div></div>
<div class="field field-grid col1" id="bimlink-wrap"><div class="field"><label>Enabled by — BIM package(s)<span class="help-tip" data-tip="Advanced Work Packaging traceability: link the BIM / model package(s) that enabled this install package. Paste the MWP number(s) or a link to the model package.">i</span></label><input type="text" id="wp_bimlink" placeholder="e.g. MWP07-FAB-CONDUITS, or a link to the model package"></div></div>
</div>
<!-- ASSIGNMENT & SCHEDULE (F6 / T7.2)
Split out of General Information, which was 1,288px on its own and the
whole of the gap between the page at rest and F6's two-screen bar. The
split is presentational: BOTH cards are the CR-006 section `general`
(WP_SECTION_NODES.general lists both), so the shared section registry in
wp-sections.js is untouched, the SOP wizard still shows one toggle, and
turning General Information off still hides every field it hid before.
CR-001 requires the P6 activity to sit beside the due date. Both are here,
adjacent, and tests/generalinfo_check.py asserts the adjacency. -->
<div class="card" id="assign-card">
<div class="sub-heading">Assignment &amp; Schedule</div>
<div class="field-grid">
<div class="field"><label>Owner <span class="help-tip" data-tip="The accountable owner (a user account on this project). Assigning notifies them by email if email notifications are enabled in the admin console.">i</span></label><select id="wp_assignee"><option value="">— Unassigned —</option></select></div>
<div class="field"><label>Assignees<span class="help-tip" data-tip="The crew and staff working this package. Pick from the project team named on the SOP; anyone without a user account can still be added by name.">i</span></label>
@@ -254,12 +278,7 @@
<input type="text" id="wp_p6_id" placeholder="e.g. A1234"></div>
<div class="field"><label>P6 activity description</label>
<input type="text" id="wp_p6_desc" placeholder="what that activity covers"></div>
<div class="field"><label>Specification section</label>
<input type="text" id="wp_spec" readonly class="locked-field" placeholder="set on the WP type in the SOP">
<div class="field-hint" id="spec-folder-link"></div></div>
</div>
<div class="field field-grid col1"><div class="field"><label>Description</label><textarea id="wp_desc" rows="2" placeholder="Short summary of the package"></textarea></div></div>
<div class="field field-grid col1" id="bimlink-wrap"><div class="field"><label>Enabled by — BIM package(s)<span class="help-tip" data-tip="Advanced Work Packaging traceability: link the BIM / model package(s) that enabled this install package. Paste the MWP number(s) or a link to the model package.">i</span></label><input type="text" id="wp_bimlink" placeholder="e.g. MWP07-FAB-CONDUITS, or a link to the model package"></div></div>
</div>
<!-- BIM / MODEL DETAILS (shown for BIM/VDC SOPs) -->
@@ -380,7 +399,12 @@
</div>
<!-- SIGN-OFFS -->
<div class="card">
<!-- The only .main > .card that had no id. buildSectionRail() filled the gap
positionally - `card.id = "sec-" + i` - and that id then rode into ?section=
as a shareable address. It moved every time the set of VISIBLE sections
changed: a CR-006 toggle, the BIM flag, or T7.2 inserting #assign-card ahead
of it. A link somebody sent then opened a different section, silently. -->
<div class="card" id="signoff-card">
<div class="sub-heading">Approvals & Sign-offs</div>
<div class="notice">Per the AWP IWP checklist. A package should be signed by these roles before release.</div>
<div class="table-wrap"><table><thead><tr><th style="width:220px">Role</th><th>Name</th><th style="width:150px">Date</th><th style="width:80px;text-align:center">Signed</th></tr></thead><tbody id="signoff-body"></tbody></table></div>
@@ -438,6 +462,25 @@
</div>
</div>
<!-- SECTION RAIL (F6 / D3)
Built by buildSectionRail() from the cards themselves, so a section added or
suppressed by a CR-006 toggle changes the rail without anybody maintaining a
second list. Every entry is a real <button>, the current one carries
aria-current, and each card's heading became a disclosure button with
aria-expanded - the two accessibility defects F6's old chip strip and
makeCollapsible() were carrying.
A sticky column beside the form at 1200px and up; a horizontal strip above it
below that, which is what fits at 390px. -->
<nav class="sec-rail" id="section-rail" aria-label="Form sections" hidden>
<div class="sec-rail-head">
<span class="sec-rail-title">Sections</span>
<button type="button" class="sec-rail-all" id="sec-expand-all" aria-pressed="false"
onclick="toggleExpandAll()">Expand all</button>
</div>
<ul class="sec-rail-list" id="sec-rail-list"></ul>
</nav>
</div>
<!-- HOLD LOG MODAL (comment 7) -->

View File

@@ -149,7 +149,10 @@
auto-hiding overlay drawer (see below) rather than a column, so it never takes
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. */
.wp-layout { display: block; width: 100%; margin: 0; }
/* F6/D3: flex, so the section rail declared AFTER the form in the markup can sit
ABOVE it at narrow widths (order:-1) and BESIDE it at desk width. Declaring it
after .main is what lets it be a sticky column without wrapping the layout. */
.wp-layout { display: flex; flex-direction: column; width: 100%; margin: 0; }
.main { min-width: 0; max-width: none; margin: 0;
padding: 22px 28px 72px calc(var(--nav-w,288px) + 28px);
transition: padding-left .18s ease; }
@@ -659,21 +662,132 @@
.so-date { font-size:13px; font-variant-numeric:tabular-nums; }
.so-ovr { margin-left:8px; font-size:11px; }
/* Collapsible form sections */
.collapse-chev { display:inline-block; width:1em; margin-right:7px; color:var(--text-muted); font-size:11px; user-select:none; }
.card.collapsed > :not(.section-header):not(.sub-heading) { display:none !important; }
.card.collapsed .section-desc { display:none; }
/* ── COLLAPSIBLE SECTIONS + SECTION RAIL (F6 / D3) ─────────────────────────
What stood here: a `.section-nav-bar` of `<span onclick>` jump chips, and a
`.card.collapsed > :not(.section-header):not(.sub-heading)` rule that hid a
card's contents without any element carrying the disclosure state. Neither
was reachable by keyboard, and neither said anything to a screen reader.
/* Section nav (jump chips) */
.section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px;
padding:8px 12px 8px calc(var(--nav-w,288px) + 28px); background:var(--wp-scrim-frosted); backdrop-filter:blur(4px);
border-bottom:1px solid var(--border); box-shadow:var(--wp-shadow-navbar);
transition:transform .22s ease; }
.section-nav-bar:empty{ display:none; }
.section-nav-bar.nav-hidden{ transform:translateY(-160%); }
.sec-chip{ font-size:12px; font-weight:600; color:var(--text-muted); background:var(--surface2);
border:1px solid var(--border); border-radius:14px; padding:4px 11px; cursor:pointer; white-space:nowrap; }
.sec-chip:hover{ border-color:var(--accent); color:var(--accent); }
Now: every heading is a real disclosure button, its contents are one
`.card-body` so `aria-controls` has a target, and the table of contents is a
rail of real buttons that marks where you are.
D3 amended F6's height criterion to "at rest". At rest one section is open,
which is what keeps this page under two screen heights; `Expand all` is a
deliberate choice to exceed it. */
/* The disclosure button lives INSIDE the existing heading, so .sub-heading's
trailing rule and the help tip beside it keep working untouched. */
.card-toggle {
display: inline-flex; align-items: center; gap: 9px;
background: none; border: 0; padding: 0; margin: 0;
font: inherit; color: inherit; text-align: left; cursor: pointer;
border-radius: var(--radius);
}
.card-toggle:hover { color: var(--accent); }
.card-toggle-label { min-width: 0; }
/* Drawn, not typed. A glyph here would be a fourth icon idiom on a page S6 is
already going to have to reconcile, and it would render differently per
platform - which is half of what S6 is about. */
.collapse-chev {
flex: 0 0 auto; width: 7px; height: 7px;
border-right: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: rotate(45deg) translate(-2px, -2px);
transition: transform .15s ease;
}
.card-toggle[aria-expanded="false"] .collapse-chev {
transform: rotate(-45deg) translate(-2px, 2px);
}
.card-body[hidden] { display: none !important; }
.card.collapsed .section-desc { display: none; }
/* A collapsed card is a heading, so it should read as a row rather than a box
with one line in it. */
/* A collapsed section should read as a ROW in a list, not as a box with one line
in it. Eleven of them at the card's own 28px padding is 660px of nothing -
which is a third of what F6 was measuring, arriving by a different door. */
.card.collapsed { padding-top: 10px; padding-bottom: 10px; }
.card.collapsed .section-header { margin-bottom: 0; padding-bottom: 0; border-bottom: 0; }
.card.collapsed .sub-heading { margin-bottom: 0; }
/* ── the rail ──
A horizontal strip above the form by default - which is what fits at 390px -
and a sticky column beside it from 1200px, where there is width to spare. */
.sec-rail {
order: -1;
position: sticky;
top: 0;
z-index: 30;
padding: 8px 28px 8px calc(var(--nav-w, 288px) + 28px);
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.sec-rail-head {
display: flex; align-items: center; gap: 12px;
margin-bottom: 6px;
}
.sec-rail-title {
font-family: var(--mono); font-size: 10px; font-weight: 600;
letter-spacing: .08em; text-transform: uppercase; color: var(--text-dim);
}
.sec-rail-all {
margin-left: auto;
background: none; border: 1px solid var(--border); border-radius: var(--radius);
padding: 3px 10px; font: inherit; font-size: 11px; color: var(--text-muted);
cursor: pointer;
}
.sec-rail-all:hover { border-color: var(--accent); color: var(--accent); }
.sec-rail-all[aria-pressed="true"] {
border-color: var(--accent); color: var(--accent); background: var(--accent-dim);
}
.sec-rail-list {
display: flex; flex-wrap: wrap; gap: 4px;
list-style: none; margin: 0; padding: 0;
}
.sec-rail-item {
display: block; width: 100%;
background: none; border: 1px solid transparent; border-radius: var(--radius);
padding: 7px 11px; font: inherit; font-size: 12px; font-weight: 500;
color: var(--text-muted); cursor: pointer; text-align: left; white-space: nowrap;
min-height: 34px;
}
.sec-rail-item:hover { color: var(--accent); border-color: var(--border); }
/* Not colour alone: the current entry is bolder, keeps a left marker and is the
one carrying aria-current. */
.sec-rail-item.is-current {
color: var(--accent); font-weight: 700;
background: var(--accent-dim);
border-color: var(--accent-dim);
box-shadow: inset 2px 0 0 0 var(--accent);
}
@media (max-width: 1199px) {
/* Horizontal strip: the entries sit side by side and the list scrolls rather
than stacking eleven full-width rows above the form. */
.sec-rail-list { flex-wrap: nowrap; overflow-x: auto; }
.sec-rail-item { width: auto; }
}
@media (min-width: 1200px) {
.wp-layout { flex-direction: row; align-items: flex-start; }
.main { flex: 1 1 auto; }
.sec-rail {
order: 0;
flex: 0 0 224px;
align-self: flex-start;
top: var(--sec-rail-top, 48px);
max-height: calc(100vh - var(--sec-rail-top, 48px));
overflow-y: auto;
margin: 22px 28px 0 0;
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.sec-rail-list { flex-direction: column; flex-wrap: nowrap; }
.sec-rail-item { white-space: normal; }
}
/* ── SOP-inherited marker ───────────────────────────────────────────────────
The "from SOP types" subtext used to sit under the field. It's now a small