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

@@ -8,6 +8,9 @@
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
timestamps during their own boot. -->
<script src="wp-format.js"></script>
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
during their own boot. -->
<script src="wp-url.js"></script>
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#161616">
@@ -440,6 +443,13 @@
}
// Reconcile the active project against the list; clear if it's gone.
const active = ProjectData.getActive();
// S3: if a project is active but the URL does not say so, make the URL say
// so — with replace, not push, because the user did not navigate here. This
// is what makes "copy the address bar" produce a link that lands somebody
// else on the same project rather than on whatever they last had open.
if(active && typeof WPUrl !== 'undefined' && WPUrl.get('project') !== active.id){
WPUrl.replace({ project: active.id });
}
const dropped = (active && !_projects.some(p => p.id === active.id)) ? active : null;
if(dropped) ProjectData.setActive(null);
renderProjectPicker();
@@ -527,10 +537,17 @@
ProjectData.save(Object.assign({}, ProjectData.SAMPLE)).then(saved => { afterProjectChosen(saved); });
}
// S3: which project you are in is addressable, so a launcher link carries it
// and Back returns to the project you were looking at before.
function urlSyncProject(id, replace){
if(typeof WPUrl === 'undefined') return;
(replace ? WPUrl.replace : WPUrl.push).call(WPUrl, { project: id || '' });
}
function selectProject(id){
if(!id){ ProjectData.setActive(null); applyActiveProject(); return; }
if(!id){ ProjectData.setActive(null); urlSyncProject('', false); applyActiveProject(); return; }
const p = _projects.find(x => x.id === id);
if(p){ ProjectData.setActive(p); applyActiveProject(); }
if(p){ ProjectData.setActive(p); urlSyncProject(p.id, false); applyActiveProject(); }
}
function afterProjectChosen(p){
@@ -577,7 +594,22 @@
reflectSOPStatus(active);
}
function clearActiveProject(){ ProjectData.setActive(null); renderProjectPicker(); applyActiveProject(); }
function clearActiveProject(){ ProjectData.setActive(null); urlSyncProject('', false); renderProjectPicker(); applyActiveProject(); }
// Back / Forward between projects.
if(typeof WPUrl !== 'undefined'){
WPUrl.onChange(function(state, viaPop){
if(!viaPop) return;
const want = state.project || '';
if(want === (ProjectData.getActiveId() || '')) return;
if(!want){ ProjectData.setActive(null); }
else {
const p = (_projects||[]).find(x=>x.id===want);
ProjectData.setActive(p || { id: want });
}
renderProjectPicker(); applyActiveProject();
});
}
// Reflect SOP completion on the tool cards (B4).
//