Compare commits

..

11 Commits

Author SHA1 Message Date
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
51 changed files with 2614 additions and 211 deletions

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,116 @@
# 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 | open | — | `seed_demo.py` still does not sign in; the smoke test does. Carried to the next revision |
| 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.
- **S13** — `seed_demo.py` sign-in.
- Product questions raised in commit messages, awaiting answers: hold
reachable from Draft/Scheduled (T7.3); CR-014 email body content (T7.6);
merged-PDF export (T9.1).
- 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

@@ -301,6 +301,17 @@ 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
``` ```
**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
@@ -507,3 +523,33 @@ deliberately deferred.
- **Why not now:** new scope — needs its own item id per the working rules, and - **Why not now:** new scope — needs its own item id per the working rules, and
a product conversation about where it displays and who reads it. a product conversation about where it displays and who reads it.
- **Suggested wave or follow-up:** next revision; needs Nick for placement. - **Suggested wave or follow-up:** next revision; needs Nick for placement.
### BL-024 — 21 native dialogs remain on the operator consoles and the launcher
- **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 — The second brand blue survives as one rgba focus tint in help.js
- **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,55 @@
# 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.

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

@@ -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,7 +142,7 @@ 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(){
@@ -151,11 +151,11 @@ async function cleanDemo(){
// 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 +175,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;
@@ -284,14 +284,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;
@@ -560,7 +560,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 +627,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(37,99,214,.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>
@@ -585,7 +598,31 @@
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 +643,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();

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,8 +216,12 @@
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));
// 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'); localStorage.setItem(nsKey('wp_suite_sop_complete', projectId), '1');
} }
}
}).catch(function () {}) }).catch(function () {})
); );
jobs.push( jobs.push(
@@ -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

@@ -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','', '');
@@ -354,11 +354,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'));
} }
} }

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();
@@ -3409,7 +3780,8 @@ function renderDashboard(){
</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 +3802,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 +4005,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 +4204,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

@@ -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; }
@@ -906,6 +908,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 +1149,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; }

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.
@@ -1294,6 +1294,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]
@@ -3383,6 +3392,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

@@ -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

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")):

106
tests/color_check.py Normal file
View File

@@ -0,0 +1,106 @@
#!/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)
chk("the second brand blue (#2563d6) is gone from the theme itself",
"2563d6" not in code and "37, 99, 214" not in code)
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:
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

@@ -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())

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

@@ -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())