T4.2 - S3: the app's state has an address; X1 is unblocked

Wave 0 counted pushState across html/ and found 0. Every page read its query
string once at boot and never wrote one again, so you could not send anyone a
link to WP07, a refresh dropped you back at the default view, and Back left the
app entirely because the app had never added a history entry.

CR-011 and CR-014 both promise an email carrying a direct link to a work package.
That is X1, and it was blocked on this. It is not blocked now.

html/wp-url.js is the whole mechanism, and it is deliberately NOT a router.
Nothing in it intercepts navigation or renders anything; it is the query string
treated as state that can be read, merged, written and subscribed to. Pages keep
their own rendering. Query parameters rather than a hash, because the server
already serves these paths and a hash is never sent to the server - which matters
the day a link has to be resolved before the page boots.

The merge behaviour is the part that earns its place: WPUrl.push({wp:id}) keeps
the active project, and WPUrl.push({wp:''}) clears one key without needing to know
what else is in the URL. Hand-built URLs losing ?project= is the usual way this
goes wrong.

WIRED: the creator (open package, dashboard view), the SOP wizard (tool, step),
the launcher (project). Each records a history entry only when the user chose the
change - restoring from the URL uses replace, or Back would immediately add an
entry and appear to do nothing.

WPUrl.absolute() is what CR-011/CR-014 will paste into an email in wave 8.

TWO BUGS THIS TASK CREATED AND FIXED, both found by the probe rather than by
reading:

  - bootSOP() calls newPackage() during boot, and newPackage() cleared ?wp=. A
    deep link therefore worked and then erased its own parameter, leaving Back
    with nothing to return to. Now guarded on wpCreatorReady.
  - goToStep() runs validateStep(), which ends in alert() when a required field
    is empty - always true on a freshly loaded page. So restoring ?step=3 from a
    shared link opened a modal dialog mid-boot, and hung the browser under CDP.
    Restoring a view is not a forward navigation and no longer runs the
    forward-navigation guard.

The second one is worth keeping in mind for the rest of wave 4: this app has 79
native dialogs, and any of them firing during a restore path will hang a headless
browser rather than fail visibly.

VERIFICATION. tests/url_state_check.py, 23 checks, all passing, covering every
done-when on the task:

  - a URL identifying a work package opens that package
  - the same URL for a SIGNED-OUT user goes to login, carries the target through
    ?next=, and lands on the work package itself after signing in
  - refresh preserves project, package, tab and view
  - Back and Forward move through states, verified as still-initialised rather
    than reloaded, and with the dashboard actually rendered rather than only the
    URL changed
  - a different user opening the same URL reaches the same view
  - nothing credential-shaped appears in the query string

Metric 8, pushState: was 0 at wave 0, now 2 in html/ (one pushState and one
replaceState, both in wp-url.js) behind 6 call sites across 4 files. The raw
count stays low by design - one place writes history, which is the same reason
the token work put one place in charge of colour.

browser_check 71/71, f_items 5 FIXED / F6 REPRODUCES, aggregates 16/16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-15 19:32:31 -05:00
parent b670ae719d
commit b54034db04
10 changed files with 511 additions and 16 deletions

View File

@@ -291,15 +291,25 @@ window.addEventListener('DOMContentLoaded',()=>{
updateProjectDisplay();
loadProjectUsers(); // team pickers: who's on this project
applyBimFlag(); // hide the BIM section unless an admin enabled it
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard | ?wp=<id>. Consumed ONCE —
// leaving ?view=dashboard in the URL used to make the Work Package Creation tab
// keep opening the dashboard for the rest of the session.
// Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard | ?wp=<id> | ?step=N.
//
// These used to be consumed ONCE, because a leftover ?view=dashboard made the
// Work Package Creation tab keep reopening the dashboard for the rest of the
// session. Since T4.2 the URL is not a one-shot instruction — it TRACKS the
// current state and switchTool() clears `view` when you leave the dashboard —
// so the param can be honoured every time and a refresh lands where you were.
//
// 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.
const tab = params.get('tab');
_deepLinkWp = params.get('wp') || '';
const wantDashboard = params.get('view') === 'dashboard';
if(wantDashboard) switchTool('dashboard');
else if(tab === 'wp' || tab === 'sop') switchTool(tab);
else if(_deepLinkWp) switchTool('wp');
if(wantDashboard) switchTool('dashboard', {fromUrl:true});
else if(tab === 'wp' || tab === 'sop') switchTool(tab, {fromUrl:true});
else if(_deepLinkWp) switchTool('wp', {fromUrl:true});
const bootStep = parseInt(params.get('step'), 10);
if(bootStep >= 1 && bootStep <= 10 && currentTool === 'sop') goToStep(bootStep, {fromUrl:true});
}
if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
ProjectData.pullProject(projId).then(afterPull).catch(afterPull);
@@ -464,8 +474,15 @@ function repopulateForm(){
}
// ── TOOL SWITCHING ────────────────────────────────────────────────────────────
function switchTool(tool){
function switchTool(tool, opts){
currentTool = tool;
// 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)){
WPUrl.push(tool === 'dashboard' ? { tab: 'wp', view: 'dashboard' }
: { tab: tool, view: '' });
}
// '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');
@@ -1148,12 +1165,31 @@ function addSource(){
}
// ── STEP NAVIGATION ────────────────────────────────────────────────────────────
function goToStep(n){
if(!validateStep(currentStep)) return;
function goToStep(n, opts){
const fromUrl = !!(opts && opts.fromUrl);
// Restoring a step from the URL is not a forward navigation, so it must not run
// the forward-navigation guard. validateStep() ends in alert() when a required
// field is empty, which on a freshly-loaded deep link is ALWAYS - so a shared
// link to step 3 opened a modal dialog before the page had finished booting.
if(!fromUrl && !validateStep(currentStep)) return;
currentStep = n;
// S3: the step you are on survives a refresh and a shared link.
if(typeof WPUrl !== 'undefined' && !fromUrl) WPUrl.push({ step: n > 1 ? n : '' });
updateStepUI();
}
// S3: Back / Forward across tools and steps. The URL is the state, so restoring is
// "read it and show that" rather than a bespoke undo stack.
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});
const step = parseInt(state.step, 10);
if(currentTool === 'sop' && step >= 1 && step !== currentStep) goToStep(step, {fromUrl:true});
});
}
function nextStep(){
if(!validateStep(currentStep)) return;
if(currentStep < 10){