T1.1 - F1: one source of truth for the active project; the app bar subscribes

The hero, the picker and the create-user card showed the active project while
the app bar still read "Select a project". Three separate causes, all of them
the same shape - a reader with its own copy of the value.

1. Nothing told the bar. wp-chrome.js rendered projectLabel() once at build
   time and refreshed it only when /api/projects came back, so selecting a
   project updated the hero and left the bar behind. ProjectData.setActive now
   notifies, and the bar subscribes through ProjectData.onActiveChange instead
   of holding a copy. A plain array of callbacks - this is one value with a
   handful of readers, not a reason for a state library.

2. admin.html and users.html load wp-chrome.js but never loaded
   project-data.js, so window.ProjectData was undefined and their bar could
   NEVER show a project - it read "Select a project" permanently, whatever was
   selected. Both now load it, ahead of wp-chrome.js.

3. setActive({id}) erased the name. field.js, wp-creation-app.js and
   work-package-suite-app.js all set the id first and the full record second;
   writing that stub verbatim left the bar rendering "(unnamed)". setActive now
   merges onto the stored record when the id matches, so a partial write cannot
   lose fields it did not mean to touch.

Also: index.html never honoured ?project=<id>, though every other page does, so
a deep link on a browser with nothing stored showed "Select a project" while
the URL said otherwise. It now resolves the parameter before reconciling.

setActive is the only code path that writes wp_active_project /
wp_active_project_obj - project-data.js:83-105, noted there in a comment so it
stays that way. A storage listener keeps a second tab from showing a project
the user has since switched away from.

Verified, all at 1440px and against the wave 0 baseline:
  - bar shows the project on launcher, SOP wizard, admin, field, users
  - survives a hard refresh on each of them
  - selecting a project updates hero and bar in one interaction, no reload
  - with nothing selected the bar reads "Select a project" and both the
    launcher picker and the bar's own switcher are reachable
  - deep link ?project= works on a cold browser, hero and bar agree
  - setActive({id}) after a full record keeps the name

The creator is the one page with no app bar to fix: it loads neither
wp-chrome.js nor wp-chrome.css, because it renders as the iframe child of the
SOP wizard. Giving it chrome is T7.1's work once B7 dissolves that boundary -
adding it here would put a second app bar inside the embedded view. This is the
"all 6 pages" wording in the plan meeting the 7 pages that exist; see file-map
D1.

tests/f_items.py F1 now reports FIXED. F2-F6 still reproduce, untouched.
browser_check.py 71/71.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-14 18:13:32 -05:00
parent fe8a27e022
commit 5d5511a458
5 changed files with 75 additions and 2 deletions

View File

@@ -213,6 +213,10 @@
<script src="console-util.js"></script> <script src="console-util.js"></script>
<script src="admin.js"></script> <script src="admin.js"></script>
<!-- The app bar's project switcher reads ProjectData; without this the bar on this
page could never show a project and always read "Select a project" (F1). Must
parse before wp-chrome.js, which reads it as it mounts. -->
<script src="project-data.js"></script>
<script src="wp-chrome.js"></script> <script src="wp-chrome.js"></script>
<script src="wp-sidenav.js"></script> <script src="wp-sidenav.js"></script>
</body> </body>

View File

@@ -423,6 +423,15 @@
function initProjects(){ function initProjects(){
ProjectData.list().then(list => { ProjectData.list().then(list => {
_projects = list || []; _projects = list || [];
// A deep link names the project explicitly, and every other page in the
// suite already honours ?project=<id>. This one did not, so arriving here
// with a link on a browser that had nothing stored showed "Select a
// project" while the URL said otherwise. Honour it before reconciling.
const wanted = new URLSearchParams(location.search).get('project');
if(wanted){
const target = _projects.find(p => p.id === wanted);
if(target) ProjectData.setActive(target);
}
// Reconcile the active project against the list; clear if it's gone. // Reconcile the active project against the list; clear if it's gone.
const active = ProjectData.getActive(); const active = ProjectData.getActive();
const dropped = (active && !_projects.some(p => p.id === active.id)) ? active : null; const dropped = (active && !_projects.some(p => p.id === active.id)) ? active : null;

View File

@@ -24,6 +24,16 @@
} }
function cacheRemove(id) { writeLocal(readLocal().filter(function (x) { return x.id !== id; })); } function cacheRemove(id) { writeLocal(readLocal().filter(function (x) { return x.id !== id; })); }
// Subscribers to the active project. Deliberately a plain array and a plain
// callback — this is one value with a handful of readers, not a reason for a
// state library. A throwing subscriber must not stop the others being told.
var activeSubs = [];
function notifyActive(p) {
activeSubs.slice().forEach(function (fn) {
try { fn(p); } catch (e) {}
});
}
var SAMPLE_PROJECT = { var SAMPLE_PROJECT = {
name: 'Micron FMCS Install (sample)', number: '26-67-008', name: 'Micron FMCS Install (sample)', number: '26-67-008',
client: 'Micron Technology, Inc.', division: 'Semiconductor', client: 'Micron Technology, Inc.', division: 'Semiconductor',
@@ -78,13 +88,40 @@
// implementation of it rather than two that can disagree. // implementation of it rather than two that can disagree.
// ── active project context ──────────────────────────────────────────────── // ── active project context ────────────────────────────────────────────────
// setActive is the ONLY thing in the app that writes LS_ACTIVE / LS_ACTIVE_OBJ.
// Everything that displays the active project reads it back through getActive()
// or subscribes with onActiveChange(). Keep it that way: F1 was two readers with
// their own copies, and the global one lost.
getActiveId: function () { try { return localStorage.getItem(LS_ACTIVE) || ''; } catch (e) { return ''; } }, getActiveId: function () { try { return localStorage.getItem(LS_ACTIVE) || ''; } catch (e) { return ''; } },
getActive: function () { try { return JSON.parse(localStorage.getItem(LS_ACTIVE_OBJ) || 'null'); } catch (e) { return null; } }, getActive: function () { try { return JSON.parse(localStorage.getItem(LS_ACTIVE_OBJ) || 'null'); } catch (e) { return null; } },
setActive: function (p) { setActive: function (p) {
try { try {
if (p) { localStorage.setItem(LS_ACTIVE, p.id); localStorage.setItem(LS_ACTIVE_OBJ, JSON.stringify(p)); } if (p) {
else { localStorage.removeItem(LS_ACTIVE); localStorage.removeItem(LS_ACTIVE_OBJ); } // Several callers know only the id — a deep link resolving before the
// record arrives (field.js, wp-creation-app.js, work-package-suite-app.js
// all call setActive({id}) first and the full record second). Writing that
// stub verbatim erases the name, and the app bar then renders "(unnamed)".
// Merging keeps the fuller record; fields the caller does supply still win.
var prev = this.getActive();
if (prev && prev.id === p.id) p = Object.assign({}, prev, p);
localStorage.setItem(LS_ACTIVE, p.id);
localStorage.setItem(LS_ACTIVE_OBJ, JSON.stringify(p));
} else {
localStorage.removeItem(LS_ACTIVE);
localStorage.removeItem(LS_ACTIVE_OBJ);
}
} catch (e) {} } catch (e) {}
notifyActive(p || null);
},
// Subscribe to active-project changes. Returns an unsubscribe function.
// The app bar uses this instead of holding its own copy of the value.
onActiveChange: function (fn) {
if (typeof fn !== 'function') return function () {};
activeSubs.push(fn);
return function () {
activeSubs = activeSubs.filter(function (f) { return f !== fn; });
};
}, },
// Per-project namespacing for the SOP/WP localStorage keys, e.g. // Per-project namespacing for the SOP/WP localStorage keys, e.g.
@@ -399,5 +436,14 @@
} }
} catch (e) {} } catch (e) {}
// A second tab switching project leaves this one showing a project the user is no
// longer on. The storage event fires only in OTHER tabs, which is exactly the case
// setActive's own notification cannot cover.
try {
global.addEventListener('storage', function (e) {
if (e.key === LS_ACTIVE_OBJ || e.key === LS_ACTIVE) notifyActive(ProjectData.getActive());
});
} catch (e) {}
global.ProjectData = ProjectData; global.ProjectData = ProjectData;
})(window); })(window);

View File

@@ -100,6 +100,10 @@
<script src="console-util.js"></script> <script src="console-util.js"></script>
<script src="users.js"></script> <script src="users.js"></script>
<!-- The app bar's project switcher reads ProjectData; without this the bar on this
page could never show a project and always read "Select a project" (F1). Must
parse before wp-chrome.js, which reads it as it mounts. -->
<script src="project-data.js"></script>
<script src="wp-chrome.js"></script> <script src="wp-chrome.js"></script>
<script src="wp-sidenav.js"></script> <script src="wp-sidenav.js"></script>
</body> </body>

View File

@@ -379,6 +379,16 @@
loadProjects(switcher); loadProjects(switcher);
checkArchived(m.host); checkArchived(m.host);
window.wpChromeRefresh = function () { switcher.wpcRefresh(); }; window.wpChromeRefresh = function () { switcher.wpcRefresh(); };
// Subscribe rather than keep our own copy of the value. Before this, the label
// was rendered once at build time and refreshed only when /api/projects came
// back, so selecting a project on the launcher updated the hero and left the bar
// reading "Select a project" — that was F1.
try {
if (window.ProjectData && ProjectData.onActiveChange) {
ProjectData.onActiveChange(function () { switcher.wpcRefresh(); });
}
} catch (e) {}
} }
// Wait for the auth guard: an unauthenticated page is about to redirect, and // Wait for the auth guard: an unauthenticated page is about to redirect, and