There is no iframe in html/ any more. The creator is a top-level document with
the same app bar and the same tab strip as the SOP wizard; the two tabs that
used to swap a frame are links between them.
DEVIATION, stated rather than smuggled. The wave file says "remove the iframe
boundary so the creator renders in the parent document". It renders as its own
document instead. Every done-when is met - no iframe, no cross-frame messaging,
F4 resolved structurally, CR-006 toggles with no special-casing, back and
forward intact with T4.2's URL state - but the route is the other one, and the
reason is in creator-frame.md's own numbers:
merge into parent make it a page
selector collisions to resolve 21 0
script global collisions 9 0
cross-frame call sites to remove 28 28
probe entry points needing rework ~29 2
The 21 and the 9 were never the cost of dissolving the boundary. They are the
cost of MERGING TWO DOCUMENTS, which is a different change the boundary was
hiding. And 29 probe call sites address wp-creation-index.html directly, so a
route that keeps that address keeps all of them. creator-frame.md section 5
records this in full.
What went, and what replaced it:
#wp-frame, applyEmbedLayout, sizeWPFrame, viewportMinusChrome, chromeHeight,
renderWPTab, the resize handler, the ResizeObserver, --wp-chrome-h,
.content-area.embed-full, body.embed-full -> the window sizes the page
?embedded=1, body.embedded, .embed-hide, .embed-first -> nothing. An old
link carrying the param is ignored rather than half-obeyed.
openWpById / showDashboard / showForm / dashApplyFlag / applySopSections
called across the frame -> the URL. ?project= ?view= ?wp= ?flag= were
already read at the creator's own boot (T4.2), which is exactly why those four
could be DELETED rather than migrated. X4 is closed: the surviving path is the
one T5.5 built and proved.
inIframe in auth-guard.js, wp-chrome.js, wp-sidenav.js, help.js and _isTop in
project-data.js -> gone. help.js now reads the explicit WP_HELP_NO_FAB flag
both tool pages set, instead of inferring intent from where it is rendered.
.main-nav / .nav-tab in work-package-suite-styles.css -> wp-chrome.css,
because a tab row only one of two documents can style is the shape that put
the tabs in the parent and the toolbar in the child to begin with.
The three questions creator-frame.md section 4 said no count could answer:
1. The creator gets the app bar. It was the only page loading neither
wp-chrome file. Its header is now the .header-left / .header-right pair the
wizard uses, so the switcher lands in the same place on both.
2. Two sequence components, scoped not merged - confirmed Aug 18 that the
sequence is authored in the SOP and adjustable per package. BL-015 stays.
3. body.embedded is gone. The header it hid is replaced by the app bar; the
sample controls are visible in a new package toolbar (D1); the analytics
button is visible there until T7.10 moves it. The Dashboard BUTTON in that
row became a TAB, which is the one place B7's "fold the toolbar into the
tab row" actually happened.
Old addresses still resolve. ?tab=wp, ?view=dashboard and ?wp=<id> are in
bookmarks, in wp-sidenav's link map, and they are the shape CR-011 and CR-014
were specified against (X1). The wizard forwards them with replace(), so Back
does not bounce. Breaking these silently was the one regression this task could
have shipped that nobody would notice for weeks. frame_check.py section 4 pins
all three.
BEHAVIOUR CHANGE, deliberate. The live cross-frame hand-off showed the creator a
section toggle that had NOT been saved: flip it, look, reload, and the section
came back. What the creator shows now is the SOP that is stored. sections_check
5b pins both halves - an unsaved toggle does not travel, a saved one does.
BEHAVIOUR CHANGE, not deliberate, logged as BL-020. A tab switch is a page exit
now, so leaving the wizard with unsaved SOP edits fires T4.3's unsaved-work
guard. Nothing is lost - the guard writes the draft first and T4.3 recovers it -
but it is friction that did not exist, and suppressing a deliberate guard is a
product decision with its own downside. Logged, not quietly handled here.
tests/frame_check.py, 39 checks, new. Two of them exist because of failures
during this task rather than in it:
- "both documents parse and boot". A const shadowing a function parameter is a
SyntaxError, and work-package-suite-app.js did not parse at all for one run.
Four checks in url_state_check went red and not one said "the script did not
load". Asserting a page's own entry points exist costs nothing.
- "focus emulation is on, so a focus reading means something". An earlier draft
called page.call instead of page.ws.call inside a try/except and measured
nothing, reporting no focus ring anywhere - which looks exactly like a
finding. Trap 5 in reverse, for the second time in this project.
The four backlog entries logged against this file, re-measured rather than
assumed:
BL-001 still reproduces (485px in a 390px viewport) but its RECORDED CAUSE IS
WRONG. --nav-w now computes to 56px, so the injected-style explanation
is spent. The overflow is the creator's data tables - #asset-body's
lays out at 520px with no scroll container. frame_check reports the
offending boxes by selector and skips position:fixed subtrees, because
the comments drawer parked off-screen at right:844 made the first
measurement blame the drawer. Pinned, not fixed: T7.2 lays out the form.
BL-013 CLOSED. It was fixed by S12 in WAVE 4 - wp-creation-styles.css:209
carries the comment naming this entry - and nobody updated it. It was
quoted as a live CLAUDE.md violation while planning wave 7 and had not
been true for four waves. a11y_check walks 120 focusable elements on
the creator and every one rings at >= 3:1.
BL-006 15 by the probe's measure, unchanged; different denominator, stated.
BL-007 68 raw radii by the probe's measure. Nothing has reduced it in four
waves; it is measured every run now instead of once.
BL-018 cost a FOURTH probe. frame_check imports set_sop from sections_check
rather than writing a fifth copy of the workaround. T9.9 owns it.
Probes re-pointed, with reasons in the files: sections_check 5b (drove the live
hand-off), pipeline_check check 2 (read through contentDocument), f_items F4
(drove standalone and embedded; there is one mode now), validation_check
(lost "the wrong tab", gained the SOP gate).
Verified: frame_check 39/39, sections_check 95/95, pipeline_check 44/44,
url_state_check 23/23, validation_check 83/83, a11y_check 22/22,
autosave_check 34/34, aggregates_check 16/16, stepper_check 71/71,
browser_check 71/71, launcher_check 58/58, generalinfo_check 49/49,
rollup_check 63/63, cards_check 44/44, locations_check 58/58.
f_items: F1-F5 fixed, F6 reproduces (T7.2).
Metrics: iframes 1 -> 0, colour literals in rules outside theme-light.css 0,
dialogs 64, <div onclick> 2, .help-tip 18.
Items: B7 D1
Task: T7.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
467 lines
22 KiB
JavaScript
467 lines
22 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.
|
|
|
|
Every page in html/ gets one. Until B7/T7.1 this script returned early inside
|
|
an iframe, because the WP creator was embedded in the suite page and a second
|
|
bar inside the frame would have been nonsense - which also meant the creator
|
|
was the only page in the app without an app bar. It is a page now, and it has
|
|
one. */
|
|
(function () {
|
|
'use strict';
|
|
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
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>') +
|
|
// B3/T5.2 removed the launcher's picker card, so "all projects" is this
|
|
// popover now and the launcher is where a project is CREATED. The link
|
|
// says the one thing that is still true there, and carries the hash the
|
|
// launcher opens its form on — otherwise it promises a list that moved.
|
|
'<div class="wpc-pop-foot"><a class="wpc-foot-btn" href="index.html#new-project">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' });
|
|
});
|
|
}
|
|
|
|
// B7/T7.1: a work package used to live inside the suite page's Creator tab,
|
|
// so a hit opened the suite and asked it to hand the package to the frame.
|
|
// The creator is a page; link straight at it. The old address still works -
|
|
// the suite page forwards it - but a search result should not need forwarding.
|
|
function hrefFor(hit) {
|
|
if (hit.kind === 'project') return 'work-package-suite.html?project=' + encodeURIComponent(hit.id);
|
|
if (hit.kind === 'wp') {
|
|
return 'wp-creation-index.html?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);
|
|
})();
|