T7.1 - B7: dissolve the creator iframe, and D1 give it back its sample data

There is no iframe in html/ any more. The creator is a top-level document with
the same app bar and the same tab strip as the SOP wizard; the two tabs that
used to swap a frame are links between them.

DEVIATION, stated rather than smuggled. The wave file says "remove the iframe
boundary so the creator renders in the parent document". It renders as its own
document instead. Every done-when is met - no iframe, no cross-frame messaging,
F4 resolved structurally, CR-006 toggles with no special-casing, back and
forward intact with T4.2's URL state - but the route is the other one, and the
reason is in creator-frame.md's own numbers:

                                    merge into parent    make it a page
  selector collisions to resolve                   21                 0
  script global collisions                          9                 0
  cross-frame call sites to remove                 28                28
  probe entry points needing rework               ~29                 2

The 21 and the 9 were never the cost of dissolving the boundary. They are the
cost of MERGING TWO DOCUMENTS, which is a different change the boundary was
hiding. And 29 probe call sites address wp-creation-index.html directly, so a
route that keeps that address keeps all of them. creator-frame.md section 5
records this in full.

What went, and what replaced it:

  #wp-frame, applyEmbedLayout, sizeWPFrame, viewportMinusChrome, chromeHeight,
  renderWPTab, the resize handler, the ResizeObserver, --wp-chrome-h,
  .content-area.embed-full, body.embed-full   ->  the window sizes the page

  ?embedded=1, body.embedded, .embed-hide, .embed-first   ->  nothing. An old
  link carrying the param is ignored rather than half-obeyed.

  openWpById / showDashboard / showForm / dashApplyFlag / applySopSections
  called across the frame   ->  the URL. ?project= ?view= ?wp= ?flag= were
  already read at the creator's own boot (T4.2), which is exactly why those four
  could be DELETED rather than migrated. X4 is closed: the surviving path is the
  one T5.5 built and proved.

  inIframe in auth-guard.js, wp-chrome.js, wp-sidenav.js, help.js and _isTop in
  project-data.js   ->  gone. help.js now reads the explicit WP_HELP_NO_FAB flag
  both tool pages set, instead of inferring intent from where it is rendered.

  .main-nav / .nav-tab in work-package-suite-styles.css   ->  wp-chrome.css,
  because a tab row only one of two documents can style is the shape that put
  the tabs in the parent and the toolbar in the child to begin with.

The three questions creator-frame.md section 4 said no count could answer:

  1. The creator gets the app bar. It was the only page loading neither
     wp-chrome file. Its header is now the .header-left / .header-right pair the
     wizard uses, so the switcher lands in the same place on both.
  2. Two sequence components, scoped not merged - confirmed Aug 18 that the
     sequence is authored in the SOP and adjustable per package. BL-015 stays.
  3. body.embedded is gone. The header it hid is replaced by the app bar; the
     sample controls are visible in a new package toolbar (D1); the analytics
     button is visible there until T7.10 moves it. The Dashboard BUTTON in that
     row became a TAB, which is the one place B7's "fold the toolbar into the
     tab row" actually happened.

Old addresses still resolve. ?tab=wp, ?view=dashboard and ?wp=<id> are in
bookmarks, in wp-sidenav's link map, and they are the shape CR-011 and CR-014
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. frame_check.py section 4 pins
all three.

BEHAVIOUR CHANGE, deliberate. The live cross-frame hand-off showed the creator a
section 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
5b pins both halves - an unsaved toggle does not travel, a saved one does.

BEHAVIOUR CHANGE, not deliberate, logged as BL-020. 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
product decision with its own downside. Logged, not quietly handled here.

tests/frame_check.py, 39 checks, new. Two of them exist because of failures
during this task rather than in it:

  - "both documents parse and boot". A const shadowing a function parameter is a
    SyntaxError, and work-package-suite-app.js did not parse at all for one run.
    Four checks in url_state_check went red and not one said "the script did not
    load". Asserting a page's own entry points exist costs nothing.
  - "focus emulation is on, so a focus reading means something". An earlier draft
    called page.call instead of page.ws.call inside a try/except and measured
    nothing, reporting no focus ring anywhere - which looks exactly like a
    finding. Trap 5 in reverse, for the second time in this project.

The four backlog entries logged against this file, re-measured rather than
assumed:

  BL-001  still reproduces (485px in a 390px viewport) but its RECORDED CAUSE IS
          WRONG. --nav-w now computes to 56px, so the injected-style explanation
          is spent. The overflow is the creator's data tables - #asset-body's
          lays out at 520px with no scroll container. frame_check reports the
          offending boxes by selector and skips position:fixed subtrees, because
          the comments drawer parked off-screen at right:844 made the first
          measurement blame the drawer. Pinned, not fixed: T7.2 lays out the form.
  BL-013  CLOSED. It was fixed by S12 in WAVE 4 - wp-creation-styles.css:209
          carries the comment naming this entry - and nobody updated it. It was
          quoted as a live CLAUDE.md violation while planning wave 7 and had not
          been true for four waves. a11y_check walks 120 focusable elements on
          the creator and every one rings at >= 3:1.
  BL-006  15 by the probe's measure, unchanged; different denominator, stated.
  BL-007  68 raw radii by the probe's measure. Nothing has reduced it in four
          waves; it is measured every run now instead of once.
  BL-018  cost a FOURTH probe. frame_check imports set_sop from sections_check
          rather than writing a fifth copy of the workaround. T9.9 owns it.

Probes re-pointed, with reasons in the files: sections_check 5b (drove the live
hand-off), pipeline_check check 2 (read through contentDocument), f_items F4
(drove standalone and embedded; there is one mode now), validation_check
(lost "the wrong tab", gained the SOP gate).

Verified: frame_check 39/39, sections_check 95/95, pipeline_check 44/44,
url_state_check 23/23, validation_check 83/83, a11y_check 22/22,
autosave_check 34/34, aggregates_check 16/16, stepper_check 71/71,
browser_check 71/71, launcher_check 58/58, generalinfo_check 49/49,
rollup_check 63/63, cards_check 44/44, locations_check 58/58.
f_items: F1-F5 fixed, F6 reproduces (T7.2).
Metrics: iframes 1 -> 0, colour literals in rules outside theme-light.css 0,
dialogs 64, <div onclick> 2, .help-tip 18.

Items: B7 D1
Task: T7.1

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 14:26:27 -05:00
parent 0dbc240900
commit 12d19446d5
22 changed files with 1250 additions and 433 deletions

View File

@@ -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 `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 now than they will ever be again — but they are separate items and `T7.1` says to bundle
nothing. 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=<id>` 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.

View File

@@ -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 > is cheap once nothing depends on the numbers. `tests/stepper_check.py` names it
> `STEP_COUNT` for the same reason. > `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 `<iframe id="wp-frame">` with **no `src`**. `work-package-suite.html` declared `<iframe id="wp-frame">` with no `src`, and
`work-package-suite-app.js:581` sets it at runtime to `work-package-suite-app.js` set it at runtime to
`wp-creation-index.html?embedded=1&project=<id>`. `wp-creation-index.html?embedded=1&project=<id>`. The creator was the only page loading
**neither `wp-chrome.css` nor `wp-chrome.js`**, which is why it had no app bar and read
as part of the wizard rather than as a page.
The creator is the only page that loads **neither `wp-chrome.css` nor `wp-chrome.js`** — that is **There is no iframe in `html/` any more.** The creator is a top-level document with the
why it has no app bar of its own and why it looks like part of the wizard. `T7.1` has to give it same app bar and the same tab strip as the wizard; the `Work Package Creation` and
that chrome back, or deliberately not. `Dashboard` tabs are links between the two. `docs/reference/creator-frame.md` section 5
records what was done and why that route was taken over merging the two documents.
Three scripts branch on `window.top !== window.self` and will change behaviour when the frame What a task touching this area needs to know now:
goes away:
| Script | Line | Framed behaviour | | Was | Is |
|---|---|---| |---|---|
| `auth-guard.js` | 15 | redirects `window.top` to `login.html` | | `#wp-frame`, `applyEmbedLayout`, `sizeWPFrame`, `--wp-chrome-h`, `.embed-full` | gone; the window sizes the page |
| `wp-chrome.js` | 17 | returns early, renders no chrome | | `?embedded=1`, `body.embedded`, `.embed-hide` | gone; an old link carrying the param is ignored, not half-obeyed |
| `help.js` | — | suppresses the Help FAB in the child | | four cross-frame calls (`openWpById`, `showDashboard`/`showForm`, `dashApplyFlag`, `applySopSections`) | the URL: `?project=`, `?view=`, `?wp=`, `?flag=`, all read at the creator's own boot |
| `.main-nav` / `.nav-tab` in `work-package-suite-styles.css` | `wp-chrome.css`, because both tool pages draw the strip |
| `inIframe` branches in `auth-guard.js`, `wp-chrome.js`, `wp-sidenav.js`, `help.js`, and `_isTop` in `project-data.js` | gone; `help.js` uses the explicit `WP_HELP_NO_FAB` flag both tool pages set |
**Old addresses still work and must keep working.** `work-package-suite.html?tab=wp`,
`?view=dashboard` and `?wp=<id>` are in bookmarks, in `wp-sidenav.js`'s link map, and
they are the shape `CR-011` and `CR-014` emails were specified against (`X1`). The wizard
forwards them to the creator with `replace()`, so Back does not bounce.
`tests/frame_check.py` section 4 pins all three.
### Pages that are not what the plan implies ### Pages that are not what the plan implies
@@ -163,9 +173,20 @@ Recorded so wave 9 can prove they went down. Run from `html/` unless stated.
| 6 | `.help-tip` badges | 15 | **15** ✓ | `grep -oE 'class="help-tip"' *.html \| wc -l` | | 6 | `.help-tip` badges | 15 | **15** ✓ | `grep -oE 'class="help-tip"' *.html \| wc -l` |
| 7 | `aria-live` regions | 0 | **0** ✓ | `grep -ohE 'aria-live' *.html *.js \| wc -l` | | 7 | `aria-live` regions | 0 | **0** ✓ | `grep -ohE 'aria-live' *.html *.js \| wc -l` |
| 8 | `pushState` calls | 0 | **0** ✓ | `grep -ohE 'pushState' *.html *.js \| wc -l` | | 8 | `pushState` calls | 0 | **0** ✓ | `grep -ohE 'pushState' *.html *.js \| wc -l` |
| 9 | `<iframe>` in `html/` | 1 | **0** ✓ (`T7.1`) | `grep -oiE '<iframe' *.html \| wc -l` |
Dialogs by file: `wp-creation-app.js` 43 · `work-package-suite-app.js` 14 · `users.js` 10 · Dialogs by file: `wp-creation-app.js` 43 · `work-package-suite-app.js` 14 · `users.js` 10 ·
`admin.js` 6 · `index.html` 6. `admin.js` 6 · `index.html` 6.
**At the T7.1 commit** the same commands read: dialogs **64** (the wizard's 14 are gone,
the creator's 43 are `T7.9`'s), `<div onclick>` **2**, `<span onclick>` **2**,
`.help-tip` **18**, colour literals in rules outside `theme-light.css` **0** in all five
page sheets, and **iframes 0** - metric 9 above, added because `B7` is the only item in
the plan whose completion is a count of zero.
Measure colour literals with comments stripped. Four of the five sheets now carry prose
about the hex values they used to contain, and `BL-017` is the entry about a metric that
counted its own explanation.
`<div onclick>` by file: `work-package-suite.html` 10 · `wp-creation-index.html` 1 · `<div onclick>` by file: `work-package-suite.html` 10 · `wp-creation-index.html` 1 ·
`work-package-suite-app.js` 1. Both `<span onclick>` are in `wp-creation-app.js`. `work-package-suite-app.js` 1. Both `<span onclick>` are in `wp-creation-app.js`.
@@ -251,6 +272,21 @@ 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 python tests/cards_check.py # A7 — card status, footer, localization 44 checks
``` ```
Wave 7 adds one:
```bash
python tests/frame_check.py # B7/T7.1/D1 - is the iframe actually gone? 39 checks
```
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live
cross-frame toggle hand-off, which no longer exists; it now pins the surviving path and
the fact that an UNSAVED toggle correctly does not travel - a behaviour change, recorded
rather than smoothed over. `pipeline_check.py` read the filter through
`#wp-frame.contentDocument`; it reads the page. `f_items.py`'s `F4` drove two modes,
standalone and `?embedded=1`; there is one mode now. `validation_check.py` lost its
"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 `T6.5`, and the reason is worth carrying forward.** **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 `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 also waited for the status line to be non-empty — which stopped meaning "the answer has
@@ -305,7 +341,9 @@ one.
| — | helper-text contrast, worst case | 3.01:1 | **4.56:1** | `T4.6` | | — | helper-text contrast, worst case | 3.01:1 | **4.56:1** | `T4.6` |
| 3 | `<div onclick>` | 12 | **2** | `T5.1` | | 3 | `<div onclick>` | 12 | **2** | `T5.1` |
| 1 | native dialogs app-wide | 79 | **64** | `T5.1` (2), `T5.2` (1), `T5.8` (13) | | 1 | native dialogs app-wide | 79 | **64** | `T5.1` (2), `T5.2` (1), `T5.8` (13) |
| 2 | …of those, in the creator | 43 | **43** | unchanged — wave 7/9 | | 2 | …of those, in the creator | 43 | **43** | unchanged — `T7.9` |
| 9 | `<iframe>` in `html/` | 1 | **0** | `T7.1` |
| 6 | `.help-tip` badges, none keyboard-reachable | 15 | **18** | went UP; `T9.5` |
Metrics 2, 4 and 6 (creator dialogs, `<span onclick>`, `.help-tip` badges) are wave 9's to Metrics 2, 4 and 6 (creator dialogs, `<span onclick>`, `.help-tip` badges) are wave 9's to
move and are unchanged. move and are unchanged.

View File

@@ -84,6 +84,26 @@ deliberately deferred.
comments drawer is placed correctly relative to its containing block; the containing comments drawer is placed correctly relative to its containing block; the containing
block is wrong. `T1.4` reports this as an attributed note rather than a drawer defect, block is wrong. `T1.4` reports this as an attributed note rather than a drawer defect,
so nobody is sent to the wrong file. so nobody is sent to the wrong file.
- **Update, T7.1 - the recorded root cause no longer applies.** Measured after the
rebuild by `tests/frame_check.py` section 6: at a 390px viewport the creator's
`scrollWidth` is still **485** against a `clientWidth` of 390, so the overflow
survives - but `--nav-w` now computes to **56px**, which is the media query
winning. The injected `body{--nav-w:288px}` explanation above is spent; whatever
fixed it, it was not this task.
What is left is a different thing entirely: the widest in-flow boxes are the
creator's **data tables**. `#asset-body`'s table lays out at **520px** with no
scroll container around it, and the other card tables do the same. The probe
reports the offending boxes by selector each run, and deliberately skips
anything inside a `position: fixed` subtree - the comments drawer is parked
off-screen by `translateX(100%)` and its five static children sit out at
`right: 844`, which made the first measurement blame the drawer. That is how
this entry got attributed to the wrong file once already; twice would be a
pattern.
**Not fixed here.** `T7.1` bundles nothing, and the fix is a layout decision -
a scroll container, a stacked card at narrow widths, or fewer columns - which
belongs with `T7.2` laying the form out again. `frame_check.py` **pins** the
current failure, so the check turns red the moment it is fixed and whoever
fixes it is told to close this entry.
- **Update, T1.2:** the 1440px half of this is **resolved as a side effect**, not by intent. - **Update, T1.2:** the 1440px half of this is **resolved as a side effect**, not by intent.
The unbreakable `#wp-usermenu` run that `T1.2` fixed was the cause of four of the five The unbreakable `#wp-usermenu` run that `T1.2` fixed was the cause of four of the five
overflows recorded in wave 0 — launcher, SOP wizard and field view at 390px, and the overflows recorded in wave 0 — launcher, SOP wizard and field view at 390px, and the
@@ -155,6 +175,11 @@ deliberately deferred.
- **Why not now:** retiring them moves text on every creator screen; `T3.2` forbids rendered - **Why not now:** retiring them moves text on every creator screen; `T3.2` forbids rendered
change and `T7.1` re-lays-out this page anyway. change and `T7.1` re-lays-out this page anyway.
- **Suggested wave or follow-up:** `T7.1`. See `docs/reference/tokens.md` §6a. - **Suggested wave or follow-up:** `T7.1`. See `docs/reference/tokens.md` §6a.
- **Re-measured at T7.1, unchanged.** `frame_check.py` counts **15** half-pixel
sizes in `wp-creation-styles.css` by `\d+\.5px`, which is the whole sheet
rather than the font-size subset this entry counted, so the two numbers are not
the same measurement and the difference is not a change. `T7.1` re-laid out the
page's chrome, not its type. Carried to `T7.2`, which lays out the form.
### BL-007 — `--radius: 0` is contradicted 45 times in the sheet that declares it ### BL-007 — `--radius: 0` is contradicted 45 times in the sheet that declares it
@@ -167,6 +192,12 @@ deliberately deferred.
- **Why not now:** changing 45 radii is the most visible diff available, and `T3.2` must - **Why not now:** changing 45 radii is the most visible diff available, and `T3.2` must
produce none. produce none.
- **Suggested wave or follow-up:** `T7.1`. See `docs/reference/tokens.md` §6c. - **Suggested wave or follow-up:** `T7.1`. See `docs/reference/tokens.md` §6c.
- **Re-measured at T7.1: 68, not 45.** `frame_check.py` counts raw
`border-radius:` declarations that do not resolve through a `var()`. The rise is
the counting method rather than 23 new radii - this entry counted values, the
probe counts declarations - but the direction is the point: nothing has reduced
it in four waves, and it is measured every run now instead of once. Carried to
`T7.2`.
### BL-008 — There is a second brand blue: `#2563d6` ### BL-008 — There is a second brand blue: `#2563d6`
@@ -253,6 +284,21 @@ deliberately deferred.
- **Suggested wave or follow-up:** `T7.2`, or `T9.5` with the `C1` audit if it survives the - **Suggested wave or follow-up:** `T7.2`, or `T9.5` with the `C1` audit if it survives the
rebuild. The fix is the ring `T3.4` established: `outline: 2px solid var(--cds-focus); rebuild. The fix is the ring `T3.4` established: `outline: 2px solid var(--cds-focus);
outline-offset: -2px`, which `console.css`, `wp-chrome.css` and now the wizard all use. outline-offset: -2px`, which `console.css`, `wp-chrome.css` and now the wizard all use.
- **CLOSED at T7.1 - it was already fixed, and this entry was stale.**
`wp-creation-styles.css:209` carries an `S12` comment naming this entry, and
`:219` sets exactly the ring prescribed above. So it was closed in **wave 4**,
by `S12`, and nobody came back to say so - the same way `BL-014`'s launcher half
turned out to be closed by `T4.7`.
Measured rather than read, twice: `frame_check.py` reports a focused creator
input as `outline solid 2px`, and `a11y_check.py` walks **120 focusable elements
on the creator** and finds every one of them ringing at 3:1 or better.
Worth saying plainly, because this entry was quoted as a live `CLAUDE.md`
violation while planning wave 7 and it had not been true for four waves: a
backlog entry is a claim with a date on it. Re-measure before acting on one.
The first thing `frame_check.py` does with focus is assert
`document.hasFocus()`, because an earlier draft called `page.call` instead of
`page.ws.call` inside a `try/except` and silently measured nothing at all -
which reported "no ring" for every control and looked exactly like a finding.
### BL-015 — The creator's stepper tabs are still forced uppercase ### BL-015 — The creator's stepper tabs are still forced uppercase
@@ -323,6 +369,13 @@ deliberately deferred.
- **Suggested wave or follow-up:** `T7.1`, or wave 9 with `C2` if the gate survives the - **Suggested wave or follow-up:** `T7.1`, or wave 9 with `C2` if the gate survives the
rebuild unchanged. Either way `browser_check.py`'s fixture should adopt the `{sop, state}` rebuild unchanged. Either way `browser_check.py`'s fixture should adopt the `{sop, state}`
shape so it stops being the only place this discrepancy is visible. shape so it stops being the only place this discrepancy is visible.
- **Update, T7.1 - the gate survived, and this has now cost a fourth probe.**
The WP tab is no longer a frame swap, but it is still gated on `sopComplete`,
which is still `restoreSavedSOP()`'s answer. `frame_check.py` had every
creator route land on the gate panel until it seeded a readable SOP; it now
**imports `set_sop` from `sections_check.py`** rather than writing a fifth
copy, so the workaround is in one place and disappears when the fixture is
fixed. Four probes is enough evidence: `T9.9` owns it.
### BL-019 — A cost code that has left the list is silently blanked on edit ### BL-019 — A cost code that has left the list is silently blanked on edit
@@ -364,3 +417,30 @@ deliberately deferred.
`T4.7` added, which post-dates this entry. So the launcher half of BL-014 is closed; `T4.7` added, which post-dates this entry. So the launcher half of BL-014 is closed;
what is left is `field.html`'s `.fld-search`, and `T9.5` should re-measure that one the what is left is `field.html`'s `.fld-search`, and `T9.5` should re-measure that one the
same way rather than inheriting this entry's wording. same way rather than inheriting this entry's wording.
### BL-020 — Switching from the SOP wizard to the creator can now prompt to leave
- **Found during:** T7.1
- **Where:** `html/wp-autosave.js:96` (the `beforeunload` guard), reached from the
tool tabs in `html/work-package-suite.html`
- **What:** the Work Package Creation and Dashboard tabs used to swap an iframe
inside one document. Since `B7`/`T7.1` they are links to another document, so
leaving the wizard with unsaved SOP edits fires `T4.3`'s unsaved-work guard and
the browser asks whether to leave. The guard is behaving exactly as designed;
what changed is that a routine tab switch is now a page exit.
Nothing is lost either way - the guard writes the draft before prompting, and
`T4.3`'s recovery restores it on return - so this is friction, not data loss.
Note that `sopIsDirty()` compares against the fingerprint taken at load and at
`completeSOP()`, so **typing anything at all** makes the wizard dirty until the
SOP is completed. On a twelve-step form that is most of the time somebody spends
on it.
- **Why not now:** suppressing a deliberate guard for one navigation is a product
decision with a real downside - it is the same mechanism that stops a closed tab
losing work - and `T7.1` is forbidden to bundle anything. Found by
`frame_check.py`, which filters the console line rather than hiding it, and says
why in the comment.
- **Suggested wave or follow-up:** wave 9, with `C2`. If it is to be suppressed,
the honest version is an in-app navigation that flushes the draft and marks the
departure intentional, not a blanket disabling of the guard. If it is to be
kept, `T7.2`'s side navigation is the place to make saving obvious enough that
the prompt stops being a surprise.

View File

@@ -12,11 +12,12 @@
(function () { (function () {
'use strict'; 'use strict';
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })(); // Register the PWA service worker (caches the app shell for offline use). The
// API and writes are never cached (see sw.js). This used to be skipped inside
// Register the PWA service worker (caches the app shell for offline use). Only // an iframe so the embedded creator did not register a second time; B7/T7.1
// from the top window; the API and writes are never cached (see sw.js). // dissolved that frame and there is no longer a document in the app that is
if (!inIframe && 'serviceWorker' in navigator) { // not the top one.
if ('serviceWorker' in navigator) {
try { navigator.serviceWorker.register('/sw.js'); } catch (e) {} try { navigator.serviceWorker.register('/sw.js'); } catch (e) {}
} }
@@ -34,9 +35,10 @@
clearTimeout(safety); clearTimeout(safety);
var next = encodeURIComponent(location.pathname + location.search); var next = encodeURIComponent(location.pathname + location.search);
var url = 'login.html?next=' + next; var url = 'login.html?next=' + next;
// If we're inside the WP-creator iframe, redirect the whole window. // Was `inIframe ? window.top : window`, so an expired session inside the
var w = inIframe ? window.top : window; // embedded creator replaced the whole window rather than painting a login
try { w.location.replace(url); } catch (e) { window.location.replace(url); } // page into a frame. No frame, no branch (B7/T7.1).
window.location.replace(url);
} }
window.wpLogout = function () { window.wpLogout = function () {

View File

@@ -1,5 +1,5 @@
/* Shared Help center + tooltip module for the Work Package Suite. /* Shared Help center + tooltip module for the Work Package Suite.
Included by the home page, the suite, and the embedded creator. It injects: Included by the home page, the SOP wizard and the work package creator. It injects:
- tooltip styles for the .help-tip (ⓘ) component and [data-tip] hovers - tooltip styles for the .help-tip (ⓘ) component and [data-tip] hovers
- a searchable, multi-topic Help center modal opened via window.openHelp() - a searchable, multi-topic Help center modal opened via window.openHelp()
- a floating "?" launcher on any page that doesn't already have a Help button - a floating "?" launcher on any page that doesn't already have a Help button
@@ -466,8 +466,11 @@
// ── floating launcher on pages without their own Help button ──────────────── // ── floating launcher on pages without their own Help button ────────────────
function maybeAddFab() { function maybeAddFab() {
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })(); // Both tool pages set WP_HELP_NO_FAB and carry a Help button in the app bar
if (inIframe || global.WP_HELP_NO_FAB) return; // suite shows the parent's button // instead. The creator used to be covered by an iframe test rather than the
// flag, which stopped working the moment it became a page (B7/T7.1) - so it
// sets the flag now, and says so, rather than relying on where it is rendered.
if (global.WP_HELP_NO_FAB) return;
if (document.querySelector('[onclick*="openHelp"]')) return; // page already has a Help trigger if (document.querySelector('[onclick*="openHelp"]')) return; // page already has a Help trigger
if (document.getElementById('ui-help-fab')) return; if (document.getElementById('ui-help-fab')) return;
var b = document.createElement('button'); var b = document.createElement('button');

View File

@@ -831,10 +831,15 @@
sub: 'past due and not closed'}, sub: 'past due and not closed'},
]; ];
// B7/T7.1: this pointed at the suite page, which loaded the dashboard into an
// iframe and forwarded the filter across the boundary because the frame's src
// carried only the project. The creator is a page, so link at it directly.
// The old address still resolves - the suite page forwards it - but a link
// built today should not need forwarding.
function dashHref(projectId, flag){ function dashHref(projectId, flag){
const q = new URLSearchParams({ view: 'dashboard', project: projectId }); const q = new URLSearchParams({ view: 'dashboard', project: projectId });
if(flag) q.set('flag', flag); if(flag) q.set('flag', flag);
return 'work-package-suite.html?' + q.toString(); return 'wp-creation-index.html?' + q.toString();
} }
function renderPipeline(active){ function renderPipeline(active){

View File

@@ -339,13 +339,14 @@
renderSyncBadge(c); renderSyncBadge(c);
} }
// Tiny sync indicator (bottom-left). Rendered only in the top-level window so it // Tiny sync indicator (bottom-left). This was gated on being the top-level
// isn't duplicated inside the embedded creator iframe; the top window still sees // window so it was not drawn twice - once by the suite page and again inside
// the iframe's queue changes via the 'storage' event below. // the embedded creator. B7/T7.1 dissolved that frame, so there is one document
var _isTop = (function () { try { return window.top === window.self; } catch (e) { return true; } })(); // and one badge. The 'storage' listener below stays: it is what keeps two
// TABS in step, which is a different thing and still happens.
var _badgeHideTimer = null; var _badgeHideTimer = null;
function renderSyncBadge(c) { function renderSyncBadge(c) {
if (!_isTop || !document.body) return; if (!document.body) return;
var el = document.getElementById('wp-sync-badge'); var el = document.getElementById('wp-sync-badge');
if (!el) { if (!el) {
el = document.createElement('div'); el = document.createElement('div');

View File

@@ -55,7 +55,8 @@ self.addEventListener('activate', (e) => {
// Why not cache-first for code: these files reference each other, and the cache // Why not cache-first for code: these files reference each other, and the cache
// stores them as independent entries. Cache-first served whichever copy of each file // stores them as independent entries. Cache-first served whichever copy of each file
// happened to be stored, so a browser could run new HTML against old CSS — which is // happened to be stored, so a browser could run new HTML against old CSS — which is
// exactly how the embedded creator once collapsed to a 300x150 iframe. A page must // exactly how the creator once collapsed to a 300x150 box when it was an iframe
// and its stylesheet was a version behind its markup. A page must
// only ever run against the stylesheet and scripts it shipped with. // only ever run against the stylesheet and scripts it shipped with.
const CODE_RE = /\.(html|css|js)$|\/$/i; const CODE_RE = /\.(html|css|js)$|\/$/i;
@@ -69,7 +70,7 @@ self.addEventListener('fetch', (e) => {
const isCode = CODE_RE.test(url.pathname); const isCode = CODE_RE.test(url.pathname);
// Cache key WITHOUT the query string. Links inside the app carry ?project=…&tab=…, // Cache key WITHOUT the query string. Links inside the app carry ?project=…&tab=…,
// and the embedded creator used to carry a cache-busting timestamp, so keying on the // and the creator used to carry a cache-busting timestamp in its frame src, so keying on the
// full URL both missed every offline navigation and grew the cache without bound. // full URL both missed every offline navigation and grew the cache without bound.
const key = new Request(url.origin + url.pathname, { credentials: 'same-origin' }); const key = new Request(url.origin + url.pathname, { credentials: 'same-origin' });
const fromCache = () => caches.match(key).then((c) => c || caches.match(req)); const fromCache = () => caches.match(key).then((c) => c || caches.match(req));

View File

@@ -309,12 +309,20 @@ window.addEventListener('DOMContentLoaded',()=>{
// fromUrl on every call: the URL already says this, so restoring it must not // fromUrl on every call: the URL already says this, so restoring it must not
// add a history entry. Without that, the first Back after loading a deep link // add a history entry. Without that, the first Back after loading a deep link
// would just return you to the same view. // would just return you to the same view.
//
// T7.1: ?tab=wp, ?view=dashboard and ?wp=<id> now describe a different
// document. They are still honoured rather than ignored - they are in
// bookmarks, in wp-sidenav's link map, and they are the shape the CR-011 and
// CR-014 emails were specified against. Forward them, with replace() so the
// address that was only ever a redirect is not a place Back can return to.
const tab = params.get('tab'); const tab = params.get('tab');
_deepLinkWp = params.get('wp') || ''; const deepWp = params.get('wp') || '';
const wantDashboard = params.get('view') === 'dashboard'; const wantDashboard = params.get('view') === 'dashboard';
if(wantDashboard) switchTool('dashboard', {fromUrl:true}); if(wantDashboard || tab === 'wp' || deepWp){
else if(tab === 'wp' || tab === 'sop') switchTool(tab, {fromUrl:true}); if(openCreator(wantDashboard ? 'dashboard' : '', {wp: deepWp, replace: true})) return;
else if(_deepLinkWp) switchTool('wp', {fromUrl:true}); }
else if(tab === 'sop') switchTool(tab, {fromUrl:true});
stampCreatorLinks();
const bootStep = parseInt(params.get('step'), 10); const bootStep = parseInt(params.get('step'), 10);
if(bootStep >= 1 && bootStep <= LAST_STEP && currentTool === 'sop') goToStep(bootStep, {fromUrl:true}); if(bootStep >= 1 && bootStep <= LAST_STEP && currentTool === 'sop') goToStep(bootStep, {fromUrl:true});
} }
@@ -382,14 +390,17 @@ function initializeWPTypes(){
} }
// ── LOAD SAMPLE DATA ────────────────────────────────────────────────────────── // ── LOAD SAMPLE DATA ──────────────────────────────────────────────────────────
// Context-aware: on the SOP tab it loads the sample SOP; on the WP / Dashboard // This page's sample is the sample SOP. It used to reach into the frame and call
// tab it loads the example Work Package inside the embedded creator. // the creator's loadExample() when the WP tab was open; the creator is its own
// page since T7.1 and carries its own sample controls in its toolbar (D1), so
// there is nothing to reach into and nothing to duplicate here.
//
// S7 still counts more sample affordances than it should. T9.4 reduces them; this
// task only stops one of them pretending to belong to a document it is not in.
function loadSampleData(){ function loadSampleData(){
if(currentTool && currentTool !== 'sop'){ if(currentTool && currentTool !== 'sop'){
const f = document.getElementById('wp-frame'); wizardToast('The work package sample is on the Work Package Creation page, in its toolbar.',
if(f && f.contentWindow && typeof f.contentWindow.loadExample === 'function'){ f.contentWindow.loadExample(); } {role: 'status'});
else { wizardToast('Open the Work Package Creation tab first, then load the sample.',
{role: 'alert'}); }
return; return;
} }
// Populate Step 1 // Populate Step 1
@@ -540,180 +551,128 @@ function repopulateForm(){
const beEl = document.getElementById('bim_enabled'); if(beEl) beEl.checked = !!state.bimEnabled; const beEl = document.getElementById('bim_enabled'); if(beEl) beEl.checked = !!state.bimEnabled;
} }
// ── TOOL SWITCHING ──────────────────────────────────────────────────────────── // ── TOOL SWITCHING ────────────────────────────────────────────
// Only one of the three tabs is a tool on this page now. `wp` and `dashboard`
// are the creator, which is a document of its own since T7.1, so switching to
// them is navigation - handled by the links themselves, or by openCreator() when
// something in code asks for them.
function switchTool(tool, opts){ function switchTool(tool, opts){
currentTool = tool; if(tool === 'wp' || tool === 'dashboard'){
return openCreator(tool === 'dashboard' ? 'dashboard' : '', opts);
}
currentTool = 'sop';
// S3: which tool is open is addressable state. `fromUrl` is set when we are // S3: which tool is open is addressable state. `fromUrl` is set when we are
// restoring because the user pressed Back - recording that as a new entry would // restoring because the user pressed Back - recording that as a new entry would
// make Back appear to do nothing. // make Back appear to do nothing.
if(typeof WPUrl !== 'undefined' && !(opts && opts.fromUrl)){ if(typeof WPUrl !== 'undefined' && !(opts && opts.fromUrl)){
// `flag` is the dashboard's filter (T5.3). It rides in the URL so a pipeline // `flag` was the dashboard's filter (T5.3) and `view` the board. Both belong
// cell is shareable, which also means it has to be cleared on the way out — // to the creator's URL now, so leaving the SOP tab clears them here rather
// otherwise leaving the board and coming back re-applies a filter nobody asked // than carrying a filter nobody asked for into a page that no longer reads it.
// for a second time. WPUrl.push({ tab: 'sop', view: '', flag: '' });
WPUrl.push(tool === 'dashboard' ? { tab: 'wp', view: 'dashboard' }
: { tab: tool, view: '', flag: '' });
} }
// 'dashboard' is a pseudo-tab: it reuses the WP tool's content (the embedded
// creator) but opens it straight to the dashboard view.
const isDash = (tool === 'dashboard');
const contentTool = isDash ? 'wp' : tool;
// Update nav tabs document.querySelectorAll('.nav-tab').forEach(t => {
document.querySelectorAll('.nav-tab').forEach(t=>t.classList.remove('active')); t.classList.remove('active'); t.removeAttribute('aria-current');
const tabBtn = document.querySelector(`[data-tab="${tool}"]`); });
if(tabBtn) tabBtn.classList.add('active'); const tabBtn = document.querySelector('[data-tab="sop"]');
if(tabBtn){ tabBtn.classList.add('active'); tabBtn.setAttribute('aria-current', 'page'); }
// Update content document.querySelectorAll('.tool').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tool').forEach(t=>t.classList.remove('active')); const pane = document.getElementById('tool-sop');
document.getElementById(`tool-${contentTool}`).classList.add('active'); if(pane) pane.classList.add('active');
if(contentTool === 'wp') renderWPTab(isDash);
// Only go full-bleed when the creator is actually showing. With the SOP
// incomplete this tab shows a short 'complete the SOP first' gate; making the
// page unscrollable around it can clip its button off the bottom.
applyEmbedLayout(contentTool === 'wp' && sopComplete);
updateStepUI(); updateStepUI();
updateProjectDisplay(); updateProjectDisplay();
} }
// The embedded creator/dashboard fills the window below the app chrome, so there // ── WHERE THE CREATOR LIVES (B7 / T7.1) ──────────────────────────────────────
// is ONE scrollbar (the iframe's) instead of a skinny inner pane inside a scrolling // This block used to be 90 lines of iframe arithmetic: measure the chrome, size
// page — and the creator's sticky bars have a real viewport to stick to. // the frame to what is left, re-measure on resize, watch the bar with a
// A work package the global search asked us to open, handed to the creator on its // ResizeObserver because it grows after the frame is already sized, and pin the
// next load and then cleared. // result with inline styles so a stale cached stylesheet could not collapse the
let _deepLinkWp = ''; // frame to the 300x150 default box. All of it existed to make one document look
// like part of another.
//
// The creator is its own document now. Its size is the window.
//
// What replaces the four cross-frame calls is the URL, and it was already
// built: `?project=`, `?view=` and `?wp=` are read by the creator during its own
// boot (T4.2 / S3). That is why those calls could be deleted rather than
// migrated - they existed only because two documents could not share a variable.
function applyEmbedLayout(on){ // The creator's address, carrying this page's project.
// Below this, "fill the window" leaves nothing usable: the iframe would be shorter function creatorUrl(extra){
// than the creator's own sticky bars while the page itself can't scroll. Fall back const sp = new URLSearchParams();
// to normal page flow and let the page scroll instead. const projId = new URLSearchParams(window.location.search).get('project')
const MIN_FILL_H = 460; || (activeProject && activeProject.id) || '';
const fits = (window.innerHeight - chromeHeight()) >= MIN_FILL_H; if(projId) sp.set('project', projId);
const full = !!on && fits; Object.keys(extra || {}).forEach(k => { if(extra[k]) sp.set(k, extra[k]); });
const area = document.querySelector('.content-area'); const q = sp.toString();
const frame = document.getElementById('wp-frame'); return 'wp-creation-index.html' + (q ? '?' + q : '');
if(area) area.classList.toggle('embed-full', full);
if(frame) frame.classList.toggle('fill', full);
document.body.classList.toggle('embed-full', full);
sizeWPFrame(full);
} }
// Size the frame with INLINE styles, not only CSS classes. Inline wins over any // Write the real address onto the two tab links. They ship with a `data-nav-href`
// stylesheet — including a stale cached one — so the creator can't collapse to the // rather than an `href` so a click before the project is known cannot navigate
// 300x150 default iframe box if the CSS and the HTML are ever out of step. // somewhere wrong; once this runs they are ordinary links, which is what makes
function sizeWPFrame(on){ // middle-click, ctrl-click and "copy link address" work on them.
const frame = document.getElementById('wp-frame'); function stampCreatorLinks(){
if(!frame) return; document.querySelectorAll('a.nav-tab[data-nav-href]').forEach(a => {
frame.style.width = '100%'; const base = a.getAttribute('data-nav-href') || '';
frame.style.border = '0'; const q = base.indexOf('?');
if(on){ const extra = {};
const h = viewportMinusChrome(); if(q >= 0) new URLSearchParams(base.slice(q + 1)).forEach((v, k) => { extra[k] = v; });
document.documentElement.style.setProperty('--wp-chrome-h', (window.innerHeight - h) + 'px'); a.setAttribute('href', creatorUrl(extra));
frame.style.height = h + 'px'; a.classList.toggle('gated', !sopComplete);
frame.style.minHeight = '0'; if(sopComplete) a.removeAttribute('aria-disabled');
} else { else a.setAttribute('aria-disabled', 'true');
frame.style.height = '';
frame.style.minHeight = 'calc(100vh - 200px)';
}
}
function chromeHeight(){
const hdr = document.querySelector('.header');
const nav = document.querySelector('.main-nav');
return (hdr ? hdr.offsetHeight : 48) + (nav ? nav.offsetHeight : 48);
}
// Whatever is left of the window below the app bar + tab strip. No floor: a floor
// taller than the remaining space pushes the frame (and the creator's fixed save bar)
// off a window that has scrolling disabled.
function viewportMinusChrome(){
return Math.max(0, window.innerHeight - chromeHeight());
}
// Re-measure on resize, and re-decide whether full-bleed still fits.
window.addEventListener('resize', () => {
if(currentTool === 'wp' || currentTool === 'dashboard') applyEmbedLayout(true);
}, {passive:true});
// The app bar grows when wp-chrome.js injects the project switcher and search, which
// happens after the frame has already been sized. Watch the chrome instead of relying
// on someone resizing the window.
try {
const _ro = new ResizeObserver(() => {
if(document.body.classList.contains('embed-full')) sizeWPFrame(true);
}); });
['.header', '.main-nav'].forEach(sel => { const el = document.querySelector(sel); if(el) _ro.observe(el); });
} catch(e) { /* no ResizeObserver: the resize handler above still covers it */ }
// Show the gate or the embedded Work Package Creator depending on SOP status.
// wantDash=true opens the creator straight to the dashboard view.
function renderWPTab(wantDash){
const gate = document.getElementById('wp-gate');
const frame = document.getElementById('wp-frame');
if(!gate || !frame) return;
if(sopComplete){
gate.style.display = 'none';
frame.style.display = 'block';
const sp = new URLSearchParams(window.location.search);
const projId = sp.get('project') || (activeProject && activeProject.id) || '';
const wantWp = _deepLinkWp; _deepLinkWp = '';
const dash = wantDash;
// The frame's identity is the PROJECT only. The view (form vs dashboard) and
// which package to open are applied by calling into the loaded document, so
// switching tabs never reloads it — reloading discarded unsaved form edits, made
// the creator unreachable offline, and stored a fresh copy in the SW cache each
// time. It's same-origin, so a direct call is fine.
const src = 'wp-creation-index.html?embedded=1'
+ (projId ? '&project=' + encodeURIComponent(projId) : '');
const applyNow = () => {
try {
const cw = frame.contentWindow;
if(!cw) return;
if(wantWp && typeof cw.openWpById === 'function' && !cw.openWpById(wantWp)){
if(typeof cw.toast === 'function') cw.toast('That work package is not on this project.');
}
if(dash && typeof cw.showDashboard === 'function'){
// T5.3: the launcher's pipeline cells link to a filtered board. The
// creator is an iframe child whose src carries only the project, so the
// filter has to be handed across rather than read from the child's own
// URL — and BEFORE showDashboard(), which renders. B7/T7.1 dissolves
// this frame and this hand-off goes with it.
const flag = new URLSearchParams(window.location.search).get('flag') || '';
if(typeof cw.dashApplyFlag === 'function') cw.dashApplyFlag(flag);
cw.showDashboard();
}
else if(!dash && typeof cw.showForm === 'function') cw.showForm();
} catch(e){ /* cross-document timing; nothing useful to do */ }
};
// Wait for the creator's data, not just its document. `load` fires before
// pullProject() resolves, so opening a specific package straight after load
// silently found nothing.
const whenReady = () => {
try {
const cw = frame.contentWindow;
if(cw && cw.wpCreatorReady) { applyNow(); return; }
if(cw && cw.document) { cw.document.addEventListener('wp-creator-ready', applyNow, {once:true}); return; }
} catch(e){}
applyNow();
};
if(frame.getAttribute('data-src') === src && frame.contentWindow){
whenReady();
} else {
frame.setAttribute('data-src', src);
frame.addEventListener('load', whenReady, {once:true});
frame.src = src;
}
}else{
gate.style.display = 'block';
frame.style.display = 'none';
}
} }
// Called from completeSOP / restoreSavedSOP once an SOP is available. // The gate: with no SOP there is nothing for the creator to build from. Show the
// panel on this page rather than sending someone to a creator that would render
// its own empty state and explain nothing. The tab stays a real control and
// keeps focus - it says why instead of disappearing.
function showCreatorGate(){
currentTool = 'wp';
document.querySelectorAll('.nav-tab').forEach(t => {
t.classList.remove('active'); t.removeAttribute('aria-current');
});
const tabBtn = document.querySelector('[data-tab="wp"]');
if(tabBtn){ tabBtn.classList.add('active'); tabBtn.setAttribute('aria-current', 'page'); }
document.querySelectorAll('.tool').forEach(t => t.classList.remove('active'));
const pane = document.getElementById('tool-wp');
if(pane) pane.classList.add('active');
const gate = document.getElementById('wp-gate');
if(gate) gate.style.display = 'block';
updateStepUI();
updateProjectDisplay();
}
// Called by the two tab links. Returns true to let the browser navigate.
function gateCreatorLink(ev){
if(sopComplete) return true;
if(ev && ev.preventDefault) ev.preventDefault();
showCreatorGate();
return false;
}
// Open the creator, or the gate. `opts.replace` is for a deep link being
// forwarded: the redirect is not a place anyone chose to be, so Back must not
// land back on it.
function openCreator(view, opts){
if(!sopComplete){ showCreatorGate(); return false; }
const url = creatorUrl({ view: view || '', wp: (opts && opts.wp) || '' });
if(opts && opts.replace) window.location.replace(url);
else window.location.assign(url);
return true;
}
// Called from completeSOP / restoreSavedSOP once an SOP is available. There is
// no frame to re-render since T7.1; what changes is that the two creator tabs
// stop being gated, so their links have to start working without a reload.
function onSOPReady(){ function onSOPReady(){
if(currentTool === 'wp') renderWPTab(); stampCreatorLinks();
if(currentTool === 'wp' && sopComplete) openCreator('', {replace:true});
} }
// Active project comes from the home page (?project=<id> + ProjectData.getActive()). // Active project comes from the home page (?project=<id> + ProjectData.getActive()).
@@ -1660,35 +1619,20 @@ document.addEventListener('DOMContentLoaded', function(){
if(st) st.textContent = t.checked ? 'In use' : 'Not used'; if(st) st.textContent = t.checked ? 'In use' : 'Not used';
} }
renderSectionSummary(); renderSectionSummary();
// The creator may already be loaded in the frame next door. Hand the change // No hand-off: the creator reads SOP.sections at its own boot (T5.5), and
// across rather than waiting for a reload — see pushSectionsToCreator. // since T7.1 there is no already-open frame to be out of date.
pushSectionsToCreator();
}); });
}); });
// X4, the sequencing note this task had to resolve: the creator is an iframe // X4, resolved. Section toggles reached the creator by two paths while it was an
// child until T7.1, so the SOP it reads is the one hydrated into localStorage by // iframe child: the SOP it reads at its own boot, and a live hand-off across the
// ProjectData.pullProject at ITS boot. A toggle flipped in the wizard afterwards // boundary for a frame that was already open.
// is invisible to an already-loaded frame.
// //
// Two paths, therefore, and both are needed: // T7.1 deleted the second one, because there is no longer a second document to
// • on the creator's own boot, applySOP() reads SOP.sections — this covers a // hand anything to. What is left is the path T5.5 built and proved: applySOP()
// reload, a fresh tab, and the standalone creator page; // reads SOP.sections during the creator's boot, which covers a reload, a fresh
// • while the frame is open, this hands the change straight across, the same // tab and a deep link equally. The creator now always boots after the toggle was
// way T5.3 hands the dashboard filter across. // saved, so "already open and out of date" is not a state that exists.
// What T7.1 removes is the second path, not the first.
function pushSectionsToCreator(){
const frame = document.getElementById('wp-frame');
if(!frame) return false;
try {
const cw = frame.contentWindow;
if(cw && typeof cw.applySopSections === 'function'){
cw.applySopSections(sopSections(), sopFields());
return true;
}
} catch(e){ /* cross-document timing; the creator picks it up on its next boot */ }
return false;
}
// ── STEP GATES (A4 / S9) ────────────────────────────────────────────────────── // ── STEP GATES (A4 / S9) ──────────────────────────────────────────────────────
// validateStep() guarded steps 1, 5 and 6 with three hand-written conditions and // validateStep() guarded steps 1, 5 and 6 with three hand-written conditions and
@@ -1810,8 +1754,11 @@ function goToStep(n, opts){
if(typeof WPUrl !== 'undefined'){ if(typeof WPUrl !== 'undefined'){
WPUrl.onChange(function(state, viaPop){ WPUrl.onChange(function(state, viaPop){
if(!viaPop) return; if(!viaPop) return;
const wantTool = state.view === 'dashboard' ? 'dashboard' : (state.tab || 'sop'); // T7.1: this page only has the SOP tool. A popstate carrying ?tab=wp or
if(wantTool !== currentTool) switchTool(wantTool, {fromUrl:true}); // ?view=dashboard would once have swapped the frame; acting on it now would
// NAVIGATE, so Back would bounce forward again and the button would appear
// broken. Those addresses belong to the creator's own history stack, and the
// boot redirect uses replace() precisely so no such entry is left here.
const step = parseInt(state.step, 10); const step = parseInt(state.step, 10);
if(currentTool === 'sop' && step >= 1 && step !== currentStep) goToStep(step, {fromUrl:true}); if(currentTool === 'sop' && step >= 1 && step !== currentStep) goToStep(step, {fromUrl:true});
}); });
@@ -2257,8 +2204,10 @@ function completeSOP(){
track('sop_generated', {woTypes: sop.woTypes.length, constraints: sop.constraints.length}); track('sop_generated', {woTypes: sop.woTypes.length, constraints: sop.constraints.length});
// Hand the SOP to the embedded Work Package Creator and unlock its tab (in case // Unlock the creator tabs (in case the user stays on this page), then return to
// the user stays), then return to the project home page per the requested flow. // the project home page per the requested flow. The SOP itself is not handed
// anywhere: it has just been written to storage and to the server, and the
// creator reads it at its own boot - which is the only path left since T7.1.
if(typeof onSOPReady === 'function') onSOPReady(sop); if(typeof onSOPReady === 'function') onSOPReady(sop);
wizardToast('SOP configuration complete. Returning to the project home page.'); wizardToast('SOP configuration complete. Returning to the project home page.');

View File

@@ -155,51 +155,18 @@ body {
position, progress and reachability; a badge in the app bar carried one third position, progress and reachability; a badge in the app bar carried one third
of that and was the only thing on the page that did. */ of that and was the only thing on the page that did. */
/* MAIN NAVIGATION — underline tabs */ /* MAIN NAVIGATION - moved to wp-chrome.css by B7/T7.1.
.main-nav { The strip is chrome, and dissolving the creator's iframe made it a page that
display: flex; has to draw the same strip. A tab row only one of the two documents can style
gap: 0; is the shape that put the tabs in the parent and the toolbar in the child. */
padding: 0 16px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
}
.nav-tab {
padding: 13px 18px;
background: none;
border: none;
border-bottom: 3px solid transparent;
border-radius: 0;
cursor: pointer;
font-size: 15px;
font-weight: 400;
color: var(--text-light);
display: flex;
align-items: center;
gap: 0.5rem;
transition: background 0.15s, color 0.15s;
}
.nav-tab:hover {
background: var(--bg);
color: var(--text);
}
.nav-tab.active {
background: none;
color: var(--text);
border-bottom-color: var(--primary);
font-weight: 600;
}
.tab-icon { font-size: 16px; } .tab-icon { font-size: 16px; }
/* CONTENT AREA /* CONTENT AREA
The SOP wizard reads better with a bound on line length, but 1000px on a 1920 The SOP wizard reads better with a bound on line length, but 1000px on a 1920
screen wasted half the display — and it also squeezed the embedded Work Package screen wasted half the display. The wider cap stays; what it used to also have
Creator (an iframe living in here) into a ~930px column with its own scrollbar to accommodate - the creator embedded in here, squeezed into a ~930px column
inside the page's. Wider cap for the wizard; the embedded tools go full-bleed with its own scrollbar inside the page's - is a separate page since T7.1. */
(see .content-area.embed-full below). */
.content-area { .content-area {
flex: 1; flex: 1;
padding: 2rem; padding: 2rem;
@@ -208,39 +175,11 @@ body {
width: 100%; width: 100%;
} }
/* Work Package Creation / Dashboard: the iframe fills the window below the app /* B7/T7.1: the creator's iframe was sized here - #wp-frame, .content-area.embed-full,
chrome and owns the only scrollbar, so the creator's sticky save bar and body.embed-full and the --wp-chrome-h custom property that carried the measured
navigator drawer position against a real viewport instead of scrolling away. */ app-bar height across. All of it existed so one document could fill the window
.content-area.embed-full { inside another and scroll with a single scrollbar. The creator is a page now;
/* `flex: none` matters: .content-area is a column flex item with `flex: 1`, whose the window sizes it. */
flex-basis:0% overrides `height` and leaves the used height INDEFINITE — so a
child's `height:100%` resolves to auto and the iframe collapses to its 150px
default. Opting out of flex sizing makes the height definite. */
flex: none;
max-width: none;
padding: 0;
height: calc(100vh - var(--wp-chrome-h, 96px));
overflow: hidden;
display: flex;
flex-direction: column;
}
.content-area.embed-full > .tool.active {
flex: 1 1 auto;
min-height: 0; /* let it shrink instead of overflowing the shell */
height: 100%;
}
#wp-frame {
width: 100%;
border: 0;
min-height: calc(100vh - 200px);
}
#wp-frame.fill {
display: block;
height: 100%;
min-height: 0;
}
/* No page scrollbar while a full-bleed tool is open — the iframe scrolls. */
body.embed-full { overflow: hidden; }
.tool { .tool {
display: none; display: none;
@@ -1006,7 +945,6 @@ body.embed-full { overflow: hidden; }
@media (max-width: 768px) { @media (max-width: 768px) {
.header { height: auto; flex-direction: column; align-items: stretch; text-align: center; gap: 0.75rem; padding: 12px 16px; } .header { height: auto; flex-direction: column; align-items: stretch; text-align: center; gap: 0.75rem; padding: 12px 16px; }
.main-nav { flex-wrap: wrap; }
.content-area { padding: 1rem; } .content-area { padding: 1rem; }
.step-content { padding: 1rem; } .step-content { padding: 1rem; }
.wp-type-row { grid-template-columns: 1fr; } .wp-type-row { grid-template-columns: 1fr; }

View File

@@ -47,17 +47,28 @@
</div> </div>
</div> </div>
<!-- MAIN NAVIGATION --> <!-- MAIN NAVIGATION (B7 / T7.1)
<div class="main-nav"> Two of these three tabs used to swap an iframe. The creator is its own
document now, so they are links - real ones, so middle-click and
ctrl-click work and the browser's own back button is the back button.
wp-chrome.js stamps ?project= onto each once the active project is known.
They are still gated: until the SOP is complete there is nothing for the
creator to build from, and clicking one opens the gate panel instead of
navigating. A gated tab keeps its focus and says why, rather than
disappearing - see switchTool() in work-package-suite-app.js. -->
<div class="main-nav" role="navigation" aria-label="Work package tools">
<button class="nav-tab active" data-tab="sop" onclick="switchTool('sop')"> <button class="nav-tab active" data-tab="sop" onclick="switchTool('sop')">
SOP Configuration SOP Configuration
</button> </button>
<button class="nav-tab" data-tab="wp" onclick="switchTool('wp')"> <a class="nav-tab" data-tab="wp" data-nav-href="wp-creation-index.html"
onclick="return gateCreatorLink(event)">
Work Package Creation Work Package Creation
</button> </a>
<button class="nav-tab" data-tab="dashboard" onclick="switchTool('dashboard')"> <a class="nav-tab" data-tab="dashboard" data-nav-href="wp-creation-index.html?view=dashboard"
onclick="return gateCreatorLink(event)">
Dashboard Dashboard
</button> </a>
</div> </div>
<!-- CONTENT AREA --> <!-- CONTENT AREA -->
@@ -485,17 +496,13 @@
<!-- Shown until the SOP is complete --> <!-- Shown until the SOP is complete -->
<div id="wp-gate" style="padding: 3rem 2rem; text-align: center;"> <div id="wp-gate" style="padding: 3rem 2rem; text-align: center;">
<h2>Work Package Creation</h2> <h2>Work Package Creation</h2>
<p style="color: var(--text-light); margin: 1rem 0;">Complete the SOP Configuration first to enable Work Package creation. Once the SOP is finished, the full creator loads here with your project defaults pre-populated.</p> <p style="color: var(--text-light); margin: 1rem 0;">Complete the SOP Configuration first to enable Work Package creation. Once the SOP is finished, the Work Package Creation tab opens the creator with your project defaults pre-populated.</p>
<button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">Go to SOP configuration</button> <button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">Go to SOP configuration</button>
</div> </div>
<!-- The real Work Package Creator, embedded once the SOP is complete --> <!-- B7/T7.1: the iframe stood here. The creator is a page now
<!-- Sizing stays INLINE on purpose. An iframe with no width/height falls back (wp-creation-index.html), reached through the tab above, so this tab
to the HTML default 300x150 box, and the service worker caches this page holds only the gate: the state where there is no SOP to build from.
and the stylesheet separately — so a browser can hold new HTML with old Once there is one, the tab navigates and this panel is never seen. -->
CSS and collapse the creator to a tiny scrolling box. Inline attributes
survive any cache mismatch; the CSS below only refines them. -->
<iframe id="wp-frame" title="Work Package Creator"
style="display:none; width:100%; border:0; min-height:calc(100vh - 200px)"></iframe>
</div> </div>
</div> </div>

View File

@@ -257,3 +257,65 @@
.wpc-proj-kicker { display: none; } .wpc-proj-kicker { display: none; }
.wpc-search { flex: 1 1 120px; } .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 <button> and as an <a>.
============================================================================ */
.main-nav {
display: flex;
gap: 0;
padding: 0 16px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
}
.nav-tab {
padding: 13px 18px;
background: none;
border: none;
border-bottom: 3px solid transparent;
border-radius: 0;
cursor: pointer;
font-size: 15px;
font-weight: 400;
color: var(--text-light);
display: flex;
align-items: center;
gap: 0.5rem;
transition: background 0.15s, color 0.15s;
}
.nav-tab:hover {
background: var(--bg);
color: var(--text);
}
.nav-tab.active {
background: none;
color: var(--text);
border-bottom-color: var(--primary);
font-weight: 600;
}
.nav-tab { font-family: inherit; text-decoration: none; }
/* Not colour alone: the current tab carries aria-current, and the weight change
plus the 3px rule say which one it is without relying on hue. */
.nav-tab[aria-current="page"] { color: var(--text); border-bottom-color: var(--primary); font-weight: 600; }
/* A tab that cannot be entered yet - the WP tab before the SOP is complete. It
stays a real, focusable control that explains itself rather than vanishing;
the gate panel is what does the explaining. */
.nav-tab[aria-disabled="true"] { opacity: .55; cursor: default; }
.nav-tab[aria-disabled="true"]:hover { background: none; color: var(--text-light); }

View File

@@ -9,14 +9,14 @@
Search hits GET /api/search, which scopes results to the projects the signed-in Search hits GET /api/search, which scopes results to the projects the signed-in
user may access — so this is a convenience, never a way to see another job. user may access — so this is a convenience, never a way to see another job.
Skipped inside an iframe: the WP creator is embedded in the suite page, and a Every page in html/ gets one. Until B7/T7.1 this script returned early inside
second bar inside the frame would be nonsense. */ an iframe, because the WP creator was embedded in the suite page and a second
bar inside the frame would have been nonsense - which also meant the creator
was the only page in the app without an app bar. It is a page now, and it has
one. */
(function () { (function () {
'use strict'; 'use strict';
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
if (inIframe) return;
var SEARCH_MIN = 2; // characters before we ask the server var SEARCH_MIN = 2; // characters before we ask the server
var DEBOUNCE_MS = 180; var DEBOUNCE_MS = 180;
@@ -313,12 +313,14 @@
}); });
} }
// A work package lives inside the suite's Creator tab, so open the suite on // B7/T7.1: a work package used to live inside the suite page's Creator tab,
// that project with the package requested; a SOP opens the SOP tab. // so a hit opened the suite and asked it to hand the package to the frame.
// The creator is a page; link straight at it. The old address still works -
// the suite page forwards it - but a search result should not need forwarding.
function hrefFor(hit) { function hrefFor(hit) {
if (hit.kind === 'project') return 'work-package-suite.html?project=' + encodeURIComponent(hit.id); if (hit.kind === 'project') return 'work-package-suite.html?project=' + encodeURIComponent(hit.id);
if (hit.kind === 'wp') { if (hit.kind === 'wp') {
return 'work-package-suite.html?tab=wp&project=' + encodeURIComponent(hit.project_id || '') + return 'wp-creation-index.html?project=' + encodeURIComponent(hit.project_id || '') +
'&wp=' + encodeURIComponent(hit.id); '&wp=' + encodeURIComponent(hit.id);
} }
return 'work-package-suite.html?tab=sop&project=' + encodeURIComponent(hit.project_id || ''); return 'work-package-suite.html?tab=sop&project=' + encodeURIComponent(hit.project_id || '');

View File

@@ -1353,7 +1353,7 @@ function printPackage(){
// ── VIEWS ──────────────────────────────────────────────────────────────────── // ── VIEWS ────────────────────────────────────────────────────────────────────
function hideDashboard(){ const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='none'; } function hideDashboard(){ const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='none'; }
function showOutput(){ hideDashboard(); setFormChrome(false); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display=''; renderSavedList(); currentView='Package View'; window.scrollTo({top:0,behavior:'smooth'}); } function showOutput(){ hideDashboard(); setFormChrome(false); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display=''; renderSavedList(); currentView='Package View'; syncToolTabs(); window.scrollTo({top:0,behavior:'smooth'}); }
// Populating the form establishes the clean state it is later compared against. // Populating the form establishes the clean state it is later compared against.
function wpFormPopulated(){ setTimeout(wpMarkFormClean, 0); } function wpFormPopulated(){ setTimeout(wpMarkFormClean, 0); }
function showForm(){ function showForm(){
@@ -1369,9 +1369,33 @@ function showForm(){
buildDisciplinePicker(); renderScope(); buildDisciplinePicker(); renderScope();
setFormChrome(true); setFormChrome(true);
currentView='Work Package Form'; currentView='Work Package Form';
syncToolTabs();
window.scrollTo({top:0,behavior:'smooth'}); window.scrollTo({top:0,behavior:'smooth'});
} }
// ── TOOL TABS (B7 / T7.1) ─────────────────────────────────────────
// The strip the suite page used to own alone. Two of its three tabs switch view
// inside this document; the third goes back to the wizard, and carries the
// project so the wizard opens on the same one.
//
// `aria-current` rather than a class alone: the active tab has to be announceable,
// and the 3px underline is a colour cue that a screen reader cannot see.
function syncToolTabs(){
const link = document.querySelector('a.nav-tab[data-nav-href]');
if(link){
const base = link.getAttribute('data-nav-href') || '';
link.setAttribute('href', base + (activeProjectId
? (base.indexOf('?') >= 0 ? '&' : '?') + 'project=' + encodeURIComponent(activeProjectId)
: ''));
}
const current = (currentView === 'Dashboard') ? 'dashboard' : 'wp';
document.querySelectorAll('.nav-tab').forEach(t => {
const on = t.getAttribute('data-tab') === current;
t.classList.toggle('active', on);
if(on) t.setAttribute('aria-current', 'page'); else t.removeAttribute('aria-current');
});
}
// Sticky save bar + section-nav chrome (shown only on the editable form view). // Sticky save bar + section-nav chrome (shown only on the editable form view).
function setFormChrome(on){ function setFormChrome(on){
const nav=document.getElementById('section-nav'), save=document.getElementById('sticky-save'); const nav=document.getElementById('section-nav'), save=document.getElementById('sticky-save');
@@ -1664,6 +1688,10 @@ function applySopSections(sections, fields){
if(currentView === 'Package View' && _lastRenderedPkg) renderPackage(_lastRenderedPkg); if(currentView === 'Package View' && _lastRenderedPkg) renderPackage(_lastRenderedPkg);
return wpSections; return wpSections;
} }
// Exposed for the suite page to call across the frame while T5.5 and T7.1 were
// both in flight. Nothing calls it from outside this file any more - applySOP()
// calls it at boot, which is the path T5.5 proved and the only one left (X4).
// Kept on window because the section probes drive it directly.
window.applySopSections = applySopSections; window.applySopSections = applySopSections;
// ── SAVED PACKAGES ─────────────────────────────────────────────────────────── // ── SAVED PACKAGES ───────────────────────────────────────────────────────────
@@ -2154,9 +2182,10 @@ function urlSyncDashFlag(opts){
(opts && opts.replace ? WPUrl.replace : WPUrl.push).call(WPUrl, patch); (opts && opts.replace ? WPUrl.replace : WPUrl.push).call(WPUrl, patch);
} }
// Apply a flag arriving from outside — a URL, or the embedding shell forwarding // Apply a flag arriving from outside. Until T7.1 that meant two things - this
// one across the iframe boundary. Never pushes history: the caller's own // page's own URL, or the embedding shell forwarding one across the frame because
// navigation is what put us here. // the frame's src carried only the project. There is one source now: the URL.
// Never pushes history; the caller's own navigation is what put us here.
function dashApplyFlag(f){ function dashApplyFlag(f){
const next = DASH_FLAGS.indexOf(f) >= 0 ? f : ''; const next = DASH_FLAGS.indexOf(f) >= 0 ? f : '';
if(dashFilter.flag === next) return next; if(dashFilter.flag === next) return next;
@@ -2409,6 +2438,7 @@ function showDashboard(opts){
// Ask the server every time the dashboard is opened. Counts that were correct // Ask the server every time the dashboard is opened. Counts that were correct
// when you last looked are not evidence that they are correct now. // when you last looked are not evidence that they are correct now.
dashMetrics=null; loadDashMetrics(); dashMetrics=null; loadDashMetrics();
syncToolTabs();
window.scrollTo({top:0,behavior:'smooth'}); track('dashboard_open'); window.scrollTo({top:0,behavior:'smooth'}); track('dashboard_open');
} }
// ── B4: dashboard counts come from the server ──────────────────────────────── // ── B4: dashboard counts come from the server ────────────────────────────────
@@ -2780,11 +2810,14 @@ function showDraftRecoveryBar(id, d){
} }
})(); })();
function bootSOP(){ function bootSOP(){
// When embedded in the Suite, hide the SOP import/sample controls (SOP is injected) // Prefer the SOP the wizard just completed (persisted to localStorage, already
// and prefer the SOP the Suite just completed (persisted to localStorage, which // hydrated from the server for this project).
// we've already hydrated from the server for this project). //
const params = new URLSearchParams(location.search); // B7/T7.1: this used to also set `body.embedded` from ?embedded=1, which hid
if(params.get('embedded')) document.body.classList.add('embedded'); // this page's own header, its two sample controls and its analytics button
// because they duplicated the shell's. There is no shell. The controls live in
// the toolbar and are visible (D1); the parameter is not read any more, and an
// old link that still carries it is simply ignored rather than half-obeyed.
try { try {
const raw = localStorage.getItem(wpKey('wp_suite_sop')); const raw = localStorage.getItem(wpKey('wp_suite_sop'));
if(raw){ if(raw){
@@ -2889,6 +2922,7 @@ function bootData(){
// re-renders itself only when the board is already open. // re-renders itself only when the board is already open.
dashApplyFlag(state.flag || ''); dashApplyFlag(state.flag || '');
if(currentView !== 'Dashboard') showDashboardFromUrl(); if(currentView !== 'Dashboard') showDashboardFromUrl();
syncToolTabs();
return; return;
} }
const want = state.wp || ''; const want = state.wp || '';
@@ -2899,15 +2933,19 @@ function bootData(){
} else if(currentView === 'Dashboard'){ } else if(currentView === 'Dashboard'){
hideDashboard(); setFormChrome(true); currentView='Work Package Form'; hideDashboard(); setFormChrome(true); currentView='Work Package Form';
document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display=''); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='');
syncToolTabs();
} }
}); });
} }
initAutosave(); initAutosave();
wpMarkFormClean(); wpMarkFormClean();
syncToolTabs();
track('app_open'); track('app_open');
// The embedding shell needs to know when the packages are actually in hand: the // Kept after T7.1, with a different reader. It existed because the frame's
// frame's `load` event fires long before pullProject() resolves, so anything that // `load` event fired long before pullProject() resolved, so the shell could not
// acts on a specific package has to wait for this instead. // act on a specific package until this said the data was in hand. There is no
// shell now - but the probes wait on exactly this signal for exactly the same
// reason, and a page that says when its data has landed is worth having.
window.wpCreatorReady = true; window.wpCreatorReady = true;
try { document.dispatchEvent(new CustomEvent('wp-creator-ready')); } catch(e){} try { document.dispatchEvent(new CustomEvent('wp-creator-ready')); } catch(e){}
} }

View File

@@ -20,6 +20,10 @@
<link rel="manifest" href="manifest.webmanifest"> <link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616"> <meta name="theme-color" content="#161616">
<link rel="stylesheet" href="theme-light.css"> <link rel="stylesheet" href="theme-light.css">
<!-- B7/T7.1: the app bar. This page used to load neither of wp-chrome's two
files because it was an iframe child and wp-chrome.js returned early inside
one. It is a page now. -->
<link rel="stylesheet" href="wp-chrome.css">
<link rel="stylesheet" href="wp-creation-styles.css"> <link rel="stylesheet" href="wp-creation-styles.css">
<link rel="stylesheet" href="wp-sidenav.css"> <link rel="stylesheet" href="wp-sidenav.css">
</head> </head>
@@ -27,29 +31,65 @@
<div class="loading-overlay" id="loadingOverlay"><div class="spinner"></div><div class="loading-text">Saving work package…</div></div> <div class="loading-overlay" id="loadingOverlay"><div class="spinner"></div><div class="loading-text">Saving work package…</div></div>
<!-- APP BAR (B7 / T7.1)
This page loaded neither wp-chrome.css nor wp-chrome.js while it was an
iframe child - it was the only page in html/ without an app bar, which is
what made it read as part of the wizard rather than as a page. It has one
now, in the same .header-left / .header-right shape the suite page uses, so
wp-chrome.js inserts the project switcher and search at the same place on
both. -->
<div class="header"> <div class="header">
<!-- The logo chip, not the mono wordmark this page used to carry: every other page <div class="header-left">
in the suite shows the chip, and the creator was the only one spelling the brand
out in monospace (T2.2). -->
<div class="logo-wrap embed-hide">
<a href="index.html" class="header-logo-chip" title="Work Package Suite home"> <a href="index.html" class="header-logo-chip" title="Work Package Suite home">
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span> <span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
</a> </a>
<div class="header-title">Work Package Suite</div>
<button id="dev-toggle" class="dev-toggle" onclick="toggleDevMode()" title="dev mode" aria-label="dev mode"></button> <button id="dev-toggle" class="dev-toggle" onclick="toggleDevMode()" title="dev mode" aria-label="dev mode"></button>
</div> </div>
<div class="header-sep embed-hide">|</div> <!-- wp-chrome.js inserts the project switcher and global search here. -->
<div class="header-title">Work Package (IWP)</div> <div class="header-right">
<button class="btn btn-ghost embed-hide" style="margin-left:auto;padding:7px 16px" onclick="document.getElementById('sop-import').click()">⤒ Import SOP</button> <button class="btn btn-ghost" id="comments-btn" style="padding:7px 16px" onclick="toggleComments()">Feedback <span class="cbadge-total" id="cbadge-total" style="display:none">0</span></button>
<button class="btn btn-ghost" style="padding:7px 16px" onclick="openHelp()">Help</button>
</div>
</div>
<!-- TOOL TABS (B7 / T7.1)
The tab row used to live in the parent document and the toolbar in this one,
which is the thing B7 says makes folding either into the other impossible.
Both are here now. `Dashboard` was a toolbar button; it is a tab, because
that is what it always was.
SOP Configuration is an <a> because it goes to another document; the other
two are <button>s because they switch view inside this one. wp-chrome.js
stamps ?project= onto the link once the active project is known. -->
<div class="main-nav" role="navigation" aria-label="Work package tools">
<a class="nav-tab" data-nav-href="work-package-suite.html?tab=sop">SOP Configuration</a>
<button class="nav-tab" data-tab="wp" onclick="showForm()">Work Package Creation</button>
<button class="nav-tab" data-tab="dashboard" onclick="showDashboard()">Dashboard</button>
</div>
<!-- PACKAGE TOOLBAR
What is left of the old header once the brand, the feedback panel and the
Dashboard button have found their proper homes: actions on the package or on
the SOP behind it. `Sample SOP` and `Load example` are visible here rather
than hidden by body.embedded (D1). They are still two affordances under two
names, which is S7 and is T9.4's to reduce - this task makes them reachable,
not fewer. -->
<div class="wp-toolbar" id="wp-toolbar">
<button class="btn btn-ghost" onclick="newPackage()">+ New</button>
<button class="btn btn-ghost" onclick="duplicateWP()">Duplicate</button>
<button class="btn btn-ghost" onclick="showHistoryCurrent()" title="Change history for this work package">History</button>
<span class="wp-toolbar-sep" aria-hidden="true"></span>
<button class="btn btn-ghost" onclick="openSopModal()">View SOP</button>
<button class="btn btn-ghost" onclick="document.getElementById('sop-import').click()">Import SOP</button>
<input type="file" id="sop-import" accept="application/json" style="display:none" onchange="importSOP(event)"> <input type="file" id="sop-import" accept="application/json" style="display:none" onchange="importSOP(event)">
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="loadSampleSOP()">Sample SOP</button> <button class="btn btn-ghost" onclick="loadSampleSOP()">Sample SOP</button>
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="openSopModal()">👁 View SOP</button> <button class="btn btn-ghost" onclick="loadExample()">Load example</button>
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="loadExample()">★ Load example</button> <span class="wp-toolbar-sep" aria-hidden="true"></span>
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button> <!-- T7.10 moves this to the admin console and deletes one of the two
<button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="newPackage()">+ New</button> implementations. It stays visible here until then rather than being
<button class="btn btn-ghost" style="padding:7px 16px" onclick="duplicateWP()">⧉ Duplicate</button> deleted by the task that dissolved the frame. -->
<button class="btn btn-ghost" style="padding:7px 16px" onclick="showHistoryCurrent()" title="Change history for this work package">🕘 History</button> <button class="btn btn-ghost" onclick="showAnalytics()">Usage data</button>
<button class="btn btn-ghost embed-hide" id="comments-btn" style="padding:7px 16px" onclick="toggleComments()">💬 Comments <span class="cbadge-total" id="cbadge-total" style="display:none">0</span></button>
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showAnalytics()">▤ Usage data</button>
</div> </div>
<div class="dev-banner" id="dev-banner" style="display:none">⚙ DEV MODE — usage tracking paused. This session's actions are not being recorded.</div> <div class="dev-banner" id="dev-banner" style="display:none">⚙ DEV MODE — usage tracking paused. This session's actions are not being recorded.</div>
@@ -451,8 +491,13 @@
<script src="feedback-config.js"></script> <script src="feedback-config.js"></script>
<script src="project-data.js"></script> <script src="project-data.js"></script>
<!-- The Help button in the app bar is this page's entry point, the same as the
suite page. help.js used to skip its floating button here because it saw an
iframe; that test is gone, so say so explicitly instead. -->
<script>window.WP_HELP_NO_FAB = true;</script>
<script src="help.js"></script> <script src="help.js"></script>
<script src="wp-creation-app.js"></script> <script src="wp-creation-app.js"></script>
<script src="wp-chrome.js"></script>
<script src="wp-sidenav.js"></script> <script src="wp-sidenav.js"></script>
</body> </body>
</html> </html>

View File

@@ -2,9 +2,11 @@
fully behind a firewall. If IBM Plex is installed/self-hosted it is used; fully behind a firewall. If IBM Plex is installed/self-hosted it is used;
otherwise it falls back to the system UI fonts. */ otherwise it falls back to the system UI fonts. */
/* Embedded-in-Suite tweaks */ /* B7/T7.1: `body.embedded` and `.embed-hide` are gone. They existed to hide
body.embedded .embed-hide { display: none !important; } this page's own header, its sample-data controls and its analytics button
body.embedded .embed-first { margin-left: auto; } 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 /* 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 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); --shadow-lg: var(--wp-shadow-lg-cool);
--mono: var(--wp-font-mono); --mono: var(--wp-font-mono);
--sans: var(--wp-font-sans); --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; } * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -74,6 +82,33 @@
.header-logo-chip:hover { opacity: .92; } .header-logo-chip:hover { opacity: .92; }
.header-sep { color: var(--border-strong); } .header-sep { color: var(--border-strong); }
.header-title { font-size: 13px; font-weight: 500; color: var(--text-muted); } .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 ── */
.stepper-wrap { padding: 22px 32px 0; max-width: 1000px; margin: 0 auto; } .stepper-wrap { padding: 22px 32px 0; max-width: 1000px; margin: 0 auto; }

View File

@@ -15,14 +15,12 @@
Add it to a page with: Add it to a page with:
<link rel="stylesheet" href="wp-sidenav.css"> <link rel="stylesheet" href="wp-sidenav.css">
<script src="wp-sidenav.js"></script> <script src="wp-sidenav.js"></script>
after auth-guard.js. It mounts itself into whichever top bar the page has, and after auth-guard.js. It mounts itself into whichever top bar the page has.
skips iframes (the embedded WP creator lives inside a page that already has one). */ 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 () { (function () {
'use strict'; 'use strict';
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
if (inIframe) return;
// ── the map ──────────────────────────────────────────────────────────────── // ── the map ────────────────────────────────────────────────────────────────
// `match` is what marks a link current; `project` means "carry ?project=". // `match` is what marks a link current; `project` means "carry ?project=".
// `show` is an optional gate, evaluated once the user is known. // `show` is an optional gate, evaluated once the user is known.
@@ -32,9 +30,13 @@
sub: 'Projects & what\'s next' }, sub: 'Projects & what\'s next' },
{ href: 'work-package-suite.html?tab=sop', match: /work-package-suite\.html/, icon: '⚙', { href: 'work-package-suite.html?tab=sop', match: /work-package-suite\.html/, icon: '⚙',
label: 'SOP Configuration', sub: 'The project baseline', project: true, tab: 'sop' }, 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' }, 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' }, label: 'Dashboard', sub: 'Status & release gates', project: true, tab: 'dashboard' },
{ href: 'field.html', match: /(^|\/)field\.html$/, icon: '⚒', label: 'Field View', { href: 'field.html', match: /(^|\/)field\.html$/, icon: '⚒', label: 'Field View',
sub: 'Update packages on site', project: true }, sub: 'Update packages on site', project: true },

View File

@@ -317,9 +317,14 @@ def f3(page, base, tok):
# ── F4 ──────────────────────────────────────────────────────────────────────── # ── F4 ────────────────────────────────────────────────────────────────────────
def f4(page, base, tok): def f4(page, base, tok):
"""Comments drawer renders off-screen and over the header in the STANDALONE """Comments drawer renders off-screen and over the header in the creator.
creator (no ?embedded=1). Threshold: once open, the drawer's box sits wholly or Threshold: once open, the drawer's box sits wholly or partly outside the
partly outside the viewport, or intersects the header.""" 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(){ MEASURE = """(function(){
var d=document.querySelector('#cmt-drawer'); if(!d) return 'null'; var d=document.querySelector('#cmt-drawer'); if(!d) return 'null';
var r=d.getBoundingClientRect(); var r=d.getBoundingClientRect();
@@ -356,7 +361,7 @@ def f4(page, base, tok):
time.sleep(0.9) time.sleep(0.9)
problems, notes = [], [] problems, notes = [], []
for mode, qs in (("standalone", ""), ("embedded", "&embedded=1")): for mode, qs in (("as a page", ""),):
for w in (390, 1440): for w in (390, 1440):
page.clear_cookies() page.clear_cookies()
page.set_cookie("wp_session", tok["root"]) page.set_cookie("wp_session", tok["root"])
@@ -409,7 +414,7 @@ def f4(page, base, tok):
return report("F4", REPRO, "; ".join(problems[:4])) return report("F4", REPRO, "; ".join(problems[:4]))
if len(notes) >= 4: if len(notes) >= 4:
return report("F4", UNKNOWN, "drawer never opened: " + "; ".join(notes[:2])) 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 "")) return report("F4", FIXED, detail + (f" ({'; '.join(notes)})" if notes else ""))

456
tests/frame_check.py Normal file
View File

@@ -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 "<iframe" in body.lower():
framed.append(f)
chk("no page declares an iframe", not framed, framed)
# The four APIs that only mean something across a document boundary.
banned = {
"contentWindow": "reaching into a child document",
"contentDocument": "reading a child document",
"window.top": "asking whether we are the top document",
"window.parent": "reaching out to a parent document",
}
hits = {}
for f in scripts + pages:
body = strip_comments(read(f), "html" if f.endswith(".html") else "js")
for api, why in banned.items():
if api in body:
hits.setdefault(api + " (" + why + ")", []).append(f)
chk("no cross-frame API is called anywhere", not hits, hits)
# The embedding flag and the class set it drove.
flags = {}
for f in scripts + pages:
body = strip_comments(read(f), "html" if f.endswith(".html") else "js")
for token in ("embedded=1", "embed-hide", "embed-first", "body.embedded", "wp-frame"):
if token in body:
flags.setdefault(token, []).append(f)
chk("the embedding flag and its classes are gone", not flags, flags)
# And the sizing machinery that existed only to make a frame fill a window.
suite = strip_comments(read("work-package-suite-app.js"), "js")
gone = [n for n in ("applyEmbedLayout", "sizeWPFrame", "viewportMinusChrome",
"renderWPTab", "pushSectionsToCreator")
if n in suite]
chk("...and so is the frame-sizing machinery", not gone, gone)
# The creator was the only page loading neither of wp-chrome's two files.
creator = read("wp-creation-index.html")
chk("the creator loads the shared app bar, like every other page",
"wp-chrome.css" in creator and "wp-chrome.js" in creator)
chk("...and the tab strip lives in the sheet both tool pages load",
".nav-tab" in read("wp-chrome.css")
and ".nav-tab {" not in read("work-package-suite-styles.css"))
# ── 2. both documents boot ────────────────────────────────────────────────────
def boot_checks(page, base, tok):
print("\n2. both documents parse and boot")
for label, path, probe in (
("the SOP wizard", "/work-package-suite.html?project=projA&tab=sop",
"typeof switchTool === 'function' && typeof goToStep === 'function'"
" && typeof openCreator === 'function'"),
("the creator", "/wp-creation-index.html?project=projA",
"typeof showDashboard === 'function' && typeof newPackage === 'function'"
" && typeof syncToolTabs === 'function'"),
):
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.viewport(1440)
page.goto(base + path)
settle(1.6)
chk("%s's own entry points are defined" % label, page.eval(probe) is True,
"script did not parse; see js_errors below")
# `beforeunload` is not a fault. Leaving a page with unsaved SOP edits is
# supposed to warn, and headless blocks the panel because there was no user
# gesture. It IS a behaviour change T7.1 introduced - a tab switch used to
# stay inside one document and never prompt - and it is recorded as BL-020
# rather than quietly suppressed here.
errs = [e for e in page.js_errors()
if "favicon" not in e and "404" not in e and "beforeunload" not in e]
chk("...and it logged no script error", not errs, errs[:2])
# ── 3. the creator is a page ──────────────────────────────────────────────────
def page_checks(page, base, tok):
print("\n3. the creator is a page, not a panel")
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.viewport(1440)
page.goto(base + "/wp-creation-index.html?project=projA")
chk("the creator boots", wait_creator(page))
settle(1.4)
chk("it hosts no iframe", page.eval("document.querySelectorAll('iframe').length") == 0)
chk("the app bar mounted", page.eval("!!document.querySelector('.wp-chrome')"))
chk("...with the project switcher in it",
page.eval("!!document.querySelector('.wp-chrome .wpc-proj-name, .wp-chrome button')"))
chk("the navigation drawer mounted", page.eval("!!document.querySelector('#wp-sidenav')"))
chk("the tab strip is present", page.eval(
"document.querySelectorAll('.main-nav .nav-tab').length") == 3,
page.eval("document.querySelectorAll('.main-nav .nav-tab').length"))
chk("...marking Work Package Creation as the current one", page.eval("""(() => {
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=<id> 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 <style> injected --nav-w:288px with no media query and beat the
# stylesheet's own breakpoint.
page.viewport(390, 900, mobile=True)
page.goto(base + "/wp-creation-index.html?project=projA")
wait_creator(page)
settle(1.4)
over = json.loads(page.eval("""JSON.stringify({
scroll: document.documentElement.scrollWidth,
client: document.documentElement.clientWidth,
navw: getComputedStyle(document.body).getPropertyValue('--nav-w').trim(),
})"""))
print(" BL-001 390px: scrollWidth %s vs clientWidth %s, --nav-w %s"
% (over["scroll"], over["client"], over["navw"] or "(unset)"))
if over["scroll"] > over["client"] + 2:
print(" still reproduces. Widest boxes: %s" % page.eval(WIDEST_JS))
# Pinned, not fixed. BL-001 says to verify it at T7.1 and give it its own item
# if it survives the rebuild; T7.1 says to bundle nothing into this diff. So
# this asserts what is true TODAY and turns red the moment T7.2 lays the form
# out again - which is the point of pinning rather than printing.
chk("BL-001 is pinned: the creator still overflows at 390px, so this check "
"fails when it is fixed",
over["scroll"] > over["client"] + 2, over)
# BL-013: outline:none on every input, replaced by a 3px #edf5ff glow on white -
# a 1.05:1 edge. T7.2 owns the fix; this records whether the rebuild changed it,
# so T7.2 inherits a measurement instead of an assumption.
page.viewport(1440)
page.goto(base + "/wp-creation-index.html?project=projA")
wait_creator(page)
settle(1.2)
# Without this the headless document is not focused, :focus-visible never
# matches, and EVERY control reports no ring - a reading that looks like a
# finding and is an artefact. It is `page.ws.call`, not `page.call`: an earlier
# draft of this probe used the latter inside a try/except and silently measured
# nothing at all, which is worse than not measuring.
page.ws.call("Emulation.setFocusEmulationEnabled", {"enabled": True})
chk("focus emulation is on, so a focus reading means something",
page.eval("document.hasFocus()") is True)
ring = page.eval("""(() => {
const el = document.querySelector('#wp_subject, .main input[type=text]');
if (!el) return 'no input found';
el.focus();
const cs = getComputedStyle(el);
return 'outline ' + cs.outlineStyle + ' ' + cs.outlineWidth
+ ' | box-shadow ' + cs.boxShadow
+ ' | focused=' + (document.activeElement === el);
})()""")
print(" BL-013 focused creator input: %s" % ring)
sheet = read("wp-creation-styles.css")
halves = len(re.findall(r"\b\d+\.5px", sheet))
radii = len(re.findall(r"border-radius:\s*(?!var\()", sheet))
print(" BL-006 half-pixel font sizes in wp-creation-styles.css: %d" % halves)
print(" BL-007 raw border-radius values in the same sheet: %d" % radii)
return {"bl001": over, "bl013": ring, "bl006": halves, "bl007": radii}
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-frame-")
db_path = os.path.join(tmpdir, "check.db")
server = None
try:
tok = seed(db_path)
set_sop(db_path, {}) # a SOP the app can actually 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 iframe - B7 / T7.1\nTarget: %s" % base)
source_checks()
browser = cdp.Browser(exe)
page = browser.page()
try:
boot_checks(page, base, tok)
page_checks(page, base, tok)
address_checks(page, base, tok)
gate_checks(page, base, tok)
measurement(page, base, tok)
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 creator is a page.")
return 0
finally:
if server is not None:
try:
server.terminate()
except Exception:
pass
if __name__ == "__main__":
sys.exit(main())

View File

@@ -14,9 +14,11 @@ strip still report the server's.
4. the strip announces updates via aria-live, because it refreshes in place 4. the strip announces updates via aria-live, because it refreshes in place
5. a failed request is an error, not four zeros and not a remembered number 5. a failed request is an error, not four zeros and not a remembered number
Check 2 is the one with a seam in it: the dashboard is an iframe CHILD of the SOP Check 2 used to be the one with a seam in it: the dashboard was an iframe CHILD
page, so the filter has to cross that boundary. The probe follows the link and of the SOP page, so the filter had to cross that boundary and the probe read it
reads the filter inside the frame, rather than trusting that the URL was built. inside the frame. B7/T7.1 dissolved the frame and the cell links straight at the
creator, so the filter arrives in the page's own URL. The check still reads the
rendered board rather than trusting that the URL was built.
Exit 0 all passed, 1 a failure, 2 could not run. Exit 0 all passed, 1 a failure, 2 could not run.
""" """
@@ -199,32 +201,32 @@ def run(page, base, tok):
sorted(h.split("flag=")[1] for h in hrefs if "flag=" in h) sorted(h.split("flag=")[1] for h in hrefs if "flag=" in h)
== ["onhold", "overdue", "ready"], hrefs) == ["onhold", "overdue", "ready"], hrefs)
print(" ...and the filter survives the trip into the creator's iframe") print(" ...and the filter arrives, on a page rather than in a frame")
onhold_href = [h for h in hrefs if "flag=onhold" in h][0] onhold_href = [h for h in hrefs if "flag=onhold" in h][0]
page.goto(base + "/" + onhold_href.lstrip("/")) page.goto(base + "/" + onhold_href.lstrip("/"))
for _ in range(40): for _ in range(40):
got = page.eval("""(() => { if page.eval("!!window.wpCreatorReady"):
const f = document.getElementById('wp-frame');
try { return f && f.contentWindow && f.contentWindow.wpCreatorReady ? 'ready' : ''; }
catch (e) { return 'x'; }
})()""")
if got == "ready":
break break
time.sleep(0.3) time.sleep(0.3)
settle(2.0) settle(2.0)
# Read the FRAME'S DOM, not its globals. `dashFilter` and `currentView` are # This used to reach through `#wp-frame`.contentDocument, and had to: the
# declared with let in a classic script, so they are not properties of window # dashboard was an iframe CHILD of the SOP page, so the filter was handed
# and a cross-frame read of them comes back undefined — which looks exactly # across rather than read from the child's own URL, and the only honest place
# like a filter that was never applied. The board itself is the evidence. # to check it was inside the frame. B7/T7.1 dissolved the frame - the link now
# opens the creator directly and the filter arrives in its own URL, so this is
# an ordinary read of an ordinary page.
#
# What has NOT changed is why the BOARD is read rather than the globals:
# `dashFilter` and `currentView` are declared with let in a classic script, so
# they are not properties of window. The board itself is still the evidence.
inner = json.loads(page.eval("""(() => { inner = json.loads(page.eval("""(() => {
try { try {
const d = document.getElementById('wp-frame').contentDocument; const dv = document.getElementById('dashboard-view');
const dv = d.getElementById('dashboard-view'); const active = document.querySelector('.dash-metric.dm-active .dm-label');
const active = d.querySelector('.dash-metric.dm-active .dm-label'); // The board only - NOT the gating panel next to it, which is server-derived
// The board only — NOT the gating panel next to it, which is server-derived
// and not filtered. Counting both would let a two-package gating list pass // and not filtered. Counting both would let a two-package gating list pass
// this check whatever the filter did. // this check whatever the filter did.
const panel = [...d.querySelectorAll('.dash-panel')].find(p => { const panel = [...document.querySelectorAll('.dash-panel')].find(p => {
const t = p.querySelector('.dash-panel-title'); const t = p.querySelector('.dash-panel-title');
return t && /^Work packages/.test(t.textContent.trim()); return t && /^Work packages/.test(t.textContent.trim());
}); });
@@ -236,14 +238,16 @@ def run(page, base, tok):
shown: !!dv && getComputedStyle(dv).display !== 'none', shown: !!dv && getComputedStyle(dv).display !== 'none',
active: active ? active.textContent.trim() : null, active: active ? active.textContent.trim() : null,
rows: rows, rows: rows,
framed: !!document.getElementById('wp-frame'),
}); });
} catch (e) { return JSON.stringify({error: String(e)}); } } catch (e) { return JSON.stringify({error: String(e)}); }
})()""")) })()"""))
chk("following an On hold cell opens the dashboard", inner.get("shown") is True, inner) chk("following an On hold cell opens the dashboard", inner.get("shown") is True, inner)
chk("...with the on-hold tile already the active filter, inside the frame", chk("...with the on-hold tile already the active filter",
(inner.get("active") or "").lower() == "on hold", inner) (inner.get("active") or "").lower() == "on hold", inner)
chk("...and the board listing only the held package, not all four", chk("...and the board listing only the held package, not all four",
inner.get("rows") == ["WP03-HOLD"], inner) inner.get("rows") == ["WP03-HOLD"], inner)
chk("...and no iframe was involved (B7/T7.1)", inner.get("framed") is False, inner)
print("\n3. a project with no work packages says so, in a sentence") print("\n3. a project with no work packages says so, in a sentence")
visit("/index.html?project=projEmpty") visit("/index.html?project=projEmpty")
@@ -254,8 +258,15 @@ def run(page, base, tok):
chk("...an explanation is", bool(empty_txt.strip()), repr(empty_txt)) chk("...an explanation is", bool(empty_txt.strip()), repr(empty_txt))
chk("...naming the situation rather than showing four zeros", chk("...naming the situation rather than showing four zeros",
"no work packages" in empty_txt.lower(), repr(empty_txt.strip()[:90])) "no work packages" in empty_txt.lower(), repr(empty_txt.strip()[:90]))
chk("...and offering somewhere to go", page.eval( # Re-pointed at T7.1, not relaxed. The link said "Open the creator" and went to
"!!document.querySelector('#pipeline-strip .pipe-empty a[href*=\"work-package-suite\"]')")) # the suite page, because the creator was a tab of it. It is a page now, so the
# link goes there directly - and the check still demands a link to the place
# the sentence offers, rather than merely a link.
chk("...and offering somewhere to go, which is the creator", page.eval(
"!!document.querySelector('#pipeline-strip .pipe-empty a[href*=\"wp-creation-index\"]')"),
page.eval("(document.querySelector('#pipeline-strip .pipe-empty a')||{}).getAttribute"
" ? document.querySelector('#pipeline-strip .pipe-empty a').getAttribute('href')"
" : '(no link)'"))
chk("no zero is rendered as a headline number anywhere in the strip", chk("no zero is rendered as a headline number anywhere in the strip",
page.eval("document.querySelectorAll('#pipeline-strip .pipe-num').length") == 0) page.eval("document.querySelectorAll('#pipeline-strip .pipe-num').length") == 0)

View File

@@ -15,10 +15,12 @@ replaces.
5. toggle state propagates into the creator — and where it does not, exactly 5. toggle state propagates into the creator — and where it does not, exactly
where and why (X4) where and why (X4)
Check 5 is the one X4 warned about. The creator is an iframe child until T7.1, Check 5 is the one X4 warned about. The creator WAS an iframe child until T7.1,
so there are two propagation paths and they fail differently: the SOP the so there were two propagation paths that failed differently: the SOP the creator
creator reads at ITS boot, and a live hand-off while the frame is already open. reads at ITS boot, and a live hand-off while the frame was already open. T7.1
Both are exercised, separately, so a claim about one cannot cover for the other. dissolved the frame and with it the second path, so 5b now checks that the
deletion was safe rather than merely done - the surviving path carries the
toggle both ways, and there is no frame left anywhere to go stale.
The PDF export is checked by building the print document the way printPackage() The PDF export is checked by building the print document the way printPackage()
does — it reuses #pkg-doc's HTML — rather than by opening a print window, which does — it reuses #pkg-doc's HTML — rather than by opening a print window, which
@@ -321,54 +323,92 @@ def run(page, base, tok, db_path):
and collected["subject"] == FULL_PKG["subject"], collected) and collected["subject"] == FULL_PKG["subject"], collected)
set_sop(db_path, {}) set_sop(db_path, {})
print("\n5b. a toggle flipped while the frame is open reaches it live (X4, path two)") print("\n5b. the frame is gone, and with it the uncommitted-toggle path (X4)")
page.goto(base + "/work-package-suite.html?project=projA&tab=wp") # This check used to prove the SECOND of X4's two paths: a toggle flipped in
for _ in range(40): # the wizard was handed live across the iframe boundary to an already-loaded
inner = page.eval("""(() => { # creator, because the frame's src carried only the project and a reload would
try { const f = document.getElementById('wp-frame'); # have discarded unsaved form work.
return !!(f && f.contentWindow && f.contentWindow.wpCreatorReady); } #
catch (e) { return false; } # B7/T7.1 deleted that path by deleting the boundary, and that is a BEHAVIOUR
})()""") # CHANGE, not only a refactor. The live hand-off showed the creator a toggle
if inner: # that had not been saved yet: flip it, look at the creator, reload, and the
break # section came back. What the creator shows now is the SOP that is actually
time.sleep(0.3) # stored. Checks 1 through 3 above already prove the surviving path carries a
# SAVED toggle in both directions; what is pinned here is the rest of it -
# there is no frame anywhere, the tab is a real link, and an UNSAVED toggle
# correctly does not travel.
page.goto(base + "/work-package-suite.html?project=projA&tab=sop&step=12")
settle(1.6) settle(1.6)
chk("the wizard page hosts no iframe at all",
page.eval("document.querySelectorAll('iframe').length") == 0,
page.eval("document.querySelectorAll('iframe').length"))
chk("...and the Work Package tab is a link to the creator, not a frame swap",
page.eval("""(() => {
const a = document.querySelector('.nav-tab[data-tab="wp"]');
return !!a && a.tagName === 'A' && (a.getAttribute('href') || '')
.indexOf('wp-creation-index.html') === 0;
})()"""),
page.eval("""(() => { const a = document.querySelector('.nav-tab[data-tab="wp"]');
return a ? a.tagName + ' ' + (a.getAttribute('href')||'(no href)') : 'missing'; })()"""))
chk("...carrying the project, so the link works on a cold browser",
"project=projA" in (page.eval("""(() => {
const a = document.querySelector('.nav-tab[data-tab="wp"]');
return a ? (a.getAttribute('href') || '') : ''; })()""") or ""))
chk("...and the Dashboard tab is a link to the same page's board",
page.eval("""(() => {
const a = document.querySelector('.nav-tab[data-tab="dashboard"]');
const h = a ? (a.getAttribute('href') || '') : '';
return a && a.tagName === 'A' && h.indexOf('wp-creation-index.html') === 0
&& h.indexOf('view=dashboard') > 0;
})()"""),
page.eval("""(() => { const a = document.querySelector('.nav-tab[data-tab="dashboard"]');
return a ? a.tagName + ' ' + (a.getAttribute('href')||'(no href)') : 'missing'; })()"""))
# Flip Assets off WITHOUT completing the SOP, then follow the tab.
page.eval(STUB) page.eval(STUB)
chk("the creator is loaded in the frame", page.eval("""(() => { settle(0.6)
try { return !!document.getElementById('wp-frame').contentWindow.wpCreatorReady; } chk("Assets reads as on in the wizard to begin with", page.eval("""(() => {
catch (e) { return false; } const b = document.querySelector('#section-toggles input[data-section="assets"]');
return !!b && b.checked;
})()""")) })()"""))
chk("...showing Assets to begin with", page.eval("""(() => {
const d = document.getElementById('wp-frame').contentDocument;
const el = d.querySelector('#asset-card');
return !!el && !el.hidden;
})()"""))
page.eval("goToStep(12)")
settle(0.8)
page.eval("""(() => { page.eval("""(() => {
const b = document.querySelector('#section-toggles input[data-section="assets"]'); const b = document.querySelector('#section-toggles input[data-section="assets"]');
b.checked = false; b.dispatchEvent(new Event('change', {bubbles:true})); b.checked = false; b.dispatchEvent(new Event('change', {bubbles:true}));
return true; return true;
})()""") })()""")
settle(0.6)
chk("...and the wizard says so, in the summary line", page.eval("""(() => {
const el = document.getElementById('section-summary');
return !!el && /assets/i.test(el.textContent || '');
})()"""), page.eval("(document.getElementById('section-summary')||{}).textContent||''"))
page.eval("""(() => { document.querySelector('.nav-tab[data-tab="wp"]').click(); })()""")
settle(0.8) settle(0.8)
chk("turning Assets off reaches the already-loaded frame, with no reload", chk("following the tab lands on the creator", wait_creator(page))
settle(1.2)
chk("...and the creator page hosts no iframe either",
page.eval("document.querySelectorAll('iframe').length") == 0)
chk("...showing Assets, because the toggle was never saved",
page.eval("""(() => { page.eval("""(() => {
const d = document.getElementById('wp-frame').contentDocument; const el = document.querySelector('#asset-card');
const el = d.querySelector('#asset-card');
return !!el && el.hidden;
})()"""))
page.eval("""(() => {
const b = document.querySelector('#section-toggles input[data-section="assets"]');
b.checked = true; b.dispatchEvent(new Event('change', {bubbles:true}));
return true;
})()""")
settle(0.8)
chk("...and turning it back on brings it back",
page.eval("""(() => {
const d = document.getElementById('wp-frame').contentDocument;
const el = d.querySelector('#asset-card');
return !!el && !el.hidden; return !!el && !el.hidden;
})()""")) })()"""),
page.eval("""(() => { const el = document.querySelector('#asset-card');
return !el ? 'missing' : (el.hidden ? 'hidden' : 'shown'); })()"""))
# And the same toggle, saved, does travel - the surviving path, end to end.
set_sop(db_path, {"assets": False})
page.goto(base + "/wp-creation-index.html?project=projA")
chk("the same toggle, saved this time, reaches the creator", wait_creator(page))
settle(1.2)
chk("...and Assets is suppressed, from the SOP alone (X4, the surviving path)",
page.eval("""(() => {
const el = document.querySelector('#asset-card');
return !!el && el.hidden;
})()"""),
page.eval("""(() => { const el = document.querySelector('#asset-card');
return !el ? 'missing' : (el.hidden ? 'hidden' : 'shown'); })()"""))
set_sop(db_path, {})
print("\n an older SOP that predates CR-006 gets every section, not none") print("\n an older SOP that predates CR-006 gets every section, not none")
set_sop_no_sections(db_path) set_sop_no_sections(db_path)

View File

@@ -253,10 +253,23 @@ def run(page, base, tok):
return focused && document.getElementById('wp-toast').hidden; return focused && document.getElementById('wp-toast').hidden;
})()""")) })()"""))
page.eval("switchTool('wp'); loadSampleData()") # This used to be "loading the sample on the wrong tab interrupts", because the
# wizard's Load sample was context-aware: on the WP tab it reached into the
# iframe and called the creator's own loadExample(), and complained when the
# frame was not there. B7/T7.1 made the creator a page, so there is no wrong
# tab to be on and nothing to reach into - this page's sample is the SOP
# sample, always. What is left to check is that the one remaining edge, the
# SOP gate, still says something rather than nothing.
page.eval("showCreatorGate(); loadSampleData()")
settle(1.2) settle(1.2)
chk("loading the sample on the wrong tab interrupts (role=alert)", chk("from the gate, loading the sample announces rather than failing silently",
page.eval("document.getElementById('wp-toast').getAttribute('role')") == "alert", page.eval("!document.getElementById('wp-toast').hidden"))
chk("...politely, because being on the gate is not an error",
page.eval("document.getElementById('wp-toast').getAttribute('role')") == "status",
ascii_(page.eval("(document.getElementById('wp-toast')||{}).textContent||''")))
chk("...and says where the work package sample actually is",
"Work Package Creation" in (page.eval(
"(document.getElementById('wp-toast')||{}).textContent||''")),
ascii_(page.eval("(document.getElementById('wp-toast')||{}).textContent||''"))) ascii_(page.eval("(document.getElementById('wp-toast')||{}).textContent||''")))
page.eval("switchTool('sop')") page.eval("switchTool('sop')")
settle(0.6) settle(0.6)