diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md index 9aee245..d8f5f35 100644 --- a/docs/reference/file-map.md +++ b/docs/reference/file-map.md @@ -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 diff --git a/docs/waves/backlog.md b/docs/waves/backlog.md index 65d8edc..98031b7 100644 --- a/docs/waves/backlog.md +++ b/docs/waves/backlog.md @@ -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 diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index 8a91e62..7ee0171 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -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 ``; there were two left in the app +// - collapsing was a click listener on a heading `
`, 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 , 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 = '' + + ''; + 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 => + '
  • ').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(`${esc(label)}`); - }); - 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(); diff --git a/html/wp-creation-index.html b/html/wp-creation-index.html index 644c6de..a4d41c1 100644 --- a/html/wp-creation-index.html +++ b/html/wp-creation-index.html @@ -99,8 +99,10 @@
    - -
    +
    @@ -225,7 +227,29 @@ WP_FIELD_NODES addresses. -->
    Acumatica cost codes
    + +
    + +
    +
    + +
    + + +
    +
    Assignment & Schedule
    @@ -254,12 +278,7 @@
    -
    - -
    -
    -
    @@ -380,7 +399,12 @@ -
    + +
    Approvals & Sign-offs
    Per the AWP IWP checklist. A package should be signed by these roles before release.
    RoleNameDateSigned
    @@ -438,6 +462,25 @@
    + + + diff --git a/html/wp-creation-styles.css b/html/wp-creation-styles.css index 53b89df..ce0f4d4 100644 --- a/html/wp-creation-styles.css +++ b/html/wp-creation-styles.css @@ -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 `` 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 diff --git a/tests/form_structure_check.py b/tests/form_structure_check.py new file mode 100644 index 0000000..d2a8ddc --- /dev/null +++ b/tests/form_structure_check.py @@ -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 + # "" 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 . 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"]*onclick", code)) + print(" 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