Files
Project-SDE-WP-Suite/html/wp-chrome.js
n.siegfried b105135a98 T2.3 - B2: a breakpoint plan for the project switcher, not an ellipsis
"Micron EUV Cleanroom Enable 2667008" did not fit. The switcher capped the name
at 240px inside a 280px button, so the real project name was ellipsised to
"Micron EUV Clean..." on the one control whose job is to tell you which job you
are in - which is the failure this item exists to prevent.

The plan, now written into docs/reference/file-map.md so it is not re-litigated:

  >= 1024px   the full project NAME. Caps raised to 400px / 340px, which is
              enough for a real name with no ellipsis.
  <  1024px   the project NUMBER alone, e.g. 2667008. Dropping the name beats
              shortening it: short, stable, unambiguous.

The number is no longer prefixed to the name at wide widths. Real names already
end with their number, so the old format printed it twice - the test project
rendered as "2667008 - Micron EUV Cleanroom Enable 2667008".

The full name stays reachable at every width by three routes, none hover-only,
because the Field View is a touch surface:
  - the drawer, which now shows the active project under its head, wrapping onto
    as many lines as it needs and never truncated. This is the guaranteed route,
    and it subscribes to ProjectData.onActiveChange so it does not go stale when
    the launcher switches project without reloading.
  - the switcher's title attribute, carrying the full number and name, which
    surfaces on keyboard focus as well as hover.
  - the switcher popover, which lists every project in full.

Crossing the breakpoint re-labels, so a resized window does not keep whichever
form was chosen at load.

Verified with the real long name at 390, 768, 1024 and 1440: nothing truncated
at any width (scrollWidth vs clientWidth, not by eye), and the drawer carries
the whole name at all four. T1.1 correctness holds - the switcher never reads
"Select a project" while a project is active.

f_items: F1-F5 FIXED, F3 still passing at all four widths with the wider
switcher. browser_check 71/71.

Wave 2 complete: one drawer on six pages, no flat strip, F2 resolved for real,
S4 closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 17:32:14 -05:00

461 lines
21 KiB
JavaScript

/* Shared app chrome for the Work Package Suite: a project switcher beside the
Prime logo and a global search centered in the top bar.
One script for every page because there are two generations of top bar — the
dark UI-shell `.wp-appbar` (home, admin, field) and the older light `.header`
(SOP suite, WP creator). We find whichever exists, insert the same markup, and
flip a colour set based on how dark the host bar is.
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. */
(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;
function el(tag, cls, html) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (html != null) n.innerHTML = html;
return n;
}
function esc(v) {
return String(v == null ? '' : v)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function isDark(node) {
try {
var m = (getComputedStyle(node).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
if (!m) return false;
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
} catch (e) { return false; }
}
// ── where to put the chrome ────────────────────────────────────────────────
// Returns {host, insertBefore} or null. The insertion point matters: on the
// dark bar we sit before the spacer (so search takes the middle); on the light
// bars we sit between the left block and the right-hand buttons.
function findMount() {
var appbar = document.querySelector('.wp-appbar');
if (appbar) {
return { host: appbar, before: appbar.querySelector('.wp-appbar-spacer') };
}
var header = document.querySelector('.header');
if (header) {
// The suite page wraps its own left/right groups; the creator's bar is a
// flat row of buttons whose first button carries margin-left:auto.
var right = header.querySelector('.header-right');
if (right) return { host: header, before: right };
var firstBtn = header.querySelector('.btn, button');
return { host: header, before: firstBtn };
}
return null;
}
// ── project switcher ───────────────────────────────────────────────────────
var projects = [];
function activeProject() {
try { return (window.ProjectData && ProjectData.getActive()) || null; } catch (e) { return null; }
}
// B2 breakpoint plan. "Micron EUV Cleanroom Enable 2667008" does not fit the bar,
// and an ellipsis at every width gives you "Micron EUV Clean…" on the one control
// whose job is to tell you which job you are in. So the bar drops the name rather
// than shortening it: below 1024px it shows the project NUMBER alone, which is
// short, stable and unambiguous. The full name is never more than a hover, a focus
// or the drawer away — see projectTitle() and the drawer head.
// Documented in docs/reference/file-map.md.
var WIDE = '(min-width: 1024px)';
function isWide() {
try { return window.matchMedia(WIDE).matches; } catch (e) { return true; }
}
function projectLabel(p) {
if (!p) return 'Select a project';
var n = p.name || '(unnamed)';
if (!p.number) return n; // no number to fall back to
// Wide: the name, which is what people recognise, and which usually carries the
// number inside it anyway ("Micron EUV Cleanroom Enable 2667008") — prefixing the
// number there printed it twice. Narrow: the number alone.
return isWide() ? n : p.number;
}
// Always the whole thing, whatever the bar is showing.
function projectTitle(p) {
if (!p) return 'Select a project';
var n = p.name || '(unnamed)';
return (p.number ? (p.number + ' — ' + n) : n) + ' — switch project';
}
// Switching project reloads the current page with ?project=<id>. Every page
// already resolves its project from that param (falling back to the stored
// active id), so a reload is both the simplest and the safest route — no page
// has to re-hydrate half its state in place.
function switchProject(p) {
try { if (window.ProjectData) ProjectData.setActive(p); } catch (e) {}
var url = new URL(location.href);
url.searchParams.set('project', p.id);
url.hash = '';
location.assign(url.toString());
}
function buildProjectSwitcher() {
var wrap = el('div', 'wpc-proj');
var btn = el('button', 'wpc-proj-btn');
btn.type = 'button';
btn.setAttribute('aria-haspopup', 'listbox');
btn.setAttribute('aria-expanded', 'false');
btn.title = 'Switch project';
var cur = activeProject();
btn.innerHTML =
'<span class="wpc-proj-labels">' +
'<span class="wpc-proj-kicker">Project</span>' +
'<span class="wpc-proj-name">' + esc(projectLabel(cur)) + '</span>' +
'</span><span class="wpc-caret">▾</span>';
var pop = el('div', 'wpc-pop');
pop.hidden = true;
wrap.appendChild(btn);
wrap.appendChild(pop);
function render() {
var curId = (activeProject() || {}).id || '';
var rows = projects.map(function (p) {
return '<button type="button" class="wpc-item' + (p.id === curId ? ' is-current' : '') +
'" data-pid="' + esc(p.id) + '">' +
'<span class="wpc-item-title">' + esc(p.name || '(unnamed)') + '</span>' +
'<span class="wpc-item-sub">' + esc([p.number, p.client, p.site].filter(Boolean).join(' · ') ||
'no number') + (p.sample ? ' · sample' : '') + '</span>' +
'</button>';
}).join('');
pop.innerHTML =
'<div class="wpc-pop-head">Switch project</div>' +
(rows || '<div class="wpc-empty">No projects you can access yet.</div>') +
'<div class="wpc-pop-foot"><a class="wpc-foot-btn" href="index.html">All projects / new project</a></div>';
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (item) {
item.addEventListener('click', function () {
var p = projects.filter(function (x) { return x.id === item.getAttribute('data-pid'); })[0];
if (p) switchProject(p);
});
});
}
function open() {
render();
pop.hidden = false;
btn.setAttribute('aria-expanded', 'true');
}
function close() {
pop.hidden = true;
btn.setAttribute('aria-expanded', 'false');
}
btn.addEventListener('click', function (e) {
e.stopPropagation();
if (pop.hidden) open(); else close();
});
document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
// Refresh the label once the project list (and any active project) is known.
wrap.wpcRefresh = function () {
var c = activeProject();
var nameEl = btn.querySelector('.wpc-proj-name');
if (nameEl) nameEl.textContent = projectLabel(c);
// The full name stays reachable at every width, by hover and by keyboard focus.
btn.title = projectTitle(c);
if (!pop.hidden) render();
};
// Crossing the breakpoint has to re-label, or a resized window keeps whichever
// form was picked at load.
try {
var mq = window.matchMedia(WIDE);
var onChange = function () { wrap.wpcRefresh(); };
if (mq.addEventListener) mq.addEventListener('change', onChange);
else if (mq.addListener) mq.addListener(onChange);
} catch (e) {}
return wrap;
}
function loadProjects(switcher) {
// ProjectData.list() already hits the API and falls back to its local cache
// when offline, so there's no second request to make here.
var p;
try {
p = (window.ProjectData && ProjectData.list) ? ProjectData.list() : null;
} catch (e) { p = null; }
if (!p) {
p = fetch('/api/projects', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : []; });
}
Promise.resolve(p)
.then(function (list) {
projects = Array.isArray(list) ? list : [];
// A deep link names the project, and the bar is the one component every
// page carrying chrome has. The launcher, SOP wizard, creator and field
// view each resolve ?project= themselves; admin.html and users.html have
// no project logic at all, so without this their bar shows whatever was
// last stored — or "Select a project" on a cold browser — while the URL
// says otherwise. Resolving it here covers every page once.
try {
var wanted = new URLSearchParams(location.search).get('project');
if (wanted && window.ProjectData && ProjectData.setActive) {
var hit = projects.filter(function (x) { return x.id === wanted; })[0];
var cur = activeProject();
if (hit && (!cur || cur.id !== hit.id || !cur.name)) ProjectData.setActive(hit);
}
} catch (e) {}
switcher.wpcRefresh();
})
.catch(function () {});
}
// ── archived-project banner ────────────────────────────────────────────────
// An archived project is still readable and still deep-linkable (?project=<id>),
// but every write now 409s. With nothing on the page to say so, that reads as a
// silent failure — so the bar, the one thing every page has, carries the warning.
//
// The state comes from GET /api/projects/<id>, never from "it's missing from the
// switcher": absence also means "you have no access to it", which is a different
// message. Fails closed and silent — an error means no banner, not a broken page.
function activeProjectId() {
try {
var q = new URLSearchParams(location.search).get('project');
if (q) return q;
return (window.ProjectData && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
} catch (e) { return ''; }
}
function buildArchivedBanner() {
var bar = el('div', 'wpc-archived');
bar.setAttribute('role', 'status');
bar.innerHTML =
'<span class="wpc-archived-ico" aria-hidden="true">⚠</span>' +
'<span class="wpc-archived-text"><strong>Archived project — read-only.</strong> ' +
'Unarchive it from the Admin Console to make changes.</span>';
return bar;
}
function checkArchived(host) {
var id = activeProjectId();
if (!id || !host || !host.parentNode) return;
var pinned = false;
try { pinned = !!new URLSearchParams(location.search).get('project'); } catch (e) {}
fetch('/api/projects/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (p) {
if (!p || !p.archived) return;
// The home page reconciles the stored active project against the (now
// archive-filtered) list while this request is in flight, and drops it. If
// that happened, the id we asked about is nobody's context any more —
// banner-ing it would contradict the picker one line below. A ?project=
// deep link is pinned to this page and can't be cleared out from under us.
if (!pinned && window.ProjectData && ProjectData.getActiveId &&
ProjectData.getActiveId() !== id) return;
// Also on the document element, so the two big apps can gate their own UI
// from CSS or a boot check without a second round trip. The server stays
// the real gate; this is only there so the UI can agree with it.
try { document.documentElement.setAttribute('data-wp-archived', '1'); } catch (e) {}
if (document.querySelector('.wpc-archived')) return;
host.parentNode.insertBefore(buildArchivedBanner(), host.nextSibling);
})
.catch(function () {});
}
// ── global search ──────────────────────────────────────────────────────────
function buildSearch() {
var wrap = el('div', 'wpc-search');
var box = el('div', 'wpc-search-box');
box.innerHTML =
'<span class="wpc-search-ico" aria-hidden="true">⌕</span>' +
'<input class="wpc-search-input" type="search" autocomplete="off" spellcheck="false" ' +
'placeholder="Search work packages, projects, SOPs…" aria-label="Search">' +
'<button class="wpc-clear" type="button" title="Clear" hidden>✕</button>' +
'<span class="wpc-kbd">Ctrl K</span>';
var pop = el('div', 'wpc-pop');
pop.hidden = true;
wrap.appendChild(box);
wrap.appendChild(pop);
var input = box.querySelector('.wpc-search-input');
var clear = box.querySelector('.wpc-clear');
var timer = null, seq = 0, items = [], activeIx = -1;
// Below 620px the box is roughly 200px and the full placeholder ellipsises to
// "Search work packages, proj" — the truncation half of F2. The control is
// usable either way; this stops it reading as broken. 620px is the breakpoint
// wp-chrome.css already uses for this element, not a new one. Interim: T2.2.
try {
var narrow = window.matchMedia('(max-width: 620px)');
var setPlaceholder = function (m) {
input.placeholder = m.matches ? 'Search…' : 'Search work packages, projects, SOPs…';
};
setPlaceholder(narrow);
if (narrow.addEventListener) narrow.addEventListener('change', setPlaceholder);
else if (narrow.addListener) narrow.addListener(setPlaceholder);
} catch (e) {}
function close() { pop.hidden = true; activeIx = -1; }
function highlight() {
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (n, i) {
n.classList.toggle('is-active', i === activeIx);
if (i === activeIx && n.scrollIntoView) n.scrollIntoView({ block: 'nearest' });
});
}
// 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.
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 || '') +
'&wp=' + encodeURIComponent(hit.id);
}
return 'work-package-suite.html?tab=sop&project=' + encodeURIComponent(hit.project_id || '');
}
function go(hit) {
if (!hit) return;
if (hit.kind === 'project') {
var p = projects.filter(function (x) { return x.id === hit.id; })[0];
if (p) { switchProject(p); return; }
}
// Set the active project only from a full record — writing a stub would
// clobber the cached project (name, number, client) other pages read. The
// ?project= param in the URL is what actually switches context.
var full = projects.filter(function (x) { return x.id === hit.project_id; })[0];
if (full) { try { if (window.ProjectData) ProjectData.setActive(full); } catch (e) {} }
location.assign(hrefFor(hit));
}
function renderResults(data) {
items = [];
var html = '';
function group(title, rows) {
if (!rows.length) return;
html += '<div class="wpc-pop-head">' + esc(title) + '</div>' + rows.join('');
}
group('Work packages', (data.wps || []).map(function (w) {
items.push({ kind: 'wp', id: w.id, project_id: w.project_id, project_name: w.project_name });
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
'<span class="wpc-item-title"><span class="wpc-item-mono">' + esc(w.number || '(unnumbered)') + '</span> ' +
esc(w.subject || '') + '</span>' +
'<span class="wpc-item-sub">' + esc([w.status, w.type, w.project_name].filter(Boolean).join(' · ')) + '</span>' +
'</button>';
}));
group('Projects', (data.projects || []).map(function (p) {
items.push({ kind: 'project', id: p.id });
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
'<span class="wpc-item-title">' + esc(p.name || '(unnamed)') + '</span>' +
'<span class="wpc-item-sub">' + esc([p.number, p.client].filter(Boolean).join(' · ') || 'project') + '</span>' +
'</button>';
}));
group('SOPs', (data.sops || []).map(function (s) {
items.push({ kind: 'sop', id: s.id, project_id: s.project_id, project_name: s.project_name });
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
'<span class="wpc-item-title">' + esc(s.name || 'SOP') + '</span>' +
'<span class="wpc-item-sub">' + esc([s.complete ? 'complete' : 'draft', s.project_name].filter(Boolean).join(' · ')) + '</span>' +
'</button>';
}));
if (!items.length) {
html = '<div class="wpc-empty">Nothing matches “' + esc(data.query || '') + '” in the projects you can access.</div>';
}
pop.innerHTML = html;
pop.hidden = false;
activeIx = items.length ? 0 : -1;
highlight();
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (n) {
n.addEventListener('click', function () { go(items[+n.getAttribute('data-ix')]); });
n.addEventListener('mouseenter', function () { activeIx = +n.getAttribute('data-ix'); highlight(); });
});
}
function run(q) {
var mine = ++seq;
fetch('/api/search?q=' + encodeURIComponent(q), { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (mine !== seq) return; // a newer keystroke already won
if (!data) { close(); return; }
renderResults(data);
})
.catch(function () {
if (mine !== seq) return;
pop.innerHTML = '<div class="wpc-empty">Search is unavailable offline.</div>';
pop.hidden = false;
});
}
input.addEventListener('input', function () {
var q = input.value.trim();
clear.hidden = !q;
clearTimeout(timer);
if (q.length < SEARCH_MIN) { close(); return; }
timer = setTimeout(function () { run(q); }, DEBOUNCE_MS);
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { close(); input.blur(); return; }
if (pop.hidden || !items.length) return;
if (e.key === 'ArrowDown') { e.preventDefault(); activeIx = (activeIx + 1) % items.length; highlight(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); activeIx = (activeIx - 1 + items.length) % items.length; highlight(); }
else if (e.key === 'Enter') { e.preventDefault(); go(items[activeIx]); }
});
input.addEventListener('focus', function () {
if (input.value.trim().length >= SEARCH_MIN && items.length) pop.hidden = false;
});
clear.addEventListener('click', function () {
input.value = ''; clear.hidden = true; close(); input.focus();
});
document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
// Ctrl/Cmd-K from anywhere focuses search (matches the tools people already
// use). Ignored while typing in another field so it can't steal a shortcut.
document.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
input.focus();
input.select();
}
});
return wrap;
}
// ── mount ──────────────────────────────────────────────────────────────────
function mount() {
if (document.querySelector('.wp-chrome')) return;
var m = findMount();
if (!m) return;
var chrome = el('div', 'wp-chrome');
if (isDark(m.host)) chrome.setAttribute('data-bar', 'dark');
var switcher = buildProjectSwitcher();
chrome.appendChild(switcher);
chrome.appendChild(buildSearch());
if (m.before) m.host.insertBefore(chrome, m.before);
else m.host.appendChild(chrome);
loadProjects(switcher);
checkArchived(m.host);
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
// /api/search would 401 anyway.
if (window.WP_USER) mount();
else document.addEventListener('wp-auth-ready', mount);
})();