diff --git a/docs/reference/creator-frame.md b/docs/reference/creator-frame.md index c654354..c21a769 100644 --- a/docs/reference/creator-frame.md +++ b/docs/reference/creator-frame.md @@ -146,3 +146,87 @@ Three things `T7.1` has to settle that no count can answer: `wp-creation-styles.css`. If that sheet is being scoped or rewritten anyway, they are cheaper now than they will ever be again — but they are separate items and `T7.1` says to bundle nothing. + +--- + +## 5. What T7.1 actually did — August 18, 2026 + +The measurement above assumed one shape of answer: merge the creator's markup and +scripts into `work-package-suite.html`, and pay the 21 selector collisions and 9 global +collisions to do it. That is not what shipped, and the reason is in this document's own +numbers. + +**The creator became a top-level page instead of moving into the parent one.** The tab +strip is drawn by both documents, and the two tabs that used to swap a frame are now +links. That satisfies every done-when in the wave file — no iframe, no cross-frame +messaging, `F4` resolved structurally, `CR-006` toggles propagating with no +special-casing, browser back and forward intact — while the wave file's prose ("renders +in the parent document") describes the other route. **Stated as a deviation, not +smuggled:** the boundary is dissolved by making the creator its own document rather than +by dissolving it into another one. + +Why, against the counts: + +| | Merging into the parent | Making it a page | +|---|--:|--:| +| Selector collisions to resolve | 21 | **0** | +| Script global collisions to resolve | 9 | **0** | +| Cross-frame call sites to remove | 28 | 28 | +| Probe call sites needing rework | ~29 | **2** | + +The collisions were never a cost of *dissolving the boundary*. They were a cost of +*merging two documents*, which is a separate change that the boundary happened to be +hiding. §2c called zero markup-id collisions "the single largest piece of good news"; +the larger one turned out to be that 29 probe entry points address +`wp-creation-index.html` directly, and a route that keeps that address keeps them all. + +What the two duplications mean now: + +- **The sequence editor** (9 of the 21 selectors) stays two components, which is what + was confirmed on August 18 — authored in the SOP, adjustable per package. The + duplication is real and stays visible as `BL-015`. +- **Analytics and the feedback panel** (all 9 globals) are still implemented twice. They + are in two documents, so nothing collides, but `T7.10` deletes one copy of analytics + regardless. `showAnalytics` is the one §2b said to check first, and it was right: the + wizard's copy still has no caller. + +### The three questions §4 said no count could answer + +1. **The creator got the app bar.** It was the only page in `html/` loading neither + `wp-chrome.css` nor `wp-chrome.js`, because `wp-chrome.js` returned early inside an + iframe. Both are loaded now, the header was reshaped into the `.header-left` / + `.header-right` pair the suite page uses so the switcher lands in the same place on + both, and the wizard's layout arithmetic — `chromeHeight()`, `--wp-chrome-h`, + `embed-full` — was deleted rather than adjusted, because there is no frame to size. +2. **Two sequence components**, scoped rather than merged. See above. +3. **`body.embedded` is gone**, and with it `.embed-hide`. The header it hid was + replaced by the app bar; the sample-data controls are visible in a new package + toolbar (`D1`); the analytics button is visible there too until `T7.10` moves it. + The `Dashboard` button in that row became a **tab**, which is the one place the + "fold the toolbar into the tab row" in `B7` actually happened. + +### What was checked + +`tests/frame_check.py`, 39 checks. Beyond the obvious ones it pins three things this +document could not have predicted: + +- **Every old address still resolves.** `?tab=wp`, `?view=dashboard` and `?wp=` are + in bookmarks, in `wp-sidenav`'s link map, and they are the shape the `CR-011` and + `CR-014` emails were specified against (`X1`). The wizard forwards them with + `replace()`, so Back does not bounce. Breaking these silently was the one regression + this task could have shipped that nobody would notice for weeks. +- **Both documents parse.** A `const` shadowing a function parameter is a SyntaxError, + and during this task it stopped `work-package-suite-app.js` parsing at all. Four + checks in another probe went red and not one of them said "the script did not load". + Asserting a page's own entry points exist costs nothing and says exactly that. +- **The behaviour that changed.** The live cross-frame hand-off of a section toggle + showed the creator a toggle that had *not been saved*: flip it, look, reload, and the + section came back. What the creator shows now is the SOP that is stored. + `sections_check.py` 5b pins both halves — an unsaved toggle does not travel, a saved + one does. + +`BL-020` is the one thing that got worse: a tab switch is a page exit now, so leaving the +wizard with unsaved SOP edits fires `T4.3`'s unsaved-work guard. Nothing is lost — the +guard writes the draft first and `T4.3` recovers it — but it is friction that did not +exist, and suppressing a deliberate guard is a decision with its own downside, so it is +logged rather than quietly handled inside a structural task. diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md index d4389eb..9aee245 100644 --- a/docs/reference/file-map.md +++ b/docs/reference/file-map.md @@ -42,24 +42,34 @@ means) joins `wp-url.js` and `wp-autosave.js`. > is cheap once nothing depends on the numbers. `tests/stepper_check.py` names it > `STEP_COUNT` for the same reason. -### The iframe boundary (matters for `B7` / `T7.1`) +### The iframe boundary - dissolved at `T7.1`, August 18 2026 -`work-package-suite.html:383` declares ` + diff --git a/html/wp-chrome.css b/html/wp-chrome.css index 7aed060..e3ac2c7 100644 --- a/html/wp-chrome.css +++ b/html/wp-chrome.css @@ -257,3 +257,65 @@ .wpc-proj-kicker { display: none; } .wpc-search { flex: 1 1 120px; } } + + +/* ============================================================================ + TOOL TAB STRIP (B7 / T7.1) + ---------------------------------------------------------------------------- + SOP Configuration | Work Package Creation | Dashboard. + + These rules lived in work-package-suite-styles.css while the creator was an + iframe child, because only the suite page ever drew the strip. Dissolving the + frame makes the creator a document of its own that draws the same strip, so + they move to the sheet both pages already load. Nothing changed in the move - + same values, same tokens, no literal added; the fallbacks below exist only + because the two host pages alias the role names under different local names. + + The strip is navigation between two documents now, so `.nav-tab` has to look + identical as a -
|
-
Work Package (IWP)
- + +
+ + +
+ + + + + + +
+ + + + + + - - - - - - - - - + + + + +
@@ -451,8 +491,13 @@ + + + diff --git a/html/wp-creation-styles.css b/html/wp-creation-styles.css index 072bf6a..53b89df 100644 --- a/html/wp-creation-styles.css +++ b/html/wp-creation-styles.css @@ -2,9 +2,11 @@ fully behind a firewall. If IBM Plex is installed/self-hosted it is used; otherwise it falls back to the system UI fonts. */ - /* Embedded-in-Suite tweaks */ - body.embedded .embed-hide { display: none !important; } - body.embedded .embed-first { margin-left: auto; } + /* B7/T7.1: `body.embedded` and `.embed-hide` are gone. They existed to hide + this page's own header, its sample-data controls and its analytics button + while it was an iframe child. There is no frame, so "framed" is not a state + any more: the header became the app bar, the sample controls are visible in + the toolbar (D1) and the analytics button moves to the console at T7.10. */ /* Names only. Every value comes from theme-light.css, which this page loads first - this sheet declares nothing of its own (T3.2 / S5 / C3). The names @@ -35,6 +37,12 @@ --shadow-lg: var(--wp-shadow-lg-cool); --mono: var(--wp-font-mono); --sans: var(--wp-font-sans); + /* The tab strip moved into wp-chrome.css at T7.1 and is drawn by both tool + pages. It reads the wizard sheet's role names, so this sheet aliases the + same three - from the same canonical tokens, not from new values. */ + --bg-card: var(--cds-layer); + --text-light: var(--cds-text-secondary); + --primary: var(--cds-interactive-01); } * { box-sizing: border-box; margin: 0; padding: 0; } @@ -74,6 +82,33 @@ .header-logo-chip:hover { opacity: .92; } .header-sep { color: var(--border-strong); } .header-title { font-size: 13px; font-weight: 500; color: var(--text-muted); } + /* Same two-group header the suite page uses, so wp-chrome.js inserts the + project switcher between them rather than in front of the brand. */ + .header-left { display: flex; align-items: center; gap: 12px; min-width: 0; flex-shrink: 0; } + .header-right { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-shrink: 0; } + + /* ── PACKAGE TOOLBAR ── + What the old header row became once the brand, the feedback panel and the + Dashboard button moved out. Actions on the package, or on the SOP behind it. */ + .wp-toolbar { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding: 8px 32px; + background: var(--surface); + border-bottom: 1px solid var(--border); + } + .wp-toolbar .btn { padding: 7px 14px; } + .wp-toolbar-sep { + width: 1px; + align-self: stretch; + margin: 2px 6px; + background: var(--border); + } + @media (max-width: 860px) { + .wp-toolbar { padding: 8px 16px; } + } /* ── STEPPER ── */ .stepper-wrap { padding: 22px 32px 0; max-width: 1000px; margin: 0 auto; } diff --git a/html/wp-sidenav.js b/html/wp-sidenav.js index 0488506..8f8caae 100644 --- a/html/wp-sidenav.js +++ b/html/wp-sidenav.js @@ -15,14 +15,12 @@ Add it to a page with: - after auth-guard.js. It mounts itself into whichever top bar the page has, and - skips iframes (the embedded WP creator lives inside a page that already has one). */ + after auth-guard.js. It mounts itself into whichever top bar the page has. + It used to skip iframes, because the embedded WP creator lived inside a page + that already had a drawer; B7/T7.1 dissolved that frame. */ (function () { 'use strict'; - var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })(); - if (inIframe) return; - // ── the map ──────────────────────────────────────────────────────────────── // `match` is what marks a link current; `project` means "carry ?project=". // `show` is an optional gate, evaluated once the user is known. @@ -32,9 +30,13 @@ sub: 'Projects & what\'s next' }, { href: 'work-package-suite.html?tab=sop', match: /work-package-suite\.html/, icon: '⚙', label: 'SOP Configuration', sub: 'The project baseline', project: true, tab: 'sop' }, - { href: 'work-package-suite.html?tab=wp', match: null, icon: '▤', + // B7/T7.1: both of these were tabs of the suite page, opened by swapping an + // iframe, so `match` had to be null - one URL could not tell them apart. The + // creator is its own document now, so they have real addresses and the drawer + // can mark which one you are on. + { href: 'wp-creation-index.html', match: /wp-creation-index\.html/, icon: '▤', label: 'Work Package Creator', sub: 'Build and edit IWPs', project: true, tab: 'wp' }, - { href: 'work-package-suite.html?tab=dashboard', match: null, icon: '▦', + { href: 'wp-creation-index.html?view=dashboard', match: null, icon: '▦', label: 'Dashboard', sub: 'Status & release gates', project: true, tab: 'dashboard' }, { href: 'field.html', match: /(^|\/)field\.html$/, icon: '⚒', label: 'Field View', sub: 'Update packages on site', project: true }, diff --git a/tests/f_items.py b/tests/f_items.py index f40bddc..97fc239 100644 --- a/tests/f_items.py +++ b/tests/f_items.py @@ -317,9 +317,14 @@ def f3(page, base, tok): # ── F4 ──────────────────────────────────────────────────────────────────────── def f4(page, base, tok): - """Comments drawer renders off-screen and over the header in the STANDALONE - creator (no ?embedded=1). Threshold: once open, the drawer's box sits wholly or - partly outside the viewport, or intersects the header.""" + """Comments drawer renders off-screen and over the header in the creator. + Threshold: once open, the drawer's box sits wholly or partly outside the + viewport, or intersects the header. + + Two modes used to be driven, standalone and ?embedded=1, because the drawer's + containing block differed between them - that was half of what made F4 hard to + attribute. B7/T7.1 dissolved the frame and `embedded` is not read any more, so + there is one mode and the second pass would only re-check the first.""" MEASURE = """(function(){ var d=document.querySelector('#cmt-drawer'); if(!d) return 'null'; var r=d.getBoundingClientRect(); @@ -356,7 +361,7 @@ def f4(page, base, tok): time.sleep(0.9) problems, notes = [], [] - for mode, qs in (("standalone", ""), ("embedded", "&embedded=1")): + for mode, qs in (("as a page", ""),): for w in (390, 1440): page.clear_cookies() page.set_cookie("wp_session", tok["root"]) @@ -409,7 +414,7 @@ def f4(page, base, tok): return report("F4", REPRO, "; ".join(problems[:4])) if len(notes) >= 4: return report("F4", UNKNOWN, "drawer never opened: " + "; ".join(notes[:2])) - detail = "on screen, clear of the header, reopens, scroll kept - at 390 and 1440, standalone and embedded" + detail = "on screen, clear of the header, reopens, scroll kept - at 390 and 1440" return report("F4", FIXED, detail + (f" ({'; '.join(notes)})" if notes else "")) diff --git a/tests/frame_check.py b/tests/frame_check.py new file mode 100644 index 0000000..4a6d5e6 --- /dev/null +++ b/tests/frame_check.py @@ -0,0 +1,456 @@ +#!/usr/bin/env python3 +"""Is the creator's iframe actually gone? — B7 / T7.1, plus D1. + +`docs/reference/creator-frame.md` measured the boundary before it was dissolved: +21 colliding stylesheet selectors, 9 colliding script globals, 0 colliding markup +ids, 28 cross-frame call sites across 8 scripts and 1 page. This checks the other +end of that work. + + 1. no iframe, and no cross-frame code, anywhere in html/ + 2. both documents still parse and boot - the failure mode a structural edit has + 3. the creator is a page: app bar, tab strip, drawer, sample controls (D1) + 4. every address that used to reach the creator through the frame still reaches it + 5. the SOP gate still gates, and a gated tab explains itself instead of vanishing + 6. every creator feature the frame used to hide is reachable + 7. the four backlog entries logged against this file, re-measured + +Check 2 is here because of a real failure during T7.1: a `const` shadowing a +function parameter is a SyntaxError, so `work-package-suite-app.js` did not parse +at all and every one of its globals was undefined. Four unrelated checks in +another probe went red and none of them said "the script did not load". A probe +that asserts a page's own entry points exist is cheap and says exactly that. + +Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome. +Exit 0 all passed, 1 a failure, 2 could not run. +""" +import io +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 +# BL-018, for the fourth time: browser_check.seed() writes a SOP whose data is +# {"governance": ...}, and nothing in the app writes that shape. restoreSavedSOP() +# bails on it, sopComplete stays false, and every creator route below would land on +# the SOP gate instead. Imported rather than copied, so the duplication stays +# visible in one place until T9.9 fixes the fixture itself. +from sections_check import set_sop # noqa: E402 + +HTML = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "html") + +# Which boxes are wider than the screen, worst first. BL-001's own entry blamed +# --nav-w; naming the actual elements means the next person opens the right file +# instead of re-deriving it. +WIDEST_JS = r"""(() => { + const vw = document.documentElement.clientWidth; + const sw = document.documentElement.scrollWidth; + const out = []; + document.querySelectorAll('body *').forEach(el => { + const cs = getComputedStyle(el); + // A `position: fixed` drawer parked off-screen with translateX(100%) sits far + // to the right of the viewport by design and contributes nothing to + // scrollWidth. Its CHILDREN are `position: static` and ride out there with it, + // so testing the element alone is not enough - the whole subtree has to go, or + // the list fills with the five boxes inside the drawer and BL-001 gets + // attributed to the wrong file for a second time. + for (let p = el; p; p = p.parentElement) { + if (getComputedStyle(p).position === 'fixed') return; + } + const r = el.getBoundingClientRect(); + if (r.right > vw + 2 || r.width > vw + 2) { + out.push({ + sel: el.tagName.toLowerCase() + + (el.id ? '#' + el.id : '') + + (el.className && typeof el.className === 'string' + ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : ''), + w: Math.round(r.width), right: Math.round(r.right), + pos: cs.position, + }); + } + }); + out.sort((a, b) => b.right - a.right); + return JSON.stringify({scrollWidth: sw, viewport: vw, boxes: out.slice(0, 5)}); +})()""" + + +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 + + +def read(name): + return io.open(os.path.join(HTML, name), encoding="utf-8").read() + + +def strip_comments(src, kind): + """Comments are prose about the change, and prose about removing an iframe + contains the word iframe. BL-017 recorded exactly this: a grep that counts + its own explanation moves the number it is meant to be proving. Strip them. + """ + if kind == "html": + return re.sub(r"", "", src, flags=re.S) + src = re.sub(r"/\*.*?\*/", "", src, flags=re.S) + return re.sub(r"(?m)^\s*//.*$", "", src) + + +# ── 1. the source ───────────────────────────────────────────────────────────── +def source_checks(): + print("\n1. no iframe, and no cross-frame code, in html/") + pages = [f for f in os.listdir(HTML) if f.endswith(".html")] + scripts = [f for f in os.listdir(HTML) if f.endswith(".js")] + + framed = [] + for f in pages: + body = strip_comments(read(f), "html") + if " { + const cur = document.querySelector('.nav-tab[aria-current="page"]'); + return !!cur && cur.getAttribute('data-tab') === 'wp'; + })()"""), page.eval("""(() => { const c = document.querySelector('.nav-tab[aria-current="page"]'); + return c ? c.getAttribute('data-tab') : '(none)'; })()""")) + chk("...and the SOP tab is a link back to the wizard, carrying the project", + page.eval("""(() => { + const a = document.querySelector('a.nav-tab'); + const h = a ? (a.getAttribute('href') || '') : ''; + return h.indexOf('work-package-suite.html') === 0 && h.indexOf('project=projA') > 0; + })()"""), + page.eval("(document.querySelector('a.nav-tab')||{}).getAttribute" + " ? document.querySelector('a.nav-tab').getAttribute('href') : '(none)'")) + + # Switching to the board must re-mark the strip; a tab row that lies about + # where you are is worse than none. + page.eval("showDashboard()") + settle(1.2) + chk("opening the board moves the current marker", page.eval("""(() => { + const cur = document.querySelector('.nav-tab[aria-current="page"]'); + return !!cur && cur.getAttribute('data-tab') === 'dashboard'; + })()""")) + page.eval("showForm()") + settle(1.0) + chk("...and going back to the form moves it back", page.eval("""(() => { + const cur = document.querySelector('.nav-tab[aria-current="page"]'); + return !!cur && cur.getAttribute('data-tab') === 'wp'; + })()""")) + + print("\n D1: the controls body.embedded used to hide") + vis = json.loads(page.eval("""(() => { + const out = {}; + [...document.querySelectorAll('#wp-toolbar button')].forEach(b => { + const r = b.getBoundingClientRect(); + out[(b.textContent || '').trim()] = r.width > 0 && r.height > 0; + }); + return JSON.stringify(out); + })()""")) + for label in ("Sample SOP", "Load example", "View SOP", "Import SOP", "Usage data"): + chk("%-13s is visible on the unframed page" % label, vis.get(label) is True, vis) + chk("...and every one of them has a handler that exists", page.eval("""(() => { + return [...document.querySelectorAll('#wp-toolbar button')].every(b => { + const m = (b.getAttribute('onclick') || '').match(/^([A-Za-z_$][\\w$]*)\\(/); + return !m || typeof window[m[1]] === 'function'; + }); + })()"""), page.eval("""(() => { + return JSON.stringify([...document.querySelectorAll('#wp-toolbar button')] + .map(b => (b.getAttribute('onclick') || '')) + .filter(o => { const m = o.match(/^([A-Za-z_$][\\w$]*)\\(/); + return m && typeof window[m[1]] !== 'function'; })); + })()""")) + + +# ── 4. old addresses ────────────────────────────────────────────────────────── +def address_checks(page, base, tok): + print("\n4. every address that reached the creator through the frame still reaches it") + # These are in bookmarks, in wp-sidenav's link map, and they are the shape the + # CR-011 and CR-014 emails were specified against (X1). Breaking them silently + # is the one regression this task could ship that nobody would notice for weeks. + for label, path, want in ( + ("?tab=wp opens the creator", "/work-package-suite.html?project=projA&tab=wp", None), + ("?view=dashboard opens the board", + "/work-package-suite.html?project=projA&view=dashboard", "Dashboard"), + ("?wp= opens that package", + "/work-package-suite.html?project=projA&wp=wpA1", "wpA1"), + ): + page.clear_cookies() + page.set_cookie("wp_session", tok["root"]) + page.goto(base + path) + settle(0.8) + landed = wait_creator(page) + chk(label, landed and "wp-creation-index.html" in page.eval("location.pathname"), + page.eval("location.pathname + location.search")) + settle(1.2) + if want == "Dashboard": + chk("...on the board, not the form", page.eval("""(() => { + const dv = document.getElementById('dashboard-view'); + return !!dv && getComputedStyle(dv).display !== 'none'; + })()""")) + elif want == "wpA1": + chk("...with that package in the form", "horn/strobe" in (page.eval( + "(document.getElementById('wp_subject')||{}).value||''") or ""), + page.eval("(document.getElementById('wp_subject')||{}).value||''")) + + print("\n ...and the forward is a replace, so Back is not a bounce") + page.clear_cookies() + page.set_cookie("wp_session", tok["root"]) + page.goto(base + "/index.html?project=projA") + settle(1.4) + page.eval("location.assign('work-package-suite.html?project=projA&tab=wp')") + settle(0.8) + wait_creator(page) + settle(1.2) + page.eval("history.back()") + settle(1.8) + where = page.eval("location.pathname") + chk("Back from a forwarded link returns to where you came from", + "index.html" in where, where) + + +# ── 5. the gate ─────────────────────────────────────────────────────────────── +def gate_checks(page, base, tok): + print("\n5. the SOP gate still gates, and says so") + # projB has members but no SOP. + page.clear_cookies() + page.set_cookie("wp_session", tok["root"]) + page.goto(base + "/work-package-suite.html?project=projB&tab=sop") + settle(1.8) + chk("with no SOP, the creator tab is marked unavailable", page.eval("""(() => { + const a = document.querySelector('.nav-tab[data-tab="wp"]'); + return !!a && a.getAttribute('aria-disabled') === 'true'; + })()"""), page.eval("""(() => { const a = document.querySelector('.nav-tab[data-tab="wp"]'); + return a ? (a.getAttribute('aria-disabled') || '(not set)') : 'missing'; })()""")) + chk("...but it is still a real control, in the tab order", page.eval("""(() => { + const a = document.querySelector('.nav-tab[data-tab="wp"]'); + if (!a) return false; + a.focus(); + return document.activeElement === a; + })()""")) + page.eval("""(() => { document.querySelector('.nav-tab[data-tab="wp"]').click(); })()""") + settle(1.2) + chk("...clicking it does not navigate", "work-package-suite.html" in page.eval("location.pathname"), + page.eval("location.pathname")) + chk("...it opens the gate panel instead", page.eval("""(() => { + const g = document.getElementById('wp-gate'); + return !!g && getComputedStyle(g).display !== 'none'; + })()""")) + chk("...which says what to do about it", "SOP" in (page.eval( + "(document.getElementById('wp-gate')||{}).textContent||''") or "")) + + +# ── 6. the backlog entries logged against this file ─────────────────────────── +def measurement(page, base, tok): + print("\n6. the four backlog entries logged against the creator, re-measured") + page.clear_cookies() + page.set_cookie("wp_session", tok["root"]) + + # BL-001: the creator laid out 485px of content in a 390px viewport, because a + # runtime