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();