Compare commits

...

21 Commits

Author SHA1 Message Date
29c4cd313e S13 - already fixed at T1.6; the records said otherwise, now corrected
The housekeeping list carried S13 ('seed_demo.py does not sign in') from
completion.md and CLAUDE.md. It is not true and has not been since wave 1:
T1.6 (357712e) rewired seed_demo.py onto smoketest's opener - one cookie jar,
one login flow - and the file's own docstring says so. What actually happened:
the wave-1 exit checkbox was never ticked, and every later document inherited
the unticked box as fact.

Verified live before correcting anything, per the working rules: against a
throwaway server, seed_demo.py signs in as an admin, seeds the DEMO project
(7+ packages visible via the API), and --clean removes it, exit 0 both ways.

Corrected: the wave-1 exit box (ticked, with the reason), completion.md's S13
row (open -> built at T1.6, records error named), and CLAUDE.md's
verification step 4, which taught every future session the stale claim.

Item: S13 (closed as already-built; records corrected).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:26:40 -07:00
8fe7b25cd8 BL-025 - the last tint of the second brand blue, and the grep that missed it
help.js's search-focus ring was rgba(37,99,214,.15) - the banned #2563d6 as a
space-free rgb triple, which slid past color_check's spaced grep ('37, 99,
214') from the day BL-008 removed the colour. C4's recorded exception
legitimately allows rgba ALPHAS as opacity recipes; the defect was the base
colour under the alpha. Rebased onto THE blue: rgba(15,98,254,.15).

color_check compares space-free and case-insensitive now, in both the theme
check and the consumer sweep, so no spelling of the dead blue can return.

Items: BL-025 (closed), C4, BL-008 lineage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:24:43 -07:00
560f0cb3cc BL-024 - the last 21 native dialogs, onto the shared kit
S1 counted 79 native dialogs app-wide and its tasks removed 58; the audit
found the rest on surfaces no S1 task named: admin.js (6), users.js (10), the
launcher's inline script (5). All 21 now go through wp-dialog.js - the T7.9
kit extracted as a self-injecting shared component: markup and styles land on
first use, styles are theme tokens only with its own wp-dlg-* class names (the
consoles' existing .modal styles are untouched), 44px targets on coarse
pointers, and the whole file is guarded so the creator's inline copy - which
owns the same-id markup in its HTML - still wins on its own page. The kit's
toast comes along (S10 role rules), since none of the three pages had one.

Conversion follows the T7.9 precedent: confirms -> wpConfirmDialog with named
ok-labels, the password prompt -> wpPromptDialog whose validate() finally
enforces min-12 AT the input (it was label-text-only before, server-enforced),
API failures with detail -> wpAlertDialog, small info/validation messages ->
the announced toast.

New probe console_dialogs_check (17): counts pinned at 0, kit guarded and
loaded by all three pages, and the users console driven live with natives
poisoned - reset a password end to end (short refused inline, good one accepted
by the server and announced), cancel a delete and prove nothing died.

Items: BL-024 (closed), S1 completed to zero app-wide, C1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:24:09 -07:00
24f60151e5 BL-021 - the critical-reopen mail reaches the PM and CM, at last
project_sop_team() read sop.data['project']; pushSOP stores every row as
data={sop, state}, so the project block is one level deeper. The lookup
returned [] for every real row, silently, and the on-hold email promised to
'Owner + PM + CM + distribution' has reached only owner + distribution since
the day it shipped. One line: the same nested-first tolerant read
project_qa_group has used all along (whose docstring logged this very bug).

New probe critical_reopen_check (11): the fixture writes the PRODUCTION shape
- a hand-built flat row would have passed against the bug, which is exactly
how it went unverified this long. Sink-verified end to end: assignee + PM +
CM and nobody else; constraint name, title, location, deep link and the house
footer in the body (the footer this body alone used to lack, fixed at CR-014).

Items: BL-021 (closed), CR-011 recipients.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:17:31 -07:00
031dc6b995 D12 - the productivity factor, on the dashboard (was BL-023)
Nick's decision: 'find a spot on the dashboard.' The spot: an eighth metric
card beside Est./Actual hrs - actual/estimated to two decimals, green at or
under 1.0, red over. Both hour fields are optional (CR-017), 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), the same m.est_hours/actual_hours its neighbours already render - zero
new fetches, and the card stays inside the block the metrics-failure path
skips, so an outage still shows the error panel and no cards.

aggregates_check gains the pin (16 -> 17): the card must equal the quotient
of the SERVER's sums, or the em dash when either sum is zero - derived, not
hardcoded. Backlog entry corrected in passing where it credited
/api/projects/{id}/summary with hour sums it never carried.

Items: D12 (decisions-2026-08-20.md), CR-017 read, B4 discipline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:14:15 -07:00
0f28a27441 CR-014 - bodies carry customer context and the link carries the content
Nick's decision, 2026-08-20: 'email bodies provide links back to the system.
we can talk about customers we just cant exposed their confidential
documents.' The T7.6-era rule (no customer IP at all, so number + link only)
is refined: context IN, content OUT.

- wp_titled() and wp_where() compose 'number - title' and the CR-004
  location (structured paths first, legacy free text second); the where-line
  is dropped entirely when unset rather than mailing 'Where: '.
- assign, qa-ready, qa-reject and hold bodies gain title + location. The
  scope summary the original CR asked for stays OUT - scope text is document
  content; the link is its summary. Rejection comments stay on the package.
- hold_body gains the house footer it alone lacked.
- kitting and material-request bodies adopt wp_titled for the same identity
  line (their delivery-location rule is unchanged).
- notify.py's docstring states the new rule where the transport documents it.

Pins flipped WITH the rule, reasons in code: qa_gate_check's location canary
is now asserted PRESENT in QA bodies; a new DESC_CANARY (document content) is
asserted absent from every message (40 -> 41 checks). The sink also gains a
decoded-body view: the em-dash switches smtplib to quoted-printable, whose
column-76 soft breaks made raw-payload substring pins pass or fail on luck of
line position - content pins now read the decoded body, header pins still
read the wire payload.

Battery: qa_gate_check 41/41, kitting_notify_check 17/17, mreq_check 19/19.

Items: CR-014 (rule per decisions-2026-08-20.md), CR-011 pins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:11:37 -07:00
16afc56c0a CR-011 transport - pin the EHLO name; DNS trouble was stalling every send 5s
smtplib calls getfqdn() on every connect when local_hostname is not given, and
that reverse-DNS lookup blocks ~5s per send whenever DNS is slow or down (found
when the office link dropped today: qa_gate_check's sink saw one mail per ~5s
and its 12s waits timed out). Sends are sequential background tasks, so the
stall compounded across a notification batch - in production a QA transition
with a 3-person group would take 15+ seconds to finish mailing.

socket.gethostname() never touches the network; the EHLO name is now computed
once. Measured against the capture sink: 5.3s -> 0.3s for a two-recipient
batch. Server mail path otherwise untouched.

Item: CR-011 (the send path's transport).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 18:05:43 -07:00
8efe624d5d F6 - strict 2.0: the creator fits two screens at rest (closes BL-022)
Nick's answer: 'strict 2.0'. The 154px overage was chrome, and every trim
densifies rather than deletes - A2's one-warning banner and the SOP identity
strip both stay:
- collapsed section rows 46 -> 36px on fine pointers (13 rows at rest was
  ~130px of the overage); coarse pointers keep the 44px tablet row (C1)
- ctx-bar 12 -> 7px padding; banner margin 14 -> 8, padding 11 -> 8
- .main top pad 22 -> 14 (bottom stays clear of the sticky bar)
- nav-row 24/24 -> 14/14

Measured at 1440x900: 1,954 -> 1,784px = 1.98 screens. form_structure_check
is 51/51 for the first time - the check never moved, the page now fits it.
mobile_check 24/24 (the coarse-pointer targets held).

Items: F6, BL-022 (closed), C1 preserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 17:52:28 -07:00
24476c86a6 Decisions of 2026-08-20 (evening), recorded
Six answers from Nick: F6 is a strict 2.0 screens (build task, chrome
compresses); hold stays reachable from any status (T7.3 question closed);
CR-014 bodies get deep links and may name customer context but never embed
confidential document content; CR-008 merged-PDF becomes KNOWN-ISSUES 3
(decided, not deferred by accident); BL-023 becomes D12, the productivity
factor on the dashboard; BL-020 closed as decided-keep. Housekeeping
(BL-021, BL-024, BL-025, S13) approved to build on this branch.

Items: F6, CR-008, CR-014, D12, BL-020, BL-022, BL-023.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 17:45:58 -07:00
cc761c8f7d Merge branch 'feat/wp-suite-r2-implementation': the R2 plan, complete
All 66 items: the 55 of IMPLEMENTATION.md section 6, D1-D10 (decisions
2026-08-18), and D11 (the Micron asset picker from origin/Micron-Assets,
merged and adapted 2026-08-20). Nine waves, one task per commit, reconciled
item by item in docs/reference/completion.md.

Also carried: the C4 transparency regression fix (undefined token names),
the S8 glossary-class leak fix (the always-lit Issue pill), and the seven
findings of the D11 adversarial integration review.

Verification: ~31 self-contained probe suites in tests/ (~950 checks), run
one at a time; all green except form_structure_check's deliberate BL-022 red
(F6's 'roughly two screens' = 2.17, held open for a product answer).

Open items for the next revision are listed in docs/waves/backlog.md
(BL-020..BL-025, S13) and completion.md section 'For the next revision'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 17:34:17 -07:00
fb89b1f6e1 S8 fix - help centre glossary classes leaked onto the Issue (hold) status pill
help.js injects its stylesheet on every page, and its glossary pills used bare
class selectors (.pill-draft ... .pill-hold). The creator's Issue (hold) status
radio also carries the class pill-hold, so the injected rule painted that radio
error-red at ALL times - selected or not. Reported by Nick ('why is the issues
(hold) button illuminated at all times'), 2026-08-20.

Pre-existing, not from this branch: help.js has had the bare selectors since
the login-portal commit, and the creator's pill-hold class predates the R2
branch. Every glossary rule is now scoped to .ui-help-pill.pill-*, which the
glossary markup already carries. Verified live: unselected, the hold pill's
computed style now matches its neighbours exactly; selected, it is still the
red fill; the glossary's own Hold pill keeps its tint. helptip_check gains the
pin (13 -> 14): no bare .pill-* selector in help.js, ever again.

Item: S8 (the help component's app-wide surface).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 15:59:30 -07:00
8663d81af3 C4/D11 follow-up - the integration review's seven confirmed findings
An adversarial review (four lenses, every finding independently verified by two
skeptics told to refute it) ran over 2a5f6b3 and 8cf8c0f. Seven findings
survived; all seven are fixed here.

Against the C4 fix:
- help.js: the nav hover was renamed onto its own surface token, keeping a
  no-op T9.9 had introduced (two different grays had been mapped to one name).
  Hover is now --cds-layer-hover, the token that exists for exactly this.
- wp-creation-app.js: the drawer's critical CSS pre-painted --cds-layer-accent
  while the stylesheet paints --wp-nav-bg; now both paint --wp-nav-bg.

Against D11:
- wp-sections.js: the Assets toggle note still described the pre-D11 card
  ('Asset tags and controls.dev links') with a rationale the picker inverts.
- runAssetSearch: the result cap counted contains-matches before the exact and
  prefix tiers finished, so 500 alphabetically-early substring hits could evict
  the exact match - and Enter then added the wrong asset, ID-locked. The cap
  now bounds each tier; the scan always sees the whole catalog.
- addCatalogAsset: the one mutation in the section with no announced outcome
  was the successful pick. It now toasts (role=status), matching every sibling
  path (C1).
- assets_db.py: failures are remembered for FAIL_CACHE_SECONDS (default 30s)
  and a stale catalog is served over an error, so a Micron outage costs one
  CONNECT_TIMEOUT per window instead of one per page load stacking up in the
  shared sync threadpool until login itself stalls.
- assets_db.py: MICRON_ASSETS_CACHE_SECONDS='5m' no longer crashes the boot -
  a malformed knob on an OPTIONAL feature degrades to its default, loudly.

assets_check grows four regressions for these (27 -> 31): per-tier cap against
600 decoys, the announced pick, boot with a malformed knob, and the stable
cached 503. Battery: assets_check 31/31, color_check 5/5, sections_check ALL
PASS.

Items: C4, D11.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 12:09:40 -07:00
8cf8c0f882 D11 - merge origin/Micron-Assets: the Micron asset picker, adapted to R2
Integrates Cody Schaefer's 7ef1fcd (written against pre-R2 main) per Nick's
instruction of Aug 20. The catalog lookup arrives whole: read-only /api/assets
backed by server/assets_db.py (one SELECT, env-only MICRON_DB_URL, 503-not-500
when broken, driver errors logged not propagated), the searchable picker with
CSV import and Excel column paste, catalog rows badged and locked to the DB's
casing, manual rows visibly unvouched, and graceful absent/unreachable states.

Three conflicts, resolved as unions of both sides' intent; the adaptations and
their reasons are recorded in docs/waves/decisions-2026-08-20.md:
- renderPackage: Cody's two-column asset table inside T9.1's sectioned
  add('assets', ...) frame, so the CR-006 toggle keeps governing the export.
- bootData: initAssetPicker() joins the R2 loads instead of replacing them.
- The asset card: his picker UI, plus role=status on the source note (C1).
- Six imported alert() calls converted to the creator's idioms: file errors
  through toast(msg,'alert') as the drawings uploader does; the instructional
  and summary messages through the T7.9 kit, which gains the one-button
  wpAlertDialog shape (BL-024's console conversions will want it too).

New probe: assets_check (27) - read-only structurally, unconfigured/broken as
first-class states, no credential echo, search ranking, casing canonicalisation,
import fallback + dedup, kit-not-native summary. One sections_check pin
re-pointed with the reason in code: normaliseAsset now stamps legacy rows
source:'manual' on load, so the CR-016 check compares content, not bytes.

Battery after merge: assets_check 27/27, creator_dialogs_check 20/20,
sections_check ALL PASS, export_check 20/20, helptip_check 13/13,
mobile_check 24/24, icon_check 5/5, color_check 5/5, form_structure_check
50/51 (the one red is BL-022, unchanged, deliberate).

Item: D11 (new scope, new id per the working rules). Out-of-scope note in
completion.md amended - 'no integration code exists' was true when written.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 11:45:32 -07:00
2a5f6b3549 C4 fix - five undefined token names rendered surfaces transparent
The T9.9 token sweep pointed seven files (help.js, auth-guard.js, wp-format.js,
project-data.js, index.html, field.html, wp-creation-app.js) at Carbon names
the theme never defined: --cds-layer-01/-02, --cds-border-subtle-01/-strong-01,
--cds-layer-hover-01. theme-light.css carries no -01 suffixes. An undefined
var() invalidates the whole declaration, so the help-centre modal, the
change-password and language dialogs, the print popup's inlined values, the
creator nav drawer and the sync badge all rendered TRANSPARENT backgrounds -
reported by Nick against the help menu, 2026-08-20.

Renamed every consumer to the canonical tokens (--cds-layer, --cds-layer-accent,
--cds-layer-hover, --cds-border-subtle, --cds-border-strong), matched to the
hex each replacement originally stood in for. color_check gains check 3: every
var() consumed anywhere must resolve to a definition somewhere - the class of
this bug, pinned. Verified live: the modal computes rgb(255,255,255) over an
opaque gray nav, and the language dialog is opaque too. BL-025 logged for the
one wrong-base-colour rgba tint noticed in passing.

Item: C4 (regression in its own enforcement). Probe: color_check 5/5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 11:27:12 -07:00
454bfa0fe1 T9.7 + wave 9 exit - all 65 items, reconciled
docs/reference/completion.md walks every item: the 55 from IMPLEMENTATION.md
section 6 and the 10 from decisions-2026-08-18.md. For each: status, the task
that delivered it, the probe that re-verifies it on every run, and every
deviation from written acceptance criteria - B7's page-not-merge (measured),
CR-014's email body (the no-customer-IP rule won), CR-008's merge-vs-list
(recommended, not decided), F6's 2.17-vs-2.0 (BL-022), S1's residual 21
dialogs (BL-024).

The four out-of-scope items are confirmed unbuilt - two of them by probes
that grep for their fields on every run. Section 8's outstanding inputs are
restated (the material workbook and the B100 list still have not arrived;
both upload paths are ready). The follow-ups for the next revision are in one
place, including three product questions raised in commit messages along the
way and the acceptance criteria that turned out wrong, for calibration.

One item in the whole plan is knowingly open: S13 (seed_demo sign-in),
carried with a reason, and F6's last number awaits a product answer.

Wave 9 exit criteria: seven of seven, ticked with their verifying probes.

Items: all

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 14:29:29 -07:00
23ee0b052f T9.9 - C4 + the backlog sweep: nine entries closed, each re-measured first
The colour half (C4, approved Aug 18 - "change them"):
- BL-004: the help centre's own 52-colour palette collapsed onto theme tokens
- BL-005: the JS-built dialogs (auth-guard, wp-format) and project-data's
  badges read tokens; the creator's categorical badge palette moved to
  theme-light as --wp-chart-1..10, read by computed style at boot; the print
  popup - a document with no stylesheet - inlines live token VALUES
- BL-008: the second brand blue (#2563d6) is deleted; .sop-inherited tints
  with THE blue at the same 7% alpha
- BL-009: the ninth amber (--wp-status-warning-text-alt) is deleted
- theme-light gained the two missing feedback tokens the consoles carried as
  literals (--wp-status-success-text / -error-text)
- NEW tests/color_check.py 4/4: zero hex literals outside theme-light.css,
  comments stripped (the BL-017 lesson), with the exceptions named in full
  (meta theme-color cannot resolve a var; rgba alphas are opacity recipes)

The correctness half, each re-measured before touching, as the task ordered:
- BL-011 STILL REPRODUCED: the sync badge mounted on the first async sync
  event; its holder now mounts at DOMContentLoaded, so the three overlays land
  in script order deterministically
- BL-012 fixed and MEASURED: baseline_shots freezes Date and Math.random per
  document; two consecutive admin captures came back byte-identical
- BL-016 fixed: a step-less wizard URL is step 1; stepper_check's deliberately
  wrong pin flipped with the fix, exactly as the entry planned
- BL-018 fixed both halves: the false-complete write now requires the
  {sop,state} production shape, and browser_check.seed writes that shape -
  which un-detoured four probes' creators from the SOP gate. stepper_check
  re-pointed at projB (no SOP) because its premise is a wizard someone is
  STARTING, and projA now legitimately restores a finished one.
- BL-019 fixed: a stored cost code that left COST_CODES is kept as an option
  (the gov_wosize pattern), so opening a package no longer blanks its record
- hold_check's AST sweep refined in passing detection: it flagged T8.3's
  notification-row .status as a release transition; it now reads wp.status only

Every wave-9-pointing backlog entry is closed with its measurement recorded.

Verification (each probe run alone): color_check 4/4, stepper_check 71/71,
validation_check 77/77, url_state_check 23/23, autosave_check 34/34,
a11y_check 22/22, launcher_check 58/58, aggregates_check 16/16,
kitting_check 26/26, hold_check 50/50, mobile_check 24/24, frame_check 38/38,
sections_check 95/95, form_structure_check 50/51 (BL-022's question).

Items: C4, BL-004, BL-005, BL-008, BL-009, BL-011, BL-012, BL-016, BL-018, BL-019

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 14:27:26 -07:00
771672273d T9.8 - D7: archiving stops reading as deletion - for project admins
Archiving already froze a project (the server refuses every write); what did
not exist was the way back in. Now:

- GET /api/projects?archived=only|all filters the answer BY PER-PROJECT ROLE:
  a project admin (or super/app admin) on THAT project sees it; everyone else
  receives an empty list from the same request - archived projects appear
  nowhere for them, counts and pickers included (the default listing already
  excluded them for everyone; asking is what got gated). Admin-on-Job-A does
  not surface archived Job B.
- The launcher gains a visibly separate, labelled "Archived projects"
  section (dashed border, read-only stated in words), rendered only when the
  server returns rows. Opening one makes it active; the launcher's reconcile
  learned that an active project whose stored summary says archived:true was
  opened ON PURPOSE and keeps it, while a project archived out from under
  someone still drops with the existing explanation.
- The creator shows ARCHIVED - READ-ONLY where the project is named (both
  ctx-bar branches, from the SERVER's answer - the page's project comes from
  the URL, so a stale local summary is not trusted) and refuses saves with a
  reason before the round trip. The courtesy; the server's refusal is the
  rule, verified by calling the endpoints directly (wp upsert AND the
  material-list write both refuse with "archived" even for an admin).
- No unarchive button, no second mechanism, and it fits at 390px.

Verification (each probe run alone): NEW tests/archived_check.py 15/15.
Regressions: launcher_check 58/58, sample_check 10/10, export_check 20/20,
frame_check 38/38.

Items: D7

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:58:51 -07:00
6201fcfb4a T9.6 - C2: the 390px pass, measured on all seven pages
The deliberate mobile pass the original proposal never had. Driven page by
page at 390px with mobile emulation (the media queries under test actually
fire) by NEW tests/mobile_check.py, 24/24:

- no page scrolls sideways - all seven (the creator joined at T9.5 when
  BL-001 died)
- no visible control is clipped past the viewport (the probe learned
  frame_check's two lessons: an off-canvas drawer is PARKED, not clipped,
  and a row inside an overflow-x container is scrollable)
- tap targets: the shared coarse-pointer block in wp-chrome.css puts every
  button, input, select, nav link and appbar control at a 44px minimum on
  phone widths and coarse pointers; checkboxes, radios and help-tip badges
  get the 24px WCAG floor with spacing doing the rest. Field View - the
  gloved-hands surface - measures 44px on EVERY control. Inline text links
  are exempt per WCAG 2.5.8's own exception. Even the deliberately
  unobtrusive dev toggle grew to the floor: subtle by opacity, not by size.
- CR-007 attachments offline at 390px and T8.5 requests at 390px were already
  pinned by files_check and mreq_check; this pass cites rather than repeats.

After-screenshots for all seven pages at 390px are committed in
docs/reference/baseline/after-wave9, beside the wave 0 set, captured by the
same baseline_shots.py fixture.

Verification (each probe run alone): NEW tests/mobile_check.py 24/24.
Regressions: form_structure_check 50/51 (BL-022's standing question),
files_check 36/36.

Items: C2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:47:17 -07:00
0dcea8d725 T9.5 - C1+S8: the help-tip is real, the audit is written, BL-001 is dead
S8, finished where the plan said it would be: every .help-tip badge is a
<button> - upgraded by the component itself at load (help.js), with
helpTipUpgrade() for late renders, so a badge added tomorrow is born
reachable. The count the task warned about came true: 15 at wave 0, 18 at the
wave 6 exit, 20 at the start of this task - all 20 buttons now, and the fix
being in the component is what stops the number growing again. One
viewport-clamped role=tooltip bubble serves every badge: focus shows it,
Escape hides it, tap toggles it, tap-elsewhere closes it - the touch path
Field View's tablets never had. The injected styles now use theme tokens
(four raw hexes of the S5 kind, gone).

BL-001, CLOSED after three causes and nine waves: the old CSS ::after escaped
its badge to the right and was the creator's last 390px overflow. The clamped
bubble ends it - scrollWidth 390 vs clientWidth 390 - and frame_check's pin
FLIPPED, exactly as designed: it asserted the failure until the fix landed,
and now asserts the fix so a regression reopens the entry loudly.

The audit (docs/reference/accessibility-audit.md), every number probe-backed:
- div/span click handlers: 12/2 at wave 0 -> 0 (the wizard's constraint
  library entries and the dashboard chips became buttons here; the comments
  backdrop stopped pretending to be a control)
- outline:none without replacement: 0 (wp-chrome's one is the documented S12
  exception - its ring is on :focus-within, one ring not two)
- aria-live: every toast system and banner announces
- native dialogs: 79 -> 21, all on surfaces no S1 task named (admin, users,
  launcher) - documented as BL-024 with the T7.9 kit ready for them
- keyboard-only primary flow: covered leg by leg by the probes that dispatch
  real CDP key events, cited in the document

Three stale count-pins re-pointed to the numbers this task reached (stepper's
baseline-minus-10, form_structure's one-span-left, frame_check's BL-001 pin) -
each now pins the TARGET so slack cannot hide a regression.

Verification (each probe run alone): NEW tests/helptip_check.py 13/13.
Regressions: a11y_check 22/22, stepper_check 71/71, form_structure_check
50/51 (BL-022's product question), pipeline_check 44/44, frame_check 38/38.

Items: C1, S8 (BL-001 closed, BL-024 opened)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:37:10 -07:00
b83f2fd8d5 T9.3 - S6: one icon system - monochrome text glyphs, one meaning each
The set mixed colour emoji with dingbats, and the same glyph read as two
things partly BECAUSE emoji render as per-platform artwork. The system chosen:
monochrome text-presentation glyphs - the suite is classic-script vanilla HTML
with no bundler, so an SVG sprite or icon font is a new asset pipeline, while
text glyphs render through the same font stack as the words beside them. The
enforceable form of "renders identically on Windows, macOS and a tablet":
no emoji-range codepoint and no U+FE0F selector anywhere in UI source,
swept by the probe on every run.

Converted: green-check/red-cross emoji in the admin and users consoles to
checkmark/cross, no-entry to circled-slash (blocked/on hold), the lock to the
pencil already meaning "edit with a logged reason" on sign-offs, the star to
the diamond, the folder to the reference marker, the side nav's lightning to
the gear, and the WATCH glyph (U+231A - emoji-presentation BY DEFAULT per
Unicode) to a text-presentation clock face. Dropped where the label already
carried the meaning: lightning on Save & view, the camera on Add photo, the
page/frame pictograms on file rows (the filename is the label). Stale help
copy fixed while its emoji left: it still described the pre-T9.4 "Load
sample" and the pre-T7.10 "Usage Logs" locations.

The meaning-to-icon mapping is in docs/reference/tokens.md - one meaning per
glyph, one glyph per meaning, both directions asserted from the document
itself; the probe also sweeps every page for glyphs not in the approved set,
so an unmapped icon cannot creep in.

Verification (each probe run alone): NEW tests/icon_check.py 5/5.
Regressions: frame_check 38/38, files_check 36/36, a11y_check 22/22,
cards_check 44/44.

Items: S6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:15:59 -07:00
7ef1fcdd96 Add Micron asset picker to work package creator
Adds an optional read-only Micron asset catalog lookup for the WP creator, with searchable asset IDs, CSV import, and graceful fallback to manual asset entry when the catalog is absent or unreachable. This includes the backend /api/assets endpoint, SQL Server connector configuration, Docker network changes for outbound access, and UI updates/documentation to make the catalog read-only and clearly distinguish Micron-vetted assets from manual entries.
2026-08-18 14:56:55 -05:00
64 changed files with 3346 additions and 288 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

@@ -35,12 +35,22 @@ services:
# default and enabled from the Admin console; this is the only email # default and enabled from the Admin console; this is the only email
# secret and it is never stored in the DB. Leave unset until configured. # secret and it is never stored in the DB. Leave unset until configured.
SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-}
# Optional — read-only SQL Server connection to the Micron asset catalog,
# which backs the asset picker in the work package creator. Leave unset and
# the picker cleanly falls back to manual entry (see server/assets_db.py).
# Use a db_datareader login: the app only ever SELECTs.
MICRON_DB_URL: ${MICRON_DB_URL:-}
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
db: db:
condition: service_healthy # waits for postgres to accept connections condition: service_healthy # waits for postgres to accept connections
networks: networks:
- internal - internal
# Reaching the Micron database means leaving this compose project, and
# `internal` is deliberately egress-free. `outbound` is attached to the api
# container ONLY — the database and backup containers stay sealed. Detach it
# again if you are not using the Micron asset picker.
- outbound
db: db:
image: postgres:16-alpine image: postgres:16-alpine
@@ -98,4 +108,12 @@ networks:
name: proxy name: proxy
external: true external: true
internal: internal:
internal: true # no outbound internet access from api/db internal: true # no route off the host for anything on this network alone
outbound:
# An ordinary bridge network, i.e. one that HAS a default gateway. `internal`
# above removes the gateway entirely, which blocks not just the internet but
# the LAN and the VPN too — so the api container needs this second network to
# reach the Micron asset database. Attached to `api` alone: `db` and `backup`
# remain on `internal` only and still have no way off the host.
# Detach it from api if you are not using the Micron asset picker.
driver: bridge

View File

@@ -0,0 +1,73 @@
# Accessibility audit — C1 + S8 (T9.5, 2026-08-19)
Approved Aug 14 2026 (C1): any component rebuilt ships accessible or it is not
done. This document records the audit at the end of wave 9 against the wave 0
baseline, per CLAUDE.md's rules. Every number below is re-measured by a probe
on every run — the citations name which one.
## The metrics
| Metric | Wave 0 baseline | Now | Target | Verified by |
|---|---|---|---|---|
| `<div>` / `<span>` with `onclick` | 12 / 2 | **0** | 0 | `helptip_check.py` (grep, comments stripped) |
| `.help-tip` unreachable by keyboard | 15 (18 by wave 6) — re-measured at T9.5 start: **20** | **0** | 0 | `helptip_check.py` (driven with real keys and taps) |
| `aria-live` regions | 0 | ≥1 per toast system and banner (login, both toasts, release banner, autosave indicator, list-import reports, field toast) | ≥1 each | `a11y_check.py`, `warning_check.py`, `creator_dialogs_check.py` |
| Text below 4.5:1 | present | none found on the audited surfaces | 0 | `a11y_check.py` (creator sweep), `frame_check.py` BL-013 note |
| `outline: none` without replacement | present | **0** (grep with replacement detection) | 0 | `helptip_check.py` |
| Native dialogs | 79 | **21** | 0 or documented | `creator_dialogs_check.py` prints the count; see the gap below |
**The count went up before it went down, exactly as the task predicted:** the
wave 6 exit counted 18 unreachable help-tips; at the start of T9.5 there were
**20** (T6.x and wave 7/8 tasks reused the component as designed). All 20 are
buttons now — the fix is in the component (`help.js` upgrades every badge at
load and exposes `helpTipUpgrade()` for late renders), so a badge added
tomorrow is born reachable.
## The documented gap — 21 native dialogs
`admin.js` (6), `users.js` (10), `index.html` (5). These are the operator
consoles and the launcher — surfaces **no S1 task ever named** (S1's two
halves were the wizard, T5.8, and the creator, T7.9; both measure 0). They are
admin-only or low-frequency flows, every one a genuine confirm-before-destroy.
Logged as **BL-024** for conversion to the T7.9 dialog kit rather than done
here: converting three more pages inside the audit task is the drive-by
CLAUDE.md forbids.
## The help-tip component (S8)
- The badge is a `<button>` with `aria-label`, `aria-expanded`, and a
`:focus-visible` ring from the shared `--cds-focus` token.
- The tooltip is one `role="tooltip"` bubble, viewport-clamped on both axes —
which also ended BL-001: the old CSS `::after` escaping its badge was the
creator's last 390px overflow.
- Paths: keyboard (focus shows, Escape hides), touch (tap toggles, tap
elsewhere closes), pointer (hover shows). Driven at 390px by
`helptip_check.py`.
- The injected styles now use theme tokens; the block previously carried four
raw hexes of the kind S5 counted.
## Keyboard-only primary flow
Sign in → pick a project → SOP wizard → create a work package → issue it.
Covered by probes that dispatch **real CDP key events** (synthetic
`KeyboardEvent`s never reach native activation — the wave 5 lesson, recorded
in `form_structure_check.py`):
| Leg | Probe |
|---|---|
| Sign in | `server/smoketest.py` (form submit), `login.html` roles verified in `a11y_check.py` |
| Launcher → project | `launcher_check.py` (B3, keyboard section) |
| SOP wizard steps | `stepper_check.py` (A4/S9: ten real buttons, keyboard operable) |
| Creator sections + save | `form_structure_check.py` §7 (Tab/Enter/Space on rail and headings), `creator_dialogs_check.py` (validation focus order) |
| Issue | `hold_check.py` (the status control end to end) |
## Per-page results
| Page | Interactive elements | Announcements | Focus | Notes |
|---|---|---|---|---|
| login.html | native form controls | `role="alert"`/`role="status"` (the app's reference pattern) | visible | the pattern every other page copies |
| index.html (launcher) | buttons/links | status line announced | visible | 5 native dialogs → BL-024 |
| work-package-suite.html (wizard) | 0 div/span handlers; library entries are buttons (T9.5) | `wp-toast` role-differentiated | T3.4 ring | 0 native dialogs |
| wp-creation-index.html (creator) | 0 div/span handlers; chips are buttons (T9.5) | toast + release banner + field errors, all live regions | ring on all 120+ focusables (`a11y_check`) | 0 native dialogs |
| field.html | buttons throughout, 44px targets | `role="status"`/`alert` toast | visible | offline drawings reachable (files_check) |
| admin.html / users.html | buttons | banners | visible | 16 native dialogs → BL-024 |

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

@@ -0,0 +1,115 @@
# Completion — the R2 plan, reconciled (T9.7, 2026-08-19)
All 65 items accounted for: the 55 in `IMPLEMENTATION.md` §6 and the 10 in
`docs/waves/decisions-2026-08-18.md`. Delivery references are task ids; the
branch is local-only by instruction (one task = one commit, ids in every
commit message), so the commit history IS the PR trail. Verification counts
name the probe that re-checks the item on every run.
## Change requests
| Item | Status | Delivered by | Notes / deviations |
|---|---|---|---|
| CR-001 | built | T6.1 (`generalinfo_check` 49) | |
| CR-002 | built | waves 12, field toggles T5.7; **applied to the export at T9.1** | the Micron samples now name `costCode:false, acumaticaTask:false` |
| CR-003 | built | T6.2 | three priorities, escalating order |
| CR-004 | built | T6.3 (`locations_check`, `rollup_check`) | paths, not labels |
| CR-005 | built | T5.4 | upload path; **B100 list still not supplied** (§8) |
| CR-006 | built | T5.7 (`sections_check` 95) | hidden, never deleted — pinned |
| CR-007 | built | T7.7 (`files_check` 36) | D8 numbers enforced server-side; offline verified against a killed server |
| CR-008 | built | T9.1 (`export_check` 20) | **deviation raised, not decided:** merge-vs-list — recommendation is inline images + listed PDFs; merged-PDF output needs Nick |
| CR-009 | built | T8.1 (`kitting_check`) | statuses adopted as proposed; kitting off for Micron EUV by toggle |
| CR-010 | built | T8.2 | owner on the PACKAGE (confirmed Aug 18), account-backed, orphan-safe |
| CR-011 | built | T8.3 (`kitting_notify_check` 17) | coalesced; T7.6 gate reused; sink-verified |
| CR-012 | built | T8.4 | shared location lists + detail field; prints and mails |
| CR-013 | built | T8.5 (`mreq_check` 19) | lightweight scope exactly; fences grepped |
| CR-014 | built | T7.6 (`qa_gate_check` 40) | **deviation, stated in the commit:** the email body omits location/scope — the done-when's no-customer-IP rule outranked the Do-paragraph; fuller body needs Nick |
| CR-015 | built | T7.3 (`hold_check` 50) | root cause stated (both halves); regression test specific to clear-last-constraint |
| CR-016 | built | T5.7 | |
| CR-017 | guarded | T9.2 | present, optional, rolls up; BL-023 logs the productivity factor |
| CR-018 | built | T6.4 (`rollup_check` 63) | |
## Findings and structural items
| Item | Status | Delivered by | Notes |
|---|---|---|---|
| F1F5 | built | waves 13 | |
| F6 | built, one number open | T7.2 (5,399 → 1,954px) | **BL-022:** 2.17 screens vs the strict 2.0 encoding of “roughly two” — product call, check stays red |
| S1 | built | T5.8 wizard, T7.9 creator (`creator_dialogs_check` 20) | 79 → 21 dialogs; the 21 live on surfaces no S1 task named — **BL-024** |
| S2S5, S9S12 | built | waves 25 | |
| 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 | 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 |
| A3A5, A7 | built | waves 16 | localization re-verified through T7.10's admin edits (`cards_check`) |
| A6 | built | T7.5 (`triage_check` 16) | |
| B1B5 | built | waves 25 | |
| B6 | built | T7.8 (`sticky_bar_check` 12) | |
| B7 | built | T7.1 (`frame_check` 38) | **deviation, stated in the commit:** the creator became its own page rather than merging into the parent — measured trade (0 collisions vs 21+9) |
| C1 | audited | T9.5 (`accessibility-audit.md`) | every metric probe-backed |
| C2 | audited | T9.6 (`mobile_check` 24) | screenshots committed beside the wave 0 baseline |
| C3 | built | wave 3 | |
| C4 | built | wave 4 interim, T9.9 full (`color_check` 4) | zero literals outside `theme-light.css` |
## The August 18 decisions
| Item | Status | Delivered by |
|---|---|---|
| D1 | built | T7.1 (sample controls visible; consolidated at T9.4 per the S7 reconciliation) |
| D2 | built | T7.6 (QA group on the SOP wizard) |
| D3 | built | T7.2 (rail + collapse; the "at rest" amendment recorded) |
| D4 | built | T7.3 (Urgent surfaces the audited path; the override names what it crosses) |
| D5 | built | T7.10 (one analytics core; admin report) |
| D6 | built | T8.6 (material list, the CR-005 pattern, one shared component) |
| D7 | built | T9.8 (`archived_check` 15) |
| D8 | built | T7.7 (5MB / PDF+image / 2GB, 80% warning) |
| D9 | built | T7.6 (Field View text pill at 390px) |
| D10 | built | T7.6 / reused T8.3 (stored setting, admin-only, audited, sink-verified) |
| D11 | built | merge of `origin/Micron-Assets` + integration, Aug 20 (`assets_check`); see `decisions-2026-08-20.md` |
## Out of scope, confirmed unbuilt
- **Parts catalog / live inventory / warehouse integration** — `mreq_check` and
`materials_check` grep the model and the diff for stock/inventory/price
fields on every run; none exist. D6's uploaded list is project-scoped data
entry, not a catalog.
- **Asset database integration** — SUPERSEDED by D11 on Aug 20: Cody Schaefer's
Micron asset picker (read-only catalog lookup, `origin/Micron-Assets`) merged
and adapted to the R2 creator. The assets section stays a CR-006 toggle
(off on the Micron sample). This line was true when written.
- **CxAlloy integration** — CR-014 is notification-only, as the task footnote
ordered; the platforms block stores names and URLs, nothing calls them.
- **P6 activity import** — CR-001 renders the two fields; nothing imports.
## Outstanding inputs (IMPLEMENTATION.md §8, restated)
- Nate's spreadsheet and the master material workbook: **still not supplied.**
D6 built the upload path so their arrival is a paste, not a build.
- The real B100 floor/area list: **still not supplied.** CR-005's upload is
ready for it; every seeded value remains obviously fake.
- SMTP host/credentials for production mail: the gate ships off; the password
is env-only. Nothing on this branch has sent a real email.
## For the next revision
- **BL-020** — the wizard→creator navigation prompts to leave (T4.3's guard
doing its job on what is now a page exit); product call on suppression.
- **BL-021** — `project_sop_team()` reads a path `pushSOP` never writes; the
critical-reopen mail has never reached the PM/CM. One line, needs its own
sink verification.
- **BL-022** — F6's "roughly two screens": 2.17 vs the strict 2.0. Bless it or
name the chrome to trim.
- **BL-023** — the productivity factor (actual ÷ estimated); data already
aggregated, placement needs Nick.
- **BL-024** — 21 native dialogs on admin/users/launcher; the T7.9 kit is
ready for them.
- 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).
- Acceptance criteria that turned out wrong, for the next plan's calibration:
F6's height bar collided with D3's own chosen design (amended once, then
left red rather than moved again); A2's "tab count badge" predated D3
removing tabs (the rail carried it); S1's "0 dialogs" never named the
console pages that held a quarter of them.

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
@@ -301,6 +301,19 @@ Wave 9 adds these:
```bash ```bash
python tests/export_check.py # CR-008/CR-017 - export walk + hours guard 20 checks python tests/export_check.py # CR-008/CR-017 - export walk + hours guard 20 checks
python tests/sample_check.py # S7 - one sample affordance, confirmed+fenced 10 checks python tests/sample_check.py # S7 - one sample affordance, confirmed+fenced 10 checks
python tests/icon_check.py # S6 - one icon system, no emoji, mapped 5 checks
python tests/helptip_check.py # C1/S8 - tips by keyboard+touch, audit greps 14 checks
python tests/mobile_check.py # C2 - all 7 pages at 390px, targets + fit 24 checks
python tests/archived_check.py # D7 - archived projects, admins only, frozen 15 checks
python tests/color_check.py # C4 - zero literals outside theme-light 5 checks
```
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 **Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live

View File

@@ -961,3 +961,51 @@ Column `#` is the line number in `html/theme-light.css`. Read down each column p
**120 declarations.** Lines 3116 are the Carbon g10 set; lines 170175 are the app-shell **120 declarations.** Lines 3116 are the Carbon g10 set; lines 170175 are the app-shell
group added by the suite. Many share a literal by design — that is Carbon's v10→v11 alias group added by the suite. Many share a literal by design — that is Carbon's v10→v11 alias
layer, not the `S5` defect. See the note at the end of §3. layer, not the `S5` defect. See the note at the end of §3.
## Icons (S6 / T9.3)
One system: **monochrome text-presentation glyphs**, chosen because the suite
is classic-script vanilla HTML with no bundler — an SVG sprite or icon font is
a new asset pipeline, while text glyphs render through the same font stack as
the words beside them. The trade accepted: text glyphs vary slightly by font,
but never flip into per-platform colour artwork the way emoji do, which is the
failure S6 names. **No emoji anywhere in the UI**; `tests/icon_check.py`
sweeps every page for emoji-range codepoints and the U+FE0F emoji-presentation
selector on every run.
One meaning per glyph, one glyph per meaning:
| Meaning | Glyph | Codepoint | Notes |
|---|---|---|---|
| done / ok / success | ✓ | U+2713 | replaced emoji ✅ |
| close / remove / failure | ✕ | U+2715 | replaced emoji ❌ |
| needs attention / warning | ⚠ | U+26A0 | text presentation |
| blocked / on hold | ⊘ | U+2298 | replaced emoji ⛔ |
| edit with a logged reason | ✎ | U+270E | replaced emoji 🔒; same meaning on sign-off overrides |
| revert / refresh | ↺ / ↻ | U+21BA / U+21BB | direction distinguishes undo from reload |
| settings / admin | ⚙ | U+2699 | replaced ⚡ on the side nav |
| export / download | ⤓ | U+2913 | |
| import / upload | ⤒ | U+2912 | |
| key point (help copy) | ◆ | U+25C6 | replaced emoji ⭐ |
| reference link | ▸ | U+25B8 | replaced emoji 📁 |
| info tooltip badge | ⓘ | U+24D8 | the `.help-tip` component (S8/T9.5) |
| back / navigation | ← → | U+2190 U+2039 U+203A U+2192 | |
| views (nav) | ◔ ▤ ▦ ▧ | U+25D4 U+25A4 U+25A6 U+25A7 | one per view, never reused |
| drag handle | ⣿ | U+283F | sequence reordering |
| home | ⌂ | U+2302 | side nav |
| search | ⌕ | U+2315 | app bar |
| print / save PDF | ⎙ | U+2399 | |
| power / sign out | ⏻ | U+23FB | side nav |
| sort direction | ▲ ▼ | U+25B2 U+25BC | column headers, paired |
| in the QA queue | ◉ | U+25C9 | the CR-014 banner |
| clock / language & time | ◷ | U+25F7 | replaced ⌚ U+231A, which is emoji-presentation by default |
| menu (off-canvas) | ☰ | U+2630 | app bar |
| people / directory | ☺ | U+263A | side nav; text presentation |
| field view | ⚒ | U+2692 | side nav; text presentation |
| password / key | ⚿ | U+26BF | side nav |
Dropped rather than mapped: ⚡ on action buttons (the label carries the
action), 📷 on “Add photo” (labelled), 📄/🖼 on file rows (the filename is the
label; a pictogram repeating “this is a file” said nothing). Every remaining
glyph sits beside visible text or carries an accessible name — none is the
only carrier of meaning.

View File

@@ -54,7 +54,7 @@ deliberately deferred.
## Found during implementation ## Found during implementation
### BL-001 — The creator overflows horizontally at 1440px ### BL-001 — CLOSED at T9.5 — The creator overflows horizontally (1440px, then 390px)
- **Found during:** T0.2 - **Found during:** T0.2
- **Where:** `html/wp-creation-index.html` / `html/wp-creation-styles.css` - **Where:** `html/wp-creation-index.html` / `html/wp-creation-styles.css`
@@ -123,6 +123,13 @@ deliberately deferred.
owns this entry now. `tests/form_structure_check.py` reports the measurement on owns this entry now. `tests/form_structure_check.py` reports the measurement on
every run, and `tests/frame_check.py` keeps the failure pinned so the entry every run, and `tests/frame_check.py` keeps the failure pinned so the entry
cannot be closed by silence. cannot be closed by silence.
- **CLOSED, T9.5.** The `S8` rebuild replaced the escaping CSS `::after` tooltip
with a viewport-clamped bubble element, and the creator measures
**scrollWidth 390 vs clientWidth 390** at a 390px viewport. `frame_check.py`'s
pin flipped: it now asserts the ABSENCE of overflow, so a regression reopens
this entry loudly. Three causes in this entry's lifetime - the user-menu run
(fixed by `T1.2`), the injected `--nav-w` (spent by `T7.1`), the tooltip
(fixed here) - each found only because the measurement kept running.
### BL-002 — `outline: none` appears three times in the wizard sheet, not once ### BL-002 — `outline: none` appears three times in the wizard sheet, not once
@@ -150,7 +157,7 @@ deliberately deferred.
- **Suggested wave or follow-up:** `T2.2` should ship the drawer with adequate targets; - **Suggested wave or follow-up:** `T2.2` should ship the drawer with adequate targets;
`C1`'s audit at `T9.5` confirms it app-wide. `C1`'s audit at `T9.5` confirms it app-wide.
### BL-004 — `help.js` ships a 52-colour palette in a different design language ### BL-004 — CLOSED at T9.9 — `help.js` ships a 52-colour palette in a different design language
- **Found during:** T3.1 - **Found during:** T3.1
- **Where:** `html/help.js:79` (the injected `<style>`) - **Where:** `html/help.js:79` (the injected `<style>`)
@@ -165,8 +172,9 @@ deliberately deferred.
work, not a non-issue. work, not a non-issue.
- **Suggested wave or follow-up:** wave 9, alongside `C4`. Documented in - **Suggested wave or follow-up:** wave 9, alongside `C4`. Documented in
`docs/reference/tokens.md` §1. `docs/reference/tokens.md` §1.
- **CLOSED, T9.9:** the help centre's palette collapsed onto theme-light tokens; color_check.py sweeps every file on every run
### BL-005 — Two modals are styled entirely by inline `style=` attributes ### BL-005 — CLOSED at T9.9 — Two modals are styled entirely by inline `style=` attributes
- **Found during:** T3.1 - **Found during:** T3.1
- **Where:** `html/auth-guard.js:67-92` (change-password) and `html/wp-format.js:120-150` - **Where:** `html/auth-guard.js:67-92` (change-password) and `html/wp-format.js:120-150`
@@ -177,6 +185,7 @@ deliberately deferred.
- **Why not now:** they are markup built by JS, not a stylesheet, so they are outside `T3.2`'s - **Why not now:** they are markup built by JS, not a stylesheet, so they are outside `T3.2`'s
four-sheet surface. Both dialogs are rebuilt as accessible components under `C1`. four-sheet surface. Both dialogs are rebuilt as accessible components under `C1`.
- **Suggested wave or follow-up:** `T9.5`, with the `C1` audit. - **Suggested wave or follow-up:** `T9.5`, with the `C1` audit.
- **CLOSED, T9.9:** both JS-built dialog kits (auth-guard, wp-format) and project-data's badges read tokens; zero literals remain
### BL-006 — Seventeen half-pixel font sizes ### BL-006 — Seventeen half-pixel font sizes
@@ -212,7 +221,7 @@ deliberately deferred.
it in four waves, and it is measured every run now instead of once. Carried to it in four waves, and it is measured every run now instead of once. Carried to
`T7.2`. `T7.2`.
### BL-008 — There is a second brand blue: `#2563d6` ### BL-008 — CLOSED at T9.9 — There is a second brand blue: `#2563d6`
- **Found during:** T3.1 - **Found during:** T3.1
- **Where:** `html/wp-creation-styles.css:565`, `html/help.js`, `html/wp-creation-app.js:1257` - **Where:** `html/wp-creation-styles.css:565`, `html/help.js`, `html/wp-creation-app.js:1257`
@@ -224,8 +233,9 @@ deliberately deferred.
- **Why not now:** swapping it changes a rendered fill, which `T3.2` forbids. It is the same - **Why not now:** swapping it changes a rendered fill, which `T3.2` forbids. It is the same
conversation as the green action buttons. conversation as the green action buttons.
- **Suggested wave or follow-up:** wave 9, with `C4`. `T3.5` is scoped to buttons; this is a field fill. See `docs/reference/tokens.md` §8-E. - **Suggested wave or follow-up:** wave 9, with `C4`. `T3.5` is scoped to buttons; this is a field fill. See `docs/reference/tokens.md` §8-E.
- **CLOSED, T9.9:** the second brand blue is deleted - .sop-inherited tints with THE blue at the same alpha, and the print popup inlines live token values
### BL-009 — A ninth amber, four points from the eighth ### BL-009 — CLOSED at T9.9 — A ninth amber, four points from the eighth
- **Found during:** T3.2 - **Found during:** T3.2
- **Where:** `html/field.html:35` (`.pill.warn`) - **Where:** `html/field.html:35` (`.pill.warn`)
@@ -236,6 +246,7 @@ deliberately deferred.
- **Why not now:** merging it moves a rendered colour, which `T3.2` forbids. `T3.2` named it - **Why not now:** merging it moves a rendered colour, which `T3.2` forbids. `T3.2` named it
`--wp-status-warning-text-alt` so it is visible rather than hidden in a hex. `--wp-status-warning-text-alt` so it is visible rather than hidden in a hex.
- **Suggested wave or follow-up:** wave 9, with `C4`. `T3.5` is scoped to buttons; this is a status pill. See `docs/reference/tokens.md` §8-K. - **Suggested wave or follow-up:** wave 9, with `C4`. `T3.5` is scoped to buttons; this is a status pill. See `docs/reference/tokens.md` §8-K.
- **CLOSED, T9.9:** --wp-status-warning-text-alt is deleted; its one consumer (field.html warn pill) uses the real amber
### BL-010 — 829 raw spacing, type and radius values remain inside rules ### BL-010 — 829 raw spacing, type and radius values remain inside rules
@@ -253,7 +264,7 @@ deliberately deferred.
- **Suggested wave or follow-up:** `T5.x` and `T7.1`, where these pages are re-laid-out and the - **Suggested wave or follow-up:** `T5.x` and `T7.1`, where these pages are re-laid-out and the
values are being chosen again anyway. See `docs/reference/tokens.md` §6b and §11. values are being chosen again anyway. See `docs/reference/tokens.md` §6b and §11.
### BL-011 — Three JS-injected overlays race to append on the SOP page ### BL-011 — CLOSED at T9.9 — Three JS-injected overlays race to append on the SOP page
- **Found during:** T3.2 - **Found during:** T3.2
- **Where:** `html/work-package-suite.html` — `#wp-sync-badge`, `.wp-navscrim`, `#wp-sidenav` - **Where:** `html/work-package-suite.html` — `#wp-sync-badge`, `.wp-navscrim`, `#wp-sidenav`
@@ -266,8 +277,9 @@ deliberately deferred.
- **Why not now:** invisible to users, and the fix is ordering in three separate scripts, which - **Why not now:** invisible to users, and the fix is ordering in three separate scripts, which
is a change with no observable benefit while `T7.1` is still going to move this code. is a change with no observable benefit while `T7.1` is still going to move this code.
- **Suggested wave or follow-up:** wave 9, if it is still true after `T7.1`. - **Suggested wave or follow-up:** wave 9, if it is still true after `T7.1`.
- **CLOSED, T9.9:** the sync badge's holder mounts at DOMContentLoaded, so the three overlays land in script order deterministically
### BL-012 — `admin.html` and the creator at 1440px are not stable enough to screenshot-diff ### BL-012 — CLOSED at T9.9 — `admin.html` and the creator at 1440px are not stable enough to screenshot-diff
- **Found during:** T3.2 - **Found during:** T3.2
- **Where:** `tests/baseline_shots.py` output for `admin-390`, `admin-1440`, `creator-1440` - **Where:** `tests/baseline_shots.py` output for `admin-390`, `admin-1440`, `creator-1440`
@@ -280,6 +292,7 @@ deliberately deferred.
covers what the diff was being asked to prove, and covers it better. covers what the diff was being asked to prove, and covers it better.
- **Suggested wave or follow-up:** wave 9, alongside `C2`. Either freeze the clock in the - **Suggested wave or follow-up:** wave 9, alongside `C2`. Either freeze the clock in the
fixture or exclude the live regions from capture — otherwise every later wave re-learns this. fixture or exclude the live regions from capture — otherwise every later wave re-learns this.
- **CLOSED, T9.9:** baseline_shots.py freezes Date and Math.random per document; two consecutive admin captures measured byte-identical
### BL-013 — The creator's inputs have no visible focus ring at all ### BL-013 — The creator's inputs have no visible focus ring at all
@@ -323,7 +336,7 @@ deliberately deferred.
- **Why not now:** out of `A5`'s stated scope, and `A4`/`S9` rebuild the stepper. - **Why not now:** out of `A5`'s stated scope, and `A4`/`S9` rebuild the stepper.
- **Suggested wave or follow-up:** `T7.x`, with the stepper rebuild. - **Suggested wave or follow-up:** `T7.x`, with the stepper rebuild.
### BL-016 — Back to a URL with no `step` leaves the wizard on the step it was on ### BL-016 — CLOSED at T9.9 — Back to a URL with no `step` leaves the wizard on the step it was on
- **Found during:** T5.1 - **Found during:** T5.1
- **Where:** `html/work-package-suite-app.js`, the `WPUrl.onChange` handler - **Where:** `html/work-package-suite-app.js`, the `WPUrl.onChange` handler
@@ -340,6 +353,7 @@ deliberately deferred.
unreviewable. unreviewable.
- **Suggested wave or follow-up:** wave 9, with `C2`. `tests/stepper_check.py` pins the - **Suggested wave or follow-up:** wave 9, with `C2`. `tests/stepper_check.py` pins the
current behaviour with a named check so the fix has a test waiting for it. current behaviour with a named check so the fix has a test waiting for it.
- **CLOSED, T9.9:** a step-less wizard URL is step 1 (parseInt || 1); stepper_check's pin flipped with the fix, as the entry planned
### BL-017 — The native-dialog baseline metric counts prose ### BL-017 — The native-dialog baseline metric counts prose
@@ -357,7 +371,7 @@ deliberately deferred.
a comment-stripped figure alongside the raw one and state both. Wave 9 sets the a comment-stripped figure alongside the raw one and state both. Wave 9 sets the
target against the stripped figure. target against the stripped figure.
### BL-018 — The Work Package tab's gate is the last localStorage-derived status ### BL-018 — CLOSED at T9.9 — The Work Package tab's gate is the last localStorage-derived status
- **Found during:** T5.3 - **Found during:** T5.3
- **Where:** `html/work-package-suite-app.js` — `restoreSavedSOP()` sets `sopComplete`, - **Where:** `html/work-package-suite-app.js` — `restoreSavedSOP()` sets `sopComplete`,
@@ -389,8 +403,9 @@ deliberately deferred.
**imports `set_sop` from `sections_check.py`** rather than writing a fifth **imports `set_sop` from `sections_check.py`** rather than writing a fifth
copy, so the workaround is in one place and disappears when the fixture is copy, so the workaround is in one place and disappears when the fixture is
fixed. Four probes is enough evidence: `T9.9` owns it. fixed. Four probes is enough evidence: `T9.9` owns it.
- **CLOSED, T9.9:** the false-complete write requires the {sop,state} shape, and browser_check.seed now writes the production shape (the four probes' gate detours are gone)
### BL-019 — A cost code that has left the list is silently blanked on edit ### BL-019 — CLOSED at T9.9 — A cost code that has left the list is silently blanked on edit
- **Found during:** T5.6 - **Found during:** T5.6
- **Where:** `html/wp-creation-app.js` — `buildCostCodes()` at `:185`, consumed by - **Where:** `html/wp-creation-app.js` — `buildCostCodes()` at `:185`, consumed by
@@ -411,6 +426,7 @@ deliberately deferred.
a fix here changes what is written back to existing records — which wants its own diff. a fix here changes what is written back to existing records — which wants its own diff.
- **Suggested wave or follow-up:** wave 9. The fix is the four lines already written for - **Suggested wave or follow-up:** wave 9. The fix is the four lines already written for
`gov_wosize`. `gov_wosize`.
- **CLOSED, T9.9:** a stored cost code with no matching option is kept as an option (the gov_wosize pattern), so the round-trip preserves it
### BL-014 — Four controls fall back to the browser's default focus ring ### BL-014 — Four controls fall back to the browser's default focus ring
@@ -431,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
@@ -458,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()`
@@ -475,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`
@@ -494,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
@@ -502,8 +518,39 @@ 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 — 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)
- **What:** the app-wide native dialog count fell 79 → 21 across `S1`'s two
tasks (`T5.8` wizard, `T7.9` creator). The remainder sit on surfaces no `S1`
task ever named — admin-only or low-frequency flows, every one a genuine
confirm-before-destroy. The T7.9 dialog kit (`wpConfirmDialog`/
`wpPromptDialog`) is built and proven; conversion is mechanical.
- **Why not now:** converting three more pages inside the audit task is the
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 — 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:
`box-shadow:0 0 0 2px rgba(37,99,214,.15)`
- **What:** BL-008 removed the second brand blue (#2563d6 = rgb 37,99,214) and
`color_check` greps both spellings — but only inside `theme-light.css`, and
only with spaces (`37, 99, 214`). This space-free rgba consumer slid past
both nets. C4's recorded exception legitimately allows rgba **alphas** as
opacity recipes, so this is not a token-rule defect; it is the wrong BASE
colour under the alpha. The correct tint is THE blue: `rgba(15,98,254,.15)`.
- **Why not now:** noticed in passing during an unrelated fix; one-line change
plus widening `color_check`'s grep to space-free spellings deserves its own
entry rather than a drive-by.
- **Suggested wave or follow-up:** next housekeeping pass, with the check
widened so it cannot recur.

View File

@@ -0,0 +1,102 @@
# Decisions — August 20, 2026
One item. Like the August 18 set, it is a **new item** with its own `D` id, not a
reinterpretation of an existing one.
---
## D11 — The Micron asset picker merges into the R2 creator
- **Arrived as:** `origin/Micron-Assets` (`7ef1fcd`, Cody Schaefer, Aug 18) — written
against pre-R2 `main`, integrated here by Nick's instruction on Aug 20.
- **Amends:** the R2 completion record's "Asset database integration — out of scope,
confirmed unbuilt" line, which was true when written and stops being true here.
- **Surface:** `html/` (creator), `server/` (`assets_db.py`, `/api/assets`),
`docker-compose.yml`, `requirements.txt`.
### What the branch brought
A read-only lookup onto the Micron asset catalog (a SQL Server instance outside this
repo): the whole catalog is fetched once per creator page load through `/api/assets`
and searched in memory; picked assets are stored on the package tagged
`source:'catalog'` with the DB's own casing; anything not in the catalog is added by
hand and visibly tagged manual. CSV import and Excel column paste bulk-add with the
same matching. Unconfigured (`MICRON_DB_URL` unset) and unreachable are first-class
states that degrade to manual entry — the suite runs without Micron wired up.
### What integration changed (and why)
The branch predates waves 59, so it used surfaces R2 replaced. Each adaptation keeps
Cody's behaviour and moves it onto the R2 idiom:
1. **Six `alert()` calls → the T7.9 dialog kit and toast.** The creator ships zero
native dialogs (`creator_dialogs_check` pins the count). File-handling errors use
`toast(msg,'alert')` exactly as the drawings uploader and comment import do;
the instructional message and the import summary use the kit, which gained the
one-button `wpAlertDialog` shape it was always going to need (BL-024 wants it too).
2. **The export block** moved inside T9.1's sectioned `add('assets', …)` frame, so the
CR-006 assets toggle keeps governing it. Content is Cody's: two columns, Asset ID +
Note, no controls.dev link column.
3. **`initAssetPicker()`** joined the R2 `bootData()` loads rather than replacing them.
4. **`role="status"`** on the picker's source note, so loading → ready/absent/error
announces (C1, the login.html pattern).
5. Everything else landed as written: his `⤒` import glyph is already the S6-mapped
U+2912, `.material-actions` is the creator's own class, and the styles block
declares no colour literal (`color_check` re-verifies).
### Recorded properties, restated as constraints
- **Read-only, structurally.** `assets_db.py` contains one SELECT and no other
statement; there is no POST route. `assets_check` greps this on every run.
- **Credentials are env-only** (`MICRON_DB_URL`), matching the SMTP password rule.
Driver errors are logged server-side and never propagated to the browser, because
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`.

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

@@ -307,10 +307,10 @@ criteria turned out wrong, inputs still outstanding, and follow-ups logged along
## Wave 9 exit criteria ## Wave 9 exit criteria
- [ ] the export matches the final structure - [x] the export matches the final structure (`export_check.py`, 20 checks — required fields present, CR-002 removals absent, CR-006 suppression honoured, tablet-legible)
- [ ] one icon system, one sample-data affordance - [x] one icon system, one sample-data affordance (`icon_check.py`, `sample_check.py`)
- [ ] accessibility metrics hit target or are documented - [x] accessibility metrics hit target or are documented (`docs/reference/accessibility-audit.md`; the one gap — 21 dialogs on surfaces no S1 task named — is BL-024)
- [ ] the primary flow works at 390px - [x] the primary flow works at 390px (`mobile_check.py`, 24 checks, all seven pages; screenshots committed)
- [ ] archived projects are readable by project admins and invisible to everyone else (`D7`) - [x] archived projects are readable by project admins and invisible to everyone else (`archived_check.py`, 15 checks)
- [ ] the backlog has no entry still pointing at wave 9 - [x] the backlog has no entry still pointing at wave 9 (nine closed at T9.9, each with its measurement)
- [ ] every item is reconciled - all 65 - [x] every item is reconciled all 65 (`docs/reference/completion.md`)

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

@@ -36,13 +36,13 @@ async function checkHealth(){
b.className='banner'; b.textContent='Checking…'; b.className='banner'; b.textContent='Checking…';
const { status, json } = await api('GET','/api/health'); const { status, json } = await api('GET','/api/health');
if(status===200 && json && json.ok){ if(status===200 && json && json.ok){
b.className='banner ok'; b.textContent=' API reachable — /api/health returned ok.'; b.className='banner ok'; b.textContent=' API reachable — /api/health returned ok.';
} else if(status===404){ } else if(status===404){
b.className='banner bad'; b.textContent=' /api/ returns 404 — the reverse proxy is not routing /api/ to the API. The site loads but the API is unreachable from the browser.'; b.className='banner bad'; b.textContent=' /api/ returns 404 — the reverse proxy is not routing /api/ to the API. The site loads but the API is unreachable from the browser.';
} else if(status===0){ } else if(status===0){
b.className='banner bad'; b.textContent=' Could not reach the server: '+json; b.className='banner bad'; b.textContent=' Could not reach the server: '+json;
} else { } else {
b.className='banner bad'; b.textContent=' Unexpected response: HTTP '+status; b.className='banner bad'; b.textContent=' Unexpected response: HTTP '+status;
} }
} }
@@ -121,9 +121,9 @@ function stdConstraints(open){ return ['Safety & Permitting','Quality Control /
async function seedDemo(){ async function seedDemo(){
const o=document.getElementById('demo-out'); o.innerHTML=''; const o=document.getElementById('demo-out'); o.innerHTML='';
let r = await api('GET','/api/health'); let r = await api('GET','/api/health');
if(!(r.status===200 && r.json && r.json.ok)){ demoLog(' API unreachable — fix /api/ routing first.'); return; } if(!(r.status===200 && r.json && r.json.ok)){ demoLog(' API unreachable — fix /api/ routing first.'); return; }
r = await api('POST','/api/projects',{name:'DEMO — Micron INC (test data)',number:'DEMO-001',client:'Micron Technology, Inc.',division:'Semiconductor',site:'Boise, ID — Fab',created_by:'admin-console'}); r = await api('POST','/api/projects',{name:'DEMO — Micron INC (test data)',number:'DEMO-001',client:'Micron Technology, Inc.',division:'Semiconductor',site:'Boise, ID — Fab',created_by:'admin-console'});
if(r.status!==200){ demoLog(' create project failed (HTTP '+r.status+')'); return; } if(r.status!==200){ demoLog(' create project failed (HTTP '+r.status+')'); return; }
const pid=r.json.id; demoLog('Project created: '+r.json.name); const pid=r.json.id; demoLog('Project created: '+r.json.name);
r = await api('POST','/api/sops',{project_id:pid,name:'DEMO SOP',number:'DEMO-001',complete:true,data:{governance:{woFormat:'WP##-[Sector]-[TYPE]',disciplines:['Mechanical','Electrical','Tech'],discMode:'choice',instanceSuffix:'letter',woSize:'Standard — 35 days (≈4080 hrs)',sizeHoursMax:'80'}}}); r = await api('POST','/api/sops',{project_id:pid,name:'DEMO SOP',number:'DEMO-001',complete:true,data:{governance:{woFormat:'WP##-[Sector]-[TYPE]',disciplines:['Mechanical','Electrical','Tech'],discMode:'choice',instanceSuffix:'letter',woSize:'Standard — 35 days (≈4080 hrs)',sizeHoursMax:'80'}}});
const sid=r.json && r.json.id; demoLog('SOP created (complete).'); const sid=r.json && r.json.id; demoLog('SOP created (complete).');
@@ -142,20 +142,22 @@ async function seedDemo(){
await mk('WP05-3P-PANEL','3P panel install','Panel Install','Draft',{disciplines:['Electrical'],hours:'120',constraints:stdConstraints(['Schedule']),due:'2026-07-20'}); await mk('WP05-3P-PANEL','3P panel install','Panel Install','Draft',{disciplines:['Electrical'],hours:'120',constraints:stdConstraints(['Schedule']),due:'2026-07-20'});
r = await api('GET','/api/wps/metrics?project_id='+pid); r = await api('GET','/api/wps/metrics?project_id='+pid);
demoLog('\nMetrics (masters excluded): '+JSON.stringify(r.json)); demoLog('\nMetrics (masters excluded): '+JSON.stringify(r.json));
demoLog('\n Done — "DEMO — Micron INC (test data)" now appears in the home picker.'); demoLog('\n Done — "DEMO — Micron INC (test data)" now appears in the home picker.');
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.
const r = await api('GET','/api/projects?archived=all'); const r = await api('GET','/api/projects?archived=all');
if(r.status!==200){ demoLog(' API unreachable (HTTP '+r.status+').'); return; } if(r.status!==200){ demoLog(' API unreachable (HTTP '+r.status+').'); return; }
const targets=(r.json||[]).filter(p=>/^(DEMO-|SMOKE-)/.test(String(p.number||''))); const targets=(r.json||[]).filter(p=>/^(DEMO-|SMOKE-)/.test(String(p.number||'')));
if(!targets.length){ demoLog('Nothing to remove.'); return; } if(!targets.length){ demoLog('Nothing to remove.'); return; }
for(const p of targets){ await api('DELETE','/api/projects/'+p.id); demoLog('Deleted: '+p.name+' ('+p.number+')'); } for(const p of targets){ await api('DELETE','/api/projects/'+p.id); demoLog('Deleted: '+p.name+' ('+p.number+')'); }
demoLog('\n Removed '+targets.length+' project(s).'); demoLog('\n Removed '+targets.length+' project(s).');
snapshot(); snapshot();
} }
@@ -175,14 +177,14 @@ async function loadProjects(){
const { status, json } = await api('GET','/api/projects?archived=all'); const { status, json } = await api('GET','/api/projects?archived=all');
if(status===403){ if(status===403){
banner.className='banner bad'; banner.className='banner bad';
banner.textContent=' Your account is not an admin, so you cant archive or delete projects here.'; banner.textContent=' Your account is not an admin, so you cant archive or delete projects here.';
wrap.innerHTML=''; return; wrap.innerHTML=''; return;
} }
if(status===401){ if(status===401){
banner.className='banner bad'; banner.textContent=' Not signed in. Reload and log in again.'; wrap.innerHTML=''; return; banner.className='banner bad'; banner.textContent=' Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
} }
if(status!==200 || !Array.isArray(json)){ if(status!==200 || !Array.isArray(json)){
banner.className='banner bad'; banner.textContent=' Could not load projects (HTTP '+status+').'; wrap.innerHTML=''; return; banner.className='banner bad'; banner.textContent=' Could not load projects (HTTP '+status+').'; wrap.innerHTML=''; return;
} }
banner.style.display='none'; banner.style.display='none';
_adminProjects = json; _adminProjects = json;
@@ -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',
'Its SOP, EVERY work package on it and every access assignment are deleted with it '+ message:'DELETE “'+name+'” permanently?\n\n'+
'(database cascade). This cannot be undone.\n\n'+ 'Its SOP, EVERY work package on it and every access assignment are deleted with it '+
'If you only want it out of the way, cancel and use Archive instead.')) return; '(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); 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 ─────────────────────────────────────────────
@@ -284,14 +291,14 @@ async function loadDefaultMembers(){
const { status, json } = await api('GET','/api/auth/users'); const { status, json } = await api('GET','/api/auth/users');
if(status===403){ if(status===403){
banner.className='banner bad'; banner.className='banner bad';
banner.textContent=' Your account is not an admin, so you cant change who is added to new projects.'; banner.textContent=' Your account is not an admin, so you cant change who is added to new projects.';
wrap.innerHTML=''; return; wrap.innerHTML=''; return;
} }
if(status===401){ if(status===401){
banner.className='banner bad'; banner.textContent=' Not signed in. Reload and log in again.'; wrap.innerHTML=''; return; banner.className='banner bad'; banner.textContent=' Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
} }
if(status!==200 || !Array.isArray(json)){ if(status!==200 || !Array.isArray(json)){
banner.className='banner bad'; banner.textContent=' Could not load users (HTTP '+status+').'; wrap.innerHTML=''; return; banner.className='banner bad'; banner.textContent=' Could not load users (HTTP '+status+').'; wrap.innerHTML=''; return;
} }
banner.style.display='none'; banner.style.display='none';
_defMemUsers = json; _defMemUsers = json;
@@ -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();
} }
} }
@@ -560,7 +568,7 @@ async function saveLocalization(){
const m = document.getElementById('l10n-msg'); const m = document.getElementById('l10n-msg');
if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; } if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; }
} else { } else {
msg.textContent = ' '+((json && json.detail) || ('HTTP '+status)); msg.textContent = ' '+((json && json.detail) || ('HTTP '+status));
msg.style.color = 'var(--red)'; msg.style.color = 'var(--red)';
} }
} }
@@ -627,8 +635,8 @@ async function saveSettings(){
async function testEmail(){ async function testEmail(){
const msg = document.getElementById('set-msg'); msg.textContent = 'Sending test…'; msg.style.color = 'var(--muted)'; const msg = document.getElementById('set-msg'); msg.textContent = 'Sending test…'; msg.style.color = 'var(--muted)';
const { status, json } = await api('POST','/api/settings/test-email', {}); const { status, json } = await api('POST','/api/settings/test-email', {});
if(status===200) { msg.textContent = ' Test sent to '+((json&&json.to)||'you')+'.'; msg.style.color = 'var(--green)'; } if(status===200) { msg.textContent = ' Test sent to '+((json&&json.to)||'you')+'.'; msg.style.color = 'var(--green)'; }
else { msg.textContent = ' '+((json && json.detail) || ('HTTP '+status)); msg.style.color = 'var(--red)'; } else { msg.textContent = ' '+((json && json.detail) || ('HTTP '+status)); msg.style.color = 'var(--red)'; }
} }
async function loadNotifications(){ async function loadNotifications(){
const box = document.getElementById('notif-box'); if(!box) return; const box = document.getElementById('notif-box'); if(!box) return;

View File

@@ -68,11 +68,11 @@
ov.id = 'wp-pw-modal'; ov.id = 'wp-pw-modal';
ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' + ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' +
'justify-content:center;z-index:10002;padding:20px;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;'; 'justify-content:center;z-index:10002;padding:20px;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
var inp = 'width:100%;padding:9px 10px;margin-bottom:12px;border:1px solid #8d8d8d;border-radius:4px;font-size:14px;'; var inp = 'width:100%;padding:9px 10px;margin-bottom:12px;border:1px solid var(--cds-border-strong);border-radius:4px;font-size:14px;';
var lbl = 'display:block;font-size:12px;color:#525252;margin-bottom:4px;'; var lbl = 'display:block;font-size:12px;color:var(--cds-text-secondary);margin-bottom:4px;';
ov.innerHTML = ov.innerHTML =
'<div style="background:#fff;color:#161616;border-radius:10px;max-width:380px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' + '<div style="background:var(--cds-layer);color:var(--cds-text-primary);border-radius:10px;max-width:380px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
'<div style="padding:14px 18px;border-bottom:1px solid #e0e0e0;font-weight:700;">Change password</div>' + '<div style="padding:14px 18px;border-bottom:1px solid var(--cds-border-subtle);font-weight:700;">Change password</div>' +
'<div style="padding:16px 18px;">' + '<div style="padding:16px 18px;">' +
'<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' + '<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' +
'<label style="' + lbl + '">Current password</label>' + '<label style="' + lbl + '">Current password</label>' +
@@ -82,16 +82,16 @@
'<label style="' + lbl + '">Confirm new password</label>' + '<label style="' + lbl + '">Confirm new password</label>' +
'<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' + '<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' +
'</div>' + '</div>' +
'<div style="padding:12px 18px;border-top:1px solid #e0e0e0;display:flex;gap:8px;justify-content:flex-end;">' + '<div style="padding:12px 18px;border-top:1px solid var(--cds-border-subtle);display:flex;gap:8px;justify-content:flex-end;">' +
'<button type="button" id="wp-pw-cancel" style="padding:8px 14px;border:1px solid #8d8d8d;background:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' + '<button type="button" id="wp-pw-cancel" style="padding:8px 14px;border:1px solid var(--cds-border-strong);background:var(--cds-layer);border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
'<button type="button" id="wp-pw-save" style="padding:8px 14px;border:none;background:#0f62fe;color:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Update password</button>' + '<button type="button" id="wp-pw-save" style="padding:8px 14px;border:none;background:var(--cds-interactive-01);color:var(--cds-text-on-color);border-radius:6px;cursor:pointer;font-weight:600;">Update password</button>' +
'</div>' + '</div>' +
'</div>'; '</div>';
function close() { var m = document.getElementById('wp-pw-modal'); if (m) m.remove(); } function close() { var m = document.getElementById('wp-pw-modal'); if (m) m.remove(); }
function msg(text, ok) { function msg(text, ok) {
var el = document.getElementById('wp-pw-msg'); var el = document.getElementById('wp-pw-msg');
el.style.display = 'block'; el.textContent = text; el.style.display = 'block'; el.textContent = text;
el.style.background = ok ? '#defbe6' : '#fff1f1'; el.style.color = ok ? '#0e6027' : '#da1e28'; el.style.background = ok ? 'var(--wp-status-success-bg)' : 'var(--wp-status-error-bg)'; el.style.color = ok ? 'var(--wp-status-success-text)' : 'var(--cds-support-error)';
} }
ov.addEventListener('click', function (e) { if (e.target === ov) close(); }); ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
document.body.appendChild(ov); document.body.appendChild(ov);

View File

@@ -213,3 +213,9 @@ select.role-select:disabled{ color:var(--cds-text-disabled); border-color:var(--
@media (max-width:620px){ @media (max-width:620px){
.urow input, .urow select, .urow button{ flex:1 1 100%; } .urow input, .urow select, .urow button{ flex:1 1 100%; }
} }
/* C2 / T9.6: the console header links are standalone targets, not inline text,
so they meet the touch floor at coarse pointers / phone widths. */
@media (max-width: 500px), (pointer: coarse) {
a.home { min-height: 44px; display: inline-flex; align-items: center; }
}

View File

@@ -35,7 +35,7 @@
.pill { display: inline-block; font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 14px; } .pill { display: inline-block; font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 14px; }
.pill.st { background: var(--cds-layer-accent); color: var(--cds-text-secondary); } .pill.st { background: var(--cds-layer-accent); color: var(--cds-text-secondary); }
.pill.ok { background: var(--wp-status-success-bg); color: var(--wp-hover-success); } .pill.ok { background: var(--wp-status-success-bg); color: var(--wp-hover-success); }
.pill.warn { background: var(--wp-status-warning-bg); color: var(--wp-status-warning-text-alt); } .pill.warn { background: var(--wp-status-warning-bg); color: var(--wp-status-warning-text); }
.pill.bad { background: var(--wp-status-error-bg); color: var(--cds-support-error); } .pill.bad { background: var(--wp-status-error-bg); color: var(--cds-support-error); }
.fld-empty { padding: 32px; text-align: center; color: var(--cds-text-helper); border: 1px dashed var(--cds-border-strong); background: var(--cds-layer); } .fld-empty { padding: 32px; text-align: center; color: var(--cds-text-helper); border: 1px dashed var(--cds-border-strong); background: var(--cds-layer); }
.fld-empty a { color: var(--cds-link-primary); } .fld-empty a { color: var(--cds-link-primary); }
@@ -66,9 +66,9 @@
.fld-toast.show { opacity: 1; } .fld-toast.show { opacity: 1; }
/* CR-007: drawings open from the card, offline once prefetched. 44px rows. */ /* CR-007: drawings open from the card, offline once prefetched. 44px rows. */
.fld-drawing { display:block; padding:12px 10px; min-height:44px; box-sizing:border-box; .fld-drawing { display:block; padding:12px 10px; min-height:44px; box-sizing:border-box;
border:1px solid var(--cds-border-subtle-01); border-radius:6px; margin-bottom:8px; border:1px solid var(--cds-border-subtle); border-radius:6px; margin-bottom:8px;
color: var(--cds-link-primary); text-decoration:none; font-size:14px; } color: var(--cds-link-primary); text-decoration:none; font-size:14px; }
.fld-drawing:active { background: var(--cds-layer-hover-01); } .fld-drawing:active { background: var(--cds-layer-hover); }
</style> </style>
</head> </head>
<body> <body>

View File

@@ -149,11 +149,11 @@ function renderDetail() {
(((p.files) || []).length ? '<div class="fld-sec"><h3>Drawings</h3>' + (((p.files) || []).length ? '<div class="fld-sec"><h3>Drawings</h3>' +
p.files.map(function (f) { p.files.map(function (f) {
return '<a class="fld-drawing" href="/api/files/' + esc(f.id) + '" target="_blank" rel="noopener">' + return '<a class="fld-drawing" href="/api/files/' + esc(f.id) + '" target="_blank" rel="noopener">' +
'📄 ' + esc(f.name || 'drawing') + (f.description ? ' — ' + esc(f.description) : '') + '</a>'; '' + esc(f.name || 'drawing') + (f.description ? ' — ' + esc(f.description) : '') + '</a>';
}).join('') + '</div>' : '') + }).join('') + '</div>' : '') +
'<div class="fld-sec"><h3>Add field update</h3>' + '<div class="fld-sec"><h3>Add field update</h3>' +
'<textarea class="fld-note" id="fld-note" placeholder="What happened on site? (progress, blockers, notes)" oninput="draftNote=this.value">' + esc(draftNote) + '</textarea>' + '<textarea class="fld-note" id="fld-note" placeholder="What happened on site? (progress, blockers, notes)" oninput="draftNote=this.value">' + esc(draftNote) + '</textarea>' +
'<div class="fld-photo-row"><label class="fld-btn">📷 Add photo<input type="file" accept="image/*" capture="environment" style="display:none" onchange="onPhoto(event)"></label>' + '<div class="fld-photo-row"><label class="fld-btn">Add photo<input type="file" accept="image/*" capture="environment" style="display:none" onchange="onPhoto(event)"></label>' +
'<span id="photo-status" style="font-size:13px;color:var(--cds-text-secondary)">' + (pendingPhoto ? 'Photo attached ✓' : '') + '</span></div>' + '<span id="photo-status" style="font-size:13px;color:var(--cds-text-secondary)">' + (pendingPhoto ? 'Photo attached ✓' : '') + '</span></div>' +
'<div style="margin-top:12px"><button class="fld-btn primary" onclick="addUpdate()">Add to log</button></div>' + '<div style="margin-top:12px"><button class="fld-btn primary" onclick="addUpdate()">Add to log</button></div>' +
'</div>' + '</div>' +

View File

@@ -12,67 +12,174 @@
(function (global) { (function (global) {
'use strict'; 'use strict';
// ── the help-tip component (S8 / T9.5) ─────────────────────────────────────
// Markup writes <span class="help-tip" data-tip="…">i</span>; this upgrades
// every one to a real <button> at load (and via global.helpTipUpgrade(root)
// for anything rendered later). One bubble serves all badges: focus and hover
// show it, click/tap toggles it (the touch path tablets need), Escape and
// leaving close it. The bubble is clamped to the viewport on both axes.
var _tipOpenFor = null;
function tipBubble() {
var b = document.getElementById('wp-tip-bubble');
if (!b) {
b = document.createElement('div');
b.id = 'wp-tip-bubble';
b.setAttribute('role', 'tooltip');
b.hidden = true;
document.body.appendChild(b);
}
return b;
}
function tipShow(btn) {
var b = tipBubble();
b.textContent = btn.getAttribute('data-tip') || '';
b.hidden = false;
var r = btn.getBoundingClientRect();
b.style.left = '0px'; b.style.top = '0px'; // measure at origin
var bw = b.offsetWidth, bh = b.offsetHeight;
var left = Math.min(Math.max(12, r.left + r.width / 2 - bw / 2),
window.innerWidth - bw - 12);
var top = r.top - bh - 8;
if (top < 8) top = r.bottom + 8;
b.style.left = left + 'px';
b.style.top = top + 'px';
btn.setAttribute('aria-describedby', 'wp-tip-bubble');
}
function tipHide(btn) {
var b = document.getElementById('wp-tip-bubble');
if (b) b.hidden = true;
if (btn) { btn.removeAttribute('aria-describedby'); btn.setAttribute('aria-expanded', 'false'); }
if (_tipOpenFor === btn) _tipOpenFor = null;
}
function upgradeTip(el) {
if (el.tagName === 'BUTTON') return el;
var btn = document.createElement('button');
btn.type = 'button';
btn.className = el.className;
btn.setAttribute('data-tip', el.getAttribute('data-tip') || '');
btn.setAttribute('aria-label', 'More information');
btn.setAttribute('aria-expanded', 'false');
btn.textContent = el.textContent || 'i';
el.parentNode.replaceChild(btn, el);
return btn;
}
function helpTipUpgrade(root) {
(root || document).querySelectorAll('span.help-tip').forEach(upgradeTip);
}
global.helpTipUpgrade = helpTipUpgrade;
document.addEventListener('DOMContentLoaded', function () {
helpTipUpgrade(document);
// Delegated, so badges rendered later work without re-wiring.
document.addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('.help-tip') : null;
if (btn && btn.tagName !== 'BUTTON') btn = upgradeTip(btn);
if (btn) {
e.preventDefault();
if (_tipOpenFor === btn) { tipHide(btn); return; }
if (_tipOpenFor) tipHide(_tipOpenFor);
_tipOpenFor = btn;
btn.setAttribute('aria-expanded', 'true');
tipShow(btn);
return;
}
if (_tipOpenFor) tipHide(_tipOpenFor); // tap elsewhere closes
});
document.addEventListener('focusin', function (e) {
var btn = e.target.classList && e.target.classList.contains('help-tip') ? e.target : null;
if (btn) tipShow(btn);
else if (_tipOpenFor) tipHide(_tipOpenFor);
});
document.addEventListener('focusout', function (e) {
var btn = e.target.classList && e.target.classList.contains('help-tip') ? e.target : null;
if (btn && _tipOpenFor !== btn) tipHide(btn);
});
document.addEventListener('mouseover', function (e) {
var btn = e.target.closest ? e.target.closest('.help-tip') : null;
if (btn) { if (btn.tagName !== 'BUTTON') btn = upgradeTip(btn); tipShow(btn); }
else if (!_tipOpenFor) tipHide(null);
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && _tipOpenFor) tipHide(_tipOpenFor);
});
});
// ── styles ──────────────────────────────────────────────────────────────── // ── styles ────────────────────────────────────────────────────────────────
var css = ` var css = `
.help-tip{ display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px; /* S8 / T9.5: the badge is a BUTTON - reachable by keyboard and by touch, which
margin-left:5px; border-radius:50%; background:#525252; color:#fff; font-size:10px; font-weight:700; the old span never was (its :focus rule was dead code: no tabindex). The
tooltip itself is #wp-tip-bubble below, a positioned element CLAMPED to the
viewport - the old ::after escaped its badge to the right and was the last
cause of the creator's 390px overflow (BL-001). Colours come from the
theme's tokens; this block owned four of the raw hexes S5 counted. */
.help-tip{ display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px;
margin-left:5px; padding:0; border:0; border-radius:50%;
background:var(--cds-icon-secondary); color:var(--cds-text-inverse); font-size:10px; font-weight:700;
font-family:ui-sans-serif,system-ui,sans-serif; cursor:help; vertical-align:middle; position:relative; } font-family:ui-sans-serif,system-ui,sans-serif; cursor:help; vertical-align:middle; position:relative; }
.help-tip::after{ content:attr(data-tip); position:absolute; bottom:130%; left:50%; transform:translateX(-50%); .help-tip:focus-visible{ outline:2px solid var(--cds-focus); outline-offset:1px; }
background:#161616; color:#fff; padding:7px 10px; border-radius:0; font-size:12px; font-weight:400; .help-tip[aria-expanded="true"]{ background:var(--cds-focus); }
line-height:1.4; white-space:normal; width:max-content; max-width:260px; text-align:left; z-index:9999; #wp-tip-bubble{ position:fixed; z-index:10001; max-width:min(280px, calc(100vw - 24px));
opacity:0; pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); } background:var(--cds-background-inverse); color:var(--cds-text-inverse);
.help-tip::before{ content:''; position:absolute; bottom:130%; left:50%; transform:translate(-50%,95%); padding:7px 10px; font-size:12px; font-weight:400; line-height:1.4; text-align:left;
border:5px solid transparent; border-top-color:#161616; opacity:0; transition:opacity .12s; z-index:9999; } box-shadow:0 4px 14px rgba(20,30,50,.22); }
.help-tip:hover::after, .help-tip:hover::before, .help-tip:focus::after, .help-tip:focus::before{ opacity:1; }
.ui-help-overlay{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:none; align-items:center; .ui-help-overlay{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:none; align-items:center;
justify-content:center; z-index:10000; padding:4vh 16px; } justify-content:center; z-index:10000; padding:4vh 16px; }
.ui-help-overlay.open{ display:flex; } .ui-help-overlay.open{ display:flex; }
.ui-help-modal{ background:#fff; color:#161616; max-width:980px; width:100%; height:88vh; max-height:880px; .ui-help-modal{ background:var(--cds-layer); color:var(--cds-text-primary); max-width:980px; width:100%; height:88vh; max-height:880px;
border-radius:0; box-shadow:0 12px 40px rgba(20,30,50,.3); display:flex; flex-direction:column; overflow:hidden; border-radius:0; box-shadow:0 12px 40px rgba(20,30,50,.3); display:flex; flex-direction:column; overflow:hidden;
font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; } font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; }
.ui-help-head{ display:flex; align-items:center; gap:14px; padding:13px 18px; border-bottom:1px solid #e0e0e0; flex:none; } .ui-help-head{ display:flex; align-items:center; gap:14px; padding:13px 18px; border-bottom:1px solid var(--cds-border-subtle); flex:none; }
.ui-help-head .ui-help-title{ font-size:15px; font-weight:700; white-space:nowrap; } .ui-help-head .ui-help-title{ font-size:15px; font-weight:700; white-space:nowrap; }
.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 #8d8d8d; 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:#f7f8fa; } font-size:13px; outline:none; background:var(--cds-layer-accent); }
.ui-help-search input:focus{ border-color:#0f62fe; background:#fff; 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:#525252; 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 #e0e0e0; overflow:auto; padding:10px 8px; background:#fafbfc; } .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 a{ display:block; padding:7px 10px; border-radius:0; color:#27313f; text-decoration:none; font-size:13px; .ui-help-nav a{ display:block; padding:7px 10px; border-radius:0; color:var(--cds-text-primary); text-decoration:none; font-size:13px;
cursor:pointer; margin-bottom:1px; } cursor:pointer; margin-bottom:1px; }
.ui-help-nav a:hover{ background:#eef1f6; } .ui-help-nav a:hover{ background:var(--cds-layer-hover); }
.ui-help-nav a.active{ background:#edf5ff; color:#0353e9; font-weight:600; } .ui-help-nav a.active{ background:var(--cds-highlight); color:var(--cds-link-primary-hover); font-weight:600; }
.ui-help-nav a.nohit{ display:none; } .ui-help-nav a.nohit{ display:none; }
.ui-help-content{ flex:1; overflow:auto; padding:22px 28px; scroll-behavior:smooth; } .ui-help-content{ flex:1; overflow:auto; padding:22px 28px; scroll-behavior:smooth; }
.ui-help-sec{ margin-bottom:30px; } .ui-help-sec{ margin-bottom:30px; }
.ui-help-sec.hide{ display:none; } .ui-help-sec.hide{ display:none; }
.ui-help-sec h3{ font-size:18px; margin:0 0 10px; color:#161616; scroll-margin-top:10px; } .ui-help-sec h3{ font-size:18px; margin:0 0 10px; color:var(--cds-text-primary); scroll-margin-top:10px; }
.ui-help-sec h4{ margin:18px 0 6px; font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:#0f62fe; } .ui-help-sec h4{ margin:18px 0 6px; font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:var(--cds-link-primary); }
.ui-help-content p{ font-size:13.5px; line-height:1.62; margin:0 0 9px; color:#27313f; } .ui-help-content p{ font-size:13.5px; line-height:1.62; margin:0 0 9px; color:var(--cds-text-primary); }
.ui-help-content ol, .ui-help-content ul{ margin:0 0 10px; padding-left:20px; font-size:13.5px; line-height:1.6; } .ui-help-content ol, .ui-help-content ul{ margin:0 0 10px; padding-left:20px; font-size:13.5px; line-height:1.6; }
.ui-help-content li{ margin-bottom:5px; } .ui-help-content li{ margin-bottom:5px; }
.ui-help-content code{ background:#eef1f6; padding:1px 5px; border-radius:4px; font-size:12px; } .ui-help-content code{ background:var(--cds-layer-accent); padding:1px 5px; border-radius:4px; font-size:12px; }
.ui-help-content table{ border-collapse:collapse; width:100%; font-size:12.5px; margin:6px 0 12px; } .ui-help-content table{ border-collapse:collapse; width:100%; font-size:12.5px; margin:6px 0 12px; }
.ui-help-content th, .ui-help-content td{ border:1px solid #e0e0e0; padding:6px 9px; text-align:left; vertical-align:top; } .ui-help-content th, .ui-help-content td{ border:1px solid var(--cds-border-subtle); padding:6px 9px; text-align:left; vertical-align:top; }
.ui-help-content th{ background:#f4f6f9; font-weight:600; } .ui-help-content th{ background:var(--cds-layer-accent); font-weight:600; }
.ui-help-pill{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600; } .ui-help-pill{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600; }
.pill-draft{ background:#eef1f6; color:#525252; } .pill-sched{ background:#edf5ff; color:#0353e9; } /* Scoped to .ui-help-pill: this block is injected on EVERY page, and the
.pill-prog{ background:#fef3e0; color:#b45309; } .pill-issued{ background:#e4f6ec; color:#15924f; } creator's Issue (hold) status radio also carries the class pill-hold - the
.pill-qc{ background:#f3e8ff; color:#7c3aed; } .pill-closed{ background:#e2e8f0; color:#334155; } bare selector painted that radio error-red at all times, selected or not
.pill-hold{ background:#fde8e8; color:#c0392b; } (found by Nick 2026-08-20; the collision dates to the login-portal era). */
.ui-help-callout{ background:#f4f8ff; border-left:3px solid #0f62fe; padding:10px 14px; border-radius:0; .ui-help-pill.pill-draft{ background:var(--cds-layer-accent); color:var(--cds-text-secondary); } .ui-help-pill.pill-sched{ background:var(--cds-highlight); color:var(--cds-link-primary-hover); }
.ui-help-pill.pill-prog{ background:var(--wp-status-warning-bg); color:var(--wp-status-warning-text); } .ui-help-pill.pill-issued{ background:var(--wp-status-success-bg); color:var(--wp-status-success-text); }
.ui-help-pill.pill-qc{ background:var(--cds-highlight); color:var(--cds-link-primary); } .ui-help-pill.pill-closed{ background:var(--cds-layer-accent); color:var(--cds-text-secondary); }
.ui-help-pill.pill-hold{ background:var(--wp-status-error-bg); color:var(--wp-status-error-text); }
.ui-help-callout{ background:var(--cds-highlight); border-left:3px solid var(--cds-link-primary); padding:10px 14px; border-radius:0;
font-size:13px; line-height:1.55; margin:10px 0; } font-size:13px; line-height:1.55; margin:10px 0; }
.ui-help-noresult{ display:none; color:#525252; font-size:14px; padding:10px 2px; } .ui-help-noresult{ display:none; color:var(--cds-text-secondary); font-size:14px; padding:10px 2px; }
.ui-help-content mark{ background:#fff1a8; color:inherit; border-radius:2px; padding:0 1px; } .ui-help-content mark{ background:var(--wp-status-warning-border-a); color:inherit; border-radius:2px; padding:0 1px; }
.ui-help-fab{ position:fixed; bottom:12px; left:12px; z-index:9998; width:38px; height:38px; border-radius:50%; .ui-help-fab{ position:fixed; bottom:12px; left:12px; z-index:9998; width:38px; height:38px; border-radius:50%;
border:none; background:#0f62fe; color:#fff; font-size:18px; font-weight:700; cursor:pointer; border:none; background:var(--cds-interactive-01); color:var(--cds-text-on-color); font-size:18px; font-weight:700; cursor:pointer;
box-shadow:0 2px 10px rgba(20,30,50,.28); } box-shadow:0 2px 10px rgba(20,30,50,.28); }
.ui-help-fab:hover{ background:#0353e9; } .ui-help-fab:hover{ background:var(--cds-hover-primary); }
@media (max-width:760px){ @media (max-width:760px){
.ui-help-modal{ height:92vh; } .ui-help-wrap{ flex-direction:column; } .ui-help-modal{ height:92vh; } .ui-help-wrap{ flex-direction:column; }
.ui-help-nav{ width:auto; display:flex; flex-wrap:wrap; gap:4px; border-right:none; border-bottom:1px solid #e0e0e0; } .ui-help-nav{ width:auto; display:flex; flex-wrap:wrap; gap:4px; border-right:none; border-bottom:1px solid var(--cds-border-subtle); }
.ui-help-nav a{ margin:0; font-size:12px; padding:5px 9px; } .ui-help-nav a{ margin:0; font-size:12px; padding:5px 9px; }
.ui-help-head{ flex-wrap:wrap; } .ui-help-head{ flex-wrap:wrap; }
}`; }`;
@@ -93,7 +200,7 @@
<li><strong>Dashboard</strong> — track status, hours, due dates, and what's gating each package across the project.</li> <li><strong>Dashboard</strong> — track status, hours, due dates, and what's gating each package across the project.</li>
</ol> </ol>
<h4>Moving around</h4> <h4>Moving around</h4>
<p>From the home page, open <strong>SOP Configuration</strong>, the <strong>Work Package Creator</strong>, or the <strong>Dashboard</strong>. Inside the suite, switch any time using the top tabs: <strong>⚙ SOP Configuration</strong>, <strong>📋 Work Package Creation</strong>, and <strong>📊 Dashboard</strong>. The active project and SOP follow you across all of them.</p> <p>From the home page, open <strong>SOP Configuration</strong>, the <strong>Work Package Creator</strong>, or the <strong>Dashboard</strong>. Inside the suite, switch any time using the top tabs: <strong>⚙SOP Configuration</strong>, <strong>Work Package Creation</strong>, and <strong>Dashboard</strong>. The active project and SOP follow you across all of them.</p>
<h4>Quick start</h4> <h4>Quick start</h4>
<ol> <ol>
<li><strong>Open “SOP Configuration”</strong> and complete the 10 steps for your project (~15 minutes).</li> <li><strong>Open “SOP Configuration”</strong> and complete the 10 steps for your project (~15 minutes).</li>
@@ -101,7 +208,7 @@
<li><strong>Open “Work Package Creation”</strong> to author packages with your SOP defaults pre-populated.</li> <li><strong>Open “Work Package Creation”</strong> to author packages with your SOP defaults pre-populated.</li>
<li><strong>Update from the field</strong> using the <strong>Field View</strong>, and <strong>leave feedback</strong> on any page with the Feedback button.</li> <li><strong>Update from the field</strong> using the <strong>Field View</strong>, and <strong>leave feedback</strong> on any page with the Feedback button.</li>
</ol> </ol>
<div class="ui-help-callout">New here? On the home page choose the <strong>Sample Project</strong>, then click <strong>Load sample</strong> in the suite to see a fully filled-out SOP and an example Work Package.</div>` }, <div class="ui-help-callout">New here? On the home page choose the <strong>Sample Project</strong>, then click <strong>Load sample data</strong> in the suite to see a fully filled-out SOP and an example Work Package.</div>` },
{ id: 'projects', title: 'Projects', body: ` { id: 'projects', title: 'Projects', body: `
<h3>Projects</h3> <h3>Projects</h3>
@@ -133,7 +240,7 @@
<li><strong>Release Gate Constraints</strong> — choose which standard AWP constraints apply and add custom ones (see <a data-help-jump="constraints">Constraints</a>).</li> <li><strong>Release Gate Constraints</strong> — choose which standard AWP constraints apply and add custom ones (see <a data-help-jump="constraints">Constraints</a>).</li>
<li><strong>Engineering Sources &amp; References</strong> — labelled links (Design Drawings, Specs, …) that appear as quick-access buttons in the WP Creator's <em>Drawings &amp; Attachments</em>.</li> <li><strong>Engineering Sources &amp; References</strong> — labelled links (Design Drawings, Specs, …) that appear as quick-access buttons in the WP Creator's <em>Drawings &amp; Attachments</em>.</li>
</ol> </ol>
<div class="ui-help-callout">Fields a WP inherits from the SOP show a <strong>"from SOP"</strong> tag and are locked. You can override a locked field with <strong>🔒 Edit</strong>, which requires a logged reason.</div>` }, <div class="ui-help-callout">Fields a WP inherits from the SOP show a <strong>"from SOP"</strong> tag and are locked. You can override a locked field with <strong> Edit</strong>, which requires a logged reason.</div>` },
{ id: 'wps', title: 'Work Packages', body: ` { id: 'wps', title: 'Work Packages', body: `
<h3>Creating Work Packages</h3> <h3>Creating Work Packages</h3>
@@ -141,7 +248,7 @@
<h4>Key fields</h4> <h4>Key fields</h4>
<ul> <ul>
<li><strong>Subject / Title</strong> (required) and <strong>WP Type</strong> (required, from the SOP).</li> <li><strong>Subject / Title</strong> (required) and <strong>WP Type</strong> (required, from the SOP).</li>
<li><strong>Assets</strong> — link each controls.dev asset the package covers.</li> <li><strong>Assets</strong> — search the Micron DB by asset ID and add each asset the package covers. Anything not in the Micron DB can still be typed in by hand.</li>
<li><strong>Disciplines</strong> — which trades the package covers (see <a data-help-jump="disciplines">Disciplines &amp; Split</a>).</li> <li><strong>Disciplines</strong> — which trades the package covers (see <a data-help-jump="disciplines">Disciplines &amp; Split</a>).</li>
<li><strong>Scope &amp; Work</strong> — the sequenced steps the crew performs (per-discipline in multi-discipline mode).</li> <li><strong>Scope &amp; Work</strong> — the sequenced steps the crew performs (per-discipline in multi-discipline mode).</li>
<li><strong>Labor Est. Hrs.</strong> — drives the sizing check (see <a data-help-jump="sizing">Sizing</a>).</li> <li><strong>Labor Est. Hrs.</strong> — drives the sizing check (see <a data-help-jump="sizing">Sizing</a>).</li>
@@ -152,7 +259,7 @@
<li><strong>Quality / Hold Points</strong>, <strong>Approvals &amp; Sign-offs</strong>, and <strong>Closeout</strong> (actual hours, as-builts, lessons learned — shown at QC/Closed).</li> <li><strong>Quality / Hold Points</strong>, <strong>Approvals &amp; Sign-offs</strong>, and <strong>Closeout</strong> (actual hours, as-builts, lessons learned — shown at QC/Closed).</li>
</ul> </ul>
<h4>Saving</h4> <h4>Saving</h4>
<p><strong>Save Draft</strong> stores the package; <strong>Save &amp; View</strong> saves and renders the print-ready output. Drafts auto-save to your browser as you type, so nothing is lost if you close the tab.</p>` }, <p><strong>Save Draft</strong> stores the package; <strong>Save &amp; View</strong> saves and renders the print-ready output. Drafts auto-save to your browser as you type, so nothing is lost if you close the tab.</p>` },
{ id: 'statuses', title: 'Statuses', body: ` { id: 'statuses', title: 'Statuses', body: `
<h3>Work Package statuses</h3> <h3>Work Package statuses</h3>
@@ -223,7 +330,7 @@
{ id: 'dashboard', title: 'Dashboard', body: ` { id: 'dashboard', title: 'Dashboard', body: `
<h3>Dashboard &amp; metrics</h3> <h3>Dashboard &amp; metrics</h3>
<p>The dashboard aggregates every (non-master) package in the active project. Open it from the home page, the suite's <strong>📊 Dashboard</strong> tab, or the Creator header.</p> <p>The dashboard aggregates every (non-master) package in the active project. Open it from the home page, the suite's <strong>Dashboard</strong> tab, or the Creator header.</p>
<h4>Metric cards (click to filter)</h4> <h4>Metric cards (click to filter)</h4>
<ul> <ul>
<li><strong>Total WPs</strong>, <strong>Release-ready</strong>, <strong>On hold</strong>, <strong>Overdue</strong></li> <li><strong>Total WPs</strong>, <strong>Release-ready</strong>, <strong>On hold</strong>, <strong>Overdue</strong></li>
@@ -232,7 +339,7 @@
<h4>Breakdowns &amp; gates</h4> <h4>Breakdowns &amp; gates</h4>
<ul> <ul>
<li><strong>By status</strong> and <strong>by discipline</strong> chips.</li> <li><strong>By status</strong> and <strong>by discipline</strong> chips.</li>
<li><strong> Gating constraints</strong> — lists every blocked package and exactly which constraints are holding it.</li> <li><strong> Gating constraints</strong> — lists every blocked package and exactly which constraints are holding it.</li>
</ul> </ul>
<h4>The table</h4> <h4>The table</h4>
<p>Shows WP #, subject, type, discipline, status, <strong>Gates</strong> (<em>clear</em>, <em>n open</em>, or <em>master</em>), due date (red if overdue), and hours. Row actions: <strong>issue</strong> (when release-ready), <strong>view</strong>, and <strong>edit</strong>. Filter with the search box and the status / discipline dropdowns.</p> <p>Shows WP #, subject, type, discipline, status, <strong>Gates</strong> (<em>clear</em>, <em>n open</em>, or <em>master</em>), due date (red if overdue), and hours. Row actions: <strong>issue</strong> (when release-ready), <strong>view</strong>, and <strong>edit</strong>. Filter with the search box and the status / discipline dropdowns.</p>
@@ -241,7 +348,7 @@
{ id: 'data', title: 'Samples, sharing & comments', body: ` { id: 'data', title: 'Samples, sharing & comments', body: `
<h3>Samples, import / export &amp; comments</h3> <h3>Samples, import / export &amp; comments</h3>
<h4>Load sample</h4> <h4>Load sample</h4>
<p><strong>Load sample</strong> is context-aware: on the SOP tab it loads a complete sample SOP; on the WP tab it loads an example Work Package. Great for learning the tool or demoing.</p> <p><strong>Load sample data</strong> is context-aware: on the SOP tab it loads a complete sample SOP; on the WP tab it loads an example Work Package. Great for learning the tool or demoing.</p>
<h4>Import / Export</h4> <h4>Import / Export</h4>
<ul> <ul>
<li><strong>Work Packages</strong> — <em>⤓ Export (JSON)</em> downloads all saved packages; import restores them.</li> <li><strong>Work Packages</strong> — <em>⤓ Export (JSON)</em> downloads all saved packages; import restores them.</li>
@@ -249,9 +356,9 @@
<li><strong>Materials</strong> — import a bill of materials from Excel/CSV, or download a template.</li> <li><strong>Materials</strong> — import a bill of materials from Excel/CSV, or download a template.</li>
</ul> </ul>
<h4>Comments &amp; feedback</h4> <h4>Comments &amp; feedback</h4>
<p>Leave feedback from the home page, per-step comments in the SOP tool (<strong>💬 Step Comments</strong>), or package comments in the Creator's <strong>💬 Comments</strong> drawer. Comments are saved and can be exported/imported as <code>.json</code> so reviewers can share them — and, when the API is reachable, they're collected centrally too.</p> <p>Leave feedback from the home page, per-step comments in the SOP tool (<strong>Step Comments</strong>), or package comments in the Creator's <strong>Comments</strong> drawer. Comments are saved and can be exported/imported as <code>.json</code> so reviewers can share them — and, when the API is reachable, they're collected centrally too.</p>
<h4>Usage logs</h4> <h4>Usage logs</h4>
<p><strong>📊 Usage Logs</strong> / <strong>▤ Usage Data</strong> shows session and event counts and can export the full log. A <strong>dev-mode</strong> toggle pauses tracking during demos.</p>` }, <p><strong>Usage Logs</strong> / <strong>▤ Usage Data</strong> shows session and event counts and can export the full log. A <strong>dev-mode</strong> toggle pauses tracking during demos.</p>` },
{ id: 'shortcuts', title: 'Tips & shortcuts', body: ` { id: 'shortcuts', title: 'Tips & shortcuts', body: `
<h3>Tips &amp; keyboard shortcuts</h3> <h3>Tips &amp; keyboard shortcuts</h3>
@@ -281,7 +388,7 @@
<tr><td><strong>Sequence</strong></td><td>SOP-defined construction phases; a WP can name a predecessor step.</td></tr> <tr><td><strong>Sequence</strong></td><td>SOP-defined construction phases; a WP can name a predecessor step.</td></tr>
<tr><td><strong>Bagged &amp; tagged</strong></td><td>Materials on site, kitted, and labelled — part of the Materials constraint.</td></tr> <tr><td><strong>Bagged &amp; tagged</strong></td><td>Materials on site, kitted, and labelled — part of the Materials constraint.</td></tr>
<tr><td><strong>MIMO</strong></td><td>Material In / Material Out — kitting and staging logistics.</td></tr> <tr><td><strong>MIMO</strong></td><td>Material In / Material Out — kitting and staging logistics.</td></tr>
<tr><td><strong>Asset</strong></td><td>A controls.dev record (equipment/system) a package is built around.</td></tr> <tr><td><strong>Asset</strong></td><td>An asset ID from the Micron DB that a package is built around. The Micron DB is read-only here — picking an asset never changes it.</td></tr>
<tr><td><strong>Hold / Witness point</strong></td><td>Hold = work stops until inspection sign-off; Witness = inspection offered but work may proceed.</td></tr> <tr><td><strong>Hold / Witness point</strong></td><td>Hold = work stops until inspection sign-off; Witness = inspection offered but work may proceed.</td></tr>
<tr><td><strong>Active project</strong></td><td>The currently selected project; all data is scoped to it.</td></tr> <tr><td><strong>Active project</strong></td><td>The currently selected project; all data is scoped to it.</td></tr>
</table>` }, </table>` },

View File

@@ -427,6 +427,19 @@
══════════════════════════════════════════════════════════════════════ --> ══════════════════════════════════════════════════════════════════════ -->
<div id="proj-status"><div class="proj-loading">Loading projects…</div></div> <div id="proj-status"><div class="proj-loading">Loading projects…</div></div>
<!-- D7 / T9.8: the way back into an archived project - PROJECT ADMINS ONLY
(the server filters; everyone else gets an empty list and this section
never renders). Visually its own thing, so nobody opens one thinking
it is live: the server refuses every write regardless. -->
<section class="section" id="archived-projects" hidden
style="border:1px dashed var(--cds-border-subtle); background:var(--cds-layer-accent); opacity:.92">
<h2>Archived projects</h2>
<p style="font-size:13px; color:var(--cds-text-secondary)">Read-only. Visible to project
admins only. Opening one lets you read everything; nothing on it can be changed while
it stays archived.</p>
<div id="archived-projects-list"></div>
</section>
<!-- (a) no projects at all --> <!-- (a) no projects at all -->
<section class="section first-run" id="first-run" hidden> <section class="section first-run" id="first-run" hidden>
<h2>No projects yet</h2> <h2>No projects yet</h2>
@@ -580,12 +593,37 @@
<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;
let _projects = []; let _projects = [];
// D7: render the archived list for whoever the server says may see one.
function renderArchivedProjects(){
ProjectData.listArchivedProjects().then(rows => {
const sec = $('archived-projects');
const list = $('archived-projects-list');
if(!sec || !list) return;
if(!rows.length){ sec.hidden = true; return; }
sec.hidden = false;
list.innerHTML = rows.map(p =>
`<button type="button" class="card-button" style="display:block; width:100%; text-align:left; margin-bottom:8px"
data-open-archived="${esc(p.id)}">
${esc(p.name || p.id)} ${p.number ? '· ' + esc(p.number) : ''}
<span style="font-size:11px; color:var(--cds-text-secondary)"> — archived, read-only</span>
</button>`).join('');
list.querySelectorAll('[data-open-archived]').forEach(b => {
b.addEventListener('click', () => {
const p = rows.find(x => x.id === b.dataset.openArchived);
if(p){ ProjectData.setActive(p); location.reload(); }
});
});
});
}
function initProjects(){ function initProjects(){
renderArchivedProjects();
ProjectData.list().then(list => { ProjectData.list().then(list => {
_projects = list || []; _projects = list || [];
// A deep link names the project explicitly, and every other page in the // A deep link names the project explicitly, and every other page in the
@@ -606,7 +644,12 @@
if(active && typeof WPUrl !== 'undefined' && WPUrl.get('project') !== active.id){ if(active && typeof WPUrl !== 'undefined' && WPUrl.get('project') !== active.id){
WPUrl.replace({ project: active.id }); WPUrl.replace({ project: active.id });
} }
const dropped = (active && !_projects.some(p => p.id === active.id)) ? active : null; // D7: a project OPENED FROM THE ARCHIVED LIST is active on purpose - its
// stored summary says archived:true, and only someone the server let see
// that list could have stored it. A project archived out from under
// someone still drops and gets explained, exactly as before.
const dropped = (active && !_projects.some(p => p.id === active.id)
&& !active.archived) ? active : null;
if(dropped) ProjectData.setActive(null); if(dropped) ProjectData.setActive(null);
_listLoaded = true; _listLoaded = true;
renderProjectEntry(); renderProjectEntry();
@@ -1004,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;
} }
@@ -1025,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');
@@ -1043,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));
@@ -1051,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

@@ -52,6 +52,15 @@
.catch(function () { return readLocal(); }); .catch(function () { return readLocal(); });
}, },
// D7 / T9.8: the way back in, for project admins. The server filters the
// answer by per-project role; everyone else simply receives [].
listArchivedProjects: function () {
return fetch(API + '/projects?archived=only', { headers: { 'Accept': 'application/json' } })
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
.then(function (rows) { return (rows || []).filter(function (p) { return p.archived; }); })
.catch(function () { return []; });
},
get: function (id) { get: function (id) {
return fetch(API + '/projects/' + encodeURIComponent(id)) return fetch(API + '/projects/' + encodeURIComponent(id))
.then(function (r) { if (!r.ok) throw 0; return r.json(); }) .then(function (r) { if (!r.ok) throw 0; return r.json(); })
@@ -207,7 +216,11 @@
var d = sopRow.data; // { sop, state } as written by pushSOP var d = sopRow.data; // { sop, state } as written by pushSOP
if (d.sop) localStorage.setItem(nsKey('wp_suite_sop', projectId), JSON.stringify(d.sop)); if (d.sop) localStorage.setItem(nsKey('wp_suite_sop', projectId), JSON.stringify(d.sop));
if (d.state) localStorage.setItem(nsKey('wp_suite_state', projectId), JSON.stringify(d.state)); if (d.state) localStorage.setItem(nsKey('wp_suite_state', projectId), JSON.stringify(d.state));
localStorage.setItem(nsKey('wp_suite_sop_complete', projectId), '1'); // BL-018: only a row in the shape pushSOP writes counts as complete.
// Marking '1' for ANY row meant a malformed record opened the gate.
if (d.sop && d.state) {
localStorage.setItem(nsKey('wp_suite_sop_complete', projectId), '1');
}
} }
}).catch(function () {}) }).catch(function () {})
); );
@@ -345,6 +358,12 @@
// and one badge. The 'storage' listener below stays: it is what keeps two // and one badge. The 'storage' listener below stays: it is what keeps two
// TABS in step, which is a different thing and still happens. // TABS in step, which is a different thing and still happens.
var _badgeHideTimer = null; var _badgeHideTimer = null;
// BL-011 (fixed at T9.9): the badge used to mount on the FIRST SYNC EVENT,
// which is async, so the three fixed overlays on the SOP page landed in a
// different DOM order run to run and every index-keyed comparison saw
// phantom diffs. Mounting the (hidden) holder at DOMContentLoaded puts the
// three in script order, deterministically.
document.addEventListener('DOMContentLoaded', function () { renderSyncBadge(null); });
function renderSyncBadge(c) { function renderSyncBadge(c) {
if (!document.body) return; if (!document.body) return;
var el = document.getElementById('wp-sync-badge'); var el = document.getElementById('wp-sync-badge');
@@ -356,9 +375,10 @@
el.setAttribute('role', 'status'); el.setAttribute('role', 'status');
el.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:9998;pointer-events:none;display:none;align-items:center;gap:7px;' + el.style.cssText = 'position:fixed;right:12px;bottom:12px;z-index:9998;pointer-events:none;display:none;align-items:center;gap:7px;' +
'font:500 12px/1.3 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' + 'font:500 12px/1.3 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' +
'padding:6px 12px;border:1px solid #e0e0e0;background:#fff;color:#525252;box-shadow:0 1px 4px rgba(0,0,0,.12);transition:opacity .2s;'; 'padding:6px 12px;border:1px solid var(--cds-border-subtle);background:var(--cds-layer);color:var(--cds-text-secondary);box-shadow:0 1px 4px rgba(0,0,0,.12);transition:opacity .2s;';
document.body.appendChild(el); document.body.appendChild(el);
} }
if (!c) return; // the eager DOMContentLoaded mount: holder only, no state yet
if (_badgeHideTimer) { clearTimeout(_badgeHideTimer); _badgeHideTimer = null; } if (_badgeHideTimer) { clearTimeout(_badgeHideTimer); _badgeHideTimer = null; }
// A dead op is a refusal, not a hiccup — "retrying" would be a lie, and the // A dead op is a refusal, not a hiccup — "retrying" would be a lie, and the
// reason is the only thing that tells the user what to do (e.g. the project is // reason is the only thing that tells the user what to do (e.g. the project is
@@ -378,16 +398,16 @@
if (c.dead) { if (c.dead) {
el.innerHTML = '<span>✕ ' + c.dead + ' change' + (c.dead === 1 ? '' : 's') + ' rejected by the project — not saved</span>' + el.innerHTML = '<span>✕ ' + c.dead + ' change' + (c.dead === 1 ? '' : 's') + ' rejected by the project — not saved</span>' +
(c.reason ? '<span style="font-weight:400">' + esc(c.reason) + '</span>' : ''); (c.reason ? '<span style="font-weight:400">' + esc(c.reason) + '</span>' : '');
el.style.color = '#a2191f'; el.style.borderColor = '#ffd7d9'; el.style.background = '#fff1f1'; el.style.display = 'inline-flex'; el.style.color = 'var(--wp-status-error-text)'; el.style.borderColor = 'var(--wp-status-error-border-a)'; el.style.background = 'var(--wp-status-error-bg)'; el.style.display = 'inline-flex';
} else if (c.failed) { } else if (c.failed) {
el.textContent = '⚠ ' + c.failed + ' change' + (c.failed === 1 ? '' : 's') + ' not yet sent to the project — retrying'; el.textContent = '⚠ ' + c.failed + ' change' + (c.failed === 1 ? '' : 's') + ' not yet sent to the project — retrying';
el.style.color = '#8a6d00'; el.style.borderColor = '#f1c21b'; el.style.background = '#fdf6dd'; el.style.display = 'inline-flex'; el.style.color = 'var(--wp-status-warning-text)'; el.style.borderColor = 'var(--cds-support-warning)'; el.style.background = 'var(--wp-status-warning-bg)'; el.style.display = 'inline-flex';
} else if (c.pending) { } else if (c.pending) {
el.textContent = '↻ Sending ' + c.pending + ' change' + (c.pending === 1 ? '' : 's') + ' to the project…'; el.textContent = '↻ Sending ' + c.pending + ' change' + (c.pending === 1 ? '' : 's') + ' to the project…';
el.style.color = '#525252'; el.style.borderColor = '#e0e0e0'; el.style.background = '#fff'; el.style.display = 'inline-flex'; el.style.color = 'var(--cds-text-secondary)'; el.style.borderColor = 'var(--cds-border-subtle)'; el.style.background = 'var(--cds-layer)'; el.style.display = 'inline-flex';
} else { } else {
el.textContent = '✓ Everything sent to the project'; el.textContent = '✓ Everything sent to the project';
el.style.color = '#0e6027'; el.style.borderColor = '#a7f0ba'; el.style.background = '#defbe6'; el.style.display = 'inline-flex'; el.style.color = 'var(--wp-status-success-text)'; el.style.borderColor = 'var(--wp-status-success-border-a)'; el.style.background = 'var(--wp-status-success-bg)'; el.style.display = 'inline-flex';
_badgeHideTimer = setTimeout(function () { if (el) el.style.display = 'none'; }, 1800); _badgeHideTimer = setTimeout(function () { if (el) el.style.display = 'none'; }, 1800);
} }
} }

View File

@@ -143,11 +143,8 @@
--wp-status-error-bg: #fff1f1; --wp-status-error-bg: #fff1f1;
--wp-status-warning-bg: #fdf6dd; --wp-status-warning-bg: #fdf6dd;
--wp-status-warning-text: #8e6a00; --wp-status-warning-text: #8e6a00;
/* Four points from --wp-status-warning-text and doing the same job, on the /* BL-009, CLOSED at T9.9 (C4): the ninth amber (--wp-status-warning-text-alt,
field view's warn pill. Almost certainly a typo rather than a decision, but #8a6d00, four points from this one) is deleted; its consumers use this. */
merging it moves a rendered colour, so T3.2 names it and T3.5 merges it.
BL-009 / docs/reference/tokens.md section 8-K. */
--wp-status-warning-text-alt: #8a6d00;
/* Carbon green-70. The value is Carbon, the role is not — Carbon has no /* Carbon green-70. The value is Carbon, the role is not — Carbon has no
"hover for a green fill", because green is not one of its action colours. "hover for a green fill", because green is not one of its action colours.
Declared in no sheet today; written raw in five places. */ Declared in no sheet today; written raw in five places. */
@@ -321,11 +318,22 @@
--wp-btn-danger-fill-bg: var(--cds-support-error); --wp-btn-danger-fill-bg: var(--cds-support-error);
--wp-btn-danger-fill-fg: var(--cds-text-on-color); --wp-btn-danger-fill-fg: var(--cds-text-on-color);
/* -- the second blue ------------------------------------------------------- /* BL-008, CLOSED at T9.9 (C4, approved Aug 18): the second brand blue is
#2563d6, not #0f62fe. Fills .sop-inherited — every field a work package gone. .sop-inherited now tints with THE blue at the same 7% alpha. */
inherited from its SOP — at 7% alpha, which is why nobody has noticed a --wp-sop-inherited-bg: rgba(15, 98, 254, .07);
second brand blue. Named here so it is visible; swapped at T3.5 (BL-008). */
--wp-sop-inherited-bg: rgba(37, 99, 214, .07); /* The console feedback trio's success text (auth-guard / project-data /
wp-format carried it as a literal until C4). */
--wp-status-success-text: #0e6027;
--wp-status-error-text: #a2191f;
/* The categorical badge palette (the creator's navigator). Data-vis colours,
not UI states - named here because here is the only place a colour value
may exist (C4); the app reads them by computed style at boot. */
--wp-chart-1: #0f62fe; --wp-chart-2: #8a3ffc; --wp-chart-3: #007d79;
--wp-chart-4: #d02670; --wp-chart-5: #ba4e00; --wp-chart-6: #1192e8;
--wp-chart-7: #198038; --wp-chart-8: #a56eff; --wp-chart-9: #9f1853;
--wp-chart-10: #005d5d;
} }
/* Typography */ /* Typography */

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

@@ -34,7 +34,7 @@ async function boot(){
_scope = (status === 200 && json) ? json : { can_manage_users:false, scope:'projects', _scope = (status === 200 && json) ? json : { can_manage_users:false, scope:'projects',
grantable_roles:[], grantable_project_roles:[], managed_projects:[], project_roles:PROJECT_ROLES }; grantable_roles:[], grantable_project_roles:[], managed_projects:[], project_roles:PROJECT_ROLES };
if(status !== 200){ if(status !== 200){
banner('scope-banner','bad',' '+apiError(status, json, 'Could not work out what you may do here')+ banner('scope-banner','bad',' '+apiError(status, json, 'Could not work out what you may do here')+
' Showing the directory read-only.'); ' Showing the directory read-only.');
} else { } else {
renderScope(); renderScope();
@@ -85,7 +85,7 @@ async function loadUsers(){
const wrap = document.getElementById('users-table'); const wrap = document.getElementById('users-table');
const { status, json } = await api('GET','/api/auth/users'); const { status, json } = await api('GET','/api/auth/users');
if(status !== 200 || !Array.isArray(json)){ if(status !== 200 || !Array.isArray(json)){
banner('users-banner','bad',' '+apiError(status, json, 'Could not load the directory')); banner('users-banner','bad',' '+apiError(status, json, 'Could not load the directory'));
wrap.innerHTML = ''; return; wrap.innerHTML = ''; return;
} }
banner('users-banner','', ''); banner('users-banner','', '');
@@ -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 ────────────────────────────────────────────────────────────────────
@@ -354,11 +361,11 @@ async function createUser(){
role: val('nu-role'), project_role: val('nu-project-role'), role: val('nu-role'), project_role: val('nu-project-role'),
}); });
if(status === 200){ if(status === 200){
say('var(--green)',' Created '+username+'.'); say('var(--green)',' Created '+username+'.');
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id => document.getElementById(id).value = ''); ['nu-username','nu-fullname','nu-email','nu-password'].forEach(id => document.getElementById(id).value = '');
loadUsers(); loadUsers();
} else { } else {
say('var(--red)',' '+apiError(status, json, 'Could not create the account')); say('var(--red)',' '+apiError(status, json, 'Could not create the account'));
} }
} }
@@ -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

@@ -655,7 +655,7 @@ function renderWPTypes(){
const nameCell = t.custom const nameCell = t.custom
? `<div style="display:flex; gap:6px; align-items:center;"> ? `<div style="display:flex; gap:6px; align-items:center;">
<input type="text" placeholder="Custom type name" value="${(t.name||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].name=this.value" style="flex:1; padding:0.5rem; border:1px solid var(--border); border-radius:4px; font-weight:600;"> <input type="text" placeholder="Custom type name" value="${(t.name||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].name=this.value" style="flex:1; padding:0.5rem; border:1px solid var(--border); border-radius:4px; font-weight:600;">
<button onclick="removeWPType(${i})" title="Remove custom type" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600; flex:none;">✕</button> <button onclick="removeWPType(${i})" title="Remove custom type" style="background:var(--danger); color:var(--cds-text-on-color); border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600; flex:none;">✕</button>
</div>` </div>`
: `<div style="font-weight:600;">${t.name}</div>`; : `<div style="font-weight:600;">${t.name}</div>`;
row.innerHTML = ` row.innerHTML = `
@@ -669,7 +669,7 @@ function renderWPTypes(){
}); });
const addRow = document.createElement('div'); const addRow = document.createElement('div');
addRow.style.cssText = 'margin-top:0.85rem;'; addRow.style.cssText = 'margin-top:0.85rem;';
addRow.innerHTML = `<button onclick="addCustomWPType()" style="background:var(--primary,#0f62fe); color:#fff; border:none; padding:0.55rem 1rem; border-radius:4px; font-weight:600; cursor:pointer; font-size:13px;">+ Add custom type</button>`; addRow.innerHTML = `<button onclick="addCustomWPType()" style="background:var(--primary); color:var(--cds-text-on-color); border:none; padding:0.55rem 1rem; border-radius:4px; font-weight:600; cursor:pointer; font-size:13px;">+ Add custom type</button>`;
container.appendChild(addRow); container.appendChild(addRow);
} }
@@ -955,7 +955,7 @@ function renderCustomConstraints(){
<strong>${escAttr(c.name)}</strong> <strong>${escAttr(c.name)}</strong>
<span style="display:flex; align-items:center; gap:0.75rem;"> <span style="display:flex; align-items:center; gap:0.75rem;">
${criticalToggle(c.name, true, !!c.critical)} ${criticalToggle(c.name, true, !!c.critical)}
<button onclick="removeCustomConstraint('${escHandlerArg(c.name)}')" title="Remove" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button> <button onclick="removeCustomConstraint('${escHandlerArg(c.name)}')" title="Remove" style="background:var(--danger); color:var(--cds-text-on-color); border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button>
</span> </span>
</div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`; </div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`;
} }
@@ -980,11 +980,13 @@ function toggleConstraint(name){
function showConstraintLibrary(){ function showConstraintLibrary(){
const modal = document.getElementById('constraint-modal'); const modal = document.getElementById('constraint-modal');
const lib = document.getElementById('constraint-library'); const lib = document.getElementById('constraint-library');
// C1/T9.5: a library entry is an ACTION, so it is a button - keyboard and
// touch come free, and the hover styling moved to CSS where it belongs.
lib.innerHTML = CONSTRAINT_LIBRARY.map(c=>` lib.innerHTML = CONSTRAINT_LIBRARY.map(c=>`
<div class="constraint-option" style="padding:0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem; cursor:pointer; transition:all 0.2s;" onmouseover="this.style.borderColor='var(--primary)'; this.style.background='var(--primary-light)'" onmouseout="this.style.borderColor='var(--border)'; this.style.background='var(--bg)'" onclick="addCustomConstraint('${c}')"> <button type="button" class="constraint-option" onclick="addCustomConstraint('${c}')">
<strong>${c}</strong> <strong>${c}</strong>
<div style="font-size:12px; color:var(--text-light); margin-top:0.25rem;">Click to add to this project</div> <div style="font-size:12px; color:var(--text-light); margin-top:0.25rem;">Add to this project</div>
</div> </button>
`).join(''); `).join('');
modal.style.display = 'flex'; modal.style.display = 'flex';
} }
@@ -1559,7 +1561,10 @@ if(typeof WPUrl !== 'undefined'){
// NAVIGATE, so Back would bounce forward again and the button would appear // NAVIGATE, so Back would bounce forward again and the button would appear
// broken. Those addresses belong to the creator's own history stack, and the // broken. Those addresses belong to the creator's own history stack, and the
// boot redirect uses replace() precisely so no such entry is left here. // boot redirect uses replace() precisely so no such entry is left here.
const step = parseInt(state.step, 10); // BL-016 (fixed at T9.9): Back from ?step=6 to a URL with NO step used to
// parse NaN and do nothing - the wizard stayed on 6 while the address bar
// said otherwise. A step-less wizard URL IS step 1.
const step = parseInt(state.step, 10) || 1;
if(currentTool === 'sop' && step >= 1 && step !== currentStep) goToStep(step, {fromUrl:true}); if(currentTool === 'sop' && step >= 1 && step !== currentStep) goToStep(step, {fromUrl:true});
}); });
} }

View File

@@ -741,6 +741,13 @@ body {
/* .field-error is declared once, in theme-light.css — the launcher's create form /* .field-error is declared once, in theme-light.css — the launcher's create form
and T5.8's step validation use the same component. */ and T5.8's step validation use the same component. */
/* The constraint-library entries (C1/T9.5): real buttons, block layout. */
.constraint-option { display:block; width:100%; text-align:left; padding:0.75rem;
background:var(--bg); border:1px solid var(--border); border-radius:6px;
margin-bottom:0.5rem; cursor:pointer; font:inherit; color:inherit; transition:all .2s; }
.constraint-option:hover, .constraint-option:focus-visible {
border-color:var(--primary); background:var(--primary-light); }
/* NAVIGATION /* NAVIGATION
B6 / T7.8: sticky, the creator's pattern. On the Constraints and Sequence B6 / T7.8: sticky, the creator's pattern. On the Constraints and Sequence
steps the proposal's beside-the-fields actions meant scrolling to save; the steps the proposal's beside-the-fields actions meant scrolling to save; the

View File

@@ -319,3 +319,20 @@
the gate panel is what does the explaining. */ the gate panel is what does the explaining. */
.nav-tab[aria-disabled="true"] { opacity: .55; cursor: default; } .nav-tab[aria-disabled="true"] { opacity: .55; cursor: default; }
.nav-tab[aria-disabled="true"]:hover { background: none; color: var(--text-light); } .nav-tab[aria-disabled="true"]:hover { background: none; color: var(--text-light); }
/* C2 / T9.6: touch sizing. At phone widths (and any coarse pointer) every
control meets the 44px bar the field surfaces are held to; checkboxes,
radios and the help-tip badge get the 24px WCAG floor with spacing doing
the rest. Shared here because every page loads this sheet - six copies of
this block is how the six pages drift apart again. */
@media (max-width: 500px), (pointer: coarse) {
button, .btn, .add-btn, .nav-btn, .header-button,
input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]),
select, textarea { min-height: 44px; }
a.wp-appbar-link, .wp-sidenav-item, .nav-tab {
min-height: 44px; display: inline-flex; align-items: center; }
input[type="checkbox"], input[type="radio"] { min-width: 24px; min-height: 24px; }
.help-tip { min-width: 24px; min-height: 24px; }
.wp-navbtn, .ui-help-fab, .wp-sidenav-close { min-width: 44px; }
.wp-appbar-brand { min-height: 44px; display: inline-flex; align-items: center; }
}

View File

@@ -285,6 +285,7 @@ function _openDialog(opts){
document.getElementById('wp-dialog-err').textContent=''; document.getElementById('wp-dialog-err').textContent='';
document.getElementById('wp-dialog-ok').textContent=opts.okLabel||'OK'; document.getElementById('wp-dialog-ok').textContent=opts.okLabel||'OK';
document.getElementById('wp-dialog-cancel').textContent=opts.cancelLabel||'Cancel'; document.getElementById('wp-dialog-cancel').textContent=opts.cancelLabel||'Cancel';
document.getElementById('wp-dialog-cancel').style.display=opts.okOnly?'none':'';
ov.classList.add('open'); ov.classList.add('open');
setTimeout(()=>{ (opts.input?inp:document.getElementById('wp-dialog-ok')).focus(); },0); setTimeout(()=>{ (opts.input?inp:document.getElementById('wp-dialog-ok')).focus(); },0);
}); });
@@ -314,6 +315,10 @@ function wpConfirmDialog(opts){ return _openDialog({...opts, input:false}); }
// prompt() said string or null; so does this - and a validate() answer renders // prompt() said string or null; so does this - and a validate() answer renders
// AT the input instead of round-tripping through another dialog. // AT the input instead of round-tripping through another dialog.
function wpPromptDialog(opts){ return _openDialog({...opts, input:true}); } function wpPromptDialog(opts){ return _openDialog({...opts, input:true}); }
// alert() said one thing and offered one button; so does this (D11 - the asset
// importer arrived using alert(), and the kit had no one-button shape).
// Escape still closes it; the resolved value is not meaningful for alerts.
function wpAlertDialog(opts){ return _openDialog({...opts, input:false, okOnly:true}); }
document.addEventListener('keydown', e=>{ document.addEventListener('keydown', e=>{
const ov=document.getElementById('wp-dialog'); const ov=document.getElementById('wp-dialog');
if(e.key==='Escape' && ov && ov.classList.contains('open')) wpDialogCancel(); if(e.key==='Escape' && ov && ov.classList.contains('open')) wpDialogCancel();
@@ -435,7 +440,7 @@ function applySOP(){
applyKind(); applyKind();
if(!pkgMaterials.length){ pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); } if(!pkgMaterials.length){ pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); }
if(!pkgAttach.length){ pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); } if(!pkgAttach.length){ pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); }
if(!pkgAssets.length){ pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); } if(!pkgAssets.length){ buildAssets(); } // renders the "no assets yet" empty state
if(!pkgWorkSteps.length){ pkgWorkSteps=['']; buildWorkSteps(); } if(!pkgWorkSteps.length){ pkgWorkSteps=['']; buildWorkSteps(); }
updateNumber(); updateReleaseBanner(); updateNumber(); updateReleaseBanner();
// CR-006. Last, after every builder has rendered its card — applying it earlier // CR-006. Last, after every builder has rendered its card — applying it earlier
@@ -467,7 +472,7 @@ function applyKindVisibility(){
const show = (id, on) => { const el = document.getElementById(id); if(el) el.style.display = on ? '' : 'none'; }; const show = (id, on) => { const el = document.getElementById(id); if(el) el.style.display = on ? '' : 'none'; };
show('kind-row', bimProj); show('kind-row', bimProj);
show('bim-card', ewp); // model area / clash + IFF # / scan show('bim-card', ewp); // model area / clash + IFF # / scan
show('asset-card', !ewp); // controls.dev assets show('asset-card', !ewp); // Micron DB assets
show('material-card', !ewp); // bill of materials show('material-card', !ewp); // bill of materials
show('mimo-card', !ewp); // kitting / MIMO show('mimo-card', !ewp); // kitting / MIMO
show('bimlink-wrap', bimProj && !ewp); // an IWP references the BIM package that enabled it show('bimlink-wrap', bimProj && !ewp); // an IWP references the BIM package that enabled it
@@ -519,7 +524,7 @@ function lockQuality(id){
el.classList.toggle('sop-inherited', fromSOP); // comment 8: blue-tinted SOP field el.classList.toggle('sop-inherited', fromSOP); // comment 8: blue-tinted SOP field
el.classList.toggle('locked-field', false); el.classList.toggle('locked-field', false);
const wrap=el.closest('.field'); const btn=wrap&&wrap.querySelector('.lock-edit'); const note=wrap&&wrap.querySelector('.override-note'); const wrap=el.closest('.field'); const btn=wrap&&wrap.querySelector('.lock-edit'); const note=wrap&&wrap.querySelector('.override-note');
if(btn) btn.textContent = fromSOP ? '🔒 Edit (reason required)' : '↺ Revert to SOP'; if(btn) btn.textContent = fromSOP ? ' Edit (reason required)' : '↺ Revert to SOP';
if(note) note.innerHTML = pkgOverrides[id] ? `Overridden: ${esc(pkgOverrides[id])}` : ''; if(note) note.innerHTML = pkgOverrides[id] ? `Overridden: ${esc(pkgOverrides[id])}` : '';
} }
function editQuality(id){ function editQuality(id){
@@ -542,15 +547,20 @@ function editQuality(id){
} }
function renderCtxBar(){ function renderCtxBar(){
const bar=document.getElementById('ctx-bar'); const bar=document.getElementById('ctx-bar');
const archMark = window._projArchived
? ` <span class="ctx-sample" style="background:var(--red);color:var(--cds-text-on-color)">ARCHIVED — READ-ONLY</span>` : '';
if(!SOP){ if(!SOP){
bar.innerHTML = activeProjectId bar.innerHTML = activeProjectId
? `<div class="ctx-empty">No SOP found for this project yet — complete the <strong>SOP Configuration</strong> first, then return here.</div>` ? `<div class="ctx-empty">No SOP found for this project yet — complete the <strong>SOP Configuration</strong> first, then return here.${archMark}</div>`
: `<div class="ctx-empty">No SOP loaded — import one from the Configuration tool, or use <strong>Load sample data</strong> in the toolbar above.</div>`; : `<div class="ctx-empty">No SOP loaded — import one from the Configuration tool, or use <strong>Load sample data</strong> in the toolbar above.${archMark}</div>`;
return; return;
} }
const p=SOP.project||{}, g=SOP.governance||{}; const p=SOP.project||{}, g=SOP.governance||{};
// D7: an archived project is read-only. The chip is the courtesy; the server's
// write refusal is the rule, and savePackage() says so before the round trip.
const archived = archMark;
const sample=SOP.meta&&SOP.meta.sample?`<span class="ctx-sample">SAMPLE</span>`:''; const sample=SOP.meta&&SOP.meta.sample?`<span class="ctx-sample">SAMPLE</span>`:'';
bar.innerHTML=`<div class="ctx-main"><div class="ctx-proj">${esc(p.name||'Untitled')} ${sample}</div> bar.innerHTML=`<div class="ctx-main"><div class="ctx-proj">${esc(p.name||'Untitled')} ${sample}${archived}</div>
<div class="ctx-sub">${esc(p.number||'')}${p.division?' · '+esc(p.division):''}</div></div> <div class="ctx-sub">${esc(p.number||'')}${p.division?' · '+esc(p.division):''}</div></div>
<div class="ctx-meta"><span><b>${enabledTypes().length}</b> types</span><span>format <code>${esc(g.woFormat||'—')}</code></span><span>track: <b>${esc((SOP.field&&SOP.field.trackPlatform)||'—')}</b></span></div>`; <div class="ctx-meta"><span><b>${enabledTypes().length}</b> types</span><span>format <code>${esc(g.woFormat||'—')}</code></span><span>track: <b>${esc((SOP.field&&SOP.field.trackPlatform)||'—')}</b></span></div>`;
} }
@@ -570,7 +580,7 @@ function renderSopRefLinks(){
const srcs=sopLinkedSources(); const srcs=sopLinkedSources();
if(!srcs.length){ box.innerHTML=''; return; } if(!srcs.length){ box.innerHTML=''; return; }
box.innerHTML=`<div class="ref-links-title">Reference folders (from SOP) — navigate to find &amp; copy the specific file link:</div>`+ box.innerHTML=`<div class="ref-links-title">Reference folders (from SOP) — navigate to find &amp; copy the specific file link:</div>`+
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`; `<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link"> ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`;
} }
function renderSpecFolderLink(){ function renderSpecFolderLink(){
const el=document.getElementById('spec-folder-link'); if(!el) return; const el=document.getElementById('spec-folder-link'); if(!el) return;
@@ -802,7 +812,7 @@ function renderPredHint(){
el.textContent = 'None — this package can be released as soon as its constraints are cleared.'; el.textContent = 'None — this package can be released as soon as its constraints are cleared.';
el.style.color = ''; el.style.color = '';
} else if(blocking.length){ } else if(blocking.length){
el.innerHTML = ' Waiting on ' + blocking.map(p => esc(p.number || p.id) + ' (' + esc(p.status) + ')').join(', ') + el.innerHTML = ' Waiting on ' + blocking.map(p => esc(p.number || p.id) + ' (' + esc(p.status) + ')').join(', ') +
'. Release is gated until they are Closed.'; '. Release is gated until they are Closed.';
el.style.color = 'var(--red)'; el.style.color = 'var(--red)';
} else { } else {
@@ -1152,20 +1162,349 @@ async function splitByDiscipline(){
if(untagged) toast(untagged+' material line'+(untagged===1?'':'s')+' had no discipline tag and stayed on the master only.', 'alert'); if(untagged) toast(untagged+' material line'+(untagged===1?'':'s')+' had no discipline tag and stayed on the master only.', 'alert');
} }
// ── ASSETS (controls.dev) ──────────────────────────────────────────────────── // ── ASSETS (Micron asset catalog) ────────────────────────────────────────────
// Interim: assets are linked manually back to controls.dev. A future direct // Assets are picked from the Micron asset catalog, a SQL Server database this app
// integration will let the user pick them from a list instead of pasting links. // reads through /api/assets. The lookup is strictly read-only — picking an asset
// never writes to the catalog, and there is no endpoint that could.
//
// The catalog can be absent (not configured) or unreachable (VPN/host down), and
// neither may block someone from writing a work package: in both cases the picker
// says so and manual entry carries on. Manually entered assets are marked
// source:'manual' so it stays visible which rows the catalog vouches for.
// The whole catalog is fetched once when the page loads and searched in memory —
// it is slow-moving reference data, so a request per keystroke would buy nothing
// and cost latency on every one.
let assetCatalog = []; // the full catalog, loaded once
let assetCatalogIndex = new Map(); // lowercased id -> the Micron DB's own casing
let assetCatalogState = 'loading'; // loading | ready | absent | error
let assetResults = []; // current matches; the result list indexes into this
const ASSET_RESULT_MAX = 500; // results shown at once — the box scrolls, not the search
const ASSET_IMPORT_MAX = 1000; // rows accepted from one CSV — see importAssets()
function assetKey(a){ return String((a && a.tag) || '').trim().toLowerCase(); }
function assetAlreadyAdded(tag){
const k = String(tag||'').trim().toLowerCase();
return pkgAssets.some(a => assetKey(a) === k && k);
}
function buildAssets(){ function buildAssets(){
const tb=document.getElementById('asset-body'); if(!tb) return; tb.innerHTML=''; const tb=document.getElementById('asset-body'); if(!tb) return; tb.innerHTML='';
pkgAssets.forEach((a,i)=>{ const tr=document.createElement('tr'); if(!pkgAssets.length){
tr.innerHTML=`<td><input type="text" value="${(a.tag||'').replace(/"/g,'&quot;')}" placeholder="controls.dev asset tag / ID" oninput="pkgAssets[${i}].tag=this.value"></td> tb.innerHTML = `<tr><td colspan="3" class="asset-empty">No assets yet — search the Micron DB above to add the assets this package covers.</td></tr>`;
<td><input type="text" value="${(a.desc||'').replace(/"/g,'&quot;')}" placeholder="what it is (optional)" oninput="pkgAssets[${i}].desc=this.value"></td> return;
<td><input type="url" value="${(a.link||'').replace(/"/g,'&quot;')}" placeholder="https://controls.dev/..." oninput="pkgAssets[${i}].link=this.value"></td> }
<td class="center"><button class="row-del" onclick="removeAsset(${i})">✕</button></td>`; pkgAssets.forEach((a,i)=>{
tb.appendChild(tr); }); const tr=document.createElement('tr');
// The asset ID on a catalog row is shown as text, not an input: the catalog
// is the source of truth for it and a locally edited copy would silently
// disagree. The note is always the user's own, so it stays editable either
// way. Every interpolation below is esc()'d text content or a numeric index
// — never a raw string inside an inline handler, which is the bug pattern
// recorded in KNOWN-ISSUES.md §1.
const idCell = a.source === 'catalog'
? `<td><span class="asset-tag">${esc(a.tag)}</span> <span class="asset-badge" title="From the Micron DB">Micron DB</span></td>`
: `<td><input type="text" value="${esc(a.tag)}" placeholder="asset ID" oninput="pkgAssets[${i}].tag=this.value"></td>`;
tr.innerHTML = idCell +
`<td><input type="text" value="${esc(a.desc)}" placeholder="what it is / why it's in scope" oninput="pkgAssets[${i}].desc=this.value"></td>
<td class="center"><button class="row-del" onclick="removeAsset(${i})" title="Remove">✕</button></td>`;
tb.appendChild(tr);
});
}
// Kept for saved packages written before the picker existed: their rows have no
// `source`, so they would render as read-only catalog rows with no way to fix a
// typo. Anything that didn't come from the catalog is treated as manual.
function normaliseAsset(a){
const o = Object.assign({ tag:'', desc:'', link:'', source:'manual' }, a||{});
if(o.source !== 'catalog') o.source = 'manual';
return o;
}
function addManualAsset(){
pkgAssets.push(normaliseAsset({}));
buildAssets();
track('asset_added',{source:'manual'});
}
// ── CSV IMPORT ───────────────────────────────────────────────────────────────
// Bulk-add a list of asset ids. Each imported id is checked against the loaded
// Micron DB: a hit is added as a catalog row (badge, id locked, stored with the
// DB's own casing); a miss is added as a manual row so it is visibly NOT vouched
// for rather than silently dropped. Nothing is ever written back to Micron.
const ASSET_ID_HEADERS = ['asset id','assetid','asset_id','asset','asset tag','assettag','tag','id'];
function importAssets(ev){
const f = ev.target.files && ev.target.files[0];
if(!f){ return; }
const clear = () => { ev.target.value = ''; };
if(/\.xlsx?$/i.test(f.name)){
wpAlertDialog({title:'Load from CSV', message:'Please save the workbook as CSV first (File → Save As → CSV), then load it here.'});
clear(); return;
}
const r = new FileReader();
r.onload = () => {
let rows;
try { rows = parseCSV(r.result); }
catch(e){ toast('Could not parse that CSV.', 'alert'); clear(); return; }
if(!rows.length){ toast('That file has no rows.', 'alert'); clear(); return; }
applyImportedAssets(rows);
clear();
};
r.onerror = () => { toast('Could not read that file.', 'alert'); clear(); };
r.readAsText(f);
}
// Which column holds the ids, and whether row 0 is a header.
// - a recognised header name wins outright;
// - otherwise pick the column with the most Micron DB hits, so an export with
// the ids in column D works without the user rearranging it;
// - failing both (nothing matches — e.g. the DB is offline), use column 0.
function pickAssetColumn(rows){
const head = (rows[0] || []).map(c => String(c || '').trim().toLowerCase());
const named = head.findIndex(h => ASSET_ID_HEADERS.includes(h));
if(named >= 0) return { col: named, start: 1 };
const width = rows.slice(0, 200).reduce((w, r) => Math.max(w, r.length), 1);
let best = 0, bestHits = 0;
for(let c = 0; c < width; c++){
let hits = 0;
for(let i = 0; i < Math.min(rows.length, 200); i++){
const v = String((rows[i] || [])[c] || '').trim();
if(v && assetCatalogIndex.has(v.toLowerCase())) hits++;
}
if(hits > bestHits){ bestHits = hits; best = c; }
}
return { col: best, start: 0 };
}
function applyImportedAssets(rows){
const { col, start } = pickAssetColumn(rows);
// Collect, trimmed and de-duplicated within the file itself.
const seen = new Set(), ids = [];
for(let i = start; i < rows.length; i++){
const v = String((rows[i] || [])[col] || '').trim();
if(!v) continue;
const k = v.toLowerCase();
if(seen.has(k)) continue;
seen.add(k); ids.push(v);
}
if(!ids.length){ toast('No asset ids found in that file.', 'alert'); return; }
// Cap the import rather than building a table with thousands of rows. Reported,
// never silent — a truncated import that looked complete would be worse.
const capped = ids.length > ASSET_IMPORT_MAX;
const take = capped ? ids.slice(0, ASSET_IMPORT_MAX) : ids;
let matched = 0, unmatched = 0, dupes = 0;
take.forEach(id => {
if(assetAlreadyAdded(id)){ dupes++; return; }
const canonical = assetCatalogIndex.get(id.toLowerCase());
if(canonical){
pkgAssets.push({ tag: canonical, desc: '', link: '', source: 'catalog' });
matched++;
} else {
pkgAssets.push({ tag: id, desc: '', link: '', source: 'manual' });
unmatched++;
}
});
buildAssets();
renderAssetResults(); // rows just added should now read "added"
track('asset_imported', { matched: matched, unmatched: unmatched });
// Every id matched, nothing skipped, nothing truncated: a toast is enough.
// Anything the user needs to act on — unmatched ids, a silent-looking
// truncation, an unchecked import — interrupts with the detail instead.
const offline = assetCatalogState !== 'ready';
if(matched && !unmatched && !dupes && !capped && !offline){
toast('Added ' + matched + ' asset' + (matched === 1 ? '' : 's') + ' from the Micron DB');
return;
}
const parts = [];
if(matched) parts.push(matched + ' found in the Micron DB');
if(unmatched) parts.push(unmatched + ' not in the Micron DB (added as manual rows)');
if(dupes) parts.push(dupes + ' already on this package (skipped)');
let msg = 'Imported ' + (matched + unmatched) + ' asset' + ((matched + unmatched) === 1 ? '' : 's') +
(parts.length ? ':\n\n• ' + parts.join('\n• ') : '');
if(capped) msg += '\n\nThe list held ' + ids.length.toLocaleString() + ' ids — only the first ' +
ASSET_IMPORT_MAX.toLocaleString() + ' were added.';
if(offline) msg += '\n\nNote: the Micron DB was not loaded, so nothing could be ' +
'checked against it — every row was added as manual.';
wpAlertDialog({title:'Asset import', message:msg});
}
function removeAsset(i){
pkgAssets.splice(i,1);
buildAssets();
renderAssetResults(); // a removed asset becomes addable again
}
// ── Catalog lookup ───────────────────────────────────────────────────────────
function assetSourceNote(msg, tone){
const el = document.getElementById('asset-source-note'); if(!el) return;
el.textContent = msg || '';
el.style.color = tone === 'warn' ? 'var(--red)' : '';
}
function initAssetPicker(){
const box = document.getElementById('asset-search'); if(!box) return;
box.addEventListener('input', () => runAssetSearch(box.value));
// Re-open on focus only when the list is actually closed. Adding an asset
// returns focus to this box, and re-running the search there would rebuild the
// list under the cursor and throw away the scroll position mid-multi-add.
box.addEventListener('focus', () => {
const results = document.getElementById('asset-results');
if(results && results.hidden && box.value.trim()) runAssetSearch(box.value);
});
// Pasting a column of ids straight out of Excel adds them all, rather than
// dropping a multi-line blob into a search box that can only match one thing.
// Excel gives \r\n between rows and \t between columns — i.e. exactly the CSV
// importer's row/cell shape, so it goes through the same matching path.
// A single value is left alone: that is an ordinary search, not a bulk add.
box.addEventListener('paste', e => {
const cb = e.clipboardData || window.clipboardData;
const text = cb ? cb.getData('text') : '';
if(!text) return;
const lines = text.replace(/\r\n?/g, '\n').split('\n').filter(l => l.trim());
if(lines.length < 2) return; // one id — paste it and search as normal
e.preventDefault();
applyImportedAssets(lines.map(l => l.split('\t')));
box.value = '';
assetResults = [];
openAssetResults(false);
});
box.addEventListener('keydown', e => {
if(e.key === 'Escape'){ openAssetResults(false); box.blur(); }
// Enter adds the first result that isn't already on the package — the common
// case of typing an exact tag and taking it without reaching for the mouse.
if(e.key === 'Enter'){
e.preventDefault();
const ix = assetResults.findIndex(tag => !assetAlreadyAdded(tag));
if(ix >= 0) addCatalogAsset(ix);
}
});
// Click-away closes, matching the .pp-menu pickers elsewhere on this form.
// Tested against composedPath() rather than e.target: adding an asset can
// re-render the row that was clicked, and a detached target reports itself as
// outside every container, which would close the list on every add.
document.addEventListener('click', e => {
const path = typeof e.composedPath === 'function' ? e.composedPath() : null;
const inside = path && path.length
? path.some(n => n && n.id === 'asset-pick')
: !!(e.target.closest && e.target.closest('#asset-pick'));
if(!inside) openAssetResults(false);
});
// Delegated so result rows never need an inline handler carrying catalog text.
const results = document.getElementById('asset-results');
if(results) results.addEventListener('click', e => {
const row = e.target.closest('[data-asset-ix]'); if(!row) return;
addCatalogAsset(parseInt(row.getAttribute('data-asset-ix'), 10));
});
box.disabled = true;
box.placeholder = 'Loading asset IDs from the Micron DB…';
assetSourceNote('Loading asset IDs from the Micron DB…');
fetch('/api/assets', { headers:{ 'Accept':'application/json' } })
.then(r => r.ok ? r.json()
: r.json().catch(() => ({})).then(b => Promise.reject(b.detail || 'The Micron DB could not be read.')))
.then(body => {
if(!body.configured){
assetCatalogState = 'absent';
box.placeholder = 'Micron DB not configured — add assets manually below';
assetSourceNote('The Micron DB is not connected, so assets are entered by hand. Use “+ Add asset not in the Micron DB”.');
return;
}
assetCatalog = (body.assets || []).map(a => String(a.tag || ''));
// Lowercased lookup for the CSV importer: it decides whether an imported id
// is a real Micron asset, and maps it back to the DB's own casing so an
// id typed as "ahu-2p-014" is stored exactly as Micron spells it.
assetCatalogIndex = new Map(assetCatalog.map(t => [t.toLowerCase(), t]));
assetCatalogState = 'ready';
box.disabled = false;
box.placeholder = 'Search asset IDs, or paste a column from Excel…';
assetSourceNote(assetCatalog.length.toLocaleString() + ' asset IDs loaded from the Micron DB (read-only).');
})
.catch(err => {
assetCatalogState = 'error';
box.placeholder = 'Micron DB unavailable — add assets manually below';
assetSourceNote(typeof err === 'string' ? err + ' You can still add assets manually.'
: 'The Micron DB could not be reached. You can still add assets manually.', 'warn');
});
}
// Filters the loaded catalog in memory. Exact match, then prefix, then contains —
// so typing a full asset ID puts that asset first rather than whichever ID
// happens to sort first.
function runAssetSearch(q){
q = String(q||'').trim().toLowerCase();
if(!q || assetCatalogState !== 'ready'){
assetResults = []; renderAssetResults(); openAssetResults(false); return;
}
const exact=[], prefix=[], other=[];
// The cap bounds each TIER, never the scan: breaking on a combined count let
// 500 alphabetically-early contains-matches evict an exact or prefix match
// that sorted after them - and Enter then added the wrong asset. The scan is
// in-memory and cheap; "the box scrolls, not the search" means the search
// sees everything.
for(const tag of assetCatalog){
const t = tag.toLowerCase();
if(t === q) exact.push(tag);
else if(t.startsWith(q)){ if(prefix.length < ASSET_RESULT_MAX) prefix.push(tag); }
else if(t.includes(q)){ if(other.length < ASSET_RESULT_MAX) other.push(tag); }
}
assetResults = exact.concat(prefix, other).slice(0, ASSET_RESULT_MAX);
renderAssetResults();
openAssetResults(true);
}
function renderAssetResults(){
const box = document.getElementById('asset-results'); if(!box) return;
if(!assetResults.length){
box.innerHTML = `<div class="asset-result-note">No matching asset IDs. Add it manually if it isnt in the Micron DB yet.</div>`;
return;
}
box.innerHTML = assetResults.map((tag,ix) => {
const on = assetAlreadyAdded(tag);
return `<button type="button" class="asset-result${on?' is-added':''}" ${on?'disabled':''} data-asset-ix="${ix}">
<span class="asset-result-tag">${esc(tag)}</span>
<span class="asset-result-add">${on ? 'added' : '+ add'}</span>
</button>`;
}).join('');
}
function openAssetResults(open){
const box = document.getElementById('asset-results');
const inp = document.getElementById('asset-search');
if(box) box.hidden = !open;
if(inp) inp.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function addCatalogAsset(ix){
const tag = assetResults[ix]; if(!tag) return;
if(assetAlreadyAdded(tag)){ toast('That asset is already on this package'); return; }
pkgAssets.push({ tag: tag, desc: '', link: '', source: 'catalog' });
buildAssets();
toast('Added ' + tag); // announced (role=status) - a keyboard pick is otherwise silent
// Mark just this row instead of re-rendering the list: the results stay open
// for the next pick, the scroll position holds, and the clicked element is
// never detached mid-click (see the composedPath note in initAssetPicker).
markAssetResultAdded(ix);
const box = document.getElementById('asset-search');
if(box) box.focus(); // keep typing straight into the next search
track('asset_added',{source:'catalog'});
}
function markAssetResultAdded(ix){
const row = document.querySelector('#asset-results [data-asset-ix="' + ix + '"]');
if(!row) return;
row.classList.add('is-added');
row.disabled = true;
const label = row.querySelector('.asset-result-add');
if(label) label.textContent = 'added';
} }
function addAsset(){ pkgAssets.push({tag:'',desc:'',link:''}); buildAssets(); track('asset_added'); }
function removeAsset(i){ pkgAssets.splice(i,1); if(!pkgAssets.length)pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); }
// ── ATTACHMENTS ────────────────────────────────────────────────────────────── // ── ATTACHMENTS ──────────────────────────────────────────────────────────────
function buildAttach(){ function buildAttach(){
@@ -1241,7 +1580,7 @@ function wpFilesMirror(){
function wpFilesRender(){ function wpFilesRender(){
const box=document.getElementById('wp-file-list'); if(!box) return; const box=document.getElementById('wp-file-list'); if(!box) return;
box.innerHTML=pkgFiles.map(f=>`<div class="wp-file-item"> box.innerHTML=pkgFiles.map(f=>`<div class="wp-file-item">
<a href="/api/files/${encodeURIComponent(f.id)}" target="_blank" rel="noopener">${f.mime==='application/pdf'?'📄':'🖼'} ${esc(f.name||'drawing')}</a> <a href="/api/files/${encodeURIComponent(f.id)}" target="_blank" rel="noopener">${esc(f.name||'drawing')}</a>
<span class="wf-size">${wpFileSize(f.size||0)}</span> <span class="wf-size">${wpFileSize(f.size||0)}</span>
<input type="text" value="${(f.description||'').replace(/"/g,'&quot;')}" placeholder="focus area, e.g. Tray section, Level 3 east only" <input type="text" value="${(f.description||'').replace(/"/g,'&quot;')}" placeholder="focus area, e.g. Tray section, Level 3 east only"
aria-label="Description of ${esc(f.name||'drawing')}" onchange="wpFileDescSave('${f.id}', this.value)"> aria-label="Description of ${esc(f.name||'drawing')}" onchange="wpFileDescSave('${f.id}', this.value)">
@@ -1310,7 +1649,7 @@ function renderSopFileFolders(){
const srcs=sopLinkedSources(); const srcs=sopLinkedSources();
box.innerHTML = srcs.length box.innerHTML = srcs.length
? `<div class="field-hint">1) Open a folder, multi-select files in SharePoint, then use <b>Copy link</b>:</div>`+ ? `<div class="field-hint">1) Open a folder, multi-select files in SharePoint, then use <b>Copy link</b>:</div>`+
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>` `<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link"> ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`
: `<div class="field-hint">No SOP folders defined — load or import an SOP first.</div>`; : `<div class="field-hint">No SOP folders defined — load or import an SOP first.</div>`;
} }
function toggleSopFilePanel(){ function toggleSopFilePanel(){
@@ -1417,7 +1756,7 @@ function readiness(){
function updateReleaseBanner(){ function updateReleaseBanner(){
const b=document.getElementById('release-banner'); const r=readiness(); const st=getRadio('status'); const b=document.getElementById('release-banner'); const r=readiness(); const st=getRadio('status');
let cls, txt; let cls, txt;
if(st==='Issue'){ cls='rb-hold'; txt=` On hold — ${r.open} constraint${r.open===1?'':'s'} reopened. Resolve to resume.`; } if(st==='Issue'){ cls='rb-hold'; txt=` On hold — ${r.open} constraint${r.open===1?'':'s'} reopened. Resolve to resume.`; }
else if(st==='Ready for QA'){ else if(st==='Ready for QA'){
// CR-014: the QA decision lives where the state is announced. Accept moves to // CR-014: the QA decision lives where the state is announced. Accept moves to
// QC; reject returns to the crew and REQUIRES a comment (the modal enforces // QC; reject returns to the crew and REQUIRES a comment (the modal enforces
@@ -1764,6 +2103,10 @@ function collectPackage(){
}; };
} }
async function savePackage(view){ async function savePackage(view){
if(window._projArchived){
toast('This project is archived — read-only. Nothing can be saved to it.', 'alert');
return;
}
if(!wpValidateForm()) return; if(!wpValidateForm()) return;
// A BIM package marked "Signed off (IFF)" without the number isn't traceable. // A BIM package marked "Signed off (IFF)" without the number isn't traceable.
// The status can also be set programmatically (the per-discipline roll-up), so // The status can also be set programmatically (the per-discipline roll-up), so
@@ -1839,10 +2182,12 @@ function renderPackage(pkg){
// because "the toggle does nothing" and "the toggle governs one row" look the // because "the toggle does nothing" and "the toggle governs one row" look the
// same from outside. // same from outside.
// D11: two columns now. The controls.dev link column died with the link field;
// a catalog row's identity is its ID, and the note is the user's own text.
if(pkg.assets&&pkg.assets.length){ if(pkg.assets&&pkg.assets.length){
let t=`<table><thead><tr><th style="width:180px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link</th></tr></thead><tbody>`; let t=`<table><thead><tr><th style="width:240px">Asset ID</th><th>Note</th></tr></thead><tbody>`;
pkg.assets.forEach(a=>t+=`<tr><td>${cell(a.tag)}</td><td>${cell(a.desc)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`); pkg.assets.forEach(a=>t+=`<tr><td>${cell(a.tag)}</td><td>${cell(a.desc)}</td></tr>`);
add('assets', 'Assets (controls.dev)', t+`</tbody></table>`); add('assets', 'Assets', t+`</tbody></table>`);
} }
let scopeHtml; let scopeHtml;
@@ -1935,7 +2280,11 @@ function renderPackage(pkg){
} }
function printPackage(){ function printPackage(){
const c=document.getElementById('pkg-doc').innerHTML; const w=window.open('','_blank'); const c=document.getElementById('pkg-doc').innerHTML; const w=window.open('','_blank');
w.document.write(`<!DOCTYPE html><html><head><title>Work Package</title><style>body{font-family:Arial,sans-serif;font-size:12px;line-height:1.6;color:#1a2230;padding:40px;max-width:820px;margin:0 auto}h1{font-size:20px;margin-bottom:4px}h2{font-size:13px;font-weight:700;text-transform:uppercase;border-bottom:2px solid #cdd9f2;padding-bottom:4px;margin-top:22px;color:#2563d6}table{width:100%;border-collapse:collapse;margin:10px 0;font-size:11px}th{background:#f0f2f5;border:1px solid #ccc;padding:5px 8px;text-align:left}td{border:1px solid #ccc;padding:5px 8px}@page{margin:18mm}</style></head><body>${c}</body></html>`); // C4/T9.9 (and BL-008): the popup document has no stylesheet, so the theme's
// values are read from the live page and inlined - including THE blue where
// the second brand blue (#2563d6) used to be.
const tok=(name)=>getComputedStyle(document.documentElement).getPropertyValue(name).trim();
w.document.write(`<!DOCTYPE html><html><head><title>Work Package</title><style>body{font-family:Arial,sans-serif;font-size:12px;line-height:1.6;color:${tok('--cds-text-primary')};padding:40px;max-width:820px;margin:0 auto}h1{font-size:20px;margin-bottom:4px}h2{font-size:13px;font-weight:700;text-transform:uppercase;border-bottom:2px solid ${tok('--cds-highlight')};padding-bottom:4px;margin-top:22px;color:${tok('--cds-link-primary')}}table{width:100%;border-collapse:collapse;margin:10px 0;font-size:11px}th{background:${tok('--cds-layer-accent')};border:1px solid ${tok('--cds-border-subtle')};padding:5px 8px;text-align:left}td{border:1px solid ${tok('--cds-border-subtle')};padding:5px 8px}@page{margin:18mm}</style></head><body>${c}</body></html>`);
w.document.close(); w.print(); w.document.close(); w.print();
} }
@@ -2585,12 +2934,12 @@ const WP_NAV_CRITICAL_CSS = `
body{--nav-w:288px;} body{--nav-w:288px;}
body.wp-nav-collapsed{--nav-w:56px;} body.wp-nav-collapsed{--nav-w:56px;}
.wp-nav{position:fixed;top:var(--rail-top,48px);left:0;bottom:0;width:var(--nav-w); .wp-nav{position:fixed;top:var(--rail-top,48px);left:0;bottom:0;width:var(--nav-w);
z-index:120;display:flex;flex-direction:column;overflow:hidden;background:#fbfbfc; z-index:120;display:flex;flex-direction:column;overflow:hidden;background:var(--wp-nav-bg);
border-right:1px solid #e0e0e0;} border-right:1px solid var(--cds-border-subtle);}
.wp-nav-list{flex:1 1 auto;overflow-y:auto;overflow-x:hidden;} .wp-nav-list{flex:1 1 auto;overflow-y:auto;overflow-x:hidden;}
.wp-nav-item,.wp-nav-link{display:flex;align-items:center;gap:11px;width:100%; .wp-nav-item,.wp-nav-link{display:flex;align-items:center;gap:11px;width:100%;
background:none;border:0;text-align:left;cursor:pointer;font:inherit;} background:none;border:0;text-align:left;cursor:pointer;font:inherit;}
.wp-nav-badge{flex:0 0 28px;width:28px;height:28px;border-radius:5px;color:#fff; .wp-nav-badge{flex:0 0 28px;width:28px;height:28px;border-radius:5px;color:var(--cds-text-on-color);
display:inline-flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;} display:inline-flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;}
.wp-nav-body{min-width:0;flex:1 1 auto;} .wp-nav-body{min-width:0;flex:1 1 auto;}
.wp-nav-num,.wp-nav-subj{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .wp-nav-num,.wp-nav-subj{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
@@ -2682,8 +3031,19 @@ function setWpNavView(view){
// A stable colour per package so the same one is always the same swatch — the cue // A stable colour per package so the same one is always the same swatch — the cue
// Planner gets from its plan avatars. Hashed from the WP number, not its index, so // Planner gets from its plan avatars. Hashed from the WP number, not its index, so
// it doesn't shuffle when a package is added or deleted. // it doesn't shuffle when a package is added or deleted.
const WP_BADGE_COLORS = ['#0f62fe','#8a3ffc','#007d79','#d02670','#ba4e00', const WP_BADGE_COLORS = (() => {
'#1192e8','#198038','#a56eff','#9f1853','#005d5d']; // C4/T9.9: the values live in theme-light.css (--wp-chart-1..10), the ONE
// place a colour may exist; this reads them at boot. The fallback array is
// token NAMES, not values - if the theme fails to load there are no colours
// anywhere, which is the correct failure.
const cs = getComputedStyle(document.documentElement);
const out = [];
for(let k = 1; k <= 10; k++){
const v = (cs.getPropertyValue('--wp-chart-' + k) || '').trim();
out.push(v || 'var(--wp-chart-' + k + ')');
}
return out;
})();
function wpBadgeColor(p){ function wpBadgeColor(p){
const key = (p.number || p.id || '') + ''; const key = (p.number || p.id || '') + '';
let h = 0; let h = 0;
@@ -2790,7 +3150,7 @@ function renderWpNav(){
const h = live[live.length - 1]; const h = live[live.length - 1];
const why = h ? (h.constraint ? h.constraint + ': ' : '') + (h.details || '') const why = h ? (h.constraint ? h.constraint + ': ' : '') + (h.details || '')
: 'no reason recorded — log it from the status control'; : 'no reason recorded — log it from the status control';
holdLine = '<span class="wp-nav-hold"> ' + (open ? open + ' open — ' : '') holdLine = '<span class="wp-nav-hold"> ' + (open ? open + ' open — ' : '')
+ esc(why) + '</span>'; + esc(why) + '</span>';
} }
return '<button type="button" class="wp-nav-item' + active + '" onclick="wpNavOpen(' + r.i + ')"' + return '<button type="button" class="wp-nav-item' + active + '" onclick="wpNavOpen(' + r.i + ')"' +
@@ -2911,7 +3271,18 @@ function loadPackageIntoForm(p){
onClashChange(); onClashChange();
applyKind(); applyKind();
buildTypePicker(); document.getElementById('wp_type').value=p.type||''; buildTypePicker(); document.getElementById('wp_type').value=p.type||'';
buildCostCodes(); document.getElementById('wp_cost').value=p.cost||''; buildCostCodes();
// BL-019 (fixed at T9.9): setting a <select> to a value with no option does
// NOTHING, silently - so a code that left COST_CODES blanked on open and the
// next save wrote the blank over the record. Same fix as gov_wosize: keep
// the stored value as an option so the round-trip preserves it.
{ const cs=document.getElementById('wp_cost');
if(p.cost && ![...cs.options].some(o=>o.value===p.cost)){
const o=document.createElement('option');
o.value=p.cost; o.textContent=p.cost+' (not in the current list)';
cs.appendChild(o);
}
cs.value=p.cost||''; }
set('wp_wbs',p.wbs); set('wp_wbs',p.wbs);
document.getElementById('wp_kit_status').value=p.kitStatus||''; document.getElementById('wp_kit_status').value=p.kitStatus||'';
buildSequencePicker(); document.getElementById('wp_seq').value=p.seq||''; buildSequencePicker(); document.getElementById('wp_seq').value=p.seq||'';
@@ -2933,7 +3304,7 @@ function loadPackageIntoForm(p){
set('wp_hold', (p.hold&&p.hold.trim())?p.hold:sopValueFor('wp_hold')); set('wp_hold', (p.hold&&p.hold.trim())?p.hold:sopValueFor('wp_hold'));
lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold'); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
// collections // collections
pkgAssets=(p.assets&&p.assets.length)?p.assets.map(a=>({...a})):[{tag:'',desc:'',link:''}]; buildAssets(); pkgAssets=(p.assets||[]).map(normaliseAsset); buildAssets();
pkgMaterials=(p.materials&&p.materials.length)?p.materials.map(m=>({...m,unit:(m.unit||'').toUpperCase()})):[{qty:'',unit:'',desc:''}]; buildMaterials(); pkgMaterials=(p.materials&&p.materials.length)?p.materials.map(m=>({...m,unit:(m.unit||'').toUpperCase()})):[{qty:'',unit:'',desc:''}]; buildMaterials();
pkgAttach=(p.attachments&&p.attachments.length)?p.attachments.map(a=>({...a})):[{doc:'',rev:'',link:''}]; buildAttach(); pkgAttach=(p.attachments&&p.attachments.length)?p.attachments.map(a=>({...a})):[{doc:'',rev:'',link:''}]; buildAttach();
pkgWorkSteps=(p.workSteps&&p.workSteps.length)?p.workSteps.slice():(p.work?String(p.work).split('\n').filter(Boolean):['']); if(!pkgWorkSteps.length)pkgWorkSteps=['']; buildWorkSteps(); pkgWorkSteps=(p.workSteps&&p.workSteps.length)?p.workSteps.slice():(p.work?String(p.work).split('\n').filter(Boolean):['']); if(!pkgWorkSteps.length)pkgWorkSteps=['']; buildWorkSteps();
@@ -3004,7 +3375,7 @@ function newPackage(){
setRadio('status','Draft'); setRadio('status','Draft');
numberDims={}; buildNumberDims(); numberDims={}; buildNumberDims();
pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange(); pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange();
pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); pkgAssets=[]; buildAssets();
pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials();
pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach();
pkgWorkSteps=['']; buildWorkSteps(); pkgWorkSteps=['']; buildWorkSteps();
@@ -3406,10 +3777,22 @@ 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)
const statusChip=(label,count,cls,status)=>`<span class="dash-chip${cls?' '+cls:''}${dashFilter.status===status?' chip-active':''}" onclick="dashSetStatus('${status}')" title="Click to filter the board">${esc(label)}: <b>${count}</b></span>`; // C1/T9.5: the chip filters the board, so it is a button.
const statusChip=(label,count,cls,status)=>`<button type="button" class="dash-chip${cls?' '+cls:''}${dashFilter.status===status?' chip-active':''}" onclick="dashSetStatus('${status}')" title="Click to filter the board">${esc(label)}: <b>${count}</b></button>`;
const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>statusChip(s,byStatus[s],'',s)).join('') const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>statusChip(s,byStatus[s],'',s)).join('')
+ (byStatus['Issue']?statusChip('On Hold',byStatus['Issue'],'chip-red','Issue'):''); + (byStatus['Issue']?statusChip('On Hold',byStatus['Issue'],'chip-red','Issue'):'');
const discChips=Object.keys(byDisc).map(d=>`<span class="dash-chip">${esc(d)}: <b>${byDisc[d]}</b></span>`).join('')||'<span class="dash-chip">—</span>'; const discChips=Object.keys(byDisc).map(d=>`<span class="dash-chip">${esc(d)}: <b>${byDisc[d]}</b></span>`).join('')||'<span class="dash-chip">—</span>';
@@ -3430,7 +3813,7 @@ function renderDashboard(){
// gating panel — what's blocking release, from the server // gating panel — what's blocking release, from the server
const gated=m.gating||[]; const gated=m.gating||[];
h+=`<div class="dash-panel"><div class="dash-panel-title"> Gating constraints (${gated.length} package${gated.length===1?'':'s'} blocked)</div>`; h+=`<div class="dash-panel"><div class="dash-panel-title"> Gating constraints (${gated.length} package${gated.length===1?'':'s'} blocked)</div>`;
h+= gated.length ? `<table class="dash-table"><thead><tr><th>WP #</th><th>Subject</th><th>Blocked by</th></tr></thead><tbody>`+ h+= gated.length ? `<table class="dash-table"><thead><tr><th>WP #</th><th>Subject</th><th>Blocked by</th></tr></thead><tbody>`+
gated.map(g=>`<tr><td class="row-label">${esc(g.number||'—')}</td><td>${esc(g.subject||'')}</td> gated.map(g=>`<tr><td class="row-label">${esc(g.number||'—')}</td><td>${esc(g.subject||'')}</td>
<td>${(g.blocked_by||[]).map(c=>esc(c.name)+(c.comment?` <span style="color:var(--text-dim)">(${esc(c.comment)})</span>`:'')).join('<br>')}</td></tr>`).join('')+ <td>${(g.blocked_by||[]).map(c=>esc(c.name)+(c.comment?` <span style="color:var(--text-dim)">(${esc(c.comment)})</span>`:'')).join('<br>')}</td></tr>`).join('')+
@@ -3633,7 +4016,9 @@ async function clearMyComments(){ const d=cmtLoad(); const mine=d.comments.filte
if(!mine){ toast('No comments to clear.', 'alert'); return; } if(!mine){ toast('No comments to clear.', 'alert'); return; }
if(!(await wpConfirmDialog({title:'Clear my comments', message:`Delete your ${mine} comment(s)?`, okLabel:'Delete them'}))) return; if(!(await wpConfirmDialog({title:'Clear my comments', message:`Delete your ${mine} comment(s)?`, okLabel:'Delete them'}))) return;
d.comments=d.comments.filter(c=>c.clientId!==d.clientId); cmtSave(d); renderComments(); refreshCommentBadges(); } d.comments=d.comments.filter(c=>c.clientId!==d.clientId); cmtSave(d); renderComments(); refreshCommentBadges(); }
function cmtInit(){ const d=cmtLoad(); cmtSave(d); const a=document.getElementById('cmt-author'); if(a)a.value=d.author||''; cmtUpdateCurStep(); renderComments(); refreshCommentBadges(); } function cmtInit(){
const ov=document.getElementById('cmt-overlay');
if(ov && !ov._wired){ ov._wired=true; ov.addEventListener('click', toggleComments); } const d=cmtLoad(); cmtSave(d); const a=document.getElementById('cmt-author'); if(a)a.value=d.author||''; cmtUpdateCurStep(); renderComments(); refreshCommentBadges(); }
// ── STATUS PILLS ───────────────────────────────────────────────────────────── // ── STATUS PILLS ─────────────────────────────────────────────────────────────
document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventListener('click',e=>{ document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventListener('click',e=>{
@@ -3830,6 +4215,16 @@ function bootData(){
loadLocations(); // CR-004: the option lists come from the server loadLocations(); // CR-004: the option lists come from the server
wpFilesRefreshUsage(); // CR-007: the storage meter is live before the first upload wpFilesRefreshUsage(); // CR-007: the storage meter is live before the first upload
mreqLoadMaterials(); // CR-013/D6: the request datalist comes from the project list mreqLoadMaterials(); // CR-013/D6: the request datalist comes from the project list
initAssetPicker(); // D11: the Micron catalog, fetched once per page load
// D7: the read-only courtesy needs the SERVER's answer, not a stale local
// summary - the page's project comes from the URL, which may not be the one
// last stored. The chip and the save guard both read this flag.
if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.get){
ProjectData.get(activeProjectId).then(p=>{
window._projArchived = !!(p && p.archived);
if(window._projArchived) renderCtxBar();
}).catch(()=>{});
}
initWpNavDrawer(); initWpNavDrawer();
renderSavedList(); renderSavedList();
positionSectionNav(); positionSectionNav();

View File

@@ -303,12 +303,24 @@
</div> </div>
</div> </div>
<!-- ASSETS (controls.dev) --> <!-- ASSETS (Micron asset catalog) -->
<div class="card" id="asset-card"> <div class="card" id="asset-card">
<div class="sub-heading">Assets</div> <div class="sub-heading">Assets<span class="help-tip" data-tip="Every work package is built around one or more assets. Search the Micron DB by asset ID, paste a column of IDs straight from Excel, or load a CSV. IDs found in the Micron DB are tagged as such; the rest are added as manual rows. The Micron DB is read-only here — picking an asset never changes it.">i</span></div>
<div class="notice">Every work package is based on one or more assets managed in <strong>controls.dev</strong>. Paste the controls.dev link for each asset this package covers. <span style="color:var(--text-dim)">A direct integration to pick assets from a list is planned — for now, link them manually.</span></div> <div class="notice">Every work package is based on one or more assets from the <strong>Micron DB</strong>. Search by asset ID, or paste a column of IDs straight from Excel, to add each asset this package covers. <span style="color:var(--text-dim)">The Micron DB is read-only — nothing you do here changes it.</span></div>
<div class="table-wrap"><table><thead><tr><th style="width:200px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link <span class="req">*</span></th><th style="width:44px"></th></tr></thead><tbody id="asset-body"></tbody></table></div> <div class="asset-pick" id="asset-pick">
<button class="add-btn" onclick="addAsset()">+ Add asset</button> <input type="search" class="asset-search" id="asset-search" autocomplete="off"
placeholder="Search asset IDs, or paste a column from Excel…"
aria-label="Search the Micron DB by asset ID" aria-controls="asset-results" aria-expanded="false">
<div class="asset-results" id="asset-results" hidden></div>
</div>
<!-- role=status: loading -> ready/absent/error announces (the login.html pattern) -->
<div class="field-hint" id="asset-source-note" role="status"></div>
<div class="table-wrap"><table><thead><tr><th style="width:260px">Asset ID</th><th>Note <span style="font-weight:400;color:var(--text-dim)">(what this asset is / why it's in scope)</span></th><th style="width:44px"></th></tr></thead><tbody id="asset-body"></tbody></table></div>
<div class="material-actions">
<button class="add-btn" onclick="addManualAsset()" title="Add an asset that is not in the Micron DB yet">+ Add asset not in the Micron DB</button>
<button class="add-btn" onclick="document.getElementById('asset-import').click()" title="Load a list of asset IDs from a CSV. IDs found in the Micron DB are tagged as such; the rest are added as manual rows.">⤒ Load from CSV</button>
<input type="file" id="asset-import" accept=".csv,text/csv" style="display:none" onchange="importAssets(event)">
</div>
</div> </div>
<!-- DISCIPLINES --> <!-- DISCIPLINES -->
@@ -440,12 +452,12 @@
<div class="sub-heading">Quality, Inspection & Hold Points</div> <div class="sub-heading">Quality, Inspection & Hold Points</div>
<div class="field-grid"> <div class="field-grid">
<div class="field"><label>QC required</label><input type="text" id="wp_qc" placeholder="from SOP" readonly> <div class="field"><label>QC required</label><input type="text" id="wp_qc" placeholder="from SOP" readonly>
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_qc')">🔒 Edit (reason required)</button><span class="override-note"></span></div></div> <div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_qc')"> Edit (reason required)</button><span class="override-note"></span></div></div>
<div class="field"><label>Photo documentation</label><input type="text" id="wp_photo" placeholder="from SOP" readonly> <div class="field"><label>Photo documentation</label><input type="text" id="wp_photo" placeholder="from SOP" readonly>
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_photo')">🔒 Edit (reason required)</button><span class="override-note"></span></div></div> <div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_photo')"> Edit (reason required)</button><span class="override-note"></span></div></div>
</div> </div>
<div class="field field-grid col1"><div class="field"><label>Witness / hold points</label><textarea id="wp_hold" rows="2" readonly placeholder="from SOP"></textarea> <div class="field field-grid col1"><div class="field"><label>Witness / hold points</label><textarea id="wp_hold" rows="2" readonly placeholder="from SOP"></textarea>
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_hold')">🔒 Edit (reason required)</button><span class="override-note"></span></div> <div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_hold')"> Edit (reason required)</button><span class="override-note"></span></div>
<div class="field-hint">Inherited from the SOP. A <strong>Hold Point</strong> stops work until inspection sign-off; a <strong>Witness Point</strong> is offered for inspection but work may proceed if declined.</div></div></div> <div class="field-hint">Inherited from the SOP. A <strong>Hold Point</strong> stops work until inspection sign-off; a <strong>Witness Point</strong> is offered for inspection but work may proceed if declined.</div></div></div>
</div> </div>
@@ -476,7 +488,7 @@
<div class="nav-row"><button class="btn btn-ghost" onclick="newPackage()">↺ Clear</button> <div class="nav-row"><button class="btn btn-ghost" onclick="newPackage()">↺ Clear</button>
<div style="display:flex;gap:10px"> <div style="display:flex;gap:10px">
<button class="btn btn-ghost" onclick="savePackage(false)">Save draft</button> <button class="btn btn-ghost" onclick="savePackage(false)">Save draft</button>
<button class="btn btn-generate" onclick="savePackage(true)">Save &amp; view</button> <button class="btn btn-generate" onclick="savePackage(true)">Save &amp; view</button>
</div></div> </div></div>
<!-- DASHBOARD --> <!-- DASHBOARD -->
@@ -591,7 +603,9 @@
</div> </div>
<!-- COMMENTS DRAWER --> <!-- COMMENTS DRAWER -->
<div class="cmt-overlay" id="cmt-overlay" onclick="toggleComments()"></div> <!-- The backdrop is NOT a control (C1): pointer dismissal is attached in
cmtInit(), and Escape + the drawer's close button are the real paths. -->
<div class="cmt-overlay" id="cmt-overlay"></div>
<aside class="cmt-drawer" id="cmt-drawer" aria-hidden="true"> <aside class="cmt-drawer" id="cmt-drawer" aria-hidden="true">
<div class="cmt-head"><div class="cmt-title">Review Comments</div><button class="cmt-x" onclick="toggleComments()" title="Close"></button></div> <div class="cmt-head"><div class="cmt-title">Review Comments</div><button class="cmt-x" onclick="toggleComments()" title="Close"></button></div>
<div class="cmt-namebar"><label>Your name</label><input type="text" id="cmt-author" placeholder="e.g. J. Park" oninput="cmtSaveAuthor(this.value)"></div> <div class="cmt-namebar"><label>Your name</label><input type="text" id="cmt-author" placeholder="e.g. J. Park" oninput="cmtSaveAuthor(this.value)"></div>
@@ -614,7 +628,7 @@
<span class="sticky-status" id="sticky-status"></span> <span class="sticky-status" id="sticky-status"></span>
<div class="sticky-actions"> <div class="sticky-actions">
<button class="btn btn-ghost" onclick="savePackage(false)">Save draft</button> <button class="btn btn-ghost" onclick="savePackage(false)">Save draft</button>
<button class="btn btn-generate" onclick="savePackage(true)">Save &amp; view</button> <button class="btn btn-generate" onclick="savePackage(true)">Save &amp; view</button>
</div> </div>
</div> </div>

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); }
@@ -689,7 +689,9 @@
/* Dev mode (comment 7) */ /* Dev mode (comment 7) */
.logo-wrap { position:relative; display:flex; align-items:center; } .logo-wrap { position:relative; display:flex; align-items:center; }
.dev-toggle { position:absolute; left:2px; bottom:-9px; width:18px; height:7px; padding:0; border:none; /* C2/T9.6: a deliberately unobtrusive dev switch is still a control - it
meets the 24px floor and earns its subtlety with opacity, not size. */
.dev-toggle { position:absolute; left:2px; bottom:-12px; width:24px; height:24px; padding:0; border:none;
background:var(--text-dim); opacity:0.10; border-radius:3px; cursor:pointer; } background:var(--text-dim); opacity:0.10; border-radius:3px; cursor:pointer; }
.dev-toggle:hover { opacity:0.35; } .dev-toggle:hover { opacity:0.35; }
.dev-banner { background:var(--wp-dev-bg); color:var(--wp-dev-fg); font-size:12.5px; font-weight:700; text-align:center; padding:7px 14px; letter-spacing:.3px; } .dev-banner { background:var(--wp-dev-bg); color:var(--wp-dev-fg); font-size:12.5px; font-weight:700; text-align:center; padding:7px 14px; letter-spacing:.3px; }
@@ -756,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. */
.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 .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; }
@@ -906,6 +914,38 @@
border:1px solid var(--border); border-radius:3px; } border:1px solid var(--border); border-radius:3px; }
.pp-free .field-hint { margin-top:4px; } .pp-free .field-hint { margin-top:4px; }
/* ── asset picker (Micron asset catalog) ────────────────────────────────────
A search box over a read-only catalog. Results drop below the input and are
added to the table as rows; the catalog itself is never written to. */
.asset-pick { position:relative; margin-bottom:10px; }
.asset-search { width:100%; padding:8px 10px; font:inherit; font-size:13px;
border:1px solid var(--border-strong); border-radius:4px; background:var(--surface);
box-sizing:border-box; }
.asset-search:focus { outline:2px solid var(--accent); outline-offset:-2px; }
.asset-search:disabled { background:var(--surface2); color:var(--text-dim); cursor:not-allowed; }
.asset-results { position:absolute; top:calc(100% + 4px); left:0; right:0; z-index:60;
max-height:320px; overflow-y:auto; background:var(--surface);
border:1px solid var(--border-strong); border-radius:4px; padding:4px 0;
box-shadow:0 8px 24px rgba(20,30,50,.18); }
.asset-results[hidden] { display:none; }
.asset-result { display:flex; align-items:baseline; justify-content:space-between; gap:10px;
width:100%; text-align:left; background:none; border:0;
font:inherit; font-size:13px; padding:7px 12px; cursor:pointer; color:var(--text); }
.asset-result:hover:not(:disabled) { background:var(--surface2); }
.asset-result:disabled { cursor:default; opacity:.55; }
.asset-result-tag { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.asset-result-add { color:var(--accent); font-size:11.5px; font-weight:700; white-space:nowrap; }
.asset-result.is-added .asset-result-add { color:var(--text-dim); font-weight:400; }
.asset-result-note { padding:9px 12px; font-size:12.5px; color:var(--text-muted); }
/* Marks rows the catalog vouches for, so a manually typed asset is never
mistaken for a looked-up one. */
.asset-badge { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px;
font-size:10px; font-weight:700; letter-spacing:.02em; text-transform:uppercase;
color:var(--accent); background:var(--accent-dim); vertical-align:middle;
white-space:nowrap; } /* two words now — must not wrap under the asset ID */
.asset-tag { font-weight:600; }
.asset-empty { color:var(--text-dim); font-size:12.5px; font-style:italic; }
/* Critical constraint marker (from the SOP) */ /* Critical constraint marker (from the SOP) */
.crit-tag { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px; font-size:10px; .crit-tag { display:inline-block; margin-left:6px; padding:1px 7px; border-radius:10px; font-size:10px;
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim); font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);
@@ -1115,7 +1155,8 @@
.dash-metric[onclick] { cursor:pointer; transition:border-color .12s, box-shadow .12s; } .dash-metric[onclick] { cursor:pointer; transition:border-color .12s, box-shadow .12s; }
.dash-metric[onclick]:hover { border-color:var(--accent); } .dash-metric[onclick]:hover { border-color:var(--accent); }
.dash-metric.dm-active { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-dim); } .dash-metric.dm-active { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-dim); }
.dash-chip[onclick] { cursor:pointer; } /* Chips are buttons since T9.5; reset the button chrome, keep the chip look. */
button.dash-chip { font:inherit; font-size:12px; cursor:pointer; }
.dash-chip.chip-active { border-color:var(--accent); color:var(--accent); background:var(--accent-dim); } .dash-chip.chip-active { border-color:var(--accent); color:var(--accent); background:var(--accent-dim); }
.dash-breakdown { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; } .dash-breakdown { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; }
.dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; } .dash-bd-title { font-size:11px; font-weight:700; text-transform:uppercase; color:var(--text-muted); margin-bottom:6px; }

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

@@ -120,12 +120,12 @@
ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' + ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' +
'justify-content:center;z-index:10002;padding:20px;font:14px/1.45 "IBM Plex Sans",-apple-system,' + 'justify-content:center;z-index:10002;padding:20px;font:14px/1.45 "IBM Plex Sans",-apple-system,' +
'BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;'; 'BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
var fld = 'width:100%;padding:9px 10px;margin-bottom:4px;border:1px solid #8d8d8d;border-radius:4px;font-size:14px;background:#fff;'; var fld = 'width:100%;padding:9px 10px;margin-bottom:4px;border:1px solid var(--cds-border-strong);border-radius:4px;font-size:14px;background:var(--cds-layer);';
var lbl = 'display:block;font-size:12px;color:#525252;margin:14px 0 4px;font-weight:600;'; var lbl = 'display:block;font-size:12px;color:var(--cds-text-secondary);margin:14px 0 4px;font-weight:600;';
var hint = 'font-size:11.5px;color:#6f6f6f;margin-bottom:6px;'; var hint = 'font-size:11.5px;color:var(--cds-text-helper);margin-bottom:6px;';
ov.innerHTML = ov.innerHTML =
'<div style="background:#fff;color:#161616;border-radius:10px;max-width:460px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' + '<div style="background:var(--cds-layer);color:var(--cds-text-primary);border-radius:10px;max-width:460px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
'<div style="padding:14px 18px;border-bottom:1px solid #e0e0e0;font-weight:700;">Language &amp; time</div>' + '<div style="padding:14px 18px;border-bottom:1px solid var(--cds-border-subtle);font-weight:700;">Language &amp; time</div>' +
'<div style="padding:4px 18px 16px;">' + '<div style="padding:4px 18px 16px;">' +
'<div id="wp-prefs-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin:12px 0 0;"></div>' + '<div id="wp-prefs-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin:12px 0 0;"></div>' +
'<label style="' + lbl + '">Language &amp; number format</label>' + '<label style="' + lbl + '">Language &amp; number format</label>' +
@@ -135,19 +135,19 @@
'<select id="wp-prefs-tz" style="' + fld + '"></select>' + '<select id="wp-prefs-tz" style="' + fld + '"></select>' +
'<div style="' + hint + '">Times (MIMO windows, history, notifications) are shown in this zone. ' + '<div style="' + hint + '">Times (MIMO windows, history, notifications) are shown in this zone. ' +
'Calendar dates like a due date are never shifted.</div>' + 'Calendar dates like a due date are never shifted.</div>' +
'<div id="wp-prefs-preview" style="margin-top:14px;padding:10px 12px;background:#f4f4f4;border-radius:6px;font-size:12.5px;"></div>' + '<div id="wp-prefs-preview" style="margin-top:14px;padding:10px 12px;background:var(--cds-layer-accent);border-radius:6px;font-size:12.5px;"></div>' +
'</div>' + '</div>' +
'<div style="padding:12px 18px;border-top:1px solid #e0e0e0;display:flex;gap:8px;justify-content:flex-end;">' + '<div style="padding:12px 18px;border-top:1px solid var(--cds-border-subtle);display:flex;gap:8px;justify-content:flex-end;">' +
'<button type="button" id="wp-prefs-cancel" style="padding:8px 14px;border:1px solid #8d8d8d;background:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' + '<button type="button" id="wp-prefs-cancel" style="padding:8px 14px;border:1px solid var(--cds-border-strong);background:var(--cds-layer);border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
'<button type="button" id="wp-prefs-save" style="padding:8px 14px;border:none;background:#0f62fe;color:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Save</button>' + '<button type="button" id="wp-prefs-save" style="padding:8px 14px;border:none;background:var(--cds-interactive-01);color:var(--cds-text-on-color);border-radius:6px;cursor:pointer;font-weight:600;">Save</button>' +
'</div>' + '</div>' +
'</div>'; '</div>';
function close() { var m = document.getElementById('wp-prefs-modal'); if (m) m.remove(); } function close() { var m = document.getElementById('wp-prefs-modal'); if (m) m.remove(); }
function msg(text, ok) { function msg(text, ok) {
var e = document.getElementById('wp-prefs-msg'); var e = document.getElementById('wp-prefs-msg');
e.style.display = 'block'; e.textContent = text; e.style.display = 'block'; e.textContent = text;
e.style.background = ok ? '#defbe6' : '#fff1f1'; e.style.background = ok ? 'var(--wp-status-success-bg)' : 'var(--wp-status-error-bg)';
e.style.color = ok ? '#0e6027' : '#da1e28'; e.style.color = ok ? 'var(--wp-status-success-text)' : 'var(--cds-support-error)';
} }
ov.addEventListener('click', function (e) { if (e.target === ov) close(); }); ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
document.body.appendChild(ov); document.body.appendChild(ov);

View File

@@ -32,7 +32,7 @@
{ id: 'scope', label: 'Scope of Work', { id: 'scope', label: 'Scope of Work',
note: 'The ordered steps the crew performs, and the labour estimate.' }, note: 'The ordered steps the crew performs, and the labour estimate.' },
{ id: 'assets', label: 'Assets', { id: 'assets', label: 'Assets',
note: 'Asset tags and controls.dev links. Off for Micron EUV — the content duplicates the database Clintons team maintains (CR-016).' }, note: 'Asset IDs picked read-only from the Micron DB (D11), with manual entry for anything not listed. Off for Micron EUV — the customers own database stays the source of truth; this section only references it (CR-016).' },
{ id: 'materials', label: 'Materials', { id: 'materials', label: 'Materials',
note: 'The bill of materials that feeds kitting.' }, note: 'The bill of materials that feeds kitting.' },
{ id: 'kitting', label: 'Kitting', { id: 'kitting', label: 'Kitting',

View File

@@ -43,7 +43,7 @@
{ section: 'People' }, { section: 'People' },
{ href: 'users.html', match: /(^|\/)users\.html$/, icon: '☺', label: 'User Directory', { href: 'users.html', match: /(^|\/)users\.html$/, icon: '☺', label: 'User Directory',
sub: 'Who\'s on the project' }, sub: 'Who\'s on the project' },
{ href: 'admin.html', match: /(^|\/)admin\.html$/, icon: '', label: 'Admin Console', { href: 'admin.html', match: /(^|\/)admin\.html$/, icon: '', label: 'Admin Console',
sub: 'Settings & diagnostics', sub: 'Settings & diagnostics',
show: function () { return typeof window.wpIsAdmin === 'function' && window.wpIsAdmin(); } }, show: function () { return typeof window.wpIsAdmin === 'function' && window.wpIsAdmin(); } },
// Account actions, inherited from the flat user menu that used to sit in the app // Account actions, inherited from the flat user menu that used to sit in the app
@@ -51,7 +51,7 @@
// drawer already had; these two were its only unique contents, so they moved here // drawer already had; these two were its only unique contents, so they moved here
// rather than being lost with it. `action` items render as buttons, not links. // rather than being lost with it. `action` items render as buttons, not links.
{ section: 'Account' }, { section: 'Account' },
{ action: 'wpPreferences', icon: '', label: 'Language & time', { action: 'wpPreferences', icon: '', label: 'Language & time',
sub: 'Dates, numbers and time zone' }, sub: 'Dates, numbers and time zone' },
{ action: 'wpChangePassword', icon: '⚿', label: 'Password', sub: 'Change your password' }, { action: 'wpChangePassword', icon: '⚿', label: 'Password', sub: 'Change your password' },
]; ];

View File

@@ -30,3 +30,25 @@ AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
# notifications are marked "skipped", nothing is sent) until both the toggle is # notifications are marked "skipped", nothing is sent) until both the toggle is
# on and SMTP is configured. # on and SMTP is configured.
# SMTP_PASSWORD=your-smtp-app-password # SMTP_PASSWORD=your-smtp-app-password
# ── Micron asset catalog (optional) ───────────────────────────────────────────
# Backs the searchable asset picker in the work package creator. READ-ONLY: the
# app only ever runs the single SELECT in server/assets_db.py, so give it a
# db_datareader login and nothing more.
#
# Leave this unset and the suite works normally — the picker reports that no
# catalog is configured and people type asset tags in by hand.
#
# URL-encode special characters in the password (@ = %40, # = %23, / = %2F …).
# MICRON_DB_URL=mssql+pymssql://readonly_user:PASSWORD@sqlhost.example.com:1433/MicronDB
#
# To use pyodbc instead of pymssql you must also add pyodbc to requirements.txt
# and install the Microsoft ODBC driver in the image:
# MICRON_DB_URL=mssql+pyodbc://readonly_user:PASSWORD@sqlhost.example.com/MicronDB?driver=ODBC+Driver+18+for+SQL+Server
#
# Two things to check when the picker says the catalog is unreachable:
# 1. The table/column names in ASSET_QUERY (server/assets_db.py) match the real
# Micron schema — that one constant is the whole schema contract.
# 2. The api container is on the `outbound` network in docker-compose.yml. The
# `internal` network has no default gateway, which blocks the VPN as well as
# the internet.

View File

@@ -26,7 +26,7 @@ from sqlalchemy import select, delete, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .db import Base, engine, get_db from .db import Base, engine, get_db
from . import models, auth, notify from . import models, auth, notify, assets_db
# Schema management: # Schema management:
# • Local dev (SQLite) auto-creates tables for a zero-config run. # • Local dev (SQLite) auto-creates tables for a zero-config run.
@@ -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."
) )
@@ -1294,6 +1320,15 @@ def list_projects(
elif archived != "all": elif archived != "all":
stmt = stmt.where(models.Project.archived_at.is_(None)) # default: hide archived stmt = stmt.where(models.Project.archived_at.is_(None)) # default: hide archived
rows = db.scalars(stmt.order_by(models.Project.updated_at.desc())).all() rows = db.scalars(stmt.order_by(models.Project.updated_at.desc())).all()
# D7 / T9.8: archived projects are readable by PROJECT ADMINS only - anyone
# below that sees them nowhere, counts and pickers included. The default
# listing already excludes them; asking for them is what gets gated, and it
# is gated per project, so admin-on-Job-A does not surface archived Job B.
if archived != "exclude":
rows = [p for p in rows
if p.archived_at is None
or effective_role(db, user, p.id) in (
auth.ROLE_ADMIN, auth.ROLE_PROJECT_SUPER, auth.ROLE_PROJECT_ADMIN)]
return [p.summary() for p in rows] return [p.summary() for p in rows]
@@ -1588,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(
@@ -1643,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."
@@ -1663,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."
@@ -1696,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."
) )
@@ -1724,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"
@@ -1740,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"
@@ -3383,6 +3429,29 @@ def list_comments(
return [c.to_dict() for c in rows] return [c.to_dict() for c in rows]
# ── Micron asset catalog (read-only lookup) ────────────────────────────────────
# Backs the asset picker in the work package creator. This is a *lookup*, not a
# resource this app owns: there is no POST, and nothing here ever writes to the
# Micron database. It is deliberately not project-scoped by the app's own access
# rules — the catalog is reference data, and any signed-in user who can build a
# work package needs to be able to name the assets it covers. Authentication is
# still required (the auth_gate middleware covers every /api/ path).
@app.get("/api/assets")
def list_assets(_user: models.User = Depends(auth.get_current_user)):
"""The whole catalog, fetched once when the creator loads. Searching happens
in the browser — there is no per-keystroke endpoint by design."""
if not assets_db.configured():
# Not an error — the suite is designed to run without Micron wired up.
# The picker reads this and switches to manual entry.
return {"configured": False, "assets": [], "detail": assets_db.status()["detail"]}
try:
return {"configured": True, "assets": assets_db.load()}
except assets_db.AssetSourceError as exc:
# 503, not 500: the suite is healthy, its upstream lookup is not. The
# picker degrades to manual entry rather than blocking the package.
raise HTTPException(status_code=503, detail=str(exc))
# ── Local dev convenience: serve the static site from this app ────────────────── # ── Local dev convenience: serve the static site from this app ──────────────────
# In production NGINX serves html/ and only proxies /api/ here, so this app never # In production NGINX serves html/ and only proxies /api/ here, so this app never
# receives "/" requests, and the api Docker image doesn't even include html/ — so # receives "/" requests, and the api Docker image doesn't even include html/ — so

246
server/assets_db.py Normal file
View File

@@ -0,0 +1,246 @@
"""Read-only reader for the Micron asset catalog.
The work package creator used to ask people to paste a controls.dev link for
every asset. Assets actually live in the Micron database — a SQL Server instance
that is NOT part of this repo and whose schema is not managed here. This module
gives the API a *read-only* window onto it so the creator can offer a searchable
picker instead of free-text links.
How it works: the whole catalog is fetched in one query and handed to the browser
when the creator loads. Searching then happens in the browser with no round trip
at all. The catalog is a list of asset IDs — about 9k of them today and not
expected past 100k — so it is small enough to send whole, and it is slow-moving
reference data, so there is nothing to gain from querying it per keystroke and a
lot of latency to lose. A short server-side cache keeps a room full of people
opening the page from turning into a query each.
Other deliberate constraints:
* **Read-only, always.** The only statement in this file is the SELECT below.
Point it at a login with `db_datareader` and nothing else.
* **No prime_db dependency.** A plain SQLAlchemy connection built from a
connection string, kept separate from the app's own engine in `db.py`, so a
Micron outage can never affect the suite's own database.
Unconfigured is a first-class state: with no `MICRON_DB_URL` set, `configured()`
returns False, the API says so, and the UI falls back to manual entry. The suite
boots and runs fine without the Micron database being reachable.
"""
import os
import time
import logging
import threading
from sqlalchemy import create_engine, text
from sqlalchemy.exc import SQLAlchemyError
log = logging.getLogger(__name__)
try:
from dotenv import load_dotenv
load_dotenv()
except Exception:
pass
# ── The query ─────────────────────────────────────────────────────────────────
# The only place the Micron schema appears; everything else here is plumbing.
# Returns one row per asset, aliased `tag`. No row cap: the catalog is small
# enough to hand over whole, and a partial list would silently hide assets.
#
# Add a WHERE clause here if some rows should never be offered at all
# (decommissioned assets, other sites, …). Filtering at the source keeps the
# payload small, which matters more than anything else here.
ASSET_QUERY = """
SELECT a.AssetID AS tag
FROM Asset.Asset AS a
ORDER BY a.AssetID
"""
def _env_int(name: str, default: int) -> int:
"""A malformed tuning knob degrades to its default; it must never keep the
suite from booting. app.py imports this module unconditionally, so a bare
int() here would turn "300s" in someone's .env into a crash-looping API -
the total-outage switch an OPTIONAL feature is not allowed to own."""
raw = os.getenv(name, "")
try:
return int(raw.strip()) if raw.strip() else default
except ValueError:
log.warning("%s=%r is not an integer; using the default %d.", name, raw, default)
return default
# How long a fetched catalog is reused before the next page load re-queries.
CACHE_SECONDS = _env_int("MICRON_ASSETS_CACHE_SECONDS", 300) # 5 min
# How long a FAILURE is remembered before the next request retries the source.
# Without this, every page load during a Micron outage spends CONNECT_TIMEOUT
# seconds inside a worker thread; enough concurrent loads exhaust the app's
# shared sync threadpool and take unrelated endpoints down with the picker.
FAIL_CACHE_SECONDS = _env_int("MICRON_ASSETS_FAIL_CACHE_SECONDS", 30)
CONNECT_TIMEOUT = 8
class AssetSourceError(RuntimeError):
"""The catalog is configured but could not be read."""
# ── Engine (lazy, process-wide) ───────────────────────────────────────────────
# A full SQLAlchemy URL, e.g.
# mssql+pymssql://user:pass@host:1433/MicronDB
# mssql+pyodbc://user:pass@host/MicronDB?driver=ODBC+Driver+18+for+SQL+Server
# URL-encode any special characters in the password.
_engine = None
_engine_lock = threading.Lock()
def _db_url() -> str:
return os.getenv("MICRON_DB_URL", "").strip()
def configured() -> bool:
return bool(_db_url())
def _validate_url(url: str) -> None:
"""Catch the one URL mistake that produces a baffling error message.
A password containing an unencoded '@' makes the URL ambiguous: the parser
splits on the first '@', so part of the password ends up parsed as the host.
The driver then reports a connection failure against a nonsense hostname that
happens to contain a fragment of the password — confusing to read and unsafe
to display. Detect it here and say plainly what is wrong.
Nothing from the URL is included in the message; it never leaves this process.
"""
authority = url.split("://", 1)[-1].split("/", 1)[0]
if authority.count("@") > 1:
raise AssetSourceError(
"MICRON_DB_URL is ambiguous: the username or password contains an "
"unencoded '@'. Percent-encode the special characters — @ = %40, "
": = %3A, / = %2F, # = %23, ? = %3F, % = %25."
)
def _connect_args(url: str) -> dict:
"""Per-driver connect timeouts, so an unreachable Micron host fails fast
instead of tying up a worker until the OS gives up."""
if url.startswith("mssql+pymssql"):
return {"login_timeout": CONNECT_TIMEOUT, "timeout": CONNECT_TIMEOUT}
if url.startswith("mssql+pyodbc"):
return {"timeout": CONNECT_TIMEOUT}
return {}
def _get_engine():
global _engine
if _engine is not None:
return _engine
url = _db_url()
if not url:
raise AssetSourceError("The Micron DB is not configured.")
_validate_url(url)
with _engine_lock:
if _engine is None:
try:
_engine = create_engine(
url,
connect_args=_connect_args(url),
pool_pre_ping=True, # a recycled dead connection retries instead of erroring
pool_recycle=1800,
pool_size=1, # one catalog query now and then, not a workload
max_overflow=1,
future=True,
)
except Exception as exc: # bad URL, missing driver package, …
# See the note on load() — the exception text can echo the
# connection string, so it is logged and not propagated.
log.error("Micron asset catalog: could not open the connection: %s", exc)
raise AssetSourceError(
"Could not open a connection to the Micron DB. "
"Check MICRON_DB_URL and the API log for the driver error."
) from exc
return _engine
# ── Cache ─────────────────────────────────────────────────────────────────────
# Every page load asks for the whole catalog, so without this a shift change
# would be one full-table query per person. Held per worker process.
_cache: list[dict] | None = None
_cached_at = 0.0
_error: str | None = None # negative cache: the last failure's user-safe text
_error_at = 0.0
_cache_lock = threading.Lock()
def load(force: bool = False) -> list[dict]:
"""Return the whole catalog as [{'tag': …}, …]. Never writes.
Failures are handled in two tiers so a Micron outage stays the picker's
problem and never the suite's (the module contract above):
* a previously fetched catalog is served STALE - it is slow-moving
reference data, and old-but-real beats an error;
* with nothing to serve, the failure itself is cached for
FAIL_CACHE_SECONDS, so an outage costs one CONNECT_TIMEOUT per window
instead of one per page load stacking up in the shared threadpool."""
global _cache, _cached_at, _error, _error_at
with _cache_lock:
if _cache is not None and not force and (time.monotonic() - _cached_at) < CACHE_SECONDS:
return _cache
if (_error is not None and not force
and (time.monotonic() - _error_at) < FAIL_CACHE_SECONDS
and _cache is None):
raise AssetSourceError(_error)
try:
engine = _get_engine()
with engine.connect() as conn:
result = conn.execute(text(ASSET_QUERY)).mappings().all()
except AssetSourceError as exc:
# _get_engine already logged and sanitised; remember or stale-serve.
with _cache_lock:
if _cache is not None:
log.warning("Micron asset catalog unavailable; serving the cached "
"catalog (%d rows).", len(_cache))
return _cache
_error, _error_at = str(exc), time.monotonic()
raise
except SQLAlchemyError as exc:
# The driver's message is NOT propagated. AssetSourceError text reaches the
# browser, and connection errors quote the host, the login, and — when the
# URL is malformed — fragments of the password. Operators get the detail
# from the API log, where it belongs; users get a message they can act on.
log.error("Micron asset catalog query failed: %s", exc)
msg = ("The Micron DB could not be read. Check that the host is "
"reachable, that the login has SELECT on the asset table, and that "
"ASSET_QUERY matches the real schema — the API log has the driver error.")
with _cache_lock:
if _cache is not None:
log.warning("Micron asset catalog unavailable; serving the cached "
"catalog (%d rows).", len(_cache))
return _cache
_error, _error_at = msg, time.monotonic()
raise AssetSourceError(msg) from exc
# Drop rows with no identifier — an asset with no tag is not selectable and
# would render as a blank line in the picker.
rows = [{"tag": str(r["tag"])} for r in result if r.get("tag") not in (None, "")]
with _cache_lock:
_cache, _cached_at = rows, time.monotonic()
_error = None
return rows
def status() -> dict:
"""Describe the source for the UI, so it can explain itself rather than just
showing an empty dropdown."""
if not configured():
return {
"configured": False, "ok": False, "count": 0,
"detail": "The Micron DB is not configured — enter assets manually.",
}
try:
rows = load()
except AssetSourceError as exc:
return {"configured": True, "ok": False, "count": 0, "detail": str(exc)}
return {"configured": True, "ok": True, "count": len(rows),
"detail": f"{len(rows):,} asset IDs from the Micron DB."}

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

@@ -9,6 +9,12 @@ gunicorn==26.0.0
sqlalchemy==2.0.51 sqlalchemy==2.0.51
alembic==1.18.5 # database migrations alembic==1.18.5 # database migrations
psycopg[binary]==3.3.4 psycopg[binary]==3.3.4
pymssql==2.3.13 # read-only lookups against the Micron asset DB (SQL Server).
# Chosen over pyodbc because it ships self-contained wheels —
# pyodbc would also need msodbcsql18 + unixODBC installed in
# the image. To use pyodbc instead, add it here, install the
# Microsoft ODBC driver in the Dockerfile, and switch
# MICRON_DB_URL to mssql+pyodbc://…?driver=ODBC+Driver+18+for+SQL+Server
pydantic==2.13.4 pydantic==2.13.4
python-dotenv==1.2.2 python-dotenv==1.2.2
bcrypt==5.0.0 # password hashing bcrypt==5.0.0 # password hashing

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("""(() => {

181
tests/archived_check.py Normal file
View File

@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""Can a project admin get back into an archived project — and only they? — D7, T9.8.
Archiving read as deletion because there was no way back in. Now: a separate,
labelled, read-only list on the launcher for project admins; the server filters
the answer by per-project role, refuses every write regardless of what the
browser sends, and shows archived projects to nobody else anywhere - counts and
pickers included.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
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 sections_check import set_sop # noqa: E402
from stepper_check import dismiss_dialogs # noqa: E402
from qa_gate_check import api # noqa: E402
def ascii_(v, n=280):
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
def settle(seconds=0.5):
time.sleep(seconds)
def archive_projB(db_path):
from server.db import SessionLocal
from server import models
with SessionLocal() as db:
proj = db.get(models.Project, "projB")
proj.archived_at = models.utcnow()
db.commit()
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
tmpdir = tempfile.mkdtemp(prefix="wpsuite-arch-")
db_path = os.path.join(tmpdir, "check.db")
server = None
browser = None
try:
tok = seed(db_path)
set_sop(db_path, {})
archive_projB(db_path)
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
root, bob, pat = tok["root"], tok["bob"], tok["pat"]
# ── 1. who sees what ──────────────────────────────────────────────────
print("\n1. visibility, by role")
_, rows = api(base, "/api/projects", root)
chk("the default list hides archived projects from EVERYONE, admin included",
all(p["id"] != "projB" for p in rows), ascii_([p["id"] for p in rows]))
_, rows = api(base, "/api/projects?archived=only", root)
chk("an admin asking for the archived list gets it",
[p["id"] for p in rows] == ["projB"], ascii_(rows))
_, rows = api(base, "/api/projects?archived=only", bob)
chk("a plain project user ON that project gets an empty list - no leak",
rows == [], ascii_(rows))
_, rows = api(base, "/api/projects?archived=all", bob)
chk("...and cannot smuggle it through archived=all either",
all(p["id"] != "projB" for p in rows), ascii_(rows))
_, rows = api(base, "/api/projects?archived=only", pat)
chk("a user with no access to it sees nothing, same as before",
rows == [], ascii_(rows))
# ── 2. the server refuses writes regardless of the browser ───────────
print("\n2. frozen means frozen")
code, out = api(base, "/api/wps", root, "POST", {
"id": "wpArch1", "project_id": "projB", "number": "AR-1",
"subject": "write into the archive", "status": "Draft",
"data": {"constraints": []}})
chk("a direct write to an archived project is refused, even for an admin",
code in (403, 409) and "archived" in str(out).lower(), ascii_((code, out)))
code, _ = api(base, "/api/projects/projB/materials", root, "POST",
{"description": "Sample sneak", "unit": "EA"})
chk("...and so is every other write route (material list)", code in (403, 409), code)
code, wps = api(base, "/api/wps?project_id=projB", root)
chk("reading it still works - archived is readable, not gone",
code == 200, code)
# ── 3. the launcher, both roles, at 390px ─────────────────────────────
print("\n3. the launcher")
browser = cdp.Browser(exe)
page = browser.page()
page.clear_cookies()
page.set_cookie("wp_session", root)
page.viewport(390, 844, mobile=True)
page.goto(base + "/index.html")
dismiss_dialogs(page)
settle(2.5)
sec = json.loads(page.eval("""JSON.stringify((() => {
const s = document.getElementById('archived-projects');
return {hidden: !s || s.hidden,
text: s ? s.textContent : '',
buttons: s ? s.querySelectorAll('button').length : 0};
})())"""))
chk("a project admin sees the archived list, separate and labelled",
not sec["hidden"] and "Archived projects" in sec["text"]
and "read-only" in sec["text"].lower() and sec["buttons"] == 1, ascii_(sec))
chk("...and it fits at 390px", page.eval(
"document.getElementById('archived-projects').scrollWidth <= 392"))
page.eval("document.querySelector('[data-open-archived]').click()")
settle(2.0)
chk("opening one makes it the active project",
page.eval("(ProjectData.getActive()||{}).id") == "projB")
# the creator's read-only courtesy on top of the server's rule
page.goto(base + "/wp-creation-index.html?project=projB")
dismiss_dialogs(page)
settle(2.5)
page.eval("window.alert=()=>{}; window.confirm=()=>false; window.prompt=()=>null;")
chk("the creator says ARCHIVED where the project is named",
"ARCHIVED" in page.eval(
"(document.getElementById('ctx-bar')||{textContent:''}).textContent"))
page.eval("document.getElementById('wp_subject').value='x'")
page.eval("document.getElementById('wp_type').value='Conduit Install'")
n0 = page.eval("savedPackages.length")
page.eval("void savePackage(false)")
settle(0.8)
chk("saving is refused with a reason, before the round trip",
page.eval("savedPackages.length") == n0
and "archived" in page.eval(
"(document.getElementById('toast')||{textContent:''}).textContent").lower())
# a NON-admin's launcher shows no archived section at all
page.clear_cookies()
page.set_cookie("wp_session", bob)
page.goto(base + "/index.html")
dismiss_dialogs(page)
settle(2.5)
chk("a non-admin's launcher never shows the section",
page.eval("(() => { const s=document.getElementById('archived-projects');"
" return !s || s.hidden; })()"))
# projB has no SOP, and GET /api/sops/latest answering 404 for it is the
# correct answer, not an error - the seed fixture documents exactly this
# false alarm.
js_errors = [e for e in page.js_errors()
if "beforeunload" not in e and "sops/latest" not in e]
chk("no JavaScript errors anywhere in this run", not js_errors,
ascii_(js_errors[:2]))
finally:
if browser is not None:
try:
browser.close()
except Exception:
pass
if server is not None:
try:
server.terminate()
except Exception:
pass
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())

263
tests/assets_check.py Normal file
View File

@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""Is the Micron asset picker read-only, and does it degrade to manual entry? — D11.
Cody Schaefer's `origin/Micron-Assets` branch, merged Aug 20 2026 and adapted to
the R2 creator (decisions-2026-08-20.md). The properties this pins:
* **Read-only, structurally.** assets_db.py holds one SELECT and nothing else;
/api/assets has no writing verb. Picking an asset can never change Micron.
* **Unconfigured is a first-class state.** No MICRON_DB_URL -> configured:false,
the picker says so, and manual entry carries the package. The suite must run
without Micron existing at all — every other probe implicitly relies on that.
* **Broken is not a leak.** A configured-but-unusable URL 503s with a message
that never echoes the connection string (whose parse errors can quote
password fragments).
* **The client honours the catalog.** Search ranks exact matches first, a
picked row is locked to the DB's own casing and badged, imports canonicalise
casing / fall back to manual / skip duplicates, and the import summary goes
through the T7.9 dialog kit, not a native alert().
Boots its own throwaway SQLite + uvicorn + headless browser; run it alone, not
back to back with other probes. Exit 0 all passed, 1 a failure, 2 could not run.
"""
import io
import json
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 sections_check import set_sop # noqa: E402
from stepper_check import dismiss_dialogs # 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")
SERVER = os.path.join(ROOT, "server")
def ascii_(v, n=240):
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
def wait_creator(page, tries=40):
for _ in range(tries):
if page.eval("!!window.wpCreatorReady"):
return True
time.sleep(0.3)
return False
def strip_py(src):
src = re.sub(r'""".*?"""', "", src, flags=re.S)
return "\n".join(re.sub(r"#.*$", "", ln) for ln in src.split("\n"))
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
# ── 1. read-only, structurally ─────────────────────────────────────────────
print("\n1. read-only, structurally")
src = strip_py(io.open(os.path.join(SERVER, "assets_db.py"), encoding="utf-8").read())
verbs = re.findall(r"\b(INSERT|UPDATE|DELETE|MERGE|EXEC|TRUNCATE|DROP|ALTER)\b",
src, re.I)
chk("assets_db.py contains no writing SQL verb", not verbs, verbs)
chk("...and exactly one SELECT (the whole schema contract)",
len(re.findall(r"\bSELECT\b", src, re.I)) == 1)
app_src = io.open(os.path.join(SERVER, "app.py"), encoding="utf-8").read()
chk("/api/assets is a GET and only a GET",
len(re.findall(r'@app\.get\("/api/assets"\)', app_src)) == 1
and not re.findall(r'@app\.(post|put|patch|delete)\("/api/assets', app_src))
outside = [f for f in ("models.py", "auth.py", "notify.py")
if "MICRON_DB_URL" in io.open(os.path.join(SERVER, f), encoding="utf-8").read()]
chk("the connection string is env-only plumbing, not model or auth state",
not outside, outside)
tmpdir = tempfile.mkdtemp(prefix="wpsuite-assets-")
db_path = os.path.join(tmpdir, "check.db")
server = None
browser = None
try:
tok = seed(db_path)
set_sop(db_path, {})
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
# ── 2. the API's unconfigured state ────────────────────────────────────
print("\n2. unconfigured is a first-class state")
st, _ = api(base, "/api/assets", "not-a-session")
chk("anonymous gets 401, same as every other /api/ path", st == 401, st)
st, body = api(base, "/api/assets", tok["root"])
chk("signed in, no MICRON_DB_URL: 200 with configured:false",
st == 200 and body and body.get("configured") is False
and body.get("assets") == [], ascii_(body))
chk("...and the detail tells the user what to do instead",
"manual" in (body.get("detail") or "").lower(), ascii_(body))
# ── 3. the picker, catalog absent ──────────────────────────────────────
print("\n3. the picker degrades to manual entry")
browser = cdp.Browser(exe)
page = browser.page()
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.viewport(1440, 900)
page.goto(base + "/wp-creation-index.html?project=projA")
dismiss_dialogs(page)
chk("the creator boots", wait_creator(page))
time.sleep(1.2)
chk("the search box is disabled and says the catalog is not configured",
page.eval("(() => { const b=document.getElementById('asset-search');"
" return b.disabled && /not configured/i.test(b.placeholder); })()"))
chk("the source note announces it (role=status, non-empty)",
page.eval("(() => { const n=document.getElementById('asset-source-note');"
" return n.getAttribute('role')==='status' && n.textContent.length>0; })()"))
chk("no assets yet: the empty state renders instead of a blank table",
page.eval("/No assets yet/.test(document.getElementById('asset-body').textContent)"))
page.eval("addManualAsset()")
chk("+ Add asset adds an editable manual row",
page.eval("pkgAssets.length") == 1
and page.eval("pkgAssets[0].source") == "manual"
and page.eval("!!document.querySelector('#asset-body input')"))
page.eval("document.querySelector('#asset-body input').value='HAND-01';"
"document.querySelector('#asset-body input')"
".dispatchEvent(new Event('input',{bubbles:true}))")
chk("...and typing lands in the model", page.eval("pkgAssets[0].tag") == "HAND-01")
# ── 4. the client honours the catalog (injected; no SQL Server here) ──
print("\n4. search, pick, import — against an injected catalog")
page.eval("pkgAssets=[]; buildAssets();"
"assetCatalog=['AHU-2P-014','AHU-2P-015','PUMP-01','XPUMP-PUMP-011','CT-100'];"
"assetCatalogIndex=new Map(assetCatalog.map(t=>[t.toLowerCase(),t]));"
"assetCatalogState='ready';"
"(() => { const b=document.getElementById('asset-search');"
" b.disabled=false; b.placeholder='Search asset IDs'; })()")
page.eval("runAssetSearch('pump-01')")
chk("an exact match outranks a longer contains-match",
page.eval("JSON.stringify(assetResults)") == '["PUMP-01","XPUMP-PUMP-011"]',
ascii_(page.eval("JSON.stringify(assetResults)")))
chk("results render as real <button>s, none disabled yet",
page.eval("(() => { const r=[...document.querySelectorAll('#asset-results button.asset-result')];"
" return r.length===2 && r.every(b=>!b.disabled); })()"))
page.eval("addCatalogAsset(0)")
chk("the pick is announced (role=status toast) - a keyboard pick is otherwise silent",
page.eval("(() => { const t=document.getElementById('toast');"
" return !!t && t.getAttribute('role')==='status'"
" && /Added PUMP-01/.test(t.textContent); })()"))
chk("picking adds a catalog row: locked ID (no input), badge, source:'catalog'",
page.eval("pkgAssets.length") == 1
and page.eval("pkgAssets[0].source") == "catalog"
and page.eval("(() => { const tr=document.querySelector('#asset-body tr');"
" return !!tr.querySelector('.asset-badge')"
" && !tr.cells[0].querySelector('input'); })()"))
n0 = page.eval("pkgAssets.length")
page.eval("addCatalogAsset(0)")
chk("picking it again is refused (already on the package)",
page.eval("pkgAssets.length") == n0)
chk("normaliseAsset: no source means manual; an unknown source means manual",
page.eval("normaliseAsset({tag:'X'}).source") == "manual"
and page.eval("normaliseAsset({tag:'X',source:'evil'}).source") == "manual"
and page.eval("normaliseAsset({tag:'X',source:'catalog'}).source") == "catalog")
page.eval("void applyImportedAssets([['asset id'],['ahu-2p-015'],['NOT-IN-DB'],['AHU-2P-015']])")
time.sleep(0.4)
got = json.loads(page.eval(
"JSON.stringify(pkgAssets.map(a=>({t:a.tag,s:a.source})))"))
chk("import: a hit is canonicalised to the DB's own casing and badged catalog",
{"t": "AHU-2P-015", "s": "catalog"} in got, ascii_(got))
chk("...a miss is kept, visibly manual — not silently dropped",
{"t": "NOT-IN-DB", "s": "manual"} in got, ascii_(got))
chk("...the in-file duplicate is skipped (3 rows total: pick + hit + miss)",
len(got) == 3, ascii_(got))
chk("...and the summary is the T7.9 dialog, not a native alert()",
page.eval("document.getElementById('wp-dialog').classList.contains('open')")
and page.eval("document.getElementById('wp-dialog-cancel').style.display") == "none")
page.eval("wpDialogOk()")
page.eval("(() => { const b=document.getElementById('asset-search');"
" b.value='ct-1'; runAssetSearch(b.value);"
" b.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true})); })()")
chk("Enter takes the first result not already on the package",
page.eval("pkgAssets[pkgAssets.length-1].tag") == "CT-100")
# removal reopens the row for re-adding
page.eval("runAssetSearch('ct-100')")
chk("a just-added result reads 'added' and is disabled",
page.eval("(() => { const b=document.querySelector('#asset-results button');"
" return b.disabled && /added/.test(b.textContent); })()"))
page.eval("removeAsset(pkgAssets.length-1)")
chk("removing the asset makes it addable again",
page.eval("(() => { const b=document.querySelector('#asset-results button');"
" return !b.disabled && /add/.test(b.textContent); })()"))
# The tier-cap regression (review finding, fixed same day): 600
# alphabetically-early contains-matches must not evict a prefix match
# that sorts after every one of them. Before the fix the scan broke at
# a COMBINED 500 and Enter added the wrong asset, ID-locked.
got = json.loads(page.eval(
"(() => { const c=[];"
" for(let i=0;i<600;i++) c.push('A'+String(i).padStart(4,'0')+'-PMP-10');"
" c.push('PMP-10-EXTRA');"
" assetCatalog=c; assetCatalogIndex=new Map(c.map(t=>[t.toLowerCase(),t]));"
" assetCatalogState='ready'; runAssetSearch('pmp-10');"
" return JSON.stringify([assetResults[0], assetResults.length]); })()"))
chk("a prefix match outranks 600 earlier contains-matches (cap is per tier)",
got[0] == "PMP-10-EXTRA" and got[1] == 500, ascii_(got))
# ── 5. configured-but-broken: a 503 that does not leak ────────────────
print("\n5. broken is not a leak")
browser.close()
browser = None
server.terminate()
server.wait(timeout=10)
os.environ["MICRON_DB_URL"] = "mssql+pymssql://user:S3CRETpw@127.0.0.1:1/MicronDB"
# A malformed tuning knob must degrade, not crash the boot (review
# finding: int() at import time made "5m" a total-outage switch).
os.environ["MICRON_ASSETS_CACHE_SECONDS"] = "5m"
try:
port2 = cdp.free_port()
base2 = "http://127.0.0.1:%d" % port2
server = start_server(port2, db_path)
chk("the suite boots with MICRON_ASSETS_CACHE_SECONDS='5m' (degrades, no crash)",
server is not None and server.poll() is None)
st, body = api(base2, "/api/assets", tok["root"])
detail = (body or {}).get("detail") or ""
chk("a configured-but-unusable catalog answers 503, not 500",
st == 503, (st, ascii_(body)))
chk("...and the message never echoes the URL, login or password",
"S3CRETpw" not in detail and "user" not in detail
and "127.0.0.1:1" not in detail, ascii_(detail))
# The negative cache (review finding): the second request inside the
# failure window must answer from the remembered error - same 503,
# same safe text - not stack another connect attempt in a worker.
st2, body2 = api(base2, "/api/assets", tok["root"])
chk("...and a second request answers the cached failure, stable and safe",
st2 == 503 and (body2 or {}).get("detail") == detail,
(st2, ascii_(body2)))
finally:
del os.environ["MICRON_DB_URL"]
del os.environ["MICRON_ASSETS_CACHE_SECONDS"]
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

@@ -178,6 +178,24 @@ def main():
print(f"\n {len(pages)} page(s) x {len(widths)} width(s) -> {args.out}\n") print(f"\n {len(pages)} page(s) x {len(widths)} width(s) -> {args.out}\n")
browser = cdp.Browser() browser = cdp.Browser()
page = browser.page() page = browser.page()
# BL-012 (fixed at T9.9): admin's captured height varied ~600px between
# runs and the creator at 1440px shifted, because live timestamps and
# relative times re-render per run. Freezing Date (and Math.random) in
# every new document makes a capture comparable with the last one.
page.ws.call("Page.addScriptToEvaluateOnNewDocument", {"source": (
"(function(){"
"var FIXED = 1755600000000;" # 2026-08-19T10:40Z
"var RealDate = Date;"
"function FrozenDate(){ return new RealDate(FIXED); }"
"FrozenDate.now = function(){ return FIXED; };"
"FrozenDate.parse = RealDate.parse; FrozenDate.UTC = RealDate.UTC;"
"FrozenDate.prototype = RealDate.prototype;"
"window.Date = FrozenDate;"
"var seed = 42;"
"Math.random = function(){ seed = (seed * 9301 + 49297) % 233280;"
" return seed / 233280; };"
"})();"
)})
for name, filename, user, wait_for in pages: for name, filename, user, wait_for in pages:
capture(page, base, tok, name, filename, user, wait_for, capture(page, base, tok, name, filename, user, wait_for,
widths, args.out, args.label) widths, args.out, args.label)

View File

@@ -109,9 +109,34 @@ def seed(db_path):
# Job A gets a complete SOP and two packages. Without a SOP the field view's # Job A gets a complete SOP and two packages. Without a SOP the field view's
# GET /api/sops/latest correctly answers 404 ("No SOP found") and the browser # GET /api/sops/latest correctly answers 404 ("No SOP found") and the browser
# logs it as an error — a false alarm in a page-boot check. # logs it as an error — a false alarm in a page-boot check.
# BL-018 (fixed at T9.9): the production shape is {sop, state}, as
# ProjectData.pushSOP writes it. The old {"governance": ...} blob was a
# shape no code path produces, and it sent four probes' creators to the
# SOP gate until each imported set_sop() to overwrite it.
db.add(models.Sop(id="sopA", project_id="projA", name="Job A SOP", number="A-1", db.add(models.Sop(id="sopA", project_id="projA", name="Job A SOP", number="A-1",
complete=True, complete=True,
data={"governance": {"disciplines": ["Mechanical", "Electrical"]}})) data={"sop": {"meta": {"tool": "Work Package Configuration", "sample": False},
"project": {"name": "Job A", "number": "A-1", "client": "Internal QA"},
"governance": {"disciplines": ["Mechanical", "Electrical"],
"woFormat": "WP##-[TYPE]"},
"woTypes": [{"name": "Conduit Install", "enabled": True}],
"sections": {}},
"state": {"project": {"name": "Job A", "number": "A-1", "client": "Internal QA",
"division": "Internal", "site": "QA Lab"},
"team": {"pm": "", "apm": "", "cm": "", "qm": ""},
"teamIds": {"pm": "", "apm": "", "cm": "", "qm": ""},
"teamMembers": [], "sections": {},
"signoffRoles": [{"role": "Superintendent", "name": ""},
{"role": "Foreman", "name": ""}],
"wpTypes": [{"name": "Conduit Install", "enabled": True}],
"governance": {"woformat": "WP##-[TYPE]", "wosize": "", "issuance": [],
"disciplines": ["Mechanical", "Electrical"],
"discMode": "choice", "instanceSuffix": "letter",
"sizeHoursMax": ""},
"quality": {"qcreq": "Yes", "photo": "", "hold": ""},
"platforms": {"tracking": "CxAlloy", "commissioning": "CxAlloy",
"trackingUrl": "", "commissioningUrl": ""},
"constraints": [], "sequence": [], "sources": []}}))
db.flush() db.flush()
for wid, num, subj, status in (("wpA1", "WP01-COND", "1P horn/strobe conduit", "Issued"), for wid, num, subj, status in (("wpA1", "WP01-COND", "1P horn/strobe conduit", "Issued"),
("wpA2", "WP02-WIRE", "1P wire pull", "In Progress")): ("wpA2", "WP02-WIRE", "1P wire pull", "In Progress")):

110
tests/color_check.py Normal file
View File

@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Is theme-light.css the only place a colour exists? — C4, T9.9.
The token rule, finally enforceable everywhere: after this sweep no colour
literal survives outside theme-light.css - not in page stylesheets, not in the
help centre's injected styles (BL-004), not in the JS-built dialogs (BL-005),
not in the print popup. One accent blue (BL-008 - the second brand blue is
gone, .sop-inherited tints with THE blue) and one warning amber (BL-009 - the
alt token is deleted). Comments are stripped first: quoting a hex while
explaining it is not declaring one (the BL-017 lesson).
The exceptions, in full: <meta name="theme-color"> (a meta attribute cannot
resolve a CSS var), and rgba() shadow/overlay alphas, which are opacity
recipes, not palette entries.
Static sweep - no browser needed. Exit 0 all passed, 1 a failure.
"""
import io
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from browser_check import chk, _PASS, _FAIL # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HTML = os.path.join(ROOT, "html")
def strip_comments(src, is_css):
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
if not is_css:
src = "\n".join(re.sub(r"(?<![:'\"])//.*$", "", ln) for ln in src.split("\n"))
src = re.sub(r"<!--.*?-->", "", src, flags=re.S)
return src
def main():
print("\n1. hex literals outside theme-light.css")
offenders = []
for name in sorted(os.listdir(HTML)):
if not name.endswith((".js", ".html", ".css")) or name == "theme-light.css":
continue
src = strip_comments(io.open(os.path.join(HTML, name), encoding="utf-8").read(),
name.endswith(".css"))
# the one exception: the browser-chrome hint, which cannot use var()
src = re.sub(r'<meta name="theme-color" content="#[0-9a-fA-F]{6}"\s*/?>', "", src)
for m in re.finditer(r"#[0-9a-fA-F]{3}\b|#[0-9a-fA-F]{6}\b", src):
offenders.append("%s: %s" % (name, m.group(0)))
chk("no hex colour literal outside theme-light.css; grep confirms",
not offenders, offenders[:8])
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.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 = []
for name in sorted(os.listdir(HTML)):
if name == "theme-light.css" or not name.endswith((".js", ".css", ".html")):
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.lower()
or "37,99,214" in src.replace(" ", "")):
others.append(name)
chk("...and no consumer still references either", not others, others)
print("\n3. every token consumed is a token defined")
# The bug this pins: help.js (and six other files) shipped consuming
# --cds-layer-01/-02 and --cds-border-subtle-01/-strong-01 - names the theme
# never defined (its names carry no -01 suffix). An undefined var() makes
# the whole declaration invalid, so the help centre modal, the password and
# language dialogs, and the print popup all rendered TRANSPARENT
# backgrounds. Found by the user, 2026-08-20. Definitions are collected
# from every file (page aliases are legal); consumption of a name nobody
# defines is the defect.
defined, consumed = set(), {}
for name in sorted(os.listdir(HTML)):
if not name.endswith((".js", ".html", ".css")):
continue
src = io.open(os.path.join(HTML, name), encoding="utf-8").read()
for m in re.finditer(r"(--[a-zA-Z0-9-]+)\s*:", src):
defined.add(m.group(1))
for m in re.finditer(r"setProperty\(\s*['\"](--[a-zA-Z0-9-]+)", src):
defined.add(m.group(1))
for m in re.finditer(r"var\(\s*(--[a-zA-Z0-9-]+)", src):
consumed.setdefault(m.group(1), set()).add(name)
# --wp-chart- is the creator's JS-concatenated fallback ('--wp-chart-'+k);
# the numbered names it builds are all defined, the fragment is not a name.
unresolved = ["%s (%s)" % (t, ", ".join(sorted(fs)))
for t, fs in sorted(consumed.items())
if t not in defined and t != "--wp-chart-"]
chk("no var() anywhere names a token that nothing defines",
not unresolved, unresolved[:8])
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,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

@@ -190,7 +190,10 @@ def run(page, base, tok, db_path):
spans = len(re.findall(r"<span[^>]*onclick", code)) spans = len(re.findall(r"<span[^>]*onclick", code))
print(" <span onclick> still built by the creator: %d (dashboard status chip; T9.5)" print(" <span onclick> still built by the creator: %d (dashboard status chip; T9.5)"
% spans) % spans)
chk("...and the only one left in this file is the dashboard's", spans == 1, spans) # T9.5 converted the dashboard chip to a button, so the count is 0 now -
# pinned there, because a new span-with-onclick would be a C1 regression.
chk("...and none is left in this file at all (the chip became a button at T9.5)",
spans == 0, spans)
# ── 2. the rail ────────────────────────────────────────────────────────── # ── 2. the rail ──────────────────────────────────────────────────────────
print("\n2. the rail is built from the cards") print("\n2. the rail is built from the cards")

View File

@@ -366,13 +366,13 @@ def measurement(page, base, tok):
% (over["scroll"], over["client"], over["navw"] or "(unset)")) % (over["scroll"], over["client"], over["navw"] or "(unset)"))
if over["scroll"] > over["client"] + 2: if over["scroll"] > over["client"] + 2:
print(" still reproduces. Widest boxes: %s" % page.eval(WIDEST_JS)) print(" still reproduces. Widest boxes: %s" % page.eval(WIDEST_JS))
# Pinned, not fixed. BL-001 says to verify it at T7.1 and give it its own item # CLOSED at T9.5. The pin below held this failure in view from T7.1 until the
# if it survives the rebuild; T7.1 says to bundle nothing into this diff. So # cause was actually removed: the help-tip's CSS ::after escaped its badge to
# this asserts what is true TODAY and turns red the moment T7.2 lays the form # the right, and rebuilding the component (S8) with a viewport-clamped bubble
# out again - which is the point of pinning rather than printing. # ended the overflow. The check now asserts the FIX, so a regression reopens
chk("BL-001 is pinned: the creator still overflows at 390px, so this check " # BL-001 loudly instead of quietly re-widening the page.
"fails when it is fixed", chk("BL-001 is closed: the creator does not overflow at 390px",
over["scroll"] > over["client"] + 2, over) over["scroll"] <= over["client"] + 2, over)
# BL-013: outline:none on every input, replaced by a 3px #edf5ff glow on white - # BL-013: outline:none on every input, replaced by a 3px #edf5ff glow on white -
# a 1.05:1 edge. T7.2 owns the fix; this records whether the rebuild changed it, # a 1.05:1 edge. T7.2 owns the fix; this records whether the rebuild changed it,

196
tests/helptip_check.py Normal file
View File

@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Is every help-tip reachable by keyboard and by touch? — C1 + S8, T9.5.
The badges were <span> elements whose :focus CSS was dead code (no tabindex)
and whose touch path did not exist - on tablets, Field View's surface. Now the
component upgrades every badge to a button at load, and one viewport-clamped
role=tooltip bubble serves them all. This probe drives a badge with REAL key
events and a tap at 390px, then greps the app-wide metrics the audit document
cites.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import io
import json
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 sections_check import set_sop # noqa: E402
from stepper_check import dismiss_dialogs # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HTML = os.path.join(ROOT, "html")
def ascii_(v, n=280):
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
def settle(seconds=0.5):
time.sleep(seconds)
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 wait_creator(page, tries=40):
for _ in range(tries):
if page.eval("!!window.wpCreatorReady"):
return True
time.sleep(0.3)
return False
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
# ── 1. the greps the audit cites ──────────────────────────────────────────
print("\n1. the audit's grep metrics")
divspan = 0
outline_bad = []
for name in sorted(os.listdir(HTML)):
if not name.endswith((".html", ".js", ".css")):
continue
src = io.open(os.path.join(HTML, name), encoding="utf-8").read()
code = strip_js(src) if not name.endswith(".css") else re.sub(r"/\*.*?\*/", "", src, flags=re.S)
divspan += len(re.findall(r"<(div|span)[^>]*\bonclick=", code))
for m in re.finditer(r"outline:\s*none", code):
# The replacement can sit in the rule ABOVE (wp-chrome's search shell
# rings on :focus-within, and ringing input + shell would draw two),
# so the window looks both ways.
ctx = code[max(0, m.start() - 400):m.start() + 260]
tail = ctx[ctx.find("outline:") + 12:]
if ("outline" not in tail and "box-shadow" not in ctx
and "focus-within" not in ctx and "border" not in tail):
outline_bad.append(name)
chk("div/span click handlers app-wide: 0 (baseline 12/2)", divspan == 0, divspan)
chk("outline:none without a replacement: 0", not outline_bad, outline_bad[:4])
# The glossary pill classes are injected app-wide and MUST stay scoped:
# a bare .pill-hold painted the creator's Issue (hold) status radio
# error-red at all times (found 2026-08-20).
help_src = io.open(os.path.join(HTML, "help.js"), encoding="utf-8").read()
bare = re.findall(r"(?<!\.ui-help-pill)\.pill-[a-z]+(?=\s*\{)", help_src)
chk("help.js pill classes are scoped to .ui-help-pill (no bare .pill-*)",
not bare, bare[:4])
chk("the audit document exists with the per-page table",
"## Per-page results" in io.open(os.path.join(ROOT, "docs", "reference",
"accessibility-audit.md"), encoding="utf-8").read())
tmpdir = tempfile.mkdtemp(prefix="wpsuite-tip-")
db_path = os.path.join(tmpdir, "check.db")
server = None
browser = None
try:
tok = seed(db_path)
set_sop(db_path, {})
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
browser = cdp.Browser(exe)
page = browser.page()
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.viewport(390, 844, mobile=True)
page.goto(base + "/wp-creation-index.html?project=projA")
dismiss_dialogs(page)
chk("the creator boots at 390px", wait_creator(page))
settle(1.8)
# ── 2. every badge is a real button ──────────────────────────────────
print("\n2. the component, upgraded")
counts = json.loads(page.eval("""JSON.stringify({
total: document.querySelectorAll('.help-tip').length,
buttons: document.querySelectorAll('button.help-tip').length,
spans: document.querySelectorAll('span.help-tip').length,
})"""))
chk("every help-tip on the page is a <button>; zero spans remain",
counts["total"] > 0 and counts["spans"] == 0
and counts["buttons"] == counts["total"], ascii_(counts))
chk("...with an accessible name and a declared state",
page.eval("""[...document.querySelectorAll('button.help-tip')]
.every(b => b.getAttribute('aria-label') && b.getAttribute('aria-expanded') !== null)"""))
# ── 3. keyboard ───────────────────────────────────────────────────────
print("\n3. keyboard")
# Programmatic focus() fires no focusin unless the document HAS focus -
# the exact trap form_structure_check documents. Emulate it, loudly.
page.ws.call("Emulation.setFocusEmulationEnabled", {"enabled": True})
chk("focus emulation is on, so a focus reading means something",
page.eval("document.hasFocus()") is True)
page.eval("""(() => {
const b = [...document.querySelectorAll('button.help-tip')]
.find(x => x.offsetParent !== null) || document.querySelector('button.help-tip');
b.scrollIntoView({block:'center'}); b.focus();
})()""")
settle(0.4)
chk("focusing a badge shows the tooltip",
page.eval("!!(document.getElementById('wp-tip-bubble') && !document.getElementById('wp-tip-bubble').hidden)")
and page.eval("(document.getElementById('wp-tip-bubble')||{}).textContent.length > 0"))
chk("...as a role=tooltip the badge points at",
page.eval("document.getElementById('wp-tip-bubble').getAttribute('role')") == "tooltip"
and page.eval("document.activeElement.getAttribute('aria-describedby')") == "wp-tip-bubble")
bubble = json.loads(page.eval("""JSON.stringify((() => {
const r = document.getElementById('wp-tip-bubble').getBoundingClientRect();
return {left: r.left, right: r.right};
})())"""))
chk("390px: the bubble is CLAMPED to the viewport (BL-001's cause, dead)",
bubble["left"] >= 0 and bubble["right"] <= 390, ascii_(bubble))
# ── 4. touch ──────────────────────────────────────────────────────────
print("\n4. touch")
page.eval("document.activeElement.blur()")
settle(0.3)
page.eval("""(() => {
const b = [...document.querySelectorAll('button.help-tip')]
.find(x => x.offsetParent !== null);
b.click();
})()""")
settle(0.4)
chk("tapping a badge opens the tooltip and says so with aria-expanded",
page.eval("!!(document.getElementById('wp-tip-bubble') && !document.getElementById('wp-tip-bubble').hidden)")
and page.eval("!!document.querySelector(%s)"
% json.dumps('button.help-tip[aria-expanded="true"]')))
page.eval("document.body.click()")
settle(0.3)
chk("tapping elsewhere closes it",
page.eval("!document.getElementById('wp-tip-bubble') || document.getElementById('wp-tip-bubble').hidden"))
js_errors = [e for e in page.js_errors() if "beforeunload" not in e]
chk("no JavaScript errors anywhere in this run", not js_errors,
ascii_(js_errors[:2]))
finally:
if browser is not None:
try:
browser.close()
except Exception:
pass
if server is not None:
try:
server.terminate()
except Exception:
pass
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

@@ -460,8 +460,12 @@ def main():
tree = ast.parse(open(os.path.join(ROOT, "server", "app.py"), encoding="utf-8").read()) tree = ast.parse(open(os.path.join(ROOT, "server", "app.py"), encoding="utf-8").read())
bad = [] bad = []
for fn in [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]: for fn in [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]:
# wp.status specifically - T8.3's coalescer sets a NOTIFICATION
# row's .status (held.status = "pending"), which is outbox state,
# not a release transition.
assigns = [n for n in ast.walk(fn) if isinstance(n, ast.Assign) assigns = [n for n in ast.walk(fn) if isinstance(n, ast.Assign)
and any(isinstance(t, ast.Attribute) and t.attr == "status" and any(isinstance(t, ast.Attribute) and t.attr == "status"
and isinstance(t.value, ast.Name) and t.value.id == "wp"
for t in n.targets)] for t in n.targets)]
if not assigns: if not assigns:
continue continue

91
tests/icon_check.py Normal file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Is there one icon system, with one meaning per glyph? — S6, T9.3.
The set mixed emoji and dingbats, and at least one glyph carried two meanings.
Now: monochrome text-presentation glyphs only, mapped one-to-one in
docs/reference/tokens.md. Emoji render as per-platform colour artwork - which
is WHY the same glyph read as two things - so the enforceable form of "renders
identically on Windows, macOS and a tablet" is: no emoji-range codepoint and
no U+FE0F emoji-presentation selector anywhere in the UI source.
Static sweep - no browser needed. Exit 0 all passed, 1 a failure.
"""
import io
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from browser_check import chk, _PASS, _FAIL # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HTML = os.path.join(ROOT, "html")
# The approved system, verbatim from docs/reference/tokens.md.
APPROVED = {
0x2713, 0x2715, 0x26A0, 0x2298, 0x270E, 0x21BA, 0x21BB, 0x2699,
0x2913, 0x2912, 0x25C6, 0x25B8, 0x24D8, 0x2190, 0x2039, 0x203A,
0x2192, 0x2193, 0x25D4, 0x25A4, 0x25A6, 0x25A7, 0x283F, 0x25BE,
0x2248, 0x2398, 0x29C9, 0x2022, 0x00B7, 0x2298,
0x2302, 0x2315, 0x2399, 0x23FB, 0x25B2, 0x25BC, 0x25C9, 0x25F7,
0x2630, 0x263A, 0x2692, 0x26BF,
}
# Emoji territory: anything here in UI source is a system violation.
def is_emoji(o):
return (0x1F000 <= o <= 0x1FAFF) or o in (0x2705, 0x274C, 0x26D4, 0x2B50,
0x26A1, 0xFE0F, 0x2757, 0x2B55)
def main():
print("\n1. no emoji, anywhere in the UI")
offenders = []
glyphs_seen = set()
for name in sorted(os.listdir(HTML)):
if not name.endswith((".html", ".js")):
continue
src = io.open(os.path.join(HTML, name), encoding="utf-8").read()
for ch in set(src):
o = ord(ch)
if is_emoji(o):
offenders.append((name, "U+%04X" % o))
if o > 0x2000 and o not in (0x2013, 0x2014, 0x2018, 0x2019,
0x201C, 0x201D, 0x2026):
glyphs_seen.add(o)
chk("no emoji-range codepoint and no U+FE0F selector survives in any page",
not offenders, offenders[:8])
print("\n2. the mapping document")
tokens = io.open(os.path.join(ROOT, "docs", "reference", "tokens.md"),
encoding="utf-8").read()
chk("the meaning-to-icon mapping is documented in tokens.md",
"## Icons (S6 / T9.3)" in tokens and "U+2713" in tokens and "U+2298" in tokens)
body = tokens[tokens.find("## Icons"):]
rows = re.findall(r"^\| ([^|]+) \| [^|]+ \| (U\+[0-9A-F]{4}[^|]*) \|", body, re.M)
meanings = [r[0].strip() for r in rows]
chk("no meaning appears twice in the mapping", len(meanings) == len(set(meanings)),
[m for m in meanings if meanings.count(m) > 1])
codes = []
for _, cp in rows:
codes.extend(re.findall(r"U\+([0-9A-F]{4})", cp))
chk("no glyph carries two meanings", len(codes) == len(set(codes)),
[c for c in codes if codes.count(c) > 1])
print("\n3. what the pages use is what the document names")
# Box-drawing comment art (U+2500-257F) and the A7 locale samples in
# wp-format.js are not icons; everything else above U+2200 must be mapped.
unmapped = sorted("U+%04X" % o for o in glyphs_seen
if o >= 0x2200 and o not in APPROVED
and not (0x2500 <= o <= 0x257F)
and not (0x4E00 <= o <= 0xD7FF)) # CJK/Hangul: A7 locale data
chk("every glyph in use above the punctuation range is in the approved set",
not unmapped, unmapped[:10])
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))

180
tests/mobile_check.py Normal file
View File

@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Does the whole suite hold together at 390px? — C2, T9.6.
Nothing in the original proposal touched mobile, and it is where the worst
rendering was found. This drives all seven pages at 390px (mobile emulation,
so the media queries under test actually fire) and asserts:
- no page scrolls sideways
- no visible control is clipped past the viewport or collapsed to nothing
- every control meets the 24px WCAG floor; on the gloved-hands surfaces
(Field View, and the creator's rail / status / save controls) the bar is
44px, which is what the shared coarse-pointer sizing in wp-chrome.css
delivers
The after-screenshots live in docs/reference/baseline/after-wave9 (captured by
baseline_shots.py) beside the wave 0 set.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
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 sections_check import set_sop # noqa: E402
from stepper_check import dismiss_dialogs # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PAGES = [
("login", "/login.html"),
("launcher", "/index.html"),
("wizard", "/work-package-suite.html?tab=sop&project=projA"),
("creator", "/wp-creation-index.html?project=projA"),
("field", "/field.html?project=projA"),
("admin", "/admin.html"),
("users", "/users.html"),
]
MEASURE = """(function(){
var out = {sw: document.documentElement.scrollWidth, total:0, under24:[],
clipped:[]};
function skip(el){
// An off-canvas drawer is PARKED outside the viewport by design, and a row
// inside an overflow-x container is scrollable, not clipped - the same two
// lessons frame_check's widest-box scan learned the hard way.
for (var n = el; n; n = n.parentElement){
var cs = getComputedStyle(n);
if (cs.overflowX === 'auto' || cs.overflowX === 'scroll') return true;
if (cs.position === 'fixed' && cs.transform && cs.transform !== 'none') return true;
if (n.getAttribute && n.getAttribute('aria-hidden') === 'true') return true;
}
return false;
}
var els = document.querySelectorAll('button, a[href], input, select, textarea, [role=button]');
for (var i=0;i<els.length;i++){
var el=els[i]; var r=el.getBoundingClientRect();
if (r.width===0 || r.height===0 || el.disabled || el.type==='hidden') continue;
if (skip(el)) continue;
out.total++;
var inlineText = el.tagName==='A' && getComputedStyle(el).display==='inline';
var m=Math.min(r.width,r.height);
var id=el.tagName+'.'+String(el.className).slice(0,24)+' '+Math.round(r.width)+'x'+Math.round(r.height);
if (m < 24 && !inlineText && out.under24.length < 6) out.under24.push(id);
if ((r.left < -2 || r.right > 392) && out.clipped.length < 6) out.clipped.push(id);
}
return JSON.stringify(out);
})()"""
FIELD44 = """(function(){
var out = {total:0, under:[]};
function skip(el){
for (var n = el; n; n = n.parentElement){
var cs = getComputedStyle(n);
if (cs.position === 'fixed' && cs.transform && cs.transform !== 'none') return true;
if (n.getAttribute && n.getAttribute('aria-hidden') === 'true') return true;
}
return false;
}
var els = document.querySelectorAll(
'button, a[href], input:not([type=checkbox]):not([type=radio]), select, textarea');
for (var i=0;i<els.length;i++){
var el=els[i]; var r=el.getBoundingClientRect();
if (r.width===0 || r.height===0 || el.disabled) continue;
if (skip(el)) continue;
out.total++;
if (Math.min(r.width, r.height) < 44 && out.under.length < 6)
out.under.push(el.tagName+'.'+String(el.className).slice(0,24)+' '
+Math.round(r.width)+'x'+Math.round(r.height));
}
return JSON.stringify(out);
})()"""
def ascii_(v, n=280):
return re.sub(r"\s+", " ", str(v)).encode("ascii", "replace").decode()[:n]
def main():
exe = cdp.find_browser()
if not exe:
print("no headless-capable browser found; set WP_BROWSER.")
return 2
shots = os.path.join(ROOT, "docs", "reference", "baseline", "after-wave9")
chk("the after-screenshots are committed beside the wave 0 baseline",
os.path.isdir(shots) and len([f for f in os.listdir(shots)
if f.endswith("-390.png")]) >= 7,
shots)
tmpdir = tempfile.mkdtemp(prefix="wpsuite-mobile-")
db_path = os.path.join(tmpdir, "check.db")
server = None
browser = None
try:
tok = seed(db_path)
set_sop(db_path, {})
port = cdp.free_port()
base = "http://127.0.0.1:%d" % port
server = start_server(port, db_path)
browser = cdp.Browser(exe)
page = browser.page()
page.clear_cookies()
page.set_cookie("wp_session", tok["root"])
page.viewport(390, 844, mobile=True)
for name, path in PAGES:
print("\n%s" % name)
page.goto(base + path)
dismiss_dialogs(page)
time.sleep(2.4)
m = json.loads(page.eval(MEASURE))
chk("%s: no sideways scrolling" % name, m["sw"] <= 392, m["sw"])
chk("%s: no control clipped past the viewport" % name,
not m["clipped"], ascii_(m["clipped"]))
chk("%s: every control meets the 24px floor" % name,
not m["under24"], ascii_(m["under24"]))
# the gloved-hands bar: Field View, everything 44px
print("\nfield view, the 44px bar")
page.goto(base + "/field.html?project=projA")
dismiss_dialogs(page)
time.sleep(2.4)
f = json.loads(page.eval(FIELD44))
chk("field view: every control is a 44px touch target",
f["total"] > 0 and not f["under"], ascii_(f))
js_errors = [e for e in page.js_errors() if "beforeunload" not in e]
chk("no JavaScript errors across the whole sweep", not js_errors,
ascii_(js_errors[:2]))
finally:
if browser is not None:
try:
browser.close()
except Exception:
pass
if server is not None:
try:
server.terminate()
except Exception:
pass
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

@@ -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"})

View File

@@ -329,8 +329,15 @@ def run(page, base, tok, db_path):
return JSON.stringify({assets: out.assets, materials: out.materials, return JSON.stringify({assets: out.assets, materials: out.materials,
kitStatus: out.kitStatus, subject: out.subject}); kitStatus: out.kitStatus, subject: out.subject});
})()""" % json.dumps(FULL_PKG))) })()""" % json.dumps(FULL_PKG)))
# Content, not byte-equality: since D11 (Aug 20) every asset row loaded into
# the form is normalised - a row the Micron catalog does not vouch for gains
# source:'manual' on its way through. That stamp is the feature working, not
# the section toggle leaking; what CR-016 requires to survive is the DATA.
chk("a package edited while Assets is off keeps its assets on save", chk("a package edited while Assets is off keeps its assets on save",
collected["assets"] == FULL_PKG["assets"], collected["assets"]) [{"tag": a.get("tag"), "desc": a.get("desc")} for a in collected["assets"]]
== [{"tag": a["tag"], "desc": a["desc"]} for a in FULL_PKG["assets"]]
and all(a.get("source") in ("manual", "catalog") for a in collected["assets"]),
collected["assets"])
# Compared on the content, not the whole row: the creator upper-cases a # Compared on the content, not the whole row: the creator upper-cases a
# material unit on its way through the form ("ea" -> "EA"), which is its own # material unit on its way through the form ("ea" -> "EA"), which is its own
# long-standing behaviour and nothing to do with section toggles. Asserting # long-standing behaviour and nothing to do with section toggles. Asserting

View File

@@ -186,7 +186,11 @@ def source_counts():
def run(page, base, tok): def run(page, base, tok):
def open_wizard(query="?project=projA"): # BL-018's fixture fix (T9.9) gave projA a PRODUCTION-shape completed SOP,
# so the wizard on projA now legitimately restores a finished configuration.
# This file's premise is a wizard someone is STARTING - projB has no SOP,
# which is that premise, honestly.
def open_wizard(query="?project=projB"):
# Leave the outgoing page clean first: the unsaved-work guard from T4.3 is # Leave the outgoing page clean first: the unsaved-work guard from T4.3 is
# doing its job, and a "Leave site?" prompt would stall the navigation. # doing its job, and a "Leave site?" prompt would stall the navigation.
try: try:
@@ -232,9 +236,13 @@ def run(page, base, tok):
# ── 6. the div-onclick baseline moved ───────────────────────────────────── # ── 6. the div-onclick baseline moved ─────────────────────────────────────
print("\n6. the wave 0 <div onclick> count dropped by 10") print("\n6. the wave 0 <div onclick> count dropped by 10")
divs, spans = source_counts() divs, spans = source_counts()
chk("app-wide div-with-onclick is %d, down 10 from %d" # Re-pointed at T9.5: the C1 audit drove the app-wide count to ZERO (the
% (divs, BASELINE_DIV_ONCLICK), divs == BASELINE_DIV_ONCLICK - 10, # last two - the wizard's constraint-library entries and the dashboard
"counted %d" % divs) # chips - became buttons). "Exactly baseline-10" was right while wave 9 was
# future; asserting <= that now would let regressions hide under the slack,
# so the pin is the final number.
chk("app-wide div-with-onclick is %d - the C1 target, reached at T9.5"
% divs, divs == 0, "counted %d" % divs)
chk("...and none of the survivors is in the wizard's rail", chk("...and none of the survivors is in the wizard's rail",
page.eval("document.querySelectorAll('#step-rail div').length") == 0) page.eval("document.querySelectorAll('#step-rail div').length") == 0)
print(" span-with-onclick unchanged at %d (wave 9 owns those)" % spans) print(" span-with-onclick unchanged at %d (wave 9 owns those)" % spans)
@@ -248,7 +256,7 @@ def run(page, base, tok):
chk("...and it is the step being shown", cur and cur[0]["step"] == 1, cur) chk("...and it is the step being shown", cur and cur[0]["step"] == 1, cur)
chk("...which also says so in words", cur and cur[0]["state"] == "Current step", cur) chk("...which also says so in words", cur and cur[0]["state"] == "Current step", cur)
# projA's fixture project has no division or site, so step 1 is incomplete on # projB has no SOP at all, so step 1 is incomplete on
# a fresh load and everything ahead of it is genuinely out of reach. # a fresh load and everything ahead of it is genuinely out of reach.
locked = [r for r in rows if r["ariaDisabled"] == "true"] locked = [r for r in rows if r["ariaDisabled"] == "true"]
chk("with step 1 incomplete, steps 2-10 are unavailable", chk("with step 1 incomplete, steps 2-10 are unavailable",
@@ -406,14 +414,14 @@ def run(page, base, tok):
settle(page, 1.1) settle(page, 1.1)
chk("...and Back returns to the previous step", page.eval("currentStep") == 2, chk("...and Back returns to the previous step", page.eval("currentStep") == 2,
page.eval("currentStep")) page.eval("currentStep"))
# Back to a URL with NO step at all does not return to step 1 — the popstate # BL-016, FIXED at T9.9: a step-less wizard URL is step 1. This check pinned
# handler parses `step` and ignores a NaN. That is T4.2's restore rather than # the WRONG behaviour until the fix landed, and flipped with it - which was
# the rail's, it predates this task, and it is logged as BL-016. # the plan recorded on the entry.
chk("...and the known step-1 gap is still exactly that, and no wider", chk("...and Back continues to work",
page.eval("(() => { history.back(); return true; })()") is True) page.eval("(() => { history.back(); return true; })()") is True)
settle(page, 1.1) settle(page, 1.1)
chk("...(BL-016) Back to a step-less URL leaves the step where it was", chk("...(BL-016) Back to a step-less URL returns to step 1",
page.eval("currentStep") == 2 and "step=" not in page.eval("location.search"), page.eval("currentStep") == 1 and "step=" not in page.eval("location.search"),
[page.eval("currentStep"), page.eval("location.search")]) [page.eval("currentStep"), page.eval("location.search")])
# ── narrow width: the gloved-hands surface ──────────────────────────────── # ── narrow width: the gloved-hands surface ────────────────────────────────
@@ -480,7 +488,10 @@ def run(page, base, tok):
chk("the page does not scroll sideways at 1440px", chk("the page does not scroll sideways at 1440px",
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"), page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"),
page.eval("[document.documentElement.scrollWidth, window.innerWidth]")) page.eval("[document.documentElement.scrollWidth, window.innerWidth]"))
chk("the wizard still boots without a JavaScript error", not page.js_errors(), # projB has no SOP, so /api/sops/latest answering 404 is the CORRECT answer
# being logged by the browser, not an error in the page.
chk("the wizard still boots without a JavaScript error",
not [e for e in page.js_errors() if "sops/latest" not in e],
page.js_errors()) page.js_errors())