Merge branch 'feat/wp-suite-r3-housekeeping': the Aug 20 decisions, built

Nick's six answers (decisions-2026-08-20.md, evening section) plus the
approved housekeeping, one commit per item:

- F6 strict 2.0: the creator fits two screens at rest (1,954 -> 1,784px);
  form_structure_check 51/51 and the suite has ZERO red checks for the
  first time. Closes BL-022.
- Hold reachable from any status: recorded as-is, question closed.
- CR-014: bodies carry customer context (number - title, location, deep
  link), never document content; canary pins split to match the rule.
- CR-008 merged-PDF: KNOWN-ISSUES 3, decided not deferred.
- D12: the productivity factor (act/est) on the dashboard, server sums.
- BL-020 closed (keep the prompt). BL-021 fixed: the critical-reopen mail
  reaches the PM and CM at last (critical_reopen_check, 11, sink-verified).
- BL-024: the last 21 native dialogs onto the shared wp-dialog.js kit;
  app-wide native count is now zero (console_dialogs_check, 17).
- BL-025: the final second-brand-blue tint rebased; check widened.
- S13: already fixed at T1.6 - stale records corrected, incl. CLAUDE.md.
- CR-011 transport: EHLO pinned; DNS trouble was stalling every send ~5s.

Battery: 16 suites re-run, all green, no deliberate exceptions remain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 10:28:09 -07:00
25 changed files with 739 additions and 84 deletions

View File

@@ -93,7 +93,7 @@ checks. In addition, for any task touching the frontend:
2. Exercise the affected flow at **390px** and at **1440px**. Field View at 390px is the 2. Exercise the affected flow at **390px** and at **1440px**. Field View at 390px is the
gloved-hands surface and is where the worst rendering was found. gloved-hands surface and is where the worst rendering was found.
3. Capture before and after screenshots into the PR. 3. Capture before and after screenshots into the PR.
4. Run the existing smoke test. It signs in; `server/seed_demo.py` does not, which is S13. 4. Run the existing smoke test. It signs in, and so does `server/seed_demo.py` (S13, fixed at T1.6 - this line said otherwise until Aug 20 2026, a stale record).
If a done-when check cannot be verified, do not mark the task complete. Say which check If a done-when check cannot be verified, do not mark the task complete. Say which check
failed and why. failed and why.

View File

@@ -12,6 +12,7 @@ Close an entry by deleting it in the same commit that fixes it.
|---|-------|----------|--------|--------| |---|-------|----------|--------|--------|
| 1 | XSS via SOP discipline names in the WP creator | Medium (internal), High if externally reachable | 2026-08-05 | Open | | 1 | XSS via SOP discipline names in the WP creator | Medium (internal), High if externally reachable | 2026-08-05 | Open |
| 2 | Archived projects: the two big apps don't grey out their own controls | Low | 2026-08-05 | Open | | 2 | Archived projects: the two big apps don't grey out their own controls | Low | 2026-08-05 | Open |
| 3 | Export is not one merged PDF; drawings ride along as a list | Low | 2026-08-20 | Open — decided |
--- ---
@@ -161,3 +162,35 @@ save/issue controls, or add a boot check in each app that disables them and show
read-only notice inline. Decide separately how the embedded creator read-only notice inline. Decide separately how the embedded creator
(`wp-creation-index.html`) surfaces it, since it runs in an iframe where the shared (`wp-creation-index.html`) surfaces it, since it runs in an iframe where the shared
app bar — and therefore the banner — is deliberately skipped. app bar — and therefore the banner — is deliberately skipped.
---
## 3. Export is not one merged PDF; drawings ride along as a list
**Files:** `html/wp-creation-app.js` (the T9.1 export walk), `CR-008`
**Decided:** 2026-08-20, by Nick — "add this to known issues."
### What is wrong
CR-008 asked for the work package "as one document." What shipped (T9.1)
renders every section inline — including images — and lists PDF drawing
attachments with links, rather than merging their pages into a single PDF.
### What it costs
A crew printing the package gets the form and the inline images in one pass,
but linked PDF drawings are separate opens/prints. For field hand-offs that
want literally one file, someone stitches it manually.
### Why it is still open
Real PDF merging needs either a server-side PDF library (a new dependency and
a render pipeline for arbitrary uploaded PDFs) or a client-side one (heavy,
and the creator is deliberately dependency-free). The recommendation made at
T9.1 — inline images + listed PDFs — was accepted as the shipped behaviour.
### What closing it takes
A server-side merge endpoint (e.g. pypdf) that concatenates the rendered
package with each attached PDF, streamed back as one download; plus a size
ceiling consistent with D8's upload limits. One task, one new dependency.

View File

@@ -40,7 +40,7 @@ name the probe that re-checks the item on every run.
| S6 | built | T9.3 (`icon_check` 5) | one monochrome system, mapped in `tokens.md` | | S6 | built | T9.3 (`icon_check` 5) | one monochrome system, mapped in `tokens.md` |
| S7 | built | T9.4 (`sample_check` 10) | one affordance, confirmed, fenced — verified against a real project | | S7 | built | T9.4 (`sample_check` 10) | one affordance, confirmed, fenced — verified against a real project |
| S8 | built | T9.5 (`helptip_check` 13) | 20 badges → buttons; **closed BL-001** | | S8 | built | T9.5 (`helptip_check` 13) | 20 badges → buttons; **closed BL-001** |
| S13 | open | — | `seed_demo.py` still does not sign in; the smoke test does. Carried to the next revision | | S13 | built | T1.6 (re-verified 2026-08-20) | this row said "open / does not sign in" until Aug 20 - a records error: T1.6 fixed it in wave 1 (it reuses smoketest's login) and the wave-1 exit box was simply never ticked. Verified live: sign-in, seed, `--clean` |
| A1 | preserved | T7.3 | `confirmEarlyRelease()` by name; async now, same contract | | A1 | preserved | T7.3 | `confirmEarlyRelease()` by name; async now, same contract |
| A2 | built | T7.4 (`warning_check` 17) | one warning; the count on the sticky rail | | A2 | built | T7.4 (`warning_check` 17) | one warning; the count on the sticky rail |
| A3A5, A7 | built | waves 16 | localization re-verified through T7.10's admin edits (`cards_check`) | | A3A5, A7 | built | waves 16 | localization re-verified through T7.10's admin edits (`cards_check`) |
@@ -105,7 +105,6 @@ name the probe that re-checks the item on every run.
aggregated, placement needs Nick. aggregated, placement needs Nick.
- **BL-024** — 21 native dialogs on admin/users/launcher; the T7.9 kit is - **BL-024** — 21 native dialogs on admin/users/launcher; the T7.9 kit is
ready for them. ready for them.
- **S13** — `seed_demo.py` sign-in.
- Product questions raised in commit messages, awaiting answers: hold - Product questions raised in commit messages, awaiting answers: hold
reachable from Draft/Scheduled (T7.3); CR-014 email body content (T7.6); reachable from Draft/Scheduled (T7.3); CR-014 email body content (T7.6);
merged-PDF export (T9.1). merged-PDF export (T9.1).

View File

@@ -247,7 +247,7 @@ Wave 4 added three more, each written because its task's done-when could not be
anything that already existed: anything that already existed:
```bash ```bash
python tests/aggregates_check.py # B4 — do the counts come from the server? 16 checks python tests/aggregates_check.py # B4 — do the counts come from the server? 17 checks
python tests/url_state_check.py # S3 — does the app's state have an address? 23 checks python tests/url_state_check.py # S3 — does the app's state have an address? 23 checks
python tests/autosave_check.py # S2/B5 — does unsaved work survive? 34 checks python tests/autosave_check.py # S2/B5 — does unsaved work survive? 34 checks
python tests/a11y_check.py # S10/S11/S12 — announce, legible, focus 22 checks python tests/a11y_check.py # S10/S11/S12 — announce, legible, focus 22 checks
@@ -280,7 +280,7 @@ python tests/form_structure_check.py # F6/D3 - rail, disclosure, one open sectio
python tests/hold_check.py # CR-015/A1/D4 - the hold clears, gates hold 50 checks python tests/hold_check.py # CR-015/A1/D4 - the hold clears, gates hold 50 checks
python tests/warning_check.py # A2 - one warning, a badge from anywhere 17 checks python tests/warning_check.py # A2 - one warning, a badge from anywhere 17 checks
python tests/triage_check.py # A6 - the sidebar answers the stand-up 16 checks python tests/triage_check.py # A6 - the sidebar answers the stand-up 16 checks
python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink 40 checks python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink 41 checks
python tests/files_check.py # CR-007/D8 - drawings upload + real offline 36 checks python tests/files_check.py # CR-007/D8 - drawings upload + real offline 36 checks
python tests/sticky_bar_check.py # B6 - save reachable on every wizard step 12 checks python tests/sticky_bar_check.py # B6 - save reachable on every wizard step 12 checks
python tests/usage_check.py # D5 - one analytics core, admin report 15 checks python tests/usage_check.py # D5 - one analytics core, admin report 15 checks
@@ -312,6 +312,8 @@ The August 20 integration adds:
```bash ```bash
python tests/assets_check.py # D11 - Micron picker: read-only, degrades 31 checks python tests/assets_check.py # D11 - Micron picker: read-only, degrades 31 checks
python tests/critical_reopen_check.py # BL-021 - on-hold mail reaches PM + CM 11 checks
python tests/console_dialogs_check.py # BL-024 - consoles/launcher: 21 natives -> 0 17 checks
``` ```
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live **Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live

View File

@@ -447,7 +447,7 @@ deliberately deferred.
what is left is `field.html`'s `.fld-search`, and `T9.5` should re-measure that one the what is left is `field.html`'s `.fld-search`, and `T9.5` should re-measure that one the
same way rather than inheriting this entry's wording. same way rather than inheriting this entry's wording.
### BL-020 — Switching from the SOP wizard to the creator can now prompt to leave ### BL-020 — CLOSED (decided 2026-08-20: keep it) — the wizard-exit prompt stays
- **Found during:** T7.1 - **Found during:** T7.1
- **Where:** `html/wp-autosave.js:96` (the `beforeunload` guard), reached from the - **Where:** `html/wp-autosave.js:96` (the `beforeunload` guard), reached from the
@@ -474,7 +474,7 @@ deliberately deferred.
kept, `T7.2`'s side navigation is the place to make saving obvious enough that kept, `T7.2`'s side navigation is the place to make saving obvious enough that
the prompt stops being a surprise. the prompt stops being a surprise.
### BL-021 — `project_sop_team()` reads a path `pushSOP` never writes ### BL-021 — CLOSED 2026-08-20 (`project_sop_team()` reads nested-first; `critical_reopen_check` 11, sink-verified)
- **Found during:** T7.6 - **Found during:** T7.6
- **Where:** `server/app.py`, `project_sop_team()` - **Where:** `server/app.py`, `project_sop_team()`
@@ -491,7 +491,7 @@ deliberately deferred.
- **Suggested wave or follow-up:** wave 9 backlog sweep (`T9.9`), verified with - **Suggested wave or follow-up:** wave 9 backlog sweep (`T9.9`), verified with
the `tests/qa_gate_check.py` sink pattern. the `tests/qa_gate_check.py` sink pattern.
### BL-022 — F6's "roughly two screen heights": 2.17 against a strict 2.0 ### BL-022 — CLOSED 2026-08-20 (strict 2.0; the chrome compressed to 1,784px = 1.98 screens; form_structure_check 51/51 for the first time)
- **Found during:** T7.2, re-measured at the wave 7 exit - **Found during:** T7.2, re-measured at the wave 7 exit
- **Where:** `html/wp-creation-index.html` page chrome; `tests/form_structure_check.py` - **Where:** `html/wp-creation-index.html` page chrome; `tests/form_structure_check.py`
@@ -510,7 +510,7 @@ deliberately deferred.
becomes a small T9 task. The strict check stays red so the question cannot be becomes a small T9 task. The strict check stays red so the question cannot be
forgotten. forgotten.
### BL-023 — Productivity factor: actual against estimated hours ### BL-023 — CLOSED into D12 (decided 2026-08-20: the dashboard) — see decisions-2026-08-20.md
- **Found during:** T9.2 (logged as that task's done-when requires) - **Found during:** T9.2 (logged as that task's done-when requires)
- **Where:** future — dashboard / rollups - **Where:** future — dashboard / rollups
@@ -518,13 +518,14 @@ deliberately deferred.
hours exist on every package; nothing yet compares them. A productivity hours exist on every package; nothing yet compares them. A productivity
factor (actual ÷ estimated, rolled up by discipline / building / type the way factor (actual ÷ estimated, rolled up by discipline / building / type the way
CR-018 rolls cost) is the measurement Marlena's tracking exists to enable. CR-018 rolls cost) is the measurement Marlena's tracking exists to enable.
The rollup endpoints (`/api/wps/metrics`, `/api/projects/{id}/summary`) `/api/wps/metrics` already carries both sums, so this is a presentation
already carry both sums, so this is a presentation task, not a data one. task, not a data one. (Corrected at D12: the entry originally credited
`/api/projects/{id}/summary` too, which carries no hours at all.)
- **Why not now:** new scope — needs its own item id per the working rules, and - **Why not now:** new scope — needs its own item id per the working rules, and
a product conversation about where it displays and who reads it. a product conversation about where it displays and who reads it.
- **Suggested wave or follow-up:** next revision; needs Nick for placement. - **Suggested wave or follow-up:** next revision; needs Nick for placement.
### BL-024 — 21 native dialogs remain on the operator consoles and the launcher ### BL-024 — CLOSED 2026-08-20 (wp-dialog.js, the T7.9 kit shared; 21 -> 0; `console_dialogs_check` 17)
- **Found during:** T9.5 (the audit's dialog count) - **Found during:** T9.5 (the audit's dialog count)
- **Where:** `admin.js` (6), `users.js` (10), `index.html` (5) - **Where:** `admin.js` (6), `users.js` (10), `index.html` (5)
@@ -537,7 +538,7 @@ deliberately deferred.
drive-by CLAUDE.md forbids; the audit's job was to measure and document. drive-by CLAUDE.md forbids; the audit's job was to measure and document.
- **Suggested wave or follow-up:** next revision, one task, using the T7.9 kit. - **Suggested wave or follow-up:** next revision, one task, using the T7.9 kit.
### BL-025 — The second brand blue survives as one rgba focus tint in help.js ### BL-025 — CLOSED 2026-08-20 (tint rebased onto THE blue; color_check greps space-free spellings)
- **Found during:** the 2026-08-20 transparency fix (undefined-token sweep) - **Found during:** the 2026-08-20 transparency fix (undefined-token sweep)
- **Where:** `help.js`, the help-centre search input's `:focus` rule: - **Where:** `help.js`, the help-centre search input's `:focus` rule:

View File

@@ -53,3 +53,50 @@ Cody's behaviour and moves it onto the R2 idiom:
a malformed URL's error text can quote password fragments. a malformed URL's error text can quote password fragments.
- **Unconfigured is not an error.** Local dev and the demo DB run with the picker in - **Unconfigured is not an error.** Local dev and the demo DB run with the picker in
manual mode; nothing in the suite requires the catalog to exist. manual mode; nothing in the suite requires the catalog to exist.
---
# The evening decisions (same day)
Six answers from Nick, given in one message. Recorded verbatim in intent; each
names the item it settles. One new item id is assigned (D12); everything else
amends or closes an existing question.
## The answers
1. **BL-022 — "strict 2.0."** F6/D3's "roughly two screen heights" means
**2.0**, not 2.17. The overage is chrome (~154px: the context bar, the
release banner's spacing, header/toolbar padding), so this becomes a build
task: compress the chrome without deleting what other items placed
deliberately (A2's one-warning banner and the SOP identity strip STAY —
they get denser, not removed). `form_structure_check`'s red check flips
green by the page actually fitting, not by moving the bar.
2. **Hold from Draft/Scheduled — "no, leave as is."** The hold branch stays
reachable from any status. T7.3's raised question is closed; the shipped
behaviour is the decided behaviour.
3. **CR-014 email bodies — links back to the system; customer context is
allowed, confidential documents are not.** The T7.6-era rule ("no customer
IP in emails") is refined: naming the customer, the project, the package
and where the work happens is fine; what must never be embedded is
confidential document CONTENT (drawings, attachments, scope text). Every
work-package email carries a deep link back to the package in the system.
Build task, sink-verified.
4. **CR-008 merged-PDF export — known issue, not a build.** The export keeps
inline images + listed PDF attachments. Recorded as KNOWN-ISSUES.md §3 so
the limitation is a commitment, not a surprise.
5. **BL-023 → D12 — the productivity factor gets a spot on the dashboard.**
Placement delegated ("find a spot on the dashboard"). New item id **D12**:
actual ÷ estimated hours, from data the rollup endpoints already carry.
6. **BL-020 — "keep it."** The unsaved-work prompt on leaving the wizard
stays. Closed as decided-keep; no build.
Plus: **"do what's left on the housekeeping"** — BL-021 (the
critical-reopen recipient bug), BL-024 (the 21 console/launcher dialogs onto
the shared kit), BL-025 (the last second-blue tint + the widened check), and
S13 (seed_demo sign-in) are approved to build now, one commit each, on
`feat/wp-suite-r3-housekeeping`.

View File

@@ -180,6 +180,6 @@ The script authenticates like a client; the server does not get weaker.
- [ ] `F1`, `F3`, `F4` fully resolved and confirmed against the wave 0 baseline screenshots - [ ] `F1`, `F3`, `F4` fully resolved and confirmed against the wave 0 baseline screenshots
- [ ] `F2` and `F5` contained, with their real fixes referenced (`T2.2`, `T3.4`) - [ ] `F2` and `F5` contained, with their real fixes referenced (`T2.2`, `T3.4`)
- [ ] `S13` fixed and seeding works - [x] `S13` fixed and seeding works (ticked 2026-08-20: the box was missed at the wave exit; re-verified end to end - sign-in, seed, --clean)
- [ ] no new `<div onclick>`, no new raw hex values, no new `alert()` calls introduced - [ ] no new `<div onclick>`, no new raw hex values, no new `alert()` calls introduced
- [ ] `F6` untouched — it is a structural problem fixed by section tabs in `T7.2` - [ ] `F6` untouched — it is a structural problem fixed by section tabs in `T7.2`

View File

@@ -217,7 +217,8 @@
<script src="wp-usage.js"></script> <script src="wp-usage.js"></script>
<script src="console-util.js"></script> <script src="console-util.js"></script>
<script src="admin.js"></script> <script src="wp-dialog.js"></script>
<script src="admin.js"></script>
<!-- The app bar's project switcher reads ProjectData; without this the bar on this <!-- 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 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. --> parse before wp-chrome.js, which reads it as it mounts. -->

View File

@@ -146,7 +146,9 @@ async function seedDemo(){
snapshot(); snapshot();
} }
async function cleanDemo(){ async function cleanDemo(){
if(!confirm('Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?')) return; if(!(await wpConfirmDialog({title:'Delete demo data',
message:'Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?',
okLabel:'Delete them'}))) return;
const o=document.getElementById('demo-out'); o.innerHTML=''; const o=document.getElementById('demo-out'); o.innerHTML='';
// archived=all, or an archived DEMO-/SMOKE- project becomes unreachable from // archived=all, or an archived DEMO-/SMOKE- project becomes unreachable from
// this button — the default list hides it and nothing else here can delete it. // this button — the default list hides it and nothing else here can delete it.
@@ -250,10 +252,12 @@ async function archiveProject(id, name, archived){
'• Nothing is deleted. Unarchive here at any time to bring it back.' '• Nothing is deleted. Unarchive here at any time to bring it back.'
: 'Unarchive “'+name+'”?\n\n'+ : 'Unarchive “'+name+'”?\n\n'+
'It becomes visible in the pickers again and can be edited as normal.'; 'It becomes visible in the pickers again and can be edited as normal.';
if(!confirm(ask)) return; if(!(await wpConfirmDialog({title:(archived?'Archive':'Unarchive')+' project',
message:ask, okLabel:archived?'Archive':'Unarchive'}))) return;
const { status, json } = await api('POST','/api/projects/'+id+'/archive',{archived:!!archived}); const { status, json } = await api('POST','/api/projects/'+id+'/archive',{archived:!!archived});
if(status===200) loadProjects(); if(status===200) loadProjects();
else alert('Could not '+(archived?'archive':'unarchive')+' '+name+': '+((json && json.detail)||('HTTP '+status))); else wpAlertDialog({title:(archived?'Archive':'Unarchive')+' failed',
message:'Could not '+(archived?'archive':'unarchive')+' '+name+': '+((json && json.detail)||('HTTP '+status))});
} }
// Named deleteProjectAdmin, not deleteProject: every function in this file is a // Named deleteProjectAdmin, not deleteProject: every function in this file is a
@@ -261,13 +265,16 @@ async function archiveProject(id, name, archived){
// enough to collide with one of them later. The -Admin suffix also says which of the // enough to collide with one of them later. The -Admin suffix also says which of the
// two project deletions this is — the console's, not a project member's. // two project deletions this is — the console's, not a project member's.
async function deleteProjectAdmin(id, name){ async function deleteProjectAdmin(id, name){
if(!confirm('DELETE “'+name+' permanently?\n\n'+ if(!(await wpConfirmDialog({title:'Delete project permanently',
message:'DELETE “'+name+'” permanently?\n\n'+
'Its SOP, EVERY work package on it and every access assignment are deleted with it '+ 'Its SOP, EVERY work package on it and every access assignment are deleted with it '+
'(database cascade). This cannot be undone.\n\n'+ '(database cascade). This cannot be undone.\n\n'+
'If you only want it out of the way, cancel and use Archive instead.')) return; 'If you only want it out of the way, cancel and use Archive instead.',
okLabel:'Delete permanently'}))) return;
const { status, json } = await api('DELETE','/api/projects/'+id); const { status, json } = await api('DELETE','/api/projects/'+id);
if(status===200) loadProjects(); if(status===200) loadProjects();
else alert('Could not delete '+name+': '+((json && json.detail)||('HTTP '+status))); else wpAlertDialog({title:'Delete failed',
message:'Could not delete '+name+': '+((json && json.detail)||('HTTP '+status))});
} }
// ── default members on new projects ───────────────────────────────────────────── // ── default members on new projects ─────────────────────────────────────────────
@@ -373,7 +380,8 @@ async function setAutoAdd(id, username){
_defMemUsers = _defMemUsers.map(u => u.id===json.id ? json : u); _defMemUsers = _defMemUsers.map(u => u.id===json.id ? json : u);
renderDefaultMembers(); renderDefaultMembers();
} else { } else {
alert('Could not change the new-project default for '+username+': '+((json && json.detail)||('HTTP '+status))); wpAlertDialog({title:'Change failed',
message:'Could not change the new-project default for '+username+': '+((json && json.detail)||('HTTP '+status))});
loadDefaultMembers(); loadDefaultMembers();
} }
} }

View File

@@ -139,7 +139,7 @@
.ui-help-search{ flex:1; position:relative; max-width:420px; } .ui-help-search{ flex:1; position:relative; max-width:420px; }
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid var(--cds-border-strong); border-radius:0; .ui-help-search input{ width:100%; padding:8px 12px; border:1px solid var(--cds-border-strong); border-radius:0;
font-size:13px; outline:none; background:var(--cds-layer-accent); } font-size:13px; outline:none; background:var(--cds-layer-accent); }
.ui-help-search input:focus{ border-color:var(--cds-focus); background:var(--cds-layer); box-shadow:0 0 0 2px rgba(37,99,214,.15); } .ui-help-search input:focus{ border-color:var(--cds-focus); background:var(--cds-layer); box-shadow:0 0 0 2px rgba(15,98,254,.15); }
.ui-help-head .ui-help-x{ margin-left:auto; background:none; border:none; font-size:20px; cursor:pointer; color:var(--cds-text-secondary); line-height:1; } .ui-help-head .ui-help-x{ margin-left:auto; background:none; border:none; font-size:20px; cursor:pointer; color:var(--cds-text-secondary); line-height:1; }
.ui-help-wrap{ display:flex; flex:1; min-height:0; } .ui-help-wrap{ display:flex; flex:1; min-height:0; }
.ui-help-nav{ width:230px; flex:none; border-right:1px solid var(--cds-border-subtle); overflow:auto; padding:10px 8px; background:var(--cds-layer-accent); } .ui-help-nav{ width:230px; flex:none; border-right:1px solid var(--cds-border-subtle); overflow:auto; padding:10px 8px; background:var(--cds-layer-accent); }

View File

@@ -593,6 +593,7 @@
<script src="feedback-config.js"></script> <script src="feedback-config.js"></script>
<script src="project-data.js"></script> <script src="project-data.js"></script>
<script src="help.js"></script> <script src="help.js"></script>
<script src="wp-dialog.js"></script>
<script> <script>
// ── PROJECT SELECTION ───────────────────────────────────────────────────── // ── PROJECT SELECTION ─────────────────────────────────────────────────────
const esc = ProjectData.esc; const esc = ProjectData.esc;
@@ -1046,7 +1047,7 @@
const text = document.getElementById('comment-text').value.trim(); const text = document.getElementById('comment-text').value.trim();
if (!text) { if (!text) {
alert('Please enter feedback.'); toast('Please enter feedback.', 'alert');
return; return;
} }
@@ -1067,7 +1068,7 @@
function exportFeedback() { function exportFeedback() {
const saved = localStorage.getItem('wp_suite_index_comments'); const saved = localStorage.getItem('wp_suite_index_comments');
const data = saved ? JSON.parse(saved) : []; const data = saved ? JSON.parse(saved) : [];
if (!data.length) { alert('No feedback to export yet.'); return; } if (!data.length) { toast('No feedback to export yet.', 'alert'); return; }
const payload = { app: 'Work Package Suite', source: 'home', exportedAt: new Date().toISOString(), comments: data }; const payload = { app: 'Work Package Suite', source: 'home', exportedAt: new Date().toISOString(), comments: data };
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const a = document.createElement('a'); const a = document.createElement('a');
@@ -1085,7 +1086,7 @@
try { try {
const inc = JSON.parse(r.result); const inc = JSON.parse(r.result);
const incoming = Array.isArray(inc) ? inc : (inc.comments || []); const incoming = Array.isArray(inc) ? inc : (inc.comments || []);
if (!incoming.length) { alert('No feedback found in that file.'); return; } if (!incoming.length) { toast('No feedback found in that file.', 'alert'); return; }
const saved = localStorage.getItem('wp_suite_index_comments'); const saved = localStorage.getItem('wp_suite_index_comments');
allComments = saved ? JSON.parse(saved) : []; allComments = saved ? JSON.parse(saved) : [];
const seen = new Set(allComments.map(c => c.timestamp + '|' + c.text)); const seen = new Set(allComments.map(c => c.timestamp + '|' + c.text));
@@ -1093,8 +1094,8 @@
incoming.forEach(c => { const k = c.timestamp + '|' + c.text; if (c.text && !seen.has(k)) { allComments.push(c); seen.add(k); added++; } }); incoming.forEach(c => { const k = c.timestamp + '|' + c.text; if (c.text && !seen.has(k)) { allComments.push(c); seen.add(k); added++; } });
localStorage.setItem('wp_suite_index_comments', JSON.stringify(allComments)); localStorage.setItem('wp_suite_index_comments', JSON.stringify(allComments));
loadComments(); loadComments();
alert('Imported ' + added + ' feedback item' + (added === 1 ? '' : 's') + '.'); toast('Imported ' + added + ' feedback item' + (added === 1 ? '' : 's') + '.');
} catch (e) { alert('Could not read that file.'); } } catch (e) { toast('Could not read that file.', 'alert'); }
ev.target.value = ''; ev.target.value = '';
}; };
r.readAsText(f); r.readAsText(f);

View File

@@ -102,7 +102,8 @@
</div> </div>
<script src="console-util.js"></script> <script src="console-util.js"></script>
<script src="users.js"></script> <script src="wp-dialog.js"></script>
<script src="users.js"></script>
<!-- The app bar's project switcher reads ProjectData; without this the bar on this <!-- 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 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. --> parse before wp-chrome.js, which reads it as it mounts. -->

View File

@@ -255,37 +255,44 @@ function projAccessCell(u){
// Each one reloads on failure so a control can never sit there showing a value the // Each one reloads on failure so a control can never sit there showing a value the
// server refused. // server refused.
async function resetPw(id, username){ async function resetPw(id, username){
const pw = prompt('New password for "'+username+'" (min 12 characters):'); // The min-12 rule was stated in the prompt label and enforced only by the
// server round-trip; the kit's validate() answers AT the input instead.
const pw = await wpPromptDialog({title:'Reset password',
message:'Set a new password for "'+username+'". Their existing sessions are signed out.',
label:'New password (min 12 characters)',
validate:v => (v && v.length >= 12) ? '' : 'At least 12 characters.'});
if(pw === null) return; if(pw === null) return;
const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw}); const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw});
if(status === 200) alert('Password reset for '+username+'. Their existing sessions are signed out.'); if(status === 200) toast('Password reset for '+username+'. Their existing sessions are signed out.');
else alert('Could not reset the password: '+apiError(status, json)); else wpAlertDialog({title:'Reset failed', message:'Could not reset the password: '+apiError(status, json)});
} }
async function toggleActive(id, makeActive){ async function toggleActive(id, makeActive){
const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive}); const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive});
if(status === 200) loadUsers(); if(status === 200) loadUsers();
else { alert('Could not change that account: '+apiError(status, json)); loadUsers(); } else { wpAlertDialog({title:'Change failed', message:'Could not change that account: '+apiError(status, json)}); loadUsers(); }
} }
async function changeRole(id, role, username){ async function changeRole(id, role, username){
const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role}); const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role});
if(status !== 200) alert('Could not change permissions for '+username+': '+apiError(status, json)); if(status !== 200) wpAlertDialog({title:'Change failed', message:'Could not change permissions for '+username+': '+apiError(status, json)});
loadUsers(); loadUsers();
} }
async function changeProjectRole(id, project_role, username){ async function changeProjectRole(id, project_role, username){
const { status, json } = await api('POST','/api/auth/users/'+id+'/project-role',{project_role}); const { status, json } = await api('POST','/api/auth/users/'+id+'/project-role',{project_role});
if(status !== 200) alert('Could not set the project role for '+username+': '+apiError(status, json)); if(status !== 200) wpAlertDialog({title:'Change failed', message:'Could not set the project role for '+username+': '+apiError(status, json)});
loadUsers(); loadUsers();
} }
async function deleteUser(id, username){ async function deleteUser(id, username){
if(!confirm('Delete user "'+username+'"?\n\nTheir account and every project assignment go with it. '+ if(!(await wpConfirmDialog({title:'Delete user',
'This cannot be undone — disable the account instead if you only want to block sign-in.')) return; message:'Delete user "'+username+'"?\n\nTheir account and every project assignment go with it. '+
'This cannot be undone — disable the account instead if you only want to block sign-in.',
okLabel:'Delete user'}))) return;
const { status, json } = await api('DELETE','/api/auth/users/'+id); const { status, json } = await api('DELETE','/api/auth/users/'+id);
if(status === 200) loadUsers(); if(status === 200) loadUsers();
else alert('Could not delete '+username+': '+apiError(status, json)); else wpAlertDialog({title:'Delete failed', message:'Could not delete '+username+': '+apiError(status, json)});
} }
// ── create ──────────────────────────────────────────────────────────────────── // ── create ────────────────────────────────────────────────────────────────────
@@ -368,7 +375,7 @@ async function createUser(){
// more the person is on, and a save leaves those others untouched. // more the person is on, and a save leaves those others untouched.
async function manageProjects(id, username){ async function manageProjects(id, username){
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects'); const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
if(status !== 200 || !json){ alert('Could not load projects: '+apiError(status, json)); return; } if(status !== 200 || !json){ wpAlertDialog({title:'Could not load projects', message:'Could not load projects: '+apiError(status, json)}); return; }
openProjectModal(id, username, json); openProjectModal(id, username, json);
} }
function closeProjectModal(){ const m = document.getElementById('proj-modal'); if(m) m.remove(); } function closeProjectModal(){ const m = document.getElementById('proj-modal'); if(m) m.remove(); }
@@ -453,7 +460,7 @@ function openProjectModal(userId, username, data){
const { status, json } = await api('PUT','/api/auth/users/'+userId+'/projects', const { status, json } = await api('PUT','/api/auth/users/'+userId+'/projects',
{ project_ids: ids, roles: roleMap }); { project_ids: ids, roles: roleMap });
if(status === 200){ closeProjectModal(); loadUsers(); } if(status === 200){ closeProjectModal(); loadUsers(); }
else alert('Save failed: '+apiError(status, json)); else wpAlertDialog({title:'Save failed', message:'Save failed: '+apiError(status, json)});
}; };
} }

View File

@@ -3777,6 +3777,17 @@ function renderDashboard(){
${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')} ${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')}
${card('Est. hrs', Math.round(estH))} ${card('Est. hrs', Math.round(estH))}
${card('Actual hrs', Math.round(actH))} ${card('Actual hrs', Math.round(actH))}
${(()=>{
// D12 (was BL-023): the productivity factor - actual against estimated,
// the measure CR-017's tracking exists to enable. At or under 1.0 the
// work beat the estimate (green); over it (red). Both hour fields are
// optional, so with nothing to divide the card shows an em dash rather
// than vanishing - a metric that disappears reads as "no such measure",
// not "nothing logged yet". Server sums (B4), same as its neighbours.
const pf = (estH > 0 && actH > 0) ? actH / estH : null;
return card('Productivity (act/est)', pf === null ? '—' : pf.toFixed(2),
pf === null ? '' : (pf <= 1 ? 'dm-green' : 'dm-red'));
})()}
</div>`; </div>`;
// status + discipline breakdown chips (status chips also filter the board) // status + discipline breakdown chips (status chips also filter the board)

View File

@@ -154,7 +154,7 @@
after .main is what lets it be a sticky column without wrapping the layout. */ after .main is what lets it be a sticky column without wrapping the layout. */
.wp-layout { display: flex; flex-direction: column; width: 100%; margin: 0; } .wp-layout { display: flex; flex-direction: column; width: 100%; margin: 0; }
.main { min-width: 0; max-width: none; margin: 0; .main { min-width: 0; max-width: none; margin: 0;
padding: 22px 28px 72px calc(var(--nav-w,288px) + 28px); padding: 14px 28px 72px calc(var(--nav-w,288px) + 28px); /* F6: top pad only; bottom stays clear of the sticky bar */
transition: padding-left .18s ease; } transition: padding-left .18s ease; }
.section { display: none; } .section { display: none; }
@@ -308,7 +308,7 @@
.deliv-text .dt-sub { display: block; font-size: 11px; color: var(--text-muted); margin-top: 1px; } .deliv-text .dt-sub { display: block; font-size: 11px; color: var(--text-muted); margin-top: 1px; }
/* ── NAV ── */ /* ── NAV ── */
.nav-row { display: flex; justify-content: space-between; align-items: center; padding-top: 24px; margin-top: 24px; border-top: 1px solid var(--border); } .nav-row { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; margin-top: 14px; border-top: 1px solid var(--border); } /* F6 */
.btn { .btn {
padding: 10px 22px; border-radius: var(--radius); font-family: var(--mono); font-size: 11px; font-weight: 600; padding: 10px 22px; border-radius: var(--radius); font-family: var(--mono); font-size: 11px; font-weight: 600;
letter-spacing: .08em; cursor: pointer; border: 1px solid; transition: all .15s; letter-spacing: .08em; cursor: pointer; border: 1px solid; transition: all .15s;
@@ -537,7 +537,7 @@
border-radius:var(--radius); padding:7px 10px; font-size:11px; } border-radius:var(--radius); padding:7px 10px; font-size:11px; }
/* ── CREATION TOOL ───────────────────────────────────────────────── */ /* ── CREATION TOOL ───────────────────────────────────────────────── */
.ctx-bar { max-width:none; margin:0; padding:12px 28px 12px calc(var(--nav-w,288px) + 28px); display:flex; align-items:center; gap:20px; .ctx-bar { max-width:none; margin:0; padding:7px 28px 7px calc(var(--nav-w,288px) + 28px); display:flex; align-items:center; gap:20px; /* F6: denser, still the SOP identity strip */
border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; } border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; }
.ctx-empty { color:var(--text-muted); font-size:13px; } .ctx-empty { color:var(--text-muted); font-size:13px; }
.ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; } .ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; }
@@ -575,7 +575,7 @@
/* ── WORK PACKAGE FORM ───────────────────────────────────────────── */ /* ── WORK PACKAGE FORM ───────────────────────────────────────────── */
.sop-hint { color:var(--accent) !important; } .sop-hint { color:var(--accent) !important; }
.release-banner { max-width:none; margin:0; padding:0 28px 0 calc(var(--nav-w,288px) + 28px); } .release-banner { max-width:none; margin:0; padding:0 28px 0 calc(var(--nav-w,288px) + 28px); }
.release-banner .rb-inner { margin-top:14px; border-radius:var(--radius); padding:11px 16px; font-size:13px; font-weight:600; .release-banner .rb-inner { margin-top:8px; border-radius:var(--radius); padding:8px 16px; font-size:13px; font-weight:600; /* F6: A2's one warning, denser */
display:flex; align-items:center; gap:10px; flex-wrap:wrap; } display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid var(--wp-status-success-border-b); } .rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid var(--wp-status-success-border-b); }
.rb-notready { background:var(--accent-amber-dim); color:var(--accent-amber); border:1px solid var(--wp-status-warning-border-b); } .rb-notready { background:var(--accent-amber-dim); color:var(--accent-amber); border:1px solid var(--wp-status-warning-border-b); }
@@ -758,7 +758,13 @@
/* A collapsed section should read as a ROW in a list, not as a box with one line /* A collapsed section should read as a ROW in a list, not as a box with one line
in it. Eleven of them at the card's own 28px padding is 660px of nothing - in it. Eleven of them at the card's own 28px padding is 660px of nothing -
which is a third of what F6 was measuring, arriving by a different door. */ which is a third of what F6 was measuring, arriving by a different door. */
/* F6 strict 2.0: a collapsed row is 36px on fine pointers - 13 of them at
rest is where most of the two-screens overage lived. Coarse pointers keep
the taller row below (the 44px tablet target, C1). */
.card.collapsed { padding-top: 5px; padding-bottom: 5px; }
@media (pointer: coarse) {
.card.collapsed { padding-top: 10px; padding-bottom: 10px; } .card.collapsed { padding-top: 10px; padding-bottom: 10px; }
}
.card.collapsed .section-header { margin-bottom: 0; padding-bottom: 0; border-bottom: 0; } .card.collapsed .section-header { margin-bottom: 0; padding-bottom: 0; border-bottom: 0; }
.card.collapsed .sub-heading { margin-bottom: 0; } .card.collapsed .sub-heading { margin-bottom: 0; }

172
html/wp-dialog.js Normal file
View File

@@ -0,0 +1,172 @@
/* Dialog kit + toast, shared (BL-024, 2026-08-20).
*
* The T7.9 kit, extracted for the pages the S1 tasks never named: the launcher
* (index.html), the admin console and the user console carried 21 native
* dialogs between them. Same contract as the creator's copy:
*
* wpConfirmDialog({title, message, okLabel, cancelLabel}) -> Promise<bool>
* wpPromptDialog({title, message, label, value, validate}) -> Promise<string|null>
* wpAlertDialog({title, message, okLabel}) -> Promise (value not meaningful)
* toast(msg, kind) kind 'alert' interrupts (role=alert); default role=status
*
* Self-contained on purpose: markup and styles are injected on first use, the
* styles are theme tokens only (the token rule), and the class names are its
* own (wp-dlg-*) so the consoles' existing .modal styles are never touched.
* The creator keeps its inline copy - it owns the same-id markup in its HTML -
* so everything here is guarded: if the page already has the kit, this file
* defines nothing.
*/
(function (global) {
'use strict';
if (typeof global.wpConfirmDialog === 'function') return; // the creator's copy wins
var CSS =
'#wp-dlg-overlay{position:fixed;inset:0;background:var(--wp-scrim-cool-strong);' +
'display:none;align-items:center;justify-content:center;z-index:10500;padding:20px;}' +
'#wp-dlg-overlay.open{display:flex;}' +
'.wp-dlg{background:var(--cds-layer);color:var(--cds-text-primary);max-width:480px;width:100%;' +
'border-radius:8px;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;' +
'font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;font-size:14px;}' +
'.wp-dlg-head{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;' +
'border-bottom:1px solid var(--cds-border-subtle);font-weight:700;}' +
'.wp-dlg-x{background:none;border:none;font-size:18px;line-height:1;cursor:pointer;' +
'color:var(--cds-text-secondary);padding:4px 6px;}' +
'.wp-dlg-x:focus-visible{outline:2px solid var(--cds-focus);outline-offset:1px;}' +
'.wp-dlg-body{padding:16px 18px;}' +
'#wp-dlg-msg{white-space:pre-wrap;line-height:1.5;}' +
'#wp-dlg-input-wrap{margin-top:10px;}' +
'#wp-dlg-input-wrap label{display:block;font-size:12px;margin-bottom:4px;color:var(--cds-text-secondary);}' +
'#wp-dlg-input{width:100%;box-sizing:border-box;padding:8px 10px;font:inherit;' +
'border:1px solid var(--cds-border-strong);border-radius:4px;background:var(--cds-field);}' +
'#wp-dlg-input:focus{outline:2px solid var(--cds-focus);outline-offset:-1px;}' +
'#wp-dlg-err{color:var(--cds-text-error);font-size:12px;font-weight:600;margin-top:4px;}' +
'#wp-dlg-err:empty{display:none;}' +
'.wp-dlg-foot{display:flex;justify-content:flex-end;gap:10px;padding:12px 18px;' +
'border-top:1px solid var(--cds-border-subtle);}' +
'.wp-dlg-btn{font:inherit;font-weight:600;padding:8px 16px;border-radius:6px;cursor:pointer;' +
'border:1px solid var(--cds-border-strong);background:var(--cds-layer);color:var(--cds-text-primary);}' +
'.wp-dlg-btn.primary{background:var(--cds-interactive-01);border-color:var(--cds-interactive-01);' +
'color:var(--cds-text-on-color);}' +
'.wp-dlg-btn:focus-visible{outline:2px solid var(--cds-focus);outline-offset:1px;}' +
'@media(pointer:coarse){.wp-dlg-btn{min-height:44px;}.wp-dlg-x{min-width:44px;min-height:44px;}}' +
'#toast{position:fixed;bottom:26px;left:50%;transform:translateX(-50%) translateY(20px);' +
'background:var(--cds-background-inverse);color:var(--cds-text-inverse);padding:9px 16px;' +
'border-radius:6px;font-size:13px;opacity:0;transition:opacity .18s,transform .18s;' +
'pointer-events:none;z-index:10600;max-width:min(480px,calc(100vw - 32px));}' +
'#toast.show{opacity:1;transform:translateX(-50%) translateY(0);}';
function ensure() {
var ov = document.getElementById('wp-dlg-overlay');
if (ov) return ov;
var st = document.createElement('style');
st.textContent = CSS;
document.head.appendChild(st);
ov = document.createElement('div');
ov.id = 'wp-dlg-overlay';
ov.setAttribute('role', 'dialog');
ov.setAttribute('aria-modal', 'true');
ov.setAttribute('aria-labelledby', 'wp-dlg-title');
ov.innerHTML =
'<div class="wp-dlg">' +
'<div class="wp-dlg-head"><div id="wp-dlg-title"></div>' +
'<button type="button" class="wp-dlg-x" id="wp-dlg-x" title="Cancel" aria-label="Cancel">✕</button></div>' +
'<div class="wp-dlg-body">' +
'<div id="wp-dlg-msg"></div>' +
'<div id="wp-dlg-input-wrap">' +
'<label id="wp-dlg-label" for="wp-dlg-input"></label>' +
'<input type="text" id="wp-dlg-input">' +
'<div id="wp-dlg-err" role="alert"></div>' +
'</div>' +
'</div>' +
'<div class="wp-dlg-foot">' +
'<button type="button" class="wp-dlg-btn" id="wp-dlg-cancel">Cancel</button>' +
'<button type="button" class="wp-dlg-btn primary" id="wp-dlg-ok">OK</button>' +
'</div>' +
'</div>';
document.body.appendChild(ov);
document.getElementById('wp-dlg-x').addEventListener('click', cancel);
document.getElementById('wp-dlg-cancel').addEventListener('click', cancel);
document.getElementById('wp-dlg-ok').addEventListener('click', ok);
document.getElementById('wp-dlg-input').addEventListener('keydown', function (e) {
if (e.key === 'Enter') ok();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && ov.classList.contains('open')) cancel();
});
return ov;
}
var resolveFn = null;
function open(opts) {
return new Promise(function (res) {
resolveFn = res;
var ov = ensure();
ov._opts = opts || {};
document.getElementById('wp-dlg-title').textContent = opts.title || 'Confirm';
document.getElementById('wp-dlg-msg').textContent = opts.message || '';
document.getElementById('wp-dlg-input-wrap').style.display = opts.input ? '' : 'none';
document.getElementById('wp-dlg-label').textContent = opts.label || '';
var inp = document.getElementById('wp-dlg-input');
inp.value = (opts.value != null ? String(opts.value) : '');
document.getElementById('wp-dlg-err').textContent = '';
document.getElementById('wp-dlg-ok').textContent = opts.okLabel || 'OK';
var cb = document.getElementById('wp-dlg-cancel');
cb.textContent = opts.cancelLabel || 'Cancel';
cb.style.display = opts.okOnly ? 'none' : '';
ov.classList.add('open');
setTimeout(function () {
(opts.input ? inp : document.getElementById('wp-dlg-ok')).focus();
}, 0);
});
}
function close(val) {
var ov = document.getElementById('wp-dlg-overlay');
if (ov) ov.classList.remove('open');
var r = resolveFn;
resolveFn = null;
if (r) r(val);
}
function ok() {
var ov = document.getElementById('wp-dlg-overlay');
var opts = (ov && ov._opts) || {};
if (opts.input) {
var v = document.getElementById('wp-dlg-input').value;
if (opts.validate) {
var err = opts.validate(v);
if (err) {
document.getElementById('wp-dlg-err').textContent = err;
document.getElementById('wp-dlg-input').focus();
return;
}
}
close(v);
} else close(true);
}
function cancel() {
var ov = document.getElementById('wp-dlg-overlay');
var opts = (ov && ov._opts) || {};
close(opts.input ? null : false);
}
global.wpConfirmDialog = function (opts) { return open(Object.assign({}, opts, { input: false })); };
global.wpPromptDialog = function (opts) { return open(Object.assign({}, opts, { input: true })); };
global.wpAlertDialog = function (opts) { return open(Object.assign({}, opts, { input: false, okOnly: true })); };
if (typeof global.toast !== 'function') {
// S10's rule, same as the creator: role BEFORE text, 'alert' interrupts.
global.toast = function (msg, kind) {
ensure();
var t = document.getElementById('toast');
if (!t) { t = document.createElement('div'); t.id = 'toast'; document.body.appendChild(t); }
t.setAttribute('role', kind === 'alert' ? 'alert' : 'status');
t.textContent = msg;
t.classList.add('show');
clearTimeout(global.toast._t);
global.toast._t = setTimeout(function () { t.classList.remove('show'); }, 2200);
};
}
})(window);

View File

@@ -458,15 +458,41 @@ def wp_link(db: Session, wp: "models.WorkPackage") -> str:
return (base + path) if base else path return (base + path) if base else path
def wp_titled(wp: "models.WorkPackage") -> str:
"""Number — title, for a message body. Decided 2026-08-20: the title is
customer CONTEXT and may ride in mail; document CONTENT may not."""
t = (wp.subject or "").strip()
n = wp.number or "a work package"
return f"{n}{t}" if t else n
def wp_where(wp: "models.WorkPackage") -> str:
"""Where the work happens, for a message body: the CR-004 structured
fields (stored as paths — stable, and readable to the people these mails
address), else the pre-CR-004 free text. Empty string when unset, and
callers drop the line entirely rather than mail 'Where: '."""
data = wp.data or {}
parts = [str(data.get(d) or "").strip() for d in LOCATION_DIMENSIONS]
parts = [p for p in parts if p]
return " / ".join(parts) if parts else str(data.get("location") or "").strip()
def _where_line(wp: "models.WorkPackage") -> str:
w = wp_where(wp)
return f"Where: {w}\n" if w else ""
def assign_body(assignee: "models.User", wp: "models.WorkPackage", actor: "models.User", link: str) -> str: def assign_body(assignee: "models.User", wp: "models.WorkPackage", actor: "models.User", link: str) -> str:
# Deliberately minimal — a WP number + a link, NOT the package contents (keeps # Number, title and location — customer context, allowed since the
# customer IP inside the app, behind login). # 2026-08-20 decision (decisions-2026-08-20.md). Contents stay behind
# the link: no scope text, no descriptions, no attachments.
who = actor.full_name or actor.username who = actor.full_name or actor.username
name = assignee.full_name or assignee.username name = assignee.full_name or assignee.username
return ( return (
f"Hi {name},\n\n" f"Hi {name},\n\n"
f"{who} assigned you a work package: {wp.number or '(no number)'}.\n\n" f"{who} assigned you a work package: {wp_titled(wp)}.\n"
f"Open the Work Package Suite to view and action it:\n{link}\n\n" + _where_line(wp) +
f"\nOpen the Work Package Suite to view and action it:\n{link}\n\n"
f"— This is an automated message from the Work Package Suite." f"— This is an automated message from the Work Package Suite."
) )
@@ -1597,15 +1623,22 @@ def project_sop_team(db: Session, project_id: Optional[str]) -> list[str]:
).first() ).first()
if not sop: if not sop:
return [] return []
proj = (sop.data or {}).get("project") or {} data = sop.data or {}
# BL-021, fixed 2026-08-20: pushSOP writes the row as data={sop, state}, so
# the project block lives at data['sop']['project']. This read was one
# level too shallow - always {} - and the critical-reopen mail never
# reached the PM or CM its docstring promises. Same tolerant read as
# project_qa_group below: nested shape first, flat shape for hand-written rows.
proj = ((data.get("sop") or {}).get("project")
or data.get("project") or {})
return [i for i in (proj.get("pmId"), proj.get("cmId")) if i] return [i for i in (proj.get("pmId"), proj.get("cmId")) if i]
def project_qa_group(db: Session, project_id: Optional[str]) -> list["models.User"]: def project_qa_group(db: Session, project_id: Optional[str]) -> list["models.User"]:
"""D2: the QA group named on the project's latest complete SOP. pushSOP writes """D2: the QA group named on the project's latest complete SOP. pushSOP writes
the row as data={sop, state}, so the project block is data['sop']['project'] - the row as data={sop, state}, so the project block is data['sop']['project'] -
note that project_sop_team above reads data['project'], which that shape never project_sop_team above read the flat shape until BL-021 was fixed
has (BL-021, logged, not fixed here).""" (2026-08-20); both now read nested-first, exactly alike."""
if not project_id: if not project_id:
return [] return []
sop = db.scalars( sop = db.scalars(
@@ -1652,14 +1685,15 @@ def enforce_qa_rejection_comment(data: Optional[dict], new_status: str,
def qa_ready_body(user: "models.User", wp: "models.WorkPackage", def qa_ready_body(user: "models.User", wp: "models.WorkPackage",
actor: "models.User", link: str) -> str: actor: "models.User", link: str) -> str:
# A WP number and a deep link - NOT the package contents. The task text asked # Number, title and location ride in the body — the 2026-08-20 decision
# for location and a scope summary, but the done-when list (and the standing # restored the location the T7.6 done-when had excluded. The SCOPE summary
# rule) says no customer IP in a message body; the link is the summary. # stays out: scope text is document content, and the link is its summary.
who = actor.full_name or actor.username who = actor.full_name or actor.username
name = user.full_name or user.username name = user.full_name or user.username
return ( return (
f"Hi {name},\n\n" f"Hi {name},\n\n"
f"{who} moved {wp.number or 'a work package'} to Ready for QA.\n" f"{who} moved {wp_titled(wp)} to Ready for QA.\n"
+ _where_line(wp) +
f"It is in the QA queue waiting to be accepted or returned.\n\n" f"It is in the QA queue waiting to be accepted or returned.\n\n"
f"Open it here:\n{link}\n\n" f"Open it here:\n{link}\n\n"
f"— This is an automated message from the Work Package Suite." f"— This is an automated message from the Work Package Suite."
@@ -1672,7 +1706,8 @@ def qa_reject_body(user: "models.User", wp: "models.WorkPackage",
name = user.full_name or user.username name = user.full_name or user.username
return ( return (
f"Hi {name},\n\n" f"Hi {name},\n\n"
f"{who} returned {wp.number or 'a work package'} from Ready for QA to In Progress.\n" f"{who} returned {wp_titled(wp)} from Ready for QA to In Progress.\n"
+ _where_line(wp) +
f"The reason is recorded on the package.\n\n" f"The reason is recorded on the package.\n\n"
f"Open it here:\n{link}\n\n" f"Open it here:\n{link}\n\n"
f"— This is an automated message from the Work Package Suite." f"— This is an automated message from the Work Package Suite."
@@ -1705,18 +1740,20 @@ def notify_qa_transition(db: Session, wp: "models.WorkPackage",
def hold_body(user: "models.User", wp: "models.WorkPackage", names: list[str], def hold_body(user: "models.User", wp: "models.WorkPackage", names: list[str],
actor: "models.User", link: str) -> str: actor: "models.User", link: str) -> str:
# Constraint names and a WP number only — no package contents, same rule as the # Constraint names, number, title and location — customer context per the
# assignment mail. # 2026-08-20 decision. No package contents; the link carries those.
who = user.full_name or user.username who = user.full_name or user.username
by = actor.full_name or actor.username by = actor.full_name or actor.username
which = ", ".join(names) which = ", ".join(names)
return ( return (
f"Hi {who},\n\n" f"Hi {who},\n\n"
f"A critical constraint was reopened on {wp.number or 'a work package'} " f"A critical constraint was reopened on {wp_titled(wp)} "
f"after it was released to the field, so the package is on hold.\n\n" f"after it was released to the field, so the package is on hold.\n\n"
f"Constraint: {which}\n" f"Constraint: {which}\n"
+ _where_line(wp) +
f"Reopened by: {by}\n\n" f"Reopened by: {by}\n\n"
f"Open the package:\n{link}\n" f"Open the package:\n{link}\n\n"
f"— This is an automated message from the Work Package Suite."
) )
@@ -1733,7 +1770,7 @@ def kitting_body(user: "models.User", wp: "models.WorkPackage", actor: "models.U
or (wp.data or {}).get("mimoLoc") or "").strip() or "not set" or (wp.data or {}).get("mimoLoc") or "").strip() or "not set"
return ( return (
f"Hi {name},\n\n" f"Hi {name},\n\n"
f"{who} moved kitting on {wp.number or 'a work package'} from " f"{who} moved kitting on {wp_titled(wp)} from "
f"{old_status or 'Not Started'} to {new_status or 'Not Started'}.\n" f"{old_status or 'Not Started'} to {new_status or 'Not Started'}.\n"
f"Delivery location: {delivery}.\n\n" f"Delivery location: {delivery}.\n\n"
f"Open it here:\n{link}\n\n" f"Open it here:\n{link}\n\n"
@@ -1749,7 +1786,7 @@ def material_request_body(user: "models.User", wp: "models.WorkPackage",
needed_line = f" needed by {needed}" if needed else "" needed_line = f" needed by {needed}" if needed else ""
return ( return (
f"Hi {name},\n\n" f"Hi {name},\n\n"
f"{who} raised a material request on {wp.number or 'a work package'}: " f"{who} raised a material request on {wp_titled(wp)}: "
f"{n_lines} line{'' if n_lines == 1 else 's'}{needed_line}.\n" f"{n_lines} line{'' if n_lines == 1 else 's'}{needed_line}.\n"
f"Delivery location: {delivery}.\n\n" f"Delivery location: {delivery}.\n\n"
f"Open it here:\n{link}\n\n" f"Open it here:\n{link}\n\n"

View File

@@ -7,11 +7,14 @@ and is NEVER stored in the database or shown in the UI.
Every notable event (e.g. a WP assignment) writes a `notifications` row — an in-app Every notable event (e.g. a WP assignment) writes a `notifications` row — an in-app
record — and, when email is on + SMTP is set, the row is delivered by email in a record — and, when email is on + SMTP is set, the row is delivered by email in a
background task. Notification bodies deliberately avoid customer IP: they carry a WP background task. Notification bodies carry customer CONTEXT — the WP number, its
number and a deep link, not the work-package contents. title, where the work happens — and a deep link, never customer document CONTENT
(scope text, descriptions, comments, attachments). Decided 2026-08-20; the link is
the summary of everything a body leaves out.
""" """
import os import os
import smtplib import smtplib
import socket
import uuid import uuid
import logging import logging
from email.message import EmailMessage from email.message import EmailMessage
@@ -107,7 +110,13 @@ def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
port = int(s.get("smtp_port") or 587) port = int(s.get("smtp_port") or 587)
user = s.get("smtp_username") or "" user = s.get("smtp_username") or ""
pw = os.getenv("SMTP_PASSWORD", "") pw = os.getenv("SMTP_PASSWORD", "")
with smtplib.SMTP(host, port, timeout=15) as srv: # local_hostname pins the EHLO name. Without it smtplib calls getfqdn() on
# EVERY connect, and that reverse-DNS lookup stalls ~5s per send whenever DNS
# is slow or unreachable - sends are sequential background tasks, so a batch
# of notifications trickled out one per five seconds. gethostname() never
# touches the network. Found 2026-08-20 when the office link dropped.
with smtplib.SMTP(host, port, timeout=15,
local_hostname=(socket.gethostname() or "wp-suite")) as srv:
if s.get("smtp_use_tls", True): if s.get("smtp_use_tls", True):
srv.starttls() srv.starttls()
if user: if user:

View File

@@ -155,6 +155,18 @@ def main():
str(shown) == str(as_root["total"]), str(shown) == str(as_root["total"]),
"shown=%r server=%r (poisoned cache said 2)" % (shown, as_root["total"])) "shown=%r server=%r (poisoned cache said 2)" % (shown, as_root["total"]))
chk("...and is therefore not the poisoned cache's 2", str(shown) != "2", shown) chk("...and is therefore not the poisoned cache's 2", str(shown) != "2", shown)
# D12: the productivity factor card, computed from the SAME server
# sums as its neighbours. Both hour fields are optional (CR-017),
# so the expected value is derived, not hardcoded: a real quotient
# when both sums exist, an em dash when either is zero.
pf_shown = page.eval(
"(()=>{const e=[...document.querySelectorAll('.dash-metric')]"
".find(x=>/Productivity/i.test(x.textContent));"
"return e?e.querySelector('.dm-val').textContent.trim():null})()")
est, act = as_root.get("est_hours") or 0, as_root.get("actual_hours") or 0
pf_want = ("%.2f" % (act / est)) if est > 0 and act > 0 else ""
chk("the D12 productivity card shows actual/estimated from the server sums",
pf_shown == pf_want, "shown=%r want=%r (est=%r act=%r)" % (pf_shown, pf_want, est, act))
print("\n3. a failed aggregate request is an error, not a zero") print("\n3. a failed aggregate request is an error, not a zero")
page.eval("""(() => { page.eval("""(() => {

View File

@@ -53,8 +53,11 @@ def main():
print("\n2. one blue, one amber") print("\n2. one blue, one amber")
theme = io.open(os.path.join(HTML, "theme-light.css"), encoding="utf-8").read() theme = io.open(os.path.join(HTML, "theme-light.css"), encoding="utf-8").read()
code = strip_comments(theme, True) code = strip_comments(theme, True)
# BL-025 widened this: the rgb spelling is compared space-free, because
# rgba(37,99,214,.15) in help.js slid past the spaced grep for months.
chk("the second brand blue (#2563d6) is gone from the theme itself", chk("the second brand blue (#2563d6) is gone from the theme itself",
"2563d6" not in code and "37, 99, 214" not in code) "2563d6" not in code.lower()
and "37,99,214" not in code.replace(" ", ""))
chk("the ninth amber (--wp-status-warning-text-alt) is deleted", chk("the ninth amber (--wp-status-warning-text-alt) is deleted",
"--wp-status-warning-text-alt" not in code) "--wp-status-warning-text-alt" not in code)
others = [] others = []
@@ -63,7 +66,8 @@ def main():
continue continue
src = strip_comments(io.open(os.path.join(HTML, name), encoding="utf-8").read(), src = strip_comments(io.open(os.path.join(HTML, name), encoding="utf-8").read(),
name.endswith(".css")) name.endswith(".css"))
if "warning-text-alt" in src or "2563d6" in src: if ("warning-text-alt" in src or "2563d6" in src.lower()
or "37,99,214" in src.replace(" ", "")):
others.append(name) others.append(name)
chk("...and no consumer still references either", not others, others) chk("...and no consumer still references either", not others, others)

View File

@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Are the consoles' and launcher's 21 native dialogs gone? — BL-024, 2026-08-20.
S1 counted 79 native dialogs app-wide; its two tasks (T5.8 wizard, T7.9
creator) removed 58 and the audit found the remaining 21 on surfaces no S1
task named: admin.js (6), users.js (10), the launcher's inline script (5).
They now go through `wp-dialog.js` — the T7.9 kit extracted as a shared,
self-injecting component (guarded so the creator's inline copy still wins on
its own page).
Static half greps the counts; browser half drives the password-reset prompt on
the users console with natives poisoned, and proves validate() answers AT the
input while the server round-trip completes end to end.
Boots its own throwaway SQLite + uvicorn + headless browser; run it alone.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import io
import os
import re
import sys
import tempfile
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
from qa_gate_check import api # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HTML = os.path.join(ROOT, "html")
def ascii_(v, n=240):
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
def strip_js(src):
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
return "\n".join(re.sub(r"(?<![:'\"])//.*$", "", ln) for ln in src.split("\n"))
def natives(name):
code = strip_js(io.open(os.path.join(HTML, name), encoding="utf-8").read())
return len(re.findall(r"(?<![\w.$])(alert|confirm|prompt)\(", code))
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
print("\n1. the counts (baseline 6 + 10 + 5 = 21)")
for name in ("admin.js", "users.js", "index.html"):
chk("%s: 0 native dialogs" % name, natives(name) == 0, natives(name))
chk("wp-dialog.js exists, has the guard, and no natives of its own",
natives("wp-dialog.js") == 0
and "typeof global.wpConfirmDialog === 'function'" in
io.open(os.path.join(HTML, "wp-dialog.js"), encoding="utf-8").read())
for page in ("index.html", "admin.html", "users.html"):
chk("%s loads the kit" % page,
'src="wp-dialog.js"' in io.open(os.path.join(HTML, page), encoding="utf-8").read())
chk("the creator keeps its own copy (it owns the same-id markup in its HTML)",
"function wpConfirmDialog" in
io.open(os.path.join(HTML, "wp-creation-app.js"), encoding="utf-8").read())
tmpdir = tempfile.mkdtemp(prefix="wpsuite-condlg-")
db_path = os.path.join(tmpdir, "check.db")
server = None
browser = None
try:
tok = seed(db_path)
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
print("\n2. the users console, natives poisoned")
browser = cdp.Browser(exe)
page = browser.page()
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.viewport(1440, 900)
page.goto(base + "/users.html")
time.sleep(2.0)
page.eval("window.alert=()=>{throw new Error('native alert reached')};"
"window.confirm=()=>{throw new Error('native confirm reached')};"
"window.prompt=()=>{throw new Error('native prompt reached')};")
chk("the console booted with a user table",
page.eval("!!document.querySelector('table')"))
page.eval("void resetPw('user_pat','pat')")
time.sleep(0.4)
chk("the reset prompt is the kit's modal, open, focused at the input",
page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
" return !!o && o.classList.contains('open')"
" && document.activeElement.id==='wp-dlg-input'; })()"))
page.eval("document.getElementById('wp-dlg-input').value='short';"
"document.getElementById('wp-dlg-ok').click()")
chk("a short password is refused AT the input - dialog stays, error says why",
page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
" return o.classList.contains('open')"
" && /12 characters/.test(document.getElementById('wp-dlg-err').textContent); })()"))
page.eval("document.getElementById('wp-dlg-input').value='CorrectHorseBattery10';"
"document.getElementById('wp-dlg-ok').click()")
time.sleep(1.2)
chk("a good answer closes the dialog and the server accepts it",
page.eval("!document.getElementById('wp-dlg-overlay').classList.contains('open')"))
chk("...announced through the kit's toast (role=status)",
page.eval("(() => { const t=document.getElementById('toast');"
" return !!t && t.getAttribute('role')==='status'"
" && /Password reset for pat/.test(t.textContent); })()"))
st, _ = api(base, "/api/auth/login", "x", "POST",
{"username": "pat", "password": "CorrectHorseBattery10"})
chk("...and the new password actually works", st == 200, st)
print("\n3. destroy needs a real yes")
page.eval("void deleteUser('user_bob','bob')")
time.sleep(0.4)
chk("the delete asks through the kit, spelling out what goes with it",
page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
" return o.classList.contains('open')"
" && /cannot be undone/.test(document.getElementById('wp-dlg-msg').textContent); })()"))
page.eval("document.getElementById('wp-dlg-cancel').click()")
time.sleep(0.6)
st, users = api(base, "/api/auth/users", tok["root"])
chk("cancel means no: bob is still an account",
st == 200 and any(u.get("username") == "bob" for u in (users or [])),
ascii_([u.get("username") for u in (users or [])]))
errs = [e for e in page.js_errors() if "beforeunload" not in e]
chk("no JavaScript errors, and no path reached a native dialog (they throw here)",
not errs, ascii_(errs[:3]))
finally:
if browser:
browser.close()
if server:
server.terminate()
print("\n" + "-" * 54)
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Does the critical-reopen mail reach the PM and CM? — BL-021, fixed 2026-08-20.
`project_sop_team()` read `sop.data['project']`, but `pushSOP` stores every row
as `data={sop, state}` — the project block is one level deeper. The lookup
returned `[]` for every real row, so the on-hold email's recipient list was
silently reduced to assignee + distribution: the PM and CM named in
`notify_critical_reopen`'s own docstring never got it, from the day it shipped.
The fixture writes the PRODUCTION shape (nested under 'sop'), because a
hand-built flat row would have passed against the bug — which is exactly how it
went unverified this long. Sink pattern from qa_gate_check, one implementation.
Boots its own throwaway SQLite + uvicorn; run it alone, not back to back.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import io
import json
import os
import re
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL # noqa: E402
from sections_check import set_sop # noqa: E402
from qa_gate_check import SmtpSink, api, wait_for # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def ascii_(v, n=280):
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
def set_team(pm_id, cm_id):
"""The PRODUCTION shape: data['sop']['project'], as pushSOP writes it."""
from server.db import SessionLocal
from server import models
with SessionLocal() as db:
sop = db.get(models.Sop, "sopA")
data = json.loads(json.dumps(sop.data or {}))
proj = data.setdefault("sop", {}).setdefault("project", {})
proj["pmId"], proj["cmId"] = pm_id, cm_id
sop.data = data
db.commit()
def main():
print("\n1. the read matches the written shape (static)")
src = io.open(os.path.join(ROOT, "server", "app.py"), encoding="utf-8").read()
fn = src[src.index("def project_sop_team"):src.index("def project_qa_group")]
chk("project_sop_team reads the nested data['sop']['project'] first",
'.get("sop")' in fn and '.get("project")' in fn)
tmpdir = tempfile.mkdtemp(prefix="wpsuite-reopen-")
db_path = os.path.join(tmpdir, "check.db")
server = None
sink = SmtpSink()
sink.start()
try:
tok = seed(db_path)
set_sop(db_path, {})
set_team("user_sue", "user_pat")
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
root = tok["root"]
api(base, "/api/settings", root, "PUT", {
"email_enabled": True, "smtp_host": "127.0.0.1", "smtp_port": sink.port,
"smtp_use_tls": False, "from_addr": "suite@sink.local",
"app_base_url": base})
print("\n2. a released package, its critical constraint reopened")
body = {
"id": "wpCR1", "project_id": "projA", "number": "CR-01",
"subject": "energize MCC-4", "status": "In Progress",
"assignee_id": "user_mix",
"data": {"location": "B-100 / Level 2",
"constraints": [{"name": "Power shutdown", "status": "cleared",
"critical": True, "comment": ""}]}}
code, _ = api(base, "/api/wps", root, "POST", body)
chk("the package saves", code == 200, code)
# The creation enqueues a wp_assigned mail delivered by a background
# task; wait for it BEFORE clearing or it leaks into the reopen count.
wait_for(lambda: len(sink.messages) >= 1, 10)
code, _ = api(base, "/api/wps/wpCR1/status", root, "POST", {"status": "Issued"})
chk("...and releases (the critical constraint is cleared)", code == 200, code)
sink.messages.clear()
body["status"] = "Issued"
body["data"]["constraints"][0]["status"] = "open"
code, wp = api(base, "/api/wps", root, "POST", body)
chk("reopening the critical constraint saves through the normal upsert",
code == 200, code)
print("\n3. the mail reaches everyone the docstring promises")
chk("three messages: assignee + PM + CM (the actor is excluded)",
wait_for(lambda: len(sink.messages) == 3, 15), len(sink.messages))
rcpts = sorted(m["to"][0] for m in sink.messages)
chk("...the PM and CM are among them - THE BL-021 fix, sink-verified",
"sue@example.test" in rcpts and "pat@example.test" in rcpts, ascii_(rcpts))
chk("...and the assignee, exactly them, nobody twice",
rcpts == ["mix@example.test", "pat@example.test", "sue@example.test"],
ascii_(rcpts))
text = next((m["text"] for m in sink.messages if "On hold:" in m["data"]), "")
chk("the body names the constraint and the package, title included (CR-014)",
"Power shutdown" in text and "CR-01" in text and "energize MCC-4" in text,
ascii_(text, 300))
chk("...and where the work happens, and a deep link to THAT package",
"B-100 / Level 2" in text
and "/wp-creation-index.html?project=projA&wp=wpCR1" in text, ascii_(text, 300))
chk("...in the house convention (the footer this body alone used to lack)",
"automated message from the Work Package Suite" in text)
_, ev = api(base, "/api/audit?entity_type=wp&entity_id=wpCR1"
"&action=constraint_reopened", root)
chk("the reopen is in the audit history", bool(ev), ascii_(ev[:1] if ev else ev))
finally:
sink.stop()
if server:
server.terminate()
print("\n" + "-" * 54)
print("%d/%d checks passed." % (len(_PASS), len(_PASS) + len(_FAIL)))
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -106,7 +106,7 @@ def main():
rcpts = sorted(m["to"][0] for m in sink.messages) rcpts = sorted(m["to"][0] for m in sink.messages)
chk("...exactly them, actor excluded", chk("...exactly them, actor excluded",
rcpts == ["pat@example.test", "sue@example.test"], ascii_(rcpts)) rcpts == ["pat@example.test", "sue@example.test"], ascii_(rcpts))
body = sink.messages[0]["data"] if sink.messages else "" body = sink.messages[0]["text"] if sink.messages else ""
chk("the mail says old status, new status and who", chk("the mail says old status, new status and who",
"In Transit" in body and "Delivered" in body and "Root" in body, "In Transit" in body and "Delivered" in body and "Root" in body,
ascii_(body, 260)) ascii_(body, 260))

View File

@@ -121,13 +121,14 @@ def main():
len((wp.get("data") or {}).get("materialRequests") or []) == 1) len((wp.get("data") or {}).get("materialRequests") or []) == 1)
chk("...and the warehouse owner is notified through the T7.6 gate", chk("...and the warehouse owner is notified through the T7.6 gate",
wait_for(lambda: any("Material request" in m["data"] for m in sink.messages), 12)) wait_for(lambda: any("Material request" in m["data"] for m in sink.messages), 12))
body = next((m for m in sink.messages if "Material request" in m["data"]), {"data": "", "to": [""]}) body = next((m for m in sink.messages if "Material request" in m["data"]),
{"data": "", "text": "", "to": [""]})
chk("...the mail goes to the owner, says the size, the date, the delivery " chk("...the mail goes to the owner, says the size, the date, the delivery "
"and carries the deep link", "and carries the deep link",
body["to"] == ["sue@example.test"] and "2 lines" in body["data"] body["to"] == ["sue@example.test"] and "2 lines" in body["text"]
and "2026-09-01" in body["data"] and "Shark cage 7" in body["data"] and "2026-09-01" in body["text"] and "Shark cage 7" in body["text"]
and ("/wp-creation-index.html?project=projA&wp=" + wp_id) in body["data"], and ("/wp-creation-index.html?project=projA&wp=" + wp_id) in body["text"],
ascii_(body["data"], 300)) ascii_(body["text"], 300))
_, ev = api(base, "/api/audit?entity_type=wp&entity_id=%s&action=material_requested" % wp_id, root) _, ev = api(base, "/api/audit?entity_type=wp&entity_id=%s&action=material_requested" % wp_id, root)
chk("...and the audit history has it", bool(ev)) chk("...and the audit history has it", bool(ev))

View File

@@ -40,7 +40,8 @@ from sections_check import set_sop # noqa: E40
from stepper_check import dismiss_dialogs # noqa: E402 from stepper_check import dismiss_dialogs # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CANARY = "FAB-9 SECRET SECTOR" # rides on the package; must never reach a body CANARY = "FAB-9 SECRET SECTOR" # location: customer CONTEXT - IN bodies since 2026-08-20
DESC_CANARY = "PULL-SCHED-CANARY-7X" # document CONTENT - must never reach a body
def ascii_(v, n=300): def ascii_(v, n=300):
@@ -87,7 +88,7 @@ class SmtpSink(threading.Thread):
self.sock.bind(("127.0.0.1", 0)) self.sock.bind(("127.0.0.1", 0))
self.sock.listen(8) self.sock.listen(8)
self.port = self.sock.getsockname()[1] self.port = self.sock.getsockname()[1]
self.messages = [] # {"to": [...], "data": str} self.messages = [] # {"to": [...], "data": wire str, "text": decoded body}
self._stop = False self._stop = False
def run(self): def run(self):
@@ -127,8 +128,22 @@ class SmtpSink(threading.Thread):
return return
if in_data: if in_data:
if line.rstrip(b"\r\n") == b".": if line.rstrip(b"\r\n") == b".":
raw = b"".join(buf)
# "data" is the wire payload (headers + body, transfer-encoded).
# "text" is the DECODED body: any non-ASCII character (the
# bodies' em-dash) switches smtplib to quoted-printable, whose
# soft line breaks split words at column 76 - a substring pin
# against "data" then fails on luck of line position. Content
# pins read "text"; header pins (Subject:) still read "data".
import email as _email
try:
_msg = _email.message_from_bytes(raw)
_text = _msg.get_payload(decode=True).decode("utf-8", "replace")
except Exception:
_text = raw.decode("utf-8", "replace")
self.messages.append({"to": list(rcpt), self.messages.append({"to": list(rcpt),
"data": b"".join(buf).decode("utf-8", "replace")}) "data": raw.decode("utf-8", "replace"),
"text": _text})
rcpt, in_data, buf = [], False, [] rcpt, in_data, buf = [], False, []
conn.sendall(b"250 OK\r\n") conn.sendall(b"250 OK\r\n")
else: else:
@@ -177,7 +192,7 @@ def set_qa_group(ids):
def mkwp(base, tok, wp_id, status="In Progress", extra_data=None, assignee=None): def mkwp(base, tok, wp_id, status="In Progress", extra_data=None, assignee=None):
data = {"constraints": [{"name": "Boom lift", "status": "cleared", "comment": ""}], data = {"constraints": [{"name": "Boom lift", "status": "cleared", "comment": ""}],
"location": CANARY, "desc": "600ft of 3/4 EMT through " + CANARY} "location": CANARY, "desc": "600ft of 3/4 EMT through " + DESC_CANARY}
data.update(extra_data or {}) data.update(extra_data or {})
return api(base, "/api/wps", tok, "POST", { return api(base, "/api/wps", tok, "POST", {
"id": wp_id, "project_id": "projA", "number": "QA-" + wp_id[-2:], "id": wp_id, "project_id": "projA", "number": "QA-" + wp_id[-2:],
@@ -263,12 +278,16 @@ def main():
rcpts = sorted(m["to"][0] for m in qa_msgs()) rcpts = sorted(m["to"][0] for m in qa_msgs())
chk("...addressed to the group members and NOBODY else", chk("...addressed to the group members and NOBODY else",
rcpts == ["pat@example.test", "sue@example.test"], ascii_(rcpts)) rcpts == ["pat@example.test", "sue@example.test"], ascii_(rcpts))
body = qa_msgs()[0]["data"] if qa_msgs() else "" body = qa_msgs()[0]["text"] if qa_msgs() else ""
chk("the message carries the WP number", "QA-A2" in body, ascii_(body, 200)) chk("the message carries the WP number", "QA-A2" in body, ascii_(body, 200))
chk("...and a link that opens THAT work package, not the app root", chk("...and a link that opens THAT work package, not the app root",
"/wp-creation-index.html?project=projA&wp=wpQA2" in body, ascii_(body, 400)) "/wp-creation-index.html?project=projA&wp=wpQA2" in body, ascii_(body, 400))
chk("...and no customer IP: the location canary does not appear", # Decided 2026-08-20: context IN, content OUT. This pin asserted the
CANARY not in body and all(CANARY not in m["data"] for m in sink.messages)) # location's ABSENCE until that decision; it flipped with the rule.
chk("...and the location and title ride in the body (context, allowed)",
CANARY in body and "conduit" in body, ascii_(body, 300))
chk("...but document content never does: the desc canary appears nowhere",
all(DESC_CANARY not in m["text"] + m["data"] for m in sink.messages))
chk("...and no SMTP password either", "SMTP_PASSWORD" not in body chk("...and no SMTP password either", "SMTP_PASSWORD" not in body
and os.getenv("SMTP_PASSWORD", "hunter2-not-set") not in body) and os.getenv("SMTP_PASSWORD", "hunter2-not-set") not in body)
@@ -290,7 +309,7 @@ def main():
chk("...exactly them", rcpts == ["mix@example.test", "pat@example.test", chk("...exactly them", rcpts == ["mix@example.test", "pat@example.test",
"sue@example.test"], ascii_(rcpts)) "sue@example.test"], ascii_(rcpts))
chk("...and the comment itself stays on the package, out of the mail", chk("...and the comment itself stays on the package, out of the mail",
all("Torque strap" not in m["data"] for m in sink.messages)) all("Torque strap" not in m["text"] + m["data"] for m in sink.messages))
# The upsert path enforces the same comment rule (it is how the browser saves). # The upsert path enforces the same comment rule (it is how the browser saves).
code, _ = api(base, "/api/wps/wpQA2/status", root, "POST", {"status": "Ready for QA"}) code, _ = api(base, "/api/wps/wpQA2/status", root, "POST", {"status": "Ready for QA"})