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

@@ -12,11 +12,12 @@
(function () {
'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). Only
// from the top window; the API and writes are never cached (see sw.js).
if (!inIframe && 'serviceWorker' in navigator) {
// 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
// an iframe so the embedded creator did not register a second time; B7/T7.1
// dissolved that frame and there is no longer a document in the app that is
// not the top one.
if ('serviceWorker' in navigator) {
try { navigator.serviceWorker.register('/sw.js'); } catch (e) {}
}
@@ -34,9 +35,10 @@
clearTimeout(safety);
var next = encodeURIComponent(location.pathname + location.search);
var url = 'login.html?next=' + next;
// If we're inside the WP-creator iframe, redirect the whole window.
var w = inIframe ? window.top : window;
try { w.location.replace(url); } catch (e) { window.location.replace(url); }
// Was `inIframe ? window.top : window`, so an expired session inside the
// embedded creator replaced the whole window rather than painting a login
// page into a frame. No frame, no branch (B7/T7.1).
window.location.replace(url);
}
window.wpLogout = function () {

View File

@@ -1,5 +1,5 @@
/* 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
- 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
@@ -466,8 +466,11 @@
// ── floating launcher on pages without their own Help button ────────────────
function maybeAddFab() {
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
if (inIframe || global.WP_HELP_NO_FAB) return; // suite shows the parent's button
// Both tool pages set WP_HELP_NO_FAB and carry a Help button in the app bar
// 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.getElementById('ui-help-fab')) return;
var b = document.createElement('button');

View File

@@ -831,10 +831,15 @@
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){
const q = new URLSearchParams({ view: 'dashboard', project: projectId });
if(flag) q.set('flag', flag);
return 'work-package-suite.html?' + q.toString();
return 'wp-creation-index.html?' + q.toString();
}
function renderPipeline(active){

View File

@@ -339,13 +339,14 @@
renderSyncBadge(c);
}
// Tiny sync indicator (bottom-left). Rendered only in the top-level window so it
// isn't duplicated inside the embedded creator iframe; the top window still sees
// the iframe's queue changes via the 'storage' event below.
var _isTop = (function () { try { return window.top === window.self; } catch (e) { return true; } })();
// Tiny sync indicator (bottom-left). This was gated on being the top-level
// window so it was not drawn twice - once by the suite page and again inside
// the embedded creator. B7/T7.1 dissolved that frame, so there is one document
// 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;
function renderSyncBadge(c) {
if (!_isTop || !document.body) return;
if (!document.body) return;
var el = document.getElementById('wp-sync-badge');
if (!el) {
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
// 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
// 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.
const CODE_RE = /\.(html|css|js)$|\/$/i;
@@ -69,7 +70,7 @@ self.addEventListener('fetch', (e) => {
const isCode = CODE_RE.test(url.pathname);
// 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.
const key = new Request(url.origin + url.pathname, { credentials: 'same-origin' });
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
// 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.');

View File

@@ -155,51 +155,18 @@ body {
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. */
/* MAIN NAVIGATION — underline tabs */
.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;
}
/* MAIN NAVIGATION - moved to wp-chrome.css by B7/T7.1.
The strip is chrome, and dissolving the creator's iframe made it a page that
has to draw the same strip. A tab row only one of the two documents can style
is the shape that put the tabs in the parent and the toolbar in the child. */
.tab-icon { font-size: 16px; }
/* CONTENT AREA
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
Creator (an iframe living in here) into a ~930px column with its own scrollbar
inside the page's. Wider cap for the wizard; the embedded tools go full-bleed
(see .content-area.embed-full below). */
screen wasted half the display. The wider cap stays; what it used to also have
to accommodate - the creator embedded in here, squeezed into a ~930px column
with its own scrollbar inside the page's - is a separate page since T7.1. */
.content-area {
flex: 1;
padding: 2rem;
@@ -208,39 +175,11 @@ body {
width: 100%;
}
/* Work Package Creation / Dashboard: the iframe fills the window below the app
chrome and owns the only scrollbar, so the creator's sticky save bar and
navigator drawer position against a real viewport instead of scrolling away. */
.content-area.embed-full {
/* `flex: none` matters: .content-area is a column flex item with `flex: 1`, whose
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; }
/* B7/T7.1: the creator's iframe was sized here - #wp-frame, .content-area.embed-full,
body.embed-full and the --wp-chrome-h custom property that carried the measured
app-bar height across. All of it existed so one document could fill the window
inside another and scroll with a single scrollbar. The creator is a page now;
the window sizes it. */
.tool {
display: none;
@@ -1006,7 +945,6 @@ body.embed-full { overflow: hidden; }
@media (max-width: 768px) {
.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; }
.step-content { padding: 1rem; }
.wp-type-row { grid-template-columns: 1fr; }

View File

@@ -47,17 +47,28 @@
</div>
</div>
<!-- MAIN NAVIGATION -->
<div class="main-nav">
<!-- MAIN NAVIGATION (B7 / T7.1)
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')">
SOP Configuration
</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
</button>
<button class="nav-tab" data-tab="dashboard" onclick="switchTool('dashboard')">
</a>
<a class="nav-tab" data-tab="dashboard" data-nav-href="wp-creation-index.html?view=dashboard"
onclick="return gateCreatorLink(event)">
Dashboard
</button>
</a>
</div>
<!-- CONTENT AREA -->
@@ -485,17 +496,13 @@
<!-- Shown until the SOP is complete -->
<div id="wp-gate" style="padding: 3rem 2rem; text-align: center;">
<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>
</div>
<!-- The real Work Package Creator, embedded once the SOP is complete -->
<!-- Sizing stays INLINE on purpose. An iframe with no width/height falls back
to the HTML default 300x150 box, and the service worker caches this page
and the stylesheet separately — so a browser can hold new HTML with old
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>
<!-- B7/T7.1: the iframe stood here. The creator is a page now
(wp-creation-index.html), reached through the tab above, so this tab
holds only the gate: the state where there is no SOP to build from.
Once there is one, the tab navigates and this panel is never seen. -->
</div>
</div>

View File

@@ -257,3 +257,65 @@
.wpc-proj-kicker { display: none; }
.wpc-search { flex: 1 1 120px; }
}
/* ============================================================================
TOOL TAB STRIP (B7 / T7.1)
----------------------------------------------------------------------------
SOP Configuration | Work Package Creation | Dashboard.
These rules lived in work-package-suite-styles.css while the creator was an
iframe child, because only the suite page ever drew the strip. Dissolving the
frame makes the creator a document of its own that draws the same strip, so
they move to the sheet both pages already load. Nothing changed in the move -
same values, same tokens, no literal added; the fallbacks below exist only
because the two host pages alias the role names under different local names.
The strip is navigation between two documents now, so `.nav-tab` has to look
identical as a <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
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
second bar inside the frame would be nonsense. */
Every page in html/ gets one. Until B7/T7.1 this script returned early inside
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 () {
'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 DEBOUNCE_MS = 180;
@@ -313,12 +313,14 @@
});
}
// A work package lives inside the suite's Creator tab, so open the suite on
// that project with the package requested; a SOP opens the SOP tab.
// B7/T7.1: a work package used to live inside the suite page's Creator 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) {
if (hit.kind === 'project') return 'work-package-suite.html?project=' + encodeURIComponent(hit.id);
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);
}
return 'work-package-suite.html?tab=sop&project=' + encodeURIComponent(hit.project_id || '');

View File

@@ -1353,7 +1353,7 @@ function printPackage(){
// ── VIEWS ────────────────────────────────────────────────────────────────────
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.
function wpFormPopulated(){ setTimeout(wpMarkFormClean, 0); }
function showForm(){
@@ -1369,9 +1369,33 @@ function showForm(){
buildDisciplinePicker(); renderScope();
setFormChrome(true);
currentView='Work Package Form';
syncToolTabs();
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).
function setFormChrome(on){
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);
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;
// ── SAVED PACKAGES ───────────────────────────────────────────────────────────
@@ -2154,9 +2182,10 @@ function urlSyncDashFlag(opts){
(opts && opts.replace ? WPUrl.replace : WPUrl.push).call(WPUrl, patch);
}
// Apply a flag arriving from outside — a URL, or the embedding shell forwarding
// one across the iframe boundary. Never pushes history: the caller's own
// navigation is what put us here.
// Apply a flag arriving from outside. Until T7.1 that meant two things - this
// page's own URL, or the embedding shell forwarding one across the frame because
// 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){
const next = DASH_FLAGS.indexOf(f) >= 0 ? f : '';
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
// when you last looked are not evidence that they are correct now.
dashMetrics=null; loadDashMetrics();
syncToolTabs();
window.scrollTo({top:0,behavior:'smooth'}); track('dashboard_open');
}
// ── B4: dashboard counts come from the server ────────────────────────────────
@@ -2780,11 +2810,14 @@ function showDraftRecoveryBar(id, d){
}
})();
function bootSOP(){
// When embedded in the Suite, hide the SOP import/sample controls (SOP is injected)
// and prefer the SOP the Suite just completed (persisted to localStorage, which
// we've already hydrated from the server for this project).
const params = new URLSearchParams(location.search);
if(params.get('embedded')) document.body.classList.add('embedded');
// Prefer the SOP the wizard just completed (persisted to localStorage, already
// hydrated from the server for this project).
//
// B7/T7.1: this used to also set `body.embedded` from ?embedded=1, which hid
// 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 {
const raw = localStorage.getItem(wpKey('wp_suite_sop'));
if(raw){
@@ -2889,6 +2922,7 @@ function bootData(){
// re-renders itself only when the board is already open.
dashApplyFlag(state.flag || '');
if(currentView !== 'Dashboard') showDashboardFromUrl();
syncToolTabs();
return;
}
const want = state.wp || '';
@@ -2899,15 +2933,19 @@ function bootData(){
} else if(currentView === 'Dashboard'){
hideDashboard(); setFormChrome(true); currentView='Work Package Form';
document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='');
syncToolTabs();
}
});
}
initAutosave();
wpMarkFormClean();
syncToolTabs();
track('app_open');
// The embedding shell needs to know when the packages are actually in hand: the
// frame's `load` event fires long before pullProject() resolves, so anything that
// acts on a specific package has to wait for this instead.
// Kept after T7.1, with a different reader. It existed because the frame's
// `load` event fired long before pullProject() resolved, so the shell could not
// 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;
try { document.dispatchEvent(new CustomEvent('wp-creator-ready')); } catch(e){}
}

View File

@@ -20,6 +20,10 @@
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
<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-sidenav.css">
</head>
@@ -27,29 +31,65 @@
<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">
<!-- The logo chip, not the mono wordmark this page used to carry: every other page
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">
<div class="header-left">
<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>
</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>
</div>
<div class="header-sep embed-hide">|</div>
<div class="header-title">Work Package (IWP)</div>
<button class="btn btn-ghost embed-hide" style="margin-left:auto;padding:7px 16px" onclick="document.getElementById('sop-import').click()">⤒ Import SOP</button>
<!-- wp-chrome.js inserts the project switcher and global search here. -->
<div class="header-right">
<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)">
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" 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 embed-hide" style="padding:7px 16px" onclick="loadExample()">★ Load example</button>
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button>
<button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="newPackage()">+ New</button>
<button class="btn btn-ghost" style="padding:7px 16px" onclick="duplicateWP()">⧉ Duplicate</button>
<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 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>
<button class="btn btn-ghost" onclick="loadSampleSOP()">Sample SOP</button>
<button class="btn btn-ghost" onclick="loadExample()">Load example</button>
<span class="wp-toolbar-sep" aria-hidden="true"></span>
<!-- T7.10 moves this to the admin console and deletes one of the two
implementations. It stays visible here until then rather than being
deleted by the task that dissolved the frame. -->
<button class="btn btn-ghost" onclick="showAnalytics()">Usage data</button>
</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="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="wp-creation-app.js"></script>
<script src="wp-chrome.js"></script>
<script src="wp-sidenav.js"></script>
</body>
</html>

View File

@@ -2,9 +2,11 @@
fully behind a firewall. If IBM Plex is installed/self-hosted it is used;
otherwise it falls back to the system UI fonts. */
/* Embedded-in-Suite tweaks */
body.embedded .embed-hide { display: none !important; }
body.embedded .embed-first { margin-left: auto; }
/* B7/T7.1: `body.embedded` and `.embed-hide` are gone. They existed to hide
this page's own header, its sample-data controls and its analytics button
while it was an iframe child. There is no frame, so "framed" is not a state
any more: the header became the app bar, the sample controls are visible in
the toolbar (D1) and the analytics button moves to the console at T7.10. */
/* Names only. Every value comes from theme-light.css, which this page loads
first - this sheet declares nothing of its own (T3.2 / S5 / C3). The names
@@ -35,6 +37,12 @@
--shadow-lg: var(--wp-shadow-lg-cool);
--mono: var(--wp-font-mono);
--sans: var(--wp-font-sans);
/* The tab strip moved into wp-chrome.css at T7.1 and is drawn by both tool
pages. It reads the wizard sheet's role names, so this sheet aliases the
same three - from the same canonical tokens, not from new values. */
--bg-card: var(--cds-layer);
--text-light: var(--cds-text-secondary);
--primary: var(--cds-interactive-01);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -74,6 +82,33 @@
.header-logo-chip:hover { opacity: .92; }
.header-sep { color: var(--border-strong); }
.header-title { font-size: 13px; font-weight: 500; color: var(--text-muted); }
/* Same two-group header the suite page uses, so wp-chrome.js inserts the
project switcher between them rather than in front of the brand. */
.header-left { display: flex; align-items: center; gap: 12px; min-width: 0; flex-shrink: 0; }
.header-right { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-shrink: 0; }
/* ── PACKAGE TOOLBAR ──
What the old header row became once the brand, the feedback panel and the
Dashboard button moved out. Actions on the package, or on the SOP behind it. */
.wp-toolbar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 8px 32px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.wp-toolbar .btn { padding: 7px 14px; }
.wp-toolbar-sep {
width: 1px;
align-self: stretch;
margin: 2px 6px;
background: var(--border);
}
@media (max-width: 860px) {
.wp-toolbar { padding: 8px 16px; }
}
/* ── STEPPER ── */
.stepper-wrap { padding: 22px 32px 0; max-width: 1000px; margin: 0 auto; }

View File

@@ -15,14 +15,12 @@
Add it to a page with:
<link rel="stylesheet" href="wp-sidenav.css">
<script src="wp-sidenav.js"></script>
after auth-guard.js. It mounts itself into whichever top bar the page has, and
skips iframes (the embedded WP creator lives inside a page that already has one). */
after auth-guard.js. It mounts itself into whichever top bar the page has.
It used to skip iframes, because the embedded WP creator lived inside a page
that already had a drawer; B7/T7.1 dissolved that frame. */
(function () {
'use strict';
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
if (inIframe) return;
// ── the map ────────────────────────────────────────────────────────────────
// `match` is what marks a link current; `project` means "carry ?project=".
// `show` is an optional gate, evaluated once the user is known.
@@ -32,9 +30,13 @@
sub: 'Projects & what\'s next' },
{ href: 'work-package-suite.html?tab=sop', match: /work-package-suite\.html/, icon: '⚙',
label: 'SOP Configuration', sub: 'The project baseline', project: true, tab: 'sop' },
{ href: 'work-package-suite.html?tab=wp', match: null, icon: '▤',
// B7/T7.1: both of these were tabs of the suite page, opened by swapping an
// iframe, so `match` had to be null - one URL could not tell them apart. The
// creator is its own document now, so they have real addresses and the drawer
// can mark which one you are on.
{ href: 'wp-creation-index.html', match: /wp-creation-index\.html/, icon: '▤',
label: 'Work Package Creator', sub: 'Build and edit IWPs', project: true, tab: 'wp' },
{ href: 'work-package-suite.html?tab=dashboard', match: null, icon: '▦',
{ href: 'wp-creation-index.html?view=dashboard', match: null, icon: '▦',
label: 'Dashboard', sub: 'Status & release gates', project: true, tab: 'dashboard' },
{ href: 'field.html', match: /(^|\/)field\.html$/, icon: '⚒', label: 'Field View',
sub: 'Update packages on site', project: true },