diff --git a/CLAUDE.md b/CLAUDE.md index 0e1c631..e164d88 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 gloved-hands surface and is where the worst rendering was found. 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 failed and why. diff --git a/KNOWN-ISSUES.md b/KNOWN-ISSUES.md index 5f4952d..95f8f44 100644 --- a/KNOWN-ISSUES.md +++ b/KNOWN-ISSUES.md @@ -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 | | 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 (`wp-creation-index.html`) surfaces it, since it runs in an iframe where the shared 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. diff --git a/docs/reference/completion.md b/docs/reference/completion.md index 51a0c7c..217d433 100644 --- a/docs/reference/completion.md +++ b/docs/reference/completion.md @@ -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` | | 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** | -| 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 | | A2 | built | T7.4 (`warning_check` 17) | one warning; the count on the sticky rail | | A3–A5, A7 | built | waves 1–6 | 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. - **BL-024** — 21 native dialogs on admin/users/launcher; the T7.9 kit is ready for them. -- **S13** — `seed_demo.py` sign-in. - Product questions raised in commit messages, awaiting answers: hold reachable from Draft/Scheduled (T7.3); CR-014 email body content (T7.6); merged-PDF export (T9.1). diff --git a/docs/reference/file-map.md b/docs/reference/file-map.md index 742a75f..b513009 100644 --- a/docs/reference/file-map.md +++ b/docs/reference/file-map.md @@ -247,7 +247,7 @@ Wave 4 added three more, each written because its task's done-when could not be anything that already existed: ```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/autosave_check.py # S2/B5 — does unsaved work survive? 34 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/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/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/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 @@ -312,6 +312,8 @@ The August 20 integration adds: ```bash 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 diff --git a/docs/waves/backlog.md b/docs/waves/backlog.md index e289835..4071b05 100644 --- a/docs/waves/backlog.md +++ b/docs/waves/backlog.md @@ -447,7 +447,7 @@ deliberately deferred. 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. -### 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 - **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 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 - **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 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 - **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 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) - **Where:** future — dashboard / rollups @@ -518,13 +518,14 @@ deliberately deferred. hours exist on every package; nothing yet compares them. A productivity factor (actual ÷ estimated, rolled up by discipline / building / type the way CR-018 rolls cost) is the measurement Marlena's tracking exists to enable. - The rollup endpoints (`/api/wps/metrics`, `/api/projects/{id}/summary`) - already carry both sums, so this is a presentation task, not a data one. + `/api/wps/metrics` already carries both sums, so this is a presentation + 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 a product conversation about where it displays and who reads it. - **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) - **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. - **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) - **Where:** `help.js`, the help-centre search input's `:focus` rule: diff --git a/docs/waves/decisions-2026-08-20.md b/docs/waves/decisions-2026-08-20.md index 3351819..1febb87 100644 --- a/docs/waves/decisions-2026-08-20.md +++ b/docs/waves/decisions-2026-08-20.md @@ -53,3 +53,50 @@ Cody's behaviour and moves it onto the R2 idiom: 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 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`. diff --git a/docs/waves/wave-1.md b/docs/waves/wave-1.md index 44bcf9f..0b6dce9 100644 --- a/docs/waves/wave-1.md +++ b/docs/waves/wave-1.md @@ -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 - [ ] `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 `
`, no new raw hex values, no new `alert()` calls introduced - [ ] `F6` untouched — it is a structural problem fixed by section tabs in `T7.2` diff --git a/html/admin.html b/html/admin.html index 7a23417..470e610 100644 --- a/html/admin.html +++ b/html/admin.html @@ -217,7 +217,8 @@ - + + diff --git a/html/admin.js b/html/admin.js index 7805658..f339bd1 100644 --- a/html/admin.js +++ b/html/admin.js @@ -146,7 +146,9 @@ async function seedDemo(){ snapshot(); } 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=''; // 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. @@ -250,10 +252,12 @@ async function archiveProject(id, name, archived){ '• Nothing is deleted. Unarchive here at any time to bring it back.' : 'Unarchive “'+name+'”?\n\n'+ '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}); 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 @@ -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 // two project deletions this is — the console's, not a project member's. async function deleteProjectAdmin(id, name){ - if(!confirm('DELETE “'+name+'” permanently?\n\n'+ - 'Its SOP, EVERY work package on it and every access assignment are deleted with it '+ - '(database cascade). This cannot be undone.\n\n'+ - 'If you only want it out of the way, cancel and use Archive instead.')) return; + 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 '+ + '(database cascade). This cannot be undone.\n\n'+ + '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); 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 ───────────────────────────────────────────── @@ -373,7 +380,8 @@ async function setAutoAdd(id, username){ _defMemUsers = _defMemUsers.map(u => u.id===json.id ? json : u); renderDefaultMembers(); } 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(); } } diff --git a/html/help.js b/html/help.js index e96fcd3..677d511 100644 --- a/html/help.js +++ b/html/help.js @@ -139,7 +139,7 @@ .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; 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-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); } diff --git a/html/index.html b/html/index.html index 4ee3c3a..6b16c0d 100644 --- a/html/index.html +++ b/html/index.html @@ -593,6 +593,7 @@ + - + + diff --git a/html/users.js b/html/users.js index fe63e98..2a32c77 100644 --- a/html/users.js +++ b/html/users.js @@ -255,37 +255,44 @@ function projAccessCell(u){ // Each one reloads on failure so a control can never sit there showing a value the // server refused. 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; 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.'); - else alert('Could not reset the password: '+apiError(status, json)); + if(status === 200) toast('Password reset for '+username+'. Their existing sessions are signed out.'); + else wpAlertDialog({title:'Reset failed', message:'Could not reset the password: '+apiError(status, json)}); } async function toggleActive(id, makeActive){ const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive}); 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){ 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(); } async function changeProjectRole(id, project_role, username){ 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(); } async function deleteUser(id, username){ - if(!confirm('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.')) return; + if(!(await wpConfirmDialog({title:'Delete user', + 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); 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 ──────────────────────────────────────────────────────────────────── @@ -368,7 +375,7 @@ async function createUser(){ // more the person is on, and a save leaves those others untouched. async function manageProjects(id, username){ 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); } 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', { project_ids: ids, roles: roleMap }); if(status === 200){ closeProjectModal(); loadUsers(); } - else alert('Save failed: '+apiError(status, json)); + else wpAlertDialog({title:'Save failed', message:'Save failed: '+apiError(status, json)}); }; } diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js index eeeb083..61c3797 100644 --- a/html/wp-creation-app.js +++ b/html/wp-creation-app.js @@ -3777,6 +3777,17 @@ function renderDashboard(){ ${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')} ${card('Est. hrs', Math.round(estH))} ${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')); + })()}
`; // status + discipline breakdown chips (status chips also filter the board) diff --git a/html/wp-creation-styles.css b/html/wp-creation-styles.css index a593b06..0d9f35b 100644 --- a/html/wp-creation-styles.css +++ b/html/wp-creation-styles.css @@ -154,7 +154,7 @@ 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; } .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; } .section { display: none; } @@ -308,7 +308,7 @@ .deliv-text .dt-sub { display: block; font-size: 11px; color: var(--text-muted); margin-top: 1px; } /* ── 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 { 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; @@ -537,7 +537,7 @@ border-radius:var(--radius); padding:7px 10px; font-size:11px; } /* ── 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; } .ctx-empty { color:var(--text-muted); font-size:13px; } .ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; } @@ -575,7 +575,7 @@ /* ── WORK PACKAGE FORM ───────────────────────────────────────────── */ .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 .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; } .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); } @@ -758,7 +758,13 @@ /* 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 - which is a third of what F6 was measuring, arriving by a different door. */ - .card.collapsed { padding-top: 10px; padding-bottom: 10px; } + /* 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 .section-header { margin-bottom: 0; padding-bottom: 0; border-bottom: 0; } .card.collapsed .sub-heading { margin-bottom: 0; } diff --git a/html/wp-dialog.js b/html/wp-dialog.js new file mode 100644 index 0000000..6e99bc5 --- /dev/null +++ b/html/wp-dialog.js @@ -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 + * wpPromptDialog({title, message, label, value, validate}) -> Promise + * 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 = + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + 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); diff --git a/server/app.py b/server/app.py index b8b6fe7..1a381fe 100644 --- a/server/app.py +++ b/server/app.py @@ -458,15 +458,41 @@ def wp_link(db: Session, wp: "models.WorkPackage") -> str: 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: - # Deliberately minimal — a WP number + a link, NOT the package contents (keeps - # customer IP inside the app, behind login). + # Number, title and location — customer context, allowed since the + # 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 name = assignee.full_name or assignee.username return ( f"Hi {name},\n\n" - f"{who} assigned you a work package: {wp.number or '(no number)'}.\n\n" - f"Open the Work Package Suite to view and action it:\n{link}\n\n" + f"{who} assigned you a work package: {wp_titled(wp)}.\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." ) @@ -1597,15 +1623,22 @@ def project_sop_team(db: Session, project_id: Optional[str]) -> list[str]: ).first() if not sop: 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] 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 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 - has (BL-021, logged, not fixed here).""" + project_sop_team above read the flat shape until BL-021 was fixed + (2026-08-20); both now read nested-first, exactly alike.""" if not project_id: return [] 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", actor: "models.User", link: str) -> str: - # A WP number and a deep link - NOT the package contents. The task text asked - # for location and a scope summary, but the done-when list (and the standing - # rule) says no customer IP in a message body; the link is the summary. + # Number, title and location ride in the body — the 2026-08-20 decision + # restored the location the T7.6 done-when had excluded. The SCOPE summary + # stays out: scope text is document content, and the link is its summary. who = actor.full_name or actor.username name = user.full_name or user.username return ( 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"Open it here:\n{link}\n\n" 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 return ( 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"Open it here:\n{link}\n\n" 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], actor: "models.User", link: str) -> str: - # Constraint names and a WP number only — no package contents, same rule as the - # assignment mail. + # Constraint names, number, title and location — customer context per the + # 2026-08-20 decision. No package contents; the link carries those. who = user.full_name or user.username by = actor.full_name or actor.username which = ", ".join(names) return ( 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"Constraint: {which}\n" + + _where_line(wp) + 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" return ( 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"Delivery location: {delivery}.\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 "" return ( 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"Delivery location: {delivery}.\n\n" f"Open it here:\n{link}\n\n" diff --git a/server/notify.py b/server/notify.py index d28edc0..7774390 100644 --- a/server/notify.py +++ b/server/notify.py @@ -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 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 -number and a deep link, not the work-package contents. +background task. Notification bodies carry customer CONTEXT — the WP number, its +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 smtplib +import socket import uuid import logging 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) user = s.get("smtp_username") or "" 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): srv.starttls() if user: diff --git a/tests/aggregates_check.py b/tests/aggregates_check.py index 70c6c40..2d3a45f 100644 --- a/tests/aggregates_check.py +++ b/tests/aggregates_check.py @@ -155,6 +155,18 @@ def main(): str(shown) == str(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) + # 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") page.eval("""(() => { diff --git a/tests/color_check.py b/tests/color_check.py index 46cec43..29a037a 100644 --- a/tests/color_check.py +++ b/tests/color_check.py @@ -53,8 +53,11 @@ def main(): print("\n2. one blue, one amber") theme = io.open(os.path.join(HTML, "theme-light.css"), encoding="utf-8").read() 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", - "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", "--wp-status-warning-text-alt" not in code) others = [] @@ -63,7 +66,8 @@ def main(): continue src = strip_comments(io.open(os.path.join(HTML, name), encoding="utf-8").read(), 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) chk("...and no consumer still references either", not others, others) diff --git a/tests/console_dialogs_check.py b/tests/console_dialogs_check.py new file mode 100644 index 0000000..346db79 --- /dev/null +++ b/tests/console_dialogs_check.py @@ -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"(?{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()) diff --git a/tests/critical_reopen_check.py b/tests/critical_reopen_check.py new file mode 100644 index 0000000..1a3634a --- /dev/null +++ b/tests/critical_reopen_check.py @@ -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()) diff --git a/tests/kitting_notify_check.py b/tests/kitting_notify_check.py index 77eb850..29c504c 100644 --- a/tests/kitting_notify_check.py +++ b/tests/kitting_notify_check.py @@ -106,7 +106,7 @@ def main(): rcpts = sorted(m["to"][0] for m in sink.messages) chk("...exactly them, actor excluded", 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", "In Transit" in body and "Delivered" in body and "Root" in body, ascii_(body, 260)) diff --git a/tests/mreq_check.py b/tests/mreq_check.py index f1db5b1..e127f3d 100644 --- a/tests/mreq_check.py +++ b/tests/mreq_check.py @@ -121,13 +121,14 @@ def main(): len((wp.get("data") or {}).get("materialRequests") or []) == 1) 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)) - 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 " "and carries the deep link", - body["to"] == ["sue@example.test"] and "2 lines" in body["data"] - and "2026-09-01" in body["data"] and "Shark cage 7" in body["data"] - and ("/wp-creation-index.html?project=projA&wp=" + wp_id) in body["data"], - ascii_(body["data"], 300)) + body["to"] == ["sue@example.test"] and "2 lines" in body["text"] + 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["text"], + ascii_(body["text"], 300)) _, 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)) diff --git a/tests/qa_gate_check.py b/tests/qa_gate_check.py index 7218118..7ac3080 100644 --- a/tests/qa_gate_check.py +++ b/tests/qa_gate_check.py @@ -40,7 +40,8 @@ from sections_check import set_sop # noqa: E40 from stepper_check import dismiss_dialogs # noqa: E402 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): @@ -87,7 +88,7 @@ class SmtpSink(threading.Thread): self.sock.bind(("127.0.0.1", 0)) self.sock.listen(8) self.port = self.sock.getsockname()[1] - self.messages = [] # {"to": [...], "data": str} + self.messages = [] # {"to": [...], "data": wire str, "text": decoded body} self._stop = False def run(self): @@ -127,8 +128,22 @@ class SmtpSink(threading.Thread): return if in_data: 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), - "data": b"".join(buf).decode("utf-8", "replace")}) + "data": raw.decode("utf-8", "replace"), + "text": _text}) rcpt, in_data, buf = [], False, [] conn.sendall(b"250 OK\r\n") else: @@ -177,7 +192,7 @@ def set_qa_group(ids): def mkwp(base, tok, wp_id, status="In Progress", extra_data=None, assignee=None): 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 {}) return api(base, "/api/wps", tok, "POST", { "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()) chk("...addressed to the group members and NOBODY else", 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("...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)) - chk("...and no customer IP: the location canary does not appear", - CANARY not in body and all(CANARY not in m["data"] for m in sink.messages)) + # Decided 2026-08-20: context IN, content OUT. This pin asserted the + # 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 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", "sue@example.test"], ascii_(rcpts)) 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). code, _ = api(base, "/api/wps/wpQA2/status", root, "POST", {"status": "Ready for QA"})