Compare commits
88 Commits
Micron-Ass
...
29c4cd313e
| Author | SHA1 | Date | |
|---|---|---|---|
| 29c4cd313e | |||
| 8fe7b25cd8 | |||
| 560f0cb3cc | |||
| 24f60151e5 | |||
| 031dc6b995 | |||
| 0f28a27441 | |||
| 16afc56c0a | |||
| 8efe624d5d | |||
| 24476c86a6 | |||
| cc761c8f7d | |||
| fb89b1f6e1 | |||
| 8663d81af3 | |||
| 8cf8c0f882 | |||
| 2a5f6b3549 | |||
| 454bfa0fe1 | |||
| 23ee0b052f | |||
| 771672273d | |||
| 6201fcfb4a | |||
| 0dcea8d725 | |||
| b83f2fd8d5 | |||
| b44afa7672 | |||
| c2a1cc7c26 | |||
| eb1497d574 | |||
| bf28489954 | |||
| 898e5dab94 | |||
| 190144c539 | |||
| b9d5f8ef92 | |||
| 829fa22236 | |||
| 96387105f4 | |||
| 79c4a36c70 | |||
| b1a7fe04bc | |||
| 7f712b7e00 | |||
| 82a8f30074 | |||
| e3de3c7c00 | |||
| c084f730b3 | |||
| 2486f87010 | |||
| 5d27a1e086 | |||
| 60434d452c | |||
| 2b597e68d8 | |||
| 755c976841 | |||
| 12d19446d5 | |||
| 0dbc240900 | |||
| d092db3920 | |||
| 7e33a3cbfd | |||
| 815f266039 | |||
| 4d186ea6a0 | |||
| ce2d008897 | |||
| 7893a56ea2 | |||
| 55caefb099 | |||
| 3bc3abe209 | |||
| c453e50412 | |||
| ae30c58337 | |||
| 2081c1ad3c | |||
| 6088ef17e8 | |||
| 77c043c3db | |||
| 5e1f6e75ba | |||
| 6fbc5b9735 | |||
| c024cba844 | |||
| 0cce0b4191 | |||
| ab3c9e52d1 | |||
| b54034db04 | |||
| b670ae719d | |||
| 12c0e5ca74 | |||
| ce6fb840f0 | |||
| 03d24138ed | |||
| 2485ca7b61 | |||
| 9ab7b48de2 | |||
| 0e40e967a0 | |||
| b105135a98 | |||
| 05aa67ff32 | |||
| d3d2a46922 | |||
| 0a080be1f8 | |||
| 357712e93e | |||
| 5f3141e2a3 | |||
| 4d3258113a | |||
| d9f96f20e1 | |||
| 440f3239a4 | |||
| 0f6f91ce2c | |||
| d22834f2f1 | |||
| 5d5511a458 | |||
| fe8a27e022 | |||
| c2e35b9261 | |||
| 3d99d4b9d0 | |||
| 9459e76a6c | |||
| 7f831bf1ca | |||
| 64a5fd5612 | |||
| c99ef08cf1 | |||
| 4ace2afb1c |
108
CLAUDE.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Working rules — Work Package Suite
|
||||
|
||||
This repo is being changed against a fixed spec. Read `IMPLEMENTATION.md` before starting
|
||||
any task, and read the wave file for the task you are on. Do not work from this file alone.
|
||||
|
||||
## The spec is the source of truth
|
||||
|
||||
Every change traces to an item ID (`CR-001`, `F1`, `S1`, `A1`, `B1`, `C1`, `D1`). If you are about
|
||||
to make a change that has no ID, stop. Either it belongs to an existing item and you should
|
||||
say which, or it is out of scope and should be logged in `docs/waves/backlog.md` instead of
|
||||
built.
|
||||
|
||||
Do not renumber, merge, or reinterpret item IDs. They are referenced in documents outside
|
||||
this repo that other people are reading. New scope decided mid-build gets a **new** ID rather
|
||||
than a widened old one - that is what the `D` prefix is for. See
|
||||
`docs/waves/decisions-2026-08-18.md`.
|
||||
|
||||
## Scope discipline
|
||||
|
||||
- **One task per PR.** Task IDs are `T<wave>.<n>`. Reference the task ID and the item IDs in
|
||||
the commit message and PR title.
|
||||
- **Do not fix things you notice in passing.** The codebase has known problems documented as
|
||||
S1 through S13, all scheduled. Fixing S6 while doing T3.2 makes the diff unreviewable and
|
||||
breaks the wave ordering. Log it, move on.
|
||||
- **Do not reorder waves.** The ordering is dependency-driven and documented in
|
||||
`IMPLEMENTATION.md` section 4. Waves 1 through 4 are prerequisites: they produce almost no
|
||||
visible change and every later wave assumes them.
|
||||
- **Do not start a wave until the previous wave is merged**, unless the task explicitly says
|
||||
it is independent.
|
||||
|
||||
## Frontend and backend boundary
|
||||
|
||||
The UX review that produced F1-F6 and S1-S13 covered `html/` only. Several change requests
|
||||
need server work and will be silently half-built if you treat them as frontend-only:
|
||||
|
||||
| Item | Needs server work |
|
||||
|---|---|
|
||||
| CR-004, CR-018 | Structured location storage and aggregate endpoints. Not localStorage. |
|
||||
| CR-007 | File upload, storage, and retrieval for drawing attachments. |
|
||||
| CR-011, CR-014 | Outbound email and a durable link target per work package. |
|
||||
| CR-013 | Material request persistence. |
|
||||
| B4 | Aggregate endpoints replacing localStorage-derived counts. |
|
||||
|
||||
If a task touches one of these and you find yourself writing to `localStorage`, you are
|
||||
building the wrong thing. Say so and stop.
|
||||
|
||||
## Things that must not change
|
||||
|
||||
These are recorded decisions, not oversights. Do not "clean them up":
|
||||
|
||||
- **Actual Hours stays in Closeout (CR-017).** Its removal was proposed and rejected.
|
||||
- **Localization stays (A7).** `admin.js` language and time handling is a shipped feature.
|
||||
- **Uppercase card headers in `console.css` stay (A5).** The sentence-case rule applies to
|
||||
buttons and field labels only. The uppercase header idiom is deliberate.
|
||||
- **The logged-override path for predecessors stays (A1).** It is an audited business rule,
|
||||
not a bug. It is `confirmEarlyRelease()` in `wp-creation-app.js`, called from the issue and
|
||||
release paths. Named by function, not by line: this file and `IMPLEMENTATION.md` X2 both
|
||||
cited `wp-creation-app.js:1962-1972` until Aug 18 2026, and those lines are
|
||||
`deletePackage()`/`clearSaved()` - a different rule entirely. Corrected before T7.3, which
|
||||
is the task told not to remove it.
|
||||
- **Removed fields are hidden, not deleted (CR-002, CR-016).** Retain the data and the model.
|
||||
Removal is expressed through the CR-006 section toggles.
|
||||
|
||||
## The token rule
|
||||
|
||||
After wave 3 there is exactly one place a color, spacing or type value is defined. Page
|
||||
stylesheets alias that source and declare nothing new.
|
||||
|
||||
Adding a raw hex value to a page stylesheet is a defect regardless of what the task asked
|
||||
for. Four parallel token systems is what produced S5, and the `.field-hint` comment at
|
||||
`work-package-suite-styles.css:336` is the bug that resulted. Do not recreate it.
|
||||
|
||||
## Accessibility is in scope
|
||||
|
||||
Approved Aug 14, 2026 (C1). Any component you rebuild ships accessible or it is not done:
|
||||
|
||||
- Interactive elements are `<button>`, `<a>`, or an input. Never a `<div>` with `onclick`.
|
||||
There are currently 12 `<div>` and 2 `<span>` click handlers app-wide; do not add a 15th.
|
||||
- Anything conveying instructions is reachable by keyboard and by touch. Hover-only is not
|
||||
acceptable — Field View runs on tablets.
|
||||
- Status changes and toasts announce through an `aria-live` region. `login.html` already does
|
||||
this correctly with `role="alert"` and `role="status"`. Copy that pattern.
|
||||
- Focus is always visible. Do not use `outline: none` without a replacement of at least equal
|
||||
visibility.
|
||||
- Text meets 4.5:1 against its background, 3:1 for large text.
|
||||
|
||||
## Verification
|
||||
|
||||
A task is not done because the code is written. Every task file lists its own done-when
|
||||
checks. In addition, for any task touching the frontend:
|
||||
|
||||
1. Run the app locally: `uvicorn server.app:app` against a throwaway SQLite database.
|
||||
2. Exercise the affected flow at **390px** and at **1440px**. Field View at 390px is the
|
||||
gloved-hands surface and is where the worst rendering was found.
|
||||
3. Capture before and after screenshots into the PR.
|
||||
4. Run the existing smoke test. It signs in, and so does `server/seed_demo.py` (S13, fixed at T1.6 - this line said otherwise until Aug 20 2026, a stale record).
|
||||
|
||||
If a done-when check cannot be verified, do not mark the task complete. Say which check
|
||||
failed and why.
|
||||
|
||||
## Asking versus assuming
|
||||
|
||||
The four gating decisions are closed and recorded in `IMPLEMENTATION.md` section 2. Nothing
|
||||
else in the spec is a decision waiting to be made.
|
||||
|
||||
Where a task says "confirm with Nick", that is a product question, not an implementation
|
||||
blocker: build to the written acceptance criteria, and raise the question in the PR
|
||||
description. Do not invent a different behavior because the written one seems incomplete.
|
||||
@@ -195,12 +195,18 @@ good deploy look broken.
|
||||
Sanity checks — all four should take under a minute:
|
||||
|
||||
1. Log in. The home page offers to select or create a project.
|
||||
2. Open **Admin Console** (the link is on the home page; you need an admin account).
|
||||
The user table should read as **one line per user** — if rows are three lines tall
|
||||
and the table spills outside its white card, you are still on the old cached
|
||||
2. Open **User Directory** (the `Users` link in the top-right menu, or the tile on the
|
||||
home page). The table should read as **one line per user** — if rows are three lines
|
||||
tall and the table spills outside its white card, you are still on the old cached
|
||||
files: hard-reload again.
|
||||
3. Two new cards are present and load: **Projects**, and **Default members on new
|
||||
projects**. Both should list rows, not an error.
|
||||
|
||||
> Changed since this runbook was written: user accounts moved out of the Admin
|
||||
> Console into `users.html` when the **Project Super User** role was added, so that
|
||||
> a project admin can create accounts on their own job. If you are deploying a build
|
||||
> from before that change, read this step as "Admin Console → the user table".
|
||||
3. Open **Admin Console** (admin account required). Two new cards are present and
|
||||
load: **Projects**, and **Default members on new projects**. Both should list rows,
|
||||
not an error.
|
||||
4. In the **Projects** card, click **Archive** on a project you don't mind hiding
|
||||
(a `DEMO-` one if there is one), confirm the prompt, then tick **Show archived** —
|
||||
it should reappear marked `archived`. Click **Unarchive** to put it back. That
|
||||
|
||||
@@ -146,20 +146,64 @@ docker compose exec db psql -U wpsuite -d wpsuite -c "select id, name from proje
|
||||
|
||||
### Automated smoke test
|
||||
|
||||
`server/smoketest.py` exercises the whole stack end-to-end (health → project →
|
||||
SOP → Work Package → the AWP issue gate → status → metrics → comments → cascade
|
||||
cleanup). Stdlib only — no pip/jq.
|
||||
`server/smoketest.py` exercises the whole stack end-to-end (health → sign-in →
|
||||
project → SOP → Work Package → the AWP issue gate → status → metrics → comments →
|
||||
archive round trip → cascade cleanup → sign-out). Stdlib only — no pip/jq.
|
||||
|
||||
It **signs in first**, because every `/api/` route except `/api/health` requires a
|
||||
session. Credentials come from the environment so a password stays out of shell
|
||||
history, and the account must be an **admin**: the run creates a project and deletes
|
||||
it again, and archiving or deleting one takes Project Admin on it. The script checks
|
||||
the signed-in role up front and warns if it is too low rather than letting you find
|
||||
out in the cleanup step.
|
||||
|
||||
```bash
|
||||
export WP_SMOKE_USER=<admin-account>
|
||||
export WP_SMOKE_PASSWORD='…'
|
||||
|
||||
# Through the proxy (use --insecure for a self-signed internal cert):
|
||||
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
||||
|
||||
# Or from inside the api container (hits FastAPI directly):
|
||||
docker compose exec api python /app/server/smoketest.py http://localhost:8000
|
||||
# Or from inside the api container (hits FastAPI directly). Pass the vars through:
|
||||
docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD api \
|
||||
python /app/server/smoketest.py http://localhost:8000
|
||||
|
||||
# Add --keep to leave a demo project in the DB so you can open it in the UI.
|
||||
# --user / --password override the environment if you'd rather be explicit.
|
||||
```
|
||||
|
||||
Exit codes: **0** all checks passed · **1** one or more checks failed · **2** the run
|
||||
could not start (host unreachable, or credentials missing or rejected). The last is
|
||||
kept separate on purpose — "I could not test this" is a different answer from "this is
|
||||
broken", and automation should not treat them alike.
|
||||
|
||||
### Front-end browser check
|
||||
|
||||
`tests/browser_check.py` is the other half: the smoke test proves the API works, this
|
||||
proves the **pages** work. It runs them in headless Edge (or Chrome) over the DevTools
|
||||
Protocol and asserts what only a browser can settle — that each page boots without a
|
||||
JavaScript error, that the role-dependent renderings are right, and that the layout
|
||||
rules the console pages depend on are actually in effect.
|
||||
|
||||
Self-contained: it creates a throwaway SQLite database, seeds a fixture (two projects,
|
||||
an admin, a Project Super User, a plain member, and accounts positioned to exercise
|
||||
in-scope / out-of-scope / invisible), starts its own server on a free port, and tears
|
||||
all of it down. **Your real database is never touched.** Stdlib only.
|
||||
|
||||
```bash
|
||||
python tests/browser_check.py # everything, ~71 checks
|
||||
python tests/browser_check.py --keep-server # leave it up to poke at by hand
|
||||
WP_BROWSER=/path/to/chrome python tests/browser_check.py
|
||||
```
|
||||
|
||||
Same exit codes as the smoke test, including **2** for "no browser found" — a missing
|
||||
browser is not a failing app.
|
||||
|
||||
Run this after any change to `html/users.js`, `html/wp-sidenav.js`, `html/console.css`
|
||||
or `html/admin.js`. It is the check that would have caught a rule lost while
|
||||
`console.css` was being extracted out of `admin.html`, which is a silent, whole-page
|
||||
regression that no server-side test can see.
|
||||
|
||||
Exit code 0 and "ALL PASS" means the API, the Python logic, and SQL are all
|
||||
working. It cleans up after itself (the test project and its SOP/WPs are
|
||||
deleted via cascade); a single tagged test comment remains (there's no comment
|
||||
@@ -332,18 +376,45 @@ console's **Reset password** button).
|
||||
|
||||
`User.role` is the **permissions** role; `User.project_role` is the person's **job
|
||||
function** on the project (Project Manager, Superintendent, …) and grants nothing.
|
||||
Both are set in the Admin console's user table.
|
||||
Both are set on the **User Directory** page (`users.html`) — not the Admin Console,
|
||||
which no longer manages accounts.
|
||||
|
||||
| Role | May do |
|
||||
|---|---|
|
||||
| `admin` | User administration, app settings, and every project |
|
||||
| `admin` | User administration everywhere, app settings, and every project |
|
||||
| `project_super_user` | Everything `project_admin` may do, **plus user administration on the projects they hold the role on**: create accounts, reset passwords, set permissions, grant project access |
|
||||
| `project_admin` | On assigned projects: delete work packages, change a **completed** SOP, delete the project |
|
||||
| `project_user` | Create/edit work packages, author a SOP up to completion; may archive a WP but not delete one |
|
||||
|
||||
Enforced server-side by `require_project_admin` in `server/app.py`; the front end
|
||||
only hides controls to avoid dead-end clicks. Accounts created before this change
|
||||
only hides controls to avoid dead-end clicks. Accounts created before roles existed
|
||||
carried the role `user`, which the migration rewrites to `project_user`.
|
||||
|
||||
### Project Super User — what bounds it
|
||||
|
||||
The role exists so a project admin can staff their own job without an app admin.
|
||||
Its limits are what make it safe to hand out, and all of them are server-side
|
||||
(`managed_project_ids`, `manage_user_problem`, `grantable_roles` in `server/app.py`):
|
||||
|
||||
* **Scope comes from projects, not the job title.** A super user administers the users
|
||||
of the projects they hold the role on — via their account role, or via
|
||||
`ProjectMember.role` for a super user on one job only. No projects, no authority.
|
||||
* **Account changes need EXCLUSIVE scope.** Resetting a password, disabling, renaming,
|
||||
changing permissions or deleting are global acts, so they are refused when the
|
||||
target is also on a project the caller does not administer. The directory shows
|
||||
those rows read-only with the reason. An app admin has to make the change.
|
||||
* **No admin or super-user targets, and none granted.** A super user may hand out
|
||||
`project_admin` / `project_user` only, and may not touch an admin's or another
|
||||
super user's account — so the role cannot become a route to app-wide control.
|
||||
* **Saving project access never reaches outside scope.** `PUT
|
||||
/api/auth/users/{id}/projects` rebuilds only the caller's own slice; memberships on
|
||||
projects they don't administer are left untouched.
|
||||
* **App settings, feature flags and the default-member rule stay admin-only.**
|
||||
|
||||
No migration is needed for the new role — `users.role` is already `String(20)` and
|
||||
`project_super_user` fits. Grant it from the User Directory (Permissions column), or
|
||||
per project from **Project access → Project Super User here**.
|
||||
|
||||
## Feature flags
|
||||
|
||||
**Admin console → Features.** `bim_enabled` is **OFF by default**: the SOP creator
|
||||
|
||||
204
IMPLEMENTATION.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Work Package Suite — Implementation Plan
|
||||
|
||||
Derived from **Consolidated Change Request R2**, August 14, 2026.
|
||||
Companion document for humans: `WorkPackageSuite_Consolidated_Change_Request_R2.docx`.
|
||||
|
||||
55 items, 9 waves, dependency-ordered. Read `CLAUDE.md` first.
|
||||
|
||||
Ten further items (`D1`-`D10`) were decided on August 18 2026 and are recorded in
|
||||
`docs/waves/decisions-2026-08-18.md`. 65 items total.
|
||||
|
||||
---
|
||||
|
||||
## 1. Where this came from
|
||||
|
||||
Three inputs were merged to produce this plan:
|
||||
|
||||
| Source | Produced | IDs |
|
||||
|---|---|---|
|
||||
| 13 in-app feedback comments, Aug 14 2026 (01:11–01:32 PM) | Functional change requests | `CR-001`–`CR-018` |
|
||||
| Micron EUV review meeting, Aug 14 2026 | Functional change requests and context | included above |
|
||||
| UX review of `html/`, Aug 14 2026, branch `users/directory-super-user` | Rendering defects, structural findings, proposal assessment | `F1`–`F6`, `S1`–`S13`, `A1`–`A7`, `B1`–`B7`, `C1`–`C4` |
|
||||
|
||||
`F1`–`F6` were verified in a browser and are reproducible. `S1`–`S13` were read from source
|
||||
and carry file and line references. `A`/`B`/`C` items assess a separately proposed UX change
|
||||
list; their IDs are carried over unchanged so the two documents line up.
|
||||
|
||||
## 2. Decisions (all closed)
|
||||
|
||||
Approved by Nick Siegfried, August 14 2026. Nothing in this plan is blocked pending a
|
||||
decision. Do not reopen these without a spec revision.
|
||||
|
||||
| ID | Decision |
|
||||
|---|---|
|
||||
| `CR-009` | **Keep and extend Kitting.** Build `CR-010`, `CR-011`, `CR-012`. Ship it toggled **off** for the Micron EUV SOP via the `CR-006` section toggles. Kitting is not happening at Micron today; the team expects to get there. |
|
||||
| `CR-013` | **Build the lightweight material request.** Line items (description, quantity, unit), needed-by date, requestor, delivery location, status. **Defer** the parts catalog, live inventory, and warehouse integration. Sequence after the creator has real section structure (see `X6`). |
|
||||
| `B7` | **Dissolve the creator iframe.** Scheduled as `T7.1`, the first task of wave 7. This is the largest engineering item in the plan and most of wave 7 depends on it. |
|
||||
| `C1` | **Accessibility is in scope.** Fold it into components already being rebuilt rather than treating it as a separate pass. Rules are in `CLAUDE.md`. |
|
||||
|
||||
> `B7` was confirmed as "dissolve the iframe" on August 14 2026 and again at the wave 7
|
||||
> handoff. It is not a scheduling decision.
|
||||
|
||||
**A second set of decisions closed on August 18 2026**, at the wave 6 exit, in answer to
|
||||
twenty-one questions and eleven follow-ups. They are recorded in
|
||||
`docs/waves/decisions-2026-08-18.md` as `D1`-`D10`. They add three tasks and amend
|
||||
acceptance criteria on seven others; they do not reopen anything above.
|
||||
|
||||
| ID | Decision | Amends |
|
||||
|---|---|---|
|
||||
| `D1` | Sample data returns to the creator, visible on the unframed page | `B7`, `S7` |
|
||||
| `D2` | The QA distribution list is configured in the SOP, not per work package | `CR-014` |
|
||||
| `D3` | The creator stays one page: side navigation plus collapsible sections | `F6` |
|
||||
| `D4` | `Urgent` surfaces the audited override; it does not bypass the constraint gate | `CR-003`, `A1` |
|
||||
| `D5` | Usage data moves to the admin console | `B7` |
|
||||
| `D6` | The material list uploads at SOP configuration | `CR-013` |
|
||||
| `D7` | Archived projects are readable, by project admins only | `B3`, `C1` |
|
||||
| `D8` | 5MB a file, 2GB a project, PDFs and images, stored in the same database | `CR-007` |
|
||||
| `D9` | `Ready for QA` appears in Field View | `CR-014` |
|
||||
| `D10` | Email is switched on and off from the admin console | `CR-011`, `CR-014` |
|
||||
|
||||
## 3. Cross-track dependencies
|
||||
|
||||
These are the reason the wave order is what it is. Each row is a change request that cannot
|
||||
be built as written, or cannot be built once, until something else lands.
|
||||
|
||||
| ID | Item | Depends on | Type | Why |
|
||||
|---|---|---|---|---|
|
||||
| `X1` | `CR-011`, `CR-014` | `S3` | **Blocking** | Both promise an email containing a direct link to the work package. There is no `pushState` anywhere in the app, so no work package has an addressable URL. The emails cannot be built until URL state exists. |
|
||||
| `X2` | `CR-015` | `A1` | **Blocking** | The hold-not-clearing bug and the transition model are the same code. `confirmEarlyRelease()` in `wp-creation-app.js` is a deliberate logged-override path, so hold state is not purely derived from open constraints. Fixing them separately means fixing it twice. **Corrected Aug 18 2026:** this row and `CLAUDE.md` both cited `wp-creation-app.js:1962-1972`, which is `deletePackage()`/`clearSaved()`. The override path is `confirmEarlyRelease()`, called from the issue and release paths. Named by function so it survives the `T7.1` rewrite. |
|
||||
| `X3` | `CR-014` | `A1` | Sequencing | `Ready for QA` is a new state and must land inside the guarded transition model, not beside it. |
|
||||
| `X4` | `CR-006` | `B7` | Sequencing | Section toggles must suppress sections inside the creator, which is an iframe child until `T7.1`. |
|
||||
| `X5` | `CR-004`, `CR-018` | `B4` | **Blocking** | Rollup by building, floor and sector cannot come from `localStorage`. Same data dependency that blocks the pipeline strip. |
|
||||
| `X6` | `CR-013` | `F6`, `A4` | Sequencing | A material request card is a 12th card on a form already ~4,700px tall. Build it after the form has section structure. |
|
||||
| `X7` | `CR-003` | `C3` | Sequencing | Priority color coding needs one source of truth for color, or Normal/High/Urgent get four implementations. |
|
||||
| `X8` | `CR-007` | `F2`, `S2` | **Blocking** | Offline drawing access is a tablet feature and the app bar breaks at 390px. Uploading into a form with no autosave risks losing the upload. |
|
||||
| `X9` | `CR-005` | `A4`, `F5`, `S9` | Sequencing | A new wizard step should be built during the stepper rebuild, with the read-only-looking fields and keyboard access fixed at the same time. |
|
||||
| `X10` | `CR-002`, `CR-016` | `CR-006`, `C3` | Sequencing | Both removals are expressed as toggles, not deletions. Without token consolidation the toggled styling drifts. |
|
||||
|
||||
## 4. Wave order
|
||||
|
||||
| Wave | Focus | File | Items |
|
||||
|---|---|---|---|
|
||||
| 0 | Orientation and file map | `docs/waves/wave-0.md` | — |
|
||||
| 1 | Rendering defects and tooling | `docs/waves/wave-1.md` | `F1` `F3` `F4` `F5`(interim) `S13` |
|
||||
| 2 | Drawer everywhere, flat strip deleted | `docs/waves/wave-2.md` | `B1` `S4` `F2` |
|
||||
| 3 | Token consolidation and button system | `docs/waves/wave-3.md` | `C3` `S5` `A3` `A5` |
|
||||
| 4 | Platform prerequisites | `docs/waves/wave-4.md` | `B4` `S3` `S2` `B5` `S10` `S11` `S12` |
|
||||
| 5 | Launcher and SOP wizard | `docs/waves/wave-5.md` | `A4` `B3` `S9` `CR-005` `CR-006` `CR-002` `CR-016` `S1`(wizard) |
|
||||
| 6 | Work package general information | `docs/waves/wave-6.md` | `CR-001` `CR-003` `CR-004` `CR-018` `A7` |
|
||||
| 7 | The creator | `docs/waves/wave-7.md` | `B7` `A1` `CR-015` `A2` `A6` `CR-014` `CR-007` `B6` `S1`(creator) `F6` `D1` `D2` `D3` `D4` `D5` `D8` `D9` `D10` |
|
||||
| 8 | Kitting and material | `docs/waves/wave-8.md` | `CR-009` `CR-010` `CR-011` `CR-012` `CR-013` `D6` `D10` |
|
||||
| 9 | Verification and cleanup | `docs/waves/wave-9.md` | `CR-008` `CR-017` `S6` `S7` `C1` `C2` `C4` `D7` |
|
||||
|
||||
**Waves 1 through 4 produce almost no field-visible change.** That is deliberate and it is
|
||||
roughly the first third of the effort. It is called out here because the Micron team is
|
||||
already skeptical about time spent, and silence during these waves reads as nothing
|
||||
happening. The trade is that waves 5 through 8 land once instead of being rebuilt, which is
|
||||
the failure mode the review documented at `work-package-suite-styles.css:336`.
|
||||
|
||||
### Deltas from the R2 sequence
|
||||
|
||||
The R2 document's nine-wave table did not place `A5`, `A7`, or `F6`. This plan places `A5`
|
||||
in wave 3 (it depends on token consolidation), `A7` in wave 6 (it is general-information
|
||||
surface work), and `F6` in wave 7 (its actual fix is section tabs, not a CSS change). `F2`
|
||||
moves fully to wave 2 because `B1` resolves it; wave 1 only stops the clipping.
|
||||
|
||||
## 5. Task format
|
||||
|
||||
Every task in a wave file follows this shape. Do not start a task that is missing a
|
||||
done-when list; flag it instead.
|
||||
|
||||
```markdown
|
||||
### T5.4 — CR-005: Upload location lists at SOP setup
|
||||
|
||||
- **Items:** CR-005
|
||||
- **Depends on:** T5.1
|
||||
- **Blocks:** T6.3
|
||||
- **Surface:** html/ + server/
|
||||
- **Files:** <paths from docs/reference/file-map.md>
|
||||
- **Do:** ...
|
||||
- **Do not:** ...
|
||||
- **Done when:**
|
||||
- [ ] verifiable check
|
||||
- [ ] verifiable check
|
||||
```
|
||||
|
||||
`Done when` entries must be checkable by running or looking at something. "Confirm with
|
||||
Nick" is never a done-when; it goes in the PR description.
|
||||
|
||||
## 6. Traceability
|
||||
|
||||
Every one of the 55 items appears in exactly one wave, except where noted.
|
||||
|
||||
| Item | Wave | Item | Wave | Item | Wave |
|
||||
|---|---|---|---|---|---|
|
||||
| CR-001 | 6 | F1 | 1 | A1 | 7 |
|
||||
| CR-002 | 5 | F2 | 1 interim, 2 full | A2 | 7 |
|
||||
| CR-003 | 6 | F3 | 1 | A3 | 1 interim, 3 full |
|
||||
| CR-004 | 6 | F4 | 1 | A4 | 5 |
|
||||
| CR-005 | 5 | F5 | 1 interim, 3 full | A5 | 3 |
|
||||
| CR-006 | 5 | F6 | 7 | A6 | 7 |
|
||||
| CR-007 | 7 | S1 | 5 wizard, 7 creator | A7 | 6 |
|
||||
| CR-008 | 9 | S2 | 4 | B1 | 2 |
|
||||
| CR-009 | 8 | S3 | 4 | B2 | 2 |
|
||||
| CR-010 | 8 | S4 | 2 | B3 | 5 |
|
||||
| CR-011 | 8 | S5 | 3 | B4 | 4 |
|
||||
| CR-012 | 8 | S6 | 9 | B5 | 4 |
|
||||
| CR-013 | 8 | S7 | 9 | B6 | 7 |
|
||||
| CR-014 | 7 | S8 | 9 | B7 | 7 |
|
||||
| CR-015 | 7 | S9 | 5 | C1 | all, audited in 9 |
|
||||
| CR-016 | 5 | S10 | 4 | C2 | all, audited in 9 |
|
||||
| CR-017 | 9 (guard only) | S11 | 4 | C3 | 3 |
|
||||
| CR-018 | 6 | S12 | 4 | C4 | 4 and 9 |
|
||||
| | | S13 | 1 | | |
|
||||
|
||||
### The August 18 items
|
||||
|
||||
| Item | Wave | Task | Item | Wave | Task |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 7 | T7.1 | D6 | 8 | T8.6 |
|
||||
| D2 | 7 | T7.6 | D7 | 9 | T9.8 |
|
||||
| D3 | 7 | T7.2 | D8 | 7 | T7.7 |
|
||||
| D4 | 7 | T7.3 | D9 | 7 | T7.6 |
|
||||
| D5 | 7 | T7.10 | D10 | 7 and 8 | T7.6, T8.3 |
|
||||
|
||||
That is 65 items across 9 waves. `D10` is the only one split across two waves: the gate and
|
||||
its console control are built once at `T7.6` and reused by `T8.3`.
|
||||
|
||||
`F5` and `A3` are the same defect from two sources. `C1` and `C2` are standing requirements
|
||||
enforced per task under `CLAUDE.md`, with a dedicated audit in wave 9. `CR-017` is a
|
||||
do-not-remove guard rather than a build.
|
||||
|
||||
## 7. Out of scope
|
||||
|
||||
Deferred by decision. Do not build these, and do not treat them as gaps:
|
||||
|
||||
- Full material request and inventory system, including the parts catalog and warehouse
|
||||
integration. Revisit once Paul Coonrod is engaged.
|
||||
- Asset database integration. Clinton's team owns that database; the Assets section is
|
||||
toggled off in the interim (`CR-016`).
|
||||
- CxAlloy or GC QA system integration. The QA gate is notification-only until CxAlloy lands.
|
||||
- P6 activity list import and validated lookup. `CR-001` uses free text.
|
||||
|
||||
## 8. Inputs the plan is waiting on
|
||||
|
||||
These do not block code, but they block the data that makes several features real. Track
|
||||
them; do not invent placeholder values in their absence.
|
||||
|
||||
| Input | Owner | Blocks |
|
||||
|---|---|---|
|
||||
| B100 floor and area list | David Leal | `CR-004`, `CR-005` real data |
|
||||
| Material spreadsheet | Nate, via David Leal | `CR-013` field set |
|
||||
| Master material workbook, preferred request format | Paul Coonrod | `CR-013` scope |
|
||||
| QA process of record (Excel questionnaire moving into CxAlloy) | David Ramstorf, Lupe, Cody Schaefer | `CR-014` beyond notification |
|
||||
|
||||
Where a list is required and not yet supplied, build the upload path (`CR-005`) and seed with
|
||||
an obviously-fake sample. Do not hardcode guessed floor names.
|
||||
|
||||
**Status, August 18 2026.** None of the four has arrived. Confirmed at the wave 6 exit that
|
||||
this does not change the plan: `D6` extends the `CR-005` upload pattern to the material list
|
||||
so `CR-013` is buildable without Nate's spreadsheet, and the B100 list is still expected
|
||||
through the same door. Because no real list has ever been loaded, every location on record
|
||||
is sample data - which is why the free-text migration question was closed without work
|
||||
(`decisions-2026-08-18.md`, "Closed without work"). That answer does not survive the first
|
||||
real project.
|
||||
@@ -12,6 +12,7 @@ Close an entry by deleting it in the same commit that fixes it.
|
||||
|---|-------|----------|--------|--------|
|
||||
| 1 | XSS via SOP discipline names in the WP creator | Medium (internal), High if externally reachable | 2026-08-05 | Open |
|
||||
| 2 | Archived projects: the two big apps don't grey out their own controls | Low | 2026-08-05 | Open |
|
||||
| 3 | Export is not one merged PDF; drawings ride along as a list | Low | 2026-08-20 | Open — decided |
|
||||
|
||||
---
|
||||
|
||||
@@ -62,6 +63,14 @@ the token cannot be read, but the injected code does not need it: it runs in the
|
||||
victim's page and can call any API the victim can, including
|
||||
`POST /api/auth/users/{id}/role`.
|
||||
|
||||
**The `project_super_user` role (added 2026-08-05) widens the set of victims whose
|
||||
session is worth stealing, without raising the ceiling.** Previously only an app
|
||||
admin's session could create accounts or change permissions; now a super user's can
|
||||
too, within the projects they administer. The ceiling is unchanged — it was already
|
||||
`admin` — but the odds of landing on a session that can mint an account go up, and a
|
||||
super user is likelier than an admin to be reading a WP creator on a live job. It is
|
||||
one more reason the accidental-breakage case is not the only one that matters.
|
||||
|
||||
Two controls that look like they would contain this do not:
|
||||
|
||||
- **CSP does not mitigate it.** `nginx-wp-suite.conf:58` serves
|
||||
@@ -83,6 +92,12 @@ malicious one.
|
||||
suite is exposed outside the corporate network, accounts are issued to
|
||||
subcontractors or clients, or self-registration is added.
|
||||
|
||||
Note that the second of those got easier to reach without anyone deciding to: a
|
||||
Project Super User can now issue accounts on their own job without an app admin
|
||||
involved, so "accounts are issued to subcontractors" can become true by ordinary
|
||||
delegated use rather than by a policy change. Worth checking the directory
|
||||
occasionally against who is actually on staff.
|
||||
|
||||
### What closing it takes
|
||||
|
||||
Small — roughly half an hour. The helper already exists; it was added to the SOP
|
||||
@@ -102,8 +117,9 @@ function escHandlerArg(v){ return escAttr(String(v==null?'':v).replace(/\\/g,'\\
|
||||
4. Confirm with a discipline named `Owner's Equipment`: the pill must respond to
|
||||
clicks and the name must display intact.
|
||||
|
||||
The equivalent fix on the admin side is `jsq()` in `html/admin.js` — same ordering,
|
||||
same reasoning, worth reading before starting.
|
||||
The equivalent fix on the admin side is `jsq()` in `html/console-util.js` (it moved
|
||||
out of `html/admin.js` on 2026-08-05 when the User Directory started needing it) —
|
||||
same ordering, same reasoning, worth reading before starting.
|
||||
|
||||
---
|
||||
|
||||
@@ -146,3 +162,35 @@ save/issue controls, or add a boot check in each app that disables them and show
|
||||
read-only notice inline. Decide separately how the embedded creator
|
||||
(`wp-creation-index.html`) surfaces it, since it runs in an iframe where the shared
|
||||
app bar — and therefore the banner — is deliberately skipped.
|
||||
|
||||
---
|
||||
|
||||
## 3. Export is not one merged PDF; drawings ride along as a list
|
||||
|
||||
**Files:** `html/wp-creation-app.js` (the T9.1 export walk), `CR-008`
|
||||
**Decided:** 2026-08-20, by Nick — "add this to known issues."
|
||||
|
||||
### What is wrong
|
||||
|
||||
CR-008 asked for the work package "as one document." What shipped (T9.1)
|
||||
renders every section inline — including images — and lists PDF drawing
|
||||
attachments with links, rather than merging their pages into a single PDF.
|
||||
|
||||
### What it costs
|
||||
|
||||
A crew printing the package gets the form and the inline images in one pass,
|
||||
but linked PDF drawings are separate opens/prints. For field hand-offs that
|
||||
want literally one file, someone stitches it manually.
|
||||
|
||||
### Why it is still open
|
||||
|
||||
Real PDF merging needs either a server-side PDF library (a new dependency and
|
||||
a render pipeline for arbitrary uploaded PDFs) or a client-side one (heavy,
|
||||
and the creator is deliberately dependency-free). The recommendation made at
|
||||
T9.1 — inline images + listed PDFs — was accepted as the shipped behaviour.
|
||||
|
||||
### What closing it takes
|
||||
|
||||
A server-side merge endpoint (e.g. pypdf) that concatenates the rendered
|
||||
package with each attached PDF, streamed back as one download; plus a size
|
||||
ceiling consistent with D8's upload limits. One task, one new dependency.
|
||||
|
||||
120
UX-REVIEW-2026-08-14.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# UX Review — Work Package Suite
|
||||
|
||||
**Date:** 2026-08-14 · **Branch:** `users/directory-super-user` · **Scope:** `html/` — 6 pages, 4 stylesheets, ~11.9k lines
|
||||
|
||||
Full write-up with screenshots: <https://claude.ai/code/artifact/09d89aa1-54b9-41db-9e09-e1fc8fb55852>
|
||||
|
||||
**Method.** Two passes. A full read of the frontend, then the app run locally
|
||||
(`uvicorn server.app:app`, throwaway SQLite DB) and driven in Chrome through the real
|
||||
first-run flow: sign in → create project → SOP wizard → creator → directory → field view
|
||||
at 390px. Section 1 below is browser-verified; section 2 is read from source.
|
||||
|
||||
> Incidental find: `server/seed_demo.py` no longer authenticates, so it can't seed a
|
||||
> running instance — every `/api/` route requires a session and the script sends none.
|
||||
> It predates the commit that taught the smoke test to sign in.
|
||||
|
||||
---
|
||||
|
||||
## 1. Rendering defects (browser-verified, reproducible today)
|
||||
|
||||
No design decisions needed for any of these.
|
||||
|
||||
| # | Defect | Severity |
|
||||
|---|---|---|
|
||||
| F1 | **Header project switcher is stale on every page.** Hero, picker and create-user card show the active project; the app bar still reads "Select a project". Two sources of truth, global one loses. | High |
|
||||
| F2 | **App bar breaks at 390px — on Field View.** Nav wraps to 3 rows and clips: "Sign out" cut in half, search truncated. This is the gloved-hands surface. | High |
|
||||
| F3 | **Logo/project-name collision in the SOP header.** `.header-left` and the injected chrome fight for the same run of the bar. | Medium |
|
||||
| F4 | **Comments drawer renders off-screen over the header** in the standalone creator. | Medium |
|
||||
| F5 | **SOP wizard fields read as read-only** — `#f4f4f4` fill, `#e0e0e0` border. Cause: the wizard redeclares its own tokens and never sees `--cds-field: #ffffff`. | Medium |
|
||||
| F6 | **The creator is one ~4,700px form** — 11 cards rendered at once, jump links standing in for structure. | Medium |
|
||||
|
||||
F1 and F2 are prerequisites for the proposed chrome work.
|
||||
|
||||
---
|
||||
|
||||
## 2. Structural findings (from source)
|
||||
|
||||
### Costs users work
|
||||
- **Validation is `alert()`-only, covering 3 of 10 wizard steps.** 79 native dialogs app-wide,
|
||||
43 in the creator. `wp-creation-app.js:1144` says "Subject and WP Type are required" without
|
||||
naming, highlighting or scrolling to the field — on a form ten cards deep.
|
||||
`validateStep` guards only steps 1, 5, 6 while the markup marks required fields throughout.
|
||||
- **No autosave, no unsaved-work guard.** The only `beforeunload` listener is analytics dwell
|
||||
tracking (`work-package-suite-app.js:326`).
|
||||
- **Nothing is linkable.** No `pushState` anywhere. Tab switches, dashboard views and package
|
||||
selections change no URL — can't send someone a link to WP07; refresh loses your place.
|
||||
- **No global nav on the two pages people live in.** Drawer + app bar ship on `admin`, `field`,
|
||||
`users`. The SOP wizard has its own header and no drawer; the creator has neither, plus a
|
||||
different brand treatment (mono wordmark vs. logo chip).
|
||||
|
||||
### Costs consistency
|
||||
- **Four parallel token systems.** `#0f62fe` is declared 4× (`--cds-interactive-01`, `--primary`,
|
||||
`--accent` twice). Already caused a bug — see the comment at
|
||||
`work-package-suite-styles.css:336` about `.field-hint` rendering unstyled.
|
||||
- **Icon set is mixed emoji + dingbats** (⤓ ⤒ 👁 ★ 📊 ⧉ 🕘 💬 ▤ ⚙ 🔒 ⚡ ⎙ ◔ ▦). `▤` is used for
|
||||
two different things on the same page.
|
||||
- **Four sample-data affordances under three names.** One sits a click from live project data
|
||||
with no confirm and no undo.
|
||||
|
||||
### Costs accessibility (and the field case)
|
||||
- **Hover-only tooltips carry load-bearing instructions.** 15 `.help-tip` badges, `<span>`s with
|
||||
no `tabindex` — so the `:focus` half of their CSS rule is dead code and there's no touch path.
|
||||
- **SOP step rail not keyboard-reachable.** All 10 steps are `<div onclick>`; 12 `<div>` + 2
|
||||
`<span>` click handlers app-wide.
|
||||
- **Zero `aria-live` regions.** Both toast systems and every admin banner announce nothing.
|
||||
`login.html` gets this right with `role="alert"`/`role="status"`; the pattern never spread.
|
||||
- **Helper text at ~2.9:1** (`--text-dim: #8d8d8d` at 12px). `console.css:85-87` already
|
||||
diagnosed and fixed this; the other two sheets didn't follow.
|
||||
- **Wizard removes the focus ring** — `outline: none` plus a pale 3px glow
|
||||
(`work-package-suite-styles.css:322-328`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Assessment of the proposed change list
|
||||
|
||||
Strong work — nearly every item maps to something verifiable, and two rank above anything in
|
||||
this review's own findings. 9 endorsed, 7 need adjustment, 6 gaps.
|
||||
|
||||
### Endorse as proposed
|
||||
| ID | Item | Note |
|
||||
|---|---|---|
|
||||
| A1 | **Guarded status transitions** | Best item on the list. But: Issue (Hold) is a *branch* from several states, and there's a deliberate **logged-override** path for predecessors (`wp-creation-app.js:1962-1972`). "One legal next action" is too narrow — model as primary-next **+ audited override**. |
|
||||
| A2 | De-duplicate constraint warning | Triplication confirmed. Caveat: the top banner is the only one visible without scrolling, so the tab count badge must carry that weight. |
|
||||
| A3 | Fields that look editable | Confirmed (F5). Pair with token consolidation or it drifts back. |
|
||||
| A4 | Vertical stepper + progress | Also retires the orphaned "1 / 10" counter. Make the steps real `<button>`s while rebuilding. |
|
||||
| A5 | One button system, no green/blue mix | Real: green `Save & View` + green `SOP Complete` vs blue elsewhere. **Scope "sentence case" to buttons/field labels** — the uppercase card-header idiom in `console.css` is deliberate and works. |
|
||||
| A6 | Sidebar triage data + inline hold reason | Cheap — hold reason is already captured by the hold modal. |
|
||||
| A7 | Card status lines · footer clarity · Language & time | Localization is a real shipped feature (`admin.js:484-517`), so keeping it is correct. |
|
||||
|
||||
### Right diagnosis, adjust the prescription
|
||||
| ID | Item | Adjustment |
|
||||
|---|---|---|
|
||||
| B1 | Collapse the 8-item nav | **Don't build a new avatar menu — ship the drawer you already have.** It's the best chrome in the app and already holds the whole nav; it's just on 3 pages of 6 while a flat strip duplicates it. Put it everywhere, delete the strip. Fixes F2. |
|
||||
| B2 | Un-truncate the switcher | Fix **correctness (F1) first**. "Micron EUV Cleanroom Enable · 2667008" won't fit 280px, and existing rules narrow it to 150px under 900px — needs a breakpoint plan. |
|
||||
| B3 | Remove the project-picker card | The first-run empty state lives *inside* that card. A header dropdown is the wrong home for "create your first project" — the launcher needs an explicit empty state. |
|
||||
| B4 | 4-cell pipeline strip | Hard data dependency: launcher SOP status reads `localStorage`, and the creator's dashboard admits it "reads local data now; wires to SQL in Phase 2". Per-browser numbers that look authoritative are worse than none. **Server aggregates first.** |
|
||||
| B5 | Autosave status text | Must ship with **actual autosave + dirty guard**. A "✓ All changes saved" toast already appears (that's the sync outbox, not your draft), so the app arguably implies this today. |
|
||||
| B6 | Wizard actions beside the fields | On Constraints/Sequence steps that means scrolling to save. **Prefer sticky** — the creator's sticky save bar already solves it. |
|
||||
| B7 | Toolbar into the tab row | Unstated prerequisite: tab row is in the parent, toolbar in the iframe child. This **requires dissolving the iframe** — the largest engineering item on the list. Name and schedule it. |
|
||||
|
||||
### Not addressed
|
||||
| ID | Gap |
|
||||
|---|---|
|
||||
| C1 | **Accessibility** — and the redesign touches every broken part. The step rail being rebuilt is 10 `<div onclick>`s; the status pills being replaced are the moment for a real radio group; the tooltips are unreachable on the tablet Field View targets. Skipping these means paying for the same components twice. |
|
||||
| C2 | **Mobile** — no item on the list, and it's where the worst rendering is (F2). |
|
||||
| C3 | **Token consolidation** — without it, "one button system" gets implemented four times and drifts, exactly as `.field-hint` did. |
|
||||
| C4 | **URL state · icon system · unsaved-changes guard · sample-data sprawl** — none addressed. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommended sequence
|
||||
|
||||
Ordered by dependency, not preference.
|
||||
|
||||
1. **The six rendering defects.** No design decisions; F1/F2 unblock the chrome work.
|
||||
2. **Drawer everywhere, flat strip deleted.** Reuses the best existing component, fixes phone-width collapse, settles global nav before anything is redrawn.
|
||||
3. **Token consolidation.** One source of truth, page sheets aliasing to it. Every later visual item lands once instead of four times.
|
||||
4. **Launcher + SOP wizard.** Pipeline strip (after server aggregates), empty-state ownership, vertical stepper, editable fields, autosave + guard. Make the 10 steps buttons while rebuilding.
|
||||
5. **The creator.** Guarded transitions, de-duplicated warnings, section tabs, triage data. **Decide the iframe question up front** — most of this depends on it.
|
||||
|
||||
**Dependencies:** B1 resolves F2 · B2 depends on F1 · A3 depends on C3 · B7 gates most of step 5.
|
||||
73
docs/reference/accessibility-audit.md
Normal 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 |
|
||||
97
docs/reference/baseline/README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Baseline — August 14, 2026
|
||||
|
||||
**Task:** `T0.2` · **Branch:** `feat/wp-suite-r2-implementation` · commit before wave 1
|
||||
|
||||
The before images every later PR compares against, and the record of which rendering defects
|
||||
were confirmed present at the start.
|
||||
|
||||
## Screenshots
|
||||
|
||||
14 images, `<page>-<width>.png`, at 390px and 1440px. The plan says 12 (6 pages × 2); there are
|
||||
7 pages, so there are 14 — see `file-map.md` D1.
|
||||
|
||||
| Page | 390px | 1440px |
|
||||
|---|---|---|
|
||||
| login | `login-390.png` | `login-1440.png` |
|
||||
| launcher | `launcher-390.png` | `launcher-1440.png` |
|
||||
| SOP wizard | `sop-390.png` | `sop-1440.png` |
|
||||
| creator | `creator-390.png` | `creator-1440.png` |
|
||||
| admin | `admin-390.png` | `admin-1440.png` |
|
||||
| field view | `field-390.png` | `field-1440.png` |
|
||||
| directory/users | `users-390.png` | `users-1440.png` |
|
||||
|
||||
Regenerate, or capture the "after" half of a comparison:
|
||||
|
||||
```bash
|
||||
python tests/baseline_shots.py # -> here
|
||||
python tests/baseline_shots.py --out /tmp/after --label after
|
||||
```
|
||||
|
||||
390px is captured with Chrome's mobile flag set, not as a narrow desktop window. Every page
|
||||
declares `width=device-width`, so this is the layout a field tablet actually gets. The script
|
||||
asserts the width it asked for is the width the page saw.
|
||||
|
||||
### Two pages are not byte-stable — do not diff them blindly
|
||||
|
||||
Found at `T1.5`. Capturing twice with **no code change at all** produces different bytes for
|
||||
`admin` and `users` at both widths. `login`, `launcher`, `sop`, `creator` and `field` are
|
||||
stable. The console pages render live timestamps, so a byte comparison of them reports a
|
||||
change on every run.
|
||||
|
||||
A task whose done-when is "no layout change at 1440px" therefore cannot use a byte diff on
|
||||
those two. Run the capture twice before drawing any conclusion, or compare a page that is
|
||||
stable. Four of the "changes" in this document's own history were this, not code.
|
||||
|
||||
## F1–F6: all six reproduce
|
||||
|
||||
Measured in a browser by `tests/f_items.py`, not read from source. Re-run any time:
|
||||
|
||||
```bash
|
||||
python tests/f_items.py # all six
|
||||
python tests/f_items.py F2 F5 # a subset, after one task
|
||||
```
|
||||
|
||||
Each probe reports `REPRODUCES`, `FIXED` or `INCONCLUSIVE` — never a silent pass. The same
|
||||
script is the regression check for waves 1–3: an item is done when its probe flips to `FIXED`.
|
||||
|
||||
| Item | Verdict | Measured | Evidence |
|
||||
|---|---|---|---|
|
||||
| **F1** | REPRODUCES | Picked "Job A" in the launcher picker: hero became `Job A`, app bar stayed `Select a project`, `wp_active_project=projA`, no reload. | `launcher-1440.png` |
|
||||
| **F2** | REPRODUCES | Field view at 390px: `Sign out` occupies x 382–432 against a 390px viewport — cut in half. Bar is 424px of content in 374px, ~3 rows tall. | `field-390.png`, `launcher-390.png` |
|
||||
| **F3** | REPRODUCES | SOP header with the real long name: injected chrome paints over the logo by 106×32px at 1024px and 41×32px at 1440px; `.header-left` collapses to `clientWidth 0`. | `f-evidence/F3-sop-header-*-longname.png` |
|
||||
| **F4** | REPRODUCES | Standalone creator: comments drawer overlaps the header by 380×91px once open. | `creator-1440.png` |
|
||||
| **F5** | REPRODUCES | 5 of 5 **enabled** wizard inputs compute to `rgb(244,244,244)` fill with `rgb(224,224,224)` border — the `#f4f4f4`/`#e0e0e0` the review named. | `sop-1440.png` |
|
||||
| **F6** | REPRODUCES | Creator is 11 cards in a single 5,017px scroll, 0 sectioning controls, 3 jump links. Review said ~4,700px; it has grown. | `creator-1440.png` |
|
||||
|
||||
### Notes that change how a fix gets verified
|
||||
|
||||
- **F1** is only visible if `localStorage` is *not* primed first. Setting both
|
||||
`wp_active_project` and `wp_active_project_obj` before load makes the two sources agree and
|
||||
hides the defect. The probe clears storage and drives the real picker.
|
||||
- **F3** is only visible with a genuinely long project name, and it has to be long **in the
|
||||
database** — any page reached with `?project=` re-pulls the project from the server and
|
||||
overwrites a name faked in `localStorage`. The probe seeds
|
||||
`Micron EUV Cleanroom Enable 2667008` as a real project.
|
||||
- **F3** cannot be measured by comparing `.header-left` to the chrome. Under the long name
|
||||
`.header-left` (`flex:1; min-width:0`) collapses to zero width, so that comparison reports a
|
||||
tidy zero gap while the chrome is painting across the logo. The probe measures against
|
||||
`.logo`, which is `flex-shrink:0` and therefore the one box in the bar whose position means
|
||||
something. A fix that leaves `.header-left` collapsed has not fixed F3.
|
||||
- **F5** must ignore genuinely disabled inputs, or the fix looks done while real fields stay
|
||||
grey. The probe counts only enabled, visible, non-hidden fields.
|
||||
|
||||
## Horizontal overflow, measured at capture time
|
||||
|
||||
`documentElement.scrollWidth` against `clientWidth`. Recorded because four pages overflow at
|
||||
390px and one also overflows at desk width, which no `F` item covers.
|
||||
|
||||
| Page | 390px viewport | 1440px viewport |
|
||||
|---|---|---|
|
||||
| launcher | 425px content | — |
|
||||
| SOP wizard | 429px content | — |
|
||||
| creator | 485px content | **1551px content** |
|
||||
| field view | 432px content | — |
|
||||
| login, admin, users | fits | fits |
|
||||
|
||||
The 1440px creator overflow is logged as `BL-001`. The 390px ones are `F2` and its
|
||||
neighbourhood, resolved properly by `B1` in wave 2.
|
||||
BIN
docs/reference/baseline/admin-1440.png
Normal file
|
After Width: | Height: | Size: 296 KiB |
BIN
docs/reference/baseline/admin-390.png
Normal file
|
After Width: | Height: | Size: 243 KiB |
BIN
docs/reference/baseline/after-wave9/admin-390.png
Normal file
|
After Width: | Height: | Size: 249 KiB |
BIN
docs/reference/baseline/after-wave9/creator-390.png
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
docs/reference/baseline/after-wave9/field-390.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
docs/reference/baseline/after-wave9/launcher-390.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
docs/reference/baseline/after-wave9/login-390.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
docs/reference/baseline/after-wave9/sop-390.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
docs/reference/baseline/after-wave9/users-390.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
docs/reference/baseline/creator-1440.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
docs/reference/baseline/creator-390.png
Normal file
|
After Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 25 KiB |
BIN
docs/reference/baseline/field-1440.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
docs/reference/baseline/field-390.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
docs/reference/baseline/launcher-1440.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
docs/reference/baseline/launcher-390.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
docs/reference/baseline/login-1440.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
docs/reference/baseline/login-390.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
docs/reference/baseline/sop-1440.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
docs/reference/baseline/sop-390.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
docs/reference/baseline/users-1440.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
docs/reference/baseline/users-390.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
115
docs/reference/completion.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Completion — the R2 plan, reconciled (T9.7, 2026-08-19)
|
||||
|
||||
All 65 items accounted for: the 55 in `IMPLEMENTATION.md` §6 and the 10 in
|
||||
`docs/waves/decisions-2026-08-18.md`. Delivery references are task ids; the
|
||||
branch is local-only by instruction (one task = one commit, ids in every
|
||||
commit message), so the commit history IS the PR trail. Verification counts
|
||||
name the probe that re-checks the item on every run.
|
||||
|
||||
## Change requests
|
||||
|
||||
| Item | Status | Delivered by | Notes / deviations |
|
||||
|---|---|---|---|
|
||||
| CR-001 | built | T6.1 (`generalinfo_check` 49) | |
|
||||
| CR-002 | built | waves 1–2, 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 |
|
||||
|---|---|---|---|
|
||||
| F1–F5 | built | waves 1–3 | |
|
||||
| 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** |
|
||||
| S2–S5, S9–S12 | built | waves 2–5 | |
|
||||
| S6 | built | T9.3 (`icon_check` 5) | one monochrome system, mapped in `tokens.md` |
|
||||
| S7 | built | T9.4 (`sample_check` 10) | one affordance, confirmed, fenced — verified against a real project |
|
||||
| S8 | built | T9.5 (`helptip_check` 13) | 20 badges → buttons; **closed BL-001** |
|
||||
| S13 | built | T1.6 (re-verified 2026-08-20) | this row said "open / does not sign in" until Aug 20 - a records error: T1.6 fixed it in wave 1 (it reuses smoketest's login) and the wave-1 exit box was simply never ticked. Verified live: sign-in, seed, `--clean` |
|
||||
| A1 | preserved | T7.3 | `confirmEarlyRelease()` by name; async now, same contract |
|
||||
| A2 | built | T7.4 (`warning_check` 17) | one warning; the count on the sticky rail |
|
||||
| A3–A5, A7 | built | waves 1–6 | localization re-verified through T7.10's admin edits (`cards_check`) |
|
||||
| A6 | built | T7.5 (`triage_check` 16) | |
|
||||
| B1–B5 | built | waves 2–5 | |
|
||||
| B6 | built | T7.8 (`sticky_bar_check` 12) | |
|
||||
| B7 | built | T7.1 (`frame_check` 38) | **deviation, stated in the commit:** the creator became its own page rather than merging into the parent — measured trade (0 collisions vs 21+9) |
|
||||
| C1 | audited | T9.5 (`accessibility-audit.md`) | every metric probe-backed |
|
||||
| C2 | audited | T9.6 (`mobile_check` 24) | screenshots committed beside the wave 0 baseline |
|
||||
| C3 | built | wave 3 | |
|
||||
| C4 | built | wave 4 interim, T9.9 full (`color_check` 4) | zero literals outside `theme-light.css` |
|
||||
|
||||
## The August 18 decisions
|
||||
|
||||
| Item | Status | Delivered by |
|
||||
|---|---|---|
|
||||
| D1 | built | T7.1 (sample controls visible; consolidated at T9.4 per the S7 reconciliation) |
|
||||
| D2 | built | T7.6 (QA group on the SOP wizard) |
|
||||
| D3 | built | T7.2 (rail + collapse; the "at rest" amendment recorded) |
|
||||
| D4 | built | T7.3 (Urgent surfaces the audited path; the override names what it crosses) |
|
||||
| D5 | built | T7.10 (one analytics core; admin report) |
|
||||
| D6 | built | T8.6 (material list, the CR-005 pattern, one shared component) |
|
||||
| D7 | built | T9.8 (`archived_check` 15) |
|
||||
| D8 | built | T7.7 (5MB / PDF+image / 2GB, 80% warning) |
|
||||
| D9 | built | T7.6 (Field View text pill at 390px) |
|
||||
| D10 | built | T7.6 / reused T8.3 (stored setting, admin-only, audited, sink-verified) |
|
||||
| D11 | built | merge of `origin/Micron-Assets` + integration, Aug 20 (`assets_check`); see `decisions-2026-08-20.md` |
|
||||
|
||||
## Out of scope, confirmed unbuilt
|
||||
|
||||
- **Parts catalog / live inventory / warehouse integration** — `mreq_check` and
|
||||
`materials_check` grep the model and the diff for stock/inventory/price
|
||||
fields on every run; none exist. D6's uploaded list is project-scoped data
|
||||
entry, not a catalog.
|
||||
- **Asset database integration** — SUPERSEDED by D11 on Aug 20: Cody Schaefer's
|
||||
Micron asset picker (read-only catalog lookup, `origin/Micron-Assets`) merged
|
||||
and adapted to the R2 creator. The assets section stays a CR-006 toggle
|
||||
(off on the Micron sample). This line was true when written.
|
||||
- **CxAlloy integration** — CR-014 is notification-only, as the task footnote
|
||||
ordered; the platforms block stores names and URLs, nothing calls them.
|
||||
- **P6 activity import** — CR-001 renders the two fields; nothing imports.
|
||||
|
||||
## Outstanding inputs (IMPLEMENTATION.md §8, restated)
|
||||
|
||||
- Nate's spreadsheet and the master material workbook: **still not supplied.**
|
||||
D6 built the upload path so their arrival is a paste, not a build.
|
||||
- The real B100 floor/area list: **still not supplied.** CR-005's upload is
|
||||
ready for it; every seeded value remains obviously fake.
|
||||
- SMTP host/credentials for production mail: the gate ships off; the password
|
||||
is env-only. Nothing on this branch has sent a real email.
|
||||
|
||||
## For the next revision
|
||||
|
||||
- **BL-020** — the wizard→creator navigation prompts to leave (T4.3's guard
|
||||
doing its job on what is now a page exit); product call on suppression.
|
||||
- **BL-021** — `project_sop_team()` reads a path `pushSOP` never writes; the
|
||||
critical-reopen mail has never reached the PM/CM. One line, needs its own
|
||||
sink verification.
|
||||
- **BL-022** — F6's "roughly two screens": 2.17 vs the strict 2.0. Bless it or
|
||||
name the chrome to trim.
|
||||
- **BL-023** — the productivity factor (actual ÷ estimated); data already
|
||||
aggregated, placement needs Nick.
|
||||
- **BL-024** — 21 native dialogs on admin/users/launcher; the T7.9 kit is
|
||||
ready for them.
|
||||
- Product questions raised in commit messages, awaiting answers: hold
|
||||
reachable from Draft/Scheduled (T7.3); CR-014 email body content (T7.6);
|
||||
merged-PDF export (T9.1).
|
||||
- Acceptance criteria that turned out wrong, for the next plan's calibration:
|
||||
F6's height bar collided with D3's own chosen design (amended once, then
|
||||
left red rather than moved again); A2's "tab count badge" predated D3
|
||||
removing tabs (the rail carried it); S1's "0 dialogs" never named the
|
||||
console pages that held a quarter of them.
|
||||
232
docs/reference/creator-frame.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# The creator's iframe boundary — what `T7.1` has to untangle
|
||||
|
||||
**Produced for:** `T7.1` (`B7`) · **Measured:** August 17, 2026 · **Branch:** `feat/wp-suite-r2-implementation`
|
||||
**Read by:** `T7.1`, and every task from `T7.2` down, since all of them depend on it
|
||||
|
||||
`T7.1` is described in the plan as the largest engineering item in it, and the wave file is
|
||||
explicit that it ships as its own change with no feature work attached. This document is the
|
||||
measurement that should precede it — the same role `docs/reference/tokens.md` played for
|
||||
`T3.2`, and produced for the same reason: the estimate in the plan came from a read of the
|
||||
symptom, not a count of the work.
|
||||
|
||||
**Nothing here is a decision.** It is four numbers and where they come from.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the boundary actually is
|
||||
|
||||
`work-package-suite.html` declares `<iframe id="wp-frame">` with no `src`.
|
||||
`work-package-suite-app.js` sets it at runtime to
|
||||
`wp-creation-index.html?embedded=1&project=<id>`.
|
||||
|
||||
The creator is the only page in `html/` that loads **neither `wp-chrome.css` nor
|
||||
`wp-chrome.js`** — that is why it has no app bar of its own and why it reads as part of the
|
||||
wizard rather than as a page. Dissolving the frame means deciding whether it gets that chrome
|
||||
back or deliberately does not; the wave file leaves that open and it is the one design
|
||||
question inside an otherwise mechanical task.
|
||||
|
||||
---
|
||||
|
||||
## 2. The four collisions, counted
|
||||
|
||||
Merging two documents means merging four namespaces. Three of the four are far smaller than
|
||||
they look, and the fourth is zero.
|
||||
|
||||
| Namespace | Wizard | Creator | Colliding |
|
||||
|---|--:|--:|--:|
|
||||
| Page-stylesheet class/id selectors | 104 | 307 | **21** |
|
||||
| Script top-level names | 156 | 299 | **9** |
|
||||
| Markup `id` attributes | 96 | 127 | **0** |
|
||||
| Cross-frame call sites | — | — | **28**, across 8 scripts and 1 page |
|
||||
|
||||
### 2a. The 21 colliding selectors
|
||||
|
||||
```
|
||||
active add-btn col1 drag-over dragging field field-grid field-hint
|
||||
gate header header-title is-current modal notice seq-arrow
|
||||
seq-gate-badge seq-handle seq-label seq-num seq-step sub-heading
|
||||
```
|
||||
|
||||
`file-map.md` §2 predicted exactly this and said not to assume it stays harmless: *"They are
|
||||
mutually exclusive per page today … so nothing currently breaks — wave 3 must not assume that
|
||||
stays true once chrome is unified."* It is now the thing that breaks.
|
||||
|
||||
Nine of the twenty-one are the **sequence editor** (`seq-*`, `drag-over`, `dragging`, `gate`),
|
||||
which exists in both because the wizard authors the sequence and the creator renders it. Those
|
||||
are the same component drawn twice, and merging them is a real question rather than a rename.
|
||||
|
||||
The other twelve are generic layout names — `field`, `notice`, `modal`, `header` — where the
|
||||
two sheets simply disagree about padding and type. Those are a rename or a scope, not a
|
||||
design decision.
|
||||
|
||||
### 2b. The 9 colliding script names
|
||||
|
||||
```
|
||||
ANALYTICS_KEY analyticsLoad analyticsSave downloadAnalytics showAnalytics
|
||||
exportComments importComments toggleComments track
|
||||
```
|
||||
|
||||
Every one of them is **the same feature implemented twice** — usage analytics and the feedback
|
||||
panel. None is a genuine name clash between two different things. That means the merge is a
|
||||
de-duplication rather than a rename, and it makes the count misleadingly small in the other
|
||||
direction: nine names, but two parallel implementations of two features behind them.
|
||||
|
||||
`showAnalytics` is the one to check first — `T5.8` recorded that the wizard's copy has **no
|
||||
caller in the wizard's markup**, because the "Usage data" button lives on the creator and calls
|
||||
the creator's own.
|
||||
|
||||
### 2c. Markup ids: zero collisions
|
||||
|
||||
96 and 127 ids, none shared. That is luck rather than design, and it is the single largest
|
||||
piece of good news in this document: every `getElementById` in both files keeps working.
|
||||
|
||||
### 2d. The 28 cross-frame call sites
|
||||
|
||||
Spread across `auth-guard.js`, `help.js`, `project-data.js`, `sw.js`,
|
||||
`work-package-suite-app.js`, `wp-chrome.js`, `wp-creation-app.js`, `wp-sidenav.js` and
|
||||
`work-package-suite.html`.
|
||||
|
||||
Three scripts branch on `window.top !== window.self` and change behaviour when the frame goes:
|
||||
|
||||
| Script | Framed behaviour today |
|
||||
|---|---|
|
||||
| `auth-guard.js` | redirects `window.top` to `login.html` |
|
||||
| `wp-chrome.js` | returns early, renders no chrome |
|
||||
| `help.js` | suppresses the Help FAB in the child |
|
||||
|
||||
And the parent reaches **into** the child at four call sites, all added or touched by waves 5
|
||||
and 6, all commented as `T7.1` removes them:
|
||||
|
||||
| Call | Added by | Purpose |
|
||||
|---|---|---|
|
||||
| `cw.openWpById(id)` | before wave 5 | open a deep-linked package |
|
||||
| `cw.showDashboard()` / `cw.showForm()` | before wave 5 | switch view |
|
||||
| `cw.dashApplyFlag(flag)` | `T5.3` | carry the pipeline strip's filter across |
|
||||
| `cw.applySopSections(sections, fields)` | `T5.5` / `T5.6` | carry a section toggle across |
|
||||
|
||||
The last two are the ones `X4` is about. **Both become unnecessary** when the frame goes —
|
||||
they exist only because the two documents cannot share a variable.
|
||||
|
||||
---
|
||||
|
||||
## 3. What wave 5 and 6 already did to shrink this
|
||||
|
||||
Recorded so `T7.1` does not redo it:
|
||||
|
||||
- **`T5.5` proved the SOP-borne path works.** Section toggles reach the creator through the
|
||||
SOP it reads at its own boot, with nothing crossing the boundary. The live hand-off is the
|
||||
*second* path and is the only part `T7.1` deletes. `X4`'s concern was resolved in wave 5 and
|
||||
is not outstanding.
|
||||
- **`T5.3`'s dashboard filter is URL state.** The creator reads `?flag=` at boot. The
|
||||
hand-off exists only for the already-loaded frame, and goes the same way.
|
||||
- **`T6.3`'s location dropdowns fetch from the server**, not from the parent. Nothing new
|
||||
crosses the boundary.
|
||||
|
||||
Every cross-frame call added since wave 4 is a *shim over the boundary*, is commented as such,
|
||||
and is deleted rather than migrated.
|
||||
|
||||
---
|
||||
|
||||
## 4. What this does not tell you
|
||||
|
||||
Three things `T7.1` has to settle that no count can answer:
|
||||
|
||||
1. **Does the creator get the app bar back?** It is the only page without one. Giving it one
|
||||
changes the wizard's layout maths (`chromeHeight()`, `--wp-chrome-h`, the `embed-full`
|
||||
sizing); not giving it one leaves a page that is not a page.
|
||||
2. **One sequence component or two?** Nine of the twenty-one selector collisions are the
|
||||
sequence editor. Merging it is a genuine consolidation; scoping it is a rename that leaves
|
||||
the duplication in place for wave 9 to find again.
|
||||
3. **What happens to `body.embedded`?** `wp-creation-styles.css` opens with
|
||||
`body.embedded .embed-hide { display: none }` — the creator hides its own header, its own
|
||||
sample-data controls and its own analytics button when framed. Dissolved, "framed" stops
|
||||
being a state and those controls need a home or a deletion.
|
||||
|
||||
`BL-001`, `BL-006`, `BL-007` and `BL-013` are all logged against `T7.1` and all live in
|
||||
`wp-creation-styles.css`. If that sheet is being scoped or rewritten anyway, they are cheaper
|
||||
now than they will ever be again — but they are separate items and `T7.1` says to bundle
|
||||
nothing.
|
||||
|
||||
---
|
||||
|
||||
## 5. What T7.1 actually did — August 18, 2026
|
||||
|
||||
The measurement above assumed one shape of answer: merge the creator's markup and
|
||||
scripts into `work-package-suite.html`, and pay the 21 selector collisions and 9 global
|
||||
collisions to do it. That is not what shipped, and the reason is in this document's own
|
||||
numbers.
|
||||
|
||||
**The creator became a top-level page instead of moving into the parent one.** The tab
|
||||
strip is drawn by both documents, and the two tabs that used to swap a frame are now
|
||||
links. That satisfies every done-when in the wave file — no iframe, no cross-frame
|
||||
messaging, `F4` resolved structurally, `CR-006` toggles propagating with no
|
||||
special-casing, browser back and forward intact — while the wave file's prose ("renders
|
||||
in the parent document") describes the other route. **Stated as a deviation, not
|
||||
smuggled:** the boundary is dissolved by making the creator its own document rather than
|
||||
by dissolving it into another one.
|
||||
|
||||
Why, against the counts:
|
||||
|
||||
| | Merging into the parent | Making it a page |
|
||||
|---|--:|--:|
|
||||
| Selector collisions to resolve | 21 | **0** |
|
||||
| Script global collisions to resolve | 9 | **0** |
|
||||
| Cross-frame call sites to remove | 28 | 28 |
|
||||
| Probe call sites needing rework | ~29 | **2** |
|
||||
|
||||
The collisions were never a cost of *dissolving the boundary*. They were a cost of
|
||||
*merging two documents*, which is a separate change that the boundary happened to be
|
||||
hiding. §2c called zero markup-id collisions "the single largest piece of good news";
|
||||
the larger one turned out to be that 29 probe entry points address
|
||||
`wp-creation-index.html` directly, and a route that keeps that address keeps them all.
|
||||
|
||||
What the two duplications mean now:
|
||||
|
||||
- **The sequence editor** (9 of the 21 selectors) stays two components, which is what
|
||||
was confirmed on August 18 — authored in the SOP, adjustable per package. The
|
||||
duplication is real and stays visible as `BL-015`.
|
||||
- **Analytics and the feedback panel** (all 9 globals) are still implemented twice. They
|
||||
are in two documents, so nothing collides, but `T7.10` deletes one copy of analytics
|
||||
regardless. `showAnalytics` is the one §2b said to check first, and it was right: the
|
||||
wizard's copy still has no caller.
|
||||
|
||||
### The three questions §4 said no count could answer
|
||||
|
||||
1. **The creator got the app bar.** It was the only page in `html/` loading neither
|
||||
`wp-chrome.css` nor `wp-chrome.js`, because `wp-chrome.js` returned early inside an
|
||||
iframe. Both are loaded now, the header was reshaped into the `.header-left` /
|
||||
`.header-right` pair the suite page uses so the switcher lands in the same place on
|
||||
both, and the wizard's layout arithmetic — `chromeHeight()`, `--wp-chrome-h`,
|
||||
`embed-full` — was deleted rather than adjusted, because there is no frame to size.
|
||||
2. **Two sequence components**, scoped rather than merged. See above.
|
||||
3. **`body.embedded` is gone**, and with it `.embed-hide`. The header it hid was
|
||||
replaced by the app bar; the sample-data controls are visible in a new package
|
||||
toolbar (`D1`); the analytics button is visible there too until `T7.10` moves it.
|
||||
The `Dashboard` button in that row became a **tab**, which is the one place the
|
||||
"fold the toolbar into the tab row" in `B7` actually happened.
|
||||
|
||||
### What was checked
|
||||
|
||||
`tests/frame_check.py`, 39 checks. Beyond the obvious ones it pins three things this
|
||||
document could not have predicted:
|
||||
|
||||
- **Every old address still resolves.** `?tab=wp`, `?view=dashboard` and `?wp=<id>` are
|
||||
in bookmarks, in `wp-sidenav`'s link map, and they are the shape the `CR-011` and
|
||||
`CR-014` emails were specified against (`X1`). The wizard forwards them with
|
||||
`replace()`, so Back does not bounce. Breaking these silently was the one regression
|
||||
this task could have shipped that nobody would notice for weeks.
|
||||
- **Both documents parse.** A `const` shadowing a function parameter is a SyntaxError,
|
||||
and during this task it stopped `work-package-suite-app.js` parsing at all. Four
|
||||
checks in another probe went red and not one of them said "the script did not load".
|
||||
Asserting a page's own entry points exist costs nothing and says exactly that.
|
||||
- **The behaviour that changed.** The live cross-frame hand-off of a section toggle
|
||||
showed the creator a toggle that had *not been saved*: flip it, look, reload, and the
|
||||
section came back. What the creator shows now is the SOP that is stored.
|
||||
`sections_check.py` 5b pins both halves — an unsaved toggle does not travel, a saved
|
||||
one does.
|
||||
|
||||
`BL-020` is the one thing that got worse: a tab switch is a page exit now, so leaving the
|
||||
wizard with unsaved SOP edits fires `T4.3`'s unsaved-work guard. Nothing is lost — the
|
||||
guard writes the draft first and `T4.3` recovers it — but it is friction that did not
|
||||
exist, and suppressing a deliberate guard is a decision with its own downside, so it is
|
||||
logged rather than quietly handled inside a structural task.
|
||||
573
docs/reference/file-map.md
Normal file
@@ -0,0 +1,573 @@
|
||||
# File map — `html/` and the references the plan depends on
|
||||
|
||||
**Task:** `T0.1` · **Produced:** August 14, 2026 · **Branch:** `feat/wp-suite-r2-implementation`
|
||||
|
||||
Wave 0 exists because the item IDs and line numbers in this plan came from a UX review of
|
||||
branch `users/directory-super-user`, not from a fresh read. This document is the fresh read.
|
||||
Everything below was verified against the working tree, not quoted from the review.
|
||||
|
||||
**Read the [Discrepancies](#discrepancies) section before starting any wave.** Four things the
|
||||
plan asserts are wrong, and one of them (`A1`) guards a code path CLAUDE.md says must not change.
|
||||
|
||||
---
|
||||
|
||||
## 1. Pages
|
||||
|
||||
`html/` holds **7 pages, 6 stylesheets, 11,867 lines**. The plan says "6 pages, 4 stylesheets,
|
||||
roughly 11,900 lines" — the line count is right, the other two are not. See D1.
|
||||
|
||||
| Page | Called in this plan | Lines | Stylesheets (load order) | Scripts (load order) | Iframe |
|
||||
|---|---|---|---|---|---|
|
||||
| `html/login.html` | login | 163 | `theme-light.css` | `login.js` | neither |
|
||||
| `html/index.html` | launcher | 703 | `theme-light.css`, `wp-chrome.css` | `auth-guard`, `wp-format`, `feedback-config`, `project-data`, `help`, *(inline 418–700)*, `wp-chrome` | neither |
|
||||
| `html/work-package-suite.html` | SOP wizard | 437 | `theme-light.css`, `wp-chrome.css`, `work-package-suite-styles.css` | `auth-guard`, `wp-format`, `feedback-config`, `project-data`, `help`, `work-package-suite-app`, `wp-chrome` | **hosts** |
|
||||
| `html/wp-creation-index.html` | creator | 401 | `theme-light.css`, `wp-creation-styles.css` | `auth-guard`, `wp-format`, `feedback-config`, `project-data`, `help`, `wp-creation-app` | **child** |
|
||||
| `html/admin.html` | admin | 219 | `theme-light.css`, `wp-chrome.css`, `console.css`, `wp-sidenav.css` | `auth-guard`, `wp-format`, `console-util`, `admin`, `wp-chrome`, `wp-sidenav` | neither |
|
||||
| `html/users.html` | directory/users | 106 | `theme-light.css`, `wp-chrome.css`, `console.css`, `wp-sidenav.css` | `auth-guard`, `wp-format`, `console-util`, `users`, `wp-chrome`, `wp-sidenav` | neither |
|
||||
| `html/field.html` | field view | 93 | `theme-light.css`, `wp-chrome.css`, `wp-sidenav.css` | `auth-guard`, `wp-format`, `project-data`, `help`, `field`, `wp-chrome`, `wp-sidenav` | neither |
|
||||
|
||||
No script anywhere in `html/` uses `defer`, `async`, or `type="module"`. Every one is a
|
||||
render-blocking classic script. `auth-guard.js` and `wp-format.js` load in `<head>`; the rest
|
||||
load at the end of `<body>`.
|
||||
|
||||
Wave 5 added two `<head>` scripts, both shared: `wp-sections.js` (the `CR-006` section list,
|
||||
read by the wizard *and* the creator, so the two cannot disagree about what "Assets is off"
|
||||
means) joins `wp-url.js` and `wp-autosave.js`.
|
||||
|
||||
> **The SOP wizard has 12 steps, not 10, since wave 5.** `T5.4` appended Locations (11) and
|
||||
> `T5.5` appended Sections (12). Both were **appended** rather than inserted where they
|
||||
> belong by subject: renumbering 2–10 would touch every `sop-step-N` id, every
|
||||
> `collectStepData` case, every gate key and the analytics history, for an ordering change.
|
||||
> The count lives in one place — `LAST_STEP` in `work-package-suite-app.js` — and reordering
|
||||
> is cheap once nothing depends on the numbers. `tests/stepper_check.py` names it
|
||||
> `STEP_COUNT` for the same reason.
|
||||
|
||||
### The iframe boundary - dissolved at `T7.1`, August 18 2026
|
||||
|
||||
`work-package-suite.html` declared `<iframe id="wp-frame">` with no `src`, and
|
||||
`work-package-suite-app.js` set it at runtime to
|
||||
`wp-creation-index.html?embedded=1&project=<id>`. The creator was the only page loading
|
||||
**neither `wp-chrome.css` nor `wp-chrome.js`**, which is why it had no app bar and read
|
||||
as part of the wizard rather than as a page.
|
||||
|
||||
**There is no iframe in `html/` any more.** The creator is a top-level document with the
|
||||
same app bar and the same tab strip as the wizard; the `Work Package Creation` and
|
||||
`Dashboard` tabs are links between the two. `docs/reference/creator-frame.md` section 5
|
||||
records what was done and why that route was taken over merging the two documents.
|
||||
|
||||
What a task touching this area needs to know now:
|
||||
|
||||
| Was | Is |
|
||||
|---|---|
|
||||
| `#wp-frame`, `applyEmbedLayout`, `sizeWPFrame`, `--wp-chrome-h`, `.embed-full` | gone; the window sizes the page |
|
||||
| `?embedded=1`, `body.embedded`, `.embed-hide` | gone; an old link carrying the param is ignored, not half-obeyed |
|
||||
| four cross-frame calls (`openWpById`, `showDashboard`/`showForm`, `dashApplyFlag`, `applySopSections`) | the URL: `?project=`, `?view=`, `?wp=`, `?flag=`, all read at the creator's own boot |
|
||||
| `.main-nav` / `.nav-tab` in `work-package-suite-styles.css` | `wp-chrome.css`, because both tool pages draw the strip |
|
||||
| `inIframe` branches in `auth-guard.js`, `wp-chrome.js`, `wp-sidenav.js`, `help.js`, and `_isTop` in `project-data.js` | gone; `help.js` uses the explicit `WP_HELP_NO_FAB` flag both tool pages set |
|
||||
|
||||
**Old addresses still work and must keep working.** `work-package-suite.html?tab=wp`,
|
||||
`?view=dashboard` and `?wp=<id>` are in bookmarks, in `wp-sidenav.js`'s link map, and
|
||||
they are the shape `CR-011` and `CR-014` emails were specified against (`X1`). The wizard
|
||||
forwards them to the creator with `replace()`, so Back does not bounce.
|
||||
`tests/frame_check.py` section 4 pins all three.
|
||||
|
||||
### Pages that are not what the plan implies
|
||||
|
||||
- **`field.html` is 93 lines.** It is a shell; `field.js` (178 lines) builds it. A task that says
|
||||
"edit the field view page" almost always means `field.js`.
|
||||
- **`index.html` carries ~296 lines of inline `<style>`** (lines 16–312) — the bulk of the
|
||||
launcher's CSS is not in any stylesheet. `T3.x` token work has to reach inside it.
|
||||
- **`admin.html` and `users.html` are near-identical shells** over `console.css` + `console-util.js`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Stylesheets
|
||||
|
||||
| File | Lines | Purpose | Consumed by |
|
||||
|---|---|---|---|
|
||||
| `theme-light.css` | 249 | Carbon light (g10) token set, base type, shared dark appbar | **all 7 pages** (always first) |
|
||||
| `console.css` | 191 | dense admin tables and toolbars | admin, users |
|
||||
| `work-package-suite-styles.css` | 610 | SOP wizard page sheet | SOP wizard |
|
||||
| `wp-chrome.css` | 237 | injected chrome: project switcher, global search | launcher, SOP wizard, admin, users, field |
|
||||
| `wp-creation-styles.css` | 884 | creator page sheet | creator |
|
||||
| `wp-sidenav.css` | 90 | off-canvas nav drawer | admin, users, field |
|
||||
|
||||
**No `@import` exists in any stylesheet.** Composition is entirely `<link>` order.
|
||||
`theme-light.css` is linked first on all 7 pages.
|
||||
|
||||
### The four parallel token systems (`S5`, consolidated in wave 3)
|
||||
|
||||
| Sheet | Prefix | Root tokens |
|
||||
|---|---|---|
|
||||
| `theme-light.css` | `--cds-*` (2–117) and `--wp-appbar-*` (169–176) | 114 + 6 |
|
||||
| `console.css` | **unprefixed** (12–20) | 28 |
|
||||
| `work-package-suite-styles.css` | **unprefixed** (1–18) | 16 |
|
||||
| `wp-creation-styles.css` | **unprefixed** (9–31) | 21 |
|
||||
| `wp-chrome.css` | `--wpc-*`, scoped to `.wp-chrome`, **not `:root`** (18–36) | 0 at root |
|
||||
| `wp-sidenav.css` | none — every colour is a hardcoded hex | 0 |
|
||||
|
||||
The three unprefixed sheets are the collision risk. They are **mutually exclusive per page**
|
||||
today (console on admin+users, wizard sheet on the wizard, creator sheet on the creator), so
|
||||
nothing currently breaks — wave 3 must not assume that stays true once chrome is unified.
|
||||
|
||||
Redeclared across sheets with the **same** value: `--bg`, `--border`, `--border-strong`,
|
||||
`--text`, `--accent`, `--red`, `--shadow`, `--text-dim`.
|
||||
Redeclared with **different** values — these are the real defects:
|
||||
|
||||
- `--shadow-lg` — `0 4px 16px rgba(0,0,0,0.16)` (wizard) vs a different blur in the creator
|
||||
- `--mono` — `console.css` drops `ui-monospace` and `Segoe UI Mono` from the stack
|
||||
- `--surface` — `#fff` (console) vs `#ffffff` (creator); same colour, two notations
|
||||
- `--appbar: #161616` (wizard) duplicates `--wp-appbar-bg: #161616` (theme) under another name
|
||||
|
||||
Set at runtime on `documentElement`, declared in no stylesheet:
|
||||
`--wp-chrome-h` (`work-package-suite-app.js:528`), `--rail-top` (`wp-creation-app.js`).
|
||||
Declared on `body` rather than `:root`: `--nav-w` (`wp-creation-styles.css:673,674,805`).
|
||||
|
||||
Only cross-file dependency: `work-package-suite-styles.css` consumes `--cds-hover-primary`,
|
||||
which only `theme-light.css` declares.
|
||||
|
||||
---
|
||||
|
||||
## 3. Verified references
|
||||
|
||||
Every reference the plan relies on, checked against the working tree.
|
||||
|
||||
| Reference | Claim | Status | Actually |
|
||||
|---|---|---|---|
|
||||
| `wp-creation-app.js:1144` | `alert()` "Subject and WP Type are required" | **CONFIRMED** | exact |
|
||||
| `wp-creation-app.js:1962-1972` | logged-override path for predecessors | **MOVED — see D2** | that range is `dashIssue()`, which *refuses*. Real path: **`967–984`** |
|
||||
| `work-package-suite-app.js:326` | only `beforeunload`, analytics dwell | **CONFIRMED** | exact; sole `beforeunload` in all of `html/` |
|
||||
| `work-package-suite-styles.css:322-328` | `outline:none` + pale 3px glow | **CONFIRMED** | block runs 321–328, `outline:none` on 325 |
|
||||
| `work-package-suite-styles.css:336` | comment about `.field-hint` unstyled | **CONFIRMED** | comment 336–338, rule on 339 |
|
||||
| `console.css:85-87` | contrast fix not propagated | **CONFIRMED** | comment 85–87, fix on 88 |
|
||||
| `admin.js:484-517` | language and time localization | **CONFIRMED** | exact — "Localization defaults" block |
|
||||
| `server/seed_demo.py` | seeds without authenticating | **CONFIRMED** | zero auth code in 178 lines |
|
||||
|
||||
Notes that change what a later task should do:
|
||||
|
||||
- **`work-package-suite-styles.css`** — `outline: none` appears **three** times, not once: 325
|
||||
(the cited block), 347 (`.user-pick:focus`, same pale glow), and 501
|
||||
(`.seq-step input.seq-label:focus`, *no* replacement glow at all). `A3`/`F5` should fix all three.
|
||||
- **`console.css:85-87`** — the comment measures `#8d8d8d` on white at **3.3:1**. Wave 4's `T4.6`
|
||||
says "about 2.9:1". They disagree; `T4.6` should re-measure rather than quote either. Both
|
||||
other sheets still bind helper text to the rejected `#8d8d8d`
|
||||
(`work-package-suite-styles.css:10`, `wp-creation-styles.css:17`), and the creator applies it
|
||||
at **10px** (`wp-creation-styles.css:374`), worse than the 12px the comment measures.
|
||||
- **`seed_demo.py`** fails *loudly but confusingly*: `call()` swallows the `HTTPError` and returns
|
||||
the error body, so line 101's `proj["id"]` raises `KeyError` instead of reporting a 401.
|
||||
Only `/api/health` is unauthenticated, so the health check passes and it dies immediately after.
|
||||
|
||||
---
|
||||
|
||||
## 4. Baseline counts
|
||||
|
||||
Recorded so wave 9 can prove they went down. Run from `html/` unless stated.
|
||||
|
||||
| # | Metric | Review | Actual | Command |
|
||||
|---|---|---|---|---|
|
||||
| 1 | native dialogs app-wide | 79 | **79** ✓ | `grep -ohE '\b(alert\|confirm\|prompt)\(' *.js *.html \| wc -l` |
|
||||
| 2 | …of those, in the creator | 43 | **43** ✓ | same, over `wp-creation-app.js` |
|
||||
| 3 | `<div onclick>` | 12 | **12** ✓ | `grep -oE '<div[^>]*onclick' *.html *.js \| wc -l` |
|
||||
| 4 | `<span onclick>` | 2 | **2** ✓ | `grep -oE '<span[^>]*onclick' *.html *.js \| wc -l` |
|
||||
| 5 | `#0f62fe` accent systems | 4 | **4** ✓ | see below |
|
||||
| 6 | `.help-tip` badges | 15 | **15** ✓ | `grep -oE 'class="help-tip"' *.html \| wc -l` |
|
||||
| 7 | `aria-live` regions | 0 | **0** ✓ | `grep -ohE 'aria-live' *.html *.js \| wc -l` |
|
||||
| 8 | `pushState` calls | 0 | **0** ✓ | `grep -ohE 'pushState' *.html *.js \| wc -l` |
|
||||
| 9 | `<iframe>` in `html/` | 1 | **0** ✓ (`T7.1`) | `grep -oiE '<iframe' *.html \| wc -l` |
|
||||
|
||||
Dialogs by file: `wp-creation-app.js` 43 · `work-package-suite-app.js` 14 · `users.js` 10 ·
|
||||
`admin.js` 6 · `index.html` 6.
|
||||
|
||||
**At the T7.1 commit** the same commands read: dialogs **64** (the wizard's 14 are gone,
|
||||
the creator's 43 are `T7.9`'s), `<div onclick>` **2**, `<span onclick>` **2**,
|
||||
`.help-tip` **18**, colour literals in rules outside `theme-light.css` **0** in all five
|
||||
page sheets, and **iframes 0** - metric 9 above, added because `B7` is the only item in
|
||||
the plan whose completion is a count of zero.
|
||||
|
||||
Measure colour literals with comments stripped. Four of the five sheets now carry prose
|
||||
about the hex values they used to contain, and `BL-017` is the entry about a metric that
|
||||
counted its own explanation.
|
||||
`<div onclick>` by file: `work-package-suite.html` 10 · `wp-creation-index.html` 1 ·
|
||||
`work-package-suite-app.js` 1. Both `<span onclick>` are in `wp-creation-app.js`.
|
||||
|
||||
**Metric 5 needs its definition stated**, or wave 9 will measure a different thing.
|
||||
`#0f62fe` appears **31** times across `html/`, and **14** of those are custom-property
|
||||
declarations. The review's "4" is the number of **stylesheets that declare their own accent
|
||||
token** — the four parallel systems:
|
||||
|
||||
```
|
||||
grep -nE '^\s*--[a-zA-Z0-9-]+\s*:\s*#0f62fe' *.css # 14 declarations, in 4 sheets
|
||||
```
|
||||
|
||||
| Sheet | Token |
|
||||
|---|---|
|
||||
| `theme-light.css` | `--cds-interactive-01` (+ 6 Carbon aliases) |
|
||||
| `work-package-suite-styles.css:2` | `--primary` |
|
||||
| `wp-chrome.css:25` | `--wpc-accent` |
|
||||
| `wp-creation-styles.css:18` | `--accent` |
|
||||
|
||||
**The wave 9 target is 1.** Track the sheet count, not the raw occurrence count.
|
||||
|
||||
**Metric 6:** 15 in page markup (`work-package-suite.html` 3, `wp-creation-index.html` 12).
|
||||
A 16th lives in `help.js:261` inside the help centre's own copy, demonstrating the component —
|
||||
it is not a page badge. None carries `tabindex`, and `help.js:26` styles only `:hover`/`:focus`,
|
||||
so a `<span>` with no tabindex is **keyboard-unreachable**. That is the `C1` defect, and it
|
||||
confirms "unreachable" literally.
|
||||
|
||||
**Metric 7:** `login.html:99-100` uses `role="alert"` and `role="status"`, which are implicit
|
||||
live regions. The count of the literal `aria-live` attribute is 0, matching the review. CLAUDE.md
|
||||
points at these two lines as the pattern to copy — they are real and correct.
|
||||
|
||||
**Metric 8:** `pushState` is 0. There is **one** `history.replaceState` at `login.js:196`,
|
||||
cleaning the URL after sign-in. It is not routing, so `X1`'s premise holds: no work package has
|
||||
an addressable URL.
|
||||
|
||||
---
|
||||
|
||||
## 5. Running it
|
||||
|
||||
```bash
|
||||
uvicorn server.app:app # against a throwaway SQLite database
|
||||
python server/smoketest.py # API; needs WP_SMOKE_USER / WP_SMOKE_PASSWORD
|
||||
python tests/browser_check.py # pages boot and render, self-contained
|
||||
python tests/baseline_shots.py # screenshots, self-contained
|
||||
python tests/f_items.py # does each of F1-F6 still reproduce?
|
||||
python tests/token_check.py --out a.json # every resolved token + computed style
|
||||
python tests/token_check.py --compare a.json b.json
|
||||
```
|
||||
|
||||
`tests/token_check.py` was added by `T3.2`, because a screenshot cannot prove a token refactor:
|
||||
three of the fourteen shots are not stable capture-to-capture (see `backlog.md` BL-012), and no
|
||||
screenshot exercises a hover, focus or disabled rule, which is where half the tokens live. It
|
||||
snapshots every custom property's resolved value and every element's computed colours, shadows
|
||||
and type, on all 7 pages, and diffs two snapshots. Use it for any task that claims to change
|
||||
styling without changing appearance.
|
||||
|
||||
Wave 4 added three more, each written because its task's done-when could not be checked by
|
||||
anything that already existed:
|
||||
|
||||
```bash
|
||||
python tests/aggregates_check.py # B4 — do the counts come from the server? 17 checks
|
||||
python tests/url_state_check.py # S3 — does the app's state have an address? 23 checks
|
||||
python tests/autosave_check.py # S2/B5 — does unsaved work survive? 34 checks
|
||||
python tests/a11y_check.py # S10/S11/S12 — announce, legible, focus 22 checks
|
||||
```
|
||||
|
||||
Wave 5 added more, for the same reason:
|
||||
|
||||
```bash
|
||||
python tests/stepper_check.py # A4/S9 — ten real buttons, keyboard operable 70 checks
|
||||
python tests/launcher_check.py # B3 — can a brand-new account get started? 58 checks
|
||||
python tests/pipeline_check.py # B4 surface — server counts, shareable links 43 checks
|
||||
python tests/locations_check.py # CR-005 — codes not labels, nothing deleted 58 checks
|
||||
python tests/sections_check.py # CR-006/CR-002/CR-016 — hidden, not deleted 95 checks
|
||||
python tests/validation_check.py # S1 wizard — errors at the field, no dialogs 81 checks
|
||||
```
|
||||
|
||||
Wave 6 added three more:
|
||||
|
||||
```bash
|
||||
python tests/generalinfo_check.py # CR-001/CR-003 — P6 activity, priority 49 checks
|
||||
python tests/rollup_check.py # CR-004/CR-018 — codes, and totals that add 63 checks
|
||||
python tests/cards_check.py # A7 — card status, footer, localization 44 checks
|
||||
```
|
||||
|
||||
Wave 7 adds these:
|
||||
|
||||
```bash
|
||||
python tests/frame_check.py # B7/T7.1/D1 - is the iframe actually gone? 39 checks
|
||||
python tests/form_structure_check.py # F6/D3 - rail, disclosure, one open section 51 checks
|
||||
python tests/hold_check.py # CR-015/A1/D4 - the hold clears, gates hold 50 checks
|
||||
python tests/warning_check.py # A2 - one warning, a badge from anywhere 17 checks
|
||||
python tests/triage_check.py # A6 - the sidebar answers the stand-up 16 checks
|
||||
python tests/qa_gate_check.py # CR-014/D2/D9/D10 - QA gate + capture sink 41 checks
|
||||
python tests/files_check.py # CR-007/D8 - drawings upload + real offline 36 checks
|
||||
python tests/sticky_bar_check.py # B6 - save reachable on every wizard step 12 checks
|
||||
python tests/usage_check.py # D5 - one analytics core, admin report 15 checks
|
||||
python tests/creator_dialogs_check.py # S1 creator - 0 natives, errors at fields 20 checks
|
||||
```
|
||||
|
||||
Wave 8 adds these:
|
||||
|
||||
```bash
|
||||
python tests/kitting_check.py # CR-009/010/012 - statuses, owner, delivery 26 checks
|
||||
python tests/kitting_notify_check.py # CR-011 - kitting mail, coalesced, gated 17 checks
|
||||
python tests/materials_check.py # D6 - material list, the CR-005 pattern 17 checks
|
||||
python tests/mreq_check.py # CR-013 - lightweight request, end to end 19 checks
|
||||
```
|
||||
|
||||
Wave 9 adds these:
|
||||
|
||||
```bash
|
||||
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/icon_check.py # S6 - one icon system, no emoji, mapped 5 checks
|
||||
python tests/helptip_check.py # C1/S8 - tips by keyboard+touch, audit greps 14 checks
|
||||
python tests/mobile_check.py # C2 - all 7 pages at 390px, targets + fit 24 checks
|
||||
python tests/archived_check.py # D7 - archived projects, admins only, frozen 15 checks
|
||||
python tests/color_check.py # C4 - zero literals outside theme-light 5 checks
|
||||
```
|
||||
|
||||
The August 20 integration adds:
|
||||
|
||||
```bash
|
||||
python tests/assets_check.py # D11 - Micron picker: read-only, degrades 31 checks
|
||||
python tests/critical_reopen_check.py # BL-021 - on-hold mail reaches PM + CM 11 checks
|
||||
python tests/console_dialogs_check.py # BL-024 - consoles/launcher: 21 natives -> 0 17 checks
|
||||
```
|
||||
|
||||
**Three probes were re-pointed at `T7.1`.** `sections_check.py` 5b drove the live
|
||||
cross-frame toggle hand-off, which no longer exists; it now pins the surviving path and
|
||||
the fact that an UNSAVED toggle correctly does not travel - a behaviour change, recorded
|
||||
rather than smoothed over. `pipeline_check.py` read the filter through
|
||||
`#wp-frame.contentDocument`; it reads the page. `f_items.py`'s `F4` drove two modes,
|
||||
standalone and `?embedded=1`; there is one mode now. `validation_check.py` lost its
|
||||
"loading the sample on the wrong tab" case for the same reason - there is no wrong tab
|
||||
on the wizard any more - and gained one for the SOP gate instead.
|
||||
|
||||
**Two probes were re-pointed at `T7.2`, both for the General Information split.**
|
||||
`sections_check.py`'s section table carried one selector per section; the `general` row
|
||||
now carries two (`#general-card, #assign-card`) and every hidden/shown reading walks ALL
|
||||
of a row's cards, because the failure mode the split could have had is exactly "half the
|
||||
section hidden and half still rendering". Its chip-strip reading moved to the rail
|
||||
(`#section-nav` no longer exists). `generalinfo_check.py` asserted DOM containment in
|
||||
`#general-card`; it now asserts what that containment was FOR - the CR-006 `general`
|
||||
toggle governs the P6 fields - against whichever of the two cards holds them.
|
||||
|
||||
**Two probes were re-pointed at `T6.5`, and the reason is worth carrying forward.**
|
||||
`aggregates_check.py` matched the launcher card's exact wording (`"SOP complete"`), and it
|
||||
also waited for the status line to be non-empty — which stopped meaning "the answer has
|
||||
arrived" once the card gained a line for the in-flight state too. Both assertions now match
|
||||
the ANSWER rather than the sentence, which is what that check was ever about. A probe that
|
||||
breaks on wording is a probe somebody edits carelessly the next time wording changes.
|
||||
|
||||
**Windows consoles are cp1252.** Several probes print page text back in a failure message,
|
||||
and a glyph outside cp1252 (`✓`, `✕`, `→`) crashes the *reporter* instead of
|
||||
reporting the failure — losing the diagnosis, which is the only thing that run was for.
|
||||
The wave 5 and 6 probes route any page-derived text through an `ascii_()` helper.
|
||||
|
||||
`pipeline_check.py` reads the dashboard's state out of the **iframe's DOM**, not its
|
||||
globals: `dashFilter` and `currentView` are declared with `let` in a classic script, so
|
||||
they are not properties of `window` and a cross-frame read of either comes back
|
||||
`undefined` — which is indistinguishable from a filter that never applied.
|
||||
|
||||
`launcher_check.py` is the only probe that runs itself in **two subprocesses**, and both
|
||||
reasons are worth knowing before writing a third:
|
||||
|
||||
- The two states it tests are *database* states — an account with no projects, and an
|
||||
account with two — and faking "no projects" in the browser would test the fake.
|
||||
- `server/db.py` builds its engine at import time from `DATABASE_URL`, so a second `seed()`
|
||||
in one interpreter still points at the first phase's database, which has been deleted by
|
||||
then. That surfaces as `unable to open database file`, which reads like a broken
|
||||
environment rather than what it is.
|
||||
|
||||
It drives the rail with **real** key events over `Input.dispatchKeyEvent` rather than
|
||||
`page.key()`, which dispatches a synthetic `KeyboardEvent` on `document`. That event
|
||||
never reaches a listener bound to the rail and never triggers a button's native
|
||||
Enter/Space activation, so a rail with no keyboard support at all would have reported a
|
||||
clean pass — the same class of false green as `a11y_check.py`'s focus emulation.
|
||||
|
||||
Each tests what was *broken* rather than what is easy to assert. `aggregates_check.py` poisons
|
||||
localStorage and demands the dashboard still report the server's total; a test that only
|
||||
checked the totals were correct would have passed before `B4` was built. `a11y_check.py` runs
|
||||
with CDP focus emulation on, without which `:focus-visible` never matches in headless and every
|
||||
element reports a clean pass.
|
||||
|
||||
**Run them one at a time.** Chained back to back they exhaust the headless browser's ports and
|
||||
all three abort with "browser would not start"; that failure looks like a code fault and is not
|
||||
one.
|
||||
|
||||
### Baseline counts, updated
|
||||
|
||||
| # | Metric | Wave 0 | Now | Changed by |
|
||||
|---|---|---|---|---|
|
||||
| 5 | sheets declaring their own `#0f62fe` | 4 (really 5, see §4) | **1** | `T3.2` |
|
||||
| 7 | `aria-live` / `role="alert"\|"status"` sites | 0 | **13** | `T4.4`, `T4.5` |
|
||||
| 8 | `pushState` | 0 | **2** (6 call sites via `wp-url.js`) | `T4.2` |
|
||||
| — | `outline: none` in stylesheets | 6 | **1**, with its replacement one rule above | `T3.4`, `T4.7` |
|
||||
| — | helper-text contrast, worst case | 3.01:1 | **4.56:1** | `T4.6` |
|
||||
| 3 | `<div onclick>` | 12 | **2** | `T5.1` |
|
||||
| 1 | native dialogs app-wide | 79 | **64** | `T5.1` (2), `T5.2` (1), `T5.8` (13) |
|
||||
| 2 | …of those, in the creator | 43 | **43** | unchanged — `T7.9` |
|
||||
| 9 | `<iframe>` in `html/` | 1 | **0** | `T7.1` |
|
||||
| 6 | `.help-tip` badges, none keyboard-reachable | 15 | **18** | went UP; `T9.5` |
|
||||
|
||||
Metrics 2, 4 and 6 (creator dialogs, `<span onclick>`, `.help-tip` badges) are wave 9's to
|
||||
move and are unchanged.
|
||||
|
||||
**Metric 3 after `T5.1`:** the two survivors are `wp-creation-index.html:383`
|
||||
(`.cmt-overlay`) and `work-package-suite-app.js:1046` (the constraint-library row). Both
|
||||
are wave 7 / wave 9 work. Nothing in the SOP wizard's markup carries a click handler on a
|
||||
non-interactive element any more.
|
||||
|
||||
**Metric 1 after `T5.8`:** the SOP wizard has **none**, measured both raw and with
|
||||
comments stripped. What is left is `wp-creation-app.js` 43, `users.js` 10, `admin.js` 6 and
|
||||
`index.html` 5 — the creator's are wave 7's, the rest wave 9's.
|
||||
|
||||
**Metric 1 is noisier than it looks** — the command matches `alert(` inside a *comment* as
|
||||
readily as inside code, and waves 3 and 4 left several comments explaining dialogs they
|
||||
were removing. That is why the figure was 80 before `T5.1` rather than the 79 wave 0
|
||||
recorded. `T5.1` removed two real calls (`validateStep`'s three conditions collapsed to
|
||||
one) and reworded its own comments so they do not inflate it. Logged as **BL-017**;
|
||||
`T5.8` records a comment-stripped figure alongside the raw one.
|
||||
|
||||
`server/smoketest.py` is the one that is **not** self-contained: it drives a server you point
|
||||
it at and aborts unless `WP_SMOKE_USER` and `WP_SMOKE_PASSWORD` are set, because every route
|
||||
but `/api/health` needs a session. Use an admin account — it creates and deletes a project.
|
||||
|
||||
`tests/browser_check.py` and `tests/baseline_shots.py` are self-contained: each creates a
|
||||
throwaway SQLite database, seeds a fixture, starts its own uvicorn on a free port, drives
|
||||
headless Edge or Chrome over CDP, and tears everything down. **Your real `wpsuite.db` is never
|
||||
touched.** Both need Edge or Chrome on the machine; set `WP_BROWSER` to override discovery.
|
||||
|
||||
Screenshots for a before/after pair:
|
||||
|
||||
```bash
|
||||
python tests/baseline_shots.py --out docs/reference/baseline # before (committed)
|
||||
python tests/baseline_shots.py --out /tmp/after --label after # after
|
||||
python tests/baseline_shots.py --pages creator --widths 390,768,1024,1440
|
||||
```
|
||||
|
||||
`tests/` is referenced by **no wave file**. `T0.2` needs screenshots and `T7.3` requires "a
|
||||
regression test covers the clear-last-constraint path" without naming a home for it — both
|
||||
belong here.
|
||||
|
||||
`tests/f_items.py` is both halves of the same measurement: it recorded that all six defects
|
||||
reproduce before wave 1, and it is how waves 1–3 prove each one stopped. An item is done when
|
||||
its probe flips from `REPRODUCES` to `FIXED`. It never reports a silent pass — a probe that
|
||||
cannot decide says `INCONCLUSIVE`.
|
||||
|
||||
---
|
||||
|
||||
## The project switcher: what it shows at each width (`B2`, `T2.3`)
|
||||
|
||||
Written here because `T2.3` says to, so it is not re-litigated. Test name is always
|
||||
**"Micron EUV Cleanroom Enable 2667008"** — the real one, and the one that breaks things.
|
||||
|
||||
| Width | The app bar shows | Why |
|
||||
|---|---|---|
|
||||
| **≥ 1024px** | the full project **name** | There is room. The cap is raised to 400px (button) / 340px (name) so a real name fits without an ellipsis. |
|
||||
| **< 1024px** | the project **number** alone, e.g. `2667008` | Short, stable, unambiguous. Dropping the name entirely beats shortening it to "Micron EUV Clean…" on the one control whose job is to tell you which job you are in. |
|
||||
|
||||
The number is not prefixed to the name at wide widths. Real project names already end
|
||||
with their number, so `2667008 — Micron EUV Cleanroom Enable 2667008` printed it twice.
|
||||
|
||||
**The full name is reachable at every width by three routes**, none of them hover-only
|
||||
(Field View is a touch surface — `C1`):
|
||||
|
||||
1. **The drawer** — `.wp-sidenav-proj`, under the drawer head, on every page. Wraps onto
|
||||
as many lines as it needs and is never truncated. This is the guaranteed one.
|
||||
2. **The switcher's `title`** — the full `number — name`, so it surfaces on hover *and*
|
||||
on keyboard focus.
|
||||
3. **The switcher popover** — every project is listed with its full name and number.
|
||||
|
||||
The ellipsis is kept only as a backstop for a name longer than anything real. Verified
|
||||
at 390, 768, 1024 and 1440: no truncation at any of them, and the drawer carries the
|
||||
whole name at all four.
|
||||
|
||||
`T1.1` correctness is preserved — with a project active the switcher never reads
|
||||
"Select a project"; with none active it reads exactly that at every width.
|
||||
|
||||
## Discrepancies
|
||||
|
||||
Things the plan asserts that the repo contradicts. Listed per T0.1's fourth done-when.
|
||||
|
||||
### D1 — "6 pages, 4 stylesheets" is wrong; it is 7 and 6
|
||||
|
||||
`wave-0.md:21` says 6 pages and 4 stylesheets. There are **7 pages and 6 stylesheets**. T0.1's
|
||||
own parenthetical at `wave-0.md:24-25` lists **seven** names, so the document contradicts itself
|
||||
in the same task. The missing stylesheets are `wp-chrome.css` and `wp-sidenav.css`.
|
||||
|
||||
**Consequence:** every "all 6 pages" done-when in waves 2, 3, 5 and 9 is off by one, and `T0.2`
|
||||
asks for "12 baseline screenshots (6 pages x 2 widths)" when the correct number is **14**.
|
||||
14 were captured. Treat "6 pages" as "all pages" wherever it appears.
|
||||
|
||||
### D2 — `A1`'s protected line reference points at the wrong function
|
||||
|
||||
This is the important one. CLAUDE.md, under *Things that must not change*, says:
|
||||
|
||||
> **The logged-override path for predecessors stays (A1).** It is an audited business rule, not
|
||||
> a bug. See `wp-creation-app.js:1962-1972`.
|
||||
|
||||
`IMPLEMENTATION.md`'s `X2` cites the same range. **Lines 1962–1972 are `dashIssue()`**, which is
|
||||
the *opposite* code: the dashboard guard that **refuses** to issue and tells you to
|
||||
"Open the package to release it early with a logged reason". The reviewer read that sentence and
|
||||
correctly concluded a logged override exists — but cited the mention, not the implementation.
|
||||
|
||||
The actual audited path is:
|
||||
|
||||
| Part | Location |
|
||||
|---|---|
|
||||
| **`confirmEarlyRelease()` — the override itself** | **`wp-creation-app.js:967-984`** |
|
||||
| state `pkgGateOverride` | `:392`, reset at `:481`, `:488`, `:1744` |
|
||||
| call site — status change | `:998-1002` |
|
||||
| call site — save | `:1149-1150` |
|
||||
| persisted onto the package | `:1117` (`gateOverride:`) |
|
||||
| rendered in the printed package | `:1215` |
|
||||
| rehydrated when loading a package | `:1674` |
|
||||
| the *guard* the plan actually cited | `:1960-1974` (`dashIssue`) |
|
||||
|
||||
The comment at `:967-969` states the rule plainly: *"Releasing with an unclosed predecessor is
|
||||
allowed but must be explained. The reason rides on the package (`data.gateOverride`) and the
|
||||
server writes it to the audit log."*
|
||||
|
||||
**Consequence for `T7.3`:** the "do not remove" instruction must be applied to **`967-984` and
|
||||
its seven satellites**, not to `1962-1972`. A task that preserved only 1962–1972 would delete the
|
||||
audited business rule while believing it had protected it. `dashIssue()` must also survive — it
|
||||
is what stops the dashboard becoming a quiet way around the gate — but it is a second thing to
|
||||
protect, not the same thing.
|
||||
|
||||
`X2`'s reasoning is unaffected: hold state genuinely is not purely derived from open
|
||||
constraints, so `CR-015` and `A1` remain the same code and the same task.
|
||||
|
||||
### D3 — four documents the plan reads from do not exist yet
|
||||
|
||||
Not errors; they are deliverables not yet produced. Recorded so no task treats one as a missing
|
||||
input and goes looking for a rename.
|
||||
|
||||
| Path | Created by | Also read by |
|
||||
|---|---|---|
|
||||
| `docs/reference/file-map.md` | `T0.1` | `T0.2`, `T1.1`, `T2.1`, `T2.3` |
|
||||
| `docs/reference/tokens.md` | `T3.1` | `T3.2`, `T3.5`, `T9.3` |
|
||||
| `docs/reference/accessibility-audit.md` | `T9.5` | — |
|
||||
| `docs/reference/completion.md` | `T9.7` | — |
|
||||
|
||||
`backlog.md:16` contains `path/to/file.js:120` inside a fenced format template. It is a
|
||||
placeholder, not a reference — do not resolve it.
|
||||
|
||||
### D5 — `T2.1`'s premise is already satisfied: there are no "three near-copies"
|
||||
|
||||
`wave-2.md:25` asks to "lift the drawer into one shared implementation the pages include,
|
||||
rather than three near-copies". There are no copies. `html/wp-sidenav.js` (221 lines) and
|
||||
`html/wp-sidenav.css` (90 lines) are already **one** implementation, included by admin,
|
||||
field view and directory. Verified: `.wp-sidenav`, `.wp-navscrim` and `.wp-navbtn` are
|
||||
declared in exactly one file, and no page defines its own drawer.
|
||||
|
||||
The review said the drawer "ships on 3 of 6 pages while a flat strip duplicates it". The
|
||||
duplication is between the drawer and the **flat strip**, not among three drawers. The plan
|
||||
appears to have read "3 of 6 pages" as "3 copies".
|
||||
|
||||
It already meets every `T2.1` done-when as it stands, including the last one: `activeProjectId()`
|
||||
(`wp-sidenav.js:62-68`) reads `?project=` then falls back to `ProjectData.getActiveId()`, which
|
||||
is the single source `T1.1` established. It also self-mounts into `.wp-appbar` **or** `.header`
|
||||
and returns early inside an iframe, so it is already built to go on the remaining pages.
|
||||
|
||||
**Consequence:** `T2.1` is a no-op beyond this verification. The real work of wave 2 is `T2.2`.
|
||||
Nothing was refactored, because refactoring a single shared component into a single shared
|
||||
component would be churn with regression risk and no gain.
|
||||
|
||||
### D4 — the creator overflows horizontally at 1440px, not just at 390px
|
||||
|
||||
Measured during `T0.2`, not in the review. Content width against the viewport it was given:
|
||||
|
||||
| Page | 390px | 1440px |
|
||||
|---|---|---|
|
||||
| launcher | 425 | ok |
|
||||
| SOP wizard | 429 | ok |
|
||||
| creator | 485 | **1551** |
|
||||
| field view | 432 | ok |
|
||||
| login, admin, users | ok | ok |
|
||||
|
||||
Four pages overflow at 390px, which is `F2` and its neighbours. The creator also overflows by
|
||||
111px **at desk width**, which no `F` item covers. Logged to `docs/waves/backlog.md`; not fixed
|
||||
here, since wave 1 is scoped to `F1`–`F5` and `T7.x` rebuilds this page anyway.
|
||||
1011
docs/reference/tokens.md
Normal file
556
docs/waves/backlog.md
Normal file
@@ -0,0 +1,556 @@
|
||||
# Backlog
|
||||
|
||||
Anything noticed during implementation that is real but not in the plan goes here instead of
|
||||
into the current PR. `CLAUDE.md` requires this: every change traces to an item ID, so
|
||||
unplanned work gets logged rather than built.
|
||||
|
||||
Add an entry, do not fix it inline. This file is reviewed at `T9.7` and feeds the next spec
|
||||
revision.
|
||||
|
||||
## Format
|
||||
|
||||
```markdown
|
||||
### BL-001 — Short title
|
||||
|
||||
- **Found during:** T3.2
|
||||
- **Where:** path/to/file.js:120
|
||||
- **What:** one or two sentences on the problem
|
||||
- **Why not now:** out of scope for the current wave / needs a product decision / larger than the task
|
||||
- **Suggested wave or follow-up:** wave 9 / next revision / needs Nick
|
||||
```
|
||||
|
||||
## Known follow-ups already identified in the spec
|
||||
|
||||
These are logged from the source documents, not discovered in code. They are real but
|
||||
deliberately deferred.
|
||||
|
||||
### BL-000a — Validated P6 activity lookup
|
||||
|
||||
- **From:** `CR-001`
|
||||
- **What:** `CR-001` accepts free text for the P6 Activity ID. A validated lookup against an imported P6 activity list was identified as the eventual want.
|
||||
- **Why not now:** the Micron schedule is actively being reworked, so importing an activity list now would import churn.
|
||||
- **Suggested:** next revision, once the schedule stabilizes.
|
||||
|
||||
### BL-000b — Field-level toggles in General Information
|
||||
|
||||
- **From:** `CR-006`
|
||||
- **What:** `CR-006` toggles whole sections. General Information may need per-field toggles, since projects differ in which identifiers they use.
|
||||
- **Why not now:** section-level toggles cover every removal request currently on the list.
|
||||
- **Suggested:** next revision, if a second project needs a different field set.
|
||||
|
||||
### BL-000c — Estimated versus actual hours productivity factor
|
||||
|
||||
- **From:** `CR-017`
|
||||
- **What:** Actual Hours is retained and rolls up. Comparing it against estimated hours would produce a productivity factor, which was the stated reason for wanting the field.
|
||||
- **Why not now:** estimated hours capture is not in scope this round.
|
||||
- **Suggested:** next revision.
|
||||
|
||||
### BL-000d — Attachment merge versus list on export
|
||||
|
||||
- **From:** `CR-008` / `T9.1`
|
||||
- **What:** whether the PDF export merges attachments into one package or lists them separately.
|
||||
- **Why not now:** product decision, raised in the `T9.1` PR.
|
||||
- **Suggested:** needs Nick.
|
||||
|
||||
## Found during implementation
|
||||
|
||||
### BL-001 — CLOSED at T9.5 — The creator overflows horizontally (1440px, then 390px)
|
||||
|
||||
- **Found during:** T0.2
|
||||
- **Where:** `html/wp-creation-index.html` / `html/wp-creation-styles.css`
|
||||
- **What:** the creator lays out 1,551px of content inside a 1,440px viewport, so the page
|
||||
scrolls sideways at desk width. Measured by `tests/baseline_shots.py`, which compares
|
||||
`documentElement.scrollWidth` against `clientWidth` at each capture. `F2` covers narrow
|
||||
widths; no item covers this one. The other three overflowing pages (launcher 425px, SOP
|
||||
wizard 429px, field view 432px, all at a 390px viewport) are `F2` and are already scheduled.
|
||||
- **Why not now:** wave 1 is scoped to `F1`–`F5`, and `T7.1` dissolves this page's iframe and
|
||||
rebuilds its layout regardless — fixing it in wave 1 would be thrown away.
|
||||
- **Suggested wave or follow-up:** verify it is gone at `T7.1`; if it survives the rebuild,
|
||||
it needs its own item in the next revision.
|
||||
- **Root cause, found at T1.4 — not fixed, T7.1 owns it.** `wp-creation-styles.css:815` has
|
||||
`@media (max-width: 860px) { body { --nav-w: 56px; } }`, which is correct. But
|
||||
`wp-creation-app.js:1389` injects `body{--nav-w:288px;}` into a runtime `<style>` with no
|
||||
media query. Injected last, same specificity, so it **wins over the media query** and
|
||||
`--nav-w` stays 288px at every width. Everything keyed off it then reserves 288px of
|
||||
rail that is not there: `.main` padding-left `calc(288px + 28px)`
|
||||
(`wp-creation-styles.css:109`), `.ctx-bar` (`:452`), `.release-banner` (`:488`),
|
||||
`.section-nav-bar` (`:596`) and `.sticky-save { left: var(--nav-w,288px) }` (`:820`).
|
||||
At a 390px screen that forces the initial containing block to 485px.
|
||||
The fix is to give the injected rule the same breakpoint, or to stop injecting the
|
||||
value that the stylesheet already declares — one line, but it belongs with the creator
|
||||
rebuild rather than in a wave 1 rendering task.
|
||||
- **Consequence for `F4`:** every `position: fixed; right: 0` element on this page sits at
|
||||
the right edge of that 485px box, which is 95px off the visible 390px screen. The
|
||||
comments drawer is placed correctly relative to its containing block; the containing
|
||||
block is wrong. `T1.4` reports this as an attributed note rather than a drawer defect,
|
||||
so nobody is sent to the wrong file.
|
||||
- **Update, T7.1 - the recorded root cause no longer applies.** Measured after the
|
||||
rebuild by `tests/frame_check.py` section 6: at a 390px viewport the creator's
|
||||
`scrollWidth` is still **485** against a `clientWidth` of 390, so the overflow
|
||||
survives - but `--nav-w` now computes to **56px**, which is the media query
|
||||
winning. The injected `body{--nav-w:288px}` explanation above is spent; whatever
|
||||
fixed it, it was not this task.
|
||||
What is left is a different thing entirely: the widest in-flow boxes are the
|
||||
creator's **data tables**. `#asset-body`'s table lays out at **520px** with no
|
||||
scroll container around it, and the other card tables do the same. The probe
|
||||
reports the offending boxes by selector each run, and deliberately skips
|
||||
anything inside a `position: fixed` subtree - the comments drawer is parked
|
||||
off-screen by `translateX(100%)` and its five static children sit out at
|
||||
`right: 844`, which made the first measurement blame the drawer. That is how
|
||||
this entry got attributed to the wrong file once already; twice would be a
|
||||
pattern.
|
||||
**Not fixed here.** `T7.1` bundles nothing, and the fix is a layout decision -
|
||||
a scroll container, a stacked card at narrow widths, or fewer columns - which
|
||||
belongs with `T7.2` laying the form out again. `frame_check.py` **pins** the
|
||||
current failure, so the check turns red the moment it is fixed and whoever
|
||||
fixes it is told to close this entry.
|
||||
- **Update, T1.2:** the 1440px half of this is **resolved as a side effect**, not by intent.
|
||||
The unbreakable `#wp-usermenu` run that `T1.2` fixed was the cause of four of the five
|
||||
overflows recorded in wave 0 — launcher, SOP wizard and field view at 390px, and the
|
||||
creator at 1440px. Capture now reports overflow on 1 of 14 shots instead of 5. What
|
||||
remains is the creator at **390px** (485px of content), which is its own layout rather
|
||||
than the shared chrome. Left open so `T7.1` still checks it.
|
||||
- **Update, T7.2 - the data-table attribution is spent as well, and the cause has
|
||||
moved a third time.** T7.2 collapses every section at rest, and a collapsed card's
|
||||
tables are `hidden` - they lay out nothing. The at-rest overflow is now
|
||||
**scrollWidth 481 vs 390**, and the widest box is `help.js`'s `.help-tip::after`
|
||||
tooltip, which is rendered (not `display:none`) even when idle and escapes its
|
||||
16px badge to the right. The tables still overflow **when their section is
|
||||
expanded** - that half of the T7.1 note stands and still belongs to a layout
|
||||
decision (scroll container, stacked card, or fewer columns).
|
||||
**Deliberately not fixed in T7.2:** the help tip is the `S8` component, rebuilt
|
||||
whole at `T9.5` - a fix here would be thrown away with the component. `T9.5`
|
||||
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
|
||||
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
|
||||
|
||||
- **Found during:** T0.1
|
||||
- **Where:** `html/work-package-suite-styles.css:325`, `:347`, `:501`
|
||||
- **What:** `A3`/`F5` cite the focus-ring removal at `322-328` only. The same
|
||||
`outline:none` + pale 3px glow is repeated at `:347` (`.user-pick:focus`), and `:501`
|
||||
(`.seq-step input.seq-label:focus`) removes the outline with **no** replacement at all,
|
||||
which is a straight CLAUDE.md violation.
|
||||
- **Why not now:** it is in scope for `T3.4`, not a separate item — recorded so the task
|
||||
fixes all three rather than the one the review cited.
|
||||
- **Suggested wave or follow-up:** fold into `T3.4`.
|
||||
|
||||
### BL-003 — User-menu links are 16px tap targets
|
||||
|
||||
- **Found during:** T1.2
|
||||
- **Where:** `html/auth-guard.js:186-191` (`buildUserMenu`'s `link()`)
|
||||
- **What:** every link in the app bar's user menu — including `Sign out` — renders 16px
|
||||
tall, from `font:400 13px/1.2`. `T1.2` made them all reachable at 390px, but reachable is
|
||||
not the same as comfortably tappable on the gloved-hands surface. Well under the usual
|
||||
24–44px guidance.
|
||||
- **Why not now:** `T1.2` is explicitly triage and `T2.2` replaces this markup with the
|
||||
drawer, which has its own tap targets. Enlarging them here would change the 1440px layout
|
||||
the task must leave byte-identical, and would be thrown away in wave 2.
|
||||
- **Suggested wave or follow-up:** `T2.2` should ship the drawer with adequate targets;
|
||||
`C1`'s audit at `T9.5` confirms it app-wide.
|
||||
|
||||
### BL-004 — CLOSED at T9.9 — `help.js` ships a 52-colour palette in a different design language
|
||||
|
||||
- **Found during:** T3.1
|
||||
- **Where:** `html/help.js:79` (the injected `<style>`)
|
||||
- **What:** the help centre injects its own stylesheet with **52 colour literals and zero
|
||||
`var()`**. It is not a fourth copy of the suite palette — it is a different one: slate
|
||||
(`#27313f`, `#334155`, `#e2e8f0`), violet (`#7c3aed`, `#f3e8ff`), its own blue
|
||||
(`rgba(37,99,214,.15)`, see BL-008) and its own greys (`#fafbfc`, `#eef1f6`, `#f4f6f9`,
|
||||
`#f7f8fa`). It loads on the launcher, SOP wizard, creator and field view.
|
||||
- **Why not now:** `T3.2`'s contract is "no rendered change", and converting this palette is a
|
||||
restyle, not a consolidation — it would change the help centre on four pages and break the
|
||||
empty-screenshot-diff done-when. The token rule in `CLAUDE.md` does reach it, so it is real
|
||||
work, not a non-issue.
|
||||
- **Suggested wave or follow-up:** wave 9, alongside `C4`. Documented in
|
||||
`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 — CLOSED at T9.9 — Two modals are styled entirely by inline `style=` attributes
|
||||
|
||||
- **Found during:** T3.1
|
||||
- **Where:** `html/auth-guard.js:67-92` (change-password) and `html/wp-format.js:120-150`
|
||||
(preferences)
|
||||
- **What:** 35 raw colour literals between them — `#0f62fe`, `#8d8d8d`, `#e0e0e0`, `#defbe6`,
|
||||
`#fff1f1`, `#0e6027`, `rgba(20,30,50,.5)` and so on — written into `style=` strings, so no
|
||||
stylesheet can reach them and no token can either.
|
||||
- **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`.
|
||||
- **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
|
||||
|
||||
- **Found during:** T3.1
|
||||
- **Where:** `html/wp-creation-styles.css` (14) and `html/wp-chrome.css` (3)
|
||||
- **What:** `9.5px`, `10.5px`, `11.5px`, `12.5px`, `13.5px` sit inside an otherwise integer
|
||||
type scale of 27 distinct sizes. They round inconsistently between engines and there is no
|
||||
reason for any of them.
|
||||
- **Why not now:** retiring them moves text on every creator screen; `T3.2` forbids rendered
|
||||
change and `T7.1` re-lays-out this page anyway.
|
||||
- **Suggested wave or follow-up:** `T7.1`. See `docs/reference/tokens.md` §6a.
|
||||
- **Re-measured at T7.1, unchanged.** `frame_check.py` counts **15** half-pixel
|
||||
sizes in `wp-creation-styles.css` by `\d+\.5px`, which is the whole sheet
|
||||
rather than the font-size subset this entry counted, so the two numbers are not
|
||||
the same measurement and the difference is not a change. `T7.1` re-laid out the
|
||||
page's chrome, not its type. Carried to `T7.2`, which lays out the form.
|
||||
|
||||
### BL-007 — `--radius: 0` is contradicted 45 times in the sheet that declares it
|
||||
|
||||
- **Found during:** T3.1
|
||||
- **Where:** `html/wp-creation-styles.css:26` and 45 raw `border-radius` values in the same file
|
||||
- **What:** the creator declares `--radius: 0` and honours it 23 times, then writes `2px 3px
|
||||
4px 5px 6px 8px 9px 10px 12px 14px 20px 50%` directly in 45 other places, plus two
|
||||
asymmetric CTA radii at `:707` and `:716`. Square corners are the Carbon idiom and the
|
||||
intent everywhere else in the suite; this one sheet drifted.
|
||||
- **Why not now:** changing 45 radii is the most visible diff available, and `T3.2` must
|
||||
produce none.
|
||||
- **Suggested wave or follow-up:** `T7.1`. See `docs/reference/tokens.md` §6c.
|
||||
- **Re-measured at T7.1: 68, not 45.** `frame_check.py` counts raw
|
||||
`border-radius:` declarations that do not resolve through a `var()`. The rise is
|
||||
the counting method rather than 23 new radii - this entry counted values, the
|
||||
probe counts declarations - but the direction is the point: nothing has reduced
|
||||
it in four waves, and it is measured every run now instead of once. Carried to
|
||||
`T7.2`.
|
||||
|
||||
### BL-008 — CLOSED at T9.9 — There is a second brand blue: `#2563d6`
|
||||
|
||||
- **Found during:** T3.1
|
||||
- **Where:** `html/wp-creation-styles.css:565`, `html/help.js`, `html/wp-creation-app.js:1257`
|
||||
- **What:** `.sop-inherited` — the highlight on every field a work package inherited from its
|
||||
SOP — fills with `rgba(37,99,214,0.07)`, which is **`#2563d6`**, not the suite's `#0f62fe`.
|
||||
`help.js` carries the same blue at `.15` alpha and the print window uses it solid for
|
||||
headings. At 7% nobody has noticed, but "one accent colour" is not currently true even after
|
||||
the four token systems collapse to one.
|
||||
- **Why not now:** swapping it changes a rendered fill, which `T3.2` forbids. It is the same
|
||||
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.
|
||||
- **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 — CLOSED at T9.9 — A ninth amber, four points from the eighth
|
||||
|
||||
- **Found during:** T3.2
|
||||
- **Where:** `html/field.html:35` (`.pill.warn`)
|
||||
- **What:** the field view's warn pill uses `#8a6d00`; every other warning text in the app is
|
||||
`#8e6a00`. Four points apart, doing the same job, on the surface that is read through a
|
||||
face shield. Almost certainly a typo rather than a decision — `field.html`'s inline `<style>`
|
||||
was missed by the `T3.1` inventory, which is why it survived this long.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
- **Found during:** T3.2
|
||||
- **Where:** all five page stylesheets; 492 of them in `html/wp-creation-styles.css`
|
||||
- **What:** `T3.2` removed every raw **colour** from the page sheets, but 483 spacing values,
|
||||
281 font-sizes and 65 radii are still written literally in rules. The token *declarations*
|
||||
are aliased — `--s1`…`--s6`, `--ctl`, `--radius`, `--mono`, `--sans` all resolve from
|
||||
`theme-light.css` — but the rules that should consume them do not.
|
||||
- **Why not now:** not effort — arithmetic. The creator's spacing is every integer from 1px to
|
||||
14px, which is a histogram rather than a scale, so there is no token `padding: 9px 11px` maps
|
||||
to without changing one of the two numbers. `T3.2` forbids changing a rendered value, so
|
||||
tokenising these and honouring that constraint are mutually exclusive. This is the one `T3.2`
|
||||
done-when not met, and it is recorded as not met rather than quietly skipped.
|
||||
- **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.
|
||||
|
||||
### BL-011 — CLOSED at T9.9 — Three JS-injected overlays race to append on the SOP page
|
||||
|
||||
- **Found during:** T3.2
|
||||
- **Where:** `html/work-package-suite.html` — `#wp-sync-badge`, `.wp-navscrim`, `#wp-sidenav`
|
||||
- **What:** the sync badge, the drawer scrim and the drawer are appended to `<body>` by three
|
||||
different scripts after async work, so their DOM order varies run to run. Nothing is painted
|
||||
differently — all three are `position: fixed` with their own `z-index` — but any test that
|
||||
keys elements by sibling index sees dozens of phantom differences on this page. It cost real
|
||||
time in `T3.2` before the cause was found, and `tests/token_check.py` now keys by identity
|
||||
to avoid it.
|
||||
- **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.
|
||||
- **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 — CLOSED at T9.9 — `admin.html` and the creator at 1440px are not stable enough to screenshot-diff
|
||||
|
||||
- **Found during:** T3.2
|
||||
- **Where:** `tests/baseline_shots.py` output for `admin-390`, `admin-1440`, `creator-1440`
|
||||
- **What:** the task brief's trap 2 says `admin.html` and `users.html` are not byte-stable.
|
||||
Measured by capturing wave 2 against itself: **`users` is stable at both widths**, and the
|
||||
unstable third is the **creator at 1440px** (344,272 px differ, bbox 288,14→1439,4924).
|
||||
`admin` is worse than "live timestamps" suggests — its captured page *height* varies by about
|
||||
600px between runs, so the two images cannot even be compared pixel-for-pixel.
|
||||
- **Why not now:** the screenshots are a review aid, not a gate; `tests/token_check.py` now
|
||||
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
|
||||
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
|
||||
|
||||
- **Found during:** T3.4
|
||||
- **Where:** `html/wp-creation-styles.css:168` (`outline: none` on every input, textarea and
|
||||
select) and `:171` (`:focus` replaces it with `box-shadow: 0 0 0 3px var(--accent-dim)`)
|
||||
- **What:** the same defect `BL-002` recorded in the wizard sheet, in the sheet next door.
|
||||
Measured in the browser with focus emulation on: a focused creator input reports
|
||||
`outline-style: none`, and its only focus cue is a 3px `#edf5ff` glow against a `#ffffff`
|
||||
field — a 1.05:1 edge. `.wp-nav-search:focus` (`:765`) is the same. That is `CLAUDE.md`'s
|
||||
"outline: none without a replacement of at least equal visibility", on the page with the
|
||||
most form controls in the app.
|
||||
- **Why not now:** `T3.4`'s files are the SOP wizard stylesheet, and `BL-002` scoped the
|
||||
three sites it folded in to that sheet. The creator is rebuilt at `T7.1`/`T7.2`.
|
||||
- **Suggested wave or follow-up:** `T7.2`, or `T9.5` with the `C1` audit if it survives the
|
||||
rebuild. The fix is the ring `T3.4` established: `outline: 2px solid var(--cds-focus);
|
||||
outline-offset: -2px`, which `console.css`, `wp-chrome.css` and now the wizard all use.
|
||||
- **CLOSED at T7.1 - it was already fixed, and this entry was stale.**
|
||||
`wp-creation-styles.css:209` carries an `S12` comment naming this entry, and
|
||||
`:219` sets exactly the ring prescribed above. So it was closed in **wave 4**,
|
||||
by `S12`, and nobody came back to say so - the same way `BL-014`'s launcher half
|
||||
turned out to be closed by `T4.7`.
|
||||
Measured rather than read, twice: `frame_check.py` reports a focused creator
|
||||
input as `outline solid 2px`, and `a11y_check.py` walks **120 focusable elements
|
||||
on the creator** and finds every one of them ringing at 3:1 or better.
|
||||
Worth saying plainly, because this entry was quoted as a live `CLAUDE.md`
|
||||
violation while planning wave 7 and it had not been true for four waves: a
|
||||
backlog entry is a claim with a date on it. Re-measure before acting on one.
|
||||
The first thing `frame_check.py` does with focus is assert
|
||||
`document.hasFocus()`, because an earlier draft called `page.call` instead of
|
||||
`page.ws.call` inside a `try/except` and silently measured nothing at all -
|
||||
which reported "no ring" for every control and looked exactly like a finding.
|
||||
|
||||
### BL-015 — The creator's stepper tabs are still forced uppercase
|
||||
|
||||
- **Found during:** T3.5
|
||||
- **Where:** `html/wp-creation-styles.css:105` (`.step-tab`)
|
||||
- **What:** `A5` scopes sentence case to buttons and field labels, and `T3.5` removed the
|
||||
forced uppercase from both. `.step-tab` is neither — it is a stepper tab — so it was left,
|
||||
and it is now the only uppercase interactive text on the page.
|
||||
- **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.
|
||||
|
||||
### 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
|
||||
- **Where:** `html/work-package-suite-app.js`, the `WPUrl.onChange` handler
|
||||
- **What:** the popstate handler reads `parseInt(state.step, 10)` and acts only when
|
||||
the result is `>= 1`. Going from `?project=X&step=6` back to `?project=X` yields
|
||||
`NaN`, so nothing happens and the wizard stays on step 6 while the address bar says
|
||||
step 1. `T4.2`'s own probe never caught it because it moves between two URLs that
|
||||
both carry a `step`, so the NaN branch was never taken. The fix is one expression —
|
||||
treat a missing `step` as 1 — but it is `S3`'s code and `T4.2`'s done-whens, not
|
||||
`A4`'s.
|
||||
- **Why not now:** `CLAUDE.md` — do not fix what you notice in passing. `T5.1`'s rail
|
||||
makes it easier to reach (ten keyboard-reachable buttons instead of ten chips) but
|
||||
does not cause it, and folding an `S3` correction into an `A4` diff makes both
|
||||
unreviewable.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
- **Found during:** T5.1
|
||||
- **Where:** `docs/reference/file-map.md` §4, metric 1
|
||||
- **What:** the metric is `grep -ohE '\b(alert|confirm|prompt)\(' *.js *.html`, which
|
||||
matches those words inside comments as readily as inside code. Four comments written
|
||||
during `T5.1` — every one of them *about* removing a dialog — pushed the count from
|
||||
80 to 82 while the task was deleting two real calls. They were reworded, but the next
|
||||
person to explain a dialog in a comment will move the number again, and `T5.8` and
|
||||
wave 9 both have to drive it to a target.
|
||||
- **Why not now:** redefining a wave 0 baseline mid-plan is worse than the noise; the
|
||||
count is comparable to itself as long as everyone measures it the same way.
|
||||
- **Suggested wave or follow-up:** `T5.8`, which owns the wizard's count, should record
|
||||
a comment-stripped figure alongside the raw one and state both. Wave 9 sets the
|
||||
target against the stripped figure.
|
||||
|
||||
### BL-018 — CLOSED at T9.9 — The Work Package tab's gate is the last localStorage-derived status
|
||||
|
||||
- **Found during:** T5.3
|
||||
- **Where:** `html/work-package-suite-app.js` — `restoreSavedSOP()` sets `sopComplete`,
|
||||
`renderWPTab()` shows the gate or the creator on it
|
||||
- **What:** `T4.1` moved the *launcher's* SOP status onto `/api/projects/{id}/summary`, and
|
||||
`aggregates_check.py` proves the card reports the server's answer over a lying cache. The
|
||||
SOP **wizard page** still decides whether to show the creator or the "complete the SOP
|
||||
Configuration first" gate from `localStorage.wp_suite_sop_complete` plus a `wp_suite_state`
|
||||
blob. `ProjectData.pullProject()` refreshes both from the server on load, so a signed-in
|
||||
user with a working connection is fine — but the two answers come from different places,
|
||||
and the fallback is silent rather than an error state, which is the shape `B4` objects to.
|
||||
- **Also:** `project-data.js:210` writes `wp_suite_sop_complete = '1'` whenever
|
||||
`/api/sops/latest` returns any row, including one whose `data` carries no `state`. The
|
||||
wizard then holds a browser that believes the SOP is complete and has nothing to restore,
|
||||
so `restoreSavedSOP()` bails and `sopComplete` stays false — the flag is written and never
|
||||
read consistently. Found because `browser_check.py`'s fixture seeds exactly that shape, and
|
||||
a pipeline-strip link consequently landed on the gate. `pipeline_check.py` seeds the
|
||||
production shape (`{sop, state}`) instead.
|
||||
- **Why not now:** `T5.3` is the strip. Changing which source the WP gate trusts changes what
|
||||
the SOP wizard does when offline, and `B7`/`T7.1` dissolves that iframe and rewrites this
|
||||
hand-off wholesale.
|
||||
- **Suggested wave or follow-up:** `T7.1`, or wave 9 with `C2` if the gate survives the
|
||||
rebuild unchanged. Either way `browser_check.py`'s fixture should adopt the `{sop, state}`
|
||||
shape so it stops being the only place this discrepancy is visible.
|
||||
- **Update, T7.1 - the gate survived, and this has now cost a fourth probe.**
|
||||
The WP tab is no longer a frame swap, but it is still gated on `sopComplete`,
|
||||
which is still `restoreSavedSOP()`'s answer. `frame_check.py` had every
|
||||
creator route land on the gate panel until it seeded a readable SOP; it now
|
||||
**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
|
||||
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 — CLOSED at T9.9 — A cost code that has left the list is silently blanked on edit
|
||||
|
||||
- **Found during:** T5.6
|
||||
- **Where:** `html/wp-creation-app.js` — `buildCostCodes()` at `:185`, consumed by
|
||||
`loadPackageIntoForm()`
|
||||
- **What:** `wp_cost` is a `<select>` built from the hardcoded `COST_CODES` array, and
|
||||
`loadPackageIntoForm` sets `.value` from the saved package. Setting `.value` to something
|
||||
with no matching `<option>` does nothing at all — silently — so opening a package whose
|
||||
cost code has since been removed from the array, or that was imported from elsewhere,
|
||||
clears the field. The next save writes the blank back over the record.
|
||||
- **The same class of bug was already fixed once**, for `gov_wosize` in
|
||||
`work-package-suite-app.js:490-495`, by adding the stored value as an option when it is not
|
||||
a preset. The comment there names the reason: "if a saved value isn't one of the presets,
|
||||
add it as an option so the round-trip preserves it." Cost code never got the same treatment.
|
||||
- **Found because** a `T5.6` probe used an invented cost code to prove that hiding a field
|
||||
does not delete its value, and the value came back empty — which looked like the toggle
|
||||
eating data and was not. The probe now uses a real code and says why.
|
||||
- **Why not now:** `T5.6` hides two fields; it does not own how one of them round-trips, and
|
||||
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
|
||||
`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
|
||||
|
||||
- **Found during:** T3.4
|
||||
- **Where:** `html/index.html` `.proj-row select`, `.proj-form-grid input`, `.link-like`;
|
||||
`html/field.html` `.fld-search`
|
||||
- **What:** these have no focus rule, so they get the UA default (`1px auto #111`). Visible,
|
||||
so not a `C1` violation — but it is a fourth focus idiom beside the app's 2px `--cds-focus`
|
||||
inset ring, and it does not follow the accent if the accent ever changes.
|
||||
- **Why not now:** adding rings to the launcher and field view is outside `T3.4`, whose files
|
||||
are the wizard stylesheet, and both surfaces are touched by later waves anyway.
|
||||
- **Suggested wave or follow-up:** `T9.5`, with the `C1` audit.
|
||||
- **Update, T5.2 — two of the four sites no longer exist.** `.proj-row select` and
|
||||
`.link-like` went with the project-picker card (`B3`). The third, `.proj-form-grid input`,
|
||||
survives in the rebuilt create form and was **measured rather than assumed**: with CDP
|
||||
focus emulation on it draws `2px var(--cds-focus)` from the app-wide `:where()` floor
|
||||
`T4.7` added, which post-dates this entry. So the launcher half of BL-014 is closed;
|
||||
what is left is `field.html`'s `.fld-search`, and `T9.5` should re-measure that one the
|
||||
same way rather than inheriting this entry's wording.
|
||||
|
||||
### BL-020 — CLOSED (decided 2026-08-20: keep it) — the wizard-exit prompt stays
|
||||
|
||||
- **Found during:** T7.1
|
||||
- **Where:** `html/wp-autosave.js:96` (the `beforeunload` guard), reached from the
|
||||
tool tabs in `html/work-package-suite.html`
|
||||
- **What:** the Work Package Creation and Dashboard tabs used to swap an iframe
|
||||
inside one document. Since `B7`/`T7.1` they are links to another document, so
|
||||
leaving the wizard with unsaved SOP edits fires `T4.3`'s unsaved-work guard and
|
||||
the browser asks whether to leave. The guard is behaving exactly as designed;
|
||||
what changed is that a routine tab switch is now a page exit.
|
||||
Nothing is lost either way - the guard writes the draft before prompting, and
|
||||
`T4.3`'s recovery restores it on return - so this is friction, not data loss.
|
||||
Note that `sopIsDirty()` compares against the fingerprint taken at load and at
|
||||
`completeSOP()`, so **typing anything at all** makes the wizard dirty until the
|
||||
SOP is completed. On a twelve-step form that is most of the time somebody spends
|
||||
on it.
|
||||
- **Why not now:** suppressing a deliberate guard for one navigation is a product
|
||||
decision with a real downside - it is the same mechanism that stops a closed tab
|
||||
losing work - and `T7.1` is forbidden to bundle anything. Found by
|
||||
`frame_check.py`, which filters the console line rather than hiding it, and says
|
||||
why in the comment.
|
||||
- **Suggested wave or follow-up:** wave 9, with `C2`. If it is to be suppressed,
|
||||
the honest version is an in-app navigation that flushes the draft and marks the
|
||||
departure intentional, not a blanket disabling of the guard. If it is to be
|
||||
kept, `T7.2`'s side navigation is the place to make saving obvious enough that
|
||||
the prompt stops being a surprise.
|
||||
|
||||
### BL-021 — CLOSED 2026-08-20 (`project_sop_team()` reads nested-first; `critical_reopen_check` 11, sink-verified)
|
||||
|
||||
- **Found during:** T7.6
|
||||
- **Where:** `server/app.py`, `project_sop_team()`
|
||||
- **What:** the function reads `sop.data["project"]`, but `ProjectData.pushSOP`
|
||||
stores every SOP row as `data = {sop: ..., state: ...}` — the project block
|
||||
lives at `data["sop"]["project"]`. The lookup therefore always returns `[]`,
|
||||
and the critical-constraint-reopened email (Phase S wave) has never actually
|
||||
reached the PM or CM it names as recipients; only the owner and distribution
|
||||
got it. Found while writing `project_qa_group()` for `CR-014`, which reads the
|
||||
correct path (and tolerates the flat one for safety).
|
||||
- **Why not now:** T7.6 is scoped to the QA gate; fixing another feature's
|
||||
recipient list inside it is the drive-by CLAUDE.md forbids. The fix is one
|
||||
line, but it deserves its own verification against the capture sink.
|
||||
- **Suggested wave or follow-up:** wave 9 backlog sweep (`T9.9`), verified with
|
||||
the `tests/qa_gate_check.py` sink pattern.
|
||||
|
||||
### BL-022 — CLOSED 2026-08-20 (strict 2.0; the chrome compressed to 1,784px = 1.98 screens; form_structure_check 51/51 for the first time)
|
||||
|
||||
- **Found during:** T7.2, re-measured at the wave 7 exit
|
||||
- **Where:** `html/wp-creation-index.html` page chrome; `tests/form_structure_check.py`
|
||||
- **What:** the creator at rest measures **1,954px against a 900px viewport at
|
||||
1440px** — 2.17 screens. `D3` amended `F6`'s criterion to "no single view
|
||||
exceeds roughly two screen heights at rest"; the probe encodes "roughly two"
|
||||
strictly as 2.0 and is red by ~154px. The remainder is page chrome, not form:
|
||||
the context bar (~67px), the release banner (~45px + margin), and header/
|
||||
toolbar spacing. The form itself went from 5,399px to this.
|
||||
- **Why not now:** the criterion was amended once already, in writing (`D3`).
|
||||
Deciding that 2.17 "is roughly 2" — or trimming chrome that other items placed
|
||||
deliberately (`A2` made the banner the ONE warning; the context bar is the
|
||||
SOP identity strip) — is a product call, not an implementation detail.
|
||||
- **Suggested wave or follow-up:** needs Nick. Either bless 2.17 (one-line probe
|
||||
change, criterion satisfied as written) or name the chrome to compress and it
|
||||
becomes a small T9 task. The strict check stays red so the question cannot be
|
||||
forgotten.
|
||||
|
||||
### BL-023 — CLOSED into D12 (decided 2026-08-20: the dashboard) — see decisions-2026-08-20.md
|
||||
|
||||
- **Found during:** T9.2 (logged as that task's done-when requires)
|
||||
- **Where:** future — dashboard / rollups
|
||||
- **What:** Actual Hours is tracked (CR-017, deliberately kept) and estimated
|
||||
hours exist on every package; nothing yet compares them. A productivity
|
||||
factor (actual ÷ estimated, rolled up by discipline / building / type the way
|
||||
CR-018 rolls cost) is the measurement Marlena's tracking exists to enable.
|
||||
`/api/wps/metrics` already carries both sums, so this is a presentation
|
||||
task, not a data one. (Corrected at D12: the entry originally credited
|
||||
`/api/projects/{id}/summary` too, which carries no hours at all.)
|
||||
- **Why not now:** new scope — needs its own item id per the working rules, and
|
||||
a product conversation about where it displays and who reads it.
|
||||
- **Suggested wave or follow-up:** next revision; needs Nick for placement.
|
||||
|
||||
### BL-024 — CLOSED 2026-08-20 (wp-dialog.js, the T7.9 kit shared; 21 -> 0; `console_dialogs_check` 17)
|
||||
|
||||
- **Found during:** T9.5 (the audit's dialog count)
|
||||
- **Where:** `admin.js` (6), `users.js` (10), `index.html` (5)
|
||||
- **What:** the app-wide native dialog count fell 79 → 21 across `S1`'s two
|
||||
tasks (`T5.8` wizard, `T7.9` creator). The remainder sit on surfaces no `S1`
|
||||
task ever named — admin-only or low-frequency flows, every one a genuine
|
||||
confirm-before-destroy. The T7.9 dialog kit (`wpConfirmDialog`/
|
||||
`wpPromptDialog`) is built and proven; conversion is mechanical.
|
||||
- **Why not now:** converting three more pages inside the audit task is the
|
||||
drive-by CLAUDE.md forbids; the audit's job was to measure and document.
|
||||
- **Suggested wave or follow-up:** next revision, one task, using the T7.9 kit.
|
||||
|
||||
### BL-025 — CLOSED 2026-08-20 (tint rebased onto THE blue; color_check greps space-free spellings)
|
||||
|
||||
- **Found during:** the 2026-08-20 transparency fix (undefined-token sweep)
|
||||
- **Where:** `help.js`, the help-centre search input's `:focus` rule:
|
||||
`box-shadow:0 0 0 2px rgba(37,99,214,.15)`
|
||||
- **What:** BL-008 removed the second brand blue (#2563d6 = rgb 37,99,214) and
|
||||
`color_check` greps both spellings — but only inside `theme-light.css`, and
|
||||
only with spaces (`37, 99, 214`). This space-free rgba consumer slid past
|
||||
both nets. C4's recorded exception legitimately allows rgba **alphas** as
|
||||
opacity recipes, so this is not a token-rule defect; it is the wrong BASE
|
||||
colour under the alpha. The correct tint is THE blue: `rgba(15,98,254,.15)`.
|
||||
- **Why not now:** noticed in passing during an unrelated fix; one-line change
|
||||
plus widening `color_check`'s grep to space-free spellings deserves its own
|
||||
entry rather than a drive-by.
|
||||
- **Suggested wave or follow-up:** next housekeeping pass, with the check
|
||||
widened so it cannot recur.
|
||||
273
docs/waves/decisions-2026-08-18.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Decisions — August 18, 2026
|
||||
|
||||
Approved by Nick Siegfried during wave 7 planning, in answer to twenty-one questions raised
|
||||
at the wave 6 exit and eleven follow-ups.
|
||||
|
||||
These are **new items**, not reinterpretations of existing ones. `CLAUDE.md` forbids
|
||||
renumbering or reinterpreting `CR`/`F`/`S`/`A`/`B`/`C` IDs because they are referenced in
|
||||
documents outside this repo, so everything decided here gets its own `D` prefix and each
|
||||
entry names the existing item it amends. Where a `D` item changes an acceptance criterion,
|
||||
the original criterion is quoted and the replacement is stated, so a reader of the R2
|
||||
document can see exactly what moved.
|
||||
|
||||
Ten items. Seven are new build work; three amend criteria on work already scheduled.
|
||||
|
||||
---
|
||||
|
||||
## D1 — Sample data returns to the creator
|
||||
|
||||
- **Amends:** `B7` (`T7.1`), `S7` (`T9.4`)
|
||||
- **Surface:** `html/`
|
||||
|
||||
`wp-creation-styles.css` opens with `body.embedded .embed-hide { display: none }` — framed,
|
||||
the creator hides its own header, its sample-data controls and its analytics button. `T7.1`
|
||||
dissolves the frame, so "framed" stops being a state and those three controls need a home or
|
||||
a deletion. Decided: **the sample-data control comes back and is visible.**
|
||||
|
||||
That collides with `S7`, which reduces four sample-data affordances under three names to one.
|
||||
It is not a contradiction: `S7`'s complaint is that there are four of them, that they are
|
||||
named inconsistently, and that one sits a click from live project data. A single, clearly
|
||||
named, confirmed affordance on the creator satisfies both. `T9.4` owns making it the only
|
||||
one; `T7.1` owns making it visible.
|
||||
|
||||
**Done when:** the creator's sample-data control is reachable on the unframed page, and
|
||||
`T9.4` still ends with exactly one affordance app-wide.
|
||||
|
||||
---
|
||||
|
||||
## D2 — The QA distribution list is configured in the SOP
|
||||
|
||||
- **Amends:** `CR-014` (`T7.6`)
|
||||
- **Surface:** `server/` + `html/`
|
||||
|
||||
`CR-014` says entering `Ready for QA` "emails the assigned QA representative and the QA/QC
|
||||
members named on the work package". Asked who should receive it, the answer was **the QA
|
||||
group only, and the group is set up once during SOP configuration as a dropdown** — not typed
|
||||
per work package.
|
||||
|
||||
That makes the recipient list SOP configuration, which means a wizard field, which means
|
||||
`T7.6` acquires a dependency on the wave 5 SOP wizard it did not have.
|
||||
|
||||
Rejection routing was also settled: **a rejection notifies the work package owner and the
|
||||
distribution list**, not only the person who submitted it.
|
||||
|
||||
**Replaces the `CR-014` done-when** "entering it sends email to the QA rep and named QA/QC
|
||||
members" with:
|
||||
|
||||
- the QA distribution list is chosen in the SOP wizard from project members
|
||||
- entering `Ready for QA` emails that list and nobody else
|
||||
- rejection emails the work package owner and the same list
|
||||
|
||||
---
|
||||
|
||||
## D3 — The creator stays one page: side navigation plus collapsible sections
|
||||
|
||||
- **Amends:** `F6` (`T7.2`)
|
||||
- **Surface:** `html/`
|
||||
|
||||
`T7.2` as written says "section tabs or an equivalent that shows one coherent group at a
|
||||
time", with the done-when "no single view exceeds roughly two screen heights at 1440px".
|
||||
Asked to choose, the answer was **one long form with navigation down the side** — and then,
|
||||
when the height conflict was raised, the decision was delegated: *"do what you recommend is
|
||||
best to UX."*
|
||||
|
||||
**Recommended and adopted:** one page, persistent side navigation, and each section
|
||||
collapsible with only the current one open by default, plus an `Expand all` control for
|
||||
people who would rather scroll straight through. Tabs were rejected because they hide
|
||||
sections a first-time author does not know exist; an uncollapsed long form was rejected
|
||||
because it is the ~4,700px page `F6` exists to fix.
|
||||
|
||||
**Replaces the `F6` done-when** "no single view exceeds roughly two screen heights at 1440px"
|
||||
with "no single view exceeds roughly two screen heights at 1440px **at rest**" — that is,
|
||||
with the default collapse state, which is the state the page is actually in when it loads.
|
||||
Expanding everything deliberately is allowed to exceed it.
|
||||
|
||||
**Replaces** "toggled-off sections have no tab" with "toggled-off sections have no entry in
|
||||
the side navigation", and "tabs are keyboard operable and follow the tab pattern with
|
||||
`aria-selected` and arrow keys" with "the side navigation is keyboard operable, marks the
|
||||
current section with `aria-current`, and each section header is a real disclosure button with
|
||||
`aria-expanded`".
|
||||
|
||||
---
|
||||
|
||||
## D4 — Urgent surfaces the audited override; it does not bypass the gate
|
||||
|
||||
- **Amends:** `CR-003` (`T6.2`, shipped) and `A1` (`T7.3`)
|
||||
- **Surface:** `html/`
|
||||
|
||||
Asked whether an `Urgent` package should be issuable while a constraint is still open, the
|
||||
first answer was "maybe, with a constraint active". The concern raised back: silently
|
||||
bypassing the gate would undermine the delay-documentation use case that is the entire
|
||||
business justification for the constraint workflow — a constraint log that can be shown to
|
||||
have been skipped stops supporting a notice of delay. **Answer: yes, surface the audited
|
||||
path instead.**
|
||||
|
||||
So `Urgent` does not create a new bypass. It makes the existing logged-override path —
|
||||
`confirmEarlyRelease()` — prominent on an urgent package, so the release still produces a
|
||||
history entry with who, when and why.
|
||||
|
||||
**Adds to the `T7.3` done-when list:**
|
||||
|
||||
- an `Urgent` package with an open constraint offers the logged override in the primary
|
||||
action position rather than behind a secondary control
|
||||
- taking it still writes actor, timestamp and reason to history
|
||||
- no code path releases a constrained package without writing that entry
|
||||
|
||||
---
|
||||
|
||||
## D5 — Usage data moves to the admin console
|
||||
|
||||
- **Amends:** `B7` (`T7.1`)
|
||||
- **Surface:** `html/`
|
||||
- **New task:** `T7.10`
|
||||
|
||||
`docs/reference/creator-frame.md` §2b records that five of the nine colliding script globals
|
||||
are usage analytics implemented twice, and that the wizard's `showAnalytics` has no caller
|
||||
because the button lives on the creator. Asked where it belongs now that the creator is a
|
||||
page in its own right, the answer was **the admin console**.
|
||||
|
||||
That deletes one of the two implementations rather than merging them, and takes an
|
||||
operator-facing control off a package-authoring screen.
|
||||
|
||||
**Done when:** analytics is reachable from `admin.html`, no analytics control remains on the
|
||||
creator or the wizard, and only one of the two implementations survives.
|
||||
|
||||
---
|
||||
|
||||
## D6 — The material list uploads at SOP configuration
|
||||
|
||||
- **Amends:** `CR-013` (`T8.5`)
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **New task:** `T8.6`
|
||||
|
||||
`CR-013` is explicit that there is no parts catalog: line items are free text, because
|
||||
Nate's spreadsheet and the master material workbook have not been supplied
|
||||
(`IMPLEMENTATION.md` section 8). Asked whether to wait for them or build the upload path now,
|
||||
the answer was **upload now**.
|
||||
|
||||
This is `CR-005` applied to a second list. The location upload built at `T5.4` — paste or
|
||||
file, validated, rejected rows reported with the source line, editable afterwards — is the
|
||||
pattern to reuse rather than re-invent.
|
||||
|
||||
**This does not open the out-of-scope parts catalog.** Section 7 defers "full material request
|
||||
and inventory system, including the parts catalog and warehouse integration". A project-scoped
|
||||
list uploaded by the project is not a catalog, carries no inventory levels and integrates
|
||||
with nothing. If the diff grows a stock count, it has crossed the line.
|
||||
|
||||
**Done when:** a material list uploads during SOP configuration, `T8.5` line items select
|
||||
from it, free text still works when the list is empty, and no inventory or warehouse field
|
||||
exists in the diff.
|
||||
|
||||
---
|
||||
|
||||
## D7 — Archived projects are readable, by project admins
|
||||
|
||||
- **Amends:** `B3` (`T5.2`, shipped), `C1`
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **New task:** `T9.8`
|
||||
|
||||
Archiving exists for both projects and work packages, and the server already refuses writes
|
||||
to an archived project. What it does not do is show them: an archived project leaves the
|
||||
launcher and there is no way back to it. Asked who should be able to open one, the answer
|
||||
was **project admins only**, arrived at after considering everyone.
|
||||
|
||||
So this is a listing and a read-only render behind a role check, not a new archive
|
||||
mechanism.
|
||||
|
||||
**Done when:** a project admin can list and open archived projects, everything renders
|
||||
read-only, a non-admin sees no archived projects at all, and no write path is reachable from
|
||||
the read-only view.
|
||||
|
||||
---
|
||||
|
||||
## D8 — Upload limits: 5MB a file, 2GB a project, PDFs and images, one database
|
||||
|
||||
- **Amends:** `CR-007` (`T7.7`)
|
||||
- **Surface:** `server/` + `html/`
|
||||
|
||||
Four answers, taken together:
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| Per-file size limit | 5MB |
|
||||
| Accepted types | PDFs and images |
|
||||
| Where files live | the same database as everything else |
|
||||
| What caches offline | only the work packages assigned to that person |
|
||||
|
||||
The follow-up was the consequence: 5MB files in the same database means every encrypted
|
||||
backup carries them, and a busy project reaches a gigabyte without anyone noticing until a
|
||||
restore is slow. Splitting the files out was **rejected** on the grounds that a backup which
|
||||
excludes the drawings is a backup you cannot restore from. **A per-project ceiling of 2GB was
|
||||
approved instead**, with a warning before it is reached.
|
||||
|
||||
**Adds to the `CR-007` done-when list:**
|
||||
|
||||
- a file over 5MB is refused before upload, with the limit stated
|
||||
- a type outside PDF and image is refused before upload, with the accepted types stated
|
||||
- a project at 80% of 2GB warns, and at 2GB refuses with a message naming the ceiling
|
||||
- the running project total is visible where uploads happen
|
||||
- offline caching covers the requesting user's assigned packages, not the whole project
|
||||
|
||||
---
|
||||
|
||||
## D9 — Ready for QA appears in Field View
|
||||
|
||||
- **Amends:** `CR-014` (`T7.6`)
|
||||
- **Surface:** `html/`
|
||||
|
||||
`CR-014` puts the new state on "a dashboard view or filter". Asked whether the field needs to
|
||||
see it too, the answer was **yes, it shows in field view**.
|
||||
|
||||
**Adds to the `T7.6` done-when list:** Field View shows which packages are in `Ready for QA`,
|
||||
and the state is legible at 390px without colour being the only signal.
|
||||
|
||||
---
|
||||
|
||||
## D10 — Email is switched on and off from the admin console
|
||||
|
||||
- **Amends:** `CR-011` (`T8.3`), `CR-014` (`T7.6`)
|
||||
- **Surface:** `server/` + `html/`
|
||||
|
||||
All new outbound email in this plan ships **gated off** — a standing instruction from the
|
||||
wave 5 kickoff, verified against a capture sink rather than a real mail server. Asked who
|
||||
turns it on, the answer was **the admin controls email**.
|
||||
|
||||
That makes the gate a stored setting with a console control, not an environment variable
|
||||
somebody edits on the server. Existing hardening constraints stand: the SMTP password lives
|
||||
in the environment and nowhere else, and no customer IP appears in an email body.
|
||||
|
||||
**Done when:** the send path is complete and exercised against a capture sink, the default is
|
||||
off, an administrator can turn it on from `admin.html`, a non-administrator cannot, and
|
||||
changing it is written to the audit log.
|
||||
|
||||
---
|
||||
|
||||
## Closed without work
|
||||
|
||||
**Free-text location migration.** Work packages created before `CR-004` recorded their
|
||||
location as typed text, and those values match nothing in the structured list, so they do not
|
||||
roll up. The question was whether somebody should match them by hand or leave them as
|
||||
history. Answer: *"It is just sample data at this point so that is ok."* No migration is
|
||||
built and no item is opened. If this plan ever runs against a database with real
|
||||
pre-`CR-004` packages in it, this decision has to be revisited before `T6.4`'s rollups can be
|
||||
trusted — noted here so that is a decision and not a surprise.
|
||||
|
||||
**The off-palette colours.** `BL-004`, `BL-005`, `BL-008` and `BL-009` were each deferred by
|
||||
their own wave on the same grounds: fixing them changes a rendered colour, and the wave that
|
||||
found them was forbidden to change one. Together they are the help centre's separate
|
||||
52-colour palette, two dialogs styled by inline attributes, a second brand blue and a ninth
|
||||
amber. Asked whether to unify them or leave them, the answer was **change them**. They stay
|
||||
in wave 9 under `C4`, but they are now approved work rather than four open questions.
|
||||
|
||||
---
|
||||
|
||||
## What this changes in the wave table
|
||||
|
||||
| Wave | Was | Now also |
|
||||
|---|---|---|
|
||||
| 7 | `B7` `A1` `CR-015` `A2` `A6` `CR-014` `CR-007` `B6` `S1`(creator) `F6` | `D1` `D2` `D3` `D4` `D5` `D8` `D9` `D10`(half) |
|
||||
| 8 | `CR-009` `CR-010` `CR-011` `CR-012` `CR-013` | `D6` `D10`(half) |
|
||||
| 9 | `CR-008` `CR-017` `S6` `S7` `C1` `C2` `C4` | `D7`, and `C4` now carries the four approved colour items |
|
||||
|
||||
Three new tasks — `T7.10`, `T8.6` and `T9.8`. Everything else is an amended criterion on a
|
||||
task that already existed.
|
||||
102
docs/waves/decisions-2026-08-20.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Decisions — August 20, 2026
|
||||
|
||||
One item. Like the August 18 set, it is a **new item** with its own `D` id, not a
|
||||
reinterpretation of an existing one.
|
||||
|
||||
---
|
||||
|
||||
## D11 — The Micron asset picker merges into the R2 creator
|
||||
|
||||
- **Arrived as:** `origin/Micron-Assets` (`7ef1fcd`, Cody Schaefer, Aug 18) — written
|
||||
against pre-R2 `main`, integrated here by Nick's instruction on Aug 20.
|
||||
- **Amends:** the R2 completion record's "Asset database integration — out of scope,
|
||||
confirmed unbuilt" line, which was true when written and stops being true here.
|
||||
- **Surface:** `html/` (creator), `server/` (`assets_db.py`, `/api/assets`),
|
||||
`docker-compose.yml`, `requirements.txt`.
|
||||
|
||||
### What the branch brought
|
||||
|
||||
A read-only lookup onto the Micron asset catalog (a SQL Server instance outside this
|
||||
repo): the whole catalog is fetched once per creator page load through `/api/assets`
|
||||
and searched in memory; picked assets are stored on the package tagged
|
||||
`source:'catalog'` with the DB's own casing; anything not in the catalog is added by
|
||||
hand and visibly tagged manual. CSV import and Excel column paste bulk-add with the
|
||||
same matching. Unconfigured (`MICRON_DB_URL` unset) and unreachable are first-class
|
||||
states that degrade to manual entry — the suite runs without Micron wired up.
|
||||
|
||||
### What integration changed (and why)
|
||||
|
||||
The branch predates waves 5–9, so it used surfaces R2 replaced. Each adaptation keeps
|
||||
Cody's behaviour and moves it onto the R2 idiom:
|
||||
|
||||
1. **Six `alert()` calls → the T7.9 dialog kit and toast.** The creator ships zero
|
||||
native dialogs (`creator_dialogs_check` pins the count). File-handling errors use
|
||||
`toast(msg,'alert')` exactly as the drawings uploader and comment import do;
|
||||
the instructional message and the import summary use the kit, which gained the
|
||||
one-button `wpAlertDialog` shape it was always going to need (BL-024 wants it too).
|
||||
2. **The export block** moved inside T9.1's sectioned `add('assets', …)` frame, so the
|
||||
CR-006 assets toggle keeps governing it. Content is Cody's: two columns, Asset ID +
|
||||
Note, no controls.dev link column.
|
||||
3. **`initAssetPicker()`** joined the R2 `bootData()` loads rather than replacing them.
|
||||
4. **`role="status"`** on the picker's source note, so loading → ready/absent/error
|
||||
announces (C1, the login.html pattern).
|
||||
5. Everything else landed as written: his `⤒` import glyph is already the S6-mapped
|
||||
U+2912, `.material-actions` is the creator's own class, and the styles block
|
||||
declares no colour literal (`color_check` re-verifies).
|
||||
|
||||
### Recorded properties, restated as constraints
|
||||
|
||||
- **Read-only, structurally.** `assets_db.py` contains one SELECT and no other
|
||||
statement; there is no POST route. `assets_check` greps this on every run.
|
||||
- **Credentials are env-only** (`MICRON_DB_URL`), matching the SMTP password rule.
|
||||
Driver errors are logged server-side and never propagated to the browser, because
|
||||
a malformed URL's error text can quote password fragments.
|
||||
- **Unconfigured is not an error.** Local dev and the demo DB run with the picker in
|
||||
manual mode; nothing in the suite requires the catalog to exist.
|
||||
|
||||
---
|
||||
|
||||
# The evening decisions (same day)
|
||||
|
||||
Six answers from Nick, given in one message. Recorded verbatim in intent; each
|
||||
names the item it settles. One new item id is assigned (D12); everything else
|
||||
amends or closes an existing question.
|
||||
|
||||
## The answers
|
||||
|
||||
1. **BL-022 — "strict 2.0."** F6/D3's "roughly two screen heights" means
|
||||
**2.0**, not 2.17. The overage is chrome (~154px: the context bar, the
|
||||
release banner's spacing, header/toolbar padding), so this becomes a build
|
||||
task: compress the chrome without deleting what other items placed
|
||||
deliberately (A2's one-warning banner and the SOP identity strip STAY —
|
||||
they get denser, not removed). `form_structure_check`'s red check flips
|
||||
green by the page actually fitting, not by moving the bar.
|
||||
|
||||
2. **Hold from Draft/Scheduled — "no, leave as is."** The hold branch stays
|
||||
reachable from any status. T7.3's raised question is closed; the shipped
|
||||
behaviour is the decided behaviour.
|
||||
|
||||
3. **CR-014 email bodies — links back to the system; customer context is
|
||||
allowed, confidential documents are not.** The T7.6-era rule ("no customer
|
||||
IP in emails") is refined: naming the customer, the project, the package
|
||||
and where the work happens is fine; what must never be embedded is
|
||||
confidential document CONTENT (drawings, attachments, scope text). Every
|
||||
work-package email carries a deep link back to the package in the system.
|
||||
Build task, sink-verified.
|
||||
|
||||
4. **CR-008 merged-PDF export — known issue, not a build.** The export keeps
|
||||
inline images + listed PDF attachments. Recorded as KNOWN-ISSUES.md §3 so
|
||||
the limitation is a commitment, not a surprise.
|
||||
|
||||
5. **BL-023 → D12 — the productivity factor gets a spot on the dashboard.**
|
||||
Placement delegated ("find a spot on the dashboard"). New item id **D12**:
|
||||
actual ÷ estimated hours, from data the rollup endpoints already carry.
|
||||
|
||||
6. **BL-020 — "keep it."** The unsaved-work prompt on leaving the wizard
|
||||
stays. Closed as decided-keep; no build.
|
||||
|
||||
Plus: **"do what's left on the housekeeping"** — BL-021 (the
|
||||
critical-reopen recipient bug), BL-024 (the 21 console/launcher dialogs onto
|
||||
the shared kit), BL-025 (the last second-blue tint + the widened check), and
|
||||
S13 (seed_demo sign-in) are approved to build now, one commit each, on
|
||||
`feat/wp-suite-r3-housekeeping`.
|
||||
91
docs/waves/wave-0.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Wave 0 — Orientation
|
||||
|
||||
**Do this before wave 1.** Nothing else in the plan should be started until `T0.1` is merged.
|
||||
|
||||
The item IDs and file references throughout this plan come from a UX review conducted on
|
||||
branch `users/directory-super-user`, not from a fresh read of the repo you are in. The line
|
||||
numbers were accurate at the time of the review. Confirm them before relying on them.
|
||||
|
||||
---
|
||||
|
||||
### T0.1 — Build the file map
|
||||
|
||||
- **Items:** none (prerequisite)
|
||||
- **Depends on:** nothing
|
||||
- **Blocks:** every other task
|
||||
- **Surface:** documentation only
|
||||
- **Files:** creates `docs/reference/file-map.md`
|
||||
|
||||
**Do:**
|
||||
|
||||
Inventory the frontend and record it. The review described `html/` as 6 pages, 4
|
||||
stylesheets, roughly 11,900 lines. Confirm that and write down what is actually there.
|
||||
|
||||
For each of the 6 pages record: file path, what it is called in this plan (launcher, SOP
|
||||
wizard, creator, admin, field view, directory/users, login), which stylesheets it loads,
|
||||
which scripts it loads, and whether it renders inside an iframe or hosts one.
|
||||
|
||||
For each stylesheet record: path, and which pages consume it.
|
||||
|
||||
Then verify these specific references from the review and record the current line number
|
||||
next to each, or mark it `NOT FOUND`:
|
||||
|
||||
| Reference | Claim |
|
||||
|---|---|
|
||||
| `wp-creation-app.js:1144` | `alert()` reading "Subject and WP Type are required" |
|
||||
| `wp-creation-app.js:1962-1972` | logged-override path for predecessor constraints |
|
||||
| `work-package-suite-app.js:326` | the only `beforeunload` listener, analytics dwell tracking |
|
||||
| `work-package-suite-styles.css:322-328` | `outline: none` plus a pale 3px focus glow |
|
||||
| `work-package-suite-styles.css:336` | comment about `.field-hint` rendering unstyled |
|
||||
| `console.css:85-87` | the contrast fix that was not propagated |
|
||||
| `admin.js:484-517` | language and time localization |
|
||||
| `server/seed_demo.py` | seeding script that does not authenticate |
|
||||
|
||||
Also record counts, so later waves can prove they went down:
|
||||
|
||||
- occurrences of `alert(`, `confirm(`, `prompt(` — review found 79 app-wide, 43 in the creator
|
||||
- `<div` and `<span` elements carrying `onclick` — review found 12 and 2
|
||||
- declarations of `#0f62fe` — review found 4
|
||||
- `.help-tip` elements — review found 15
|
||||
- `aria-live` regions — review found 0
|
||||
- `pushState` calls — review found 0
|
||||
|
||||
**Do not:** change any application code in this task. It is a read and a document.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] `docs/reference/file-map.md` exists and lists all pages, stylesheets and scripts
|
||||
- [ ] every reference in the table above is confirmed with a current line number or marked `NOT FOUND`
|
||||
- [ ] the seven counts are recorded with the command used to produce each
|
||||
- [ ] any file path used in a later wave file that does not exist is listed under a `Discrepancies` heading
|
||||
|
||||
---
|
||||
|
||||
### T0.2 — Confirm the app runs and the baseline is captured
|
||||
|
||||
- **Items:** none (prerequisite)
|
||||
- **Depends on:** T0.1
|
||||
- **Blocks:** every task with a visual done-when
|
||||
- **Surface:** local environment
|
||||
- **Files:** creates `docs/reference/baseline/`
|
||||
|
||||
**Do:**
|
||||
|
||||
Run the app locally (`uvicorn server.app:app` against a throwaway SQLite database) and drive
|
||||
the first-run flow: sign in, create a project, SOP wizard, creator, directory, field view.
|
||||
|
||||
Capture baseline screenshots at **390px** and **1440px** for each of the 6 pages into
|
||||
`docs/reference/baseline/`. Name them `<page>-<width>.png`. These are the before images
|
||||
every later PR compares against.
|
||||
|
||||
While doing this, confirm each of `F1` through `F6` still reproduces and note it. If one does
|
||||
not reproduce, say so in the PR rather than deleting the task from wave 1.
|
||||
|
||||
**Do not:** fix anything you see. Wave 1 does that.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] the app runs locally from a clean database
|
||||
- [ ] 12 baseline screenshots exist (6 pages x 2 widths)
|
||||
- [ ] each of `F1`–`F6` is marked reproduces / does not reproduce, with a screenshot reference
|
||||
- [ ] the exact run command and any setup steps are recorded in `docs/reference/file-map.md`
|
||||
185
docs/waves/wave-1.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Wave 1 — Rendering defects and tooling
|
||||
|
||||
**Items:** `F1`, `F2` (interim), `F3`, `F4`, `F5` (interim), `S13`
|
||||
**Depends on:** wave 0
|
||||
**Why first:** none of these need a design decision, and `F1` and `F2` block all chrome work
|
||||
in wave 2.
|
||||
|
||||
Two tasks here are deliberately partial. `F2` gets a containment fix only because `B1` in
|
||||
wave 2 resolves it properly, and `F5` gets a one-line fix because `C3` in wave 3 removes the
|
||||
underlying cause. Do not over-build either.
|
||||
|
||||
---
|
||||
|
||||
### T1.1 — F1: Header project switcher is stale on every page
|
||||
|
||||
- **Items:** `F1`
|
||||
- **Depends on:** T0.1
|
||||
- **Blocks:** T2.1, T2.2
|
||||
- **Surface:** `html/`
|
||||
- **Files:** per file-map — the app bar/header component and whatever holds active-project state
|
||||
|
||||
**Problem:** The hero, the project picker and the create-user card all show the active
|
||||
project. The app bar still reads "Select a project". Two sources of truth for the same value,
|
||||
and the global one loses.
|
||||
|
||||
**Do:**
|
||||
|
||||
Find every place the active project is read or stored. There is more than one. Collapse them
|
||||
to a single source, and have the app bar subscribe to it rather than holding its own copy.
|
||||
Set the value on project selection and on page load, including a hard refresh and a
|
||||
deep-linked page.
|
||||
|
||||
**Do not:** introduce a state library. This is a single value with a small number of readers.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] the app bar shows the active project on all 6 pages
|
||||
- [ ] value survives a hard refresh on every page
|
||||
- [ ] switching projects updates the app bar and the hero in the same interaction, with no reload
|
||||
- [ ] with no project selected, the app bar reads "Select a project" and the picker is reachable
|
||||
- [ ] exactly one code path writes active-project state; note its location in the PR
|
||||
|
||||
---
|
||||
|
||||
### T1.2 — F2 (interim): stop the app bar clipping at 390px
|
||||
|
||||
- **Items:** `F2` partial
|
||||
- **Depends on:** T1.1
|
||||
- **Blocks:** nothing (superseded by T2.2)
|
||||
- **Surface:** `html/`
|
||||
- **Files:** app bar styles
|
||||
|
||||
**Problem:** At 390px the nav wraps to three rows and clips. "Sign out" is cut in half and
|
||||
search is truncated. Field View runs at this width on tablets with gloved hands.
|
||||
|
||||
**Do:** The smallest change that guarantees no control is unreachable or visually cut. Allow
|
||||
the bar to scroll or stack rather than clip. This is triage, not a redesign — `T2.2` deletes
|
||||
this markup.
|
||||
|
||||
**Do not:** build a responsive menu, a hamburger, or an avatar dropdown. Wave 2 ships the
|
||||
existing drawer instead, and anything built here would be thrown away.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] at 390px no nav control is clipped or unreachable on any of the 6 pages
|
||||
- [ ] "Sign out" is fully visible and tappable at 390px
|
||||
- [ ] search is either fully usable or deliberately hidden at this width, not truncated
|
||||
- [ ] no layout change at 1440px — screenshot diff against baseline is empty
|
||||
- [ ] the PR states this is interim and names `T2.2` as the real fix
|
||||
|
||||
---
|
||||
|
||||
### T1.3 — F3: Logo and project name collide in the SOP header
|
||||
|
||||
- **Items:** `F3`
|
||||
- **Depends on:** T1.1
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `html/`
|
||||
- **Files:** SOP wizard page and its header styles, `.header-left`
|
||||
|
||||
**Problem:** `.header-left` and the injected chrome compete for the same run of the bar and
|
||||
overlap.
|
||||
|
||||
**Do:** Give the two elements a defined relationship rather than letting both claim the same
|
||||
space. Verify with a long project name — "Micron EUV Cleanroom Enable 2667008" is the real
|
||||
one and it is what breaks things.
|
||||
|
||||
**Do not:** solve it by truncating the project name. `B2` in wave 2 owns truncation and
|
||||
needs a breakpoint plan, not a silent ellipsis.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] no overlap at 390px, 768px, 1024px and 1440px
|
||||
- [ ] tested with the longest real project name
|
||||
- [ ] the logo remains fully visible at all four widths
|
||||
|
||||
---
|
||||
|
||||
### T1.4 — F4: Comments drawer renders off-screen over the header
|
||||
|
||||
- **Items:** `F4`
|
||||
- **Depends on:** T0.1
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `html/`
|
||||
- **Files:** standalone creator page and comments drawer styles
|
||||
|
||||
**Problem:** In the standalone creator, the comments drawer renders off-screen and over the
|
||||
header.
|
||||
|
||||
**Do:** Fix the positioning context and stacking. Check whether the drawer is positioned
|
||||
relative to the iframe or the parent — if the iframe boundary is implicated, note it in the
|
||||
PR because `T7.1` removes that boundary.
|
||||
|
||||
**Do not:** raise `z-index` until something happens to work. Identify the containing block
|
||||
first and say what it was in the PR.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] the drawer opens fully on screen in the standalone creator at 390px and 1440px
|
||||
- [ ] the drawer does not cover the header
|
||||
- [ ] the drawer closes and reopens correctly, and scroll position is not lost
|
||||
- [ ] behavior is identical in the embedded and standalone creator
|
||||
|
||||
---
|
||||
|
||||
### T1.5 — F5 (interim): SOP wizard fields read as read-only
|
||||
|
||||
- **Items:** `F5` partial, `A3` partial
|
||||
- **Depends on:** T0.1
|
||||
- **Blocks:** nothing (completed by T3.4)
|
||||
- **Surface:** `html/`
|
||||
- **Files:** SOP wizard stylesheet
|
||||
|
||||
**Problem:** Wizard inputs render with a `#f4f4f4` fill and `#e0e0e0` border, which reads as
|
||||
disabled. Users do not type in them. The cause is that the wizard redeclares its own tokens
|
||||
and never sees `--cds-field: #ffffff`.
|
||||
|
||||
**Do:** Make the fields look editable. The minimal correct fix is to have the wizard consume
|
||||
the existing field token instead of its local override.
|
||||
|
||||
**Do not:** start token consolidation here. That is `T3.1`–`T3.3` and it is a separate,
|
||||
mechanical, whole-app change. Fix the field appearance only.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] wizard inputs render with a white fill and a border visually consistent with inputs elsewhere
|
||||
- [ ] genuinely disabled inputs still look disabled and are distinguishable from enabled ones
|
||||
- [ ] no other component changes appearance — screenshot diff limited to the wizard
|
||||
- [ ] the PR notes that `T3.4` removes the underlying duplicate token
|
||||
|
||||
---
|
||||
|
||||
### T1.6 — S13: `server/seed_demo.py` cannot seed a running instance
|
||||
|
||||
- **Items:** `S13`
|
||||
- **Depends on:** T0.1
|
||||
- **Blocks:** nothing, but makes every later task's setup faster
|
||||
- **Surface:** `server/`
|
||||
- **Files:** `server/seed_demo.py`, and the smoke test for reference
|
||||
|
||||
**Problem:** Every `/api/` route requires a session and the script sends none, so it cannot
|
||||
seed a running instance. It predates the commit that taught the smoke test to sign in.
|
||||
|
||||
**Do:** Give the script the same authentication the smoke test uses. Reuse that code path
|
||||
rather than writing a second one.
|
||||
|
||||
**Do not:** add a bypass, a debug flag, or an unauthenticated seeding route to the server.
|
||||
The script authenticates like a client; the server does not get weaker.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] `seed_demo.py` runs against a freshly started instance and exits 0
|
||||
- [ ] the seeded data is visible in the UI after running it
|
||||
- [ ] running it twice does not error or duplicate data, or it fails with a clear message
|
||||
- [ ] no new unauthenticated route exists — confirm by diff
|
||||
|
||||
---
|
||||
|
||||
## Wave 1 exit criteria
|
||||
|
||||
- [ ] `F1`, `F3`, `F4` fully resolved and confirmed against the wave 0 baseline screenshots
|
||||
- [ ] `F2` and `F5` contained, with their real fixes referenced (`T2.2`, `T3.4`)
|
||||
- [x] `S13` fixed and seeding works (ticked 2026-08-20: the box was missed at the wave exit; re-verified end to end - sign-in, seed, --clean)
|
||||
- [ ] no new `<div onclick>`, no new raw hex values, no new `alert()` calls introduced
|
||||
- [ ] `F6` untouched — it is a structural problem fixed by section tabs in `T7.2`
|
||||
108
docs/waves/wave-2.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Wave 2 — Drawer everywhere, flat strip deleted
|
||||
|
||||
**Items:** `B1`, `B2`, `S4`, `F2` (full)
|
||||
**Depends on:** wave 1 merged
|
||||
**Why here:** this settles global navigation before anything else is redrawn, and it resolves
|
||||
the 390px collapse properly.
|
||||
|
||||
**The correction that defines this wave.** The proposed UX change list called for building a
|
||||
new avatar menu to collapse the eight-item nav. Do not build it. The app already has a
|
||||
drawer that holds the entire nav, and the review's judgment was that it is the best chrome in
|
||||
the app. It ships on 3 of 6 pages while a flat strip duplicates it. Shipping the existing
|
||||
drawer everywhere and deleting the strip is less work than building the menu, and it fixes
|
||||
`F2` as a side effect.
|
||||
|
||||
---
|
||||
|
||||
### T2.1 — Extract the drawer into a shared component
|
||||
|
||||
- **Items:** `B1` part 1
|
||||
- **Depends on:** T1.1
|
||||
- **Blocks:** T2.2
|
||||
- **Surface:** `html/`
|
||||
- **Files:** the drawer markup/styles/script currently used by admin, field view and directory
|
||||
|
||||
**Do:** Lift the drawer into one shared implementation the pages include, rather than three
|
||||
near-copies. Keep its current behavior and appearance — this is extraction, not redesign.
|
||||
Where the three copies differ, take the admin version unless the PR explains otherwise.
|
||||
|
||||
**Do not:** change the drawer's visual design, its animation, or its contents in this task.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] one drawer implementation exists; no page defines its own
|
||||
- [ ] admin, field view and directory look and behave identically to the wave 0 baseline
|
||||
- [ ] screenshot diff against baseline for those three pages is empty at both widths
|
||||
- [ ] the drawer reads active-project state from the single source established in `T1.1`
|
||||
|
||||
---
|
||||
|
||||
### T2.2 — Mount the drawer on all six pages and delete the flat strip
|
||||
|
||||
- **Items:** `B1` part 2, `S4`, `F2` full
|
||||
- **Depends on:** T2.1
|
||||
- **Blocks:** T5.1, T7.2
|
||||
- **Surface:** `html/`
|
||||
- **Files:** all 6 pages, the flat nav strip markup and styles
|
||||
|
||||
**Problem:** The SOP wizard has its own header and no drawer. The creator has neither. These
|
||||
are the two pages people actually live in, so global nav is missing exactly where it is most
|
||||
needed (`S4`).
|
||||
|
||||
**Do:** Mount the shared drawer on all six pages, including the SOP wizard and the creator.
|
||||
Delete the flat nav strip and its styles entirely. Reconcile the SOP wizard's bespoke header
|
||||
against the shared chrome — the wizard keeps its step context, it does not keep its own
|
||||
global nav.
|
||||
|
||||
Unify the brand treatment while you are here: the creator uses a mono wordmark and everything
|
||||
else uses a logo chip. Pick the logo chip.
|
||||
|
||||
**Do not:** leave the flat strip in place behind a media query "just in case". Delete it. Its
|
||||
existence is what produced the duplication.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] all 6 pages render the same drawer with the same contents
|
||||
- [ ] the flat nav strip is deleted; grep confirms no remaining references
|
||||
- [ ] at 390px on every page, no nav control is clipped and every destination is reachable
|
||||
- [ ] Field View at 390px passes with tap targets of at least 44px
|
||||
- [ ] the creator and the SOP wizard show the same brand treatment as the other four pages
|
||||
- [ ] the interim fix from `T1.2` is removed rather than layered on top
|
||||
|
||||
---
|
||||
|
||||
### T2.3 — B2: Project switcher truncation and breakpoints
|
||||
|
||||
- **Items:** `B2`
|
||||
- **Depends on:** T2.2
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `html/`
|
||||
- **Files:** app bar / drawer header styles
|
||||
|
||||
**Problem:** "Micron EUV Cleanroom Enable 2667008" does not fit 280px, and existing rules
|
||||
narrow the switcher to 150px below 900px. This needs a breakpoint plan, not a width bump.
|
||||
|
||||
**Do:** Decide what the switcher shows at each breakpoint and implement that. A workable
|
||||
plan: full name where it fits, project code alone at narrow widths, full name always
|
||||
available in the drawer or on hover/focus. Whatever you choose, write it into
|
||||
`docs/reference/file-map.md` so it is not re-litigated.
|
||||
|
||||
**Do not:** apply a plain ellipsis at every width. A project name truncated to "Micron EUV
|
||||
Clean..." on the page where someone confirms which project they are in is the failure this
|
||||
item exists to prevent.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] the active project is identifiable at 390px, 768px, 1024px and 1440px
|
||||
- [ ] the full name is reachable at every width by some means, and that means is documented
|
||||
- [ ] correctness from `T1.1` is preserved — the switcher never reads "Select a project" while a project is active
|
||||
- [ ] tested with the longest real project name
|
||||
|
||||
---
|
||||
|
||||
## Wave 2 exit criteria
|
||||
|
||||
- [ ] one drawer, six pages, no flat strip
|
||||
- [ ] `F2` resolved for real; the `T1.2` interim is gone
|
||||
- [ ] `S4` closed: global nav exists on the SOP wizard and the creator
|
||||
- [ ] no visual regression on admin, field view or directory versus baseline
|
||||
147
docs/waves/wave-3.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Wave 3 — Token consolidation and one button system
|
||||
|
||||
**Items:** `C3`, `S5`, `A3`/`F5` (full), `A5`
|
||||
**Depends on:** wave 2 merged
|
||||
**Why here:** every remaining visual task lands once instead of four times. Skipping this
|
||||
means "one button system" gets implemented in four token systems and drifts, which is exactly
|
||||
how `.field-hint` broke.
|
||||
|
||||
This wave should produce **no intentional visual change** except the two known defects it
|
||||
fixes. Treat any other diff in the screenshot comparison as a bug in your refactor.
|
||||
|
||||
---
|
||||
|
||||
### T3.1 — Inventory the token systems
|
||||
|
||||
- **Items:** `C3` part 1, `S5` part 1
|
||||
- **Depends on:** T2.3
|
||||
- **Blocks:** T3.2
|
||||
- **Surface:** documentation
|
||||
- **Files:** creates `docs/reference/tokens.md`
|
||||
|
||||
**Problem:** Four parallel token systems. `#0f62fe` alone is declared four times, as
|
||||
`--cds-interactive-01`, `--primary`, and `--accent` twice.
|
||||
|
||||
**Do:** Produce a mapping table: every custom property and every raw hex, hsl or rgb value in
|
||||
the four stylesheets, where it is declared, what consumes it, and which canonical token it
|
||||
maps to. Include spacing and type scales, not just color. Mark genuine duplicates,
|
||||
near-duplicates (values one or two hex digits apart, which are usually accidents), and
|
||||
one-offs.
|
||||
|
||||
**Do not:** change any stylesheet in this task.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] `docs/reference/tokens.md` lists every declaration with file and line
|
||||
- [ ] every value is mapped to a proposed canonical token or flagged as a one-off with a reason
|
||||
- [ ] near-duplicates are called out explicitly, since those are where unintended visual change will come from
|
||||
- [ ] the `#0f62fe` count matches or corrects the wave 0 baseline count
|
||||
|
||||
---
|
||||
|
||||
### T3.2 — Establish one source of truth
|
||||
|
||||
- **Items:** `C3` part 2, `S5` part 2
|
||||
- **Depends on:** T3.1
|
||||
- **Blocks:** T3.3, T3.4, T4.6, T4.7
|
||||
- **Surface:** `html/`
|
||||
- **Files:** all 4 stylesheets
|
||||
|
||||
**Do:** Create the canonical token set in one place. Convert the other stylesheets to alias
|
||||
it. Page stylesheets declare no new values. Keep existing custom property names as aliases
|
||||
where deleting them would touch large amounts of markup — an alias pointing at the canonical
|
||||
token is fine; a second declaration of the same literal is not.
|
||||
|
||||
**Do not:** change any rendered value in this task, including the two known defects. Fix
|
||||
those in `T3.3` and `T3.4` so their diffs are reviewable separately from a 4-stylesheet
|
||||
refactor.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] exactly one declaration exists per canonical value; grep for `#0f62fe` returns 1
|
||||
- [ ] no page stylesheet declares a raw color, spacing or type value
|
||||
- [ ] screenshot diff against the wave 2 state is empty on all 6 pages at both widths
|
||||
- [ ] `docs/reference/tokens.md` is updated to reflect what was actually built
|
||||
|
||||
---
|
||||
|
||||
### T3.3 — Fix `.field-hint` and the drift it documents
|
||||
|
||||
- **Items:** `S5` part 3
|
||||
- **Depends on:** T3.2
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `html/`
|
||||
- **Files:** `work-package-suite-styles.css` around line 336
|
||||
|
||||
**Problem:** There is a comment at `work-package-suite-styles.css:336` describing
|
||||
`.field-hint` rendering unstyled. It is the documented consequence of the duplicate token
|
||||
systems.
|
||||
|
||||
**Do:** Fix `.field-hint` properly now that the tokens are consolidated, and delete the
|
||||
comment. Then check for the same class of failure elsewhere: any selector that silently
|
||||
renders unstyled because it referenced a token the page never saw.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] `.field-hint` renders correctly everywhere it appears
|
||||
- [ ] the stale comment is deleted
|
||||
- [ ] any other silently-unstyled selectors found are listed in the PR and either fixed or logged
|
||||
|
||||
---
|
||||
|
||||
### T3.4 — A3/F5 full fix: fields look editable
|
||||
|
||||
- **Items:** `A3`, `F5` full
|
||||
- **Depends on:** T3.2
|
||||
- **Blocks:** T5.1
|
||||
- **Surface:** `html/`
|
||||
- **Files:** SOP wizard stylesheet
|
||||
|
||||
**Do:** Remove the wizard's local field token override entirely, so it consumes the canonical
|
||||
field token. Remove the interim fix from `T1.5` rather than leaving both.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] the wizard declares no field-specific color of its own
|
||||
- [ ] enabled inputs are visually identical to enabled inputs on every other page
|
||||
- [ ] disabled inputs remain clearly distinguishable from enabled ones
|
||||
- [ ] the `T1.5` interim change is gone; grep confirms it
|
||||
|
||||
---
|
||||
|
||||
### T3.5 — A5: one button system
|
||||
|
||||
- **Items:** `A5`
|
||||
- **Depends on:** T3.2
|
||||
- **Blocks:** T5.1, T7.2
|
||||
- **Surface:** `html/`
|
||||
- **Files:** button styles, and any page overriding them
|
||||
|
||||
**Problem:** Green `Save & View` and green `SOP Complete` against blue everywhere else. Two
|
||||
button languages competing.
|
||||
|
||||
**Do:** One button system: primary, secondary, tertiary, destructive. Map every existing
|
||||
button to one of those roles. Green becomes a status color, not an action color.
|
||||
|
||||
Apply sentence case **to buttons and field labels only**.
|
||||
|
||||
**Do not:** touch the uppercase card-header idiom in `console.css`. It is deliberate, it
|
||||
works, and the review specifically scoped the sentence-case rule to exclude it. Changing it
|
||||
is out of scope.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] every button in the app maps to one of four documented roles
|
||||
- [ ] no green action buttons remain; `Save & View` and `SOP Complete` use the primary role
|
||||
- [ ] buttons and field labels are sentence case
|
||||
- [ ] uppercase card headers in `console.css` are unchanged — confirm by diff
|
||||
- [ ] button roles are documented in `docs/reference/tokens.md`
|
||||
|
||||
---
|
||||
|
||||
## Wave 3 exit criteria
|
||||
|
||||
- [ ] one token source of truth; page sheets alias only
|
||||
- [ ] `#0f62fe` declared once
|
||||
- [ ] no unintended visual change on any page versus wave 2
|
||||
- [ ] fields look editable, buttons speak one language
|
||||
215
docs/waves/wave-4.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# Wave 4 — Platform prerequisites
|
||||
|
||||
**Items:** `B4`, `S3`, `S2`, `B5`, `S10`, `S11`, `S12`, part of `C4`
|
||||
**Depends on:** wave 3 merged
|
||||
**Why here:** `X1` and `X5` are blocking dependencies that resolve in this wave. Two of the
|
||||
most valuable field requests — the QA gate (`CR-014`) and the kitting notification
|
||||
(`CR-011`) — promise emails containing a direct link to a work package. Nothing in this app
|
||||
has a URL. Until `T4.2` lands, those emails cannot be built.
|
||||
|
||||
`T4.5` through `T4.7` are small and could be done any time. They are here because every
|
||||
component built in waves 5 through 8 inherits them, and retrofitting is what `C1` warns
|
||||
about.
|
||||
|
||||
---
|
||||
|
||||
### T4.1 — B4: Server aggregates replace localStorage-derived counts
|
||||
|
||||
- **Items:** `B4`
|
||||
- **Depends on:** T3.2
|
||||
- **Blocks:** T5.3, T6.3, T6.5
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **Files:** server API, launcher, creator dashboard
|
||||
|
||||
**Problem:** Launcher SOP status reads `localStorage`, and the creator's dashboard carries a
|
||||
comment admitting it reads local data and wires to SQL in Phase 2. Per-browser numbers that
|
||||
look authoritative are worse than no numbers: two people looking at the same project see
|
||||
different counts and neither is told.
|
||||
|
||||
**Do:** Add server endpoints returning the aggregate counts the launcher and dashboard need,
|
||||
computed from the database. Point both surfaces at them. Delete the `localStorage` reads for
|
||||
anything presented as a count or a status rollup.
|
||||
|
||||
Design the aggregate shape to support grouping by building, floor and sector, because
|
||||
`CR-018` needs exactly that in wave 6. You do not have to expose the grouping yet, but do not
|
||||
build a shape that cannot.
|
||||
|
||||
**Do not:** leave a `localStorage` fallback for counts. A silently-stale fallback is the
|
||||
failure mode being fixed. If the endpoint fails, show an error state.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] launcher and dashboard counts come from the server
|
||||
- [ ] two different browsers signed in as different users show identical counts for the same project
|
||||
- [ ] no `localStorage` read remains behind any displayed count; grep confirms
|
||||
- [ ] endpoint failure shows an explicit error state, not a zero and not a stale number
|
||||
- [ ] the aggregate response can be grouped by location without a schema change
|
||||
|
||||
---
|
||||
|
||||
### T4.2 — S3: URL state
|
||||
|
||||
- **Items:** `S3`, part of `C4`
|
||||
- **Depends on:** T2.2
|
||||
- **Blocks:** T7.6, T8.3 (blocking dependency `X1`)
|
||||
- **Surface:** `html/`
|
||||
- **Files:** all 6 pages, routing/state handling
|
||||
|
||||
**Problem:** No `pushState` anywhere. Tab switches, dashboard views and package selections
|
||||
change no URL. You cannot send someone a link to WP07, and a refresh loses your place.
|
||||
|
||||
**Do:** Give addressable state a URL. At minimum: the selected project, the selected work
|
||||
package, the active tab or section, and the active dashboard view or filter. Restore state
|
||||
from the URL on load. Make browser back and forward behave.
|
||||
|
||||
Deep links must work for a signed-out user: land on login, then continue to the requested
|
||||
target after signing in. An email link that dumps the recipient on a dashboard is the
|
||||
failure this task exists to prevent.
|
||||
|
||||
**Do not:** use hash-only routing if the server can serve paths. Do not put anything secret
|
||||
in the URL.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] a URL identifying a specific work package opens that work package directly
|
||||
- [ ] the same URL works for a signed-out user via login, landing on the requested target
|
||||
- [ ] refresh preserves project, package, tab and view
|
||||
- [ ] browser back and forward move through states without a full reload or a broken view
|
||||
- [ ] copying the URL and opening it in a second browser reaches the same view
|
||||
- [ ] `pushState` count is greater than 0; record the new count
|
||||
|
||||
---
|
||||
|
||||
### T4.3 — S2: Autosave and unsaved-work guard
|
||||
|
||||
- **Items:** `S2`, part of `C4`
|
||||
- **Depends on:** T4.2
|
||||
- **Blocks:** T7.7 (blocking dependency `X8`)
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **Files:** creator, SOP wizard, `work-package-suite-app.js:326`
|
||||
|
||||
**Problem:** No autosave and no unsaved-work guard. The only `beforeunload` listener is
|
||||
analytics dwell tracking. On a form roughly 4,700px tall, a mis-click loses everything.
|
||||
|
||||
**Do:** Autosave drafts on a debounce and on step or section change. Add a real
|
||||
`beforeunload` guard for genuinely unsaved changes, alongside the analytics listener rather
|
||||
than replacing it. Add draft recovery: if a session ends with unsaved work, offer it back on
|
||||
return.
|
||||
|
||||
**Do not:** fire the guard when nothing has changed. A dialog that appears on every exit
|
||||
gets clicked through and is worse than none.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] typing then navigating away and returning restores the work
|
||||
- [ ] typing then closing the tab prompts; not typing then closing does not
|
||||
- [ ] autosave survives a browser crash simulation (kill the tab, reopen)
|
||||
- [ ] the analytics dwell listener still functions
|
||||
- [ ] autosave failure is surfaced to the user, not swallowed
|
||||
|
||||
---
|
||||
|
||||
### T4.4 — B5: Honest save status
|
||||
|
||||
- **Items:** `B5`
|
||||
- **Depends on:** T4.3
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `html/`
|
||||
- **Files:** creator, SOP wizard, existing toast system
|
||||
|
||||
**Problem:** An "All changes saved" toast already appears, but it belongs to the sync outbox,
|
||||
not the draft. The app already implies a promise it does not keep.
|
||||
|
||||
**Do:** A persistent save-state indicator tied to the actual draft: saving, saved with a
|
||||
timestamp, or failed with a retry. Distinguish it clearly from the sync outbox message, or
|
||||
rename the outbox message so the two are not confused.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] the indicator reflects real draft state, verified by watching it during a save failure
|
||||
- [ ] a failed save is visually distinct from a successful one and offers a retry
|
||||
- [ ] the sync outbox message no longer reads as a draft-save confirmation
|
||||
- [ ] the indicator is announced via `aria-live` (see `T4.5`)
|
||||
|
||||
---
|
||||
|
||||
### T4.5 — S10: `aria-live` regions
|
||||
|
||||
- **Items:** `S10`
|
||||
- **Depends on:** T3.2
|
||||
- **Blocks:** nothing, but every later task depends on the pattern existing
|
||||
- **Surface:** `html/`
|
||||
- **Files:** both toast systems, admin banners, `login.html` as the reference
|
||||
|
||||
**Problem:** Zero `aria-live` regions app-wide. Both toast systems and every admin banner
|
||||
announce nothing. `login.html` already does this correctly with `role="alert"` and
|
||||
`role="status"`; the pattern never spread.
|
||||
|
||||
**Do:** Copy the `login.html` pattern into both toast systems and the admin banners. Errors
|
||||
use `role="alert"` (assertive), confirmations use `role="status"` (polite).
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] every toast and banner announces
|
||||
- [ ] errors interrupt, confirmations do not
|
||||
- [ ] verified with a screen reader, or with an automated check plus a manual read of the markup
|
||||
- [ ] `aria-live` region count is greater than 0; record it
|
||||
|
||||
---
|
||||
|
||||
### T4.6 — S11: Helper text contrast
|
||||
|
||||
- **Items:** `S11`
|
||||
- **Depends on:** T3.2
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `html/`
|
||||
- **Files:** canonical tokens, `console.css:85-87` as the reference
|
||||
|
||||
**Problem:** `--text-dim: #8d8d8d` at 12px is about 2.9:1. `console.css:85-87` already
|
||||
diagnosed and fixed this; the other two sheets did not follow. Now that tokens are
|
||||
consolidated, fix it once.
|
||||
|
||||
**Do:** Raise the dim-text token to meet 4.5:1 against its actual backgrounds. Check it on
|
||||
every surface it lands on, including shaded cards and table rows, not just white.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] all helper and hint text measures at least 4.5:1 against its real background
|
||||
- [ ] the fix lives in the canonical token, not per-sheet
|
||||
- [ ] `console.css` no longer needs its local override; remove it
|
||||
- [ ] measured values are recorded in the PR
|
||||
|
||||
---
|
||||
|
||||
### T4.7 — S12: Restore the focus ring
|
||||
|
||||
- **Items:** `S12`
|
||||
- **Depends on:** T3.2
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `html/`
|
||||
- **Files:** `work-package-suite-styles.css:322-328`
|
||||
|
||||
**Problem:** `outline: none` plus a pale 3px glow. Keyboard users cannot see where they are.
|
||||
|
||||
**Do:** A visible focus indicator meeting at least 3:1 against adjacent colors, applied
|
||||
consistently to every interactive element. Use `:focus-visible` so mouse users do not see
|
||||
rings on click.
|
||||
|
||||
**Do not:** ship `outline: none` without a replacement anywhere in the app.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] tabbing through every page shows a clearly visible indicator at each stop
|
||||
- [ ] the indicator meets 3:1 against adjacent colors on every background it appears on
|
||||
- [ ] no `outline: none` remains without an accompanying visible replacement; grep confirms
|
||||
- [ ] mouse clicks do not leave a persistent ring
|
||||
|
||||
---
|
||||
|
||||
## Wave 4 exit criteria
|
||||
|
||||
- [ ] counts come from the server and match across browsers
|
||||
- [ ] work packages are addressable by URL, including from a signed-out state
|
||||
- [ ] drafts autosave and unsaved work is guarded
|
||||
- [ ] toasts announce, helper text is legible, focus is visible
|
||||
- [ ] `X1` and `X5` are cleared, unblocking `CR-011`, `CR-014`, `CR-004` and `CR-018`
|
||||
310
docs/waves/wave-5.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Wave 5 — Launcher and SOP wizard
|
||||
|
||||
**Items:** `A4`, `B3`, `S9`, `S1` (wizard), `CR-005`, `CR-006`, `CR-002`, `CR-016`
|
||||
**Depends on:** wave 4 merged
|
||||
**Why here:** `CR-006` is the enabler for every removal request in the plan. Building it
|
||||
before `CR-002` and `CR-016` means nothing gets deleted that another project may need.
|
||||
|
||||
First field-visible wave. Everything before this was prerequisites.
|
||||
|
||||
---
|
||||
|
||||
### T5.1 — A4 + S9: Vertical stepper with real buttons
|
||||
|
||||
- **Items:** `A4`, `S9`
|
||||
- **Depends on:** T3.4, T3.5, T2.2
|
||||
- **Blocks:** T5.4, T5.5
|
||||
- **Surface:** `html/`
|
||||
- **Files:** SOP wizard step rail
|
||||
|
||||
**Problem:** The step rail is 10 `<div>` elements with `onclick`, so it is unreachable by
|
||||
keyboard. There is also an orphaned "1 / 10" counter doing the job a stepper should do.
|
||||
|
||||
**Do:** Rebuild the rail as a vertical stepper showing all 10 steps, which is complete, which
|
||||
is current, and which are not yet reachable. Every step is a real `<button>`. Retire the
|
||||
"1 / 10" counter.
|
||||
|
||||
Per `C1`, this is the moment to make it accessible — it is being rebuilt anyway.
|
||||
|
||||
**Do not:** ship a single `<div onclick>` in the replacement. The app-wide count from wave 0
|
||||
must go **down** by 10.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] all 10 steps are `<button>` elements
|
||||
- [ ] the rail is fully operable by keyboard: tab, arrow keys, enter and space
|
||||
- [ ] current step is exposed with `aria-current`
|
||||
- [ ] completed, current and unavailable states are distinguishable without relying on color alone
|
||||
- [ ] the "1 / 10" counter is gone
|
||||
- [ ] app-wide `<div onclick>` count dropped by 10 versus the wave 0 baseline
|
||||
|
||||
---
|
||||
|
||||
### T5.2 — B3: Launcher empty state, then remove the picker card
|
||||
|
||||
- **Items:** `B3`
|
||||
- **Depends on:** T4.1
|
||||
- **Blocks:** T5.3
|
||||
- **Surface:** `html/`
|
||||
- **Files:** launcher, project picker card
|
||||
|
||||
**Problem:** The proposal removes the project-picker card, but the first-run empty state
|
||||
lives inside it. A header dropdown is the wrong home for "create your first project".
|
||||
|
||||
**Do:** Build an explicit first-run empty state on the launcher: no projects yet, here is how
|
||||
to create one, here is the sample project. Only once that exists, remove the picker card.
|
||||
Order matters — removing the card first strands new users.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] a brand-new account with zero projects sees a clear path to create one
|
||||
- [ ] the sample project remains discoverable from the empty state
|
||||
- [ ] the picker card is removed only after the empty state ships
|
||||
- [ ] switching projects still works from the header for users who have projects
|
||||
|
||||
---
|
||||
|
||||
### T5.3 — B4 surface: pipeline strip
|
||||
|
||||
- **Items:** `B4` UI half
|
||||
- **Depends on:** T4.1, T5.2
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `html/`
|
||||
- **Files:** launcher
|
||||
|
||||
**Do:** Build the four-cell pipeline strip against the server aggregates from `T4.1`. Each
|
||||
cell links through to the filtered list, using the URL state from `T4.2`.
|
||||
|
||||
**Do not:** ship it against `localStorage`. That is the specific thing `B4` warns about.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] every number comes from a server endpoint
|
||||
- [ ] each cell links to a filtered view via a shareable URL
|
||||
- [ ] a project with zero work packages renders a sensible empty state, not four zeros that look broken
|
||||
- [ ] the strip announces updates via `aria-live` if it refreshes in place
|
||||
|
||||
---
|
||||
|
||||
### T5.4 — CR-005: Upload building / floor / sector lists at SOP setup
|
||||
|
||||
- **Items:** `CR-005`
|
||||
- **Depends on:** T5.1
|
||||
- **Blocks:** T6.3
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **Files:** SOP wizard, server API, schema
|
||||
|
||||
**Intent:** The location taxonomy differs per project. On Micron, floors within B100 behave
|
||||
like separate buildings, so floor and sector are the unit of both execution and cost
|
||||
tracking. Configure it once per SOP rather than hard-coding it.
|
||||
|
||||
**Do:** A wizard step accepting a bulk upload (CSV or paste) of Building / Floor / Sector
|
||||
values, with the hierarchy preserved. Editable after import: add, rename, deactivate. Store
|
||||
as structured codes, not display strings — `CR-018` needs to group by them.
|
||||
|
||||
Deactivating a value hides it from new work packages without breaking existing ones that
|
||||
reference it.
|
||||
|
||||
**Do not:** hardcode floor or building names. The B100 list has not been supplied yet (see
|
||||
`IMPLEMENTATION.md` section 8). Seed with obviously-fake sample values.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] CSV upload and paste both work and report rejected rows with reasons
|
||||
- [ ] duplicates are detected and reported rather than silently merged
|
||||
- [ ] values are editable after import
|
||||
- [ ] deactivating a value hides it from new work packages; an existing work package referencing it still renders
|
||||
- [ ] values are stored as codes suitable for grouping
|
||||
- [ ] no guessed real-world floor names exist anywhere in the code
|
||||
|
||||
---
|
||||
|
||||
### T5.5 — CR-006: Section toggles
|
||||
|
||||
- **Items:** `CR-006`
|
||||
- **Depends on:** T5.1
|
||||
- **Blocks:** T5.6, T5.7, T7.2
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **Files:** SOP wizard, work package renderer, PDF export, creator
|
||||
|
||||
**Intent:** This is the structural fix behind most removal requests in the plan. Rather than
|
||||
deleting fields globally, each project turns on only the sections it uses. It is what lets
|
||||
Micron drop Kitting and Assets while another project keeps them.
|
||||
|
||||
**Do:** In the SOP creation tool, list every work package section with an on/off toggle:
|
||||
General Information, Location, Scope of Work, Assets, Materials, Kitting, Drawings and
|
||||
Attachments, Constraints, QA/QC, Closeout. Default all on for a new SOP.
|
||||
|
||||
A section toggled off does not render in the creation form, the detail view, or the PDF
|
||||
export. Data already captured is retained, and toggling back on restores it intact.
|
||||
|
||||
**Do not:** delete data on toggle-off. Ever. And note `X4`: the creator is an iframe child
|
||||
until `T7.1`, so toggle propagation into the creator may need revisiting in wave 7 — test it
|
||||
and say what you found.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] all 10 sections are toggleable
|
||||
- [ ] a section toggled off is absent from the form, the detail view and the PDF export
|
||||
- [ ] toggling off then on restores prior data with no loss
|
||||
- [ ] new SOPs default to all sections on
|
||||
- [ ] toggle state propagates into the creator, or the PR documents exactly where it does not and why
|
||||
|
||||
---
|
||||
|
||||
### T5.6 — CR-002: Remove Acumatica Cost Code and Task
|
||||
|
||||
- **Items:** `CR-002`
|
||||
- **Depends on:** T5.5
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `html/` + `server/`
|
||||
- **Files:** work package form, detail view, PDF export
|
||||
|
||||
**Intent:** The team concluded these are noise on a field work package. Cost codes are
|
||||
effectively constant and the Acumatica task mapping is a PM concern. The cost visibility they
|
||||
actually want is by building and floor, which is `CR-004` and `CR-018`.
|
||||
|
||||
**Do:** Hide both fields from the form, the detail view and the PDF export, using the section
|
||||
or field toggle mechanism from `T5.5`. Retain the data and the model.
|
||||
|
||||
**Do not:** drop the columns or delete the data. Another project may need these fields, and
|
||||
that is what the toggles are for.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] neither field appears in the form, detail view or PDF export for the Micron EUV SOP
|
||||
- [ ] existing records still hold their values; confirm by querying
|
||||
- [ ] the fields can be re-enabled for another SOP without a code change
|
||||
- [ ] no schema migration drops data
|
||||
|
||||
---
|
||||
|
||||
### T5.7 — CR-016: Assets section off for Micron EUV
|
||||
|
||||
- **Items:** `CR-016`
|
||||
- **Depends on:** T5.5
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** configuration
|
||||
- **Files:** SOP configuration for Micron EUV
|
||||
|
||||
**Intent:** Asset content duplicates the database Clinton's team maintains. Integration is
|
||||
deferred, so the section comes out in the interim.
|
||||
|
||||
**Do:** Toggle Assets off for the Micron EUV SOP. Leave the section and its data model in the
|
||||
application so the eventual database integration has somewhere to land.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] Assets does not render for Micron EUV in the form, detail view or export
|
||||
- [ ] the section and model remain in the codebase
|
||||
- [ ] another SOP can enable Assets and it works
|
||||
|
||||
---
|
||||
|
||||
### T5.8 — S1 (wizard): Inline validation
|
||||
|
||||
- **Items:** `S1` wizard half
|
||||
- **Depends on:** T5.1
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `html/`
|
||||
- **Files:** SOP wizard validation, `wp-creation-app.js:1144` for the pattern to replace
|
||||
|
||||
**Problem:** Validation is `alert()`-only and `validateStep` guards only steps 1, 5 and 6,
|
||||
while the markup marks required fields throughout. The message "Subject and WP Type are
|
||||
required" names no field, highlights nothing and scrolls nowhere.
|
||||
|
||||
**Do:** Inline validation on every step that has required fields. Errors appear at the field,
|
||||
the first invalid field receives focus, and the message says what is wrong. Announce via the
|
||||
`aria-live` region from `T4.5`. Remove the wizard's `alert()` calls.
|
||||
|
||||
**Do not:** add a native `alert()`, `confirm()` or `prompt()` anywhere. The app-wide count
|
||||
from wave 0 must go down.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] every step with required fields validates them
|
||||
- [ ] each error renders at its field and is associated via `aria-describedby`
|
||||
- [ ] submitting an invalid step focuses and scrolls to the first error
|
||||
- [ ] errors announce to screen readers
|
||||
- [ ] the wizard's native dialog count is 0; record the new app-wide count
|
||||
|
||||
---
|
||||
|
||||
## Wave 5 exit criteria
|
||||
|
||||
- [x] the stepper is keyboard operable and `<div onclick>` count dropped by at least 10
|
||||
— **12 → 2**, exactly ten, and the two survivors are wave 7's and wave 9's
|
||||
- [x] section toggles work end to end, including the export
|
||||
- [x] Acumatica fields and Assets are hidden by configuration, with no data deleted
|
||||
- [x] the wizard has no `alert()` calls left — **0**, raw and comment-stripped
|
||||
- [x] first-run users can create a project
|
||||
|
||||
---
|
||||
|
||||
## Wave 5 exit — what shipped, and what it cost
|
||||
|
||||
First field-visible wave. Everything before this was prerequisites, and the difference
|
||||
shows: waves 1–4 moved almost nothing on screen, and every page in this one looks
|
||||
different.
|
||||
|
||||
### The tasks
|
||||
|
||||
| Task | Items | What landed |
|
||||
|---|---|---|
|
||||
| `T5.1` | `A4` `S9` | Vertical stepper, ten real `<button>`s, keyboard operable, states in words |
|
||||
| `T5.2` | `B3` | First-run empty state, then the picker card removed; switching moved to the app bar |
|
||||
| `T5.3` | `B4` UI | Four-cell pipeline strip, every number from the server, every cell a shareable link |
|
||||
| `T5.4` | `CR-005` | Per-project Building / Floor / Sector taxonomy — new table, four routes, step 11 |
|
||||
| `T5.5` | `CR-006` | Section toggles, shared registry, form + detail view + export |
|
||||
| `T5.6` | `CR-002` | Acumatica cost code and task, hidden by field toggle |
|
||||
| `T5.7` | `CR-016` | Assets off for Micron EUV, by configuration |
|
||||
| `T5.8` | `S1` wizard | Inline validation on five steps; thirteen native dialogs retired |
|
||||
|
||||
### Baseline counts
|
||||
|
||||
| Metric | Wave 0 | End of wave 5 | Moved by |
|
||||
|---|---|---|---|
|
||||
| `<div onclick>` | 12 | **2** | `T5.1` |
|
||||
| native dialogs app-wide | 79 | **64** | `T5.1` (2), `T5.2` (1), `T5.8` (13) |
|
||||
| …of those, in the SOP wizard | 14 | **0** | `T5.8` |
|
||||
| …of those, in the creator | 43 | 43 | wave 7 |
|
||||
| colour literals outside `theme-light.css` | 0 | **0** | held |
|
||||
| SOP wizard steps | 10 | **12** | `T5.4`, `T5.5` |
|
||||
|
||||
### Probes added
|
||||
|
||||
Five, each written because its task's done-when could not be checked by anything that
|
||||
already existed. Run them **one at a time**.
|
||||
|
||||
```bash
|
||||
python tests/stepper_check.py # A4/S9 71 checks
|
||||
python tests/launcher_check.py # B3 58 checks (two seeded databases)
|
||||
python tests/pipeline_check.py # B4 UI 43 checks
|
||||
python tests/locations_check.py # CR-005 58 checks
|
||||
python tests/sections_check.py # CR-006/CR-002/CR-016 88 checks
|
||||
python tests/validation_check.py # S1 81 checks
|
||||
```
|
||||
|
||||
### The four things the probes caught that a review would not have
|
||||
|
||||
1. **`T5.1`** — the rail did not track the form. Fill step 1 and every step ahead stayed
|
||||
padlocked until you navigated. A rail that lies about the form is worse than the strip
|
||||
it replaced, because that one at least lied consistently.
|
||||
2. **`T5.2`** — `f_items.py`'s `F1` went `INCONCLUSIVE`, not `FAIL`. Its probe drove the
|
||||
picker card's `<select>`, which `B3` deleted. It refused to guess, which is the
|
||||
behaviour `f_items` exists for.
|
||||
3. **`T5.3`** — a pipeline link landed on "complete the SOP first". Not the strip's fault:
|
||||
`browser_check`'s fixture stores a SOP shape production never writes. Logged as BL-018.
|
||||
4. **`T5.6`** — a probe reported the cost code being eaten by a section toggle. It was not;
|
||||
the probe used an invented code and `wp_cost` is a `<select>` that silently drops an
|
||||
unknown value. That silent drop is real, is not this wave's, and is BL-019.
|
||||
|
||||
### Open, carried into wave 6 and beyond
|
||||
|
||||
- **BL-010** (raw spacing and type values) is **unchanged**. Every rule wave 5 added
|
||||
consumes `--wp-s*`; none of the 483/281/65 pre-existing literals were converted, because
|
||||
none of the pages carrying them were re-laid-out here. `T7.1` still owns it.
|
||||
- **X4** is resolved rather than deferred — see `T5.5`'s commit. The SOP-borne propagation
|
||||
path is unaffected by `B7`; the live hand-off across the frame is what `T7.1` removes.
|
||||
- **BL-018** (the WP tab's gate is the last `localStorage`-derived status) is the one
|
||||
finding in this wave that is a real user-facing weakness rather than a test artefact.
|
||||
208
docs/waves/wave-6.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Wave 6 — Work package general information
|
||||
|
||||
**Items:** `CR-001`, `CR-003`, `CR-004`, `CR-018`, `A7`
|
||||
**Depends on:** wave 5 merged
|
||||
**Why here:** these are the field's most-requested changes and they need the location lists
|
||||
(`T5.4`), the server aggregates (`T4.1`) and the token system (`T3.2`) to already exist.
|
||||
|
||||
---
|
||||
|
||||
### T6.1 — CR-001: P6 Activity ID and Description
|
||||
|
||||
- **Items:** `CR-001`
|
||||
- **Depends on:** T5.5
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **Files:** work package model, General Information section, list/dashboard, PDF export
|
||||
|
||||
**Intent:** Every work package traces back to the P6 schedule activity that drives it, so a
|
||||
date on the work package is anchored to a schedule activity rather than floating on its own.
|
||||
The meeting placed this at the top of the work package, next to the due date.
|
||||
|
||||
**Do:** Add `P6 Activity ID` and `P6 Activity Description` to General Information. Free text
|
||||
is acceptable — a validated lookup against an imported activity list is explicitly deferred,
|
||||
partly because the Micron schedule is actively being reworked.
|
||||
|
||||
Display the Activity ID next to the Due Date so the schedule driver is visible without
|
||||
scrolling. Add it to the list/dashboard as a sortable column and to the PDF export.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] both fields exist, persist, and survive a reload
|
||||
- [ ] Activity ID renders next to Due Date on the detail view
|
||||
- [ ] the list/dashboard column sorts correctly, including with empty values
|
||||
- [ ] both fields appear on the PDF export
|
||||
- [ ] the fields respect the `CR-006` section toggles
|
||||
|
||||
---
|
||||
|
||||
### T6.2 — CR-003: Priority
|
||||
|
||||
- **Items:** `CR-003`
|
||||
- **Depends on:** T3.5
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **Files:** work package model, General Information, list/dashboard, PDF export
|
||||
|
||||
**Intent:** Due date alone does not convey urgency. Exactly three levels, agreed live in the
|
||||
meeting: Normal, High, Urgent. Normal is the baseline default.
|
||||
|
||||
**Do:** A required dropdown with those three values, defaulting to Normal. Filterable and
|
||||
sortable on the dashboard. Color-coded using canonical tokens only (`X7`). Printed on the
|
||||
PDF export.
|
||||
|
||||
Priority is independent of status: it can change after issue without a status change.
|
||||
|
||||
**Do not:** add a fourth level. Do not introduce a new color outside the token system.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] exactly three values exist; Normal is the default on a new work package
|
||||
- [ ] the dashboard filters and sorts by priority
|
||||
- [ ] priority colors come from canonical tokens; no raw hex added
|
||||
- [ ] color is not the only signal — the label is always present
|
||||
- [ ] priority prints on the PDF export
|
||||
- [ ] changing priority does not alter status
|
||||
|
||||
---
|
||||
|
||||
### T6.3 — CR-004: Building / Floor / Sector dropdowns
|
||||
|
||||
- **Items:** `CR-004`
|
||||
- **Depends on:** T5.4, T4.1
|
||||
- **Blocks:** T6.4, T8.4
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **Files:** Location section, work package model, dashboard filters
|
||||
|
||||
**Intent:** Users pick location from a controlled list rather than typing it. Floors within
|
||||
B100 behave like separate buildings and are the unit of both execution and cost tracking.
|
||||
|
||||
**Do:** Three dependent dropdowns in the Location section, sourced from the project
|
||||
configuration built in `T5.4`. Selecting a Building filters Floors; selecting a Floor filters
|
||||
Sectors. Store codes, not display strings. Add dashboard filters for all three.
|
||||
|
||||
**Do not:** free-text these fields, and do not read the option lists from `localStorage`
|
||||
(`X5`).
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] all three render as dropdowns populated from project configuration
|
||||
- [ ] dependent filtering works, and clearing a parent clears its children
|
||||
- [ ] values persist as codes; confirm by inspecting stored data
|
||||
- [ ] the dashboard filters by each of the three
|
||||
- [ ] a work package referencing a deactivated value still renders correctly
|
||||
- [ ] all option data comes from the server
|
||||
|
||||
---
|
||||
|
||||
### T6.4 — CR-018: Rollup by floor and area
|
||||
|
||||
- **Items:** `CR-018`
|
||||
- **Depends on:** T6.3, T4.1
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **Files:** aggregate endpoints, dashboard
|
||||
|
||||
**Intent:** This is why the Acumatica cost code was removed rather than relabeled. The
|
||||
tracking dimension the team wants is floor and area, not an accounting code. For Phase 2 the
|
||||
intent is to stop treating B100 as one building and monitor by floor, since each floor
|
||||
carries its own instrumentation and material load.
|
||||
|
||||
**Do:** Extend the `T4.1` aggregates to group and total by Building, Floor and Sector. Surface
|
||||
grouped totals on the dashboard. Where hours are captured (Closeout `Actual Hours`, retained
|
||||
per `CR-017`), roll them up along the same dimensions.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] the dashboard groups and totals by Building, Floor and Sector
|
||||
- [ ] totals reconcile against an unfiltered count; verify with a known data set
|
||||
- [ ] Actual Hours rolls up along the same dimensions
|
||||
- [ ] grouping is computed server-side
|
||||
- [ ] work packages with no location assigned appear in an explicit "unassigned" group rather than vanishing
|
||||
|
||||
---
|
||||
|
||||
### T6.5 — A7: Card status lines, footer clarity, language and time
|
||||
|
||||
- **Items:** `A7`
|
||||
- **Depends on:** T3.5
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `html/`
|
||||
- **Files:** card components, footer, `admin.js:484-517`
|
||||
|
||||
**Do:** Clarify status lines on cards and tidy the footer as proposed.
|
||||
|
||||
**Do not:** remove localization. `admin.js:484-517` handles language and time and it is a
|
||||
shipped feature. The review specifically endorsed keeping it. If the proposal reads as
|
||||
removing it, that reading is wrong.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] card status lines read clearly and use the canonical status colors
|
||||
- [ ] the footer is unambiguous about what it is showing
|
||||
- [ ] localization still functions; verify by switching language and time format
|
||||
- [ ] `admin.js:484-517` behavior is unchanged
|
||||
|
||||
---
|
||||
|
||||
## Wave 6 exit criteria
|
||||
|
||||
- [x] P6 activity, priority and structured location all persist and export
|
||||
- [x] the dashboard filters by priority and by all three location levels
|
||||
- [x] rollups are server-computed and reconcile — at **every** level, not only the leaf
|
||||
- [x] localization intact — `admin.js` is byte-identical, and the feature was driven
|
||||
end to end rather than read
|
||||
|
||||
---
|
||||
|
||||
## Wave 6 exit — what shipped
|
||||
|
||||
| Task | Items | What landed |
|
||||
|---|---|---|
|
||||
| `T6.1` | `CR-001` | P6 activity ID and description, beside the due date; a sortable board column |
|
||||
| `T6.2` | `CR-003` | Priority — three levels, Normal default, sortable by escalation, filterable |
|
||||
| `T6.3` | `CR-004` | Dependent Building / Floor / Sector dropdowns; codes, not typed strings |
|
||||
| `T6.4` | `CR-018` | Server-side rollup at every level, hours included, unassigned explicit |
|
||||
| `T6.5` | `A7` | Card status in all three states; a footer that says what it is showing |
|
||||
|
||||
### The one decision each task had to make, rather than inherit
|
||||
|
||||
- **`T6.1`** — "sorts correctly, including with empty values" is not a behaviour, it is a
|
||||
choice. **Blanks sort last in both directions**, because nobody sorts by a column in
|
||||
order to look at the rows with nothing in it.
|
||||
- **`T6.2`** — priority sorts by **escalation**, not alphabetically. `High, Normal, Urgent`
|
||||
would put the most urgent last, which is the one thing the column exists to prevent.
|
||||
- **`T6.3`** — the stored value is the full **path** (`B-ONE/L1`), not the node's own code.
|
||||
A floor's own code is not unique across buildings; its path is. That is what lets a
|
||||
building filter match everything beneath it with a prefix test.
|
||||
- **`T6.4`** — the unassigned group is **shown**. A group set that omits it does not sum to
|
||||
the project total, and a rollup that does not reconcile is decoration.
|
||||
- **`T6.5`** — a card has **three** states, so it needs three status lines, not one plus
|
||||
silence. The silent one was the commonest state on a live project.
|
||||
|
||||
### What did not change, deliberately
|
||||
|
||||
`admin.js` — not one byte. `A7`'s note about localization is the loudest "do not" in the
|
||||
wave file, and `tests/cards_check.py` proves it two ways: by driving the feature through a
|
||||
save and a reload, and by diffing the file.
|
||||
|
||||
### Probes
|
||||
|
||||
```bash
|
||||
python tests/generalinfo_check.py # CR-001 / CR-003 49 checks
|
||||
python tests/rollup_check.py # CR-004 / CR-018 63 checks
|
||||
python tests/cards_check.py # A7 44 checks
|
||||
```
|
||||
|
||||
Both `CR-004` and `CR-018` are checked the way `aggregates_check.py` checks its own: by
|
||||
**poisoning `localStorage`** and demanding the server's answer. `X5` makes both blocking on
|
||||
`B4` for exactly that reason, so agreeing with the browser would have proved nothing.
|
||||
|
||||
### Carried forward
|
||||
|
||||
- **BL-010** unchanged again. Wave 6 added rules that consume tokens; it re-laid-out no page
|
||||
that carries the 829 literals. `T7.1` still owns it.
|
||||
- **BL-018** unchanged and now touched twice: three separate probes have had to seed a SOP
|
||||
in the `{sop, state}` shape because `browser_check.py`'s fixture does not use it. That
|
||||
fixture should adopt the production shape when BL-018 is fixed.
|
||||
- **BL-019** unchanged — the cost-code `<select>` still drops a value that has left the list.
|
||||
439
docs/waves/wave-7.md
Normal file
@@ -0,0 +1,439 @@
|
||||
# Wave 7 — The creator
|
||||
|
||||
**Items:** `B7`, `A1`, `CR-015`, `A2`, `A6`, `CR-014`, `CR-007`, `B6`, `S1` (creator), `F6`,
|
||||
and from August 18: `D1`, `D2`, `D3`, `D4`, `D5`, `D8`, `D9`, `D10`
|
||||
**Depends on:** wave 6 merged, and `T4.2` (URL state) merged
|
||||
**Why last among the build waves:** most of it depends on the iframe decision, and `CR-014`
|
||||
depends on `X1`.
|
||||
|
||||
`T7.1` is the largest engineering item in the plan. Decided and approved: the iframe gets
|
||||
dissolved. Do it first - everything after it is cheaper on the far side.
|
||||
|
||||
**Read `docs/waves/decisions-2026-08-18.md` before starting.** Eight of its ten items land in
|
||||
this wave, four of them as changes to done-when lists written below. Where a task carries an
|
||||
`Amended` block, that block wins over the text above it.
|
||||
|
||||
**Read `docs/reference/creator-frame.md` before `T7.1`.** It is the measurement of the
|
||||
boundary: 21 colliding stylesheet selectors, 9 colliding script globals, 0 colliding markup
|
||||
ids, 28 cross-frame call sites, and the three questions no count can answer.
|
||||
|
||||
---
|
||||
|
||||
### T7.1 — B7: Dissolve the creator iframe
|
||||
|
||||
- **Items:** `B7`, `D1`
|
||||
- **Depends on:** T6.5
|
||||
- **Blocks:** T7.2 through T7.10
|
||||
- **Surface:** `html/`
|
||||
- **Files:** parent page hosting the creator, the creator itself, cross-frame messaging
|
||||
|
||||
**Problem:** The tab row lives in the parent and the toolbar lives in the iframe child, so
|
||||
folding one into the other is impossible while the boundary exists. The boundary is also
|
||||
implicated in `F4` and possibly in `CR-006` toggle propagation (`X4`).
|
||||
|
||||
**Do:** Remove the iframe boundary so the creator renders in the parent document. Expect to
|
||||
untangle: cross-frame messaging, duplicated styling, focus and scroll management, and
|
||||
anything relying on separate document contexts.
|
||||
|
||||
Do this as its own PR with no feature work attached. It will be large and it needs to be
|
||||
reviewable as a structural change.
|
||||
|
||||
**Do not:** bundle any other item into this task, and do not leave a dormant iframe path
|
||||
behind a flag.
|
||||
|
||||
**Amended August 18 (`D1`, `D5`).** `creator-frame.md` section 4 lists three questions this
|
||||
task has to settle. Two are now answered:
|
||||
|
||||
- **Does the creator get the app bar back?** Yes. It is the only page in `html/` that loads
|
||||
neither `wp-chrome.css` nor `wp-chrome.js`, and a page without the app bar is not a page.
|
||||
Expect to redo the wizard's layout arithmetic - `chromeHeight()`, `--wp-chrome-h`, the
|
||||
`embed-full` sizing.
|
||||
- **What happens to `body.embedded`?** It goes, and with it `.embed-hide`. The three controls
|
||||
it was hiding: the creator's own header stays hidden because the app bar replaces it; the
|
||||
**sample-data control becomes visible** (`D1`); the analytics button stays visible here and
|
||||
**moves to the admin console at `T7.10`** (`D5`). Do not delete it in this task - a task
|
||||
that both dissolves the frame and deletes a feature is two diffs.
|
||||
- **One sequence component or two?** Still open, and still this task's call. Nine of the 21
|
||||
colliding selectors are the sequence editor. The wizard authors the sequence and the creator
|
||||
renders it; confirmed August 18 that those stay two adjustable things, which argues for
|
||||
scoping rather than merging. If you scope, say so and leave `BL-015` open for wave 9.
|
||||
|
||||
**Do not** fold `BL-001`, `BL-006`, `BL-007` or `BL-013` into this diff. They are logged
|
||||
against this file and they are cheaper now than ever, and they are still separate items. What
|
||||
this task owes them is a **measurement**: record whether each still reproduces after the
|
||||
rebuild, so `T7.2` and `T9.5` inherit a fact rather than an assumption.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] no iframe remains in the creator; grep confirms
|
||||
- [ ] every creator feature works as it did before: verify against the wave 0 baseline screenshots and a functional pass
|
||||
- [ ] `F4` no longer reproduces
|
||||
- [ ] `CR-006` section toggles propagate into the creator with no special-casing
|
||||
- [ ] browser back and forward still behave with the `T4.2` URL state
|
||||
- [ ] no cross-frame messaging code remains
|
||||
- [ ] the creator renders the standard app bar, and the wizard's chrome arithmetic still holds at 390px and 1440px
|
||||
- [ ] `body.embedded` and `.embed-hide` are gone; grep confirms
|
||||
- [ ] the sample-data control is visible and reachable on the creator (`D1`)
|
||||
- [ ] `BL-001`, `BL-006`, `BL-007` and `BL-013` are each re-measured after the rebuild and their entries updated with the result
|
||||
|
||||
---
|
||||
|
||||
### T7.2 — F6 + D3: Side navigation and collapsible sections replace the 4,700px form
|
||||
|
||||
- **Items:** `F6`, `D3`
|
||||
- **Depends on:** T7.1, T5.5, T3.5
|
||||
- **Blocks:** T7.9
|
||||
- **Surface:** `html/`
|
||||
- **Files:** creator layout, jump links
|
||||
|
||||
**Problem:** The creator is one form roughly 4,700px tall with 11 cards rendered at once and
|
||||
jump links standing in for structure.
|
||||
|
||||
**Do:** Give the form real structure — section tabs or an equivalent that shows one coherent
|
||||
group at a time. Respect the `CR-006` toggles: a section toggled off has no tab. Retire the
|
||||
jump links. Section identity belongs in the URL (`T4.2`) so a section is linkable.
|
||||
|
||||
**Amended August 18 (`D3`) - the answer is not tabs.** Asked to choose, the decision was one
|
||||
page with navigation down the side; asked about the height conflict that creates, the call was
|
||||
delegated. Adopted: **one page, persistent side navigation, sections collapsible, only the
|
||||
current one open by default, plus an `Expand all`.** Tabs were rejected because they hide
|
||||
sections a first-time author does not know exist. An uncollapsed long form was rejected
|
||||
because it is the 4,700px page `F6` exists to fix. The done-when list below is the amended
|
||||
one; it replaces four of the six entries as written.
|
||||
|
||||
`BL-013` is folded in here and only here: the creator's inputs have `outline: none` with a
|
||||
1.05:1 glow as their only focus cue, which is a standing `CLAUDE.md` violation on the page
|
||||
with the most form controls in the app. A rebuilt form ships the `T3.4` ring or it is not
|
||||
done.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] no single view exceeds roughly two screen heights at 1440px **at rest** - that is, in the default collapse state the page loads in
|
||||
- [ ] toggled-off sections have no entry in the side navigation
|
||||
- [ ] each section is addressable by URL and survives refresh
|
||||
- [ ] jump links are gone
|
||||
- [ ] the side navigation is keyboard operable and marks the current section with `aria-current`
|
||||
- [ ] each section header is a real `<button>` with `aria-expanded`, not a `<div>` with a click handler
|
||||
- [ ] `Expand all` and its inverse exist and are keyboard reachable
|
||||
- [ ] deep-linking to a collapsed section expands it
|
||||
- [ ] unsaved work is preserved when moving between sections (`T4.3`)
|
||||
- [ ] every focusable control on the page draws a ring of at least 3:1 against its background (`BL-013`)
|
||||
|
||||
---
|
||||
|
||||
### T7.3 — A1 + CR-015: Guarded transitions and the hold bug
|
||||
|
||||
- **Items:** `A1`, `CR-015`, `D4`
|
||||
- **Depends on:** T7.1
|
||||
- **Blocks:** T7.4, T7.6
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **Files:** status handling, constraint handling, `confirmEarlyRelease()` in `wp-creation-app.js`
|
||||
|
||||
**This is one task, not two (`X2`).** The hold-not-clearing bug and the transition model are
|
||||
the same code. `CR-015` is the highest-severity item in the plan and it was reproduced live
|
||||
in front of the Micron team.
|
||||
|
||||
**Problem:** Clearing all constraints does not clear the hold. Hold state is not purely
|
||||
derived from open constraints, which is why it goes stale.
|
||||
|
||||
**Do:** Model transitions explicitly. For each state, define the primary next action **plus
|
||||
an audited override**. "One legal next action" is too narrow - `confirmEarlyRelease()` in
|
||||
`wp-creation-app.js` is a deliberate logged-override path for predecessors, and Issue (Hold)
|
||||
is a branch from several states, not a single linear step.
|
||||
|
||||
**Corrected August 18.** This task's file list, `IMPLEMENTATION.md` `X2` and `CLAUDE.md` all
|
||||
cited `wp-creation-app.js:1962-1972` as the protected override path. Those lines are
|
||||
`deletePackage()` and `clearSaved()` - a different rule entirely. The path to preserve is
|
||||
`confirmEarlyRelease()`, called from the issue and release paths. All three documents now name
|
||||
it by function so the reference survives this rebuild.
|
||||
|
||||
Derive hold state from open constraints rather than storing it independently, or if it must
|
||||
be stored, recalculate it on every constraint change. Write every hold and release to the
|
||||
work package history with timestamp, user and reason.
|
||||
|
||||
**Do not:** remove the logged-override path. It is an audited business rule. And do not fix
|
||||
the symptom by recalculating on page load only.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] clearing the last open constraint clears the hold and returns the work package to its prior status, with no refresh
|
||||
- [ ] logging a new constraint on an in-progress work package places it back on hold
|
||||
- [ ] clearing that constraint releases it again
|
||||
- [ ] the override path still works and still logs
|
||||
- [ ] Issue (Hold) is reachable from every state it should branch from
|
||||
- [ ] every hold and release is in the history with timestamp, user and reason
|
||||
- [ ] a regression test covers the clear-last-constraint path specifically
|
||||
- [ ] the exact root cause is stated in the PR
|
||||
|
||||
**Amended August 18 (`D4`) - what `Urgent` does.** Asked whether an `Urgent` package should be
|
||||
issuable with an open constraint, the first answer was "maybe". The concern raised back was
|
||||
that a silent bypass destroys the delay-documentation use case that justifies the whole
|
||||
constraint workflow: a constraint log that can be shown to have been skipped stops supporting
|
||||
a notice of delay. Confirmed: **surface the audited path, do not add a new one.**
|
||||
|
||||
- [ ] an `Urgent` package with an open constraint offers `confirmEarlyRelease()` in the primary action position, not behind a secondary control
|
||||
- [ ] taking it still writes actor, timestamp and reason to history
|
||||
- [ ] no code path releases a constrained package without writing that entry; verify by grep and by driving it
|
||||
- [ ] a `Normal` or `High` package is unchanged - the override is no more prominent than it is today
|
||||
|
||||
**Why the history matters:** the delay-documentation use case is the business justification
|
||||
for the whole constraint workflow. If a constraint log can be shown to have been wrong, it
|
||||
stops supporting a notice of delay or a change order claim.
|
||||
|
||||
---
|
||||
|
||||
### T7.4 — A2: De-duplicate the constraint warning
|
||||
|
||||
- **Items:** `A2`
|
||||
- **Depends on:** T7.3
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `html/`
|
||||
- **Files:** creator constraint warnings
|
||||
|
||||
**Problem:** The same warning appears three times.
|
||||
|
||||
**Do:** Keep one. The top banner is the only one visible without scrolling, so it stays. The
|
||||
tab count badge then has to carry the weight the removed duplicates were carrying — make the
|
||||
count prominent enough to notice from any section.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] the warning appears once
|
||||
- [ ] the tab badge shows the open constraint count and is legible at 390px
|
||||
- [ ] with the constraints section scrolled out of view, a user can still tell constraints are open
|
||||
- [ ] the banner announces via `aria-live` when the count changes
|
||||
|
||||
---
|
||||
|
||||
### T7.5 — A6: Sidebar triage data and inline hold reason
|
||||
|
||||
- **Items:** `A6`
|
||||
- **Depends on:** T7.3
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `html/`
|
||||
- **Files:** creator sidebar
|
||||
|
||||
**Do:** Surface triage data in the sidebar, including the hold reason inline. The hold reason
|
||||
is already captured by the hold modal, so this is display work.
|
||||
|
||||
**Use case to test against:** someone in a stand-up is asked why a package has not moved.
|
||||
They open it on a phone and need the answer without scrolling or clicking.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] the hold reason is visible in the sidebar without opening the modal
|
||||
- [ ] status, priority, due date, P6 activity and open constraint count are all visible together
|
||||
- [ ] usable at 390px
|
||||
- [ ] a package with no hold shows a sensible state rather than an empty slot
|
||||
|
||||
---
|
||||
|
||||
### T7.6 — CR-014: Ready for QA gate
|
||||
|
||||
- **Items:** `CR-014`, `D2`, `D9`, `D10`
|
||||
- **Depends on:** T7.3, T4.2 (blocking dependency `X1`), and the wave 5 SOP wizard for the `D2` field
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **Files:** status model, notification service, QA section
|
||||
|
||||
**Intent:** Looping QA in has historically been the failure point. Marlena raised this
|
||||
directly: QA needs to see inbound work and organize their schedule ahead of time rather than
|
||||
being told after the fact.
|
||||
|
||||
**Do:** Add `Ready for QA` between In Progress and Complete/Closed, inside the `T7.3`
|
||||
transition model (`X3`). Entering that state emails the assigned QA representative and the
|
||||
QA/QC members named on the work package.
|
||||
|
||||
The email includes the work package number and title, location, a scope summary, and **a
|
||||
direct link that opens that work package** — this is why `T4.2` is a hard prerequisite. A
|
||||
dashboard view lists everything sitting in Ready for QA. The QA representative can accept or
|
||||
reject; rejection returns the package to In Progress with a required comment.
|
||||
|
||||
**Do not:** build the email with a link to the app root. That is the failure `X1` exists to
|
||||
prevent.
|
||||
|
||||
**Amended August 18 (`D2`, `D9`, `D10`).** Three answers change this task:
|
||||
|
||||
- **Who receives it.** Not "the QA rep and the QA/QC members named on the work package" -
|
||||
**the QA group, configured once during SOP setup as a dropdown of project members.** That
|
||||
makes the recipient list SOP configuration, so this task acquires a wizard field and a
|
||||
dependency on wave 5 that it did not have.
|
||||
- **Who hears about a rejection.** The **work package owner and the same distribution list**,
|
||||
not only the submitter.
|
||||
- **Where the state is visible.** The dashboard **and Field View**.
|
||||
- **Who turns email on.** An administrator, from `admin.html`. The gate is a stored setting,
|
||||
not an environment variable. It ships **off** and is verified against a capture sink; no
|
||||
real mail is sent from this branch.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] the new state exists in the transition model, not beside it
|
||||
- [ ] the QA distribution list is chosen in the SOP wizard from project members and persists
|
||||
- [ ] entering `Ready for QA` emails that list and nobody else
|
||||
- [ ] rejection emails the work package owner and the same list
|
||||
- [ ] the link in the email opens that specific work package, including for a signed-out recipient via login
|
||||
- [ ] a dashboard view or filter lists all packages in Ready for QA
|
||||
- [ ] Field View shows which packages are in `Ready for QA`, legibly at 390px, with something other than colour carrying the state (`D9`)
|
||||
- [ ] rejection returns to In Progress and requires a comment
|
||||
- [ ] the transition is written to history
|
||||
- [ ] email failure is logged and surfaced, not silent
|
||||
- [ ] the send path is exercised end to end against a capture sink, with the count of captured messages asserted
|
||||
- [ ] the default is off; an administrator can turn it on from `admin.html` and a non-administrator cannot (`D10`)
|
||||
- [ ] changing the setting is written to the audit log
|
||||
- [ ] no SMTP password appears anywhere outside the environment, and no customer IP appears in a message body
|
||||
|
||||
**Note:** QA currently runs off a comprehensive Excel questionnaire being migrated into
|
||||
CxAlloy. Integration with CxAlloy is explicitly out of scope. This gate is notification only.
|
||||
|
||||
---
|
||||
|
||||
### T7.7 — CR-007: Upload drawings for offline access
|
||||
|
||||
- **Items:** `CR-007`, `D8`
|
||||
- **Depends on:** T7.2, T4.3 (blocking dependency `X8`)
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **Files:** Drawings and Attachments section, file storage, export
|
||||
|
||||
**Intent:** Today the section holds links, typically to a Bluebeam session. The field wants
|
||||
the specific PDF attached instead: a general foreman opens the package and sees exactly the
|
||||
sheet and the highlighted section relevant to their scope, with no navigation and no
|
||||
connectivity dependency. Links stay supported for those who want them.
|
||||
|
||||
**Do:** File upload alongside the existing link support. Store files with the work package.
|
||||
Include them when the package is exported or downloaded. Make them available offline on a
|
||||
tablet. Each attachment gets a short description field so the creator can note the focus
|
||||
area, for example "Tray section, Level 3 east only".
|
||||
|
||||
Show file size limits and supported types in the UI before upload, not after failure.
|
||||
|
||||
**Do not:** build this before `T4.3`. Uploading into a form with no autosave risks losing
|
||||
both the upload and the surrounding work.
|
||||
|
||||
**Amended August 18 (`D8`) - the numbers.** `CR-007` says "show file size limits and supported
|
||||
types" without saying what they are. They are: **5MB a file, PDFs and images, stored in the
|
||||
same database as everything else.** Offline caching covers **only the packages assigned to
|
||||
the requesting user**, not the whole project.
|
||||
|
||||
The consequence was raised and settled rather than discovered later: files in the database
|
||||
means every encrypted backup carries them. Splitting them out was rejected - a backup that
|
||||
excludes the drawings is a backup you cannot restore from - so a **2GB per-project ceiling**
|
||||
was approved instead, with a warning at 80%.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] PDF upload works and the file is retrievable
|
||||
- [ ] image upload works and the file is retrievable
|
||||
- [ ] links still work alongside uploads
|
||||
- [ ] attachments are included in export or download
|
||||
- [ ] an uploaded drawing opens on a tablet with the network disabled
|
||||
- [ ] offline caching covers the requesting user's assigned packages only; verify a package assigned to someone else is not cached
|
||||
- [ ] the description field persists and prints on the export
|
||||
- [ ] limits and supported types are stated before upload
|
||||
- [ ] a file over 5MB is refused before upload, naming the limit
|
||||
- [ ] a type outside PDF and image is refused before upload, naming the accepted types
|
||||
- [ ] a project at 80% of 2GB warns; at 2GB it refuses, naming the ceiling
|
||||
- [ ] the running project total is visible where uploads happen
|
||||
- [ ] both limits are enforced on the server, not only in the browser
|
||||
- [ ] upload failure is clear and recoverable, with no loss of surrounding form work
|
||||
- [ ] the whole flow works at 390px
|
||||
|
||||
**Accepted tradeoff:** this shifts effort onto the package creator to isolate the right
|
||||
sheet. That was raised in the meeting and accepted.
|
||||
|
||||
---
|
||||
|
||||
### T7.8 — B6: Sticky action bar
|
||||
|
||||
- **Items:** `B6`
|
||||
- **Depends on:** T7.2
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `html/`
|
||||
- **Files:** creator and wizard action bars
|
||||
|
||||
**Problem:** The proposal placed actions beside the fields, but on the Constraints and
|
||||
Sequence steps that means scrolling to save.
|
||||
|
||||
**Do:** A sticky action bar instead. The creator already solves this; reuse that pattern in
|
||||
the wizard rather than inventing a second one.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] save and primary actions are reachable without scrolling on every step and section
|
||||
- [ ] verified specifically on Constraints and Sequence
|
||||
- [ ] the bar does not obscure fields at 390px
|
||||
- [ ] it shows the save state from `T4.4`
|
||||
|
||||
---
|
||||
|
||||
### T7.9 — S1 (creator): Inline validation
|
||||
|
||||
- **Items:** `S1` creator half
|
||||
- **Depends on:** T7.2
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `html/`
|
||||
- **Files:** creator validation, `wp-creation-app.js:1144`
|
||||
|
||||
**Problem:** 43 native dialogs in the creator alone. `wp-creation-app.js:1144` says "Subject
|
||||
and WP Type are required" without naming, highlighting or scrolling to the field, on a form
|
||||
ten cards deep.
|
||||
|
||||
**Do:** Inline validation matching the wizard pattern from `T5.8`. Errors at the field, focus
|
||||
and scroll to the first invalid one, and with section tabs now in place, the tab containing
|
||||
errors is marked. Remove the creator's native dialogs.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] no `alert()`, `confirm()` or `prompt()` remains in the creator; count is 0
|
||||
- [ ] every required field validates inline
|
||||
- [ ] submitting focuses and scrolls to the first error, switching sections if needed
|
||||
- [ ] sections containing errors are marked in the tab row
|
||||
- [ ] errors announce via `aria-live`
|
||||
- [ ] the app-wide native dialog count is recorded against the wave 0 baseline of 79
|
||||
|
||||
---
|
||||
|
||||
### T7.10 — D5: Usage data moves to the admin console
|
||||
|
||||
- **Items:** `D5`
|
||||
- **Depends on:** T7.1
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `html/`
|
||||
- **Files:** `admin.html`, `admin.js`, `wp-creation-app.js`, `work-package-suite-app.js`
|
||||
|
||||
**Problem:** Usage analytics exists **twice**. `creator-frame.md` section 2b counts five of
|
||||
the nine colliding script globals as this one feature - `ANALYTICS_KEY`, `analyticsLoad`,
|
||||
`analyticsSave`, `downloadAnalytics`, `showAnalytics` - and records that the wizard's copy has
|
||||
no caller, because the button that opens it lives on the creator. Once the frame is gone, two
|
||||
implementations sit in one document.
|
||||
|
||||
**Do:** Keep one implementation and put its entry point in the admin console, where an
|
||||
operator-facing report belongs. Remove the analytics control from the creator and from the
|
||||
wizard. This is not a rewrite of what analytics collects - the same data, reached from one
|
||||
place instead of a button on a package-authoring screen.
|
||||
|
||||
**Do not:** change what is tracked, and do not leave the second implementation behind
|
||||
unreferenced. An unreferenced duplicate is what produced this item.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] usage data is reachable from `admin.html`
|
||||
- [ ] no analytics control remains on the creator or the wizard; grep confirms
|
||||
- [ ] exactly one implementation of `analyticsLoad`/`analyticsSave`/`downloadAnalytics` survives; grep confirms
|
||||
- [ ] the data recorded before this task is still readable after it
|
||||
- [ ] the console entry point is behind the same role check as the rest of `admin.html`
|
||||
- [ ] the report is usable at 390px
|
||||
|
||||
---
|
||||
|
||||
## Wave 7 exit criteria
|
||||
|
||||
- [x] no iframe; `F4` resolved structurally (`frame_check.py`, 39 checks)
|
||||
- [x] `CR-015` fixed with a regression test and a stated root cause (`hold_check.py`; the cause is in the T7.3 commit, both halves - `prevStatus` destroyed client-side, `_released('Issue')` true server-side)
|
||||
- [x] hold state is derived, and history records every transition (client `data.holds`, server `hold_logged`/`hold_released` audit rows on both write paths)
|
||||
- [x] the QA gate emails a working deep link, to the SOP-configured list, with the gate off by default (`qa_gate_check.py` against the capture sink)
|
||||
- [x] drawings upload and open offline on a tablet, inside the 5MB and 2GB limits (`files_check.py`; the offline half runs against a genuinely killed server)
|
||||
- [x] creator native dialog count is 0 (`creator_dialogs_check.py`; app-wide count printed against the wave 0 baseline of 79)
|
||||
- [ ] the creator is a page: app bar, side navigation, collapsible sections, focus rings — all shipped — but **at rest it reads 1,954px against a 900px viewport: 2.17 screens against the strict 2.0 encoding of "roughly two screen heights."** Down from 5,399px. Whether 2.17 satisfies "roughly two" is a product judgment, deliberately not made here after the criterion was already amended once (`D3`); `BL-022` carries the remaining ~154px of page chrome and the question. `form_structure_check.py` keeps the strict check red until it is answered.
|
||||
- [x] `D1` through `D5`, `D8`, `D9` and `D10` are each satisfied (D1 T7.1, D2/D9/D10 T7.6, D3 T7.2, D4 T7.3, D5 T7.10, D8 T7.7)
|
||||
- [x] no real email left this branch; the capture sink count is recorded (every send in `qa_gate_check.py` terminates at an in-process SMTP sink; counts and recipients asserted)
|
||||
228
docs/waves/wave-8.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# Wave 8 — Kitting and material
|
||||
|
||||
**Items:** `CR-009`, `CR-010`, `CR-011`, `CR-012`, `CR-013`, and from August 18: `D6`, `D10`
|
||||
**Depends on:** wave 7 merged
|
||||
|
||||
**Build order is not task order.** `T8.6` is numbered last because it was added on August 18,
|
||||
but it **blocks `T8.5`** - the request cannot select from a list that does not exist yet. Build
|
||||
`T8.1`, `T8.2`, `T8.3`, `T8.4`, `T8.6`, then `T8.5`. Task IDs are referenced outside this repo
|
||||
and are not renumbered to make the order read left to right.
|
||||
**Decision, approved Aug 14 2026:** keep and extend Kitting, then ship it **toggled off** for
|
||||
the Micron EUV SOP. Kitting is not happening at Micron today — it is complicated by
|
||||
journeyman ratio and warehouse cost — but the team expects to get there, and prefab arguably
|
||||
already qualifies.
|
||||
|
||||
That means this wave builds functionality that will not be visible on the pilot project. That
|
||||
is intentional. Do not shortcut it on the grounds that nobody is using it, and do not switch
|
||||
it on for Micron.
|
||||
|
||||
---
|
||||
|
||||
### T8.1 — CR-009: Kitting section retained and structured
|
||||
|
||||
- **Items:** `CR-009`
|
||||
- **Depends on:** T7.9
|
||||
- **Blocks:** T8.2, T8.3, T8.4
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **Files:** Kitting section, SOP configuration
|
||||
|
||||
**Do:** Confirm the Kitting section is intact and structured to receive `T8.2` through
|
||||
`T8.4`. Define kitting statuses explicitly. Proposed set, adjust only with a stated reason:
|
||||
`Not Started`, `Picking`, `Staged`, `In Transit`, `Delivered`.
|
||||
|
||||
Then set the section off for the Micron EUV SOP via the `CR-006` toggles.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] kitting statuses are defined as an explicit set, not free text
|
||||
- [ ] the section is off for Micron EUV and absent from its form, detail view and export
|
||||
- [ ] a different SOP can enable it and it works fully
|
||||
- [ ] no kitting data is deleted anywhere
|
||||
|
||||
---
|
||||
|
||||
### T8.2 — CR-010: Warehouse Owner dropdown
|
||||
|
||||
- **Items:** `CR-010`
|
||||
- **Depends on:** T8.1
|
||||
- **Blocks:** T8.3
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **Files:** Kitting section, dashboard filters
|
||||
|
||||
**Intent:** A named person owns fulfillment of the kit. Today that is Paul Coonrod and
|
||||
everything bottlenecks through him informally, so the work package should record it
|
||||
explicitly.
|
||||
|
||||
**Do:** A Warehouse Owner dropdown populated from project members. That person receives
|
||||
kitting notifications by default. Filterable on the dashboard so a warehouse owner can see
|
||||
everything assigned to them.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] the dropdown lists project members and persists a selection
|
||||
- [ ] the selected owner is a default recipient for `T8.3` notifications
|
||||
- [ ] the dashboard filters by warehouse owner
|
||||
- [ ] removing someone from the project does not break existing work packages referencing them
|
||||
|
||||
**Confirmed August 18.** Asked whether the warehouse owner belongs to the project or to each
|
||||
work package, the answer was **on the work package**. A project default is fine as a
|
||||
convenience, but the field that notifications read is the one on the package, so a package
|
||||
retargeted to a different warehouse notifies the right person without touching the project.
|
||||
|
||||
---
|
||||
|
||||
### T8.3 — CR-011: Kitting status change notification
|
||||
|
||||
- **Items:** `CR-011`, `D10`
|
||||
- **Depends on:** T8.2, T7.6 (the email gate), T4.2 (blocking dependency `X1`)
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **Files:** notification service, kitting status handling
|
||||
|
||||
**Intent:** Extends the existing status-change notification pattern to kitting so the field
|
||||
knows when material moves from requested to staged to delivered without chasing it in Teams.
|
||||
|
||||
**Do:** A kitting status change emails the work package distribution list. The email states
|
||||
the work package, the old and new status, who changed it, the delivery location, and a direct
|
||||
link to the work package. Match the existing status-change email pattern so users see one
|
||||
convention, not two.
|
||||
|
||||
**Do not:** send a link to the app root (`X1`).
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] every kitting status change sends to the distribution list
|
||||
- [ ] the email includes old status, new status, actor, delivery location and a working deep link
|
||||
- [ ] the link opens that specific work package, including via login for a signed-out recipient
|
||||
- [ ] formatting and tone match the existing status-change email
|
||||
- [ ] rapid consecutive changes do not produce a burst of near-identical emails
|
||||
- [ ] send failures are logged and surfaced
|
||||
- [ ] the gate built at `T7.6` is reused, not re-implemented; kitting email is off by default and turned on from `admin.html` (`D10`)
|
||||
- [ ] the send path is exercised against a capture sink with the captured count asserted; no real mail leaves this branch
|
||||
|
||||
---
|
||||
|
||||
### T8.4 — CR-012: Delivery location on Kitting / Material
|
||||
|
||||
- **Items:** `CR-012`
|
||||
- **Depends on:** T8.1, T6.3
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **Files:** Kitting section, print/pick output
|
||||
|
||||
**Intent:** Delivery logistics are the real pain. Staging at the warehouse, getting it on a
|
||||
truck, then making sure material reaches the correct floor lay-down, shark cage or conduit
|
||||
tree rather than being picked at will by whoever is closest.
|
||||
|
||||
**Do:** Capture delivery location using the same Building / Floor / Sector lists as `CR-004`,
|
||||
plus a free-text detail field for specifics like lay-down area, shark cage or conduit tree.
|
||||
Include the delivery location on the kitting notification email and on any pick or delivery
|
||||
printout.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] delivery location uses the shared location lists, not a parallel free-text field
|
||||
- [ ] the detail field persists and prints
|
||||
- [ ] the location appears on the `T8.3` email
|
||||
- [ ] it appears on any pick or delivery printout
|
||||
|
||||
---
|
||||
|
||||
### T8.5 — CR-013: Lightweight material request
|
||||
|
||||
- **Items:** `CR-013`, `D6`
|
||||
- **Depends on:** T8.4, T8.6, T7.2 (sequencing dependency `X6`)
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **Files:** Materials section, request model, dashboard
|
||||
|
||||
**Decision, approved Aug 14 2026:** build the lightweight version. Defer the parts catalog,
|
||||
live inventory and warehouse integration.
|
||||
|
||||
**Intent:** Material requests run today through per-floor Teams chats and a spreadsheet, all
|
||||
funneling to Paul Coonrod. The comparison drawn in the meeting was a project where requests
|
||||
went into a OneNote and became, in the words used at the time, word vomit. The point of this
|
||||
item is structure, not features.
|
||||
|
||||
**In scope:** line items (description, quantity, unit), needed-by date, requestor, delivery
|
||||
location (reuse `T8.4`), and status.
|
||||
|
||||
**Out of scope, do not build:** master parts catalog dropdown, live inventory levels,
|
||||
warehouse system integration.
|
||||
|
||||
**Do:** Build the in-scope set. Requests are visible on the dashboard and filterable by
|
||||
delivery location and status.
|
||||
|
||||
**Do not:** build this before `T7.2`. A material card added to the pre-tab creator is a 12th
|
||||
card on a 4,700px form. And do not invent a parts list — Nate's spreadsheet and the master
|
||||
material workbook have not been supplied yet (see `IMPLEMENTATION.md` section 8).
|
||||
|
||||
**Amended August 18 (`D6`).** Two answers change this task. Line items **select from a
|
||||
project material list uploaded during SOP configuration** (`T8.6`) rather than being typed
|
||||
from nothing - and free text still works when no list has been loaded, which is the state
|
||||
every project is in today. And submitting a request **notifies the warehouse owner named on
|
||||
the work package** (`CR-010`), which is the routing that replaces the informal funnel through
|
||||
one person.
|
||||
|
||||
`D6` does not open the deferred parts catalog. A project-scoped list the project uploaded is
|
||||
not a catalog: no inventory levels, no warehouse integration, no shared master data. If a
|
||||
stock count appears in the diff, it has crossed the line `IMPLEMENTATION.md` section 7 draws.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] line items can be added, edited and removed, with quantity and unit
|
||||
- [ ] line items select from the `T8.6` list when one exists, and accept free text when it does not
|
||||
- [ ] needed-by date, requestor, delivery location and status all persist
|
||||
- [ ] requests appear on the dashboard, filterable by location and status
|
||||
- [ ] submitting a request notifies the warehouse owner named on the work package
|
||||
- [ ] no parts catalog, inventory count or warehouse integration exists in the diff
|
||||
- [ ] the section respects `CR-006` toggles
|
||||
- [ ] the whole flow works at 390px, since requests originate in the field
|
||||
|
||||
---
|
||||
|
||||
### T8.6 — D6: Upload the material list at SOP configuration
|
||||
|
||||
- **Items:** `D6`
|
||||
- **Depends on:** T8.1
|
||||
- **Blocks:** T8.5
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **Files:** SOP wizard, material list model and routes, `T5.4`'s upload component
|
||||
|
||||
**Intent:** `CR-013` was written to accept free text because Nate's spreadsheet and the master
|
||||
material workbook had not been supplied, and they still have not been. Asked on August 18
|
||||
whether to wait for them, the answer was **build the upload path now**, the same call that was
|
||||
made for the location list at `CR-005`.
|
||||
|
||||
**Do:** Reuse `T5.4`. The location upload already does paste-or-file, validates, reports
|
||||
rejected rows with the source line number, and stays editable afterwards. Build the material
|
||||
list the same way and against the same component, not beside it.
|
||||
|
||||
Keep the field set small and obvious: description, unit, and an optional code. That is what
|
||||
`T8.5` needs to populate a line item.
|
||||
|
||||
**Do not:** add inventory levels, stock counts, pricing or a warehouse identifier. Do not make
|
||||
the list mandatory - a project with no list must still be able to raise a request. Do not
|
||||
hardcode sample part numbers; seed with an obviously-fake sample as `CR-005` does.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] a material list uploads or pastes during SOP configuration and persists per project
|
||||
- [ ] rejected rows are reported with the source line and a reason
|
||||
- [ ] the list is editable after upload, and entries can be deactivated rather than deleted
|
||||
- [ ] `T8.5` line items select from it
|
||||
- [ ] a project with no list can still raise a request with free text
|
||||
- [ ] the upload component is the `T5.4` one; grep confirms there is not a second implementation
|
||||
- [ ] no inventory, pricing or stock field exists in the diff
|
||||
- [ ] the flow works at 390px
|
||||
|
||||
---
|
||||
|
||||
## Wave 8 exit criteria
|
||||
|
||||
- [x] Kitting is fully built and toggled off for Micron EUV (`kitting_check.py`: the five statuses, the account-backed owner, the delivery trio, the CR-006 toggle on both samples, nothing deleted)
|
||||
- [x] notifications carry working deep links and a delivery location, and are off by default (`kitting_notify_check.py` against the sink; the coalescer keeps bursts to one mail per recipient)
|
||||
- [x] the material request is the lightweight scope, nothing more (`mreq_check.py` greps for catalog/inventory/warehouse and finds none)
|
||||
- [x] a material list uploads at SOP configuration and feeds the request (`D6` - `materials_check.py`; the T5.4 component extracted to `wp-list-import.js`, one implementation for both lists)
|
||||
- [x] no guessed part numbers or floor names anywhere in the code (every seeded value is `Sample …` / `SAMPLE-…`; `locations_check.py` still sweeps the tree for the unspecified B100 names)
|
||||
- [x] no real email left this branch; the capture sink count is recorded (every send in `kitting_notify_check.py` and `mreq_check.py` terminates at the in-process sink; counts and recipients asserted)
|
||||
316
docs/waves/wave-9.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# Wave 9 — Verification and cleanup
|
||||
|
||||
**Items:** `CR-008`, `CR-017` (guard), `S6`, `S7`, `S8`, `C1` audit, `C2` audit, part of `C4`,
|
||||
and from August 18: `D7`
|
||||
**Depends on:** wave 8 merged
|
||||
|
||||
**Build order is not task order.** `T9.8` and `T9.9` were added on August 18 and are numbered
|
||||
after `T9.7`, but `T9.7` is the reconciliation and stays last. Build `T9.1` through `T9.6`,
|
||||
then `T9.8`, `T9.9`, then `T9.7`. They appear in that order in this file. Task IDs are
|
||||
referenced outside this repo and are not renumbered to make the order read left to right.
|
||||
**Why last:** `CR-008` reviews the PDF export against the final structure rather than
|
||||
reviewing it twice. The audits confirm the standing requirements actually held across eight
|
||||
waves rather than being asserted per PR.
|
||||
|
||||
---
|
||||
|
||||
### T9.1 — CR-008: PDF export review
|
||||
|
||||
- **Items:** `CR-008`
|
||||
- **Depends on:** T8.5
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **Files:** PDF export
|
||||
|
||||
**Problem:** The export was never walked through in the meeting. It needs a pass to confirm
|
||||
it prints what the field actually needs and nothing it does not, now that the work package
|
||||
structure has changed substantially.
|
||||
|
||||
**Do:** Review the export section by section against the final structure. Sections toggled
|
||||
off under `CR-006` must be omitted. The export must include: P6 Activity ID and Description,
|
||||
Priority, Building / Floor / Sector, scope of work, materials, constraints with status,
|
||||
QA/QC, and an attachment index. Removed fields — ACU Cost Code, Acumatica Task — must not
|
||||
appear.
|
||||
|
||||
Open question to raise in the PR, not to decide alone: should attachments merge into a single
|
||||
PDF package, or be listed as separate files? The field preference expressed in the meeting
|
||||
was to hand someone exactly the sheet they need and nothing more, which argues for a merged
|
||||
package with only the attached sheets. Propose, do not assume.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] every listed field appears in the export
|
||||
- [ ] toggled-off sections are absent
|
||||
- [ ] ACU Cost Code and Acumatica Task appear nowhere
|
||||
- [ ] the export is legible printed in black and white
|
||||
- [ ] the export is legible opened on a tablet
|
||||
- [ ] the attachment index lists every attachment with its description
|
||||
- [ ] the merge-versus-list question is raised in the PR with a recommendation
|
||||
|
||||
---
|
||||
|
||||
### T9.2 — CR-017: Actual Hours guard
|
||||
|
||||
- **Items:** `CR-017`
|
||||
- **Depends on:** T9.1
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** verification only
|
||||
- **Files:** Closeout section
|
||||
|
||||
**This is a guard, not a build.** Removing Actual Hours was floated in the meeting and
|
||||
rejected — Marlena said she likes tracking actual hours spent so it can be measured. It is
|
||||
listed here so a later simplification pass does not quietly drop it.
|
||||
|
||||
**Do:** Confirm Actual Hours still exists in Closeout, is optional rather than required, and
|
||||
survived eight waves of change.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] Actual Hours exists in Closeout and persists
|
||||
- [ ] it is optional, and a work package can be closed without it
|
||||
- [ ] it rolls up per `T6.4`
|
||||
- [ ] a follow-up item is logged for comparing actual against estimated hours to produce a productivity factor
|
||||
|
||||
---
|
||||
|
||||
### T9.3 — S6: One icon system
|
||||
|
||||
- **Items:** `S6`
|
||||
- **Depends on:** T9.1
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `html/`
|
||||
- **Files:** every page
|
||||
|
||||
**Problem:** The icon set mixes emoji and dingbats, and at least one glyph is used for two
|
||||
different meanings on the same page.
|
||||
|
||||
**Do:** Pick one icon system and convert everything to it. Build a mapping of meaning to icon
|
||||
and record it in `docs/reference/tokens.md`. Resolve every duplicate meaning.
|
||||
|
||||
**Do not:** leave emoji in the UI. They render differently per platform, which is part of why
|
||||
the same glyph reads as two things.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] one icon system across all 6 pages
|
||||
- [ ] no glyph carries two meanings anywhere
|
||||
- [ ] every icon has a text label or an accessible name
|
||||
- [ ] the meaning-to-icon mapping is documented
|
||||
- [ ] icons render identically on Windows, macOS and a tablet
|
||||
|
||||
---
|
||||
|
||||
### T9.4 — S7: Sample-data cleanup
|
||||
|
||||
- **Items:** `S7`
|
||||
- **Depends on:** T9.1
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `html/` + `server/`
|
||||
- **Files:** every sample-data affordance
|
||||
|
||||
**Problem:** Four sample-data affordances under three names. One sits a single click from
|
||||
live project data with no confirm and no undo.
|
||||
|
||||
**Do:** Reduce to one affordance with one name. Move it away from live project actions. Add a
|
||||
confirmation stating exactly what will happen, and make it reversible or clearly scoped to a
|
||||
sample project only.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] exactly one sample-data affordance exists, under one name
|
||||
- [ ] it is not adjacent to any live project action
|
||||
- [ ] it confirms before acting, naming what it will do
|
||||
- [ ] it cannot touch live project data; verify by attempting it against a real project
|
||||
- [ ] the other three affordances are removed; grep confirms
|
||||
|
||||
**Reconciled with `D1`.** `T7.1` makes the creator's sample-data control **visible** - it was
|
||||
hidden only because the page was framed. That is not a fourth affordance returning; it is the
|
||||
one that should survive becoming reachable. This task still ends with exactly one, and if the
|
||||
survivor is the creator's, the launcher and wizard copies go.
|
||||
|
||||
---
|
||||
|
||||
### T9.5 — C1 + S8: Accessibility audit
|
||||
|
||||
- **Items:** `C1`, `S8`
|
||||
- **Depends on:** T9.3
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** `html/`
|
||||
- **Files:** all pages, `.help-tip` elements
|
||||
|
||||
**`S8` is finished here** because tooltips are the one accessibility item with no natural
|
||||
host component earlier in the plan. `.help-tip` badges are `<span>` elements with no
|
||||
`tabindex`, so the `:focus` half of their CSS rule is dead code and there is no touch path at
|
||||
all - on tablets, which is exactly what Field View targets.
|
||||
|
||||
**The count went up, not down.** Wave 0 measured 15. At the wave 6 exit there were **18**:
|
||||
three were added during waves 5 and 6 by tasks reusing the component as designed, and every
|
||||
one of them is unreachable for the same reason. That is the cost of scheduling a broken
|
||||
component late - each wave that reuses it makes this task bigger. Measure again before
|
||||
starting and record the number; do not inherit 15 or 18.
|
||||
|
||||
**Do:** Make every `.help-tip` reachable by keyboard and by touch. Then audit the whole app
|
||||
against the `CLAUDE.md` rules and record results in `docs/reference/accessibility-audit.md`.
|
||||
|
||||
Compare against the wave 0 baseline counts:
|
||||
|
||||
| Metric | Baseline | Target |
|
||||
|---|---|---|
|
||||
| `<div>` / `<span>` with `onclick` | 12 / 2 | 0 |
|
||||
| `.help-tip` unreachable by keyboard | 15 at wave 0, 18 at the wave 6 exit | 0 |
|
||||
| `aria-live` regions | 0 | at least 1 per toast system and banner |
|
||||
| Text below 4.5:1 | present | 0 |
|
||||
| `outline: none` without replacement | present | 0 |
|
||||
| Native dialogs (`alert`/`confirm`/`prompt`) | 79 | 0, or documented exceptions |
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] every `.help-tip` works by keyboard and by touch
|
||||
- [ ] every metric above meets target, or the gap is documented with a reason and a follow-up item
|
||||
- [ ] a full keyboard-only pass completes the primary flow: sign in, create project, SOP wizard, create work package, issue it
|
||||
- [ ] the audit document exists with per-page results
|
||||
|
||||
---
|
||||
|
||||
### T9.6 — C2: Mobile pass
|
||||
|
||||
- **Items:** `C2`
|
||||
- **Depends on:** T9.5
|
||||
- **Blocks:** T9.8
|
||||
- **Surface:** `html/`
|
||||
- **Files:** all pages
|
||||
|
||||
**Problem:** Nothing in the original proposal touched mobile, and it is where the worst
|
||||
rendering was found. Field View at 390px is the gloved-hands surface.
|
||||
|
||||
**Do:** A deliberate pass at 390px across all 6 pages and the full primary flow. Capture
|
||||
after screenshots and compare against the wave 0 baseline.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] the full primary flow completes at 390px without horizontal scrolling
|
||||
- [ ] tap targets are at least 44px throughout
|
||||
- [ ] no control is clipped, overlapped or unreachable on any page
|
||||
- [ ] `CR-007` attachments open at 390px, offline
|
||||
- [ ] `T8.5` material requests can be raised at 390px
|
||||
- [ ] after screenshots for all 6 pages at 390px are committed alongside the baseline
|
||||
|
||||
---
|
||||
|
||||
### T9.8 — D7: Archived projects, readable by project admins
|
||||
|
||||
- **Items:** `D7`
|
||||
- **Depends on:** T9.6
|
||||
- **Blocks:** T9.7
|
||||
- **Surface:** `server/` + `html/`
|
||||
- **Files:** launcher project list, project routes, role checks
|
||||
|
||||
**Problem:** Archiving already exists for projects and for work packages, and the server
|
||||
already refuses writes to an archived project. What does not exist is a way back in: an
|
||||
archived project leaves the launcher and there is no route to it, so archiving reads as
|
||||
deletion to everyone using it. Asked on August 18 who should be able to open one, the answer
|
||||
was **project admins only**.
|
||||
|
||||
**Do:** List archived projects behind a role check, separately from the live list so nobody
|
||||
opens one by accident. Opening one renders read-only. The server is the enforcement point:
|
||||
the read-only view is a courtesy, the role check and the write refusal are the rule.
|
||||
|
||||
**Do not:** build a second archive mechanism, add an unarchive button, or show archived
|
||||
projects to anyone below project admin - including in counts, pickers or aggregate totals
|
||||
where the number would leak their existence.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] a project admin can list archived projects and open one
|
||||
- [ ] everything in the opened project renders read-only, with no reachable write control
|
||||
- [ ] the server refuses writes to it regardless of what the browser sends; verify by calling the endpoint directly
|
||||
- [ ] a non-admin sees no archived projects anywhere, including in counts and pickers
|
||||
- [ ] the archived list is visually distinct from the live list and says what it is
|
||||
- [ ] it works at 390px
|
||||
|
||||
---
|
||||
|
||||
### T9.9 — Backlog sweep: the items wave 9 was told to own
|
||||
|
||||
- **Items:** `C4`, plus `BL-004`, `BL-005`, `BL-008`, `BL-009`, `BL-011`, `BL-012`, `BL-016`, `BL-018`, `BL-019`
|
||||
- **Depends on:** T9.5
|
||||
- **Blocks:** T9.7
|
||||
- **Surface:** `html/` + `tests/`
|
||||
- **Files:** as listed per entry in `docs/waves/backlog.md`
|
||||
|
||||
**Why this is a task and not a list.** Nine backlog entries name wave 9 as their home. Left
|
||||
unscheduled they get discovered at `T9.7`, which is a reconciliation task with no room to fix
|
||||
anything. Each was deferred for a stated reason and every one of those reasons has now
|
||||
expired.
|
||||
|
||||
**The four colour items are approved work, not open questions.** `BL-004` (the help centre's
|
||||
separate 52-colour palette, loaded on four pages), `BL-005` (35 colour literals in two
|
||||
JS-built dialogs), `BL-008` (a second brand blue, `#2563d6`) and `BL-009` (a ninth amber, four
|
||||
points from the eighth) were each deferred on the same grounds: fixing them changes a rendered
|
||||
colour and the wave that found them was forbidden to change one. Asked on August 18, the
|
||||
answer was **change them**. That is the `C4` half of this task and it is the only part with a
|
||||
visible diff.
|
||||
|
||||
The rest are correctness and tooling:
|
||||
|
||||
| Entry | What it is | Note |
|
||||
|---|---|---|
|
||||
| `BL-011` | Three overlays race to append on the SOP page | Re-check first - `T7.1` may have removed the cause |
|
||||
| `BL-012` | `admin` and the creator are not stable enough to screenshot-diff | Freeze the clock in the fixture; the creator half may be gone after `T7.2` |
|
||||
| `BL-016` | Back to a URL with no `step` leaves the wizard where it was | One expression. `stepper_check.py` already pins the wrong behaviour, so the test flips with the fix |
|
||||
| `BL-018` | The work package gate is the last localStorage-derived status | `T7.1` may have resolved it; if not, it is `B4`'s last hole. Fix `browser_check.py`'s fixture either way |
|
||||
| `BL-019` | A cost code that left the list is silently blanked on edit | The four lines already written for `gov_wosize` |
|
||||
|
||||
**Do not:** fix anything here that a wave 7 or 8 task already fixed. Re-measure each before
|
||||
touching it - four of these were logged against code that has since been rewritten, and a fix
|
||||
applied to a problem that no longer exists is worse than the problem.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] every colour literal outside `theme-light.css` is gone, help centre and JS-built dialogs included; grep confirms
|
||||
- [ ] one accent blue and one warning amber app-wide
|
||||
- [ ] each of `BL-011`, `BL-012`, `BL-016`, `BL-018`, `BL-019` is fixed, or closed with a recorded measurement showing it no longer reproduces
|
||||
- [ ] `stepper_check.py`'s pinned `BL-016` check is inverted to assert the fixed behaviour
|
||||
- [ ] `browser_check.py`'s fixture uses the production SOP shape
|
||||
- [ ] `docs/waves/backlog.md` has no entry still pointing at wave 9
|
||||
|
||||
---
|
||||
|
||||
### T9.7 — Final reconciliation
|
||||
|
||||
- **Items:** all
|
||||
- **Depends on:** T9.8, T9.9
|
||||
- **Blocks:** nothing
|
||||
- **Surface:** documentation
|
||||
- **Files:** creates `docs/reference/completion.md`
|
||||
|
||||
**Do:** Walk all 65 items - the 55 in `IMPLEMENTATION.md` section 6 and the 10 in
|
||||
`docs/waves/decisions-2026-08-18.md` and record, for each: built /
|
||||
deferred / not applicable, the PR that delivered it, and any deviation from its written
|
||||
acceptance criteria.
|
||||
|
||||
Explicitly confirm the four out-of-scope items in section 7 were not built: no parts catalog
|
||||
or inventory, no asset database integration, no CxAlloy integration, no P6 activity import.
|
||||
|
||||
Then list what the plan learned that the next revision should carry: items whose acceptance
|
||||
criteria turned out wrong, inputs still outstanding, and follow-ups logged along the way.
|
||||
|
||||
**Done when:**
|
||||
|
||||
- [ ] all 65 items are accounted for with a PR reference or a stated reason - the 55 from section 6 and the 10 from `decisions-2026-08-18.md`
|
||||
- [ ] every deviation from written acceptance criteria is documented
|
||||
- [ ] the four out-of-scope items are confirmed unbuilt
|
||||
- [ ] outstanding inputs from `IMPLEMENTATION.md` section 8 are restated with current status
|
||||
- [ ] follow-ups are collected in one place for the next revision
|
||||
|
||||
---
|
||||
|
||||
## Wave 9 exit criteria
|
||||
|
||||
- [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)
|
||||
- [x] one icon system, one sample-data affordance (`icon_check.py`, `sample_check.py`)
|
||||
- [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)
|
||||
- [x] the primary flow works at 390px (`mobile_check.py`, 24 checks, all seven pages; screenshots committed)
|
||||
- [x] archived projects are readable by project admins and invisible to everyone else (`archived_check.py`, 15 checks)
|
||||
- [x] the backlog has no entry still pointing at wave 9 (nine closed at T9.9, each with its measurement)
|
||||
- [x] every item is reconciled — all 65 (`docs/reference/completion.md`)
|
||||
260
html/admin.html
@@ -8,203 +8,53 @@
|
||||
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
|
||||
timestamps during their own boot. -->
|
||||
<script src="wp-format.js"></script>
|
||||
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
|
||||
during their own boot. -->
|
||||
<script src="wp-url.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<link rel="stylesheet" href="wp-chrome.css">
|
||||
<link rel="stylesheet" href="console.css">
|
||||
<link rel="stylesheet" href="wp-sidenav.css">
|
||||
<style>
|
||||
/* ══ TOKENS ══════════════════════════════════════════════════════════════
|
||||
The console is the only page in the suite that is mostly dense tables, so
|
||||
it carries its own sheet. The palette, the square corners and the type are
|
||||
Carbon's — the same ones theme-light.css sets — so it still reads as one
|
||||
product with the rest of the suite. Two scales do all the spacing and all
|
||||
the control sizing; nothing in here should invent its own. */
|
||||
:root{ --bg:#f4f4f4; --surface:#fff; --border:#e0e0e0; --border-strong:#8d8d8d; --text:#161616;
|
||||
--muted:#525252; --dim:#8d8d8d; --accent:#0f62fe; --accent-hover:#0353e9; --accent-soft:#edf5ff;
|
||||
--green:#198038; --green-bg:#defbe6;
|
||||
--red:#da1e28; --red-bg:#fff1f1; --amber:#8e6a00; --amber-bg:#fdf6dd;
|
||||
--head-bg:#f4f4f4; --zebra:#fafafa; --row-hover:#eef0f2;
|
||||
--mono:'IBM Plex Mono','Cascadia Mono',Consolas,monospace;
|
||||
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:20px; --s6:28px;
|
||||
--ctl:32px; /* every button / input / select that sits in a form row */
|
||||
--ctl-sm:26px; } /* every control that sits inside a table cell */
|
||||
*{ box-sizing:border-box; }
|
||||
body{ margin:0; font-family:'IBM Plex Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); }
|
||||
/* Page-specific only — the tokens, cards, controls, tables, banners and modal
|
||||
live in console.css, shared with the User Directory. What stays here is what
|
||||
only this page has: the per-card scroll boxes admin.js paints tables into, the
|
||||
column exceptions for those tables, and the admins-only notice.
|
||||
|
||||
/* ══ PAGE ════════════════════════════════════════════════════════════════
|
||||
1240px, not 860: the user table is nine columns wide and at 860 it spilled
|
||||
straight out of its own white card. Wide enough for that table, still a
|
||||
readable measure for the prose, which is capped separately. */
|
||||
.wrap{ max-width:1240px; margin:0 auto; padding:var(--s6) var(--s5) 80px; }
|
||||
h1{ font-size:20px; line-height:1.2; margin:0 0 2px; }
|
||||
.sub{ color:var(--muted); font-size:13px; line-height:1.5; margin:0 0 var(--s3); max-width:96ch; }
|
||||
a.home{ color:var(--accent); font-size:13px; text-decoration:none; white-space:nowrap; }
|
||||
a.home:hover{ text-decoration:underline; }
|
||||
These are addressed by ID because admin.js emits the tables without per-cell
|
||||
classes. */
|
||||
|
||||
/* ══ CARDS ═══════════════════════════════════════════════════════════════ */
|
||||
.card{ background:var(--surface); border:1px solid var(--border); border-radius:0;
|
||||
padding:var(--s4) var(--s5) var(--s5); margin-bottom:var(--s4); }
|
||||
/* One card header everywhere: small uppercase accent label on a hairline.
|
||||
admin.js also emits h2 for sub-sections inside a card (Localization
|
||||
defaults, Step views, Actions) with an inline margin-top — the same
|
||||
treatment reads correctly as a divider there, so both get it. */
|
||||
.card h2{ font-size:12px; font-weight:600; letter-spacing:.08em; text-transform:uppercase;
|
||||
color:var(--accent); margin:0 0 var(--s3); padding-bottom:var(--s2); border-bottom:1px solid var(--border); }
|
||||
.wrap code{ font-family:var(--mono); font-size:.92em; background:var(--bg); padding:1px 4px; }
|
||||
/* These start life as empty divs that admin.js fills on demand, so they only earn
|
||||
their gap once they are actually saying something. */
|
||||
#projects-banner:not(:empty), #defmem-banner:not(:empty){ margin-bottom:var(--s3); }
|
||||
|
||||
/* ══ CONTROLS ════════════════════════════════════════════════════════════
|
||||
Every button, input and select in a form row is exactly --ctl tall, so a
|
||||
toolbar is one clean band instead of a ragged one. */
|
||||
button{ font:inherit; font-size:13px; font-weight:600; line-height:1; white-space:nowrap;
|
||||
height:var(--ctl); padding:0 var(--s3); border-radius:0; cursor:pointer;
|
||||
border:1px solid var(--border-strong); background:#fff; color:var(--text); }
|
||||
button:hover{ border-color:var(--accent); color:var(--accent); }
|
||||
button:focus-visible{ outline:2px solid var(--accent); outline-offset:-3px; }
|
||||
button:disabled, button:disabled:hover{ color:var(--dim); border-color:var(--border); background:#fff; cursor:default; }
|
||||
button.primary{ background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||
button.primary:hover{ background:var(--accent-hover); border-color:var(--accent-hover); color:#fff; }
|
||||
button.danger{ border-color:var(--red); color:var(--red); }
|
||||
button.danger:hover{ background:var(--red-bg); border-color:var(--red); color:var(--red); }
|
||||
.row{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; }
|
||||
/* The filter / search / button strip at the top of a card. */
|
||||
.toolbar{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; margin:0 0 var(--s3); }
|
||||
.toolbar + .banner{ margin-top:0; }
|
||||
.urow{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; }
|
||||
/* Checkboxes are excluded: they are drawn by the platform and want none of a
|
||||
text field's height, padding or border. */
|
||||
.toolbar input:not([type=checkbox]), .toolbar select,
|
||||
.urow input:not([type=checkbox]), .urow select{
|
||||
height:var(--ctl); padding:0 var(--s2); font:inherit; font-size:13px; line-height:normal;
|
||||
border:1px solid var(--border-strong); border-radius:0; background:#fff; color:var(--text); }
|
||||
.toolbar select, .urow select{ cursor:pointer; padding-right:var(--s1); }
|
||||
.toolbar input:focus-visible, .toolbar select:focus-visible,
|
||||
.urow input:focus-visible, .urow select:focus-visible{ outline:2px solid var(--accent); outline-offset:-2px; }
|
||||
.toolbar > input{ flex:1 1 240px; min-width:150px; }
|
||||
.urow input:not([type=checkbox]){ flex:1 1 140px; min-width:0; }
|
||||
/* Inline checkbox + label, sized to sit on the same line as the buttons. */
|
||||
.chk{ display:inline-flex; align-items:center; gap:var(--s2); height:var(--ctl); padding:0 var(--s1);
|
||||
font-size:13px; color:var(--muted); white-space:nowrap; cursor:pointer; }
|
||||
.chk input{ width:16px; height:16px; margin:0; accent-color:var(--accent); cursor:pointer; }
|
||||
|
||||
/* ══ FEEDBACK: banners, notes, console output, key/value ═════════════════ */
|
||||
.banner{ margin:var(--s3) 0 0; padding:9px var(--s3); border-radius:0; font-size:13px; font-weight:600;
|
||||
line-height:1.4; border:1px solid var(--border); border-left:3px solid var(--border-strong);
|
||||
background:var(--surface); color:var(--text); }
|
||||
.banner.ok{ background:var(--green-bg); color:var(--green); border-color:#a7f0ba; border-left-color:var(--green); }
|
||||
.banner.bad{ background:var(--red-bg); color:var(--red); border-color:#ffd7d9; border-left-color:var(--red); }
|
||||
/* These three start life as empty divs that admin.js fills on demand, so they
|
||||
only earn their gap once they are actually saying something. */
|
||||
#users-banner:not(:empty), #projects-banner:not(:empty), #defmem-banner:not(:empty){ margin-bottom:var(--s3); }
|
||||
/* --muted, not --dim: #8d8d8d on white is 3.3:1, under the 4.5:1 floor at 12px,
|
||||
and #features-box / #settings-box are themselves .note — their primary toggle
|
||||
labels inherit this colour. */
|
||||
.note{ font-size:12px; line-height:1.55; color:var(--muted); margin-top:var(--s2); }
|
||||
.note strong, .note em{ color:var(--text); }
|
||||
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:0; padding:var(--s3) var(--s4); font-family:var(--mono);
|
||||
font-size:12px; line-height:1.55; white-space:pre-wrap; max-height:340px; overflow:auto; margin:var(--s3) 0 0; }
|
||||
pre.out .p{ color:#56d364; font-weight:700; } pre.out .f{ color:#ff7b72; font-weight:700; }
|
||||
table.kv{ border-collapse:collapse; font-size:13px; margin-top:var(--s2); }
|
||||
table.kv th{ text-align:left; padding:var(--s1) var(--s5) var(--s1) 0; color:var(--muted); font-weight:600; white-space:nowrap; }
|
||||
table.kv td{ padding:var(--s1) 0; font-variant-numeric:tabular-nums; font-weight:700; color:var(--text); }
|
||||
|
||||
/* ══ DATA TABLES ═════════════════════════════════════════════════════════
|
||||
table.users is the name admin.js already emits; table.grid is the same
|
||||
object under the shared name. One rule set serves both, so existing markup
|
||||
picks up the dense styling without being rewritten. border-collapse is
|
||||
separate rather than collapse because a collapsed border does not travel
|
||||
with a sticky header. */
|
||||
table.grid, table.users{ width:100%; border-collapse:separate; border-spacing:0;
|
||||
font-size:13px; color:var(--text); background:var(--surface); }
|
||||
table.grid th, table.users th{ position:sticky; top:0; z-index:2; background:var(--head-bg);
|
||||
text-align:left; padding:var(--s2) var(--s3); white-space:nowrap;
|
||||
font-size:11px; font-weight:600; letter-spacing:.04em; text-transform:uppercase; color:var(--muted);
|
||||
box-shadow:inset 0 -1px 0 var(--border); }
|
||||
/* Cells never wrap: a wrapped cell turns one user into a 100px tall band and
|
||||
the table stops reading as rows. Anything genuinely long truncates (.ell)
|
||||
or is exempted by name further down. */
|
||||
table.grid td, table.users td{ padding:var(--s1) var(--s3); border-bottom:1px solid var(--border);
|
||||
vertical-align:middle; white-space:nowrap; }
|
||||
table.grid tbody tr:last-child td, table.users tbody tr:last-child td{ border-bottom:none; }
|
||||
table.grid tbody tr:nth-child(even) td, table.users tbody tr:nth-child(even) td{ background:var(--zebra); }
|
||||
/* A neutral hover, not --accent-soft: that is .tag.admin's fill, and an "all
|
||||
projects" pill sitting on its own colour disappears the moment you hover it. */
|
||||
table.grid tbody tr:hover td, table.users tbody tr:hover td{ background:var(--row-hover); }
|
||||
/* Truncation has to hang off a block INSIDE the cell. max-width on a <td> is
|
||||
advisory under table-layout:auto — the cell just grows to fit and the ellipsis
|
||||
never appears, which is the usual reason this trick looks like it works in the
|
||||
stylesheet and doesn't on the page. admin.js emits <td class="ell"><span>. */
|
||||
.ell{ max-width:240px; }
|
||||
.ell > span{ display:block; max-width:240px; overflow:hidden; text-overflow:ellipsis;
|
||||
white-space:nowrap; }
|
||||
/* Every action cell admin.js renders is a .cellactions, and it must not wrap:
|
||||
unwrapped, the three buttons stack and the row grows fourfold — which is what
|
||||
the console looked like before this pass. */
|
||||
.cellactions{ display:flex; flex-wrap:nowrap; align-items:center; gap:var(--s1); white-space:nowrap; }
|
||||
/* Controls that live in a cell are one step smaller, which is what keeps a
|
||||
row at ~34px instead of ~100px. .chk is form-row sized by default, so it needs
|
||||
saying again here or the default-members rows stand 6px taller than the rest. */
|
||||
button.mini{ height:var(--ctl-sm); padding:0 var(--s2); font-size:12px; }
|
||||
table.grid td .chk, table.users td .chk{ height:var(--ctl-sm); }
|
||||
select.role-select{ height:var(--ctl-sm); max-width:170px; padding:0 var(--s1) 0 var(--s2);
|
||||
font:inherit; font-size:12px; border:1px solid var(--border-strong); border-radius:0;
|
||||
background:#fff; color:var(--text); cursor:pointer; }
|
||||
select.role-select:hover{ border-color:var(--accent); }
|
||||
select.role-select.is-admin{ color:var(--accent); border-color:var(--accent); font-weight:600; }
|
||||
.tag{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600;
|
||||
line-height:1.55; white-space:nowrap; vertical-align:middle; }
|
||||
.tag.admin{ background:var(--accent-soft); color:var(--accent); }
|
||||
.tag.user{ background:#e8e8e8; color:var(--muted); }
|
||||
.tag.on{ background:var(--green-bg); color:var(--green); }
|
||||
.tag.off{ background:var(--red-bg); color:var(--red); }
|
||||
.tag.archived{ background:var(--amber-bg); color:var(--amber); }
|
||||
.me-tag{ font-size:11px; color:var(--dim); margin-left:6px; white-space:nowrap; }
|
||||
|
||||
/* A wide table scrolls inside its own box so the page never scrolls sideways,
|
||||
and the capped height is what gives the sticky header something to do. Every
|
||||
container admin.js paints a table into is one, so a sticky header always has
|
||||
a scrollport of its own rather than sliding up behind the app bar. */
|
||||
.tscroll, #users-table, #comments-admin, #audit-admin, #notif-box, #usage-admin,
|
||||
#projects-table, #defmem-table{
|
||||
/* Every container admin.js paints a table into is a scrollport of its own, so a
|
||||
sticky header always has something to stick to rather than sliding up behind
|
||||
the app bar. Same rule as console.css's .tscroll. */
|
||||
#comments-admin, #audit-admin, #notif-box, #usage-admin, #projects-table, #defmem-table{
|
||||
overflow:auto; max-height:min(70vh,640px); overscroll-behavior:contain; }
|
||||
/* If admin.js wraps its table in its own .tscroll, the outer box steps aside
|
||||
so one table never ends up with two scrollbars. */
|
||||
#users-table:has(.tscroll), #comments-admin:has(.tscroll), #audit-admin:has(.tscroll),
|
||||
#notif-box:has(.tscroll), #usage-admin:has(.tscroll), #projects-table:has(.tscroll),
|
||||
#defmem-table:has(.tscroll){
|
||||
/* If admin.js wraps its table in its own .tscroll, the outer box steps aside so
|
||||
one table never ends up with two scrollbars. */
|
||||
#comments-admin:has(.tscroll), #audit-admin:has(.tscroll), #notif-box:has(.tscroll),
|
||||
#usage-admin:has(.tscroll), #projects-table:has(.tscroll), #defmem-table:has(.tscroll){
|
||||
overflow:visible; max-height:none; }
|
||||
|
||||
/* Column exceptions, addressed by card because admin.js emits these tables
|
||||
without per-cell classes. Email is the one user cell long enough to stretch
|
||||
a row, so it truncates; comment text and audit detail are the two columns
|
||||
you are actually here to read, so they wrap inside a sane width instead. */
|
||||
#users-table table.users td:nth-child(3){ max-width:230px; overflow:hidden; text-overflow:ellipsis; }
|
||||
#comments-admin table.users td:nth-child(5){ white-space:normal; min-width:260px; max-width:640px; }
|
||||
#audit-admin table.users td:nth-child(6){ white-space:normal; max-width:420px; }
|
||||
/* Comment text and audit detail are the two columns you are actually here to
|
||||
read, so they wrap inside a sane width instead of truncating. */
|
||||
#comments-admin table td:nth-child(5){ white-space:normal; min-width:260px; max-width:640px; }
|
||||
#audit-admin table td:nth-child(6){ white-space:normal; max-width:420px; }
|
||||
|
||||
/* ══ GATES & WARNINGS ════════════════════════════════════════════════════ */
|
||||
.gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:var(--s5); z-index:9999; }
|
||||
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius:0; padding:var(--s6); max-width:380px; width:100%; box-shadow:0 8px 30px rgba(20,30,50,.12); }
|
||||
.gate-box h2{ margin:0 0 var(--s1); padding:0; border:0; font-size:17px; text-transform:none; letter-spacing:0; color:var(--text); }
|
||||
.gate-box p{ color:var(--muted); font-size:13px; margin:0 0 var(--s4); }
|
||||
.gate-box input{ width:100%; height:var(--ctl); padding:0 var(--s3); font:inherit; font-size:14px; border:1px solid var(--border-strong); border-radius:0; margin-bottom:var(--s3); }
|
||||
.gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:var(--s2); }
|
||||
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:0; padding:9px 13px; font-size:12px; margin-bottom:var(--s4); }
|
||||
/* The denial notice is a sentence, not a table — don't stretch it to 1240px. */
|
||||
#admin-denied .card{ max-width:560px; }
|
||||
.gate-box input{ width:100%; height:var(--ctl); padding:0 var(--s3); font:inherit; font-size:14px;
|
||||
border:1px solid var(--border-strong); border-radius:0; margin-bottom:var(--s3); }
|
||||
|
||||
/* ══ NARROW SCREENS ══════════════════════════════════════════════════════
|
||||
The page itself must never scroll sideways; the wide tables scroll inside
|
||||
their own box instead, and there they get the full page height to do it. */
|
||||
@media (max-width:900px){
|
||||
.wrap{ padding:var(--s4) var(--s3) 60px; }
|
||||
.card{ padding:var(--s3) var(--s4) var(--s4); }
|
||||
.toolbar > input{ flex:1 1 100%; }
|
||||
.tscroll, #users-table, #comments-admin, #audit-admin, #notif-box, #usage-admin,
|
||||
#projects-table, #defmem-table{ max-height:none; }
|
||||
}
|
||||
@media (max-width:620px){
|
||||
.urow input, .urow select, .urow button{ flex:1 1 100%; }
|
||||
#comments-admin, #audit-admin, #notif-box, #usage-admin, #projects-table, #defmem-table{
|
||||
max-height:none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -241,29 +91,14 @@
|
||||
<div class="banner" id="health-banner">—</div>
|
||||
</div>
|
||||
|
||||
<!-- USER ADMINISTRATION -->
|
||||
<!-- USER ADMINISTRATION — moved out to its own page -->
|
||||
<div class="card">
|
||||
<h2>User administration</h2>
|
||||
<div class="sub">Login accounts for the portal. Requires an <strong>admin</strong> role on your own account.</div>
|
||||
<div class="toolbar"><button onclick="loadUsers()">Refresh users</button></div>
|
||||
<div id="users-banner"></div>
|
||||
<div id="users-table"></div>
|
||||
|
||||
<h2 style="margin-top:var(--s6)">Add a user</h2>
|
||||
<div class="urow">
|
||||
<input id="nu-username" placeholder="Username *" autocomplete="off">
|
||||
<input id="nu-fullname" placeholder="Full name" autocomplete="off">
|
||||
<input id="nu-email" placeholder="Email" autocomplete="off">
|
||||
<select id="nu-role" title="Permissions — what this account may do">
|
||||
<option value="project_user">Project User</option>
|
||||
<option value="project_admin">Project Admin</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
<select id="nu-project-role" title="Job function on the project"></select>
|
||||
<input id="nu-password" type="password" placeholder="Password (min 12)" autocomplete="new-password">
|
||||
<button class="primary" onclick="createUser()">Create user</button>
|
||||
</div>
|
||||
<div id="users-create-msg" class="note"></div>
|
||||
<h2>User accounts</h2>
|
||||
<div class="sub">Login accounts, permissions and project access now live on the
|
||||
<strong>User Directory</strong> page. They moved because user administration is no longer
|
||||
admin-only: a <strong>Project Super User</strong> creates and manages the accounts on the
|
||||
projects they administer, and they must never be sent through this console to do it.</div>
|
||||
<div class="toolbar"><a class="home" href="users.html"><button class="primary">Open the user directory →</button></a></div>
|
||||
</div>
|
||||
|
||||
<!-- PROJECTS (ARCHIVE / UNARCHIVE) -->
|
||||
@@ -286,8 +121,10 @@
|
||||
<h2>Default members on new projects</h2>
|
||||
<div class="sub">Everyone flagged here is added automatically to every project created from now on,
|
||||
with the role chosen here. It does not touch projects that already exist — for those, use
|
||||
<strong>Project access</strong> in the user table above. Administrators are listed with nothing
|
||||
to set: they already reach every project.</div>
|
||||
<strong>Project access</strong> on the <a class="home" href="users.html">User Directory</a>.
|
||||
Administrators are listed with nothing to set: they already reach every project. This card stays
|
||||
in the console because it is a rule about <em>every</em> future project, including the ones a
|
||||
Project Super User has no part in — so only an admin sets it.</div>
|
||||
<div class="toolbar"><button onclick="loadDefaultMembers()">Refresh</button></div>
|
||||
<div id="defmem-banner"></div>
|
||||
<div id="defmem-table"><div class="note">Click “Refresh” to load.</div></div>
|
||||
@@ -341,10 +178,11 @@
|
||||
<!-- USAGE LOGS -->
|
||||
<div class="card">
|
||||
<h2>Usage logs</h2>
|
||||
<div class="sub">Engagement recorded by the suite — sessions, step views, and actions. Note: stored locally per browser, so this reflects activity on <strong>this</strong> machine.</div>
|
||||
<div class="sub">Engagement recorded by both tools — the work package creator and the SOP wizard —
|
||||
sessions, actions and counts, with a download per tool (D5). Note: stored locally per browser,
|
||||
so this reflects activity on <strong>this</strong> machine.</div>
|
||||
<div class="toolbar">
|
||||
<button onclick="loadUsage()">Refresh</button>
|
||||
<button onclick="downloadUsage()">Download JSON</button>
|
||||
</div>
|
||||
<div id="usage-admin" class="note">Click refresh to load.</div>
|
||||
</div>
|
||||
@@ -377,7 +215,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="admin.js"></script>
|
||||
<script src="wp-usage.js"></script>
|
||||
<script src="console-util.js"></script>
|
||||
<script src="wp-dialog.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
<!-- The app bar's project switcher reads ProjectData; without this the bar on this
|
||||
page could never show a project and always read "Select a project" (F1). Must
|
||||
parse before wp-chrome.js, which reads it as it mounts. -->
|
||||
<script src="project-data.js"></script>
|
||||
<script src="wp-chrome.js"></script>
|
||||
<script src="wp-sidenav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
488
html/admin.js
@@ -4,14 +4,20 @@
|
||||
ACCESS: the console is gated on the signed-in user's ROLE. auth-guard.js
|
||||
already requires a login (redirecting to login.html otherwise) and publishes
|
||||
window.WP_USER; here we show the console only when that user is an admin, and
|
||||
show an "Admins only" notice otherwise. Every user-management API is also
|
||||
enforced as admin-only server-side, so this is a real gate, not obfuscation. */
|
||||
show an "Admins only" notice otherwise. Every API this page calls is also
|
||||
enforced as admin-only server-side, so this is a real gate, not obfuscation.
|
||||
|
||||
USER ACCOUNTS LIVE ON users.html, not here. They moved when the Project Super
|
||||
User role arrived: administering users is no longer an admin-only act, so the
|
||||
page that does it can't be behind an admins-only gate. What stays here is what
|
||||
genuinely is app-wide and admin-only — settings, feature flags, diagnostics,
|
||||
project archiving, and the default-member rule for future projects.
|
||||
|
||||
Shared helpers (api, uesc, jsq, the role vocabulary) come from console-util.js. */
|
||||
|
||||
function reveal(){
|
||||
document.getElementById('admin-main').style.display='';
|
||||
fillProjectRoleOptions();
|
||||
checkHealth();
|
||||
loadUsers();
|
||||
loadProjects();
|
||||
loadDefaultMembers();
|
||||
loadSettings();
|
||||
@@ -24,31 +30,19 @@ function showDenied(){
|
||||
document.getElementById('admin-denied').style.display='';
|
||||
}
|
||||
|
||||
// ── api helper ──────────────────────────────────────────────────────────────
|
||||
async function api(method, path, body){
|
||||
const opt = { method, headers:{ 'Accept':'application/json' } };
|
||||
if(body !== undefined){ opt.headers['Content-Type']='application/json'; opt.body=JSON.stringify(body); }
|
||||
try {
|
||||
const r = await fetch(path, opt);
|
||||
const t = await r.text();
|
||||
let json; try { json = t ? JSON.parse(t) : null; } catch(_){ json = t; }
|
||||
return { status:r.status, json };
|
||||
} catch(e){ return { status:0, json:String(e) }; }
|
||||
}
|
||||
|
||||
// ── connectivity ──────────────────────────────────────────────────────────────
|
||||
async function checkHealth(){
|
||||
const b = document.getElementById('health-banner');
|
||||
b.className='banner'; b.textContent='Checking…';
|
||||
const { status, json } = await api('GET','/api/health');
|
||||
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){
|
||||
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){
|
||||
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 {
|
||||
b.className='banner bad'; b.textContent='❌ Unexpected response: HTTP '+status;
|
||||
b.className='banner bad'; b.textContent='✕ Unexpected response: HTTP '+status;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,9 +121,9 @@ function stdConstraints(open){ return ['Safety & Permitting','Quality Control /
|
||||
async function seedDemo(){
|
||||
const o=document.getElementById('demo-out'); o.innerHTML='';
|
||||
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'});
|
||||
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);
|
||||
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 — 3–5 days (≈40–80 hrs)',sizeHoursMax:'80'}}});
|
||||
const sid=r.json && r.json.id; demoLog('SOP created (complete).');
|
||||
@@ -148,334 +142,25 @@ 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'});
|
||||
r = await api('GET','/api/wps/metrics?project_id='+pid);
|
||||
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();
|
||||
}
|
||||
async function cleanDemo(){
|
||||
if(!confirm('Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?')) return;
|
||||
if(!(await wpConfirmDialog({title:'Delete demo data',
|
||||
message:'Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?',
|
||||
okLabel:'Delete them'}))) return;
|
||||
const o=document.getElementById('demo-out'); o.innerHTML='';
|
||||
// archived=all, or an archived DEMO-/SMOKE- project becomes unreachable from
|
||||
// this button — the default list hides it and nothing else here can delete it.
|
||||
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||'')));
|
||||
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+')'); }
|
||||
demoLog('\n✅ Removed '+targets.length+' project(s).');
|
||||
demoLog('\n✓ Removed '+targets.length+' project(s).');
|
||||
snapshot();
|
||||
}
|
||||
|
||||
// ── user administration ────────────────────────────────────────────────────────
|
||||
function uesc(v){ return v==null ? '' : String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
// A value bound into an inline handler — onclick="fn('…')" — is escaped TWICE: once
|
||||
// for the JS string literal it lands in, and again for the HTML attribute carrying
|
||||
// it. The order is the whole point. Escape the backslashes FIRST, then the quotes,
|
||||
// then hand the result to uesc: uesc leaves \ and ' alone, so the JS escaping
|
||||
// survives, and the browser decodes the entities before the JS parser runs.
|
||||
//
|
||||
// Doing it the other way round — uesc(v).replace(/'/g,"\\'") — silently fails on a
|
||||
// value containing a backslash: the \ we add is itself escaped by the stored one,
|
||||
// the quote closes the literal, and everything after it runs as code. Project names
|
||||
// and full names are free text that any signed-in user can write, so that is a real
|
||||
// path from a project_user to whatever an admin's session can do. Use jsq() for
|
||||
// EVERY value that lands inside an inline handler.
|
||||
function jsq(v){
|
||||
return uesc(String(v==null ? '' : v).replace(/\\/g,'\\\\').replace(/'/g,"\\'"));
|
||||
}
|
||||
|
||||
async function currentUserId(){
|
||||
if(window.WP_USER && window.WP_USER.id) return window.WP_USER.id;
|
||||
const { status, json } = await api('GET','/api/auth/me');
|
||||
return (status===200 && json && json.user) ? json.user.id : null;
|
||||
}
|
||||
|
||||
async function loadUsers(){
|
||||
const banner=document.getElementById('users-banner');
|
||||
const wrap=document.getElementById('users-table');
|
||||
banner.className='banner'; banner.textContent='Loading…'; banner.style.display='';
|
||||
const { status, json } = await api('GET','/api/auth/users');
|
||||
if(status===403){
|
||||
banner.className='banner bad';
|
||||
banner.textContent='❌ Your account is not an admin, so you can’t manage users. Ask an admin, or use the CLI: python -m server.manage_users';
|
||||
wrap.innerHTML=''; return;
|
||||
}
|
||||
if(status===401){
|
||||
banner.className='banner bad'; banner.textContent='❌ Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
|
||||
}
|
||||
if(status!==200 || !Array.isArray(json)){
|
||||
banner.className='banner bad'; banner.textContent='❌ Could not load users (HTTP '+status+').'; wrap.innerHTML=''; return;
|
||||
}
|
||||
banner.style.display='none';
|
||||
const meId = await currentUserId();
|
||||
renderUsers(json, meId);
|
||||
// Fill in the project-access counts, then repaint that column.
|
||||
await loadProjectCounts(json);
|
||||
renderUsers(json, meId);
|
||||
}
|
||||
|
||||
// Permissions roles (what an account may do) — mirrors auth.ROLES on the server.
|
||||
const PERM_ROLES = ['admin','project_admin','project_user'];
|
||||
const PERM_LABELS = { admin:'Administrator', project_admin:'Project Admin', project_user:'Project User' };
|
||||
// Job functions on a project. Descriptive only — no permissions attached.
|
||||
const PROJECT_ROLES = ['Project Manager','Assistant Project Manager','Construction Manager',
|
||||
'Quality Manager','Superintendent','General Foreman','Foreman','Planner / Scheduler',
|
||||
'BIM / VDC Coordinator','Engineer','Safety (HSE)','Warehouse / Materials','Commissioning',
|
||||
'Field Technician'];
|
||||
// Accounts created before permissions roles existed carry the legacy value 'user'.
|
||||
function normRole(r){ return r==='user' ? 'project_user' : (PERM_ROLES.indexOf(r)>=0 ? r : 'project_user'); }
|
||||
|
||||
function fillProjectRoleOptions(){
|
||||
const sel=document.getElementById('nu-project-role'); if(!sel) return;
|
||||
sel.innerHTML='<option value="">Project role…</option>'+
|
||||
PROJECT_ROLES.map(r=>'<option value="'+uesc(r)+'">'+uesc(r)+'</option>').join('');
|
||||
}
|
||||
|
||||
// Per-user project access gets its own column: it was buried among the action
|
||||
// buttons, which is exactly where you'd fail to find "which projects can this
|
||||
// person see, and what may they do there".
|
||||
let _userProjectCounts = {}; // user id -> number of assigned projects
|
||||
|
||||
function projAccessCell(u){
|
||||
const uname = jsq(u.username);
|
||||
if(normRole(u.role) === 'admin'){
|
||||
return '<span class="tag admin" title="Admins can access every project">all projects</span>';
|
||||
}
|
||||
const n = _userProjectCounts[u.id];
|
||||
const label = (n === undefined) ? 'Projects…'
|
||||
: (n === 0 ? 'No projects yet' : n + ' project' + (n === 1 ? '' : 's'));
|
||||
return '<button class="mini' + (n === 0 ? ' danger' : '') +
|
||||
'" onclick="manageProjects(\'' + jsq(u.id) + '\',\'' + uname + '\')"' +
|
||||
' title="Choose which projects this user can access, and their role on each">' +
|
||||
label + '</button>';
|
||||
}
|
||||
|
||||
// A project's role dropdown only matters while that project is ticked.
|
||||
function projRowToggled(cb){
|
||||
const row = cb.closest('div');
|
||||
const sel = row && row.querySelector('select');
|
||||
if(sel) sel.disabled = !cb.checked;
|
||||
}
|
||||
|
||||
// Counts for that column. One call per user, but only for non-admins and only on a
|
||||
// refresh — the admin console is not a hot path.
|
||||
async function loadProjectCounts(list){
|
||||
const targets = (list || []).filter(u => normRole(u.role) !== 'admin');
|
||||
await Promise.all(targets.map(async u => {
|
||||
const { status, json } = await api('GET','/api/auth/users/'+u.id+'/projects');
|
||||
if(status === 200 && json) _userProjectCounts[u.id] = (json.assigned || []).length;
|
||||
}));
|
||||
}
|
||||
|
||||
function renderUsers(list, meId){
|
||||
const wrap=document.getElementById('users-table');
|
||||
if(!list.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; }
|
||||
const fmt = s => s ? wpFormatDateTime(s) : '—';
|
||||
let rows = list.map(u=>{
|
||||
const me = u.id===meId;
|
||||
const active = u.is_active;
|
||||
const disableBtn = me
|
||||
? '<button class="mini" disabled title="You can’t disable yourself">—</button>'
|
||||
: '<button class="mini" onclick="toggleActive(\''+jsq(u.id)+'\','+(!active)+')">'+(active?'Disable':'Enable')+'</button>';
|
||||
const delBtn = me
|
||||
? ''
|
||||
: '<button class="mini danger" onclick="deleteUser(\''+jsq(u.id)+'\',\''+jsq(u.username)+'\')">Delete</button>';
|
||||
// Role can be changed at any time via an inline dropdown. Your own row is
|
||||
// locked (a shown-as-tag) so an admin can't accidentally demote themselves.
|
||||
const escUname = jsq(u.username);
|
||||
const escUid = jsq(u.id);
|
||||
// PERMISSIONS role — what the account may do. Your own row is locked (shown as
|
||||
// a tag) so an admin can't accidentally demote themselves.
|
||||
const role = normRole(u.role);
|
||||
const roleCell = me
|
||||
? '<span class="tag '+(role==='admin'?'admin':'user')+'">'+uesc(PERM_LABELS[role]||role)+'</span><span class="me-tag">locked</span>'
|
||||
: '<select class="role-select'+(role==='admin'?' is-admin':'')+'" title="Change what this account may do" onchange="changeRole(\''+escUid+'\',this.value,\''+escUname+'\')">'+
|
||||
PERM_ROLES.map(function(r){
|
||||
return '<option value="'+r+'"'+(role===r?' selected':'')+'>'+uesc(PERM_LABELS[r])+'</option>';
|
||||
}).join('')+
|
||||
'</select>';
|
||||
// PROJECT role — the person's job function. Descriptive only; grants nothing.
|
||||
const pr = u.project_role || '';
|
||||
const projRoleCell =
|
||||
'<select class="role-select" title="Job function on the project" onchange="changeProjectRole(\''+escUid+'\',this.value,\''+escUname+'\')">'+
|
||||
'<option value=""'+(pr?'':' selected')+'>— none —</option>'+
|
||||
PROJECT_ROLES.map(function(r){
|
||||
return '<option value="'+uesc(r)+'"'+(pr===r?' selected':'')+'>'+uesc(r)+'</option>';
|
||||
}).join('')+
|
||||
// Keep a title that isn't on the list (set via the API or an older record).
|
||||
(pr && PROJECT_ROLES.indexOf(pr)<0 ? '<option value="'+uesc(pr)+'" selected>'+uesc(pr)+'</option>' : '')+
|
||||
'</select>';
|
||||
return '<tr>'+
|
||||
'<td><strong>'+uesc(u.username)+'</strong>'+(me?'<span class="me-tag">you</span>':'')+'</td>'+
|
||||
'<td>'+uesc(u.full_name||'')+'</td>'+
|
||||
// The address is truncated with the full value on the title: a long one used
|
||||
// to wrap mid-word and push the whole row onto three lines.
|
||||
'<td class="ell" title="'+uesc(u.email||'')+'"><span>'+uesc(u.email||'')+'</span></td>'+
|
||||
'<td>'+roleCell+'</td>'+
|
||||
'<td>'+projRoleCell+'</td>'+
|
||||
'<td><div class="cellactions">'+projAccessCell(u)+'</div></td>'+
|
||||
'<td><span class="tag '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></td>'+
|
||||
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
|
||||
'<td><div class="cellactions">'+
|
||||
'<button class="mini" onclick="resetPw(\''+escUid+'\',\''+escUname+'\')">Reset password</button>'+
|
||||
disableBtn+delBtn+
|
||||
'</div></td>'+
|
||||
'</tr>';
|
||||
}).join('');
|
||||
// Nine columns outrun even the widened card, so the table scrolls inside .tscroll
|
||||
// rather than forcing every cell to wrap. The explanatory note stays outside it.
|
||||
wrap.innerHTML='<div class="tscroll"><table class="users grid"><thead><tr>'+
|
||||
'<th>Username</th><th>Name</th><th>Email</th>'+
|
||||
'<th title="What this account may do in the app">Permissions</th>'+
|
||||
'<th title="Job function on the project — descriptive only">Project role</th>'+
|
||||
'<th title="Which projects this user can access, and their role on each">Project access</th>'+
|
||||
'<th>Status</th><th>Last login</th><th>Actions</th>'+
|
||||
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
|
||||
'<div class="note" style="margin-top:10px"><strong>Permissions</strong> — '+
|
||||
'<em>Administrator</em>: manages users, settings and every project. '+
|
||||
'<em>Project Admin</em>: on their assigned projects, may delete work packages, '+
|
||||
'change a completed SOP, and delete the project. '+
|
||||
'<em>Project User</em>: creates and edits work packages and authors the SOP, '+
|
||||
'but cannot delete WPs or change the SOP once it\'s complete. '+
|
||||
'<strong>Project role</strong> is the person\'s job function — it feeds the SOP '+
|
||||
'team pickers and notification routing, and grants nothing on its own.</div>';
|
||||
}
|
||||
|
||||
async function createUser(){
|
||||
const msg=document.getElementById('users-create-msg');
|
||||
const username=document.getElementById('nu-username').value.trim();
|
||||
const full_name=document.getElementById('nu-fullname').value.trim();
|
||||
const email=document.getElementById('nu-email').value.trim();
|
||||
const role=document.getElementById('nu-role').value;
|
||||
const project_role=(document.getElementById('nu-project-role')||{}).value||'';
|
||||
const password=document.getElementById('nu-password').value;
|
||||
if(!username){ msg.style.color='var(--red)'; msg.textContent='Username is required.'; return; }
|
||||
if(password.length<12){ msg.style.color='var(--red)'; msg.textContent='Password must be at least 12 characters.'; return; }
|
||||
msg.style.color='var(--muted)'; msg.textContent='Creating…';
|
||||
const { status, json } = await api('POST','/api/auth/users',{username,full_name,email,role,project_role,password});
|
||||
if(status===200){
|
||||
msg.style.color='var(--green)'; msg.textContent='✅ Created '+username+'.';
|
||||
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id=>document.getElementById(id).value='');
|
||||
loadUsers();
|
||||
} else {
|
||||
msg.style.color='var(--red)';
|
||||
msg.textContent='❌ '+((json && json.detail) ? json.detail : ('Failed (HTTP '+status+').'));
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPw(id, username){
|
||||
const pw=prompt('New password for "'+username+'" (min 8 characters):');
|
||||
if(pw===null) return;
|
||||
if(pw.length<8){ alert('Password must be at least 8 characters.'); return; }
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw});
|
||||
if(status===200) alert('Password reset for '+username+'.');
|
||||
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||||
}
|
||||
|
||||
async function toggleActive(id, makeActive){
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive});
|
||||
if(status===200) loadUsers();
|
||||
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||||
}
|
||||
|
||||
// Change a user's role (user ↔ admin) at any time. The server enforces the same
|
||||
// admin-only rule as every other user-management call, and refuses to remove the
|
||||
// last admin. On any failure we reload so the dropdown snaps back to the truth.
|
||||
async function changeRole(id, role, username){
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role});
|
||||
if(status===200){ loadUsers(); }
|
||||
else {
|
||||
alert('Could not change permissions for '+username+': '+((json && json.detail)||('HTTP '+status)));
|
||||
loadUsers();
|
||||
}
|
||||
}
|
||||
async function changeProjectRole(id, project_role, username){
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/project-role',{project_role});
|
||||
if(status===200){ loadUsers(); }
|
||||
else {
|
||||
alert('Could not set the project role for '+username+': '+((json && json.detail)||('HTTP '+status)));
|
||||
loadUsers();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(id, username){
|
||||
if(!confirm('Delete user "'+username+'"? This cannot be undone.')) return;
|
||||
const { status, json } = await api('DELETE','/api/auth/users/'+id);
|
||||
if(status===200) loadUsers();
|
||||
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||||
}
|
||||
|
||||
// ── project access assignment ───────────────────────────────────────────────────
|
||||
async function manageProjects(id, username){
|
||||
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
|
||||
if(status!==200 || !json){ alert('Could not load projects (HTTP '+status+').'); return; }
|
||||
openProjectModal(id, username, json.projects||[], new Set(json.assigned||[]), json.user, json.roles||{});
|
||||
}
|
||||
function closeProjectModal(){ const m=document.getElementById('proj-modal'); if(m) m.remove(); }
|
||||
function openProjectModal(userId, username, projects, assigned, userObj, roles){
|
||||
closeProjectModal();
|
||||
const isAdmin = userObj && normRole(userObj.role)==='admin';
|
||||
const acctRole = userObj ? normRole(userObj.role) : 'project_user';
|
||||
roles = roles || {};
|
||||
// Each project row: access tick + the role ON THAT project. "Same as account"
|
||||
// inherits the account's Permissions, so the common case needs no thought.
|
||||
// Live jobs first — an archived one is still listed (an existing assignment has to
|
||||
// stay removable) but it is finished work, so it doesn't belong at the top of a
|
||||
// list you're using to staff someone.
|
||||
projects = projects.slice().sort((a,b) => (a.archived?1:0) - (b.archived?1:0));
|
||||
const items = projects.length ? projects.map(p => {
|
||||
const on = assigned.has(p.id);
|
||||
const cur = roles[p.id] || '';
|
||||
const sel = '<select data-role-for="'+uesc(p.id)+'"'+(isAdmin||!on?' disabled':'')+
|
||||
' style="padding:3px 6px;font-size:12px;border:1px solid var(--border-strong);background:#fff;">'+
|
||||
'<option value=""'+(cur===''?' selected':'')+'>Same as account ('+uesc(PERM_LABELS[acctRole]||acctRole)+')</option>'+
|
||||
'<option value="project_admin"'+(cur==='project_admin'?' selected':'')+'>Project Admin here</option>'+
|
||||
'<option value="project_user"'+(cur==='project_user'?' selected':'')+'>Project User here</option>'+
|
||||
'</select>';
|
||||
return '<div style="display:flex;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid var(--border);font-size:13px;">'+
|
||||
'<label style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;cursor:pointer;">'+
|
||||
'<input type="checkbox" value="'+uesc(p.id)+'"'+(on?' checked':'')+(isAdmin?' disabled':'')+
|
||||
' onchange="projRowToggled(this)">'+
|
||||
'<span style="overflow:hidden;text-overflow:ellipsis;"><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
|
||||
(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+
|
||||
(p.archived?' <span class="tag archived" title="Archived — read-only until an admin unarchives it">archived</span>':'')+'</span>'+
|
||||
'</label>'+ sel +
|
||||
'</div>';
|
||||
}).join('') : '<div class="note">No projects exist yet.</div>';
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'proj-modal';
|
||||
modal.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;';
|
||||
modal.innerHTML =
|
||||
'<div style="background:#fff;border-radius:10px;max-width:660px;width:100%;max-height:82vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 40px rgba(20,30,50,.3);">'+
|
||||
'<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;">Project access & permissions — '+uesc(username)+'</div>'+
|
||||
'<div style="padding:14px 18px;overflow:auto;">'+
|
||||
(isAdmin ? '<div class="banner" style="margin:0 0 10px">This user is an <strong>Administrator</strong> and can access every project regardless of assignment.</div>'
|
||||
: '<div class="note" style="margin:0 0 10px">Tick the projects this user may access, and set their role on each. '+
|
||||
'<strong>Project Admin</strong> can delete work packages, change a completed SOP and delete that project; '+
|
||||
'<strong>Project User</strong> cannot. Leave it on <em>Same as account</em> to use their Permissions setting.</div>')+
|
||||
'<div id="proj-list">'+items+'</div>'+
|
||||
'</div>'+
|
||||
'<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">'+
|
||||
'<button onclick="closeProjectModal()">Cancel</button>'+
|
||||
(isAdmin ? '' : '<button class="primary" id="proj-save">Save</button>')+
|
||||
'</div>'+
|
||||
'</div>';
|
||||
modal.addEventListener('click', e => { if(e.target===modal) closeProjectModal(); });
|
||||
document.body.appendChild(modal);
|
||||
const saveBtn = document.getElementById('proj-save');
|
||||
if(saveBtn) saveBtn.onclick = async () => {
|
||||
const ids = [...modal.querySelectorAll('#proj-list input[type=checkbox]:checked')].map(c=>c.value);
|
||||
const roleMap = {};
|
||||
ids.forEach(pid => {
|
||||
const sel = modal.querySelector('#proj-list select[data-role-for="'+pid+'"]');
|
||||
if(sel && sel.value) roleMap[pid] = sel.value;
|
||||
});
|
||||
const { status } = await api('PUT','/api/auth/users/'+userId+'/projects',{project_ids:ids, roles:roleMap});
|
||||
if(status===200){ closeProjectModal(); loadUsers(); }
|
||||
else alert('Save failed (HTTP '+status+').');
|
||||
};
|
||||
}
|
||||
|
||||
// ── projects: archive / unarchive ───────────────────────────────────────────────
|
||||
// Archiving is the answer to "this job is over but I can't throw the data away".
|
||||
// An archived project disappears from every picker, switcher and search in the
|
||||
@@ -492,14 +177,14 @@ async function loadProjects(){
|
||||
const { status, json } = await api('GET','/api/projects?archived=all');
|
||||
if(status===403){
|
||||
banner.className='banner bad';
|
||||
banner.textContent='❌ Your account is not an admin, so you can’t archive or delete projects here.';
|
||||
banner.textContent='✕ Your account is not an admin, so you can’t archive or delete projects here.';
|
||||
wrap.innerHTML=''; return;
|
||||
}
|
||||
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)){
|
||||
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';
|
||||
_adminProjects = json;
|
||||
@@ -567,10 +252,12 @@ async function archiveProject(id, name, archived){
|
||||
'• Nothing is deleted. Unarchive here at any time to bring it back.'
|
||||
: 'Unarchive “'+name+'”?\n\n'+
|
||||
'It becomes visible in the pickers again and can be edited as normal.';
|
||||
if(!confirm(ask)) return;
|
||||
if(!(await wpConfirmDialog({title:(archived?'Archive':'Unarchive')+' project',
|
||||
message:ask, okLabel:archived?'Archive':'Unarchive'}))) return;
|
||||
const { status, json } = await api('POST','/api/projects/'+id+'/archive',{archived:!!archived});
|
||||
if(status===200) loadProjects();
|
||||
else alert('Could not '+(archived?'archive':'unarchive')+' '+name+': '+((json && json.detail)||('HTTP '+status)));
|
||||
else wpAlertDialog({title:(archived?'Archive':'Unarchive')+' failed',
|
||||
message:'Could not '+(archived?'archive':'unarchive')+' '+name+': '+((json && json.detail)||('HTTP '+status))});
|
||||
}
|
||||
|
||||
// Named deleteProjectAdmin, not deleteProject: every function in this file is a
|
||||
@@ -578,13 +265,16 @@ async function archiveProject(id, name, archived){
|
||||
// enough to collide with one of them later. The -Admin suffix also says which of the
|
||||
// two project deletions this is — the console's, not a project member's.
|
||||
async function deleteProjectAdmin(id, name){
|
||||
if(!confirm('DELETE “'+name+'” permanently?\n\n'+
|
||||
'Its SOP, EVERY work package on it and every access assignment are deleted with it '+
|
||||
'(database cascade). This cannot be undone.\n\n'+
|
||||
'If you only want it out of the way, cancel and use Archive instead.')) return;
|
||||
if(!(await wpConfirmDialog({title:'Delete project permanently',
|
||||
message:'DELETE “'+name+'” permanently?\n\n'+
|
||||
'Its SOP, EVERY work package on it and every access assignment are deleted with it '+
|
||||
'(database cascade). This cannot be undone.\n\n'+
|
||||
'If you only want it out of the way, cancel and use Archive instead.',
|
||||
okLabel:'Delete permanently'}))) return;
|
||||
const { status, json } = await api('DELETE','/api/projects/'+id);
|
||||
if(status===200) loadProjects();
|
||||
else alert('Could not delete '+name+': '+((json && json.detail)||('HTTP '+status)));
|
||||
else wpAlertDialog({title:'Delete failed',
|
||||
message:'Could not delete '+name+': '+((json && json.detail)||('HTTP '+status))});
|
||||
}
|
||||
|
||||
// ── default members on new projects ─────────────────────────────────────────────
|
||||
@@ -601,22 +291,22 @@ async function loadDefaultMembers(){
|
||||
const { status, json } = await api('GET','/api/auth/users');
|
||||
if(status===403){
|
||||
banner.className='banner bad';
|
||||
banner.textContent='❌ Your account is not an admin, so you can’t change who is added to new projects.';
|
||||
banner.textContent='✕ Your account is not an admin, so you can’t change who is added to new projects.';
|
||||
wrap.innerHTML=''; return;
|
||||
}
|
||||
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)){
|
||||
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';
|
||||
_defMemUsers = json;
|
||||
renderDefaultMembers();
|
||||
}
|
||||
|
||||
// Same idea as projRowToggled() — the role only matters while the tick is on — but
|
||||
// here the select sits in a sibling <td>, so the lookup is scoped to the row.
|
||||
// The role only matters while the tick is on. The select sits in a sibling <td>, so
|
||||
// the lookup is scoped to the row.
|
||||
function defMemToggled(cb){
|
||||
const row = cb.closest('tr');
|
||||
const sel = row && row.querySelector('select');
|
||||
@@ -634,8 +324,7 @@ function renderDefaultMembers(){
|
||||
const who = '<td><strong>'+uesc(u.username)+'</strong>'+
|
||||
(u.full_name ? ' <span class="note">'+uesc(u.full_name)+'</span>' : '')+'</td>'+
|
||||
'<td class="ell" title="'+uesc(u.email||'')+'"><span>'+uesc(u.email||'—')+'</span></td>';
|
||||
// Admins reach every project already, so there is nothing to add them to —
|
||||
// the same thing projAccessCell() says in the user table.
|
||||
// Admins reach every project already, so there is nothing to add them to.
|
||||
if(role === 'admin'){
|
||||
return '<tr>'+who+
|
||||
'<td><span class="tag admin">'+uesc(PERM_LABELS.admin)+'</span></td>'+
|
||||
@@ -645,8 +334,15 @@ function renderDefaultMembers(){
|
||||
}
|
||||
const on = !!u.auto_add_projects;
|
||||
const cur = u.auto_add_role || '';
|
||||
// Every project-scoped role is offered, super user included: this card is
|
||||
// admin-only, and "the QA lead runs the users on every new job" is exactly the
|
||||
// sort of standing rule it exists to express.
|
||||
const opts = ['<option value=""'+(cur===''?' selected':'')+'>Same as account ('+
|
||||
uesc(PERM_LABELS[role]||role)+')</option>']
|
||||
.concat(PROJECT_SCOPED_ROLES.map(r =>
|
||||
'<option value="'+r+'"'+(cur===r?' selected':'')+'>'+uesc(PERM_LABELS[r])+' here</option>'));
|
||||
return '<tr>'+who+
|
||||
'<td><span class="tag user">'+uesc(PERM_LABELS[role]||role)+'</span></td>'+
|
||||
'<td><span class="tag '+roleTagClass(role)+'">'+uesc(PERM_LABELS[role]||role)+'</span></td>'+
|
||||
'<td><label class="chk">'+
|
||||
'<input type="checkbox" id="defmem-cb-'+uesc(u.id)+'"'+(on?' checked':'')+
|
||||
' title="Add this user to every project created from now on"'+
|
||||
@@ -654,11 +350,7 @@ function renderDefaultMembers(){
|
||||
'</label></td>'+
|
||||
'<td><select class="role-select" id="defmem-role-'+uesc(u.id)+'"'+(on?'':' disabled')+
|
||||
' title="The role this user gets on those projects"'+
|
||||
' onchange="setAutoAdd(\''+uid+'\',\''+uname+'\')">'+
|
||||
'<option value=""'+(cur===''?' selected':'')+'>Same as account ('+uesc(PERM_LABELS[role]||role)+')</option>'+
|
||||
'<option value="project_admin"'+(cur==='project_admin'?' selected':'')+'>Project Admin here</option>'+
|
||||
'<option value="project_user"'+(cur==='project_user'?' selected':'')+'>Project User here</option>'+
|
||||
'</select></td>'+
|
||||
' onchange="setAutoAdd(\''+uid+'\',\''+uname+'\')">'+opts.join('')+'</select></td>'+
|
||||
'</tr>';
|
||||
}).join('');
|
||||
wrap.innerHTML =
|
||||
@@ -669,8 +361,8 @@ function renderDefaultMembers(){
|
||||
'<th title="Their role on those projects">Role on those projects</th>'+
|
||||
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
|
||||
'<div class="note">This only affects projects created <strong>from now on</strong> — existing projects '+
|
||||
'are untouched. Use <strong>Project access</strong> in the user table above to add someone to a project '+
|
||||
'that already exists.</div>';
|
||||
'are untouched. Use <strong>Project access</strong> on the <a class="home" href="users.html">User '+
|
||||
'Directory</a> to add someone to a project that already exists.</div>';
|
||||
}
|
||||
|
||||
// Saves on every tick and every dropdown change — there is no Save button, so a
|
||||
@@ -688,7 +380,8 @@ async function setAutoAdd(id, username){
|
||||
_defMemUsers = _defMemUsers.map(u => u.id===json.id ? json : u);
|
||||
renderDefaultMembers();
|
||||
} else {
|
||||
alert('Could not change the new-project default for '+username+': '+((json && json.detail)||('HTTP '+status)));
|
||||
wpAlertDialog({title:'Change failed',
|
||||
message:'Could not change the new-project default for '+username+': '+((json && json.detail)||('HTTP '+status))});
|
||||
loadDefaultMembers();
|
||||
}
|
||||
}
|
||||
@@ -875,7 +568,7 @@ async function saveLocalization(){
|
||||
const m = document.getElementById('l10n-msg');
|
||||
if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; }
|
||||
} else {
|
||||
msg.textContent = '❌ '+((json && json.detail) || ('HTTP '+status));
|
||||
msg.textContent = '✕ '+((json && json.detail) || ('HTTP '+status));
|
||||
msg.style.color = 'var(--red)';
|
||||
}
|
||||
}
|
||||
@@ -942,8 +635,8 @@ async function saveSettings(){
|
||||
async function testEmail(){
|
||||
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', {});
|
||||
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)'; }
|
||||
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)'; }
|
||||
}
|
||||
async function loadNotifications(){
|
||||
const box = document.getElementById('notif-box'); if(!box) return;
|
||||
@@ -964,39 +657,46 @@ async function loadNotifications(){
|
||||
}
|
||||
|
||||
// ── usage logs (read from this browser's localStorage) ──────────────────────────
|
||||
const USAGE_KEY = 'wp_suite_analytics_v1';
|
||||
function usageLoad(){ try { return JSON.parse(localStorage.getItem(USAGE_KEY)) || {events:[]}; } catch(e){ return {events:[]}; } }
|
||||
// D5 / T7.10: the report for BOTH tools' recorded usage, in the one place an
|
||||
// operator-facing readout belongs - behind the same admin gate as this whole
|
||||
// page (gateByRole() below shows nothing else either). Data comes from
|
||||
// wp-usage.js, the single implementation; the keys predate the move, so
|
||||
// everything recorded before it is still here.
|
||||
function loadUsage(){
|
||||
const box = document.getElementById('usage-admin');
|
||||
const evs = (usageLoad().events) || [];
|
||||
if(!evs.length){ box.innerHTML = '<div class="note">No usage recorded in this browser yet.</div>'; return; }
|
||||
const byEvent = {}, byStep = {}, sessions = new Set();
|
||||
let first = evs[0].ts, last = evs[0].ts;
|
||||
evs.forEach(e => {
|
||||
byEvent[e.event] = (byEvent[e.event]||0)+1;
|
||||
if(e.session) sessions.add(e.session);
|
||||
if(e.event==='step_view' && e.detail) byStep[e.detail.step] = (byStep[e.detail.step]||0)+1;
|
||||
if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts;
|
||||
if(!box) return;
|
||||
const tools = [
|
||||
['Work package creator', WPUsage.KEYS.creator, 'wp-iwp-usage'],
|
||||
['SOP wizard', WPUsage.KEYS.wizard, 'wp-suite-usage'],
|
||||
];
|
||||
let html = '';
|
||||
tools.forEach(([label, key, prefix]) => {
|
||||
const evs = (WPUsage.load(key).events) || [];
|
||||
html += '<h2 style="margin-top:16px">' + uesc(label) + '</h2>';
|
||||
if(!evs.length){
|
||||
html += '<div class="note">No usage recorded in this browser yet.</div>';
|
||||
return;
|
||||
}
|
||||
const byEvent = {}, sessions = new Set();
|
||||
let first = evs[0].ts, last = evs[0].ts;
|
||||
evs.forEach(e => {
|
||||
byEvent[e.event] = (byEvent[e.event]||0)+1;
|
||||
if(e.session) sessions.add(e.session);
|
||||
if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts;
|
||||
});
|
||||
const fmt = v => v ? wpFormatDateTime(v) : '—';
|
||||
html += '<table class="kv">'+
|
||||
'<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+
|
||||
'<tr><th>Events</th><td>'+evs.length+'</td></tr>'+
|
||||
'<tr><th>Range</th><td style="font-weight:600">'+fmt(first)+' → '+fmt(last)+'</td></tr></table>';
|
||||
html += '<table class="users"><thead><tr><th>Event</th><th>Count</th></tr></thead><tbody>';
|
||||
Object.keys(byEvent).sort().forEach(k => html += '<tr><td>'+uesc(k)+'</td><td>'+byEvent[k]+'</td></tr>');
|
||||
html += '</tbody></table>';
|
||||
html += '<div class="toolbar" style="margin-top:8px"><button onclick="WPUsage.download(WPUsage.KEYS.'+
|
||||
(key === WPUsage.KEYS.creator ? 'creator' : 'wizard')+', ' + jsq(prefix) + ')">Download the full event log</button></div>';
|
||||
});
|
||||
const fmt = s => s ? wpFormatDateTime(s) : '—';
|
||||
let html = '<table class="kv">'+
|
||||
'<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+
|
||||
'<tr><th>Events</th><td>'+evs.length+'</td></tr>'+
|
||||
'<tr><th>Range</th><td style="font-weight:600">'+fmt(first)+' → '+fmt(last)+'</td></tr></table>';
|
||||
html += '<h2 style="margin-top:16px">Step views</h2><table class="users"><thead><tr><th>Step</th><th>Views</th></tr></thead><tbody>';
|
||||
for(let i=1;i<=10;i++) html += '<tr><td>Step '+i+'</td><td>'+(byStep[i]||0)+'</td></tr>';
|
||||
html += '</tbody></table>';
|
||||
html += '<h2 style="margin-top:16px">Actions</h2><table class="users"><thead><tr><th>Event</th><th>Count</th></tr></thead><tbody>';
|
||||
Object.keys(byEvent).sort().forEach(k => html += '<tr><td>'+uesc(k)+'</td><td>'+byEvent[k]+'</td></tr>');
|
||||
html += '</tbody></table>';
|
||||
box.innerHTML = html;
|
||||
}
|
||||
function downloadUsage(){
|
||||
const blob = new Blob([JSON.stringify(usageLoad(),null,2)], {type:'application/json'});
|
||||
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
||||
a.download = 'wp-suite-usage-' + new Date().toISOString().slice(0,10) + '.json';
|
||||
a.click(); setTimeout(()=>URL.revokeObjectURL(a.href), 1000);
|
||||
}
|
||||
|
||||
// ── access control: admins only ─────────────────────────────────────────────────
|
||||
// auth-guard.js requires a login and sets window.WP_USER (firing 'wp-auth-ready').
|
||||
|
||||
@@ -12,11 +12,12 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
|
||||
|
||||
// Register the PWA service worker (caches the app shell for offline use). Only
|
||||
// from the top window; the API and writes are never cached (see sw.js).
|
||||
if (!inIframe && 'serviceWorker' in navigator) {
|
||||
// Register the PWA service worker (caches the app shell for offline use). The
|
||||
// API and writes are never cached (see sw.js). This used to be skipped inside
|
||||
// an iframe so the embedded creator did not register a second time; B7/T7.1
|
||||
// dissolved that frame and there is no longer a document in the app that is
|
||||
// not the top one.
|
||||
if ('serviceWorker' in navigator) {
|
||||
try { navigator.serviceWorker.register('/sw.js'); } catch (e) {}
|
||||
}
|
||||
|
||||
@@ -34,9 +35,10 @@
|
||||
clearTimeout(safety);
|
||||
var next = encodeURIComponent(location.pathname + location.search);
|
||||
var url = 'login.html?next=' + next;
|
||||
// If we're inside the WP-creator iframe, redirect the whole window.
|
||||
var w = inIframe ? window.top : window;
|
||||
try { w.location.replace(url); } catch (e) { window.location.replace(url); }
|
||||
// Was `inIframe ? window.top : window`, so an expired session inside the
|
||||
// embedded creator replaced the whole window rather than painting a login
|
||||
// page into a frame. No frame, no branch (B7/T7.1).
|
||||
window.location.replace(url);
|
||||
}
|
||||
|
||||
window.wpLogout = function () {
|
||||
@@ -66,11 +68,11 @@
|
||||
ov.id = 'wp-pw-modal';
|
||||
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;';
|
||||
var inp = 'width:100%;padding:9px 10px;margin-bottom:12px;border:1px solid #8d8d8d;border-radius:4px;font-size:14px;';
|
||||
var lbl = 'display:block;font-size:12px;color:#525252;margin-bottom:4px;';
|
||||
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:var(--cds-text-secondary);margin-bottom:4px;';
|
||||
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="padding:14px 18px;border-bottom:1px solid #e0e0e0;font-weight:700;">Change password</div>' +
|
||||
'<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 var(--cds-border-subtle);font-weight:700;">Change password</div>' +
|
||||
'<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>' +
|
||||
'<label style="' + lbl + '">Current password</label>' +
|
||||
@@ -80,16 +82,16 @@
|
||||
'<label style="' + lbl + '">Confirm new password</label>' +
|
||||
'<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' +
|
||||
'</div>' +
|
||||
'<div style="padding:12px 18px;border-top:1px solid #e0e0e0;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-save" style="padding:8px 14px;border:none;background:#0f62fe;color:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Update password</button>' +
|
||||
'<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 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:var(--cds-interactive-01);color:var(--cds-text-on-color);border-radius:6px;cursor:pointer;font-weight:600;">Update password</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
function close() { var m = document.getElementById('wp-pw-modal'); if (m) m.remove(); }
|
||||
function msg(text, ok) {
|
||||
var el = document.getElementById('wp-pw-msg');
|
||||
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(); });
|
||||
document.body.appendChild(ov);
|
||||
@@ -124,14 +126,24 @@
|
||||
return r === 'user' ? 'project_user' : r;
|
||||
};
|
||||
window.wpIsAdmin = function () { return window.wpRole() === 'admin'; };
|
||||
// A Project Super User is a Project Admin with user administration on top, so it
|
||||
// counts here too (server: auth.is_project_admin).
|
||||
window.wpIsProjectAdmin = function () {
|
||||
var r = window.wpRole();
|
||||
return r === 'admin' || r === 'project_admin';
|
||||
return r === 'admin' || r === 'project_super_user' || r === 'project_admin';
|
||||
};
|
||||
// Deleting a work package, deleting a project, and editing a completed SOP are
|
||||
// all Project Admin actions (see server require_project_admin).
|
||||
window.wpCanDeleteWP = window.wpIsProjectAdmin;
|
||||
window.wpCanEditCompletedSOP = window.wpIsProjectAdmin;
|
||||
// Whether this account can administer USER accounts. The account role is only half
|
||||
// the answer — the role can also be held on a single project — so anything that
|
||||
// needs the real verdict asks GET /api/auth/user-scope (users.js does). This is the
|
||||
// cheap hint used to decide whether to bother offering a control.
|
||||
window.wpMayManageUsers = function () {
|
||||
var r = window.wpRole();
|
||||
return r === 'admin' || r === 'project_super_user';
|
||||
};
|
||||
|
||||
// ── app feature flags ──────────────────────────────────────────────────────
|
||||
// Cached per page load. Pages that must know before rendering should await
|
||||
@@ -155,74 +167,14 @@
|
||||
// stale cache errs toward hiding the unfinished tooling rather than showing it.
|
||||
window.wpBimEnabled = function () { return !!(window.WP_FLAGS && window.WP_FLAGS.bim_enabled); };
|
||||
|
||||
function isDarkBg(el) {
|
||||
try {
|
||||
var m = (getComputedStyle(el).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (!m) return true;
|
||||
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
|
||||
} catch (e) { return true; }
|
||||
}
|
||||
|
||||
// The user menu (name · Admin · Password · Sign out). Text colors adapt to the
|
||||
// bar it sits in (light links on a dark bar, blue links on a light bar).
|
||||
function buildUserMenu(user, dark) {
|
||||
var wrap = document.createElement('div');
|
||||
wrap.id = 'wp-usermenu';
|
||||
var linkColor = dark ? '#ffffff' : '#0f62fe';
|
||||
wrap.style.cssText = 'display:flex;align-items:center;gap:8px;margin-left:auto;padding-left:14px;white-space:nowrap;' +
|
||||
'font:400 13px/1.2 "IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;' +
|
||||
'color:' + (dark ? '#c6c6c6' : '#525252') + ';';
|
||||
function sep() { var s = document.createElement('span'); s.textContent = '·'; s.style.color = dark ? '#6f6f6f' : '#a8a8a8'; return s; }
|
||||
function link(text, onClick, href) {
|
||||
var a = document.createElement('a'); a.textContent = text; a.href = href || '#';
|
||||
a.style.cssText = 'color:' + linkColor + ';text-decoration:none;font-weight:600;';
|
||||
if (onClick) a.addEventListener('click', function (e) { e.preventDefault(); onClick(); });
|
||||
return a;
|
||||
}
|
||||
var who = document.createElement('span');
|
||||
who.textContent = user.full_name || user.username;
|
||||
who.style.color = dark ? '#ffffff' : '#161616';
|
||||
wrap.appendChild(who);
|
||||
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
|
||||
if (window.wpIsAdmin() && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); }
|
||||
// Always offered; wp-format.js may still be parsing when the menu is built, so
|
||||
// the check happens at click time rather than once, up front.
|
||||
wrap.appendChild(sep());
|
||||
wrap.appendChild(link('Language & time', function () {
|
||||
if (typeof window.wpPreferences === 'function') window.wpPreferences();
|
||||
}));
|
||||
wrap.appendChild(sep()); wrap.appendChild(link('Password', function () { window.wpChangePassword(); }));
|
||||
wrap.appendChild(sep()); wrap.appendChild(link('Sign out', function () { window.wpLogout(); }));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function addLogoutPill(user) {
|
||||
if (inIframe) return; // the parent page already shows it
|
||||
if (document.getElementById('wp-usermenu') || document.getElementById('wp-logout-pill')) return;
|
||||
|
||||
// Preferred: drop the menu INTO the top bar so it never floats over the
|
||||
// header's own links (Help, etc.). Works with the dark UI-shell appbar and
|
||||
// the older .header bars alike.
|
||||
var host = document.querySelector('.wp-appbar') || document.querySelector('.header');
|
||||
if (host) {
|
||||
var menu = buildUserMenu(user, isDarkBg(host));
|
||||
// The older .header bars already right-align their own toolbar (via flex:1
|
||||
// or a button's margin-left:auto). A second auto-margin would split the free
|
||||
// space, so only the .wp-appbar (which may have no spacer, e.g. admin) keeps it.
|
||||
if (!host.classList.contains('wp-appbar')) menu.style.marginLeft = '0';
|
||||
host.appendChild(menu);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback for any page with no header bar: a floating pill (as before).
|
||||
var pill = document.createElement('div');
|
||||
pill.id = 'wp-logout-pill';
|
||||
pill.style.cssText = 'position:fixed;top:12px;right:12px;z-index:10001;' +
|
||||
'display:flex;align-items:center;background:#fff;border:1px solid #e0e0e0;' +
|
||||
'box-shadow:0 1px 4px rgba(0,0,0,.16);border-radius:16px;padding:5px 12px;';
|
||||
pill.appendChild(buildUserMenu(user, false));
|
||||
document.body.appendChild(pill);
|
||||
}
|
||||
// The flat user menu that used to sit in this bar is gone (T2.2). It duplicated
|
||||
// Admin, Users and Sign out from the navigation drawer, and being one unbreakable
|
||||
// 412px run with an inline white-space:nowrap, it was what clipped the bar at 390px
|
||||
// and cut "Sign out" in half — F2. wp-sidenav.js now carries all of it, including
|
||||
// the two items that were only here: Language & time, and Password.
|
||||
//
|
||||
// Nothing replaces it. Every signed-in page mounts the drawer, so there is no page
|
||||
// left that would need a floating fallback pill.
|
||||
|
||||
function proceed(user) {
|
||||
clearTimeout(safety);
|
||||
@@ -231,8 +183,6 @@
|
||||
if (window.WP_USER) {
|
||||
window.wpFlags(); // start the feature-flag fetch; pages await it as needed
|
||||
try { document.dispatchEvent(new CustomEvent('wp-auth-ready', { detail: window.WP_USER })); } catch (e) {}
|
||||
if (document.body) addLogoutPill(window.WP_USER);
|
||||
else document.addEventListener('DOMContentLoaded', function () { addLogoutPill(window.WP_USER); });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
140
html/console-util.js
Normal file
@@ -0,0 +1,140 @@
|
||||
/* Shared helpers for the suite's admin pages (Admin Console, User Directory).
|
||||
|
||||
These used to live in admin.js. They are here because the User Directory needs
|
||||
the same escaping and the same role vocabulary, and a second copy of either is a
|
||||
liability: a divergent jsq() is an XSS, and a divergent role list quietly offers
|
||||
a permission the server will refuse.
|
||||
|
||||
Loaded as plain globals (no modules) to match the rest of the suite. */
|
||||
|
||||
// ── api ──────────────────────────────────────────────────────────────────────
|
||||
// Never throws: returns {status, json} with status 0 when the request itself
|
||||
// failed, so every caller can branch on one shape.
|
||||
async function api(method, path, body){
|
||||
const opt = { method, headers:{ 'Accept':'application/json' } };
|
||||
if(body !== undefined){ opt.headers['Content-Type']='application/json'; opt.body=JSON.stringify(body); }
|
||||
try {
|
||||
const r = await fetch(path, opt);
|
||||
const t = await r.text();
|
||||
let json; try { json = t ? JSON.parse(t) : null; } catch(_){ json = t; }
|
||||
return { status:r.status, json };
|
||||
} catch(e){ return { status:0, json:String(e) }; }
|
||||
}
|
||||
|
||||
// The message to show for a failed call, preferring the server's own words.
|
||||
function apiError(status, json, fallback){
|
||||
if(json && json.detail) return json.detail;
|
||||
if(status === 0) return 'Could not reach the server.';
|
||||
if(status === 401) return 'Not signed in. Reload and log in again.';
|
||||
return (fallback || 'Request failed') + ' (HTTP ' + status + ').';
|
||||
}
|
||||
|
||||
// ── escaping ─────────────────────────────────────────────────────────────────
|
||||
function uesc(v){ return v==null ? '' : String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
// A value bound into an inline handler — onclick="fn('…')" — is escaped TWICE: once
|
||||
// for the JS string literal it lands in, and again for the HTML attribute carrying
|
||||
// it. The order is the whole point. Escape the backslashes FIRST, then the quotes,
|
||||
// then hand the result to uesc: uesc leaves \ and ' alone, so the JS escaping
|
||||
// survives, and the browser decodes the entities before the JS parser runs.
|
||||
//
|
||||
// Doing it the other way round — uesc(v).replace(/'/g,"\\'") — silently fails on a
|
||||
// value containing a backslash: the \ we add is itself escaped by the stored one,
|
||||
// the quote closes the literal, and everything after it runs as code. Project names,
|
||||
// full names and usernames are free text that a signed-in user can write, so that is
|
||||
// a real path from a project_user to whatever an admin's session can do. Use jsq()
|
||||
// for EVERY value that lands inside an inline handler.
|
||||
function jsq(v){
|
||||
return uesc(String(v==null ? '' : v).replace(/\\/g,'\\\\').replace(/'/g,"\\'"));
|
||||
}
|
||||
|
||||
// ── role vocabulary (mirrors server/auth.py) ─────────────────────────────────
|
||||
// Permissions roles: what an account may DO. Ordered most- to least-privileged,
|
||||
// same as auth.ROLES, because that is the order the dropdowns render in.
|
||||
const PERM_ROLES = ['admin','project_super_user','project_admin','project_user'];
|
||||
const PERM_LABELS = {
|
||||
admin:'Administrator',
|
||||
project_super_user:'Project Super User',
|
||||
project_admin:'Project Admin',
|
||||
project_user:'Project User',
|
||||
};
|
||||
// One-line description of each, used in the legends and dropdown titles.
|
||||
const PERM_HELP = {
|
||||
admin:'Manages users, app settings and every project.',
|
||||
project_super_user:'On their assigned projects: everything a Project Admin can do, '+
|
||||
'plus creating and managing that project\'s user accounts.',
|
||||
project_admin:'On their assigned projects: may delete work packages, change a completed SOP, '+
|
||||
'and delete the project.',
|
||||
project_user:'Creates and edits work packages and authors the SOP, but cannot delete WPs '+
|
||||
'or change the SOP once it is complete.',
|
||||
};
|
||||
// Roles that can be held on a SINGLE project (ProjectMember.role); '' inherits the
|
||||
// account's own. 'admin' is app-wide by definition and never appears here.
|
||||
const PROJECT_SCOPED_ROLES = ['project_super_user','project_admin','project_user'];
|
||||
// Job functions on a project. Descriptive only — no permissions attached.
|
||||
const PROJECT_ROLES = ['Project Manager','Assistant Project Manager','Construction Manager',
|
||||
'Quality Manager','Superintendent','General Foreman','Foreman','Planner / Scheduler',
|
||||
'BIM / VDC Coordinator','Engineer','Safety (HSE)','Warehouse / Materials','Commissioning',
|
||||
'Field Technician'];
|
||||
|
||||
// Accounts created before permissions roles existed carry the legacy value 'user'.
|
||||
function normRole(r){ return r==='user' ? 'project_user' : (PERM_ROLES.indexOf(r)>=0 ? r : 'project_user'); }
|
||||
function roleLabel(r){ const n = normRole(r); return PERM_LABELS[n] || n; }
|
||||
// Which pill a role wears. Admin and super user each get their own colour because
|
||||
// "can reach every project" and "can create users here" are the two facts you scan
|
||||
// this column for.
|
||||
function roleTagClass(r){
|
||||
const n = normRole(r);
|
||||
return n==='admin' ? 'admin' : n==='project_super_user' ? 'super' : 'user';
|
||||
}
|
||||
|
||||
// ── announcements (S10 / T4.5) ───────────────────────────────────────────────
|
||||
// Every banner on these pages announces. They did not: the app had zero aria-live
|
||||
// regions, and login.html's role="alert" / role="status" pair was the only correct
|
||||
// example in the codebase. This is that pattern, applied where the banners are.
|
||||
//
|
||||
// Done with an observer rather than by editing thirteen assignment sites in
|
||||
// admin.js, for two reasons. Those sites set className and textContent together
|
||||
// and would each need the same two extra lines, which is thirteen chances to get
|
||||
// it wrong; and any banner added later would silently miss out. The rule lives in
|
||||
// one place instead: a banner that carries `.bad` interrupts, and everything else
|
||||
// waits its turn.
|
||||
(function () {
|
||||
'use strict';
|
||||
function politeness(el) {
|
||||
// .bad is an error the user has to act on, so it interrupts (assertive).
|
||||
// Success and progress do not: announcing "loading" over the top of whatever
|
||||
// someone was reading is how a screen reader becomes unusable.
|
||||
return /\bbad\b/.test(el.className || '') ? 'alert' : 'status';
|
||||
}
|
||||
function mark(el) {
|
||||
if (!el) return;
|
||||
var want = politeness(el);
|
||||
if (el.getAttribute('role') !== want) el.setAttribute('role', want);
|
||||
}
|
||||
function markAll(root) {
|
||||
var sel = '.banner, [id$="-banner"], .secwarn, .gate-msg';
|
||||
try {
|
||||
(root || document).querySelectorAll(sel).forEach(mark);
|
||||
} catch (e) {}
|
||||
}
|
||||
function start() {
|
||||
markAll(document);
|
||||
try {
|
||||
new MutationObserver(function (muts) {
|
||||
muts.forEach(function (m) {
|
||||
if (m.type === 'attributes') mark(m.target);
|
||||
(m.addedNodes || []).forEach(function (n) {
|
||||
if (n.nodeType !== 1) return;
|
||||
mark(n);
|
||||
markAll(n);
|
||||
});
|
||||
});
|
||||
}).observe(document.documentElement, {
|
||||
subtree: true, childList: true, attributes: true, attributeFilter: ['class'],
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
|
||||
else start();
|
||||
})();
|
||||
221
html/console.css
Normal file
@@ -0,0 +1,221 @@
|
||||
/* Shared styling for the suite's dense admin pages — the Admin Console and the
|
||||
User Directory. Both are mostly tables and toolbars, which is a different job
|
||||
from the wizard pages, so they carry this sheet instead of theme-light.css's
|
||||
form-heavy one. The palette, the square corners and the type are still Carbon's,
|
||||
so the pages read as one product with the rest of the suite.
|
||||
|
||||
Two scales do all the spacing and all the control sizing; nothing that uses this
|
||||
sheet should invent its own. Page-specific rules (per-ID scroll boxes, column
|
||||
exceptions) stay in the page that owns them.
|
||||
|
||||
══ TOKENS ══════════════════════════════════════════════════════════════════
|
||||
Names only. Every value comes from theme-light.css, which every page loads
|
||||
first — this sheet declares nothing of its own (T3.2 / S5 / C3). The names
|
||||
stay because admin.js and users.js read 21 of them from JavaScript, where a
|
||||
rename fails silently: no build error, no console warning, just an unstyled
|
||||
element. See docs/reference/tokens.md section 9. */
|
||||
:root{ --bg:var(--cds-background); --surface:var(--cds-layer); --border:var(--cds-border-subtle);
|
||||
--border-strong:var(--cds-border-strong); --text:var(--cds-text-primary);
|
||||
--muted:var(--cds-text-secondary); --dim:var(--cds-text-helper); --accent:var(--cds-interactive-01);
|
||||
--accent-hover:var(--cds-hover-primary); --accent-soft:var(--cds-highlight);
|
||||
--green:var(--cds-support-success); --green-bg:var(--wp-status-success-bg);
|
||||
--red:var(--cds-support-error); --red-bg:var(--wp-status-error-bg);
|
||||
--amber:var(--wp-status-warning-text); --amber-bg:var(--wp-status-warning-bg);
|
||||
--head-bg:var(--cds-layer-accent);
|
||||
/* --zebra is six points lighter than --head-bg, not equal to it: collapsing
|
||||
the two erases the striping on a nine-column table, which is the one place
|
||||
striping is load-bearing. --row-hover is deliberately NOT --accent-soft;
|
||||
see the note above table.grid tbody tr:hover. Both are one-offs and both
|
||||
are named in docs/reference/tokens.md section 8-A/8-B. */
|
||||
--zebra:var(--wp-table-zebra); --row-hover:var(--wp-table-row-hover);
|
||||
/* The narrow mono stack, kept as-is: widening it changes the rendered face on
|
||||
any machine without IBM Plex Mono installed, and this sheet's two pages are
|
||||
the ones a font change would show up on. T3.5 picks one stack (8-H). */
|
||||
--mono:var(--wp-font-mono-2);
|
||||
--s1:var(--wp-s1); --s2:var(--wp-s2); --s3:var(--wp-s3);
|
||||
--s4:var(--wp-s4); --s5:var(--wp-s5); --s6:var(--wp-s6);
|
||||
--ctl:var(--wp-ctl); /* every button / input / select that sits in a form row */
|
||||
--ctl-sm:var(--wp-ctl-sm); } /* every control that sits inside a table cell */
|
||||
*{ box-sizing:border-box; }
|
||||
body{ margin:0; font-family:var(--wp-font-sans-2); background:var(--bg); color:var(--text); }
|
||||
|
||||
/* ══ PAGE ══════════════════════════════════════════════════════════════════════
|
||||
1240px, not 860: the user table is nine columns wide and at 860 it spilled
|
||||
straight out of its own white card. Wide enough for that table, still a
|
||||
readable measure for the prose, which is capped separately. */
|
||||
.wrap{ max-width:1240px; margin:0 auto; padding:var(--s6) var(--s5) 80px; }
|
||||
h1{ font-size:20px; line-height:1.2; margin:0 0 2px; }
|
||||
.sub{ color:var(--muted); font-size:13px; line-height:1.5; margin:0 0 var(--s3); max-width:96ch; }
|
||||
a.home{ color:var(--accent); font-size:13px; text-decoration:none; white-space:nowrap; }
|
||||
a.home:hover{ text-decoration:underline; }
|
||||
|
||||
/* ══ CARDS ═════════════════════════════════════════════════════════════════════ */
|
||||
.card{ background:var(--surface); border:1px solid var(--border); border-radius:0;
|
||||
padding:var(--s4) var(--s5) var(--s5); margin-bottom:var(--s4); }
|
||||
/* One card header everywhere: small uppercase accent label on a hairline. The
|
||||
scripts also emit h2 for sub-sections inside a card with an inline margin-top —
|
||||
the same treatment reads correctly as a divider there, so both get it. */
|
||||
.card h2{ font-size:12px; font-weight:600; letter-spacing:.08em; text-transform:uppercase;
|
||||
color:var(--accent); margin:0 0 var(--s3); padding-bottom:var(--s2); border-bottom:1px solid var(--border); }
|
||||
.wrap code{ font-family:var(--mono); font-size:.92em; background:var(--bg); padding:1px 4px; }
|
||||
|
||||
/* ══ CONTROLS ══════════════════════════════════════════════════════════════════
|
||||
Every button, input and select in a form row is exactly --ctl tall, so a
|
||||
toolbar is one clean band instead of a ragged one. */
|
||||
button{ font:inherit; font-size:13px; font-weight:600; line-height:1; white-space:nowrap;
|
||||
height:var(--ctl); padding:0 var(--s3); border-radius:0; cursor:pointer;
|
||||
border:1px solid var(--wp-btn-secondary-border); background:var(--wp-btn-secondary-bg); color:var(--wp-btn-secondary-fg); }
|
||||
button:hover{ border-color:var(--wp-btn-secondary-hover-fg); color:var(--wp-btn-secondary-hover-fg); }
|
||||
/* S12: this was outline-offset:-3px — an INSET accent ring, which on button.primary
|
||||
is a blue ring drawn inside a blue button and measures 1.00:1. Outside now, where
|
||||
it lands on the card behind the toolbar. The filled variants are handled by the
|
||||
rule in theme-light.css, which offsets further to clear their own fill. */
|
||||
button:focus-visible{ outline:2px solid var(--accent); outline-offset:1px; }
|
||||
button:disabled, button:disabled:hover{ color:var(--cds-text-disabled); border-color:var(--border); background:var(--surface); cursor:default; }
|
||||
button.primary{ background:var(--wp-btn-primary-bg); border-color:var(--wp-btn-primary-bg); color:var(--wp-btn-primary-fg); }
|
||||
button.primary:hover{ background:var(--wp-btn-primary-hover); border-color:var(--wp-btn-primary-hover); color:var(--wp-btn-primary-fg); }
|
||||
button.danger{ border-color:var(--wp-btn-danger-border); color:var(--wp-btn-danger-fg); }
|
||||
button.danger:hover{ background:var(--wp-btn-danger-soft-bg); border-color:var(--wp-btn-danger-border); color:var(--wp-btn-danger-fg); }
|
||||
.row{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; }
|
||||
/* The filter / search / button strip at the top of a card. */
|
||||
.toolbar{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; margin:0 0 var(--s3); }
|
||||
.toolbar + .banner{ margin-top:0; }
|
||||
.urow{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; }
|
||||
/* Checkboxes are excluded: they are drawn by the platform and want none of a
|
||||
text field's height, padding or border. */
|
||||
.toolbar input:not([type=checkbox]), .toolbar select,
|
||||
.urow input:not([type=checkbox]), .urow select{
|
||||
height:var(--ctl); padding:0 var(--s2); font:inherit; font-size:13px; line-height:normal;
|
||||
border:1px solid var(--border-strong); border-radius:0; background:var(--surface); color:var(--text); }
|
||||
.toolbar select, .urow select{ cursor:pointer; padding-right:var(--s1); }
|
||||
.toolbar input:focus-visible, .toolbar select:focus-visible,
|
||||
.urow input:focus-visible, .urow select:focus-visible{ outline:2px solid var(--accent); outline-offset:-2px; }
|
||||
.toolbar > input{ flex:1 1 240px; min-width:150px; }
|
||||
.urow input:not([type=checkbox]){ flex:1 1 140px; min-width:0; }
|
||||
/* Inline checkbox + label, sized to sit on the same line as the buttons. */
|
||||
.chk{ display:inline-flex; align-items:center; gap:var(--s2); height:var(--ctl); padding:0 var(--s1);
|
||||
font-size:13px; color:var(--muted); white-space:nowrap; cursor:pointer; }
|
||||
.chk input{ width:16px; height:16px; margin:0; accent-color:var(--accent); cursor:pointer; }
|
||||
|
||||
/* ══ FEEDBACK: banners, notes, console output, key/value ════════════════════════ */
|
||||
.banner{ margin:var(--s3) 0 0; padding:9px var(--s3); border-radius:0; font-size:13px; font-weight:600;
|
||||
line-height:1.4; border:1px solid var(--border); border-left:3px solid var(--border-strong);
|
||||
background:var(--surface); color:var(--text); }
|
||||
.banner.ok{ background:var(--green-bg); color:var(--green); border-color:var(--wp-status-success-border-a); border-left-color:var(--green); }
|
||||
.banner.bad{ background:var(--red-bg); color:var(--red); border-color:var(--wp-status-error-border-a); border-left-color:var(--red); }
|
||||
.banner.warn{ background:var(--amber-bg); color:var(--amber); border-color:var(--wp-status-warning-border-a); border-left-color:var(--amber); }
|
||||
/* This used to read --muted rather than --dim, because --dim was #8d8d8d and
|
||||
measured 3.3:1 on white — under the floor at 12px. S11 (T4.6) fixed --dim at the
|
||||
source instead, so the local override is gone and helper text on these pages is
|
||||
the same colour as helper text everywhere else. Re-measured: 5.02:1 on white,
|
||||
4.81:1 on the zebra stripe, 4.57:1 on a shaded card. */
|
||||
.note{ font-size:12px; line-height:1.55; color:var(--dim); margin-top:var(--s2); }
|
||||
.note strong, .note em{ color:var(--text); }
|
||||
pre.out{ background:var(--wp-term-bg); color:var(--wp-term-fg); border-radius:0; padding:var(--s3) var(--s4); font-family:var(--mono);
|
||||
font-size:12px; line-height:1.55; white-space:pre-wrap; max-height:340px; overflow:auto; margin:var(--s3) 0 0; }
|
||||
pre.out .p{ color:var(--wp-term-pass); font-weight:700; } pre.out .f{ color:var(--wp-term-fail); font-weight:700; }
|
||||
table.kv{ border-collapse:collapse; font-size:13px; margin-top:var(--s2); }
|
||||
table.kv th{ text-align:left; padding:var(--s1) var(--s5) var(--s1) 0; color:var(--muted); font-weight:600; white-space:nowrap; }
|
||||
table.kv td{ padding:var(--s1) 0; font-variant-numeric:tabular-nums; font-weight:700; color:var(--text); }
|
||||
|
||||
/* ══ DATA TABLES ═══════════════════════════════════════════════════════════════
|
||||
table.users is the name admin.js already emits; table.grid is the same object
|
||||
under the shared name. One rule set serves both, so existing markup picks up the
|
||||
dense styling without being rewritten. border-collapse is separate rather than
|
||||
collapse because a collapsed border does not travel with a sticky header. */
|
||||
table.grid, table.users{ width:100%; border-collapse:separate; border-spacing:0;
|
||||
font-size:13px; color:var(--text); background:var(--surface); }
|
||||
table.grid th, table.users th{ position:sticky; top:0; z-index:2; background:var(--head-bg);
|
||||
text-align:left; padding:var(--s2) var(--s3); white-space:nowrap;
|
||||
font-size:11px; font-weight:600; letter-spacing:.04em; text-transform:uppercase; color:var(--muted);
|
||||
box-shadow:inset 0 -1px 0 var(--border); }
|
||||
/* Cells never wrap: a wrapped cell turns one user into a 100px tall band and the
|
||||
table stops reading as rows. Anything genuinely long truncates (.ell) or is
|
||||
exempted by name in the page that owns the table. */
|
||||
table.grid td, table.users td{ padding:var(--s1) var(--s3); border-bottom:1px solid var(--border);
|
||||
vertical-align:middle; white-space:nowrap; }
|
||||
table.grid tbody tr:last-child td, table.users tbody tr:last-child td{ border-bottom:none; }
|
||||
table.grid tbody tr:nth-child(even) td, table.users tbody tr:nth-child(even) td{ background:var(--zebra); }
|
||||
/* A neutral hover, not --accent-soft: that is .tag.admin's fill, and an "all
|
||||
projects" pill sitting on its own colour disappears the moment you hover it. */
|
||||
table.grid tbody tr:hover td, table.users tbody tr:hover td{ background:var(--row-hover); }
|
||||
/* A row for an account this caller may see but not change. Dimmed as a whole so
|
||||
the disabled controls aren't the only clue. */
|
||||
table.grid tbody tr.is-locked td, table.users tbody tr.is-locked td{ color:var(--muted); }
|
||||
/* Truncation has to hang off a block INSIDE the cell. max-width on a <td> is
|
||||
advisory under table-layout:auto — the cell just grows to fit and the ellipsis
|
||||
never appears, which is the usual reason this trick looks like it works in the
|
||||
stylesheet and doesn't on the page. The scripts emit <td class="ell"><span>. */
|
||||
.ell{ max-width:240px; }
|
||||
.ell > span{ display:block; max-width:240px; overflow:hidden; text-overflow:ellipsis;
|
||||
white-space:nowrap; }
|
||||
/* Every action cell the scripts render is a .cellactions, and it must not wrap:
|
||||
unwrapped, the three buttons stack and the row grows fourfold. */
|
||||
.cellactions{ display:flex; flex-wrap:nowrap; align-items:center; gap:var(--s1); white-space:nowrap; }
|
||||
/* Controls that live in a cell are one step smaller, which is what keeps a row at
|
||||
~34px instead of ~100px. .chk is form-row sized by default, so it needs saying
|
||||
again here or checkbox rows stand 6px taller than the rest. */
|
||||
button.mini{ height:var(--ctl-sm); padding:0 var(--s2); font-size:12px; }
|
||||
table.grid td .chk, table.users td .chk{ height:var(--ctl-sm); }
|
||||
select.role-select{ height:var(--ctl-sm); max-width:170px; padding:0 var(--s1) 0 var(--s2);
|
||||
font:inherit; font-size:12px; border:1px solid var(--border-strong); border-radius:0;
|
||||
background:var(--surface); color:var(--text); cursor:pointer; }
|
||||
select.role-select:hover{ border-color:var(--accent); }
|
||||
select.role-select.is-admin{ color:var(--accent); border-color:var(--accent); font-weight:600; }
|
||||
select.role-select:disabled{ color:var(--cds-text-disabled); border-color:var(--border); background:var(--bg); cursor:default; }
|
||||
.tag{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600;
|
||||
line-height:1.55; white-space:nowrap; vertical-align:middle; }
|
||||
.tag.admin{ background:var(--accent-soft); color:var(--accent); }
|
||||
.tag.super{ background:var(--wp-tag-super-bg); color:var(--wp-tag-super-text); }
|
||||
.tag.user{ background:var(--cds-layer-hover); color:var(--muted); }
|
||||
.tag.on{ background:var(--green-bg); color:var(--green); }
|
||||
.tag.off{ background:var(--red-bg); color:var(--red); }
|
||||
.tag.archived{ background:var(--amber-bg); color:var(--amber); }
|
||||
.me-tag{ font-size:11px; color:var(--dim); margin-left:6px; white-space:nowrap; }
|
||||
|
||||
/* A wide table scrolls inside its own box so the page never scrolls sideways, and
|
||||
the capped height is what gives the sticky header something to do. */
|
||||
.tscroll{ overflow:auto; max-height:min(70vh,640px); overscroll-behavior:contain; }
|
||||
|
||||
/* ══ MODALS ════════════════════════════════════════════════════════════════════
|
||||
The project-access dialog, shared by both pages. */
|
||||
.modal-ov{ position:fixed; inset:0; background:var(--wp-scrim-cool-modal); display:flex; align-items:center;
|
||||
justify-content:center; z-index:10002; padding:var(--s5); }
|
||||
.modal-box{ background:var(--surface); border-radius:0; max-width:660px; width:100%; max-height:82vh;
|
||||
display:flex; flex-direction:column; overflow:hidden; box-shadow:var(--wp-shadow-modal); }
|
||||
.modal-head{ padding:var(--s3) var(--s4); border-bottom:1px solid var(--border); font-weight:700; }
|
||||
.modal-body{ padding:var(--s3) var(--s4); overflow:auto; }
|
||||
.modal-foot{ padding:var(--s3) var(--s4); border-top:1px solid var(--border);
|
||||
display:flex; gap:var(--s2); justify-content:flex-end; }
|
||||
.pickrow{ display:flex; align-items:center; gap:var(--s3); padding:var(--s2) var(--s1);
|
||||
border-bottom:1px solid var(--border); font-size:13px; }
|
||||
.pickrow:last-child{ border-bottom:none; }
|
||||
.pickrow > label{ display:flex; align-items:center; gap:var(--s2); flex:1; min-width:0; cursor:pointer; }
|
||||
.pickrow > label > span{ overflow:hidden; text-overflow:ellipsis; }
|
||||
|
||||
/* ══ GATES & WARNINGS ══════════════════════════════════════════════════════════ */
|
||||
.gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:var(--s5); z-index:9999; }
|
||||
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius:0; padding:var(--s6); max-width:380px; width:100%; box-shadow:var(--wp-shadow-gate); }
|
||||
.gate-box h2{ margin:0 0 var(--s1); padding:0; border:0; font-size:17px; text-transform:none; letter-spacing:0; color:var(--text); }
|
||||
.gate-box p{ color:var(--muted); font-size:13px; margin:0 0 var(--s4); }
|
||||
.gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:var(--s2); }
|
||||
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:0; padding:9px 13px; font-size:12px; margin-bottom:var(--s4); }
|
||||
|
||||
/* ══ NARROW SCREENS ════════════════════════════════════════════════════════════
|
||||
The page itself must never scroll sideways; the wide tables scroll inside their
|
||||
own box instead, and there they get the full page height to do it. */
|
||||
@media (max-width:900px){
|
||||
.wrap{ padding:var(--s4) var(--s3) 60px; }
|
||||
.card{ padding:var(--s3) var(--s4) var(--s4); }
|
||||
.toolbar > input{ flex:1 1 100%; }
|
||||
.tscroll{ max-height:none; }
|
||||
}
|
||||
@media (max-width:620px){
|
||||
.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; }
|
||||
}
|
||||
@@ -8,18 +8,22 @@
|
||||
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
|
||||
timestamps during their own boot. -->
|
||||
<script src="wp-format.js"></script>
|
||||
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
|
||||
during their own boot. -->
|
||||
<script src="wp-url.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<link rel="stylesheet" href="wp-chrome.css">
|
||||
<link rel="stylesheet" href="wp-sidenav.css">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { -webkit-text-size-adjust: 100%; }
|
||||
.field-wrap { max-width: 760px; margin: 0 auto; padding: 16px 16px 40px; }
|
||||
.fld-ctx { font-size: 13px; color: var(--cds-text-secondary); margin-bottom: 12px; }
|
||||
.fld-ctx b { color: var(--cds-text-primary); }
|
||||
.fld-search { width: 100%; padding: 14px; font-size: 16px; border: 1px solid var(--cds-border-strong); background: #fff; margin-bottom: 14px; }
|
||||
.fld-search { width: 100%; padding: 14px; font-size: 16px; border: 1px solid var(--cds-border-strong); background: var(--cds-field); margin-bottom: 14px; }
|
||||
.fld-search:focus { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
|
||||
.wp-card { display: block; width: 100%; text-align: left; background: var(--cds-layer); border: 1px solid var(--cds-border-subtle); border-left: 4px solid var(--cds-border-strong); padding: 14px 16px; margin-bottom: 10px; cursor: pointer; font-family: inherit; }
|
||||
.wp-card:active { background: var(--cds-layer-hover); }
|
||||
@@ -30,10 +34,10 @@
|
||||
.wp-card .meta { margin-top: 10px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.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.ok { background: #defbe6; color: #0e6027; }
|
||||
.pill.warn { background: #fdf6dd; color: #8a6d00; }
|
||||
.pill.bad { background: #fff1f1; color: #da1e28; }
|
||||
.fld-empty { padding: 32px; text-align: center; color: var(--cds-text-helper); border: 1px dashed var(--cds-border-strong); background: #fff; }
|
||||
.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); }
|
||||
.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 a { color: var(--cds-link-primary); }
|
||||
.fld-back { background: none; border: none; color: var(--cds-link-primary); font-size: 15px; padding: 8px 0; cursor: pointer; font-family: inherit; }
|
||||
.fld-h1 { font-size: 20px; font-weight: 600; margin: 4px 0 2px; }
|
||||
@@ -41,25 +45,30 @@
|
||||
.fld-sec { background: var(--cds-layer); border: 1px solid var(--cds-border-subtle); padding: 14px 16px; margin-bottom: 14px; }
|
||||
.fld-sec h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--cds-text-helper); margin-bottom: 10px; }
|
||||
.st-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; }
|
||||
.st-btn { padding: 14px 10px; font-size: 15px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: #fff; color: var(--cds-text-secondary); cursor: pointer; font-family: inherit; }
|
||||
.st-btn.on { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); color: #fff; }
|
||||
.st-btn { padding: 14px 10px; font-size: 15px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: var(--cds-field); color: var(--cds-text-secondary); cursor: pointer; font-family: inherit; }
|
||||
.st-btn.on { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); color: var(--cds-text-on-color); }
|
||||
.st-btn.hold.on { background: var(--cds-support-error); border-color: var(--cds-support-error); }
|
||||
.cx-row { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--cds-border-subtle); }
|
||||
.cx-row:last-child { border-bottom: none; }
|
||||
.cx-name { flex: 1; font-size: 15px; }
|
||||
.cx-state { min-width: 96px; padding: 10px 12px; font-size: 14px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: #fff; cursor: pointer; text-align: center; font-family: inherit; }
|
||||
.cx-state.cleared { background: #defbe6; color: #0e6027; border-color: #a7f0ba; }
|
||||
.cx-state { min-width: 96px; padding: 10px 12px; font-size: 14px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: var(--cds-field); cursor: pointer; text-align: center; font-family: inherit; }
|
||||
.cx-state.cleared { background: var(--wp-status-success-bg); color: var(--wp-hover-success); border-color: var(--wp-status-success-border-a); }
|
||||
.cx-state.na { background: var(--cds-layer-accent); color: var(--cds-text-secondary); }
|
||||
.cx-state.open { background: #fff1f1; color: #da1e28; border-color: #ffd7d9; }
|
||||
.cx-state.open { background: var(--wp-status-error-bg); color: var(--cds-support-error); border-color: var(--wp-status-error-border-a); }
|
||||
.fld-note { width: 100%; padding: 12px; font-size: 16px; border: 1px solid var(--cds-border-strong); min-height: 84px; font-family: inherit; resize: vertical; }
|
||||
.fld-photo-row { display: flex; gap: 10px; align-items: center; margin-top: 10px; flex-wrap: wrap; }
|
||||
.fld-btn { padding: 12px 18px; font-size: 15px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: #fff; cursor: pointer; font-family: inherit; }
|
||||
.fld-btn.primary { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); color: #fff; }
|
||||
.fld-btn { padding: 12px 18px; font-size: 15px; font-weight: 600; border: 1px solid var(--cds-border-strong); background: var(--cds-field); cursor: pointer; font-family: inherit; }
|
||||
.fld-btn.primary { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); color: var(--cds-text-on-color); }
|
||||
.log-item { border: 1px solid var(--cds-border-subtle); padding: 10px 12px; margin-bottom: 8px; font-size: 14px; color: var(--cds-text-primary); white-space: pre-wrap; }
|
||||
.log-item .lm { color: var(--cds-text-helper); font-size: 11px; margin-bottom: 4px; }
|
||||
.log-item img { max-width: 160px; max-height: 120px; margin-top: 6px; display: block; border: 1px solid var(--cds-border-subtle); }
|
||||
.fld-toast { position: fixed; bottom: 76px; left: 50%; transform: translateX(-50%); background: #161616; color: #fff; padding: 12px 20px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 50; }
|
||||
.fld-toast { position: fixed; bottom: 76px; left: 50%; transform: translateX(-50%); background: var(--cds-ui-05); color: var(--cds-text-on-color); padding: 12px 20px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 50; }
|
||||
.fld-toast.show { opacity: 1; }
|
||||
/* 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;
|
||||
border:1px solid var(--cds-border-subtle); border-radius:6px; margin-bottom:8px;
|
||||
color: var(--cds-link-primary); text-decoration:none; font-size:14px; }
|
||||
.fld-drawing:active { background: var(--cds-layer-hover); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -69,7 +78,9 @@
|
||||
<span class="wp-appbar-title">Field View</span>
|
||||
</a>
|
||||
<div class="wp-appbar-spacer"></div>
|
||||
<a class="wp-appbar-link" href="index.html">Home</a>
|
||||
<!-- The "Home" link that sat here is gone (T2.2): the drawer's first entry is
|
||||
Home, and the brand to its left already links there. Two more ways to the
|
||||
same place is the duplication this wave exists to remove. -->
|
||||
</header>
|
||||
|
||||
<div class="field-wrap">
|
||||
@@ -87,5 +98,6 @@
|
||||
<script src="help.js"></script>
|
||||
<script src="field.js"></script>
|
||||
<script src="wp-chrome.js"></script>
|
||||
<script src="wp-sidenav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
'use strict';
|
||||
|
||||
var PID = '', PROJECT = null, WPS = [], curId = null, pendingPhoto = '', draftNote = '';
|
||||
var STATUSES = ['Draft', 'Scheduled', 'Issued', 'In Progress', 'QC', 'Closed', 'Issue'];
|
||||
var GATED = ['Issued', 'In Progress', 'QC', 'Closed']; // need all constraints cleared to enter
|
||||
var STATUSES = ['Draft', 'Scheduled', 'Issued', 'In Progress', 'Ready for QA', 'QC', 'Closed', 'Issue'];
|
||||
var GATED = ['Issued', 'In Progress', 'Ready for QA', 'QC', 'Closed']; // need all constraints cleared to enter
|
||||
|
||||
function esc(s) { return s == null ? '' : String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
|
||||
function nsKey(id) { return 'wp_iwp_v1__' + id; }
|
||||
@@ -24,7 +24,13 @@ function waitingCount(p, all) {
|
||||
}
|
||||
function fmtTs(s) { try { return wpFormatDateTime(s); } catch (e) { return s || ''; } }
|
||||
function me() { try { return (window.WP_USER && (window.WP_USER.full_name || window.WP_USER.username)) || ''; } catch (e) { return ''; } }
|
||||
function toast(m) { var t = document.getElementById('toast'); if (!t) return; t.textContent = m; t.classList.add('show'); clearTimeout(toast._t); toast._t = setTimeout(function () { t.classList.remove('show'); }, 2000); }
|
||||
// S10: see the note on the creator's toast. Role first, then text.
|
||||
function toast(m, kind) {
|
||||
var t = document.getElementById('toast'); if (!t) return;
|
||||
t.setAttribute('role', kind === 'alert' ? 'alert' : 'status');
|
||||
t.textContent = m; t.classList.add('show');
|
||||
clearTimeout(toast._t); toast._t = setTimeout(function () { t.classList.remove('show'); }, 2000);
|
||||
}
|
||||
|
||||
// ── boot / data ──────────────────────────────────────────────────────────────
|
||||
function boot() {
|
||||
@@ -32,7 +38,15 @@ function boot() {
|
||||
PID = params.get('project') || (ProjectData.getActiveId && ProjectData.getActiveId()) || '';
|
||||
if (!PID) { showNoProject(); return; }
|
||||
if (ProjectData.getActiveId && ProjectData.getActiveId() !== PID) { try { ProjectData.setActive({ id: PID }); } catch (e) {} }
|
||||
if (ProjectData.get) { ProjectData.get(PID).then(function (p) { PROJECT = p; renderCtx(); }).catch(function () {}); }
|
||||
// Publish the resolved record rather than keeping it to ourselves: line 34 could
|
||||
// only write the id, and the app bar reads the shared record, not PROJECT.
|
||||
if (ProjectData.get) {
|
||||
ProjectData.get(PID).then(function (p) {
|
||||
PROJECT = p;
|
||||
if (p && p.name) { try { ProjectData.setActive(p); } catch (e) {} }
|
||||
renderCtx();
|
||||
}).catch(function () {});
|
||||
}
|
||||
loadWPs();
|
||||
}
|
||||
function renderCtx() {
|
||||
@@ -51,9 +65,23 @@ function loadWPs() {
|
||||
ProjectData.pullProject(PID).then(function () {
|
||||
WPS = activePkgs(readCache());
|
||||
if (!curId) renderList(); else renderDetail();
|
||||
prefetchMyDrawings();
|
||||
}).catch(function () {});
|
||||
}
|
||||
}
|
||||
|
||||
// CR-007/D8: warm the drawing cache for MY packages while there is a network.
|
||||
// Deliberately only the requesting user's assignments - the decision was that
|
||||
// offline coverage follows assignment, not the whole project's 2GB.
|
||||
function prefetchMyDrawings() {
|
||||
var myId = (window.WP_USER || {}).id;
|
||||
if (!myId || !('serviceWorker' in navigator)) return;
|
||||
WPS.filter(function (p) { return p.assigneeId === myId; }).forEach(function (p) {
|
||||
((p.files) || []).forEach(function (f) {
|
||||
if (f && f.id) fetch('/api/files/' + f.id).catch(function () {});
|
||||
});
|
||||
});
|
||||
}
|
||||
function showNoProject() {
|
||||
var s = document.getElementById('screen-list');
|
||||
if (s) s.innerHTML = '<div class="fld-empty">No project selected.<br><a href="index.html">Pick a project on the home page</a>, then reopen the field view.</div>';
|
||||
@@ -70,6 +98,7 @@ function renderList() {
|
||||
var waiting = waitingCount(p, WPS); // the full set, not the filtered rows
|
||||
var cls = p.status === 'Issue' ? 'hold' : ((open === 0 && !waiting) ? 'ready' : '');
|
||||
var readyPill = p.status === 'Issue' ? '<span class="pill bad">On hold</span>'
|
||||
: p.status === 'Ready for QA' ? '<span class="pill ok">Ready for QA</span>'
|
||||
: (open ? '<span class="pill warn">' + open + ' open</span>'
|
||||
: (waiting ? '<span class="pill warn">waits on ' + waiting + '</span>'
|
||||
: '<span class="pill ok">Ready</span>'));
|
||||
@@ -117,9 +146,14 @@ function renderDetail() {
|
||||
'<div class="fld-sub">' + esc(p.subject || '') + (p.type ? ' · ' + esc(p.type) : '') + '</div>' +
|
||||
'<div class="fld-sec"><h3>Status</h3><div class="st-grid">' + stBtns + '</div></div>' +
|
||||
'<div class="fld-sec"><h3>Constraints — ' + openCount(p) + ' open</h3>' + cxRows + '</div>' +
|
||||
(((p.files) || []).length ? '<div class="fld-sec"><h3>Drawings</h3>' +
|
||||
p.files.map(function (f) {
|
||||
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>';
|
||||
}).join('') + '</div>' : '') +
|
||||
'<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>' +
|
||||
'<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>' +
|
||||
'<div style="margin-top:12px"><button class="fld-btn primary" onclick="addUpdate()">Add to log</button></div>' +
|
||||
'</div>' +
|
||||
|
||||
210
html/help.js
@@ -1,5 +1,5 @@
|
||||
/* Shared Help center + tooltip module for the Work Package Suite.
|
||||
Included by the home page, the suite, and the embedded creator. It injects:
|
||||
Included by the home page, the SOP wizard and the work package creator. It injects:
|
||||
- tooltip styles for the .help-tip (ⓘ) component and [data-tip] hovers
|
||||
- a searchable, multi-topic Help center modal opened via window.openHelp()
|
||||
- a floating "?" launcher on any page that doesn't already have a Help button
|
||||
@@ -12,67 +12,174 @@
|
||||
(function (global) {
|
||||
'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 ────────────────────────────────────────────────────────────────
|
||||
var css = `
|
||||
.help-tip{ display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px;
|
||||
margin-left:5px; border-radius:50%; background:#525252; color:#fff; font-size:10px; font-weight:700;
|
||||
/* S8 / T9.5: the badge is a BUTTON - reachable by keyboard and by touch, which
|
||||
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; }
|
||||
.help-tip::after{ content:attr(data-tip); position:absolute; bottom:130%; left:50%; transform:translateX(-50%);
|
||||
background:#161616; color:#fff; padding:7px 10px; border-radius:0; font-size:12px; font-weight:400;
|
||||
line-height:1.4; white-space:normal; width:max-content; max-width:260px; text-align:left; z-index:9999;
|
||||
opacity:0; pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); }
|
||||
.help-tip::before{ content:''; position:absolute; bottom:130%; left:50%; transform:translate(-50%,95%);
|
||||
border:5px solid transparent; border-top-color:#161616; opacity:0; transition:opacity .12s; z-index:9999; }
|
||||
.help-tip:hover::after, .help-tip:hover::before, .help-tip:focus::after, .help-tip:focus::before{ opacity:1; }
|
||||
.help-tip:focus-visible{ outline:2px solid var(--cds-focus); outline-offset:1px; }
|
||||
.help-tip[aria-expanded="true"]{ background:var(--cds-focus); }
|
||||
#wp-tip-bubble{ position:fixed; z-index:10001; max-width:min(280px, calc(100vw - 24px));
|
||||
background:var(--cds-background-inverse); color:var(--cds-text-inverse);
|
||||
padding:7px 10px; font-size:12px; font-weight:400; line-height:1.4; text-align:left;
|
||||
box-shadow:0 4px 14px rgba(20,30,50,.22); }
|
||||
|
||||
.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; }
|
||||
.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;
|
||||
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-search{ flex:1; position:relative; max-width:420px; }
|
||||
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid #8d8d8d; border-radius:0;
|
||||
font-size:13px; outline:none; background:#f7f8fa; }
|
||||
.ui-help-search input:focus{ border-color:#0f62fe; background:#fff; 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-search input{ width:100%; padding:8px 12px; border:1px solid var(--cds-border-strong); border-radius:0;
|
||||
font-size:13px; outline:none; background:var(--cds-layer-accent); }
|
||||
.ui-help-search input:focus{ border-color:var(--cds-focus); background:var(--cds-layer); box-shadow:0 0 0 2px rgba(15,98,254,.15); }
|
||||
.ui-help-head .ui-help-x{ margin-left:auto; background:none; border:none; font-size:20px; cursor:pointer; color:var(--cds-text-secondary); line-height:1; }
|
||||
.ui-help-wrap{ display:flex; flex:1; min-height:0; }
|
||||
.ui-help-nav{ width:230px; flex:none; border-right:1px solid #e0e0e0; overflow:auto; padding:10px 8px; background:#fafbfc; }
|
||||
.ui-help-nav a{ display:block; padding:7px 10px; border-radius:0; color:#27313f; text-decoration:none; font-size:13px;
|
||||
.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:var(--cds-text-primary); text-decoration:none; font-size:13px;
|
||||
cursor:pointer; margin-bottom:1px; }
|
||||
.ui-help-nav a:hover{ background:#eef1f6; }
|
||||
.ui-help-nav a.active{ background:#edf5ff; color:#0353e9; font-weight:600; }
|
||||
.ui-help-nav a:hover{ background:var(--cds-layer-hover); }
|
||||
.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-content{ flex:1; overflow:auto; padding:22px 28px; scroll-behavior:smooth; }
|
||||
.ui-help-sec{ margin-bottom:30px; }
|
||||
.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 h4{ margin:18px 0 6px; font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:#0f62fe; }
|
||||
.ui-help-content p{ font-size:13.5px; line-height:1.62; margin:0 0 9px; color:#27313f; }
|
||||
.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:var(--cds-link-primary); }
|
||||
.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 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 th, .ui-help-content td{ border:1px solid #e0e0e0; padding:6px 9px; text-align:left; vertical-align:top; }
|
||||
.ui-help-content th{ background:#f4f6f9; font-weight:600; }
|
||||
.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: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; }
|
||||
.pill-draft{ background:#eef1f6; color:#525252; } .pill-sched{ background:#edf5ff; color:#0353e9; }
|
||||
.pill-prog{ background:#fef3e0; color:#b45309; } .pill-issued{ background:#e4f6ec; color:#15924f; }
|
||||
.pill-qc{ background:#f3e8ff; color:#7c3aed; } .pill-closed{ background:#e2e8f0; color:#334155; }
|
||||
.pill-hold{ background:#fde8e8; color:#c0392b; }
|
||||
.ui-help-callout{ background:#f4f8ff; border-left:3px solid #0f62fe; padding:10px 14px; border-radius:0;
|
||||
/* Scoped to .ui-help-pill: this block is injected on EVERY page, and the
|
||||
creator's Issue (hold) status radio also carries the class pill-hold - the
|
||||
bare selector painted that radio error-red at all times, selected or not
|
||||
(found by Nick 2026-08-20; the collision dates to the login-portal era). */
|
||||
.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; }
|
||||
.ui-help-noresult{ display:none; color:#525252; font-size:14px; padding:10px 2px; }
|
||||
.ui-help-content mark{ background:#fff1a8; color:inherit; border-radius:2px; padding:0 1px; }
|
||||
.ui-help-noresult{ display:none; color:var(--cds-text-secondary); font-size:14px; padding:10px 2px; }
|
||||
.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%;
|
||||
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); }
|
||||
.ui-help-fab:hover{ background:#0353e9; }
|
||||
.ui-help-fab:hover{ background:var(--cds-hover-primary); }
|
||||
@media (max-width:760px){
|
||||
.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-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>
|
||||
</ol>
|
||||
<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>
|
||||
<ol>
|
||||
<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>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>
|
||||
<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: `
|
||||
<h3>Projects</h3>
|
||||
@@ -120,7 +227,7 @@
|
||||
|
||||
{ id: 'sop', title: 'SOP Configuration', body: `
|
||||
<h3>SOP Configuration (10 steps)</h3>
|
||||
<p>The SOP is the project baseline. Walk the 10 steps with <strong>← Back</strong> / <strong>Next →</strong>, or jump using the step indicators. The final step is <strong>✓ SOP Complete</strong> — saving it unlocks the Work Package Creator and turns the home-page card green.</p>
|
||||
<p>The SOP is the project baseline. Walk the 10 steps with <strong>← Back</strong> / <strong>Next →</strong>, or jump using the step indicators. The final step is <strong>✓ SOP complete</strong> — saving it unlocks the Work Package Creator and turns the home-page card green.</p>
|
||||
<ol>
|
||||
<li><strong>Project Basics</strong> — name, number, client, division/sector, site. Inherited by every WP.</li>
|
||||
<li><strong>Project Team Leadership</strong> — PM, APM, CM, QM, plus any additional members (<em>+ Add Team Member</em>).</li>
|
||||
@@ -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>Engineering Sources & References</strong> — labelled links (Design Drawings, Specs, …) that appear as quick-access buttons in the WP Creator's <em>Drawings & Attachments</em>.</li>
|
||||
</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: `
|
||||
<h3>Creating Work Packages</h3>
|
||||
@@ -152,7 +259,7 @@
|
||||
<li><strong>Quality / Hold Points</strong>, <strong>Approvals & Sign-offs</strong>, and <strong>Closeout</strong> (actual hours, as-builts, lessons learned — shown at QC/Closed).</li>
|
||||
</ul>
|
||||
<h4>Saving</h4>
|
||||
<p><strong>Save Draft</strong> stores the package; <strong>⚡ Save & 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 & 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: `
|
||||
<h3>Work Package statuses</h3>
|
||||
@@ -223,7 +330,7 @@
|
||||
|
||||
{ id: 'dashboard', title: 'Dashboard', body: `
|
||||
<h3>Dashboard & 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>
|
||||
<ul>
|
||||
<li><strong>Total WPs</strong>, <strong>Release-ready</strong>, <strong>On hold</strong>, <strong>Overdue</strong></li>
|
||||
@@ -232,7 +339,7 @@
|
||||
<h4>Breakdowns & gates</h4>
|
||||
<ul>
|
||||
<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>
|
||||
<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>
|
||||
@@ -240,8 +347,8 @@
|
||||
|
||||
{ id: 'data', title: 'Samples, sharing & comments', body: `
|
||||
<h3>Samples, import / export & comments</h3>
|
||||
<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>
|
||||
<h4>Load sample</h4>
|
||||
<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>
|
||||
<ul>
|
||||
<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>
|
||||
</ul>
|
||||
<h4>Comments & 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>
|
||||
<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: `
|
||||
<h3>Tips & keyboard shortcuts</h3>
|
||||
@@ -289,7 +396,7 @@
|
||||
{ id: 'faq', title: 'FAQ', body: `
|
||||
<h3>Frequently asked questions</h3>
|
||||
<h4>The Work Package Creator is locked — why?</h4>
|
||||
<p>The SOP for the active project isn't complete yet. Finish SOP Configuration and click <strong>✓ SOP Complete</strong> on the last step; the Creator unlocks and the home card turns green.</p>
|
||||
<p>The SOP for the active project isn't complete yet. Finish SOP Configuration and click <strong>✓ SOP complete</strong> on the last step; the Creator unlocks and the home card turns green.</p>
|
||||
<h4>Why can't I set a package to Issued?</h4>
|
||||
<p>At least one constraint is still <strong>Open</strong>. Clear or mark N/A every constraint — the release banner turns green — and the suite will offer to issue it.</p>
|
||||
<h4>My package's materials didn't all carry over when I split it.</h4>
|
||||
@@ -466,8 +573,11 @@
|
||||
|
||||
// ── floating launcher on pages without their own Help button ────────────────
|
||||
function maybeAddFab() {
|
||||
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
|
||||
if (inIframe || global.WP_HELP_NO_FAB) return; // suite shows the parent's button
|
||||
// Both tool pages set WP_HELP_NO_FAB and carry a Help button in the app bar
|
||||
// instead. The creator used to be covered by an iframe test rather than the
|
||||
// flag, which stopped working the moment it became a page (B7/T7.1) - so it
|
||||
// sets the flag now, and says so, rather than relying on where it is rendered.
|
||||
if (global.WP_HELP_NO_FAB) return;
|
||||
if (document.querySelector('[onclick*="openHelp"]')) return; // page already has a Help trigger
|
||||
if (document.getElementById('ui-help-fab')) return;
|
||||
var b = document.createElement('button');
|
||||
|
||||
682
html/index.html
@@ -8,11 +8,15 @@
|
||||
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
|
||||
timestamps during their own boot. -->
|
||||
<script src="wp-format.js"></script>
|
||||
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
|
||||
during their own boot. -->
|
||||
<script src="wp-url.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<link rel="stylesheet" href="wp-chrome.css">
|
||||
<link rel="stylesheet" href="wp-sidenav.css">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
@@ -76,7 +80,7 @@
|
||||
.card-badge {
|
||||
display: inline-block;
|
||||
background: var(--cds-button-primary);
|
||||
color: white;
|
||||
color: var(--cds-text-on-color);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
@@ -102,7 +106,7 @@
|
||||
display: inline-block;
|
||||
align-self: flex-start;
|
||||
background: var(--cds-button-primary);
|
||||
color: white;
|
||||
color: var(--cds-text-on-color);
|
||||
padding: 0.7rem 1.25rem;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
@@ -121,8 +125,18 @@
|
||||
.card.complete {
|
||||
border-left-color: var(--cds-support-success);
|
||||
}
|
||||
.card.complete .card-button { background: var(--cds-support-success); }
|
||||
.card.complete .card-button:hover { background: #0e6027; }
|
||||
/* A5: the card-button stays primary when the SOP completes. The completion
|
||||
is still said, twice — the card's left border turns green and .card-status
|
||||
appears — which is green doing its job as a status rather than as an
|
||||
action colour. */
|
||||
/* A7 / T6.5. Every card says its state, in all THREE states — the version
|
||||
before this said "complete" when it was and nothing when it was not, so
|
||||
the most common state on a live project was the one with no line at all.
|
||||
|
||||
Each state carries a glyph and a word as well as a colour, and the three
|
||||
colours are the canonical status tokens: green complete, the suite's amber
|
||||
for unknown, and secondary text for in-progress. Nothing is declared
|
||||
here that theme-light.css does not already hold. */
|
||||
.card-status {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
@@ -130,6 +144,11 @@
|
||||
color: var(--cds-support-success);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
/* Not finished yet — a real answer, and neither green nor a warning. */
|
||||
.card-status.card-status-pending { color: var(--cds-text-secondary); }
|
||||
/* B4: an unreachable server is a third state, and it has to look like neither
|
||||
of the other two. Not green, not silence. */
|
||||
.card-status.card-status-error { color: var(--wp-status-warning-text); }
|
||||
.card.disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
@@ -178,6 +197,19 @@
|
||||
}
|
||||
|
||||
.footer a:hover { text-decoration: underline; }
|
||||
|
||||
.footer p + p { margin-top: 0.4rem; }
|
||||
/* "Pilot" is the word that changes what the rest of the sentence means, so it
|
||||
is marked rather than left to be read past. */
|
||||
.footer-tag {
|
||||
display: inline-block;
|
||||
background: var(--wp-status-warning-bg);
|
||||
color: var(--wp-status-warning-text);
|
||||
border: 1px solid var(--cds-support-warning);
|
||||
padding: 0 6px;
|
||||
margin-right: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* COMMENTS SECTION */
|
||||
.comments-section {
|
||||
@@ -190,7 +222,7 @@
|
||||
.comments-toggle {
|
||||
padding: 0.7rem 1.25rem;
|
||||
background: var(--cds-button-primary);
|
||||
color: white;
|
||||
color: var(--cds-text-on-color);
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
@@ -246,7 +278,7 @@
|
||||
|
||||
.submit-btn {
|
||||
background: var(--cds-button-primary);
|
||||
color: white;
|
||||
color: var(--cds-text-on-color);
|
||||
}
|
||||
|
||||
.submit-btn:hover { background: var(--cds-hover-primary); }
|
||||
@@ -283,25 +315,68 @@
|
||||
color: var(--cds-text-primary);
|
||||
}
|
||||
|
||||
/* PROJECT PICKER */
|
||||
/* PROJECT ENTRY POINTS — B3
|
||||
The picker card (a "Select a project…" dropdown in its own section) is gone.
|
||||
Switching projects is the app bar's switcher, which is on every page; the
|
||||
launcher keeps the two things a dropdown could not do — the first-run empty
|
||||
state, and creating a project. */
|
||||
.proj-loading { color: var(--cds-text-secondary); font-style: italic; font-size: 13px; }
|
||||
.proj-row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; }
|
||||
.proj-row select { flex: 1; min-width: 240px; padding: 0.6rem 0.7rem; font-size: 14px;
|
||||
border: 1px solid var(--cds-border-strong, #8d8d8d); background: #fff; }
|
||||
.proj-empty { background: var(--cds-ui-01, #fff); border: 1px dashed var(--cds-border-strong, #8d8d8d);
|
||||
padding: 1.25rem; }
|
||||
.proj-empty p { margin: 0 0 0.9rem; color: var(--cds-text-secondary); }
|
||||
.proj-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
|
||||
.proj-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; }
|
||||
/* Shown once when the project someone had open turns out to have been archived
|
||||
rather than deleted — otherwise the picker just silently resets on them. */
|
||||
.proj-archived-note { background: #fdf6dd; border: 1px solid #f1c21b; color: #8e6a00;
|
||||
rather than deleted — otherwise the launcher just silently resets on them. */
|
||||
.proj-archived-note { background: var(--wp-status-warning-bg); border: 1px solid var(--cds-support-warning); color: var(--wp-status-warning-text);
|
||||
padding: 0.7rem 0.9rem; margin-bottom: 0.9rem; font-size: 13px; line-height: 1.5; }
|
||||
.proj-form { margin-top: 1rem; padding: 1rem; border: 1px solid var(--cds-ui-03, #e0e0e0); background: var(--cds-ui-01, #fff); }
|
||||
.proj-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.75rem; margin-bottom: 0.9rem; }
|
||||
.proj-form-grid label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 12px; font-weight: 600; color: var(--cds-text-secondary); }
|
||||
.proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong, #8d8d8d); }
|
||||
.proj-active { margin-top: 0.85rem; font-size: 13px; color: var(--cds-text-primary); }
|
||||
.link-like { background: none; border: none; color: var(--cds-link-01, #0f62fe); cursor: pointer; font-size: 13px; padding: 0; text-decoration: underline; }
|
||||
.proj-form-grid input { padding: 0.55rem 0.65rem; font-size: 14px; border: 1px solid var(--cds-border-strong); background: var(--cds-field); }
|
||||
.proj-form-grid input[aria-invalid="true"] { border-color: var(--cds-support-error); }
|
||||
/* .link-like and .proj-row went with the picker card. Both were named in
|
||||
BL-014 as controls falling back to the UA focus ring; two of that entry's
|
||||
four sites no longer exist. */
|
||||
|
||||
/* .field-error — the inline error beside a field — is declared once, in
|
||||
theme-light.css, because three surfaces in wave 5 grew one. */
|
||||
|
||||
/* PIPELINE STRIP — B4 surface (T5.3)
|
||||
Four counts, every one of them from /api/wps/metrics. There is deliberately
|
||||
no localStorage fallback anywhere in here: a per-browser number that looks
|
||||
authoritative is the thing B4 removes, and a stale four beside a live four
|
||||
is worse than an error. */
|
||||
.pipeline { margin-bottom: 1.5rem; }
|
||||
.pipeline-head { font-size: 13px; font-weight: 600; text-transform: none;
|
||||
color: var(--cds-text-secondary); margin-bottom: 0.6rem; }
|
||||
.pipeline-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1px;
|
||||
background: var(--cds-border-subtle); border: 1px solid var(--cds-border-subtle); }
|
||||
.pipe-cell { display: flex; flex-direction: column; gap: 0.15rem; padding: 1rem 1.1rem;
|
||||
background: var(--cds-layer); text-decoration: none; color: var(--cds-text-primary);
|
||||
border-left: 3px solid transparent; transition: background 0.15s; }
|
||||
.pipe-cell:hover { background: var(--cds-layer-hover); }
|
||||
.pipe-num { font-size: 1.75rem; font-weight: 300; line-height: 1.1; }
|
||||
.pipe-label { font-size: 13px; font-weight: 600; }
|
||||
.pipe-sub { font-size: 12px; color: var(--cds-text-secondary); }
|
||||
/* Each cell's accent says which slice it is, and the label says it in words —
|
||||
the number alone is the same shape in all four (C1). */
|
||||
.pipe-cell.is-total { border-left-color: var(--cds-interactive-01); }
|
||||
.pipe-cell.is-ready { border-left-color: var(--cds-support-success); }
|
||||
.pipe-cell.is-onhold { border-left-color: var(--wp-status-warning-text); }
|
||||
.pipe-cell.is-overdue { border-left-color: var(--cds-support-error); }
|
||||
/* Zero is a real answer for one slice of a project that has work in it. Four
|
||||
zeros on a project with no work packages at all is not a reading, it is a
|
||||
broken-looking strip, so that case gets a sentence instead. */
|
||||
.pipe-empty, .pipe-error { grid-column: 1 / -1; background: var(--cds-layer);
|
||||
padding: 1.1rem 1.2rem; font-size: 13px; color: var(--cds-text-secondary); }
|
||||
.pipe-error { color: var(--wp-status-warning-text); }
|
||||
.pipe-empty a { color: var(--cds-link-primary); }
|
||||
.pipe-loading { grid-column: 1 / -1; background: var(--cds-layer); padding: 1.1rem 1.2rem;
|
||||
font-size: 13px; color: var(--cds-text-secondary); font-style: italic; }
|
||||
|
||||
/* FIRST RUN
|
||||
A brand-new account has no projects, so it has no tool cards either — this
|
||||
is the whole page for that person, and it has to say what to do next. */
|
||||
.first-run { border-left: 3px solid var(--cds-interactive-01); }
|
||||
.first-run-note { font-size: 13px; margin-top: 1rem; }
|
||||
.first-run-sample { margin-top: 1.5rem; padding-top: 1.25rem; border-top: 1px solid var(--cds-border-subtle); }
|
||||
.first-run-sample h3 { margin-bottom: 0.35rem; }
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media (max-width: 768px) {
|
||||
@@ -335,12 +410,108 @@
|
||||
<p id="hero-sub">Standardized Work Package creation for Prime Controls construction projects. Select a project to begin — or create one.</p>
|
||||
</div>
|
||||
|
||||
<!-- PROJECT SELECTION -->
|
||||
<div class="section" id="project-section">
|
||||
<h2>Project</h2>
|
||||
<p style="color:var(--cds-text-secondary);font-size:13px;margin:-.25rem 0 1rem">Projects are stored centrally. Pick the project you're working on, or set up a new one.</p>
|
||||
<div id="project-picker"><div class="proj-loading">Loading projects…</div></div>
|
||||
</div>
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||||
PROJECT ENTRY POINTS — B3 / T5.2
|
||||
|
||||
The project-picker card that used to sit here — <h2>Project</h2> over a
|
||||
"Select a project…" dropdown — is gone. Switching projects is the app
|
||||
bar's switcher, which is on every page rather than only this one.
|
||||
|
||||
What a dropdown could NOT do is what stayed: the first-run empty state,
|
||||
and creating a project. The empty state was built INSIDE the picker card,
|
||||
so removing the card first would have stranded every new account on a
|
||||
page whose only instruction was to pick from an empty list.
|
||||
|
||||
Exactly one of these three is visible at a time; #proj-status says which
|
||||
and is where the archived note lands.
|
||||
══════════════════════════════════════════════════════════════════════ -->
|
||||
<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 -->
|
||||
<section class="section first-run" id="first-run" hidden>
|
||||
<h2>No projects yet</h2>
|
||||
<p>A project holds one SOP — the baseline every work package on the job
|
||||
inherits — and the work packages written against it. Everything else in
|
||||
the suite hangs off a project, so this is the first thing to make.
|
||||
Fill in the form below, or start from the sample underneath it.</p>
|
||||
</section>
|
||||
|
||||
<!-- (b) projects exist, none chosen -->
|
||||
<section class="section" id="pick-prompt" hidden>
|
||||
<h2>Choose a project</h2>
|
||||
<p id="pick-prompt-sub">Pick the job you are working on from the project
|
||||
switcher in the bar at the top of the page. It is there on every page, so
|
||||
you can change job without coming back here.</p>
|
||||
<div class="proj-actions">
|
||||
<button type="button" class="card-button" id="open-switcher-btn">Open the project switcher</button>
|
||||
<button type="button" class="close-btn" id="new-project-btn">New project</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- (c) the create form. Open on first run, on demand after that. -->
|
||||
<section class="section" id="new-project" hidden>
|
||||
<h2 id="new-project-head">Create a project</h2>
|
||||
<form id="np-form" novalidate>
|
||||
<div class="proj-form-grid">
|
||||
<label for="np_name">Project name *
|
||||
<input type="text" id="np_name" name="np_name" required
|
||||
aria-describedby="np_name_err" placeholder="e.g. Micron — INC Construction">
|
||||
</label>
|
||||
<label for="np_number">Project number
|
||||
<input type="text" id="np_number" placeholder="e.g. 26-67-008"></label>
|
||||
<label for="np_client">Client
|
||||
<input type="text" id="np_client" placeholder="e.g. Micron Technology, Inc."></label>
|
||||
<label for="np_division">Division
|
||||
<input type="text" id="np_division" placeholder="e.g. Semiconductor"></label>
|
||||
<label for="np_site">Site / location
|
||||
<input type="text" id="np_site" placeholder="e.g. Boise, ID — Fab"></label>
|
||||
</div>
|
||||
<!-- The error belongs at the field, not in a dialog that names no field
|
||||
and highlights nothing. role="alert" so it is heard (C1 / S10). -->
|
||||
<div class="field-error" id="np_name_err" role="alert"></div>
|
||||
<div class="proj-actions">
|
||||
<button type="submit" class="card-button">Create & select</button>
|
||||
<button type="button" class="close-btn" id="np-cancel">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- (d) the sample. Offered on first run, where it is the alternative to
|
||||
filling in a form about a job you may not have started yet. It stays
|
||||
reachable afterwards from the create form's own section. -->
|
||||
<section class="section" id="sample-offer" hidden>
|
||||
<h2>Not ready to set one up?</h2>
|
||||
<p>The sample project is a fully configured example — a finished SOP and a
|
||||
few work packages — so you can see what the tools do before committing a
|
||||
real job to them. It is labelled as a sample everywhere it appears, and it
|
||||
can be deleted later.</p>
|
||||
<div class="proj-actions">
|
||||
<button type="button" class="close-btn" id="use-sample-btn">Use the sample project</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PIPELINE STRIP — B4 surface / T5.3
|
||||
Four counts from /api/wps/metrics, each one a link into the dashboard
|
||||
filtered to that slice. The URL carries the filter (?view=dashboard&
|
||||
flag=…), so a cell can be sent to someone rather than only clicked.
|
||||
aria-live because it refreshes in place when the project changes. -->
|
||||
<section class="pipeline" id="pipeline" hidden aria-labelledby="pipeline-head">
|
||||
<h2 class="pipeline-head" id="pipeline-head">Work package pipeline</h2>
|
||||
<div class="pipeline-strip" id="pipeline-strip" aria-live="polite" aria-busy="true"></div>
|
||||
</section>
|
||||
|
||||
<!-- TOOL CARDS (shown once a project is active) -->
|
||||
<div class="cards-grid" id="overview" style="display:none">
|
||||
@@ -349,35 +520,42 @@
|
||||
<a href="work-package-suite.html?tab=sop" class="card" id="card-sop">
|
||||
<h3>SOP Configuration</h3>
|
||||
<p>Define the project baseline in 10 steps — team, sign-offs, WP types, governance, quality, platforms, sequence, constraints, and sources. Every Work Package inherits these defaults.</p>
|
||||
<button class="card-button" id="card-sop-btn">Open Tool</button>
|
||||
<button class="card-button" id="card-sop-btn">Open tool</button>
|
||||
</a>
|
||||
|
||||
<!-- WP CREATOR -->
|
||||
<a href="work-package-suite.html?tab=wp" class="card" id="card-wp">
|
||||
<h3>Work Package Creator</h3>
|
||||
<p>Author individual Work Packages against the project SOP — pre-populated defaults, constraint checklists, and exportable IWPs. Complete the SOP first to unlock.</p>
|
||||
<button class="card-button" id="card-wp-btn">Open Tool</button>
|
||||
<button class="card-button" id="card-wp-btn">Open tool</button>
|
||||
</a>
|
||||
|
||||
<!-- WP DASHBOARD -->
|
||||
<a href="work-package-suite.html?view=dashboard" class="card" id="card-dash">
|
||||
<h3>Work Package Dashboard</h3>
|
||||
<p>Track status and gating across every Work Package — release-readiness, on-hold packages, overdue work, hours, and breakdowns by status and discipline. Issue release-ready packages in one click.</p>
|
||||
<button class="card-button" id="card-dash-btn">Open Dashboard</button>
|
||||
<button class="card-button" id="card-dash-btn">Open dashboard</button>
|
||||
</a>
|
||||
|
||||
<!-- FIELD VIEW -->
|
||||
<a href="field.html" class="card" id="card-field">
|
||||
<h3>Field View</h3>
|
||||
<p>A phone-friendly view for the work face — update status, clear constraints, and log photos and notes. Installable to a home screen; works offline and syncs when you're back on network.</p>
|
||||
<button class="card-button" id="card-field-btn">Open Field View</button>
|
||||
<button class="card-button" id="card-field-btn">Open field view</button>
|
||||
</a>
|
||||
|
||||
<!-- USER DIRECTORY -->
|
||||
<a href="users.html" class="card" id="card-users">
|
||||
<h3>User Directory</h3>
|
||||
<p>Who is on this project — names, job functions and how to reach them. Administrators and Project Super Users also create accounts, set permissions and grant project access from here.</p>
|
||||
<button class="card-button" id="card-users-btn">Open directory</button>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- COMMENTS SECTION -->
|
||||
<div class="comments-section" id="comments">
|
||||
<button class="comments-toggle" onclick="toggleComments()">Leave Feedback</button>
|
||||
<button class="comments-toggle" onclick="toggleComments()">Leave feedback</button>
|
||||
<div class="comments-panel" id="comments-panel">
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="font-weight: 600; font-size: 13px; color: var(--cds-text-primary);">Name (optional)</label>
|
||||
@@ -400,27 +578,81 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<!-- FOOTER — A7 / T6.5
|
||||
Was "Work Package Suite v1.0 | Prime Controls - Business Technology Group |
|
||||
Pilot Use Only", which left three questions open: v1.0 of what, who is
|
||||
Business Technology Group to this page, and what does Pilot Use Only
|
||||
restrict. Each line now says one thing and says whose it is. -->
|
||||
<footer class="footer">
|
||||
<p>Work Package Suite v1.0 | Prime Controls - Business Technology Group | Pilot Use Only</p>
|
||||
<p><strong>Work Package Suite</strong> — built and maintained by the Prime Controls
|
||||
Business Technology Group.</p>
|
||||
<p><span class="footer-tag">Pilot</span> This is a pilot release. Work packages created
|
||||
here are real and are kept, but the tools around them are still changing.</p>
|
||||
</footer>
|
||||
|
||||
<script src="feedback-config.js"></script>
|
||||
<script src="project-data.js"></script>
|
||||
<script src="help.js"></script>
|
||||
<script src="wp-dialog.js"></script>
|
||||
<script>
|
||||
// ── PROJECT SELECTION ─────────────────────────────────────────────────────
|
||||
const esc = ProjectData.esc;
|
||||
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(){
|
||||
renderArchivedProjects();
|
||||
ProjectData.list().then(list => {
|
||||
_projects = list || [];
|
||||
// A deep link names the project explicitly, and every other page in the
|
||||
// suite already honours ?project=<id>. This one did not, so arriving here
|
||||
// with a link on a browser that had nothing stored showed "Select a
|
||||
// project" while the URL said otherwise. Honour it before reconciling.
|
||||
const wanted = new URLSearchParams(location.search).get('project');
|
||||
if(wanted){
|
||||
const target = _projects.find(p => p.id === wanted);
|
||||
if(target) ProjectData.setActive(target);
|
||||
}
|
||||
// Reconcile the active project against the list; clear if it's gone.
|
||||
const active = ProjectData.getActive();
|
||||
const dropped = (active && !_projects.some(p => p.id === active.id)) ? active : null;
|
||||
// S3: if a project is active but the URL does not say so, make the URL say
|
||||
// so — with replace, not push, because the user did not navigate here. This
|
||||
// is what makes "copy the address bar" produce a link that lands somebody
|
||||
// else on the same project rather than on whatever they last had open.
|
||||
if(active && typeof WPUrl !== 'undefined' && WPUrl.get('project') !== active.id){
|
||||
WPUrl.replace({ project: active.id });
|
||||
}
|
||||
// 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);
|
||||
renderProjectPicker();
|
||||
_listLoaded = true;
|
||||
renderProjectEntry();
|
||||
applyActiveProject();
|
||||
// "No longer in the list" used to mean one thing — deleted. Now it also
|
||||
// means archived, and resetting someone to "Select a project" with no word
|
||||
@@ -434,7 +666,7 @@
|
||||
// taken away) fails here, and that case genuinely has nothing to say.
|
||||
ProjectData.get(p.id).then(full => {
|
||||
if(!full || !full.archived) return;
|
||||
const box = document.getElementById('project-picker');
|
||||
const box = document.getElementById('proj-status');
|
||||
if(!box || document.getElementById('proj-archived-note')) return;
|
||||
const note = document.createElement('div');
|
||||
note.id = 'proj-archived-note';
|
||||
@@ -446,55 +678,86 @@
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function createFormHtml(){
|
||||
return `<div class="proj-form" id="proj-form" style="display:none">
|
||||
<div class="proj-form-grid">
|
||||
<label>Project Name *<input type="text" id="np_name" placeholder="e.g. Micron — INC Construction"></label>
|
||||
<label>Project Number<input type="text" id="np_number" placeholder="e.g. 26-67-008"></label>
|
||||
<label>Client<input type="text" id="np_client" placeholder="e.g. Micron Technology, Inc."></label>
|
||||
<label>Division<input type="text" id="np_division" placeholder="e.g. Semiconductor"></label>
|
||||
<label>Site / Location<input type="text" id="np_site" placeholder="e.g. Boise, ID — Fab"></label>
|
||||
</div>
|
||||
<div class="proj-actions">
|
||||
<button class="card-button" onclick="saveNewProject()">Create & Select</button>
|
||||
<button class="close-btn" onclick="hideCreateProject()">Cancel</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
// ── PROJECT ENTRY POINTS (B3 / T5.2) ──────────────────────────────────────
|
||||
// Three states, one visible at a time. The picker card that used to render
|
||||
// here is gone; the app bar's switcher does that job on every page, and this
|
||||
// page keeps the two things a dropdown could not do.
|
||||
//
|
||||
// ORDER MATTERS, and it is the whole reason B3 exists: the first-run empty
|
||||
// state used to live INSIDE the picker card. Deleting the card without
|
||||
// building this first would have left a brand-new account on a page whose
|
||||
// only instruction was to choose from a list with nothing in it.
|
||||
const $ = id => document.getElementById(id);
|
||||
let _createOpenedManually = false;
|
||||
let _focusCreateOnRender = false;
|
||||
// Nothing renders until the list is in. "No projects yet" is a claim, and
|
||||
// making it while the request is still in flight would show every new-account
|
||||
// page to every returning user for a beat.
|
||||
let _listLoaded = false;
|
||||
|
||||
function renderProjectPicker(){
|
||||
const box = document.getElementById('project-picker');
|
||||
const activeId = ProjectData.getActiveId();
|
||||
if(!_projects.length){
|
||||
box.innerHTML = `<div class="proj-empty">
|
||||
<p>No projects yet. Create your first project, or start from a sample.</p>
|
||||
<div class="proj-actions">
|
||||
<button class="card-button" onclick="showCreateProject()">+ Create Project</button>
|
||||
<button class="close-btn" onclick="useSampleProject()">Use Sample Project</button>
|
||||
</div>
|
||||
</div>` + createFormHtml();
|
||||
return;
|
||||
function show(id, on){ const el = $(id); if(el) el.hidden = !on; }
|
||||
|
||||
function renderProjectEntry(){
|
||||
if(!_listLoaded) return;
|
||||
const status = $('proj-status');
|
||||
const loading = status && status.querySelector('.proj-loading');
|
||||
if(loading) loading.remove();
|
||||
|
||||
const firstRun = !_projects.length;
|
||||
const active = ProjectData.getActive();
|
||||
|
||||
show('first-run', firstRun);
|
||||
show('sample-offer', firstRun);
|
||||
// On first run the form IS the page — there is nothing else to do here, so
|
||||
// hiding it behind a button would be one click of ceremony in front of the
|
||||
// only path forward.
|
||||
show('new-project', firstRun || _createOpenedManually);
|
||||
show('pick-prompt', !firstRun && !active && !_createOpenedManually);
|
||||
|
||||
const head = $('new-project-head');
|
||||
if(head) head.textContent = firstRun ? 'Create your first project' : 'Create a project';
|
||||
// Nothing to cancel back to on first run.
|
||||
show('np-cancel', !firstRun);
|
||||
|
||||
if(_focusCreateOnRender && !$('new-project').hidden){
|
||||
_focusCreateOnRender = false;
|
||||
const n = $('np_name');
|
||||
if(n){ n.focus(); n.scrollIntoView({behavior:'smooth', block:'center'}); }
|
||||
}
|
||||
const opts = _projects.map(p =>
|
||||
`<option value="${esc(p.id)}" ${p.id===activeId?'selected':''}>${esc(p.name||'(unnamed)')}${p.number?' — '+esc(p.number):''}${p.sample?' [sample]':''}</option>`
|
||||
).join('');
|
||||
box.innerHTML = `<div class="proj-row">
|
||||
<select id="project-select" onchange="selectProject(this.value)">
|
||||
<option value="">Select a project…</option>${opts}
|
||||
</select>
|
||||
<button class="card-button" onclick="showCreateProject()">+ New</button>
|
||||
<button class="close-btn" onclick="useSampleProject()">Sample</button>
|
||||
</div>
|
||||
<div id="active-project-info"></div>` + createFormHtml();
|
||||
}
|
||||
|
||||
function showCreateProject(){ const f=document.getElementById('proj-form'); if(f){ f.style.display=''; const n=document.getElementById('np_name'); if(n) n.focus(); } }
|
||||
function hideCreateProject(){ const f=document.getElementById('proj-form'); if(f) f.style.display='none'; }
|
||||
function showCreateProject(){
|
||||
_createOpenedManually = true;
|
||||
_focusCreateOnRender = true;
|
||||
renderProjectEntry();
|
||||
}
|
||||
function hideCreateProject(){
|
||||
_createOpenedManually = false;
|
||||
setFieldError('np_name', '');
|
||||
renderProjectEntry();
|
||||
}
|
||||
|
||||
// An error at the field, associated with aria-describedby and announced —
|
||||
// rather than a native dialog that names no field and highlights nothing.
|
||||
// Same shape T5.8 gives the SOP wizard (C1).
|
||||
function setFieldError(fieldId, message){
|
||||
const field = $(fieldId), box = $(fieldId + '_err');
|
||||
if(box) box.textContent = message || '';
|
||||
if(field){
|
||||
if(message) field.setAttribute('aria-invalid', 'true');
|
||||
else field.removeAttribute('aria-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function saveNewProject(){
|
||||
const v = id => (document.getElementById(id)?.value || '').trim();
|
||||
const v = id => ($(id)?.value || '').trim();
|
||||
const name = v('np_name');
|
||||
if(!name){ alert('Project name is required.'); return; }
|
||||
if(!name){
|
||||
setFieldError('np_name', 'Enter a project name — it is the only field this needs.');
|
||||
$('np_name')?.focus();
|
||||
return;
|
||||
}
|
||||
setFieldError('np_name', '');
|
||||
const p = { name, number:v('np_number'), client:v('np_client'), division:v('np_division'), site:v('np_site'), sample:false };
|
||||
ProjectData.save(p).then(saved => { afterProjectChosen(saved); });
|
||||
}
|
||||
@@ -505,16 +768,50 @@
|
||||
ProjectData.save(Object.assign({}, ProjectData.SAMPLE)).then(saved => { afterProjectChosen(saved); });
|
||||
}
|
||||
|
||||
function selectProject(id){
|
||||
if(!id){ ProjectData.setActive(null); applyActiveProject(); return; }
|
||||
const p = _projects.find(x => x.id === id);
|
||||
if(p){ ProjectData.setActive(p); applyActiveProject(); }
|
||||
// The switcher is wp-chrome.js's, injected into the app bar after this script
|
||||
// runs. Reaching for its button is deliberate: the alternative is a second
|
||||
// project list on this page, which is the card B3 removes.
|
||||
function openProjectSwitcher(){
|
||||
const btn = document.querySelector('.wpc-proj-btn');
|
||||
if(btn){ btn.click(); btn.focus(); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
$('np-form')?.addEventListener('submit', function(e){ e.preventDefault(); saveNewProject(); });
|
||||
$('np-cancel')?.addEventListener('click', hideCreateProject);
|
||||
$('new-project-btn')?.addEventListener('click', showCreateProject);
|
||||
$('use-sample-btn')?.addEventListener('click', useSampleProject);
|
||||
$('open-switcher-btn')?.addEventListener('click', function(){
|
||||
if(!openProjectSwitcher()){
|
||||
// No chrome mounted (it returns early inside an iframe, and it loads
|
||||
// after this file). Say so rather than doing nothing on a click.
|
||||
const sub = $('pick-prompt-sub');
|
||||
if(sub) sub.textContent = 'The project switcher is in the bar at the top of the page.';
|
||||
}
|
||||
});
|
||||
// The app bar's popover links here for "new project". Honour it, so that
|
||||
// link lands on the form rather than on a page that no longer has one.
|
||||
if(location.hash === '#new-project') showCreateProject();
|
||||
});
|
||||
|
||||
// S3: which project you are in is addressable, so a launcher link carries it
|
||||
// and Back returns to the project you were looking at before.
|
||||
function urlSyncProject(id, replace){
|
||||
if(typeof WPUrl === 'undefined') return;
|
||||
(replace ? WPUrl.replace : WPUrl.push).call(WPUrl, { project: id || '' });
|
||||
}
|
||||
|
||||
// selectProject() went with the picker card's <select>. The app bar's switcher
|
||||
// reloads with ?project=<id> rather than swapping state in place, so there is
|
||||
// nothing left on this page that chooses a project without navigating.
|
||||
|
||||
function afterProjectChosen(p){
|
||||
if(!_projects.some(x => x.id === p.id)) _projects.unshift(p);
|
||||
ProjectData.setActive(p);
|
||||
renderProjectPicker();
|
||||
_createOpenedManually = false;
|
||||
urlSyncProject(p.id, false);
|
||||
renderProjectEntry();
|
||||
applyActiveProject();
|
||||
document.getElementById('overview').scrollIntoView({ behavior:'smooth', block:'start' });
|
||||
}
|
||||
@@ -525,13 +822,17 @@
|
||||
const cards = document.getElementById('overview');
|
||||
const heroTitle = document.getElementById('hero-title');
|
||||
const heroSub = document.getElementById('hero-sub');
|
||||
const info = document.getElementById('active-project-info');
|
||||
|
||||
if(!active){
|
||||
cards.style.display = 'none';
|
||||
const strip = document.getElementById('pipeline');
|
||||
if(strip) strip.hidden = true;
|
||||
heroTitle.textContent = 'Work Package Suite';
|
||||
heroSub.textContent = 'Select a project to begin — or create one.';
|
||||
if(info) info.innerHTML = '';
|
||||
// Two different situations, and telling someone to "select a project"
|
||||
// when there are none to select is the thing B3 is about.
|
||||
heroSub.textContent = _projects.length
|
||||
? 'Choose a project to begin — or create one.'
|
||||
: 'Create your first project to begin.';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -545,27 +846,112 @@
|
||||
cards.style.display = '';
|
||||
heroTitle.textContent = active.name || 'Work Package Suite';
|
||||
heroSub.textContent = [active.number, active.client, active.site].filter(Boolean).join(' · ') || 'Active project';
|
||||
if(info) info.innerHTML = `<div class="proj-active">✓ Active project: <strong>${esc(active.name||'')}</strong>${active.number?' ('+esc(active.number)+')':''}
|
||||
<button class="link-like" onclick="clearActiveProject()">change</button></div>`;
|
||||
|
||||
// Pull the project's shared SOP/WPs from the server into the local cache
|
||||
// first, so the SOP "Complete / Review" status reflects what other users did.
|
||||
if(ProjectData.pullProject){ ProjectData.pullProject(active.id).then(()=>reflectSOPStatus(active)).catch(()=>reflectSOPStatus(active)); }
|
||||
else reflectSOPStatus(active);
|
||||
// Pull the project's shared SOP/WPs into the local cache so the tools boot
|
||||
// with data. The card status below does NOT come from that cache — see
|
||||
// reflectSOPStatus.
|
||||
if(ProjectData.pullProject){ ProjectData.pullProject(active.id).catch(()=>{}); }
|
||||
reflectSOPStatus(active);
|
||||
renderPipeline(active);
|
||||
}
|
||||
|
||||
function clearActiveProject(){ ProjectData.setActive(null); renderProjectPicker(); applyActiveProject(); }
|
||||
// ── PIPELINE STRIP (B4 surface / T5.3) ────────────────────────────────────
|
||||
// Four counts, all four from /api/wps/metrics. There is deliberately no cache
|
||||
// fallback: B4's whole point is that a per-browser number which looks
|
||||
// authoritative is worse than no number, and the same is true of a remembered
|
||||
// one shown next to live ones.
|
||||
//
|
||||
// The flags match wp-creation-app.js's DASH_FLAGS. A cell linking to a filter
|
||||
// the dashboard does not recognise is a dead link that still looks live.
|
||||
const PIPE_CELLS = [
|
||||
{flag: '', cls: 'is-total', label: 'Work packages', key: 'total',
|
||||
sub: 'everything on this project'},
|
||||
{flag: 'ready', cls: 'is-ready', label: 'Release ready', key: 'release_ready',
|
||||
sub: 'no open constraints, nothing waiting'},
|
||||
{flag: 'onhold', cls: 'is-onhold', label: 'On hold', key: 'on_hold',
|
||||
sub: 'raised as an issue'},
|
||||
{flag: 'overdue', cls: 'is-overdue', label: 'Overdue', key: 'overdue',
|
||||
sub: 'past due and not closed'},
|
||||
];
|
||||
|
||||
// Reflect SOP completion on the tool cards (scoped to the active project).
|
||||
// B7/T7.1: this pointed at the suite page, which loaded the dashboard into an
|
||||
// iframe and forwarded the filter across the boundary because the frame's src
|
||||
// carried only the project. The creator is a page, so link at it directly.
|
||||
// The old address still resolves - the suite page forwards it - but a link
|
||||
// built today should not need forwarding.
|
||||
function dashHref(projectId, flag){
|
||||
const q = new URLSearchParams({ view: 'dashboard', project: projectId });
|
||||
if(flag) q.set('flag', flag);
|
||||
return 'wp-creation-index.html?' + q.toString();
|
||||
}
|
||||
|
||||
function renderPipeline(active){
|
||||
const host = document.getElementById('pipeline');
|
||||
const strip = document.getElementById('pipeline-strip');
|
||||
if(!host || !strip) return;
|
||||
host.hidden = false;
|
||||
strip.setAttribute('aria-busy', 'true');
|
||||
strip.innerHTML = '<div class="pipe-loading">Counting work packages…</div>';
|
||||
|
||||
fetch('/api/wps/metrics?project_id=' + encodeURIComponent(active.id),
|
||||
{ headers: { 'Accept': 'application/json' } })
|
||||
.then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(m => {
|
||||
if(ProjectData.getActiveId() !== active.id) return; // switched while in flight
|
||||
strip.setAttribute('aria-busy', 'false');
|
||||
if(!m.total){
|
||||
// Four zeros on a project with no work packages is not a reading, it
|
||||
// is a strip that looks broken. Say the true thing instead.
|
||||
strip.innerHTML = `<div class="pipe-empty">No work packages on this project yet.
|
||||
<a href="${esc(dashHref(active.id, ''))}">Open the creator</a> to write the first one.</div>`;
|
||||
return;
|
||||
}
|
||||
strip.innerHTML = PIPE_CELLS.map(c => `
|
||||
<a class="pipe-cell ${c.cls}" href="${esc(dashHref(active.id, c.flag))}">
|
||||
<span class="pipe-num">${Number(m[c.key] || 0)}</span>
|
||||
<span class="pipe-label">${esc(c.label)}</span>
|
||||
<span class="pipe-sub">${esc(c.sub)}</span>
|
||||
</a>`).join('');
|
||||
})
|
||||
.catch(err => {
|
||||
if(ProjectData.getActiveId() !== active.id) return;
|
||||
strip.setAttribute('aria-busy', 'false');
|
||||
strip.innerHTML = `<div class="pipe-error">⚠ Could not load work package counts —
|
||||
${esc((err && err.message) || 'offline')}. These numbers come from the server;
|
||||
nothing stale is shown in their place.</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
function clearActiveProject(){ ProjectData.setActive(null); urlSyncProject('', false); renderProjectEntry(); applyActiveProject(); }
|
||||
|
||||
// Back / Forward between projects.
|
||||
if(typeof WPUrl !== 'undefined'){
|
||||
WPUrl.onChange(function(state, viaPop){
|
||||
if(!viaPop) return;
|
||||
const want = state.project || '';
|
||||
if(want === (ProjectData.getActiveId() || '')) return;
|
||||
if(!want){ ProjectData.setActive(null); }
|
||||
else {
|
||||
const p = (_projects||[]).find(x=>x.id===want);
|
||||
ProjectData.setActive(p || { id: want });
|
||||
}
|
||||
renderProjectEntry(); applyActiveProject();
|
||||
});
|
||||
}
|
||||
|
||||
// Reflect SOP completion on the tool cards (B4).
|
||||
//
|
||||
// This used to read `wp_suite_sop_complete` out of localStorage. That key is a
|
||||
// per-browser mirror of the server, so the card answered "has THIS browser seen
|
||||
// the SOP completed", not "is the SOP complete" — and the two diverge the moment
|
||||
// a colleague finishes the SOP on their own machine. The card said "Complete SOP
|
||||
// first" and nothing indicated the answer was stale, which is the failure mode
|
||||
// B4 describes: a per-browser number that looks authoritative.
|
||||
//
|
||||
// There is deliberately no cache fallback. If the server cannot be reached the
|
||||
// card says so; it does not guess, and it does not show a remembered answer as
|
||||
// though it were current.
|
||||
function reflectSOPStatus(active){
|
||||
let complete = false, projName = '';
|
||||
try {
|
||||
// Storage is namespaced per project, so these already scope to `active`.
|
||||
complete = localStorage.getItem(ProjectData.key('wp_suite_sop_complete')) === '1';
|
||||
const sop = JSON.parse(localStorage.getItem(ProjectData.key('wp_suite_sop')) || 'null');
|
||||
projName = sop && sop.project && sop.project.name || '';
|
||||
} catch(e){}
|
||||
|
||||
const sopCard = document.getElementById('card-sop');
|
||||
const sopBtn = document.getElementById('card-sop-btn');
|
||||
const wpCard = document.getElementById('card-wp');
|
||||
@@ -575,20 +961,75 @@
|
||||
// reset (re-render can run multiple times)
|
||||
sopCard.classList.remove('complete');
|
||||
wpCard && wpCard.classList.remove('disabled');
|
||||
const oldStatus = sopCard.querySelector('.card-status'); if(oldStatus) oldStatus.remove();
|
||||
|
||||
if(complete){
|
||||
sopCard.classList.add('complete');
|
||||
sopBtn.textContent = 'Review';
|
||||
const status = document.createElement('div');
|
||||
status.className = 'card-status';
|
||||
status.textContent = '✓ SOP Complete' + (projName ? ' — ' + projName : '');
|
||||
sopCard.insertBefore(status, sopCard.firstChild);
|
||||
if(wpBtn) wpBtn.textContent = 'Open Creator';
|
||||
} else {
|
||||
if(wpCard) wpCard.classList.add('disabled');
|
||||
if(wpBtn) wpBtn.textContent = 'Complete SOP first';
|
||||
}
|
||||
// One status line per card, replaced in place rather than removed and
|
||||
// re-added — a card that briefly has no status line reads as "no status",
|
||||
// which is one of the three real answers and must not be shown by accident.
|
||||
//
|
||||
// role="status" so the change is announced: the line is written by a fetch
|
||||
// that lands after the page has settled, which is exactly the case
|
||||
// aria-live exists for (S10 / T4.5). Polite, because a project's SOP being
|
||||
// incomplete is information, not an interruption.
|
||||
const cardStatus = (card, text, cls) => {
|
||||
if(!card) return null;
|
||||
let el = card.querySelector('.card-status');
|
||||
if(!el){
|
||||
el = document.createElement('div');
|
||||
el.className = 'card-status';
|
||||
el.setAttribute('role', 'status');
|
||||
card.insertBefore(el, card.firstChild);
|
||||
}
|
||||
el.className = 'card-status' + (cls ? ' ' + cls : '');
|
||||
el.textContent = text;
|
||||
return el;
|
||||
};
|
||||
const setStatus = (text, cls) => cardStatus(sopCard, text, cls);
|
||||
const setWpStatus = (text, cls) => cardStatus(wpCard, text, cls);
|
||||
|
||||
// A7 / T6.5. Two things were doing one job badly.
|
||||
//
|
||||
// The SOP card said "✓ SOP complete" when it was and NOTHING when it was
|
||||
// not, so the most common state on a live project was the one with no
|
||||
// status line at all. And the Work Package card carried its status in its
|
||||
// BUTTON — "Complete SOP first", "Checking…" — which is a button describing
|
||||
// a state instead of naming what pressing it does.
|
||||
//
|
||||
// Now: every card says its state in its own status line, in all three
|
||||
// states, and every button says what it does. The buttons no longer change
|
||||
// text at all.
|
||||
sopBtn.textContent = 'Open tool';
|
||||
if(wpBtn) wpBtn.textContent = 'Open creator';
|
||||
setStatus('Checking the SOP…', 'card-status-pending');
|
||||
setWpStatus('Checking the SOP…', 'card-status-pending');
|
||||
|
||||
fetch('/api/projects/' + encodeURIComponent(active.id) + '/summary',
|
||||
{ headers: { 'Accept': 'application/json' } })
|
||||
.then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(sum => {
|
||||
if(ProjectData.getActiveId() !== active.id) return; // switched while in flight
|
||||
if(sum.sop_complete){
|
||||
sopCard.classList.add('complete');
|
||||
sopBtn.textContent = 'Review';
|
||||
setStatus('✓ Complete' + (sum.sop_name ? ' — ' + sum.sop_name : ''));
|
||||
setWpStatus('✓ Ready — the SOP is complete');
|
||||
} else {
|
||||
if(wpCard) wpCard.classList.add('disabled');
|
||||
setStatus('• Not finished — the project baseline is still being set up',
|
||||
'card-status-pending');
|
||||
setWpStatus('• Not available yet — finish the SOP first',
|
||||
'card-status-pending');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
if(ProjectData.getActiveId() !== active.id) return;
|
||||
// Explicitly unknown, and it looks like neither of the other two. The
|
||||
// Creator is left reachable rather than disabled: locking somebody out
|
||||
// of their work because a status request failed is worse than letting
|
||||
// the tool tell them itself.
|
||||
const why = '⚠ Unknown — could not reach the server (' + ((err && err.message) || 'offline') + ')';
|
||||
setStatus(why, 'card-status-error');
|
||||
setWpStatus(why, 'card-status-error');
|
||||
});
|
||||
}
|
||||
|
||||
initProjects();
|
||||
@@ -606,7 +1047,7 @@
|
||||
const text = document.getElementById('comment-text').value.trim();
|
||||
|
||||
if (!text) {
|
||||
alert('Please enter feedback.');
|
||||
toast('Please enter feedback.', 'alert');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -627,7 +1068,7 @@
|
||||
function exportFeedback() {
|
||||
const saved = localStorage.getItem('wp_suite_index_comments');
|
||||
const data = saved ? JSON.parse(saved) : [];
|
||||
if (!data.length) { alert('No feedback to export yet.'); return; }
|
||||
if (!data.length) { toast('No feedback to export yet.', 'alert'); return; }
|
||||
const payload = { app: 'Work Package Suite', source: 'home', exportedAt: new Date().toISOString(), comments: data };
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
@@ -645,7 +1086,7 @@
|
||||
try {
|
||||
const inc = JSON.parse(r.result);
|
||||
const incoming = Array.isArray(inc) ? inc : (inc.comments || []);
|
||||
if (!incoming.length) { alert('No feedback found in that file.'); return; }
|
||||
if (!incoming.length) { toast('No feedback found in that file.', 'alert'); return; }
|
||||
const saved = localStorage.getItem('wp_suite_index_comments');
|
||||
allComments = saved ? JSON.parse(saved) : [];
|
||||
const seen = new Set(allComments.map(c => c.timestamp + '|' + c.text));
|
||||
@@ -653,8 +1094,8 @@
|
||||
incoming.forEach(c => { const k = c.timestamp + '|' + c.text; if (c.text && !seen.has(k)) { allComments.push(c); seen.add(k); added++; } });
|
||||
localStorage.setItem('wp_suite_index_comments', JSON.stringify(allComments));
|
||||
loadComments();
|
||||
alert('Imported ' + added + ' feedback item' + (added === 1 ? '' : 's') + '.');
|
||||
} catch (e) { alert('Could not read that file.'); }
|
||||
toast('Imported ' + added + ' feedback item' + (added === 1 ? '' : 's') + '.');
|
||||
} catch (e) { toast('Could not read that file.', 'alert'); }
|
||||
ev.target.value = '';
|
||||
};
|
||||
r.readAsText(f);
|
||||
@@ -692,5 +1133,6 @@
|
||||
}
|
||||
</script>
|
||||
<script src="wp-chrome.js"></script>
|
||||
<script src="wp-sidenav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
button:disabled { background: var(--cds-disabled-02); cursor: not-allowed; }
|
||||
.error {
|
||||
display: none;
|
||||
background: #fff1f1;
|
||||
background: var(--wp-status-error-bg);
|
||||
border-left: 3px solid var(--cds-support-error);
|
||||
color: var(--cds-text-error);
|
||||
padding: 0.75rem;
|
||||
@@ -72,9 +72,9 @@
|
||||
.foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; }
|
||||
.ok {
|
||||
display: none;
|
||||
background: #defbe6;
|
||||
background: var(--wp-status-success-bg);
|
||||
border-left: 3px solid var(--cds-support-success);
|
||||
color: #0e6027;
|
||||
color: var(--wp-hover-success);
|
||||
padding: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
margin-bottom: 1.25rem;
|
||||
|
||||
@@ -24,6 +24,16 @@
|
||||
}
|
||||
function cacheRemove(id) { writeLocal(readLocal().filter(function (x) { return x.id !== id; })); }
|
||||
|
||||
// Subscribers to the active project. Deliberately a plain array and a plain
|
||||
// callback — this is one value with a handful of readers, not a reason for a
|
||||
// state library. A throwing subscriber must not stop the others being told.
|
||||
var activeSubs = [];
|
||||
function notifyActive(p) {
|
||||
activeSubs.slice().forEach(function (fn) {
|
||||
try { fn(p); } catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
var SAMPLE_PROJECT = {
|
||||
name: 'Micron FMCS Install (sample)', number: '26-67-008',
|
||||
client: 'Micron Technology, Inc.', division: 'Semiconductor',
|
||||
@@ -42,6 +52,15 @@
|
||||
.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) {
|
||||
return fetch(API + '/projects/' + encodeURIComponent(id))
|
||||
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||
@@ -78,13 +97,59 @@
|
||||
// implementation of it rather than two that can disagree.
|
||||
|
||||
// ── active project context ────────────────────────────────────────────────
|
||||
// setActive is the ONLY thing in the app that writes LS_ACTIVE / LS_ACTIVE_OBJ.
|
||||
// Everything that displays the active project reads it back through getActive()
|
||||
// or subscribes with onActiveChange(). Keep it that way: F1 was two readers with
|
||||
// their own copies, and the global one lost.
|
||||
getActiveId: function () { try { return localStorage.getItem(LS_ACTIVE) || ''; } catch (e) { return ''; } },
|
||||
getActive: function () { try { return JSON.parse(localStorage.getItem(LS_ACTIVE_OBJ) || 'null'); } catch (e) { return null; } },
|
||||
setActive: function (p) {
|
||||
try {
|
||||
if (p) { localStorage.setItem(LS_ACTIVE, p.id); localStorage.setItem(LS_ACTIVE_OBJ, JSON.stringify(p)); }
|
||||
else { localStorage.removeItem(LS_ACTIVE); localStorage.removeItem(LS_ACTIVE_OBJ); }
|
||||
if (p) {
|
||||
// Several callers know only the id — a deep link resolving before the
|
||||
// record arrives (field.js, wp-creation-app.js, work-package-suite-app.js
|
||||
// all call setActive({id}) first and the full record second). Writing that
|
||||
// stub verbatim erases the name, and the app bar then renders "(unnamed)".
|
||||
// Merging keeps the fuller record; fields the caller does supply still win.
|
||||
var prev = this.getActive();
|
||||
if (prev && prev.id === p.id) p = Object.assign({}, prev, p);
|
||||
localStorage.setItem(LS_ACTIVE, p.id);
|
||||
localStorage.setItem(LS_ACTIVE_OBJ, JSON.stringify(p));
|
||||
} else {
|
||||
localStorage.removeItem(LS_ACTIVE);
|
||||
localStorage.removeItem(LS_ACTIVE_OBJ);
|
||||
}
|
||||
} catch (e) {}
|
||||
notifyActive(p || null);
|
||||
|
||||
// A caller that knew only the id leaves the store holding a stub, and every
|
||||
// reader then renders "(unnamed)" — field.js sets {id} on boot and resolves the
|
||||
// record into a variable of its own. Fill the stub in from the cached list, or
|
||||
// from the API when the cache has not loaded yet. The re-entry carries a name,
|
||||
// so it cannot loop; the id re-check stops a slow response from overwriting a
|
||||
// project the user has since switched to.
|
||||
if (p && p.id && !p.name) {
|
||||
var self = this;
|
||||
var cached = readLocal().filter(function (x) { return x.id === p.id; })[0];
|
||||
if (cached && cached.name) { self.setActive(cached); return; }
|
||||
try {
|
||||
if (self.get) {
|
||||
self.get(p.id).then(function (full) {
|
||||
if (full && full.name && self.getActiveId() === full.id) self.setActive(full);
|
||||
}).catch(function () {});
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
},
|
||||
|
||||
// Subscribe to active-project changes. Returns an unsubscribe function.
|
||||
// The app bar uses this instead of holding its own copy of the value.
|
||||
onActiveChange: function (fn) {
|
||||
if (typeof fn !== 'function') return function () {};
|
||||
activeSubs.push(fn);
|
||||
return function () {
|
||||
activeSubs = activeSubs.filter(function (f) { return f !== fn; });
|
||||
};
|
||||
},
|
||||
|
||||
// Per-project namespacing for the SOP/WP localStorage keys, e.g.
|
||||
@@ -151,7 +216,11 @@
|
||||
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.state) localStorage.setItem(nsKey('wp_suite_state', projectId), JSON.stringify(d.state));
|
||||
localStorage.setItem(nsKey('wp_suite_sop_complete', projectId), '1');
|
||||
// BL-018: only a row in the shape pushSOP writes counts as complete.
|
||||
// Marking '1' for ANY row meant a malformed record opened the gate.
|
||||
if (d.sop && d.state) {
|
||||
localStorage.setItem(nsKey('wp_suite_sop_complete', projectId), '1');
|
||||
}
|
||||
}
|
||||
}).catch(function () {})
|
||||
);
|
||||
@@ -283,22 +352,33 @@
|
||||
renderSyncBadge(c);
|
||||
}
|
||||
|
||||
// Tiny sync indicator (bottom-left). Rendered only in the top-level window so it
|
||||
// isn't duplicated inside the embedded creator iframe; the top window still sees
|
||||
// the iframe's queue changes via the 'storage' event below.
|
||||
var _isTop = (function () { try { return window.top === window.self; } catch (e) { return true; } })();
|
||||
// Tiny sync indicator (bottom-left). This was gated on being the top-level
|
||||
// window so it was not drawn twice - once by the suite page and again inside
|
||||
// the embedded creator. B7/T7.1 dissolved that frame, so there is one document
|
||||
// and one badge. The 'storage' listener below stays: it is what keeps two
|
||||
// TABS in step, which is a different thing and still happens.
|
||||
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) {
|
||||
if (!_isTop || !document.body) return;
|
||||
if (!document.body) return;
|
||||
var el = document.getElementById('wp-sync-badge');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = 'wp-sync-badge';
|
||||
// S10. Polite: this reports background syncing, and interrupting someone to
|
||||
// say a queue drained is exactly the noise that gets aria-live turned off.
|
||||
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;' +
|
||||
'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);
|
||||
}
|
||||
if (!c) return; // the eager DOMContentLoaded mount: holder only, no state yet
|
||||
if (_badgeHideTimer) { clearTimeout(_badgeHideTimer); _badgeHideTimer = null; }
|
||||
// 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
|
||||
@@ -306,19 +386,28 @@
|
||||
el.style.flexDirection = c.dead ? 'column' : 'row';
|
||||
el.style.alignItems = c.dead ? 'flex-start' : 'center';
|
||||
el.style.maxWidth = c.dead ? 'min(340px, calc(100vw - 32px))' : 'none';
|
||||
// B5. This badge reports the OUTBOX — whether saved records have reached the
|
||||
// project — and it used to say "✓ All changes saved", which is what a draft
|
||||
// autosave says. So the app was already making the promise B5 says it does not
|
||||
// keep: the badge went green when the queue emptied, whether or not anything in
|
||||
// the form had been saved at all.
|
||||
//
|
||||
// Every string here now names the project explicitly. The draft indicator
|
||||
// (WPAutosave.mountIndicator) is the one that speaks for the form, and the two
|
||||
// can no longer be read as each other.
|
||||
if (c.dead) {
|
||||
el.innerHTML = '<span>✕ ' + c.dead + ' change' + (c.dead === 1 ? '' : 's') + ' rejected — 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>' : '');
|
||||
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) {
|
||||
el.textContent = '⚠ ' + c.failed + ' change' + (c.failed === 1 ? '' : 's') + ' not saved — retrying';
|
||||
el.style.color = '#8a6d00'; el.style.borderColor = '#f1c21b'; el.style.background = '#fdf6dd'; el.style.display = 'inline-flex';
|
||||
el.textContent = '⚠ ' + c.failed + ' change' + (c.failed === 1 ? '' : 's') + ' not yet sent to the project — retrying';
|
||||
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) {
|
||||
el.textContent = '↻ Saving ' + c.pending + ' change' + (c.pending === 1 ? '' : 's') + '…';
|
||||
el.style.color = '#525252'; el.style.borderColor = '#e0e0e0'; el.style.background = '#fff'; el.style.display = 'inline-flex';
|
||||
el.textContent = '↻ Sending ' + c.pending + ' change' + (c.pending === 1 ? '' : 's') + ' to the project…';
|
||||
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 {
|
||||
el.textContent = '✓ All changes saved';
|
||||
el.style.color = '#0e6027'; el.style.borderColor = '#a7f0ba'; el.style.background = '#defbe6'; el.style.display = 'inline-flex';
|
||||
el.textContent = '✓ Everything sent to the project';
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -399,5 +488,14 @@
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// A second tab switching project leaves this one showing a project the user is no
|
||||
// longer on. The storage event fires only in OTHER tabs, which is exactly the case
|
||||
// setActive's own notification cannot cover.
|
||||
try {
|
||||
global.addEventListener('storage', function (e) {
|
||||
if (e.key === LS_ACTIVE_OBJ || e.key === LS_ACTIVE) notifyActive(ProjectData.getActive());
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
global.ProjectData = ProjectData;
|
||||
})(window);
|
||||
|
||||
33
html/sw.js
@@ -14,15 +14,21 @@
|
||||
'use strict';
|
||||
// Bumped when the shell file list changes, so clients fetch the new assets
|
||||
// instead of serving a half-old shell from the previous cache.
|
||||
const CACHE = 'wp-suite-shell-v5';
|
||||
const CACHE = 'wp-suite-shell-v6';
|
||||
// CR-007/D8: uploaded drawings, cached at first fetch so an assigned package's
|
||||
// sheets open with no network. The CLIENT decides what gets fetched (field.js
|
||||
// prefetches only the requesting user's assigned packages); this worker just
|
||||
// keeps whatever came through. Never precached - a fresh sign-in starts empty.
|
||||
const DRAWINGS = 'wp-suite-drawings-v1';
|
||||
const SHELL = [
|
||||
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
|
||||
'/field.html', '/login.html', '/admin.html',
|
||||
'/field.html', '/login.html', '/admin.html', '/users.html',
|
||||
'/theme-light.css', '/work-package-suite-styles.css', '/wp-creation-styles.css',
|
||||
'/wp-chrome.css',
|
||||
'/wp-chrome.css', '/console.css', '/wp-sidenav.css',
|
||||
'/auth-guard.js', '/project-data.js', '/feedback-config.js', '/help.js',
|
||||
'/work-package-suite-app.js', '/wp-creation-app.js', '/field.js',
|
||||
'/wp-chrome.js', '/wp-format.js', '/login.js', '/admin.js',
|
||||
'/wp-chrome.js', '/wp-sidenav.js', '/wp-format.js', '/login.js',
|
||||
'/console-util.js', '/admin.js', '/users.js',
|
||||
'/prime-controls-logo.jpg', '/favicon.ico',
|
||||
'/manifest.webmanifest', '/icon-192.png', '/icon-512.png',
|
||||
];
|
||||
@@ -42,7 +48,7 @@ self.addEventListener('install', (e) => {
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE && k !== DRAWINGS).map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
@@ -54,7 +60,8 @@ self.addEventListener('activate', (e) => {
|
||||
// Why not cache-first for code: these files reference each other, and the cache
|
||||
// stores them as independent entries. Cache-first served whichever copy of each file
|
||||
// happened to be stored, so a browser could run new HTML against old CSS — which is
|
||||
// exactly how the embedded creator once collapsed to a 300x150 iframe. A page must
|
||||
// exactly how the creator once collapsed to a 300x150 box when it was an iframe
|
||||
// and its stylesheet was a version behind its markup. A page must
|
||||
// only ever run against the stylesheet and scripts it shipped with.
|
||||
const CODE_RE = /\.(html|css|js)$|\/$/i;
|
||||
|
||||
@@ -63,12 +70,24 @@ self.addEventListener('fetch', (e) => {
|
||||
if (req.method !== 'GET') return; // outbox owns writes
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return; // third-party: default
|
||||
// Drawing bytes are immutable once uploaded (edits replace the row id), so
|
||||
// cache-first is safe and is what makes them open offline (CR-007/D8).
|
||||
if (url.pathname.startsWith('/api/files/')) {
|
||||
e.respondWith(
|
||||
caches.open(DRAWINGS).then((c) => c.match(req).then((hit) => hit ||
|
||||
fetch(req).then((res) => {
|
||||
if (res && res.ok) c.put(req, res.clone());
|
||||
return res;
|
||||
})))
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (url.pathname.startsWith('/api/')) return; // never cache the API
|
||||
|
||||
const isCode = CODE_RE.test(url.pathname);
|
||||
|
||||
// Cache key WITHOUT the query string. Links inside the app carry ?project=…&tab=…,
|
||||
// and the embedded creator used to carry a cache-busting timestamp, so keying on the
|
||||
// and the creator used to carry a cache-busting timestamp in its frame src, so keying on the
|
||||
// full URL both missed every offline navigation and grew the cache without bound.
|
||||
const key = new Request(url.origin + url.pathname, { credentials: 'same-origin' });
|
||||
const fromCache = () => caches.match(key).then((c) => c || caches.match(req));
|
||||
|
||||
@@ -114,11 +114,231 @@
|
||||
--cds-support-info-inverse: #4589ff;
|
||||
--cds-interactive: #0f62fe;
|
||||
--cds-shadow: rgba(0, 0, 0, .16);
|
||||
/* Carbon's own $highlight, which the page sheets each declared for themselves
|
||||
as --accent-soft / --primary-light / --accent-dim. Real Carbon name and
|
||||
real Carbon value; this sheet simply never carried it. (T3.2 / S5) */
|
||||
--cds-highlight: #edf5ff; /* blue-10 */
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
SUITE TOKENS (--wp-*) — roles IBM Carbon does not define
|
||||
----------------------------------------------------------------------------
|
||||
Everything above is Carbon g10 and should stay that way. Everything here is a
|
||||
value the suite invented because Carbon has no slot for it. Keeping the two
|
||||
namespaces apart is what stops the next person "correcting" #8e6a00 into a
|
||||
Carbon yellow and breaking the warning banners.
|
||||
|
||||
Together with the --cds-* block above, this is the one place a colour,
|
||||
spacing or type value is defined (CLAUDE.md, "The token rule"). Page
|
||||
stylesheets alias these; they declare nothing of their own.
|
||||
|
||||
Full mapping, and the provenance of every value: docs/reference/tokens.md
|
||||
============================================================================ */
|
||||
:root {
|
||||
/* -- status fills and their text ------------------------------------------
|
||||
The greens and reds are Carbon 10-step values; the amber pair is not
|
||||
Carbon at all (Carbon yellow-10 is #fcf4d6). --wp-status-warning-text is
|
||||
the suite's own accessible amber for text sitting on --wp-status-warning-bg. */
|
||||
--wp-status-success-bg: #defbe6;
|
||||
--wp-status-error-bg: #fff1f1;
|
||||
--wp-status-warning-bg: #fdf6dd;
|
||||
--wp-status-warning-text: #8e6a00;
|
||||
/* BL-009, CLOSED at T9.9 (C4): the ninth amber (--wp-status-warning-text-alt,
|
||||
#8a6d00, four points from this one) is deleted; its consumers use this. */
|
||||
/* 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.
|
||||
Declared in no sheet today; written raw in five places. */
|
||||
--wp-hover-success: #0e6027;
|
||||
|
||||
/* -- status borders -------------------------------------------------------
|
||||
Eight values doing four jobs, because two sheets each picked their own.
|
||||
Every one of them renders somewhere today, so T3.2 names all eight rather
|
||||
than choosing four winners — choosing is a visual change. T3.5 decides.
|
||||
See docs/reference/tokens.md section 8-D. */
|
||||
--wp-status-success-border-a: #a7f0ba; /* green-20, Carbon — console */
|
||||
--wp-status-success-border-b: #b6e3c6; /* not Carbon — creator */
|
||||
--wp-status-error-border-a: #ffd7d9; /* red-20, Carbon — console */
|
||||
--wp-status-error-border-b: #f3c4c4; /* not Carbon — creator, release banner */
|
||||
--wp-status-error-border-c: #ffc4c4; /* not Carbon — creator, .crit-tag */
|
||||
--wp-status-warning-border-a: #fddc69; /* yellow-30, Carbon — console */
|
||||
--wp-status-warning-border-b: #f0d9ad; /* not Carbon — creator */
|
||||
--wp-accent-border-a: #b9d2fb; /* neither is Carbon blue-20 (#d0e2ff) */
|
||||
--wp-accent-border-b: #cdd9f2;
|
||||
|
||||
/* -- type ------------------------------------------------------------------
|
||||
Three mono stacks and two sans stacks exist because five sheets each wrote
|
||||
their own. They are NOT interchangeable: on a machine without IBM Plex Mono
|
||||
installed — which is most of the target environment — each falls through to
|
||||
a different face. Unifying them changes what admin and users render, so
|
||||
T3.2 names all five and T3.5 picks. See docs/reference/tokens.md 6d, 8-H. */
|
||||
--wp-font-sans: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||
--wp-font-sans-2: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--wp-font-mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', Consolas, monospace;
|
||||
--wp-font-mono-2: 'IBM Plex Mono', 'Cascadia Mono', Consolas, monospace;
|
||||
--wp-font-mono-3: 'IBM Plex Mono', ui-monospace, Consolas, monospace;
|
||||
|
||||
/* -- spacing ---------------------------------------------------------------
|
||||
Promoted from console.css, which is the only sheet in the repo whose
|
||||
spacing is a scale rather than a histogram. The wizard's rem ladder and the
|
||||
creator's raw px are inventoried in docs/reference/tokens.md 6b and convert
|
||||
as those pages are rebuilt (T5.x, T7.x) — not here, where any change to a
|
||||
padding is a visual change. */
|
||||
--wp-s1: 4px;
|
||||
--wp-s2: 8px;
|
||||
--wp-s3: 12px;
|
||||
--wp-s4: 16px;
|
||||
--wp-s5: 20px;
|
||||
--wp-s6: 28px;
|
||||
|
||||
/* Control heights: --wp-ctl for anything in a form row, --wp-ctl-sm for
|
||||
anything inside a table cell. This is what keeps a toolbar one clean band
|
||||
and a table row ~34px instead of ~100px. */
|
||||
--wp-ctl: 32px;
|
||||
--wp-ctl-sm: 26px;
|
||||
|
||||
/* -- shape and elevation ---------------------------------------------------
|
||||
Square corners are the Carbon idiom and the intent everywhere except the
|
||||
creator, which drifted to 12 different radii (BL-007, owned by T7.1).
|
||||
The two large shadows share their geometry exactly — 0 4px 16px — and
|
||||
differ only in tint, so they can be unified later with no layout
|
||||
consequence. Until then, both are named. */
|
||||
--wp-radius-0: 0;
|
||||
--wp-shadow-none: none;
|
||||
--wp-shadow-lg-neutral: 0 4px 16px rgba(0, 0, 0, .16);
|
||||
--wp-shadow-lg-cool: 0 4px 16px rgba(20, 30, 50, .12);
|
||||
|
||||
/* -- elevation -------------------------------------------------------------
|
||||
Twelve distinct shadows, which is eleven more than a flat Carbon UI wants.
|
||||
They are listed rather than merged for the same reason as the borders: each
|
||||
one renders today and merging is a visual change. That the list is this long
|
||||
is itself the finding — a later pass can collapse it to two or three now
|
||||
that they are all visible in one place. */
|
||||
--wp-shadow-pop: 0 8px 28px rgba(20, 30, 50, .22);
|
||||
--wp-shadow-menu: 0 8px 24px rgba(20, 30, 50, .18);
|
||||
--wp-shadow-menu-lg: 0 10px 26px rgba(20, 30, 50, .18);
|
||||
--wp-shadow-modal: 0 12px 40px rgba(20, 30, 50, .3);
|
||||
--wp-shadow-modal-lg: 0 20px 60px rgba(0, 0, 0, .3);
|
||||
--wp-shadow-gate: 0 8px 30px rgba(20, 30, 50, .12);
|
||||
--wp-shadow-rail: 6px 0 22px rgba(20, 30, 50, .16);
|
||||
--wp-shadow-drawer: 2px 0 16px rgba(0, 0, 0, .4);
|
||||
--wp-shadow-sticky: 0 -2px 10px rgba(20, 30, 50, .08);
|
||||
--wp-shadow-navbar: 0 1px 4px rgba(20, 30, 50, .06);
|
||||
--wp-shadow-tooltip: 0 4px 14px rgba(20, 30, 50, .22);
|
||||
--wp-shadow-toast: 0 6px 24px rgba(0, 0, 0, .25);
|
||||
|
||||
/* -- scrims ----------------------------------------------------------------
|
||||
Five overlay fills in three tints. --cds-overlay (rgba(22,22,22,.5)) is the
|
||||
Carbon one; the drawer's is the same colour 5% darker for no recorded
|
||||
reason, which makes it the cheapest merge in the set — but still a merge,
|
||||
so still not here. See docs/reference/tokens.md section 8-F. */
|
||||
--wp-scrim-drawer: rgba(22, 22, 22, .55);
|
||||
--wp-scrim-cool: rgba(20, 30, 50, .28);
|
||||
--wp-scrim-cool-modal: rgba(20, 30, 50, .5);
|
||||
--wp-scrim-cool-strong: rgba(20, 28, 40, .55);
|
||||
--wp-scrim-neutral: rgba(0, 0, 0, .5);
|
||||
--wp-scrim-loading: rgba(244, 245, 247, .82);
|
||||
--wp-scrim-frosted: rgba(255, 255, 255, .94); /* sticky section-nav backdrop */
|
||||
--wp-on-accent-divider: rgba(255, 255, 255, .28);
|
||||
|
||||
/* -- surfaces that are not Carbon greys ------------------------------------
|
||||
Four neutrals the suite invented, all within a few points of a Carbon step
|
||||
and none of them equal to one. --wp-table-zebra is the dangerous one: it is
|
||||
six points from --cds-layer-accent, and collapsing them erases the striping
|
||||
on the nine-column user table. Section 8-A. */
|
||||
--wp-table-zebra: #fafafa;
|
||||
--wp-table-row-hover: #eef0f2;
|
||||
--wp-nav-bg: #fbfbfc;
|
||||
--wp-nav-hover: #eef0f3;
|
||||
--wp-nav-active: #e8eaed;
|
||||
--wp-pop-divider: #f0f0f0; /* popover hairlines in the shared chrome */
|
||||
--wp-accent-soft-hover: #e2ecfc; /* hover on an --cds-highlight fill */
|
||||
|
||||
/* -- the Project Super User tag --------------------------------------------
|
||||
Carbon purple-10 / purple-60. Real Carbon values with no --cds-* slot in
|
||||
the subset this sheet carries. One consumer; promote into --cds-* if a
|
||||
second ever appears. */
|
||||
--wp-tag-super-bg: #e8daff;
|
||||
--wp-tag-super-text: #6929c4;
|
||||
|
||||
/* -- console output --------------------------------------------------------
|
||||
pre.out is a terminal emulator, not product surface. Green-on-dark pass and
|
||||
red-on-dark fail is the convention being quoted; it should not track the
|
||||
palette and must not be "corrected" to Carbon's support colours. */
|
||||
--wp-term-bg: #0f1525;
|
||||
--wp-term-fg: #d7e0f5;
|
||||
--wp-term-pass: #56d364;
|
||||
--wp-term-fail: #ff7b72;
|
||||
|
||||
/* -- dev mode --------------------------------------------------------------
|
||||
Deliberately outside the palette. The banner is MEANT to look wrong; giving
|
||||
it the product's amber would make it look intentional, which defeats it. */
|
||||
--wp-dev-bg: #3a2a00;
|
||||
--wp-dev-fg: #ffd479;
|
||||
--wp-dev-rule: #ffb000;
|
||||
|
||||
/* -- button roles (A5) -----------------------------------------------------
|
||||
Four roles and no fifth. Every button in the app is one of them, and the
|
||||
class-to-role mapping is in docs/reference/tokens.md section 12.
|
||||
|
||||
primary the one action this screen exists for. Filled accent. At most one
|
||||
per view.
|
||||
secondary every other real action. White fill, --border-strong hairline,
|
||||
accent on hover. This is the app's workhorse.
|
||||
tertiary navigational or undoing. No fill, no border, accent text.
|
||||
danger destructive. Outlined red by default; filled red only where the
|
||||
control is small enough that an outline would not read (the ✕ on
|
||||
a sequence row).
|
||||
|
||||
GREEN IS A STATUS COLOUR AND NEVER FILLS A BUTTON. It says cleared, ready,
|
||||
complete — on a dot, a pill, a banner, a badge. Two buttons broke that rule
|
||||
and are what A5 recorded: "SOP Complete" and "Save & View" shouted louder
|
||||
than every other action in the suite while doing nothing more dangerous than
|
||||
saving. They are primary now. The green did not go anywhere: the launcher
|
||||
card still turns green when its SOP completes, on the border and the status
|
||||
line, which is where a status belongs.
|
||||
|
||||
No value here is new — these are the fills the sheets already rendered,
|
||||
given one definition so that "primary" means one thing. */
|
||||
--wp-btn-primary-bg: var(--cds-interactive-01);
|
||||
--wp-btn-primary-fg: var(--cds-text-on-color);
|
||||
--wp-btn-primary-hover: var(--cds-hover-primary);
|
||||
--wp-btn-primary-active: var(--cds-active-primary);
|
||||
|
||||
--wp-btn-secondary-bg: var(--cds-field);
|
||||
--wp-btn-secondary-fg: var(--cds-text-primary);
|
||||
--wp-btn-secondary-border: var(--cds-border-strong);
|
||||
--wp-btn-secondary-hover-fg: var(--cds-interactive-01);
|
||||
|
||||
--wp-btn-tertiary-fg: var(--cds-interactive-01);
|
||||
--wp-btn-tertiary-hover-fg: var(--cds-hover-primary-text);
|
||||
|
||||
--wp-btn-danger-fg: var(--cds-support-error);
|
||||
--wp-btn-danger-border: var(--cds-support-error);
|
||||
--wp-btn-danger-soft-bg: var(--wp-status-error-bg);
|
||||
--wp-btn-danger-fill-bg: var(--cds-support-error);
|
||||
--wp-btn-danger-fill-fg: var(--cds-text-on-color);
|
||||
|
||||
/* BL-008, CLOSED at T9.9 (C4, approved Aug 18): the second brand blue is
|
||||
gone. .sop-inherited now tints with THE blue at the same 7% alpha. */
|
||||
--wp-sop-inherited-bg: rgba(15, 98, 254, .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 */
|
||||
body {
|
||||
font-family: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||
font-family: var(--wp-font-sans);
|
||||
color: var(--cds-text-primary);
|
||||
background: var(--cds-background);
|
||||
font-size: 1rem;
|
||||
@@ -173,6 +393,10 @@ input, textarea, select {
|
||||
--wp-appbar-border: #6f6f6f; /* outline for ghost buttons on the bar */
|
||||
--wp-appbar-hover: #353535;
|
||||
--wp-appbar-height: 48px;
|
||||
/* Carbon gray-90 — one step up from the bar itself. The raised surfaces that
|
||||
sit ON the dark bar or the drawer: the chrome's search field, the drawer's
|
||||
current row. Its two consumers both hardcoded it before T3.2. */
|
||||
--wp-appbar-layer: #262626;
|
||||
}
|
||||
|
||||
.wp-appbar {
|
||||
@@ -200,7 +424,7 @@ input, textarea, select {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
background: var(--cds-ui-01);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
@@ -247,3 +471,120 @@ input, textarea, select {
|
||||
.wp-appbar-actions { flex-wrap: wrap; }
|
||||
.wp-appbar-meta { width: 100%; order: 5; }
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
DRAFT STATE INDICATOR (B5 / T4.4)
|
||||
----------------------------------------------------------------------------
|
||||
Reports the DRAFT — what wp-autosave.js is holding for you on this device.
|
||||
The sync badge in project-data.js reports the OUTBOX, which is a different
|
||||
question ("has the saved record reached the project"), and its wording now
|
||||
says so. Two indicators, two sentences, neither readable as the other.
|
||||
|
||||
Lives in theme-light.css because both form pages mount the same component,
|
||||
and a second copy in a page sheet is what wave 3 spent itself removing.
|
||||
============================================================================ */
|
||||
.wp-draft-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.3;
|
||||
color: var(--cds-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wp-draft-status.is-saved { color: var(--cds-support-success); font-weight: 600; }
|
||||
.wp-draft-status.is-saving { color: var(--cds-text-secondary); }
|
||||
/* A failed autosave is not a quieter version of a successful one — it means the
|
||||
safety net is not there. Red, bold, and it keeps its retry. */
|
||||
.wp-draft-status.is-failed {
|
||||
color: var(--cds-support-error);
|
||||
font-weight: 600;
|
||||
white-space: normal;
|
||||
}
|
||||
.wp-draft-retry {
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 9px;
|
||||
cursor: pointer;
|
||||
background: var(--wp-btn-secondary-bg);
|
||||
color: var(--wp-btn-danger-fg);
|
||||
border: 1px solid var(--wp-btn-danger-border);
|
||||
border-radius: var(--wp-radius-0);
|
||||
}
|
||||
.wp-draft-retry:hover { background: var(--wp-btn-danger-soft-bg); }
|
||||
.wp-draft-retry:focus-visible { outline: 2px solid var(--cds-focus); outline-offset: -2px; }
|
||||
|
||||
/* An inline validation error, at the field it belongs to (C1).
|
||||
----------------------------------------------------------------------------
|
||||
Three surfaces grew one of these within wave 5 — the launcher's create-project
|
||||
form (T5.2), the SOP wizard's location list (T5.4), and T5.8's step validation
|
||||
— so it lives here rather than as three page-sheet rules that would drift.
|
||||
The markup pairs it with aria-describedby and role="alert" on the element; the
|
||||
:empty rule is what lets it sit in the page permanently and announce on change
|
||||
rather than being created at the moment somebody needs to hear it. */
|
||||
.field-error {
|
||||
color: var(--cds-text-error);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.field-error:empty { display: none; }
|
||||
|
||||
/* ============================================================================
|
||||
FOCUS (S12 / T4.7)
|
||||
----------------------------------------------------------------------------
|
||||
A keyboard user has to be able to see where they are. The suite's own sheets
|
||||
removed the outline in six places and replaced it, at best, with a 3px
|
||||
#edf5ff glow — a 1.05:1 edge against a white field, which is not a focus
|
||||
indicator so much as a rumour of one. T3.4 fixed the wizard's three; this is
|
||||
the app-wide floor underneath all of them.
|
||||
|
||||
:focus-visible, not :focus, so a mouse click does not leave a ring behind —
|
||||
which is the reason people reach for `outline: none` in the first place.
|
||||
|
||||
2px of --cds-focus (#0f62fe). Against every background the app actually uses
|
||||
it clears 3:1 comfortably: 8.6:1 on white, 7.8:1 on #f4f4f4, 4.9:1 on the
|
||||
Carbon highlight blue. Drawn OUTSIDE the element by default (positive offset)
|
||||
so it is not swallowed by a control's own border; components that need it
|
||||
inset say so themselves.
|
||||
|
||||
This is a floor, not an override: it is a single-class-free selector list, so
|
||||
any component rule with a class beats it and can draw its own ring.
|
||||
============================================================================ */
|
||||
:where(a, button, input, select, textarea, summary, [tabindex]:not([tabindex="-1"])):focus-visible {
|
||||
outline: 2px solid var(--cds-focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* On the dark app bar and the drawer, blue-on-near-black is 2.4:1 and fails.
|
||||
White is 15.9:1 against #161616 and is what wp-sidenav.css already used. */
|
||||
:where(.wp-appbar, .wp-sidenav, .wp-navscrim)
|
||||
:where(a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])):focus-visible {
|
||||
outline-color: var(--wp-appbar-fg);
|
||||
}
|
||||
|
||||
/* A FILLED control cannot take an inset ring: a blue ring inside a blue button
|
||||
measures 1.00:1, which is not a subtle problem — it is no indicator at all.
|
||||
These are every filled button in the suite (see docs/reference/tokens.md §12),
|
||||
and their ring is pushed clear of the fill so it lands on the page behind them.
|
||||
Class specificity, so it beats both the :where() floor above and the browser's
|
||||
own default ring, which is what was winning on the creator's primary buttons. */
|
||||
.btn-primary:focus-visible,
|
||||
.btn-generate:focus-visible,
|
||||
.use-btn:focus-visible,
|
||||
.wp-nav-cta:focus-visible,
|
||||
.wp-nav-cta-more:focus-visible,
|
||||
.mode-btn.active:focus-visible,
|
||||
.seq-del:focus-visible,
|
||||
button.primary:focus-visible,
|
||||
.card-button:focus-visible,
|
||||
.submit-btn:focus-visible,
|
||||
.comments-toggle:focus-visible,
|
||||
.add-btn:focus-visible,
|
||||
.ui-help-fab:focus-visible,
|
||||
.wp-appbar-btn.primary:focus-visible {
|
||||
outline: 2px solid var(--cds-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
114
html/users.html
Normal file
@@ -0,0 +1,114 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>User Directory — Work Package Suite</title>
|
||||
<script src="auth-guard.js"></script>
|
||||
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
|
||||
timestamps during their own boot. -->
|
||||
<script src="wp-format.js"></script>
|
||||
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
|
||||
during their own boot. -->
|
||||
<script src="wp-url.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<link rel="stylesheet" href="wp-chrome.css">
|
||||
<link rel="stylesheet" href="console.css">
|
||||
<link rel="stylesheet" href="wp-sidenav.css">
|
||||
<style>
|
||||
/* Page-specific only — everything structural is in console.css.
|
||||
|
||||
The directory is one wide table, so the column exceptions live here: email is
|
||||
the one cell long enough to stretch a row, and the two role dropdowns need
|
||||
room for "Assistant Project Manager" without pushing Actions off screen. */
|
||||
#users-table table td:nth-child(3){ max-width:230px; overflow:hidden; text-overflow:ellipsis; }
|
||||
#users-banner:not(:empty), #scope-banner:not(:empty){ margin-bottom:var(--s3); }
|
||||
/* The create form is a lot of fields; give the password one room to breathe and
|
||||
let the project picker take a full row of its own. */
|
||||
#nu-password{ flex:1 1 200px; }
|
||||
#nu-projects{ margin-top:var(--s2); }
|
||||
#nu-projects .pickrow{ padding:var(--s1) var(--s1); }
|
||||
/* A manager with one project doesn't need a scrolling picker; a manager with
|
||||
thirty does, and it must not push the Create button below the fold. */
|
||||
#nu-project-list{ max-height:200px; overflow:auto; border:1px solid var(--border); }
|
||||
.whoami-chip{ font-size:12px; color:var(--muted); }
|
||||
.whoami-chip strong{ color:var(--text); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- SHARED DARK APP BAR -->
|
||||
<header class="wp-appbar">
|
||||
<a href="index.html" class="wp-appbar-brand" title="Back to site">
|
||||
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
|
||||
<span class="wp-appbar-title">Work Package Suite <span class="wp-appbar-sub">| User Directory</span></span>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<div class="wrap" id="users-main" style="display:none">
|
||||
<div class="row" style="justify-content:space-between; margin-bottom:var(--s5)">
|
||||
<div>
|
||||
<h1>User Directory</h1>
|
||||
<div class="sub" style="margin:0" id="dir-sub">The people on your projects — who they are, and how to reach them.</div>
|
||||
</div>
|
||||
<div class="row"><a class="home" href="index.html">← Site</a></div>
|
||||
</div>
|
||||
|
||||
<!-- WHAT YOU MAY DO HERE (rendered from GET /api/auth/user-scope) -->
|
||||
<div id="scope-banner"></div>
|
||||
|
||||
<!-- THE DIRECTORY -->
|
||||
<div class="card">
|
||||
<h2>People</h2>
|
||||
<div class="sub" id="people-sub"></div>
|
||||
<div class="toolbar">
|
||||
<button onclick="loadUsers()">Refresh</button>
|
||||
<input id="user-search" placeholder="Search name / username / email / job function…" oninput="renderUsers()">
|
||||
<select id="user-filter" onchange="renderUsers()">
|
||||
<option value="">Everyone</option>
|
||||
<option value="active">Active only</option>
|
||||
<option value="disabled">Disabled only</option>
|
||||
<option value="mine">Accounts I manage</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="users-banner"></div>
|
||||
<div id="users-table"><div class="note">Loading…</div></div>
|
||||
</div>
|
||||
|
||||
<!-- ADD A USER (managers only; hidden otherwise) -->
|
||||
<div class="card" id="create-card" style="display:none">
|
||||
<h2>Add a user</h2>
|
||||
<div class="sub" id="create-sub"></div>
|
||||
<div class="urow">
|
||||
<input id="nu-username" placeholder="Username *" autocomplete="off">
|
||||
<input id="nu-fullname" placeholder="Full name" autocomplete="off">
|
||||
<input id="nu-email" placeholder="Email" autocomplete="off">
|
||||
<select id="nu-role" title="Permissions — what this account may do"></select>
|
||||
<select id="nu-project-role" title="Job function on the project"></select>
|
||||
<input id="nu-password" type="password" placeholder="Password (min 12)" autocomplete="new-password">
|
||||
</div>
|
||||
<div id="nu-projects">
|
||||
<div class="note" id="nu-projects-label" style="margin-bottom:var(--s1)"></div>
|
||||
<div id="nu-project-list"></div>
|
||||
</div>
|
||||
<div class="row" style="margin-top:var(--s3)">
|
||||
<button class="primary" onclick="createUser()">Create user</button>
|
||||
<span id="users-create-msg" class="note" style="margin:0"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="console-util.js"></script>
|
||||
<script src="wp-dialog.js"></script>
|
||||
<script src="users.js"></script>
|
||||
<!-- The app bar's project switcher reads ProjectData; without this the bar on this
|
||||
page could never show a project and always read "Select a project" (F1). Must
|
||||
parse before wp-chrome.js, which reads it as it mounts. -->
|
||||
<script src="project-data.js"></script>
|
||||
<script src="wp-chrome.js"></script>
|
||||
<script src="wp-sidenav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
480
html/users.js
Normal file
@@ -0,0 +1,480 @@
|
||||
/* User Directory for the Work Package Suite.
|
||||
|
||||
Moved out of the Admin Console because user administration is no longer
|
||||
admin-only: a PROJECT SUPER USER creates and manages the accounts on the projects
|
||||
they administer, which means the page has to be reachable by people who must never
|
||||
see the console's settings, diagnostics or app-wide switches.
|
||||
|
||||
ACCESS — three audiences on one page, decided by GET /api/auth/user-scope:
|
||||
• App admin every account, every control.
|
||||
• Project super user the accounts on the projects they administer. Controls
|
||||
appear per row: an account that is also on a job they
|
||||
don't administer is read-only, and the row says why.
|
||||
• Everyone else a read-only directory of the people on their own
|
||||
projects. No controls at all.
|
||||
|
||||
The server enforces every one of those rules (server/app.py: require_user_manager,
|
||||
require_manage_user, visible_user_ids). Nothing here is a security boundary — it is
|
||||
here so nobody is shown a button that would only 403, and so the reason is on the
|
||||
page instead of in an alert.
|
||||
|
||||
Shared helpers (api, uesc, jsq, the role vocabulary) come from console-util.js. */
|
||||
|
||||
let _users = []; // the directory as the server scoped it
|
||||
let _scope = null; // GET /api/auth/user-scope
|
||||
let _meId = null;
|
||||
|
||||
// ── boot ──────────────────────────────────────────────────────────────────────
|
||||
async function boot(){
|
||||
document.getElementById('users-main').style.display = '';
|
||||
_meId = (window.WP_USER && window.WP_USER.id) || null;
|
||||
const { status, json } = await api('GET','/api/auth/user-scope');
|
||||
// A failed scope call must not leave the page pretending to be read-only-with-no-
|
||||
// reason: fall back to the least-privileged rendering and say so.
|
||||
_scope = (status === 200 && json) ? json : { can_manage_users:false, scope:'projects',
|
||||
grantable_roles:[], grantable_project_roles:[], managed_projects:[], project_roles:PROJECT_ROLES };
|
||||
if(status !== 200){
|
||||
banner('scope-banner','bad','✕ '+apiError(status, json, 'Could not work out what you may do here')+
|
||||
' Showing the directory read-only.');
|
||||
} else {
|
||||
renderScope();
|
||||
}
|
||||
renderCreateForm();
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
function banner(id, kind, text){
|
||||
const el = document.getElementById(id);
|
||||
if(!el) return;
|
||||
if(!text){ el.innerHTML=''; return; }
|
||||
el.className = 'banner' + (kind ? ' '+kind : '');
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
// What this account may do here, stated once at the top rather than implied by which
|
||||
// buttons happen to be missing.
|
||||
function renderScope(){
|
||||
const el = document.getElementById('scope-banner');
|
||||
const sub = document.getElementById('dir-sub');
|
||||
if(!_scope.can_manage_users){
|
||||
el.innerHTML = '';
|
||||
if(sub) sub.textContent = 'The people on your projects — who they are, and how to reach them. '+
|
||||
'Only an administrator or a Project Super User can change accounts.';
|
||||
return;
|
||||
}
|
||||
if(_scope.scope === 'all'){
|
||||
el.className = 'banner';
|
||||
el.innerHTML = 'You are an <strong>Administrator</strong>: you manage every account in the suite. '+
|
||||
'App settings, diagnostics and the default-member rules live in the '+
|
||||
'<a class="home" href="admin.html">Admin Console</a>.';
|
||||
if(sub) sub.textContent = 'Every login account in the suite.';
|
||||
return;
|
||||
}
|
||||
const names = (_scope.managed_projects||[]).map(p => p.name || p.number || p.id);
|
||||
el.className = 'banner';
|
||||
el.innerHTML = 'You are a <strong>Project Super User</strong> on '+
|
||||
(names.length === 1 ? uesc(names[0]) : names.length+' projects')+
|
||||
' — you create and manage the accounts on '+(names.length === 1 ? 'that project' : 'those projects')+
|
||||
(names.length > 1 ? ': <strong>'+names.map(uesc).join('</strong>, <strong>')+'</strong>' : '')+'. '+
|
||||
'An account that is also on a project you don’t administer is read-only here.';
|
||||
if(sub) sub.textContent = 'The people on your projects, and the accounts you administer.';
|
||||
}
|
||||
|
||||
// ── the table ─────────────────────────────────────────────────────────────────
|
||||
async function loadUsers(){
|
||||
const wrap = document.getElementById('users-table');
|
||||
const { status, json } = await api('GET','/api/auth/users');
|
||||
if(status !== 200 || !Array.isArray(json)){
|
||||
banner('users-banner','bad','✕ '+apiError(status, json, 'Could not load the directory'));
|
||||
wrap.innerHTML = ''; return;
|
||||
}
|
||||
banner('users-banner','', '');
|
||||
_users = json;
|
||||
renderUsers();
|
||||
}
|
||||
|
||||
function manages(){ return !!(_scope && _scope.can_manage_users); }
|
||||
|
||||
function renderUsers(){
|
||||
const wrap = document.getElementById('users-table');
|
||||
const q = ((document.getElementById('user-search')||{}).value||'').trim().toLowerCase();
|
||||
const f = ((document.getElementById('user-filter')||{}).value||'');
|
||||
const total = _users.length;
|
||||
const sub = document.getElementById('people-sub');
|
||||
if(sub){
|
||||
sub.textContent = manages()
|
||||
? 'Login accounts you can see. The ones you administer carry controls; the rest are listed for reference.'
|
||||
: 'Everyone on the projects you can access, plus the administrators.';
|
||||
}
|
||||
if(!total){ wrap.innerHTML = '<div class="note">Nobody to show yet.</div>'; return; }
|
||||
const list = _users.filter(u => {
|
||||
if(f === 'active' && !u.is_active) return false;
|
||||
if(f === 'disabled' && u.is_active) return false;
|
||||
if(f === 'mine' && !u.manageable) return false;
|
||||
if(!q) return true;
|
||||
return ((u.username||'')+' '+(u.full_name||'')+' '+(u.email||'')+' '+
|
||||
(u.project_role||'')+' '+roleLabel(u.role)).toLowerCase().indexOf(q) >= 0;
|
||||
});
|
||||
const count = '<div class="note">'+list.length+' of '+total+' '+(total===1?'person':'people')+'</div>';
|
||||
if(!list.length){ wrap.innerHTML = count+'<div class="note">Nothing matches.</div>'; return; }
|
||||
|
||||
const head = manages()
|
||||
? ['Username','Name','Email',
|
||||
['Permissions','What this account may do in the app'],
|
||||
['Project role','Job function on the project — descriptive only'],
|
||||
['Project access','Which projects this user can access, and their role on each'],
|
||||
'Status','Last login','Actions']
|
||||
: ['Name','Username','Email',
|
||||
['Permissions','What this account may do in the app'],
|
||||
['Project role','Job function on the project — descriptive only'],
|
||||
'Status'];
|
||||
const ths = head.map(h => Array.isArray(h)
|
||||
? '<th title="'+uesc(h[1])+'">'+uesc(h[0])+'</th>' : '<th>'+uesc(h)+'</th>').join('');
|
||||
const rows = list.map(manages() ? managerRow : readonlyRow).join('');
|
||||
wrap.innerHTML = count+'<div class="tscroll"><table class="grid"><thead><tr>'+ths+
|
||||
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+legend();
|
||||
}
|
||||
|
||||
function legend(){
|
||||
if(!manages()){
|
||||
return '<div class="note" style="margin-top:10px"><strong>Project role</strong> is the person’s job '+
|
||||
'function — it feeds the SOP team pickers and notification routing, and grants nothing on its own.</div>';
|
||||
}
|
||||
return '<div class="note" style="margin-top:10px"><strong>Permissions</strong> — '+
|
||||
PERM_ROLES.map(r => '<em>'+uesc(PERM_LABELS[r])+'</em>: '+uesc(PERM_HELP[r])).join(' ')+
|
||||
' <strong>Project role</strong> is the person’s job function — it feeds the SOP team pickers '+
|
||||
'and notification routing, and grants nothing on its own.</div>';
|
||||
}
|
||||
|
||||
// The read-only card: name, contact, role. No ids are bound into handlers because
|
||||
// there are no handlers — that is the point of this rendering.
|
||||
function readonlyRow(u){
|
||||
return '<tr>'+
|
||||
'<td><strong>'+uesc(u.full_name || u.username)+'</strong>'+(u.id===_meId?'<span class="me-tag">you</span>':'')+'</td>'+
|
||||
'<td>'+uesc(u.username)+'</td>'+
|
||||
'<td class="ell" title="'+uesc(u.email||'')+'"><span>'+
|
||||
(u.email ? '<a class="home" href="mailto:'+uesc(u.email)+'">'+uesc(u.email)+'</a>' : '—')+'</span></td>'+
|
||||
'<td><span class="tag '+roleTagClass(u.role)+'">'+uesc(roleLabel(u.role))+'</span></td>'+
|
||||
'<td>'+uesc(u.project_role || '—')+'</td>'+
|
||||
'<td><span class="tag '+(u.is_active?'on':'off')+'">'+(u.is_active?'active':'disabled')+'</span></td>'+
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
function managerRow(u){
|
||||
const me = u.id === _meId;
|
||||
const uid = jsq(u.id), uname = jsq(u.username);
|
||||
const can = !!u.manageable;
|
||||
const why = u.manage_blocked_reason || '';
|
||||
const fmt = s => s ? wpFormatDateTime(s) : '—';
|
||||
const role = normRole(u.role);
|
||||
|
||||
// Your own row never offers the controls that could lock you out of the app.
|
||||
const roleCell = me
|
||||
? '<span class="tag '+roleTagClass(role)+'">'+uesc(roleLabel(role))+'</span><span class="me-tag">locked</span>'
|
||||
: !can
|
||||
? '<span class="tag '+roleTagClass(role)+'" title="'+uesc(why)+'">'+uesc(roleLabel(role))+'</span>'
|
||||
: roleSelect(uid, uname, role);
|
||||
|
||||
// Job function follows the same permission as everything else on the row. Note a
|
||||
// super user cannot edit their OWN row: the server refuses account changes to any
|
||||
// admin or super-user account, including the caller's.
|
||||
const projRoleCell = can
|
||||
? projRoleSelect(uid, uname, u.project_role || '')
|
||||
: projRoleReadonly(u, can, why);
|
||||
|
||||
const actions = [];
|
||||
if(can && !me) actions.push('<button class="mini" onclick="resetPw(\''+uid+'\',\''+uname+'\')">Reset password</button>');
|
||||
if(can && !me) actions.push('<button class="mini" onclick="toggleActive(\''+uid+'\','+(!u.is_active)+')">'+
|
||||
(u.is_active?'Disable':'Enable')+'</button>');
|
||||
if(can && !me) actions.push('<button class="mini danger" onclick="deleteUser(\''+uid+'\',\''+uname+'\')">Delete</button>');
|
||||
if(me) actions.push('<button class="mini" disabled title="Use the Password link in the top bar to change your own">—</button>');
|
||||
if(!can && !me) actions.push('<span class="note" style="margin:0" title="'+uesc(why)+'">read-only</span>');
|
||||
|
||||
return '<tr'+(can||me ? '' : ' class="is-locked"')+'>'+
|
||||
'<td><strong>'+uesc(u.username)+'</strong>'+(me?'<span class="me-tag">you</span>':'')+'</td>'+
|
||||
'<td>'+uesc(u.full_name||'')+'</td>'+
|
||||
// The address is truncated with the full value on the title: a long one used to
|
||||
// wrap mid-word and push the whole row onto three lines.
|
||||
'<td class="ell" title="'+uesc(u.email||'')+'"><span>'+uesc(u.email||'')+'</span></td>'+
|
||||
'<td>'+roleCell+'</td>'+
|
||||
'<td>'+projRoleCell+'</td>'+
|
||||
'<td><div class="cellactions">'+projAccessCell(u)+'</div></td>'+
|
||||
'<td><span class="tag '+(u.is_active?'on':'off')+'">'+(u.is_active?'active':'disabled')+'</span></td>'+
|
||||
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
|
||||
'<td><div class="cellactions">'+actions.join('')+'</div></td>'+
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
// Only the roles the server said this caller may grant are offered. The account's
|
||||
// CURRENT role is always included even when it isn't grantable, or the dropdown would
|
||||
// silently misreport a Project Admin as a Project User the moment it renders.
|
||||
function roleSelect(uid, uname, role){
|
||||
const grantable = (_scope && _scope.grantable_roles) || [];
|
||||
const opts = PERM_ROLES.filter(r => grantable.indexOf(r) >= 0 || r === role);
|
||||
return '<select class="role-select'+(role==='admin'?' is-admin':'')+
|
||||
'" title="Change what this account may do" onchange="changeRole(\''+uid+'\',this.value,\''+uname+'\')">'+
|
||||
opts.map(r => '<option value="'+r+'"'+(role===r?' selected':'')+
|
||||
(grantable.indexOf(r) < 0 ? ' disabled' : '')+'>'+uesc(PERM_LABELS[r])+'</option>').join('')+
|
||||
'</select>';
|
||||
}
|
||||
|
||||
function projRoleSelect(uid, uname, pr){
|
||||
const list = (_scope && _scope.project_roles) || PROJECT_ROLES;
|
||||
return '<select class="role-select" title="Job function on the project" '+
|
||||
'onchange="changeProjectRole(\''+uid+'\',this.value,\''+uname+'\')">'+
|
||||
'<option value=""'+(pr?'':' selected')+'>— none —</option>'+
|
||||
list.map(r => '<option value="'+uesc(r)+'"'+(pr===r?' selected':'')+'>'+uesc(r)+'</option>').join('')+
|
||||
// Keep a title that isn't on the list (set via the API or an older record).
|
||||
(pr && list.indexOf(pr) < 0 ? '<option value="'+uesc(pr)+'" selected>'+uesc(pr)+'</option>' : '')+
|
||||
'</select>';
|
||||
}
|
||||
function projRoleReadonly(u, can, why){
|
||||
return '<span'+(can?'':' title="'+uesc(why)+'"')+'>'+uesc(u.project_role || '—')+'</span>';
|
||||
}
|
||||
|
||||
// Per-user project access gets its own column: buried among the action buttons, it
|
||||
// was exactly where you'd fail to find "which projects can this person see, and what
|
||||
// may they do there".
|
||||
function projAccessCell(u){
|
||||
if(normRole(u.role) === 'admin'){
|
||||
return '<span class="tag admin" title="Admins can access every project">all projects</span>';
|
||||
}
|
||||
const n = u.project_count;
|
||||
const label = (n === undefined || n === null) ? 'Projects…'
|
||||
: (n === 0 ? 'No projects yet' : n+' project'+(n===1?'':'s'));
|
||||
if(!u.manageable){
|
||||
return '<span class="note" style="margin:0" title="'+uesc(u.manage_blocked_reason||'')+'">'+uesc(label)+'</span>';
|
||||
}
|
||||
return '<button class="mini'+(n === 0 ? ' danger' : '')+
|
||||
'" onclick="manageProjects(\''+jsq(u.id)+'\',\''+jsq(u.username)+'\')"'+
|
||||
' title="Choose which projects this user can access, and their role on each">'+
|
||||
uesc(label)+'</button>';
|
||||
}
|
||||
|
||||
// ── row actions ───────────────────────────────────────────────────────────────
|
||||
// Each one reloads on failure so a control can never sit there showing a value the
|
||||
// server refused.
|
||||
async function resetPw(id, username){
|
||||
// The min-12 rule was stated in the prompt label and enforced only by the
|
||||
// server round-trip; the kit's validate() answers AT the input instead.
|
||||
const pw = await wpPromptDialog({title:'Reset password',
|
||||
message:'Set a new password for "'+username+'". Their existing sessions are signed out.',
|
||||
label:'New password (min 12 characters)',
|
||||
validate:v => (v && v.length >= 12) ? '' : 'At least 12 characters.'});
|
||||
if(pw === null) return;
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw});
|
||||
if(status === 200) toast('Password reset for '+username+'. Their existing sessions are signed out.');
|
||||
else wpAlertDialog({title:'Reset failed', message:'Could not reset the password: '+apiError(status, json)});
|
||||
}
|
||||
|
||||
async function toggleActive(id, makeActive){
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive});
|
||||
if(status === 200) loadUsers();
|
||||
else { wpAlertDialog({title:'Change failed', message:'Could not change that account: '+apiError(status, json)}); loadUsers(); }
|
||||
}
|
||||
|
||||
async function changeRole(id, role, username){
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role});
|
||||
if(status !== 200) wpAlertDialog({title:'Change failed', message:'Could not change permissions for '+username+': '+apiError(status, json)});
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
async function changeProjectRole(id, project_role, username){
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/project-role',{project_role});
|
||||
if(status !== 200) wpAlertDialog({title:'Change failed', message:'Could not set the project role for '+username+': '+apiError(status, json)});
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
async function deleteUser(id, username){
|
||||
if(!(await wpConfirmDialog({title:'Delete user',
|
||||
message:'Delete user "'+username+'"?\n\nTheir account and every project assignment go with it. '+
|
||||
'This cannot be undone — disable the account instead if you only want to block sign-in.',
|
||||
okLabel:'Delete user'}))) return;
|
||||
const { status, json } = await api('DELETE','/api/auth/users/'+id);
|
||||
if(status === 200) loadUsers();
|
||||
else wpAlertDialog({title:'Delete failed', message:'Could not delete '+username+': '+apiError(status, json)});
|
||||
}
|
||||
|
||||
// ── create ────────────────────────────────────────────────────────────────────
|
||||
function renderCreateForm(){
|
||||
const card = document.getElementById('create-card');
|
||||
if(!card) return;
|
||||
if(!manages()){ card.style.display = 'none'; return; }
|
||||
card.style.display = '';
|
||||
|
||||
const grantable = _scope.grantable_roles || [];
|
||||
const roleSel = document.getElementById('nu-role');
|
||||
roleSel.innerHTML = PERM_ROLES.filter(r => grantable.indexOf(r) >= 0)
|
||||
.map(r => '<option value="'+r+'"'+(r==='project_user'?' selected':'')+'>'+uesc(PERM_LABELS[r])+'</option>').join('');
|
||||
|
||||
const prSel = document.getElementById('nu-project-role');
|
||||
prSel.innerHTML = '<option value="">Project role…</option>'+
|
||||
(_scope.project_roles||PROJECT_ROLES).map(r => '<option value="'+uesc(r)+'">'+uesc(r)+'</option>').join('');
|
||||
|
||||
// The project picker is REQUIRED for a super user and optional for an admin —
|
||||
// because a super user's authority over an account comes from the projects it is
|
||||
// on, so an account created with none is one they instantly cannot manage. The
|
||||
// server refuses that; the form says so up front rather than after a failed save.
|
||||
const admin = _scope.scope === 'all';
|
||||
const projects = _scope.managed_projects || [];
|
||||
document.getElementById('create-sub').innerHTML = admin
|
||||
? 'Creates a login account. Assign projects here or later from <strong>Project access</strong> in the table above.'
|
||||
: 'Creates a login account on your project'+(projects.length===1?'':'s')+
|
||||
'. You administer users per project, so a new account has to start on at least one of them.';
|
||||
document.getElementById('nu-projects-label').innerHTML = admin
|
||||
? 'Projects (optional — you can assign them later)'
|
||||
: 'Projects <strong>*</strong> — pick at least one';
|
||||
const live = projects.filter(p => !p.archived);
|
||||
const list = document.getElementById('nu-project-list');
|
||||
if(!projects.length){
|
||||
list.innerHTML = '<div class="note" style="padding:var(--s2)">You don’t administer any project yet.</div>';
|
||||
} else {
|
||||
// Archived projects are omitted, not disabled: staffing a frozen job is never
|
||||
// what you mean when creating an account, and an admin can still assign one
|
||||
// afterwards from the project-access dialog.
|
||||
list.innerHTML = (live.length ? live : []).map(p =>
|
||||
'<div class="pickrow"><label><input type="checkbox" value="'+uesc(p.id)+'"'+
|
||||
(live.length === 1 ? ' checked' : '')+'>'+
|
||||
'<span><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
|
||||
(p.number ? ' <span style="color:var(--muted)">'+uesc(p.number)+'</span>' : '')+'</span></label></div>').join('')
|
||||
|| '<div class="note" style="padding:var(--s2)">Every project you administer is archived.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser(){
|
||||
const msg = document.getElementById('users-create-msg');
|
||||
const val = id => (document.getElementById(id)||{}).value || '';
|
||||
const username = val('nu-username').trim();
|
||||
const password = val('nu-password');
|
||||
const project_ids = [...document.querySelectorAll('#nu-project-list input[type=checkbox]:checked')]
|
||||
.map(c => c.value);
|
||||
const say = (color, text) => { msg.style.color = color; msg.textContent = text; };
|
||||
if(!username){ say('var(--red)','Username is required.'); return; }
|
||||
if(password.length < 12){ say('var(--red)','Password must be at least 12 characters.'); return; }
|
||||
if(_scope.scope !== 'all' && !project_ids.length){
|
||||
say('var(--red)','Pick at least one project — you administer users per project.'); return;
|
||||
}
|
||||
say('var(--muted)','Creating…');
|
||||
const { status, json } = await api('POST','/api/auth/users',{
|
||||
username, password, project_ids,
|
||||
full_name: val('nu-fullname').trim(), email: val('nu-email').trim(),
|
||||
role: val('nu-role'), project_role: val('nu-project-role'),
|
||||
});
|
||||
if(status === 200){
|
||||
say('var(--green)','✓ Created '+username+'.');
|
||||
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id => document.getElementById(id).value = '');
|
||||
loadUsers();
|
||||
} else {
|
||||
say('var(--red)','✕ '+apiError(status, json, 'Could not create the account'));
|
||||
}
|
||||
}
|
||||
|
||||
// ── project access dialog ─────────────────────────────────────────────────────
|
||||
// For an admin this is the whole of a person's access. For a super user it is their
|
||||
// slice of it: the server returns only the projects they administer and says how many
|
||||
// more the person is on, and a save leaves those others untouched.
|
||||
async function manageProjects(id, username){
|
||||
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
|
||||
if(status !== 200 || !json){ wpAlertDialog({title:'Could not load projects', message:'Could not load projects: '+apiError(status, json)}); return; }
|
||||
openProjectModal(id, username, json);
|
||||
}
|
||||
function closeProjectModal(){ const m = document.getElementById('proj-modal'); if(m) m.remove(); }
|
||||
|
||||
// A project's role dropdown only matters while that project is ticked.
|
||||
function projRowToggled(cb){
|
||||
const row = cb.closest('.pickrow');
|
||||
const sel = row && row.querySelector('select');
|
||||
if(sel) sel.disabled = !cb.checked;
|
||||
}
|
||||
|
||||
function openProjectModal(userId, username, data){
|
||||
closeProjectModal();
|
||||
const projects = (data.projects||[]).slice()
|
||||
// Live jobs first — an archived one is still listed (an existing assignment has
|
||||
// to stay removable) but it is finished work, so it doesn't belong at the top of
|
||||
// a list you're using to staff someone.
|
||||
.sort((a,b) => (a.archived?1:0) - (b.archived?1:0));
|
||||
const assigned = new Set(data.assigned||[]);
|
||||
const roles = data.roles || {};
|
||||
const userObj = data.user || {};
|
||||
const isAdmin = normRole(userObj.role) === 'admin';
|
||||
const acctRole = normRole(userObj.role);
|
||||
const grantable = data.grantable_project_roles || PROJECT_SCOPED_ROLES;
|
||||
|
||||
const items = projects.length ? projects.map(p => {
|
||||
const on = assigned.has(p.id);
|
||||
const cur = roles[p.id] || '';
|
||||
const opts = ['<option value=""'+(cur===''?' selected':'')+'>Same as account ('+
|
||||
uesc(PERM_LABELS[acctRole]||acctRole)+')</option>']
|
||||
.concat(PROJECT_SCOPED_ROLES.filter(r => grantable.indexOf(r) >= 0 || r === cur).map(r =>
|
||||
'<option value="'+r+'"'+(cur===r?' selected':'')+(grantable.indexOf(r)<0?' disabled':'')+'>'+
|
||||
uesc(PERM_LABELS[r])+' here</option>'));
|
||||
return '<div class="pickrow">'+
|
||||
'<label><input type="checkbox" value="'+uesc(p.id)+'"'+(on?' checked':'')+(isAdmin?' disabled':'')+
|
||||
' onchange="projRowToggled(this)">'+
|
||||
'<span><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
|
||||
(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+
|
||||
(p.archived?' <span class="tag archived" title="Archived — read-only until an admin unarchives it">archived</span>':'')+
|
||||
'</span></label>'+
|
||||
'<select class="role-select" data-role-for="'+uesc(p.id)+'"'+(isAdmin||!on?' disabled':'')+'>'+
|
||||
opts.join('')+'</select>'+
|
||||
'</div>';
|
||||
}).join('') : '<div class="note">No projects to choose from.</div>';
|
||||
|
||||
const others = data.other_projects || 0;
|
||||
const intro = isAdmin
|
||||
? '<div class="banner" style="margin:0 0 10px">This user is an <strong>Administrator</strong> and can '+
|
||||
'access every project regardless of assignment.</div>'
|
||||
: '<div class="note" style="margin:0 0 10px">Tick the projects this user may access, and set their role '+
|
||||
'on each. <strong>Project Admin</strong> can delete work packages, change a completed SOP and delete '+
|
||||
'that project; <strong>Project Super User</strong> can also manage that project’s user accounts; '+
|
||||
'<strong>Project User</strong> can do neither. Leave it on <em>Same as account</em> to use their '+
|
||||
'Permissions setting.</div>'+
|
||||
(others ? '<div class="banner warn" style="margin:0 0 10px">Also on '+others+' project'+
|
||||
(others===1?'':'s')+' you don’t administer. Those stay exactly as they are — saving here only '+
|
||||
'changes the projects listed below.</div>' : '');
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'proj-modal';
|
||||
modal.className = 'modal-ov';
|
||||
modal.innerHTML =
|
||||
'<div class="modal-box">'+
|
||||
'<div class="modal-head">Project access & permissions — '+uesc(username)+'</div>'+
|
||||
'<div class="modal-body">'+intro+'<div id="proj-list">'+items+'</div></div>'+
|
||||
'<div class="modal-foot">'+
|
||||
'<button onclick="closeProjectModal()">Cancel</button>'+
|
||||
(isAdmin ? '' : '<button class="primary" id="proj-save">Save</button>')+
|
||||
'</div>'+
|
||||
'</div>';
|
||||
modal.addEventListener('click', e => { if(e.target === modal) closeProjectModal(); });
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const saveBtn = document.getElementById('proj-save');
|
||||
if(saveBtn) saveBtn.onclick = async () => {
|
||||
const ids = [...modal.querySelectorAll('#proj-list input[type=checkbox]:checked')].map(c => c.value);
|
||||
const roleMap = {};
|
||||
ids.forEach(pid => {
|
||||
const sel = modal.querySelector('#proj-list select[data-role-for="'+pid+'"]');
|
||||
if(sel && sel.value) roleMap[pid] = sel.value;
|
||||
});
|
||||
const { status, json } = await api('PUT','/api/auth/users/'+userId+'/projects',
|
||||
{ project_ids: ids, roles: roleMap });
|
||||
if(status === 200){ closeProjectModal(); loadUsers(); }
|
||||
else wpAlertDialog({title:'Save failed', message:'Save failed: '+apiError(status, json)});
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeProjectModal(); });
|
||||
|
||||
// ── start ─────────────────────────────────────────────────────────────────────
|
||||
// auth-guard.js requires a login and publishes window.WP_USER (firing
|
||||
// 'wp-auth-ready'). Unlike the Admin Console there is no role gate here: everyone
|
||||
// signed in gets a directory, and what they can DO comes from the scope call.
|
||||
let _booted = false;
|
||||
function start(){
|
||||
if(_booted || !window.WP_USER) return;
|
||||
_booted = true;
|
||||
boot();
|
||||
}
|
||||
document.addEventListener('wp-auth-ready', start);
|
||||
start();
|
||||
@@ -1,25 +1,32 @@
|
||||
/* Names only. Every value comes from theme-light.css, which this page loads
|
||||
first — this sheet declares nothing of its own (T3.2 / S5 / C3). The names
|
||||
stay because work-package-suite-app.js reads ten of them from JavaScript,
|
||||
where a rename fails silently. See docs/reference/tokens.md section 9. */
|
||||
:root {
|
||||
--primary: #0f62fe;
|
||||
--primary-light: #edf5ff;
|
||||
--success: #198038;
|
||||
--warning: #8e6a00;
|
||||
--warning-bg: #fdf6dd;
|
||||
--danger: #da1e28;
|
||||
--text: #161616;
|
||||
--text-light: #525252;
|
||||
--text-dim: #8d8d8d;
|
||||
--border: #e0e0e0;
|
||||
--border-strong: #8d8d8d;
|
||||
--bg: #f4f4f4;
|
||||
--bg-card: #ffffff;
|
||||
--appbar: #161616;
|
||||
--shadow: none;
|
||||
--shadow-lg: 0 4px 16px rgba(0,0,0,0.16);
|
||||
--primary: var(--cds-interactive-01);
|
||||
--primary-light: var(--cds-highlight);
|
||||
--success: var(--cds-support-success);
|
||||
--warning: var(--wp-status-warning-text);
|
||||
--warning-bg: var(--wp-status-warning-bg);
|
||||
--danger: var(--cds-support-error);
|
||||
--text: var(--cds-text-primary);
|
||||
--text-light: var(--cds-text-secondary);
|
||||
--text-dim: var(--cds-text-helper); /* S11: was --cds-ui-04 (#8d8d8d, 3.32:1) */
|
||||
--border: var(--cds-border-subtle);
|
||||
--border-strong: var(--cds-border-strong);
|
||||
--bg: var(--cds-background);
|
||||
--bg-card: var(--cds-layer);
|
||||
--appbar: var(--wp-appbar-bg);
|
||||
--shadow: var(--wp-shadow-none);
|
||||
/* Neutral, where the creator's --shadow-lg is cool-tinted. Same geometry in
|
||||
both — 0 4px 16px — so the two can be merged later without moving anything;
|
||||
merging is still a rendered change, so not here. tokens.md section 8-F. */
|
||||
--shadow-lg: var(--wp-shadow-lg-neutral);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||
font-family: var(--wp-font-sans);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
line-height: 1.5;
|
||||
@@ -31,23 +38,43 @@ body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* HEADER — dark UI Shell bar */
|
||||
/* HEADER — dark UI Shell bar
|
||||
Four groups share this bar, not two: .header-left and .header-right are in the
|
||||
markup, and wp-chrome.js injects the project switcher and search between them
|
||||
while auth-guard.js appends the user menu after. At 1024px that is more than
|
||||
fits on one 48px line, and a fixed height meant the overflow had nowhere to go
|
||||
but on top of its neighbours. min-height + wrap lets the bar grow instead.
|
||||
One row still measures exactly 48px, so nothing moves at desk width. */
|
||||
.header {
|
||||
background: var(--appbar);
|
||||
color: #fff;
|
||||
color: var(--wp-appbar-fg);
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 6px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Sizes to its content and never narrower than the logo.
|
||||
This was `flex: 1; min-width: 0`, which put it in a dead tie with the chrome
|
||||
wp-chrome.js injects as its sibling (`flex: 1 1 auto; min-width: 0`): both
|
||||
claimed the same run of the bar, both were allowed to shrink to nothing, and
|
||||
the chrome's wider content won every time. .header-left computed to 0 while
|
||||
its `flex-shrink: 0` logo kept its 106px and overflowed — so the project
|
||||
switcher rendered straight across the logo (F3).
|
||||
Now the chrome is the only one that grows, and `min-width: auto` restores the
|
||||
content-based floor, which is the logo plus the gap: the inner title block
|
||||
keeps its own `min-width: 0`, so the project name still gives way first, by
|
||||
the ellipsis .header-subtitle already carries. Truncation policy itself is
|
||||
B2's, not this task's. */
|
||||
.header-left {
|
||||
flex: 1;
|
||||
flex: 0 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
/* Prime logo (white-background wordmark) sits in a white chip on the dark bar */
|
||||
@@ -55,7 +82,7 @@ body {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
background: var(--cds-ui-01);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
@@ -82,30 +109,36 @@ body {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0;
|
||||
color: #fff;
|
||||
color: var(--wp-appbar-fg);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 12px;
|
||||
color: #c6c6c6;
|
||||
color: var(--wp-appbar-fg-dim);
|
||||
min-height: 16px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Holds its natural size. Left to the default `flex-shrink: 1` it was squeezed
|
||||
narrower than its buttons, so "Load Sample" ran underneath "Feedback". The
|
||||
chrome between the two groups is the only flexible one, which is what gives
|
||||
the three a defined order of giving way: right keeps its buttons, left keeps
|
||||
its logo, the middle absorbs the difference. */
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.header-button {
|
||||
padding: 7px 14px;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
border: 1px solid #6f6f6f;
|
||||
color: var(--wp-appbar-fg);
|
||||
border: 1px solid var(--wp-appbar-border);
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
@@ -114,65 +147,26 @@ body {
|
||||
}
|
||||
|
||||
.header-button:hover {
|
||||
background: #353535;
|
||||
border-color: #6f6f6f;
|
||||
background: var(--wp-appbar-hover);
|
||||
border-color: var(--wp-appbar-border);
|
||||
}
|
||||
|
||||
.step-counter {
|
||||
background: transparent;
|
||||
border: 1px solid #6f6f6f;
|
||||
color: #c6c6c6;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* .step-counter — the "1 / 10" pill — is gone with A4/S9. The step rail carries
|
||||
position, progress and reachability; a badge in the app bar carried one third
|
||||
of that and was the only thing on the page that did. */
|
||||
|
||||
/* MAIN NAVIGATION — underline tabs */
|
||||
.main-nav {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 0 16px;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
padding: 13px 18px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.nav-tab:hover {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
background: none;
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* MAIN NAVIGATION - moved to wp-chrome.css by B7/T7.1.
|
||||
The strip is chrome, and dissolving the creator's iframe made it a page that
|
||||
has to draw the same strip. A tab row only one of the two documents can style
|
||||
is the shape that put the tabs in the parent and the toolbar in the child. */
|
||||
|
||||
.tab-icon { font-size: 16px; }
|
||||
|
||||
/* CONTENT AREA
|
||||
The SOP wizard reads better with a bound on line length, but 1000px on a 1920
|
||||
screen wasted half the display — and it also squeezed the embedded Work Package
|
||||
Creator (an iframe living in here) into a ~930px column with its own scrollbar
|
||||
inside the page's. Wider cap for the wizard; the embedded tools go full-bleed
|
||||
(see .content-area.embed-full below). */
|
||||
screen wasted half the display. The wider cap stays; what it used to also have
|
||||
to accommodate - the creator embedded in here, squeezed into a ~930px column
|
||||
with its own scrollbar inside the page's - is a separate page since T7.1. */
|
||||
.content-area {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
@@ -181,39 +175,11 @@ body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Work Package Creation / Dashboard: the iframe fills the window below the app
|
||||
chrome and owns the only scrollbar, so the creator's sticky save bar and
|
||||
navigator drawer position against a real viewport instead of scrolling away. */
|
||||
.content-area.embed-full {
|
||||
/* `flex: none` matters: .content-area is a column flex item with `flex: 1`, whose
|
||||
flex-basis:0% overrides `height` and leaves the used height INDEFINITE — so a
|
||||
child's `height:100%` resolves to auto and the iframe collapses to its 150px
|
||||
default. Opting out of flex sizing makes the height definite. */
|
||||
flex: none;
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
height: calc(100vh - var(--wp-chrome-h, 96px));
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.content-area.embed-full > .tool.active {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0; /* let it shrink instead of overflowing the shell */
|
||||
height: 100%;
|
||||
}
|
||||
#wp-frame {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
min-height: calc(100vh - 200px);
|
||||
}
|
||||
#wp-frame.fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
/* No page scrollbar while a full-bleed tool is open — the iframe scrolls. */
|
||||
body.embed-full { overflow: hidden; }
|
||||
/* B7/T7.1: the creator's iframe was sized here - #wp-frame, .content-area.embed-full,
|
||||
body.embed-full and the --wp-chrome-h custom property that carried the measured
|
||||
app-bar height across. All of it existed so one document could fill the window
|
||||
inside another and scroll with a single scrollbar. The creator is a page now;
|
||||
the window sizes it. */
|
||||
|
||||
.tool {
|
||||
display: none;
|
||||
@@ -223,42 +189,138 @@ body.embed-full { overflow: hidden; }
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* STEP NAV */
|
||||
.step-nav {
|
||||
margin-bottom: 2rem;
|
||||
overflow-x: auto;
|
||||
/* ══ STEP RAIL — vertical stepper (A4 / S9 / C1) ═══════════════════════════════
|
||||
Replaces `.step-nav > .steps-container > .step-item`: ten <div onclick> chips
|
||||
in a horizontal scroller. They were unreachable by keyboard, said nothing
|
||||
about progress, and the "1 / 10" pill in the app bar was doing the one job
|
||||
they should have been doing.
|
||||
|
||||
The rail is a column beside the form rather than a strip above it, so all ten
|
||||
steps and their states are visible while you work in one. Below 900px there is
|
||||
no room for a second column, so it collapses — see the breakpoint at the foot
|
||||
of this block.
|
||||
|
||||
Spacing comes from --wp-s* (the suite's only real spacing scale) rather than
|
||||
fresh literals; type does not, because there is no type token to consume yet.
|
||||
That is BL-010 and it is unchanged in kind by this task. */
|
||||
#tool-sop.active {
|
||||
display: grid;
|
||||
grid-template-columns: 15rem minmax(0, 1fr);
|
||||
gap: var(--wp-s5);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.steps-container {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
min-width: min-content;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 0;
|
||||
.step-rail {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / span 2; /* beside the form AND its navigation bar */
|
||||
/* Step 5 is roughly three screens tall. A rail that scrolls away is a rail you
|
||||
have to hunt for, which is how the app ended up with a counter in the bar. */
|
||||
position: sticky;
|
||||
top: var(--wp-s4);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-light);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
border-radius: 0;
|
||||
padding: var(--wp-s3);
|
||||
}
|
||||
|
||||
.step-item:hover { background: var(--bg); border-color: var(--border-strong); color: var(--text); }
|
||||
.step-item.active { background: var(--primary); color: white; border-color: var(--primary); font-weight: 600; }
|
||||
.step-rail-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.step-rail-item { margin: 0; }
|
||||
|
||||
/* Every step is a real button, in the tab order, activated by Enter and Space
|
||||
for free. Arrow keys, Home and End are added in work-package-suite-app.js. */
|
||||
.step-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wp-s3);
|
||||
padding: var(--wp-s2);
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 0;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: var(--text-light);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.step-btn:hover { background: var(--bg); color: var(--text); }
|
||||
|
||||
.step-btn-marker {
|
||||
flex: none;
|
||||
width: var(--wp-ctl-sm);
|
||||
height: var(--wp-ctl-sm);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.step-btn-body { min-width: 0; display: flex; flex-direction: column; }
|
||||
.step-btn-label { font-size: 13px; font-weight: 500; }
|
||||
|
||||
/* The state in words. C1: completed / current / unavailable must be tellable
|
||||
apart without colour, so each carries a label here AND a marker shape — filled
|
||||
disc, filled disc with a tick, dashed outline — not a hue alone. */
|
||||
.step-btn-state { font-size: 11px; color: var(--text-light); }
|
||||
.step-btn-state:empty { display: none; }
|
||||
|
||||
.step-btn.is-current {
|
||||
background: var(--primary-light);
|
||||
border-left-color: var(--primary);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.step-btn.is-current .step-btn-marker {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--cds-text-on-color);
|
||||
}
|
||||
|
||||
/* Green because this is a status, not an action — see tokens.md section 12.
|
||||
Green never fills a button in this suite; it fills the badge on one. */
|
||||
.step-btn.is-complete .step-btn-marker {
|
||||
background: var(--success);
|
||||
border-color: var(--success);
|
||||
color: var(--cds-text-on-color);
|
||||
}
|
||||
|
||||
/* Still focusable and still in the tab order: a control removed from the tab
|
||||
order is a control a keyboard user cannot even discover, and "you cannot go
|
||||
there yet, here is why" is worth reaching. aria-disabled, not disabled. */
|
||||
.step-btn.is-locked { cursor: not-allowed; }
|
||||
.step-btn.is-locked .step-btn-marker { border-style: dashed; }
|
||||
|
||||
.step-rail-msg {
|
||||
margin: var(--wp-s2) 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--danger);
|
||||
}
|
||||
.step-rail-msg:empty { display: none; }
|
||||
|
||||
/* Wide widths: the list IS the control, so the disclosure has nothing to do. */
|
||||
.step-rail-toggle { display: none; }
|
||||
|
||||
/* STEP CONTENT */
|
||||
.step-content {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
background: var(--bg-card);
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.step { display: none; }
|
||||
@@ -306,45 +368,95 @@ body.embed-full { overflow: hidden; }
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* An editable field is white on --border-strong, which is what an editable field
|
||||
is on every other page in the suite. It used to be filled with var(--bg) — this
|
||||
sheet's PAGE BACKGROUND, #f4f4f4 — so an empty required field was indistinguishable
|
||||
from a locked one and people did not type in them (F5/A3).
|
||||
|
||||
The cause was the wizard declaring its own token set and therefore never seeing
|
||||
--cds-field, which theme-light.css had been supplying to this page all along.
|
||||
T1.5 reached past the local tokens with a fallback; T3.2 removed the local tokens
|
||||
entirely, so the fallback is now dead code and the canonical token is simply
|
||||
consumed. No field-specific colour is declared on this page. */
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 0;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
background: var(--cds-field);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus,
|
||||
.field textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-light);
|
||||
/* Editable fields being white means genuinely locked ones have to say so, or the
|
||||
signal inverts rather than being fixed. --cds-field-02 is the theme's own
|
||||
secondary field surface and matches .locked-field in the creator.
|
||||
The border is deliberately the same on both states: identical --border-strong is
|
||||
what makes a field read as a field, and the fill is what carries locked versus
|
||||
editable. Verified as #ffffff vs #f4f4f4 against an identical border. */
|
||||
.field input:disabled,
|
||||
.field select:disabled,
|
||||
.field textarea:disabled,
|
||||
.field input[readonly],
|
||||
.field textarea[readonly] {
|
||||
background: var(--cds-field-02);
|
||||
color: var(--text-light);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.field small {
|
||||
/* One focus ring for every control on this page (BL-002).
|
||||
All three focus rules here removed the outline. Two replaced it with a 3px
|
||||
var(--primary-light) glow — #edf5ff against a #ffffff field, which is a 1.05:1
|
||||
edge: a faint halo on the card, invisible on the field itself. The third,
|
||||
.seq-step input.seq-label, removed the outline with no replacement at all.
|
||||
CLAUDE.md allows outline:none only with a replacement of at least equal
|
||||
visibility, so two were marginal and one was a straight violation.
|
||||
|
||||
This is Carbon's ring and the one console.css:69 and wp-chrome.css:206 already
|
||||
draw — 2px of --cds-focus inset over the control's own edge — so it is the app's
|
||||
existing idiom rather than a fourth. border-color stays as a second cue and as
|
||||
the fallback if outline is ever suppressed. */
|
||||
.field input:focus,
|
||||
.field select:focus,
|
||||
.field textarea:focus,
|
||||
.user-pick:focus,
|
||||
.seq-step input.seq-label:focus {
|
||||
outline: 2px solid var(--cds-focus);
|
||||
outline-offset: -2px;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Helper text under a field, by either of the two names the markup uses: <small>
|
||||
inside a .field, and .field-hint anywhere. They were two rules with byte-identical
|
||||
declarations — the duplication that produced the bug this block used to carry a
|
||||
comment about, in miniature. One rule now, so they cannot drift apart again.
|
||||
|
||||
The colour is deliberately left as --text-dim. #8d8d8d on white measures 3.3:1,
|
||||
under the 4.5:1 floor, and console.css:103 already rejected it for exactly this
|
||||
job — but propagating that fix is T4.6, which re-measures rather than inheriting
|
||||
either published figure. Changing it here would be a rendered change in a wave
|
||||
that must produce none, and would take T4.6's decision away from it. */
|
||||
.field small,
|
||||
.field-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Small helper text under a field. It's used on this page (step 2's CM hint, the
|
||||
team-member notices) but its only rule used to live in wp-creation-styles.css,
|
||||
which this page does not link — so it rendered as unstyled body text. */
|
||||
.field-hint { font-size: 12px; color: var(--text-dim); margin-top: 0.25rem; }
|
||||
.field-hint strong { color: var(--text-light); }
|
||||
|
||||
/* The sign-off name pickers sit outside .field, so they got no form styling at all. */
|
||||
/* The sign-off name pickers sit outside .field, so they got no form styling at all.
|
||||
They carried the F5/A3 defect too, and were missed because the review counted
|
||||
inputs inside .field: --bg fill on a --border hairline is the grey-on-grey that
|
||||
made empty required fields read as locked. These are enabled selects and now look
|
||||
like every other enabled control in the suite — --cds-field on --border-strong.
|
||||
Focus is the shared ring above. */
|
||||
.user-pick {
|
||||
padding: 0.75rem; border: 1px solid var(--border); border-radius: 0;
|
||||
font-size: 14px; font-family: inherit; color: var(--text); background: var(--bg);
|
||||
padding: 0.75rem; border: 1px solid var(--border-strong); border-radius: 0;
|
||||
font-size: 14px; font-family: inherit; color: var(--text); background: var(--cds-field);
|
||||
}
|
||||
.user-pick:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-light); }
|
||||
|
||||
/* ROLES */
|
||||
.required-roles {
|
||||
@@ -408,11 +520,12 @@ body.embed-full { overflow: hidden; }
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* BUTTONS */
|
||||
/* BUTTONS — roles are documented in docs/reference/tokens.md section 12 (A5) */
|
||||
/* primary */
|
||||
.add-btn {
|
||||
padding: 0.7rem 1.25rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
background: var(--wp-btn-primary-bg);
|
||||
color: var(--wp-btn-primary-fg);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
font-size: 14px;
|
||||
@@ -421,39 +534,243 @@ body.embed-full { overflow: hidden; }
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.add-btn:hover { background: var(--cds-hover-primary, #0353e9); }
|
||||
.add-btn:hover { background: var(--wp-btn-primary-hover); }
|
||||
|
||||
/* secondary */
|
||||
.nav-btn {
|
||||
padding: 0.7rem 1.4rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--wp-btn-secondary-bg);
|
||||
border: 1px solid var(--wp-btn-secondary-border);
|
||||
border-radius: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
color: var(--wp-btn-secondary-fg);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nav-btn:hover { border-color: var(--primary); color: var(--primary); background: var(--bg); }
|
||||
.nav-btn:hover { border-color: var(--wp-btn-secondary-hover-fg); color: var(--wp-btn-secondary-hover-fg); background: var(--bg); }
|
||||
|
||||
/* A5: this was green — the loudest control in the suite, on a button whose job
|
||||
is to save a form. The completion it announced is still announced, on the
|
||||
launcher card's border and status line, which is where a status belongs. */
|
||||
.nav-btn.primary {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border-color: var(--success);
|
||||
background: var(--wp-btn-primary-bg);
|
||||
color: var(--wp-btn-primary-fg);
|
||||
border-color: var(--wp-btn-primary-bg);
|
||||
}
|
||||
|
||||
.nav-btn.primary:hover { background: #0e6027; border-color: #0e6027; color: white; }
|
||||
.nav-btn.primary:hover { background: var(--wp-btn-primary-hover); border-color: var(--wp-btn-primary-hover); color: var(--wp-btn-primary-fg); }
|
||||
|
||||
.nav-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* NAVIGATION */
|
||||
/* ══ LOCATION LIST — CR-005 (step 11) ═════════════════════════════════════════
|
||||
Building / floor / sector, configured per project. The code beside each name
|
||||
is the thing CR-018 rolls cost up by, and it is shown rather than hidden so it
|
||||
is obvious that renaming a value does not move it. */
|
||||
.loc-actions { display: flex; gap: var(--wp-s2); flex-wrap: wrap; margin-top: var(--wp-s3); }
|
||||
|
||||
.loc-report { margin-top: var(--wp-s3); font-size: 13px; }
|
||||
.loc-report:empty { display: none; }
|
||||
.loc-report-line { margin: 0 0 var(--wp-s2); }
|
||||
.loc-report-group { margin-top: var(--wp-s3); }
|
||||
.loc-report-title { font-size: 12px; font-weight: 600; color: var(--text-light); }
|
||||
.loc-report-list { margin: var(--wp-s1) 0 0; padding-left: var(--wp-s5); }
|
||||
.loc-report-list li { margin-bottom: var(--wp-s1); color: var(--text-light); }
|
||||
.loc-line { font-weight: 600; color: var(--text); }
|
||||
/* A report that lost rows says so in its own right, not only by wording. */
|
||||
.loc-report.is-problem { border-left: 3px solid var(--warning); padding-left: var(--wp-s3);
|
||||
background: var(--warning-bg); padding-top: var(--wp-s2); padding-bottom: var(--wp-s2); }
|
||||
|
||||
#loc-list { display: flex; flex-direction: column; margin-top: var(--wp-s3); }
|
||||
.loc-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wp-s3);
|
||||
padding: var(--wp-s2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* Indentation is the hierarchy, and it is not the only cue — .loc-level names
|
||||
the level in words beside it (C1: never colour or position alone). */
|
||||
.loc-depth-1 { padding-left: var(--wp-s5); }
|
||||
.loc-depth-2 { padding-left: var(--wp-s6); }
|
||||
.loc-level {
|
||||
flex: none; min-width: 68px;
|
||||
font-size: 11px; font-weight: 600; letter-spacing: .04em;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.loc-name {
|
||||
flex: 1 1 180px; min-width: 0;
|
||||
padding: var(--wp-s1) var(--wp-s2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 0;
|
||||
background: var(--cds-field);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.loc-code {
|
||||
flex: none;
|
||||
font-family: var(--wp-font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.loc-toggle { display: flex; align-items: center; gap: var(--wp-s1); font-size: 12px; color: var(--text-light); }
|
||||
/* A deactivated value is still listed — that is how it is brought back — but it
|
||||
reads as out of use, by strikethrough as well as by the unticked box. */
|
||||
.loc-row.is-off .loc-name { text-decoration: line-through; color: var(--text-light); }
|
||||
.loc-row.is-off .loc-code { text-decoration: line-through; }
|
||||
|
||||
.loc-addrow {
|
||||
display: flex; align-items: center; gap: var(--wp-s2); flex-wrap: wrap;
|
||||
margin-top: var(--wp-s4);
|
||||
}
|
||||
.loc-addlabel { font-size: 12px; font-weight: 600; color: var(--text-light); }
|
||||
.loc-addrow select, .loc-addrow input {
|
||||
padding: var(--wp-s2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 0;
|
||||
background: var(--cds-field);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.loc-addrow input { flex: 1 1 200px; min-width: 0; }
|
||||
|
||||
/* ══ SECTION TOGGLES — CR-006 (step 12) ═══════════════════════════════════════
|
||||
Ten rows, each a real <label> wrapping a real checkbox, so the whole row is a
|
||||
target and the browser gives the keyboard and screen-reader behaviour for
|
||||
free. The state is said in words as well as by the tick (C1). */
|
||||
#section-toggles { display: flex; flex-direction: column; margin-top: var(--wp-s3); }
|
||||
|
||||
.section-toggle {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--wp-s3);
|
||||
padding: var(--wp-s3);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--success);
|
||||
background: var(--bg-card);
|
||||
margin-bottom: -1px; /* collapse the hairline between rows */
|
||||
cursor: pointer;
|
||||
}
|
||||
.section-toggle:hover { background: var(--bg); }
|
||||
.section-toggle input { flex: none; width: 18px; height: 18px; margin-top: 2px; }
|
||||
.section-toggle-body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: var(--wp-s1); }
|
||||
.section-toggle-name { font-size: 14px; font-weight: 600; color: var(--text); }
|
||||
.section-toggle-note { font-size: 12px; color: var(--text-light); }
|
||||
.section-toggle-state {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-light);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Off is said three ways: the box is unticked, the row says "Not used", and the
|
||||
rule down its left edge goes from green to grey. Colour is the one nobody has
|
||||
to be able to see. */
|
||||
.section-toggle.is-off { border-left-color: var(--border-strong); }
|
||||
.section-toggle.is-off .section-toggle-name { color: var(--text-light); }
|
||||
|
||||
/* CR-002: a field inside a section, not an eleventh section. Indented and
|
||||
smaller so the nesting is visible, and it inherits every other rule above so
|
||||
the two cannot look like different kinds of control. */
|
||||
.field-toggle {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--wp-s3);
|
||||
padding: var(--wp-s2) var(--wp-s3) var(--wp-s2) var(--wp-s6);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--success);
|
||||
background: var(--bg);
|
||||
margin-bottom: -1px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.field-toggle:hover { background: var(--cds-layer-hover); }
|
||||
.field-toggle input { flex: none; width: 16px; height: 16px; margin-top: 2px; }
|
||||
.field-toggle .section-toggle-name { font-size: 13px; }
|
||||
.field-toggle.is-off { border-left-color: var(--border-strong); }
|
||||
.field-toggle.is-off .section-toggle-name { color: var(--text-light); }
|
||||
|
||||
/* ══ ANNOUNCEMENTS — S1 / T5.8 ════════════════════════════════════════════════
|
||||
Where the wizard's thirteen native dialogs went. Fixed to the bottom of the
|
||||
viewport so it is visible from any scroll position on a step that is three
|
||||
screens tall, and never over the step navigation, which is where somebody's
|
||||
attention is when a validation message fires. */
|
||||
.wp-toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: var(--wp-s5);
|
||||
transform: translateX(-50%);
|
||||
z-index: 1400; /* over the drawer scrim, under a modal */
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--wp-s3);
|
||||
max-width: min(640px, calc(100vw - 2 * var(--wp-s4)));
|
||||
padding: var(--wp-s3) var(--wp-s4);
|
||||
background: var(--cds-ui-05);
|
||||
color: var(--cds-text-on-color);
|
||||
box-shadow: var(--wp-shadow-toast);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wp-toast[hidden] { display: none; }
|
||||
.wp-toast-text { flex: 1 1 auto; min-width: 0; }
|
||||
/* An error looks different, not just louder: a red rule down its edge, and it
|
||||
stays until dismissed where a confirmation times out (C1 — never colour
|
||||
alone, and the persistence is the second channel). */
|
||||
.wp-toast.is-alert { border-left: 4px solid var(--danger); }
|
||||
.wp-toast-action,
|
||||
.wp-toast-close {
|
||||
flex: none;
|
||||
background: none;
|
||||
border: 1px solid var(--wp-appbar-border);
|
||||
border-radius: 0;
|
||||
color: var(--cds-text-on-color);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: var(--wp-s1) var(--wp-s2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.wp-toast-close { border-color: transparent; padding: var(--wp-s1); }
|
||||
.wp-toast-action:hover,
|
||||
.wp-toast-close:hover { background: var(--wp-appbar-hover); }
|
||||
|
||||
/* .field-error is declared once, in theme-light.css — the launcher's create form
|
||||
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
|
||||
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
|
||||
bar now rides the bottom of the viewport while a tall step scrolls, and sits
|
||||
in its natural place on short ones. position:sticky (not the creator's
|
||||
fixed) because the bar lives inside the wizard's grid column: sticky keeps
|
||||
its slot in the flow, so it can never obscure a field at any width - there
|
||||
is nothing to pad around. The T4.4 save-state indicator already mounts in
|
||||
this bar (B5) and rides along. */
|
||||
.step-navigation {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
background: var(--bg-card);
|
||||
border-top: 1px solid var(--border);
|
||||
box-shadow: var(--wp-shadow-sticky);
|
||||
border-radius: 0;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
@@ -477,7 +794,7 @@ body.embed-full { overflow: hidden; }
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.comments-toggle:hover { background: var(--primary); color: white; }
|
||||
.comments-toggle:hover { background: var(--primary); color: var(--cds-text-on-color); }
|
||||
|
||||
/* CONSTRUCTION SEQUENCE (drag & drop) */
|
||||
#sequence-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
@@ -498,16 +815,19 @@ body.embed-full { overflow: hidden; }
|
||||
.seq-step input.seq-label {
|
||||
border: 1px solid transparent; background: transparent; font-size: 14px; padding: 5px 8px; color: var(--text); flex: 1; border-radius: 4px;
|
||||
}
|
||||
.seq-step input.seq-label:focus { background: var(--bg); border-color: var(--primary); outline: none; }
|
||||
/* Fill only — the ring itself is the shared rule above, which this used to opt out
|
||||
of entirely with a bare `outline: none`. */
|
||||
.seq-step input.seq-label:focus { background: var(--bg); }
|
||||
/* danger, filled: a 28px square is too small for an outline to read */
|
||||
.seq-del {
|
||||
background: var(--danger); color: white; border: none; border-radius: 4px;
|
||||
background: var(--wp-btn-danger-fill-bg); color: var(--wp-btn-danger-fill-fg); border: none; border-radius: 4px;
|
||||
width: 28px; height: 28px; cursor: pointer; font-weight: 600; flex-shrink: 0;
|
||||
}
|
||||
.seq-arrow { text-align: center; color: var(--text-dim); font-size: 13px; line-height: .4; margin: -2px 0; }
|
||||
.seq-step.gate { border-color: var(--warning); background: var(--warning-bg); border-style: dashed; }
|
||||
.seq-step.gate .seq-label { color: var(--warning); font-weight: 500; }
|
||||
.seq-gate-badge {
|
||||
flex-shrink: 0; padding: 3px 9px; border-radius: 20px; background: var(--warning); color: #fff;
|
||||
flex-shrink: 0; padding: 3px 9px; border-radius: 20px; background: var(--warning); color: var(--cds-text-on-color);
|
||||
font-size: 9px; font-weight: 600; letter-spacing: .08em; white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -551,7 +871,7 @@ body.embed-full { overflow: hidden; }
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
background: var(--wp-scrim-neutral);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -599,9 +919,53 @@ body.embed-full { overflow: hidden; }
|
||||
}
|
||||
|
||||
/* RESPONSIVE */
|
||||
|
||||
/* Below 900px there is no room for a rail beside the form. Ten vertical rows
|
||||
stacked above the fields is most of a 390px screen before you reach the first
|
||||
input, so the rail becomes a disclosure: where you are, tap to see the rest.
|
||||
It is still the same ten buttons — nothing is hidden from the keyboard that is
|
||||
not also hidden from the mouse. */
|
||||
@media (max-width: 899px) {
|
||||
#tool-sop.active { grid-template-columns: minmax(0, 1fr); gap: var(--wp-s4); }
|
||||
.step-rail,
|
||||
.step-content,
|
||||
.step-navigation { grid-column: 1; grid-row: auto; }
|
||||
/* The disclosure button carries the box at this width; the rail itself is only
|
||||
a wrapper, and two nested borders read as two controls. */
|
||||
.step-rail { position: static; background: none; border: 0; padding: 0; }
|
||||
.step-rail:not(.is-collapsed) .step-rail-list {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
padding: var(--wp-s2);
|
||||
}
|
||||
|
||||
.step-rail-toggle {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--wp-s2);
|
||||
padding: var(--wp-s3);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 0;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.step-rail-toggle-chev { transition: transform 0.15s; }
|
||||
.step-rail.is-collapsed .step-rail-list { display: none; }
|
||||
.step-rail:not(.is-collapsed) .step-rail-toggle { margin-bottom: var(--wp-s2); }
|
||||
.step-rail:not(.is-collapsed) .step-rail-toggle-chev { transform: rotate(180deg); }
|
||||
/* Gloved hands on a tablet: 44px minimum, which the 26px marker plus 8px of
|
||||
padding does not reach on its own. */
|
||||
.step-btn { min-height: 44px; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header { height: auto; flex-direction: column; align-items: stretch; text-align: center; gap: 0.75rem; padding: 12px 16px; }
|
||||
.main-nav { flex-wrap: wrap; }
|
||||
.content-area { padding: 1rem; }
|
||||
.step-content { padding: 1rem; }
|
||||
.wp-type-row { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -8,11 +8,22 @@
|
||||
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
|
||||
timestamps during their own boot. -->
|
||||
<script src="wp-format.js"></script>
|
||||
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
|
||||
during their own boot. -->
|
||||
<script src="wp-url.js"></script>
|
||||
<script src="wp-usage.js"></script>
|
||||
<script src="wp-list-import.js"></script>
|
||||
<!-- Autosave, unsaved-work guard, draft recovery (S2). -->
|
||||
<script src="wp-autosave.js"></script>
|
||||
<!-- Which work package sections this project uses (CR-006). Shared with the
|
||||
creator so the two cannot disagree about what "Assets is off" means. -->
|
||||
<script src="wp-sections.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<link rel="stylesheet" href="wp-chrome.css">
|
||||
<link rel="stylesheet" href="wp-sidenav.css">
|
||||
<link rel="stylesheet" href="work-package-suite-styles.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -29,24 +40,36 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="load-sample-btn" class="header-button" onclick="loadSampleData()" title="Load sample data for the current tool (SOP or Work Package)">Load Sample</button>
|
||||
<button class="header-button" onclick="toggleComments()" title="Leave feedback for the current step">Feedback</button>
|
||||
<button class="header-button" onclick="openHelp()" title="How the suite works + key concepts">Help</button>
|
||||
<span class="step-counter"><span id="current-step">1</span> / <span id="total-steps">10</span></span>
|
||||
<!-- A4/S9: the orphaned "1 / 10" counter lived here. It is retired — the
|
||||
step rail below says where you are, what is done and what is not
|
||||
reachable yet, which is the job the counter was standing in for. -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MAIN NAVIGATION -->
|
||||
<div class="main-nav">
|
||||
<!-- MAIN NAVIGATION (B7 / T7.1)
|
||||
Two of these three tabs used to swap an iframe. The creator is its own
|
||||
document now, so they are links - real ones, so middle-click and
|
||||
ctrl-click work and the browser's own back button is the back button.
|
||||
wp-chrome.js stamps ?project= onto each once the active project is known.
|
||||
|
||||
They are still gated: until the SOP is complete there is nothing for the
|
||||
creator to build from, and clicking one opens the gate panel instead of
|
||||
navigating. A gated tab keeps its focus and says why, rather than
|
||||
disappearing - see switchTool() in work-package-suite-app.js. -->
|
||||
<div class="main-nav" role="navigation" aria-label="Work package tools">
|
||||
<button class="nav-tab active" data-tab="sop" onclick="switchTool('sop')">
|
||||
SOP Configuration
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="wp" onclick="switchTool('wp')">
|
||||
<a class="nav-tab" data-tab="wp" data-nav-href="wp-creation-index.html"
|
||||
onclick="return gateCreatorLink(event)">
|
||||
Work Package Creation
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="dashboard" onclick="switchTool('dashboard')">
|
||||
</a>
|
||||
<a class="nav-tab" data-tab="dashboard" data-nav-href="wp-creation-index.html?view=dashboard"
|
||||
onclick="return gateCreatorLink(event)">
|
||||
Dashboard
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- CONTENT AREA -->
|
||||
@@ -57,21 +80,53 @@
|
||||
<!-- ═════════════════════════════════════════════════════════════════════ -->
|
||||
<div id="tool-sop" class="tool active">
|
||||
|
||||
<!-- SOP STEP INDICATORS -->
|
||||
<div class="step-nav">
|
||||
<div class="steps-container">
|
||||
<div class="step-item active" data-step="1" onclick="goToStep(1)">Project</div>
|
||||
<div class="step-item" data-step="2" onclick="goToStep(2)">Team</div>
|
||||
<div class="step-item" data-step="3" onclick="goToStep(3)">Sign-Offs</div>
|
||||
<div class="step-item" data-step="4" onclick="goToStep(4)">WP Types</div>
|
||||
<div class="step-item" data-step="5" onclick="goToStep(5)">Governance</div>
|
||||
<div class="step-item" data-step="6" onclick="goToStep(6)">Quality</div>
|
||||
<div class="step-item" data-step="7" onclick="goToStep(7)">Platforms</div>
|
||||
<div class="step-item" data-step="8" onclick="goToStep(8)">Sequence</div>
|
||||
<div class="step-item" data-step="9" onclick="goToStep(9)">Constraints</div>
|
||||
<div class="step-item" data-step="10" onclick="goToStep(10)">Sources</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SOP STEP RAIL — A4 / S9 / C1
|
||||
Was ten div elements carrying onclick, in a horizontally scrolling
|
||||
strip: not in the tab order, not operable by keyboard, and silent
|
||||
about which steps were finished or reachable. (Spelled out rather
|
||||
than quoted, because the wave 0 baseline counts that markup with a
|
||||
grep and a comment about it would inflate the number it is proving
|
||||
went down.) Every step is a real button now, the
|
||||
states are rendered by renderStepRail() in work-package-suite-app.js,
|
||||
and each state carries a word as well as a colour.
|
||||
|
||||
The list is static markup rather than JS-built so the rail exists
|
||||
before any script runs — a step you cannot see is a step you cannot
|
||||
reach, and the wizard's scripts are render-blocking classics. -->
|
||||
<nav class="step-rail is-collapsed" id="step-rail" aria-label="SOP configuration steps">
|
||||
<!-- Narrow widths only (see the 899px breakpoint). Ten vertical rows
|
||||
above the form is most of a 390px screen, so the rail collapses to
|
||||
where-you-are and opens on demand. -->
|
||||
<button type="button" class="step-rail-toggle" id="step-rail-toggle"
|
||||
aria-expanded="false" aria-controls="step-rail-list">
|
||||
<span class="step-rail-toggle-text">Step <span id="step-rail-pos">1</span> of <span id="step-rail-total">12</span> · <span id="step-rail-here">Project</span></span>
|
||||
<span class="step-rail-toggle-chev" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
<ol class="step-rail-list" id="step-rail-list">
|
||||
<li class="step-rail-item"><button type="button" class="step-btn" data-step="1"><span class="step-btn-marker" aria-hidden="true">1</span><span class="step-btn-body"><span class="step-btn-label">Project</span><span class="step-btn-state"></span></span></button></li>
|
||||
<li class="step-rail-item"><button type="button" class="step-btn" data-step="2"><span class="step-btn-marker" aria-hidden="true">2</span><span class="step-btn-body"><span class="step-btn-label">Team</span><span class="step-btn-state"></span></span></button></li>
|
||||
<li class="step-rail-item"><button type="button" class="step-btn" data-step="3"><span class="step-btn-marker" aria-hidden="true">3</span><span class="step-btn-body"><span class="step-btn-label">Sign-offs</span><span class="step-btn-state"></span></span></button></li>
|
||||
<li class="step-rail-item"><button type="button" class="step-btn" data-step="4"><span class="step-btn-marker" aria-hidden="true">4</span><span class="step-btn-body"><span class="step-btn-label">WP types</span><span class="step-btn-state"></span></span></button></li>
|
||||
<li class="step-rail-item"><button type="button" class="step-btn" data-step="5"><span class="step-btn-marker" aria-hidden="true">5</span><span class="step-btn-body"><span class="step-btn-label">Governance</span><span class="step-btn-state"></span></span></button></li>
|
||||
<li class="step-rail-item"><button type="button" class="step-btn" data-step="6"><span class="step-btn-marker" aria-hidden="true">6</span><span class="step-btn-body"><span class="step-btn-label">Quality</span><span class="step-btn-state"></span></span></button></li>
|
||||
<li class="step-rail-item"><button type="button" class="step-btn" data-step="7"><span class="step-btn-marker" aria-hidden="true">7</span><span class="step-btn-body"><span class="step-btn-label">Platforms</span><span class="step-btn-state"></span></span></button></li>
|
||||
<li class="step-rail-item"><button type="button" class="step-btn" data-step="8"><span class="step-btn-marker" aria-hidden="true">8</span><span class="step-btn-body"><span class="step-btn-label">Sequence</span><span class="step-btn-state"></span></span></button></li>
|
||||
<li class="step-rail-item"><button type="button" class="step-btn" data-step="9"><span class="step-btn-marker" aria-hidden="true">9</span><span class="step-btn-body"><span class="step-btn-label">Constraints</span><span class="step-btn-state"></span></span></button></li>
|
||||
<li class="step-rail-item"><button type="button" class="step-btn" data-step="10"><span class="step-btn-marker" aria-hidden="true">10</span><span class="step-btn-body"><span class="step-btn-label">Sources</span><span class="step-btn-state"></span></span></button></li>
|
||||
<!-- CR-005. Appended rather than inserted next to Project, which is where
|
||||
it belongs by subject: renumbering steps 2-10 would touch every
|
||||
sop-step-N id, every collectStepData case, every gate key and the
|
||||
analytics history, for an ordering change. Numbers are cheap to
|
||||
reorder once nothing depends on them; a renumbering buried in a
|
||||
feature diff is not. -->
|
||||
<li class="step-rail-item"><button type="button" class="step-btn" data-step="11"><span class="step-btn-marker" aria-hidden="true">11</span><span class="step-btn-body"><span class="step-btn-label">Locations</span><span class="step-btn-state"></span></span></button></li>
|
||||
<li class="step-rail-item"><button type="button" class="step-btn" data-step="12"><span class="step-btn-marker" aria-hidden="true">12</span><span class="step-btn-body"><span class="step-btn-label">Sections</span><span class="step-btn-state"></span></span></button></li>
|
||||
</ol>
|
||||
<!-- Why a step you asked for did not open. role="alert" because a refused
|
||||
navigation is an error and waiting for a pause to say so is too late
|
||||
(S10 / T4.5). Empty until something is refused. -->
|
||||
<p class="step-rail-msg" id="step-rail-msg" role="alert"></p>
|
||||
</nav>
|
||||
|
||||
<!-- SOP STEP CONTENT -->
|
||||
<div class="step-content">
|
||||
@@ -82,23 +137,23 @@
|
||||
<div class="notice">Define the core project information that will be inherited by all Work Packages.</div>
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label>Project Name *</label>
|
||||
<label>Project name *</label>
|
||||
<input type="text" id="proj_name" placeholder="e.g., Micron — INC Construction Work Packages" oninput="updateProjectDisplay()">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Project Number *</label>
|
||||
<label>Project number *</label>
|
||||
<input type="text" id="proj_number" placeholder="e.g., 26-67-008">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Client Name *</label>
|
||||
<label>Client name *</label>
|
||||
<input type="text" id="proj_client" placeholder="e.g., Micron Technology, Inc.">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Division / Sector *</label>
|
||||
<label>Division / sector *</label>
|
||||
<input type="text" id="proj_division" placeholder="e.g., Semiconductor, Oil & Gas, Data Center">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Site Location *</label>
|
||||
<label>Site location *</label>
|
||||
<input type="text" id="proj_site" placeholder="e.g., Boise, ID — Fab 7">
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,27 +169,32 @@
|
||||
<div id="team-accounts-warn" class="notice" style="display:none; background:var(--warning-bg); color:var(--warning);"></div>
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label>Project Manager (PM)</label>
|
||||
<label>Project manager (PM)</label>
|
||||
<select id="proj_pm" class="team-pick" data-team="pm"></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Assistant Project Manager (APM)</label>
|
||||
<label>Assistant project manager (APM)</label>
|
||||
<select id="proj_apm" class="team-pick" data-team="apm"></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Construction Manager (CM)</label>
|
||||
<label>Construction manager (CM)</label>
|
||||
<select id="proj_cm" class="team-pick" data-team="cm"></select>
|
||||
<div class="field-hint">Kept on the distribution list of every work package by default.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Quality Manager (QM)</label>
|
||||
<label>Quality manager (QM)</label>
|
||||
<select id="proj_qm" class="team-pick" data-team="qm"></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>QA group — notified at Ready for QA</label>
|
||||
<select id="proj_qagroup" multiple size="4" aria-describedby="qagroup-hint"></select>
|
||||
<div class="field-hint" id="qagroup-hint">Everyone picked here is emailed when a work package reaches <strong>Ready for QA</strong> (CR-014). Hold Ctrl (Cmd on Mac) to pick several.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
|
||||
<div class="sub-heading">Additional Team Members (optional)</div>
|
||||
<div id="team-members-list" style="margin-top: 1rem;"></div>
|
||||
<button class="add-btn" onclick="addTeamMember()">+ Add Team Member</button>
|
||||
<button class="add-btn" onclick="addTeamMember()">+ Add team member</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -161,7 +221,7 @@
|
||||
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
|
||||
<div class="sub-heading">Optional Additional Roles</div>
|
||||
<div id="optional-roles-list" style="margin-top: 1rem;"></div>
|
||||
<button class="add-btn" onclick="addOptionalRole()">+ Add Role</button>
|
||||
<button class="add-btn" onclick="addOptionalRole()">+ Add role</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -173,7 +233,7 @@
|
||||
<label id="bim-toggle-wrap" style="display:flex; align-items:flex-start; gap:0.6rem; padding:0.85rem 1rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin:0 0 1rem; cursor:pointer;">
|
||||
<input type="checkbox" id="bim_enabled" onchange="setBimEnabled(this.checked)" style="width:18px; height:18px; margin-top:2px; flex:none;">
|
||||
<span><strong>Include BIM / VDC work packages on this project</strong><br>
|
||||
<span style="color:var(--text-dim); font-size:12px;">Adds model/engineering package types & release gates. In the Creator each package is then tagged <strong>Install (IWP)</strong> or <strong>BIM (EWP)</strong>, so the project can flow from BIM into construction. Leave off for install-only projects.</span></span>
|
||||
<span style="color:var(--text-dim); font-size:12px;">adds model/engineering package types & release gates. in the creator each package is then tagged <strong>install (IWP)</strong> or <strong>BIM (EWP)</strong>, so the project can flow from BIM into construction. leave off for install-only projects.</span></span>
|
||||
</label>
|
||||
<!-- Shown instead of the toggle when an admin has the BIM tooling switched off app-wide. -->
|
||||
<div id="bim-disabled-note" class="notice" style="display:none; background:var(--warning-bg); color:var(--warning);"></div>
|
||||
@@ -186,12 +246,12 @@
|
||||
<div class="notice">Define how Work Packages are formatted, sized, and issued on this project. The choices here decide how the Work Package Creator behaves for every package.</div>
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label>Work Package Number Format *</label>
|
||||
<label>Work package number format *</label>
|
||||
<input type="text" id="gov_woformat" placeholder="e.g., WP##-[Sector]-[TYPE]">
|
||||
<small>Use ## for counter, [Sector] [TYPE] as variables</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Issuance Strategy<span class="help-tip" data-tip="How Work Packages are grouped and released on this project. Pick one or more — most projects combine 'By Sector / Area' with 'By Phase / Sequence'.">i</span></label>
|
||||
<label>Issuance strategy<span class="help-tip" data-tip="How Work Packages are grouped and released on this project. Pick one or more — most projects combine 'By Sector / Area' with 'By Phase / Sequence'.">i</span></label>
|
||||
<select id="gov_issuance" multiple size="4">
|
||||
<option selected>By Sector / Area</option>
|
||||
<option>By Discipline</option>
|
||||
@@ -234,7 +294,7 @@
|
||||
<div class="notice">A Work Package should be a manageable, trackable chunk of work — typically a 1–2 week assignment. The Creator warns the planner when a package exceeds the ceiling so it can be broken down.</div>
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label>Typical WP Size</label>
|
||||
<label>Typical WP size</label>
|
||||
<select id="gov_wosize" onchange="onSizePresetChange()">
|
||||
<option value="">Select…</option>
|
||||
<option value="Small — 1–2 days (≈8–24 hrs)">Small — 1–2 days (≈8–24 hrs)</option>
|
||||
@@ -258,7 +318,7 @@
|
||||
<div class="notice">Establish project-wide quality expectations that cascade to every Work Package.</div>
|
||||
<div class="field-grid col1">
|
||||
<div class="field">
|
||||
<label>QC Required? *</label>
|
||||
<label>QC required? *</label>
|
||||
<select id="qual_qcreq">
|
||||
<option>Yes</option>
|
||||
<option>Yes — Detailed inspection items</option>
|
||||
@@ -267,7 +327,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Photo / Documentation Standard</label>
|
||||
<label>Photo / documentation standard</label>
|
||||
<select id="qual_photo">
|
||||
<option selected>Key checkpoints only</option>
|
||||
<option>Every step documented</option>
|
||||
@@ -276,7 +336,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Hold Points & Witness Requirements</label>
|
||||
<label>Hold points & witness requirements</label>
|
||||
<textarea id="qual_hold" rows="3" placeholder="e.g., HOLD: Prime QAQC to inspect rough-in before cover/cover-up. WITNESS: client QC to observe megger test before energization."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
@@ -288,7 +348,7 @@
|
||||
<div class="notice">Select the tools used for construction tracking and commissioning. These can be the same or different systems.</div>
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label>Construction Tracking Platform *</label>
|
||||
<label>Construction tracking platform *</label>
|
||||
<select id="plat_tracking">
|
||||
<option>CxAlloy</option>
|
||||
<option>Procore</option>
|
||||
@@ -297,7 +357,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Commissioning Tool *</label>
|
||||
<label>Commissioning tool *</label>
|
||||
<select id="plat_commissioning">
|
||||
<option selected>CxAlloy</option>
|
||||
<option>Procore</option>
|
||||
@@ -325,8 +385,8 @@
|
||||
<div id="sequence-list" style="margin-top: 1rem;"></div>
|
||||
<div style="display:flex; gap:0.5rem; margin-top:1rem; flex-wrap:wrap;">
|
||||
<input type="text" id="seq-add-input" placeholder="New step name" onkeydown="if(event.key==='Enter'){addSequenceStep();}" style="flex:1; min-width:200px; padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
||||
<button class="add-btn" onclick="addSequenceStep()">+ Add Step</button>
|
||||
<button class="add-btn" onclick="addSequenceGate()" style="background:var(--warning);">Add QC Hold</button>
|
||||
<button class="add-btn" onclick="addSequenceStep()">+ Add step</button>
|
||||
<button class="add-btn" onclick="addSequenceGate()" style="background:var(--warning);">Add QC hold</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -341,7 +401,7 @@
|
||||
<div style="border-top: 1px solid var(--border); padding-top: 1.5rem;">
|
||||
<div class="sub-heading">Custom Constraints (Optional)</div>
|
||||
<div id="custom-constraints-list" style="margin-top: 1rem;"></div>
|
||||
<button class="add-btn" onclick="showConstraintLibrary()">+ Add Custom Constraint</button>
|
||||
<button class="add-btn" onclick="showConstraintLibrary()">+ Add custom constraint</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -350,7 +410,123 @@
|
||||
<h2>10. Engineering Sources & References</h2>
|
||||
<div class="notice">Link to key documents and systems that WP authors will reference.</div>
|
||||
<div id="sources-list" style="margin-top: 1rem;"></div>
|
||||
<button class="add-btn" onclick="addSource()">+ Add Source</button>
|
||||
<button class="add-btn" onclick="addSource()">+ Add source</button>
|
||||
</div>
|
||||
|
||||
<!-- STEP 11: LOCATIONS (CR-005)
|
||||
Server-backed, not localStorage: CR-018 rolls cost up by these
|
||||
values, and a taxonomy each browser keeps its own copy of cannot
|
||||
be rolled up by anything. See CLAUDE.md, "Frontend and backend
|
||||
boundary". -->
|
||||
<div class="step" id="sop-step-11" style="display: none;">
|
||||
<h2>11. Location List</h2>
|
||||
<div class="notice">Building, floor and sector for this project. The shape of a
|
||||
location differs per job — on some, floors within one building behave like
|
||||
separate buildings and are the unit of both execution and cost tracking — so it
|
||||
is configured here rather than assumed. Work packages pick from this list, and
|
||||
cost rolls up by it.</div>
|
||||
|
||||
<div id="loc-noproject" class="notice" style="display:none; background:var(--warning-bg); color:var(--warning);"></div>
|
||||
|
||||
<div id="loc-tool">
|
||||
<div class="field-grid col1">
|
||||
<div class="field">
|
||||
<label for="loc-paste">Paste rows, or upload a CSV</label>
|
||||
<textarea id="loc-paste" rows="6" aria-describedby="loc-paste-hint"
|
||||
placeholder="One row per sector, e.g. Building, Floor, Sector"></textarea>
|
||||
<small id="loc-paste-hint">One row per value, deepest level last:
|
||||
<strong>building, floor, sector</strong>. Two columns describe a floor,
|
||||
one describes a building. Comma, semicolon or tab separated — a paste
|
||||
straight out of a spreadsheet works. A header row is ignored.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loc-actions">
|
||||
<input type="file" id="loc-file" accept=".csv,.txt,text/csv,text/plain" hidden>
|
||||
<button type="button" class="add-btn" id="loc-file-btn">Choose a CSV file…</button>
|
||||
<button type="button" class="add-btn" id="loc-check-btn">Check without importing</button>
|
||||
<button type="button" class="add-btn" id="loc-import-btn">Import</button>
|
||||
<button type="button" class="add-btn" id="loc-sample-btn">Load sample values</button>
|
||||
</div>
|
||||
<!-- Rejected rows and duplicates land here, with line numbers. An
|
||||
import that says "42 rows" over a file with 50 in it has lost
|
||||
eight and told nobody, which is what this exists to prevent. -->
|
||||
<div class="loc-report" id="loc-report" role="status"></div>
|
||||
|
||||
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
|
||||
<div class="sub-heading">Current list</div>
|
||||
<p class="field-hint" id="loc-count"></p>
|
||||
<div id="loc-list"></div>
|
||||
<div class="loc-addrow">
|
||||
<label class="loc-addlabel" for="loc-add-parent">Add under</label>
|
||||
<select id="loc-add-parent"></select>
|
||||
<label class="loc-addlabel" for="loc-add-name">Name</label>
|
||||
<input type="text" id="loc-add-name" placeholder="e.g. a new sector">
|
||||
<button type="button" class="add-btn" id="loc-add-btn">+ Add</button>
|
||||
</div>
|
||||
<div class="field-error" id="loc-add-err" role="alert"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MATERIAL LIST (D6 / T8.6). Same component as the location list
|
||||
above (wp-list-import.js), same rules: paste or file, dry-run
|
||||
check, rejected rows named by source line, deactivate not
|
||||
delete. Description, unit, optional code - and nothing else:
|
||||
no inventory, no pricing, no warehouse id. -->
|
||||
<div style="margin-top: 2.5rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
|
||||
<div class="sub-heading">Material list (D6)</div>
|
||||
<div class="step-desc">What a material request picks from (CR-013). Optional —
|
||||
a project with no list still raises requests with free text.</div>
|
||||
<div id="mat-noproject" class="notice" style="display:none; background:var(--warning-bg); color:var(--warning);"></div>
|
||||
<div id="mat-tool">
|
||||
<div class="field-grid col1">
|
||||
<div class="field">
|
||||
<label for="mat-paste">Paste rows, or upload a CSV</label>
|
||||
<textarea id="mat-paste" rows="5" aria-describedby="mat-paste-hint"
|
||||
placeholder="One row per material, e.g. Description, unit, code"></textarea>
|
||||
<small id="mat-paste-hint">One row per material:
|
||||
<strong>description, unit, code</strong> — unit and code optional.
|
||||
Comma, semicolon or tab separated. A header row is ignored.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loc-actions">
|
||||
<input type="file" id="mat-file" accept=".csv,.txt,text/csv,text/plain" hidden>
|
||||
<button type="button" class="add-btn" id="mat-file-btn">Choose a CSV file…</button>
|
||||
<button type="button" class="add-btn" id="mat-check-btn">Check without importing</button>
|
||||
<button type="button" class="add-btn" id="mat-import-btn">Import</button>
|
||||
<button type="button" class="add-btn" id="mat-sample-btn">Load sample values</button>
|
||||
</div>
|
||||
<div class="loc-report" id="mat-report" role="status"></div>
|
||||
<div style="margin-top: 1.5rem;">
|
||||
<div class="sub-heading">Current list</div>
|
||||
<p class="field-hint" id="mat-count"></p>
|
||||
<div id="mat-list"></div>
|
||||
<div class="loc-addrow">
|
||||
<label class="loc-addlabel" for="mat-add-name">Description</label>
|
||||
<input type="text" id="mat-add-name" placeholder="e.g. a strut channel">
|
||||
<label class="loc-addlabel" for="mat-add-unit">Unit</label>
|
||||
<input type="text" id="mat-add-unit" placeholder="EA / FT" style="max-width:90px">
|
||||
<button type="button" class="add-btn" id="mat-add-btn">+ Add</button>
|
||||
</div>
|
||||
<div class="field-error" id="mat-add-err" role="alert"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 12: WORK PACKAGE SECTIONS (CR-006)
|
||||
The structural fix behind most removal requests in the plan: rather
|
||||
than deleting fields globally, each project turns on only the
|
||||
sections it uses. CR-002 and CR-016 are expressed through these. -->
|
||||
<div class="step" id="sop-step-12" style="display: none;">
|
||||
<h2>12. Work Package Sections</h2>
|
||||
<div class="notice">Turn off the parts of a work package this project does not
|
||||
use. A section that is off does not appear in the creation form, in the work
|
||||
package itself, or in the PDF export. <strong>Nothing is deleted.</strong>
|
||||
Anything already captured stays on the package and comes back intact if the
|
||||
section is turned on again — which is what makes this different from removing
|
||||
a field.</div>
|
||||
<div id="section-toggles"></div>
|
||||
<p class="field-hint" id="section-summary" role="status"></p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -359,7 +535,7 @@
|
||||
<div class="step-navigation">
|
||||
<button class="nav-btn" id="sop-prev-btn" onclick="previousStep()">Back</button>
|
||||
<button class="nav-btn" id="sop-next-btn" onclick="nextStep()">Next</button>
|
||||
<button class="nav-btn primary" id="sop-complete-btn" onclick="completeSOP()" style="display: none;">SOP Complete</button>
|
||||
<button class="nav-btn primary" id="sop-complete-btn" onclick="completeSOP()" style="display: none;">SOP complete</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -371,17 +547,13 @@
|
||||
<!-- Shown until the SOP is complete -->
|
||||
<div id="wp-gate" style="padding: 3rem 2rem; text-align: center;">
|
||||
<h2>Work Package Creation</h2>
|
||||
<p style="color: var(--text-light); margin: 1rem 0;">Complete the SOP Configuration first to enable Work Package creation. Once the SOP is finished, the full creator loads here with your project defaults pre-populated.</p>
|
||||
<button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">Go to SOP Configuration</button>
|
||||
<p style="color: var(--text-light); margin: 1rem 0;">Complete the SOP Configuration first to enable Work Package creation. Once the SOP is finished, the Work Package Creation tab opens the creator with your project defaults pre-populated.</p>
|
||||
<button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">Go to SOP configuration</button>
|
||||
</div>
|
||||
<!-- The real Work Package Creator, embedded once the SOP is complete -->
|
||||
<!-- Sizing stays INLINE on purpose. An iframe with no width/height falls back
|
||||
to the HTML default 300x150 box, and the service worker caches this page
|
||||
and the stylesheet separately — so a browser can hold new HTML with old
|
||||
CSS and collapse the creator to a tiny scrolling box. Inline attributes
|
||||
survive any cache mismatch; the CSS below only refines them. -->
|
||||
<iframe id="wp-frame" title="Work Package Creator"
|
||||
style="display:none; width:100%; border:0; min-height:calc(100vh - 200px)"></iframe>
|
||||
<!-- B7/T7.1: the iframe stood here. The creator is a page now
|
||||
(wp-creation-index.html), reached through the tab above, so this tab
|
||||
holds only the gate: the state where there is no SOP to build from.
|
||||
Once there is one, the tab navigates and this panel is never seen. -->
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -395,15 +567,16 @@
|
||||
<button onclick="toggleComments()" class="comments-dropdown-close" title="Close">✕</button>
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="font-weight: 600; font-size: 13px;">Your Name</label>
|
||||
<label style="font-weight: 600; font-size: 13px;">Your name</label>
|
||||
<input type="text" id="commenter-name" placeholder="(signed-in user)" readonly title="Taken from your sign-in" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem; background: var(--bg);">
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="font-weight: 600; font-size: 13px;">Feedback</label>
|
||||
<textarea id="comment-text" rows="3" placeholder="Your feedback here..." style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem; font-family: inherit;"></textarea>
|
||||
<label style="font-weight: 600; font-size: 13px;" for="comment-text">Feedback</label>
|
||||
<textarea id="comment-text" rows="3" placeholder="Your feedback here..." aria-describedby="comment-text_err" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem; font-family: inherit;"></textarea>
|
||||
<div class="field-error" id="comment-text_err" role="alert"></div>
|
||||
</div>
|
||||
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
|
||||
<button onclick="submitComment()" style="background: var(--primary); color: white; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Submit</button>
|
||||
<button onclick="submitComment()" style="background: var(--primary); color: var(--cds-text-on-color); padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Submit</button>
|
||||
<button onclick="exportComments()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">Export</button>
|
||||
<button onclick="document.getElementById('sop-comments-import').click()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">Import</button>
|
||||
<input type="file" id="sop-comments-import" accept="application/json" style="display:none" onchange="importComments(event)">
|
||||
@@ -428,10 +601,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- S1 / T5.8. Every message the wizard used to put in a native dialog lands
|
||||
here instead. role is set per message: an error interrupts, a confirmation
|
||||
does not (T4.5). A dialog cannot be styled, cannot be read by a screen
|
||||
reader as anything but a modal interruption, and — the reason this matters
|
||||
beyond taste — blocks the page until it is dismissed. -->
|
||||
<div id="wp-toast" class="wp-toast" role="status" hidden></div>
|
||||
|
||||
<script src="feedback-config.js"></script>
|
||||
<script src="project-data.js"></script>
|
||||
<script src="help.js"></script>
|
||||
<script src="work-package-suite-app.js"></script>
|
||||
<script src="wp-chrome.js"></script>
|
||||
<script src="wp-sidenav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
230
html/wp-autosave.js
Normal file
@@ -0,0 +1,230 @@
|
||||
/* Autosave, unsaved-work guard and draft recovery — S2 / T4.3.
|
||||
---------------------------------------------------------------------------
|
||||
The work package form is roughly 4,700px tall and there was no autosave and no
|
||||
unsaved-work guard on it. The only beforeunload listener in the app was
|
||||
analytics dwell tracking (work-package-suite-app.js), so a mis-click, a closed
|
||||
tab or a crash lost everything typed since the last explicit Save.
|
||||
|
||||
Three separate things, often confused:
|
||||
|
||||
THE DRAFT what you have typed, saved here, locally, continuously.
|
||||
THE RECORD what you have explicitly Saved, which goes to the server.
|
||||
THE OUTBOX project-data.js, which gets the RECORD to the server reliably.
|
||||
|
||||
This file owns the first only. It never writes to the server: a draft is
|
||||
"unfinished work this browser is holding for you", and pushing unfinished work
|
||||
to a shared project is a different feature with different consequences.
|
||||
|
||||
The guard fires only when the form actually differs from the record. A dialog
|
||||
that appears on every exit gets clicked through within a day and is worse than
|
||||
no dialog, which is why `Do not: fire the guard when nothing has changed` is
|
||||
part of the task rather than a nicety.
|
||||
*/
|
||||
(function (window, document) {
|
||||
'use strict';
|
||||
|
||||
var DRAFT_PREFIX = 'wp_draft::';
|
||||
var DEBOUNCE_MS = 1200;
|
||||
|
||||
var reg = null; // the single registered surface for this page
|
||||
var timer = null;
|
||||
var status = { state: 'idle', at: null, error: null };
|
||||
var statusSubs = [];
|
||||
var guardInstalled = false;
|
||||
|
||||
function now() { return new Date().toISOString(); }
|
||||
|
||||
function emit() {
|
||||
statusSubs.forEach(function (fn) {
|
||||
try { fn(Object.assign({}, status), reg ? reg.isDirty() : false); } catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
function setStatus(state, extra) {
|
||||
status = Object.assign({ state: state, at: status.at, error: null }, extra || {});
|
||||
emit();
|
||||
}
|
||||
|
||||
function draftKey(id) { return DRAFT_PREFIX + id; }
|
||||
|
||||
function readDraft(id) {
|
||||
try { return JSON.parse(window.localStorage.getItem(draftKey(id)) || 'null'); }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
|
||||
function writeDraft(id, payload) {
|
||||
// A failed write is the one case that MUST be surfaced rather than swallowed:
|
||||
// it means the safety net is not there, and the user is the only one who can
|
||||
// act on that (close a tab, free some quota, save explicitly now).
|
||||
window.localStorage.setItem(draftKey(id), JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function clearDraft(id) {
|
||||
try { window.localStorage.removeItem(draftKey(id)); } catch (e) {}
|
||||
}
|
||||
|
||||
function save(reason) {
|
||||
if (!reg) return false;
|
||||
if (!reg.isDirty()) { setStatus('idle'); return false; }
|
||||
setStatus('saving');
|
||||
try {
|
||||
writeDraft(reg.draftId(), {
|
||||
v: 1, at: now(), reason: reason || 'debounce',
|
||||
entity: reg.id, data: reg.collect(),
|
||||
});
|
||||
setStatus('saved', { at: now() });
|
||||
return true;
|
||||
} catch (e) {
|
||||
// QuotaExceededError, private-mode storage, a locked profile.
|
||||
setStatus('failed', { error: (e && e.message) || String(e) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function schedule(reason) {
|
||||
if (!reg) return;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(function () { save(reason || 'debounce'); }, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function installGuard() {
|
||||
if (guardInstalled) return;
|
||||
guardInstalled = true;
|
||||
// ADDED alongside the analytics dwell listener, never replacing it. Both fire;
|
||||
// beforeunload supports multiple listeners and the analytics one does not
|
||||
// preventDefault, so the two do not interact.
|
||||
window.addEventListener('beforeunload', function (e) {
|
||||
if (!reg || !reg.isDirty()) return undefined; // nothing unsaved: stay silent
|
||||
save('unload'); // one last draft write
|
||||
e.preventDefault();
|
||||
e.returnValue = ''; // required by Chrome
|
||||
return '';
|
||||
});
|
||||
// A crash or a killed tab never fires beforeunload. `visibilitychange` to
|
||||
// hidden does, and it is the last reliable moment to write the draft - which
|
||||
// is what makes the recovery survive "kill the tab and reopen".
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.visibilityState === 'hidden') save('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
window.WPAutosave = {
|
||||
/* Register the page's editable surface.
|
||||
|
||||
id stable name for the surface, e.g. 'wp-form'
|
||||
scope element to watch for input/change (defaults to document)
|
||||
draftId () => storage id, usually project + entity so two projects do
|
||||
not share one draft
|
||||
collect () => a JSON-serialisable snapshot of the form
|
||||
isDirty () => does the form differ from the last explicitly saved record
|
||||
restore (data) => put a recovered snapshot back into the form
|
||||
*/
|
||||
register: function (opts) {
|
||||
reg = {
|
||||
id: opts.id,
|
||||
scope: opts.scope || document,
|
||||
draftId: opts.draftId || function () { return opts.id; },
|
||||
collect: opts.collect,
|
||||
isDirty: opts.isDirty,
|
||||
restore: opts.restore,
|
||||
};
|
||||
reg.scope.addEventListener('input', function () { schedule('input'); });
|
||||
reg.scope.addEventListener('change', function () { schedule('change'); });
|
||||
installGuard();
|
||||
return window.WPAutosave;
|
||||
},
|
||||
|
||||
// Autosave now rather than on the debounce - for a step or section change,
|
||||
// where the user has visibly moved on and expects the previous part kept.
|
||||
flush: function (reason) { clearTimeout(timer); return save(reason || 'flush'); },
|
||||
|
||||
isDirty: function () { return !!(reg && reg.isDirty()); },
|
||||
status: function () { return Object.assign({}, status); },
|
||||
onStatus: function (fn) {
|
||||
statusSubs.push(fn);
|
||||
try { fn(Object.assign({}, status), reg ? reg.isDirty() : false); } catch (e) {}
|
||||
return function () {
|
||||
var i = statusSubs.indexOf(fn);
|
||||
if (i >= 0) statusSubs.splice(i, 1);
|
||||
};
|
||||
},
|
||||
|
||||
/* Recovery. Returns the stored draft for an id, or null. The caller decides
|
||||
whether to offer it - only it knows whether the draft is actually newer
|
||||
than the record, and offering to restore work that is already saved is its
|
||||
own kind of alarming. */
|
||||
peek: function (id) { return readDraft(id); },
|
||||
discard: function (id) { clearTimeout(timer); clearDraft(id); setStatus('idle'); },
|
||||
|
||||
/* Called after an explicit Save succeeded: the record now holds this work, so
|
||||
the draft is no longer protecting anything and keeping it would make the
|
||||
next load offer to "recover" work that is already saved.
|
||||
|
||||
clearTimeout FIRST. A save typically follows typing, so there is usually a
|
||||
debounced write already scheduled; without cancelling it, that write lands
|
||||
a second after the draft was cleared and resurrects it — and the next load
|
||||
offers to recover work that is already saved, which is the exact thing this
|
||||
method exists to prevent. */
|
||||
settled: function (id) { clearTimeout(timer); clearDraft(id); setStatus('idle'); },
|
||||
|
||||
/* A persistent draft-state indicator — B5 / T4.4.
|
||||
|
||||
The suite already showed "✓ All changes saved", but that badge belonged to
|
||||
the OUTBOX: it reported whether saved records had reached the project, and
|
||||
went green when the queue emptied whether or not anything in the form had
|
||||
been saved at all. This indicator speaks only for the draft, and the outbox
|
||||
badge's wording now names the project explicitly, so neither can be read as
|
||||
the other.
|
||||
|
||||
role="status" so the state is announced politely (S10 / T4.5); a failure
|
||||
swaps in role="alert" so it interrupts, because a failed autosave means the
|
||||
safety net is not there and waiting for a pause to say so is too late. */
|
||||
mountIndicator: function (host, opts) {
|
||||
if (!host) return function () {};
|
||||
opts = opts || {};
|
||||
var el = document.createElement('div');
|
||||
el.className = 'wp-draft-status';
|
||||
el.id = opts.id || 'wp-draft-status';
|
||||
host.appendChild(el);
|
||||
|
||||
function fmt(ts) {
|
||||
try { return new Date(ts).toLocaleTimeString(); } catch (e) { return ''; }
|
||||
}
|
||||
|
||||
var un = window.WPAutosave.onStatus(function (st, dirty) {
|
||||
var role = 'status', cls = '', text = '';
|
||||
if (st.state === 'failed') {
|
||||
role = 'alert';
|
||||
cls = 'is-failed';
|
||||
text = '⚠ Draft not saved on this device — ' + (st.error || 'storage unavailable');
|
||||
} else if (st.state === 'saving') {
|
||||
cls = 'is-saving';
|
||||
text = '↻ Saving draft…';
|
||||
} else if (st.state === 'saved') {
|
||||
cls = 'is-saved';
|
||||
text = '✓ Draft saved at ' + fmt(st.at);
|
||||
} else {
|
||||
cls = 'is-idle';
|
||||
// "No unsaved changes" is a different statement from "saved", and it is
|
||||
// the true one when nothing has been typed.
|
||||
text = dirty ? 'Unsaved changes' : 'No unsaved changes';
|
||||
}
|
||||
el.className = 'wp-draft-status ' + cls;
|
||||
el.setAttribute('role', role);
|
||||
el.textContent = text;
|
||||
// Retry is only offered where it can do something.
|
||||
if (st.state === 'failed') {
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'wp-draft-retry';
|
||||
b.textContent = 'Retry';
|
||||
b.onclick = function () { window.WPAutosave.flush('retry'); };
|
||||
el.appendChild(b);
|
||||
}
|
||||
});
|
||||
return function () { un(); if (el.parentNode) el.parentNode.removeChild(el); };
|
||||
},
|
||||
|
||||
_key: draftKey,
|
||||
};
|
||||
})(window, document);
|
||||
@@ -14,25 +14,28 @@
|
||||
min-width: 0; /* lets the search shrink instead of overflowing */
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
/* Seven role names, resolved twice — once for each kind of host bar. The switch
|
||||
is the point of this block and it stays; only the literals move behind the
|
||||
canonical tokens in theme-light.css (T3.2 / S5 / C3). */
|
||||
/* Light host bar (the two tool pages) */
|
||||
.wp-chrome {
|
||||
--wpc-fg: #161616;
|
||||
--wpc-fg-dim: #525252;
|
||||
--wpc-bg: #ffffff;
|
||||
--wpc-bg-soft: #f4f4f4;
|
||||
--wpc-border: #c6c6c6;
|
||||
--wpc-hover: #e8e8e8;
|
||||
--wpc-accent: #0f62fe;
|
||||
--wpc-fg: var(--cds-text-primary);
|
||||
--wpc-fg-dim: var(--cds-text-secondary);
|
||||
--wpc-bg: var(--cds-layer);
|
||||
--wpc-bg-soft: var(--cds-layer-accent);
|
||||
--wpc-border: var(--cds-border-subtle-selected);
|
||||
--wpc-hover: var(--cds-layer-hover);
|
||||
--wpc-accent: var(--cds-interactive-01);
|
||||
}
|
||||
/* Dark host bar (the UI-shell appbar) */
|
||||
.wp-chrome[data-bar="dark"] {
|
||||
--wpc-fg: #ffffff;
|
||||
--wpc-fg-dim: #c6c6c6;
|
||||
--wpc-bg: #262626;
|
||||
--wpc-bg-soft: #393939;
|
||||
--wpc-border: #6f6f6f;
|
||||
--wpc-hover: #353535;
|
||||
--wpc-accent: #78a9ff;
|
||||
--wpc-fg: var(--wp-appbar-fg);
|
||||
--wpc-fg-dim: var(--wp-appbar-fg-dim);
|
||||
--wpc-bg: var(--wp-appbar-layer);
|
||||
--wpc-bg-soft: var(--cds-inverse-02);
|
||||
--wpc-border: var(--wp-appbar-border);
|
||||
--wpc-hover: var(--wp-appbar-hover);
|
||||
--wpc-accent: var(--cds-link-inverse);
|
||||
}
|
||||
|
||||
/* ── archived-project banner ──────────────────────────────────────────────── */
|
||||
@@ -47,11 +50,11 @@
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 9px 16px;
|
||||
background: #fdf6dd;
|
||||
color: #8e6a00;
|
||||
border-bottom: 1px solid #f1c21b;
|
||||
background: var(--wp-status-warning-bg);
|
||||
color: var(--wp-status-warning-text);
|
||||
border-bottom: 1px solid var(--cds-support-warning);
|
||||
border-radius: 0;
|
||||
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-family: var(--wp-font-sans-2);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
@@ -90,6 +93,13 @@
|
||||
text-transform: uppercase;
|
||||
color: var(--wpc-fg-dim);
|
||||
}
|
||||
/* B2. These two caps are the "does not fit 280px" the review measured: a real name
|
||||
ran past 240px and was ellipsised on the one control whose job is to say which job
|
||||
you are in. Above 1024px the bar has the room, so the caps are raised until a real
|
||||
project name fits — "Micron EUV Cleanroom Enable 2667008" is the one to test with.
|
||||
Below 1024px the label is the project number instead (see wp-chrome.js), which is
|
||||
short enough that these caps never bite. The ellipsis stays only as a backstop for
|
||||
a name longer than anything real. */
|
||||
.wpc-proj-name {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
@@ -98,6 +108,10 @@
|
||||
text-overflow: ellipsis;
|
||||
max-width: 240px;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.wpc-proj-btn { max-width: 400px; }
|
||||
.wpc-proj-name { max-width: 340px; }
|
||||
}
|
||||
.wpc-caret { flex: 0 0 auto; align-self: flex-end; margin-bottom: 3px; font-size: 10px;
|
||||
line-height: 1; color: var(--wpc-fg-dim); }
|
||||
|
||||
@@ -111,10 +125,10 @@
|
||||
max-width: min(460px, 92vw);
|
||||
max-height: min(70vh, 560px);
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
color: #161616;
|
||||
border: 1px solid #e0e0e0;
|
||||
box-shadow: 0 8px 28px rgba(20, 30, 50, .22);
|
||||
background: var(--cds-layer);
|
||||
color: var(--cds-text-primary);
|
||||
border: 1px solid var(--cds-border-subtle);
|
||||
box-shadow: var(--wp-shadow-pop);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.wpc-pop[hidden] { display: none; }
|
||||
@@ -124,8 +138,8 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: .07em;
|
||||
text-transform: uppercase;
|
||||
color: #6f6f6f;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
color: var(--cds-text-helper);
|
||||
border-bottom: 1px solid var(--wp-pop-divider);
|
||||
}
|
||||
.wpc-item {
|
||||
display: block;
|
||||
@@ -137,19 +151,19 @@
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: #161616;
|
||||
color: var(--cds-text-primary);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.wpc-item:hover, .wpc-item.is-active { background: #f4f4f4; }
|
||||
.wpc-item.is-current { border-left-color: #0f62fe; background: #edf5ff; }
|
||||
.wpc-item:hover, .wpc-item.is-active { background: var(--cds-layer-accent); }
|
||||
.wpc-item.is-current { border-left-color: var(--cds-interactive-01); background: var(--cds-highlight); }
|
||||
.wpc-item-title { display: block; font-weight: 600; }
|
||||
.wpc-item-sub { display: block; font-size: 11.5px; color: #6f6f6f; }
|
||||
.wpc-item-mono { font-family: 'IBM Plex Mono', ui-monospace, Consolas, monospace; font-size: 12px; color: #0f62fe; }
|
||||
.wpc-empty { padding: 14px 12px; font-size: 13px; color: #6f6f6f; }
|
||||
.wpc-item-sub { display: block; font-size: 11.5px; color: var(--cds-text-helper); }
|
||||
.wpc-item-mono { font-family: var(--wp-font-mono-3); font-size: 12px; color: var(--cds-interactive-01); }
|
||||
.wpc-empty { padding: 14px 12px; font-size: 13px; color: var(--cds-text-helper); }
|
||||
.wpc-pop-foot {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
border-top: 1px solid var(--wp-pop-divider);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
@@ -159,14 +173,14 @@
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid #c6c6c6;
|
||||
background: #fff;
|
||||
color: #161616;
|
||||
border: 1px solid var(--cds-border-subtle-selected);
|
||||
background: var(--cds-layer);
|
||||
color: var(--cds-text-primary);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.wpc-foot-btn:hover { border-color: #0f62fe; color: #0f62fe; }
|
||||
.wpc-foot-btn:hover { border-color: var(--cds-interactive-01); color: var(--cds-interactive-01); }
|
||||
|
||||
/* ── global search ────────────────────────────────────────────────────────── */
|
||||
/* Centered in the bar: the wrapper takes the free space and centres a capped box,
|
||||
@@ -192,6 +206,11 @@
|
||||
border: 1px solid var(--wpc-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
/* The ring is drawn on the BOX, not the input: the input is a borderless field
|
||||
inside a bordered shell, so ringing the input would draw a rectangle floating
|
||||
inside another rectangle. --wpc-accent resolves to #0f62fe on a light bar
|
||||
(8.6:1 against #ffffff) and #78a9ff on the dark one (6.6:1 against #262626),
|
||||
so it clears 3:1 on both hosts. */
|
||||
.wpc-search-box:focus-within { outline: 2px solid var(--wpc-accent); outline-offset: -2px; }
|
||||
.wpc-search-ico { flex: 0 0 auto; color: var(--wpc-fg-dim); font-size: 13px; }
|
||||
.wpc-search-input {
|
||||
@@ -199,6 +218,9 @@
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
/* S12: the ONE `outline: none` left in the app, and it has its replacement in
|
||||
the rule above — the shell rings on :focus-within, which fires for exactly the
|
||||
same interactions. Ringing both would draw two. */
|
||||
outline: none;
|
||||
color: var(--wpc-fg);
|
||||
font: inherit;
|
||||
@@ -207,7 +229,7 @@
|
||||
.wpc-search-input::placeholder { color: var(--wpc-fg-dim); }
|
||||
.wpc-kbd {
|
||||
flex: 0 0 auto;
|
||||
font-family: 'IBM Plex Mono', ui-monospace, Consolas, monospace;
|
||||
font-family: var(--wp-font-mono-3);
|
||||
font-size: 10.5px;
|
||||
color: var(--wpc-fg-dim);
|
||||
border: 1px solid var(--wpc-border);
|
||||
@@ -235,3 +257,82 @@
|
||||
.wpc-proj-kicker { display: none; }
|
||||
.wpc-search { flex: 1 1 120px; }
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================================
|
||||
TOOL TAB STRIP (B7 / T7.1)
|
||||
----------------------------------------------------------------------------
|
||||
SOP Configuration | Work Package Creation | Dashboard.
|
||||
|
||||
These rules lived in work-package-suite-styles.css while the creator was an
|
||||
iframe child, because only the suite page ever drew the strip. Dissolving the
|
||||
frame makes the creator a document of its own that draws the same strip, so
|
||||
they move to the sheet both pages already load. Nothing changed in the move -
|
||||
same values, same tokens, no literal added; the fallbacks below exist only
|
||||
because the two host pages alias the role names under different local names.
|
||||
|
||||
The strip is navigation between two documents now, so `.nav-tab` has to look
|
||||
identical as a <button> and as an <a>.
|
||||
============================================================================ */
|
||||
.main-nav {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 0 16px;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
padding: 13px 18px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: var(--text-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.nav-tab:hover {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
background: none;
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-tab { font-family: inherit; text-decoration: none; }
|
||||
/* Not colour alone: the current tab carries aria-current, and the weight change
|
||||
plus the 3px rule say which one it is without relying on hue. */
|
||||
.nav-tab[aria-current="page"] { color: var(--text); border-bottom-color: var(--primary); font-weight: 600; }
|
||||
/* A tab that cannot be entered yet - the WP tab before the SOP is complete. It
|
||||
stays a real, focusable control that explains itself rather than vanishing;
|
||||
the gate panel is what does the explaining. */
|
||||
.nav-tab[aria-disabled="true"] { opacity: .55; cursor: default; }
|
||||
.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; }
|
||||
}
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
Search hits GET /api/search, which scopes results to the projects the signed-in
|
||||
user may access — so this is a convenience, never a way to see another job.
|
||||
|
||||
Skipped inside an iframe: the WP creator is embedded in the suite page, and a
|
||||
second bar inside the frame would be nonsense. */
|
||||
Every page in html/ gets one. Until B7/T7.1 this script returned early inside
|
||||
an iframe, because the WP creator was embedded in the suite page and a second
|
||||
bar inside the frame would have been nonsense - which also meant the creator
|
||||
was the only page in the app without an app bar. It is a page now, and it has
|
||||
one. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
|
||||
if (inIframe) return;
|
||||
|
||||
var SEARCH_MIN = 2; // characters before we ask the server
|
||||
var DEBOUNCE_MS = 180;
|
||||
|
||||
@@ -67,10 +67,31 @@
|
||||
try { return (window.ProjectData && ProjectData.getActive()) || null; } catch (e) { return null; }
|
||||
}
|
||||
|
||||
// B2 breakpoint plan. "Micron EUV Cleanroom Enable 2667008" does not fit the bar,
|
||||
// and an ellipsis at every width gives you "Micron EUV Clean…" on the one control
|
||||
// whose job is to tell you which job you are in. So the bar drops the name rather
|
||||
// than shortening it: below 1024px it shows the project NUMBER alone, which is
|
||||
// short, stable and unambiguous. The full name is never more than a hover, a focus
|
||||
// or the drawer away — see projectTitle() and the drawer head.
|
||||
// Documented in docs/reference/file-map.md.
|
||||
var WIDE = '(min-width: 1024px)';
|
||||
function isWide() {
|
||||
try { return window.matchMedia(WIDE).matches; } catch (e) { return true; }
|
||||
}
|
||||
function projectLabel(p) {
|
||||
if (!p) return 'Select a project';
|
||||
var n = p.name || '(unnamed)';
|
||||
return p.number ? (p.number + ' — ' + n) : n;
|
||||
if (!p.number) return n; // no number to fall back to
|
||||
// Wide: the name, which is what people recognise, and which usually carries the
|
||||
// number inside it anyway ("Micron EUV Cleanroom Enable 2667008") — prefixing the
|
||||
// number there printed it twice. Narrow: the number alone.
|
||||
return isWide() ? n : p.number;
|
||||
}
|
||||
// Always the whole thing, whatever the bar is showing.
|
||||
function projectTitle(p) {
|
||||
if (!p) return 'Select a project';
|
||||
var n = p.name || '(unnamed)';
|
||||
return (p.number ? (p.number + ' — ' + n) : n) + ' — switch project';
|
||||
}
|
||||
|
||||
// Switching project reloads the current page with ?project=<id>. Every page
|
||||
@@ -116,7 +137,11 @@
|
||||
pop.innerHTML =
|
||||
'<div class="wpc-pop-head">Switch project</div>' +
|
||||
(rows || '<div class="wpc-empty">No projects you can access yet.</div>') +
|
||||
'<div class="wpc-pop-foot"><a class="wpc-foot-btn" href="index.html">All projects / new project</a></div>';
|
||||
// B3/T5.2 removed the launcher's picker card, so "all projects" is this
|
||||
// popover now and the launcher is where a project is CREATED. The link
|
||||
// says the one thing that is still true there, and carries the hash the
|
||||
// launcher opens its form on — otherwise it promises a list that moved.
|
||||
'<div class="wpc-pop-foot"><a class="wpc-foot-btn" href="index.html#new-project">New project</a></div>';
|
||||
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (item) {
|
||||
item.addEventListener('click', function () {
|
||||
var p = projects.filter(function (x) { return x.id === item.getAttribute('data-pid'); })[0];
|
||||
@@ -146,8 +171,18 @@
|
||||
var c = activeProject();
|
||||
var nameEl = btn.querySelector('.wpc-proj-name');
|
||||
if (nameEl) nameEl.textContent = projectLabel(c);
|
||||
// The full name stays reachable at every width, by hover and by keyboard focus.
|
||||
btn.title = projectTitle(c);
|
||||
if (!pop.hidden) render();
|
||||
};
|
||||
// Crossing the breakpoint has to re-label, or a resized window keeps whichever
|
||||
// form was picked at load.
|
||||
try {
|
||||
var mq = window.matchMedia(WIDE);
|
||||
var onChange = function () { wrap.wpcRefresh(); };
|
||||
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
||||
else if (mq.addListener) mq.addListener(onChange);
|
||||
} catch (e) {}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -163,7 +198,24 @@
|
||||
.then(function (r) { return r.ok ? r.json() : []; });
|
||||
}
|
||||
Promise.resolve(p)
|
||||
.then(function (list) { projects = Array.isArray(list) ? list : []; switcher.wpcRefresh(); })
|
||||
.then(function (list) {
|
||||
projects = Array.isArray(list) ? list : [];
|
||||
// A deep link names the project, and the bar is the one component every
|
||||
// page carrying chrome has. The launcher, SOP wizard, creator and field
|
||||
// view each resolve ?project= themselves; admin.html and users.html have
|
||||
// no project logic at all, so without this their bar shows whatever was
|
||||
// last stored — or "Select a project" on a cold browser — while the URL
|
||||
// says otherwise. Resolving it here covers every page once.
|
||||
try {
|
||||
var wanted = new URLSearchParams(location.search).get('project');
|
||||
if (wanted && window.ProjectData && ProjectData.setActive) {
|
||||
var hit = projects.filter(function (x) { return x.id === wanted; })[0];
|
||||
var cur = activeProject();
|
||||
if (hit && (!cur || cur.id !== hit.id || !cur.name)) ProjectData.setActive(hit);
|
||||
}
|
||||
} catch (e) {}
|
||||
switcher.wpcRefresh();
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
@@ -238,6 +290,20 @@
|
||||
var clear = box.querySelector('.wpc-clear');
|
||||
var timer = null, seq = 0, items = [], activeIx = -1;
|
||||
|
||||
// Below 620px the box is roughly 200px and the full placeholder ellipsises to
|
||||
// "Search work packages, proj" — the truncation half of F2. The control is
|
||||
// usable either way; this stops it reading as broken. 620px is the breakpoint
|
||||
// wp-chrome.css already uses for this element, not a new one. Interim: T2.2.
|
||||
try {
|
||||
var narrow = window.matchMedia('(max-width: 620px)');
|
||||
var setPlaceholder = function (m) {
|
||||
input.placeholder = m.matches ? 'Search…' : 'Search work packages, projects, SOPs…';
|
||||
};
|
||||
setPlaceholder(narrow);
|
||||
if (narrow.addEventListener) narrow.addEventListener('change', setPlaceholder);
|
||||
else if (narrow.addListener) narrow.addListener(setPlaceholder);
|
||||
} catch (e) {}
|
||||
|
||||
function close() { pop.hidden = true; activeIx = -1; }
|
||||
|
||||
function highlight() {
|
||||
@@ -247,12 +313,14 @@
|
||||
});
|
||||
}
|
||||
|
||||
// A work package lives inside the suite's Creator tab, so open the suite on
|
||||
// that project with the package requested; a SOP opens the SOP tab.
|
||||
// B7/T7.1: a work package used to live inside the suite page's Creator tab,
|
||||
// so a hit opened the suite and asked it to hand the package to the frame.
|
||||
// The creator is a page; link straight at it. The old address still works -
|
||||
// the suite page forwards it - but a search result should not need forwarding.
|
||||
function hrefFor(hit) {
|
||||
if (hit.kind === 'project') return 'work-package-suite.html?project=' + encodeURIComponent(hit.id);
|
||||
if (hit.kind === 'wp') {
|
||||
return 'work-package-suite.html?tab=wp&project=' + encodeURIComponent(hit.project_id || '') +
|
||||
return 'wp-creation-index.html?project=' + encodeURIComponent(hit.project_id || '') +
|
||||
'&wp=' + encodeURIComponent(hit.id);
|
||||
}
|
||||
return 'work-package-suite.html?tab=sop&project=' + encodeURIComponent(hit.project_id || '');
|
||||
@@ -379,6 +447,16 @@
|
||||
loadProjects(switcher);
|
||||
checkArchived(m.host);
|
||||
window.wpChromeRefresh = function () { switcher.wpcRefresh(); };
|
||||
|
||||
// Subscribe rather than keep our own copy of the value. Before this, the label
|
||||
// was rendered once at build time and refreshed only when /api/projects came
|
||||
// back, so selecting a project on the launcher updated the hero and left the bar
|
||||
// reading "Select a project" — that was F1.
|
||||
try {
|
||||
if (window.ProjectData && ProjectData.onActiveChange) {
|
||||
ProjectData.onActiveChange(function () { switcher.wpcRefresh(); });
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Wait for the auth guard: an unauthenticated page is about to redirect, and
|
||||
|
||||
@@ -8,45 +8,108 @@
|
||||
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
|
||||
timestamps during their own boot. -->
|
||||
<script src="wp-format.js"></script>
|
||||
<!-- Addressable state (S3). Parses before the app scripts, which read the URL
|
||||
during their own boot. -->
|
||||
<script src="wp-url.js"></script>
|
||||
<script src="wp-usage.js"></script>
|
||||
<!-- Autosave, unsaved-work guard, draft recovery (S2). -->
|
||||
<script src="wp-autosave.js"></script>
|
||||
<!-- Which sections this project uses (CR-006). The same file the SOP wizard
|
||||
reads, so "Assets is off" means one thing in both. -->
|
||||
<script src="wp-sections.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<!-- B7/T7.1: the app bar. This page used to load neither of wp-chrome's two
|
||||
files because it was an iframe child and wp-chrome.js returned early inside
|
||||
one. It is a page now. -->
|
||||
<link rel="stylesheet" href="wp-chrome.css">
|
||||
<link rel="stylesheet" href="wp-creation-styles.css">
|
||||
<link rel="stylesheet" href="wp-sidenav.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="loading-overlay" id="loadingOverlay"><div class="spinner"></div><div class="loading-text">Saving work package…</div></div>
|
||||
|
||||
<!-- APP BAR (B7 / T7.1)
|
||||
This page loaded neither wp-chrome.css nor wp-chrome.js while it was an
|
||||
iframe child - it was the only page in html/ without an app bar, which is
|
||||
what made it read as part of the wizard rather than as a page. It has one
|
||||
now, in the same .header-left / .header-right shape the suite page uses, so
|
||||
wp-chrome.js inserts the project switcher and search at the same place on
|
||||
both. -->
|
||||
<div class="header">
|
||||
<div class="logo-wrap embed-hide">
|
||||
<div class="header-logo">Prime Controls</div>
|
||||
<div class="header-left">
|
||||
<a href="index.html" class="header-logo-chip" title="Work Package Suite home">
|
||||
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
|
||||
</a>
|
||||
<div class="header-title">Work Package Suite</div>
|
||||
<button id="dev-toggle" class="dev-toggle" onclick="toggleDevMode()" title="dev mode" aria-label="dev mode"></button>
|
||||
</div>
|
||||
<div class="header-sep embed-hide">|</div>
|
||||
<div class="header-title">Work Package (IWP)</div>
|
||||
<button class="btn btn-ghost embed-hide" style="margin-left:auto;padding:7px 16px" onclick="document.getElementById('sop-import').click()">⤒ Import SOP</button>
|
||||
<!-- wp-chrome.js inserts the project switcher and global search here. -->
|
||||
<div class="header-right">
|
||||
<button class="btn btn-ghost" id="comments-btn" style="padding:7px 16px" onclick="toggleComments()">Feedback <span class="cbadge-total" id="cbadge-total" style="display:none">0</span></button>
|
||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="openHelp()">Help</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOOL TABS (B7 / T7.1)
|
||||
The tab row used to live in the parent document and the toolbar in this one,
|
||||
which is the thing B7 says makes folding either into the other impossible.
|
||||
Both are here now. `Dashboard` was a toolbar button; it is a tab, because
|
||||
that is what it always was.
|
||||
|
||||
SOP Configuration is an <a> because it goes to another document; the other
|
||||
two are <button>s because they switch view inside this one. wp-chrome.js
|
||||
stamps ?project= onto the link once the active project is known. -->
|
||||
<div class="main-nav" role="navigation" aria-label="Work package tools">
|
||||
<a class="nav-tab" data-nav-href="work-package-suite.html?tab=sop">SOP Configuration</a>
|
||||
<button class="nav-tab" data-tab="wp" onclick="showForm()">Work Package Creation</button>
|
||||
<button class="nav-tab" data-tab="dashboard" onclick="showDashboard()">Dashboard</button>
|
||||
</div>
|
||||
|
||||
<!-- PACKAGE TOOLBAR
|
||||
What is left of the old header once the brand, the feedback panel and the
|
||||
Dashboard button have found their proper homes: actions on the package or on
|
||||
the SOP behind it. `Sample SOP` and `Load example` are visible here rather
|
||||
than hidden by body.embedded (D1). They are still two affordances under two
|
||||
names, which is S7 and is T9.4's to reduce - this task makes them reachable,
|
||||
not fewer. -->
|
||||
<div class="wp-toolbar" id="wp-toolbar">
|
||||
<button class="btn btn-ghost" onclick="newPackage()">+ New</button>
|
||||
<button class="btn btn-ghost" onclick="duplicateWP()">Duplicate</button>
|
||||
<button class="btn btn-ghost" onclick="showHistoryCurrent()" title="Change history for this work package">History</button>
|
||||
<span class="wp-toolbar-sep" aria-hidden="true"></span>
|
||||
<button class="btn btn-ghost" onclick="openSopModal()">View SOP</button>
|
||||
<button class="btn btn-ghost" onclick="document.getElementById('sop-import').click()">Import SOP</button>
|
||||
<input type="file" id="sop-import" accept="application/json" style="display:none" onchange="importSOP(event)">
|
||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="loadSampleSOP()">⤓ Sample SOP</button>
|
||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="openSopModal()">👁 View SOP</button>
|
||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="loadExample()">★ Load Example</button>
|
||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button>
|
||||
<button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="newPackage()">+ New</button>
|
||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="duplicateWP()">⧉ Duplicate</button>
|
||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="showHistoryCurrent()" title="Change history for this work package">🕘 History</button>
|
||||
<button class="btn btn-ghost embed-hide" id="comments-btn" style="padding:7px 16px" onclick="toggleComments()">💬 Comments <span class="cbadge-total" id="cbadge-total" style="display:none">0</span></button>
|
||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showAnalytics()">▤ Usage Data</button>
|
||||
<!-- S7 / T9.4: THE sample-data affordance - exactly one in the whole suite,
|
||||
under one name. Pushed to the far end, away from the live actions (New /
|
||||
Duplicate sit at the other side of two separators), it confirms before
|
||||
acting and names exactly what it does. It fills THIS PAGE only: nothing
|
||||
is written to the project unless the user then saves, which the probe
|
||||
verifies against a real project. The wizard's copy and the split
|
||||
Sample SOP / Load example pair are gone. -->
|
||||
<span class="wp-toolbar-sep" aria-hidden="true" style="margin-left:auto"></span>
|
||||
<button class="btn btn-ghost" onclick="loadSampleAll()">Load sample data</button>
|
||||
</div>
|
||||
|
||||
<div class="dev-banner" id="dev-banner" style="display:none">⚙ DEV MODE — usage tracking paused. This session's actions are not being recorded.</div>
|
||||
|
||||
<div class="ctx-bar" id="ctx-bar"></div>
|
||||
|
||||
<!-- RELEASE READINESS BANNER -->
|
||||
<div class="release-banner" id="release-banner"></div>
|
||||
<!-- RELEASE READINESS BANNER - the ONE readiness warning on the page (A2).
|
||||
role="status" is a polite live region, the login.html pattern: a change in
|
||||
the open-constraint count is announced without stealing focus. The two
|
||||
duplicates this page used to render (the sticky bar's copy and the static
|
||||
hint under the status control) are gone; the rail badge carries the count. -->
|
||||
<div class="release-banner" id="release-banner" role="status"></div>
|
||||
|
||||
<!-- SECTION NAV (jump links, built from the form cards) -->
|
||||
<div class="section-nav-bar" id="section-nav"></div>
|
||||
<!-- F6/D3: the jump-link strip stood here. It was a row of `<span onclick>` chips
|
||||
that scrolled you somewhere inside a 5,399px page and then told you nothing
|
||||
about where you had landed. The replacement is a real rail, declared below the
|
||||
form so it can be a sticky column beside it at desk width. -->
|
||||
|
||||
<div class="wp-layout">
|
||||
|
||||
@@ -121,34 +184,79 @@
|
||||
</div>
|
||||
|
||||
<!-- GENERAL INFORMATION -->
|
||||
<div class="card">
|
||||
<!-- CR-006 gives every toggleable section a stable id. The map lives in
|
||||
wp-creation-app.js (WP_SECTION_NODES); the ids are what it addresses. -->
|
||||
<div class="card" id="general-card">
|
||||
<div class="section-header"><div class="section-title">General Information</div>
|
||||
<div class="section-desc">Parameters in <span style="color:var(--accent)">blue</span> are inherited from the project SOP. Fill the rest for this package.</div></div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>WP Number <span class="auto-tag">auto</span><span class="help-tip" data-tip="Built automatically from the SOP number format — the scope fields below (e.g. Sector) plus the WP type and a sequence counter.">i</span></label><input type="text" id="wp_number" readonly class="locked-field" placeholder="auto-built"><div class="field-hint sop-hint" id="wp_number_hint"></div></div>
|
||||
<div class="field"><label>WP number <span class="auto-tag">auto</span><span class="help-tip" data-tip="Built automatically from the SOP number format — the scope fields below (e.g. Sector) plus the WP type and a sequence counter.">i</span></label><input type="text" id="wp_number" readonly class="locked-field" placeholder="auto-built"><div class="field-hint sop-hint" id="wp_number_hint"></div></div>
|
||||
<div class="field"><label>Status</label>
|
||||
<div class="radio-group" id="status-group" style="margin-bottom:0">
|
||||
<label class="radio-pill" data-val="Draft"><input type="radio" name="status"><span class="dot"></span>Draft</label>
|
||||
<label class="radio-pill" data-val="Scheduled"><input type="radio" name="status"><span class="dot"></span>Scheduled</label>
|
||||
<label class="radio-pill" data-val="Issued"><input type="radio" name="status"><span class="dot"></span>Issued</label>
|
||||
<label class="radio-pill" data-val="In Progress"><input type="radio" name="status"><span class="dot"></span>In Progress</label>
|
||||
<label class="radio-pill pill-hold" data-val="Issue"><input type="radio" name="status"><span class="dot"></span>Issue (Hold)</label>
|
||||
<label class="radio-pill" data-val="In Progress"><input type="radio" name="status"><span class="dot"></span>In progress</label>
|
||||
<label class="radio-pill" data-val="Ready for QA"><input type="radio" name="status"><span class="dot"></span>Ready for QA</label>
|
||||
<label class="radio-pill pill-hold" data-val="Issue"><input type="radio" name="status"><span class="dot"></span>Issue (hold)</label>
|
||||
<label class="radio-pill" data-val="QC"><input type="radio" name="status"><span class="dot"></span>QC</label>
|
||||
<label class="radio-pill" data-val="Closed"><input type="radio" name="status"><span class="dot"></span>Closed</label>
|
||||
</div>
|
||||
<div class="field-hint">Cannot move to <strong>Issued</strong> or beyond until all constraints are cleared.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notice">WP number builds automatically from these scope fields + the WP type (per the SOP naming format):</div>
|
||||
<div class="field-grid" id="number-dims"></div>
|
||||
<div class="field field-grid col1"><div class="field"><label>Subject / Title <span class="req">*</span></label><input type="text" id="wp_subject" placeholder="e.g. Utility Level 2P Inert Gas Room Wall Mount Midas/ Rack"></div></div>
|
||||
<div class="field field-grid col1"><div class="field"><label>Subject / title <span class="req">*</span></label><input type="text" id="wp_subject" placeholder="e.g. Utility Level 2P Inert Gas Room Wall Mount Midas/ Rack"></div></div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>WP Type <span class="req">*</span></label><select id="wp_type" onchange="onTypeChange()"></select><div class="field-hint sop-hint">from SOP types</div></div>
|
||||
<div class="field"><label>System / Facility Code / UPN</label><input type="text" id="wp_system" placeholder="ties to controls.dev / COIN"></div>
|
||||
<div class="field"><label>Location</label><input type="text" id="wp_location" placeholder="building / level / sector / room"></div>
|
||||
<div class="field"><label>Cost Code</label><select id="wp_cost"></select><div class="field-hint sop-hint">Acumatica cost codes</div></div>
|
||||
<div class="field"><label>Acumatica Task</label><input type="text" id="wp_wbs" placeholder="Acumatica task no."></div>
|
||||
<div class="field"><label>WP type <span class="req">*</span></label><select id="wp_type" onchange="onTypeChange()"></select><div class="field-hint sop-hint">from SOP types</div></div>
|
||||
<div class="field"><label>System / facility code / UPN</label><input type="text" id="wp_system" placeholder="ties to controls.dev / COIN"></div>
|
||||
<!-- Location is its own CR-006 section while still living inside General
|
||||
Information's grid. CR-004 gives it structured building/floor/sector
|
||||
fields of its own in wave 6; only this wrapper's contents change then. -->
|
||||
<!-- CR-004. Three dependent dropdowns off the project's own taxonomy
|
||||
(CR-005 / T5.4), not free text: the values are what CR-018 rolls cost
|
||||
up by, and a rollup keyed on what somebody typed is not a rollup.
|
||||
wp_location survives as a hidden field so a package written before
|
||||
this keeps what it said — CLAUDE.md, removals are hidden not deleted. -->
|
||||
<div class="field" id="location-card">
|
||||
<label for="wp_building">Location<span class="help-tip" data-tip="Building, floor and sector come from this project's own location list, configured on step 11 of the SOP. Cost and progress roll up by these, which is why they are picked rather than typed.">i</span></label>
|
||||
<div class="loc-picker">
|
||||
<select id="wp_building" aria-label="Building"></select>
|
||||
<select id="wp_floor" aria-label="Floor"></select>
|
||||
<select id="wp_sector" aria-label="Sector"></select>
|
||||
</div>
|
||||
<div class="field-hint" id="wp_location_note"></div>
|
||||
<input type="hidden" id="wp_location">
|
||||
</div>
|
||||
<!-- CR-002 hides these two rather than deleting them: the columns, the model
|
||||
and every value already captured stay exactly as they are, and another
|
||||
project can turn them back on without a code change. The ids are what
|
||||
WP_FIELD_NODES addresses. -->
|
||||
<div class="field" id="field-costCode"><label>Cost code</label><select id="wp_cost"></select><div class="field-hint sop-hint">Acumatica cost codes</div></div>
|
||||
<div class="field" id="field-acumaticaTask"><label>Acumatica task</label><input type="text" id="wp_wbs" placeholder="Acumatica task no."></div>
|
||||
<!-- Moved up from the second grid by T7.2. The specification section is
|
||||
classification, not assignment: it is read-only and filled from the WP
|
||||
type on the SOP, so it belongs beside Subject and Type. -->
|
||||
<div class="field"><label>Specification section</label>
|
||||
<input type="text" id="wp_spec" readonly class="locked-field" placeholder="set on the WP type in the SOP">
|
||||
<div class="field-hint" id="spec-folder-link"></div></div>
|
||||
</div>
|
||||
<div class="field field-grid col1"><div class="field"><label>Description</label><textarea id="wp_desc" rows="2" placeholder="Short summary of the package"></textarea></div></div>
|
||||
<div class="field field-grid col1" id="bimlink-wrap"><div class="field"><label>Enabled by — BIM package(s)<span class="help-tip" data-tip="Advanced Work Packaging traceability: link the BIM / model package(s) that enabled this install package. Paste the MWP number(s) or a link to the model package.">i</span></label><input type="text" id="wp_bimlink" placeholder="e.g. MWP07-FAB-CONDUITS, or a link to the model package"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- ASSIGNMENT & SCHEDULE (F6 / T7.2)
|
||||
Split out of General Information, which was 1,288px on its own and the
|
||||
whole of the gap between the page at rest and F6's two-screen bar. The
|
||||
split is presentational: BOTH cards are the CR-006 section `general`
|
||||
(WP_SECTION_NODES.general lists both), so the shared section registry in
|
||||
wp-sections.js is untouched, the SOP wizard still shows one toggle, and
|
||||
turning General Information off still hides every field it hid before.
|
||||
|
||||
CR-001 requires the P6 activity to sit beside the due date. Both are here,
|
||||
adjacent, and tests/generalinfo_check.py asserts the adjacency. -->
|
||||
<div class="card" id="assign-card">
|
||||
<div class="sub-heading">Assignment & Schedule</div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Owner <span class="help-tip" data-tip="The accountable owner (a user account on this project). Assigning notifies them by email if email notifications are enabled in the admin console.">i</span></label><select id="wp_assignee"><option value="">— Unassigned —</option></select></div>
|
||||
<div class="field"><label>Assignees<span class="help-tip" data-tip="The crew and staff working this package. Pick from the project team named on the SOP; anyone without a user account can still be added by name.">i</span></label>
|
||||
@@ -157,13 +265,27 @@
|
||||
<div class="field"><label>Distribution<span class="help-tip" data-tip="Who gets notified about this package. The project's Construction Manager is included by default and can be removed per package.">i</span></label>
|
||||
<div class="people-pick" id="pick_distribution"></div>
|
||||
<input type="hidden" id="wp_distribution"></div>
|
||||
<div class="field"><label>Due Date</label><input type="date" id="wp_due"></div>
|
||||
<div class="field"><label>Specification Section</label>
|
||||
<input type="text" id="wp_spec" readonly class="locked-field" placeholder="set on the WP type in the SOP">
|
||||
<div class="field-hint" id="spec-folder-link"></div></div>
|
||||
<!-- CR-003. Three levels, agreed live in the meeting, and there is no fourth.
|
||||
Independent of status: a package can become Urgent after it is issued
|
||||
without its status moving. -->
|
||||
<div class="field"><label>Priority <span class="req">*</span><span class="help-tip" data-tip="How urgent this package is, independently of its status and its due date. Normal is the baseline; High and Urgent are exceptions and are meant to stay rare.">i</span></label>
|
||||
<select id="wp_priority">
|
||||
<option value="Normal" selected>Normal</option>
|
||||
<option value="High">High</option>
|
||||
<option value="Urgent">Urgent</option>
|
||||
</select></div>
|
||||
<div class="field"><label>Due date</label><input type="date" id="wp_due"></div>
|
||||
<!-- CR-001. Beside the due date on purpose: a date on a work package that
|
||||
is not anchored to a schedule activity is a date floating on its own,
|
||||
and the meeting placed the activity next to it for exactly that reason.
|
||||
Free text — a validated lookup against an imported P6 activity list is
|
||||
deferred (BL-000a) partly because the Micron schedule is being reworked,
|
||||
and importing it now would import churn. -->
|
||||
<div class="field"><label>P6 activity ID<span class="help-tip" data-tip="The Primavera P6 schedule activity this package delivers. Free text for now — a validated lookup against an imported activity list is deferred while the schedule is being reworked.">i</span></label>
|
||||
<input type="text" id="wp_p6_id" placeholder="e.g. A1234"></div>
|
||||
<div class="field"><label>P6 activity description</label>
|
||||
<input type="text" id="wp_p6_desc" placeholder="what that activity covers"></div>
|
||||
</div>
|
||||
<div class="field field-grid col1"><div class="field"><label>Description</label><textarea id="wp_desc" rows="2" placeholder="Short summary of the package"></textarea></div></div>
|
||||
<div class="field field-grid col1" id="bimlink-wrap"><div class="field"><label>Enabled by — BIM package(s)<span class="help-tip" data-tip="Advanced Work Packaging traceability: link the BIM / model package(s) that enabled this install package. Paste the MWP number(s) or a link to the model package.">i</span></label><input type="text" id="wp_bimlink" placeholder="e.g. MWP07-FAB-CONDUITS, or a link to the model package"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- BIM / MODEL DETAILS (shown for BIM/VDC SOPs) -->
|
||||
@@ -171,13 +293,13 @@
|
||||
<div class="sub-heading">BIM / Model Details</div>
|
||||
<div class="notice">For BIM/VDC work packages — the model deliverable's level of detail, area, source scan, and coordination status.</div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Model Area / Zone</label><input type="text" id="wp_model_area" placeholder="e.g. Fab 09 Subfab — Level 2"></div>
|
||||
<div class="field"><label>Clash / Coordination Status</label>
|
||||
<div class="field"><label>Model area / zone</label><input type="text" id="wp_model_area" placeholder="e.g. Fab 09 Subfab — Level 2"></div>
|
||||
<div class="field"><label>Clash / coordination status</label>
|
||||
<select id="wp_clash" onchange="onClashChange()"><option value="">—</option><option>Not started</option><option>In coordination</option><option>Clashes open</option><option>Clash-free</option><option>Signed off (IFF)</option></select></div>
|
||||
<div class="field"><label>IFF #<span class="help-tip" data-tip="Issued-For-Fabrication/Field number — the GC sign-off reference for this model package. Required once the coordination status is Signed off (IFF).">i</span></label>
|
||||
<input type="text" id="wp_iff" placeholder="e.g. IFF-2026-0142" oninput="onClashChange()">
|
||||
<div class="field-hint" id="iff-hint"></div></div>
|
||||
<div class="field"><label>Linked Scan / Point Cloud</label><input type="url" id="wp_scan_link" placeholder="WebShare / BIM360 / SharePoint link"></div>
|
||||
<div class="field"><label>Linked scan / point cloud</label><input type="url" id="wp_scan_link" placeholder="WebShare / BIM360 / SharePoint link"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -191,7 +313,8 @@
|
||||
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>
|
||||
<div class="field-hint" id="asset-source-note"></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>
|
||||
@@ -208,19 +331,19 @@
|
||||
</div>
|
||||
|
||||
<!-- SCOPE & WORK -->
|
||||
<div class="card">
|
||||
<div class="card" id="scope-card">
|
||||
<div class="sub-heading">Scope & Work<span class="help-tip" data-tip="Ordered steps the crew performs. With multiple disciplines selected, each gets its own scope section and status. Use Split by Discipline to break a large package into WP01A / WP01B / WP01C instances.">i</span></div>
|
||||
<div id="flat-scope">
|
||||
<div class="field"><label>Description of Work (sequenced steps)</label>
|
||||
<div class="field"><label>Description of work (sequenced steps)</label>
|
||||
<div class="notice">Enter the work as ordered steps — added in sequence, the way the crew performs them.</div>
|
||||
<div id="worksteps-body"></div>
|
||||
<button class="add-btn" onclick="addWorkStep()">+ Add Step</button>
|
||||
<button class="add-btn" onclick="addWorkStep()">+ Add step</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="scope-by-discipline" style="display:none"></div>
|
||||
<button class="btn btn-ghost" id="split-disc-btn" style="display:none;margin-top:10px" onclick="splitByDiscipline()" title="Break this multi-discipline package into one numbered instance per discipline">⎘ Split by Discipline</button>
|
||||
<button class="btn btn-ghost" id="split-disc-btn" style="display:none;margin-top:10px" onclick="splitByDiscipline()" title="Break this multi-discipline package into one numbered instance per discipline">⎘ Split by discipline</button>
|
||||
<div class="field-grid" style="margin-top:14px">
|
||||
<div class="field"><label>Labor – Est. Hrs.</label><input type="number" id="wp_hours" min="0" step="1" placeholder="e.g. 20" oninput="onHoursChange()"><div class="field-hint" id="size-check"></div></div>
|
||||
<div class="field"><label>Labor – est. hrs.</label><input type="number" id="wp_hours" min="0" step="1" placeholder="e.g. 20" oninput="onHoursChange()"><div class="field-hint" id="size-check"></div></div>
|
||||
<div class="field"><label>Predecessor work packages<span class="help-tip" data-tip="The packages that must be Closed before this one can be released. A package with an open predecessor is not release-ready — you can still release it, but the override is logged.">i</span></label>
|
||||
<div class="people-pick" id="pick_predecessors"></div>
|
||||
<div class="field-hint" id="pred-hint"></div></div>
|
||||
@@ -235,19 +358,55 @@
|
||||
<div class="notice">Structured bill of materials. Feeds kitting and the delivery forecast. Unit is from the Acumatica unit list.</div>
|
||||
<div class="table-wrap"><table><thead><tr><th style="width:90px">Qty</th><th style="width:120px">Unit</th><th>Description</th><th id="mat-disc-th" style="width:140px;display:none">Discipline</th><th style="width:44px"></th></tr></thead><tbody id="material-body"></tbody></table></div>
|
||||
<div class="material-actions">
|
||||
<button class="add-btn" onclick="addMaterial()">+ Add Material Line</button>
|
||||
<button class="add-btn" onclick="addMaterial()">+ Add material line</button>
|
||||
<button class="add-btn" onclick="document.getElementById('material-import').click()">⤒ Import from Excel/CSV</button>
|
||||
<button class="add-btn" onclick="downloadMaterialTemplate()">⤓ Download Template</button>
|
||||
<button class="add-btn" onclick="downloadMaterialTemplate()">⤓ Download template</button>
|
||||
<input type="file" id="material-import" accept=".csv,.xlsx,.xls" style="display:none" onchange="importMaterials(event)">
|
||||
</div>
|
||||
|
||||
<!-- MATERIAL REQUESTS (CR-013 / D6, T8.5). The lightweight scope, exactly:
|
||||
line items, needed-by, requestor, delivery (T8.4's fields on this
|
||||
package), status. Items pick from the project material list through
|
||||
the datalist when one exists and stay free text when it does not.
|
||||
No catalog, no inventory, no warehouse integration. -->
|
||||
<div style="margin-top:1.5rem; border-top:1px solid var(--border); padding-top:1rem;">
|
||||
<div class="sub-heading">Material Requests<span class="help-tip" data-tip="Field requests for material against this package. Submitting notifies the warehouse owner named above (CR-010). Delivery uses this package's delivery location.">i</span></div>
|
||||
<div id="mreq-list" class="mreq-list"></div>
|
||||
<div class="mreq-new" id="mreq-new">
|
||||
<div class="table-wrap"><table><thead><tr><th style="width:90px">Qty</th><th style="width:110px">Unit</th><th>Description</th><th style="width:44px"></th></tr></thead><tbody id="mreq-items"></tbody></table></div>
|
||||
<datalist id="mat-datalist"></datalist>
|
||||
<div class="mreq-row">
|
||||
<button type="button" class="add-btn" onclick="mreqAddItem()">+ Add line</button>
|
||||
<label for="mreq-needed">Needed by</label>
|
||||
<input type="date" id="mreq-needed">
|
||||
<button type="button" class="btn btn-generate" onclick="mreqSubmit()">Submit request</button>
|
||||
</div>
|
||||
<div class="field-hint" id="mreq-hint"></div>
|
||||
<div class="field-error" id="mreq-err" role="alert"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DRAWINGS / ATTACHMENTS -->
|
||||
<div class="card">
|
||||
<div class="card" id="drawings-card">
|
||||
<div class="sub-heading">Drawings & Attachments</div>
|
||||
<div id="sop-ref-links" class="sop-ref-links"></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Document / Drawing</th><th style="width:90px">Rev</th><th>Link / Note</th><th style="width:44px"></th></tr></thead><tbody id="attach-body"></tbody></table></div>
|
||||
<button class="add-btn" onclick="addAttach()">+ Add Document</button>
|
||||
<!-- CR-007/D8: uploads live beside the links, never instead of them. The
|
||||
limits are stated HERE, before anyone picks a file, and the running
|
||||
project total is on the same line (warning style from 80%). -->
|
||||
<div class="file-rules" id="file-rules">
|
||||
Uploads: <strong>PDF or image, up to 5MB a file.</strong>
|
||||
<span id="file-usage" aria-live="polite"></span>
|
||||
</div>
|
||||
<div id="wp-file-list" class="wp-file-list"></div>
|
||||
<div class="wp-file-row">
|
||||
<label class="add-btn wp-file-pick">⇪ Upload drawing
|
||||
<input type="file" id="wp-file-input" accept="application/pdf,image/png,image/jpeg,image/gif,image/webp" style="display:none" onchange="wpFileUpload(event)">
|
||||
</label>
|
||||
<input type="text" id="wp-file-desc" placeholder="focus area, e.g. Tray section, Level 3 east only" aria-label="Description for the next upload">
|
||||
</div>
|
||||
<button class="add-btn" onclick="addAttach()">+ Add document</button>
|
||||
<button class="add-btn" onclick="toggleSopFilePanel()">+ Add files from SOP folder</button>
|
||||
<div id="sop-file-panel" style="display:none; margin-top:0.75rem; padding:0.75rem; border:1px dashed var(--border); border-radius:6px; background:var(--bg);">
|
||||
<div id="sop-file-folders"></div>
|
||||
@@ -264,12 +423,20 @@
|
||||
<div class="card" id="mimo-card">
|
||||
<div class="sub-heading">Kitting & Material Movement (MIMO)</div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Kitting Status</label>
|
||||
<select id="wp_kit_status"><option value="">—</option><option>Open</option><option>In Progress</option><option>Kitted</option><option>Delivered</option></select></div>
|
||||
<div class="field"><label>Warehouse Owner</label><input type="text" id="wp_kit_owner" placeholder="name"></div>
|
||||
<div class="field"><label>Kitting Need Date</label><input type="date" id="wp_kit_date"></div>
|
||||
<div class="field"><label>MIMO Sch. Time</label><input type="datetime-local" id="wp_mimo_time"><div class="field-hint">scheduled material-move date & time</div></div>
|
||||
<div class="field"><label>MIMO Location</label><input type="text" id="wp_mimo_loc" placeholder="staging / move location"></div>
|
||||
<div class="field"><label>Kitting status</label>
|
||||
<select id="wp_kit_status"><option value="">—</option></select>
|
||||
<div class="field-hint">Options come from KIT_STATUSES (CR-009): an explicit set, never free text. A pre-CR-009 value on a saved package is kept as a legacy option.</div></div>
|
||||
<div class="field"><label>Warehouse owner <span class="help-tip" data-tip="Who owns fulfillment of this kit — a user account on this project. They are a default recipient of kitting notifications (CR-011). Recorded ON the package: retargeting one package notifies the right warehouse without touching the project.">i</span></label><select id="wp_kit_owner_sel"><option value="">— not assigned —</option></select><input type="hidden" id="wp_kit_owner"></div>
|
||||
<div class="field"><label>Kitting need date</label><input type="date" id="wp_kit_date"></div>
|
||||
<div class="field"><label>MIMO sch. time</label><input type="datetime-local" id="wp_mimo_time"><div class="field-hint">scheduled material-move date & time</div></div>
|
||||
<div class="field"><label>MIMO location</label><input type="text" id="wp_mimo_loc" placeholder="staging / move location"></div>
|
||||
<!-- CR-012 / T8.4: delivery uses the SHARED Building/Floor/Sector lists
|
||||
(CR-004), never a parallel free-text copy; the detail field carries
|
||||
the last fifty feet - lay-down area, shark cage, conduit tree. -->
|
||||
<div class="field"><label>Delivery building</label><select id="wp_deliv_building" onchange="onDeliveryLocChange('building')"><option value="">Building…</option></select></div>
|
||||
<div class="field"><label>Delivery floor</label><select id="wp_deliv_floor" onchange="onDeliveryLocChange('floor')"><option value="">Floor…</option></select></div>
|
||||
<div class="field"><label>Delivery sector</label><select id="wp_deliv_sector" onchange="onDeliveryLocChange('sector')"><option value="">Sector…</option></select></div>
|
||||
<div class="field"><label>Delivery detail</label><input type="text" id="wp_deliv_detail" placeholder="lay-down area, shark cage, conduit tree…"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -281,21 +448,26 @@
|
||||
</div>
|
||||
|
||||
<!-- QUALITY & HOLD POINTS -->
|
||||
<div class="card">
|
||||
<div class="card" id="quality-card">
|
||||
<div class="sub-heading">Quality, Inspection & Hold Points</div>
|
||||
<div class="field-grid">
|
||||
<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="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="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="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>
|
||||
<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="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="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>
|
||||
|
||||
<!-- SIGN-OFFS -->
|
||||
<div class="card">
|
||||
<!-- The only .main > .card that had no id. buildSectionRail() filled the gap
|
||||
positionally - `card.id = "sec-" + i` - and that id then rode into ?section=
|
||||
as a shareable address. It moved every time the set of VISIBLE sections
|
||||
changed: a CR-006 toggle, the BIM flag, or T7.2 inserting #assign-card ahead
|
||||
of it. A link somebody sent then opened a different section, silently. -->
|
||||
<div class="card" id="signoff-card">
|
||||
<div class="sub-heading">Approvals & Sign-offs</div>
|
||||
<div class="notice">Per the AWP IWP checklist. A package should be signed by these roles before release.</div>
|
||||
<div class="table-wrap"><table><thead><tr><th style="width:220px">Role</th><th>Name</th><th style="width:150px">Date</th><th style="width:80px;text-align:center">Signed</th></tr></thead><tbody id="signoff-body"></tbody></table></div>
|
||||
@@ -306,23 +478,23 @@
|
||||
<div class="sub-heading">Closeout</div>
|
||||
<div class="notice">Completed at QC / Closed — captures as-built reality and lessons learned.</div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Actual Hrs.</label><input type="number" id="wp_actual_hrs" min="0" step="1"></div>
|
||||
<div class="field"><label>Installed Quantity</label><input type="text" id="wp_installed_qty" placeholder="e.g. 42 of 42 tags"></div>
|
||||
<div class="field"><label>Actual hrs.</label><input type="number" id="wp_actual_hrs" min="0" step="1"></div>
|
||||
<div class="field"><label>Installed quantity</label><input type="text" id="wp_installed_qty" placeholder="e.g. 42 of 42 tags"></div>
|
||||
</div>
|
||||
<div class="field field-grid col1"><div class="field"><label>Redlines / As-Built Notes</label><textarea id="wp_redlines" rows="2"></textarea></div></div>
|
||||
<div class="field field-grid col1"><div class="field"><label>Lessons Learned</label><textarea id="wp_lessons" rows="2"></textarea></div></div>
|
||||
<div class="field field-grid col1"><div class="field"><label>Redlines / as-built notes</label><textarea id="wp_redlines" rows="2"></textarea></div></div>
|
||||
<div class="field field-grid col1"><div class="field"><label>Lessons learned</label><textarea id="wp_lessons" rows="2"></textarea></div></div>
|
||||
</div>
|
||||
|
||||
<div class="nav-row"><button class="btn btn-ghost" onclick="newPackage()">↺ Clear</button>
|
||||
<div style="display:flex;gap:10px">
|
||||
<button class="btn btn-ghost" onclick="savePackage(false)">Save Draft</button>
|
||||
<button class="btn btn-generate" onclick="savePackage(true)">⚡ Save & View</button>
|
||||
<button class="btn btn-ghost" onclick="savePackage(false)">Save draft</button>
|
||||
<button class="btn btn-generate" onclick="savePackage(true)">Save & view</button>
|
||||
</div></div>
|
||||
|
||||
<!-- DASHBOARD -->
|
||||
<div id="dashboard-view" style="display:none">
|
||||
<div class="output-toolbar">
|
||||
<button class="btn btn-ghost" onclick="showForm()">← Back to Form</button>
|
||||
<button class="btn btn-ghost" onclick="showForm()">← Back to form</button>
|
||||
<div style="font-weight:700;font-size:15px">Work Package Dashboard</div>
|
||||
<div style="display:flex;gap:10px;margin-left:auto">
|
||||
<button class="btn btn-ghost" onclick="newPackage()">+ New WP</button>
|
||||
@@ -337,8 +509,8 @@
|
||||
<!-- OUTPUT -->
|
||||
<div id="pkg-output" style="display:none">
|
||||
<div class="output-toolbar">
|
||||
<button class="btn btn-ghost" onclick="showForm()">← Back to Form</button>
|
||||
<button class="btn btn-primary" onclick="printPackage()">⎙ Print / Save PDF</button>
|
||||
<button class="btn btn-ghost" onclick="showForm()">← Back to form</button>
|
||||
<button class="btn btn-primary" onclick="printPackage()">⎙ Print / save PDF</button>
|
||||
<button class="btn btn-ghost" onclick="exportPackages()">⤓ Export (JSON)</button>
|
||||
</div>
|
||||
<div class="output-doc" id="pkg-doc"></div>
|
||||
@@ -348,11 +520,30 @@
|
||||
<div class="card" id="saved-card" style="display:none">
|
||||
<div class="sub-heading">Saved Work Packages <span id="saved-count"></span></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>WP #</th><th>Type</th><th>Subject</th><th>Status</th><th style="width:110px">Ready?</th><th style="width:120px"></th></tr></thead><tbody id="saved-body"></tbody></table></div>
|
||||
<button class="add-btn" onclick="exportPackages()">⤓ Export All (JSON)</button>
|
||||
<button class="add-btn" onclick="clearSaved()">Clear All</button>
|
||||
<button class="add-btn" onclick="exportPackages()">⤓ Export all (JSON)</button>
|
||||
<button class="add-btn" onclick="clearSaved()">Clear all</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- SECTION RAIL (F6 / D3)
|
||||
Built by buildSectionRail() from the cards themselves, so a section added or
|
||||
suppressed by a CR-006 toggle changes the rail without anybody maintaining a
|
||||
second list. Every entry is a real <button>, the current one carries
|
||||
aria-current, and each card's heading became a disclosure button with
|
||||
aria-expanded - the two accessibility defects F6's old chip strip and
|
||||
makeCollapsible() were carrying.
|
||||
|
||||
A sticky column beside the form at 1200px and up; a horizontal strip above it
|
||||
below that, which is what fits at 390px. -->
|
||||
<nav class="sec-rail" id="section-rail" aria-label="Form sections" hidden>
|
||||
<div class="sec-rail-head">
|
||||
<span class="sec-rail-title">Sections</span>
|
||||
<button type="button" class="sec-rail-all" id="sec-expand-all" aria-pressed="false"
|
||||
onclick="toggleExpandAll()">Expand all</button>
|
||||
</div>
|
||||
<ul class="sec-rail-list" id="sec-rail-list"></ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- HOLD LOG MODAL (comment 7) -->
|
||||
@@ -362,11 +553,43 @@
|
||||
<div class="modal-body">
|
||||
<div class="notice">Moving a package to <strong>Issue (Hold)</strong> requires logging the constraint that blocked it.</div>
|
||||
<div class="field"><label>Constraint type <span class="req">*</span></label><select id="hold-constraint"></select></div>
|
||||
<div class="field"><label>Details <span class="req">*</span></label><textarea id="hold-details" rows="3" placeholder="What reopened / blocked this package?"></textarea></div>
|
||||
<div class="field"><label>Details <span class="req">*</span></label><textarea id="hold-details" rows="3" placeholder="What reopened / blocked this package?" aria-describedby="hold-details_err"></textarea><div class="field-error" id="hold-details_err" role="alert"></div></div>
|
||||
<div class="field"><label>Supporting document link</label><input type="text" id="hold-doclink" placeholder="link to RFI, photo, email, etc. (optional)"></div>
|
||||
<div class="field"><label>Supporting photo</label><input type="file" id="hold-photo" accept="image/*" onchange="holdPhotoChange(event)"><div class="hold-photo-preview" id="hold-photo-preview"></div></div>
|
||||
</div>
|
||||
<div class="modal-foot"><button class="btn btn-ghost" onclick="cancelHold()">Cancel</button><button class="btn btn-generate" onclick="submitHold()">Log Hold</button></div>
|
||||
<div class="modal-foot"><button class="btn btn-ghost" onclick="cancelHold()">Cancel</button><button class="btn btn-generate" onclick="submitHold()">Log hold</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DIALOG (S1 / T7.9). The one replacement for the creator's 43 native
|
||||
dialogs: a modal with a message, an optional input with an inline error,
|
||||
and real buttons. Promise-based - wpConfirmDialog()/wpPromptDialog(). -->
|
||||
<div class="modal-overlay" id="wp-dialog" role="dialog" aria-modal="true" aria-labelledby="wp-dialog-title">
|
||||
<div class="modal" style="max-width:480px">
|
||||
<div class="modal-head"><div class="modal-title" id="wp-dialog-title"></div><button class="cmt-x" onclick="wpDialogCancel()" title="Cancel">✕</button></div>
|
||||
<div class="modal-body">
|
||||
<div id="wp-dialog-msg" style="white-space:pre-wrap"></div>
|
||||
<div class="field" id="wp-dialog-input-wrap" style="margin-top:10px">
|
||||
<label id="wp-dialog-label" for="wp-dialog-input"></label>
|
||||
<input type="text" id="wp-dialog-input" onkeydown="if(event.key==='Enter'){wpDialogOk();}">
|
||||
<div class="field-error" id="wp-dialog-err" role="alert"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-foot"><button class="btn btn-ghost" id="wp-dialog-cancel" onclick="wpDialogCancel()">Cancel</button><button class="btn btn-generate" id="wp-dialog-ok" onclick="wpDialogOk()">OK</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QA REJECT MODAL (CR-014). The comment is not optional: a rejection with no
|
||||
reason is the after-the-fact surprise this gate exists to end, and the server
|
||||
refuses the transition without one. -->
|
||||
<div class="modal-overlay" id="qa-reject-modal">
|
||||
<div class="modal">
|
||||
<div class="modal-head"><div class="modal-title">Return to the crew — QA rejection</div><button class="cmt-x" onclick="qaRejectCancel()" title="Cancel">✕</button></div>
|
||||
<div class="modal-body">
|
||||
<div class="notice">The package goes back to <strong>In Progress</strong>. The owner and the QA group are notified, and the comment stays on the package.</div>
|
||||
<div class="field"><label>What needs fixing <span class="req">*</span></label><textarea id="qa-reject-comment" rows="3" placeholder="What QA found — required" aria-describedby="qa-reject-comment_err"></textarea><div class="field-error" id="qa-reject-comment_err" role="alert"></div></div>
|
||||
</div>
|
||||
<div class="modal-foot"><button class="btn btn-ghost" onclick="qaRejectCancel()">Cancel</button><button class="btn btn-generate" onclick="qaRejectSubmit()">Reject — back to In Progress</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -380,33 +603,44 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<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-compose"><div class="cmt-compose-label">Comment on <strong id="cmt-cur-step">this form</strong></div>
|
||||
<textarea id="cmt-input" rows="3" placeholder="Add feedback…"></textarea>
|
||||
<button class="btn btn-primary cmt-add" onclick="addComment()">Add Comment</button></div>
|
||||
<button class="btn btn-primary cmt-add" onclick="addComment()">Add comment</button></div>
|
||||
<div class="cmt-list" id="cmt-list"></div>
|
||||
<div class="cmt-foot"><div class="cmt-note">Comments are saved in your browser. Use <strong>Export</strong> to send feedback back; the owner can <strong>Import</strong> each file.</div>
|
||||
<div class="cmt-foot-btns"><button class="btn btn-ghost" onclick="exportComments()">⤓ Export</button>
|
||||
<button class="btn btn-ghost" onclick="document.getElementById('cmt-import').click()">⤒ Import</button>
|
||||
<button class="btn btn-ghost cmt-clear" onclick="clearMyComments()">Clear Mine</button>
|
||||
<button class="btn btn-ghost cmt-clear" onclick="clearMyComments()">Clear mine</button>
|
||||
<input type="file" id="cmt-import" accept="application/json" style="display:none" onchange="importComments(event)"></div></div>
|
||||
</aside>
|
||||
|
||||
<!-- STICKY SAVE BAR (always-visible save + release status) -->
|
||||
<!-- STICKY SAVE BAR. The status span belongs to the B5 autosave indicator and
|
||||
nothing else: the readiness text that used to be written here was duplicate
|
||||
two of the A2 warning, and writing it via textContent destroyed the mounted
|
||||
indicator every time the count changed. -->
|
||||
<div class="sticky-save" id="sticky-save" style="display:none">
|
||||
<span class="sticky-status" id="sticky-status"></span>
|
||||
<div class="sticky-actions">
|
||||
<button class="btn btn-ghost" onclick="savePackage(false)">Save Draft</button>
|
||||
<button class="btn btn-generate" onclick="savePackage(true)">⚡ Save & View</button>
|
||||
<button class="btn btn-ghost" onclick="savePackage(false)">Save draft</button>
|
||||
<button class="btn btn-generate" onclick="savePackage(true)">Save & view</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="feedback-config.js"></script>
|
||||
<script src="project-data.js"></script>
|
||||
<!-- The Help button in the app bar is this page's entry point, the same as the
|
||||
suite page. help.js used to skip its floating button here because it saw an
|
||||
iframe; that test is gone, so say so explicitly instead. -->
|
||||
<script>window.WP_HELP_NO_FAB = true;</script>
|
||||
<script src="help.js"></script>
|
||||
<script src="wp-creation-app.js"></script>
|
||||
<script src="wp-chrome.js"></script>
|
||||
<script src="wp-sidenav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,32 +2,47 @@
|
||||
fully behind a firewall. If IBM Plex is installed/self-hosted it is used;
|
||||
otherwise it falls back to the system UI fonts. */
|
||||
|
||||
/* Embedded-in-Suite tweaks */
|
||||
body.embedded .embed-hide { display: none !important; }
|
||||
body.embedded .embed-first { margin-left: auto; }
|
||||
/* B7/T7.1: `body.embedded` and `.embed-hide` are gone. They existed to hide
|
||||
this page's own header, its sample-data controls and its analytics button
|
||||
while it was an iframe child. There is no frame, so "framed" is not a state
|
||||
any more: the header became the app bar, the sample controls are visible in
|
||||
the toolbar (D1) and the analytics button moves to the console at T7.10. */
|
||||
|
||||
/* Names only. Every value comes from theme-light.css, which this page loads
|
||||
first - this sheet declares nothing of its own (T3.2 / S5 / C3). The names
|
||||
stay because wp-creation-app.js reads eight of them from JavaScript, where
|
||||
a rename fails silently. See docs/reference/tokens.md section 9. */
|
||||
:root {
|
||||
--bg: #f4f4f4;
|
||||
--surface: #ffffff;
|
||||
--surface2: #f4f4f4;
|
||||
--border: #e0e0e0;
|
||||
--border-strong: #8d8d8d;
|
||||
--text: #161616;
|
||||
--text-muted: #525252;
|
||||
--text-dim: #8d8d8d;
|
||||
--accent: #0f62fe;
|
||||
--accent-dim: #edf5ff;
|
||||
--accent-green: #198038;
|
||||
--accent-green-dim: #defbe6;
|
||||
--accent-amber: #8e6a00;
|
||||
--accent-amber-dim: #fdf6dd;
|
||||
--red: #da1e28;
|
||||
--red-dim: #fff1f1;
|
||||
--radius: 0;
|
||||
--shadow: none;
|
||||
--shadow-lg: 0 4px 16px rgba(20,30,50,.12);
|
||||
--mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', Consolas, monospace;
|
||||
--sans: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||
--bg: var(--cds-background);
|
||||
--surface: var(--cds-layer);
|
||||
--surface2: var(--cds-layer-accent);
|
||||
--border: var(--cds-border-subtle);
|
||||
--border-strong: var(--cds-border-strong);
|
||||
--text: var(--cds-text-primary);
|
||||
--text-muted: var(--cds-text-secondary);
|
||||
--text-dim: var(--cds-text-helper); /* S11: was --cds-ui-04 (#8d8d8d, 3.32:1) */
|
||||
--accent: var(--cds-interactive-01);
|
||||
--accent-dim: var(--cds-highlight);
|
||||
--accent-green: var(--cds-support-success);
|
||||
--accent-green-dim: var(--wp-status-success-bg);
|
||||
--accent-amber: var(--wp-status-warning-text);
|
||||
--accent-amber-dim: var(--wp-status-warning-bg);
|
||||
--red: var(--cds-support-error);
|
||||
--red-dim: var(--wp-status-error-bg);
|
||||
--radius: var(--wp-radius-0);
|
||||
--shadow: var(--wp-shadow-none);
|
||||
/* Cool-tinted, where the wizard's --shadow-lg is neutral. Same geometry in
|
||||
both - 0 4px 16px - so the two can be merged later without moving
|
||||
anything; merging is still a rendered change. tokens.md section 8-F. */
|
||||
--shadow-lg: var(--wp-shadow-lg-cool);
|
||||
--mono: var(--wp-font-mono);
|
||||
--sans: var(--wp-font-sans);
|
||||
/* The tab strip moved into wp-chrome.css at T7.1 and is drawn by both tool
|
||||
pages. It reads the wizard sheet's role names, so this sheet aliases the
|
||||
same three - from the same canonical tokens, not from new values. */
|
||||
--bg-card: var(--cds-layer);
|
||||
--text-light: var(--cds-text-secondary);
|
||||
--primary: var(--cds-interactive-01);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@@ -54,16 +69,46 @@
|
||||
z-index: 100;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.header-logo {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .15em;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
/* The brand is the shared logo chip now, the same as every other page (T2.2).
|
||||
.header-logo was this page's own mono wordmark - the one thing in the suite
|
||||
that spelled "Prime Controls" out in monospace instead of showing the mark.
|
||||
.wp-logo-chip comes from theme-light.css, which this page already loads. */
|
||||
.header-logo-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header-logo-chip:hover { opacity: .92; }
|
||||
.header-sep { color: var(--border-strong); }
|
||||
.header-title { font-size: 13px; font-weight: 500; color: var(--text-muted); }
|
||||
/* Same two-group header the suite page uses, so wp-chrome.js inserts the
|
||||
project switcher between them rather than in front of the brand. */
|
||||
.header-left { display: flex; align-items: center; gap: 12px; min-width: 0; flex-shrink: 0; }
|
||||
.header-right { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-shrink: 0; }
|
||||
|
||||
/* ── PACKAGE TOOLBAR ──
|
||||
What the old header row became once the brand, the feedback panel and the
|
||||
Dashboard button moved out. Actions on the package, or on the SOP behind it. */
|
||||
.wp-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 32px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.wp-toolbar .btn { padding: 7px 14px; }
|
||||
.wp-toolbar-sep {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
margin: 2px 6px;
|
||||
background: var(--border);
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.wp-toolbar { padding: 8px 16px; }
|
||||
}
|
||||
|
||||
/* ── STEPPER ── */
|
||||
.stepper-wrap { padding: 22px 32px 0; max-width: 1000px; margin: 0 auto; }
|
||||
@@ -104,9 +149,12 @@
|
||||
auto-hiding overlay drawer (see below) rather than a column, so it never takes
|
||||
width away from the form — which matters most when this page is embedded in the
|
||||
suite's tab and every pixel is shared with the app chrome. */
|
||||
.wp-layout { display: block; width: 100%; margin: 0; }
|
||||
/* F6/D3: flex, so the section rail declared AFTER the form in the markup can sit
|
||||
ABOVE it at narrow widths (order:-1) and BESIDE it at desk width. Declaring it
|
||||
after .main is what lets it be a sticky column without wrapping the layout. */
|
||||
.wp-layout { display: flex; flex-direction: column; width: 100%; margin: 0; }
|
||||
.main { min-width: 0; max-width: none; margin: 0;
|
||||
padding: 22px 28px 72px calc(var(--nav-w,288px) + 28px);
|
||||
padding: 14px 28px 72px calc(var(--nav-w,288px) + 28px); /* F6: top pad only; bottom stays clear of the sticky bar */
|
||||
transition: padding-left .18s ease; }
|
||||
|
||||
.section { display: none; }
|
||||
@@ -141,9 +189,12 @@
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field.span2 { grid-column: span 2; }
|
||||
|
||||
/* A5 scopes the sentence-case rule to buttons and FIELD LABELS, which is this
|
||||
rule. The mono face, the size and the tracking are the creator's idiom and
|
||||
are left alone; only the forced uppercase goes. */
|
||||
label {
|
||||
font-family: var(--mono); font-size: 10px; font-weight: 500;
|
||||
letter-spacing: .1em; color: var(--text-muted); text-transform: uppercase;
|
||||
letter-spacing: .1em; color: var(--text-muted);
|
||||
}
|
||||
label .req { color: var(--accent); margin-left: 3px; }
|
||||
|
||||
@@ -155,14 +206,22 @@
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
padding: 9px 12px;
|
||||
outline: none;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
width: 100%;
|
||||
}
|
||||
/* S12 / BL-013. `outline: none` used to sit in the rule above, replaced on focus
|
||||
by a 3px --accent-dim glow: #edf5ff against a #ffffff field is 1.05:1, which is
|
||||
not a visible indicator. The border change and the glow stay as secondary cues;
|
||||
the ring is the theme's, inset over the control's own edge so it does not shift
|
||||
the layout of a dense form. */
|
||||
input:focus, textarea:focus, select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
input:focus-visible, textarea:focus-visible, select:focus-visible {
|
||||
outline: 2px solid var(--cds-focus);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
textarea { resize: vertical; min-height: 70px; line-height: 1.5; }
|
||||
::placeholder { color: var(--text-dim); }
|
||||
|
||||
@@ -224,8 +283,8 @@
|
||||
.sub-heading::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
|
||||
.notice {
|
||||
background: var(--accent-dim); border: 1px solid #b9d2fb; border-radius: var(--radius);
|
||||
padding: 10px 14px; font-size: 12px; color: #0043ce; margin-bottom: 18px; font-family: var(--mono);
|
||||
background: var(--accent-dim); border: 1px solid var(--wp-accent-border-a); border-radius: var(--radius);
|
||||
padding: 10px 14px; font-size: 12px; color: var(--cds-link-secondary); margin-bottom: 18px; font-family: var(--mono);
|
||||
}
|
||||
|
||||
/* ── DELIVERABLES ── */
|
||||
@@ -242,24 +301,29 @@
|
||||
.deliv-box {
|
||||
width: 16px; height: 16px; border: 1.5px solid var(--border-strong); border-radius: 3px;
|
||||
flex-shrink: 0; margin-top: 2px; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 11px; color: #fff; transition: all .12s;
|
||||
font-size: 11px; color: var(--cds-text-on-color); transition: all .12s;
|
||||
}
|
||||
.deliv-item.checked .deliv-box { background: var(--accent); border-color: var(--accent); }
|
||||
.deliv-text { font-size: 12.5px; line-height: 1.35; color: var(--text); }
|
||||
.deliv-text .dt-sub { display: block; font-size: 11px; color: var(--text-muted); margin-top: 1px; }
|
||||
|
||||
/* ── NAV ── */
|
||||
.nav-row { display: flex; justify-content: space-between; align-items: center; padding-top: 24px; margin-top: 24px; border-top: 1px solid var(--border); }
|
||||
.nav-row { display: flex; justify-content: space-between; align-items: center; padding-top: 14px; margin-top: 14px; border-top: 1px solid var(--border); } /* F6 */
|
||||
.btn {
|
||||
padding: 10px 22px; border-radius: var(--radius); font-family: var(--mono); font-size: 11px; font-weight: 600;
|
||||
letter-spacing: .08em; text-transform: uppercase; cursor: pointer; border: 1px solid; transition: all .15s;
|
||||
letter-spacing: .08em; cursor: pointer; border: 1px solid; transition: all .15s;
|
||||
}
|
||||
.btn-ghost { background: var(--surface); border-color: var(--border-strong); color: var(--text-muted); }
|
||||
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: var(--shadow); }
|
||||
.btn-primary:hover { background: #0353e9; }
|
||||
.btn-generate { background: var(--accent-green); border-color: var(--accent-green); color: #fff; font-weight: 700; box-shadow: var(--shadow); }
|
||||
.btn-generate:hover { background: #0e6027; }
|
||||
/* secondary */
|
||||
.btn-ghost { background: var(--wp-btn-secondary-bg); border-color: var(--wp-btn-secondary-border); color: var(--text-muted); }
|
||||
.btn-ghost:hover { border-color: var(--wp-btn-secondary-hover-fg); color: var(--wp-btn-secondary-hover-fg); }
|
||||
/* primary */
|
||||
.btn-primary { background: var(--wp-btn-primary-bg); border-color: var(--wp-btn-primary-bg); color: var(--wp-btn-primary-fg); box-shadow: var(--shadow); }
|
||||
.btn-primary:hover { background: var(--wp-btn-primary-hover); }
|
||||
/* A5: .btn-generate was green. It is the same role as .btn-primary and now
|
||||
renders identically; the class is kept because the markup and the scripts
|
||||
both use it, and renaming it is T7.2's business, not a colour task's. */
|
||||
.btn-generate { background: var(--wp-btn-primary-bg); border-color: var(--wp-btn-primary-bg); color: var(--wp-btn-primary-fg); font-weight: 700; box-shadow: var(--shadow); }
|
||||
.btn-generate:hover { background: var(--wp-btn-primary-hover); }
|
||||
|
||||
/* ── OUTPUT ── */
|
||||
#output-section { display: none; }
|
||||
@@ -280,7 +344,19 @@
|
||||
.output-doc p { margin-bottom: 10px; }
|
||||
.output-doc ul { padding-left: 20px; margin-bottom: 10px; }
|
||||
.output-doc li { margin-bottom: 3px; }
|
||||
.output-doc table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 12px; border: 1px solid var(--border); }
|
||||
.output-doc table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 12px; border: 1px solid var(--border);
|
||||
/* CR-008 / T9.1: the export opens on tablets. Fixed layout + wrap-anywhere
|
||||
keeps every table inside the screen instead of laying out at its content's
|
||||
natural 520px and dragging the whole document sideways. */
|
||||
table-layout: fixed; }
|
||||
.output-doc td, .output-doc th { overflow-wrap: anywhere; }
|
||||
/* On a phone/tablet the inline column widths the desktop layout carries
|
||||
(width:200px on header cells) exceed the available line; !important is the
|
||||
one CSS mechanism that outranks an inline style, which is exactly the job. */
|
||||
@media (max-width: 768px) {
|
||||
.output-doc { padding: 20px 14px; }
|
||||
.output-doc th, .output-doc td { width: auto !important; }
|
||||
}
|
||||
.output-doc th { background: var(--surface2); border: 1px solid var(--border); padding: 6px 10px; font-family: var(--mono); font-size: 9px; letter-spacing: .08em; text-transform: uppercase; text-align: left; color: var(--text-muted); }
|
||||
.output-doc td { border: 1px solid var(--border); padding: 7px 10px; vertical-align: top; }
|
||||
.output-doc .badge { display: inline-block; padding: 1px 8px; border-radius: 3px; font-family: var(--mono); font-size: 10px; font-weight: 600; }
|
||||
@@ -296,7 +372,7 @@
|
||||
|
||||
/* ── LOADING ── */
|
||||
.loading-overlay {
|
||||
display: none; position: fixed; inset: 0; background: rgba(244,245,247,.82); z-index: 200;
|
||||
display: none; position: fixed; inset: 0; background: var(--wp-scrim-loading); z-index: 200;
|
||||
align-items: center; justify-content: center; flex-direction: column; gap: 16px; backdrop-filter: blur(2px);
|
||||
}
|
||||
.loading-overlay.active { display: flex; }
|
||||
@@ -325,11 +401,12 @@
|
||||
background: var(--surface); color: var(--text-dim); cursor: pointer; font-size: 16px; line-height: 1;
|
||||
display: flex; align-items: center; justify-content: center; transition: all .12s; flex-shrink: 0;
|
||||
}
|
||||
.row-del:hover { border-color: var(--red); color: var(--red); background: var(--red-dim); }
|
||||
/* danger, outlined */
|
||||
.row-del:hover { border-color: var(--wp-btn-danger-border); color: var(--wp-btn-danger-fg); background: var(--wp-btn-danger-soft-bg); }
|
||||
.add-btn {
|
||||
display: inline-flex; align-items: center; gap: 7px; padding: 8px 16px; border-radius: var(--radius);
|
||||
border: 1px dashed var(--border-strong); background: var(--surface); color: var(--text-muted);
|
||||
font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase;
|
||||
font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: .06em;
|
||||
cursor: pointer; transition: all .15s;
|
||||
}
|
||||
.add-btn:hover { border-color: var(--accent); color: var(--accent); border-style: solid; background: var(--accent-dim); }
|
||||
@@ -366,7 +443,7 @@
|
||||
.seq-step.gate { border-color: var(--accent-amber); background: var(--accent-amber-dim); border-style: dashed; }
|
||||
.seq-step.gate .seq-label { color: var(--accent-amber); font-weight: 500; }
|
||||
.seq-gate-badge {
|
||||
flex-shrink: 0; padding: 3px 9px; border-radius: 20px; background: var(--accent-amber); color: #fff;
|
||||
flex-shrink: 0; padding: 3px 9px; border-radius: 20px; background: var(--accent-amber); color: var(--cds-text-on-color);
|
||||
font-family: var(--mono); font-size: 9px; font-weight: 600; letter-spacing: .08em; white-space: nowrap;
|
||||
}
|
||||
.add-btn-gate { border-color: var(--accent-amber); color: var(--accent-amber); }
|
||||
@@ -379,26 +456,46 @@
|
||||
|
||||
/* ── REVIEW COMMENTS ─────────────────────────────────────────────── */
|
||||
.cbadge-total { display:inline-block; min-width:16px; padding:0 5px; margin-left:4px; font-family:var(--mono);
|
||||
font-size:10px; font-weight:700; line-height:16px; text-align:center; color:#fff; background:var(--accent); border-radius:9px; }
|
||||
font-size:10px; font-weight:700; line-height:16px; text-align:center; color:var(--cds-text-on-color); background:var(--accent); border-radius:9px; }
|
||||
.step-tab { position:relative; }
|
||||
.step-tab .cbadge { position:absolute; top:4px; right:4px; min-width:15px; height:15px; padding:0 4px;
|
||||
font-family:var(--mono); font-size:9px; font-weight:700; line-height:15px; text-align:center;
|
||||
color:#fff; background:var(--accent-amber); border-radius:8px; box-shadow:0 0 0 2px var(--surface); }
|
||||
color:var(--cds-text-on-color); background:var(--accent-amber); border-radius:8px; box-shadow:0 0 0 2px var(--surface); }
|
||||
|
||||
.cmt-overlay { position:fixed; inset:0; background:rgba(20,30,50,.28); opacity:0; pointer-events:none;
|
||||
.cmt-overlay { position:fixed; inset:0; background:var(--wp-scrim-cool); opacity:0; pointer-events:none;
|
||||
transition:opacity .2s ease; z-index:60; }
|
||||
.cmt-overlay.open { opacity:1; pointer-events:auto; }
|
||||
.cmt-drawer { position:fixed; top:0; right:0; height:100vh; width:380px; max-width:92vw; background:var(--surface);
|
||||
/* Starts below the header, not behind it. The containing block was already the
|
||||
viewport (this is a body child, no transformed ancestor), so `top:0` put the
|
||||
drawer's own head — its title and its ✕ — underneath the sticky .header, which
|
||||
carries z-index:100 against the drawer's 61 and therefore won. The drawer was
|
||||
not off-screen so much as roofed over, and the close button was unreachable.
|
||||
Raising z-index would have put the panel OVER the header instead, which is the
|
||||
same collision with the layers swapped. --rail-top is the header's measured
|
||||
height, set by wp-creation-app.js:1328 and already used by .wp-nav for exactly
|
||||
this — reusing it keeps one definition of "below the header". */
|
||||
/* S12 / C1: `visibility:hidden` while closed, not just translated off-screen.
|
||||
A transform moves a thing; it does not remove it from the tab order. The
|
||||
closed drawer's name field, its textarea, its Add comment button and its ✕
|
||||
were all still focusable, so a keyboard user tabbing through the form fell
|
||||
into a panel they could not see and could not tell they were in. Found by
|
||||
tests/a11y_check.py, which measured a focus ring on a control no sighted
|
||||
user could be looking at.
|
||||
The transition delays visibility to the end of the slide when closing, and
|
||||
applies it immediately when opening, so the panel still animates both ways. */
|
||||
.cmt-drawer { position:fixed; top:var(--rail-top,48px); right:0; height:calc(100vh - var(--rail-top,48px));
|
||||
width:380px; max-width:92vw; background:var(--surface);
|
||||
border-left:1px solid var(--border); box-shadow:var(--shadow-lg); transform:translateX(100%);
|
||||
transition:transform .24s ease; z-index:61; display:flex; flex-direction:column; }
|
||||
.cmt-drawer.open { transform:translateX(0); }
|
||||
visibility:hidden;
|
||||
transition:transform .24s ease, visibility 0s linear .24s; z-index:61; display:flex; flex-direction:column; }
|
||||
.cmt-drawer.open { transform:translateX(0); visibility:visible; transition:transform .24s ease, visibility 0s; }
|
||||
.cmt-head { display:flex; align-items:center; justify-content:space-between; padding:16px 18px;
|
||||
border-bottom:1px solid var(--border); }
|
||||
.cmt-title { font-weight:700; font-size:14px; color:var(--text); }
|
||||
.cmt-x { background:none; border:none; color:var(--text-muted); font-size:15px; cursor:pointer; padding:4px 8px; border-radius:4px; }
|
||||
.cmt-x:hover { background:var(--surface2); color:var(--text); }
|
||||
.cmt-namebar { padding:12px 18px; border-bottom:1px solid var(--border); }
|
||||
.cmt-namebar label { display:block; font-size:10px; text-transform:uppercase; letter-spacing:.04em; color:var(--text-muted); margin-bottom:5px; }
|
||||
.cmt-namebar label { display:block; font-size:10px; letter-spacing:.04em; color:var(--text-muted); margin-bottom:5px; }
|
||||
.cmt-namebar input { width:100%; padding:8px 10px; border:1px solid var(--border-strong); border-radius:var(--radius); font-family:var(--sans); font-size:13px; color:var(--text); background:var(--surface); }
|
||||
.cmt-compose { padding:14px 18px; border-bottom:1px solid var(--border); background:var(--surface2); }
|
||||
.cmt-compose-label { font-size:11px; color:var(--text-muted); margin-bottom:7px; }
|
||||
@@ -418,7 +515,7 @@
|
||||
.cmt-text { font-size:13px; color:var(--text); line-height:1.5; white-space:pre-wrap; word-break:break-word; }
|
||||
.cmt-del { background:none; border:none; color:var(--text-dim); cursor:pointer; font-size:11px; padding:2px 5px; border-radius:4px; }
|
||||
.cmt-del:hover { background:var(--red-dim); color:var(--red); }
|
||||
.cmt-jump { background:none; border:none; color:var(--accent); cursor:pointer; font-size:10px; padding:0; margin-top:4px; }
|
||||
.cmt-jump { background:none; border:none; color:var(--wp-btn-tertiary-fg); cursor:pointer; font-size:10px; padding:0; margin-top:4px; }
|
||||
.cmt-jump:hover { text-decoration:underline; }
|
||||
.cmt-foot { border-top:1px solid var(--border); padding:12px 18px; }
|
||||
.cmt-note { font-size:10px; color:var(--text-muted); line-height:1.5; margin-bottom:10px; }
|
||||
@@ -429,33 +526,35 @@
|
||||
/* ── REV1: validation, governance, summary ───────────────────────── */
|
||||
.radio-group.group-invalid, .check-group.group-invalid { outline:2px solid var(--red); outline-offset:4px; border-radius:var(--radius); }
|
||||
.ov-select.ov-unset { color:var(--red) !important; border-color:var(--red); }
|
||||
/* primary. Was green (A5) — it applies a suggested value, which is an action. */
|
||||
.use-btn { display:inline-block; margin-left:8px; padding:4px 14px; font-family:var(--sans); font-size:11px; font-weight:700;
|
||||
color:#fff; background:var(--accent-green); border:none; border-radius:var(--radius); cursor:pointer; letter-spacing:.03em; }
|
||||
.use-btn:hover { background:#0e6027; }
|
||||
color:var(--wp-btn-primary-fg); background:var(--wp-btn-primary-bg); border:none; border-radius:var(--radius); cursor:pointer; letter-spacing:.03em; }
|
||||
.use-btn:hover { background:var(--wp-btn-primary-hover); }
|
||||
.sum-chips { display:flex; flex-wrap:wrap; gap:7px; }
|
||||
.sum-chip { background:var(--accent-dim); color:var(--accent); border:1px solid #b9d2fb; border-radius:3px;
|
||||
.sum-chip { background:var(--accent-dim); color:var(--accent); border:1px solid var(--wp-accent-border-a); border-radius:3px;
|
||||
padding:3px 10px; font-family:var(--mono); font-size:10px; }
|
||||
.sum-warn { margin-top:10px; color:var(--accent-amber); background:var(--accent-amber-dim); border:1px solid #f0d9ad;
|
||||
.sum-warn { margin-top:10px; color:var(--accent-amber); background:var(--accent-amber-dim); border:1px solid var(--wp-status-warning-border-b);
|
||||
border-radius:var(--radius); padding:7px 10px; font-size:11px; }
|
||||
|
||||
/* ── CREATION TOOL ───────────────────────────────────────────────── */
|
||||
.ctx-bar { max-width:none; margin:0; padding:12px 28px 12px calc(var(--nav-w,288px) + 28px); display:flex; align-items:center; gap:20px;
|
||||
.ctx-bar { max-width:none; margin:0; padding:7px 28px 7px calc(var(--nav-w,288px) + 28px); display:flex; align-items:center; gap:20px; /* F6: denser, still the SOP identity strip */
|
||||
border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; }
|
||||
.ctx-empty { color:var(--text-muted); font-size:13px; }
|
||||
.ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; }
|
||||
.ctx-main .ctx-sub { font-size:11px; color:var(--text-muted); margin-top:2px; }
|
||||
.ctx-sample { font-family:var(--mono); font-size:9px; font-weight:700; color:var(--accent-amber);
|
||||
background:var(--accent-amber-dim); border:1px solid #f0d9ad; border-radius:9px; padding:1px 7px; margin-left:6px; vertical-align:middle; }
|
||||
background:var(--accent-amber-dim); border:1px solid var(--wp-status-warning-border-b); border-radius:9px; padding:1px 7px; margin-left:6px; vertical-align:middle; }
|
||||
.ctx-meta { margin-left:auto; display:flex; gap:16px; font-size:11px; color:var(--text-muted); flex-wrap:wrap; }
|
||||
.ctx-meta b { color:var(--accent); }
|
||||
.ctx-meta code { background:var(--surface2); padding:1px 6px; border-radius:3px; color:var(--accent); }
|
||||
.link-btn { background:none; border:none; color:var(--accent); cursor:pointer; font-size:inherit; padding:0; text-decoration:underline; }
|
||||
/* tertiary */
|
||||
.link-btn { background:none; border:none; color:var(--wp-btn-tertiary-fg); cursor:pointer; font-size:inherit; padding:0; text-decoration:underline; }
|
||||
|
||||
.mode-wrap { max-width:none; margin:0; padding:16px 28px 0; display:flex; align-items:center; gap:16px; }
|
||||
.mode-toggle { display:inline-flex; border:1px solid var(--border-strong); border-radius:6px; overflow:hidden; }
|
||||
.mode-btn { padding:8px 18px; font-family:var(--sans); font-size:13px; font-weight:600; border:none; background:var(--surface);
|
||||
color:var(--text-muted); cursor:pointer; }
|
||||
.mode-btn.active { background:var(--accent); color:#fff; }
|
||||
.mode-btn.active { background:var(--accent); color:var(--cds-text-on-color); }
|
||||
.created-count { font-size:11px; color:var(--text-muted); }
|
||||
|
||||
.wo-section { border:1px solid var(--border); border-radius:var(--radius); padding:11px 13px; margin-bottom:9px; background:var(--surface); }
|
||||
@@ -476,38 +575,71 @@
|
||||
/* ── WORK PACKAGE FORM ───────────────────────────────────────────── */
|
||||
.sop-hint { color:var(--accent) !important; }
|
||||
.release-banner { max-width:none; margin:0; padding:0 28px 0 calc(var(--nav-w,288px) + 28px); }
|
||||
.release-banner .rb-inner { margin-top:14px; border-radius:var(--radius); padding:11px 16px; font-size:13px; font-weight:600;
|
||||
display:flex; align-items:center; gap:10px; }
|
||||
.rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid #b6e3c6; }
|
||||
.rb-notready { background:var(--accent-amber-dim); color:var(--accent-amber); border:1px solid #f0d9ad; }
|
||||
.rb-hold { background:var(--red-dim); color:var(--red); border:1px solid #f3c4c4; }
|
||||
.release-banner .rb-inner { margin-top:8px; border-radius:var(--radius); padding:8px 16px; font-size:13px; font-weight:600; /* F6: A2's one warning, denser */
|
||||
display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
.rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid var(--wp-status-success-border-b); }
|
||||
.rb-notready { background:var(--accent-amber-dim); color:var(--accent-amber); border:1px solid var(--wp-status-warning-border-b); }
|
||||
.rb-hold { background:var(--red-dim); color:var(--red); border:1px solid var(--wp-status-error-border-b); }
|
||||
/* CR-014: the QA-queue state. Informational, not alarming - accent, not amber. */
|
||||
.rb-qa { background:var(--accent-dim); color:var(--accent); border:1px solid var(--accent); }
|
||||
.rb-act-ghost { background:transparent; color:var(--accent); border:1px solid var(--accent); margin-left:8px; }
|
||||
.rb-act-ghost:hover { background:var(--accent-dim); }
|
||||
/* D4: the audited-override action on the release banner. A real button in the
|
||||
primary position for an Urgent package; it simply never renders otherwise. */
|
||||
.rb-act { margin-left:auto; border:none; border-radius:var(--radius); cursor:pointer;
|
||||
background:var(--primary); color:var(--cds-text-on-color); font-family:var(--sans);
|
||||
font-size:12px; font-weight:600; padding:7px 14px; min-height:32px; }
|
||||
.rb-act:hover { background:var(--cds-hover-primary); }
|
||||
.pill-hold.selected { background:var(--red) !important; border-color:var(--red) !important; }
|
||||
.pill-hold.selected .dot { background:#fff !important; }
|
||||
.pill-hold.selected .dot { background:var(--cds-text-on-color) !important; }
|
||||
|
||||
/* CR-007/D8: the upload strip in Drawings & Attachments. */
|
||||
.file-rules { margin:10px 0 6px; font-size:12px; color:var(--text-muted); }
|
||||
.file-rules .fr-warn { color:var(--accent-amber); font-weight:700; }
|
||||
.file-rules .fr-full { color:var(--red); font-weight:700; }
|
||||
.wp-file-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:6px 0; }
|
||||
.wp-file-row input[type="text"] { flex:1 1 240px; }
|
||||
.wp-file-list { display:flex; flex-direction:column; gap:6px; margin:6px 0; }
|
||||
.wp-file-item { display:flex; gap:10px; align-items:center; flex-wrap:wrap;
|
||||
border:1px solid var(--border); border-radius:var(--radius); padding:8px 10px; }
|
||||
.wp-file-item a { color:var(--accent); text-decoration:none; font-weight:600; overflow-wrap:anywhere; }
|
||||
.wp-file-item .wf-size { color:var(--text-muted); font-size:11px; }
|
||||
.wp-file-item input { flex:1 1 200px; font-size:12px; }
|
||||
.wp-file-x { margin-left:auto; }
|
||||
|
||||
/* CR-013: material requests. Rows wrap at 390px - requests originate in
|
||||
the field. */
|
||||
.mreq-row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-top:8px; }
|
||||
.mreq-list { display:flex; flex-direction:column; gap:8px; margin:8px 0; }
|
||||
.mreq-item { border:1px solid var(--border); border-radius:var(--radius); padding:8px 10px; font-size:12px; }
|
||||
.mreq-item .mr-head { display:flex; gap:10px; flex-wrap:wrap; align-items:center; font-weight:600; }
|
||||
.mreq-item .mr-status { padding:1px 8px; border-radius:9px; background:var(--accent-dim); color:var(--accent); font-size:11px; font-weight:700; }
|
||||
.mreq-item .mr-lines { color:var(--text-muted); margin-top:3px; }
|
||||
|
||||
.cstatus { display:inline-flex; border:1px solid var(--border-strong); border-radius:5px; overflow:hidden; }
|
||||
.cstatus button { border:none; background:var(--surface); color:var(--text-muted); font-family:var(--sans); font-size:11px;
|
||||
font-weight:600; padding:4px 10px; cursor:pointer; border-right:1px solid var(--border); }
|
||||
.cstatus button:last-child { border-right:none; }
|
||||
.cstatus button.on-open { background:var(--red); color:#fff; }
|
||||
.cstatus button.on-cleared { background:var(--accent-green); color:#fff; }
|
||||
.cstatus button.on-na { background:var(--text-muted); color:#fff; }
|
||||
.cstatus button.on-open { background:var(--red); color:var(--cds-text-on-color); }
|
||||
.cstatus button.on-cleared { background:var(--accent-green); color:var(--cds-text-on-color); }
|
||||
.cstatus button.on-na { background:var(--text-muted); color:var(--cds-text-on-color); }
|
||||
#material-body input, #attach-body input, #constraint-body input, #signoff-body input { width:100%; }
|
||||
.signoff-check { width:18px; height:18px; cursor:pointer; }
|
||||
|
||||
/* ── COMMENT-DRIVEN ADDITIONS ────────────────────────────────────── */
|
||||
.locked-field { background:var(--surface2)!important; color:var(--text-muted); cursor:not-allowed; }
|
||||
.lock-row { display:flex; align-items:center; gap:10px; margin-top:5px; }
|
||||
.lock-edit { background:none; border:none; color:var(--accent); cursor:pointer; font-size:11px; padding:0; }
|
||||
.lock-edit { background:none; border:none; color:var(--wp-btn-tertiary-fg); cursor:pointer; font-size:11px; padding:0; }
|
||||
.override-note { font-size:11px; color:var(--accent-amber); }
|
||||
.sop-tag { font-size:9px; font-weight:700; color:var(--accent); background:var(--accent-dim,#eaf0fd); border:1px solid #cdd9f2; border-radius:9px; padding:1px 6px; margin-left:6px; vertical-align:middle; }
|
||||
.sop-tag { font-size:9px; font-weight:700; color:var(--accent); background:var(--accent-dim); border:1px solid var(--wp-accent-border-b); border-radius:9px; padding:1px 6px; margin-left:6px; vertical-align:middle; }
|
||||
|
||||
#toast { position:fixed; bottom:26px; left:50%; transform:translateX(-50%) translateY(20px); background:var(--text); color:#fff;
|
||||
padding:10px 20px; border-radius:8px; font-size:13px; font-weight:600; opacity:0; pointer-events:none; transition:all .25s; z-index:9999; box-shadow:0 6px 24px rgba(0,0,0,.25); }
|
||||
#toast { position:fixed; bottom:26px; left:50%; transform:translateX(-50%) translateY(20px); background:var(--text); color:var(--cds-text-on-color);
|
||||
padding:10px 20px; border-radius:8px; font-size:13px; font-weight:600; opacity:0; pointer-events:none; transition:all .25s; z-index:9999; box-shadow:var(--wp-shadow-toast); }
|
||||
#toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
|
||||
|
||||
.modal-overlay { position:fixed; inset:0; background:rgba(20,28,40,.55); display:none; align-items:center; justify-content:center; z-index:9000; padding:20px; }
|
||||
.modal-overlay { position:fixed; inset:0; background:var(--wp-scrim-cool-strong); display:none; align-items:center; justify-content:center; z-index:9000; padding:20px; }
|
||||
.modal-overlay.open { display:flex; }
|
||||
.modal { background:var(--surface); border-radius:0; width:100%; max-width:520px; box-shadow:0 20px 60px rgba(0,0,0,.3); overflow:hidden; max-height:90vh; display:flex; flex-direction:column; }
|
||||
.modal { background:var(--surface); border-radius:0; width:100%; max-width:520px; box-shadow:var(--wp-shadow-modal-lg); overflow:hidden; max-height:90vh; display:flex; flex-direction:column; }
|
||||
.modal-head { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border); }
|
||||
.modal-title { font-weight:700; font-size:15px; color:var(--text); }
|
||||
.modal-body { padding:18px 20px; overflow-y:auto; }
|
||||
@@ -516,11 +648,11 @@
|
||||
.hold-photo-preview img { max-width:160px; max-height:120px; border-radius:6px; border:1px solid var(--border); margin-top:8px; display:block; }
|
||||
|
||||
/* ── REV 2 ADDITIONS ─────────────────────────────────────────────── */
|
||||
.auto-tag { font-size:9px; font-weight:700; color:var(--accent-green); background:var(--accent-green-dim); border:1px solid #b6e3c6; border-radius:9px; padding:1px 6px; margin-left:6px; vertical-align:middle; }
|
||||
.auto-tag { font-size:9px; font-weight:700; color:var(--accent-green); background:var(--accent-green-dim); border:1px solid var(--wp-status-success-border-b); border-radius:9px; padding:1px 6px; margin-left:6px; vertical-align:middle; }
|
||||
.derived-box { padding:9px 11px; border:1px dashed var(--border-strong); border-radius:var(--radius); background:var(--surface2); color:var(--text); font-size:13px; font-weight:600; min-height:38px; display:flex; align-items:center; }
|
||||
.material-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:8px; }
|
||||
.workstep-row { display:flex; align-items:flex-start; gap:10px; margin-bottom:8px; }
|
||||
.workstep-row .ws-num { flex:0 0 26px; height:26px; border-radius:50%; background:var(--accent); color:#fff; font-size:12px; font-weight:700; display:flex; align-items:center; justify-content:center; margin-top:5px; }
|
||||
.workstep-row .ws-num { flex:0 0 26px; height:26px; border-radius:50%; background:var(--accent); color:var(--cds-text-on-color); font-size:12px; font-weight:700; display:flex; align-items:center; justify-content:center; margin-top:5px; }
|
||||
.workstep-row textarea { flex:1; padding:8px 10px; border:1px solid var(--border-strong); border-radius:var(--radius); font-family:var(--sans); font-size:13px; resize:vertical; min-height:38px; }
|
||||
.workstep-row .row-del { flex:0 0 auto; margin-top:6px; }
|
||||
|
||||
@@ -539,35 +671,39 @@
|
||||
.modal { max-width:100% !important; }
|
||||
.output-toolbar { flex-wrap:wrap; }
|
||||
.material-actions .add-btn { flex:1 1 auto; }
|
||||
table { min-width:520px; } /* keep columns legible; .table-wrap scrolls */
|
||||
/* Scoped to the FORM's scroll containers only. This was a bare `table` and
|
||||
it reached the export document too, forcing every printed table to 520px
|
||||
inside a 390px screen - the export must FIT a tablet, not scroll (CR-008). */
|
||||
.table-wrap table { min-width:520px; } /* keep columns legible; .table-wrap scrolls */
|
||||
.cstatus button { padding:6px 8px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.header-logo { font-size:14px; }
|
||||
.workstep-row textarea { font-size:16px; } /* avoid iOS zoom */
|
||||
input, select, textarea { font-size:16px; } /* avoid iOS zoom on focus */
|
||||
}
|
||||
|
||||
/* ── REV 3 ADDITIONS ─────────────────────────────────────────────── */
|
||||
/* SOP-inherited field highlight (comment 8) */
|
||||
.sop-inherited { background:rgba(37,99,214,0.07) !important; border-color:var(--accent) !important; opacity:0.85; color:var(--text); }
|
||||
.sop-inherited { background:var(--wp-sop-inherited-bg) !important; border-color:var(--accent) !important; opacity:0.85; color:var(--text); }
|
||||
.sop-inherited:focus { opacity:1; }
|
||||
|
||||
/* Dev mode (comment 7) */
|
||||
.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; }
|
||||
.dev-toggle:hover { opacity:0.35; }
|
||||
.dev-banner { background:#3a2a00; color:#ffd479; font-size:12.5px; font-weight:700; text-align:center; padding:7px 14px; letter-spacing:.3px; }
|
||||
body.dev-mode .header { box-shadow: inset 0 -3px 0 #ffb000; }
|
||||
.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; }
|
||||
body.dev-mode .header { box-shadow: inset 0 -3px 0 var(--wp-dev-rule); }
|
||||
|
||||
/* SOP reference links (comments 1,3,4) */
|
||||
.sop-ref-links { margin-bottom:12px; }
|
||||
.ref-links-title { font-size:12px; color:var(--text-muted); margin-bottom:6px; }
|
||||
.ref-links { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.ref-link { display:inline-flex; align-items:center; gap:6px; font-size:12.5px; font-weight:600; color:var(--accent);
|
||||
background:var(--accent-dim,#eef3fd); border:1px solid #cdd9f2; border-radius:8px; padding:6px 11px; text-decoration:none; }
|
||||
.ref-link:hover { background:#e2ecfc; }
|
||||
background:var(--accent-dim); border:1px solid var(--wp-accent-border-b); border-radius:8px; padding:6px 11px; text-decoration:none; }
|
||||
.ref-link:hover { background:var(--wp-accent-soft-hover); }
|
||||
.ref-link .ref-sys { font-weight:400; color:var(--text-muted); font-size:11px; }
|
||||
.ref-link-sm { font-size:12px; font-weight:600; color:var(--accent); text-decoration:none; }
|
||||
.ref-link-sm:hover { text-decoration:underline; }
|
||||
@@ -576,21 +712,154 @@
|
||||
.so-date { font-size:13px; font-variant-numeric:tabular-nums; }
|
||||
.so-ovr { margin-left:8px; font-size:11px; }
|
||||
|
||||
/* Collapsible form sections */
|
||||
.collapse-chev { display:inline-block; width:1em; margin-right:7px; color:var(--text-muted); font-size:11px; user-select:none; }
|
||||
.card.collapsed > :not(.section-header):not(.sub-heading) { display:none !important; }
|
||||
.card.collapsed .section-desc { display:none; }
|
||||
/* ── COLLAPSIBLE SECTIONS + SECTION RAIL (F6 / D3) ─────────────────────────
|
||||
What stood here: a `.section-nav-bar` of `<span onclick>` jump chips, and a
|
||||
`.card.collapsed > :not(.section-header):not(.sub-heading)` rule that hid a
|
||||
card's contents without any element carrying the disclosure state. Neither
|
||||
was reachable by keyboard, and neither said anything to a screen reader.
|
||||
|
||||
/* Section nav (jump chips) */
|
||||
.section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px;
|
||||
padding:8px 12px 8px calc(var(--nav-w,288px) + 28px); background:rgba(255,255,255,.94); backdrop-filter:blur(4px);
|
||||
border-bottom:1px solid var(--border); box-shadow:0 1px 4px rgba(20,30,50,.06);
|
||||
transition:transform .22s ease; }
|
||||
.section-nav-bar:empty{ display:none; }
|
||||
.section-nav-bar.nav-hidden{ transform:translateY(-160%); }
|
||||
.sec-chip{ font-size:12px; font-weight:600; color:var(--text-muted); background:var(--surface2);
|
||||
border:1px solid var(--border); border-radius:14px; padding:4px 11px; cursor:pointer; white-space:nowrap; }
|
||||
.sec-chip:hover{ border-color:var(--accent); color:var(--accent); }
|
||||
Now: every heading is a real disclosure button, its contents are one
|
||||
`.card-body` so `aria-controls` has a target, and the table of contents is a
|
||||
rail of real buttons that marks where you are.
|
||||
|
||||
D3 amended F6's height criterion to "at rest". At rest one section is open,
|
||||
which is what keeps this page under two screen heights; `Expand all` is a
|
||||
deliberate choice to exceed it. */
|
||||
|
||||
/* The disclosure button lives INSIDE the existing heading, so .sub-heading's
|
||||
trailing rule and the help tip beside it keep working untouched. */
|
||||
.card-toggle {
|
||||
display: inline-flex; align-items: center; gap: 9px;
|
||||
background: none; border: 0; padding: 0; margin: 0;
|
||||
font: inherit; color: inherit; text-align: left; cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.card-toggle:hover { color: var(--accent); }
|
||||
.card-toggle-label { min-width: 0; }
|
||||
|
||||
/* Drawn, not typed. A glyph here would be a fourth icon idiom on a page S6 is
|
||||
already going to have to reconcile, and it would render differently per
|
||||
platform - which is half of what S6 is about. */
|
||||
.collapse-chev {
|
||||
flex: 0 0 auto; width: 7px; height: 7px;
|
||||
border-right: 1.5px solid currentColor;
|
||||
border-bottom: 1.5px solid currentColor;
|
||||
transform: rotate(45deg) translate(-2px, -2px);
|
||||
transition: transform .15s ease;
|
||||
}
|
||||
.card-toggle[aria-expanded="false"] .collapse-chev {
|
||||
transform: rotate(-45deg) translate(-2px, 2px);
|
||||
}
|
||||
|
||||
.card-body[hidden] { display: none !important; }
|
||||
.card.collapsed .section-desc { display: none; }
|
||||
/* A collapsed card is a heading, so it should read as a row rather than a box
|
||||
with one line in it. */
|
||||
/* A collapsed section should read as a ROW in a list, not as a box with one line
|
||||
in it. Eleven of them at the card's own 28px padding is 660px of nothing -
|
||||
which is a third of what F6 was measuring, arriving by a different door. */
|
||||
/* F6 strict 2.0: a collapsed row is 36px on fine pointers - 13 of them at
|
||||
rest is where most of the two-screens overage lived. Coarse pointers keep
|
||||
the taller row below (the 44px tablet target, C1). */
|
||||
.card.collapsed { padding-top: 5px; padding-bottom: 5px; }
|
||||
@media (pointer: coarse) {
|
||||
.card.collapsed { padding-top: 10px; padding-bottom: 10px; }
|
||||
}
|
||||
.card.collapsed .section-header { margin-bottom: 0; padding-bottom: 0; border-bottom: 0; }
|
||||
.card.collapsed .sub-heading { margin-bottom: 0; }
|
||||
|
||||
/* ── the rail ──
|
||||
A horizontal strip above the form by default - which is what fits at 390px -
|
||||
and a sticky column beside it from 1200px, where there is width to spare. */
|
||||
.sec-rail {
|
||||
order: -1;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
padding: 8px 28px 8px calc(var(--nav-w, 288px) + 28px);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sec-rail-head {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.sec-rail-title {
|
||||
font-family: var(--mono); font-size: 10px; font-weight: 600;
|
||||
letter-spacing: .08em; text-transform: uppercase; color: var(--text-dim);
|
||||
}
|
||||
.sec-rail-all {
|
||||
margin-left: auto;
|
||||
background: none; border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 3px 10px; font: inherit; font-size: 11px; color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.sec-rail-all:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.sec-rail-all[aria-pressed="true"] {
|
||||
border-color: var(--accent); color: var(--accent); background: var(--accent-dim);
|
||||
}
|
||||
.sec-rail-list {
|
||||
display: flex; flex-wrap: wrap; gap: 4px;
|
||||
list-style: none; margin: 0; padding: 0;
|
||||
}
|
||||
.sec-rail-item {
|
||||
display: block; width: 100%;
|
||||
background: none; border: 1px solid transparent; border-radius: var(--radius);
|
||||
padding: 7px 11px; font: inherit; font-size: 12px; font-weight: 500;
|
||||
color: var(--text-muted); cursor: pointer; text-align: left; white-space: nowrap;
|
||||
min-height: 34px;
|
||||
}
|
||||
.sec-rail-item:hover { color: var(--accent); border-color: var(--border); }
|
||||
/* Not colour alone: the current entry is bolder, keeps a left marker and is the
|
||||
one carrying aria-current. */
|
||||
/* S1 / T7.9: inline validation. The message sits AT the field (role=alert in
|
||||
the DOM, so it announces), and a section holding an error says so on its
|
||||
rail entry with a character, not only a colour. */
|
||||
.field-error { color: var(--red); font-size: 12px; font-weight: 600; margin-top: 4px; }
|
||||
.field-error:empty { display: none; }
|
||||
[aria-invalid="true"] { border-color: var(--red) !important; }
|
||||
.sec-rail-item .sec-err { display:inline-block; margin-right:6px; min-width:16px;
|
||||
text-align:center; border-radius:8px; background:var(--red);
|
||||
color:var(--cds-text-on-color); font-size:11px; font-weight:700; line-height:16px; }
|
||||
|
||||
/* A2: the open-constraint count. Red chip + a number - the number is the
|
||||
content, so the state is never colour-only. Sized to stay legible at 390px. */
|
||||
.sec-badge { display:inline-block; margin-left:8px; min-width:18px; padding:1px 6px;
|
||||
border-radius:9px; background:var(--red); color:var(--cds-text-on-color);
|
||||
font-size:12px; font-weight:700; line-height:16px; text-align:center; }
|
||||
.sec-badge[hidden] { display:none; }
|
||||
.sec-rail-item.is-current {
|
||||
color: var(--accent); font-weight: 700;
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent-dim);
|
||||
box-shadow: inset 2px 0 0 0 var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
/* Horizontal strip: the entries sit side by side and the list scrolls rather
|
||||
than stacking eleven full-width rows above the form. */
|
||||
.sec-rail-list { flex-wrap: nowrap; overflow-x: auto; }
|
||||
.sec-rail-item { width: auto; }
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.wp-layout { flex-direction: row; align-items: flex-start; }
|
||||
.main { flex: 1 1 auto; }
|
||||
.sec-rail {
|
||||
order: 0;
|
||||
flex: 0 0 224px;
|
||||
align-self: flex-start;
|
||||
top: var(--sec-rail-top, 48px);
|
||||
max-height: calc(100vh - var(--sec-rail-top, 48px));
|
||||
overflow-y: auto;
|
||||
margin: 22px 28px 0 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.sec-rail-list { flex-direction: column; flex-wrap: nowrap; }
|
||||
.sec-rail-item { white-space: normal; }
|
||||
}
|
||||
|
||||
/* ── SOP-inherited marker ───────────────────────────────────────────────────
|
||||
The "from SOP types" subtext used to sit under the field. It's now a small
|
||||
@@ -599,16 +868,16 @@
|
||||
hover, and "this value came from the SOP" is the part people need to see. */
|
||||
.field-hint.sop-hint { display: none; }
|
||||
.sop-chip { display:inline-block; margin-left:6px; padding:0 6px; border-radius:9px;
|
||||
background:var(--accent-dim); color:var(--accent); border:1px solid #b9d2fb;
|
||||
background:var(--accent-dim); color:var(--accent); border:1px solid var(--wp-accent-border-a);
|
||||
font-size:9.5px; font-weight:700; letter-spacing:.04em; text-transform:uppercase;
|
||||
vertical-align:middle; cursor:help; position:relative; }
|
||||
.sop-chip::after { content:attr(data-tip); position:absolute; bottom:135%; left:50%;
|
||||
transform:translateX(-50%); background:#161616; color:#fff; padding:7px 10px; font-size:12px;
|
||||
transform:translateX(-50%); background:var(--cds-ui-05); color:var(--cds-text-on-color); padding:7px 10px; font-size:12px;
|
||||
font-weight:400; letter-spacing:0; text-transform:none; line-height:1.4; white-space:normal;
|
||||
width:max-content; max-width:260px; text-align:left; z-index:9999; opacity:0;
|
||||
pointer-events:none; transition:opacity .12s; box-shadow:0 4px 14px rgba(20,30,50,.22); }
|
||||
pointer-events:none; transition:opacity .12s; box-shadow:var(--wp-shadow-tooltip); }
|
||||
.sop-chip::before { content:''; position:absolute; bottom:135%; left:50%;
|
||||
transform:translate(-50%,95%); border:5px solid transparent; border-top-color:#161616;
|
||||
transform:translate(-50%,95%); border:5px solid transparent; border-top-color:var(--cds-ui-05);
|
||||
opacity:0; transition:opacity .12s; z-index:9999; }
|
||||
.sop-chip:hover::after, .sop-chip:hover::before,
|
||||
.sop-chip:focus::after, .sop-chip:focus::before { opacity:1; }
|
||||
@@ -621,7 +890,7 @@
|
||||
.pp-chip { display:inline-flex; align-items:center; gap:5px; padding:2px 6px 2px 8px;
|
||||
background:var(--surface2); border:1px solid var(--border); border-radius:12px;
|
||||
font-size:12px; max-width:100%; }
|
||||
.pp-chip.pp-locked { background:var(--accent-dim); border-color:#b9d2fb; color:var(--accent); }
|
||||
.pp-chip.pp-locked { background:var(--accent-dim); border-color:var(--wp-accent-border-a); color:var(--accent); }
|
||||
.pp-chip .pp-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.pp-chip .pp-x { background:none; border:0; cursor:pointer; color:var(--text-muted);
|
||||
font-size:12px; line-height:1; padding:0 1px; }
|
||||
@@ -632,7 +901,7 @@
|
||||
.pp-add-btn:hover { border-color:var(--accent); color:var(--accent); }
|
||||
.pp-menu { position:absolute; top:calc(100% + 4px); left:0; z-index:60; min-width:270px;
|
||||
max-height:300px; overflow-y:auto; background:var(--surface); border:1px solid var(--border-strong);
|
||||
box-shadow:0 8px 24px rgba(20,30,50,.18); border-radius:4px; padding:6px 0; }
|
||||
box-shadow:var(--wp-shadow-menu); border-radius:4px; padding:6px 0; }
|
||||
.pp-menu[hidden] { display:none; }
|
||||
.pp-group { font-size:9.5px; font-weight:700; letter-spacing:.07em; text-transform:uppercase;
|
||||
color:var(--text-dim); padding:7px 10px 3px; }
|
||||
@@ -680,7 +949,7 @@
|
||||
/* Critical constraint marker (from the SOP) */
|
||||
.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);
|
||||
border:1px solid #ffc4c4; white-space:nowrap; vertical-align:middle; }
|
||||
border:1px solid var(--wp-status-error-border-c); white-space:nowrap; vertical-align:middle; }
|
||||
|
||||
/* -- WORK PACKAGE NAVIGATOR ------------------------------------------------
|
||||
A persistent side panel in the spirit of MS Planner: collapse toggle, one
|
||||
@@ -697,7 +966,7 @@
|
||||
z-index: 120;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fbfbfc;
|
||||
background: var(--wp-nav-bg);
|
||||
border-right: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
transition: width .16s ease;
|
||||
@@ -705,6 +974,18 @@
|
||||
body { --nav-w: 288px; }
|
||||
body.wp-nav-collapsed { --nav-w: 56px; }
|
||||
|
||||
/* A6: the triage line and the inline hold reason. These WRAP - an ellipsis
|
||||
here would hide exactly the data the row exists to show. The hold reason is
|
||||
clamped at three lines so one essay of a reason cannot swallow the panel. */
|
||||
.wp-nav-item { align-items: flex-start; }
|
||||
.wp-nav-item .wp-nav-badge { margin-top: 2px; }
|
||||
.wp-nav-triage { display: block; font-size: 10.5px; line-height: 1.5;
|
||||
color: var(--text-muted); white-space: normal; margin-top: 1px; }
|
||||
.wp-nav-hold { display: -webkit-box; -webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical; overflow: hidden;
|
||||
font-size: 10.5px; line-height: 1.45; color: var(--red);
|
||||
white-space: normal; margin-top: 2px; }
|
||||
|
||||
/* -- collapse toggle -- */
|
||||
.wp-nav-top { display: flex; align-items: center; padding: 8px 10px 2px; }
|
||||
.wp-nav-toggle {
|
||||
@@ -713,7 +994,7 @@
|
||||
background: transparent; border: 1px solid transparent; border-radius: 5px;
|
||||
color: var(--text-muted); cursor: pointer;
|
||||
}
|
||||
.wp-nav-toggle:hover { background: #eef0f3; color: var(--text); }
|
||||
.wp-nav-toggle:hover { background: var(--wp-nav-hover); color: var(--text); }
|
||||
/* Arrow flips to point right when the panel is closed. */
|
||||
body.wp-nav-collapsed .wp-nav-toggle-arrow { transform: rotate(180deg); transform-origin: 11px 10px; }
|
||||
|
||||
@@ -723,23 +1004,23 @@
|
||||
flex: 1 1 auto; min-width: 0;
|
||||
display: inline-flex; align-items: center; justify-content: flex-start; gap: 9px;
|
||||
height: 40px; padding: 0 14px;
|
||||
background: var(--accent); color: #fff;
|
||||
background: var(--wp-btn-primary-bg); color: var(--wp-btn-primary-fg);
|
||||
border: 0; border-radius: 6px 0 0 6px;
|
||||
font: inherit; font-size: 14px; font-weight: 600;
|
||||
cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.wp-nav-cta:hover { background: #0353e9; }
|
||||
.wp-nav-cta:hover { background: var(--wp-btn-primary-hover); }
|
||||
.wp-nav-cta-plus { font-size: 17px; font-weight: 400; line-height: 1; }
|
||||
.wp-nav-cta-more {
|
||||
flex: 0 0 auto; width: 30px; height: 40px;
|
||||
background: var(--accent); color: #fff; border: 0; border-left: 1px solid rgba(255,255,255,.28);
|
||||
background: var(--wp-btn-primary-bg); color: var(--wp-btn-primary-fg); border: 0; border-left: 1px solid var(--wp-on-accent-divider);
|
||||
border-radius: 0 6px 6px 0; font: inherit; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.wp-nav-cta-more:hover { background: #0353e9; }
|
||||
.wp-nav-cta-more:hover { background: var(--wp-btn-primary-hover); }
|
||||
.wp-nav-menu {
|
||||
position: absolute; top: calc(100% - 6px); left: 10px; right: 10px; z-index: 10;
|
||||
background: var(--surface); border: 1px solid var(--border-strong); border-radius: 6px;
|
||||
box-shadow: 0 10px 26px rgba(20,30,50,.18); padding: 5px 0;
|
||||
box-shadow: var(--wp-shadow-menu-lg); padding: 5px 0;
|
||||
}
|
||||
.wp-nav-menu[hidden] { display: none; }
|
||||
.wp-nav-menu button {
|
||||
@@ -757,8 +1038,8 @@
|
||||
font: inherit; font-size: 14px; color: var(--text);
|
||||
cursor: pointer; text-align: left; white-space: nowrap;
|
||||
}
|
||||
.wp-nav-link:hover { background: #eef0f3; }
|
||||
.wp-nav-link.is-current { background: #e8eaed; font-weight: 600; }
|
||||
.wp-nav-link:hover { background: var(--wp-nav-hover); }
|
||||
.wp-nav-link.is-current { background: var(--wp-nav-active); font-weight: 600; }
|
||||
.wp-nav-ico { flex: 0 0 20px; width: 20px; text-align: center; font-size: 15px; color: var(--text-muted); }
|
||||
.wp-nav-link-label { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; }
|
||||
.wp-nav-link-n { flex: 0 0 auto; font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
||||
@@ -775,7 +1056,9 @@
|
||||
width: 100%; padding: 7px 10px; font: inherit; font-size: 13px;
|
||||
border: 1px solid var(--border); border-radius: 6px; background: var(--surface); color: var(--text);
|
||||
}
|
||||
.wp-nav-search:focus { outline: none; border-color: var(--accent); }
|
||||
/* S12: keeps the border cue, drops the bare `outline: none`; the theme's ring
|
||||
applies on keyboard focus. */
|
||||
.wp-nav-search:focus { border-color: var(--accent); }
|
||||
|
||||
/* -- package rows -- */
|
||||
.wp-nav-list { flex: 1 1 auto; overflow-y: auto; overflow-x: hidden; padding: 0 8px 14px; }
|
||||
@@ -790,8 +1073,8 @@
|
||||
background: none; border: 0; border-radius: 6px;
|
||||
font: inherit; color: var(--text); text-align: left; cursor: pointer;
|
||||
}
|
||||
.wp-nav-item:hover { background: #eef0f3; }
|
||||
.wp-nav-item.active { background: #e8eaed; }
|
||||
.wp-nav-item:hover { background: var(--wp-nav-hover); }
|
||||
.wp-nav-item.active { background: var(--wp-nav-active); }
|
||||
/* Left accent bar on the current package, like Planner's selected plan. */
|
||||
.wp-nav-item.active::before {
|
||||
content: ''; position: absolute; left: 0; top: 6px; bottom: 6px;
|
||||
@@ -801,7 +1084,7 @@
|
||||
flex: 0 0 28px; width: 28px; height: 28px; border-radius: 5px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-family: var(--sans); font-size: 11px; font-weight: 700; letter-spacing: .02em;
|
||||
color: #fff; text-transform: uppercase;
|
||||
color: var(--cds-text-on-color); text-transform: uppercase;
|
||||
}
|
||||
.wp-nav-body { min-width: 0; flex: 1 1 auto; }
|
||||
.wp-nav-num { display: block; font-size: 13.5px; font-weight: 600; color: var(--text);
|
||||
@@ -835,18 +1118,15 @@
|
||||
/* Narrow screens: keep the rail collapsed-width so the form still has room. */
|
||||
@media (max-width: 860px) {
|
||||
body { --nav-w: 56px; }
|
||||
body:not(.wp-nav-collapsed) .wp-nav { width: 288px; box-shadow: 6px 0 22px rgba(20,30,50,.16); }
|
||||
body:not(.wp-nav-collapsed) .wp-nav { width: 288px; box-shadow: var(--wp-shadow-rail); }
|
||||
}
|
||||
|
||||
/* Sticky save bar */
|
||||
.sticky-save{ position:fixed; left:var(--nav-w,288px); right:0; bottom:0; z-index:40; display:flex; align-items:center;
|
||||
justify-content:space-between; gap:14px; padding:10px 20px; background:#fff;
|
||||
border-top:1px solid var(--border-strong); box-shadow:0 -2px 10px rgba(20,30,50,.08); }
|
||||
justify-content:space-between; gap:14px; padding:10px 20px; background:var(--surface);
|
||||
border-top:1px solid var(--border-strong); box-shadow:var(--wp-shadow-sticky); }
|
||||
.sticky-save .sticky-status{ font-size:13px; font-weight:600; }
|
||||
.sticky-save .sticky-actions{ display:flex; gap:10px; }
|
||||
.ss-ready{ color:var(--accent-green); }
|
||||
.ss-notready{ color:var(--accent-amber); }
|
||||
.ss-hold{ color:var(--red); }
|
||||
body.has-sticky-save .main{ padding-bottom:74px; }
|
||||
|
||||
/* Disciplines + per-discipline scope */
|
||||
@@ -856,7 +1136,7 @@
|
||||
.disc-pill:hover { border-color:var(--accent); color:var(--text); }
|
||||
.disc-pill input { display:none; }
|
||||
.disc-pill .dot { width:7px; height:7px; border-radius:50%; background:var(--border-strong); transition:background .12s; flex-shrink:0; }
|
||||
.disc-pill.selected { border-color:var(--accent); background:var(--accent-dim,#eef3fd); color:var(--accent); }
|
||||
.disc-pill.selected { border-color:var(--accent); background:var(--accent-dim); color:var(--accent); }
|
||||
.disc-pill.selected .dot { background:var(--accent); }
|
||||
.disc-scope { border:1px solid var(--border); border-left:3px solid var(--accent); border-radius:6px; padding:12px 14px; margin-bottom:12px; background:var(--bg); }
|
||||
.disc-scope-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:8px; flex-wrap:wrap; }
|
||||
@@ -871,11 +1151,12 @@
|
||||
.dash-metric .dm-label { font-size:11px; color:var(--text-muted); margin-top:6px; text-transform:uppercase; letter-spacing:.03em; }
|
||||
.dash-metric.dm-green .dm-val { color:var(--accent-green); }
|
||||
.dash-metric.dm-red .dm-val { color:var(--red); }
|
||||
.dash-metric.dm-blue .dm-val { color:var(--accent, #0f62fe); }
|
||||
.dash-metric.dm-blue .dm-val { color:var(--accent); }
|
||||
.dash-metric[onclick] { cursor:pointer; transition:border-color .12s, box-shadow .12s; }
|
||||
.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-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-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; }
|
||||
@@ -886,6 +1167,56 @@
|
||||
.dash-table { width:100%; border-collapse:collapse; font-size:12.5px; }
|
||||
.dash-table th { text-align:left; background:var(--surface2); border-bottom:1px solid var(--border); padding:6px 8px; font-size:11px; text-transform:uppercase; color:var(--text-muted); }
|
||||
.dash-table td { border-bottom:1px solid var(--border); padding:6px 8px; vertical-align:top; }
|
||||
/* CR-001 / T6.1: a sortable header is a real <button> inside the <th>, so it is
|
||||
in the tab order and Enter and Space work without being wired up. It inherits
|
||||
the header's own type rather than declaring its own, so the row still reads as
|
||||
one strip. A th with no sort (Gates, the actions column) has no button and is
|
||||
therefore not offered as one. */
|
||||
.dash-table th .dash-sort {
|
||||
background:none; border:none; padding:0; margin:0; border-radius:0;
|
||||
font:inherit; color:inherit; text-transform:inherit; letter-spacing:inherit;
|
||||
cursor:pointer; display:inline-flex; align-items:center; gap:4px;
|
||||
}
|
||||
.dash-table th .dash-sort:hover { color:var(--accent); }
|
||||
/* The sorted column is said three ways: bold, an arrow, and aria-sort on the
|
||||
th — so it survives both "cannot see colour" and "cannot see the arrow". */
|
||||
.dash-table th .dash-sort.is-sorted { color:var(--text); font-weight:700; }
|
||||
|
||||
/* CR-003 / X7: priority, coloured from canonical tokens only. X7's warning is
|
||||
that without one source of truth for colour, Normal/High/Urgent gets four
|
||||
implementations — so these alias the same status tokens the badges and
|
||||
banners already use, and declare nothing.
|
||||
|
||||
The LABEL is always rendered. Colour is a second channel and never the only
|
||||
one: Normal is a plain outline, High is amber-filled and Urgent is
|
||||
red-filled, so the three differ by weight and fill as well as by hue. */
|
||||
.prio {
|
||||
display:inline-block; padding:1px 8px; border:1px solid var(--border-strong);
|
||||
border-radius:20px; font-size:10px; font-weight:600; letter-spacing:.04em;
|
||||
color:var(--text-muted); background:var(--surface); white-space:nowrap;
|
||||
}
|
||||
.prio-high {
|
||||
color:var(--accent-amber); border-color:var(--wp-status-warning-border-a);
|
||||
background:var(--accent-amber-dim);
|
||||
}
|
||||
/* CR-004: three dependent dropdowns where a free-text box used to be. They wrap
|
||||
rather than shrink — three selects squeezed onto one 390px line are three
|
||||
controls nobody can read the options of. */
|
||||
/* CR-018: one table per level, each adding up to the project on its own. */
|
||||
.loc-rollup { margin-top:12px; }
|
||||
.loc-rollup .dash-table { margin-top:4px; }
|
||||
/* The unassigned row and the total row are marked structurally, not only by
|
||||
colour: one is italic, the other bold with a rule above it. */
|
||||
.dash-table tr.loc-unassigned td { font-style:italic; color:var(--text-muted); }
|
||||
.dash-table tr.loc-total td { font-weight:700; border-top:2px solid var(--border-strong); }
|
||||
|
||||
.loc-picker { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.loc-picker select { flex:1 1 120px; min-width:0; }
|
||||
.loc-picker select:disabled { opacity:.55; cursor:not-allowed; }
|
||||
|
||||
.prio-urgent {
|
||||
color:var(--wp-btn-danger-fill-fg); border-color:var(--red); background:var(--red);
|
||||
}
|
||||
.dash-filters { display:flex; flex-wrap:wrap; gap:10px; margin-bottom:14px; }
|
||||
.dash-filters input, .dash-filters select { padding:7px 10px; border:1px solid var(--border-strong); border-radius:6px; font-size:13px; }
|
||||
.dash-filters input[type=search] { flex:1; min-width:200px; }
|
||||
|
||||
172
html/wp-dialog.js
Normal file
@@ -0,0 +1,172 @@
|
||||
/* Dialog kit + toast, shared (BL-024, 2026-08-20).
|
||||
*
|
||||
* The T7.9 kit, extracted for the pages the S1 tasks never named: the launcher
|
||||
* (index.html), the admin console and the user console carried 21 native
|
||||
* dialogs between them. Same contract as the creator's copy:
|
||||
*
|
||||
* wpConfirmDialog({title, message, okLabel, cancelLabel}) -> Promise<bool>
|
||||
* wpPromptDialog({title, message, label, value, validate}) -> Promise<string|null>
|
||||
* wpAlertDialog({title, message, okLabel}) -> Promise (value not meaningful)
|
||||
* toast(msg, kind) kind 'alert' interrupts (role=alert); default role=status
|
||||
*
|
||||
* Self-contained on purpose: markup and styles are injected on first use, the
|
||||
* styles are theme tokens only (the token rule), and the class names are its
|
||||
* own (wp-dlg-*) so the consoles' existing .modal styles are never touched.
|
||||
* The creator keeps its inline copy - it owns the same-id markup in its HTML -
|
||||
* so everything here is guarded: if the page already has the kit, this file
|
||||
* defines nothing.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
if (typeof global.wpConfirmDialog === 'function') return; // the creator's copy wins
|
||||
|
||||
var CSS =
|
||||
'#wp-dlg-overlay{position:fixed;inset:0;background:var(--wp-scrim-cool-strong);' +
|
||||
'display:none;align-items:center;justify-content:center;z-index:10500;padding:20px;}' +
|
||||
'#wp-dlg-overlay.open{display:flex;}' +
|
||||
'.wp-dlg{background:var(--cds-layer);color:var(--cds-text-primary);max-width:480px;width:100%;' +
|
||||
'border-radius:8px;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;' +
|
||||
'font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;font-size:14px;}' +
|
||||
'.wp-dlg-head{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;' +
|
||||
'border-bottom:1px solid var(--cds-border-subtle);font-weight:700;}' +
|
||||
'.wp-dlg-x{background:none;border:none;font-size:18px;line-height:1;cursor:pointer;' +
|
||||
'color:var(--cds-text-secondary);padding:4px 6px;}' +
|
||||
'.wp-dlg-x:focus-visible{outline:2px solid var(--cds-focus);outline-offset:1px;}' +
|
||||
'.wp-dlg-body{padding:16px 18px;}' +
|
||||
'#wp-dlg-msg{white-space:pre-wrap;line-height:1.5;}' +
|
||||
'#wp-dlg-input-wrap{margin-top:10px;}' +
|
||||
'#wp-dlg-input-wrap label{display:block;font-size:12px;margin-bottom:4px;color:var(--cds-text-secondary);}' +
|
||||
'#wp-dlg-input{width:100%;box-sizing:border-box;padding:8px 10px;font:inherit;' +
|
||||
'border:1px solid var(--cds-border-strong);border-radius:4px;background:var(--cds-field);}' +
|
||||
'#wp-dlg-input:focus{outline:2px solid var(--cds-focus);outline-offset:-1px;}' +
|
||||
'#wp-dlg-err{color:var(--cds-text-error);font-size:12px;font-weight:600;margin-top:4px;}' +
|
||||
'#wp-dlg-err:empty{display:none;}' +
|
||||
'.wp-dlg-foot{display:flex;justify-content:flex-end;gap:10px;padding:12px 18px;' +
|
||||
'border-top:1px solid var(--cds-border-subtle);}' +
|
||||
'.wp-dlg-btn{font:inherit;font-weight:600;padding:8px 16px;border-radius:6px;cursor:pointer;' +
|
||||
'border:1px solid var(--cds-border-strong);background:var(--cds-layer);color:var(--cds-text-primary);}' +
|
||||
'.wp-dlg-btn.primary{background:var(--cds-interactive-01);border-color:var(--cds-interactive-01);' +
|
||||
'color:var(--cds-text-on-color);}' +
|
||||
'.wp-dlg-btn:focus-visible{outline:2px solid var(--cds-focus);outline-offset:1px;}' +
|
||||
'@media(pointer:coarse){.wp-dlg-btn{min-height:44px;}.wp-dlg-x{min-width:44px;min-height:44px;}}' +
|
||||
'#toast{position:fixed;bottom:26px;left:50%;transform:translateX(-50%) translateY(20px);' +
|
||||
'background:var(--cds-background-inverse);color:var(--cds-text-inverse);padding:9px 16px;' +
|
||||
'border-radius:6px;font-size:13px;opacity:0;transition:opacity .18s,transform .18s;' +
|
||||
'pointer-events:none;z-index:10600;max-width:min(480px,calc(100vw - 32px));}' +
|
||||
'#toast.show{opacity:1;transform:translateX(-50%) translateY(0);}';
|
||||
|
||||
function ensure() {
|
||||
var ov = document.getElementById('wp-dlg-overlay');
|
||||
if (ov) return ov;
|
||||
var st = document.createElement('style');
|
||||
st.textContent = CSS;
|
||||
document.head.appendChild(st);
|
||||
ov = document.createElement('div');
|
||||
ov.id = 'wp-dlg-overlay';
|
||||
ov.setAttribute('role', 'dialog');
|
||||
ov.setAttribute('aria-modal', 'true');
|
||||
ov.setAttribute('aria-labelledby', 'wp-dlg-title');
|
||||
ov.innerHTML =
|
||||
'<div class="wp-dlg">' +
|
||||
'<div class="wp-dlg-head"><div id="wp-dlg-title"></div>' +
|
||||
'<button type="button" class="wp-dlg-x" id="wp-dlg-x" title="Cancel" aria-label="Cancel">✕</button></div>' +
|
||||
'<div class="wp-dlg-body">' +
|
||||
'<div id="wp-dlg-msg"></div>' +
|
||||
'<div id="wp-dlg-input-wrap">' +
|
||||
'<label id="wp-dlg-label" for="wp-dlg-input"></label>' +
|
||||
'<input type="text" id="wp-dlg-input">' +
|
||||
'<div id="wp-dlg-err" role="alert"></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="wp-dlg-foot">' +
|
||||
'<button type="button" class="wp-dlg-btn" id="wp-dlg-cancel">Cancel</button>' +
|
||||
'<button type="button" class="wp-dlg-btn primary" id="wp-dlg-ok">OK</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(ov);
|
||||
document.getElementById('wp-dlg-x').addEventListener('click', cancel);
|
||||
document.getElementById('wp-dlg-cancel').addEventListener('click', cancel);
|
||||
document.getElementById('wp-dlg-ok').addEventListener('click', ok);
|
||||
document.getElementById('wp-dlg-input').addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') ok();
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && ov.classList.contains('open')) cancel();
|
||||
});
|
||||
return ov;
|
||||
}
|
||||
|
||||
var resolveFn = null;
|
||||
|
||||
function open(opts) {
|
||||
return new Promise(function (res) {
|
||||
resolveFn = res;
|
||||
var ov = ensure();
|
||||
ov._opts = opts || {};
|
||||
document.getElementById('wp-dlg-title').textContent = opts.title || 'Confirm';
|
||||
document.getElementById('wp-dlg-msg').textContent = opts.message || '';
|
||||
document.getElementById('wp-dlg-input-wrap').style.display = opts.input ? '' : 'none';
|
||||
document.getElementById('wp-dlg-label').textContent = opts.label || '';
|
||||
var inp = document.getElementById('wp-dlg-input');
|
||||
inp.value = (opts.value != null ? String(opts.value) : '');
|
||||
document.getElementById('wp-dlg-err').textContent = '';
|
||||
document.getElementById('wp-dlg-ok').textContent = opts.okLabel || 'OK';
|
||||
var cb = document.getElementById('wp-dlg-cancel');
|
||||
cb.textContent = opts.cancelLabel || 'Cancel';
|
||||
cb.style.display = opts.okOnly ? 'none' : '';
|
||||
ov.classList.add('open');
|
||||
setTimeout(function () {
|
||||
(opts.input ? inp : document.getElementById('wp-dlg-ok')).focus();
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function close(val) {
|
||||
var ov = document.getElementById('wp-dlg-overlay');
|
||||
if (ov) ov.classList.remove('open');
|
||||
var r = resolveFn;
|
||||
resolveFn = null;
|
||||
if (r) r(val);
|
||||
}
|
||||
|
||||
function ok() {
|
||||
var ov = document.getElementById('wp-dlg-overlay');
|
||||
var opts = (ov && ov._opts) || {};
|
||||
if (opts.input) {
|
||||
var v = document.getElementById('wp-dlg-input').value;
|
||||
if (opts.validate) {
|
||||
var err = opts.validate(v);
|
||||
if (err) {
|
||||
document.getElementById('wp-dlg-err').textContent = err;
|
||||
document.getElementById('wp-dlg-input').focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
close(v);
|
||||
} else close(true);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
var ov = document.getElementById('wp-dlg-overlay');
|
||||
var opts = (ov && ov._opts) || {};
|
||||
close(opts.input ? null : false);
|
||||
}
|
||||
|
||||
global.wpConfirmDialog = function (opts) { return open(Object.assign({}, opts, { input: false })); };
|
||||
global.wpPromptDialog = function (opts) { return open(Object.assign({}, opts, { input: true })); };
|
||||
global.wpAlertDialog = function (opts) { return open(Object.assign({}, opts, { input: false, okOnly: true })); };
|
||||
|
||||
if (typeof global.toast !== 'function') {
|
||||
// S10's rule, same as the creator: role BEFORE text, 'alert' interrupts.
|
||||
global.toast = function (msg, kind) {
|
||||
ensure();
|
||||
var t = document.getElementById('toast');
|
||||
if (!t) { t = document.createElement('div'); t.id = 'toast'; document.body.appendChild(t); }
|
||||
t.setAttribute('role', kind === 'alert' ? 'alert' : 'status');
|
||||
t.textContent = msg;
|
||||
t.classList.add('show');
|
||||
clearTimeout(global.toast._t);
|
||||
global.toast._t = setTimeout(function () { t.classList.remove('show'); }, 2200);
|
||||
};
|
||||
}
|
||||
})(window);
|
||||
@@ -120,12 +120,12 @@
|
||||
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,' +
|
||||
'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 lbl = 'display:block;font-size:12px;color:#525252;margin:14px 0 4px;font-weight:600;';
|
||||
var hint = 'font-size:11.5px;color:#6f6f6f;margin-bottom:6px;';
|
||||
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:var(--cds-text-secondary);margin:14px 0 4px;font-weight:600;';
|
||||
var hint = 'font-size:11.5px;color:var(--cds-text-helper);margin-bottom:6px;';
|
||||
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="padding:14px 18px;border-bottom:1px solid #e0e0e0;font-weight:700;">Language & time</div>' +
|
||||
'<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 var(--cds-border-subtle);font-weight:700;">Language & time</div>' +
|
||||
'<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>' +
|
||||
'<label style="' + lbl + '">Language & number format</label>' +
|
||||
@@ -135,19 +135,19 @@
|
||||
'<select id="wp-prefs-tz" style="' + fld + '"></select>' +
|
||||
'<div style="' + hint + '">Times (MIMO windows, history, notifications) are shown in this zone. ' +
|
||||
'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 style="padding:12px 18px;border-top:1px solid #e0e0e0;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-save" style="padding:8px 14px;border:none;background:#0f62fe;color:#fff;border-radius:6px;cursor:pointer;font-weight:600;">Save</button>' +
|
||||
'<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 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:var(--cds-interactive-01);color:var(--cds-text-on-color);border-radius:6px;cursor:pointer;font-weight:600;">Save</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
function close() { var m = document.getElementById('wp-prefs-modal'); if (m) m.remove(); }
|
||||
function msg(text, ok) {
|
||||
var e = document.getElementById('wp-prefs-msg');
|
||||
e.style.display = 'block'; e.textContent = text;
|
||||
e.style.background = ok ? '#defbe6' : '#fff1f1';
|
||||
e.style.color = ok ? '#0e6027' : '#da1e28';
|
||||
e.style.background = ok ? 'var(--wp-status-success-bg)' : 'var(--wp-status-error-bg)';
|
||||
e.style.color = ok ? 'var(--wp-status-success-text)' : 'var(--cds-support-error)';
|
||||
}
|
||||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||||
document.body.appendChild(ov);
|
||||
|
||||
210
html/wp-list-import.js
Normal file
@@ -0,0 +1,210 @@
|
||||
/* The project-list import component — T5.4's machinery, extracted (D6 / T8.6).
|
||||
|
||||
One implementation of paste-or-file → server-side import with dry-run →
|
||||
a report that names every rejected row with its SOURCE line number → an
|
||||
editable list whose entries deactivate rather than delete. The location list
|
||||
(CR-005) and the material list (D6) are both instances of this; building the
|
||||
material path "the same way and against the same component, not beside it"
|
||||
is the T8.6 instruction, and extracting the component is what makes that
|
||||
literally true rather than a copy with the names changed.
|
||||
|
||||
The page supplies what differs: the API base, the sample, how a row renders,
|
||||
and what the add-row collects. Everything generic — the file reader feeding
|
||||
the paste box (one parser, on the server), the dry-run wiring, the report
|
||||
roles (a report that lost rows interrupts; a clean one does not, per T4.5) —
|
||||
lives here once.
|
||||
|
||||
Classic script, no modules: exposes window.WPListImport. */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
function el(id) { return document.getElementById(id); }
|
||||
|
||||
window.WPListImport = function (cfg) {
|
||||
// cfg.prefix DOM id prefix: '<p>-paste', '<p>-file', '<p>-file-btn',
|
||||
// '<p>-check-btn', '<p>-import-btn', '<p>-sample-btn',
|
||||
// '<p>-report', '<p>-add-name', '<p>-add-btn', '<p>-add-err',
|
||||
// '<p>-tool', '<p>-noproject'
|
||||
// cfg.api(suffix) URL builder for the project-scoped routes
|
||||
// cfg.projectId() current project id ('' = not opened from a project)
|
||||
// cfg.sample text the sample button loads
|
||||
// cfg.loadKey response key holding the rows ('nodes' | 'items')
|
||||
// cfg.render() paints the current list from state.rows
|
||||
// cfg.rejectedRow(r) <li> HTML for one rejected row
|
||||
// cfg.duplicateRow(r) <li> HTML for one duplicate row
|
||||
// cfg.addPayload() reads the add-row; {payload} to POST or {error}
|
||||
// cfg.addedMessage(body) confirmation HTML after a successful add
|
||||
// cfg.noProjectMessage what to say when there is no project
|
||||
// cfg.esc the page's escaper
|
||||
var p = cfg.prefix;
|
||||
var esc = cfg.esc;
|
||||
var state = { rows: [], loaded: false };
|
||||
|
||||
// Every message goes through here so the role is decided in one place:
|
||||
// a report that lost rows interrupts (T4.5), a clean one does not.
|
||||
function say(html, isProblem) {
|
||||
var box = el(p + '-report');
|
||||
if (!box) return;
|
||||
box.setAttribute('role', isProblem ? 'alert' : 'status');
|
||||
box.innerHTML = html || '';
|
||||
box.classList.toggle('is-problem', !!isProblem);
|
||||
}
|
||||
|
||||
function setAddError(msg) {
|
||||
var box = el(p + '-add-err');
|
||||
if (box) box.textContent = msg || '';
|
||||
var input = el(p + '-add-name');
|
||||
if (input) {
|
||||
if (msg) input.setAttribute('aria-invalid', 'true');
|
||||
else input.removeAttribute('aria-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function load(force) {
|
||||
var tool = el(p + '-tool');
|
||||
var warn = el(p + '-noproject');
|
||||
var pid = cfg.projectId();
|
||||
if (!pid) {
|
||||
if (tool) tool.style.display = 'none';
|
||||
if (warn) { warn.style.display = ''; warn.textContent = cfg.noProjectMessage; }
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (tool) tool.style.display = '';
|
||||
if (warn) warn.style.display = 'none';
|
||||
if (state.loaded && !force) return Promise.resolve();
|
||||
return fetch(cfg.api('?include_inactive=true'), { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(function (data) { state.rows = data[cfg.loadKey] || []; state.loaded = true; cfg.render(); })
|
||||
.catch(function (err) {
|
||||
state.loaded = false;
|
||||
cfg.render();
|
||||
say('⚠ Could not load the list — ' + esc((err && err.message) || 'offline')
|
||||
+ '. It is stored on the server, so nothing local is shown in its place.', true);
|
||||
});
|
||||
}
|
||||
|
||||
function report(res) {
|
||||
var bits = [];
|
||||
var problem = (res.rejected || []).length > 0 || (res.duplicates || []).length > 0;
|
||||
var verb = res.dry_run ? 'would be added' : 'added';
|
||||
var nCreated = (res.created || []).length;
|
||||
bits.push('<p class="loc-report-line"><strong>' + res.read + ' row' + (res.read === 1 ? '' : 's')
|
||||
+ ' read.</strong> ' + nCreated + ' value' + (nCreated === 1 ? '' : 's') + ' ' + verb
|
||||
+ ((res.reactivated || []).length ? ', ' + res.reactivated.length + ' brought back into use' : '')
|
||||
+ '.</p>');
|
||||
var rowList = function (title, rows, fmt) {
|
||||
if (!rows || !rows.length) return '';
|
||||
return '<div class="loc-report-group"><div class="loc-report-title">' + esc(title)
|
||||
+ ' (' + rows.length + ')</div><ul class="loc-report-list">' + rows.map(fmt).join('') + '</ul></div>';
|
||||
};
|
||||
bits.push(rowList('Rejected', res.rejected, cfg.rejectedRow));
|
||||
bits.push(rowList('Duplicates, not merged', res.duplicates, cfg.duplicateRow));
|
||||
if (!problem && !nCreated && !(res.reactivated || []).length) {
|
||||
bits.push('<p class="loc-report-line">Nothing to do — every row is already on this project.</p>');
|
||||
}
|
||||
say(bits.join(''), problem);
|
||||
}
|
||||
|
||||
function importText(dryRun) {
|
||||
var text = (el(p + '-paste') || {}).value || '';
|
||||
if (!text.trim()) { say('Paste some rows or choose a CSV file first.', true); return; }
|
||||
if (!cfg.projectId()) { load(); return; }
|
||||
say('Checking…', false);
|
||||
fetch(cfg.api('/import'), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ text: text, dry_run: !!dryRun }),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
say('⚠ Import refused — ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
||||
return;
|
||||
}
|
||||
report(res.body);
|
||||
if (!dryRun) return load(true);
|
||||
})
|
||||
.catch(function (err) {
|
||||
say('⚠ Could not reach the server — ' + esc((err && err.message) || 'offline')
|
||||
+ '. Nothing was imported.', true);
|
||||
});
|
||||
}
|
||||
|
||||
function add() {
|
||||
var read = cfg.addPayload();
|
||||
if (read.error) {
|
||||
setAddError(read.error);
|
||||
var input = el(p + '-add-name');
|
||||
if (input) input.focus();
|
||||
return;
|
||||
}
|
||||
setAddError('');
|
||||
fetch(cfg.api(''), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(read.payload),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
setAddError((res.body && res.body.detail) || ('Could not add it (HTTP ' + res.status + ')'));
|
||||
return;
|
||||
}
|
||||
var input = el(p + '-add-name');
|
||||
if (input) input.value = '';
|
||||
say(cfg.addedMessage(res.body), false);
|
||||
return load(true);
|
||||
})
|
||||
.catch(function (err) { setAddError('Could not reach the server — ' + ((err && err.message) || 'offline')); });
|
||||
}
|
||||
|
||||
function patch(id, patchBody, describe) {
|
||||
return fetch(cfg.api('/' + encodeURIComponent(id)), {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(patchBody),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
say('⚠ ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
||||
return load(true);
|
||||
}
|
||||
say(describe(res.body), false);
|
||||
return load(true);
|
||||
})
|
||||
.catch(function (err) {
|
||||
say('⚠ Could not reach the server — ' + esc((err && err.message) || 'offline'), true);
|
||||
});
|
||||
}
|
||||
|
||||
function wire() {
|
||||
var paste = el(p + '-paste');
|
||||
var file = el(p + '-file');
|
||||
var btn = function (suffix) { return el(p + suffix); };
|
||||
if (btn('-file-btn')) btn('-file-btn').addEventListener('click', function () { if (file) file.click(); });
|
||||
if (file) file.addEventListener('change', function (ev) {
|
||||
var f = ev.target.files && ev.target.files[0];
|
||||
if (!f) return;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
// One parser, on the server. Reading the file here and posting its text
|
||||
// is what stops "what does a blank column mean" having two answers.
|
||||
if (paste) paste.value = String(reader.result || '');
|
||||
say('Read <strong>' + esc(f.name) + '</strong>. Check it, then import.', false);
|
||||
};
|
||||
reader.onerror = function () { say('⚠ Could not read that file.', true); };
|
||||
reader.readAsText(f);
|
||||
ev.target.value = '';
|
||||
});
|
||||
if (btn('-check-btn')) btn('-check-btn').addEventListener('click', function () { importText(true); });
|
||||
if (btn('-import-btn')) btn('-import-btn').addEventListener('click', function () { importText(false); });
|
||||
if (btn('-sample-btn')) btn('-sample-btn').addEventListener('click', function () {
|
||||
if (paste) paste.value = cfg.sample;
|
||||
say('Sample values loaded into the box — obviously fake, and safe to import '
|
||||
+ 'on a throwaway project.', false);
|
||||
});
|
||||
if (btn('-add-btn')) btn('-add-btn').addEventListener('click', add);
|
||||
}
|
||||
|
||||
return { state: state, say: say, setAddError: setAddError, load: load,
|
||||
importText: importText, add: add, patch: patch, wire: wire };
|
||||
};
|
||||
})();
|
||||
157
html/wp-sections.js
Normal file
@@ -0,0 +1,157 @@
|
||||
/* Work package section toggles — CR-006 / T5.5.
|
||||
---------------------------------------------------------------------------
|
||||
The structural fix behind most of the removal requests in the plan. Rather
|
||||
than deleting fields globally, each project turns on only the sections it
|
||||
uses: it is what lets Micron drop Kitting and Assets while another project
|
||||
keeps them, and it is why CR-002 and CR-016 are toggles rather than deletions.
|
||||
|
||||
THE ONE RULE: toggling a section OFF never deletes anything. It stops the
|
||||
section rendering — in the creation form, in the detail view and in the PDF
|
||||
export — and that is all. Whatever was captured stays on the package, and
|
||||
toggling back on shows it again, intact. Everything in this file is about
|
||||
what is DISPLAYED; nothing here writes to a package.
|
||||
|
||||
This list lives in its own file because three surfaces read it and they must
|
||||
not drift: the SOP wizard renders the toggles, the creator applies them to
|
||||
its form and its printed output, and a work package's detail view honours
|
||||
them. A fourth copy is how "Assets is off" and "Assets is off, except in the
|
||||
export" happen.
|
||||
|
||||
IDS ARE PERMANENT. They are written into every SOP that has ever been saved,
|
||||
so renaming one silently turns that section back on for every existing
|
||||
project. Change `label` freely; never change `id`.
|
||||
*/
|
||||
(function (window) {
|
||||
'use strict';
|
||||
|
||||
var LIST = [
|
||||
{ id: 'general', label: 'General Information',
|
||||
note: 'Holds the WP number, subject and type. Turning this off leaves nothing to identify a package by — it is listed for completeness, not as a suggestion.' },
|
||||
{ id: 'location', label: 'Location',
|
||||
note: 'Where the work happens. CR-004 gives this its own structured fields; today it is the location field inside General Information.' },
|
||||
{ id: 'scope', label: 'Scope of Work',
|
||||
note: 'The ordered steps the crew performs, and the labour estimate.' },
|
||||
{ id: 'assets', label: 'Assets',
|
||||
note: 'Asset IDs picked read-only from the Micron DB (D11), with manual entry for anything not listed. Off for Micron EUV — the customer’s own database stays the source of truth; this section only references it (CR-016).' },
|
||||
{ id: 'materials', label: 'Materials',
|
||||
note: 'The bill of materials that feeds kitting.' },
|
||||
{ id: 'kitting', label: 'Kitting',
|
||||
note: 'Kitting status, warehouse owner and MIMO. Off for Micron EUV, which is not kitting today (CR-009).' },
|
||||
{ id: 'drawings', label: 'Drawings and Attachments',
|
||||
note: 'Drawing references and attachment links.' },
|
||||
{ id: 'constraints', label: 'Constraints',
|
||||
note: 'Release-readiness items. A package cannot be issued while one is open.' },
|
||||
{ id: 'qaqc', label: 'QA/QC',
|
||||
note: 'Quality requirements, photo standard and hold points.' },
|
||||
{ id: 'closeout', label: 'Closeout',
|
||||
note: 'Actual hours, installed quantity, redlines and lessons learned. Actual Hours stays here — its removal was proposed and rejected (CR-017).' },
|
||||
];
|
||||
|
||||
var IDS = LIST.map(function (s) { return s.id; });
|
||||
|
||||
/* Individual fields that can be switched off inside a section — CR-002.
|
||||
|
||||
A second, narrower list rather than more sections, because a section is a
|
||||
block of the document and these are two rows inside one. BL-000b asks
|
||||
whether General Information wants per-field toggles generally; this is not
|
||||
that. It is the two fields CR-002 names, expressed as toggles because
|
||||
CLAUDE.md says removals are expressed through toggles and the data is
|
||||
retained — the columns and the model stay exactly as they are.
|
||||
|
||||
Same rule as sections: an id is permanent, absent means ON. */
|
||||
var FIELDS = [
|
||||
{ id: 'costCode', section: 'general', label: 'Acumatica cost code',
|
||||
note: 'Effectively constant on a job, so it is noise on a field work package (CR-002). The value stays on every package that has one.' },
|
||||
{ id: 'acumaticaTask', section: 'general', label: 'Acumatica task',
|
||||
note: 'A PM concern rather than a field one (CR-002). The cost visibility the team actually wants is by building and floor — CR-004 and CR-018.' },
|
||||
];
|
||||
|
||||
var FIELD_IDS = FIELDS.map(function (f) { return f.id; });
|
||||
|
||||
function fieldDefaults() {
|
||||
var out = {};
|
||||
FIELD_IDS.forEach(function (id) { out[id] = true; });
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeFields(stored) {
|
||||
var out = fieldDefaults();
|
||||
if (stored && typeof stored === 'object') {
|
||||
FIELD_IDS.forEach(function (id) {
|
||||
if (Object.prototype.hasOwnProperty.call(stored, id)) out[id] = stored[id] !== false;
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fieldsFor(sectionId) {
|
||||
return FIELDS.filter(function (f) { return f.section === sectionId; });
|
||||
}
|
||||
|
||||
function defaults() {
|
||||
// A new SOP has everything on. A project opts OUT of what it does not use;
|
||||
// it does not have to discover and opt in to what it does.
|
||||
var out = {};
|
||||
IDS.forEach(function (id) { out[id] = true; });
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Fill in anything a stored SOP does not mention.
|
||||
|
||||
This is what makes adding an eleventh section safe: every SOP saved before
|
||||
it existed says nothing about it, and "says nothing" has to mean ON. The
|
||||
alternative — absent meaning off — would switch a brand-new section off for
|
||||
every project in the estate the moment it shipped. */
|
||||
function normalize(stored) {
|
||||
var out = defaults();
|
||||
if (stored && typeof stored === 'object') {
|
||||
IDS.forEach(function (id) {
|
||||
if (Object.prototype.hasOwnProperty.call(stored, id)) out[id] = stored[id] !== false;
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isOn(stored, id) {
|
||||
if (IDS.indexOf(id) < 0) return true; // not a section we govern
|
||||
return normalize(stored)[id];
|
||||
}
|
||||
|
||||
function offList(stored) {
|
||||
var s = normalize(stored);
|
||||
return LIST.filter(function (x) { return !s[x.id]; }).map(function (x) { return x.label; });
|
||||
}
|
||||
|
||||
/* A field is on only if its own toggle is on AND the section holding it is.
|
||||
Asked as one question so no caller has to remember to ask both — a field
|
||||
showing inside a hidden section is not a state anyone wants to reason
|
||||
about. */
|
||||
function fieldOn(sections, fields, id) {
|
||||
var f = FIELDS.filter(function (x) { return x.id === id; })[0];
|
||||
if (!f) return true;
|
||||
if (!isOn(sections, f.section)) return false;
|
||||
return normalizeFields(fields)[id];
|
||||
}
|
||||
|
||||
function offFieldList(fields) {
|
||||
var s = normalizeFields(fields);
|
||||
return FIELDS.filter(function (x) { return !s[x.id]; }).map(function (x) { return x.label; });
|
||||
}
|
||||
|
||||
window.WPSections = {
|
||||
LIST: LIST,
|
||||
IDS: IDS,
|
||||
defaults: defaults,
|
||||
normalize: normalize,
|
||||
isOn: isOn,
|
||||
offList: offList,
|
||||
|
||||
FIELDS: FIELDS,
|
||||
FIELD_IDS: FIELD_IDS,
|
||||
fieldDefaults: fieldDefaults,
|
||||
normalizeFields: normalizeFields,
|
||||
fieldsFor: fieldsFor,
|
||||
fieldOn: fieldOn,
|
||||
offFieldList: offFieldList,
|
||||
};
|
||||
})(window);
|
||||
115
html/wp-sidenav.css
Normal file
@@ -0,0 +1,115 @@
|
||||
/* Global app navigation drawer (see wp-sidenav.js).
|
||||
|
||||
An off-canvas panel rather than a pinned rail, at every width: the field view is a
|
||||
centred 760px column read on a phone or a tablet in a glove, and a permanent
|
||||
sidebar would either squeeze that column or hide on the one device that matters.
|
||||
Overlay behaves identically everywhere, which is also one less layout to test.
|
||||
|
||||
Colours come from the dark app bar it hangs off, so the drawer reads as an
|
||||
extension of the bar. Until T3.2 that intent was written as hardcoded hex —
|
||||
30 of them — which made the drawer only accidentally match the bar. It now
|
||||
reads the same --wp-appbar-* tokens the bar does, so the stated intent is
|
||||
actually true and flipping --wp-appbar-bg takes the drawer with it. */
|
||||
|
||||
.wp-navbtn{
|
||||
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 40px; height: 40px; margin-right: 4px; padding: 0;
|
||||
background: none; border: none; border-radius: 0; cursor: pointer;
|
||||
color: var(--cds-text-inverse); font-family: inherit; line-height: 1;
|
||||
}
|
||||
.wp-navbtn:hover{ background: var(--wp-appbar-hover); }
|
||||
.wp-navbtn:focus-visible{ outline: 2px solid var(--wp-appbar-fg); outline-offset: -2px; }
|
||||
/* A light bar (the SOP suite / creator headers) needs the opposite ink. */
|
||||
.wp-navbtn[data-bar="light"]{ color: var(--cds-text-primary); }
|
||||
.wp-navbtn[data-bar="light"]:hover{ background: var(--cds-layer-hover); }
|
||||
/* ...and the opposite ring. S12: the rule above is white, which is correct on the
|
||||
near-black bar and invisible on the creator's white header — the same button,
|
||||
the same class, two hosts. Measured 1.00:1 before this line existed. */
|
||||
.wp-navbtn[data-bar="light"]:focus-visible{ outline-color: var(--cds-focus); }
|
||||
|
||||
.wp-navscrim{
|
||||
position: fixed; inset: 0; z-index: 10010;
|
||||
background: var(--wp-scrim-drawer);
|
||||
opacity: 0; transition: opacity .18s ease;
|
||||
}
|
||||
.wp-navscrim.is-open{ opacity: 1; }
|
||||
.wp-navscrim[hidden]{ display: none; }
|
||||
|
||||
.wp-sidenav{
|
||||
position: fixed; top: 0; left: 0; bottom: 0; z-index: 10011;
|
||||
width: min(284px, 84vw);
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--wp-appbar-bg); color: var(--cds-text-inverse);
|
||||
font-family: var(--wp-font-sans-2);
|
||||
transform: translateX(-100%); transition: transform .2s ease;
|
||||
box-shadow: var(--wp-shadow-drawer);
|
||||
overflow: hidden;
|
||||
}
|
||||
.wp-sidenav.is-open{ transform: translateX(0); }
|
||||
/* Respect a reduced-motion preference: the drawer still opens, it just doesn't slide. */
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
.wp-sidenav, .wp-navscrim{ transition: none; }
|
||||
}
|
||||
|
||||
.wp-sidenav-head{
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px 14px; border-bottom: 1px solid var(--cds-inverse-02); flex: 0 0 auto;
|
||||
}
|
||||
.wp-sidenav-head .wp-logo-chip{ flex: 0 0 auto; }
|
||||
.wp-sidenav-title{ font-size: 13px; font-weight: 600; line-height: 1.25; }
|
||||
.wp-sidenav-title span{ display: block; font-size: 11px; font-weight: 400; color: var(--cds-text-placeholder); }
|
||||
|
||||
/* The active project, in full (B2). Below 1024px the app bar shows the project
|
||||
number alone, so this is where the whole name has to be readable — it wraps on
|
||||
as many lines as it needs and is never truncated. */
|
||||
.wp-sidenav-proj{
|
||||
padding: 12px 14px; border-bottom: 1px solid var(--cds-inverse-02); flex: 0 0 auto;
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.wp-sidenav-proj[hidden]{ display: none; }
|
||||
.wp-sidenav-proj-k{
|
||||
font-size: 10px; font-weight: 600; letter-spacing: .08em;
|
||||
text-transform: uppercase; color: var(--cds-text-placeholder);
|
||||
}
|
||||
.wp-sidenav-proj strong{
|
||||
font-size: 13px; font-weight: 600; line-height: 1.3; color: var(--cds-text-inverse);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.wp-sidenav-proj-n{ font-size: 11px; color: var(--wp-appbar-fg-dim); }
|
||||
.wp-sidenav-close{
|
||||
margin-left: auto; width: 32px; height: 32px; padding: 0; flex: 0 0 auto;
|
||||
background: none; border: none; border-radius: 0; color: var(--wp-appbar-fg-dim);
|
||||
font-size: 18px; line-height: 1; cursor: pointer; font-family: inherit;
|
||||
}
|
||||
.wp-sidenav-close:hover{ background: var(--wp-appbar-hover); color: var(--wp-appbar-fg); }
|
||||
|
||||
.wp-sidenav-body{ flex: 1 1 auto; overflow-y: auto; padding: 6px 0 18px; }
|
||||
.wp-sidenav-sect{
|
||||
padding: 14px 16px 4px; font-size: 11px; font-weight: 600;
|
||||
letter-spacing: .06em; text-transform: uppercase; color: var(--cds-ui-04);
|
||||
}
|
||||
.wp-sidenav-link{
|
||||
display: flex; align-items: center; gap: 12px; width: 100%;
|
||||
/* 44px minimum: this is tapped with a work glove on. */
|
||||
min-height: 44px; padding: 10px 16px;
|
||||
background: none; border: none; border-left: 3px solid transparent; border-radius: 0;
|
||||
color: var(--cds-text-inverse); font: inherit; font-size: 14px; text-align: left; text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wp-sidenav-link:hover{ background: var(--wp-appbar-hover); }
|
||||
.wp-sidenav-link:focus-visible{ outline: 2px solid var(--wp-appbar-fg); outline-offset: -2px; }
|
||||
.wp-sidenav-link.is-current{ background: var(--wp-appbar-layer); border-left-color: var(--cds-interactive-01); font-weight: 600; }
|
||||
.wp-sidenav-ico{
|
||||
flex: 0 0 20px; width: 20px; text-align: center; font-size: 15px; color: var(--wp-appbar-fg-dim);
|
||||
}
|
||||
.wp-sidenav-link.is-current .wp-sidenav-ico{ color: var(--cds-link-inverse); }
|
||||
.wp-sidenav-label{ flex: 1 1 auto; min-width: 0; }
|
||||
.wp-sidenav-label small{ display: block; font-size: 11.5px; font-weight: 400; color: var(--cds-text-placeholder); }
|
||||
|
||||
.wp-sidenav-foot{
|
||||
flex: 0 0 auto; border-top: 1px solid var(--cds-inverse-02); padding: 8px 0;
|
||||
}
|
||||
.wp-sidenav-who{
|
||||
padding: 6px 16px 8px; font-size: 12px; color: var(--cds-text-placeholder);
|
||||
}
|
||||
.wp-sidenav-who strong{ display: block; color: var(--cds-text-inverse); font-size: 13px; font-weight: 600; }
|
||||
278
html/wp-sidenav.js
Normal file
@@ -0,0 +1,278 @@
|
||||
/* Global app navigation drawer for the Work Package Suite.
|
||||
|
||||
The suite grew page by page and the only way between them was the browser's back
|
||||
button or the home page. This is the one place that lists everywhere you can go —
|
||||
a ☰ button in the app bar opening an off-canvas drawer.
|
||||
|
||||
ROLE GATING: the drawer only offers what the signed-in account can actually reach.
|
||||
The Admin Console is admins-only, so it appears for admins only; the User Directory
|
||||
is readable by everyone (that's the point of a directory), so it always appears.
|
||||
Every destination re-checks server-side — this is navigation, not a permission.
|
||||
|
||||
PROJECT CONTEXT: links that open a project-scoped page carry the active ?project=
|
||||
so the drawer doesn't silently drop the job you were looking at.
|
||||
|
||||
Add it to a page with:
|
||||
<link rel="stylesheet" href="wp-sidenav.css">
|
||||
<script src="wp-sidenav.js"></script>
|
||||
after auth-guard.js. It mounts itself into whichever top bar the page has.
|
||||
It used to skip iframes, because the embedded WP creator lived inside a page
|
||||
that already had a drawer; B7/T7.1 dissolved that frame. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ── the map ────────────────────────────────────────────────────────────────
|
||||
// `match` is what marks a link current; `project` means "carry ?project=".
|
||||
// `show` is an optional gate, evaluated once the user is known.
|
||||
var LINKS = [
|
||||
{ section: 'Work' },
|
||||
{ href: 'index.html', match: /(^|\/)(index\.html)?$/, icon: '⌂', label: 'Home',
|
||||
sub: 'Projects & what\'s next' },
|
||||
{ href: 'work-package-suite.html?tab=sop', match: /work-package-suite\.html/, icon: '⚙',
|
||||
label: 'SOP Configuration', sub: 'The project baseline', project: true, tab: 'sop' },
|
||||
// B7/T7.1: both of these were tabs of the suite page, opened by swapping an
|
||||
// iframe, so `match` had to be null - one URL could not tell them apart. The
|
||||
// creator is its own document now, so they have real addresses and the drawer
|
||||
// can mark which one you are on.
|
||||
{ href: 'wp-creation-index.html', match: /wp-creation-index\.html/, icon: '▤',
|
||||
label: 'Work Package Creator', sub: 'Build and edit IWPs', project: true, tab: 'wp' },
|
||||
{ href: 'wp-creation-index.html?view=dashboard', match: null, icon: '▦',
|
||||
label: 'Dashboard', sub: 'Status & release gates', project: true, tab: 'dashboard' },
|
||||
{ href: 'field.html', match: /(^|\/)field\.html$/, icon: '⚒', label: 'Field View',
|
||||
sub: 'Update packages on site', project: true },
|
||||
{ section: 'People' },
|
||||
{ href: 'users.html', match: /(^|\/)users\.html$/, icon: '☺', label: 'User Directory',
|
||||
sub: 'Who\'s on the project' },
|
||||
{ href: 'admin.html', match: /(^|\/)admin\.html$/, icon: '⚙', label: 'Admin Console',
|
||||
sub: 'Settings & diagnostics',
|
||||
show: function () { return typeof window.wpIsAdmin === 'function' && window.wpIsAdmin(); } },
|
||||
// Account actions, inherited from the flat user menu that used to sit in the app
|
||||
// bar (T2.2). Everything else that menu offered — Admin, Users, Sign out — the
|
||||
// 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.
|
||||
{ section: 'Account' },
|
||||
{ action: 'wpPreferences', icon: '◷', label: 'Language & time',
|
||||
sub: 'Dates, numbers and time zone' },
|
||||
{ action: 'wpChangePassword', icon: '⚿', label: 'Password', sub: 'Change your password' },
|
||||
];
|
||||
|
||||
function esc(v) {
|
||||
return String(v == null ? '' : v)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
function isDark(node) {
|
||||
try {
|
||||
var m = (getComputedStyle(node).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (!m) return true;
|
||||
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
|
||||
} catch (e) { return true; }
|
||||
}
|
||||
|
||||
function activeProjectId() {
|
||||
try {
|
||||
var q = new URLSearchParams(location.search).get('project');
|
||||
if (q) return q;
|
||||
return (window.ProjectData && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
|
||||
// The suite page reads ?tab= and ?project=; keeping the current project on the link
|
||||
// is the difference between "open the dashboard" and "open the dashboard, then pick
|
||||
// the job again".
|
||||
function hrefFor(item) {
|
||||
if (!item.project) return item.href;
|
||||
var pid = activeProjectId();
|
||||
if (!pid) return item.href;
|
||||
var sep = item.href.indexOf('?') >= 0 ? '&' : '?';
|
||||
return item.href + sep + 'project=' + encodeURIComponent(pid);
|
||||
}
|
||||
|
||||
// Current-page marking. The three suite tabs share one file, so they're told apart
|
||||
// by ?tab= (defaulting to sop, which is what work-package-suite.html itself does).
|
||||
function isCurrent(item) {
|
||||
var path = location.pathname;
|
||||
if (item.tab) {
|
||||
if (!/work-package-suite\.html$/.test(path)) return false;
|
||||
var tab = '';
|
||||
try { tab = new URLSearchParams(location.search).get('tab') || 'sop'; } catch (e) { tab = 'sop'; }
|
||||
return tab === item.tab;
|
||||
}
|
||||
return !!(item.match && item.match.test(path));
|
||||
}
|
||||
|
||||
// ── build ──────────────────────────────────────────────────────────────────
|
||||
var drawer, scrim, btn, lastFocus = null;
|
||||
|
||||
function buildDrawer(user) {
|
||||
scrim = document.createElement('div');
|
||||
scrim.className = 'wp-navscrim';
|
||||
scrim.hidden = true;
|
||||
scrim.addEventListener('click', close);
|
||||
|
||||
drawer = document.createElement('nav');
|
||||
drawer.className = 'wp-sidenav';
|
||||
drawer.id = 'wp-sidenav';
|
||||
drawer.setAttribute('aria-label', 'Suite navigation');
|
||||
drawer.setAttribute('aria-hidden', 'true');
|
||||
|
||||
var rows = '';
|
||||
LINKS.forEach(function (item) {
|
||||
if (item.section) { rows += '<div class="wp-sidenav-sect">' + esc(item.section) + '</div>'; return; }
|
||||
if (item.show && !item.show()) return;
|
||||
var inner =
|
||||
'<span class="wp-sidenav-ico" aria-hidden="true">' + esc(item.icon) + '</span>' +
|
||||
'<span class="wp-sidenav-label">' + esc(item.label) +
|
||||
(item.sub ? '<small>' + esc(item.sub) + '</small>' : '') + '</span>';
|
||||
// An action opens a dialog on the current page rather than going anywhere, so
|
||||
// it is a button. Never a <div> with a click handler — CLAUDE.md, and the
|
||||
// drawer is keyboard-navigable precisely because everything in it is focusable.
|
||||
if (item.action) {
|
||||
rows += '<button type="button" class="wp-sidenav-link" data-action="' +
|
||||
esc(item.action) + '">' + inner + '</button>';
|
||||
return;
|
||||
}
|
||||
rows += '<a class="wp-sidenav-link' + (isCurrent(item) ? ' is-current' : '') + '" href="' +
|
||||
esc(hrefFor(item)) + '"' + (isCurrent(item) ? ' aria-current="page"' : '') + '>' +
|
||||
inner + '</a>';
|
||||
});
|
||||
|
||||
var who = user ? (user.full_name || user.username || '') : '';
|
||||
drawer.innerHTML =
|
||||
'<div class="wp-sidenav-head">' +
|
||||
'<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>' +
|
||||
'<span class="wp-sidenav-title">Work Package Suite<span>Prime Controls</span></span>' +
|
||||
'<button type="button" class="wp-sidenav-close" title="Close" aria-label="Close navigation">✕</button>' +
|
||||
'</div>' +
|
||||
// The active project in full, wrapped rather than truncated. Below 1024px the
|
||||
// app bar shows the project NUMBER alone (B2), so this is where the whole name
|
||||
// is always readable. It is also the only place it is guaranteed to fit.
|
||||
'<div class="wp-sidenav-proj" id="wp-sidenav-proj"></div>' +
|
||||
'<div class="wp-sidenav-body">' + rows + '</div>' +
|
||||
'<div class="wp-sidenav-foot">' +
|
||||
(who ? '<div class="wp-sidenav-who">Signed in as<strong>' + esc(who) + '</strong></div>' : '') +
|
||||
'<button type="button" class="wp-sidenav-link" id="wp-sidenav-signout">' +
|
||||
'<span class="wp-sidenav-ico" aria-hidden="true">⏻</span>' +
|
||||
'<span class="wp-sidenav-label">Sign out</span></button>' +
|
||||
'</div>';
|
||||
|
||||
drawer.querySelector('.wp-sidenav-close').addEventListener('click', close);
|
||||
drawer.querySelector('#wp-sidenav-signout').addEventListener('click', function () {
|
||||
if (typeof window.wpLogout === 'function') window.wpLogout();
|
||||
});
|
||||
// Close first, then act: these open a dialog, and leaving the drawer over it
|
||||
// would put a scrim between the user and the thing they just asked for. The
|
||||
// handler is looked up at click time because wp-format.js may still be parsing
|
||||
// when the drawer is built — the flat menu had the same note.
|
||||
Array.prototype.forEach.call(drawer.querySelectorAll('[data-action]'), function (el) {
|
||||
el.addEventListener('click', function () {
|
||||
var fn = window[el.getAttribute('data-action')];
|
||||
close();
|
||||
if (typeof fn === 'function') fn();
|
||||
});
|
||||
});
|
||||
document.body.appendChild(scrim);
|
||||
document.body.appendChild(drawer);
|
||||
paintProject();
|
||||
// Selecting a project on the launcher does not reload, so subscribe rather than
|
||||
// paint once — the same single source T1.1 established.
|
||||
try {
|
||||
if (window.ProjectData && ProjectData.onActiveChange) {
|
||||
ProjectData.onActiveChange(paintProject);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Full name, never abbreviated. Absent rather than empty when no project is active,
|
||||
// so the drawer does not carry a stray blank band.
|
||||
function paintProject() {
|
||||
var box = document.getElementById('wp-sidenav-proj');
|
||||
if (!box) return;
|
||||
var p = null;
|
||||
try { p = (window.ProjectData && ProjectData.getActive && ProjectData.getActive()) || null; } catch (e) {}
|
||||
if (!p || !(p.name || p.number)) { box.innerHTML = ''; box.hidden = true; return; }
|
||||
box.hidden = false;
|
||||
box.innerHTML = '<span class="wp-sidenav-proj-k">Project</span>' +
|
||||
'<strong>' + esc(p.name || '(unnamed)') + '</strong>' +
|
||||
(p.number ? '<span class="wp-sidenav-proj-n">' + esc(p.number) + '</span>' : '');
|
||||
}
|
||||
|
||||
function focusables() {
|
||||
return drawer ? drawer.querySelectorAll('a[href], button:not([disabled])') : [];
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!drawer) return;
|
||||
lastFocus = document.activeElement;
|
||||
scrim.hidden = false;
|
||||
// Two frames: the element has to be laid out un-transitioned before the class
|
||||
// that animates it lands, or it simply appears.
|
||||
requestAnimationFrame(function () {
|
||||
scrim.classList.add('is-open');
|
||||
drawer.classList.add('is-open');
|
||||
});
|
||||
drawer.setAttribute('aria-hidden', 'false');
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
var f = focusables();
|
||||
if (f.length) f[0].focus();
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!drawer) return;
|
||||
drawer.classList.remove('is-open');
|
||||
scrim.classList.remove('is-open');
|
||||
drawer.setAttribute('aria-hidden', 'true');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
// Keep the scrim in the tree until the slide-out finishes, or the panel snaps.
|
||||
setTimeout(function () { if (!drawer.classList.contains('is-open')) scrim.hidden = true; }, 220);
|
||||
if (lastFocus && lastFocus.focus) lastFocus.focus();
|
||||
}
|
||||
|
||||
function isOpen() { return !!(drawer && drawer.classList.contains('is-open')); }
|
||||
|
||||
// Escape closes; Tab cycles inside the drawer while it's open, so focus can't walk
|
||||
// off into the page behind the scrim.
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (!isOpen()) return;
|
||||
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
|
||||
if (e.key !== 'Tab') return;
|
||||
var f = focusables();
|
||||
if (!f.length) return;
|
||||
var first = f[0], last = f[f.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
});
|
||||
|
||||
// ── mount ──────────────────────────────────────────────────────────────────
|
||||
// The button goes at the START of the bar, before the brand: that is where a menu
|
||||
// affordance is looked for, and it keeps clear of the project switcher and search
|
||||
// that wp-chrome.js inserts into the middle of the same bar.
|
||||
function mount() {
|
||||
if (document.getElementById('wp-sidenav')) return;
|
||||
var host = document.querySelector('.wp-appbar') || document.querySelector('.header');
|
||||
if (!host) return;
|
||||
|
||||
btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'wp-navbtn';
|
||||
btn.id = 'wp-navbtn';
|
||||
btn.title = 'Menu';
|
||||
btn.setAttribute('aria-label', 'Open navigation');
|
||||
btn.setAttribute('aria-haspopup', 'true');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
btn.setAttribute('aria-controls', 'wp-sidenav');
|
||||
if (!isDark(host)) btn.setAttribute('data-bar', 'light');
|
||||
btn.innerHTML = '<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true">' +
|
||||
'<path d="M3 5.5h14M3 10h14M3 14.5h14" fill="none" stroke="currentColor" ' +
|
||||
'stroke-width="1.6" stroke-linecap="round"/></svg>';
|
||||
btn.addEventListener('click', function () { if (isOpen()) close(); else open(); });
|
||||
|
||||
host.insertBefore(btn, host.firstChild);
|
||||
buildDrawer(window.WP_USER);
|
||||
}
|
||||
|
||||
// Wait for the auth guard: the gated links depend on the signed-in role, and an
|
||||
// unauthenticated page is about to redirect anyway.
|
||||
if (window.WP_USER) mount();
|
||||
else document.addEventListener('wp-auth-ready', mount);
|
||||
})();
|
||||
126
html/wp-url.js
Normal file
@@ -0,0 +1,126 @@
|
||||
/* Addressable state — S3 / T4.2.
|
||||
---------------------------------------------------------------------------
|
||||
Before this file there was no pushState anywhere in the suite. Every page read
|
||||
its query string once at boot and never wrote one again, so:
|
||||
|
||||
• you could not send anyone a link to WP07 — the URL said the same thing
|
||||
whatever you were looking at;
|
||||
• a refresh dropped you back at the default view;
|
||||
• Back left the app entirely, because the app had never added a history entry.
|
||||
|
||||
CR-011 and CR-014 both promise an email containing a direct link to a work
|
||||
package (X1). Those emails cannot exist until a work package has an address,
|
||||
which is what this provides.
|
||||
|
||||
WHAT IT IS NOT: a router. Nothing here intercepts navigation or renders
|
||||
anything. It is the query string, treated as state that can be read, merged,
|
||||
written and subscribed to. Pages keep their own rendering.
|
||||
|
||||
Query parameters, not a hash: the server serves these paths already, so a hash
|
||||
would be a workaround for a problem this app does not have, and hashes are not
|
||||
sent to the server — which matters the day a link needs to be resolved before
|
||||
the page boots.
|
||||
|
||||
Nothing secret goes in the URL. It is copied into emails, chat and tickets.
|
||||
*/
|
||||
(function (window, document) {
|
||||
'use strict';
|
||||
|
||||
var listeners = [];
|
||||
var LAST = serialize(current());
|
||||
|
||||
function current() {
|
||||
var out = {};
|
||||
try {
|
||||
new URLSearchParams(window.location.search).forEach(function (v, k) { out[k] = v; });
|
||||
} catch (e) {}
|
||||
return out;
|
||||
}
|
||||
|
||||
function serialize(state) {
|
||||
var keys = Object.keys(state).filter(function (k) {
|
||||
return state[k] !== '' && state[k] != null && state[k] !== false;
|
||||
}).sort();
|
||||
var sp = new URLSearchParams();
|
||||
keys.forEach(function (k) { sp.set(k, String(state[k])); });
|
||||
return sp.toString();
|
||||
}
|
||||
|
||||
// Merge a patch over the current state. Undefined/null/'' removes a key, so a
|
||||
// caller can clear `wp` without having to know what else is in the URL — the
|
||||
// usual reason ad-hoc URL building loses the active project.
|
||||
function merge(patch) {
|
||||
var next = current();
|
||||
Object.keys(patch || {}).forEach(function (k) {
|
||||
var v = patch[k];
|
||||
if (v === undefined || v === null || v === '' || v === false) delete next[k];
|
||||
else next[k] = v;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
function href(patch) {
|
||||
var qs = serialize(merge(patch));
|
||||
return window.location.pathname + (qs ? '?' + qs : '') + window.location.hash;
|
||||
}
|
||||
|
||||
function apply(patch, opts) {
|
||||
opts = opts || {};
|
||||
var next = merge(patch);
|
||||
var qs = serialize(next);
|
||||
if (qs === LAST && !opts.force) return false; // nothing to record
|
||||
var url = window.location.pathname + (qs ? '?' + qs : '') + window.location.hash;
|
||||
try {
|
||||
if (opts.replace) window.history.replaceState({ wpurl: qs }, '', url);
|
||||
else window.history.pushState({ wpurl: qs }, '', url);
|
||||
} catch (e) {
|
||||
return false; // file:// and the like
|
||||
}
|
||||
LAST = qs;
|
||||
return true;
|
||||
}
|
||||
|
||||
function notify(state, viaPop) {
|
||||
listeners.forEach(function (fn) {
|
||||
try { fn(state, viaPop); } catch (e) { /* one bad subscriber must not stop the rest */ }
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', function () {
|
||||
LAST = serialize(current());
|
||||
notify(current(), true);
|
||||
});
|
||||
|
||||
window.WPUrl = {
|
||||
// Read one parameter, or everything.
|
||||
get: function (name) { var s = current(); return name == null ? s : (s[name] || ''); },
|
||||
all: current,
|
||||
|
||||
/* Record a state change in history. Merges over what is already there.
|
||||
WPUrl.push({ wp: id }) -> new history entry, Back returns
|
||||
WPUrl.push({ wp: '' }) -> clears it
|
||||
WPUrl.replace({ view: 'form' }) -> corrects the URL without a new entry
|
||||
replace() is for normalising on load or for a change the user did not ask
|
||||
for; push() is for one they did, because Back should undo exactly the
|
||||
things they chose to do. */
|
||||
push: function (patch) { return apply(patch, { replace: false }); },
|
||||
replace: function (patch) { return apply(patch, { replace: true }); },
|
||||
|
||||
// A URL string for the same merge, without navigating. For hrefs and for the
|
||||
// links that go into CR-011 / CR-014 emails.
|
||||
href: href,
|
||||
absolute: function (patch) {
|
||||
return window.location.origin + href(patch);
|
||||
},
|
||||
|
||||
/* Subscribe to state changes. Called on Back/Forward with viaPop === true.
|
||||
Returns an unsubscribe function. */
|
||||
onChange: function (fn) {
|
||||
listeners.push(fn);
|
||||
return function () {
|
||||
var i = listeners.indexOf(fn);
|
||||
if (i >= 0) listeners.splice(i, 1);
|
||||
};
|
||||
},
|
||||
};
|
||||
})(window, document);
|
||||
58
html/wp-usage.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/* Usage analytics core — the ONE implementation (D5 / T7.10).
|
||||
|
||||
This existed three times: the creator's copy, the wizard's copy (which had no
|
||||
caller — the button lived on the creator), and the admin console's own reader.
|
||||
Once the creator stopped being an iframe (B7/T7.1) the first two sat in one
|
||||
document as five colliding globals; an unreferenced duplicate is exactly what
|
||||
produced D5. One core now; the pages keep only a thin track() wrapper because
|
||||
page state (the creator's dev-mode pause) belongs to the page.
|
||||
|
||||
The storage KEYS are unchanged on purpose: everything recorded before this
|
||||
file existed is still readable through it. No field VALUES are ever stored —
|
||||
a field-edit event records the field id, nothing else.
|
||||
|
||||
Classic script, no modules: exposes window.WPUsage. */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
var SESSION = 's_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
|
||||
function load(key) {
|
||||
try { return JSON.parse(localStorage.getItem(key)) || { events: [] }; }
|
||||
catch (e) { return { events: [] }; }
|
||||
}
|
||||
|
||||
function save(key, data) {
|
||||
try { localStorage.setItem(key, JSON.stringify(data)); }
|
||||
catch (e) { /* storage unavailable — degrade silently */ }
|
||||
}
|
||||
|
||||
function track(key, event, detail) {
|
||||
try {
|
||||
var d = load(key);
|
||||
d.events.push({ ts: new Date().toISOString(), session: SESSION, event: event, detail: detail || null });
|
||||
if (d.events.length > 5000) d.events = d.events.slice(-5000);
|
||||
save(key, d);
|
||||
} catch (e) { /* never let telemetry break the tool it watches */ }
|
||||
}
|
||||
|
||||
function download(key, prefix) {
|
||||
var blob = new Blob([JSON.stringify(load(key), null, 2)], { type: 'application/json' });
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = (prefix || 'wp-usage') + '-' + new Date().toISOString().slice(0, 10) + '.json';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
|
||||
}
|
||||
|
||||
window.WPUsage = {
|
||||
load: load,
|
||||
save: save,
|
||||
track: track,
|
||||
download: download,
|
||||
// The pre-D5 keys, verbatim — continuity of the recorded data is a done-when.
|
||||
KEYS: { creator: 'wp_iwp_analytics_v1', wizard: 'wp_suite_analytics_v1' },
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,45 @@
|
||||
"""per-project material list (D6 / T8.6)
|
||||
|
||||
CR-013 was written to accept free text because Nate's spreadsheet and the
|
||||
master material workbook had not been supplied - and they still have not.
|
||||
The Aug 18 call was the same one made for locations at CR-005: build the
|
||||
upload path now. One row per line item a request can pick from: description,
|
||||
unit, an optional code. Deliberately NO inventory level, price or warehouse
|
||||
id - a project-scoped uploaded list is not the deferred parts catalog.
|
||||
|
||||
Additive only: a new table, no change to any existing one.
|
||||
|
||||
Revision ID: a1b8c6d4e2f9
|
||||
Revises: f3a9d2c1e8b7
|
||||
Create Date: 2026-08-19 15:40:00.000000
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'a1b8c6d4e2f9'
|
||||
down_revision = 'f3a9d2c1e8b7'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'material_items',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('project_id', sa.String(length=40), nullable=False),
|
||||
sa.Column('code', sa.String(length=80), nullable=False, server_default=''),
|
||||
sa.Column('description', sa.String(length=300), nullable=False, server_default=''),
|
||||
sa.Column('unit', sa.String(length=20), nullable=False, server_default=''),
|
||||
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
|
||||
sa.Column('sort', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('ix_material_items_project_id', 'material_items', ['project_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_material_items_project_id', table_name='material_items')
|
||||
op.drop_table('material_items')
|
||||
65
server/alembic/versions/e2a4c7d91b30_location_taxonomy.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""per-project Building / Floor / Sector taxonomy (CR-005)
|
||||
|
||||
The location taxonomy differs per project — on Micron, floors within B100 behave
|
||||
like separate buildings — so it is configured once per SOP instead of hard-coded.
|
||||
CR-018 rolls cost up by these values, which is why the table stores CODES
|
||||
(`code`, `path`) beside the display `name`: a rollup keyed on a label breaks the
|
||||
day somebody fixes a typo in it.
|
||||
|
||||
`active` rather than a delete. Deactivating hides a value from new work packages
|
||||
while every package already referencing it still resolves its label, which is the
|
||||
same rule CR-002 and CR-016 apply to fields.
|
||||
|
||||
Additive only: a new table, no change to any existing one, so nothing to backfill
|
||||
and nothing to migrate.
|
||||
|
||||
Revision ID: e2a4c7d91b30
|
||||
Revises: a7c31f9e5b02
|
||||
Create Date: 2026-08-16 09:41:02.118307
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'e2a4c7d91b30'
|
||||
down_revision = 'a7c31f9e5b02'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'location_nodes',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('project_id', sa.String(length=40), nullable=False),
|
||||
sa.Column('parent_id', sa.String(length=40), nullable=True),
|
||||
sa.Column('level', sa.String(length=20), nullable=False, server_default='building'),
|
||||
sa.Column('code', sa.String(length=60), nullable=False, server_default=''),
|
||||
sa.Column('path', sa.String(length=200), nullable=False, server_default=''),
|
||||
sa.Column('name', sa.String(length=200), nullable=False, server_default=''),
|
||||
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column('sort', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('created_by', sa.String(length=200), nullable=False, server_default=''),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
# One row per (project, path). This is what makes a re-import report a
|
||||
# duplicate instead of quietly creating a second B100/L2/1P.
|
||||
sa.UniqueConstraint('project_id', 'path', name='uq_location_path'),
|
||||
)
|
||||
op.create_index(op.f('ix_location_nodes_project_id'), 'location_nodes',
|
||||
['project_id'], unique=False)
|
||||
op.create_index(op.f('ix_location_nodes_parent_id'), 'location_nodes',
|
||||
['parent_id'], unique=False)
|
||||
op.create_index(op.f('ix_location_nodes_path'), 'location_nodes', ['path'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_location_nodes_path'), table_name='location_nodes')
|
||||
op.drop_index(op.f('ix_location_nodes_parent_id'), table_name='location_nodes')
|
||||
op.drop_index(op.f('ix_location_nodes_project_id'), table_name='location_nodes')
|
||||
op.drop_table('location_nodes')
|
||||
@@ -0,0 +1,51 @@
|
||||
"""drawing uploads stored with the package (CR-007 / D8)
|
||||
|
||||
The field wants the specific PDF attached, not a link to a Bluebeam session: a
|
||||
general foreman opens the package and sees exactly the sheet relevant to their
|
||||
scope, offline. The bytes live in this table - IN the same database as
|
||||
everything else, settled Aug 18: splitting files out was rejected because a
|
||||
backup that excludes the drawings is a backup you cannot restore from. The cost
|
||||
of that decision is bounded by the D8 numbers, enforced in the API: 5MB a file,
|
||||
PDFs and images only, 2GB per project with a warning at 80%.
|
||||
|
||||
Additive only: a new table, no change to any existing one, so nothing to
|
||||
backfill and nothing to migrate.
|
||||
|
||||
Revision ID: f3a9d2c1e8b7
|
||||
Revises: e2a4c7d91b30
|
||||
Create Date: 2026-08-19 11:20:00.000000
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f3a9d2c1e8b7'
|
||||
down_revision = 'e2a4c7d91b30'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'wp_files',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('wp_id', sa.String(length=40), nullable=False),
|
||||
sa.Column('project_id', sa.String(length=40), nullable=False),
|
||||
sa.Column('name', sa.String(length=300), nullable=False, server_default=''),
|
||||
sa.Column('mime', sa.String(length=100), nullable=False, server_default=''),
|
||||
sa.Column('size', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('description', sa.String(length=500), nullable=False, server_default=''),
|
||||
sa.Column('data', sa.LargeBinary(), nullable=False),
|
||||
sa.Column('uploaded_by', sa.String(length=120), nullable=False, server_default=''),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('ix_wp_files_wp_id', 'wp_files', ['wp_id'])
|
||||
op.create_index('ix_wp_files_project_id', 'wp_files', ['project_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_wp_files_project_id', table_name='wp_files')
|
||||
op.drop_index('ix_wp_files_wp_id', table_name='wp_files')
|
||||
op.drop_table('wp_files')
|
||||
1817
server/app.py
@@ -57,8 +57,26 @@ ASSET_QUERY = """
|
||||
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 = int(os.getenv("MICRON_ASSETS_CACHE_SECONDS", "300")) # 5 min
|
||||
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
|
||||
|
||||
|
||||
@@ -149,37 +167,66 @@ def _get_engine():
|
||||
# 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."""
|
||||
global _cache, _cached_at
|
||||
"""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)
|
||||
|
||||
engine = _get_engine()
|
||||
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)
|
||||
raise AssetSourceError(
|
||||
"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."
|
||||
) from 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
|
||||
|
||||
|
||||
|
||||
@@ -20,11 +20,21 @@ Permissions roles (`User.role`) — distinct from a person's job function on the
|
||||
project, which lives in `User.project_role` and grants nothing:
|
||||
• admin application administrator: user administration, app settings,
|
||||
and implicit access to every project.
|
||||
• project_super_user
|
||||
everything a project_admin may do, plus USER ADMINISTRATION
|
||||
scoped to the projects they hold the role on: they create and
|
||||
manage the accounts on their own jobs without an app admin
|
||||
having to do it for them. They cannot reach app settings, and
|
||||
they cannot create or alter an admin / super-user account.
|
||||
• project_admin within their assigned projects: may delete work packages,
|
||||
modify a SOP after it has been completed, and delete projects.
|
||||
• project_user normal member: creates and edits work packages, authors a SOP
|
||||
up to completion. May NOT delete WPs or change a completed SOP.
|
||||
|
||||
The user-administration SCOPE of a super user is worked out in server/app.py
|
||||
(`managed_project_ids`, `manage_user_problem`), because it depends on project
|
||||
membership rows — this module only decides which roles carry the power at all.
|
||||
|
||||
Password reset: a short-lived signed token (see `create_reset_token`) is emailed
|
||||
to the account's address. It is single-use by construction — it embeds the user's
|
||||
`token_version`, which is bumped when the password changes, so a used or
|
||||
@@ -56,14 +66,21 @@ RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
|
||||
|
||||
# ── permissions roles ─────────────────────────────────────────────────────────
|
||||
ROLE_ADMIN = "admin"
|
||||
ROLE_PROJECT_SUPER = "project_super_user"
|
||||
ROLE_PROJECT_ADMIN = "project_admin"
|
||||
ROLE_PROJECT_USER = "project_user"
|
||||
ROLES = (ROLE_ADMIN, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
|
||||
# Ordered most- to least-privileged; the console renders dropdowns in this order.
|
||||
ROLES = (ROLE_ADMIN, ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
|
||||
ROLE_LABELS = {
|
||||
ROLE_ADMIN: "Administrator",
|
||||
ROLE_PROJECT_SUPER: "Project Super User",
|
||||
ROLE_PROJECT_ADMIN: "Project Admin",
|
||||
ROLE_PROJECT_USER: "Project User",
|
||||
}
|
||||
# Roles that may be held ON A SINGLE PROJECT via ProjectMember.role, so someone can
|
||||
# run the users on one job and be an ordinary member of the next. '' means "inherit
|
||||
# the account's own role" and is always allowed alongside these.
|
||||
PROJECT_SCOPED_ROLES = (ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
|
||||
# Job functions offered in the admin console. Free text underneath, so a project
|
||||
# can use a title that isn't on this list.
|
||||
PROJECT_ROLES = (
|
||||
@@ -90,9 +107,19 @@ def is_admin(user: "models.User") -> bool:
|
||||
|
||||
|
||||
def is_project_admin(user: "models.User") -> bool:
|
||||
"""True for app admins and project admins — the two roles allowed to delete
|
||||
work packages and change a completed SOP."""
|
||||
return normalize_role(user.role) in (ROLE_ADMIN, ROLE_PROJECT_ADMIN)
|
||||
"""True for the roles allowed to delete work packages and change a completed
|
||||
SOP. A super user is a project admin with user administration on top, so it is
|
||||
included here — never enumerate the two roles by hand."""
|
||||
return normalize_role(user.role) in (ROLE_ADMIN, ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN)
|
||||
|
||||
|
||||
|
||||
# NOTE: "may this account administer users?" is deliberately NOT answered here. The
|
||||
# super-user role can be held per project (ProjectMember.role), so the question needs
|
||||
# membership rows to answer and lives in app.py — `is_user_manager` /
|
||||
# `require_user_manager` / `managed_project_ids`. An account-role-only version of the
|
||||
# same question used to exist here and silently disagreed with the scoped one, which
|
||||
# locked per-project super users out of the routes they were entitled to.
|
||||
|
||||
# Password policy (shared by the API and the CLI).
|
||||
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
|
||||
@@ -297,6 +324,8 @@ def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.Us
|
||||
return user
|
||||
|
||||
|
||||
|
||||
|
||||
# ── account helpers (shared by routes and the CLI) ──────────────────────────────
|
||||
def find_user(db: Session, username: str) -> Optional["models.User"]:
|
||||
"""Look up by username, case-insensitively (also matches on email)."""
|
||||
|
||||
21
server/db.py
@@ -12,7 +12,7 @@ Connection precedence:
|
||||
The schema is identical either way (SQLAlchemy handles dialect differences).
|
||||
"""
|
||||
import os
|
||||
from sqlalchemy import create_engine, URL
|
||||
from sqlalchemy import create_engine, event, URL
|
||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||
|
||||
# Load a local .env if present (dev convenience).
|
||||
@@ -46,6 +46,25 @@ _is_sqlite = isinstance(DATABASE_URL, str) and DATABASE_URL.startswith("sqlite")
|
||||
connect_args = {"check_same_thread": False} if _is_sqlite else {}
|
||||
|
||||
engine = create_engine(DATABASE_URL, connect_args=connect_args, pool_pre_ping=True, future=True)
|
||||
|
||||
if _is_sqlite:
|
||||
# SQLite ships with foreign keys DISABLED and the pragma is per-connection, so
|
||||
# without this every `ondelete="CASCADE"` in models.py is silently a no-op on a
|
||||
# dev database while working correctly on Postgres. That divergence is worse than
|
||||
# it sounds: deleting a project left its SOPs, work packages and membership rows
|
||||
# behind as orphans pointing at an id that no longer exists, and deleting a user
|
||||
# left their project_members rows — and the smoke test's cascade assertion failed
|
||||
# on dev while passing in production, which is the exact failure that makes a
|
||||
# smoke test worth ignoring.
|
||||
#
|
||||
# Registered on the engine, not a session, because the pragma has to be set on
|
||||
# each new DBAPI connection as the pool creates it.
|
||||
@event.listens_for(engine, "connect")
|
||||
def _sqlite_enforce_foreign_keys(dbapi_connection, _record):
|
||||
cur = dbapi_connection.cursor()
|
||||
cur.execute("PRAGMA foreign_keys=ON")
|
||||
cur.close()
|
||||
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
|
||||
|
||||
|
||||
@@ -45,8 +45,12 @@ def _prompt_password(provided: str | None, username: str = "") -> str:
|
||||
|
||||
def cmd_create(args, role: str | None = None) -> None:
|
||||
role = role or args.role
|
||||
if role not in ("admin", "user"):
|
||||
sys.exit("role must be 'admin' or 'user'")
|
||||
# 'user' is the pre-roles spelling of 'project_user' and is still accepted so the
|
||||
# documented one-liners keep working; anything else has to be a current role.
|
||||
if role == "user":
|
||||
role = auth.ROLE_PROJECT_USER
|
||||
if role not in auth.ROLES:
|
||||
sys.exit(f"role must be one of {', '.join(auth.ROLES)}")
|
||||
pw = _prompt_password(getattr(args, "password", None), args.username)
|
||||
with SessionLocal() as db:
|
||||
if auth.find_user(db, args.username):
|
||||
@@ -70,9 +74,10 @@ def cmd_list(args) -> None:
|
||||
if not rows:
|
||||
print("No users yet. Create one with: create-admin <username>")
|
||||
return
|
||||
print(f"{'USERNAME':<24}{'ROLE':<8}{'ACTIVE':<8}{'NAME'}")
|
||||
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'NAME'}")
|
||||
for u in rows:
|
||||
print(f"{u.username:<24}{u.role:<8}{('yes' if u.is_active else 'no'):<8}{u.full_name}")
|
||||
print(f"{u.username:<24}{auth.normalize_role(u.role):<20}"
|
||||
f"{('yes' if u.is_active else 'no'):<8}{u.full_name}")
|
||||
|
||||
|
||||
def cmd_reset_password(args) -> None:
|
||||
@@ -113,7 +118,8 @@ def main() -> None:
|
||||
|
||||
add_create("create-admin", "create an admin account")
|
||||
c = add_create("create", "create an account")
|
||||
c.add_argument("--role", choices=["admin", "user"], default="user")
|
||||
c.add_argument("--role", choices=list(auth.ROLES) + ["user"], default=auth.ROLE_PROJECT_USER,
|
||||
help="permissions role ('user' is the legacy name for project_user)")
|
||||
|
||||
sub.add_parser("list", help="list all accounts")
|
||||
|
||||
|
||||
146
server/models.py
@@ -9,10 +9,25 @@ The full client document for a SOP or WP is kept verbatim in a JSON `data`
|
||||
column, with the most-queried fields promoted to real columns for listing and
|
||||
filtering. IDs are short strings (client- or server-generated) so the browser
|
||||
can upsert without round-tripping a sequence.
|
||||
|
||||
NO relationship() DECLARATIONS, ON PURPOSE — and one consequence to know about.
|
||||
Every link here is a plain column plus a ForeignKey; nothing is navigable as
|
||||
`project.work_packages`. Queries are explicit selects, which suits an API that
|
||||
mostly reads one scoped list at a time and never wants a lazy load firing inside
|
||||
a response.
|
||||
|
||||
The consequence: SQLAlchemy's unit of work derives FLUSH ORDER from relationships,
|
||||
not from ForeignKey metadata. With none declared it has no dependency edge to
|
||||
follow, so if you add a parent and its child in the SAME flush it may emit the
|
||||
child's INSERT first and the database will reject it. Both engines enforce foreign
|
||||
keys (Postgres always; SQLite since db.py sets `PRAGMA foreign_keys=ON`), so this
|
||||
is a real error, not a dev-only quirk. Call `db.flush()` after adding the parent —
|
||||
see `create_user` in app.py, which creates an account and its ProjectMember rows
|
||||
together.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON, UniqueConstraint
|
||||
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON, LargeBinary, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from .db import Base
|
||||
|
||||
@@ -132,7 +147,8 @@ class User(Base):
|
||||
|
||||
Two independent notions of "role", deliberately separate:
|
||||
• role the PERMISSIONS role — what the account may do in the app.
|
||||
'admin' | 'project_admin' | 'project_user' (see auth.ROLES).
|
||||
'admin' | 'project_super_user' | 'project_admin' |
|
||||
'project_user' (see auth.ROLES).
|
||||
• project_role the person's JOB FUNCTION on the project (Project Manager,
|
||||
Superintendent, QA/QC, …). Carries no permissions; it's what
|
||||
the SOP team pickers and notification routing read.
|
||||
@@ -189,8 +205,11 @@ class ProjectMember(Base):
|
||||
entirely). One row per (user, project) pair.
|
||||
|
||||
`role` is the permissions role ON THIS PROJECT: someone can be Project Admin on
|
||||
one job and a normal Project User on another. Empty means "inherit the account's
|
||||
own role" (User.role), which is how every existing row behaves."""
|
||||
one job and a normal Project User on another, or a Project Super User (who
|
||||
administers that job's user accounts) on one job only. Empty means "inherit the
|
||||
account's own role" (User.role), which is how every existing row behaves.
|
||||
Values: '' | 'project_super_user' | 'project_admin' | 'project_user'
|
||||
(auth.PROJECT_SCOPED_ROLES) — never 'admin', which is app-wide by definition."""
|
||||
__tablename__ = "project_members"
|
||||
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)
|
||||
|
||||
@@ -205,6 +224,68 @@ class ProjectMember(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class LocationNode(Base):
|
||||
"""One value in a project's Building / Floor / Sector taxonomy — CR-005.
|
||||
|
||||
The taxonomy differs per project. On Micron, floors within B100 behave like
|
||||
separate buildings, so floor and sector are the unit of both execution and
|
||||
cost tracking; on another job "building" may be the only level that means
|
||||
anything. So it is configured once per SOP rather than hard-coded, and no
|
||||
real-world floor name appears anywhere in this repository.
|
||||
|
||||
CODES, NOT DISPLAY STRINGS. `CR-018` rolls cost up by these, and a rollup
|
||||
keyed on a label breaks the day somebody fixes a typo in it. Two columns
|
||||
carry that:
|
||||
|
||||
code this node's own slug among its siblings, derived once from the name
|
||||
it was imported with and then NEVER recomputed — renaming a node is
|
||||
a display change, which is exactly what makes rename safe for the
|
||||
work packages already pointing at it.
|
||||
path the full slug path from the root, '/'-joined and unique per project
|
||||
(`B100/L2/1P`). This is the grouping key and the value a work
|
||||
package stores.
|
||||
|
||||
DEACTIVATE, NEVER DELETE. `active=False` hides a value from new work
|
||||
packages; every existing package referencing it still resolves its label,
|
||||
because the row is still there. Same rule as `CR-002`/`CR-016`: removal is
|
||||
expressed as a toggle, and the data is retained.
|
||||
"""
|
||||
__tablename__ = "location_nodes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("project_id", "path", name="uq_location_path"),
|
||||
)
|
||||
|
||||
LEVELS = ("building", "floor", "sector")
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
# Self-reference by id. No ForeignKey to its own table for the same reason the
|
||||
# rest of this file declares none — see the module docstring — and because a
|
||||
# self-referential FK plus SQLite's deferred-constraint behaviour makes a bulk
|
||||
# import fiddly for no gain. Orphans are prevented in the API, which is the
|
||||
# only writer.
|
||||
parent_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||
level: Mapped[str] = mapped_column(String(20), default="building") # building | floor | sector
|
||||
code: Mapped[str] = mapped_column(String(60), default="") # own slug
|
||||
path: Mapped[str] = mapped_column(String(200), default="", index=True) # full slug path
|
||||
name: Mapped[str] = mapped_column(String(200), default="") # display label
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
sort: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_by: Mapped[str] = mapped_column(String(200), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id, "project_id": self.project_id, "parent_id": self.parent_id,
|
||||
"level": self.level, "code": self.code, "path": self.path, "name": self.name,
|
||||
"active": bool(self.active), "sort": self.sort,
|
||||
"created_at": _iso(self.created_at), "updated_at": _iso(self.updated_at),
|
||||
}
|
||||
|
||||
|
||||
class Comment(Base):
|
||||
__tablename__ = "comments"
|
||||
|
||||
@@ -266,6 +347,63 @@ class AppSetting(Base):
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class MaterialItem(Base):
|
||||
"""One line of a project's material list - D6 / T8.6, the CR-005 call made
|
||||
again: build the upload path now rather than wait for the master workbook.
|
||||
Deliberately small: description, unit, an optional code. NO inventory level,
|
||||
NO price, NO warehouse id - a project-scoped list the project uploaded is
|
||||
not the deferred parts catalog, and the moment a stock count appears here it
|
||||
has crossed the line IMPLEMENTATION.md section 7 draws. `active` rather than
|
||||
delete, same as everything else: a request already referencing a line must
|
||||
keep rendering it."""
|
||||
__tablename__ = "material_items"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
project_id: Mapped[str] = mapped_column(String(40), index=True)
|
||||
code: Mapped[str] = mapped_column(String(80), default="")
|
||||
description: Mapped[str] = mapped_column(String(300), default="")
|
||||
unit: Mapped[str] = mapped_column(String(20), default="")
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
sort: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"id": self.id, "project_id": self.project_id, "code": self.code,
|
||||
"description": self.description, "unit": self.unit,
|
||||
"active": self.active, "sort": self.sort}
|
||||
|
||||
|
||||
class WpFile(Base):
|
||||
"""CR-007 / D8: a drawing uploaded onto a work package. The BYTES live here,
|
||||
in the same database as everything else - settled Aug 18: a backup that
|
||||
excludes the drawings is a backup you cannot restore from. The limits are
|
||||
the D8 numbers: 5MB a file, PDFs and images, 2GB per project (80% warning).
|
||||
A meta copy (no bytes) is mirrored into the package's data["files"] by the
|
||||
server so the list is exportable and readable offline; that key is
|
||||
server-owned and survives client upserts."""
|
||||
__tablename__ = "wp_files"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
wp_id: Mapped[str] = mapped_column(String(40), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(40), index=True)
|
||||
name: Mapped[str] = mapped_column(String(300), default="")
|
||||
mime: Mapped[str] = mapped_column(String(100), default="")
|
||||
size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
description: Mapped[str] = mapped_column(String(500), default="")
|
||||
data: Mapped[bytes] = mapped_column(LargeBinary, default=b"")
|
||||
uploaded_by: Mapped[str] = mapped_column(String(120), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
# Meta only - the bytes go through GET /api/files/{id}, never through JSON.
|
||||
return {
|
||||
"id": self.id, "wp_id": self.wp_id, "project_id": self.project_id,
|
||||
"name": self.name, "mime": self.mime, "size": self.size,
|
||||
"description": self.description, "uploaded_by": self.uploaded_by,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
"""Outbox for user notifications (an in-app record + an optional email). A row
|
||||
is written when something notable happens (e.g. a WP assignment); the email
|
||||
|
||||
@@ -7,11 +7,14 @@ and is NEVER stored in the database or shown in the UI.
|
||||
|
||||
Every notable event (e.g. a WP assignment) writes a `notifications` row — an in-app
|
||||
record — and, when email is on + SMTP is set, the row is delivered by email in a
|
||||
background task. Notification bodies deliberately avoid customer IP: they carry a WP
|
||||
number and a deep link, not the work-package contents.
|
||||
background task. Notification bodies carry customer CONTEXT — the WP number, its
|
||||
title, where the work happens — and a deep link, never customer document CONTENT
|
||||
(scope text, descriptions, comments, attachments). Decided 2026-08-20; the link is
|
||||
the summary of everything a body leaves out.
|
||||
"""
|
||||
import os
|
||||
import smtplib
|
||||
import socket
|
||||
import uuid
|
||||
import logging
|
||||
from email.message import EmailMessage
|
||||
@@ -107,7 +110,13 @@ def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
|
||||
port = int(s.get("smtp_port") or 587)
|
||||
user = s.get("smtp_username") or ""
|
||||
pw = os.getenv("SMTP_PASSWORD", "")
|
||||
with smtplib.SMTP(host, port, timeout=15) as srv:
|
||||
# local_hostname pins the EHLO name. Without it smtplib calls getfqdn() on
|
||||
# EVERY connect, and that reverse-DNS lookup stalls ~5s per send whenever DNS
|
||||
# is slow or unreachable - sends are sequential background tasks, so a batch
|
||||
# of notifications trickled out one per five seconds. gethostname() never
|
||||
# touches the network. Found 2026-08-20 when the office link dropped.
|
||||
with smtplib.SMTP(host, port, timeout=15,
|
||||
local_hostname=(socket.gethostname() or "wp-suite")) as srv:
|
||||
if s.get("smtp_use_tls", True):
|
||||
srv.starttls()
|
||||
if user:
|
||||
|
||||
@@ -7,31 +7,59 @@ a multi-discipline master with its split instances (A/B/C), an overdue package,
|
||||
and an over-threshold draft. Use it to prove the SQL + Python layer end-to-end
|
||||
and to have data to inspect.
|
||||
|
||||
Every /api/ route except /api/health requires a session, so this signs in first and
|
||||
keeps the session cookie for the rest of the run — the same way server/smoketest.py
|
||||
does, reusing its opener rather than growing a second implementation of it.
|
||||
Credentials come from the environment so the password never has to appear in a
|
||||
command line or shell history:
|
||||
|
||||
export WP_SEED_USER=<admin-account> # or WP_SMOKE_USER, which is reused
|
||||
export WP_SEED_PASSWORD='…' # or WP_SMOKE_PASSWORD
|
||||
|
||||
…or pass --user / --password. Use an admin account: seeding creates a project, and
|
||||
--clean deletes one, which needs Project Admin on it.
|
||||
|
||||
USAGE
|
||||
python3 server/seed_demo.py https://wp-suite.company.local --insecure
|
||||
docker compose exec api python /app/server/seed_demo.py http://localhost:8000
|
||||
python3 server/seed_demo.py https://wp-suite.company.local --clean # remove DEMO-* projects
|
||||
|
||||
IMPORTANT — what shows where:
|
||||
* The DEMO **project** is API/SQL-backed, so it appears in the home-page
|
||||
project picker immediately (proves the projects → SQL path in the UI).
|
||||
* The DEMO **SOP and Work Packages** are written to SQL too, but the current
|
||||
front end still reads SOPs/WPs from the browser (localStorage), so they will
|
||||
NOT render in the WP Creator / Dashboard yet — that's the pending Phase 2
|
||||
wiring. Verify them at the SQL/API layer instead:
|
||||
WHAT SHOWS WHERE
|
||||
Everything it writes is API/SQL-backed and renders in the UI: the project appears
|
||||
in the home-page picker, and selecting it shows its Work Packages in the Field
|
||||
View. Verified at T1.6 — 7 cards from a fresh seed.
|
||||
|
||||
(This block used to warn that SOPs and Work Packages would NOT render because the
|
||||
front end still read them from localStorage, pending "Phase 2 wiring". That
|
||||
stopped being true when the sync layer landed, and the warning outlived it. If
|
||||
you are checking whether seeding worked, the UI is now a fair test.)
|
||||
|
||||
To check at the SQL/API layer instead:
|
||||
python3 server/smoketest.py <url> # automated end-to-end check
|
||||
docker compose exec db psql -U wpsuite -d wpsuite \
|
||||
-c "select number,subject,status from work_packages order by number;"
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# The session handling is smoketest.py's, imported rather than copied: one cookie
|
||||
# jar implementation, one login flow, one place to fix. Importing is safe — that
|
||||
# module does its work under `if __name__ == "__main__"`.
|
||||
from smoketest import build_opener # noqa: E402
|
||||
|
||||
BASE = ""
|
||||
CTX = None
|
||||
# Carries the cookie jar holding the session issued by /api/auth/login. This
|
||||
# script used to call urllib.request.urlopen() directly, which has no cookie
|
||||
# support, so the session was dropped and every data route answered 401 (S13).
|
||||
OPENER = None
|
||||
DEMO_NUMBER = "DEMO-001" # project number prefix used to find/clean demo data
|
||||
|
||||
|
||||
@@ -41,7 +69,7 @@ def call(method, path, body=None):
|
||||
req = urllib.request.Request(url, data=data, method=method,
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=CTX, timeout=20) as r:
|
||||
with OPENER.open(req, timeout=20) as r:
|
||||
raw = r.read().decode(); status = r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode(); status = e.code
|
||||
@@ -52,6 +80,27 @@ def call(method, path, body=None):
|
||||
return status, parsed
|
||||
|
||||
|
||||
def abort(msg, hint=""):
|
||||
"""Could not run, as distinct from ran and failed."""
|
||||
print("\nABORT " + msg)
|
||||
if hint:
|
||||
print(hint)
|
||||
print()
|
||||
return 2
|
||||
|
||||
|
||||
def expect(status, body, what):
|
||||
"""Stop on the first refused write with the status, instead of dying on a
|
||||
KeyError three lines later. A 401 here used to surface as
|
||||
`TypeError: 'NoneType' object is not subscriptable`, which reads like a broken
|
||||
stack rather than a missing session."""
|
||||
if status not in (200, 201):
|
||||
detail = body.get("detail") if isinstance(body, dict) else body
|
||||
raise SystemExit(abort(f"{what} failed (HTTP {status}): {detail}",
|
||||
" The account needs Project Admin to create and delete projects."))
|
||||
return body
|
||||
|
||||
|
||||
def constraints(open_names=()):
|
||||
base = ["Safety & Permitting", "Quality Control / Inspection", "IFC Drawings & Specs",
|
||||
"Schedule", "Materials (on site, bagged & tagged)", "Work Access & Laydown"]
|
||||
@@ -60,16 +109,36 @@ def constraints(open_names=()):
|
||||
|
||||
|
||||
def main():
|
||||
global BASE, CTX
|
||||
global BASE, CTX, OPENER
|
||||
ap = argparse.ArgumentParser(description="Seed a demo project into the Work Package Suite")
|
||||
ap.add_argument("base_url", nargs="?", default="http://localhost:8000",
|
||||
help="Site root, no /api (default: http://localhost:8000)")
|
||||
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
|
||||
ap.add_argument("--clean", action="store_true", help="delete existing DEMO-* projects and exit")
|
||||
ap.add_argument("--user", default=os.getenv("WP_SEED_USER", "") or os.getenv("WP_SMOKE_USER", ""),
|
||||
help="account to sign in as (default: $WP_SEED_USER, then $WP_SMOKE_USER). "
|
||||
"Use an admin account.")
|
||||
ap.add_argument("--password",
|
||||
default=os.getenv("WP_SEED_PASSWORD", "") or os.getenv("WP_SMOKE_PASSWORD", ""),
|
||||
help="its password (default: $WP_SEED_PASSWORD, then $WP_SMOKE_PASSWORD — "
|
||||
"preferred, so it stays out of shell history)")
|
||||
args = ap.parse_args()
|
||||
BASE = args.base_url.rstrip("/")
|
||||
if args.insecure:
|
||||
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
|
||||
OPENER = build_opener(CTX)
|
||||
|
||||
if not args.user or not args.password:
|
||||
missing = " and ".join(n for n, v in (("WP_SEED_USER", args.user),
|
||||
("WP_SEED_PASSWORD", args.password)) if not v)
|
||||
return abort(
|
||||
f"no credentials — {missing} not set.",
|
||||
" Every /api/ route except /api/health needs a session, so there is nothing\n"
|
||||
" this can seed without one. Set them and re-run:\n\n"
|
||||
" export WP_SEED_USER=<admin-account>\n"
|
||||
" export WP_SEED_PASSWORD='…'\n\n"
|
||||
" Or pass --user/--password. WP_SMOKE_USER / WP_SMOKE_PASSWORD are accepted\n"
|
||||
" too, so one set of credentials serves this and smoketest.py.")
|
||||
|
||||
# health gate
|
||||
try:
|
||||
@@ -79,6 +148,31 @@ def main():
|
||||
if st != 200:
|
||||
print(f"ABORT: /api/health returned {st}"); return 1
|
||||
|
||||
# Sign in. The cookie the response sets is held by OPENER's jar and rides every
|
||||
# request after this one.
|
||||
st, body = call("POST", "/api/auth/login",
|
||||
{"username": args.user, "password": args.password})
|
||||
if st != 200:
|
||||
detail = body.get("detail") if isinstance(body, dict) else body
|
||||
hint = (" The account may be locked: the API locks an account for a while after a\n"
|
||||
" few consecutive failures, so retrying with the wrong password makes this\n"
|
||||
" worse. Check the password, then wait out the lockout window."
|
||||
if st in (401, 403, 423, 429) else
|
||||
" Unexpected status from the login endpoint — check the API logs.")
|
||||
return abort(f"could not sign in as '{args.user}' (HTTP {st}): {detail}", hint)
|
||||
logged_in = True
|
||||
print(f"Signed in as {args.user}.")
|
||||
|
||||
try:
|
||||
return seed(args)
|
||||
finally:
|
||||
if logged_in:
|
||||
try: call("POST", "/api/auth/logout")
|
||||
except Exception: pass
|
||||
|
||||
|
||||
def seed(args):
|
||||
|
||||
# --clean: remove any prior demo projects (cascade removes their SOP + WPs).
|
||||
# archived=all because /api/projects hides archived projects by default — an
|
||||
# archived DEMO project is still a DEMO project, and --clean has to find it.
|
||||
@@ -90,14 +184,22 @@ def main():
|
||||
call("DELETE", f"/api/projects/{p['id']}")
|
||||
print(f"Removed {len(demos)} DEMO project(s).")
|
||||
return 0
|
||||
# Refuse rather than pile up a second identical DEMO project. This used to be a
|
||||
# note that scrolled past, and running the script twice left two of everything
|
||||
# with no way to tell them apart.
|
||||
if demos:
|
||||
print(f"Note: {len(demos)} DEMO project(s) already exist. Run with --clean first to avoid duplicates.\n")
|
||||
names = ", ".join(f"{p.get('number','?')} ({p.get('id','?')})" for p in demos[:5])
|
||||
print(f"\n{len(demos)} DEMO project(s) already exist: {names}")
|
||||
print("Nothing was created. Remove them first, then re-run:\n")
|
||||
print(f" python3 server/seed_demo.py {BASE} --clean\n")
|
||||
return 1
|
||||
|
||||
# 1) Project
|
||||
st, proj = call("POST", "/api/projects", {
|
||||
"name": "DEMO — Micron INC (test data)", "number": DEMO_NUMBER,
|
||||
"client": "Micron Technology, Inc.", "division": "Semiconductor",
|
||||
"site": "Boise, ID — Fab", "created_by": "seed_demo"})
|
||||
expect(st, proj, "creating the DEMO project")
|
||||
pid = proj["id"]
|
||||
print(f"Project: {proj['name']} ({pid})")
|
||||
|
||||
@@ -109,6 +211,7 @@ def main():
|
||||
"disciplines": ["Mechanical", "Electrical", "Tech"],
|
||||
"discMode": "choice", "instanceSuffix": "letter",
|
||||
"woSize": "Standard — 3–5 days (≈40–80 hrs)", "sizeHoursMax": "80"}}})
|
||||
expect(st, sop, "creating the DEMO SOP")
|
||||
sid = sop["id"]
|
||||
print(f"SOP: complete ({sid})")
|
||||
|
||||
|
||||
@@ -5,6 +5,24 @@ Exercises the real HTTP endpoints the way the front end does, proving that
|
||||
NGINX → FastAPI → PostgreSQL all work and that the Python logic (the AWP
|
||||
release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq.
|
||||
|
||||
AUTHENTICATION
|
||||
Every /api/ route except /api/health requires a session (auth_gate in
|
||||
server/app.py), so the script signs in first and keeps the session cookie for
|
||||
the rest of the run. Credentials come from the environment by preference, so a
|
||||
password never has to appear in a command line or shell history:
|
||||
|
||||
export WP_SMOKE_USER=smoketest
|
||||
export WP_SMOKE_PASSWORD='…'
|
||||
python3 server/smoketest.py https://wp-suite.company.local
|
||||
|
||||
…or pass --user / --password explicitly.
|
||||
|
||||
Use an ADMIN account. The script creates a project and deletes it again at the
|
||||
end, and deleting one takes Project Admin on that project (require_project_admin);
|
||||
a plain project_user can create a project but not clean it up. The script checks
|
||||
the signed-in role up front and warns if it is too low, rather than letting you
|
||||
discover it in the cleanup step.
|
||||
|
||||
USAGE
|
||||
# Against the deployed site (through the NGINX proxy):
|
||||
python3 server/smoketest.py https://wp-suite.company.local
|
||||
@@ -13,16 +31,24 @@ USAGE
|
||||
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
||||
|
||||
# From inside the api container (hits FastAPI directly):
|
||||
docker compose exec api python /app/server/smoketest.py http://localhost:8000
|
||||
docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD api \
|
||||
python /app/server/smoketest.py http://localhost:8000
|
||||
|
||||
# Leave the demo project in the database so you can open it in the UI:
|
||||
python3 server/smoketest.py https://wp-suite.company.local --keep
|
||||
|
||||
The base URL is the SITE root (no /api). Default: http://localhost:8000
|
||||
Exit code 0 = all checks passed, 1 = one or more failed.
|
||||
|
||||
Exit codes: 0 = all checks passed · 1 = one or more checks failed · 2 = the run
|
||||
could not start (unreachable host, missing or rejected credentials). 2 is kept
|
||||
distinct on purpose: "I could not test this" is not the same answer as "this is
|
||||
broken", and conflating them is what made an unauthenticated version of this
|
||||
script report a wall of failures against a perfectly healthy stack.
|
||||
"""
|
||||
import argparse
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
@@ -40,6 +66,18 @@ def check(name, cond, detail=""):
|
||||
|
||||
BASE = ""
|
||||
CTX = None
|
||||
# One opener for the whole run, carrying the cookie jar that holds the session
|
||||
# issued by /api/auth/login. urlopen() has no cookie support, which is why the
|
||||
# session used to be dropped on the floor and every data route answered 401.
|
||||
OPENER = None
|
||||
|
||||
|
||||
def build_opener(ctx=None):
|
||||
handlers = [urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())]
|
||||
if ctx is not None:
|
||||
handlers.append(urllib.request.HTTPSHandler(context=ctx))
|
||||
return urllib.request.build_opener(*handlers)
|
||||
|
||||
|
||||
def call(method, path, body=None):
|
||||
"""Returns (status_code, parsed_body). Never raises on HTTP status."""
|
||||
@@ -50,7 +88,7 @@ def call(method, path, body=None):
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=CTX, timeout=20) as r:
|
||||
with OPENER.open(req, timeout=20) as r:
|
||||
raw = r.read().decode(); status = r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode(); status = e.code
|
||||
@@ -61,33 +99,95 @@ def call(method, path, body=None):
|
||||
return status, parsed
|
||||
|
||||
|
||||
def abort(msg, hint=""):
|
||||
"""Could not run — distinct from 'ran and found problems'. See exit codes above."""
|
||||
print(_c("\nABORT", "31") + " " + msg)
|
||||
if hint:
|
||||
print(hint)
|
||||
print()
|
||||
return 2
|
||||
|
||||
|
||||
def main():
|
||||
global BASE, CTX
|
||||
global BASE, CTX, OPENER
|
||||
ap = argparse.ArgumentParser(description="Work Package Suite API smoke test")
|
||||
ap.add_argument("base_url", nargs="?", default="http://localhost:8000",
|
||||
help="Site root, no /api (default: http://localhost:8000)")
|
||||
ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
|
||||
ap.add_argument("--keep", action="store_true", help="keep the demo project (don't delete)")
|
||||
ap.add_argument("--user", default=os.getenv("WP_SMOKE_USER", ""),
|
||||
help="account to sign in as (default: $WP_SMOKE_USER). Use an admin account.")
|
||||
ap.add_argument("--password", default=os.getenv("WP_SMOKE_PASSWORD", ""),
|
||||
help="its password (default: $WP_SMOKE_PASSWORD — preferred, "
|
||||
"so it stays out of shell history)")
|
||||
args = ap.parse_args()
|
||||
BASE = args.base_url.rstrip("/")
|
||||
if args.insecure:
|
||||
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
|
||||
OPENER = build_opener(CTX)
|
||||
|
||||
print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n")
|
||||
|
||||
# Refuse to start without credentials rather than running headlong into 401s.
|
||||
if not args.user or not args.password:
|
||||
missing = " and ".join(
|
||||
n for n, v in (("WP_SMOKE_USER", args.user), ("WP_SMOKE_PASSWORD", args.password)) if not v)
|
||||
return abort(
|
||||
f"no credentials — {missing} not set.",
|
||||
" Every /api/ route except /api/health needs a session, so there is nothing\n"
|
||||
" meaningful to test without one. Set them and re-run:\n\n"
|
||||
" export WP_SMOKE_USER=<admin-account>\n"
|
||||
" export WP_SMOKE_PASSWORD='…'\n\n"
|
||||
" Or pass --user/--password. Use an admin account: the run creates a project\n"
|
||||
" and deletes it again, and the delete needs Project Admin on it.")
|
||||
|
||||
project_id = None
|
||||
# Guards the sign-out in `finally`. Without it an ABORT on a rejected login still
|
||||
# ran the logout checks, which "passed" — a session that never existed is trivially
|
||||
# refused after logout — and printed PASS lines underneath an abort message.
|
||||
logged_in = False
|
||||
try:
|
||||
# 1) Health — API is up and reachable through the proxy.
|
||||
# 1) Health — API is up and reachable through the proxy. Exempt from auth,
|
||||
# so this also isolates "host unreachable" from "credentials rejected".
|
||||
try:
|
||||
st, body = call("GET", "/api/health")
|
||||
except urllib.error.URLError as e:
|
||||
print(_c("\nABORT", "31") + f" cannot reach {BASE}/api/health — {e}\n"
|
||||
" Is the stack up (docker compose ps) and the URL correct?\n")
|
||||
return 1
|
||||
return abort(f"cannot reach {BASE}/api/health — {e}",
|
||||
" Is the stack up (docker compose ps) and the URL correct?")
|
||||
check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True,
|
||||
f"status={st} body={body}")
|
||||
|
||||
# 2) Create a project (writes to the projects table).
|
||||
# 2) Sign in. The cookie the response sets is held by OPENER's jar and rides
|
||||
# every request after this one.
|
||||
st, body = call("POST", "/api/auth/login",
|
||||
{"username": args.user, "password": args.password})
|
||||
if st != 200:
|
||||
detail = body.get("detail") if isinstance(body, dict) else body
|
||||
hint = (" The account may be locked: the API locks an account for a while after\n"
|
||||
" a few consecutive failures (AUTH_MAX_ATTEMPTS / AUTH_LOCKOUT_MINUTES),\n"
|
||||
" so re-running with the wrong password makes this worse, not better.\n"
|
||||
" Check the password, then wait out the lockout window."
|
||||
if st in (401, 403, 423, 429) else
|
||||
" Unexpected status from the login endpoint — check the API logs.")
|
||||
return abort(f"could not sign in as '{args.user}' (HTTP {st}): {detail}", hint)
|
||||
logged_in = True
|
||||
check("login issues a session", st == 200)
|
||||
|
||||
# 3) Prove the session actually travels — this is the check whose absence let
|
||||
# an unauthenticated version of this script look like a broken stack.
|
||||
st, me = call("GET", "/api/auth/me")
|
||||
who = (me or {}).get("user", {}) if isinstance(me, dict) else {}
|
||||
check("session is accepted on an authenticated route",
|
||||
st == 200 and who.get("username", "").lower() == args.user.lower(),
|
||||
f"status={st} body={me}")
|
||||
role = who.get("role", "?")
|
||||
print(f" ..... signed in as {who.get('username', args.user)} (role: {role})")
|
||||
if role not in ("admin", "project_super_user", "project_admin"):
|
||||
print(_c(" NOTE", "33") + f" '{role}' cannot archive or delete a project, so the "
|
||||
"archive checks and the\n cleanup step will fail and a stray test project "
|
||||
"will be left behind.\n Re-run with an admin account for a clean pass.")
|
||||
|
||||
# 4) Create a project (writes to the projects table).
|
||||
st, proj = call("POST", "/api/projects", {
|
||||
"name": "ZZ Smoke Test Project", "number": "SMOKE-001",
|
||||
"client": "Internal QA", "division": "Controls", "site": "Test Host",
|
||||
@@ -96,14 +196,14 @@ def main():
|
||||
project_id = proj.get("id") if isinstance(proj, dict) else None
|
||||
check("create project", st == 200 and bool(project_id), f"status={st}")
|
||||
|
||||
# 3) Read it back + confirm it's in the list (SQL round-trip).
|
||||
# 5) Read it back + confirm it's in the list (SQL round-trip).
|
||||
st, got = call("GET", f"/api/projects/{project_id}")
|
||||
check("fetch project by id", st == 200 and got.get("number") == "SMOKE-001", f"status={st}")
|
||||
st, lst = call("GET", "/api/projects")
|
||||
check("project appears in list", st == 200 and any(p.get("id") == project_id for p in lst),
|
||||
f"status={st} count={len(lst) if isinstance(lst, list) else '?'}")
|
||||
|
||||
# 4) Create a SOP linked to the project.
|
||||
# 6) Create a SOP linked to the project.
|
||||
st, sop = call("POST", "/api/sops", {
|
||||
"project_id": project_id, "name": "ZZ Smoke SOP", "number": "SMOKE-001",
|
||||
"complete": True, "created_by": "smoketest",
|
||||
@@ -116,7 +216,7 @@ def main():
|
||||
st, latest = call("GET", f"/api/sops/latest?project_id={project_id}")
|
||||
check("latest SOP for project resolves", st == 200 and latest.get("id") == sop_id, f"status={st}")
|
||||
|
||||
# 5) Create a Work Package with one OPEN constraint (not release-ready).
|
||||
# 7) Create a Work Package with one OPEN constraint (not release-ready).
|
||||
st, wp = call("POST", "/api/wps", {
|
||||
"project_id": project_id, "sop_id": sop_id,
|
||||
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
|
||||
@@ -128,11 +228,11 @@ def main():
|
||||
wp_id = wp.get("id") if isinstance(wp, dict) else None
|
||||
check("create work package", st == 200 and bool(wp_id), f"status={st}")
|
||||
|
||||
# 6) The AWP release gate: issuing with an open constraint must be REFUSED (409).
|
||||
# 8) The AWP release gate: issuing with an open constraint must be REFUSED (409).
|
||||
st, refused = call("POST", f"/api/wps/{wp_id}/issue")
|
||||
check("issue is blocked while a constraint is open (409)", st == 409, f"status={st} body={refused}")
|
||||
|
||||
# 7) Clear the constraint (upsert), then issue must SUCCEED (200, status Issued).
|
||||
# 9) Clear the constraint (upsert), then issue must SUCCEED (200, status Issued).
|
||||
call("POST", "/api/wps", {
|
||||
"id": wp_id, "project_id": project_id, "sop_id": sop_id,
|
||||
"number": "WP01-SMOKE", "subject": "Smoke test package", "type": "Conduit Install",
|
||||
@@ -146,16 +246,16 @@ def main():
|
||||
f"status={st}")
|
||||
check("issued_at timestamp is set", isinstance(issued, dict) and bool(issued.get("issued_at")))
|
||||
|
||||
# 8) Status transition endpoint.
|
||||
# 10) Status transition endpoint.
|
||||
st, prog = call("POST", f"/api/wps/{wp_id}/status", {"status": "In Progress"})
|
||||
check("status transition endpoint", st == 200 and prog.get("status") == "In Progress", f"status={st}")
|
||||
|
||||
# 9) Metrics aggregate for the project (Python aggregation over SQL rows).
|
||||
# 11) Metrics aggregate for the project (Python aggregation over SQL rows).
|
||||
st, m = call("GET", f"/api/wps/metrics?project_id={project_id}")
|
||||
check("metrics endpoint aggregates", st == 200 and isinstance(m, dict) and m.get("total", 0) >= 1,
|
||||
f"status={st} metrics={m}")
|
||||
|
||||
# 10) Comment / feedback write + read.
|
||||
# 12) Comment / feedback write + read.
|
||||
st, c = call("POST", "/api/feedback", {
|
||||
"type": "wp_review_comment", "name": "smoketest", "wp_id": wp_id,
|
||||
"text": "SMOKE TEST comment — safe to delete", "page": "/smoketest"})
|
||||
@@ -164,11 +264,11 @@ def main():
|
||||
check("comment is queryable", st == 200 and any("SMOKE TEST" in (x.get("text") or "") for x in comments),
|
||||
f"status={st}")
|
||||
|
||||
# 11) WPs filter by project.
|
||||
# 13) WPs filter by project.
|
||||
st, wps = call("GET", f"/api/wps?project_id={project_id}")
|
||||
check("list WPs by project", st == 200 and any(w.get("id") == wp_id for w in wps), f"status={st}")
|
||||
|
||||
# 12) Archiving a project: it leaves the default list, stays reachable with
|
||||
# 14) Archiving a project: it leaves the default list, stays reachable with
|
||||
# archived=all, and freezes read-only — then unarchiving restores all three.
|
||||
# The freeze is the whole point of the feature, so it is asserted, not assumed.
|
||||
st, arch = call("POST", f"/api/projects/{project_id}/archive", {"archived": True})
|
||||
@@ -193,7 +293,7 @@ def main():
|
||||
check("writing succeeds again once unarchived", st == 200, f"status={st}")
|
||||
|
||||
finally:
|
||||
# 13) Cleanup — deleting the project cascades to its SOPs and WPs (FK ON DELETE CASCADE).
|
||||
# 15) Cleanup — deleting the project cascades to its SOPs and WPs (FK ON DELETE CASCADE).
|
||||
if project_id and not args.keep:
|
||||
st, _ = call("DELETE", f"/api/projects/{project_id}")
|
||||
check("delete project (cascades SOP + WPs)", st == 200, f"status={st}")
|
||||
@@ -203,6 +303,15 @@ def main():
|
||||
elif project_id and args.keep:
|
||||
print(f"\n --keep: left demo project {project_id} ('ZZ Smoke Test Project') in the database.")
|
||||
|
||||
# 16) Sign out. Exercises the logout endpoint, and means a run does not end
|
||||
# holding a live session — which matters when this is run from a shared
|
||||
# jump host or a CI worker. Only if we got one: see `logged_in`.
|
||||
if logged_in:
|
||||
st, _ = call("POST", "/api/auth/logout")
|
||||
check("logout clears the session", st == 200, f"status={st}")
|
||||
st, _ = call("GET", "/api/auth/me")
|
||||
check("session is refused after logout (401)", st == 401, f"status={st}")
|
||||
|
||||
# ── summary ────────────────────────────────────────────────────────────────
|
||||
total = len(_PASS) + len(_FAIL)
|
||||
print(f"\n{'-'*52}\n{len(_PASS)}/{total} checks passed.")
|
||||
|
||||
353
tests/a11y_check.py
Normal file
@@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Announcements, contrast and focus — S10 / S11 / S12 (T4.5, T4.6, T4.7).
|
||||
|
||||
Wave 0 counted zero aria-live regions app-wide, helper text at 3.32:1, and
|
||||
`outline: none` in six places. login.html's role="alert" / role="status" pair was
|
||||
the only correct example of any of it in the codebase.
|
||||
|
||||
T4.5 every toast and banner announces; errors interrupt, confirmations do not
|
||||
T4.6 helper and hint text measures >= 4.5:1 against its REAL background
|
||||
T4.7 every interactive element shows a visible ring on keyboard focus, >= 3:1,
|
||||
and a mouse click leaves none
|
||||
|
||||
Contrast is measured against the background actually painted behind the text,
|
||||
walking up the ancestors for the first non-transparent one — not against an
|
||||
assumed white, which is how "it passes on paper" and "it fails on the page" end
|
||||
up disagreeing.
|
||||
|
||||
Exit 0 all passed, 1 a failure, 2 could not run.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
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, _c # noqa: E402
|
||||
|
||||
PAGES = [("login", "/login.html", None), ("launcher", "/index.html", "root"),
|
||||
("sop", "/work-package-suite.html?project=projA", "root"),
|
||||
("creator", "/wp-creation-index.html?project=projA", "root"),
|
||||
("admin", "/admin.html", "root"), ("users", "/users.html", "root"),
|
||||
("field", "/field.html?project=projA", "root")]
|
||||
|
||||
# Effective background + contrast, computed in the page.
|
||||
CONTRAST_JS = r"""
|
||||
(() => {
|
||||
const lum = (c) => {
|
||||
const m = c.match(/[\d.]+/g); if (!m) return null;
|
||||
const [r,g,b] = m.slice(0,3).map(Number);
|
||||
const a = m.length > 3 ? Number(m[3]) : 1;
|
||||
if (a === 0) return null;
|
||||
const f = (v) => { v /= 255; return v <= 0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055, 2.4); };
|
||||
return 0.2126*f(r) + 0.7152*f(g) + 0.0722*f(b);
|
||||
};
|
||||
const bgOf = (el) => {
|
||||
let n = el;
|
||||
while (n && n.nodeType === 1) {
|
||||
const c = getComputedStyle(n).backgroundColor;
|
||||
const l = lum(c);
|
||||
if (l !== null) return {color: c, lum: l};
|
||||
n = n.parentElement;
|
||||
}
|
||||
return {color: 'rgb(255,255,255)', lum: 1};
|
||||
};
|
||||
const ratio = (a, b) => { const hi = Math.max(a,b), lo = Math.min(a,b); return (hi+0.05)/(lo+0.05); };
|
||||
|
||||
// Helper/hint text: the classes that carry it, plus anything at <= 12px that is
|
||||
// real text. Hidden elements are skipped - they have no contrast to measure.
|
||||
const sel = '.field-hint, .note, .sub, small, .field small, .dm-label, .prog-sub, ' +
|
||||
'.wp-nav-subj, .cmt-note, .req-hint, .empty-hint, .me-tag, .card-status';
|
||||
const out = [];
|
||||
for (const el of document.querySelectorAll(sel)) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (!r.width || !r.height) continue;
|
||||
const txt = (el.textContent || '').trim();
|
||||
if (!txt) continue;
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.visibility === 'hidden' || cs.opacity === '0') continue;
|
||||
const fl = lum(cs.color); if (fl === null) continue;
|
||||
const bg = bgOf(el);
|
||||
const size = parseFloat(cs.fontSize);
|
||||
const bold = parseInt(cs.fontWeight, 10) >= 700;
|
||||
// WCAG "large text": >=24px, or >=18.66px bold.
|
||||
const large = size >= 24 || (bold && size >= 18.66);
|
||||
out.push({
|
||||
cls: el.className || el.tagName, size: size, large: large,
|
||||
fg: cs.color, bg: bg.color, ratio: +ratio(fl, bg.lum).toFixed(2),
|
||||
floor: large ? 3.0 : 4.5,
|
||||
text: txt.slice(0, 40),
|
||||
});
|
||||
}
|
||||
return JSON.stringify(out);
|
||||
})()
|
||||
"""
|
||||
|
||||
FOCUS_JS = r"""
|
||||
(() => {
|
||||
const lum = (c) => {
|
||||
const m = c.match(/[\d.]+/g); if (!m) return null;
|
||||
const [r,g,b] = m.slice(0,3).map(Number);
|
||||
if (m.length > 3 && Number(m[3]) === 0) return null;
|
||||
const f = (v) => { v /= 255; return v <= 0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055, 2.4); };
|
||||
return 0.2126*f(r) + 0.7152*f(g) + 0.0722*f(b);
|
||||
};
|
||||
const bgOf = (el) => {
|
||||
let n = el;
|
||||
while (n && n.nodeType === 1) {
|
||||
const l = lum(getComputedStyle(n).backgroundColor);
|
||||
if (l !== null) return l;
|
||||
n = n.parentElement;
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
const ratio = (a, b) => { const hi = Math.max(a,b), lo = Math.min(a,b); return (hi+0.05)/(lo+0.05); };
|
||||
|
||||
const els = [...document.querySelectorAll(
|
||||
'a[href], button, input:not([type=hidden]), select, textarea, [tabindex]:not([tabindex="-1"])')]
|
||||
.filter(el => { const r = el.getBoundingClientRect(); return r.width && r.height && !el.disabled; });
|
||||
|
||||
const bad = [];
|
||||
let checked = 0;
|
||||
for (const el of els.slice(0, 120)) {
|
||||
el.focus();
|
||||
// focus() on a visibility:hidden or inert control does nothing, and a control
|
||||
// nobody can reach has no focus ring to measure. Ask whether the focus actually
|
||||
// landed rather than assuming it did — a closed drawer still has layout, so a
|
||||
// bounding box is not evidence that a user can get to its contents.
|
||||
if (document.activeElement !== el) { continue; }
|
||||
if (!el.matches(':focus-visible')) { el.blur(); continue; } // not keyboard-focusable here
|
||||
checked++;
|
||||
const cs = getComputedStyle(el);
|
||||
const hasOutline = cs.outlineStyle !== 'none' && parseFloat(cs.outlineWidth) > 0;
|
||||
let ok = false, detail = '';
|
||||
if (hasOutline) {
|
||||
const ol = lum(cs.outlineColor);
|
||||
// WHICH background the ring is actually drawn on depends on the offset. A
|
||||
// positive offset puts it outside the border box, on whatever the PARENT
|
||||
// paints; a negative one puts it over the element's own fill. Measuring both
|
||||
// against the element is how a blue ring on a blue primary button reads as
|
||||
// 8.6:1 on paper and is invisible on screen.
|
||||
// WHICH surface the ring is drawn against depends on the offset the browser
|
||||
// ends up using — not the one the stylesheet asked for. Chromium redraws a
|
||||
// low-contrast author ring in white or black at offset 0 on a filled control,
|
||||
// which is MORE contrast than was asked for, not less.
|
||||
// offset > 0 outside the border box, on whatever the parent paints
|
||||
// offset < 0 inset, over the element's own fill
|
||||
// offset = 0 flush against the edge, touching both — visible if it
|
||||
// contrasts with either
|
||||
const off = parseFloat(cs.outlineOffset) || 0;
|
||||
const own = bgOf(el);
|
||||
const par = el.parentElement ? bgOf(el.parentElement) : own;
|
||||
let r;
|
||||
if (ol === null) r = 0;
|
||||
else if (off > 0) r = ratio(ol, par);
|
||||
else if (off < 0) r = ratio(ol, own);
|
||||
else r = Math.max(ratio(ol, own), ratio(ol, par));
|
||||
ok = r >= 3.0;
|
||||
detail = cs.outlineWidth + ' ' + cs.outlineColor + ' offset ' + cs.outlineOffset
|
||||
+ ' @ ' + r.toFixed(2) + ':1';
|
||||
} else {
|
||||
// A component may ring its SHELL instead of the control — the chrome's search
|
||||
// field is a borderless input inside a bordered box that outlines on
|
||||
// :focus-within. Ringing both would draw two rectangles, so an ancestor ring
|
||||
// counts, as long as it is really there while this element has focus.
|
||||
let n = el.parentElement, anc = null;
|
||||
while (n && n.nodeType === 1 && !anc) {
|
||||
const acs = getComputedStyle(n);
|
||||
if (acs.outlineStyle !== 'none' && parseFloat(acs.outlineWidth) > 0) anc = { n: n, cs: acs };
|
||||
n = n.parentElement;
|
||||
}
|
||||
if (anc) {
|
||||
const ol = lum(anc.cs.outlineColor);
|
||||
const off = parseFloat(anc.cs.outlineOffset) || 0;
|
||||
const surface = off >= 0 && anc.n.parentElement ? bgOf(anc.n.parentElement) : bgOf(anc.n);
|
||||
const r = ol === null ? 0 : ratio(ol, surface);
|
||||
ok = r >= 3.0;
|
||||
detail = 'ancestor ' + (anc.n.className || anc.n.tagName) + ' @ ' + r.toFixed(2) + ':1';
|
||||
} else {
|
||||
detail = (cs.boxShadow && cs.boxShadow !== 'none')
|
||||
? 'box-shadow only: ' + cs.boxShadow.slice(0, 50) : 'no indicator';
|
||||
}
|
||||
}
|
||||
if (!ok) {
|
||||
var chain = [], n2 = el;
|
||||
while (n2 && n2.nodeType === 1 && chain.length < 4) {
|
||||
chain.push(n2.tagName.toLowerCase() + (n2.id ? '#' + n2.id : '')
|
||||
+ (typeof n2.className === 'string' && n2.className.trim()
|
||||
? '.' + n2.className.trim().split(/\s+/)[0] : ''));
|
||||
n2 = n2.parentElement;
|
||||
}
|
||||
bad.push({ tag: el.tagName.toLowerCase(), cls: (el.className||'').toString().slice(0,40),
|
||||
detail: detail, where: chain.join(' < '), text: (el.textContent||'').trim().slice(0,24) });
|
||||
}
|
||||
el.blur();
|
||||
}
|
||||
return JSON.stringify({ checked, bad });
|
||||
})()
|
||||
"""
|
||||
|
||||
|
||||
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-a11y-")
|
||||
db_path = os.path.join(tmpdir, "check.db")
|
||||
server = None
|
||||
try:
|
||||
tok = seed(db_path)
|
||||
port = cdp.free_port()
|
||||
base = "http://127.0.0.1:%d" % port
|
||||
server = start_server(port, db_path)
|
||||
if server is None:
|
||||
print("the test server would not start.")
|
||||
return 2
|
||||
print("\nAnnouncements, contrast and focus — S10/S11/S12\nTarget: %s" % base)
|
||||
|
||||
browser = cdp.Browser(exe)
|
||||
page = browser.page()
|
||||
# Without focus emulation the headless page is not the focused document,
|
||||
# :focus-visible never matches, and every focus reading comes back clean —
|
||||
# which looks like a pass and is not one.
|
||||
page.ws.call("Emulation.setFocusEmulationEnabled", {"enabled": True})
|
||||
try:
|
||||
import json
|
||||
|
||||
print("\nT4.6 — helper text contrast against its real background")
|
||||
worst = []
|
||||
for label, path, user in PAGES:
|
||||
page.clear_cookies()
|
||||
if user:
|
||||
page.set_cookie("wp_session", tok[user])
|
||||
page.goto(base + path)
|
||||
time.sleep(1.4)
|
||||
rows = json.loads(page.eval(CONTRAST_JS))
|
||||
fails = [r for r in rows if r["ratio"] < r["floor"]]
|
||||
if rows:
|
||||
worst.append((label, min(r["ratio"] for r in rows), len(rows)))
|
||||
chk("%-9s %d helper/hint elements, all >= their floor" % (label, len(rows)),
|
||||
not fails,
|
||||
"; ".join("%s %.2f:1 (needs %.1f) %r" % (f["cls"][:24], f["ratio"], f["floor"], f["text"])
|
||||
for f in fails[:3]))
|
||||
for label, w, n in worst:
|
||||
print(" %-9s tightest %.2f:1 across %d elements" % (label, w, n))
|
||||
|
||||
print("\nT4.5 — toasts and banners announce")
|
||||
page.clear_cookies()
|
||||
page.set_cookie("wp_session", tok["root"])
|
||||
page.goto(base + "/admin.html")
|
||||
time.sleep(1.6)
|
||||
roles = json.loads(page.eval(
|
||||
"JSON.stringify([...document.querySelectorAll('.banner,[id$=\"-banner\"]')]"
|
||||
".map(e => ({cls: e.className, role: e.getAttribute('role')})))"))
|
||||
chk("admin banners all carry a role",
|
||||
bool(roles) and all(r["role"] in ("alert", "status") for r in roles),
|
||||
[r for r in roles if r["role"] not in ("alert", "status")][:3])
|
||||
chk("...an error banner interrupts (role=alert)",
|
||||
page.eval("""(() => {
|
||||
const b = document.getElementById('health-banner');
|
||||
b.className = 'banner bad'; b.textContent = 'probe';
|
||||
return new Promise(res => setTimeout(() => res(b.getAttribute('role')), 120));
|
||||
})()""") == "alert")
|
||||
chk("...a success banner does not (role=status)",
|
||||
page.eval("""(() => {
|
||||
const b = document.getElementById('health-banner');
|
||||
b.className = 'banner ok'; b.textContent = 'probe';
|
||||
return new Promise(res => setTimeout(() => res(b.getAttribute('role')), 120));
|
||||
})()""") == "status")
|
||||
chk("...and a banner added later is caught too",
|
||||
page.eval("""(() => {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'banner bad'; d.textContent = 'late';
|
||||
document.querySelector('.wrap').appendChild(d);
|
||||
return new Promise(res => setTimeout(() => res(d.getAttribute('role')), 120));
|
||||
})()""") == "alert")
|
||||
|
||||
page.goto(base + "/wp-creation-index.html?project=projA")
|
||||
for _ in range(30):
|
||||
if page.eval("!!window.wpCreatorReady"):
|
||||
break
|
||||
time.sleep(0.3)
|
||||
time.sleep(0.8)
|
||||
chk("the creator's toast announces politely by default",
|
||||
page.eval("toast('probe'); document.getElementById('toast').getAttribute('role')")
|
||||
== "status")
|
||||
chk("...and interrupts when told to",
|
||||
page.eval("toast('probe','alert'); document.getElementById('toast').getAttribute('role')")
|
||||
== "alert")
|
||||
chk("the sync badge announces politely",
|
||||
page.eval("""(() => {
|
||||
const b = document.getElementById('wp-sync-badge');
|
||||
return b ? b.getAttribute('role') : 'status';
|
||||
})()""") == "status")
|
||||
|
||||
print("\nT4.7 — a visible focus ring on every interactive element")
|
||||
for label, path, user in PAGES:
|
||||
page.clear_cookies()
|
||||
if user:
|
||||
page.set_cookie("wp_session", tok[user])
|
||||
page.goto(base + path)
|
||||
time.sleep(1.4)
|
||||
res = json.loads(page.eval(FOCUS_JS))
|
||||
chk("%-9s %d focusable elements, all ring at >= 3:1"
|
||||
% (label, res["checked"]),
|
||||
not res["bad"],
|
||||
"; ".join("%r %s | %s | %s" % (b.get("text",""), b["cls"][:24],
|
||||
b["detail"], b.get("where",""))
|
||||
for b in res["bad"][:3]))
|
||||
|
||||
print("\nT4.7 — a mouse click leaves no ring")
|
||||
page.goto(base + "/admin.html")
|
||||
time.sleep(1.2)
|
||||
clicked = page.eval("""(() => {
|
||||
const b = document.querySelector('button');
|
||||
if (!b) return 'none';
|
||||
b.dispatchEvent(new MouseEvent('mousedown', {bubbles:true}));
|
||||
b.focus();
|
||||
b.dispatchEvent(new MouseEvent('mouseup', {bubbles:true}));
|
||||
b.dispatchEvent(new MouseEvent('click', {bubbles:true}));
|
||||
return b.matches(':focus-visible') ? 'ring' : 'no-ring';
|
||||
})()""")
|
||||
chk("a mouse-focused button shows no persistent ring",
|
||||
clicked in ("no-ring", "none"), clicked)
|
||||
finally:
|
||||
page.close()
|
||||
browser.close()
|
||||
finally:
|
||||
if server:
|
||||
server.kill()
|
||||
try:
|
||||
server.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
try:
|
||||
from server.db import engine
|
||||
engine.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
import shutil
|
||||
for _ in range(10):
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
if not os.path.exists(tmpdir):
|
||||
break
|
||||
time.sleep(0.3)
|
||||
|
||||
total = len(_PASS) + len(_FAIL)
|
||||
print("\n%s\n%d/%d checks passed." % ("-" * 54, len(_PASS), total))
|
||||
if _FAIL:
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
return 1
|
||||
print("\nResult: " + _c("ALL PASS — it announces, it is legible, focus is visible.", "32") + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
281
tests/aggregates_check.py
Normal file
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Do the counts come from the server? — B4 / T4.1.
|
||||
|
||||
The defect B4 names is not "the numbers are wrong". It is that they were derived
|
||||
from the caller's own localStorage, so two people on the same project saw
|
||||
different numbers and neither was told. A test that only checks the totals are
|
||||
correct would have passed before this change, because on one browser with one
|
||||
cache they were correct.
|
||||
|
||||
So this checks the thing that was actually broken:
|
||||
|
||||
1. the same project reports the same aggregates to two different users
|
||||
2. the dashboard shows the SERVER's total even when the browser's own cache has
|
||||
been poisoned with a different one — which it cannot do if it is summing
|
||||
localStorage
|
||||
3. a failed aggregate request renders an explicit error and a retry, not a zero
|
||||
and not the last good answer
|
||||
4. the launcher's SOP status survives a poisoned cache the same way
|
||||
5. the aggregate response can be grouped by location without a schema change
|
||||
|
||||
Self-contained: throwaway SQLite, its own uvicorn, headless Edge or Chrome over
|
||||
CDP, all torn down. Exit 0 all passed, 1 a failure, 2 could not run.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
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, _c # noqa: E402
|
||||
|
||||
|
||||
def api(base, path, token, method="GET", body=None):
|
||||
req = urllib.request.Request(base + path, method=method)
|
||||
req.add_header("Cookie", "wp_session=" + token)
|
||||
req.add_header("Accept", "application/json")
|
||||
data = None
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode()
|
||||
req.add_header("Content-Type", "application/json")
|
||||
with urllib.request.urlopen(req, data, timeout=15) as r:
|
||||
return json.loads(r.read().decode() or "null")
|
||||
|
||||
|
||||
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-aggregates-")
|
||||
db_path = os.path.join(tmpdir, "check.db")
|
||||
server = None
|
||||
try:
|
||||
tok = seed(db_path)
|
||||
port = cdp.free_port()
|
||||
base = "http://127.0.0.1:%d" % port
|
||||
server = start_server(port, db_path)
|
||||
if server is None:
|
||||
print("the test server would not start.")
|
||||
return 2
|
||||
print("\nAggregate counts — B4 / T4.1\nTarget: %s" % base)
|
||||
|
||||
# Extra packages so the counts are not all the same number: one on hold,
|
||||
# one blocked by an open constraint, one closed, each with a location.
|
||||
for i, (num, status, constraint, loc, hours) in enumerate([
|
||||
("WP03-HOLD", "Issue", "cleared", "B100 / L2 / P", "12"),
|
||||
("WP04-GATE", "Draft", "open", "B100 / L2 / P", "8"),
|
||||
("WP05-DONE", "Closed", "cleared", "B100 / L3 / Q", "20"),
|
||||
]):
|
||||
api(base, "/api/wps", tok["root"], "POST", {
|
||||
"id": "wpX%d" % i, "project_id": "projA", "sop_id": "sopA",
|
||||
"number": num, "subject": num, "type": "Conduit Install",
|
||||
"status": status,
|
||||
"data": {"disciplines": ["Electrical"], "hours": hours,
|
||||
"location": loc,
|
||||
"constraints": [{"name": "Materials", "status": constraint,
|
||||
"comment": "waiting on delivery"}]},
|
||||
})
|
||||
|
||||
print("\n1. the same project reports the same aggregates to two users")
|
||||
as_root = api(base, "/api/wps/metrics?project_id=projA", tok["root"])
|
||||
as_pat = api(base, "/api/wps/metrics?project_id=projA", tok["pat"])
|
||||
comparable = ["total", "release_ready", "on_hold", "overdue",
|
||||
"est_hours", "actual_hours", "by_status", "by_discipline"]
|
||||
same = {k: as_root[k] for k in comparable} == {k: as_pat[k] for k in comparable}
|
||||
chk("root and pat get identical aggregates for projA", same,
|
||||
"root=%s pat=%s" % ({k: as_root[k] for k in comparable},
|
||||
{k: as_pat[k] for k in comparable}))
|
||||
chk("the fixture is not degenerate (>=5 packages, a hold, a gate)",
|
||||
as_root["total"] >= 5 and as_root["on_hold"] >= 1 and len(as_root["gating"]) >= 1,
|
||||
"total=%s on_hold=%s gating=%s" % (as_root["total"], as_root["on_hold"],
|
||||
len(as_root["gating"])))
|
||||
chk("'mine' is per-user, so it is allowed to differ",
|
||||
"mine" in as_root and "mine" in as_pat)
|
||||
|
||||
print("\n5. the aggregate can be grouped by location without a schema change")
|
||||
loc = as_root.get("by_location") or {}
|
||||
chk("by_location carries its dimensions", isinstance(loc.get("dimensions"), list)
|
||||
and len(loc["dimensions"]) >= 1, loc.get("dimensions"))
|
||||
groups = loc.get("groups") or []
|
||||
chk("by_location groups are keyed by those dimensions",
|
||||
bool(groups) and all(set(g["key"]) == set(loc["dimensions"]) for g in groups),
|
||||
[g.get("key") for g in groups[:3]])
|
||||
chk("each group carries its own rollup, not just a count",
|
||||
bool(groups) and all({"total", "release_ready", "on_hold", "by_status"} <= set(g)
|
||||
for g in groups),
|
||||
list(groups[0]) if groups else None)
|
||||
chk("the groups sum to the project total",
|
||||
sum(g["total"] for g in groups) == as_root["total"],
|
||||
"%s vs %s" % (sum(g["total"] for g in groups), as_root["total"]))
|
||||
|
||||
browser = cdp.Browser(exe)
|
||||
page = browser.page()
|
||||
try:
|
||||
print("\n2. the dashboard shows the server's total, not the browser's")
|
||||
page.clear_cookies()
|
||||
page.set_cookie("wp_session", tok["root"])
|
||||
page.goto(base + "/wp-creation-index.html?project=projA")
|
||||
time.sleep(1.5)
|
||||
# Poison this browser's cache with a different number of packages than
|
||||
# the server has. If any displayed count still tracks localStorage, it
|
||||
# will report 2 and the server's total will not match.
|
||||
page.eval("""(() => {
|
||||
const fake = [
|
||||
{id:'fake1', number:'FAKE-1', subject:'not on the server', status:'Draft',
|
||||
disciplines:['Electrical'], hours:'999', constraints:[]},
|
||||
{id:'fake2', number:'FAKE-2', subject:'also not', status:'Draft',
|
||||
disciplines:['Electrical'], hours:'999', constraints:[]}
|
||||
];
|
||||
localStorage.setItem('wp_iwp_v1::projA', JSON.stringify(fake));
|
||||
localStorage.setItem('wp_iwp_v1', JSON.stringify(fake));
|
||||
return true;
|
||||
})()""")
|
||||
page.goto(base + "/wp-creation-index.html?project=projA&view=dashboard")
|
||||
time.sleep(1.2)
|
||||
page.eval("typeof showDashboard==='function' && showDashboard()")
|
||||
for _ in range(30):
|
||||
ready = page.eval("!!document.querySelector('.dash-metric .dm-val')")
|
||||
if ready:
|
||||
break
|
||||
time.sleep(0.3)
|
||||
shown = page.eval(
|
||||
"(()=>{const e=[...document.querySelectorAll('.dash-metric')]"
|
||||
".find(x=>/Total WPs/i.test(x.textContent));"
|
||||
"return e?e.querySelector('.dm-val').textContent.trim():null})()")
|
||||
chk("dashboard 'Total WPs' equals the server total",
|
||||
str(shown) == str(as_root["total"]),
|
||||
"shown=%r server=%r (poisoned cache said 2)" % (shown, as_root["total"]))
|
||||
chk("...and is therefore not the poisoned cache's 2", str(shown) != "2", shown)
|
||||
# D12: the productivity factor card, computed from the SAME server
|
||||
# sums as its neighbours. Both hour fields are optional (CR-017),
|
||||
# so the expected value is derived, not hardcoded: a real quotient
|
||||
# when both sums exist, an em dash when either is zero.
|
||||
pf_shown = page.eval(
|
||||
"(()=>{const e=[...document.querySelectorAll('.dash-metric')]"
|
||||
".find(x=>/Productivity/i.test(x.textContent));"
|
||||
"return e?e.querySelector('.dm-val').textContent.trim():null})()")
|
||||
est, act = as_root.get("est_hours") or 0, as_root.get("actual_hours") or 0
|
||||
pf_want = ("%.2f" % (act / est)) if est > 0 and act > 0 else "—"
|
||||
chk("the D12 productivity card shows actual/estimated from the server sums",
|
||||
pf_shown == pf_want, "shown=%r want=%r (est=%r act=%r)" % (pf_shown, pf_want, est, act))
|
||||
|
||||
print("\n3. a failed aggregate request is an error, not a zero")
|
||||
page.eval("""(() => {
|
||||
const real = window.fetch;
|
||||
window.fetch = function(u, o){
|
||||
if (String(u).indexOf('/api/wps/metrics') !== -1)
|
||||
return Promise.reject(new Error('simulated outage'));
|
||||
return real.apply(this, arguments);
|
||||
};
|
||||
dashMetrics = null; dashMetricsErr = null;
|
||||
loadDashMetrics();
|
||||
return true;
|
||||
})()""")
|
||||
time.sleep(1.2)
|
||||
txt = page.eval("(document.getElementById('dashboard-view')||{}).textContent||''")
|
||||
chk("an explicit error panel is shown", "Counts unavailable" in txt, txt[:120])
|
||||
chk("...naming the failure", "simulated outage" in txt, txt[:160])
|
||||
chk("...offering a retry",
|
||||
page.eval("!!document.querySelector('#dashboard-view button')"))
|
||||
chk("no metric tiles are rendered alongside the error",
|
||||
page.eval("document.querySelectorAll('#dashboard-view .dash-metric').length") == 0)
|
||||
chk("the error region announces itself",
|
||||
page.eval("!!document.querySelector('#dashboard-view [role=alert]')"))
|
||||
|
||||
print("\n4. the launcher's SOP status comes from the server too")
|
||||
page.goto(base + "/index.html")
|
||||
time.sleep(0.6)
|
||||
page.eval("""(() => {
|
||||
try {
|
||||
localStorage.setItem('wp_active_project_id', 'projA');
|
||||
localStorage.setItem('wp_suite_sop_complete::projA', '0');
|
||||
localStorage.removeItem('wp_suite_sop::projA');
|
||||
} catch(e){}
|
||||
return true;
|
||||
})()""")
|
||||
page.goto(base + "/index.html")
|
||||
# A7/T6.5 gave the card a status line in EVERY state, including while
|
||||
# the request is in flight ("Checking the SOP..."). So waiting for the
|
||||
# line to be non-empty is no longer waiting for the answer — wait for
|
||||
# it to stop saying it is checking.
|
||||
for _ in range(30):
|
||||
s = page.eval("(document.querySelector('#card-sop .card-status')||{}).textContent||''")
|
||||
if s and "Checking" not in s:
|
||||
break
|
||||
time.sleep(0.3)
|
||||
status = page.eval(
|
||||
"(document.querySelector('#card-sop .card-status')||{}).textContent||''")
|
||||
# The wording moved at T6.5 ("SOP complete" -> "Complete - <name>").
|
||||
# What this check is about is WHERE the answer came from, not how it is
|
||||
# phrased, so it asserts the answer and not the sentence.
|
||||
chk("launcher reports the SOP complete despite a cache that says otherwise",
|
||||
"Complete" in status and "Not finished" not in status,
|
||||
"card said %r" % status.encode("ascii", "replace").decode("ascii"))
|
||||
|
||||
page.goto(base + "/index.html")
|
||||
time.sleep(0.4)
|
||||
page.eval("""(() => {
|
||||
const real = window.fetch;
|
||||
window.fetch = function(u, o){
|
||||
if (String(u).indexOf('/summary') !== -1)
|
||||
return Promise.reject(new Error('simulated outage'));
|
||||
return real.apply(this, arguments);
|
||||
};
|
||||
return true;
|
||||
})()""")
|
||||
page.eval("typeof applyActiveProject==='function' && applyActiveProject()")
|
||||
time.sleep(1.0)
|
||||
status = page.eval(
|
||||
"(document.querySelector('#card-sop .card-status')||{}).textContent||''")
|
||||
# Same again: the wording moved at T6.5 ("Could not check SOP status"
|
||||
# -> "Unknown - could not reach the server"). The assertion is that the
|
||||
# card states the failure rather than guessing, in whatever words.
|
||||
chk("a failed status request says so on the card",
|
||||
"Unknown" in status and "could not reach" in status.lower(),
|
||||
"card said %r" % status.encode("ascii", "replace").decode("ascii"))
|
||||
finally:
|
||||
page.close()
|
||||
browser.close()
|
||||
except urllib.error.HTTPError as e:
|
||||
print("API error: %s %s" % (e.code, e.read()[:300]))
|
||||
return 2
|
||||
finally:
|
||||
if server:
|
||||
server.kill()
|
||||
try:
|
||||
server.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
try:
|
||||
from server.db import engine
|
||||
engine.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
import shutil
|
||||
for _ in range(10):
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
if not os.path.exists(tmpdir):
|
||||
break
|
||||
time.sleep(0.3)
|
||||
|
||||
total = len(_PASS) + len(_FAIL)
|
||||
print("\n%s\n%d/%d checks passed." % ("-" * 54, len(_PASS), total))
|
||||
if _FAIL:
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
return 1
|
||||
print("\nResult: " + _c("ALL PASS — counts come from the server.", "32") + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
181
tests/archived_check.py
Normal 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
@@ -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())
|
||||
311
tests/autosave_check.py
Normal file
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Does unsaved work survive? — S2 / T4.3 (and B5 / T4.4's status).
|
||||
|
||||
The work package form is ~4,700px tall and had no autosave and no unsaved-work
|
||||
guard: the only beforeunload listener in the app was analytics dwell tracking. A
|
||||
mis-click lost everything typed since the last explicit Save.
|
||||
|
||||
1. typing autosaves a draft, without being asked
|
||||
2. typing then closing prompts; NOT typing then closing does not
|
||||
3. the draft survives a killed tab (no beforeunload) and is offered back
|
||||
4. restoring puts the work back in the form
|
||||
5. an explicit save settles the draft, so nothing offers to "recover" saved work
|
||||
6. autosave failure is surfaced rather than swallowed
|
||||
7. the analytics dwell listener still fires
|
||||
|
||||
Exit 0 all passed, 1 a failure, 2 could not run.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
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, _c # noqa: E402
|
||||
|
||||
|
||||
def boot(page, base, tok, url):
|
||||
page.clear_cookies()
|
||||
page.set_cookie("wp_session", tok["root"])
|
||||
page.goto(base + url)
|
||||
for _ in range(40):
|
||||
if page.eval("!!window.wpCreatorReady"):
|
||||
break
|
||||
time.sleep(0.3)
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
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-autosave-")
|
||||
db_path = os.path.join(tmpdir, "check.db")
|
||||
server = None
|
||||
try:
|
||||
tok = seed(db_path)
|
||||
port = cdp.free_port()
|
||||
base = "http://127.0.0.1:%d" % port
|
||||
server = start_server(port, db_path)
|
||||
if server is None:
|
||||
print("the test server would not start.")
|
||||
return 2
|
||||
print("\nAutosave and the unsaved-work guard — S2 / T4.3\nTarget: %s" % base)
|
||||
|
||||
browser = cdp.Browser(exe)
|
||||
page = browser.page()
|
||||
try:
|
||||
print("\n0. the module is registered on the form page")
|
||||
boot(page, base, tok, "/wp-creation-index.html?project=projA&wp=wpA1")
|
||||
chk("WPAutosave is loaded", page.eval("typeof WPAutosave") == "object")
|
||||
chk("an untouched form is not dirty", page.eval("WPAutosave.isDirty()") is False,
|
||||
page.eval("JSON.stringify(WPAutosave.status())"))
|
||||
|
||||
print("\n2a. not typing then leaving does NOT prompt")
|
||||
chk("no guard while the form is untouched",
|
||||
page.eval("WPAutosave.isDirty()") is False)
|
||||
|
||||
print("\n1. typing autosaves a draft without being asked")
|
||||
page.eval("""(() => {
|
||||
const el = document.getElementById('wp_subject');
|
||||
el.value = 'AUTOSAVE PROBE — typed but never saved';
|
||||
el.dispatchEvent(new Event('input', {bubbles:true}));
|
||||
return true;
|
||||
})()""")
|
||||
chk("the form is now dirty", page.eval("WPAutosave.isDirty()") is True)
|
||||
for _ in range(25):
|
||||
if page.eval("WPAutosave.status().state") == "saved":
|
||||
break
|
||||
time.sleep(0.3)
|
||||
st = page.eval("WPAutosave.status().state")
|
||||
chk("a draft is written on the debounce, unprompted", st == "saved", "status=%r" % st)
|
||||
key = page.eval("WPAutosave._key(wpDraftId())")
|
||||
stored = page.eval("localStorage.getItem(%r)" % key)
|
||||
chk("the draft holds what was typed",
|
||||
bool(stored) and "AUTOSAVE PROBE" in stored, (stored or "")[:120])
|
||||
chk("the draft is scoped to project AND package",
|
||||
"projA" in key and "wpA1" in key, key)
|
||||
chk("it did NOT go to the server (a draft is not a record)",
|
||||
page.eval("""(() => {
|
||||
const q = (localStorage.getItem('wp_outbox_v1')||'');
|
||||
return q.indexOf('AUTOSAVE PROBE') === -1;
|
||||
})()"""))
|
||||
|
||||
print("\n2b. typing then leaving DOES prompt")
|
||||
chk("the guard is armed while work is unsaved",
|
||||
page.eval("WPAutosave.isDirty()") is True)
|
||||
|
||||
print("\n3. the draft survives a killed tab")
|
||||
# No beforeunload: navigate the tab away as a crash would, relying on
|
||||
# the visibilitychange write. Then reopen the same package.
|
||||
page.eval("document.dispatchEvent(new Event('visibilitychange'))")
|
||||
time.sleep(0.4)
|
||||
boot(page, base, tok, "/wp-creation-index.html?project=projA&wp=wpA1")
|
||||
still = page.eval("localStorage.getItem(%r)" % key)
|
||||
chk("the draft is still there after reopening",
|
||||
bool(still) and "AUTOSAVE PROBE" in still, (still or "")[:80])
|
||||
chk("...and is offered back, not applied silently",
|
||||
page.eval("!!document.getElementById('draft-recovery')"))
|
||||
txt = page.eval("(document.getElementById('draft-recovery')||{}).textContent||''")
|
||||
chk("...saying plainly that nothing reached the project",
|
||||
"Nothing has been sent to the project" in txt, txt[:140])
|
||||
chk("...and announcing itself",
|
||||
page.eval("(document.getElementById('draft-recovery')||{}).getAttribute"
|
||||
"&&document.getElementById('draft-recovery').getAttribute('role')") == "status")
|
||||
|
||||
print("\n4. restoring puts the work back")
|
||||
page.eval("document.getElementById('draft-restore').click()")
|
||||
time.sleep(0.8)
|
||||
val = page.eval("(document.getElementById('wp_subject')||{}).value||''")
|
||||
chk("the typed text is back in the form", "AUTOSAVE PROBE" in val, val[:80])
|
||||
chk("the recovery bar is gone once used",
|
||||
page.eval("!document.getElementById('draft-recovery')"))
|
||||
|
||||
print("\n5. an explicit save settles the draft")
|
||||
# savePackage() can end in confirm() (the early-release gate) or alert()
|
||||
# (a missing required field). A native dialog blocks the page and hangs
|
||||
# CDP, so the probe answers them. This is the app's 79-native-dialog
|
||||
# problem showing up in a test rather than a defect in this task — S6/S7
|
||||
# in wave 9 is where those get replaced.
|
||||
page.eval("window.confirm = () => true; window.alert = () => {}; true")
|
||||
# savePackage() returns early unless subject AND type are set, and with
|
||||
# alert() stubbed that early return is silent - so make the form valid
|
||||
# first, then assert the save actually landed before asserting anything
|
||||
# about the draft.
|
||||
# The browser_check fixture's SOP defines no WP types, so the type
|
||||
# select holds only its placeholder and savePackage() correctly refuses.
|
||||
# That is the fixture, not the app: give the form a valid type so the
|
||||
# save path can actually be exercised.
|
||||
page.eval("""(() => {
|
||||
const t = document.getElementById('wp_type');
|
||||
if (t && !t.value) {
|
||||
const o = document.createElement('option');
|
||||
o.value = 'Conduit Install'; o.textContent = 'Conduit Install';
|
||||
t.appendChild(o); t.value = 'Conduit Install';
|
||||
t.dispatchEvent(new Event('change', {bubbles:true}));
|
||||
}
|
||||
return (document.getElementById('wp_subject')||{}).value + ' | ' + (t||{}).value;
|
||||
})()""")
|
||||
page.eval("typeof savePackage==='function' && savePackage(false)")
|
||||
time.sleep(1.2)
|
||||
chk("the save actually landed (otherwise the rest proves nothing)",
|
||||
page.eval("""(() => savedPackages.some(p =>
|
||||
(p.subject||'').indexOf('AUTOSAVE PROBE') !== -1))()"""),
|
||||
page.eval("JSON.stringify(savedPackages.map(p=>p.subject))")[:160])
|
||||
after = page.eval("localStorage.getItem(%r)" % key)
|
||||
chk("the draft is cleared once the record holds the work", after in (None, "null"),
|
||||
repr(after)[:80])
|
||||
boot(page, base, tok, "/wp-creation-index.html?project=projA&wp=wpA1")
|
||||
chk("...so nothing offers to recover work that is already saved",
|
||||
page.eval("!document.getElementById('draft-recovery')"))
|
||||
|
||||
print("\n6. autosave failure is surfaced, not swallowed")
|
||||
page.eval("""(() => {
|
||||
const real = localStorage.setItem.bind(localStorage);
|
||||
localStorage.setItem = function(k, v){
|
||||
if (String(k).indexOf('wp_draft::') === 0) {
|
||||
const e = new Error('QuotaExceededError (simulated)'); e.name='QuotaExceededError'; throw e;
|
||||
}
|
||||
return real(k, v);
|
||||
};
|
||||
const el = document.getElementById('wp_subject');
|
||||
el.value = 'SECOND EDIT, storage is full';
|
||||
el.dispatchEvent(new Event('input', {bubbles:true}));
|
||||
return true;
|
||||
})()""")
|
||||
for _ in range(25):
|
||||
if page.eval("WPAutosave.status().state") == "failed":
|
||||
break
|
||||
time.sleep(0.3)
|
||||
st = page.eval("JSON.stringify(WPAutosave.status())")
|
||||
chk("a failed autosave reports 'failed'",
|
||||
page.eval("WPAutosave.status().state") == "failed", st)
|
||||
chk("...and carries the reason", "simulated" in (st or ""), st)
|
||||
|
||||
print("\n8. B5 — the save indicator tells the truth about the DRAFT")
|
||||
boot(page, base, tok, "/wp-creation-index.html?project=projA&wp=wpA1")
|
||||
chk("a draft indicator is mounted",
|
||||
page.eval("!!document.getElementById('wp-draft-status')"))
|
||||
ind = "(document.getElementById('wp-draft-status')||{})"
|
||||
chk("it says 'no unsaved changes' on an untouched form",
|
||||
"No unsaved changes" in page.eval(ind + ".textContent||''"),
|
||||
page.eval(ind + ".textContent||''"))
|
||||
chk("...announced politely",
|
||||
page.eval(ind + ".getAttribute && document.getElementById('wp-draft-status').getAttribute('role')") == "status")
|
||||
page.eval("""(() => {
|
||||
const el = document.getElementById('wp_subject');
|
||||
el.value = 'B5 PROBE'; el.dispatchEvent(new Event('input', {bubbles:true}));
|
||||
return true;
|
||||
})()""")
|
||||
for _ in range(25):
|
||||
if "Draft saved" in page.eval(ind + ".textContent||''"):
|
||||
break
|
||||
time.sleep(0.3)
|
||||
txt = page.eval(ind + ".textContent||''")
|
||||
chk("it reports a real save, with a time", "Draft saved at" in txt, txt)
|
||||
chk("...and is visually distinct when saved",
|
||||
"is-saved" in page.eval(ind + ".className||''"))
|
||||
|
||||
print("\n8b. a failed save looks different and offers a retry")
|
||||
page.eval("""(() => {
|
||||
const real = localStorage.setItem.bind(localStorage);
|
||||
localStorage.setItem = function(k, v){
|
||||
if (String(k).indexOf('wp_draft::') === 0) throw new Error('disk on fire (simulated)');
|
||||
return real(k, v);
|
||||
};
|
||||
const el = document.getElementById('wp_subject');
|
||||
el.value = 'B5 PROBE 2'; el.dispatchEvent(new Event('input', {bubbles:true}));
|
||||
return true;
|
||||
})()""")
|
||||
for _ in range(25):
|
||||
if "is-failed" in page.eval(ind + ".className||''"):
|
||||
break
|
||||
time.sleep(0.3)
|
||||
cls = page.eval(ind + ".className||''")
|
||||
txt = page.eval(ind + ".textContent||''")
|
||||
chk("a failed draft save is visually distinct", "is-failed" in cls, cls)
|
||||
chk("...names the failure", "disk on fire" in txt, txt[:120])
|
||||
chk("...offers a retry",
|
||||
page.eval("!!document.querySelector('#wp-draft-status .wp-draft-retry')"))
|
||||
chk("...and interrupts rather than waiting for a pause",
|
||||
page.eval("document.getElementById('wp-draft-status').getAttribute('role')") == "alert")
|
||||
|
||||
print("\n8c. the outbox message no longer reads as a draft-save confirmation")
|
||||
src = page.eval("(() => fetch('/project-data.js').then(r=>r.text()))()") or ""
|
||||
# Only what the badge RENDERS counts. The phrase still appears in the
|
||||
# comment explaining why it was changed, and asserting on that would be
|
||||
# asserting that the reason cannot be written down.
|
||||
rendered = [ln for ln in src.split("\n")
|
||||
if ("el.textContent" in ln or "el.innerHTML" in ln)
|
||||
and "All changes saved" in ln]
|
||||
chk("the badge no longer renders 'All changes saved'", not rendered, rendered[:1])
|
||||
chk("...and every state it renders names the project",
|
||||
all("the project" in ln
|
||||
for ln in src.split("\n")
|
||||
if ("el.textContent = '" in ln or "el.innerHTML = '" in ln)
|
||||
and "wp-sync-badge" not in ln and ("✓" in ln or "⚠" in ln or "↻" in ln or "✕" in ln)),
|
||||
[ln.strip()[:70] for ln in src.split("\n")
|
||||
if ("el.textContent = '" in ln or "el.innerHTML = '" in ln)
|
||||
and "the project" not in ln and ("✓" in ln or "⚠" in ln or "↻" in ln or "✕" in ln)])
|
||||
|
||||
print("\n7. the analytics dwell listener still fires")
|
||||
boot(page, base, tok, "/work-package-suite.html?project=projA&tab=sop")
|
||||
time.sleep(0.8)
|
||||
chk("the wizard still has its analytics dwell tracker",
|
||||
page.eval("typeof trackStepDwell === 'function'"))
|
||||
chk("...and the wizard registered autosave too",
|
||||
page.eval("typeof WPAutosave === 'object' && typeof sopDraftId === 'function'"))
|
||||
before = page.eval("""(() => {
|
||||
try { return (JSON.parse(localStorage.getItem('wp_suite_analytics_v1')||'{}').events||[]).length; }
|
||||
catch(e){ return -1; }
|
||||
})()""")
|
||||
# trackStepDwell only records a dwell longer than 400ms, so give it one.
|
||||
time.sleep(0.9)
|
||||
page.eval("typeof trackStepDwell==='function' && trackStepDwell()")
|
||||
time.sleep(0.4)
|
||||
after_n = page.eval("""(() => {
|
||||
try { return (JSON.parse(localStorage.getItem('wp_suite_analytics_v1')||'{}').events||[]).length; }
|
||||
catch(e){ return -1; }
|
||||
})()""")
|
||||
chk("calling it records an event (it was not replaced by the guard)",
|
||||
after_n > before, "%s -> %s" % (before, after_n))
|
||||
finally:
|
||||
page.close()
|
||||
browser.close()
|
||||
finally:
|
||||
if server:
|
||||
server.kill()
|
||||
try:
|
||||
server.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
try:
|
||||
from server.db import engine
|
||||
engine.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
import shutil
|
||||
for _ in range(10):
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
if not os.path.exists(tmpdir):
|
||||
break
|
||||
time.sleep(0.3)
|
||||
|
||||
total = len(_PASS) + len(_FAIL)
|
||||
print("\n%s\n%d/%d checks passed." % ("-" * 54, len(_PASS), total))
|
||||
if _FAIL:
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
return 1
|
||||
print("\nResult: " + _c("ALL PASS — unsaved work survives.", "32") + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
230
tests/baseline_shots.py
Normal file
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture the suite's pages at both reference widths, for before/after comparison.
|
||||
|
||||
CLAUDE.md asks every frontend task to exercise the affected flow at 390px and at
|
||||
1440px and to put before and after screenshots in the PR. Doing that by hand 57
|
||||
times is how it stops getting done, so it is a script.
|
||||
|
||||
python tests/baseline_shots.py # -> docs/reference/baseline/
|
||||
python tests/baseline_shots.py --out /tmp/after # the "after" half of a diff
|
||||
python tests/baseline_shots.py --pages creator,field
|
||||
python tests/baseline_shots.py --widths 390,768,1024,1440
|
||||
|
||||
Self-contained, like tests/browser_check.py, whose seed() and start_server() it
|
||||
reuses rather than growing a second fixture: throwaway SQLite, its own uvicorn,
|
||||
headless Edge or Chrome over CDP, everything torn down afterwards. Your real
|
||||
database is never touched.
|
||||
|
||||
390px is emulated with the mobile flag set, not merely as a narrow desktop window.
|
||||
Every page in html/ declares width=device-width, so this is the layout a field
|
||||
tablet actually gets; without the flag Chrome lays out at 980px and the media
|
||||
queries under test never fire. The script asserts the width it asked for is the
|
||||
width the page saw, because that failure is otherwise invisible in a PNG.
|
||||
|
||||
Exit codes: 0 all captured · 1 one or more pages failed · 2 could not run.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
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 # noqa: E402
|
||||
|
||||
# Page copy contains em dashes and other non-cp1252 characters, and the default
|
||||
# Windows console encoding raises UnicodeEncodeError on them mid-run.
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
except (AttributeError, ValueError): # pragma: no cover
|
||||
pass
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DEFAULT_OUT = os.path.join(REPO, "docs", "reference", "baseline")
|
||||
|
||||
# The active project is seeded as Job A. project-data.js keeps the id and a
|
||||
# denormalised copy under these two keys; both are set, because a page that reads
|
||||
# only the object would otherwise render its empty state.
|
||||
ACTIVE_ID = "projA"
|
||||
ACTIVE_OBJ = {"id": "projA", "name": "Job A", "number": "A-1", "client": "Internal QA"}
|
||||
|
||||
# name, file, user whose session to use, JS that means "this page has its data".
|
||||
# login is visited signed OUT — it is the one page whose real state is no session.
|
||||
PAGES = [
|
||||
("login", "login.html", None, None),
|
||||
("launcher", "index.html", "root", "!!document.querySelector('body')"),
|
||||
("sop", "work-package-suite.html", "root", "!!document.querySelector('.header-left, header')"),
|
||||
("creator", "wp-creation-index.html", "root", "!!document.querySelector('#wp_number, .field')"),
|
||||
("admin", "admin.html", "root", "!!document.querySelector('main, .card')"),
|
||||
("field", "field.html", "root", "!!document.querySelector('body')"),
|
||||
("users", "users.html", "root",
|
||||
"!!document.querySelector('#users-table table, #users-table .note:not(:empty)')"),
|
||||
]
|
||||
|
||||
_OK, _BAD, _OVERFLOW = [], [], []
|
||||
|
||||
|
||||
def _c(s, code):
|
||||
return f"\033[{code}m{s}\033[0m" if sys.stdout.isatty() else s
|
||||
|
||||
|
||||
def capture(page, base, tok, name, filename, user, wait_for, widths, out, label):
|
||||
"""Shoot one page at every width. Returns True if all of them landed."""
|
||||
ok = True
|
||||
page.clear_cookies()
|
||||
if user:
|
||||
page.set_cookie("wp_session", tok[user])
|
||||
|
||||
# localStorage is per-origin, so prime it once on this origin before the real
|
||||
# navigation. Signed-out login.html is left alone: giving it an active project
|
||||
# would be staging a state that page never has.
|
||||
if user:
|
||||
page.goto(base + "/index.html")
|
||||
page.eval(
|
||||
f"localStorage.setItem('wp_active_project', {ACTIVE_ID!r});"
|
||||
f"localStorage.setItem('wp_active_project_obj', {json.dumps(ACTIVE_OBJ)!r});"
|
||||
"true")
|
||||
|
||||
for w in widths:
|
||||
page.viewport(w, 900, mobile=(w <= 500))
|
||||
page.goto(base + "/" + filename, wait_for=wait_for)
|
||||
time.sleep(0.5) # webfonts and late-injected chrome
|
||||
|
||||
# Ask the page how wide it actually ended up. A page whose content will not
|
||||
# fit forces the initial containing block wider than the device, so innerWidth
|
||||
# comes back above what was requested and everything in the shot is at the
|
||||
# wrong scale. That is a finding about the page, not a failure of the capture,
|
||||
# so it is measured and reported and the screenshot is still taken.
|
||||
m = page.eval(
|
||||
"JSON.stringify({inner: window.innerWidth,"
|
||||
" scroll: document.documentElement.scrollWidth,"
|
||||
" client: document.documentElement.clientWidth})")
|
||||
m = json.loads(m)
|
||||
overflow = m["inner"] != w or m["scroll"] > m["client"]
|
||||
if overflow:
|
||||
_OVERFLOW.append(
|
||||
f"{name}@{w}: laid out {m['inner']}px, content {m['scroll']}px "
|
||||
f"in a {m['client']}px viewport")
|
||||
|
||||
suffix = f"-{label}" if label else ""
|
||||
path = os.path.join(out, f"{name}-{w}{suffix}.png")
|
||||
try:
|
||||
page.screenshot(path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_BAD.append(f"{name}@{w}")
|
||||
print(" " + _c("FAIL", "31") + f" {name} {w}px — {exc}")
|
||||
ok = False
|
||||
continue
|
||||
|
||||
errs = page.js_errors()
|
||||
size = os.path.getsize(path)
|
||||
_OK.append(f"{name}@{w}")
|
||||
flags = []
|
||||
if overflow:
|
||||
flags.append(f"overflows: {m['scroll']}px of content")
|
||||
if errs:
|
||||
flags.append(f"{len(errs)} JS error(s)")
|
||||
note = " " + " · ".join(flags) if flags else ""
|
||||
print(" " + _c("OK", "32") + f" {name:9s} {w:>4}px {size:>7,}b "
|
||||
f"{os.path.basename(path)}{_c(note, '33')}")
|
||||
for e in errs[:3]:
|
||||
print(f" {_c('js:', '33')} {e[:110]}")
|
||||
return ok
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--out", default=DEFAULT_OUT, help="directory for the PNGs")
|
||||
ap.add_argument("--widths", default="390,1440", help="comma-separated CSS widths")
|
||||
ap.add_argument("--pages", default="", help="comma-separated subset of page names")
|
||||
ap.add_argument("--label", default="", help="suffix, e.g. --label after")
|
||||
ap.add_argument("--base-url", default="", help="use a server that is already up")
|
||||
args = ap.parse_args()
|
||||
|
||||
widths = [int(w) for w in args.widths.split(",") if w.strip()]
|
||||
wanted = {p.strip() for p in args.pages.split(",") if p.strip()}
|
||||
pages = [p for p in PAGES if not wanted or p[0] in wanted]
|
||||
if wanted - {p[0] for p in PAGES}:
|
||||
print(f"unknown page(s): {', '.join(sorted(wanted - {p[0] for p in PAGES}))}")
|
||||
print(f"known: {', '.join(p[0] for p in PAGES)}")
|
||||
return 2
|
||||
os.makedirs(args.out, exist_ok=True)
|
||||
|
||||
if not cdp.find_browser():
|
||||
print("no headless-capable browser found (set WP_BROWSER)")
|
||||
return 2
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="wpsuite-baseline-")
|
||||
db_path = os.path.join(tmpdir, "baseline.db")
|
||||
proc = browser = None
|
||||
try:
|
||||
tok = seed(db_path)
|
||||
if args.base_url:
|
||||
base = args.base_url.rstrip("/")
|
||||
else:
|
||||
port = cdp.free_port()
|
||||
proc = start_server(port, db_path)
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
if not proc:
|
||||
print("the server would not start")
|
||||
return 2
|
||||
|
||||
print(f"\n {len(pages)} page(s) x {len(widths)} width(s) -> {args.out}\n")
|
||||
browser = cdp.Browser()
|
||||
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:
|
||||
capture(page, base, tok, name, filename, user, wait_for,
|
||||
widths, args.out, args.label)
|
||||
|
||||
print(f"\n {_c(str(len(_OK)) + ' captured', '32')}"
|
||||
+ (f", {_c(str(len(_BAD)) + ' failed', '31')}" if _BAD else ""))
|
||||
if _OVERFLOW:
|
||||
print(f"\n {_c('horizontal overflow', '33')} "
|
||||
f"({len(_OVERFLOW)} of {len(_OK)} shots) — content wider than the "
|
||||
f"viewport it was asked for:")
|
||||
for line in _OVERFLOW:
|
||||
print(f" {line}")
|
||||
print()
|
||||
return 1 if _BAD else 0
|
||||
finally:
|
||||
if browser:
|
||||
browser.close()
|
||||
if proc:
|
||||
proc.kill()
|
||||
proc.wait(timeout=10)
|
||||
# seed() built an engine in this process too; drop it before deleting the
|
||||
# file or Windows keeps the handle open. Same reason as browser_check.
|
||||
try:
|
||||
from server.db import engine
|
||||
engine.dispose()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
481
tests/browser_check.py
Normal file
@@ -0,0 +1,481 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Front-end check for the Work Package Suite — runs the pages in a real browser.
|
||||
|
||||
server/smoketest.py proves the API works. This proves the PAGES work: that they
|
||||
boot without a JavaScript error, that the role-dependent renderings are what they
|
||||
should be, and that the layout rules the console pages depend on are in effect.
|
||||
Those are the things no amount of static analysis can settle, and the reason this
|
||||
exists is that they went unverified once — see the git history for KNOWN-ISSUES 3.
|
||||
|
||||
Self-contained by default: it creates a throwaway SQLite database, seeds a fixture
|
||||
(two projects, one admin, one Project Super User, one plain member, an account
|
||||
spanning both jobs), starts its own uvicorn, drives headless Edge or Chrome over
|
||||
the DevTools Protocol, and tears all of it down. Your real database is never
|
||||
touched. Stdlib only — no pip, matching server/smoketest.py.
|
||||
|
||||
python tests/browser_check.py # everything, self-contained
|
||||
python tests/browser_check.py --keep-server # leave the server up to poke at
|
||||
WP_BROWSER=/path/to/chrome python tests/browser_check.py
|
||||
|
||||
Sessions are established by minting a token with the app's own auth.create_token()
|
||||
and setting it as the wp_session cookie — the same cookie the server would issue,
|
||||
without scripting the login form.
|
||||
|
||||
Exit codes: 0 all checks passed · 1 one or more failed · 2 could not run (no
|
||||
browser found, or the server would not start). 2 is distinct on purpose: "I could
|
||||
not test this" is not the same answer as "this is broken".
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
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
|
||||
|
||||
PW = "CorrectHorseBattery9"
|
||||
_PASS, _FAIL = [], []
|
||||
|
||||
|
||||
def _c(s, code):
|
||||
return f"\033[{code}m{s}\033[0m" if sys.stdout.isatty() else s
|
||||
|
||||
|
||||
def chk(name, cond, extra=""):
|
||||
if cond:
|
||||
_PASS.append(name)
|
||||
print(" " + _c("PASS", "32") + " " + name)
|
||||
else:
|
||||
_FAIL.append(name)
|
||||
print(" " + _c("FAIL", "31") + " " + name + (f" {extra}" if extra else ""))
|
||||
return bool(cond)
|
||||
|
||||
|
||||
def abort(msg, hint=""):
|
||||
print(_c("\nABORT", "31") + " " + msg)
|
||||
if hint:
|
||||
print(hint)
|
||||
print()
|
||||
return 2
|
||||
|
||||
|
||||
# ── fixture ───────────────────────────────────────────────────────────────────
|
||||
def seed(db_path):
|
||||
"""Build the throwaway database. Returns {username: session token}.
|
||||
|
||||
The shape matters, in two ways:
|
||||
• `mix` belongs to BOTH projects while `sue` administers only Job A, which is
|
||||
what makes an out-of-scope, read-only row appear in the directory — the case
|
||||
the role exists to get right.
|
||||
• `bob` is on Job B alone, so he is invisible to `sue` entirely. Without
|
||||
someone in that position the admin and the super user would see the same
|
||||
number of rows and the scoping assertion would prove nothing."""
|
||||
os.environ["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
|
||||
os.environ.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production")
|
||||
from server.db import SessionLocal, Base, engine
|
||||
from server import models, auth
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
with SessionLocal() as db:
|
||||
def mk(username, role):
|
||||
db.add(models.User(id="user_" + username, username=username,
|
||||
email=f"{username}@example.test", full_name=username.title(),
|
||||
password_hash=auth.hash_password(PW), role=role))
|
||||
|
||||
mk("root", auth.ROLE_ADMIN)
|
||||
mk("sue", auth.ROLE_PROJECT_SUPER) # super user on Job A
|
||||
mk("pat", auth.ROLE_PROJECT_USER) # Job A only
|
||||
mk("mix", auth.ROLE_PROJECT_USER) # both jobs -> read-only to sue
|
||||
mk("bob", auth.ROLE_PROJECT_USER) # Job B only -> invisible to sue
|
||||
mk("sam", auth.ROLE_PROJECT_SUPER) # peer super user
|
||||
mk("legacy", "user") # pre-roles spelling
|
||||
db.add(models.Project(id="projA", name="Job A", number="A-1", client="Internal QA"))
|
||||
db.add(models.Project(id="projB", name="Job B", number="B-1", client="Internal QA"))
|
||||
# Parents before children: no relationship() means the ORM has no flush
|
||||
# order to follow, and foreign keys are enforced. See models.py.
|
||||
db.flush()
|
||||
for i, (uid, pid, role) in enumerate([
|
||||
("user_sue", "projA", ""), ("user_pat", "projA", ""), ("user_mix", "projA", ""),
|
||||
("user_mix", "projB", ""), ("user_bob", "projB", ""),
|
||||
("user_sam", "projA", ""), ("user_legacy", "projA", ""),
|
||||
]):
|
||||
db.add(models.ProjectMember(id=f"pm{i}", user_id=uid, project_id=pid, role=role))
|
||||
# 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
|
||||
# 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",
|
||||
complete=True,
|
||||
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()
|
||||
for wid, num, subj, status in (("wpA1", "WP01-COND", "1P horn/strobe conduit", "Issued"),
|
||||
("wpA2", "WP02-WIRE", "1P wire pull", "In Progress")):
|
||||
db.add(models.WorkPackage(
|
||||
id=wid, project_id="projA", sop_id="sopA", number=num, subject=subj,
|
||||
status=status, type="Conduit Install",
|
||||
data={"disciplines": ["Electrical"], "hours": "40",
|
||||
"constraints": [{"name": "Materials", "status": "cleared", "comment": ""}]}))
|
||||
db.commit()
|
||||
return {u.username: auth.create_token(u)
|
||||
for u in db.query(models.User).all()}
|
||||
|
||||
|
||||
def start_server(port, db_path):
|
||||
env = dict(os.environ)
|
||||
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
|
||||
env.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production")
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1",
|
||||
"--port", str(port), "--log-level", "warning"],
|
||||
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
for _ in range(160):
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=1):
|
||||
return proc
|
||||
except Exception:
|
||||
if proc.poll() is not None:
|
||||
return None
|
||||
time.sleep(0.25)
|
||||
proc.kill()
|
||||
return None
|
||||
|
||||
|
||||
# ── the checks ────────────────────────────────────────────────────────────────
|
||||
USERS_READY = "!!document.querySelector('#users-table table, #users-table .note:not(:empty)')"
|
||||
|
||||
|
||||
def run(page, base, tok):
|
||||
def visit(user, path, wait_for=None):
|
||||
page.clear_cookies()
|
||||
page.set_cookie("wp_session", tok[user])
|
||||
return page.goto(base + path, wait_for=wait_for)
|
||||
|
||||
# ── users.html as an administrator ────────────────────────────────────────
|
||||
print("\nUser Directory — as an administrator")
|
||||
visit("root", "/users.html", USERS_READY)
|
||||
chk("page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
|
||||
chk("auth resolved to the admin account", page.eval("(window.WP_USER||{}).role") == "admin")
|
||||
chk("the directory is visible",
|
||||
page.eval("getComputedStyle(document.getElementById('users-main')).display") != "none")
|
||||
chk("manager table renders 9 columns",
|
||||
page.eval("document.querySelectorAll('#users-table thead th').length") == 9,
|
||||
page.eval("document.querySelectorAll('#users-table thead th').length"))
|
||||
chk("an admin sees every account in the fixture (7)",
|
||||
page.eval("document.querySelectorAll('#users-table tbody tr').length") == 7,
|
||||
page.eval("document.querySelectorAll('#users-table tbody tr').length"))
|
||||
chk("rows are one line tall (the regression the runbook warns about)",
|
||||
page.eval("(()=>{const r=document.querySelector('#users-table tbody tr');"
|
||||
"return r ? r.getBoundingClientRect().height : 999})()") < 44,
|
||||
page.eval("(()=>{const r=document.querySelector('#users-table tbody tr');"
|
||||
"return r ? Math.round(r.getBoundingClientRect().height) : -1})()"))
|
||||
chk("the table does not overflow its card",
|
||||
page.eval("(()=>{const t=document.querySelector('#users-table');"
|
||||
"return t.scrollWidth <= t.clientWidth + 1})()"))
|
||||
chk("the page never scrolls sideways",
|
||||
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"))
|
||||
chk("create form is offered",
|
||||
page.eval("getComputedStyle(document.getElementById('create-card')).display") != "none")
|
||||
chk("an admin may grant all four roles",
|
||||
page.eval("document.querySelectorAll('#nu-role option').length") == 4,
|
||||
page.eval("[...document.querySelectorAll('#nu-role option')].map(o=>o.value)"))
|
||||
chk("job-function list is populated",
|
||||
page.eval("document.querySelectorAll('#nu-project-role option').length") == 15)
|
||||
chk("scope banner names the Administrator role",
|
||||
"Administrator" in (page.eval("document.getElementById('scope-banner').textContent") or ""))
|
||||
chk("permissions dropdowns render per row",
|
||||
page.eval("document.querySelectorAll('#users-table tbody select.role-select').length") >= 8)
|
||||
|
||||
# Your own row: permissions locked so you cannot demote yourself, job function
|
||||
# still editable. Asserted on the two cells, not "no select in the row".
|
||||
ROW = ("const r=[...document.querySelectorAll('#users-table tbody tr')]"
|
||||
".find(r=>r.querySelector('.me-tag'));")
|
||||
def own(q):
|
||||
return "(()=>{" + ROW + "if(!r)return false;const c=r.cells[3];return " + q + "})()"
|
||||
chk("your own permissions cell is locked, not a dropdown",
|
||||
page.eval(own("!c.querySelector('select') && !!c.querySelector('.tag')")))
|
||||
chk("...and wears the Administrator pill", page.eval(own("!!c.querySelector('.tag.admin')")))
|
||||
chk("...while your job function stays editable",
|
||||
page.eval("(()=>{" + ROW + "return !!r && !!r.cells[4].querySelector('select')})()"))
|
||||
|
||||
page.eval("[...document.querySelectorAll('#users-table tbody button')]"
|
||||
".find(b=>/project/i.test(b.textContent)).click()")
|
||||
time.sleep(0.9)
|
||||
page.ws.drain(0.5)
|
||||
chk("project-access dialog opens", page.eval("!!document.getElementById('proj-modal')"))
|
||||
chk("...and lists projects to tick",
|
||||
page.eval("document.querySelectorAll('#proj-list input[type=checkbox]').length") >= 1)
|
||||
page.key("Escape")
|
||||
chk("...and Escape closes it", page.eval("!document.getElementById('proj-modal')"))
|
||||
|
||||
# ── users.html as a Project Super User ────────────────────────────────────
|
||||
print("\nUser Directory — as a Project Super User (Job A only)")
|
||||
visit("sue", "/users.html", USERS_READY)
|
||||
chk("page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
|
||||
banner = page.eval("document.getElementById('scope-banner').textContent") or ""
|
||||
chk("scope banner names the Project Super User role", "Project Super User" in banner, banner[:120])
|
||||
chk("...and names the project they administer", "Job A" in banner, banner[:120])
|
||||
# 6 of the 7: everyone on Job A, plus the admin (who reaches every project), but
|
||||
# not `bob`, who is on Job B alone.
|
||||
chk("only in-scope accounts are listed (6 of 7)",
|
||||
page.eval("document.querySelectorAll('#users-table tbody tr').length") == 6,
|
||||
page.eval("document.querySelectorAll('#users-table tbody tr').length"))
|
||||
chk("...and an account on a job they cannot see is absent entirely",
|
||||
page.eval("!/\\bbob\\b/.test(document.getElementById('users-table').textContent)"))
|
||||
chk("accounts on other jobs are read-only",
|
||||
page.eval("document.querySelectorAll('#users-table tbody tr.is-locked').length") >= 1)
|
||||
chk("...and the reason is readable on hover",
|
||||
page.eval("[...document.querySelectorAll('#users-table tbody tr.is-locked [title]')]"
|
||||
".some(el=>/administer/i.test(el.title))"))
|
||||
chk("a peer super user shows its own colour-coded pill",
|
||||
page.eval("document.querySelectorAll('#users-table tbody .tag.super').length") >= 1)
|
||||
chk("a super user may grant only the two roles below their own",
|
||||
page.eval("[...document.querySelectorAll('#nu-role option')].map(o=>o.value).join(',')")
|
||||
== "project_admin,project_user",
|
||||
page.eval("[...document.querySelectorAll('#nu-role option')].map(o=>o.value)"))
|
||||
chk("create form demands a project",
|
||||
"*" in (page.eval("document.getElementById('nu-projects-label').textContent") or ""))
|
||||
chk("their single project is pre-ticked",
|
||||
page.eval("document.querySelectorAll('#nu-project-list input:checked').length") == 1)
|
||||
|
||||
# ── users.html as an ordinary member ──────────────────────────────────────
|
||||
print("\nUser Directory — as an ordinary Project User")
|
||||
visit("pat", "/users.html", USERS_READY)
|
||||
chk("page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
|
||||
chk("read-only directory renders 6 columns",
|
||||
page.eval("document.querySelectorAll('#users-table thead th').length") == 6,
|
||||
page.eval("document.querySelectorAll('#users-table thead th').length"))
|
||||
chk("no create form",
|
||||
page.eval("getComputedStyle(document.getElementById('create-card')).display") == "none")
|
||||
chk("no action controls anywhere in the table",
|
||||
page.eval("document.querySelectorAll('#users-table tbody button, "
|
||||
"#users-table tbody select').length") == 0)
|
||||
chk("no scope banner claiming rights",
|
||||
(page.eval("document.getElementById('scope-banner').textContent") or "").strip() == "")
|
||||
chk("colleagues' emails are reachable as mailto links",
|
||||
page.eval("document.querySelectorAll('#users-table tbody a[href^=mailto]').length") >= 1)
|
||||
|
||||
# ── field.html and the navigation drawer ──────────────────────────────────
|
||||
print("\nField view — navigation drawer")
|
||||
visit("pat", "/field.html", "!!document.getElementById('wp-navbtn')")
|
||||
chk("page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
|
||||
chk("hamburger is mounted in the app bar",
|
||||
page.eval("!!document.querySelector('.wp-appbar #wp-navbtn')"))
|
||||
chk("drawer starts hidden from assistive tech",
|
||||
page.eval("document.getElementById('wp-sidenav').getAttribute('aria-hidden')") == "true")
|
||||
chk("drawer is off-screen when closed",
|
||||
page.eval("document.getElementById('wp-sidenav').getBoundingClientRect().right") <= 1,
|
||||
page.eval("Math.round(document.getElementById('wp-sidenav').getBoundingClientRect().right)"))
|
||||
page.click("#wp-navbtn")
|
||||
chk("clicking it opens the drawer",
|
||||
page.eval("document.getElementById('wp-sidenav').classList.contains('is-open')"))
|
||||
chk("...fully on-screen",
|
||||
page.eval("document.getElementById('wp-sidenav').getBoundingClientRect().left") >= -1)
|
||||
chk("...with the scrim shown", page.eval("!document.querySelector('.wp-navscrim').hidden"))
|
||||
chk("...and aria-expanded flipped",
|
||||
page.eval("document.getElementById('wp-navbtn').getAttribute('aria-expanded')") == "true")
|
||||
chk("Field View is marked as the current page",
|
||||
(page.eval("(document.querySelector('.wp-sidenav-link.is-current .wp-sidenav-label')||{})"
|
||||
".textContent") or "").startswith("Field View"))
|
||||
chk("...and exposed to assistive tech as such",
|
||||
page.eval("document.querySelectorAll('.wp-sidenav-link[aria-current=page]').length") == 1)
|
||||
chk("Admin Console is hidden from a non-admin",
|
||||
page.eval("![...document.querySelectorAll('.wp-sidenav-link')]"
|
||||
".some(a=>/Admin Console/.test(a.textContent))"))
|
||||
chk("User Directory is offered to everyone",
|
||||
page.eval("[...document.querySelectorAll('.wp-sidenav-link')]"
|
||||
".some(a=>/User Directory/.test(a.textContent))"))
|
||||
chk("tap targets are at least 44px tall",
|
||||
page.eval("[...document.querySelectorAll('.wp-sidenav-link')]"
|
||||
".every(a=>a.getBoundingClientRect().height >= 44)"))
|
||||
chk("focus moved into the drawer",
|
||||
page.eval("document.getElementById('wp-sidenav').contains(document.activeElement)"))
|
||||
page.key("Escape")
|
||||
chk("Escape closes it",
|
||||
not page.eval("document.getElementById('wp-sidenav').classList.contains('is-open')"))
|
||||
page.click("#wp-navbtn")
|
||||
page.click(".wp-navscrim")
|
||||
chk("clicking the scrim closes it",
|
||||
not page.eval("document.getElementById('wp-sidenav').classList.contains('is-open')"))
|
||||
|
||||
visit("pat", "/field.html?project=projA", "!!document.getElementById('wp-sidenav')")
|
||||
chk("the drawer carries the active project on project-scoped links",
|
||||
page.eval("(()=>{const l=[...document.querySelectorAll('.wp-sidenav-link')]"
|
||||
".filter(a=>/work-package-suite|field\\.html/.test(a.getAttribute('href')||''));"
|
||||
"return l.length>0 && l.every(a=>/project=projA/.test(a.getAttribute('href')))})()"))
|
||||
chk("...and leaves non-project pages alone",
|
||||
page.eval("!/project=/.test(document.querySelector"
|
||||
"('.wp-sidenav-link[href^=\"users.html\"]').getAttribute('href'))"))
|
||||
chk("the field view lists the project's work packages",
|
||||
page.eval("document.querySelectorAll('#wp-list .wp-card').length") == 2,
|
||||
page.eval("document.querySelectorAll('#wp-list .wp-card').length"))
|
||||
chk("the drawer sits above the app bar",
|
||||
page.eval("(()=>{const z=n=>+getComputedStyle(n).zIndex||0;"
|
||||
"return z(document.getElementById('wp-sidenav')) > "
|
||||
"z(document.querySelector('.wp-appbar'))})()"))
|
||||
|
||||
visit("root", "/field.html", "!!document.getElementById('wp-sidenav')")
|
||||
chk("Admin Console appears for an admin",
|
||||
page.eval("[...document.querySelectorAll('.wp-sidenav-link')]"
|
||||
".some(a=>/Admin Console/.test(a.textContent))"))
|
||||
|
||||
# ── admin.html: the console.css extraction ────────────────────────────────
|
||||
# console.css was lifted out of admin.html's inline <style> to be shared with
|
||||
# the directory. A rule lost in that move shows up here, not on the new page.
|
||||
print("\nAdmin Console — shared console.css still in effect")
|
||||
visit("root", "/admin.html",
|
||||
"!!document.querySelector('#projects-table table, #projects-table .note')")
|
||||
chk("page boots with no JavaScript errors", not page.js_errors(), page.js_errors())
|
||||
chk("console is revealed for an admin",
|
||||
page.eval("getComputedStyle(document.getElementById('admin-main')).display") != "none")
|
||||
chk("console.css is loaded",
|
||||
page.eval("[...document.styleSheets].some(s=>(s.href||'').endsWith('console.css'))"))
|
||||
chk("shared tokens resolve (--ctl)",
|
||||
(page.eval("getComputedStyle(document.documentElement).getPropertyValue('--ctl')")
|
||||
or "").strip() == "32px")
|
||||
chk("cards keep their white surface and hairline border",
|
||||
page.eval("(()=>{const c=getComputedStyle(document.querySelector('.card'));"
|
||||
"return c.backgroundColor==='rgb(255, 255, 255)' && c.borderTopWidth==='1px'})()"))
|
||||
chk("card headings keep the uppercase accent treatment",
|
||||
page.eval("(()=>{const h=getComputedStyle(document.querySelector('.card h2'));"
|
||||
"return h.textTransform==='uppercase' && h.color==='rgb(15, 98, 254)'})()"))
|
||||
chk("dense tables keep their sticky header and 13px body",
|
||||
page.eval("(()=>{const t=document.querySelector('#projects-table table');if(!t)return false;"
|
||||
"return getComputedStyle(t.querySelector('th')).position==='sticky' && "
|
||||
"getComputedStyle(t).fontSize==='13px'})()"))
|
||||
chk("project rows are one line tall",
|
||||
page.eval("(()=>{const r=document.querySelector('#projects-table tbody tr');"
|
||||
"return r ? r.getBoundingClientRect().height : 999})()") < 44)
|
||||
chk("buttons keep the square Carbon shape",
|
||||
page.eval("getComputedStyle(document.querySelector('.card button')).borderRadius") == "0px")
|
||||
chk("user administration is gone from the console",
|
||||
page.eval("!document.getElementById('users-table')"))
|
||||
chk("...replaced by a link to the directory",
|
||||
page.eval("!!document.querySelector('a[href=\"users.html\"]')"))
|
||||
chk("the page never scrolls sideways",
|
||||
page.eval("document.documentElement.scrollWidth <= window.innerWidth + 1"))
|
||||
|
||||
visit("pat", "/admin.html", "true")
|
||||
time.sleep(0.6)
|
||||
chk("a non-admin sees the Admins-only notice",
|
||||
page.eval("getComputedStyle(document.getElementById('admin-denied')).display") != "none")
|
||||
chk("...and none of the console",
|
||||
page.eval("getComputedStyle(document.getElementById('admin-main')).display") == "none")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Work Package Suite front-end browser check")
|
||||
ap.add_argument("--base-url", help="test an already-running server instead of starting one")
|
||||
ap.add_argument("--keep-server", action="store_true",
|
||||
help="leave the throwaway server and database up afterwards")
|
||||
args = ap.parse_args()
|
||||
|
||||
exe = cdp.find_browser()
|
||||
if not exe:
|
||||
return abort("no headless-capable browser found.",
|
||||
" Install Microsoft Edge or Google Chrome, or point WP_BROWSER at one.\n"
|
||||
" Nothing was tested — this is not a failure of the app.")
|
||||
print(f"\nWork Package Suite — front-end browser check\nBrowser: {exe}")
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="wpsuite-browser-check-")
|
||||
db_path = os.path.join(tmpdir, "check.db")
|
||||
server = None
|
||||
try:
|
||||
tok = seed(db_path)
|
||||
if args.base_url:
|
||||
base = args.base_url.rstrip("/")
|
||||
else:
|
||||
port = cdp.free_port()
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
server = start_server(port, db_path)
|
||||
if server is None:
|
||||
return abort("the test server would not start.",
|
||||
" Try: python -m uvicorn server.app:app --port 8000\n"
|
||||
" and re-run with --base-url http://127.0.0.1:8000")
|
||||
print(f"Target: {base}")
|
||||
|
||||
browser = cdp.Browser(exe)
|
||||
page = browser.page()
|
||||
try:
|
||||
run(page, base, tok)
|
||||
finally:
|
||||
page.close()
|
||||
browser.close()
|
||||
except RuntimeError as e:
|
||||
return abort(str(e))
|
||||
finally:
|
||||
if args.keep_server:
|
||||
print(f"\n --keep-server: still up at {base}, database at {db_path}")
|
||||
print(" Sign in as root / " + PW)
|
||||
else:
|
||||
if server:
|
||||
# Wait for it to actually exit before deleting the database out from
|
||||
# under it: on Windows the open SQLite file blocks the rmtree, and
|
||||
# ignore_errors=True means that failure is silent — which is how six
|
||||
# abandoned temp directories accumulated the first time round.
|
||||
server.kill()
|
||||
try:
|
||||
server.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
# seed() built an engine in THIS process too, and its pool holds the
|
||||
# SQLite file open until disposed — the second reason a temp directory
|
||||
# survived a run that reported success.
|
||||
try:
|
||||
from server.db import engine
|
||||
engine.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
import shutil
|
||||
for _ in range(10):
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
if not os.path.exists(tmpdir):
|
||||
break
|
||||
time.sleep(0.3)
|
||||
if os.path.exists(tmpdir):
|
||||
print(f" note: could not remove {tmpdir} — delete it by hand")
|
||||
|
||||
total = len(_PASS) + len(_FAIL)
|
||||
print(f"\n{'-' * 54}\n{len(_PASS)}/{total} checks passed.")
|
||||
if _FAIL:
|
||||
print(_c(f"FAILED ({len(_FAIL)}):", "31"))
|
||||
for f in _FAIL:
|
||||
print(" - " + f)
|
||||
print("\nResult: " + _c("FAIL", "31") + "\n")
|
||||
return 1
|
||||
print("\nResult: " + _c("ALL PASS — the pages boot and render as intended.", "32") + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||