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

@@ -309,12 +309,20 @@ window.addEventListener('DOMContentLoaded',()=>{
// 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
// 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');
_deepLinkWp = params.get('wp') || '';
const deepWp = params.get('wp') || '';
const wantDashboard = params.get('view') === 'dashboard';
if(wantDashboard) switchTool('dashboard', {fromUrl:true});
else if(tab === 'wp' || tab === 'sop') switchTool(tab, {fromUrl:true});
else if(_deepLinkWp) switchTool('wp', {fromUrl:true});
if(wantDashboard || tab === 'wp' || deepWp){
if(openCreator(wantDashboard ? 'dashboard' : '', {wp: deepWp, replace: true})) return;
}
else if(tab === 'sop') switchTool(tab, {fromUrl:true});
stampCreatorLinks();
const bootStep = parseInt(params.get('step'), 10);
if(bootStep >= 1 && bootStep <= LAST_STEP && currentTool === 'sop') goToStep(bootStep, {fromUrl:true});
}
@@ -382,14 +390,17 @@ function initializeWPTypes(){
}
// ── LOAD SAMPLE DATA ──────────────────────────────────────────────────────────
// Context-aware: on the SOP tab it loads the sample SOP; on the WP / Dashboard
// tab it loads the example Work Package inside the embedded creator.
// This page's sample is the sample SOP. It used to reach into the frame and call
// 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(){
if(currentTool && currentTool !== 'sop'){
const f = document.getElementById('wp-frame');
if(f && f.contentWindow && typeof f.contentWindow.loadExample === 'function'){ f.contentWindow.loadExample(); }
else { wizardToast('Open the Work Package Creation tab first, then load the sample.',
{role: 'alert'}); }
wizardToast('The work package sample is on the Work Package Creation page, in its toolbar.',
{role: 'status'});
return;
}
// Populate Step 1
@@ -540,180 +551,128 @@ function repopulateForm(){
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){
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
// restoring because the user pressed Back - recording that as a new entry would
// make Back appear to do nothing.
if(typeof WPUrl !== 'undefined' && !(opts && opts.fromUrl)){
// `flag` is the dashboard's filter (T5.3). It rides in the URL so a pipeline
// cell is shareable, which also means it has to be cleared on the way out —
// otherwise leaving the board and coming back re-applies a filter nobody asked
// for a second time.
WPUrl.push(tool === 'dashboard' ? { tab: 'wp', view: 'dashboard' }
: { tab: tool, view: '', flag: '' });
// `flag` was the dashboard's filter (T5.3) and `view` the board. Both belong
// to the creator's URL now, so leaving the SOP tab clears them here rather
// than carrying a filter nobody asked for into a page that no longer reads it.
WPUrl.push({ tab: 'sop', 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=>t.classList.remove('active'));
const tabBtn = document.querySelector(`[data-tab="${tool}"]`);
if(tabBtn) tabBtn.classList.add('active');
document.querySelectorAll('.nav-tab').forEach(t => {
t.classList.remove('active'); t.removeAttribute('aria-current');
});
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.getElementById(`tool-${contentTool}`).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);
document.querySelectorAll('.tool').forEach(t => t.classList.remove('active'));
const pane = document.getElementById('tool-sop');
if(pane) pane.classList.add('active');
updateStepUI();
updateProjectDisplay();
}
// The embedded creator/dashboard fills the window below the app chrome, so there
// is ONE scrollbar (the iframe's) instead of a skinny inner pane inside a scrolling
// page — and the creator's sticky bars have a real viewport to stick to.
// A work package the global search asked us to open, handed to the creator on its
// next load and then cleared.
let _deepLinkWp = '';
// ── WHERE THE CREATOR LIVES (B7 / T7.1) ──────────────────────────────────────
// This block used to be 90 lines of iframe arithmetic: measure the chrome, size
// the frame to what is left, re-measure on resize, watch the bar with a
// ResizeObserver because it grows after the frame is already sized, and pin the
// result with inline styles so a stale cached stylesheet could not collapse the
// 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){
// Below this, "fill the window" leaves nothing usable: the iframe would be shorter
// than the creator's own sticky bars while the page itself can't scroll. Fall back
// to normal page flow and let the page scroll instead.
const MIN_FILL_H = 460;
const fits = (window.innerHeight - chromeHeight()) >= MIN_FILL_H;
const full = !!on && fits;
const area = document.querySelector('.content-area');
const frame = document.getElementById('wp-frame');
if(area) area.classList.toggle('embed-full', full);
if(frame) frame.classList.toggle('fill', full);
document.body.classList.toggle('embed-full', full);
sizeWPFrame(full);
// The creator's address, carrying this page's project.
function creatorUrl(extra){
const sp = new URLSearchParams();
const projId = new URLSearchParams(window.location.search).get('project')
|| (activeProject && activeProject.id) || '';
if(projId) sp.set('project', projId);
Object.keys(extra || {}).forEach(k => { if(extra[k]) sp.set(k, extra[k]); });
const q = sp.toString();
return 'wp-creation-index.html' + (q ? '?' + q : '');
}
// Size the frame with INLINE styles, not only CSS classes. Inline wins over any
// stylesheet — including a stale cached one — so the creator can't collapse to the
// 300x150 default iframe box if the CSS and the HTML are ever out of step.
function sizeWPFrame(on){
const frame = document.getElementById('wp-frame');
if(!frame) return;
frame.style.width = '100%';
frame.style.border = '0';
if(on){
const h = viewportMinusChrome();
document.documentElement.style.setProperty('--wp-chrome-h', (window.innerHeight - h) + 'px');
frame.style.height = h + 'px';
frame.style.minHeight = '0';
} else {
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);
// Write the real address onto the two tab links. They ship with a `data-nav-href`
// rather than an `href` so a click before the project is known cannot navigate
// somewhere wrong; once this runs they are ordinary links, which is what makes
// middle-click, ctrl-click and "copy link address" work on them.
function stampCreatorLinks(){
document.querySelectorAll('a.nav-tab[data-nav-href]').forEach(a => {
const base = a.getAttribute('data-nav-href') || '';
const q = base.indexOf('?');
const extra = {};
if(q >= 0) new URLSearchParams(base.slice(q + 1)).forEach((v, k) => { extra[k] = v; });
a.setAttribute('href', creatorUrl(extra));
a.classList.toggle('gated', !sopComplete);
if(sopComplete) a.removeAttribute('aria-disabled');
else a.setAttribute('aria-disabled', '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(){
if(currentTool === 'wp') renderWPTab();
stampCreatorLinks();
if(currentTool === 'wp' && sopComplete) openCreator('', {replace:true});
}
// 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';
}
renderSectionSummary();
// The creator may already be loaded in the frame next door. Hand the change
// across rather than waiting for a reload — see pushSectionsToCreator.
pushSectionsToCreator();
// No hand-off: the creator reads SOP.sections at its own boot (T5.5), and
// since T7.1 there is no already-open frame to be out of date.
});
});
// X4, the sequencing note this task had to resolve: the creator is an iframe
// child until T7.1, so the SOP it reads is the one hydrated into localStorage by
// ProjectData.pullProject at ITS boot. A toggle flipped in the wizard afterwards
// is invisible to an already-loaded frame.
// X4, resolved. Section toggles reached the creator by two paths while it was an
// iframe child: the SOP it reads at its own boot, and a live hand-off across the
// boundary for a frame that was already open.
//
// Two paths, therefore, and both are needed:
// • on the creator's own boot, applySOP() reads SOP.sections — this covers a
// reload, a fresh tab, and the standalone creator page;
// • while the frame is open, this hands the change straight across, the same
// way T5.3 hands the dashboard filter across.
// 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;
}
// T7.1 deleted the second one, because there is no longer a second document to
// hand anything to. What is left is the path T5.5 built and proved: applySOP()
// reads SOP.sections during the creator's boot, which covers a reload, a fresh
// tab and a deep link equally. The creator now always boots after the toggle was
// saved, so "already open and out of date" is not a state that exists.
// ── STEP GATES (A4 / S9) ──────────────────────────────────────────────────────
// 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'){
WPUrl.onChange(function(state, viaPop){
if(!viaPop) return;
const wantTool = state.view === 'dashboard' ? 'dashboard' : (state.tab || 'sop');
if(wantTool !== currentTool) switchTool(wantTool, {fromUrl:true});
// T7.1: this page only has the SOP tool. A popstate carrying ?tab=wp or
// ?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);
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});
// Hand the SOP to the embedded Work Package Creator and unlock its tab (in case
// the user stays), then return to the project home page per the requested flow.
// Unlock the creator tabs (in case the user stays on this page), then return to
// 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);
wizardToast('SOP configuration complete. Returning to the project home page.');