T5.3 - B4 surface: a pipeline strip, four server counts, four shareable links

Four cells on the launcher - Work packages, Release ready, On hold, Overdue -
every number from /api/wps/metrics, which T4.1 built. There is deliberately no
cache fallback anywhere in renderPipeline(): a remembered number sitting beside
three live ones is the failure B4 exists to remove, wearing a different hat.

The cells are the four the endpoint already computes and the dashboard already
filters on, so they map 1:1 onto its flags rather than inventing a fifth slice
nothing downstream understands. wp-creation-app.js now names them (DASH_FLAGS)
for the same reason: a cell linking to a filter the board does not recognise is
a dead link that still looks live.

"Links to a filtered view via a shareable URL" needed the filter to BE URL state,
which it was not - the dashboard kept its flag in a variable. So:

  - dashToggleFlag pushes ?flag=<f>, and clears it on the way out of the board
  - the creator applies ?flag= before its first render, not after (applying it
    after paints the whole board and throws it away)
  - Back and Forward move through filters like any other state
  - work-package-suite-app.js forwards the flag ACROSS the iframe boundary, since
    the creator's src carries only the project. B7/T7.1 dissolves that frame and
    this hand-off goes with it; it is commented as such.

Zero is a real answer for one slice of a project that has work in it. Four zeros
on a project with none is not a reading, it is a strip that looks broken - that
case gets a sentence and a way into the creator instead. A failed request gets an
explicit error naming the failure, and no cells at all.

  html/index.html            the strip, its states, PIPE_CELLS
  html/wp-creation-app.js    flag as URL state; DASH_FLAGS; dashApplyFlag
  html/work-package-suite-app.js  forward the flag into the frame; clear on exit
  tests/pipeline_check.py    new - 43 checks

Done when
  [x] every number comes from a server endpoint - proved by poisoning localStorage
      with 99 fake packages and demanding the strip still read the server's 4
  [x] each cell links to a filtered view via a shareable URL - and the probe
      FOLLOWS the link and reads the filter inside the frame rather than trusting
      that a correct-looking URL was built
  [x] a project with zero work packages renders a sensible empty state
  [x] the strip announces updates via aria-live (polite - a count is not an
      interruption) and reports aria-busy while it is counting

What the probe caught

  The link landed on "Complete the SOP Configuration first". Not the strip's
  fault: browser_check.py's fixture stores a bare {governance: …} blob as the SOP
  data, where production stores {sop, state}. restoreSavedSOP() needs `state` and
  bails without it, so sopComplete stays false and the WP tab shows its gate.
  pipeline_check seeds the production shape. The underlying wart is real and is
  logged rather than fixed - see BL-018.

Verified one at a time
  pipeline_check  43/43   new
  launcher_check  58/58
  stepper_check   70/70
  url_state       23/23   the dashboard's new flag state did not disturb it
  aggregates      16/16
  browser_check   71/71
  a11y            22/22
  autosave        34/34
  f_items         F1-F5 FIXED, F6 REPRODUCES (T7.2)

No colour literal added: still 0 across all page sheets and inline blocks. The
four cells are told apart by a label, a sentence and an accent - three channels,
so colour is not carrying it alone (C1).

Raised, not fixed
  BL-018  The WP tab's gate is the last localStorage-derived status in the app.
          T4.1 moved the launcher's card to the server; the wizard page still
          decides gate-or-creator from wp_suite_sop_complete plus a state blob.
          pullProject refreshes both on load so a connected user is fine, but the
          two answers come from different places and the fallback is silent.
          Includes a second, sharper edge: project-data.js:210 writes that flag
          for ANY row returned, including one with no `state` to restore - so the
          flag is written and never read consistently. T7.1 owns it.

Question for the PR, per CLAUDE.md: the strip counts Overdue against `data.due`,
which is free text today. CR-004/CR-018 restructure location but not dates. If
"overdue" is going to drive anything beyond a launcher tile, that field needs a
type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-16 10:38:58 -05:00
parent 77c043c3db
commit 6088ef17e8
6 changed files with 569 additions and 6 deletions

View File

@@ -317,6 +317,39 @@
.field-error { color: var(--cds-text-error); font-size: 12px; font-weight: 600; margin-top: 0.3rem; }
.field-error:empty { display: none; }
/* PIPELINE STRIP — B4 surface (T5.3)
Four counts, every one of them from /api/wps/metrics. There is deliberately
no localStorage fallback anywhere in here: a per-browser number that looks
authoritative is the thing B4 removes, and a stale four beside a live four
is worse than an error. */
.pipeline { margin-bottom: 1.5rem; }
.pipeline-head { font-size: 13px; font-weight: 600; text-transform: none;
color: var(--cds-text-secondary); margin-bottom: 0.6rem; }
.pipeline-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1px;
background: var(--cds-border-subtle); border: 1px solid var(--cds-border-subtle); }
.pipe-cell { display: flex; flex-direction: column; gap: 0.15rem; padding: 1rem 1.1rem;
background: var(--cds-layer); text-decoration: none; color: var(--cds-text-primary);
border-left: 3px solid transparent; transition: background 0.15s; }
.pipe-cell:hover { background: var(--cds-layer-hover); }
.pipe-num { font-size: 1.75rem; font-weight: 300; line-height: 1.1; }
.pipe-label { font-size: 13px; font-weight: 600; }
.pipe-sub { font-size: 12px; color: var(--cds-text-secondary); }
/* Each cell's accent says which slice it is, and the label says it in words —
the number alone is the same shape in all four (C1). */
.pipe-cell.is-total { border-left-color: var(--cds-interactive-01); }
.pipe-cell.is-ready { border-left-color: var(--cds-support-success); }
.pipe-cell.is-onhold { border-left-color: var(--wp-status-warning-text); }
.pipe-cell.is-overdue { border-left-color: var(--cds-support-error); }
/* Zero is a real answer for one slice of a project that has work in it. Four
zeros on a project with no work packages at all is not a reading, it is a
broken-looking strip, so that case gets a sentence instead. */
.pipe-empty, .pipe-error { grid-column: 1 / -1; background: var(--cds-layer);
padding: 1.1rem 1.2rem; font-size: 13px; color: var(--cds-text-secondary); }
.pipe-error { color: var(--wp-status-warning-text); }
.pipe-empty a { color: var(--cds-link-primary); }
.pipe-loading { grid-column: 1 / -1; background: var(--cds-layer); padding: 1.1rem 1.2rem;
font-size: 13px; color: var(--cds-text-secondary); font-style: italic; }
/* FIRST RUN
A brand-new account has no projects, so it has no tool cards either — this
is the whole page for that person, and it has to say what to do next. */
@@ -437,6 +470,16 @@
</div>
</section>
<!-- PIPELINE STRIP — B4 surface / T5.3
Four counts from /api/wps/metrics, each one a link into the dashboard
filtered to that slice. The URL carries the filter (?view=dashboard&
flag=…), so a cell can be sent to someone rather than only clicked.
aria-live because it refreshes in place when the project changes. -->
<section class="pipeline" id="pipeline" hidden aria-labelledby="pipeline-head">
<h2 class="pipeline-head" id="pipeline-head">Work package pipeline</h2>
<div class="pipeline-strip" id="pipeline-strip" aria-live="polite" aria-busy="true"></div>
</section>
<!-- TOOL CARDS (shown once a project is active) -->
<div class="cards-grid" id="overview" style="display:none">
@@ -712,6 +755,8 @@
if(!active){
cards.style.display = 'none';
const strip = document.getElementById('pipeline');
if(strip) strip.hidden = true;
heroTitle.textContent = 'Work Package Suite';
// Two different situations, and telling someone to "select a project"
// when there are none to select is the thing B3 is about.
@@ -737,6 +782,69 @@
// reflectSOPStatus.
if(ProjectData.pullProject){ ProjectData.pullProject(active.id).catch(()=>{}); }
reflectSOPStatus(active);
renderPipeline(active);
}
// ── PIPELINE STRIP (B4 surface / T5.3) ────────────────────────────────────
// Four counts, all four from /api/wps/metrics. There is deliberately no cache
// fallback: B4's whole point is that a per-browser number which looks
// authoritative is worse than no number, and the same is true of a remembered
// one shown next to live ones.
//
// The flags match wp-creation-app.js's DASH_FLAGS. A cell linking to a filter
// the dashboard does not recognise is a dead link that still looks live.
const PIPE_CELLS = [
{flag: '', cls: 'is-total', label: 'Work packages', key: 'total',
sub: 'everything on this project'},
{flag: 'ready', cls: 'is-ready', label: 'Release ready', key: 'release_ready',
sub: 'no open constraints, nothing waiting'},
{flag: 'onhold', cls: 'is-onhold', label: 'On hold', key: 'on_hold',
sub: 'raised as an issue'},
{flag: 'overdue', cls: 'is-overdue', label: 'Overdue', key: 'overdue',
sub: 'past due and not closed'},
];
function dashHref(projectId, flag){
const q = new URLSearchParams({ view: 'dashboard', project: projectId });
if(flag) q.set('flag', flag);
return 'work-package-suite.html?' + q.toString();
}
function renderPipeline(active){
const host = document.getElementById('pipeline');
const strip = document.getElementById('pipeline-strip');
if(!host || !strip) return;
host.hidden = false;
strip.setAttribute('aria-busy', 'true');
strip.innerHTML = '<div class="pipe-loading">Counting work packages…</div>';
fetch('/api/wps/metrics?project_id=' + encodeURIComponent(active.id),
{ headers: { 'Accept': 'application/json' } })
.then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(m => {
if(ProjectData.getActiveId() !== active.id) return; // switched while in flight
strip.setAttribute('aria-busy', 'false');
if(!m.total){
// Four zeros on a project with no work packages is not a reading, it
// is a strip that looks broken. Say the true thing instead.
strip.innerHTML = `<div class="pipe-empty">No work packages on this project yet.
<a href="${esc(dashHref(active.id, ''))}">Open the creator</a> to write the first one.</div>`;
return;
}
strip.innerHTML = PIPE_CELLS.map(c => `
<a class="pipe-cell ${c.cls}" href="${esc(dashHref(active.id, c.flag))}">
<span class="pipe-num">${Number(m[c.key] || 0)}</span>
<span class="pipe-label">${esc(c.label)}</span>
<span class="pipe-sub">${esc(c.sub)}</span>
</a>`).join('');
})
.catch(err => {
if(ProjectData.getActiveId() !== active.id) return;
strip.setAttribute('aria-busy', 'false');
strip.innerHTML = `<div class="pipe-error">⚠ Could not load work package counts —
${esc((err && err.message) || 'offline')}. These numbers come from the server;
nothing stale is shown in their place.</div>`;
});
}
function clearActiveProject(){ ProjectData.setActive(null); urlSyncProject('', false); renderProjectEntry(); applyActiveProject(); }