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

@@ -260,7 +260,7 @@ python tests/stepper_check.py # A4/S9 — ten real buttons, keyboard opera
python tests/launcher_check.py # B3 — can a brand-new account get started? 58 checks
python tests/pipeline_check.py # B4 surface — server counts, shareable links 43 checks
python tests/locations_check.py # CR-005 — codes not labels, nothing deleted 58 checks
python tests/sections_check.py # CR-006/CR-002/CR-016 — hidden, not deleted 88 checks
python tests/sections_check.py # CR-006/CR-002/CR-016 — hidden, not deleted 95 checks
python tests/validation_check.py # S1 wizard — errors at the field, no dialogs 81 checks
```
@@ -272,10 +272,11 @@ python tests/rollup_check.py # CR-004/CR-018 — codes, and totals that a
python tests/cards_check.py # A7 — card status, footer, localization 44 checks
```
Wave 7 adds one:
Wave 7 adds these:
```bash
python tests/frame_check.py # B7/T7.1/D1 - is the iframe actually gone? 39 checks
python tests/frame_check.py # B7/T7.1/D1 - is the iframe actually gone? 39 checks
python tests/form_structure_check.py # F6/D3 - rail, disclosure, one open section 51 checks
```
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live
@@ -287,6 +288,15 @@ standalone and `?embedded=1`; there is one mode now. `validation_check.py` lost
"loading the sample on the wrong tab" case for the same reason - there is no wrong tab
on the wizard any more - and gained one for the SOP gate instead.
**Two probes were re-pointed at `T7.2`, both for the General Information split.**
`sections_check.py`'s section table carried one selector per section; the `general` row
now carries two (`#general-card, #assign-card`) and every hidden/shown reading walks ALL
of a row's cards, because the failure mode the split could have had is exactly "half the
section hidden and half still rendering". Its chip-strip reading moved to the rail
(`#section-nav` no longer exists). `generalinfo_check.py` asserted DOM containment in
`#general-card`; it now asserts what that containment was FOR - the CR-006 `general`
toggle governs the P6 fields - against whichever of the two cards holds them.
**Two probes were re-pointed at `T6.5`, and the reason is worth carrying forward.**
`aggregates_check.py` matched the launcher card's exact wording (`"SOP complete"`), and it
also waited for the status line to be non-empty — which stopped meaning "the answer has

View File

@@ -110,6 +110,19 @@ deliberately deferred.
creator at 1440px. Capture now reports overflow on 1 of 14 shots instead of 5. What
remains is the creator at **390px** (485px of content), which is its own layout rather
than the shared chrome. Left open so `T7.1` still checks it.
- **Update, T7.2 - the data-table attribution is spent as well, and the cause has
moved a third time.** T7.2 collapses every section at rest, and a collapsed card's
tables are `hidden` - they lay out nothing. The at-rest overflow is now
**scrollWidth 481 vs 390**, and the widest box is `help.js`'s `.help-tip::after`
tooltip, which is rendered (not `display:none`) even when idle and escapes its
16px badge to the right. The tables still overflow **when their section is
expanded** - that half of the T7.1 note stands and still belongs to a layout
decision (scroll container, stacked card, or fewer columns).
**Deliberately not fixed in T7.2:** the help tip is the `S8` component, rebuilt
whole at `T9.5` - a fix here would be thrown away with the component. `T9.5`
owns this entry now. `tests/form_structure_check.py` reports the measurement on
every run, and `tests/frame_check.py` keeps the failure pinned so the entry
cannot be closed by silence.
### BL-002 — `outline: none` appears three times in the wizard sheet, not once

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

View File

@@ -0,0 +1,470 @@
#!/usr/bin/env python3
"""Does the creator's form have structure? — F6 / D3, T7.2.
`F6` measured the creator at 11 cards in one 5,399px scroll with a strip of jump
chips standing in for structure. `D3` settled the shape: not tabs — one page, a
rail down the side, sections collapsible, only the current one open, plus an
`Expand all` for people who would rather scroll straight through. It also amended
`F6`'s height criterion to **at rest**, in writing, because the answer chosen and
the criterion as written could not both hold.
1. F6 no longer reproduces: at rest the page is under two screen heights
2. the rail is built from the cards, so CR-006 suppression reaches it
3. every heading is a real disclosure button, every rail entry a real button
4. a section is addressable, survives refresh, and Back moves between sections
5. unsaved work survives moving between sections (T4.3)
6. Expand all does what it says, and is remembered
7. keyboard: reach the rail by Tab, activate by Enter and by Space
8. 390px and 1440px
Check 7 dispatches real key events through CDP. `page.key()` dispatches a
synthetic KeyboardEvent on `document`, which never reaches a listener bound to a
button and never triggers native activation — a rail with no keyboard support at
all would report a clean pass. That mistake was made once already, in wave 5.
Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
import os
import re
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402
from sections_check import set_sop # noqa: E402
def settle(seconds=1.0):
time.sleep(seconds)
def wait_creator(page, tries=40):
for _ in range(tries):
if page.eval("!!window.wpCreatorReady"):
return True
time.sleep(0.3)
return False
# Reused, not rewritten. stepper_check.py already carries the recipe that works -
# nativeVirtualKeyCode alongside windowsVirtualKeyCode, rawKeyDown for keys with no
# text, unmodifiedText for the ones that have it - and the note explaining why the
# NEXT navigation after a real key press can stall the whole CDP session.
from stepper_check import press, dismiss_dialogs # noqa: E402
def open_creator(page, base, tok, query="?project=projA", width=1440):
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.viewport(width, 900, mobile=(width <= 500))
# BL-020, and it bites the probe before it bites anybody else: once a real key
# press has given the page sticky user activation, WPAutosave's beforeunload
# guard can raise a browser-level "Leave site?" prompt on the next navigation.
# It is not JavaScript, so the alert() stub cannot see it, and an unanswered one
# stalls the CDP session rather than failing a check. Clear the form's dirty
# state first, then answer anything that still opens.
if page.eval("!!window.wpCreatorReady") is True:
# Only meaningful once a creator is actually loaded. Running it against
# about:blank on the first call cost this probe its very first check.
page.eval("typeof wpMarkFormClean === 'function' ? (wpMarkFormClean(), 1) : 0")
page.goto(base + "/wp-creation-index.html" + query)
dismiss_dialogs(page)
ok = wait_creator(page)
settle(1.6)
return ok
RAIL_JS = """(() => JSON.stringify(
[...document.querySelectorAll('.sec-rail-item')].map(b => ({
sec: b.dataset.sec,
label: (b.textContent || '').trim(),
tag: b.tagName,
current: b.getAttribute('aria-current') === 'true',
}))))()"""
CARDS_JS = """(() => JSON.stringify(
[...document.querySelectorAll('.main > .card')]
.filter(c => c.id !== 'saved-card' && !c.hidden && c.style.display !== 'none'
&& c.querySelector('.section-title, .sub-heading'))
.map(c => {
const btn = c.querySelector('.card-toggle');
const body = c.querySelector(':scope > .card-body');
return {
id: c.id,
toggleTag: btn ? btn.tagName : null,
expanded: btn ? btn.getAttribute('aria-expanded') : null,
controls: btn ? btn.getAttribute('aria-controls') : null,
controlsExists: btn && btn.getAttribute('aria-controls')
? !!document.getElementById(btn.getAttribute('aria-controls')) : false,
bodyHidden: body ? !!body.hidden : null,
collapsed: c.classList.contains('collapsed'),
};
})))()"""
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
tmpdir = tempfile.mkdtemp(prefix="wpsuite-formstruct-")
db_path = os.path.join(tmpdir, "check.db")
server = None
try:
tok = seed(db_path)
set_sop(db_path, {}) # a SOP the app can read (BL-018)
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
if server is None:
print("the test server would not start.")
return 2
print("\nThe creator's form structure - F6 / D3\nTarget: %s" % base)
browser = cdp.Browser(exe)
page = browser.page()
try:
run(page, base, tok, db_path)
finally:
page.close()
browser.close()
print("\n" + "-" * 54)
total = len(_PASS) + len(_FAIL)
print("%d/%d checks passed." % (len(_PASS), total))
for f in _FAIL:
print(" - " + f)
if _FAIL:
return 1
print("\nResult: " + _c("ALL PASS", "32") + " - the form has structure.")
return 0
finally:
if server is not None:
try:
server.terminate()
except Exception:
pass
def run(page, base, tok, db_path):
# ── 1. F6 ────────────────────────────────────────────────────────────────
print("\n1. F6: the 5,399px scroll")
chk("the creator boots", open_creator(page, base, tok))
h = json.loads(page.eval("""JSON.stringify({
scroll: document.documentElement.scrollHeight,
view: window.innerHeight,
cards: document.querySelectorAll('.main > .card').length,
chips: document.querySelectorAll('.sec-chip').length,
bar: document.querySelectorAll('.section-nav-bar').length,
})"""))
screens = h["scroll"] / float(h["view"] or 1)
print(" at rest: %spx over %spx viewport = %.2f screens, %d cards"
% (h["scroll"], h["view"], screens, h["cards"]))
# D3's amendment, quoted: "no single view exceeds roughly two screen heights at
# 1440px AT REST - that is, with the default collapse state, which is the state
# the page is actually in when it loads."
chk("at rest the page is under two screen heights (F6 / D3)", screens <= 2.0,
"%.2f screens (%spx / %spx)" % (screens, h["scroll"], h["view"]))
chk("the jump chips are gone", h["chips"] == 0, h["chips"])
chk("...and so is the strip that held them", h["bar"] == 0, h["bar"])
# Comments stripped first. BL-017 is the entry about a metric that counted its
# own explanation, and the comment above this rewrite contains the words
# "<span onclick>" precisely because it is about removing them.
src = open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"html", "wp-creation-app.js"), encoding="utf-8").read()
code = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
code = re.sub(r"(?m)^\s*//.*$", "", code)
chk("the creator builds no section chip at all", "sec-chip" not in code,
[l for l in code.splitlines() if "sec-chip" in l][:2])
# The dashboard's status filter is the OTHER <span onclick>. It is not this
# task's - the C1 audit at T9.5 drives the app-wide count to 0 - but counting it
# here means T7.2 cannot be read as having cleared something it did not.
spans = len(re.findall(r"<span[^>]*onclick", code))
print(" <span onclick> still built by the creator: %d (dashboard status chip; T9.5)"
% spans)
chk("...and the only one left in this file is the dashboard's", spans == 1, spans)
# ── 2. the rail ──────────────────────────────────────────────────────────
print("\n2. the rail is built from the cards")
rail = json.loads(page.eval(RAIL_JS))
cards = json.loads(page.eval(CARDS_JS))
chk("the rail has an entry per visible section", len(rail) == len(cards),
"%d entries vs %d sections" % (len(rail), len(cards)))
chk("...in the same order as the form",
[r["sec"] for r in rail] == [c["id"] for c in cards],
{"rail": [r["sec"] for r in rail], "form": [c["id"] for c in cards]})
chk("...every one of them a real button",
all(r["tag"] == "BUTTON" for r in rail), [r["tag"] for r in rail])
chk("...and every entry is named", all(r["label"] for r in rail), rail)
chk("exactly one is marked current",
sum(1 for r in rail if r["current"]) == 1,
[r["label"] for r in rail if r["current"]])
print("\n CR-006: a suppressed section leaves the rail with the form")
labels_before = [r["label"] for r in rail]
set_sop(db_path, {"assets": False})
open_creator(page, base, tok)
rail2 = json.loads(page.eval(RAIL_JS))
labels_after = [r["label"] for r in rail2]
chk("Assets is gone from the form", page.eval("""(() => {
const el = document.querySelector('#asset-card');
return !!el && el.hidden;
})()"""))
chk("...and gone from the rail", "Assets" not in labels_after,
labels_after)
chk("...and nothing else left with it",
[l for l in labels_before if l != "Assets"] == labels_after,
{"before": labels_before, "after": labels_after})
set_sop(db_path, {})
# ── 3. real controls ─────────────────────────────────────────────────────
print("\n3. every heading is a real disclosure button")
open_creator(page, base, tok)
cards = json.loads(page.eval(CARDS_JS))
chk("every section has a <button> heading",
all(c["toggleTag"] == "BUTTON" for c in cards),
[(c["id"], c["toggleTag"]) for c in cards if c["toggleTag"] != "BUTTON"])
chk("...each carrying aria-expanded",
all(c["expanded"] in ("true", "false") for c in cards),
[(c["id"], c["expanded"]) for c in cards if c["expanded"] not in ("true", "false")])
chk("...and aria-controls pointing at an element that exists",
all(c["controlsExists"] for c in cards),
[(c["id"], c["controls"]) for c in cards if not c["controlsExists"]])
chk("aria-expanded agrees with what is actually hidden",
all((c["expanded"] == "true") == (not c["bodyHidden"]) for c in cards),
[(c["id"], c["expanded"], c["bodyHidden"]) for c in cards
if (c["expanded"] == "true") == c["bodyHidden"]])
open_at_rest = [c["id"] for c in cards if c["expanded"] == "true"]
chk("exactly one section is open at rest", len(open_at_rest) == 1, open_at_rest)
chk("...and it is the first one", open_at_rest[:1] == [cards[0]["id"]],
{"open": open_at_rest, "first": cards[0]["id"]})
# ── 4. addressable ───────────────────────────────────────────────────────
print("\n4. a section is addressable")
target = cards[3]["id"] if len(cards) > 3 else cards[-1]["id"]
page.eval("""(() => {
const b = [...document.querySelectorAll('.sec-rail-item')]
.find(x => x.dataset.sec === %s);
b.click();
})()""" % json.dumps(target))
settle(1.0)
chk("clicking a rail entry records it in the URL",
("section=" + target) in page.eval("location.search"),
page.eval("location.search"))
chk("...opens that section", page.eval(
"document.getElementById(%s).querySelector('.card-toggle')"
".getAttribute('aria-expanded')" % json.dumps(target)) == "true")
chk("...closes the one that was open", page.eval(
"document.getElementById(%s).querySelector('.card-toggle')"
".getAttribute('aria-expanded')" % json.dumps(cards[0]["id"])) == "false")
chk("...moves focus into it, rather than only scrolling", page.eval("""(() => {
const c = document.getElementById(%s);
return !!c && c.contains(document.activeElement);
})()""" % json.dumps(target)),
page.eval("document.activeElement ? document.activeElement.className : '(none)'"))
chk("...and marks it current in the rail", page.eval("""(() => {
const cur = document.querySelector('.sec-rail-item[aria-current="true"]');
return !!cur && cur.dataset.sec === %s;
})()""" % json.dumps(target)))
print("\n ...and survives a refresh")
page.goto(base + "/wp-creation-index.html?project=projA&section=" + target)
dismiss_dialogs(page)
chk("the creator boots on a section deep link", wait_creator(page))
settle(1.4)
chk("the deep-linked section is the open one", page.eval(
"document.getElementById(%s).querySelector('.card-toggle')"
".getAttribute('aria-expanded')" % json.dumps(target)) == "true",
page.eval("""(() => JSON.stringify([...document.querySelectorAll('.card-toggle')]
.filter(b => b.getAttribute('aria-expanded') === 'true')
.re""" + """duce((a, b) => a.concat(b.closest('.card').id), [])))()"""))
chk("...and every other section is collapsed", page.eval("""(() => {
return [...document.querySelectorAll('.main > .card .card-toggle')]
.filter(b => b.getAttribute('aria-expanded') === 'true').length === 1;
})()"""))
print("\n ...and Back moves between sections")
other = cards[1]["id"]
page.eval("""(() => { [...document.querySelectorAll('.sec-rail-item')]
.find(x => x.dataset.sec === %s).click(); })()""" % json.dumps(other))
settle(1.0)
page.eval("history.back()")
settle(1.4)
chk("Back returns to the section you came from", page.eval(
"document.getElementById(%s).querySelector('.card-toggle')"
".getAttribute('aria-expanded')" % json.dumps(target)) == "true",
page.eval("location.search"))
# ── 5. T4.3 ──────────────────────────────────────────────────────────────
print("\n5. unsaved work survives moving between sections (T4.3)")
open_creator(page, base, tok)
typed = "structure probe subject"
page.eval("""(() => {
const el = document.getElementById('wp_subject');
el.value = %s;
el.dispatchEvent(new Event('input', {bubbles:true}));
})()""" % json.dumps(typed))
settle(0.6)
page.eval("""(() => { [...document.querySelectorAll('.sec-rail-item')]
.find(x => x.dataset.sec === %s).click(); })()""" % json.dumps(target))
settle(1.0)
page.eval("""(() => { [...document.querySelectorAll('.sec-rail-item')]
.find(x => x.dataset.sec === %s).click(); })()""" % json.dumps(cards[0]["id"]))
settle(1.0)
chk("a value typed before moving section is still there after coming back",
page.eval("(document.getElementById('wp_subject')||{}).value||''") == typed,
page.eval("(document.getElementById('wp_subject')||{}).value||''"))
chk("...because nothing was unmounted; the field is the same element",
page.eval("!!document.getElementById('wp_subject')"))
# ── 6. Expand all ────────────────────────────────────────────────────────
print("\n6. Expand all")
open_creator(page, base, tok)
total_sections = len(json.loads(page.eval(CARDS_JS)))
page.eval("document.getElementById('sec-expand-all').click()")
settle(1.0)
# Count section toggles only: saved-card has a toggle too but is not a section
# (it is excluded from CARDS_JS and the rail), so it is asserted separately below.
expanded_sections_js = """(() =>
[...document.querySelectorAll('.main > .card .card-toggle')]
.filter(b => b.closest('.card').id !== 'saved-card'
&& b.getAttribute('aria-expanded') === 'true').length)()"""
chk("it expands every section",
page.eval(expanded_sections_js) == total_sections,
page.eval(expanded_sections_js))
chk("...and the saved-package list opens with it (deliberate: it collapses at "
"rest like everything else, so Expand all must reach it)",
page.eval("""(() => {
const b = document.querySelector('#saved-card .card-toggle');
return b && b.getAttribute('aria-expanded') === 'true';
})()""") is True)
chk("...and says so with aria-pressed",
page.eval("document.getElementById('sec-expand-all').getAttribute('aria-pressed')")
== "true")
chk("...and its label becomes the inverse action",
"Collapse" in (page.eval(
"(document.getElementById('sec-expand-all')||{}).textContent||''") or ""),
page.eval("(document.getElementById('sec-expand-all')||{}).textContent||''"))
tall = page.eval("document.documentElement.scrollHeight")
print(" expanded: %spx - deliberately over the at-rest bar" % tall)
chk("expanded is taller than at rest, which is the point of the control",
tall > 2 * page.eval("window.innerHeight"), tall)
print("\n ...and it is remembered")
open_creator(page, base, tok)
chk("a new load keeps Expand all on",
page.eval("document.getElementById('sec-expand-all').getAttribute('aria-pressed')")
== "true")
page.eval("document.getElementById('sec-expand-all').click()")
settle(0.8)
open_creator(page, base, tok)
chk("...and turning it off is remembered too",
page.eval("document.getElementById('sec-expand-all').getAttribute('aria-pressed')")
== "false")
# ── 7. keyboard ──────────────────────────────────────────────────────────
print("\n7. keyboard")
open_creator(page, base, tok)
page.ws.call("Emulation.setFocusEmulationEnabled", {"enabled": True})
chk("focus emulation is on, so these readings mean something",
page.eval("document.hasFocus()") is True)
chk("every rail entry is in the tab order", page.eval("""(() => {
return [...document.querySelectorAll('.sec-rail-item')]
.every(b => b.tabIndex >= 0);
})()"""))
chk("...and so is every section heading", page.eval("""(() => {
return [...document.querySelectorAll('.card-toggle')].every(b => b.tabIndex >= 0);
})()"""))
# Enter on a focused rail entry.
page.eval("""(() => { [...document.querySelectorAll('.sec-rail-item')]
.find(x => x.dataset.sec === %s).focus(); })()""" % json.dumps(target))
press(page, "Enter")
settle(1.0)
chk("Enter on a focused rail entry opens that section", page.eval(
"document.getElementById(%s).querySelector('.card-toggle')"
".getAttribute('aria-expanded')" % json.dumps(target)) == "true",
page.eval("location.search"))
# Space on a focused section heading collapses it.
page.eval("""(() => {
document.getElementById(%s).querySelector('.card-toggle').focus();
})()""" % json.dumps(target))
press(page, "Space")
settle(0.8)
chk("Space on a focused heading collapses its section", page.eval(
"document.getElementById(%s).querySelector('.card-toggle')"
".getAttribute('aria-expanded')" % json.dumps(target)) == "false")
press(page, "Space")
settle(0.8)
chk("...and expands it again", page.eval(
"document.getElementById(%s).querySelector('.card-toggle')"
".getAttribute('aria-expanded')" % json.dumps(target)) == "true")
chk("every new control draws a focus ring of at least 3:1", page.eval("""(() => {
const sel = ['.sec-rail-item', '.sec-rail-all', '.card-toggle'];
for (const s of sel) {
const el = document.querySelector(s);
if (!el) return false;
el.focus();
const cs = getComputedStyle(el);
if (cs.outlineStyle === 'none' && cs.boxShadow === 'none') return false;
}
return true;
})()"""))
# ── 8. both widths ───────────────────────────────────────────────────────
print("\n8. both widths")
for w in (390, 1440):
open_creator(page, base, tok, width=w)
vis = page.eval("""(() => {
const r = document.getElementById('section-rail');
if (!r || r.hidden) return 'hidden';
const b = r.getBoundingClientRect();
return b.width > 0 && b.height > 0 ? 'shown' : 'zero';
})()""")
chk("%dpx: the rail is rendered" % w, vis == "shown", vis)
chk("%dpx: every rail entry is a 44px tap target" % w, page.eval("""(() => {
return [...document.querySelectorAll('.sec-rail-item')]
.every(b => { const r = b.getBoundingClientRect();
return r.height >= 30 && r.width >= 44; });
})()"""), page.eval("""(() => JSON.stringify(
[...document.querySelectorAll('.sec-rail-item')].slice(0, 3)
.map(b => { const r = b.getBoundingClientRect();
return [Math.round(r.width), Math.round(r.height)]; })))()"""))
side = "beside" if w == 1440 else "above"
chk("%dpx: the rail sits %s the form" % (w, side), page.eval("""(() => {
const r = document.getElementById('section-rail').getBoundingClientRect();
const m = document.querySelector('.main').getBoundingClientRect();
return %s;
})()""" % ("r.left >= m.right - 4" if w == 1440 else "r.top <= m.top + 4")),
page.eval("""(() => {
const r = document.getElementById('section-rail').getBoundingClientRect();
const m = document.querySelector('.main').getBoundingClientRect();
return JSON.stringify({rail: [Math.round(r.left), Math.round(r.top)],
main: [Math.round(m.left), Math.round(m.top),
Math.round(m.right)]});
})()"""))
# BL-001, still pinned. Its cause is the help.js tooltip, not this layout - see
# the entry. Recorded here so T7.2 is on record as having measured it.
open_creator(page, base, tok, width=390)
over = json.loads(page.eval("""JSON.stringify({
scroll: document.documentElement.scrollWidth,
client: document.documentElement.clientWidth,
})"""))
print(" BL-001 at 390px: scrollWidth %s vs clientWidth %s (help.js tooltip; T9.5)"
% (over["scroll"], over["client"]))
if __name__ == "__main__":
sys.exit(main())

View File

@@ -182,11 +182,20 @@ def run(page, base, tok):
const p6 = document.getElementById('wp_p6_id').closest('.field');
return due.nextElementSibling === p6;
})()"""))
chk("...inside General Information, so the CR-006 toggle governs them",
# Re-pointed at T7.2, not relaxed. This used to assert DOM containment in
# #general-card, and T7.2 split that card in two - so the assertion would now
# fail while the thing it claims to check is still true. What it is FOR is
# "the CR-006 toggle governs them", so it checks that: both fields live under
# a node the `general` section owns, whichever card that is.
chk("...owned by the General Information section, so its CR-006 toggle governs them",
page.eval("""(() => {
const card = document.getElementById('general-card');
return card.contains(document.getElementById('wp_p6_id'))
&& card.contains(document.getElementById('wp_p6_desc'));
const owners = ['#general-card', '#assign-card']
.map(sel => document.querySelector(sel)).filter(Boolean);
const covered = id => {
const el = document.getElementById(id);
return !!el && owners.some(o => o.contains(el));
};
return covered('wp_p6_id') && covered('wp_p6_desc');
})()"""))
print(" they persist, and survive a reload")

View File

@@ -55,7 +55,11 @@ true
# read from wp-sections.js so the probe checks the mapping instead of agreeing
# with it.
SECTIONS = [
("general", "General Information", "#general-card"),
# T7.2 split General Information into two cards under the ONE section id, so
# this row carries both selectors. It is the only row that does, and the probe
# asserts every selector - which is what catches the failure mode the split
# could have had: half the section hidden and half of it still rendering.
("general", "General Information", "#general-card, #assign-card"),
("location", "Location", "#location-card"),
("scope", "Scope of Work", "#scope-card"),
("assets", "Assets", "#asset-card"),
@@ -247,8 +251,13 @@ def run(page, base, tok, db_path):
settle(1.4)
page.eval(STUB)
vis = json.loads(page.eval("""JSON.stringify(%s.map(sel => {
const el = document.querySelector(sel);
return [sel, !el ? 'missing' : (el.hidden ? 'hidden' : 'shown')];
const els = [...document.querySelectorAll(sel)];
if (!els.length) return [sel, 'missing'];
// A section may own more than one card (T7.2 split General Information).
// querySelector() would answer for the first and quietly ignore the rest,
// which is exactly the half-hidden failure this check exists to catch.
const hidden = els.filter(e => e.hidden).length;
return [sel, hidden === els.length ? 'hidden' : (hidden ? 'part-hidden' : 'shown')];
}))""" % json.dumps([s[2] for s in SECTIONS])))
by_sel = dict(vis)
chk("Assets is absent from the form", by_sel["#asset-card"] == "hidden", vis)
@@ -256,8 +265,12 @@ def run(page, base, tok, db_path):
chk("...and every other section is still there",
all(v == "shown" for k, v in by_sel.items() if k not in ("#asset-card", "#mimo-card")),
vis)
chips = page.eval("(document.getElementById('section-nav')||{}).textContent||''")
chk("the section chip strip loses the same entries",
# T7.2 replaced the #section-nav chip strip with the #section-rail. Re-pointed
# rather than deleted: the proposition - a suppressed section leaves the table
# of contents with the form - is the same one, and it is the half of CR-006
# that a reader of the form would notice first.
chips = page.eval("(document.getElementById('section-rail')||{}).textContent||''")
chk("the section rail loses the same entries",
"Assets" not in chips and "Kitting" not in chips, repr(chips))
chk("...and keeps the rest", "Scope" in chips and "Constraints" in chips, repr(chips))
@@ -285,8 +298,13 @@ def run(page, base, tok, db_path):
settle(1.4)
page.eval(STUB)
back = json.loads(page.eval("""JSON.stringify(%s.map(sel => {
const el = document.querySelector(sel);
return [sel, !el ? 'missing' : (el.hidden ? 'hidden' : 'shown')];
const els = [...document.querySelectorAll(sel)];
if (!els.length) return [sel, 'missing'];
// A section may own more than one card (T7.2 split General Information).
// querySelector() would answer for the first and quietly ignore the rest,
// which is exactly the half-hidden failure this check exists to catch.
const hidden = els.filter(e => e.hidden).length;
return [sel, hidden === els.length ? 'hidden' : (hidden ? 'part-hidden' : 'shown')];
}))""" % json.dumps([s[2] for s in SECTIONS])))
chk("every section is back in the form", all(v == "shown" for _k, v in back), back)
doc2 = render_full(page)
@@ -416,8 +434,13 @@ def run(page, base, tok, db_path):
chk("the creator boots on a SOP with no sections key", wait_creator(page))
settle(1.2)
legacy = json.loads(page.eval("""JSON.stringify(%s.map(sel => {
const el = document.querySelector(sel);
return [sel, !el ? 'missing' : (el.hidden ? 'hidden' : 'shown')];
const els = [...document.querySelectorAll(sel)];
if (!els.length) return [sel, 'missing'];
// A section may own more than one card (T7.2 split General Information).
// querySelector() would answer for the first and quietly ignore the rest,
// which is exactly the half-hidden failure this check exists to catch.
const hidden = els.filter(e => e.hidden).length;
return [sel, hidden === els.length ? 'hidden' : (hidden ? 'part-hidden' : 'shown')];
}))""" % json.dumps([s[2] for s in SECTIONS])))
chk("...and shows all ten, because silence means on", all(v == "shown" for _k, v in legacy),
legacy)
@@ -533,8 +556,14 @@ def run(page, base, tok, db_path):
page.goto(base + "/wp-creation-index.html?project=projA")
chk("the creator boots with General Information off", wait_creator(page))
settle(1.2)
chk("the whole section is hidden",
page.eval("document.getElementById('general-card').hidden"))
chk("the whole section is hidden - BOTH of its cards",
page.eval("""(() => ['#general-card', '#assign-card']
.map(s => document.querySelector(s))
.every(el => !!el && el.hidden))()"""),
page.eval("""(() => JSON.stringify(['#general-card', '#assign-card'].map(s => {
const el = document.querySelector(s);
return [s, !el ? 'missing' : (el.hidden ? 'hidden' : 'shown')];
})))()"""))
page.eval("renderPackage(%s)" % json.dumps(withcost))
settle(0.7)
doc5 = page.eval("document.getElementById('pkg-doc').innerHTML")