Files
Project-SDE-WP-Suite/html/wp-url.js
n.siegfried b54034db04 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>
2026-08-15 19:32:31 -05:00

127 lines
4.7 KiB
JavaScript

/* Addressable state — S3 / T4.2.
---------------------------------------------------------------------------
Before this file there was no pushState anywhere in the suite. Every page read
its query string once at boot and never wrote one again, so:
• you could not send anyone a link to WP07 — the URL said the same thing
whatever you were looking at;
• a refresh dropped you back at the default view;
• Back left the app entirely, because the app had never added a history entry.
CR-011 and CR-014 both promise an email containing a direct link to a work
package (X1). Those emails cannot exist until a work package has an address,
which is what this provides.
WHAT IT IS NOT: a router. Nothing here 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, not a hash: the server serves these paths already, so a hash
would be a workaround for a problem this app does not have, and hashes are not
sent to the server — which matters the day a link needs to be resolved before
the page boots.
Nothing secret goes in the URL. It is copied into emails, chat and tickets.
*/
(function (window, document) {
'use strict';
var listeners = [];
var LAST = serialize(current());
function current() {
var out = {};
try {
new URLSearchParams(window.location.search).forEach(function (v, k) { out[k] = v; });
} catch (e) {}
return out;
}
function serialize(state) {
var keys = Object.keys(state).filter(function (k) {
return state[k] !== '' && state[k] != null && state[k] !== false;
}).sort();
var sp = new URLSearchParams();
keys.forEach(function (k) { sp.set(k, String(state[k])); });
return sp.toString();
}
// Merge a patch over the current state. Undefined/null/'' removes a key, so a
// caller can clear `wp` without having to know what else is in the URL — the
// usual reason ad-hoc URL building loses the active project.
function merge(patch) {
var next = current();
Object.keys(patch || {}).forEach(function (k) {
var v = patch[k];
if (v === undefined || v === null || v === '' || v === false) delete next[k];
else next[k] = v;
});
return next;
}
function href(patch) {
var qs = serialize(merge(patch));
return window.location.pathname + (qs ? '?' + qs : '') + window.location.hash;
}
function apply(patch, opts) {
opts = opts || {};
var next = merge(patch);
var qs = serialize(next);
if (qs === LAST && !opts.force) return false; // nothing to record
var url = window.location.pathname + (qs ? '?' + qs : '') + window.location.hash;
try {
if (opts.replace) window.history.replaceState({ wpurl: qs }, '', url);
else window.history.pushState({ wpurl: qs }, '', url);
} catch (e) {
return false; // file:// and the like
}
LAST = qs;
return true;
}
function notify(state, viaPop) {
listeners.forEach(function (fn) {
try { fn(state, viaPop); } catch (e) { /* one bad subscriber must not stop the rest */ }
});
}
window.addEventListener('popstate', function () {
LAST = serialize(current());
notify(current(), true);
});
window.WPUrl = {
// Read one parameter, or everything.
get: function (name) { var s = current(); return name == null ? s : (s[name] || ''); },
all: current,
/* Record a state change in history. Merges over what is already there.
WPUrl.push({ wp: id }) -> new history entry, Back returns
WPUrl.push({ wp: '' }) -> clears it
WPUrl.replace({ view: 'form' }) -> corrects the URL without a new entry
replace() is for normalising on load or for a change the user did not ask
for; push() is for one they did, because Back should undo exactly the
things they chose to do. */
push: function (patch) { return apply(patch, { replace: false }); },
replace: function (patch) { return apply(patch, { replace: true }); },
// A URL string for the same merge, without navigating. For hrefs and for the
// links that go into CR-011 / CR-014 emails.
href: href,
absolute: function (patch) {
return window.location.origin + href(patch);
},
/* Subscribe to state changes. Called on Back/Forward with viaPop === true.
Returns an unsubscribe function. */
onChange: function (fn) {
listeners.push(fn);
return function () {
var i = listeners.indexOf(fn);
if (i >= 0) listeners.splice(i, 1);
};
},
};
})(window, document);