Compare commits
127 Commits
feat/admin
...
docs/bl-02
| Author | SHA1 | Date | |
|---|---|---|---|
| 6057d05b98 | |||
| 64eac0cbbb | |||
| 8f280d4bd1 | |||
| a8e28bf3ab | |||
| 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 | |||
| 7ef1fcdd96 | |||
| 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 | |||
| 3cccdf1c4b | |||
| 153fe97a31 | |||
| 928ab8c900 | |||
| 7bb1f66588 | |||
| 26f4a9242a | |||
| 6c1dc7d45f | |||
| e5977758c0 | |||
| a9b22f2add | |||
| e3527a6e1d | |||
| 917a728399 | |||
| 40bd19b6cf | |||
| 6c3098922f | |||
| fcba74b584 | |||
| b38348e6ae | |||
| 61d1cf4bff | |||
| 79b0e955b4 | |||
| 1d004cab75 | |||
| 39b48055ff | |||
| dd37f1f551 | |||
| afdc815fb4 | |||
| ca4176ac00 | |||
| 0e698438a2 | |||
| 566ace9969 | |||
| eefa76e460 | |||
| 5ad3ffa58e | |||
| bdb798efdd | |||
| 151ccea0ac | |||
| 1f8c23c9bb | |||
| 20afb0e565 | |||
| deaf13c724 | |||
| a02f7ec511 | |||
| c010bc22a0 | |||
| 66da5b708a | |||
| 1e31aa535e |
14
.gitignore
vendored
@@ -14,3 +14,17 @@ wpsuite.db
|
|||||||
|
|
||||||
# Runtime directories (created by containers)
|
# Runtime directories (created by containers)
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
|
# Database backup dumps (large + sensitive) — keep the folder, ignore contents
|
||||||
|
/backups/*
|
||||||
|
!/backups/.gitkeep
|
||||||
|
|
||||||
|
# Local server logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Local scratch / test artifacts (curl cookie jars hold live session tokens)
|
||||||
|
_*.txt
|
||||||
|
cookies.txt
|
||||||
|
|
||||||
|
# Claude Code local workspace (agent memory, session data)
|
||||||
|
.claude/
|
||||||
|
|||||||
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.
|
||||||
103
DEPLOY-login-portal.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Deploy: Work Package Suite — login portal update
|
||||||
|
|
||||||
|
Instructions for the **Portainer admin** to take the new secure login portal live.
|
||||||
|
No prior context needed.
|
||||||
|
|
||||||
|
**Repo:** `Project-SDE-WP-Suite` (primegit) — changes are merged to **`main`**.
|
||||||
|
|
||||||
|
**What changed:** the app now has a username/password login. Going live needs:
|
||||||
|
1. one new environment variable,
|
||||||
|
2. a **rebuild** of the stack (not just a restart), and
|
||||||
|
3. creating the first admin account.
|
||||||
|
|
||||||
|
> **Why a rebuild (not a restart):** both the **nginx/webserver** and **api** images
|
||||||
|
> bake the code in at build time (`COPY html/` and `COPY server/` in their
|
||||||
|
> Dockerfiles). A plain restart will **not** pick up the new code — the images must
|
||||||
|
> be **rebuilt** from the latest `main`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Add an environment variable to the stack
|
||||||
|
|
||||||
|
In the stack's **Environment variables** section, add:
|
||||||
|
|
||||||
|
| Name | Value | Notes |
|
||||||
|
|------|-------|-------|
|
||||||
|
| `AUTH_SECRET_KEY` | a long random string | **Required.** Signs the login session cookies. |
|
||||||
|
| `AUTH_SESSION_HOURS` | `12` | *Optional.* Hours a login lasts before re-auth (defaults to 12). |
|
||||||
|
|
||||||
|
Generate the secret on the host with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl rand -base64 48
|
||||||
|
```
|
||||||
|
|
||||||
|
> If `AUTH_SECRET_KEY` is **not** set, the app still starts but falls back to a random
|
||||||
|
> per-process key — logins then reset on every restart and break across the 2 gunicorn
|
||||||
|
> workers. It must be set to a fixed value.
|
||||||
|
|
||||||
|
The existing database variables (`POSTGRES_*`) are unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Pull latest `main`, rebuild, and redeploy
|
||||||
|
|
||||||
|
- Pull the latest commit on `main` and redeploy the stack **with image rebuild enabled**
|
||||||
|
(e.g. "Re-pull and redeploy" / force rebuild). This rebuilds both the `webserver` and
|
||||||
|
`api` images.
|
||||||
|
- New Python dependencies (`bcrypt`, `PyJWT`) are in `requirements.txt` and install
|
||||||
|
automatically during the rebuild.
|
||||||
|
- The `users` table is created automatically on API startup — **no DB migration needed.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Verify the containers
|
||||||
|
|
||||||
|
- Confirm `wp_api` and the webserver container are both **running**.
|
||||||
|
- If `wp_api` fails to start, check its **Logs**. (A missing `AUTH_SECRET_KEY` only logs a
|
||||||
|
warning — it won't crash — but please confirm it's set.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Create the first admin account
|
||||||
|
|
||||||
|
The login system needs one admin user in the production (Postgres) database. Open the
|
||||||
|
**`wp_api`** container's **Console** (`/bin/sh`) and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m server.manage_users create-admin <username> --name "<Full Name>"
|
||||||
|
```
|
||||||
|
|
||||||
|
It prompts for a password (minimum 8 characters) and prints `Created admin: <username>`.
|
||||||
|
|
||||||
|
Non-interactive alternative:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m server.manage_users create-admin <username> --name "<Full Name>" --password "<password>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Other CLI commands (run the same way): `list`, `create <user> --role user`,
|
||||||
|
`reset-password <user>`, `disable <user>`, `enable <user>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Confirm it works
|
||||||
|
|
||||||
|
1. Load the site's normal URL — it should redirect to a **login page**.
|
||||||
|
2. Sign in with the admin account from step 4.
|
||||||
|
3. That admin can then add all other users from the in-app **Admin → User
|
||||||
|
administration** page (top-right **Admin** link), so no further shell access is needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference — what's in this release
|
||||||
|
|
||||||
|
- `server/auth.py` — bcrypt password hashing, JWT session cookie, the request gate.
|
||||||
|
- `server/app.py` — `/api/auth/*` endpoints + middleware that refuses every `/api` data
|
||||||
|
route without a valid session.
|
||||||
|
- `server/manage_users.py` — the CLI used in step 4.
|
||||||
|
- `html/login.html`, `html/auth-guard.js` — login page and per-page guard.
|
||||||
|
- `html/admin.html` / `admin.js` — Admin Console gated on the admin role, with the user
|
||||||
|
administration UI.
|
||||||
|
- Sessions are stateless: a signed JWT in an **HttpOnly, SameSite=Lax** cookie, marked
|
||||||
|
**Secure** automatically when served over HTTPS (via `X-Forwarded-Proto` from nginx).
|
||||||
290
DEPLOY-runbook-2026-08-04.md
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
# Deploy runbook — WP Suite
|
||||||
|
|
||||||
|
**For:** IT / whoever administers the Docker host and Portainer
|
||||||
|
**From:** n.siegfried@prime-controls.com
|
||||||
|
**Revised:** 2026-08-05 — **this replaces the 2026-08-04 version.** Same procedure,
|
||||||
|
but the deploy now carries a second database migration and a new admin screen. If
|
||||||
|
you already have the earlier copy, work from this one instead.
|
||||||
|
**Expected duration:** 10–15 minutes, including the backup
|
||||||
|
**Expected downtime:** under a minute, while containers are recreated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fill these in before handing this over
|
||||||
|
|
||||||
|
| Thing | Value |
|
||||||
|
|---|---|
|
||||||
|
| Docker host (SSH target) | `________________` |
|
||||||
|
| Stack name in Portainer | `________________` |
|
||||||
|
| Site URL | `https://________________` |
|
||||||
|
| Stack directory on the host (holds `docker-compose.yml` / `backups/`) | `________________` |
|
||||||
|
|
||||||
|
Container names are fixed by the compose file and are the same on every host:
|
||||||
|
`nginx_webserver`, `wp_api`, `wp_db`, `wp_db_backup`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this deploy changes
|
||||||
|
|
||||||
|
Front-end and nginx changes, a new admin screen, plus pending database migrations
|
||||||
|
that run automatically. Three things make it more than a routine restart:
|
||||||
|
|
||||||
|
1. **The nginx config and the entire `html/` directory are baked into the
|
||||||
|
container image at build time.** A plain restart deploys nothing — the stack
|
||||||
|
must be re-pulled and re-built.
|
||||||
|
2. **A pending migration rewrites existing rows** in the `users.role` column
|
||||||
|
(`b41c7ae90d52`, values `user` → `project_user`). That is why step 1 is a backup
|
||||||
|
and not optional. If a previous deploy already applied it, it will not run again —
|
||||||
|
step 0 tells you which of these you are actually about to run.
|
||||||
|
3. **A second migration adds new columns** (`a7c31f9e5b02`: an archive timestamp on
|
||||||
|
projects, and two default-membership fields on users). This one is additive and
|
||||||
|
has database defaults for existing rows, so it does not rewrite anything.
|
||||||
|
|
||||||
|
For the people using the app, the visible changes are: projects can now be
|
||||||
|
**archived** from the Admin Console (they disappear from the pickers and go
|
||||||
|
read-only, and can be brought back), certain users can be set to join **every new
|
||||||
|
project automatically**, and the Admin Console has been rebuilt so the user table
|
||||||
|
fits on screen.
|
||||||
|
|
||||||
|
Migrations run themselves when the `wp_api` container starts. There is nothing
|
||||||
|
to type and **no new environment variables** — do not change the stack's
|
||||||
|
environment variables.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 0 — Record the current state (needed for rollback)
|
||||||
|
|
||||||
|
SSH to the Docker host and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec wp_api alembic -c server/alembic.ini current
|
||||||
|
docker inspect nginx_webserver --format 'nginx image: {{.Image}}'
|
||||||
|
docker inspect wp_api --format 'api image: {{.Image}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Copy the output into your ticket.** Also note the Git commit the Portainer
|
||||||
|
stack is currently on (Portainer → the stack → the Git reference / last-updated
|
||||||
|
commit). Without these, rollback is guesswork.
|
||||||
|
|
||||||
|
The first command prints the migration the database is currently on. Use it to see
|
||||||
|
which migrations this deploy will actually run:
|
||||||
|
|
||||||
|
| `alembic current` shows | What will run | What that means |
|
||||||
|
|---|---|---|
|
||||||
|
| `c93f2b1d7e04` or earlier | both migrations | The `users.role` rewrite is included — the backup in step 1 matters most in this case. |
|
||||||
|
| `d15b8c4ef207` | only `a7c31f9e5b02` | The `users.role` rewrite already happened on an earlier deploy. This one is additive only. |
|
||||||
|
| `a7c31f9e5b02` | nothing | The database is already up to date; this is a code-only deploy. |
|
||||||
|
|
||||||
|
Take the backup either way.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Back up the database
|
||||||
|
|
||||||
|
On the Docker host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec wp_db_backup /scripts/db-backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This triggers the stack's existing backup sidecar once, on demand. Expected
|
||||||
|
output ends with a line like:
|
||||||
|
|
||||||
|
```
|
||||||
|
[db-backup] wrote 1.4M /backups/wpsuite-20260804-141233Z.sql.gz.enc
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm the file is on the host (substitute the stack directory):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -lt <stack-dir>/backups | head -3
|
||||||
|
```
|
||||||
|
|
||||||
|
**Record that filename.** Do not continue until you have seen the `wrote …`
|
||||||
|
line and the file in that listing.
|
||||||
|
|
||||||
|
- A `.sql.gz.enc` extension means backups are encrypted — expected and correct.
|
||||||
|
- A `.sql.gz` extension plus a `WARNING: BACKUP_ENC_PASSPHRASE not set` line
|
||||||
|
means backups are unencrypted. Not a blocker for this deploy; report it back.
|
||||||
|
- **No SSH access?** Portainer → **Containers** → `wp_db_backup` → **Console** →
|
||||||
|
connect with `/bin/sh`, then run `/scripts/db-backup.sh`. Same result: the
|
||||||
|
dump lands on the host, because `/backups` is a bind mount.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Redeploy the stack in Portainer
|
||||||
|
|
||||||
|
1. Portainer → **Stacks** → select the stack.
|
||||||
|
2. **Pull and redeploy** — with re-pull / re-build **enabled**.
|
||||||
|
3. Wait for it to report success.
|
||||||
|
|
||||||
|
A plain "restart" or "stop/start" will **not** deploy this change. See "What
|
||||||
|
this deploy changes" above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3 — Confirm the containers came up
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps --filter name=nginx_webserver --filter name=wp_api --filter name=wp_db
|
||||||
|
```
|
||||||
|
|
||||||
|
All three must be `Up`, and `wp_db` should show `(healthy)`. Then check the API
|
||||||
|
applied its migrations cleanly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs wp_api --tail 40
|
||||||
|
```
|
||||||
|
|
||||||
|
You are looking for Alembic `Running upgrade …` lines followed by gunicorn
|
||||||
|
starting up, and **no** traceback. The last one should end at `a7c31f9e5b02`. The
|
||||||
|
API deliberately refuses to start if a migration fails, so a restarting `wp_api`
|
||||||
|
container means the migration failed — go to Rollback.
|
||||||
|
|
||||||
|
Confirm the database landed on the new revision:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec wp_api alembic -c server/alembic.ini current
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `a7c31f9e5b02 (head)`.
|
||||||
|
|
||||||
|
Then verify nginx's own view of its config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec nginx_webserver nginx -t
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `syntax is ok` / `test is successful`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4 — Confirm the response headers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sI https://<site-url>/work-package-suite.html | grep -Ei 'cache-control|content-security-policy'
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `-k` if the site uses an internal or self-signed certificate.
|
||||||
|
|
||||||
|
**Both lines must come back.** Expected, approximately:
|
||||||
|
|
||||||
|
```
|
||||||
|
cache-control: no-cache, must-revalidate
|
||||||
|
content-security-policy: default-src 'self'; script-src 'self' 'unsafe-inline'; ...
|
||||||
|
```
|
||||||
|
|
||||||
|
If the `content-security-policy` line is **missing** while `cache-control` is
|
||||||
|
present, the deploy is bad — go to Rollback and send me the nginx log. (This is
|
||||||
|
the specific regression this deploy fixes; the two headers must coexist.)
|
||||||
|
|
||||||
|
Also confirm the API is reachable through the proxy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://<site-url>/api/health # → {"ok": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5 — Hard-reload once in a browser
|
||||||
|
|
||||||
|
Open the site and press **Ctrl+Shift+R** (Cmd+Shift+R on macOS) once. The app
|
||||||
|
uses a service worker; a normal reload can serve the previous version and make a
|
||||||
|
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 **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.
|
||||||
|
|
||||||
|
> 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
|
||||||
|
round trip proves the new migration and the new endpoint are both live.
|
||||||
|
|
||||||
|
**Deploy complete.** Please report back: the step 0 output (including which
|
||||||
|
migrations ran), the backup filename, and the two header lines from step 4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Pick the case that matches.
|
||||||
|
|
||||||
|
### Case A — nginx won't start, or the CSP header is missing
|
||||||
|
|
||||||
|
The database is untouched by this, so this is a code-only rollback. In Portainer,
|
||||||
|
redeploy the stack pinned to the **previous Git commit** recorded in step 0
|
||||||
|
(Portainer → the stack → change the Git reference to that commit → Pull and
|
||||||
|
redeploy). Then re-run step 3 and step 4.
|
||||||
|
|
||||||
|
**Before you do:** grab the log, because it is what I need to fix this.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs nginx_webserver --tail 100
|
||||||
|
```
|
||||||
|
|
||||||
|
Send me that output. If the container is in a restart loop the log still works.
|
||||||
|
|
||||||
|
### Case B — `wp_api` is restarting / a migration failed
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs wp_api --tail 100
|
||||||
|
```
|
||||||
|
|
||||||
|
Send me that output. **Do not restore the database and do not roll the API back
|
||||||
|
without contacting me first.** Which migration got as far as committing decides what
|
||||||
|
is safe, and they are not the same:
|
||||||
|
|
||||||
|
- **`a7c31f9e5b02`** (the new columns) is additive. If only this one ran, rolling
|
||||||
|
the API back to the previous image is safe on its own — the old code simply
|
||||||
|
ignores the extra columns. Nothing needs converting.
|
||||||
|
- **`b41c7ae90d52`** (the `users.role` rewrite) is not. If that one committed,
|
||||||
|
rolling the API back without converting those values back **will break logins**.
|
||||||
|
That conversion is a one-line command, but it has to match what actually ran.
|
||||||
|
|
||||||
|
The `alembic current` output from step 0, plus the `Running upgrade …` lines in the
|
||||||
|
log above, are exactly what tells us which case you are in — please include both.
|
||||||
|
|
||||||
|
Reach me at n.siegfried@prime-controls.com.
|
||||||
|
|
||||||
|
### Case C — restoring the backup (only if I ask for it)
|
||||||
|
|
||||||
|
Destructive: this drops and recreates the current schema and data. For an
|
||||||
|
encrypted dump, on the Docker host, in the `backups` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export BACKUP_ENC_PASSPHRASE='<the passphrase — from the stack env vars>'
|
||||||
|
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:BACKUP_ENC_PASSPHRASE \
|
||||||
|
-in wpsuite-<timestamp>.sql.gz.enc \
|
||||||
|
| gunzip \
|
||||||
|
| docker exec -i wp_db psql -U wpsuite -d wpsuite
|
||||||
|
unset BACKUP_ENC_PASSPHRASE
|
||||||
|
```
|
||||||
|
|
||||||
|
For an unencrypted dump, drop the `openssl` stage and pipe `gunzip` straight
|
||||||
|
into `psql`. Substitute the real values if `POSTGRES_USER` / `POSTGRES_DB` are
|
||||||
|
not `wpsuite`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Do not add or change environment variables for this deploy.
|
||||||
|
- Do not run `docker compose down -v` — the `-v` flag deletes the `pgdata`
|
||||||
|
volume and with it the entire database.
|
||||||
|
- `docker compose …` commands are avoided throughout this runbook on purpose:
|
||||||
|
for a Portainer-managed Git stack the compose project lives under Portainer's
|
||||||
|
own data directory, so `docker compose` from an SSH session usually can't find
|
||||||
|
it. The `docker exec <container-name>` form used here works from any directory.
|
||||||
|
- Full background documentation: `DEPLOYMENT.md` in the repository.
|
||||||
494
DEPLOYMENT.md
@@ -52,15 +52,45 @@ POSTGRES_DB=wpsuite
|
|||||||
POSTGRES_USER=wpsuite
|
POSTGRES_USER=wpsuite
|
||||||
POSTGRES_PASSWORD=<strong-random-password>
|
POSTGRES_PASSWORD=<strong-random-password>
|
||||||
|
|
||||||
# Must match the POSTGRES_* values above. Host is the compose service name "db".
|
# REQUIRED — signs login session cookies. If unset, `docker compose up` errors
|
||||||
DATABASE_URL=postgresql+psycopg://wpsuite:<strong-random-password>@db:5432/wpsuite
|
# out and the API refuses to start. Generate once and keep it stable:
|
||||||
|
# openssl rand -base64 48
|
||||||
|
AUTH_SECRET_KEY=<strong-random-secret>
|
||||||
|
|
||||||
|
# Encrypts database backups at rest (AES-256). Set this BEFORE the DB holds
|
||||||
|
# customer IP. Keep the passphrase OFF this host — losing it makes dumps
|
||||||
|
# unrecoverable: openssl rand -base64 32
|
||||||
|
BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
|
||||||
|
|
||||||
|
# OPTIONAL — SMTP password for WP-assignment email + password-reset links. Email
|
||||||
|
# is OFF by default and enabled from the Admin console; the host/port/from-address
|
||||||
|
# are configured there, but the password is only ever read from this variable
|
||||||
|
# (never stored in the DB or shown in the UI). Leave unset until you have SMTP
|
||||||
|
# details.
|
||||||
|
# SMTP_PASSWORD=<smtp-app-password>
|
||||||
|
|
||||||
|
# OPTIONAL — password-reset link lifetime (minutes) and the per-account send
|
||||||
|
# cooldown (seconds). Defaults shown; both only matter once email is enabled.
|
||||||
|
# AUTH_RESET_MINUTES=60
|
||||||
|
# AUTH_RESET_COOLDOWN_SECONDS=120
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The API builds its own DB connection string from the `POSTGRES_*`
|
||||||
|
values and **encodes the password automatically**, so a password with special
|
||||||
|
characters (`@ ! # : /` …) works without any manual escaping. `DATABASE_URL`
|
||||||
|
is **optional** and only needed if you want to point the API at some other
|
||||||
|
database; if you do set it, you must URL-encode the password yourself, and it's
|
||||||
|
ignored whenever the three `POSTGRES_*` values are present.
|
||||||
|
|
||||||
Generate a strong password with `openssl rand -base64 32`.
|
Generate a strong password with `openssl rand -base64 32`.
|
||||||
|
|
||||||
These are the only credentials in the system: the `db` container initialises
|
> **Portainer note:** for a Git-based stack these go in the stack's
|
||||||
Postgres from `POSTGRES_*`, and the `api` container connects with the matching
|
> **Environment variables** section (Portainer doesn't read a local `.env`).
|
||||||
`DATABASE_URL`. Neither value appears in the compose file or in git.
|
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` / `AUTH_SECRET_KEY` /
|
||||||
|
> `BACKUP_ENC_PASSPHRASE` (and `SMTP_PASSWORD`, if you enable email) there.
|
||||||
|
|
||||||
|
These are the only credentials in the system, and they never appear in the
|
||||||
|
compose file or in git.
|
||||||
|
|
||||||
## 3. Point your reverse proxy at the nginx container
|
## 3. Point your reverse proxy at the nginx container
|
||||||
|
|
||||||
@@ -70,6 +100,14 @@ chosen hostname (e.g. `wp-suite.company.local`) to the `nginx_webserver`
|
|||||||
container on that network. The container already proxies `/api/` to the `api`
|
container on that network. The container already proxies `/api/` to the `api`
|
||||||
service internally — no extra app config needed.
|
service internally — no extra app config needed.
|
||||||
|
|
||||||
|
> **Serve it over HTTPS, and forward the scheme.** The bundled nginx sets the
|
||||||
|
> security response headers (CSP, HSTS, `X-Frame-Options`, `nosniff`) and passes
|
||||||
|
> `X-Forwarded-Proto: https` to the API, which is what makes the session cookie
|
||||||
|
> `Secure`. If you front the stack with your **own** proxy instead, make sure it
|
||||||
|
> terminates TLS and forwards `X-Forwarded-Proto: https` — otherwise the login
|
||||||
|
> cookie won't get the `Secure` flag. HSTS also assumes the site is only ever
|
||||||
|
> reached over HTTPS.
|
||||||
|
|
||||||
## 4. Bring it up
|
## 4. Bring it up
|
||||||
|
|
||||||
From the project root:
|
From the project root:
|
||||||
@@ -108,20 +146,64 @@ docker compose exec db psql -U wpsuite -d wpsuite -c "select id, name from proje
|
|||||||
|
|
||||||
### Automated smoke test
|
### Automated smoke test
|
||||||
|
|
||||||
`server/smoketest.py` exercises the whole stack end-to-end (health → project →
|
`server/smoketest.py` exercises the whole stack end-to-end (health → sign-in →
|
||||||
SOP → Work Package → the AWP issue gate → status → metrics → comments → cascade
|
project → SOP → Work Package → the AWP issue gate → status → metrics → comments →
|
||||||
cleanup). Stdlib only — no pip/jq.
|
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
|
```bash
|
||||||
|
export WP_SMOKE_USER=<admin-account>
|
||||||
|
export WP_SMOKE_PASSWORD='…'
|
||||||
|
|
||||||
# Through the proxy (use --insecure for a self-signed internal cert):
|
# Through the proxy (use --insecure for a self-signed internal cert):
|
||||||
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
python3 server/smoketest.py https://wp-suite.company.local --insecure
|
||||||
|
|
||||||
# Or from inside the api container (hits FastAPI directly):
|
# Or from inside the api container (hits FastAPI directly). Pass the vars through:
|
||||||
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
|
||||||
|
|
||||||
# Add --keep to leave a demo project in the DB so you can open it in the UI.
|
# 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
|
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
|
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
|
deleted via cascade); a single tagged test comment remains (there's no comment
|
||||||
@@ -138,12 +220,10 @@ python3 server/seed_demo.py https://wp-suite.company.local --insecure
|
|||||||
python3 server/seed_demo.py https://wp-suite.company.local --clean # remove it later
|
python3 server/seed_demo.py https://wp-suite.company.local --clean # remove it later
|
||||||
```
|
```
|
||||||
|
|
||||||
> **What shows where:** the DEMO **project** is API/SQL-backed, so it appears in
|
> **What shows where:** the DEMO **project**, its **SOP**, and its **Work
|
||||||
> the home-page project picker right away (this is the visible proof that the
|
> Packages** are all API/SQL-backed, so they appear in the home-page project
|
||||||
> projects → SQL path works end-to-end). The DEMO **SOP and Work Packages** are
|
> picker and render in the Creator/Dashboard as soon as any user opens the
|
||||||
> written to SQL too, but the current front end still reads SOPs/WPs from the
|
> project. Inspect them at the SQL layer with `smoketest.py` or:
|
||||||
> browser, so they won't render in the Creator/Dashboard until the Phase 2
|
|
||||||
> wiring. Inspect them at the SQL layer with `smoketest.py` or:
|
|
||||||
> ```bash
|
> ```bash
|
||||||
> docker compose exec db psql -U wpsuite -d wpsuite \
|
> docker compose exec db psql -U wpsuite -d wpsuite \
|
||||||
> -c "select number, subject, status from work_packages order by number;"
|
> -c "select number, subject, status from work_packages order by number;"
|
||||||
@@ -153,42 +233,58 @@ python3 server/seed_demo.py https://wp-suite.company.local --clean # remove it
|
|||||||
|
|
||||||
## What is stored in SQL today
|
## What is stored in SQL today
|
||||||
|
|
||||||
Be aware of the current persistence split — the API + Postgres are fully
|
The API + Postgres are the system of record. Everything below is server-stored
|
||||||
deployed, and:
|
and shared across every user who opens the project:
|
||||||
|
|
||||||
| Data | Stored in PostgreSQL today? |
|
| Data | Stored in PostgreSQL today? |
|
||||||
|------|------------------------------|
|
|------|------------------------------|
|
||||||
| **Projects** | **Yes** — the front end is API-first (`/api/projects`), falling back to the browser only if the API is unreachable. |
|
| **Projects** | **Yes** — the front end is API-first (`/api/projects`), falling back to the browser only if the API is unreachable. |
|
||||||
| **Comments / feedback** | **Yes** — every feedback surface posts to `/api/feedback`. |
|
| **Comments / feedback** | **Yes** — every feedback surface posts to `/api/feedback`. |
|
||||||
| **SOPs** | Endpoints exist (`/api/sops`); the front end still keeps the SOP in the browser (namespaced per project). Wiring it to the API is the remaining **Phase 2** step. |
|
| **SOPs** | **Yes** — pulled from `/api/sops` on load and written through on every save. |
|
||||||
| **Work Packages** | Same — `/api/wps` (+ issue/status/metrics) exist and are ready; the creator still saves to the browser per project. |
|
| **Work Packages** | **Yes** — same write-through to `/api/wps` (+ issue / status / archive / metrics), including the owner assignment (`assignee_id`). |
|
||||||
|
|
||||||
So a fresh deployment gives you **shared, server-stored projects and comments
|
Saves go through a **durable client-side sync outbox**: edits are written to the
|
||||||
immediately**. Moving SOPs and Work Packages off the browser and onto the API
|
API immediately, and if the device is offline they queue and retry when it
|
||||||
(so they're shared across users too) is a front-end change only — the database
|
reconnects (4xx rejections are dropped rather than retried forever). The browser
|
||||||
and endpoints are already in place.
|
cache is only an offline fallback that reconciles through that outbox — so two
|
||||||
|
users on the same project see the same server-stored SOP and Work Packages.
|
||||||
|
|
||||||
## Data model (PostgreSQL)
|
## Data model (PostgreSQL)
|
||||||
|
|
||||||
| Table | Holds | Key columns |
|
| Table | Holds | Key columns |
|
||||||
|-------|-------|-------------|
|
|-------|-------|-------------|
|
||||||
| `projects` | top-level construction projects | `name`, `number`, `client`, `division`, `site`, `sample`, `data` |
|
| `projects` | top-level construction projects | `name`, `number`, `client`, `division`, `site`, `sample`, `archived_at`, `data` |
|
||||||
| `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) |
|
| `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) |
|
||||||
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `issued_at`, `data` (full WP JSON) |
|
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `assignee_id` (owner), `issued_at`, `archived_at`, `data` (full WP JSON) |
|
||||||
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` |
|
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` |
|
||||||
|
| `users` | login accounts | `username`, `password_hash` (bcrypt), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), login-lockout + `token_version` fields |
|
||||||
|
| `project_members` | per-project access control | `user_id` → users, `project_id` → projects |
|
||||||
|
| `audit_log` | append-only activity trail | `actor`, `action`, `entity_type`, `entity_id`, `project_id`, `summary`, `detail` |
|
||||||
|
| `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) |
|
||||||
|
| `app_settings` | admin-configured settings (e.g. email) | `key`, `value` (JSON) |
|
||||||
|
|
||||||
The complete client document is stored verbatim in each row's `data` JSON
|
The complete client document is stored verbatim in each row's `data` JSON
|
||||||
column; frequently-listed fields are promoted to real columns for filtering.
|
column; frequently-listed fields are promoted to real columns for filtering.
|
||||||
|
|
||||||
### Endpoints (summary)
|
### Endpoints (summary)
|
||||||
|
|
||||||
Projects `GET/POST /api/projects`, `GET/DELETE /api/projects/{id}` ·
|
Projects `GET/POST /api/projects`, `GET/DELETE /api/projects/{id}`,
|
||||||
|
`POST /api/projects/{id}/archive` ·
|
||||||
SOPs `GET/POST /api/sops`, `GET /api/sops/latest`, `GET/DELETE /api/sops/{id}` ·
|
SOPs `GET/POST /api/sops`, `GET /api/sops/latest`, `GET/DELETE /api/sops/{id}` ·
|
||||||
Work Packages `GET/POST /api/wps`, `GET/DELETE /api/wps/{id}`,
|
Work Packages `GET/POST /api/wps`, `GET/DELETE /api/wps/{id}`,
|
||||||
`POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `GET /api/wps/metrics` ·
|
`POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `POST /api/wps/{id}/archive`,
|
||||||
Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments`.
|
`GET /api/wps/metrics` ·
|
||||||
List/latest/metrics accept a `project_id` (and `sop_id`) filter. Full reference
|
Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments` ·
|
||||||
and request shapes: `/api/docs` and [`server/README.md`](server/README.md).
|
Auth `POST /api/auth/login` / `logout`, `GET /api/auth/me`, admin user management
|
||||||
|
under `/api/auth/users` (including `POST /api/auth/users/{id}/auto-add`) ·
|
||||||
|
Admin-only `GET/PUT /api/settings`,
|
||||||
|
`POST /api/settings/test-email`, `GET /api/notifications`,
|
||||||
|
`GET /api/projects/{id}/members`.
|
||||||
|
List/latest/metrics accept a `project_id` (and `sop_id`) filter. `GET /api/projects`
|
||||||
|
and `GET /api/wps` both take `archived=exclude|only|all` and **default to
|
||||||
|
`exclude`** — anything that needs to see archived rows (the admin console, the demo
|
||||||
|
cleanup) must ask for them. Full reference and request shapes: `/api/docs` and
|
||||||
|
[`server/README.md`](server/README.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -202,27 +298,224 @@ docker compose up -d --build api # backend change (server/)
|
|||||||
|
|
||||||
## Backups & retention
|
## Backups & retention
|
||||||
|
|
||||||
The whole dataset is in the `pgdata` volume — back it up on a schedule:
|
A **`backup` sidecar** (in `docker-compose.yml`) runs `pg_dump` on a schedule and
|
||||||
|
writes gzipped, timestamped dumps to `./backups/` on the host. It starts with the
|
||||||
|
stack — no cron to set up.
|
||||||
|
|
||||||
```bash
|
- **Cadence / retention:** daily, keeping the newest 14 dumps. Override in `.env`
|
||||||
# Backup (run from project root)
|
with `BACKUP_INTERVAL_SECONDS` (seconds between dumps) and `BACKUP_KEEP` (how many
|
||||||
docker compose exec -T db pg_dump -U wpsuite wpsuite > backup-$(date +%F).sql
|
to keep).
|
||||||
|
- **Encryption at rest:** set `BACKUP_ENC_PASSPHRASE` in `.env` and dumps are
|
||||||
|
written AES-256-encrypted as `*.sql.gz.enc`. **Do this before any customer IP
|
||||||
|
goes in** — without it the dumps (and every offsite copy) are plaintext. Store
|
||||||
|
the passphrase somewhere other than this host; if you lose it the backups can't
|
||||||
|
be restored.
|
||||||
|
- **Ad-hoc backup now:** `docker compose exec backup sh /scripts/db-backup.sh`
|
||||||
|
- **Restore (destructive — overwrites current data):**
|
||||||
|
`docker compose exec backup sh /scripts/db-restore.sh /backups/wpsuite-YYYYMMDD-HHMMSSZ.sql.gz.enc`
|
||||||
|
- **Offsite — do this:** the dumps live in `./backups/` on the host; if the host/volume
|
||||||
|
dies, so do they. Sync that folder offsite from the **host** (e.g. a cron running
|
||||||
|
`rclone`/`aws s3 sync`). The `db`/`backup` containers are on an egress-less
|
||||||
|
`internal` network on purpose, so offsite must be pushed from the host.
|
||||||
|
- **Test restores quarterly:** load the latest dump into a throwaway database and
|
||||||
|
confirm it applies. An untested backup is not a backup.
|
||||||
|
|
||||||
# Restore
|
## Field devices & data at rest
|
||||||
docker compose exec -T db psql -U wpsuite -d wpsuite < backup-YYYY-MM-DD.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
## Schema migrations (important)
|
The field view (PWA) caches a project's Work Packages/SOP in the browser's
|
||||||
|
localStorage so it works offline — i.e. **customer IP sits on the device**.
|
||||||
|
localStorage is not encrypted and is not a security boundary. Signing out clears
|
||||||
|
the cached project data, but for any tablet/phone that opens customer-IP projects:
|
||||||
|
|
||||||
Tables are auto-created on API startup (`Base.metadata.create_all`). This
|
- **Require full-disk encryption** (BitLocker / FileVault / Android FBE / iOS is
|
||||||
creates **missing tables**, but it does **not** alter existing ones. The
|
encrypted by default) and a device passcode.
|
||||||
multi-project work added the `projects` table and new columns
|
- **Enrol field devices in MDM** so a lost device can be remotely wiped, and keep
|
||||||
(`sops.project_id`, `work_packages.project_id` / `parent_id` / `issued_at`):
|
the browser profile per-user on shared devices.
|
||||||
|
- Users should **sign out** when handing off a shared device (clears the cache).
|
||||||
|
|
||||||
- On a **fresh** database these appear automatically — nothing to do.
|
## Email notifications (optional)
|
||||||
- On a database that **already has data** from an older schema, add the new
|
|
||||||
columns with a migration (introduce **Alembic**) or apply them manually with
|
Work-package **owner assignment** works out of the box (in-app only). Optional
|
||||||
`ALTER TABLE` before deploying — don't rely on `create_all` for column changes.
|
**email** on assignment is **OFF by default** and is turned on from the **Admin
|
||||||
|
console → Notifications & email** card, where an admin sets the SMTP host / port /
|
||||||
|
TLS / From address and flips the master toggle.
|
||||||
|
|
||||||
|
- The **SMTP password is never stored in the database.** It is read only from the
|
||||||
|
`SMTP_PASSWORD` environment variable (see the `.env` block in step 2 and the
|
||||||
|
`api` service in `docker-compose.yml`). The UI shows only whether it is set.
|
||||||
|
- Email stays effectively off until **all** of: the toggle is on, SMTP host + From
|
||||||
|
are configured, and `SMTP_PASSWORD` is present. Until then, assignments are
|
||||||
|
still recorded in-app (status `skipped`); nothing is sent.
|
||||||
|
- Notification emails carry only a **WP number and a deep link** — never the work
|
||||||
|
package contents — so customer IP stays behind the login.
|
||||||
|
- Use the card's **Send test email** button to confirm SMTP before enabling.
|
||||||
|
|
||||||
|
### Self-service password reset
|
||||||
|
|
||||||
|
Turning email on also enables **Forgot password** on the login page. Until then the
|
||||||
|
link explains that an admin must reset it (`server/manage_users.py`, or the Admin
|
||||||
|
console's **Reset password** button).
|
||||||
|
|
||||||
|
- The emailed link carries a short-lived signed token — `AUTH_RESET_MINUTES`
|
||||||
|
(default 60). It is **single-use**: completing a reset bumps the account's
|
||||||
|
`token_version`, which both burns the link and signs out that user's other
|
||||||
|
sessions. A completed reset also clears any login lockout.
|
||||||
|
- `/api/auth/forgot-password` answers **identically for unknown accounts**, so it
|
||||||
|
can't be used to discover usernames. Misses are recorded in the audit log
|
||||||
|
(`password_reset_miss`) instead.
|
||||||
|
- One reset mail per account+client per `AUTH_RESET_COOLDOWN_SECONDS` (default 120)
|
||||||
|
so the form can't be used to flood someone's inbox. The throttle is per worker
|
||||||
|
and in-memory; the token expiry is the real control.
|
||||||
|
- Reset mails are sent **immediately, not through the notifications outbox** — a
|
||||||
|
reset link must never be persisted where an admin could read it and take over an
|
||||||
|
account.
|
||||||
|
- Set `app_base_url` in the admin card, or the emailed link will be relative and
|
||||||
|
therefore useless.
|
||||||
|
|
||||||
|
## Permissions roles
|
||||||
|
|
||||||
|
`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 on the **User Directory** page (`users.html`) — not the Admin Console,
|
||||||
|
which no longer manages accounts.
|
||||||
|
|
||||||
|
| Role | May do |
|
||||||
|
|---|---|
|
||||||
|
| `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 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
|
||||||
|
hides the BIM/VDC section and every project is install-only (IWP). A SOP that
|
||||||
|
already has BIM enabled keeps its data — it just stops being offered — so turning
|
||||||
|
the flag off never deletes BIM types, gates, or sequence steps.
|
||||||
|
|
||||||
|
## Release gates (constraints + predecessors)
|
||||||
|
|
||||||
|
A work package reaches **Issued** only when both gates are met:
|
||||||
|
|
||||||
|
1. every constraint is **Cleared** or **N/A** — a hard gate, no override;
|
||||||
|
2. every **predecessor work package** (`data.predecessors`, a list of WP ids) is
|
||||||
|
**Closed**.
|
||||||
|
|
||||||
|
Enforced by `enforce_release_gates()` on **every** path that can set a status —
|
||||||
|
`/api/wps` (the browser and the offline outbox both save through it),
|
||||||
|
`/api/wps/{id}/issue`, and `/api/wps/{id}/status`. Also:
|
||||||
|
|
||||||
|
- **Overridable, deliberately.** Planners legitimately release ahead of upstream
|
||||||
|
close-out, so the predecessor gate accepts `data.gateOverride = {reason, by, at}`.
|
||||||
|
A blank reason is not an override. The server writes a `gate_overridden` audit
|
||||||
|
event naming the reason and what was skipped, and the reason prints on the
|
||||||
|
package. Changing the predecessor set clears the override.
|
||||||
|
- **Cycles are refused** (`check_predecessor_cycle`) — direct and through a chain,
|
||||||
|
with a 400 explaining which package already waits on this one.
|
||||||
|
- **A deleted predecessor does not block.** It would otherwise freeze everything
|
||||||
|
downstream of a package someone removed.
|
||||||
|
- The Creator's picker hides itself and any package that already waits on it, so a
|
||||||
|
cycle is hard to build in the first place; the dashboard refuses to issue a
|
||||||
|
blocked package and points at the form for the logged override.
|
||||||
|
|
||||||
|
`data.seq` (the SOP sequence phase) is still stored and shown, but it is
|
||||||
|
descriptive — it gates nothing.
|
||||||
|
|
||||||
|
## Critical constraints reopened after release
|
||||||
|
|
||||||
|
A constraint marked **Critical** on the SOP that reopens **after** the package was
|
||||||
|
released emails the **owner, PM, CM and everyone on the package's distribution
|
||||||
|
list** (minus whoever reopened it), and writes a `constraint_reopened` audit event.
|
||||||
|
|
||||||
|
Detected by comparing incoming constraints against the stored ones inside the
|
||||||
|
normal upsert — *not* a separate endpoint, because the browser saves through the
|
||||||
|
sync outbox, which only replays `POST /api/wps`; anything hung off another route
|
||||||
|
would be lost offline. It fires only on a real transition (cleared/N-A → open), so
|
||||||
|
re-saving an already-open constraint doesn't re-announce, and never for a package
|
||||||
|
that was never released or a non-critical constraint. Bodies carry the constraint
|
||||||
|
name, WP number and a link — never the package contents.
|
||||||
|
|
||||||
|
## Localization (dates, times, numbers)
|
||||||
|
|
||||||
|
Three levels, most specific first — resolved in `html/wp-format.js`:
|
||||||
|
|
||||||
|
1. **the user's own preference** — *Language & time* in the top-right menu
|
||||||
|
(`users.locale` / `users.timezone`, via `POST /api/auth/preferences`)
|
||||||
|
2. **the app default** — Admin console → Features → *Localization defaults*
|
||||||
|
(`default_locale` / `default_timezone`)
|
||||||
|
3. **the browser**, as before
|
||||||
|
|
||||||
|
Timezone names are validated against the server's own `zoneinfo` database, and the
|
||||||
|
picker is fed from `GET /api/timezones` so it can only offer what will be accepted.
|
||||||
|
Calendar dates (a due date, a kitting date) are formatted from their parts and are
|
||||||
|
**never** shifted by a timezone — only real instants (MIMO windows, history,
|
||||||
|
notifications) are converted. Use the shared helpers (`wpFormatDate`,
|
||||||
|
`wpFormatDateTime`, `wpFormatTime`, `wpFormatNumber`) rather than
|
||||||
|
`toLocaleString()`, or a page will quietly ignore the preference.
|
||||||
|
|
||||||
|
## Top-bar chrome (project switcher + search)
|
||||||
|
|
||||||
|
`html/wp-chrome.js` + `wp-chrome.css` inject a project switcher and a centered
|
||||||
|
global search into whichever top bar a page has — the dark `.wp-appbar` or the
|
||||||
|
older `.header`. It is skipped inside an iframe, so the embedded WP creator does
|
||||||
|
not get a second bar.
|
||||||
|
|
||||||
|
- Switching project reloads the current page with `?project=<id>`; every page
|
||||||
|
already resolves its project from that parameter.
|
||||||
|
- Search calls `GET /api/search?q=`, which is **scoped to the caller's projects**
|
||||||
|
(`scope_to_access`) and hides archived work packages, archived projects, and
|
||||||
|
anything belonging to an archived project. LIKE wildcards in the query are escaped,
|
||||||
|
so searching `100%` matches a literal `100%`. Two-character minimum.
|
||||||
|
- Ctrl/Cmd-K focuses the field from anywhere.
|
||||||
|
|
||||||
|
## Schema migrations (Alembic)
|
||||||
|
|
||||||
|
Schema is managed by **Alembic** (`server/alembic/`). The API container runs
|
||||||
|
`alembic upgrade head` on startup (see the `Dockerfile` CMD), so **deploys apply
|
||||||
|
pending migrations automatically**.
|
||||||
|
|
||||||
|
- The **baseline** migration is idempotent: on a fresh database it creates every
|
||||||
|
table; on a database whose tables already exist (made by the old `create_all`)
|
||||||
|
it adopts the schema as-is — no manual `alembic stamp` needed.
|
||||||
|
- Local dev on SQLite still auto-creates tables for a zero-config run; Postgres is
|
||||||
|
migrations-only.
|
||||||
|
- **To change the schema:** edit `server/models.py`, then generate and review a
|
||||||
|
migration before committing:
|
||||||
|
```bash
|
||||||
|
# from the project root (against your dev SQLite or a staging DB)
|
||||||
|
python -m alembic -c server/alembic.ini revision --autogenerate -m "describe the change"
|
||||||
|
python -m alembic -c server/alembic.ini upgrade head # apply locally to test
|
||||||
|
```
|
||||||
|
The next `docker compose up -d --build api` applies it in production on startup.
|
||||||
|
|
||||||
## Local trial without Postgres
|
## Local trial without Postgres
|
||||||
|
|
||||||
@@ -230,3 +523,110 @@ For a quick local look, the API falls back to a SQLite file when `DATABASE_URL`
|
|||||||
is unset (`sqlite:///./wpsuite.db`) — see [`server/README.md`](server/README.md)
|
is unset (`sqlite:///./wpsuite.db`) — see [`server/README.md`](server/README.md)
|
||||||
§ *Local dev*. The front end alone can also be served statically from `html/`
|
§ *Local dev*. The front end alone can also be served statically from `html/`
|
||||||
(it falls back to browser storage when the API isn't reachable).
|
(it falls back to browser storage when the API isn't reachable).
|
||||||
|
|
||||||
|
## Per-project permissions
|
||||||
|
|
||||||
|
`users.role` is the account's **default** permissions role. A membership row can
|
||||||
|
override it **per project** (`project_members.role`), so someone can be Project
|
||||||
|
Admin on one job and a plain Project User on another. Empty means "inherit the
|
||||||
|
account's role", which is how every pre-existing membership behaves.
|
||||||
|
|
||||||
|
Resolved by `effective_role()` in `server/app.py`; `require_project_admin()` uses it,
|
||||||
|
so deleting a work package, changing a completed SOP and deleting a project are all
|
||||||
|
judged **on that project**. An app `admin` is admin everywhere and bypasses
|
||||||
|
membership entirely.
|
||||||
|
|
||||||
|
Set it in **Admin console → User administration → Project access** (its own column,
|
||||||
|
showing how many projects each account can reach). The dialog ticks project access
|
||||||
|
and picks the role on each; `/api/auth/users/{id}/projects` takes
|
||||||
|
`{project_ids: [...], roles: {project_id: role}}` and only accepts the two
|
||||||
|
project-scoped roles. Changes are audit-logged as `project_access_changed`.
|
||||||
|
|
||||||
|
**Who appears in the SOP's people pickers** is `GET /api/projects/{id}/members` —
|
||||||
|
the project's members plus app admins, each with their effective role on that
|
||||||
|
project. A project with nobody assigned shows only the admins, which is why
|
||||||
|
assigning people is the first step on a new job.
|
||||||
|
|
||||||
|
### Default members on new projects
|
||||||
|
|
||||||
|
Memberships are also created automatically. **Admin console → Default members on
|
||||||
|
new projects** flags accounts (`users.auto_add_projects`) that belong on every job —
|
||||||
|
the PM who runs them all, the QC lead — with the role they should hold there
|
||||||
|
(`users.auto_add_role`, sharing `project_members.role`'s value space, `''` =
|
||||||
|
inherit the account's own).
|
||||||
|
|
||||||
|
- It applies **only to projects created after the flag is set**. Nothing is
|
||||||
|
back-filled onto existing jobs; use **Project access** for those.
|
||||||
|
- App admins are skipped (they already reach every project) and the flag is cleared
|
||||||
|
if an account is promoted to admin. Inactive accounts are skipped.
|
||||||
|
- Runs in `add_default_members()` on the `is_new` branch of `upsert_project`, so it
|
||||||
|
covers every route into project creation — the home page, the sample project, the
|
||||||
|
demo seeder. An update never re-runs it.
|
||||||
|
- If the creator is themselves a flagged member, the membership created for them as
|
||||||
|
creator carries their `auto_add_role`, so they aren't silently downgraded on the
|
||||||
|
one job they started.
|
||||||
|
- Audit-logged once per project as `project_access_granted` with
|
||||||
|
`detail.reason = "auto_add_projects"`.
|
||||||
|
|
||||||
|
## Archiving a project
|
||||||
|
|
||||||
|
A finished job is archived rather than deleted: `projects.archived_at`, set from
|
||||||
|
**Admin console → Projects** (or `POST /api/projects/{id}/archive`, which needs
|
||||||
|
Project Admin **on that project**, same bar as deleting it).
|
||||||
|
|
||||||
|
An archived project is **hidden and frozen**:
|
||||||
|
|
||||||
|
- It leaves the home picker, the app-bar switcher and global search, because
|
||||||
|
`GET /api/projects` defaults to `archived=exclude`.
|
||||||
|
- It is still readable by id, so a deep link renders it — with a read-only banner
|
||||||
|
from `wp-chrome.js` — and the admin console still lists it under
|
||||||
|
`?archived=all`.
|
||||||
|
- Every write that lands on it is refused with **409** by
|
||||||
|
`require_project_writable()`: saving a project, SOP or work package, deleting
|
||||||
|
either, issuing, status changes, WP archiving, and comments on its WPs/SOPs.
|
||||||
|
Moving a work package *into* or *out of* an archived project is refused too.
|
||||||
|
409 rather than 403 is deliberate — nobody lacks a permission, the project's state
|
||||||
|
is the objection, and the browser outbox (`html/project-data.js`) retires 4xx ops
|
||||||
|
instead of retrying them forever.
|
||||||
|
- Unarchiving and **deleting** stay allowed: unarchive is the one write an archived
|
||||||
|
project must accept, and archive-then-delete is a normal sequence.
|
||||||
|
|
||||||
|
Nothing is removed, and unarchiving restores all of it. `server/smoketest.py`
|
||||||
|
asserts the whole round trip.
|
||||||
|
|
||||||
|
## Asset freshness (why the app can't run half-updated)
|
||||||
|
|
||||||
|
A page must never run against a stylesheet or script from a previous deploy. Three
|
||||||
|
things enforce that, and all three are needed:
|
||||||
|
|
||||||
|
1. **`Cache-Control: no-cache` on HTML/CSS/JS** — set by NGINX
|
||||||
|
(`nginx/conf.d/wp-suite.conf`) and by the dev server (`_NoCacheCode` in
|
||||||
|
`server/app.py`). With no header at all the browser applies *heuristic* freshness,
|
||||||
|
roughly 10% of each file's age, so the least recently changed file gets the longest
|
||||||
|
lifetime — which is exactly how HTML and CSS drift apart. ETag/Last-Modified still
|
||||||
|
make each revalidation a cheap 304.
|
||||||
|
2. **The service worker fetches code with `cache: 'no-cache'`** (`html/sw.js`) and
|
||||||
|
precaches with `cache: 'reload'`. A plain `fetch(req)` inherits the request's
|
||||||
|
default cache mode and consults the browser HTTP cache, so "network-first" alone
|
||||||
|
was not enough. Non-`ok` responses fall back to the cache rather than replacing a
|
||||||
|
page the cache could still serve, and cache keys drop the query string so in-app
|
||||||
|
links (`?project=…&tab=…`) still resolve offline.
|
||||||
|
3. **Components whose CSS-missing state is *broken* carry their own critical layout.**
|
||||||
|
The embedded creator's iframe keeps its sizing inline (and `sizeWPFrame()` re-applies
|
||||||
|
it), and the work-package panel injects a floor of positioning rules from
|
||||||
|
`wp-creation-app.js`. Both had failure modes — a 300×150 iframe, and panel controls
|
||||||
|
dumped loose into the form — that a missing rule turned into a broken page rather
|
||||||
|
than a plain one.
|
||||||
|
|
||||||
|
If you change the shell file list in `sw.js`, bump `CACHE`.
|
||||||
|
|
||||||
|
> **NGINX note:** the `Cache-Control` value comes from a `map $uri $wp_cache_control`
|
||||||
|
> at http level, applied with a single server-level `add_header`. Do **not** move it
|
||||||
|
> into a `location` block: nginx does not inherit `add_header` into a block that
|
||||||
|
> declares its own, so a `location ~* \.(html|css|js)$` setting only `Cache-Control`
|
||||||
|
> silently drops the CSP / HSTS / X-Frame-Options / nosniff headers for exactly those
|
||||||
|
> files. After deploying, confirm both are present on one response:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> curl -sI https://wp-suite.company.local/work-package-suite.html > | grep -Ei 'cache-control|content-security-policy'
|
||||||
|
> ```
|
||||||
|
|||||||
@@ -4,5 +4,8 @@ COPY server/requirements.txt ./server/
|
|||||||
RUN pip install --no-cache-dir -r server/requirements.txt
|
RUN pip install --no-cache-dir -r server/requirements.txt
|
||||||
COPY server/ ./server/
|
COPY server/ ./server/
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", \
|
# Apply any pending DB migrations, THEN start the app. `alembic upgrade head` is
|
||||||
"-b", "0.0.0.0:8000", "--workers", "2", "server.app:app"]
|
# safe on both fresh and existing databases (the baseline migration adopts an
|
||||||
|
# existing schema, so no manual stamp is needed). `exec` hands PID 1 to gunicorn
|
||||||
|
# for correct signal handling; --preload imports the app once before forking.
|
||||||
|
CMD ["sh", "-c", "alembic -c server/alembic.ini upgrade head && exec gunicorn -k uvicorn.workers.UvicornWorker --preload -b 0.0.0.0:8000 --workers 2 server.app:app"]
|
||||||
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.
|
||||||
196
KNOWN-ISSUES.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# Known issues — Work Package Suite
|
||||||
|
|
||||||
|
Defects and limitations we know about and have decided not to fix yet. An entry
|
||||||
|
here is a commitment to a decision, not a bug tracker: it says what is wrong, what
|
||||||
|
it costs, why it is still open, and what closing it takes.
|
||||||
|
|
||||||
|
Anything genuinely urgent does not belong here — it belongs in the next deploy.
|
||||||
|
|
||||||
|
Close an entry by deleting it in the same commit that fixes it.
|
||||||
|
|
||||||
|
| # | Issue | Severity | Raised | Status |
|
||||||
|
|---|-------|----------|--------|--------|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. XSS via SOP discipline names in the WP creator
|
||||||
|
|
||||||
|
**Files:** `html/wp-creation-app.js` lines 684, 723, 727, 729 · escaping helper at
|
||||||
|
line 63
|
||||||
|
**Predates:** the 2026-08-05 archive/admin-console work. Not introduced by it.
|
||||||
|
|
||||||
|
### What is wrong
|
||||||
|
|
||||||
|
Discipline names are rendered into inline event handlers escaped with `esc()`,
|
||||||
|
which maps `'` to `'`. That is correct for text and wrong here. The browser
|
||||||
|
decodes entities in an attribute value **before** the JavaScript parser sees it, so
|
||||||
|
`'` becomes a bare `'` inside the handler's string literal and closes it early.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// html/wp-creation-app.js:684 — esc() is not sufficient for a handler argument
|
||||||
|
onchange="toggleDiscipline('${esc(d)}',this.checked)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Escaping for an inline handler has to happen in this order: **backslash, then
|
||||||
|
quote** (for the JS string literal), **then HTML** (for the attribute carrying it).
|
||||||
|
`esc()` only does the last part.
|
||||||
|
|
||||||
|
### How it is reached
|
||||||
|
|
||||||
|
1. `gov_disciplines` (`html/work-package-suite.html:219`) is a free-text field. Its
|
||||||
|
value is comma-split with no validation at `work-package-suite-app.js:1224`.
|
||||||
|
2. It is saved into `sops.data` and syncs to the server via `ProjectData.pushSOP`.
|
||||||
|
3. Every other member of that project pulls it with `pullProject()` and renders it
|
||||||
|
in the WP creator — so this is **stored** and **cross-user**, and it fires on
|
||||||
|
page load rather than needing the victim to click anything.
|
||||||
|
|
||||||
|
Any **project_user** on the job can set it while the SOP is a draft (after the SOP
|
||||||
|
is marked complete it takes project_admin). The victim is anyone who opens the WP
|
||||||
|
creator for that project, which includes administrators.
|
||||||
|
|
||||||
|
### What it costs
|
||||||
|
|
||||||
|
**The likely cost is a broken screen, not an attack.** A discipline named
|
||||||
|
`Owner's Equipment` — an ordinary thing to type — produces a syntax error in the
|
||||||
|
handler, so the discipline pill and its scope-step buttons silently stop
|
||||||
|
responding. No error message, nothing a field user can diagnose.
|
||||||
|
|
||||||
|
**The security ceiling is project_user → admin.** The session cookie is HttpOnly so
|
||||||
|
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
|
||||||
|
`script-src 'self' 'unsafe-inline'`, and `'unsafe-inline'` is what permits inline
|
||||||
|
event handlers in the first place.
|
||||||
|
- **The CSRF gate does not mitigate it.** `_csrf_ok` (`server/app.py:67`) only
|
||||||
|
requires a same-origin `Origin`, and code running inside our own page is
|
||||||
|
same-origin.
|
||||||
|
|
||||||
|
### Why it is still open
|
||||||
|
|
||||||
|
The suite is internal, behind a login, on the corporate network, with a small set
|
||||||
|
of named employee accounts and no anonymous input path. Exploiting it means an
|
||||||
|
employee deliberately attacking colleagues, and the audit log carries their name on
|
||||||
|
the SOP edit. The accidental-breakage case is far more likely to be met than the
|
||||||
|
malicious one.
|
||||||
|
|
||||||
|
**Re-rate this as High and fix it immediately if any of these become true:** the
|
||||||
|
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
|
||||||
|
builder on 2026-08-05 for the same bug in custom constraint names:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// html/work-package-suite-app.js:1120
|
||||||
|
function escHandlerArg(v){ return escAttr(String(v==null?'':v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")); }
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Add the same helper to `html/wp-creation-app.js` alongside `esc()`.
|
||||||
|
2. Use it at lines 684, 723, 727 and 729 in place of `esc(d)`.
|
||||||
|
3. Sweep the other inline handlers in that file for the same pattern. The remaining
|
||||||
|
ones interpolate server-generated ids that `check_id()` already constrains to a
|
||||||
|
safe charset, or hardcoded enum values, so they are not currently reachable —
|
||||||
|
converting them anyway keeps the pattern from coming back.
|
||||||
|
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/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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Archived projects: the two big apps don't grey out their own controls
|
||||||
|
|
||||||
|
**Files:** `html/wp-creation-app.js`, `html/work-package-suite-app.js`
|
||||||
|
**Raised:** 2026-08-05, with the project-archiving work.
|
||||||
|
|
||||||
|
### What is wrong
|
||||||
|
|
||||||
|
Archiving a project freezes it server-side — every write returns 409 (see
|
||||||
|
`require_project_writable` in `server/app.py`, and the *Archiving a project*
|
||||||
|
section of `DEPLOYMENT.md`). The front end tells the user, but does not stop them:
|
||||||
|
`wp-chrome.js` shows a read-only banner and sets `data-wp-archived="1"` on the
|
||||||
|
document element, and nothing reads that attribute yet. So on an archived project
|
||||||
|
the WP creator and the SOP builder still present working Save and Issue buttons.
|
||||||
|
|
||||||
|
### What it costs
|
||||||
|
|
||||||
|
Low, and it fails safe — the server refuses the write, so nothing is corrupted and
|
||||||
|
no data is lost. The cost is wasted effort and a confusing moment: someone deep-
|
||||||
|
linked to an archived job can fill in a form and only learn it was refused when the
|
||||||
|
sync indicator reports the change did not save.
|
||||||
|
|
||||||
|
Reaching an archived project at all takes a deep link or a stale tab, since it is
|
||||||
|
gone from every picker, switcher and search — which is why this is a rough edge
|
||||||
|
rather than a defect.
|
||||||
|
|
||||||
|
### Why it is still open
|
||||||
|
|
||||||
|
Gating every control in two large single-page apps is materially bigger than the
|
||||||
|
archive feature itself, and the server is the real enforcement boundary either way.
|
||||||
|
The banner plus the sync indicator were judged enough for a first release.
|
||||||
|
|
||||||
|
### What closing it takes
|
||||||
|
|
||||||
|
`data-wp-archived` is already on the document element for exactly this purpose.
|
||||||
|
Either add `[data-wp-archived]` rules in `wp-chrome.css` that disable and dim the
|
||||||
|
save/issue controls, or add a boot check in each app that disables them and shows a
|
||||||
|
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.
|
||||||
0
backups/.gitkeep
Normal file
@@ -19,13 +19,38 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
container_name: wp_api
|
container_name: wp_api
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
# Preferred: the API builds its own connection string from these and
|
||||||
|
# encodes the password automatically (no manual URL-encoding needed).
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_HOST: db
|
||||||
|
# Optional full-URL override (must be URL-encoded if used).
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-}
|
||||||
|
# Signs login session cookies. REQUIRED — compose fails fast if it's unset,
|
||||||
|
# and the API refuses to start in production without it (see server/auth.py).
|
||||||
|
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?set AUTH_SECRET_KEY in .env (see server/.env.example)}
|
||||||
|
AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12}
|
||||||
|
# Optional — SMTP password for WP-assignment emails. Email is off by
|
||||||
|
# default and enabled from the Admin console; this is the only email
|
||||||
|
# secret and it is never stored in the DB. Leave unset until configured.
|
||||||
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
|
# Optional — read-only SQL Server connection to the Micron asset catalog,
|
||||||
|
# which backs the asset picker in the work package creator. Leave unset and
|
||||||
|
# the picker cleanly falls back to manual entry (see server/assets_db.py).
|
||||||
|
# Use a db_datareader login: the app only ever SELECTs.
|
||||||
|
MICRON_DB_URL: ${MICRON_DB_URL:-}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy # waits for postgres to accept connections
|
condition: service_healthy # waits for postgres to accept connections
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
|
# Reaching the Micron database means leaving this compose project, and
|
||||||
|
# `internal` is deliberately egress-free. `outbound` is attached to the api
|
||||||
|
# container ONLY — the database and backup containers stay sealed. Detach it
|
||||||
|
# again if you are not using the Micron asset picker.
|
||||||
|
- outbound
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -45,6 +70,35 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
|
|
||||||
|
# Scheduled pg_dump backups. Writes gzipped, timestamped dumps to ./backups on
|
||||||
|
# the host (sync that folder offsite from the host — this container has no
|
||||||
|
# internet egress). See scripts/db-backup.sh and DEPLOYMENT.md § Backups.
|
||||||
|
backup:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: scripts/backup.Dockerfile # postgres client + openssl
|
||||||
|
container_name: wp_db_backup
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
PGHOST: db
|
||||||
|
BACKUP_DIR: /backups
|
||||||
|
BACKUP_KEEP: ${BACKUP_KEEP:-14} # keep the newest N dumps
|
||||||
|
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400} # 86400 = daily
|
||||||
|
# Set BACKUP_ENC_PASSPHRASE in .env to encrypt dumps at rest (AES-256).
|
||||||
|
# Required once the DB holds customer IP. Keep the passphrase off this host.
|
||||||
|
BACKUP_ENC_PASSPHRASE: ${BACKUP_ENC_PASSPHRASE:-}
|
||||||
|
volumes:
|
||||||
|
- ./scripts:/scripts:ro
|
||||||
|
- ./backups:/backups
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
nginx_logs:
|
nginx_logs:
|
||||||
@@ -54,4 +108,12 @@ networks:
|
|||||||
name: proxy
|
name: proxy
|
||||||
external: true
|
external: true
|
||||||
internal:
|
internal:
|
||||||
internal: true # no outbound internet access from api/db
|
internal: true # no route off the host for anything on this network alone
|
||||||
|
outbound:
|
||||||
|
# An ordinary bridge network, i.e. one that HAS a default gateway. `internal`
|
||||||
|
# above removes the gateway entirely, which blocks not just the internet but
|
||||||
|
# the LAN and the VPN too — so the api container needs this second network to
|
||||||
|
# reach the Micron asset database. Attached to `api` alone: `db` and `backup`
|
||||||
|
# remain on `internal` only and still have no way off the host.
|
||||||
|
# Detach it from api if you are not using the Micron asset picker.
|
||||||
|
driver: bridge
|
||||||
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
596
docs/waves/backlog.md
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
### BL-026 — No version stamp: "is live current?" cannot be answered from the app
|
||||||
|
|
||||||
|
- **Found during:** the 2026-08-21 outage triage (the question that started it)
|
||||||
|
- **Where:** `Dockerfile` / build, `server/app.py` `/api/health`, admin console
|
||||||
|
- **What:** the app carries no record of what code it is running. `/api/health`
|
||||||
|
returns `{"ok": true}` and nothing identifies the deployed commit, so
|
||||||
|
answering "is the live site on the latest code?" took fingerprinting
|
||||||
|
(probing for files/routes that only exist after certain merges) in the
|
||||||
|
middle of an outage. The fix: bake the git SHA into the image at build time
|
||||||
|
(`ARG GIT_SHA`), return it from `/api/health`
|
||||||
|
(`{"ok": true, "version": "<sha>"}`), and show it on the admin console's
|
||||||
|
diagnostics card. Then currency is one glance against `git log -1`.
|
||||||
|
- **Why not now:** new scope — needs its own item id per the working rules
|
||||||
|
(D13 is the natural next), and it touches the image build, which deserves a
|
||||||
|
deploy alongside someone with host access.
|
||||||
|
- **Suggested wave or follow-up:** next housekeeping pass; ~1 task including a
|
||||||
|
probe check that /api/health carries a version field.
|
||||||
|
|
||||||
|
### BL-027 — Migrations are rehearsed on SQLite only; production is Postgres
|
||||||
|
|
||||||
|
- **Found during:** the 2026-08-21 production outage (D6's `material_items`
|
||||||
|
migration crash-looped the api container)
|
||||||
|
- **Where:** `DEPLOYMENT.md` (the update/deploy steps), `tests/`
|
||||||
|
- **What:** the migration chain is verified end-to-end on scratch SQLite, but
|
||||||
|
production runs Postgres, and the dialects disagree exactly where it hurts:
|
||||||
|
`server_default=sa.text('1')` on a Boolean passed every SQLite rehearsal and
|
||||||
|
was refused by Postgres at deploy (DatatypeMismatch), taking the API down
|
||||||
|
until the table was created by hand. The hotfix (64eac0c) fixed that one
|
||||||
|
instance and pinned the Boolean-default class in `materials_check`; the
|
||||||
|
CLASS of dialect drift is still unguarded. Two cheap layers: (1) a runbook
|
||||||
|
step — render `alembic upgrade --sql` for the postgresql dialect and read it
|
||||||
|
before restarting (offline, needs no live DB; this render would have shown
|
||||||
|
`DEFAULT 1` on a boolean); (2) better, a probe that renders every migration
|
||||||
|
for the postgresql dialect on each run and fails on anything the dialect
|
||||||
|
rejects or on known-bad patterns.
|
||||||
|
- **Why not now:** the outage is resolved and the one known instance is fixed
|
||||||
|
and pinned; the systematic guard is its own small task, not a hotfix rider.
|
||||||
|
- **Suggested wave or follow-up:** next housekeeping pass, paired with BL-026
|
||||||
|
(both are "deploys should be boring" work).
|
||||||
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`)
|
||||||
236
html/admin.html
@@ -4,96 +4,209 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Admin Console — Work Package Suite</title>
|
<title>Admin Console — 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="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>
|
<style>
|
||||||
:root{ --bg:#f4f5f7; --surface:#fff; --border:#e3e6ec; --border-strong:#d0d5de; --text:#1a2230;
|
/* Page-specific only — the tokens, cards, controls, tables, banners and modal
|
||||||
--muted:#5a6675; --dim:#9aa3b2; --accent:#2563d6; --green:#15924f; --green-bg:#e4f6ec;
|
live in console.css, shared with the User Directory. What stays here is what
|
||||||
--red:#cf3b3b; --red-bg:#fbeaea; --amber:#b87100; --amber-bg:#fdf2e0; --mono:'Cascadia Mono',Consolas,monospace; }
|
only this page has: the per-card scroll boxes admin.js paints tables into, the
|
||||||
*{ box-sizing:border-box; }
|
column exceptions for those tables, and the admins-only notice.
|
||||||
body{ margin:0; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); }
|
|
||||||
.wrap{ max-width:860px; margin:0 auto; padding:28px 20px 80px; }
|
These are addressed by ID because admin.js emits the tables without per-cell
|
||||||
h1{ font-size:20px; margin:0 0 2px; }
|
classes. */
|
||||||
.sub{ color:var(--muted); font-size:13px; margin-bottom:18px; }
|
|
||||||
.card{ background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:18px 20px; margin-bottom:16px; }
|
/* These start life as empty divs that admin.js fills on demand, so they only earn
|
||||||
.card h2{ font-size:14px; margin:0 0 12px; text-transform:uppercase; letter-spacing:.03em; color:var(--accent); }
|
their gap once they are actually saying something. */
|
||||||
button{ font:inherit; font-size:13px; font-weight:600; border-radius:6px; padding:8px 14px; cursor:pointer;
|
#projects-banner:not(:empty), #defmem-banner:not(:empty){ margin-bottom:var(--s3); }
|
||||||
border:1px solid var(--border-strong); background:#fff; color:var(--text); }
|
|
||||||
button:hover{ border-color:var(--accent); color:var(--accent); }
|
/* Every container admin.js paints a table into is a scrollport of its own, so a
|
||||||
button.primary{ background:var(--accent); border-color:var(--accent); color:#fff; }
|
sticky header always has something to stick to rather than sliding up behind
|
||||||
button.primary:hover{ background:#1e54bb; color:#fff; }
|
the app bar. Same rule as console.css's .tscroll. */
|
||||||
button.danger{ border-color:var(--red); color:var(--red); }
|
#comments-admin, #audit-admin, #notif-box, #usage-admin, #projects-table, #defmem-table{
|
||||||
button.danger:hover{ background:var(--red-bg); }
|
overflow:auto; max-height:min(70vh,640px); overscroll-behavior:contain; }
|
||||||
.row{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
|
/* If admin.js wraps its table in its own .tscroll, the outer box steps aside so
|
||||||
.banner{ padding:10px 14px; border-radius:8px; font-size:13px; font-weight:600; margin-top:10px; border:1px solid var(--border); background:var(--surface); }
|
one table never ends up with two scrollbars. */
|
||||||
.banner.ok{ background:var(--green-bg); color:var(--green); border-color:var(--green); }
|
#comments-admin:has(.tscroll), #audit-admin:has(.tscroll), #notif-box:has(.tscroll),
|
||||||
.banner.bad{ background:var(--red-bg); color:var(--red); border-color:var(--red); }
|
#usage-admin:has(.tscroll), #projects-table:has(.tscroll), #defmem-table:has(.tscroll){
|
||||||
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:8px; padding:12px 14px; font-family:var(--mono);
|
overflow:visible; max-height:none; }
|
||||||
font-size:12px; line-height:1.55; white-space:pre-wrap; max-height:340px; overflow:auto; margin:12px 0 0; }
|
|
||||||
pre.out .p{ color:#56d364; font-weight:700; } pre.out .f{ color:#ff7b72; font-weight:700; }
|
/* Comment text and audit detail are the two columns you are actually here to
|
||||||
table.kv{ border-collapse:collapse; font-size:13px; margin-top:8px; }
|
read, so they wrap inside a sane width instead of truncating. */
|
||||||
table.kv th{ text-align:left; padding:5px 18px 5px 0; color:var(--muted); font-weight:600; }
|
#comments-admin table td:nth-child(5){ white-space:normal; min-width:260px; max-width:640px; }
|
||||||
table.kv td{ padding:5px 0; font-variant-numeric:tabular-nums; font-weight:700; }
|
#audit-admin table td:nth-child(6){ white-space:normal; max-width:420px; }
|
||||||
.note{ font-size:12px; color:var(--dim); margin-top:10px; }
|
|
||||||
.gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:20px; }
|
/* The denial notice is a sentence, not a table — don't stretch it to 1240px. */
|
||||||
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:28px; max-width:380px; width:100%; box-shadow:0 8px 30px rgba(20,30,50,.12); }
|
#admin-denied .card{ max-width:560px; }
|
||||||
.gate-box h2{ margin:0 0 4px; font-size:17px; }
|
.gate-box input{ width:100%; height:var(--ctl); padding:0 var(--s3); font:inherit; font-size:14px;
|
||||||
.gate-box p{ color:var(--muted); font-size:13px; margin:0 0 16px; }
|
border:1px solid var(--border-strong); border-radius:0; margin-bottom:var(--s3); }
|
||||||
.gate-box input{ width:100%; padding:10px 12px; font-size:14px; border:1px solid var(--border-strong); border-radius:6px; margin-bottom:12px; }
|
|
||||||
.gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:8px; }
|
@media (max-width:900px){
|
||||||
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:8px; padding:9px 13px; font-size:12px; margin-bottom:16px; }
|
#comments-admin, #audit-admin, #notif-box, #usage-admin, #projects-table, #defmem-table{
|
||||||
a.home{ color:var(--accent); font-size:13px; text-decoration:none; }
|
max-height:none; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- GATE -->
|
<!-- SHARED DARK APP BAR -->
|
||||||
<div class="gate-overlay" id="admin-gate">
|
<header class="wp-appbar">
|
||||||
<div class="gate-box">
|
<a href="index.html" class="wp-appbar-brand" title="Back to site">
|
||||||
<h2>🔒 Admin Console</h2>
|
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
|
||||||
<p>Enter the admin passphrase to continue.</p>
|
<span class="wp-appbar-title">Work Package Suite <span class="wp-appbar-sub">| Admin Console</span></span>
|
||||||
<input type="password" id="gate-input" placeholder="Passphrase" autocomplete="off"
|
</a>
|
||||||
onkeydown="if(event.key==='Enter') tryUnlock()">
|
</header>
|
||||||
<div class="gate-msg" id="gate-msg"></div>
|
|
||||||
<button class="primary" style="width:100%" onclick="tryUnlock()">Unlock</button>
|
<!-- ADMINS ONLY (shown if the signed-in account isn't an admin) -->
|
||||||
|
<div class="wrap" id="admin-denied" style="display:none">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Admins only</h2>
|
||||||
|
<p class="sub">Your account doesn’t have admin access. Sign in with an admin account, or ask an administrator to grant you the admin role.</p>
|
||||||
|
<div class="row"><a class="home" href="index.html">← Back to site</a> <button onclick="wpLogout()">Sign out</button></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CONSOLE -->
|
<!-- CONSOLE -->
|
||||||
<div class="wrap" id="admin-main" style="display:none">
|
<div class="wrap" id="admin-main" style="display:none">
|
||||||
<div class="row" style="justify-content:space-between">
|
<div class="row" style="justify-content:space-between; margin-bottom:var(--s5)">
|
||||||
<div><h1>Work Package Suite — Admin Console</h1><div class="sub">Stack diagnostics & tests · talks to <code>/api</code> on this host</div></div>
|
<div><h1>Admin Console</h1><div class="sub" style="margin:0">Stack diagnostics & tests · talks to <code>/api</code> on this host</div></div>
|
||||||
<div class="row"><a class="home" href="index.html">← Site</a> <button onclick="lock()">Lock</button></div>
|
<div class="row"><a class="home" href="index.html">← Site</a></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="secwarn">⚠ This page is gated client-side only — that stops casual access, not a determined user. For real protection, restrict this host/route at the network or reverse-proxy layer.</div>
|
|
||||||
|
|
||||||
<!-- CONNECTIVITY -->
|
<!-- CONNECTIVITY -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>API connectivity</h2>
|
<h2>API connectivity</h2>
|
||||||
<div class="row"><button class="primary" onclick="checkHealth()">Check /api/health</button></div>
|
<div class="toolbar"><button class="primary" onclick="checkHealth()">Check /api/health</button></div>
|
||||||
<div class="banner" id="health-banner">—</div>
|
<div class="banner" id="health-banner">—</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- USER ADMINISTRATION — moved out to its own page -->
|
||||||
|
<div class="card">
|
||||||
|
<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) -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Projects</h2>
|
||||||
|
<div class="sub">Archiving a project hides it from every picker, switcher and search, and freezes it
|
||||||
|
read-only — nothing is deleted and every work package, SOP and comment is kept exactly as it is.
|
||||||
|
Unarchive here to bring it back; the project returns unchanged.</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button onclick="loadProjects()">Refresh projects</button>
|
||||||
|
<label class="chk"><input type="checkbox" id="proj-show-archived" onchange="renderProjects()"> Show archived</label>
|
||||||
|
<input id="proj-search" placeholder="Search name / number / client…" oninput="renderProjects()">
|
||||||
|
</div>
|
||||||
|
<div id="projects-banner"></div>
|
||||||
|
<div id="projects-table"><div class="note">Click “Refresh projects” to load.</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DEFAULT MEMBERS ON NEW PROJECTS -->
|
||||||
|
<div class="card">
|
||||||
|
<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> 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FEATURE FLAGS -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Features</h2>
|
||||||
|
<div class="sub">Switches that change what the suite offers on every project.</div>
|
||||||
|
<div id="features-box" class="note">Loading…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NOTIFICATIONS / EMAIL -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Notifications & email</h2>
|
||||||
|
<div class="sub">Email notifications for work-package assignments, and self-service password resets. <strong>Off by default</strong> — turn this on only once SMTP is configured. The SMTP <strong>password</strong> is read from the <code>SMTP_PASSWORD</code> environment variable and is never stored here.</div>
|
||||||
|
<div id="settings-box" class="note">Loading…</div>
|
||||||
|
<div id="notif-box" class="note" style="margin-top:14px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ALL FEEDBACK / COMMENTS -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>All feedback & comments</h2>
|
||||||
|
<div class="sub">Every comment submitted across the suite — who wrote it, what they said, and where they were (page & step) when they commented.</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button onclick="loadComments()">Refresh comments</button>
|
||||||
|
<select id="cmt-filter" onchange="renderComments()"><option value="">All sources</option></select>
|
||||||
|
<input id="cmt-search" placeholder="Search text / author…" oninput="renderComments()">
|
||||||
|
</div>
|
||||||
|
<div id="comments-admin" class="note">Click refresh to load.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ACTIVITY LOG (AUDIT TRAIL) -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Activity log</h2>
|
||||||
|
<div class="sub">Who changed what, and when — across projects, SOPs, work packages, and user accounts. Stored server-side in the shared database.</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button onclick="loadAudit()">Refresh</button>
|
||||||
|
<select id="audit-type" onchange="renderAudit()">
|
||||||
|
<option value="">All types</option>
|
||||||
|
<option value="wp">Work packages</option>
|
||||||
|
<option value="sop">SOPs</option>
|
||||||
|
<option value="project">Projects</option>
|
||||||
|
<option value="user">User accounts</option>
|
||||||
|
</select>
|
||||||
|
<input id="audit-search" placeholder="Search actor / action / item…" oninput="renderAudit()">
|
||||||
|
</div>
|
||||||
|
<div id="audit-admin" class="note">Click refresh to load.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- USAGE LOGS -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>Usage logs</h2>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div id="usage-admin" class="note">Click refresh to load.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- DB SNAPSHOT -->
|
<!-- DB SNAPSHOT -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Database snapshot</h2>
|
<h2>Database snapshot</h2>
|
||||||
<div class="row"><button onclick="snapshot()">Refresh counts</button></div>
|
<div class="toolbar"><button onclick="snapshot()">Refresh counts</button></div>
|
||||||
<div id="snapshot-out" class="note">Click refresh to read row counts from SQL via the API.</div>
|
<div id="snapshot-out" class="note">Click refresh to read row counts from SQL via the API.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SMOKE TEST -->
|
<!-- SMOKE TEST -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>End-to-end smoke test</h2>
|
<h2>End-to-end smoke test</h2>
|
||||||
<div class="sub" style="margin-bottom:8px">Creates a throwaway project, exercises the issue gate / status / metrics / comments, then deletes it (cascade). Mirrors <code>server/smoketest.py</code>.</div>
|
<div class="sub">Creates a throwaway project, exercises the issue gate / status / metrics / comments, then deletes it (cascade). Mirrors <code>server/smoketest.py</code>.</div>
|
||||||
<div class="row"><button class="primary" onclick="runSmokeTest()">Run smoke test</button></div>
|
<div class="toolbar"><button class="primary" onclick="runSmokeTest()">Run smoke test</button></div>
|
||||||
<pre class="out" id="smoke-out">Ready.</pre>
|
<pre class="out" id="smoke-out">Ready.</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- DEMO DATA -->
|
<!-- DEMO DATA -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Demo data</h2>
|
<h2>Demo data</h2>
|
||||||
<div class="sub" style="margin-bottom:8px">Seed a realistic <code>DEMO</code> project (SOP + a spread of Work Packages) into SQL, or remove all <code>DEMO-</code>/<code>SMOKE-</code> projects.</div>
|
<div class="sub">Seed a realistic <code>DEMO</code> project (SOP + a spread of Work Packages) into SQL, or remove all <code>DEMO-</code>/<code>SMOKE-</code> projects.</div>
|
||||||
<div class="row">
|
<div class="toolbar">
|
||||||
<button class="primary" onclick="seedDemo()">Seed demo project</button>
|
<button class="primary" onclick="seedDemo()">Seed demo project</button>
|
||||||
<button class="danger" onclick="cleanDemo()">Clean DEMO / SMOKE projects</button>
|
<button class="danger" onclick="cleanDemo()">Clean DEMO / SMOKE projects</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,6 +215,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
656
html/admin.js
@@ -1,52 +1,33 @@
|
|||||||
/* Admin console for the Work Package Suite.
|
/* Admin console for the Work Package Suite.
|
||||||
Browser-side diagnostics + tests that call the same /api on this host.
|
Browser-side diagnostics + tests that call the same /api on this host.
|
||||||
|
|
||||||
PASSPHRASE GATE (lightweight / obfuscation only):
|
ACCESS: the console is gated on the signed-in user's ROLE. auth-guard.js
|
||||||
The gate compares a SHA-256 hash so the passphrase isn't in the source, but a
|
already requires a login (redirecting to login.html otherwise) and publishes
|
||||||
determined user can still bypass client-side JS. For real protection, restrict
|
window.WP_USER; here we show the console only when that user is an admin, and
|
||||||
this host/route at the network or reverse-proxy layer.
|
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.
|
||||||
|
|
||||||
Default passphrase: "prime-admin"
|
USER ACCOUNTS LIVE ON users.html, not here. They moved when the Project Super
|
||||||
To change it: compute a new hash and replace ADMIN_PASSPHRASE_SHA256 below —
|
User role arrived: administering users is no longer an admin-only act, so the
|
||||||
python3 -c "import hashlib,sys;print(hashlib.sha256(sys.argv[1].encode()).hexdigest())" "your-new-passphrase"
|
page that does it can't be behind an admins-only gate. What stays here is what
|
||||||
or in a browser console:
|
genuinely is app-wide and admin-only — settings, feature flags, diagnostics,
|
||||||
crypto.subtle.digest('SHA-256', new TextEncoder().encode('your-new-passphrase'))
|
project archiving, and the default-member rule for future projects.
|
||||||
.then(b=>console.log([...new Uint8Array(b)].map(x=>x.toString(16).padStart(2,'0')).join('')));
|
|
||||||
*/
|
Shared helpers (api, uesc, jsq, the role vocabulary) come from console-util.js. */
|
||||||
const ADMIN_PASSPHRASE_SHA256 = 'ae1fb92c43fccbad26f05434a194f574ec98a2197e0ff4080f84e6e26a8dd00f';
|
|
||||||
|
|
||||||
// ── gate ──────────────────────────────────────────────────────────────────────
|
|
||||||
async function sha256hex(s){
|
|
||||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
|
|
||||||
return [...new Uint8Array(buf)].map(b=>b.toString(16).padStart(2,'0')).join('');
|
|
||||||
}
|
|
||||||
async function tryUnlock(){
|
|
||||||
const v = document.getElementById('gate-input').value || '';
|
|
||||||
const msg = document.getElementById('gate-msg');
|
|
||||||
if(!v){ msg.textContent='Enter the passphrase.'; return; }
|
|
||||||
let h;
|
|
||||||
try { h = await sha256hex(v); }
|
|
||||||
catch(e){ msg.textContent='This page must be served over HTTPS (or localhost) to unlock.'; return; }
|
|
||||||
if(h === ADMIN_PASSPHRASE_SHA256){ sessionStorage.setItem('wp_admin_ok','1'); reveal(); }
|
|
||||||
else { msg.textContent='Incorrect passphrase.'; }
|
|
||||||
}
|
|
||||||
function reveal(){
|
function reveal(){
|
||||||
document.getElementById('admin-gate').style.display='none';
|
|
||||||
document.getElementById('admin-main').style.display='';
|
document.getElementById('admin-main').style.display='';
|
||||||
checkHealth();
|
checkHealth();
|
||||||
|
loadProjects();
|
||||||
|
loadDefaultMembers();
|
||||||
|
loadSettings();
|
||||||
|
loadNotifications();
|
||||||
|
loadComments();
|
||||||
|
loadAudit();
|
||||||
|
loadUsage();
|
||||||
}
|
}
|
||||||
function lock(){ sessionStorage.removeItem('wp_admin_ok'); location.reload(); }
|
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 ──────────────────────────────────────────────────────────────
|
// ── connectivity ──────────────────────────────────────────────────────────────
|
||||||
@@ -55,28 +36,31 @@ async function checkHealth(){
|
|||||||
b.className='banner'; b.textContent='Checking…';
|
b.className='banner'; b.textContent='Checking…';
|
||||||
const { status, json } = await api('GET','/api/health');
|
const { status, json } = await api('GET','/api/health');
|
||||||
if(status===200 && json && json.ok){
|
if(status===200 && json && json.ok){
|
||||||
b.className='banner ok'; b.textContent='✅ API reachable — /api/health returned ok.';
|
b.className='banner ok'; b.textContent='✓ API reachable — /api/health returned ok.';
|
||||||
} else if(status===404){
|
} else if(status===404){
|
||||||
b.className='banner bad'; b.textContent='❌ /api/ returns 404 — the reverse proxy is not routing /api/ to the API. The site loads but the API is unreachable from the browser.';
|
b.className='banner bad'; b.textContent='✕ /api/ returns 404 — the reverse proxy is not routing /api/ to the API. The site loads but the API is unreachable from the browser.';
|
||||||
} else if(status===0){
|
} else if(status===0){
|
||||||
b.className='banner bad'; b.textContent='❌ Could not reach the server: '+json;
|
b.className='banner bad'; b.textContent='✕ Could not reach the server: '+json;
|
||||||
} else {
|
} else {
|
||||||
b.className='banner bad'; b.textContent='❌ Unexpected response: HTTP '+status;
|
b.className='banner bad'; b.textContent='✕ Unexpected response: HTTP '+status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── db snapshot ───────────────────────────────────────────────────────────────
|
// ── db snapshot ───────────────────────────────────────────────────────────────
|
||||||
async function snapshot(){
|
async function snapshot(){
|
||||||
const out = document.getElementById('snapshot-out'); out.textContent='Loading…';
|
const out = document.getElementById('snapshot-out'); out.textContent='Loading…';
|
||||||
|
// archived=all: /api/projects now hides archived projects by default, and a row
|
||||||
|
// count that silently drops them is not a snapshot of the database.
|
||||||
const [p,s,w,c] = await Promise.all([
|
const [p,s,w,c] = await Promise.all([
|
||||||
api('GET','/api/projects'), api('GET','/api/sops'),
|
api('GET','/api/projects?archived=all'), api('GET','/api/sops'),
|
||||||
api('GET','/api/wps'), api('GET','/api/comments')]);
|
api('GET','/api/wps'), api('GET','/api/comments')]);
|
||||||
if(p.status!==200){
|
if(p.status!==200){
|
||||||
out.innerHTML = `<div class="banner bad">API not reachable (HTTP ${p.status}). Fix /api/ routing first.</div>`; return;
|
out.innerHTML = `<div class="banner bad">API not reachable (HTTP ${p.status}). Fix /api/ routing first.</div>`; return;
|
||||||
}
|
}
|
||||||
const n = r => Array.isArray(r.json) ? r.json.length : ('err '+r.status);
|
const n = r => Array.isArray(r.json) ? r.json.length : ('err '+r.status);
|
||||||
|
const archived = Array.isArray(p.json) ? p.json.filter(x => x && x.archived).length : 0;
|
||||||
out.innerHTML = `<table class="kv">
|
out.innerHTML = `<table class="kv">
|
||||||
<tr><th>Projects</th><td>${n(p)}</td></tr>
|
<tr><th>Projects</th><td>${n(p)}${archived ? ` <span class="note">(${archived} archived)</span>` : ''}</td></tr>
|
||||||
<tr><th>SOPs</th><td>${n(s)}</td></tr>
|
<tr><th>SOPs</th><td>${n(s)}</td></tr>
|
||||||
<tr><th>Work Packages</th><td>${n(w)}</td></tr>
|
<tr><th>Work Packages</th><td>${n(w)}</td></tr>
|
||||||
<tr><th>Comments</th><td>${n(c)}</td></tr></table>`;
|
<tr><th>Comments</th><td>${n(c)}</td></tr></table>`;
|
||||||
@@ -110,6 +94,14 @@ async function runSmokeTest(){
|
|||||||
r = await api('GET','/api/wps/metrics?project_id='+pid); chk('metrics aggregate', r.status===200 && r.json && r.json.total>=1, JSON.stringify(r.json));
|
r = await api('GET','/api/wps/metrics?project_id='+pid); chk('metrics aggregate', r.status===200 && r.json && r.json.total>=1, JSON.stringify(r.json));
|
||||||
r = await api('POST','/api/feedback',{type:'wp_review_comment',name:'admin-console',wp_id:wid,text:'SMOKE TEST comment — safe to delete'}); chk('post comment', r.status===200 && !!(r.json && r.json.id));
|
r = await api('POST','/api/feedback',{type:'wp_review_comment',name:'admin-console',wp_id:wid,text:'SMOKE TEST comment — safe to delete'}); chk('post comment', r.status===200 && !!(r.json && r.json.id));
|
||||||
r = await api('GET','/api/wps?project_id='+pid); chk('list WPs by project', r.status===200 && r.json.some(w=>w.id===wid));
|
r = await api('GET','/api/wps?project_id='+pid); chk('list WPs by project', r.status===200 && r.json.some(w=>w.id===wid));
|
||||||
|
// Archive round-trip: out of the default list, still there with archived=all,
|
||||||
|
// frozen against writes, and all three undone by unarchiving.
|
||||||
|
r = await api('POST','/api/projects/'+pid+'/archive',{archived:true}); chk('archive project', r.status===200 && r.json.archived===true, 'status '+r.status);
|
||||||
|
r = await api('GET','/api/projects'); chk('archived project leaves the default list', r.status===200 && !r.json.some(p=>p.id===pid));
|
||||||
|
r = await api('GET','/api/projects?archived=all'); chk('archived project visible with archived=all', r.status===200 && r.json.some(p=>p.id===pid));
|
||||||
|
r = await api('POST','/api/wps',{id:wid,project_id:pid,sop_id:sid,number:'WP01-SMOKE',subject:'edited while archived',type:'Conduit Install',status:'Scheduled',data:{disciplines:['Electrical'],hours:'40'}});
|
||||||
|
chk('write to an archived project refused (409)', r.status===409, 'status '+r.status);
|
||||||
|
r = await api('POST','/api/projects/'+pid+'/archive',{archived:false}); chk('unarchive project', r.status===200 && r.json.archived===false, 'status '+r.status);
|
||||||
} catch(e){ chk('unexpected error', false, String(e)); }
|
} catch(e){ chk('unexpected error', false, String(e)); }
|
||||||
finally {
|
finally {
|
||||||
if(pid){ const r=await api('DELETE','/api/projects/'+pid); chk('cleanup — delete project (cascades SOP+WPs)', r.status===200, 'status '+r.status); }
|
if(pid){ const r=await api('DELETE','/api/projects/'+pid); chk('cleanup — delete project (cascades SOP+WPs)', r.status===200, 'status '+r.status); }
|
||||||
@@ -129,9 +121,9 @@ function stdConstraints(open){ return ['Safety & Permitting','Quality Control /
|
|||||||
async function seedDemo(){
|
async function seedDemo(){
|
||||||
const o=document.getElementById('demo-out'); o.innerHTML='';
|
const o=document.getElementById('demo-out'); o.innerHTML='';
|
||||||
let r = await api('GET','/api/health');
|
let r = await api('GET','/api/health');
|
||||||
if(!(r.status===200 && r.json && r.json.ok)){ demoLog('❌ API unreachable — fix /api/ routing first.'); return; }
|
if(!(r.status===200 && r.json && r.json.ok)){ demoLog('✕ API unreachable — fix /api/ routing first.'); return; }
|
||||||
r = await api('POST','/api/projects',{name:'DEMO — Micron INC (test data)',number:'DEMO-001',client:'Micron Technology, Inc.',division:'Semiconductor',site:'Boise, ID — Fab',created_by:'admin-console'});
|
r = await api('POST','/api/projects',{name:'DEMO — Micron INC (test data)',number:'DEMO-001',client:'Micron Technology, Inc.',division:'Semiconductor',site:'Boise, ID — Fab',created_by:'admin-console'});
|
||||||
if(r.status!==200){ demoLog('❌ create project failed (HTTP '+r.status+')'); return; }
|
if(r.status!==200){ demoLog('✕ create project failed (HTTP '+r.status+')'); return; }
|
||||||
const pid=r.json.id; demoLog('Project created: '+r.json.name);
|
const pid=r.json.id; demoLog('Project created: '+r.json.name);
|
||||||
r = await api('POST','/api/sops',{project_id:pid,name:'DEMO SOP',number:'DEMO-001',complete:true,data:{governance:{woFormat:'WP##-[Sector]-[TYPE]',disciplines:['Mechanical','Electrical','Tech'],discMode:'choice',instanceSuffix:'letter',woSize:'Standard — 3–5 days (≈40–80 hrs)',sizeHoursMax:'80'}}});
|
r = await api('POST','/api/sops',{project_id:pid,name:'DEMO SOP',number:'DEMO-001',complete:true,data:{governance:{woFormat:'WP##-[Sector]-[TYPE]',disciplines:['Mechanical','Electrical','Tech'],discMode:'choice',instanceSuffix:'letter',woSize:'Standard — 3–5 days (≈40–80 hrs)',sizeHoursMax:'80'}}});
|
||||||
const sid=r.json && r.json.id; demoLog('SOP created (complete).');
|
const sid=r.json && r.json.id; demoLog('SOP created (complete).');
|
||||||
@@ -150,21 +142,573 @@ async function seedDemo(){
|
|||||||
await mk('WP05-3P-PANEL','3P panel install','Panel Install','Draft',{disciplines:['Electrical'],hours:'120',constraints:stdConstraints(['Schedule']),due:'2026-07-20'});
|
await mk('WP05-3P-PANEL','3P panel install','Panel Install','Draft',{disciplines:['Electrical'],hours:'120',constraints:stdConstraints(['Schedule']),due:'2026-07-20'});
|
||||||
r = await api('GET','/api/wps/metrics?project_id='+pid);
|
r = await api('GET','/api/wps/metrics?project_id='+pid);
|
||||||
demoLog('\nMetrics (masters excluded): '+JSON.stringify(r.json));
|
demoLog('\nMetrics (masters excluded): '+JSON.stringify(r.json));
|
||||||
demoLog('\n✅ Done — "DEMO — Micron INC (test data)" now appears in the home picker.');
|
demoLog('\n✓ Done — "DEMO — Micron INC (test data)" now appears in the home picker.');
|
||||||
snapshot();
|
snapshot();
|
||||||
}
|
}
|
||||||
async function cleanDemo(){
|
async function cleanDemo(){
|
||||||
if(!confirm('Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?')) return;
|
if(!(await wpConfirmDialog({title:'Delete demo data',
|
||||||
|
message:'Delete ALL projects whose number starts with DEMO- or SMOKE- (and their SOPs/WPs via cascade)?',
|
||||||
|
okLabel:'Delete them'}))) return;
|
||||||
const o=document.getElementById('demo-out'); o.innerHTML='';
|
const o=document.getElementById('demo-out'); o.innerHTML='';
|
||||||
const r = await api('GET','/api/projects');
|
// archived=all, or an archived DEMO-/SMOKE- project becomes unreachable from
|
||||||
if(r.status!==200){ demoLog('❌ API unreachable (HTTP '+r.status+').'); return; }
|
// 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; }
|
||||||
const targets=(r.json||[]).filter(p=>/^(DEMO-|SMOKE-)/.test(String(p.number||'')));
|
const targets=(r.json||[]).filter(p=>/^(DEMO-|SMOKE-)/.test(String(p.number||'')));
|
||||||
if(!targets.length){ demoLog('Nothing to remove.'); return; }
|
if(!targets.length){ demoLog('Nothing to remove.'); return; }
|
||||||
for(const p of targets){ await api('DELETE','/api/projects/'+p.id); demoLog('Deleted: '+p.name+' ('+p.number+')'); }
|
for(const p of targets){ await api('DELETE','/api/projects/'+p.id); demoLog('Deleted: '+p.name+' ('+p.number+')'); }
|
||||||
demoLog('\n✅ Removed '+targets.length+' project(s).');
|
demoLog('\n✓ Removed '+targets.length+' project(s).');
|
||||||
snapshot();
|
snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
// reveal immediately if already unlocked this session
|
// ── projects: archive / unarchive ───────────────────────────────────────────────
|
||||||
if(sessionStorage.getItem('wp_admin_ok')==='1'){ reveal(); }
|
// Archiving is the answer to "this job is over but I can't throw the data away".
|
||||||
else { const i=document.getElementById('gate-input'); if(i) i.focus(); }
|
// An archived project disappears from every picker, switcher and search in the
|
||||||
|
// suite and is frozen read-only; nothing is deleted. That makes this card the ONLY
|
||||||
|
// place an archived project is still visible, so it asks for archived=all and does
|
||||||
|
// the hiding itself — otherwise an admin could never find one to unarchive.
|
||||||
|
let _adminProjects = [];
|
||||||
|
|
||||||
|
async function loadProjects(){
|
||||||
|
const banner=document.getElementById('projects-banner');
|
||||||
|
const wrap=document.getElementById('projects-table');
|
||||||
|
if(!banner || !wrap) return;
|
||||||
|
banner.className='banner'; banner.textContent='Loading…'; banner.style.display='';
|
||||||
|
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.';
|
||||||
|
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 projects (HTTP '+status+').'; wrap.innerHTML=''; return;
|
||||||
|
}
|
||||||
|
banner.style.display='none';
|
||||||
|
_adminProjects = json;
|
||||||
|
renderProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjects(){
|
||||||
|
const wrap=document.getElementById('projects-table');
|
||||||
|
if(!wrap) return;
|
||||||
|
const showArchived = !!(document.getElementById('proj-show-archived')||{}).checked;
|
||||||
|
const q = (((document.getElementById('proj-search')||{}).value)||'').trim().toLowerCase();
|
||||||
|
const total = _adminProjects.length;
|
||||||
|
if(!total){ wrap.innerHTML='<div class="note">No projects yet.</div>'; return; }
|
||||||
|
const list = _adminProjects.filter(p => {
|
||||||
|
if(!showArchived && p.archived) return false;
|
||||||
|
if(!q) return true;
|
||||||
|
return ((p.name||'')+' '+(p.number||'')+' '+(p.client||'')+' '+(p.site||'')).toLowerCase().indexOf(q) >= 0;
|
||||||
|
});
|
||||||
|
const count = '<div class="note">'+list.length+' of '+total+' project'+(total===1?'':'s')+
|
||||||
|
(showArchived ? '' : ' <span title="Tick “Show archived” to include them">· archived hidden</span>')+'</div>';
|
||||||
|
if(!list.length){
|
||||||
|
wrap.innerHTML = count + '<div class="note">Nothing matches'+
|
||||||
|
(showArchived ? '' : ' — archived projects are hidden. Tick “Show archived” to include them')+'.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fmt = s => s ? wpFormatDateTime(s) : '—';
|
||||||
|
const rows = list.map(p => {
|
||||||
|
// Project names are free text written by whoever created the job — jsq(), not
|
||||||
|
// uesc(), is what makes them safe to bind into the handlers below.
|
||||||
|
const pid = jsq(p.id);
|
||||||
|
const pname = jsq(p.name||'(unnamed)');
|
||||||
|
const arch = !!p.archived;
|
||||||
|
return '<tr>'+
|
||||||
|
'<td><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
|
||||||
|
(p.number ? ' <span class="note">'+uesc(p.number)+'</span>' : '')+'</td>'+
|
||||||
|
'<td class="ell" title="'+uesc(p.client||'')+'"><span>'+uesc(p.client||'—')+'</span></td>'+
|
||||||
|
'<td class="ell" title="'+uesc(p.site||'')+'"><span>'+uesc(p.site||'—')+'</span></td>'+
|
||||||
|
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(p.created_at)+'</td>'+
|
||||||
|
'<td>'+(arch
|
||||||
|
? '<span class="tag archived" title="Hidden everywhere and read-only until unarchived">archived</span>'
|
||||||
|
: '<span class="tag on">active</span>')+'</td>'+
|
||||||
|
'<td><div class="cellactions">'+
|
||||||
|
'<button class="mini" onclick="archiveProject(\''+pid+'\',\''+pname+'\','+(arch?'false':'true')+')">'+
|
||||||
|
(arch?'Unarchive':'Archive')+'</button>'+
|
||||||
|
'<button class="mini danger" onclick="deleteProjectAdmin(\''+pid+'\',\''+pname+'\')">Delete</button>'+
|
||||||
|
'</div></td>'+
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
wrap.innerHTML = count +
|
||||||
|
'<div class="tscroll"><table class="grid"><thead><tr>'+
|
||||||
|
'<th>Project</th><th>Client</th><th>Site</th><th>Created</th><th>Status</th><th>Actions</th>'+
|
||||||
|
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
|
||||||
|
'<div class="note"><strong>Delete</strong> is not archive: it removes the project, its SOP and every '+
|
||||||
|
'work package on it for good. Archive first if there is any doubt.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both directions are explained in full before anything happens: archiving makes a
|
||||||
|
// project vanish for everyone else in the company, and there is no undo prompt on
|
||||||
|
// the other side of that.
|
||||||
|
async function archiveProject(id, name, archived){
|
||||||
|
const ask = archived
|
||||||
|
? 'Archive “'+name+'”?\n\n'+
|
||||||
|
'• It disappears from every project picker, switcher and search across the suite.\n'+
|
||||||
|
'• It becomes read-only — nobody can add or change its SOP or work packages.\n'+
|
||||||
|
'• 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(!(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 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
|
||||||
|
// global shared with the other scripts the page loads, and "deleteProject" is broad
|
||||||
|
// 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(!(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 wpAlertDialog({title:'Delete failed',
|
||||||
|
message:'Could not delete '+name+': '+((json && json.detail)||('HTTP '+status))});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── default members on new projects ─────────────────────────────────────────────
|
||||||
|
// A rule about the FUTURE: flagged users are auto-added to every project created
|
||||||
|
// from now on. It is not a bulk assignment — existing projects are untouched, which
|
||||||
|
// is what the note under the table is there to say.
|
||||||
|
let _defMemUsers = [];
|
||||||
|
|
||||||
|
async function loadDefaultMembers(){
|
||||||
|
const banner=document.getElementById('defmem-banner');
|
||||||
|
const wrap=document.getElementById('defmem-table');
|
||||||
|
if(!banner || !wrap) return;
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
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';
|
||||||
|
_defMemUsers = json;
|
||||||
|
renderDefaultMembers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
if(sel) sel.disabled = !cb.checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDefaultMembers(){
|
||||||
|
const wrap=document.getElementById('defmem-table');
|
||||||
|
if(!wrap) return;
|
||||||
|
if(!_defMemUsers.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; }
|
||||||
|
const rows = _defMemUsers.map(u => {
|
||||||
|
const uid = jsq(u.id);
|
||||||
|
const uname = jsq(u.username);
|
||||||
|
const role = normRole(u.role);
|
||||||
|
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.
|
||||||
|
if(role === 'admin'){
|
||||||
|
return '<tr>'+who+
|
||||||
|
'<td><span class="tag admin">'+uesc(PERM_LABELS.admin)+'</span></td>'+
|
||||||
|
'<td colspan="2"><span class="tag admin" title="Admins can access every project">all projects</span>'+
|
||||||
|
' <span class="note">Administrators already reach every project.</span></td>'+
|
||||||
|
'</tr>';
|
||||||
|
}
|
||||||
|
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 '+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"'+
|
||||||
|
' onchange="defMemToggled(this);setAutoAdd(\''+uid+'\',\''+uname+'\')"> Add automatically'+
|
||||||
|
'</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+'\')">'+opts.join('')+'</select></td>'+
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
wrap.innerHTML =
|
||||||
|
'<div class="tscroll"><table class="grid"><thead><tr>'+
|
||||||
|
'<th>User</th><th>Email</th>'+
|
||||||
|
'<th title="What this account may do in the app">Account permissions</th>'+
|
||||||
|
'<th title="Add this user to every project created from now on">Add to new projects</th>'+
|
||||||
|
'<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> 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
|
||||||
|
// failure must not leave a control showing something the server never accepted.
|
||||||
|
// On success we swap in the row the server returned (it clears the role whenever
|
||||||
|
// the flag is off); on failure we reload so the controls snap back to the truth.
|
||||||
|
async function setAutoAdd(id, username){
|
||||||
|
const cb = document.getElementById('defmem-cb-'+id);
|
||||||
|
if(!cb) return;
|
||||||
|
const sel = document.getElementById('defmem-role-'+id);
|
||||||
|
const auto_add = !!cb.checked;
|
||||||
|
const { status, json } = await api('POST','/api/auth/users/'+id+'/auto-add',
|
||||||
|
{ auto_add, role: auto_add ? ((sel && sel.value) || '') : '' });
|
||||||
|
if(status===200 && json && json.id){
|
||||||
|
_defMemUsers = _defMemUsers.map(u => u.id===json.id ? json : u);
|
||||||
|
renderDefaultMembers();
|
||||||
|
} else {
|
||||||
|
wpAlertDialog({title:'Change failed',
|
||||||
|
message:'Could not change the new-project default for '+username+': '+((json && json.detail)||('HTTP '+status))});
|
||||||
|
loadDefaultMembers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── all feedback / comments ─────────────────────────────────────────────────────
|
||||||
|
let _comments = [];
|
||||||
|
async function loadComments(){
|
||||||
|
const box = document.getElementById('comments-admin');
|
||||||
|
box.textContent = 'Loading…';
|
||||||
|
const { status, json } = await api('GET','/api/comments');
|
||||||
|
if(status!==200 || !Array.isArray(json)){
|
||||||
|
box.innerHTML = '<div class="banner bad">Could not load comments (HTTP '+status+').</div>'; return;
|
||||||
|
}
|
||||||
|
_comments = json;
|
||||||
|
const sel = document.getElementById('cmt-filter'); const cur = sel.value;
|
||||||
|
const sources = [...new Set(json.map(c=>c.source).filter(Boolean))].sort();
|
||||||
|
sel.innerHTML = '<option value="">All sources</option>' + sources.map(s=>'<option value="'+uesc(s)+'">'+uesc(s)+'</option>').join('');
|
||||||
|
sel.value = cur;
|
||||||
|
renderComments();
|
||||||
|
}
|
||||||
|
function renderComments(){
|
||||||
|
const box = document.getElementById('comments-admin');
|
||||||
|
const src = document.getElementById('cmt-filter').value;
|
||||||
|
const q = (document.getElementById('cmt-search').value||'').toLowerCase();
|
||||||
|
let rows = _comments.filter(c => (!src || c.source===src) &&
|
||||||
|
(!q || ((c.text||'')+' '+(c.author||'')).toLowerCase().indexOf(q)>=0));
|
||||||
|
if(!rows.length){ box.innerHTML = '<div class="note">No comments'+((src||q)?' match the filter.':' yet.')+'</div>'; return; }
|
||||||
|
rows = rows.slice().sort((a,b)=> String(b.created_at||'').localeCompare(String(a.created_at||'')));
|
||||||
|
const fmt = s => s ? wpFormatDateTime(s) : '—';
|
||||||
|
const where = c => {
|
||||||
|
const bits = [];
|
||||||
|
if(c.page) bits.push(uesc(c.page));
|
||||||
|
if(c.step!=null) bits.push('step '+c.step);
|
||||||
|
if(c.sop_id) bits.push('SOP '+uesc(c.sop_id));
|
||||||
|
if(c.wp_id) bits.push('WP '+uesc(c.wp_id));
|
||||||
|
return bits.join(' · ') || '—';
|
||||||
|
};
|
||||||
|
box.innerHTML = '<table class="users"><thead><tr><th>When</th><th>Who</th><th>Source</th><th>Where</th><th>Comment</th></tr></thead><tbody>'+
|
||||||
|
rows.map(c => '<tr>'+
|
||||||
|
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(c.created_at)+'</td>'+
|
||||||
|
'<td><strong>'+uesc(c.author||'Anonymous')+'</strong></td>'+
|
||||||
|
'<td>'+uesc(c.source||'—')+'</td>'+
|
||||||
|
'<td style="color:var(--muted)">'+where(c)+'</td>'+
|
||||||
|
'<td>'+uesc(c.text||'')+'</td>'+
|
||||||
|
'</tr>').join('')+'</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── activity log (audit trail) ──────────────────────────────────────────────────
|
||||||
|
let _audit = [];
|
||||||
|
async function loadAudit(){
|
||||||
|
const box = document.getElementById('audit-admin');
|
||||||
|
box.textContent = 'Loading…';
|
||||||
|
const { status, json } = await api('GET','/api/audit?limit=500');
|
||||||
|
if(status!==200 || !Array.isArray(json)){
|
||||||
|
box.innerHTML = '<div class="banner bad">Could not load activity (HTTP '+status+').</div>'; return;
|
||||||
|
}
|
||||||
|
_audit = json;
|
||||||
|
renderAudit();
|
||||||
|
}
|
||||||
|
function renderAudit(){
|
||||||
|
const box = document.getElementById('audit-admin');
|
||||||
|
const type = document.getElementById('audit-type').value;
|
||||||
|
const q = (document.getElementById('audit-search').value||'').toLowerCase();
|
||||||
|
let rows = _audit.filter(e => (!type || e.entity_type===type) &&
|
||||||
|
(!q || ((e.actor||'')+' '+(e.action||'')+' '+(e.summary||'')).toLowerCase().indexOf(q)>=0));
|
||||||
|
if(!rows.length){ box.innerHTML = '<div class="note">No activity'+((type||q)?' matches the filter.':' yet.')+'</div>'; return; }
|
||||||
|
const fmt = s => s ? wpFormatDateTime(s) : '—';
|
||||||
|
const det = e => {
|
||||||
|
const d = e.detail || {};
|
||||||
|
if(d.from!=null || d.to!=null) return uesc((d.from==null?'—':d.from)+' → '+(d.to==null?'—':d.to));
|
||||||
|
return uesc(Object.keys(d).map(k=>k+': '+d[k]).join(', '));
|
||||||
|
};
|
||||||
|
box.innerHTML = '<table class="users"><thead><tr><th>When</th><th>Who</th><th>Action</th><th>Type</th><th>Item</th><th>Detail</th></tr></thead><tbody>'+
|
||||||
|
rows.map(e => '<tr>'+
|
||||||
|
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(e.at)+'</td>'+
|
||||||
|
'<td><strong>'+uesc(e.actor||'—')+'</strong></td>'+
|
||||||
|
'<td>'+uesc((e.action||'').replace(/_/g,' '))+'</td>'+
|
||||||
|
'<td>'+uesc(e.entity_type||'')+'</td>'+
|
||||||
|
'<td>'+uesc(e.summary||e.entity_id||'')+'</td>'+
|
||||||
|
'<td style="color:var(--muted)">'+det(e)+'</td>'+
|
||||||
|
'</tr>').join('')+'</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── notifications / email settings ──────────────────────────────────────────────
|
||||||
|
let _settings = {};
|
||||||
|
async function loadSettings(){
|
||||||
|
const box = document.getElementById('settings-box');
|
||||||
|
const { status, json } = await api('GET','/api/settings');
|
||||||
|
if(status!==200 || !json){ box.innerHTML = '<div class="banner bad">Could not load settings (HTTP '+status+').</div>'; return; }
|
||||||
|
_settings = json; renderSettings();
|
||||||
|
}
|
||||||
|
// Feature flags live in the same settings record but get their own card — they're
|
||||||
|
// not email, and they change what every project sees.
|
||||||
|
function renderFeatures(){
|
||||||
|
const s = _settings, box = document.getElementById('features-box');
|
||||||
|
if(!box) return;
|
||||||
|
const bim = !!s.bim_enabled;
|
||||||
|
box.innerHTML =
|
||||||
|
'<label style="display:inline-flex;align-items:center;gap:8px;font-size:14px;font-weight:700">'+
|
||||||
|
'<input type="checkbox" id="set-bim"'+(bim?' checked':'')+' onchange="saveFeatures()"> '+
|
||||||
|
'BIM / VDC tooling is <span style="color:'+(bim?'var(--green)':'var(--muted)')+'">'+(bim?'ON':'OFF')+'</span>'+
|
||||||
|
'</label>'+
|
||||||
|
'<div class="note" style="margin-top:8px">When OFF, the SOP creator hides the BIM/VDC section entirely and '+
|
||||||
|
'every project is install-only (IWP). Existing SOPs that already have BIM enabled keep their data — it just '+
|
||||||
|
'stops being shown or offered, so no project can be put on the BIM path while it\'s off.</div>'+
|
||||||
|
'<div id="features-msg" class="note" style="margin-top:6px"></div>'+
|
||||||
|
|
||||||
|
// Localization defaults. A user's own "Language & time" preference wins over
|
||||||
|
// these; these decide what everyone else sees instead of the browser's guess.
|
||||||
|
'<h2 style="margin-top:22px">Localization defaults</h2>'+
|
||||||
|
'<div class="sub" style="margin-bottom:10px">How dates, times and numbers are written for users who haven\'t '+
|
||||||
|
'set their own preference. Each user can override this from <strong>Language & time</strong> in the '+
|
||||||
|
'top-right menu.</div>'+
|
||||||
|
'<div class="urow">'+
|
||||||
|
'<select id="set-locale" style="min-width:220px"></select>'+
|
||||||
|
'<select id="set-tz" style="min-width:240px"></select>'+
|
||||||
|
'<button class="primary" onclick="saveLocalization()">Save defaults</button>'+
|
||||||
|
'<span id="l10n-msg" class="note" style="margin:0"></span>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="note" id="l10n-preview" style="margin-top:8px"></div>';
|
||||||
|
fillLocalization();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locale shortlist mirrors wp-format.js so the admin default and the per-user
|
||||||
|
// preference offer the same choices.
|
||||||
|
const L10N_LOCALES = [['','Browser default'],['en-US','en-US — 8/3/2026, 2:07 PM'],
|
||||||
|
['en-GB','en-GB — 03/08/2026, 14:07'],['en-CA','en-CA'],['es-MX','es-MX'],['es-US','es-US'],
|
||||||
|
['fr-CA','fr-CA'],['de-DE','de-DE'],['ja-JP','ja-JP'],['ko-KR','ko-KR'],['zh-TW','zh-TW']];
|
||||||
|
const L10N_ZONES = ['America/Chicago','America/New_York','America/Denver','America/Phoenix',
|
||||||
|
'America/Los_Angeles','America/Boise','Asia/Tokyo','Asia/Taipei','Asia/Seoul','Asia/Singapore',
|
||||||
|
'Europe/Dublin','Europe/London','UTC'];
|
||||||
|
|
||||||
|
function fillLocalization(){
|
||||||
|
const s = _settings;
|
||||||
|
const loc = document.getElementById('set-locale');
|
||||||
|
const tz = document.getElementById('set-tz');
|
||||||
|
if(!loc || !tz) return;
|
||||||
|
const curL = s.default_locale || '', curZ = s.default_timezone || '';
|
||||||
|
loc.innerHTML = L10N_LOCALES.map(p =>
|
||||||
|
'<option value="'+uesc(p[0])+'"'+(p[0]===curL?' selected':'')+'>'+uesc(p[1])+'</option>').join('');
|
||||||
|
if(curL && !L10N_LOCALES.some(p=>p[0]===curL)) loc.add(new Option(curL, curL, true, true));
|
||||||
|
|
||||||
|
let browserZone = '';
|
||||||
|
try { browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch(e){}
|
||||||
|
tz.innerHTML = '<option value=""'+(curZ?'':' selected')+'>Browser default'+
|
||||||
|
(browserZone?' ('+uesc(browserZone)+')':'')+'</option>'+
|
||||||
|
L10N_ZONES.map(z => '<option value="'+uesc(z)+'"'+(z===curZ?' selected':'')+'>'+uesc(z)+'</option>').join('')+
|
||||||
|
(curZ && L10N_ZONES.indexOf(curZ)<0 ? '<option value="'+uesc(curZ)+'" selected>'+uesc(curZ)+'</option>' : '');
|
||||||
|
|
||||||
|
const preview = () => {
|
||||||
|
const el = document.getElementById('l10n-preview'); if(!el) return;
|
||||||
|
let out;
|
||||||
|
try {
|
||||||
|
out = new Intl.DateTimeFormat(loc.value||undefined, {year:'numeric',month:'short',day:'numeric',
|
||||||
|
hour:'2-digit',minute:'2-digit',timeZone:tz.value||undefined}).format(new Date());
|
||||||
|
} catch(e){ out = 'not supported by this browser'; }
|
||||||
|
el.textContent = 'Preview — right now reads: ' + out;
|
||||||
|
};
|
||||||
|
loc.onchange = preview; tz.onchange = preview; preview();
|
||||||
|
|
||||||
|
// Offer the server's full zone list once it arrives (it validates against the
|
||||||
|
// same list, so anything offered here will be accepted).
|
||||||
|
api('GET','/api/timezones').then(({status,json}) => {
|
||||||
|
if(status!==200 || !Array.isArray(json) || !json.length) return;
|
||||||
|
const rest = json.filter(z => L10N_ZONES.indexOf(z) < 0);
|
||||||
|
if(!rest.length) return;
|
||||||
|
const g = document.createElement('optgroup'); g.label = 'All time zones';
|
||||||
|
rest.forEach(z => g.appendChild(new Option(z, z, false, z === curZ)));
|
||||||
|
tz.appendChild(g);
|
||||||
|
if(curZ) tz.value = curZ;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLocalization(){
|
||||||
|
const msg = document.getElementById('l10n-msg');
|
||||||
|
const patch = {
|
||||||
|
default_locale: document.getElementById('set-locale').value,
|
||||||
|
default_timezone: document.getElementById('set-tz').value,
|
||||||
|
};
|
||||||
|
msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)';
|
||||||
|
const { status, json } = await api('PUT','/api/settings', patch);
|
||||||
|
if(status===200){
|
||||||
|
_settings = json; renderSettings();
|
||||||
|
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.style.color = 'var(--red)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFeatures(){
|
||||||
|
const el = document.getElementById('set-bim');
|
||||||
|
const msg = document.getElementById('features-msg');
|
||||||
|
if(msg){ msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)'; }
|
||||||
|
const { status, json } = await api('PUT','/api/settings', { bim_enabled: !!(el && el.checked) });
|
||||||
|
if(status===200){
|
||||||
|
_settings = json; renderFeatures();
|
||||||
|
const m = document.getElementById('features-msg');
|
||||||
|
if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; }
|
||||||
|
} else if(msg){
|
||||||
|
msg.textContent = 'Save failed (HTTP '+status+').'; msg.style.color = 'var(--red)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSettings(){
|
||||||
|
renderFeatures();
|
||||||
|
const s = _settings, box = document.getElementById('settings-box');
|
||||||
|
const on = !!s.email_enabled;
|
||||||
|
const pwOk = !!s.smtp_password_set;
|
||||||
|
box.innerHTML =
|
||||||
|
'<label style="display:inline-flex;align-items:center;gap:8px;font-size:14px;font-weight:700;margin-bottom:12px">'+
|
||||||
|
'<input type="checkbox" id="set-enabled"'+(on?' checked':'')+'> Email notifications are <span style="color:'+(on?'var(--green)':'var(--muted)')+'">'+(on?'ON':'OFF')+'</span></label>'+
|
||||||
|
'<div class="urow" style="margin-bottom:8px">'+
|
||||||
|
'<input id="set-host" placeholder="SMTP host (e.g. smtp.company.local)" value="'+uesc(s.smtp_host||'')+'">'+
|
||||||
|
'<input id="set-port" style="flex:0 0 90px;min-width:70px" placeholder="Port" value="'+uesc(s.smtp_port||587)+'">'+
|
||||||
|
'<label style="display:inline-flex;align-items:center;gap:6px;font-size:13px;white-space:nowrap"><input type="checkbox" id="set-tls"'+(s.smtp_use_tls?' checked':'')+'> STARTTLS</label>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="urow" style="margin-bottom:8px">'+
|
||||||
|
'<input id="set-from" placeholder="From address (e.g. wp-suite@company.com)" value="'+uesc(s.from_addr||'')+'">'+
|
||||||
|
'<input id="set-fromname" placeholder="From name" value="'+uesc(s.from_name||'')+'">'+
|
||||||
|
'<input id="set-user" placeholder="SMTP username (optional)" value="'+uesc(s.smtp_username||'')+'">'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="urow" style="margin-bottom:8px">'+
|
||||||
|
'<input id="set-baseurl" placeholder="App base URL for email links (e.g. https://wp.controls.dev)" value="'+uesc(s.app_base_url||'')+'">'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="note" style="margin-bottom:10px">SMTP password: '+(pwOk?'<span style="color:var(--green);font-weight:600">set via SMTP_PASSWORD env ✓</span>':'<span style="color:var(--amber);font-weight:600">not set — add SMTP_PASSWORD to the environment before enabling</span>')+'</div>'+
|
||||||
|
'<div class="row">'+
|
||||||
|
'<button class="primary" onclick="saveSettings()">Save settings</button>'+
|
||||||
|
'<button onclick="testEmail()">Send test email to me</button>'+
|
||||||
|
'<span id="set-msg" class="note" style="margin:0"></span>'+
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
async function saveSettings(){
|
||||||
|
const v = id => document.getElementById(id);
|
||||||
|
const patch = {
|
||||||
|
email_enabled: v('set-enabled').checked,
|
||||||
|
smtp_host: v('set-host').value.trim(),
|
||||||
|
smtp_port: parseInt(v('set-port').value, 10) || 587,
|
||||||
|
smtp_use_tls: v('set-tls').checked,
|
||||||
|
from_addr: v('set-from').value.trim(),
|
||||||
|
from_name: v('set-fromname').value.trim(),
|
||||||
|
smtp_username: v('set-user').value.trim(),
|
||||||
|
app_base_url: v('set-baseurl').value.trim(),
|
||||||
|
};
|
||||||
|
const msg = v('set-msg'); msg.textContent = 'Saving…'; msg.style.color = 'var(--muted)';
|
||||||
|
const { status, json } = await api('PUT','/api/settings', patch);
|
||||||
|
if(status===200){ _settings = json; renderSettings(); const m = document.getElementById('set-msg'); if(m){ m.textContent = 'Saved.'; m.style.color = 'var(--green)'; } }
|
||||||
|
else { msg.textContent = 'Save failed (HTTP '+status+').'; msg.style.color = 'var(--red)'; }
|
||||||
|
}
|
||||||
|
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)'; }
|
||||||
|
}
|
||||||
|
async function loadNotifications(){
|
||||||
|
const box = document.getElementById('notif-box'); if(!box) return;
|
||||||
|
const { status, json } = await api('GET','/api/notifications?all=1&limit=50');
|
||||||
|
if(status!==200 || !Array.isArray(json)){ box.innerHTML = ''; return; }
|
||||||
|
if(!json.length){ box.innerHTML = '<div class="note">No notifications yet.</div>'; return; }
|
||||||
|
const fmt = s => s ? wpFormatDateTime(s) : '—';
|
||||||
|
const stColor = st => st==='sent'?'var(--green)':st==='failed'?'var(--red)':st==='skipped'?'var(--muted)':'var(--amber)';
|
||||||
|
box.innerHTML = '<div class="sub" style="margin:4px 0 6px;color:var(--muted)">Recent notifications</div>'+
|
||||||
|
'<table class="users"><thead><tr><th>When</th><th>To</th><th>Kind</th><th>Subject</th><th>Status</th></tr></thead><tbody>'+
|
||||||
|
json.map(n => '<tr>'+
|
||||||
|
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(n.created_at)+'</td>'+
|
||||||
|
'<td>'+uesc(n.email||n.user_id)+'</td>'+
|
||||||
|
'<td>'+uesc((n.kind||'').replace(/_/g,' '))+'</td>'+
|
||||||
|
'<td>'+uesc(n.subject||'')+'</td>'+
|
||||||
|
'<td style="color:'+stColor(n.status)+';font-weight:600">'+uesc(n.status)+(n.error?' <span title="'+uesc(n.error)+'">ⓘ</span>':'')+'</td>'+
|
||||||
|
'</tr>').join('')+'</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── usage logs (read from this browser's localStorage) ──────────────────────────
|
||||||
|
// 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');
|
||||||
|
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>';
|
||||||
|
});
|
||||||
|
box.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── access control: admins only ─────────────────────────────────────────────────
|
||||||
|
// auth-guard.js requires a login and sets window.WP_USER (firing 'wp-auth-ready').
|
||||||
|
// Show the console for admins; otherwise show the "Admins only" notice.
|
||||||
|
let _adminGated = false;
|
||||||
|
function gateByRole(){
|
||||||
|
if(_adminGated) return;
|
||||||
|
const u = window.WP_USER;
|
||||||
|
if(!u) return; // not resolved yet — wait for wp-auth-ready
|
||||||
|
_adminGated = true;
|
||||||
|
if(u.role === 'admin') reveal();
|
||||||
|
else showDenied();
|
||||||
|
}
|
||||||
|
document.addEventListener('wp-auth-ready', gateByRole);
|
||||||
|
gateByRole(); // in case WP_USER was already set before this ran
|
||||||
|
|||||||
210
html/auth-guard.js
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
/* Auth guard for the Work Package Suite.
|
||||||
|
Included in the <head> of every protected page (before other scripts). It
|
||||||
|
confirms there is a valid session by calling /api/auth/me; if not, it sends
|
||||||
|
the user to the login page. The real protection is server-side (the API
|
||||||
|
refuses data requests without a session) — this guard is for UX so people
|
||||||
|
land on the login screen instead of an empty app.
|
||||||
|
|
||||||
|
It also exposes:
|
||||||
|
window.WP_USER the logged-in user object (set once verified)
|
||||||
|
window.wpLogout() clears the session and returns to the login page
|
||||||
|
and dispatches a 'wp-auth-ready' event on document once WP_USER is set. */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// 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) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the page until we know the user is allowed, to avoid a flash of the app
|
||||||
|
// before a redirect. A safety timer reveals it even if the check hangs.
|
||||||
|
var root = document.documentElement;
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.textContent = '.wp-auth-pending body{visibility:hidden!important}';
|
||||||
|
(document.head || root).appendChild(style);
|
||||||
|
root.className += ' wp-auth-pending';
|
||||||
|
function reveal() { root.className = root.className.replace(/\bwp-auth-pending\b/, ''); }
|
||||||
|
var safety = setTimeout(reveal, 4000);
|
||||||
|
|
||||||
|
function goToLogin() {
|
||||||
|
clearTimeout(safety);
|
||||||
|
var next = encodeURIComponent(location.pathname + location.search);
|
||||||
|
var url = 'login.html?next=' + next;
|
||||||
|
// 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 () {
|
||||||
|
try {
|
||||||
|
// Clear the auth cache AND all cached project data (customer IP) from this
|
||||||
|
// device on sign-out — important on shared/field tablets. The outbox
|
||||||
|
// (wp_sync_outbox_v1) is left intact so unsynced writes aren't lost.
|
||||||
|
// (localStorage is not a security boundary; field devices still need
|
||||||
|
// full-disk encryption / MDM — see DEPLOYMENT.md.)
|
||||||
|
localStorage.removeItem('wp_auth_cache');
|
||||||
|
Object.keys(localStorage).forEach(function (k) {
|
||||||
|
if (/^wp_(iwp_v1|suite_sop|suite_state|projects|active_project)/.test(k)) {
|
||||||
|
localStorage.removeItem(k);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
fetch('/api/auth/logout', { method: 'POST' })
|
||||||
|
.catch(function () {})
|
||||||
|
.then(function () { window.location.replace('login.html'); });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Change-password dialog (uses POST /api/auth/password, which requires the
|
||||||
|
// current password). Available from the top-right pill on any page.
|
||||||
|
window.wpChangePassword = function () {
|
||||||
|
if (document.getElementById('wp-pw-modal')) return;
|
||||||
|
var ov = document.createElement('div');
|
||||||
|
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 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: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>' +
|
||||||
|
'<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' +
|
||||||
|
'<label style="' + lbl + '">New password (at least 12 characters)</label>' +
|
||||||
|
'<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' +
|
||||||
|
'<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 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 ? '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);
|
||||||
|
document.getElementById('wp-pw-cancel').onclick = close;
|
||||||
|
document.getElementById('wp-pw-cur').focus();
|
||||||
|
document.getElementById('wp-pw-save').onclick = function () {
|
||||||
|
var cur = document.getElementById('wp-pw-cur').value;
|
||||||
|
var n1 = document.getElementById('wp-pw-new').value;
|
||||||
|
var n2 = document.getElementById('wp-pw-new2').value;
|
||||||
|
if (!cur || !n1) { msg('Please fill in every field.', false); return; }
|
||||||
|
if (n1.length < 12) { msg('New password must be at least 12 characters.', false); return; }
|
||||||
|
if (n1 !== n2) { msg('New passwords do not match.', false); return; }
|
||||||
|
fetch('/api/auth/password', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ current_password: cur, new_password: n1 })
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.ok) { msg('Password updated.', true); setTimeout(close, 1200); }
|
||||||
|
else { msg((res.j && res.j.detail) || ('Could not update (HTTP ' + res.status + ').'), false); }
|
||||||
|
})
|
||||||
|
.catch(function () { msg('Could not reach the server.', false); });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── permissions helpers ────────────────────────────────────────────────────
|
||||||
|
// The server enforces all of this; these are for hiding controls the signed-in
|
||||||
|
// user can't use, so nobody clicks a button just to get a 403.
|
||||||
|
// 'user' is the legacy value for what is now 'project_user'.
|
||||||
|
window.wpRole = function () {
|
||||||
|
var r = (window.WP_USER && window.WP_USER.role) || '';
|
||||||
|
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_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
|
||||||
|
// wpFlags(); anything already rendered can re-check on the 'wp-flags-ready' event.
|
||||||
|
window.WP_FLAGS = null;
|
||||||
|
var _flagsPromise = null;
|
||||||
|
window.wpFlags = function () {
|
||||||
|
if (window.WP_FLAGS) return Promise.resolve(window.WP_FLAGS);
|
||||||
|
if (_flagsPromise) return _flagsPromise;
|
||||||
|
_flagsPromise = fetch('/api/app-flags', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : {}; })
|
||||||
|
.catch(function () { return {}; }) // offline: fall through to defaults
|
||||||
|
.then(function (f) {
|
||||||
|
window.WP_FLAGS = f || {};
|
||||||
|
try { document.dispatchEvent(new CustomEvent('wp-flags-ready', { detail: window.WP_FLAGS })); } catch (e) {}
|
||||||
|
return window.WP_FLAGS;
|
||||||
|
});
|
||||||
|
return _flagsPromise;
|
||||||
|
};
|
||||||
|
// BIM/VDC is off unless an admin has switched it on, so an unreachable API or a
|
||||||
|
// stale cache errs toward hiding the unfinished tooling rather than showing it.
|
||||||
|
window.wpBimEnabled = function () { return !!(window.WP_FLAGS && window.WP_FLAGS.bim_enabled); };
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
window.WP_USER = user;
|
||||||
|
reveal();
|
||||||
|
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) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/api/auth/me', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.status === 401 || r.status === 403) { try { localStorage.removeItem('wp_auth_cache'); } catch (e) {} goToLogin(); return; }
|
||||||
|
if (!r.ok) { reveal(); clearTimeout(safety); return; } // unexpected; show page rather than trap
|
||||||
|
return r.json().then(function (data) {
|
||||||
|
var user = data && data.user;
|
||||||
|
// Remember the last good auth so the PWA can open offline. The server is
|
||||||
|
// still the real gate; offline writes queue in the outbox until reconnect.
|
||||||
|
try { if (user) localStorage.setItem('wp_auth_cache', JSON.stringify({ user: user, at: Date.now() })); } catch (e) {}
|
||||||
|
proceed(user);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
// Offline / API unreachable: fall back to a recent cached auth if present,
|
||||||
|
// so the app (and the field view) still open without a network.
|
||||||
|
try {
|
||||||
|
var c = JSON.parse(localStorage.getItem('wp_auth_cache') || 'null');
|
||||||
|
if (c && c.user && (Date.now() - (c.at || 0)) < 12 * 3600 * 1000) { proceed(c.user); return; }
|
||||||
|
} catch (e) {}
|
||||||
|
goToLogin();
|
||||||
|
});
|
||||||
|
})();
|
||||||
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; }
|
||||||
|
}
|
||||||
103
html/field.html
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Field View — 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="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: 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); }
|
||||||
|
.wp-card.ready { border-left-color: var(--cds-support-success); }
|
||||||
|
.wp-card.hold { border-left-color: var(--cds-support-error); }
|
||||||
|
.wp-card .num { font-weight: 600; font-size: 16px; color: var(--cds-text-primary); }
|
||||||
|
.wp-card .subj { color: var(--cds-text-secondary); font-size: 13px; margin-top: 2px; }
|
||||||
|
.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: 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; }
|
||||||
|
.fld-sub { color: var(--cds-text-secondary); font-size: 14px; margin-bottom: 16px; }
|
||||||
|
.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: 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: 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: 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: 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: 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>
|
||||||
|
<header class="wp-appbar">
|
||||||
|
<a href="index.html" class="wp-appbar-brand" title="Home">
|
||||||
|
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
|
||||||
|
<span class="wp-appbar-title">Field View</span>
|
||||||
|
</a>
|
||||||
|
<div class="wp-appbar-spacer"></div>
|
||||||
|
<!-- 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">
|
||||||
|
<div class="fld-ctx" id="fld-ctx"></div>
|
||||||
|
<section id="screen-list">
|
||||||
|
<input class="fld-search" id="fld-search" type="search" placeholder="Search work packages…" oninput="renderList()" aria-label="Search work packages">
|
||||||
|
<div id="wp-list"></div>
|
||||||
|
</section>
|
||||||
|
<section id="screen-detail" style="display:none"></section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast" class="fld-toast"></div>
|
||||||
|
|
||||||
|
<script src="project-data.js"></script>
|
||||||
|
<script src="help.js"></script>
|
||||||
|
<script src="field.js"></script>
|
||||||
|
<script src="wp-chrome.js"></script>
|
||||||
|
<script src="wp-sidenav.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
212
html/field.js
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/* Field view — a touch-optimized screen for updating a Work Package's status,
|
||||||
|
constraints, and a photo/note log from the work face. Reads the same shared
|
||||||
|
data as the desktop creator (via project-data.js) and saves through the sync
|
||||||
|
outbox, so it works offline and syncs when the network returns. */
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var PID = '', PROJECT = null, WPS = [], curId = null, pendingPhoto = '', draftNote = '';
|
||||||
|
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; }
|
||||||
|
function stLabel(s) { return s === 'Issue' ? 'Issue (Hold)' : s; }
|
||||||
|
function openCount(p) { return ((p && p.constraints) || []).filter(function (c) { return c.status === 'open'; }).length; }
|
||||||
|
// Predecessor packages that aren't Closed yet. A package waiting on upstream work
|
||||||
|
// is not release-ready either, so the field list must not call it Ready — the
|
||||||
|
// server would refuse to issue it (see enforce_release_gates).
|
||||||
|
function waitingCount(p, all) {
|
||||||
|
var preds = (p && p.predecessors) || [];
|
||||||
|
if (!preds.length) return 0;
|
||||||
|
var byId = {};
|
||||||
|
(all || []).forEach(function (x) { byId[x.id] = x; });
|
||||||
|
return preds.filter(function (id) { var q = byId[id]; return q && q.status !== 'Closed'; }).length;
|
||||||
|
}
|
||||||
|
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 ''; } }
|
||||||
|
// 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() {
|
||||||
|
var params = new URLSearchParams(location.search);
|
||||||
|
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) {} }
|
||||||
|
// 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() {
|
||||||
|
var el = document.getElementById('fld-ctx'); if (!el) return;
|
||||||
|
if (PROJECT) el.innerHTML = 'Project: <b>' + esc(PROJECT.name || '') + '</b>' + (PROJECT.number ? ' · ' + esc(PROJECT.number) : '');
|
||||||
|
else el.textContent = 'Project: ' + PID;
|
||||||
|
}
|
||||||
|
function readCache() { try { return JSON.parse(localStorage.getItem(nsKey(PID)) || '[]') || []; } catch (e) { return []; } }
|
||||||
|
function writeCache() { try { localStorage.setItem(nsKey(PID), JSON.stringify(WPS)); } catch (e) {} }
|
||||||
|
function activePkgs(list) { return list.filter(function (p) { return !p.split && !p.archived; }); } // real work, not masters/archived
|
||||||
|
|
||||||
|
function loadWPs() {
|
||||||
|
WPS = activePkgs(readCache()); // offline-first: show cached packages immediately
|
||||||
|
renderList();
|
||||||
|
if (ProjectData.pullProject) {
|
||||||
|
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>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── list ───────────────────────────────────────────────────────────────────
|
||||||
|
function renderList() {
|
||||||
|
var box = document.getElementById('wp-list'); if (!box) return;
|
||||||
|
var q = ((document.getElementById('fld-search') || {}).value || '').toLowerCase();
|
||||||
|
var rows = WPS.filter(function (p) { return !q || ((p.number || '') + ' ' + (p.subject || '') + ' ' + (p.type || '')).toLowerCase().indexOf(q) >= 0; });
|
||||||
|
if (!rows.length) { box.innerHTML = '<div class="fld-empty">' + (WPS.length ? 'No packages match your search.' : 'No work packages for this project yet.') + '</div>'; return; }
|
||||||
|
box.innerHTML = rows.map(function (p) {
|
||||||
|
var open = openCount(p);
|
||||||
|
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>'));
|
||||||
|
return '<button class="wp-card ' + cls + '" onclick="openWP(\'' + esc(p.id) + '\')">' +
|
||||||
|
'<div class="num">' + esc(p.number || '(no number)') + '</div>' +
|
||||||
|
'<div class="subj">' + esc(p.subject || '') + '</div>' +
|
||||||
|
'<div class="meta"><span class="pill st">' + esc(stLabel(p.status)) + '</span>' + readyPill +
|
||||||
|
(p.type ? '<span class="pill st">' + esc(p.type) + '</span>' : '') + '</div></button>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── detail ─────────────────────────────────────────────────────────────────
|
||||||
|
function curWP() { return WPS.find(function (p) { return p.id === curId; }); }
|
||||||
|
function openWP(id) { curId = id; pendingPhoto = ''; draftNote = ''; renderDetail(); window.scrollTo(0, 0); }
|
||||||
|
function backToList() {
|
||||||
|
curId = null; pendingPhoto = ''; draftNote = '';
|
||||||
|
document.getElementById('screen-detail').style.display = 'none';
|
||||||
|
document.getElementById('screen-list').style.display = '';
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
function renderDetail() {
|
||||||
|
var p = curWP(); if (!p) { backToList(); return; }
|
||||||
|
document.getElementById('screen-list').style.display = 'none';
|
||||||
|
var d = document.getElementById('screen-detail'); d.style.display = '';
|
||||||
|
|
||||||
|
var stBtns = STATUSES.map(function (s) {
|
||||||
|
return '<button class="st-btn' + (s === 'Issue' ? ' hold' : '') + (p.status === s ? ' on' : '') + '" onclick="setStatus(\'' + s + '\')">' + esc(stLabel(s)) + '</button>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
var cx = (p.constraints) || [];
|
||||||
|
var cxRows = cx.length ? cx.map(function (c, i) {
|
||||||
|
var st = c.status || 'open';
|
||||||
|
return '<div class="cx-row"><div class="cx-name">' + esc(c.name) + '</div>' +
|
||||||
|
'<button class="cx-state ' + st + '" onclick="cycleConstraint(' + i + ')">' + (st === 'cleared' ? 'Cleared' : st === 'na' ? 'N/A' : 'Open') + '</button></div>';
|
||||||
|
}).join('') : '<div style="color:var(--cds-text-helper);font-size:14px">No constraints on this package.</div>';
|
||||||
|
|
||||||
|
var log = ((p.fieldLog) || []).slice().reverse().map(function (e) {
|
||||||
|
return '<div class="log-item"><div class="lm">' + esc(e.by || '—') + ' · ' + esc(fmtTs(e.ts)) + (e.status ? ' · ' + esc(stLabel(e.status)) : '') + '</div>' +
|
||||||
|
(e.note ? esc(e.note) : '') + (e.photo && /^data:image\//.test(e.photo) ? '<img src="' + esc(e.photo) + '" alt="site photo">' : '') + '</div>';
|
||||||
|
}).join('') || '<div style="color:var(--cds-text-helper);font-size:14px">No field updates yet.</div>';
|
||||||
|
|
||||||
|
d.innerHTML =
|
||||||
|
'<button class="fld-back" onclick="backToList()">‹ All packages</button>' +
|
||||||
|
'<div class="fld-h1">' + esc(p.number || '(no number)') + '</div>' +
|
||||||
|
'<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>' +
|
||||||
|
'<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>' +
|
||||||
|
'<div class="fld-sec"><h3>Field log</h3>' + log + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mutations (each auto-saves via the outbox; the global sync badge shows state) ──
|
||||||
|
function saveWP(p) {
|
||||||
|
var ix = WPS.findIndex(function (x) { return x.id === p.id; });
|
||||||
|
if (ix >= 0) WPS[ix] = p;
|
||||||
|
writeCache();
|
||||||
|
if (typeof ProjectData !== 'undefined' && ProjectData.pushWP) ProjectData.pushWP(p, PID);
|
||||||
|
}
|
||||||
|
function setStatus(s) {
|
||||||
|
var p = curWP(); if (!p) return;
|
||||||
|
if (GATED.indexOf(s) >= 0 && openCount(p) > 0) { toast('Clear all constraints before moving to ' + stLabel(s)); return; }
|
||||||
|
if (p.status === s) return;
|
||||||
|
p.status = s;
|
||||||
|
if (s === 'Issued' && !p.issuedAt) p.issuedAt = new Date().toISOString();
|
||||||
|
saveWP(p); renderDetail(); toast('Status: ' + stLabel(s));
|
||||||
|
}
|
||||||
|
function cycleConstraint(i) {
|
||||||
|
var p = curWP(); if (!p || !p.constraints || !p.constraints[i]) return;
|
||||||
|
var order = ['open', 'cleared', 'na'];
|
||||||
|
var cur = p.constraints[i].status || 'open';
|
||||||
|
p.constraints[i].status = order[(order.indexOf(cur) + 1) % 3];
|
||||||
|
saveWP(p); renderDetail();
|
||||||
|
}
|
||||||
|
function onPhoto(ev) {
|
||||||
|
var f = ev.target.files && ev.target.files[0]; if (!f) return;
|
||||||
|
var st = document.getElementById('photo-status'); if (st) st.textContent = 'Processing…';
|
||||||
|
var url = URL.createObjectURL(f);
|
||||||
|
var img = new Image();
|
||||||
|
img.onload = function () {
|
||||||
|
var max = 1280, w = img.width, h = img.height, scale = Math.min(1, max / Math.max(w, h));
|
||||||
|
var cv = document.createElement('canvas');
|
||||||
|
cv.width = Math.round(w * scale); cv.height = Math.round(h * scale);
|
||||||
|
cv.getContext('2d').drawImage(img, 0, 0, cv.width, cv.height);
|
||||||
|
try { pendingPhoto = cv.toDataURL('image/jpeg', 0.7); } catch (e) { pendingPhoto = ''; }
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
if (st) st.textContent = pendingPhoto ? 'Photo attached ✓' : 'Could not read photo';
|
||||||
|
};
|
||||||
|
img.onerror = function () { URL.revokeObjectURL(url); if (st) st.textContent = 'Could not read photo'; };
|
||||||
|
img.src = url;
|
||||||
|
}
|
||||||
|
function addUpdate() {
|
||||||
|
var p = curWP(); if (!p) return;
|
||||||
|
var note = (draftNote || '').trim();
|
||||||
|
if (!note && !pendingPhoto) { toast('Add a note or photo first'); return; }
|
||||||
|
if (!p.fieldLog) p.fieldLog = [];
|
||||||
|
p.fieldLog.push({ ts: new Date().toISOString(), by: me(), note: note, photo: pendingPhoto || '', status: p.status });
|
||||||
|
pendingPhoto = ''; draftNote = '';
|
||||||
|
saveWP(p); renderDetail(); toast('Update added to log');
|
||||||
|
}
|
||||||
|
|
||||||
|
boot();
|
||||||
615
html/help.js
@@ -1,80 +1,591 @@
|
|||||||
/* Shared Help + tooltip module for the Work Package Suite.
|
/* 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
|
- tooltip styles for the .help-tip (ⓘ) component and [data-tip] hovers
|
||||||
- a Help modal (workflow + key concepts) opened via window.openHelp()
|
- a searchable, multi-topic Help center modal opened via window.openHelp()
|
||||||
Add a "❔ Help" button anywhere with onclick="openHelp()". */
|
- a floating "?" launcher on any page that doesn't already have a Help button
|
||||||
|
|
||||||
|
API (unchanged + extended):
|
||||||
|
openHelp() open the help center
|
||||||
|
openHelp('topicId') open and jump to a topic (e.g. openHelp('constraints'))
|
||||||
|
closeHelp() close it
|
||||||
|
Add a Help button anywhere with onclick="openHelp()". */
|
||||||
(function (global) {
|
(function (global) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var css = `
|
// ── the help-tip component (S8 / T9.5) ─────────────────────────────────────
|
||||||
.help-tip{ display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px;
|
// Markup writes <span class="help-tip" data-tip="…">i</span>; this upgrades
|
||||||
margin-left:5px; border-radius:50%; background:#5a6675; color:#fff; font-size:10px; font-weight:700;
|
// every one to a real <button> at load (and via global.helpTipUpgrade(root)
|
||||||
font-family:ui-sans-serif,system-ui,sans-serif; cursor:help; vertical-align:middle; position:relative; }
|
// for anything rendered later). One bubble serves all badges: focus and hover
|
||||||
.help-tip::after{ content:attr(data-tip); position:absolute; bottom:130%; left:50%; transform:translateX(-50%);
|
// show it, click/tap toggles it (the touch path tablets need), Escape and
|
||||||
background:#1a2230; color:#fff; padding:7px 10px; border-radius:6px; font-size:12px; font-weight:400;
|
// leaving close it. The bubble is clamped to the viewport on both axes.
|
||||||
line-height:1.4; white-space:normal; width:max-content; max-width:260px; text-align:left; z-index:9999;
|
var _tipOpenFor = null;
|
||||||
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:#1a2230; 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; }
|
|
||||||
|
|
||||||
.ui-help-overlay{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:none; align-items:flex-start;
|
function tipBubble() {
|
||||||
justify-content:center; z-index:10000; padding:5vh 16px; overflow:auto; }
|
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 = `
|
||||||
|
/* 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: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-overlay.open{ display:flex; }
|
||||||
.ui-help-modal{ background:#fff; color:#1a2230; max-width:680px; width:100%; border-radius:10px;
|
.ui-help-modal{ background:var(--cds-layer); color:var(--cds-text-primary); max-width:980px; width:100%; height:88vh; max-height:880px;
|
||||||
box-shadow:0 12px 40px rgba(20,30,50,.3); font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; }
|
border-radius:0; box-shadow:0 12px 40px rgba(20,30,50,.3); display:flex; flex-direction:column; overflow:hidden;
|
||||||
.ui-help-head{ display:flex; align-items:center; justify-content:space-between; padding:16px 20px;
|
font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif; }
|
||||||
border-bottom:1px solid #e3e6ec; font-size:16px; }
|
.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 button{ background:none; border:none; font-size:18px; cursor:pointer; color:#5a6675; line-height:1; }
|
.ui-help-head .ui-help-title{ font-size:15px; font-weight:700; white-space:nowrap; }
|
||||||
.ui-help-body{ padding:18px 22px; font-size:13.5px; line-height:1.6; }
|
.ui-help-search{ flex:1; position:relative; max-width:420px; }
|
||||||
.ui-help-body h4{ margin:18px 0 6px; font-size:13px; text-transform:uppercase; letter-spacing:.03em; color:#2563d6; }
|
.ui-help-search input{ width:100%; padding:8px 12px; border:1px solid var(--cds-border-strong); border-radius:0;
|
||||||
.ui-help-body h4:first-child{ margin-top:0; }
|
font-size:13px; outline:none; background:var(--cds-layer-accent); }
|
||||||
.ui-help-body ol, .ui-help-body ul{ margin:0 0 6px; padding-left:20px; }
|
.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-body li{ margin-bottom:5px; }
|
.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-body code{ background:#f0f2f5; padding:1px 5px; border-radius:4px; font-size:12px; }
|
.ui-help-wrap{ display:flex; flex:1; min-height:0; }
|
||||||
`;
|
.ui-help-nav{ width:230px; flex:none; border-right:1px solid var(--cds-border-subtle); overflow:auto; padding:10px 8px; background:var(--cds-layer-accent); }
|
||||||
|
.ui-help-nav 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: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: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: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 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; }
|
||||||
|
/* 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: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: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: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 var(--cds-border-subtle); }
|
||||||
|
.ui-help-nav a{ margin:0; font-size:12px; padding:5px 9px; }
|
||||||
|
.ui-help-head{ flex-wrap:wrap; }
|
||||||
|
}`;
|
||||||
var style = document.createElement('style');
|
var style = document.createElement('style');
|
||||||
style.textContent = css;
|
style.textContent = css;
|
||||||
(document.head || document.documentElement).appendChild(style);
|
(document.head || document.documentElement).appendChild(style);
|
||||||
|
|
||||||
var HELP_HTML = `
|
// ── content ─────────────────────────────────────────────────────────────────
|
||||||
<h4>How the suite works</h4>
|
// Each topic: { id, title, body(HTML) }. Order here is the nav order.
|
||||||
|
var TOPICS = [
|
||||||
|
{ id: 'overview', title: 'Getting started', body: `
|
||||||
|
<h3>Getting started</h3>
|
||||||
|
<p>The Work Package Suite turns a project's standard procedure into release-ready <strong>Installation Work Packages (IWPs)</strong>. You work in three stages, always in the same order:</p>
|
||||||
<ol>
|
<ol>
|
||||||
<li><strong>Pick or create a Project</strong> on the home page — projects are stored centrally and each keeps its own SOP and Work Packages.</li>
|
<li><strong>Pick or create a Project</strong> on the home page. Each project keeps its own SOP and its own Work Packages, so you can run many jobs at once.</li>
|
||||||
<li><strong>SOP Configuration</strong> — set the project baseline (team, sign-offs, WP types, governance & sizing, quality, sequence, constraints, sources). Every Work Package inherits these defaults.</li>
|
<li><strong>SOP Configuration</strong> — set the project baseline in 10 steps (team, sign-offs, WP types, governance & sizing, quality, platforms, sequence, constraints, sources). Every Work Package inherits these defaults. The Creator stays locked until the SOP is marked complete.</li>
|
||||||
<li><strong>Work Package Creation</strong> — author individual IWPs against the SOP. Use <strong>New</strong> for a blank one or <strong>Duplicate</strong> to copy an existing one.</li>
|
<li><strong>Work Package Creation</strong> — author individual IWPs against the SOP, clear their constraints, and issue them to the field.</li>
|
||||||
<li><strong>Dashboard</strong> — track status, hours, and what's gating each package across the project.</li>
|
<li><strong>Dashboard</strong> — track status, hours, due dates, and what's gating each package across the project.</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
<h4>Moving around</h4>
|
||||||
|
<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>
|
||||||
|
<li><strong>Finish the SOP</strong> — its home-page card turns green and unlocks the Work Package Creator.</li>
|
||||||
|
<li><strong>Open “Work Package Creation”</strong> to author packages with your SOP defaults pre-populated.</li>
|
||||||
|
<li><strong>Update from the field</strong> using the <strong>Field View</strong>, and <strong>leave feedback</strong> on any page with the Feedback button.</li>
|
||||||
|
</ol>
|
||||||
|
<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>` },
|
||||||
|
|
||||||
<h4>Key concepts</h4>
|
{ id: 'projects', title: 'Projects', body: `
|
||||||
|
<h3>Projects</h3>
|
||||||
|
<p>A <strong>project</strong> is the top-level container — every SOP and Work Package belongs to one. Create or select projects on the home page.</p>
|
||||||
|
<h4>Project fields</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Constraints & release readiness:</strong> a package can't move to <em>Issued</em> until every constraint is <em>Cleared</em> or <em>N/A</em>. If a constraint reopens after release, the package drops to <em>Issue (Hold)</em>.</li>
|
<li><strong>Project Name</strong> (required)</li>
|
||||||
<li><strong>Disciplines & Split:</strong> a package can carry more than one discipline (e.g. Mechanical + Electrical + Tech), each with its own scope section and status. <strong>Split by Discipline</strong> breaks it into instances — <code>WP01A</code>, <code>WP01B</code>, <code>WP01C</code> — each tied to the master.</li>
|
<li><strong>Project Number</strong></li>
|
||||||
<li><strong>WP size:</strong> the SOP sets a typical size band, which sets a max-hours <em>split threshold</em>. The creator warns when a package's estimated hours exceed it so it can be broken down.</li>
|
<li><strong>Client</strong></li>
|
||||||
<li><strong>Material by discipline:</strong> on a multi-discipline package each material line can be tagged to a discipline; splitting routes each instance only its own materials.</li>
|
<li><strong>Division / Sector</strong></li>
|
||||||
|
<li><strong>Site / Location</strong></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<h4>The active project</h4>
|
||||||
|
<p>The <strong>active project</strong> is the one you're currently working in. All SOP and Work Package data is scoped (namespaced) to it, so switching projects loads that project's own configuration and packages — nothing leaks between jobs. Use the <em>change</em> link next to the active project name to switch.</p>
|
||||||
|
<div class="ui-help-callout">Projects are stored centrally via the API and mirrored to your browser, so the suite still works offline; it re-syncs when the connection returns.</div>` },
|
||||||
|
|
||||||
<h4>Tips</h4>
|
{ 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>
|
||||||
|
<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>
|
||||||
|
<li><strong>Required Sign-Off Roles</strong> — Superintendent and Foreman are always required; add optional roles (HSE, Quality Rep, Planner, etc.) with <em>+ Add Role</em>.</li>
|
||||||
|
<li><strong>Work Package Types</strong> — enable the install types this project uses (Conduit Install, Wire Pull, Terminations, …). Enabled types populate the WP type picker.</li>
|
||||||
|
<li><strong>Governance & WP Numbering</strong> — the WP <strong>number format</strong> (e.g. <code>WP##-[Sector]-[TYPE]</code>), issuance strategy, the project's <strong>disciplines</strong>, the <strong>discipline strategy</strong>, and <strong>WP sizing</strong> (see <a data-help-jump="sizing">Sizing</a> and <a data-help-jump="disciplines">Disciplines</a>).</li>
|
||||||
|
<li><strong>Quality & Inspection Strategy</strong> — QC requirement, photo/documentation standard, and hold/witness points.</li>
|
||||||
|
<li><strong>Tracking & Commissioning Platforms</strong> — e.g. CxAlloy, Procore, ACC.</li>
|
||||||
|
<li><strong>Construction Sequence</strong> — the install flow; reorder by dragging (⠿), edit labels, add <em>◆ QC Hold</em> gates or custom steps. These feed the WP "predecessor" picker.</li>
|
||||||
|
<li><strong>Release Gate Constraints</strong> — choose which standard AWP constraints apply and add custom ones (see <a data-help-jump="constraints">Constraints</a>).</li>
|
||||||
|
<li><strong>Engineering Sources & 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>` },
|
||||||
|
|
||||||
|
{ id: 'wps', title: 'Work Packages', body: `
|
||||||
|
<h3>Creating Work Packages</h3>
|
||||||
|
<p>In the Creator, start a package with <strong>+ New</strong> (blank, auto-numbered) or <strong>⧉ Duplicate</strong> (copies a saved package and increments the number). The <strong>WP Number</strong> is built automatically from the SOP number format plus your scope fields, the WP type, and a counter — it's read-only.</p>
|
||||||
|
<h4>Key fields</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Load Sample</strong> is context-aware — it loads the sample SOP on the SOP tab and an example Work Package on the WP tab.</li>
|
<li><strong>Subject / Title</strong> (required) and <strong>WP Type</strong> (required, from the SOP).</li>
|
||||||
<li>Data is kept <strong>per project</strong>; switch projects from the home page.</li>
|
<li><strong>Assets</strong> — search the Micron DB by asset ID and add each asset the package covers. Anything not in the Micron DB can still be typed in by hand.</li>
|
||||||
<li>Hover the <span class="help-tip" data-tip="Like this one — hover any ⓘ for a hint.">i</span> icons for inline hints.</li>
|
<li><strong>Disciplines</strong> — which trades the package covers (see <a data-help-jump="disciplines">Disciplines & Split</a>).</li>
|
||||||
</ul>`;
|
<li><strong>Scope & Work</strong> — the sequenced steps the crew performs (per-discipline in multi-discipline mode).</li>
|
||||||
|
<li><strong>Labor – Est. Hrs.</strong> — drives the sizing check (see <a data-help-jump="sizing">Sizing</a>).</li>
|
||||||
|
<li><strong>Material List</strong> — the bill of materials; import from CSV/Excel or add lines manually.</li>
|
||||||
|
<li><strong>Drawings & Attachments</strong> — documents and SOP source-folder links.</li>
|
||||||
|
<li><strong>Kitting & Material Movement (MIMO)</strong> — kitting status, warehouse owner, move date/location.</li>
|
||||||
|
<li><strong>Constraints</strong> — the release gate (see <a data-help-jump="constraints">Constraints</a>).</li>
|
||||||
|
<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>` },
|
||||||
|
|
||||||
|
{ id: 'statuses', title: 'Statuses', body: `
|
||||||
|
<h3>Work Package statuses</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>Status</th><th>Meaning</th></tr>
|
||||||
|
<tr><td><span class="ui-help-pill pill-draft">Draft</span></td><td>Work in progress; not yet released.</td></tr>
|
||||||
|
<tr><td><span class="ui-help-pill pill-sched">Scheduled</span></td><td>Planned and scheduled; upcoming.</td></tr>
|
||||||
|
<tr><td><span class="ui-help-pill pill-issued">Issued</span></td><td>Released to the field. Requires <em>all constraints Cleared or N/A</em>.</td></tr>
|
||||||
|
<tr><td><span class="ui-help-pill pill-prog">In Progress</span></td><td>Actively being worked.</td></tr>
|
||||||
|
<tr><td><span class="ui-help-pill pill-qc">QC</span></td><td>In quality check / inspection.</td></tr>
|
||||||
|
<tr><td><span class="ui-help-pill pill-closed">Closed</span></td><td>Completed.</td></tr>
|
||||||
|
<tr><td><span class="ui-help-pill pill-hold">Issue (Hold)</span></td><td>A constraint reopened after release — work is paused until it's resolved.</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="ui-help-callout">A package <strong>cannot move to Issued</strong> while any constraint is Open. If a constraint reopens after a package is Issued, its status automatically drops to <strong>Issue (Hold)</strong> and the suite makes you log what happened.</div>
|
||||||
|
<p>On a multi-discipline package, each discipline carries its own status and the overall status <strong>rolls up to the least-advanced discipline</strong> — so a package is never "Closed" while one trade still lags.</p>` },
|
||||||
|
|
||||||
|
{ id: 'constraints', title: 'Constraints & release', body: `
|
||||||
|
<h3>Constraints & release readiness</h3>
|
||||||
|
<p>Constraints are the readiness checklist that gates a package's release to the field. They follow Advanced Work Packaging (AWP Vol II §2.3.2). The standard set:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Safety & Permitting</li><li>Quality Control / Inspection</li><li>IFC Drawings & Specs</li>
|
||||||
|
<li>Schedule</li><li>Materials (on site, bagged & tagged)</li><li>Prefabrication</li>
|
||||||
|
<li>Work Access & Laydown</li><li>Craft Availability</li><li>Construction Equipment & Tools</li>
|
||||||
|
<li>Scaffolding / Access Equipment</li>
|
||||||
|
</ol>
|
||||||
|
<p>Pick which apply (and add custom ones) in <strong>SOP Step 9</strong>. Each constraint on a package has one of three states:</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>State</th><th>Effect</th></tr>
|
||||||
|
<tr><td><strong>Open</strong></td><td>Not yet cleared — <em>blocks release</em>.</td></tr>
|
||||||
|
<tr><td><strong>Cleared</strong></td><td>Requirement met — counts toward release-ready.</td></tr>
|
||||||
|
<tr><td><strong>N/A</strong></td><td>Not applicable to this package — counts as cleared.</td></tr>
|
||||||
|
</table>
|
||||||
|
<h4>The release gate</h4>
|
||||||
|
<ul>
|
||||||
|
<li>A package is <strong>release-ready</strong> when every constraint is Cleared or N/A. The sticky banner shows green when ready, amber when constraints are still open, and red when on hold.</li>
|
||||||
|
<li>When the last open constraint clears, the suite offers to mark the package <strong>Issued</strong>.</li>
|
||||||
|
<li>If a constraint reopens after the package is Issued, you log the hold (what reopened, details, optional doc link & photo) and the status drops to <strong>Issue (Hold)</strong>.</li>
|
||||||
|
</ul>` },
|
||||||
|
|
||||||
|
{ id: 'disciplines', title: 'Disciplines & Split', body: `
|
||||||
|
<h3>Disciplines & Split by Discipline</h3>
|
||||||
|
<p>Disciplines are trades (Mechanical, Electrical, Tech, …) set in <strong>SOP Step 5</strong>. The <strong>discipline strategy</strong> controls how packages handle them:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Let the planner choose per package</strong> (recommended) — pick one discipline (flat scope) or several (per-discipline scope + the <em>Split</em> option).</li>
|
||||||
|
<li><strong>One discipline per package</strong> — each WP is single-discipline.</li>
|
||||||
|
<li><strong>Multiple disciplines per package</strong> — scope is always split by discipline.</li>
|
||||||
|
</ul>
|
||||||
|
<h4>Split by Discipline</h4>
|
||||||
|
<p>When a package covers 2+ disciplines, the <strong>⎘ Split by Discipline</strong> button breaks it into one numbered instance per discipline — <code>WP01A</code>, <code>WP01B</code>, <code>WP01C</code> (or <code>_MECH</code>/<code>_ELEC</code> suffixes, set in the SOP). The original is kept as a <strong>master / roll-up</strong>; each instance:</p>
|
||||||
|
<ul>
|
||||||
|
<li>becomes its own single-discipline package, issued independently;</li>
|
||||||
|
<li>receives only the <strong>scope steps</strong> and <strong>materials tagged to that discipline</strong>;</li>
|
||||||
|
<li>stays linked back to the master.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="ui-help-callout">Tag material rows to a discipline <em>before</em> splitting. <strong>Untagged rows stay on the master only</strong> and won't be routed to any instance. Masters are excluded from dashboard counts so hours aren't double-counted.</div>` },
|
||||||
|
|
||||||
|
{ id: 'sizing', title: 'Sizing', body: `
|
||||||
|
<h3>Work Package sizing</h3>
|
||||||
|
<p>In <strong>SOP Step 5</strong> you set a typical WP <strong>size band</strong>, which sets a <strong>split threshold</strong> (max labor hours):</p>
|
||||||
|
<table>
|
||||||
|
<tr><th>Size band</th><th>Split threshold</th></tr>
|
||||||
|
<tr><td>Small — 1–2 days (≈8–24 hrs)</td><td>24 hrs</td></tr>
|
||||||
|
<tr><td>Standard — 3–5 days (≈40–80 hrs)</td><td>80 hrs</td></tr>
|
||||||
|
<tr><td>Large — 1–2 weeks (≈80–160 hrs)</td><td>160 hrs</td></tr>
|
||||||
|
<tr><td>Custom…</td><td>you set it</td></tr>
|
||||||
|
</table>
|
||||||
|
<p>In the Creator, the <strong>Est. Hrs.</strong> field is checked live against the threshold. Within range you see the target band; over it you get an amber warning — <em>"⚠ … exceeds the …-hr split threshold — consider breaking this package down"</em> — and a nudge to split by discipline where that applies. It's a guide, not a hard block: you can proceed if it's intentional.</p>` },
|
||||||
|
|
||||||
|
{ 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>
|
||||||
|
<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>
|
||||||
|
<li><strong>Est. hrs</strong> and <strong>Actual hrs</strong> (summed)</li>
|
||||||
|
</ul>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
<div class="ui-help-callout">Split <strong>masters</strong> are labelled and excluded from the counts; you issue their instances one at a time as each becomes release-ready.</div>` },
|
||||||
|
|
||||||
|
{ id: 'data', title: 'Samples, sharing & comments', body: `
|
||||||
|
<h3>Samples, import / export & comments</h3>
|
||||||
|
<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>
|
||||||
|
<li><strong>SOP</strong> — the Creator can import a SOP <code>.json</code> (via <em>⤒ Import SOP</em>) or load the sample SOP.</li>
|
||||||
|
<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>
|
||||||
|
<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>` },
|
||||||
|
|
||||||
|
{ id: 'shortcuts', title: 'Tips & shortcuts', body: `
|
||||||
|
<h3>Tips & keyboard shortcuts</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Enter</strong> in a sequence, constraint, or material input adds/saves that row.</li>
|
||||||
|
<li><strong>Esc</strong> closes any modal — this help center, comments, the constraint library, and the hold-log dialog.</li>
|
||||||
|
<li>Hover any <span class="help-tip" data-tip="Like this one — hover any ⓘ for a hint.">i</span> icon for an inline hint.</li>
|
||||||
|
<li>Data is kept <strong>per project</strong> — switch projects from the home page.</li>
|
||||||
|
<li>Your work <strong>auto-saves</strong> to the browser as you type; <em>Save & View</em> produces the print-ready output.</li>
|
||||||
|
<li>Click a metric card or status chip on the <strong>Dashboard</strong> to filter the table.</li>
|
||||||
|
</ul>` },
|
||||||
|
|
||||||
|
{ id: 'glossary', title: 'Glossary', body: `
|
||||||
|
<h3>Glossary</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>Term</th><th>Meaning</th></tr>
|
||||||
|
<tr><td><strong>IWP</strong></td><td>Installation Work Package — the field-level package this tool produces.</td></tr>
|
||||||
|
<tr><td><strong>AWP</strong></td><td>Advanced Work Packaging — the methodology behind the constraint set and release gate.</td></tr>
|
||||||
|
<tr><td><strong>SOP</strong></td><td>Standard Operating Procedure — the project baseline every WP inherits.</td></tr>
|
||||||
|
<tr><td><strong>Constraint</strong></td><td>A readiness item (Open / Cleared / N/A) that gates release.</td></tr>
|
||||||
|
<tr><td><strong>Release-ready</strong></td><td>All constraints Cleared or N/A — the package can be Issued.</td></tr>
|
||||||
|
<tr><td><strong>Issued</strong></td><td>Released to the field.</td></tr>
|
||||||
|
<tr><td><strong>Issue (Hold)</strong></td><td>A released package paused because a constraint reopened.</td></tr>
|
||||||
|
<tr><td><strong>Discipline</strong></td><td>A trade (Mechanical, Electrical, Tech, …).</td></tr>
|
||||||
|
<tr><td><strong>Split / Master / Instance</strong></td><td>Breaking a multi-discipline package (master/roll-up) into single-discipline instances (WP01A/B/C).</td></tr>
|
||||||
|
<tr><td><strong>Scope</strong></td><td>The sequenced steps the crew performs.</td></tr>
|
||||||
|
<tr><td><strong>Sequence</strong></td><td>SOP-defined construction phases; a WP can name a predecessor step.</td></tr>
|
||||||
|
<tr><td><strong>Bagged & tagged</strong></td><td>Materials on site, kitted, and labelled — part of the Materials constraint.</td></tr>
|
||||||
|
<tr><td><strong>MIMO</strong></td><td>Material In / Material Out — kitting and staging logistics.</td></tr>
|
||||||
|
<tr><td><strong>Asset</strong></td><td>An asset ID from the Micron DB that a package is built around. The Micron DB is read-only here — picking an asset never changes it.</td></tr>
|
||||||
|
<tr><td><strong>Hold / Witness point</strong></td><td>Hold = work stops until inspection sign-off; Witness = inspection offered but work may proceed.</td></tr>
|
||||||
|
<tr><td><strong>Active project</strong></td><td>The currently selected project; all data is scoped to it.</td></tr>
|
||||||
|
</table>` },
|
||||||
|
|
||||||
|
{ 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>
|
||||||
|
<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>
|
||||||
|
<p>Only material rows <strong>tagged to a discipline</strong> are routed to that instance. Untagged rows stay on the master. Tag them before splitting.</p>
|
||||||
|
<h4>Why don't split masters show in the dashboard totals?</h4>
|
||||||
|
<p>Masters are roll-ups; counting them would double-count their hours and packages. The individual instances are counted instead.</p>
|
||||||
|
<h4>Will I lose my work if I close the browser?</h4>
|
||||||
|
<p>No — drafts auto-save locally per project and reload next time. Use <em>Export (JSON)</em> for a backup or to share with a teammate.</p>
|
||||||
|
<h4>Does each project keep its own data?</h4>
|
||||||
|
<p>Yes. SOP and Work Packages are scoped to the active project; switching projects loads that project's own set.</p>
|
||||||
|
<h4>How do I report a problem or suggestion?</h4>
|
||||||
|
<p>Use the feedback / comments features (home page, SOP <em>Step Comments</em>, or the Creator's <em>Comments</em> drawer).</p>` }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── build ─────────────────────────────────────────────────────────────────
|
||||||
function buildModal() {
|
function buildModal() {
|
||||||
if (document.getElementById('ui-help-overlay')) return;
|
if (document.getElementById('ui-help-overlay')) return;
|
||||||
|
|
||||||
var overlay = document.createElement('div');
|
var overlay = document.createElement('div');
|
||||||
overlay.className = 'ui-help-overlay';
|
overlay.className = 'ui-help-overlay';
|
||||||
overlay.id = 'ui-help-overlay';
|
overlay.id = 'ui-help-overlay';
|
||||||
overlay.innerHTML = '<div class="ui-help-modal" role="dialog" aria-modal="true" aria-label="Help">' +
|
|
||||||
'<div class="ui-help-head"><strong>❔ Help — Work Package Suite</strong>' +
|
var nav = TOPICS.map(function (t) {
|
||||||
'<button type="button" onclick="closeHelp()" aria-label="Close help">✕</button></div>' +
|
return '<a data-help-target="' + t.id + '">' + t.title + '</a>';
|
||||||
'<div class="ui-help-body">' + HELP_HTML + '</div></div>';
|
}).join('');
|
||||||
|
|
||||||
|
var sections = TOPICS.map(function (t) {
|
||||||
|
return '<section class="ui-help-sec" id="ui-help-sec-' + t.id + '">' + t.body + '</section>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
overlay.innerHTML =
|
||||||
|
'<div class="ui-help-modal" role="dialog" aria-modal="true" aria-label="Help center">' +
|
||||||
|
'<div class="ui-help-head">' +
|
||||||
|
'<span class="ui-help-title">Help — Work Package Suite</span>' +
|
||||||
|
'<span class="ui-help-search"><input id="ui-help-q" type="search" placeholder="Search help…" aria-label="Search help"></span>' +
|
||||||
|
'<button type="button" class="ui-help-x" onclick="closeHelp()" aria-label="Close help">✕</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="ui-help-wrap">' +
|
||||||
|
'<nav class="ui-help-nav" id="ui-help-nav">' + nav + '</nav>' +
|
||||||
|
'<div class="ui-help-content" id="ui-help-content">' +
|
||||||
|
'<p class="ui-help-noresult" id="ui-help-noresult">No matches. Try another word.</p>' +
|
||||||
|
sections +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
overlay.addEventListener('click', function (e) { if (e.target === overlay) closeHelp(); });
|
overlay.addEventListener('click', function (e) { if (e.target === overlay) closeHelp(); });
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// nav clicks + in-content cross-links jump to a section
|
||||||
|
overlay.addEventListener('click', function (e) {
|
||||||
|
var t = e.target.closest('[data-help-target],[data-help-jump]');
|
||||||
|
if (!t) return;
|
||||||
|
e.preventDefault();
|
||||||
|
jumpTo(t.getAttribute('data-help-target') || t.getAttribute('data-help-jump'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// search
|
||||||
|
var q = overlay.querySelector('#ui-help-q');
|
||||||
|
q.addEventListener('input', function () { runSearch(q.value); });
|
||||||
|
|
||||||
|
// highlight nav as you scroll
|
||||||
|
var content = overlay.querySelector('#ui-help-content');
|
||||||
|
content.addEventListener('scroll', syncActiveNav, { passive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
global.openHelp = function () { buildModal(); document.getElementById('ui-help-overlay').classList.add('open'); };
|
function jumpTo(id) {
|
||||||
global.closeHelp = function () { var o = document.getElementById('ui-help-overlay'); if (o) o.classList.remove('open'); };
|
var sec = document.getElementById('ui-help-sec-' + id);
|
||||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') global.closeHelp(); });
|
if (!sec) return;
|
||||||
|
// Clear any active search filter so the target is visible.
|
||||||
|
var q = document.getElementById('ui-help-q');
|
||||||
|
if (q && q.value) { q.value = ''; runSearch(''); }
|
||||||
|
sec.scrollIntoView({ block: 'start' });
|
||||||
|
setActiveNav(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveNav(id) {
|
||||||
|
var nav = document.getElementById('ui-help-nav');
|
||||||
|
if (!nav) return;
|
||||||
|
nav.querySelectorAll('a').forEach(function (a) {
|
||||||
|
a.classList.toggle('active', a.getAttribute('data-help-target') === id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncActiveNav() {
|
||||||
|
var content = document.getElementById('ui-help-content');
|
||||||
|
if (!content) return;
|
||||||
|
var top = content.scrollTop, best = null, bestDist = Infinity;
|
||||||
|
TOPICS.forEach(function (t) {
|
||||||
|
var sec = document.getElementById('ui-help-sec-' + t.id);
|
||||||
|
if (!sec || sec.classList.contains('hide')) return;
|
||||||
|
var d = Math.abs(sec.offsetTop - top);
|
||||||
|
if (sec.offsetTop - top <= 40 && d < bestDist) { bestDist = d; best = t.id; }
|
||||||
|
});
|
||||||
|
if (best) setActiveNav(best);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── search: filter sections + highlight matches ───────────────────────────
|
||||||
|
function clearMarks(root) {
|
||||||
|
root.querySelectorAll('mark').forEach(function (m) {
|
||||||
|
var txt = document.createTextNode(m.textContent);
|
||||||
|
m.parentNode.replaceChild(txt, m);
|
||||||
|
});
|
||||||
|
root.normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function markMatches(el, query) {
|
||||||
|
var lower = query.toLowerCase();
|
||||||
|
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
|
||||||
|
acceptNode: function (node) {
|
||||||
|
if (!node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
|
||||||
|
var p = node.parentNode.nodeName;
|
||||||
|
if (p === 'MARK' || p === 'STYLE' || p === 'SCRIPT') return NodeFilter.FILTER_REJECT;
|
||||||
|
return node.nodeValue.toLowerCase().indexOf(lower) >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var nodes = [], n;
|
||||||
|
while ((n = walker.nextNode())) nodes.push(n);
|
||||||
|
nodes.forEach(function (node) {
|
||||||
|
var val = node.nodeValue, low = val.toLowerCase(), frag = document.createDocumentFragment(), i = 0, idx;
|
||||||
|
while ((idx = low.indexOf(lower, i)) >= 0) {
|
||||||
|
if (idx > i) frag.appendChild(document.createTextNode(val.slice(i, idx)));
|
||||||
|
var mk = document.createElement('mark');
|
||||||
|
mk.textContent = val.slice(idx, idx + query.length);
|
||||||
|
frag.appendChild(mk);
|
||||||
|
i = idx + query.length;
|
||||||
|
}
|
||||||
|
if (i < val.length) frag.appendChild(document.createTextNode(val.slice(i)));
|
||||||
|
node.parentNode.replaceChild(frag, node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSearch(query) {
|
||||||
|
var content = document.getElementById('ui-help-content');
|
||||||
|
var nav = document.getElementById('ui-help-nav');
|
||||||
|
var noresult = document.getElementById('ui-help-noresult');
|
||||||
|
if (!content) return;
|
||||||
|
query = (query || '').trim();
|
||||||
|
var hits = 0;
|
||||||
|
|
||||||
|
TOPICS.forEach(function (t) {
|
||||||
|
var sec = document.getElementById('ui-help-sec-' + t.id);
|
||||||
|
var navItem = nav.querySelector('[data-help-target="' + t.id + '"]');
|
||||||
|
clearMarks(sec);
|
||||||
|
var match = !query || sec.textContent.toLowerCase().indexOf(query.toLowerCase()) >= 0;
|
||||||
|
sec.classList.toggle('hide', !match);
|
||||||
|
if (navItem) navItem.classList.toggle('nohit', !!query && !match);
|
||||||
|
if (match) {
|
||||||
|
hits++;
|
||||||
|
if (query) markMatches(sec, query);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
noresult.style.display = (query && hits === 0) ? 'block' : 'none';
|
||||||
|
if (query) { content.scrollTop = 0; }
|
||||||
|
else { syncActiveNav(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── public API ──────────────────────────────────────────────────────────────
|
||||||
|
global.openHelp = function (topicId) {
|
||||||
|
buildModal();
|
||||||
|
document.getElementById('ui-help-overlay').classList.add('open');
|
||||||
|
var q = document.getElementById('ui-help-q');
|
||||||
|
if (topicId && typeof topicId === 'string') jumpTo(topicId);
|
||||||
|
else { setActiveNav(TOPICS[0].id); if (q) setTimeout(function () { q.focus(); }, 30); }
|
||||||
|
};
|
||||||
|
global.closeHelp = function () {
|
||||||
|
var o = document.getElementById('ui-help-overlay');
|
||||||
|
if (o) o.classList.remove('open');
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') global.closeHelp();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── floating launcher on pages without their own Help button ────────────────
|
||||||
|
function maybeAddFab() {
|
||||||
|
// 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');
|
||||||
|
b.id = 'ui-help-fab'; b.className = 'ui-help-fab'; b.type = 'button';
|
||||||
|
b.title = 'Help'; b.setAttribute('aria-label', 'Open help'); b.textContent = '?';
|
||||||
|
b.addEventListener('click', function () { global.openHelp(); });
|
||||||
|
document.body.appendChild(b);
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', maybeAddFab);
|
||||||
|
else maybeAddFab();
|
||||||
})(window);
|
})(window);
|
||||||
|
|||||||
BIN
html/icon-192.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
html/icon-512.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
908
html/index.html
163
html/login.html
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Sign in — Work Package Suite</title>
|
||||||
|
<link rel="icon" href="favicon.ico" sizes="any">
|
||||||
|
<link rel="stylesheet" href="theme-light.css">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--cds-background);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background: var(--cds-layer);
|
||||||
|
border: 1px solid var(--cds-border-subtle);
|
||||||
|
border-top: 3px solid var(--cds-interactive-01);
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.brand img { height: 36px; width: auto; }
|
||||||
|
.brand .name { font-weight: 700; font-size: 0.95rem; color: var(--cds-text-primary); }
|
||||||
|
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
|
||||||
|
.sub { color: var(--cds-text-secondary); font-size: 0.875rem; margin-bottom: 1.75rem; }
|
||||||
|
label { display: block; font-size: 0.75rem; color: var(--cds-text-secondary); margin-bottom: 0.375rem; }
|
||||||
|
.field { margin-bottom: 1.25rem; }
|
||||||
|
input[type=text], input[type=password] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--cds-field);
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--cds-border-strong);
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
input:focus { outline: 2px solid var(--cds-focus); background: var(--cds-field-hover); }
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--cds-text-on-color);
|
||||||
|
background: var(--cds-button-primary);
|
||||||
|
border: none;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
button:hover:not(:disabled) { background: var(--cds-hover-primary); }
|
||||||
|
button:disabled { background: var(--cds-disabled-02); cursor: not-allowed; }
|
||||||
|
.error {
|
||||||
|
display: none;
|
||||||
|
background: var(--wp-status-error-bg);
|
||||||
|
border-left: 3px solid var(--cds-support-error);
|
||||||
|
color: var(--cds-text-error);
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.error.show { display: block; }
|
||||||
|
.foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; }
|
||||||
|
.ok {
|
||||||
|
display: none;
|
||||||
|
background: var(--wp-status-success-bg);
|
||||||
|
border-left: 3px solid var(--cds-support-success);
|
||||||
|
color: var(--wp-hover-success);
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.ok.show { display: block; }
|
||||||
|
.note {
|
||||||
|
font-size: 0.8125rem; color: var(--cds-text-secondary);
|
||||||
|
background: var(--cds-layer-accent); border-left: 3px solid var(--cds-link-primary);
|
||||||
|
padding: 0.75rem; margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.hint { font-size: 0.75rem; color: var(--cds-text-helper); margin-top: -0.75rem; margin-bottom: 1.25rem; }
|
||||||
|
a.link { color: var(--cds-link-primary); text-decoration: none; font-size: 0.8125rem; }
|
||||||
|
a.link:hover { text-decoration: underline; }
|
||||||
|
.center { text-align: center; margin-top: 1.25rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card">
|
||||||
|
<div class="brand">
|
||||||
|
<img src="prime-controls-logo.jpg" alt="Prime Controls" onerror="this.style.display='none'">
|
||||||
|
</div>
|
||||||
|
<div id="error" class="error" role="alert"></div>
|
||||||
|
<div id="ok" class="ok" role="status"></div>
|
||||||
|
|
||||||
|
<!-- SIGN IN -->
|
||||||
|
<section id="view-login">
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
<p class="sub">Work Package Suite</p>
|
||||||
|
<form id="login-form" autocomplete="on">
|
||||||
|
<div class="field">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" name="username" type="text" autocomplete="username" autofocus required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
<button id="submit" type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
<p class="center"><a href="#" id="forgot-link" class="link">Forgot password?</a></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FORGOT PASSWORD (email reset) -->
|
||||||
|
<section id="view-forgot" style="display:none">
|
||||||
|
<h1>Reset password</h1>
|
||||||
|
<p class="sub">We'll email you a link to set a new one.</p>
|
||||||
|
<div id="forgot-unavailable" class="note" style="display:none">
|
||||||
|
Password reset by email isn't switched on yet. Contact your project admin and
|
||||||
|
they'll set a new password for you. Once you're signed in you can change it
|
||||||
|
yourself from the menu in the top-right corner.
|
||||||
|
</div>
|
||||||
|
<form id="forgot-form" autocomplete="on">
|
||||||
|
<div class="field">
|
||||||
|
<label for="forgot-username">Username or email</label>
|
||||||
|
<input id="forgot-username" type="text" autocomplete="username" required>
|
||||||
|
</div>
|
||||||
|
<button id="forgot-submit" type="submit">Email me a reset link</button>
|
||||||
|
</form>
|
||||||
|
<p class="center"><a href="#" id="back-to-login" class="link">← Back to sign in</a></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SET A NEW PASSWORD (arrived from the emailed link) -->
|
||||||
|
<section id="view-reset" style="display:none">
|
||||||
|
<h1>Set a new password</h1>
|
||||||
|
<p class="sub">Choose a password you don't use anywhere else.</p>
|
||||||
|
<form id="reset-form" autocomplete="on">
|
||||||
|
<div class="field">
|
||||||
|
<label for="new-password">New password</label>
|
||||||
|
<input id="new-password" type="password" autocomplete="new-password" autofocus required>
|
||||||
|
</div>
|
||||||
|
<div class="hint">At least 12 characters.</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="new-password2">Confirm new password</label>
|
||||||
|
<input id="new-password2" type="password" autocomplete="new-password" required>
|
||||||
|
</div>
|
||||||
|
<button id="reset-submit" type="submit">Set password & sign in</button>
|
||||||
|
</form>
|
||||||
|
<p class="center"><a href="#" id="reset-to-login" class="link">← Back to sign in</a></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="foot">Authorized use only · BTG / Pilot</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="login.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
215
html/login.js
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/* Login page logic for the Work Package Suite.
|
||||||
|
|
||||||
|
Three views on one page:
|
||||||
|
• sign in posts to /api/auth/login. On success the server sets an
|
||||||
|
HttpOnly session cookie (not readable here — that's the
|
||||||
|
point) and we redirect to ?next= or the home page.
|
||||||
|
• forgot password posts to /api/auth/forgot-password, which emails a
|
||||||
|
single-use link. Only offered when the server reports
|
||||||
|
email is actually configured (/api/auth/reset-available);
|
||||||
|
otherwise we say to ask an admin.
|
||||||
|
• set a new password shown when the page is opened as login.html?reset=<token>
|
||||||
|
from that email. Posts to /api/auth/reset-password.
|
||||||
|
|
||||||
|
The reset token stays in the URL only until it's used; on success we strip it
|
||||||
|
from the address bar so it isn't left in history or copied out of the bar. */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var errorBox = document.getElementById('error');
|
||||||
|
var okBox = document.getElementById('ok');
|
||||||
|
|
||||||
|
function show(el) { if (el) el.style.display = ''; }
|
||||||
|
function hide(el) { if (el) el.style.display = 'none'; }
|
||||||
|
function byId(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
okBox.classList.remove('show');
|
||||||
|
errorBox.textContent = msg;
|
||||||
|
errorBox.classList.add('show');
|
||||||
|
}
|
||||||
|
function showOk(msg) {
|
||||||
|
errorBox.classList.remove('show');
|
||||||
|
okBox.textContent = msg;
|
||||||
|
okBox.classList.add('show');
|
||||||
|
}
|
||||||
|
function clearBanners() {
|
||||||
|
errorBox.classList.remove('show');
|
||||||
|
okBox.classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where to go after signing in: the ?next= param if it's a safe same-site
|
||||||
|
// path, otherwise the home page. (Reject absolute/scheme URLs to avoid an
|
||||||
|
// open-redirect.)
|
||||||
|
function nextTarget() {
|
||||||
|
try {
|
||||||
|
var next = new URLSearchParams(location.search).get('next') || '';
|
||||||
|
if (next && next.charAt(0) === '/' && next.charAt(1) !== '/') return next;
|
||||||
|
} catch (e) {}
|
||||||
|
return 'index.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetToken() {
|
||||||
|
try { return new URLSearchParams(location.search).get('reset') || ''; } catch (e) { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function postJson(url, payload) {
|
||||||
|
return fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}).then(function (r) {
|
||||||
|
return r.json().catch(function () { return null; }).then(function (j) {
|
||||||
|
return { status: r.status, ok: r.ok, json: j };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function detail(res, fallback) {
|
||||||
|
var d = res && res.json && res.json.detail;
|
||||||
|
return (typeof d === 'string' && d) ? d : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function view(which) {
|
||||||
|
clearBanners();
|
||||||
|
['login', 'forgot', 'reset'].forEach(function (v) {
|
||||||
|
(which === v ? show : hide)(byId('view-' + v));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sign in ────────────────────────────────────────────────────────────────
|
||||||
|
var form = byId('login-form');
|
||||||
|
var submitBtn = byId('submit');
|
||||||
|
// Guarded because a cached older login.html may not have the reset views; an
|
||||||
|
// unguarded addEventListener on null would break sign-in itself.
|
||||||
|
if (!form || !submitBtn) return;
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
clearBanners();
|
||||||
|
var username = byId('username').value.trim();
|
||||||
|
var password = byId('password').value;
|
||||||
|
if (!username || !password) { showError('Enter your username and password.'); return; }
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Signing in…';
|
||||||
|
postJson('/api/auth/login', { username: username, password: password })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.ok) { location.replace(nextTarget()); return; }
|
||||||
|
if (res.status === 401) showError('Invalid username or password.');
|
||||||
|
else if (res.status === 403) showError(detail(res, 'Your account is disabled.'));
|
||||||
|
else if (res.status === 429) showError(detail(res, 'Too many failed attempts. Try again later.'));
|
||||||
|
else showError(detail(res, 'Sign-in failed (HTTP ' + res.status + ').'));
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Sign in';
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
showError('Could not reach the server. Check your connection and try again.');
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Sign in';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── forgot password ────────────────────────────────────────────────────────
|
||||||
|
var resetAvailable = null; // null = not checked yet
|
||||||
|
|
||||||
|
function checkResetAvailable() {
|
||||||
|
if (resetAvailable !== null) return Promise.resolve(resetAvailable);
|
||||||
|
return fetch('/api/auth/reset-available')
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (j) { resetAvailable = !!(j && j.enabled); return resetAvailable; })
|
||||||
|
.catch(function () { resetAvailable = false; return false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
(byId('forgot-link') || {addEventListener: function(){}}).addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
view('forgot');
|
||||||
|
// Prefill from the sign-in box so nobody types their username twice.
|
||||||
|
var u = byId('username').value.trim();
|
||||||
|
if (u) byId('forgot-username').value = u;
|
||||||
|
checkResetAvailable().then(function (enabled) {
|
||||||
|
// With email off there's nothing to submit — say so and hide the form.
|
||||||
|
(enabled ? hide : show)(byId('forgot-unavailable'));
|
||||||
|
(enabled ? show : hide)(byId('forgot-form'));
|
||||||
|
if (enabled) byId('forgot-username').focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
(byId('back-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
view('login');
|
||||||
|
});
|
||||||
|
|
||||||
|
var forgotForm = byId('forgot-form') || document.createElement('form');
|
||||||
|
var forgotBtn = byId('forgot-submit') || document.createElement('button');
|
||||||
|
forgotForm.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
clearBanners();
|
||||||
|
var who = byId('forgot-username').value.trim();
|
||||||
|
if (!who) { showError('Enter your username or email.'); return; }
|
||||||
|
forgotBtn.disabled = true;
|
||||||
|
forgotBtn.textContent = 'Sending…';
|
||||||
|
postJson('/api/auth/forgot-password', { username: who })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.status === 503) {
|
||||||
|
showError(detail(res, "Password reset by email isn't available. Ask an administrator."));
|
||||||
|
} else if (res.ok) {
|
||||||
|
// Deliberately the same message whether or not the account exists.
|
||||||
|
showOk('If that account exists, a reset link is on its way. The link expires in an hour.');
|
||||||
|
hide(forgotForm);
|
||||||
|
} else {
|
||||||
|
showError(detail(res, 'Could not send the reset email (HTTP ' + res.status + ').'));
|
||||||
|
}
|
||||||
|
forgotBtn.disabled = false;
|
||||||
|
forgotBtn.textContent = 'Email me a reset link';
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
showError('Could not reach the server. Check your connection and try again.');
|
||||||
|
forgotBtn.disabled = false;
|
||||||
|
forgotBtn.textContent = 'Email me a reset link';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── set a new password (from the emailed link) ──────────────────────────────
|
||||||
|
(byId('reset-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
view('login');
|
||||||
|
});
|
||||||
|
|
||||||
|
var resetForm = byId('reset-form') || document.createElement('form');
|
||||||
|
var resetBtn = byId('reset-submit') || document.createElement('button');
|
||||||
|
resetForm.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
clearBanners();
|
||||||
|
var token = resetToken();
|
||||||
|
var pw = byId('new-password').value;
|
||||||
|
var pw2 = byId('new-password2').value;
|
||||||
|
if (!token) { showError('This reset link is incomplete. Request a new one.'); return; }
|
||||||
|
if (pw !== pw2) { showError('The two passwords do not match.'); return; }
|
||||||
|
if (pw.length < 12) { showError('Password must be at least 12 characters.'); return; }
|
||||||
|
|
||||||
|
resetBtn.disabled = true;
|
||||||
|
resetBtn.textContent = 'Saving…';
|
||||||
|
postJson('/api/auth/reset-password', { token: token, new_password: pw })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.ok) {
|
||||||
|
// Take the token out of the URL before anything else — it's spent.
|
||||||
|
try { history.replaceState(null, '', 'login.html'); } catch (err) {}
|
||||||
|
view('login');
|
||||||
|
showOk('Password updated. Sign in with your new password.');
|
||||||
|
byId('username').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showError(detail(res, 'Could not set your password (HTTP ' + res.status + ').'));
|
||||||
|
resetBtn.disabled = false;
|
||||||
|
resetBtn.textContent = 'Set password & sign in';
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
showError('Could not reach the server. Check your connection and try again.');
|
||||||
|
resetBtn.disabled = false;
|
||||||
|
resetBtn.textContent = 'Set password & sign in';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Arriving from the reset email opens straight into the new-password view.
|
||||||
|
if (resetToken()) view('reset');
|
||||||
|
})();
|
||||||
18
html/manifest.webmanifest
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "Prime Work Package Suite",
|
||||||
|
"short_name": "WP Suite",
|
||||||
|
"description": "Prime Controls Work Package Suite — SOPs, work packages, and field updates.",
|
||||||
|
"start_url": "/index.html",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#f4f4f4",
|
||||||
|
"theme_color": "#161616",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{ "name": "Field View", "short_name": "Field", "url": "/field.html", "description": "Update work packages from the field" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
var LS_ACTIVE_OBJ = 'wp_active_project_obj';
|
var LS_ACTIVE_OBJ = 'wp_active_project_obj';
|
||||||
|
|
||||||
function uid() { return 'proj_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
|
function uid() { return 'proj_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
|
||||||
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
function esc(v) { return v == null ? '' : String(v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
|
||||||
|
|
||||||
function readLocal() { try { return JSON.parse(localStorage.getItem(LS_PROJECTS) || '[]') || []; } catch (e) { return []; } }
|
function readLocal() { try { return JSON.parse(localStorage.getItem(LS_PROJECTS) || '[]') || []; } catch (e) { return []; } }
|
||||||
function writeLocal(list) { try { localStorage.setItem(LS_PROJECTS, JSON.stringify(list)); } catch (e) {} }
|
function writeLocal(list) { try { localStorage.setItem(LS_PROJECTS, JSON.stringify(list)); } catch (e) {} }
|
||||||
@@ -24,8 +24,18 @@
|
|||||||
}
|
}
|
||||||
function cacheRemove(id) { writeLocal(readLocal().filter(function (x) { return x.id !== id; })); }
|
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 = {
|
var SAMPLE_PROJECT = {
|
||||||
name: 'Micron — INC Construction Work Packages', number: '26-67-008',
|
name: 'Micron FMCS Install (sample)', number: '26-67-008',
|
||||||
client: 'Micron Technology, Inc.', division: 'Semiconductor',
|
client: 'Micron Technology, Inc.', division: 'Semiconductor',
|
||||||
site: 'Boise, ID — Fab', sample: true
|
site: 'Boise, ID — Fab', sample: true
|
||||||
};
|
};
|
||||||
@@ -42,6 +52,15 @@
|
|||||||
.catch(function () { return readLocal(); });
|
.catch(function () { return readLocal(); });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// D7 / T9.8: the way back in, for project admins. The server filters the
|
||||||
|
// answer by per-project role; everyone else simply receives [].
|
||||||
|
listArchivedProjects: function () {
|
||||||
|
return fetch(API + '/projects?archived=only', { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||||
|
.then(function (rows) { return (rows || []).filter(function (p) { return p.archived; }); })
|
||||||
|
.catch(function () { return []; });
|
||||||
|
},
|
||||||
|
|
||||||
get: function (id) {
|
get: function (id) {
|
||||||
return fetch(API + '/projects/' + encodeURIComponent(id))
|
return fetch(API + '/projects/' + encodeURIComponent(id))
|
||||||
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||||
@@ -59,20 +78,78 @@
|
|||||||
.catch(function () { cacheUpsert(p); return p; }); // offline / no API → local only
|
.catch(function () { cacheUpsert(p); return p; }); // offline / no API → local only
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Deleting a project cascades its SOPs and work packages, and the server
|
||||||
|
// allows it only for a Project Admin. Drop it from the local cache ONLY if
|
||||||
|
// the server actually deleted it (or it was already gone) — removing it on a
|
||||||
|
// 403 would hide a project that still exists for everyone else.
|
||||||
remove: function (id) {
|
remove: function (id) {
|
||||||
return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' })
|
return fetch(API + '/projects/' + encodeURIComponent(id), { method: 'DELETE' })
|
||||||
.then(function () { cacheRemove(id); })
|
.then(function (r) {
|
||||||
.catch(function () { cacheRemove(id); });
|
if (r.ok || r.status === 404) { cacheRemove(id); return true; }
|
||||||
|
return r.json().catch(function () { return null; }).then(function (j) {
|
||||||
|
throw new Error((j && j.detail) || ('Could not delete the project (HTTP ' + r.status + ').'));
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Archiving a project lives in the admin console (html/admin.js), which doesn't
|
||||||
|
// load this file — deliberately not mirrored here, so there's only one
|
||||||
|
// implementation of it rather than two that can disagree.
|
||||||
|
|
||||||
// ── active project context ────────────────────────────────────────────────
|
// ── 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 ''; } },
|
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; } },
|
getActive: function () { try { return JSON.parse(localStorage.getItem(LS_ACTIVE_OBJ) || 'null'); } catch (e) { return null; } },
|
||||||
setActive: function (p) {
|
setActive: function (p) {
|
||||||
try {
|
try {
|
||||||
if (p) { localStorage.setItem(LS_ACTIVE, p.id); localStorage.setItem(LS_ACTIVE_OBJ, JSON.stringify(p)); }
|
if (p) {
|
||||||
else { localStorage.removeItem(LS_ACTIVE); localStorage.removeItem(LS_ACTIVE_OBJ); }
|
// 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) {}
|
} 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.
|
// Per-project namespacing for the SOP/WP localStorage keys, e.g.
|
||||||
@@ -81,6 +158,325 @@
|
|||||||
key: function (base) { var id = this.getActiveId(); return id ? base + '__' + id : base; }
|
key: function (base) { var id = this.getActiveId(); return id ? base + '__' + id : base; }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Server sync for SOPs and Work Packages ─────────────────────────────────
|
||||||
|
// SOPs and WPs are authoritative on the server (so every user of a project sees
|
||||||
|
// the same data). To avoid rewriting the two apps, we keep their existing
|
||||||
|
// localStorage keys as a per-browser CACHE: pullProject() hydrates those exact
|
||||||
|
// keys from the API on page load, and the push* helpers write through to the
|
||||||
|
// API whenever the apps save. The apps' own (synchronous) reads are unchanged.
|
||||||
|
// (Original author: C-West8, "storing data in DB instead of client only";
|
||||||
|
// reintegrated on top of the BIM/per-package work.)
|
||||||
|
function nsKey(base, id) { return id ? base + '__' + id : base; }
|
||||||
|
function currentUser() {
|
||||||
|
try { return (window.WP_USER && (window.WP_USER.username || window.WP_USER.full_name)) || ''; } catch (e) { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A saved Work Package is a flat object in the browser; the API splits it into
|
||||||
|
// promoted columns + a `data` blob. We store the whole flat object in `data`
|
||||||
|
// for perfect round-tripping (so BIM fields, kind, projectLinks, etc. all
|
||||||
|
// survive), and mirror the few fields the API promotes to columns.
|
||||||
|
function pkgToServer(p, projectId) {
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
project_id: p.projectId || projectId || null,
|
||||||
|
parent_id: p.instanceOf || null,
|
||||||
|
number: p.number || '',
|
||||||
|
subject: p.subject || '',
|
||||||
|
type: p.type || '',
|
||||||
|
status: p.status || 'Draft',
|
||||||
|
assignee_id: p.assigneeId || null,
|
||||||
|
created_by: p.createdBy || currentUser(),
|
||||||
|
data: p
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function serverToPkg(row) {
|
||||||
|
var p = Object.assign({}, row.data || {}); // full flat object lives in data
|
||||||
|
p.id = row.id;
|
||||||
|
p.projectId = row.project_id || p.projectId || '';
|
||||||
|
if (row.number) p.number = row.number;
|
||||||
|
if (row.subject != null) p.subject = row.subject;
|
||||||
|
if (row.type != null) p.type = row.type;
|
||||||
|
if (row.status) p.status = row.status; // honor server-side status changes
|
||||||
|
if (row.parent_id) p.instanceOf = row.parent_id;
|
||||||
|
p.archived = !!row.archived_at;
|
||||||
|
p.assigneeId = row.assignee_id || '';
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull this project's SOP + WPs from the API into the localStorage keys the
|
||||||
|
// apps read. Resolves even on failure (offline / no API) so boot continues.
|
||||||
|
ProjectData.pullProject = function (projectId) {
|
||||||
|
if (!projectId) return Promise.resolve();
|
||||||
|
var jobs = [];
|
||||||
|
jobs.push(
|
||||||
|
fetch(API + '/sops/latest?complete=true&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (sopRow) {
|
||||||
|
if (sopRow && sopRow.data) {
|
||||||
|
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));
|
||||||
|
// 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 () {})
|
||||||
|
);
|
||||||
|
jobs.push(
|
||||||
|
fetch(API + '/wps?full=true&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (rows) {
|
||||||
|
if (Array.isArray(rows)) {
|
||||||
|
localStorage.setItem(nsKey('wp_iwp_v1', projectId), JSON.stringify(rows.map(serverToPkg)));
|
||||||
|
}
|
||||||
|
}).catch(function () {})
|
||||||
|
);
|
||||||
|
return Promise.all(jobs).then(function () {});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Durable write-through outbox ───────────────────────────────────────────
|
||||||
|
// SOP/WP saves must survive a flaky network, a reload, or a crash — otherwise a
|
||||||
|
// silently-failed POST leaves the browser and server divergent. Instead of a
|
||||||
|
// fire-and-forget request, each mutation is appended to a localStorage-backed
|
||||||
|
// queue and flushed to the API with retry + backoff. The API upserts by id and
|
||||||
|
// DELETE is idempotent, so re-sending a queued op is always safe. The app's own
|
||||||
|
// local cache still updates immediately, so rendering never waits on the network.
|
||||||
|
var OUTBOX_KEY = 'wp_sync_outbox_v1';
|
||||||
|
var _flushTimer = null, _backoff = 0, _flushing = false;
|
||||||
|
|
||||||
|
function qRead() { try { return JSON.parse(localStorage.getItem(OUTBOX_KEY) || '[]') || []; } catch (e) { return []; } }
|
||||||
|
function qWrite(list) { try { localStorage.setItem(OUTBOX_KEY, JSON.stringify(list)); } catch (e) {} }
|
||||||
|
|
||||||
|
// Append an op, coalescing by (kind,key) so only the latest write per entity is
|
||||||
|
// queued. A delete supersedes any pending upsert for the same id.
|
||||||
|
function enqueue(op) {
|
||||||
|
var q = qRead();
|
||||||
|
if (op.kind === 'wp-del') {
|
||||||
|
q = q.filter(function (o) { return !(o.key === op.key && (o.kind === 'wp' || o.kind === 'wp-del')); });
|
||||||
|
} else {
|
||||||
|
q = q.filter(function (o) { return !(o.kind === op.kind && o.key === op.key); });
|
||||||
|
}
|
||||||
|
op.opId = op.kind + ':' + op.key + ':' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||||
|
op.tries = 0;
|
||||||
|
q.push(op);
|
||||||
|
qWrite(q);
|
||||||
|
notifySync();
|
||||||
|
scheduleFlush(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function opRequest(op) {
|
||||||
|
if (op.kind === 'wp-del') {
|
||||||
|
return fetch(API + '/wps/' + encodeURIComponent(op.key), { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
return fetch(API + (op.kind === 'sop' ? '/sops' : '/wps'), {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(op.body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpTries(opId, err) {
|
||||||
|
var q = qRead();
|
||||||
|
for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].tries = (q[i].tries || 0) + 1; q[i].lastErr = err; break; } }
|
||||||
|
qWrite(q);
|
||||||
|
}
|
||||||
|
// Permanently-failed op (a 4xx client error) — keep it for visibility but stop
|
||||||
|
// retrying, so a rejected write can't loop forever. `err` is the server's own
|
||||||
|
// explanation when it sent one; it is what the sync badge shows the user.
|
||||||
|
function markDead(opId, err) {
|
||||||
|
var q = qRead();
|
||||||
|
for (var i = 0; i < q.length; i++) { if (q[i].opId === opId) { q[i].dead = true; q[i].lastErr = err; break; } }
|
||||||
|
qWrite(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt every live op; successes are removed, 4xx client errors are marked
|
||||||
|
// dead (won't succeed on retry), transient failures (429/5xx/network) stay queued.
|
||||||
|
function flush() {
|
||||||
|
if (_flushing) return Promise.resolve();
|
||||||
|
var q = qRead().filter(function (o) { return !o.dead; });
|
||||||
|
if (!q.length) { notifySync(); return Promise.resolve(); }
|
||||||
|
_flushing = true; notifySync();
|
||||||
|
var chain = Promise.resolve(), anyFail = false;
|
||||||
|
q.forEach(function (op) {
|
||||||
|
chain = chain.then(function () {
|
||||||
|
return opRequest(op).then(function (r) {
|
||||||
|
var status = r ? r.status : 0;
|
||||||
|
var done = r && (r.ok || (op.kind === 'wp-del' && status === 404)); // 404 on delete = already gone
|
||||||
|
if (done) { qWrite(qRead().filter(function (o) { return o.opId !== op.opId; })); }
|
||||||
|
else if (status >= 400 && status < 500 && status !== 429) {
|
||||||
|
// Refused once, refused forever — so the only useful thing left is the
|
||||||
|
// reason. A 409 here is the archived-project gate, whose detail tells
|
||||||
|
// the user the project is read-only and how to get it unarchived; a
|
||||||
|
// bare "HTTP 409" would leave them staring at a change that vanished.
|
||||||
|
return r.json().catch(function () { return null; }).then(function (j) {
|
||||||
|
var why = (j && typeof j.detail === 'string' && j.detail) || ('HTTP ' + status);
|
||||||
|
markDead(op.opId, why);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else { anyFail = true; bumpTries(op.opId, 'HTTP ' + status); }
|
||||||
|
}).catch(function (e) { anyFail = true; bumpTries(op.opId, String(e)); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return chain.then(function () {
|
||||||
|
_flushing = false;
|
||||||
|
notifySync();
|
||||||
|
if (qRead().filter(function (o) { return !o.dead; }).length) {
|
||||||
|
_backoff = anyFail ? Math.min((_backoff || 5000) * 2, 60000) : 0;
|
||||||
|
scheduleFlush(_backoff || 15000);
|
||||||
|
} else { _backoff = 0; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFlush(delay) {
|
||||||
|
if (_flushTimer) return; // one pending flush at a time
|
||||||
|
_flushTimer = setTimeout(function () { _flushTimer = null; flush(); }, delay || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sync status (drives the indicator + any listeners) ──────────────────────
|
||||||
|
// `failed` stays the total not-getting-through count (what listeners already
|
||||||
|
// read); `dead` splits out the ops the server has permanently refused, with the
|
||||||
|
// first reason it gave, because those two states need different words.
|
||||||
|
function syncCounts() {
|
||||||
|
var q = qRead(), pending = 0, failed = 0, dead = 0, reason = '';
|
||||||
|
for (var i = 0; i < q.length; i++) {
|
||||||
|
if (q[i].dead) { dead++; if (!reason && q[i].lastErr) reason = String(q[i].lastErr); }
|
||||||
|
else if ((q[i].tries || 0) >= 3) failed++;
|
||||||
|
else pending++;
|
||||||
|
}
|
||||||
|
return { pending: pending, failed: failed + dead, dead: dead, reason: reason, syncing: _flushing };
|
||||||
|
}
|
||||||
|
ProjectData.syncStatus = syncCounts;
|
||||||
|
function notifySync() {
|
||||||
|
var c = syncCounts();
|
||||||
|
try { document.dispatchEvent(new CustomEvent('wp-sync-changed', { detail: c })); } catch (e) {}
|
||||||
|
renderSyncBadge(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (!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 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
|
||||||
|
// archived). Stack it under the headline; the badge never hides in this state.
|
||||||
|
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 by the project — not saved</span>' +
|
||||||
|
(c.reason ? '<span style="font-weight:400">' + esc(c.reason) + '</span>' : '');
|
||||||
|
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 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 = '↻ 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 = '✓ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush triggers: on reconnect, on cross-frame queue changes, on tab focus, and
|
||||||
|
// a periodic backstop. Anything left from a previous session flushes on load.
|
||||||
|
try {
|
||||||
|
window.addEventListener('online', function () { _backoff = 0; scheduleFlush(0); });
|
||||||
|
window.addEventListener('storage', function (e) { if (e.key === OUTBOX_KEY) { notifySync(); scheduleFlush(0); } });
|
||||||
|
document.addEventListener('visibilitychange', function () { if (!document.hidden) scheduleFlush(0); });
|
||||||
|
setInterval(function () { if (qRead().filter(function (o) { return !o.dead; }).length) scheduleFlush(0); }, 20000);
|
||||||
|
} catch (e) {}
|
||||||
|
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function () { notifySync(); scheduleFlush(0); }); }
|
||||||
|
else { setTimeout(function () { notifySync(); scheduleFlush(0); }, 0); }
|
||||||
|
|
||||||
|
// ── public write API (now durable via the outbox) ───────────────────────────
|
||||||
|
// Write a completed SOP (plus the builder's raw state). Deterministic id per
|
||||||
|
// project so re-completing updates the same row.
|
||||||
|
ProjectData.pushSOP = function (projectId, sop, state) {
|
||||||
|
if (!projectId) return Promise.resolve(null);
|
||||||
|
enqueue({
|
||||||
|
kind: 'sop', key: 'sop__' + projectId,
|
||||||
|
body: {
|
||||||
|
id: 'sop__' + projectId, project_id: projectId,
|
||||||
|
name: (sop && sop.project && sop.project.name) || 'SOP',
|
||||||
|
number: (sop && sop.project && sop.project.number) || '',
|
||||||
|
complete: true, created_by: currentUser(), data: { sop: sop, state: state }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Promise.resolve(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upsert a single Work Package. The local cache stays the source of truth for
|
||||||
|
// immediate rendering; the outbox guarantees the write reaches the server.
|
||||||
|
ProjectData.pushWP = function (p, projectId) {
|
||||||
|
if (!p || !p.id) return Promise.resolve(null);
|
||||||
|
enqueue({ kind: 'wp', key: p.id, body: pkgToServer(p, projectId) });
|
||||||
|
return Promise.resolve(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
ProjectData.removeWP = function (id) {
|
||||||
|
if (!id) return Promise.resolve();
|
||||||
|
enqueue({ kind: 'wp-del', key: id });
|
||||||
|
return Promise.resolve(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Force a flush now and resolve when the queue drains (or a round-trip is done).
|
||||||
|
ProjectData.flushSync = function () { _backoff = 0; return flush(); };
|
||||||
|
|
||||||
|
// Archive / unarchive a Work Package (hide from active lists without deleting).
|
||||||
|
// Direct request (not the outbox) — it's a deliberate, low-frequency action and
|
||||||
|
// the caller updates the view on the returned result.
|
||||||
|
ProjectData.archiveWP = function (id, archived) {
|
||||||
|
if (!id) return Promise.resolve(null);
|
||||||
|
return fetch(API + '/wps/' + encodeURIComponent(id) + '/archive', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ archived: archived !== false })
|
||||||
|
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch this project's ARCHIVED packages (full docs) for the dashboard's
|
||||||
|
// "show archived" view. Returns app-shaped package objects (p.archived === true).
|
||||||
|
ProjectData.listArchived = function (projectId) {
|
||||||
|
if (!projectId) return Promise.resolve([]);
|
||||||
|
return fetch(API + '/wps?full=true&archived=only&project_id=' + encodeURIComponent(projectId), { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : []; })
|
||||||
|
.then(function (rows) { return Array.isArray(rows) ? rows.map(serverToPkg) : []; })
|
||||||
|
.catch(function () { return []; });
|
||||||
|
};
|
||||||
|
|
||||||
// One-time discard of pre-multi-project (un-namespaced) SOP/WP data so stale
|
// One-time discard of pre-multi-project (un-namespaced) SOP/WP data so stale
|
||||||
// global state can't leak across projects. (User chose: discard, don't migrate.)
|
// global state can't leak across projects. (User chose: discard, don't migrate.)
|
||||||
try {
|
try {
|
||||||
@@ -92,5 +488,14 @@
|
|||||||
}
|
}
|
||||||
} catch (e) {}
|
} 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;
|
global.ProjectData = ProjectData;
|
||||||
})(window);
|
})(window);
|
||||||
|
|||||||
126
html/sw.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/* Service worker for the Work Package Suite PWA.
|
||||||
|
|
||||||
|
Goal: let the app (and especially the field view) load and run offline. Data
|
||||||
|
durability is already handled by the sync outbox in project-data.js — this
|
||||||
|
worker only caches the static app shell so the pages open without a network.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
• /api/* and non-GET → never touched (pass straight to the network; offline
|
||||||
|
reads fall back to the app's localStorage cache, writes queue in the outbox).
|
||||||
|
• HTML / CSS / JS → network-first, cache as fallback. These reference each
|
||||||
|
other, so a page must never run against a stale sibling.
|
||||||
|
• images / icons / manifest → stale-while-revalidate (instant from cache).
|
||||||
|
*/
|
||||||
|
'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-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', '/users.html',
|
||||||
|
'/theme-light.css', '/work-package-suite-styles.css', '/wp-creation-styles.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-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',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (e) => {
|
||||||
|
// Cache each shell asset individually so one missing file doesn't abort install.
|
||||||
|
e.waitUntil(
|
||||||
|
caches.open(CACHE)
|
||||||
|
// cache:'reload' bypasses the browser HTTP cache. Without it the precache
|
||||||
|
// can be filled from stale HTTP entries, freezing a mismatched shell.
|
||||||
|
.then((c) => Promise.all(SHELL.map(
|
||||||
|
(u) => c.add(new Request(u, { cache: 'reload' })).catch(() => {}))))
|
||||||
|
.then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.keys()
|
||||||
|
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE && k !== DRAWINGS).map((k) => caches.delete(k))))
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Code (HTML / CSS / JS) is fetched NETWORK-FIRST, falling back to the cache when
|
||||||
|
// offline. Everything else (images, icons, the manifest) stays cache-first, which is
|
||||||
|
// where offline speed actually comes from.
|
||||||
|
//
|
||||||
|
// 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 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;
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (e) => {
|
||||||
|
const req = e.request;
|
||||||
|
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 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));
|
||||||
|
|
||||||
|
const store = (res) => {
|
||||||
|
if (res && res.ok && res.type !== 'opaque') {
|
||||||
|
const copy = res.clone();
|
||||||
|
caches.open(CACHE).then((c) => c.put(key, copy)).catch(() => {});
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isCode) {
|
||||||
|
e.respondWith(
|
||||||
|
// cache:'no-cache' forces revalidation with the server. Plain fetch() inherits
|
||||||
|
// the request's default cache mode, which consults the browser HTTP cache — so
|
||||||
|
// "network-first" alone still let a page run against a stale sibling file.
|
||||||
|
fetch(req, { cache: 'no-cache' })
|
||||||
|
.then((res) => {
|
||||||
|
// A 502/404 must not replace a page the cache could still serve.
|
||||||
|
if (!res || !res.ok) return fromCache().then((c) => c || res);
|
||||||
|
return store(res);
|
||||||
|
})
|
||||||
|
.catch(() => fromCache()) // offline → last good copy
|
||||||
|
.then((res) => res || Response.error()) // never resolve to undefined
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.respondWith(
|
||||||
|
caches.match(key).then((cached) => {
|
||||||
|
const network = fetch(req).then(store).catch(() => cached);
|
||||||
|
return cached || network.then((res) => res || Response.error());
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -114,11 +114,231 @@
|
|||||||
--cds-support-info-inverse: #4589ff;
|
--cds-support-info-inverse: #4589ff;
|
||||||
--cds-interactive: #0f62fe;
|
--cds-interactive: #0f62fe;
|
||||||
--cds-shadow: rgba(0, 0, 0, .16);
|
--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 */
|
/* Typography */
|
||||||
body {
|
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);
|
color: var(--cds-text-primary);
|
||||||
background: var(--cds-background);
|
background: var(--cds-background);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
@@ -157,3 +377,214 @@ input, textarea, select {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: var(--cds-text-primary);
|
color: var(--cds-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
App shell — shared "UI Shell" chrome (Prime Controls, IBM Carbon styling)
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
One dark top bar across every page so the suite reads as a single product.
|
||||||
|
The Prime Controls logo is a white-background wordmark, so it sits inside a
|
||||||
|
white "chip" on the near-black bar (reads as intentional, not a stray box).
|
||||||
|
Flip --wp-appbar-bg to a light value if a light header is ever preferred.
|
||||||
|
============================================================================ */
|
||||||
|
:root {
|
||||||
|
--wp-appbar-bg: #161616; /* near-black UI Shell bar */
|
||||||
|
--wp-appbar-fg: #ffffff;
|
||||||
|
--wp-appbar-fg-dim: #c6c6c6;
|
||||||
|
--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 {
|
||||||
|
background: var(--wp-appbar-bg);
|
||||||
|
color: var(--wp-appbar-fg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
height: var(--wp-appbar-height);
|
||||||
|
padding: 0 16px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.wp-appbar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--wp-appbar-fg);
|
||||||
|
}
|
||||||
|
.wp-appbar-brand:hover { text-decoration: none; opacity: .92; }
|
||||||
|
.wp-logo-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--cds-ui-01);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.wp-logo-chip img { height: 24px; width: auto; display: block; }
|
||||||
|
.wp-appbar-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--wp-appbar-fg);
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: .01em;
|
||||||
|
}
|
||||||
|
.wp-appbar-title .wp-appbar-sub { font-weight: 400; color: var(--wp-appbar-fg-dim); }
|
||||||
|
.wp-appbar-spacer { flex: 1 1 auto; }
|
||||||
|
.wp-appbar-meta { font-size: 13px; color: var(--wp-appbar-fg-dim); white-space: nowrap; }
|
||||||
|
.wp-appbar-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
/* Buttons and links that live on the dark bar */
|
||||||
|
.wp-appbar-btn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wp-appbar-fg);
|
||||||
|
border: 1px solid var(--wp-appbar-border);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.wp-appbar-btn:hover { background: var(--wp-appbar-hover); color: var(--wp-appbar-fg); text-decoration: none; }
|
||||||
|
.wp-appbar-btn.primary { background: var(--cds-interactive-01); border-color: var(--cds-interactive-01); }
|
||||||
|
.wp-appbar-btn.primary:hover { background: var(--cds-hover-primary); border-color: var(--cds-hover-primary); }
|
||||||
|
.wp-appbar-btn:focus-visible { outline: 2px solid var(--wp-appbar-fg); outline-offset: 1px; }
|
||||||
|
.wp-appbar-count { font-size: 13px; color: var(--wp-appbar-fg-dim); padding: 0 2px; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Plain text links on the dark bar (Overview / Feedback / Help, Admin, etc.) */
|
||||||
|
.wp-appbar-link { color: var(--wp-appbar-fg-dim); text-decoration: none; font-size: 14px; white-space: nowrap; }
|
||||||
|
.wp-appbar-link:hover { color: var(--wp-appbar-fg); text-decoration: none; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.wp-appbar { height: auto; flex-wrap: wrap; gap: 8px; padding: 8px 12px; }
|
||||||
|
.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();
|
||||||
@@ -4,8 +4,26 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Work Package Suite</title>
|
<title>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>
|
||||||
|
<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="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="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">
|
<link rel="stylesheet" href="work-package-suite-styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -14,33 +32,44 @@
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<a href="index.html" class="logo" title="Back to Home">
|
<a href="index.html" class="logo" title="Back to Home">
|
||||||
<img src="prime-controls-logo.jpg" alt="Prime Controls" style="height: 36px; width: auto;">
|
<img src="prime-controls-logo.jpg" alt="Prime Controls" style="height: 24px; width: auto;">
|
||||||
</a>
|
</a>
|
||||||
<div>
|
<div style="min-width:0;overflow:hidden">
|
||||||
<div class="header-title">Work Package Suite</div>
|
<div class="header-title">Work Package Suite</div>
|
||||||
<div class="header-subtitle" id="project-display"></div>
|
<div class="header-subtitle" id="project-display"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<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="toggleComments()" title="View and add comments for the current step">💬 Step Comments</button>
|
<button class="header-button" onclick="openHelp()" title="How the suite works + key concepts">Help</button>
|
||||||
<button class="header-button" onclick="showAnalytics()" title="Review usage logs for this tool">📊 Usage Logs</button>
|
<!-- A4/S9: the orphaned "1 / 10" counter lived here. It is retired — the
|
||||||
<button class="header-button" onclick="openHelp()" title="How the suite works + key concepts">❔ Help</button>
|
step rail below says where you are, what is done and what is not
|
||||||
<span class="step-counter"><span id="current-step">1</span> / <span id="total-steps">10</span></span>
|
reachable yet, which is the job the counter was standing in for. -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MAIN NAVIGATION -->
|
<!-- MAIN NAVIGATION (B7 / T7.1)
|
||||||
<div class="main-nav">
|
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')">
|
<button class="nav-tab active" data-tab="sop" onclick="switchTool('sop')">
|
||||||
<span class="tab-icon">⚙️</span> SOP Configuration
|
SOP Configuration
|
||||||
</button>
|
|
||||||
<button class="nav-tab" data-tab="wp" onclick="switchTool('wp')">
|
|
||||||
<span class="tab-icon">📋</span> Work Package Creation
|
|
||||||
</button>
|
|
||||||
<button class="nav-tab" data-tab="dashboard" onclick="switchTool('dashboard')">
|
|
||||||
<span class="tab-icon">📊</span> Dashboard
|
|
||||||
</button>
|
</button>
|
||||||
|
<a class="nav-tab" data-tab="wp" data-nav-href="wp-creation-index.html"
|
||||||
|
onclick="return gateCreatorLink(event)">
|
||||||
|
Work Package Creation
|
||||||
|
</a>
|
||||||
|
<a class="nav-tab" data-tab="dashboard" data-nav-href="wp-creation-index.html?view=dashboard"
|
||||||
|
onclick="return gateCreatorLink(event)">
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CONTENT AREA -->
|
<!-- CONTENT AREA -->
|
||||||
@@ -51,21 +80,53 @@
|
|||||||
<!-- ═════════════════════════════════════════════════════════════════════ -->
|
<!-- ═════════════════════════════════════════════════════════════════════ -->
|
||||||
<div id="tool-sop" class="tool active">
|
<div id="tool-sop" class="tool active">
|
||||||
|
|
||||||
<!-- SOP STEP INDICATORS -->
|
<!-- SOP STEP RAIL — A4 / S9 / C1
|
||||||
<div class="step-nav">
|
Was ten div elements carrying onclick, in a horizontally scrolling
|
||||||
<div class="steps-container">
|
strip: not in the tab order, not operable by keyboard, and silent
|
||||||
<div class="step-item active" data-step="1" onclick="goToStep(1)">Project</div>
|
about which steps were finished or reachable. (Spelled out rather
|
||||||
<div class="step-item" data-step="2" onclick="goToStep(2)">Team</div>
|
than quoted, because the wave 0 baseline counts that markup with a
|
||||||
<div class="step-item" data-step="3" onclick="goToStep(3)">Sign-Offs</div>
|
grep and a comment about it would inflate the number it is proving
|
||||||
<div class="step-item" data-step="4" onclick="goToStep(4)">WP Types</div>
|
went down.) Every step is a real button now, the
|
||||||
<div class="step-item" data-step="5" onclick="goToStep(5)">Governance</div>
|
states are rendered by renderStepRail() in work-package-suite-app.js,
|
||||||
<div class="step-item" data-step="6" onclick="goToStep(6)">Quality</div>
|
and each state carries a word as well as a colour.
|
||||||
<div class="step-item" data-step="7" onclick="goToStep(7)">Platforms</div>
|
|
||||||
<div class="step-item" data-step="8" onclick="goToStep(8)">Sequence</div>
|
The list is static markup rather than JS-built so the rail exists
|
||||||
<div class="step-item" data-step="9" onclick="goToStep(9)">Constraints</div>
|
before any script runs — a step you cannot see is a step you cannot
|
||||||
<div class="step-item" data-step="10" onclick="goToStep(10)">Sources</div>
|
reach, and the wizard's scripts are render-blocking classics. -->
|
||||||
</div>
|
<nav class="step-rail is-collapsed" id="step-rail" aria-label="SOP configuration steps">
|
||||||
</div>
|
<!-- 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 -->
|
<!-- SOP STEP CONTENT -->
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
@@ -76,23 +137,23 @@
|
|||||||
<div class="notice">Define the core project information that will be inherited by all Work Packages.</div>
|
<div class="notice">Define the core project information that will be inherited by all Work Packages.</div>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<div class="field">
|
<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()">
|
<input type="text" id="proj_name" placeholder="e.g., Micron — INC Construction Work Packages" oninput="updateProjectDisplay()">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Project Number *</label>
|
<label>Project number *</label>
|
||||||
<input type="text" id="proj_number" placeholder="e.g., 26-67-008">
|
<input type="text" id="proj_number" placeholder="e.g., 26-67-008">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Client Name *</label>
|
<label>Client name *</label>
|
||||||
<input type="text" id="proj_client" placeholder="e.g., Micron Technology, Inc.">
|
<input type="text" id="proj_client" placeholder="e.g., Micron Technology, Inc.">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<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">
|
<input type="text" id="proj_division" placeholder="e.g., Semiconductor, Oil & Gas, Data Center">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Site Location *</label>
|
<label>Site location *</label>
|
||||||
<input type="text" id="proj_site" placeholder="e.g., Boise, ID — Fab 7">
|
<input type="text" id="proj_site" placeholder="e.g., Boise, ID — Fab 7">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,63 +162,81 @@
|
|||||||
<!-- STEP 2: PROJECT TEAM -->
|
<!-- STEP 2: PROJECT TEAM -->
|
||||||
<div class="step" id="sop-step-2" style="display: none;">
|
<div class="step" id="sop-step-2" style="display: none;">
|
||||||
<h2>2. Project Team Leadership</h2>
|
<h2>2. Project Team Leadership</h2>
|
||||||
<div class="notice">Name the key project leaders. These are informational and will appear in SOP exports.</div>
|
<div class="notice">Pick the key project leaders from the people assigned to this project. Choosing a
|
||||||
|
<strong>user account</strong> (rather than typing a name) is what lets the Work Package Creator offer them
|
||||||
|
as an owner and lets the suite email them — so add anyone missing to the project first, in the
|
||||||
|
<a href="admin.html" target="_blank" rel="noopener">Admin Console</a>.</div>
|
||||||
|
<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-grid">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Project Manager (PM)</label>
|
<label>Project manager (PM)</label>
|
||||||
<input type="text" id="proj_pm" placeholder="e.g., Mariano Sanchez">
|
<select id="proj_pm" class="team-pick" data-team="pm"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Assistant Project Manager (APM)</label>
|
<label>Assistant project manager (APM)</label>
|
||||||
<input type="text" id="proj_apm" placeholder="e.g., Assistant PM name">
|
<select id="proj_apm" class="team-pick" data-team="apm"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Construction Manager (CM)</label>
|
<label>Construction manager (CM)</label>
|
||||||
<input type="text" id="proj_cm" placeholder="e.g., K. Boyd">
|
<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>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Quality Manager (QM)</label>
|
<label>Quality manager (QM)</label>
|
||||||
<input type="text" id="proj_qm" placeholder="e.g., D. Nguyen">
|
<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>
|
</div>
|
||||||
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
|
<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 class="sub-heading">Additional Team Members (optional)</div>
|
||||||
<div id="team-members-list" style="margin-top: 1rem;"></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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- STEP 3: SIGN-OFF ROLES -->
|
<!-- STEP 3: SIGN-OFF ROLES -->
|
||||||
<div class="step" id="sop-step-3" style="display: none;">
|
<div class="step" id="sop-step-3" style="display: none;">
|
||||||
<h2>3. Required Sign-Off Roles</h2>
|
<h2>3. Required Sign-Off Roles</h2>
|
||||||
<div class="notice">Superintendent and Foreman are required. Add other roles as needed for your project structure.</div>
|
<div class="notice">Two roles are required on every package. They default to <strong>Superintendent</strong> and <strong>Foreman</strong> — rename either to fit your project (e.g. a BIM SOP uses <em>BIM Coordinator</em> and <em>Construction Lead</em>). Add more below.</div>
|
||||||
<div class="required-roles">
|
<div class="required-roles">
|
||||||
<div class="role-required">
|
<div class="role-required">
|
||||||
<div class="role-checkbox">
|
<div class="role-checkbox">
|
||||||
<input type="checkbox" id="role_super" checked disabled>
|
<input type="checkbox" id="role_super" checked disabled>
|
||||||
<label>Superintendent *</label>
|
<input type="text" id="role_super_title" value="Superintendent" title="Required role title" style="font-weight:600; padding:0.4rem 0.5rem; border:1px solid var(--border); border-radius:4px; min-width:180px;"><span style="color:var(--danger); margin-left:4px;">*</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="role_super_name" placeholder="Name (optional)" style="flex: 1; margin-left: 1rem;">
|
<select id="role_super_name" class="user-pick" style="flex: 1; margin-left: 1rem;"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="role-required">
|
<div class="role-required">
|
||||||
<div class="role-checkbox">
|
<div class="role-checkbox">
|
||||||
<input type="checkbox" id="role_foreman" checked disabled>
|
<input type="checkbox" id="role_foreman" checked disabled>
|
||||||
<label>Foreman *</label>
|
<input type="text" id="role_foreman_title" value="Foreman" title="Required role title" style="font-weight:600; padding:0.4rem 0.5rem; border:1px solid var(--border); border-radius:4px; min-width:180px;"><span style="color:var(--danger); margin-left:4px;">*</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="role_foreman_name" placeholder="Name (optional)" style="flex: 1; margin-left: 1rem;">
|
<select id="role_foreman_name" class="user-pick" style="flex: 1; margin-left: 1rem;"></select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
|
<div style="margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
|
||||||
<div class="sub-heading">Optional Additional Roles</div>
|
<div class="sub-heading">Optional Additional Roles</div>
|
||||||
<div id="optional-roles-list" style="margin-top: 1rem;"></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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- STEP 4: WORK PACKAGE TYPES -->
|
<!-- STEP 4: WORK PACKAGE TYPES -->
|
||||||
<div class="step" id="sop-step-4" style="display: none;">
|
<div class="step" id="sop-step-4" style="display: none;">
|
||||||
<h2>4. Work Package Types</h2>
|
<h2>4. Work Package Types</h2>
|
||||||
<div class="notice">Enable the WP types your project will use. Add any special rules and the roles required to approve WO completion.</div>
|
<div class="notice">Enable the WP types your project will use. Add any special rules and the roles required to approve WO completion.
|
||||||
|
<strong>Spec Section</strong> is filled onto every work package of that type automatically, so nobody types it per package.</div>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
<div id="wp-types-table" style="margin-top: 1.5rem;"></div>
|
<div id="wp-types-table" style="margin-top: 1.5rem;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -167,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="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-grid">
|
||||||
<div class="field">
|
<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]">
|
<input type="text" id="gov_woformat" placeholder="e.g., WP##-[Sector]-[TYPE]">
|
||||||
<small>Use ## for counter, [Sector] [TYPE] as variables</small>
|
<small>Use ## for counter, [Sector] [TYPE] as variables</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<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">
|
<select id="gov_issuance" multiple size="4">
|
||||||
<option selected>By Sector / Area</option>
|
<option selected>By Sector / Area</option>
|
||||||
<option>By Discipline</option>
|
<option>By Discipline</option>
|
||||||
@@ -215,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="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-grid">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Typical WP Size</label>
|
<label>Typical WP size</label>
|
||||||
<select id="gov_wosize" onchange="onSizePresetChange()">
|
<select id="gov_wosize" onchange="onSizePresetChange()">
|
||||||
<option value="">Select…</option>
|
<option value="">Select…</option>
|
||||||
<option value="Small — 1–2 days (≈8–24 hrs)">Small — 1–2 days (≈8–24 hrs)</option>
|
<option value="Small — 1–2 days (≈8–24 hrs)">Small — 1–2 days (≈8–24 hrs)</option>
|
||||||
@@ -239,7 +318,7 @@
|
|||||||
<div class="notice">Establish project-wide quality expectations that cascade to every Work Package.</div>
|
<div class="notice">Establish project-wide quality expectations that cascade to every Work Package.</div>
|
||||||
<div class="field-grid col1">
|
<div class="field-grid col1">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>QC Required? *</label>
|
<label>QC required? *</label>
|
||||||
<select id="qual_qcreq">
|
<select id="qual_qcreq">
|
||||||
<option>Yes</option>
|
<option>Yes</option>
|
||||||
<option>Yes — Detailed inspection items</option>
|
<option>Yes — Detailed inspection items</option>
|
||||||
@@ -248,7 +327,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Photo / Documentation Standard</label>
|
<label>Photo / documentation standard</label>
|
||||||
<select id="qual_photo">
|
<select id="qual_photo">
|
||||||
<option selected>Key checkpoints only</option>
|
<option selected>Key checkpoints only</option>
|
||||||
<option>Every step documented</option>
|
<option>Every step documented</option>
|
||||||
@@ -257,7 +336,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,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="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-grid">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Construction Tracking Platform *</label>
|
<label>Construction tracking platform *</label>
|
||||||
<select id="plat_tracking">
|
<select id="plat_tracking">
|
||||||
<option>CxAlloy</option>
|
<option>CxAlloy</option>
|
||||||
<option>Procore</option>
|
<option>Procore</option>
|
||||||
@@ -278,7 +357,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Commissioning Tool *</label>
|
<label>Commissioning tool *</label>
|
||||||
<select id="plat_commissioning">
|
<select id="plat_commissioning">
|
||||||
<option selected>CxAlloy</option>
|
<option selected>CxAlloy</option>
|
||||||
<option>Procore</option>
|
<option>Procore</option>
|
||||||
@@ -287,6 +366,16 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field" style="margin-top:1rem;">
|
||||||
|
<label>Tracking platform — project homepage link</label>
|
||||||
|
<input type="url" id="plat_tracking_url" placeholder="Paste the project's URL in the tracking platform (e.g. its Procore / CxAlloy project home)">
|
||||||
|
<small>Optional. Saved with every Work Package on this project for one-click access.</small>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin-top:0.75rem;">
|
||||||
|
<label>Commissioning tool — project homepage link</label>
|
||||||
|
<input type="url" id="plat_commissioning_url" placeholder="Paste the project's URL in the commissioning tool">
|
||||||
|
<small>Optional. Saved with every Work Package on this project for one-click access.</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- STEP 8: SEQUENCE -->
|
<!-- STEP 8: SEQUENCE -->
|
||||||
@@ -296,8 +385,8 @@
|
|||||||
<div id="sequence-list" style="margin-top: 1rem;"></div>
|
<div id="sequence-list" style="margin-top: 1rem;"></div>
|
||||||
<div style="display:flex; gap:0.5rem; margin-top:1rem; flex-wrap:wrap;">
|
<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;">
|
<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="addSequenceStep()">+ Add step</button>
|
||||||
<button class="add-btn" onclick="addSequenceGate()" style="background:var(--warning);">◆ Add QC Hold</button>
|
<button class="add-btn" onclick="addSequenceGate()" style="background:var(--warning);">Add QC hold</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -312,7 +401,7 @@
|
|||||||
<div style="border-top: 1px solid var(--border); padding-top: 1.5rem;">
|
<div style="border-top: 1px solid var(--border); padding-top: 1.5rem;">
|
||||||
<div class="sub-heading">Custom Constraints (Optional)</div>
|
<div class="sub-heading">Custom Constraints (Optional)</div>
|
||||||
<div id="custom-constraints-list" style="margin-top: 1rem;"></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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -321,16 +410,132 @@
|
|||||||
<h2>10. Engineering Sources & References</h2>
|
<h2>10. Engineering Sources & References</h2>
|
||||||
<div class="notice">Link to key documents and systems that WP authors will reference.</div>
|
<div class="notice">Link to key documents and systems that WP authors will reference.</div>
|
||||||
<div id="sources-list" style="margin-top: 1rem;"></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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SOP NAVIGATION -->
|
<!-- SOP NAVIGATION -->
|
||||||
<div class="step-navigation">
|
<div class="step-navigation">
|
||||||
<button class="nav-btn" id="sop-prev-btn" onclick="previousStep()">← Back</button>
|
<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" 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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -341,12 +546,14 @@
|
|||||||
<div id="tool-wp" class="tool">
|
<div id="tool-wp" class="tool">
|
||||||
<!-- Shown until the SOP is complete -->
|
<!-- Shown until the SOP is complete -->
|
||||||
<div id="wp-gate" style="padding: 3rem 2rem; text-align: center;">
|
<div id="wp-gate" style="padding: 3rem 2rem; text-align: center;">
|
||||||
<h2>📋 Work Package Creation</h2>
|
<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>
|
<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>
|
<button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">Go to SOP configuration</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- The real Work Package Creator, embedded once the SOP is complete -->
|
<!-- B7/T7.1: the iframe stood here. The creator is a page now
|
||||||
<iframe id="wp-frame" title="Work Package Creator" style="display:none; width:100%; border:0; min-height: calc(100vh - 200px);"></iframe>
|
(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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -356,21 +563,22 @@
|
|||||||
<!-- STEP COMMENTS DROPDOWN (toggled from header) -->
|
<!-- STEP COMMENTS DROPDOWN (toggled from header) -->
|
||||||
<div id="comments-panel" class="comments-dropdown" style="display: none;">
|
<div id="comments-panel" class="comments-dropdown" style="display: none;">
|
||||||
<div class="comments-dropdown-header">
|
<div class="comments-dropdown-header">
|
||||||
<strong>💬 Step Comments</strong>
|
<strong>Feedback</strong>
|
||||||
<button onclick="toggleComments()" class="comments-dropdown-close" title="Close">✕</button>
|
<button onclick="toggleComments()" class="comments-dropdown-close" title="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom: 1rem;">
|
<div style="margin-bottom: 1rem;">
|
||||||
<label style="font-weight: 600; font-size: 13px;">Your Name (optional)</label>
|
<label style="font-weight: 600; font-size: 13px;">Your name</label>
|
||||||
<input type="text" id="commenter-name" placeholder="e.g., your name" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem;">
|
<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>
|
||||||
<div style="margin-bottom: 1rem;">
|
<div style="margin-bottom: 1rem;">
|
||||||
<label style="font-weight: 600; font-size: 13px;">Feedback</label>
|
<label style="font-weight: 600; font-size: 13px;" for="comment-text">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>
|
<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>
|
||||||
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
|
<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="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>
|
<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)">
|
<input type="file" id="sop-comments-import" accept="application/json" style="display:none" onchange="importComments(event)">
|
||||||
</div>
|
</div>
|
||||||
<div id="comments-list" style="margin-top: 1rem; max-height: 240px; overflow-y: auto;"></div>
|
<div id="comments-list" style="margin-top: 1rem; max-height: 240px; overflow-y: auto;"></div>
|
||||||
@@ -393,9 +601,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="feedback-config.js"></script>
|
||||||
<script src="project-data.js"></script>
|
<script src="project-data.js"></script>
|
||||||
<script src="help.js"></script>
|
<script src="help.js"></script>
|
||||||
<script src="work-package-suite-app.js"></script>
|
<script src="work-package-suite-app.js"></script>
|
||||||
|
<script src="wp-chrome.js"></script>
|
||||||
|
<script src="wp-sidenav.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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);
|
||||||
338
html/wp-chrome.css
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
SHARED APP CHROME — project switcher + global search
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
Injected by wp-chrome.js into whichever top bar a page has: the dark UI-shell
|
||||||
|
bar (.wp-appbar on index / admin / field) or the older light bars (.header on
|
||||||
|
the SOP suite and the WP creator). The two live side by side, so every colour
|
||||||
|
here comes from a variable that wp-chrome.js sets per host bar — the markup and
|
||||||
|
behaviour are identical on both.
|
||||||
|
============================================================================ */
|
||||||
|
.wp-chrome {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
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: 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: 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 ──────────────────────────────────────────────── */
|
||||||
|
/* Inserted by wp-chrome.js as the top bar's next sibling, so it sits directly
|
||||||
|
under the bar in normal flow and can never overlap it or eat its height (the
|
||||||
|
bar is sticky; this strip scrolls away under it). `flex: 0 0 auto` is for the
|
||||||
|
suite page, whose shell is a flex column — without it the strip would squash.
|
||||||
|
Amber tokens are the suite's warning set, same as the sync badge. */
|
||||||
|
.wpc-archived {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 16px;
|
||||||
|
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: var(--wp-font-sans-2);
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.wpc-archived-ico { flex: 0 0 auto; font-size: 14px; }
|
||||||
|
.wpc-archived-text { min-width: 0; } /* wraps instead of forcing a scrollbar */
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.wpc-archived { padding: 8px 12px; font-size: 13px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── project switcher ─────────────────────────────────────────────────────── */
|
||||||
|
.wpc-proj { position: relative; flex: 0 0 auto; }
|
||||||
|
.wpc-proj-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 280px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--wpc-fg);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.wpc-proj-btn:hover { background: var(--wpc-hover); border-color: var(--wpc-border); }
|
||||||
|
.wpc-proj-btn[aria-expanded="true"] { background: var(--wpc-hover); border-color: var(--wpc-border); }
|
||||||
|
.wpc-proj-labels { min-width: 0; }
|
||||||
|
.wpc-proj-kicker {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
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;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
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); }
|
||||||
|
|
||||||
|
/* ── dropdown / results panel (shared shell) ──────────────────────────────── */
|
||||||
|
.wpc-pop {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: min(460px, 92vw);
|
||||||
|
max-height: min(70vh, 560px);
|
||||||
|
overflow-y: auto;
|
||||||
|
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; }
|
||||||
|
.wpc-pop-head {
|
||||||
|
padding: 9px 12px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--cds-text-helper);
|
||||||
|
border-bottom: 1px solid var(--wp-pop-divider);
|
||||||
|
}
|
||||||
|
.wpc-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--cds-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.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: 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 var(--wp-pop-divider);
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.wpc-foot-btn {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 5px 10px;
|
||||||
|
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: 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,
|
||||||
|
which keeps the field mid-screen without absolute positioning (so it can never
|
||||||
|
sit on top of the bar's own buttons). */
|
||||||
|
.wpc-search {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.wpc-search-box {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 10px;
|
||||||
|
height: 34px;
|
||||||
|
background: var(--wpc-bg);
|
||||||
|
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 {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
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;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.wpc-search-input::placeholder { color: var(--wpc-fg-dim); }
|
||||||
|
.wpc-kbd {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-family: var(--wp-font-mono-3);
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--wpc-fg-dim);
|
||||||
|
border: 1px solid var(--wpc-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wpc-search .wpc-pop { left: 50%; transform: translateX(-50%); min-width: min(560px, 92vw); }
|
||||||
|
.wpc-clear {
|
||||||
|
flex: 0 0 auto; background: transparent; border: 0; cursor: pointer;
|
||||||
|
color: var(--wpc-fg-dim); font: inherit; font-size: 14px; line-height: 1; padding: 2px 4px;
|
||||||
|
}
|
||||||
|
.wpc-clear:hover { color: var(--wpc-fg); }
|
||||||
|
|
||||||
|
/* ── narrow screens ───────────────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.wpc-search-box { max-width: none; }
|
||||||
|
.wpc-kbd { display: none; }
|
||||||
|
.wpc-proj-btn { max-width: 190px; }
|
||||||
|
.wpc-proj-name { max-width: 150px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
/* Keep the switcher (you must be able to change project) and let the search
|
||||||
|
collapse to an icon-width field rather than pushing the bar out of shape. */
|
||||||
|
.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; }
|
||||||
|
}
|
||||||
466
html/wp-chrome.js
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
/* Shared app chrome for the Work Package Suite: a project switcher beside the
|
||||||
|
Prime logo and a global search centered in the top bar.
|
||||||
|
|
||||||
|
One script for every page because there are two generations of top bar — the
|
||||||
|
dark UI-shell `.wp-appbar` (home, admin, field) and the older light `.header`
|
||||||
|
(SOP suite, WP creator). We find whichever exists, insert the same markup, and
|
||||||
|
flip a colour set based on how dark the host bar is.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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 SEARCH_MIN = 2; // characters before we ask the server
|
||||||
|
var DEBOUNCE_MS = 180;
|
||||||
|
|
||||||
|
function el(tag, cls, html) {
|
||||||
|
var n = document.createElement(tag);
|
||||||
|
if (cls) n.className = cls;
|
||||||
|
if (html != null) n.innerHTML = html;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
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 false;
|
||||||
|
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── where to put the chrome ────────────────────────────────────────────────
|
||||||
|
// Returns {host, insertBefore} or null. The insertion point matters: on the
|
||||||
|
// dark bar we sit before the spacer (so search takes the middle); on the light
|
||||||
|
// bars we sit between the left block and the right-hand buttons.
|
||||||
|
function findMount() {
|
||||||
|
var appbar = document.querySelector('.wp-appbar');
|
||||||
|
if (appbar) {
|
||||||
|
return { host: appbar, before: appbar.querySelector('.wp-appbar-spacer') };
|
||||||
|
}
|
||||||
|
var header = document.querySelector('.header');
|
||||||
|
if (header) {
|
||||||
|
// The suite page wraps its own left/right groups; the creator's bar is a
|
||||||
|
// flat row of buttons whose first button carries margin-left:auto.
|
||||||
|
var right = header.querySelector('.header-right');
|
||||||
|
if (right) return { host: header, before: right };
|
||||||
|
var firstBtn = header.querySelector('.btn, button');
|
||||||
|
return { host: header, before: firstBtn };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── project switcher ───────────────────────────────────────────────────────
|
||||||
|
var projects = [];
|
||||||
|
|
||||||
|
function activeProject() {
|
||||||
|
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)';
|
||||||
|
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
|
||||||
|
// already resolves its project from that param (falling back to the stored
|
||||||
|
// active id), so a reload is both the simplest and the safest route — no page
|
||||||
|
// has to re-hydrate half its state in place.
|
||||||
|
function switchProject(p) {
|
||||||
|
try { if (window.ProjectData) ProjectData.setActive(p); } catch (e) {}
|
||||||
|
var url = new URL(location.href);
|
||||||
|
url.searchParams.set('project', p.id);
|
||||||
|
url.hash = '';
|
||||||
|
location.assign(url.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProjectSwitcher() {
|
||||||
|
var wrap = el('div', 'wpc-proj');
|
||||||
|
var btn = el('button', 'wpc-proj-btn');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.setAttribute('aria-haspopup', 'listbox');
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
btn.title = 'Switch project';
|
||||||
|
var cur = activeProject();
|
||||||
|
btn.innerHTML =
|
||||||
|
'<span class="wpc-proj-labels">' +
|
||||||
|
'<span class="wpc-proj-kicker">Project</span>' +
|
||||||
|
'<span class="wpc-proj-name">' + esc(projectLabel(cur)) + '</span>' +
|
||||||
|
'</span><span class="wpc-caret">▾</span>';
|
||||||
|
var pop = el('div', 'wpc-pop');
|
||||||
|
pop.hidden = true;
|
||||||
|
wrap.appendChild(btn);
|
||||||
|
wrap.appendChild(pop);
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
var curId = (activeProject() || {}).id || '';
|
||||||
|
var rows = projects.map(function (p) {
|
||||||
|
return '<button type="button" class="wpc-item' + (p.id === curId ? ' is-current' : '') +
|
||||||
|
'" data-pid="' + esc(p.id) + '">' +
|
||||||
|
'<span class="wpc-item-title">' + esc(p.name || '(unnamed)') + '</span>' +
|
||||||
|
'<span class="wpc-item-sub">' + esc([p.number, p.client, p.site].filter(Boolean).join(' · ') ||
|
||||||
|
'no number') + (p.sample ? ' · sample' : '') + '</span>' +
|
||||||
|
'</button>';
|
||||||
|
}).join('');
|
||||||
|
pop.innerHTML =
|
||||||
|
'<div class="wpc-pop-head">Switch project</div>' +
|
||||||
|
(rows || '<div class="wpc-empty">No projects you can access yet.</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];
|
||||||
|
if (p) switchProject(p);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
render();
|
||||||
|
pop.hidden = false;
|
||||||
|
btn.setAttribute('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
pop.hidden = true;
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
btn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (pop.hidden) open(); else close();
|
||||||
|
});
|
||||||
|
document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
|
||||||
|
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
|
||||||
|
|
||||||
|
// Refresh the label once the project list (and any active project) is known.
|
||||||
|
wrap.wpcRefresh = function () {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadProjects(switcher) {
|
||||||
|
// ProjectData.list() already hits the API and falls back to its local cache
|
||||||
|
// when offline, so there's no second request to make here.
|
||||||
|
var p;
|
||||||
|
try {
|
||||||
|
p = (window.ProjectData && ProjectData.list) ? ProjectData.list() : null;
|
||||||
|
} catch (e) { p = null; }
|
||||||
|
if (!p) {
|
||||||
|
p = fetch('/api/projects', { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : []; });
|
||||||
|
}
|
||||||
|
Promise.resolve(p)
|
||||||
|
.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 () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── archived-project banner ────────────────────────────────────────────────
|
||||||
|
// An archived project is still readable and still deep-linkable (?project=<id>),
|
||||||
|
// but every write now 409s. With nothing on the page to say so, that reads as a
|
||||||
|
// silent failure — so the bar, the one thing every page has, carries the warning.
|
||||||
|
//
|
||||||
|
// The state comes from GET /api/projects/<id>, never from "it's missing from the
|
||||||
|
// switcher": absence also means "you have no access to it", which is a different
|
||||||
|
// message. Fails closed and silent — an error means no banner, not a broken page.
|
||||||
|
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 ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArchivedBanner() {
|
||||||
|
var bar = el('div', 'wpc-archived');
|
||||||
|
bar.setAttribute('role', 'status');
|
||||||
|
bar.innerHTML =
|
||||||
|
'<span class="wpc-archived-ico" aria-hidden="true">⚠</span>' +
|
||||||
|
'<span class="wpc-archived-text"><strong>Archived project — read-only.</strong> ' +
|
||||||
|
'Unarchive it from the Admin Console to make changes.</span>';
|
||||||
|
return bar;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkArchived(host) {
|
||||||
|
var id = activeProjectId();
|
||||||
|
if (!id || !host || !host.parentNode) return;
|
||||||
|
var pinned = false;
|
||||||
|
try { pinned = !!new URLSearchParams(location.search).get('project'); } catch (e) {}
|
||||||
|
fetch('/api/projects/' + encodeURIComponent(id), { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (p) {
|
||||||
|
if (!p || !p.archived) return;
|
||||||
|
// The home page reconciles the stored active project against the (now
|
||||||
|
// archive-filtered) list while this request is in flight, and drops it. If
|
||||||
|
// that happened, the id we asked about is nobody's context any more —
|
||||||
|
// banner-ing it would contradict the picker one line below. A ?project=
|
||||||
|
// deep link is pinned to this page and can't be cleared out from under us.
|
||||||
|
if (!pinned && window.ProjectData && ProjectData.getActiveId &&
|
||||||
|
ProjectData.getActiveId() !== id) return;
|
||||||
|
// Also on the document element, so the two big apps can gate their own UI
|
||||||
|
// from CSS or a boot check without a second round trip. The server stays
|
||||||
|
// the real gate; this is only there so the UI can agree with it.
|
||||||
|
try { document.documentElement.setAttribute('data-wp-archived', '1'); } catch (e) {}
|
||||||
|
if (document.querySelector('.wpc-archived')) return;
|
||||||
|
host.parentNode.insertBefore(buildArchivedBanner(), host.nextSibling);
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── global search ──────────────────────────────────────────────────────────
|
||||||
|
function buildSearch() {
|
||||||
|
var wrap = el('div', 'wpc-search');
|
||||||
|
var box = el('div', 'wpc-search-box');
|
||||||
|
box.innerHTML =
|
||||||
|
'<span class="wpc-search-ico" aria-hidden="true">⌕</span>' +
|
||||||
|
'<input class="wpc-search-input" type="search" autocomplete="off" spellcheck="false" ' +
|
||||||
|
'placeholder="Search work packages, projects, SOPs…" aria-label="Search">' +
|
||||||
|
'<button class="wpc-clear" type="button" title="Clear" hidden>✕</button>' +
|
||||||
|
'<span class="wpc-kbd">Ctrl K</span>';
|
||||||
|
var pop = el('div', 'wpc-pop');
|
||||||
|
pop.hidden = true;
|
||||||
|
wrap.appendChild(box);
|
||||||
|
wrap.appendChild(pop);
|
||||||
|
|
||||||
|
var input = box.querySelector('.wpc-search-input');
|
||||||
|
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() {
|
||||||
|
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (n, i) {
|
||||||
|
n.classList.toggle('is-active', i === activeIx);
|
||||||
|
if (i === activeIx && n.scrollIntoView) n.scrollIntoView({ block: 'nearest' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 '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 || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function go(hit) {
|
||||||
|
if (!hit) return;
|
||||||
|
if (hit.kind === 'project') {
|
||||||
|
var p = projects.filter(function (x) { return x.id === hit.id; })[0];
|
||||||
|
if (p) { switchProject(p); return; }
|
||||||
|
}
|
||||||
|
// Set the active project only from a full record — writing a stub would
|
||||||
|
// clobber the cached project (name, number, client) other pages read. The
|
||||||
|
// ?project= param in the URL is what actually switches context.
|
||||||
|
var full = projects.filter(function (x) { return x.id === hit.project_id; })[0];
|
||||||
|
if (full) { try { if (window.ProjectData) ProjectData.setActive(full); } catch (e) {} }
|
||||||
|
location.assign(hrefFor(hit));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResults(data) {
|
||||||
|
items = [];
|
||||||
|
var html = '';
|
||||||
|
function group(title, rows) {
|
||||||
|
if (!rows.length) return;
|
||||||
|
html += '<div class="wpc-pop-head">' + esc(title) + '</div>' + rows.join('');
|
||||||
|
}
|
||||||
|
group('Work packages', (data.wps || []).map(function (w) {
|
||||||
|
items.push({ kind: 'wp', id: w.id, project_id: w.project_id, project_name: w.project_name });
|
||||||
|
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
|
||||||
|
'<span class="wpc-item-title"><span class="wpc-item-mono">' + esc(w.number || '(unnumbered)') + '</span> ' +
|
||||||
|
esc(w.subject || '') + '</span>' +
|
||||||
|
'<span class="wpc-item-sub">' + esc([w.status, w.type, w.project_name].filter(Boolean).join(' · ')) + '</span>' +
|
||||||
|
'</button>';
|
||||||
|
}));
|
||||||
|
group('Projects', (data.projects || []).map(function (p) {
|
||||||
|
items.push({ kind: 'project', id: p.id });
|
||||||
|
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
|
||||||
|
'<span class="wpc-item-title">' + esc(p.name || '(unnamed)') + '</span>' +
|
||||||
|
'<span class="wpc-item-sub">' + esc([p.number, p.client].filter(Boolean).join(' · ') || 'project') + '</span>' +
|
||||||
|
'</button>';
|
||||||
|
}));
|
||||||
|
group('SOPs', (data.sops || []).map(function (s) {
|
||||||
|
items.push({ kind: 'sop', id: s.id, project_id: s.project_id, project_name: s.project_name });
|
||||||
|
return '<button type="button" class="wpc-item" data-ix="' + (items.length - 1) + '">' +
|
||||||
|
'<span class="wpc-item-title">' + esc(s.name || 'SOP') + '</span>' +
|
||||||
|
'<span class="wpc-item-sub">' + esc([s.complete ? 'complete' : 'draft', s.project_name].filter(Boolean).join(' · ')) + '</span>' +
|
||||||
|
'</button>';
|
||||||
|
}));
|
||||||
|
if (!items.length) {
|
||||||
|
html = '<div class="wpc-empty">Nothing matches “' + esc(data.query || '') + '” in the projects you can access.</div>';
|
||||||
|
}
|
||||||
|
pop.innerHTML = html;
|
||||||
|
pop.hidden = false;
|
||||||
|
activeIx = items.length ? 0 : -1;
|
||||||
|
highlight();
|
||||||
|
Array.prototype.forEach.call(pop.querySelectorAll('.wpc-item'), function (n) {
|
||||||
|
n.addEventListener('click', function () { go(items[+n.getAttribute('data-ix')]); });
|
||||||
|
n.addEventListener('mouseenter', function () { activeIx = +n.getAttribute('data-ix'); highlight(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(q) {
|
||||||
|
var mine = ++seq;
|
||||||
|
fetch('/api/search?q=' + encodeURIComponent(q), { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (data) {
|
||||||
|
if (mine !== seq) return; // a newer keystroke already won
|
||||||
|
if (!data) { close(); return; }
|
||||||
|
renderResults(data);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (mine !== seq) return;
|
||||||
|
pop.innerHTML = '<div class="wpc-empty">Search is unavailable offline.</div>';
|
||||||
|
pop.hidden = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', function () {
|
||||||
|
var q = input.value.trim();
|
||||||
|
clear.hidden = !q;
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (q.length < SEARCH_MIN) { close(); return; }
|
||||||
|
timer = setTimeout(function () { run(q); }, DEBOUNCE_MS);
|
||||||
|
});
|
||||||
|
input.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') { close(); input.blur(); return; }
|
||||||
|
if (pop.hidden || !items.length) return;
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); activeIx = (activeIx + 1) % items.length; highlight(); }
|
||||||
|
else if (e.key === 'ArrowUp') { e.preventDefault(); activeIx = (activeIx - 1 + items.length) % items.length; highlight(); }
|
||||||
|
else if (e.key === 'Enter') { e.preventDefault(); go(items[activeIx]); }
|
||||||
|
});
|
||||||
|
input.addEventListener('focus', function () {
|
||||||
|
if (input.value.trim().length >= SEARCH_MIN && items.length) pop.hidden = false;
|
||||||
|
});
|
||||||
|
clear.addEventListener('click', function () {
|
||||||
|
input.value = ''; clear.hidden = true; close(); input.focus();
|
||||||
|
});
|
||||||
|
document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
|
||||||
|
|
||||||
|
// Ctrl/Cmd-K from anywhere focuses search (matches the tools people already
|
||||||
|
// use). Ignored while typing in another field so it can't steal a shortcut.
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
||||||
|
e.preventDefault();
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mount ──────────────────────────────────────────────────────────────────
|
||||||
|
function mount() {
|
||||||
|
if (document.querySelector('.wp-chrome')) return;
|
||||||
|
var m = findMount();
|
||||||
|
if (!m) return;
|
||||||
|
var chrome = el('div', 'wp-chrome');
|
||||||
|
if (isDark(m.host)) chrome.setAttribute('data-bar', 'dark');
|
||||||
|
var switcher = buildProjectSwitcher();
|
||||||
|
chrome.appendChild(switcher);
|
||||||
|
chrome.appendChild(buildSearch());
|
||||||
|
if (m.before) m.host.insertBefore(chrome, m.before);
|
||||||
|
else m.host.appendChild(chrome);
|
||||||
|
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
|
||||||
|
// /api/search would 401 anyway.
|
||||||
|
if (window.WP_USER) mount();
|
||||||
|
else document.addEventListener('wp-auth-ready', mount);
|
||||||
|
})();
|
||||||
@@ -4,89 +4,323 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Work Package (IWP) — Prime Controls</title>
|
<title>Work Package (IWP) — Prime Controls</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>
|
||||||
|
<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="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="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-creation-styles.css">
|
||||||
|
<link rel="stylesheet" href="wp-sidenav.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div class="loading-overlay" id="loadingOverlay"><div class="spinner"></div><div class="loading-text">Saving work package…</div></div>
|
<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="header">
|
||||||
<div class="logo-wrap embed-hide">
|
<div class="header-left">
|
||||||
<div class="header-logo">Prime Controls</div>
|
<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>
|
<button id="dev-toggle" class="dev-toggle" onclick="toggleDevMode()" title="dev mode" aria-label="dev mode"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-sep embed-hide">|</div>
|
<!-- wp-chrome.js inserts the project switcher and global search here. -->
|
||||||
<div class="header-title">Work Package (IWP)</div>
|
<div class="header-right">
|
||||||
<button class="btn btn-ghost embed-hide" style="margin-left:auto;padding:7px 16px" onclick="document.getElementById('sop-import').click()">⤒ Import SOP</button>
|
<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)">
|
<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>
|
<!-- S7 / T9.4: THE sample-data affordance - exactly one in the whole suite,
|
||||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="openSopModal()">👁 View SOP</button>
|
under one name. Pushed to the far end, away from the live actions (New /
|
||||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="loadExample()">★ Load Example</button>
|
Duplicate sit at the other side of two separators), it confirms before
|
||||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showDashboard()">📊 Dashboard</button>
|
acting and names exactly what it does. It fills THIS PAGE only: nothing
|
||||||
<button class="btn btn-ghost embed-first" style="padding:7px 16px" onclick="newPackage()">+ New</button>
|
is written to the project unless the user then saves, which the probe
|
||||||
<button class="btn btn-ghost" style="padding:7px 16px" onclick="duplicateWP()">⧉ Duplicate</button>
|
verifies against a real project. The wizard's copy and the split
|
||||||
<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>
|
Sample SOP / Load example pair are gone. -->
|
||||||
<button class="btn btn-ghost embed-hide" style="padding:7px 16px" onclick="showAnalytics()">▤ Usage Data</button>
|
<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>
|
||||||
|
|
||||||
<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="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>
|
<div class="ctx-bar" id="ctx-bar"></div>
|
||||||
|
|
||||||
<!-- RELEASE READINESS BANNER -->
|
<!-- RELEASE READINESS BANNER - the ONE readiness warning on the page (A2).
|
||||||
<div class="release-banner" id="release-banner"></div>
|
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) -->
|
<!-- F6/D3: the jump-link strip stood here. It was a row of `<span onclick>` chips
|
||||||
<div class="section-nav-bar" id="section-nav"></div>
|
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">
|
||||||
|
|
||||||
|
<!-- WORK PACKAGE NAVIGATOR
|
||||||
|
A persistent side panel (not a hover drawer): collapse toggle, a primary action,
|
||||||
|
icon nav, then the project's packages as rows with colour-coded initial badges.
|
||||||
|
Collapsing leaves a narrow icon rail so you can still see and switch packages. -->
|
||||||
|
<aside class="wp-nav" id="wp-nav" aria-label="Work packages">
|
||||||
|
<div class="wp-nav-top">
|
||||||
|
<button class="wp-nav-toggle" id="wp-nav-toggle" onclick="toggleWpNav()"
|
||||||
|
title="Collapse the panel" aria-label="Collapse the panel" aria-expanded="true">
|
||||||
|
<svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true">
|
||||||
|
<rect x="2.5" y="3.5" width="15" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
<line x1="7.5" y1="3.5" x2="7.5" y2="16.5" stroke="currentColor" stroke-width="1.4"/>
|
||||||
|
<path class="wp-nav-toggle-arrow" d="M14 10 H10 M11.6 8.2 L9.8 10 L11.6 11.8"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wp-nav-primary">
|
||||||
|
<button class="wp-nav-cta" onclick="newPackage()" title="Start a new work package">
|
||||||
|
<span class="wp-nav-cta-plus" aria-hidden="true">+</span><span class="wp-nav-cta-label">New work package</span>
|
||||||
|
</button>
|
||||||
|
<button class="wp-nav-cta-more" id="wp-nav-more-btn" onclick="toggleWpNavMore(event)"
|
||||||
|
title="More actions" aria-label="More actions" aria-haspopup="true" aria-expanded="false">▾</button>
|
||||||
|
<div class="wp-nav-menu" id="wp-nav-more" hidden>
|
||||||
|
<button type="button" onclick="wpNavAction('duplicate')">Duplicate this package</button>
|
||||||
|
<button type="button" onclick="wpNavAction('split')">Split by discipline</button>
|
||||||
|
<button type="button" onclick="wpNavAction('export')">Export all (JSON)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="wp-nav-links" aria-label="Views">
|
||||||
|
<button type="button" class="wp-nav-link" data-view="mine" onclick="setWpNavView('mine')" title="Packages you own">
|
||||||
|
<span class="wp-nav-ico" aria-hidden="true">◔</span><span class="wp-nav-link-label">My packages</span>
|
||||||
|
<span class="wp-nav-link-n" id="wp-nav-n-mine"></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="wp-nav-link is-current" data-view="all" onclick="setWpNavView('all')" title="Every package on this project">
|
||||||
|
<span class="wp-nav-ico" aria-hidden="true">▤</span><span class="wp-nav-link-label">All packages</span>
|
||||||
|
<span class="wp-nav-link-n" id="wp-nav-n-all"></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="wp-nav-link" data-view="open" onclick="setWpNavView('open')" title="Not release-ready yet">
|
||||||
|
<span class="wp-nav-ico" aria-hidden="true">⚠</span><span class="wp-nav-link-label">Needs attention</span>
|
||||||
|
<span class="wp-nav-link-n" id="wp-nav-n-open"></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="wp-nav-link" onclick="showDashboard()" title="Status and gating across the project">
|
||||||
|
<span class="wp-nav-ico" aria-hidden="true">▦</span><span class="wp-nav-link-label">Dashboard</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="wp-nav-sect">
|
||||||
|
<span class="wp-nav-sect-label" id="wp-nav-sect-label">Work packages</span>
|
||||||
|
<span class="wp-nav-count" id="wp-nav-count"></span>
|
||||||
|
</div>
|
||||||
|
<div class="wp-nav-filter">
|
||||||
|
<input type="search" class="wp-nav-search" id="wp-nav-search" placeholder="Filter packages…" oninput="renderWpNav()">
|
||||||
|
</div>
|
||||||
|
<div class="wp-nav-list" id="wp-nav-list"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<div class="main">
|
<div class="main">
|
||||||
|
|
||||||
|
<!-- PACKAGE KIND (only shown when the project's SOP includes BIM/VDC) -->
|
||||||
|
<div class="card" id="kind-row" style="display:none">
|
||||||
|
<div class="sub-heading">Package Type</div>
|
||||||
|
<div class="notice">This project includes BIM/VDC packages. Choose what this one is — it tailors the fields below and the WP types / release gates offered.</div>
|
||||||
|
<div class="radio-group" id="kind-group" style="margin-bottom:0">
|
||||||
|
<label class="radio-pill" data-val="iwp"><input type="radio" name="pkgkind" onclick="setKind('iwp')"><span class="dot"></span>Install package (IWP)</label>
|
||||||
|
<label class="radio-pill" data-val="ewp"><input type="radio" name="pkgkind" onclick="setKind('ewp')"><span class="dot"></span>BIM package (EWP)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- GENERAL INFORMATION -->
|
<!-- 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-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="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-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="field"><label>Status</label>
|
||||||
<div class="radio-group" id="status-group" style="margin-bottom:0">
|
<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="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="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="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" 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="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="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>
|
<label class="radio-pill" data-val="Closed"><input type="radio" name="status"><span class="dot"></span>Closed</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-hint">Cannot move to <strong>Issued</strong> or beyond until all constraints are cleared.</div>
|
|
||||||
</div>
|
</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="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-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-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>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>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>
|
<!-- Location is its own CR-006 section while still living inside General
|
||||||
<div class="field"><label>Cost Code</label><select id="wp_cost"></select><div class="field-hint sop-hint">Acumatica cost codes</div></div>
|
Information's grid. CR-004 gives it structured building/floor/sector
|
||||||
<div class="field"><label>Acumatica Task</label><input type="text" id="wp_wbs" placeholder="Acumatica task no."></div>
|
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>
|
||||||
<div class="field-grid">
|
<div class="field-hint" id="wp_location_note"></div>
|
||||||
<div class="field"><label>Assignees</label><input type="text" id="wp_assignees" placeholder="name (company), name (company)"></div>
|
<input type="hidden" id="wp_location">
|
||||||
<div class="field"><label>Distribution</label><input type="text" id="wp_distribution" placeholder="notify list"></div>
|
</div>
|
||||||
<div class="field"><label>Due Date</label><input type="date" id="wp_due"></div>
|
<!-- CR-002 hides these two rather than deleting them: the columns, the model
|
||||||
<div class="field"><label>Specification Section</label><input type="text" id="wp_spec" placeholder="e.g. 26_05_33_00 - Raceway and Boxes"><div class="field-hint" id="spec-folder-link"></div></div>
|
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>
|
||||||
<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"><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>
|
</div>
|
||||||
|
|
||||||
<!-- ASSETS (controls.dev) -->
|
<!-- ASSIGNMENT & SCHEDULE (F6 / T7.2)
|
||||||
<div class="card">
|
Split out of General Information, which was 1,288px on its own and the
|
||||||
<div class="sub-heading">Assets</div>
|
whole of the gap between the page at rest and F6's two-screen bar. The
|
||||||
<div class="notice">Every work package is based on one or more assets managed in <strong>controls.dev</strong>. Paste the controls.dev link for each asset this package covers. <span style="color:var(--text-dim)">A direct integration to pick assets from a list is planned — for now, link them manually.</span></div>
|
split is presentational: BOTH cards are the CR-006 section `general`
|
||||||
<div class="table-wrap"><table><thead><tr><th style="width:200px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link <span class="req">*</span></th><th style="width:44px"></th></tr></thead><tbody id="asset-body"></tbody></table></div>
|
(WP_SECTION_NODES.general lists both), so the shared section registry in
|
||||||
<button class="add-btn" onclick="addAsset()">+ Add Asset</button>
|
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>
|
||||||
|
<div class="people-pick" id="pick_assignees"></div>
|
||||||
|
<input type="hidden" id="wp_assignees"></div>
|
||||||
|
<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>
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- BIM / MODEL DETAILS (shown for BIM/VDC SOPs) -->
|
||||||
|
<div class="card" id="bim-card" style="display:none">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ASSETS (Micron asset catalog) -->
|
||||||
|
<div class="card" id="asset-card">
|
||||||
|
<div class="sub-heading">Assets<span class="help-tip" data-tip="Every work package is built around one or more assets. Search the Micron DB by asset ID, paste a column of IDs straight from Excel, or load a CSV. IDs found in the Micron DB are tagged as such; the rest are added as manual rows. The Micron DB is read-only here — picking an asset never changes it.">i</span></div>
|
||||||
|
<div class="notice">Every work package is based on one or more assets from the <strong>Micron DB</strong>. Search by asset ID, or paste a column of IDs straight from Excel, to add each asset this package covers. <span style="color:var(--text-dim)">The Micron DB is read-only — nothing you do here changes it.</span></div>
|
||||||
|
<div class="asset-pick" id="asset-pick">
|
||||||
|
<input type="search" class="asset-search" id="asset-search" autocomplete="off"
|
||||||
|
placeholder="Search asset IDs, or paste a column from Excel…"
|
||||||
|
aria-label="Search the Micron DB by asset ID" aria-controls="asset-results" aria-expanded="false">
|
||||||
|
<div class="asset-results" id="asset-results" hidden></div>
|
||||||
|
</div>
|
||||||
|
<!-- role=status: loading -> ready/absent/error announces (the login.html pattern) -->
|
||||||
|
<div class="field-hint" id="asset-source-note" role="status"></div>
|
||||||
|
<div class="table-wrap"><table><thead><tr><th style="width:260px">Asset ID</th><th>Note <span style="font-weight:400;color:var(--text-dim)">(what this asset is / why it's in scope)</span></th><th style="width:44px"></th></tr></thead><tbody id="asset-body"></tbody></table></div>
|
||||||
|
<div class="material-actions">
|
||||||
|
<button class="add-btn" onclick="addManualAsset()" title="Add an asset that is not in the Micron DB yet">+ Add asset not in the Micron DB</button>
|
||||||
|
<button class="add-btn" onclick="document.getElementById('asset-import').click()" title="Load a list of asset IDs from a CSV. IDs found in the Micron DB are tagged as such; the rest are added as manual rows.">⤒ Load from CSV</button>
|
||||||
|
<input type="file" id="asset-import" accept=".csv,text/csv" style="display:none" onchange="importAssets(event)">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- DISCIPLINES -->
|
<!-- DISCIPLINES -->
|
||||||
@@ -97,42 +331,82 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SCOPE & WORK -->
|
<!-- 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 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 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 class="notice">Enter the work as ordered steps — added in sequence, the way the crew performs them.</div>
|
||||||
<div id="worksteps-body"></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>
|
</div>
|
||||||
<div id="scope-by-discipline" style="display:none"></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-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>Package Predecessor</label><select id="wp_seq"></select><div class="field-hint">The package/step (from the SOP sequence) that must finish before this work can start. Choose "None" if it has no predecessor.</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>
|
||||||
|
<div class="field"><label>Sequence phase <span class="help-tip" data-tip="Which phase of the SOP's construction sequence this package belongs to. Descriptive — it does not gate release; predecessor packages do.">i</span></label>
|
||||||
|
<select id="wp_seq"></select><div class="field-hint sop-hint">from the SOP construction sequence</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MATERIAL LIST -->
|
<!-- MATERIAL LIST -->
|
||||||
<div class="card">
|
<div class="card" id="material-card">
|
||||||
<div class="sub-heading">Material List<span class="help-tip" data-tip="Bill of materials — feeds kitting. On a multi-discipline package each line can be tagged to a discipline so a split routes each instance only its own materials. Import from CSV is supported.">i</span></div>
|
<div class="sub-heading">Material List<span class="help-tip" data-tip="Bill of materials — feeds kitting. On a multi-discipline package each line can be tagged to a discipline so a split routes each instance only its own materials. Import from CSV is supported.">i</span></div>
|
||||||
<div class="notice">Structured bill of materials. Feeds kitting and the delivery forecast. Unit is from the Acumatica unit list.</div>
|
<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="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">
|
<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="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)">
|
<input type="file" id="material-import" accept=".csv,.xlsx,.xls" style="display:none" onchange="importMaterials(event)">
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- DRAWINGS / ATTACHMENTS -->
|
<!-- DRAWINGS / ATTACHMENTS -->
|
||||||
<div class="card">
|
<div class="card" id="drawings-card">
|
||||||
<div class="sub-heading">Drawings & Attachments</div>
|
<div class="sub-heading">Drawings & Attachments</div>
|
||||||
<div id="sop-ref-links" class="sop-ref-links"></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>
|
<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>
|
<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-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>
|
<div id="sop-file-folders"></div>
|
||||||
@@ -146,15 +420,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- KITTING & MIMO -->
|
<!-- KITTING & MIMO -->
|
||||||
<div class="card">
|
<div class="card" id="mimo-card">
|
||||||
<div class="sub-heading">Kitting & Material Movement (MIMO)</div>
|
<div class="sub-heading">Kitting & Material Movement (MIMO)</div>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<div class="field"><label>Kitting Status</label>
|
<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>
|
<select id="wp_kit_status"><option value="">—</option></select>
|
||||||
<div class="field"><label>Warehouse Owner</label><input type="text" id="wp_kit_owner" placeholder="name"></div>
|
<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>Kitting Need Date</label><input type="date" id="wp_kit_date"></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>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>Kitting need date</label><input type="date" id="wp_kit_date"></div>
|
||||||
<div class="field"><label>MIMO Location</label><input type="text" id="wp_mimo_loc" placeholder="staging / move location"></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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -166,21 +448,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- QUALITY & HOLD POINTS -->
|
<!-- QUALITY & HOLD POINTS -->
|
||||||
<div class="card">
|
<div class="card" id="quality-card">
|
||||||
<div class="sub-heading">Quality, Inspection & Hold Points</div>
|
<div class="sub-heading">Quality, Inspection & Hold Points</div>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<div class="field"><label>QC Required</label><input type="text" id="wp_qc" placeholder="from SOP" readonly>
|
<div class="field"><label>QC required</label><input type="text" id="wp_qc" placeholder="from SOP" readonly>
|
||||||
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_qc')">🔒 Edit (reason required)</button><span class="override-note"></span></div></div>
|
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_qc')">✎ Edit (reason required)</button><span class="override-note"></span></div></div>
|
||||||
<div class="field"><label>Photo Documentation</label><input type="text" id="wp_photo" placeholder="from SOP" readonly>
|
<div class="field"><label>Photo documentation</label><input type="text" id="wp_photo" placeholder="from SOP" readonly>
|
||||||
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_photo')">🔒 Edit (reason required)</button><span class="override-note"></span></div></div>
|
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_photo')">✎ Edit (reason required)</button><span class="override-note"></span></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field field-grid col1"><div class="field"><label>Witness / Hold Points</label><textarea id="wp_hold" rows="2" readonly placeholder="from SOP"></textarea>
|
<div class="field field-grid col1"><div class="field"><label>Witness / hold points</label><textarea id="wp_hold" rows="2" readonly placeholder="from SOP"></textarea>
|
||||||
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_hold')">🔒 Edit (reason required)</button><span class="override-note"></span></div>
|
<div class="lock-row"><button type="button" class="lock-edit" onclick="editQuality('wp_hold')">✎ Edit (reason required)</button><span class="override-note"></span></div>
|
||||||
<div class="field-hint">Inherited from the SOP. A <strong>Hold Point</strong> stops work until inspection sign-off; a <strong>Witness Point</strong> is offered for inspection but work may proceed if declined.</div></div></div>
|
<div class="field-hint">Inherited from the SOP. A <strong>Hold Point</strong> stops work until inspection sign-off; a <strong>Witness Point</strong> is offered for inspection but work may proceed if declined.</div></div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SIGN-OFFS -->
|
<!-- 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="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="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>
|
<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>
|
||||||
@@ -191,23 +478,23 @@
|
|||||||
<div class="sub-heading">Closeout</div>
|
<div class="sub-heading">Closeout</div>
|
||||||
<div class="notice">Completed at QC / Closed — captures as-built reality and lessons learned.</div>
|
<div class="notice">Completed at QC / Closed — captures as-built reality and lessons learned.</div>
|
||||||
<div class="field-grid">
|
<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>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>Installed quantity</label><input type="text" id="wp_installed_qty" placeholder="e.g. 42 of 42 tags"></div>
|
||||||
</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>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>Lessons learned</label><textarea id="wp_lessons" rows="2"></textarea></div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-row"><button class="btn btn-ghost" onclick="newPackage()">↺ Clear</button>
|
<div class="nav-row"><button class="btn btn-ghost" onclick="newPackage()">↺ Clear</button>
|
||||||
<div style="display:flex;gap:10px">
|
<div style="display:flex;gap:10px">
|
||||||
<button class="btn btn-ghost" onclick="savePackage(false)">Save Draft</button>
|
<button class="btn btn-ghost" onclick="savePackage(false)">Save draft</button>
|
||||||
<button class="btn btn-generate" onclick="savePackage(true)">⚡ Save & View</button>
|
<button class="btn btn-generate" onclick="savePackage(true)">Save & view</button>
|
||||||
</div></div>
|
</div></div>
|
||||||
|
|
||||||
<!-- DASHBOARD -->
|
<!-- DASHBOARD -->
|
||||||
<div id="dashboard-view" style="display:none">
|
<div id="dashboard-view" style="display:none">
|
||||||
<div class="output-toolbar">
|
<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="font-weight:700;font-size:15px">Work Package Dashboard</div>
|
||||||
<div style="display:flex;gap:10px;margin-left:auto">
|
<div style="display:flex;gap:10px;margin-left:auto">
|
||||||
<button class="btn btn-ghost" onclick="newPackage()">+ New WP</button>
|
<button class="btn btn-ghost" onclick="newPackage()">+ New WP</button>
|
||||||
@@ -222,8 +509,8 @@
|
|||||||
<!-- OUTPUT -->
|
<!-- OUTPUT -->
|
||||||
<div id="pkg-output" style="display:none">
|
<div id="pkg-output" style="display:none">
|
||||||
<div class="output-toolbar">
|
<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>
|
||||||
<button class="btn btn-primary" onclick="printPackage()">⎙ Print / Save PDF</button>
|
<button class="btn btn-primary" onclick="printPackage()">⎙ Print / save PDF</button>
|
||||||
<button class="btn btn-ghost" onclick="exportPackages()">⤓ Export (JSON)</button>
|
<button class="btn btn-ghost" onclick="exportPackages()">⤓ Export (JSON)</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="output-doc" id="pkg-doc"></div>
|
<div class="output-doc" id="pkg-doc"></div>
|
||||||
@@ -233,12 +520,32 @@
|
|||||||
<div class="card" id="saved-card" style="display:none">
|
<div class="card" id="saved-card" style="display:none">
|
||||||
<div class="sub-heading">Saved Work Packages <span id="saved-count"></span></div>
|
<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>
|
<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="exportPackages()">⤓ Export all (JSON)</button>
|
||||||
<button class="add-btn" onclick="clearSaved()">Clear All</button>
|
<button class="add-btn" onclick="clearSaved()">Clear all</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</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) -->
|
<!-- HOLD LOG MODAL (comment 7) -->
|
||||||
<div class="modal-overlay" id="hold-modal">
|
<div class="modal-overlay" id="hold-modal">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
@@ -246,11 +553,43 @@
|
|||||||
<div class="modal-body">
|
<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="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>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 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 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>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -264,33 +603,44 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- COMMENTS DRAWER -->
|
<!-- COMMENTS DRAWER -->
|
||||||
<div class="cmt-overlay" id="cmt-overlay" onclick="toggleComments()"></div>
|
<!-- The backdrop is NOT a control (C1): pointer dismissal is attached in
|
||||||
|
cmtInit(), and Escape + the drawer's close button are the real paths. -->
|
||||||
|
<div class="cmt-overlay" id="cmt-overlay"></div>
|
||||||
<aside class="cmt-drawer" id="cmt-drawer" aria-hidden="true">
|
<aside class="cmt-drawer" id="cmt-drawer" aria-hidden="true">
|
||||||
<div class="cmt-head"><div class="cmt-title">Review Comments</div><button class="cmt-x" onclick="toggleComments()" title="Close">✕</button></div>
|
<div class="cmt-head"><div class="cmt-title">Review Comments</div><button class="cmt-x" onclick="toggleComments()" title="Close">✕</button></div>
|
||||||
<div class="cmt-namebar"><label>Your name</label><input type="text" id="cmt-author" placeholder="e.g. J. Park" oninput="cmtSaveAuthor(this.value)"></div>
|
<div class="cmt-namebar"><label>Your name</label><input type="text" id="cmt-author" placeholder="e.g. J. Park" oninput="cmtSaveAuthor(this.value)"></div>
|
||||||
<div class="cmt-compose"><div class="cmt-compose-label">Comment on <strong id="cmt-cur-step">this form</strong></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>
|
<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-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"><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>
|
<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" 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>
|
<input type="file" id="cmt-import" accept="application/json" style="display:none" onchange="importComments(event)"></div></div>
|
||||||
</aside>
|
</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">
|
<div class="sticky-save" id="sticky-save" style="display:none">
|
||||||
<span class="sticky-status" id="sticky-status"></span>
|
<span class="sticky-status" id="sticky-status"></span>
|
||||||
<div class="sticky-actions">
|
<div class="sticky-actions">
|
||||||
<button class="btn btn-ghost" onclick="savePackage(false)">Save Draft</button>
|
<button class="btn btn-ghost" onclick="savePackage(false)">Save draft</button>
|
||||||
<button class="btn btn-generate" onclick="savePackage(true)">⚡ Save & View</button>
|
<button class="btn btn-generate" onclick="savePackage(true)">Save & view</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="feedback-config.js"></script>
|
<script src="feedback-config.js"></script>
|
||||||
<script src="project-data.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="help.js"></script>
|
||||||
<script src="wp-creation-app.js"></script>
|
<script src="wp-creation-app.js"></script>
|
||||||
|
<script src="wp-chrome.js"></script>
|
||||||
|
<script src="wp-sidenav.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
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);
|
||||||
232
html/wp-format.js
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
/* Localization + time formatting for the Work Package Suite.
|
||||||
|
|
||||||
|
Every date the app shows should agree, wherever it's rendered. Three sources,
|
||||||
|
most specific first:
|
||||||
|
1. the signed-in user's own preference (users.locale / users.timezone)
|
||||||
|
2. the app default set by an admin (Admin console → Localization)
|
||||||
|
3. the browser's own locale / timezone (the previous behaviour)
|
||||||
|
|
||||||
|
Why store it server-side: on a shared field tablet the browser's locale isn't
|
||||||
|
the person's, and a package due date that reads a day early because the device
|
||||||
|
sits in another zone is a real scheduling problem — not a cosmetic one.
|
||||||
|
|
||||||
|
Exposes:
|
||||||
|
wpFormatDate(v) → 3 Aug 2026 (date only)
|
||||||
|
wpFormatDateTime(v) → 3 Aug 2026, 14:07 (date + time)
|
||||||
|
wpFormatTime(v) → 14:07
|
||||||
|
wpFormatNumber(v) → locale-grouped number
|
||||||
|
wpTimeZoneLabel() → the zone in effect, for a UI hint
|
||||||
|
wpPreferences() → opens the preferences dialog
|
||||||
|
All formatters take an ISO string, Date, or epoch ms, and return '' for empty
|
||||||
|
input (never 'Invalid Date'), so they're safe to drop into a template. */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function prefs() {
|
||||||
|
var u = window.WP_USER || {};
|
||||||
|
var f = window.WP_FLAGS || {};
|
||||||
|
return {
|
||||||
|
locale: (u.locale || f.default_locale || '') || undefined,
|
||||||
|
timezone: (u.timezone || f.default_timezone || '') || undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A date-only value ('2026-08-03') is a calendar date, not an instant. Parsed as
|
||||||
|
// UTC midnight by the platform, it can render as the previous day in a western
|
||||||
|
// zone — so format these from their parts and never apply a timezone.
|
||||||
|
var DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
function toDate(v) {
|
||||||
|
if (v == null || v === '') return null;
|
||||||
|
if (v instanceof Date) return isNaN(v.getTime()) ? null : v;
|
||||||
|
if (typeof v === 'number') { var n = new Date(v); return isNaN(n.getTime()) ? null : n; }
|
||||||
|
var s = String(v).trim();
|
||||||
|
if (!s) return null;
|
||||||
|
var d = new Date(s);
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(v, opts, forceNoTz) {
|
||||||
|
var s = (typeof v === 'string') ? v.trim() : v;
|
||||||
|
var dateOnly = (typeof s === 'string') && DATE_ONLY.test(s);
|
||||||
|
var d = dateOnly ? new Date(s + 'T12:00:00') : toDate(s); // noon: immune to ±12h shifts
|
||||||
|
if (!d) return '';
|
||||||
|
var p = prefs();
|
||||||
|
var o = {};
|
||||||
|
for (var k in opts) if (Object.prototype.hasOwnProperty.call(opts, k)) o[k] = opts[k];
|
||||||
|
if (p.timezone && !dateOnly && !forceNoTz) o.timeZone = p.timezone;
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat(p.locale, o).format(d);
|
||||||
|
} catch (e) {
|
||||||
|
// Bad locale/zone (e.g. a preference set before tzdata was available):
|
||||||
|
// fall back to the platform default rather than showing nothing.
|
||||||
|
try { return new Intl.DateTimeFormat(undefined, opts).format(d); } catch (e2) { return String(v); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.wpFormatDate = function (v) {
|
||||||
|
return fmt(v, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
};
|
||||||
|
window.wpFormatDateTime = function (v) {
|
||||||
|
return fmt(v, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
window.wpFormatTime = function (v) {
|
||||||
|
return fmt(v, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
window.wpFormatNumber = function (v, opts) {
|
||||||
|
if (v == null || v === '' || isNaN(+v)) return '';
|
||||||
|
try { return new Intl.NumberFormat(prefs().locale, opts || {}).format(+v); }
|
||||||
|
catch (e) { return String(v); }
|
||||||
|
};
|
||||||
|
window.wpTimeZoneLabel = function () {
|
||||||
|
var p = prefs();
|
||||||
|
if (p.timezone) return p.timezone;
|
||||||
|
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'browser default'; }
|
||||||
|
catch (e) { return 'browser default'; }
|
||||||
|
};
|
||||||
|
window.wpLocaleLabel = function () {
|
||||||
|
var p = prefs();
|
||||||
|
if (p.locale) return p.locale;
|
||||||
|
try { return Intl.DateTimeFormat().resolvedOptions().locale || 'browser default'; }
|
||||||
|
catch (e) { return 'browser default'; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── preferences dialog ─────────────────────────────────────────────────────
|
||||||
|
var COMMON_LOCALES = [
|
||||||
|
['', 'Browser default'],
|
||||||
|
['en-US', 'English (United States) — 8/3/2026, 2:07 PM'],
|
||||||
|
['en-GB', 'English (United Kingdom) — 03/08/2026, 14:07'],
|
||||||
|
['en-CA', 'English (Canada)'],
|
||||||
|
['es-MX', 'Español (México)'],
|
||||||
|
['es-US', 'Español (Estados Unidos)'],
|
||||||
|
['fr-CA', 'Français (Canada)'],
|
||||||
|
['de-DE', 'Deutsch (Deutschland)'],
|
||||||
|
['ja-JP', '日本語 (日本)'],
|
||||||
|
['ko-KR', '한국어 (대한민국)'],
|
||||||
|
['zh-TW', '中文 (台灣)']
|
||||||
|
];
|
||||||
|
// Zones the fabs and offices actually sit in, offered before the full list.
|
||||||
|
var COMMON_ZONES = [
|
||||||
|
'America/Chicago', 'America/New_York', 'America/Denver', 'America/Phoenix',
|
||||||
|
'America/Los_Angeles', 'America/Boise', 'Asia/Tokyo', 'Asia/Taipei',
|
||||||
|
'Asia/Seoul', 'Asia/Singapore', 'Europe/Dublin', 'Europe/London', 'UTC'
|
||||||
|
];
|
||||||
|
|
||||||
|
window.wpPreferences = function () {
|
||||||
|
if (document.getElementById('wp-prefs-modal')) return;
|
||||||
|
var u = window.WP_USER || {};
|
||||||
|
var ov = document.createElement('div');
|
||||||
|
ov.id = 'wp-prefs-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.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 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: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>' +
|
||||||
|
'<select id="wp-prefs-locale" style="' + fld + '"></select>' +
|
||||||
|
'<div style="' + hint + '">Sets how dates and numbers are written. It does not translate the app.</div>' +
|
||||||
|
'<label style="' + lbl + '">Time zone</label>' +
|
||||||
|
'<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:var(--cds-layer-accent);border-radius:6px;font-size:12.5px;"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<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 ? '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);
|
||||||
|
|
||||||
|
var locSel = document.getElementById('wp-prefs-locale');
|
||||||
|
var tzSel = document.getElementById('wp-prefs-tz');
|
||||||
|
var preview = document.getElementById('wp-prefs-preview');
|
||||||
|
|
||||||
|
locSel.innerHTML = COMMON_LOCALES.map(function (p) {
|
||||||
|
return '<option value="' + p[0] + '"' + (p[0] === (u.locale || '') ? ' selected' : '') + '>' + p[1] + '</option>';
|
||||||
|
}).join('');
|
||||||
|
// A stored locale that isn't in the shortlist stays selectable.
|
||||||
|
if (u.locale && !COMMON_LOCALES.some(function (p) { return p[0] === u.locale; })) {
|
||||||
|
locSel.add(new Option(u.locale, u.locale, true, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillZones(all) {
|
||||||
|
var cur = u.timezone || '';
|
||||||
|
var browser = '';
|
||||||
|
try { browser = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {}
|
||||||
|
var html = '<option value=""' + (cur ? '' : ' selected') + '>Browser default' +
|
||||||
|
(browser ? ' (' + browser + ')' : '') + '</option>';
|
||||||
|
html += '<optgroup label="Common">' + COMMON_ZONES.map(function (z) {
|
||||||
|
return '<option value="' + z + '"' + (z === cur ? ' selected' : '') + '>' + z + '</option>';
|
||||||
|
}).join('') + '</optgroup>';
|
||||||
|
var rest = (all || []).filter(function (z) { return COMMON_ZONES.indexOf(z) < 0; });
|
||||||
|
if (rest.length) {
|
||||||
|
html += '<optgroup label="All time zones">' + rest.map(function (z) {
|
||||||
|
return '<option value="' + z + '"' + (z === cur ? ' selected' : '') + '>' + z + '</option>';
|
||||||
|
}).join('') + '</optgroup>';
|
||||||
|
} else if (cur && COMMON_ZONES.indexOf(cur) < 0) {
|
||||||
|
html += '<option value="' + cur + '" selected>' + cur + '</option>';
|
||||||
|
}
|
||||||
|
tzSel.innerHTML = html;
|
||||||
|
updatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview uses the picked values, not the saved ones, so the effect is visible
|
||||||
|
// before committing.
|
||||||
|
function updatePreview() {
|
||||||
|
var l = locSel.value || undefined, z = tzSel.value || undefined;
|
||||||
|
var now = new Date();
|
||||||
|
var out;
|
||||||
|
try {
|
||||||
|
out = new Intl.DateTimeFormat(l, {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit', timeZone: z
|
||||||
|
}).format(now);
|
||||||
|
} catch (e) { out = 'Not supported by this browser'; }
|
||||||
|
preview.innerHTML = '<strong>Preview</strong><br>Right now: ' +
|
||||||
|
String(out).replace(/[<>]/g, '') +
|
||||||
|
'<br>A due date (2026-08-03) always reads: ' + window.wpFormatDate('2026-08-03');
|
||||||
|
}
|
||||||
|
locSel.addEventListener('change', updatePreview);
|
||||||
|
tzSel.addEventListener('change', updatePreview);
|
||||||
|
|
||||||
|
// The picker offers exactly what the server will accept.
|
||||||
|
fetch('/api/timezones', { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : []; })
|
||||||
|
.then(fillZones)
|
||||||
|
.catch(function () { fillZones([]); });
|
||||||
|
|
||||||
|
document.getElementById('wp-prefs-cancel').onclick = close;
|
||||||
|
document.getElementById('wp-prefs-save').onclick = function () {
|
||||||
|
var body = { locale: locSel.value || '', timezone: tzSel.value || '' };
|
||||||
|
fetch('/api/auth/preferences', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) { msg((res.j && res.j.detail) || ('Could not save (HTTP ' + res.status + ').'), false); return; }
|
||||||
|
if (res.j && res.j.user) window.WP_USER = res.j.user;
|
||||||
|
try { localStorage.setItem('wp_auth_cache', JSON.stringify({ user: window.WP_USER, at: Date.now() })); } catch (e) {}
|
||||||
|
msg('Saved. Reloading so every date on the page agrees…', true);
|
||||||
|
// Dates are formatted at render time all over the app; a reload is the
|
||||||
|
// honest way to apply the change everywhere at once.
|
||||||
|
setTimeout(function () { location.reload(); }, 700);
|
||||||
|
})
|
||||||
|
.catch(function () { msg('Could not reach the server.', false); });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})();
|
||||||
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' },
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -14,6 +14,22 @@
|
|||||||
# 5. sudo nginx -t && sudo systemctl reload nginx
|
# 5. sudo nginx -t && sudo systemctl reload nginx
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Cache-Control per file type. Computed in a map rather than a nested location
|
||||||
|
# because nginx's add_header is NOT inherited into a block that declares its own —
|
||||||
|
# a `location ~* \.(html|css|js)$` setting only Cache-Control would silently drop the
|
||||||
|
# CSP / HSTS / X-Frame-Options / nosniff headers below for exactly those files. An
|
||||||
|
# empty value makes nginx omit the header, so images and fonts stay cacheable.
|
||||||
|
#
|
||||||
|
# Code must revalidate on every load: with no Cache-Control the browser applies
|
||||||
|
# HEURISTIC freshness (~10% of the file's age), so the least recently changed file
|
||||||
|
# gets the LONGEST lifetime — which is how a page ends up running against a
|
||||||
|
# stylesheet or script from a previous deploy. ETag/Last-Modified keep it a 304.
|
||||||
|
map $uri $wp_cache_control {
|
||||||
|
default "";
|
||||||
|
~*\.(?:html|css|js|webmanifest)$ "no-cache";
|
||||||
|
~*/$ "no-cache"; # directory index -> index.html
|
||||||
|
}
|
||||||
|
|
||||||
# Redirect plain HTTP to HTTPS
|
# Redirect plain HTTP to HTTPS
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
@@ -34,6 +50,15 @@ server {
|
|||||||
root /var/www/wp-suite; # <-- web root
|
root /var/www/wp-suite; # <-- web root
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# ── Security response headers (defense-in-depth) ─────────────────────────
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'self'" always;
|
||||||
|
# Empty for anything that isn't code, in which case nginx omits the header.
|
||||||
|
add_header Cache-Control $wp_cache_control always;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ =404;
|
try_files $uri $uri/ =404;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,43 @@
|
|||||||
# This container sits behind an external reverse proxy that handles SSL.
|
# This container sits behind an external reverse proxy that handles SSL.
|
||||||
# It listens on port 80 (plain HTTP on the internal Docker network).
|
# It listens on port 80 (plain HTTP on the internal Docker network).
|
||||||
|
|
||||||
|
# Cache-Control per file type, computed here rather than in a nested location.
|
||||||
|
# WHY A MAP: nginx's add_header is not inherited into a block that declares its own
|
||||||
|
# add_header — a `location ~* \.(html|css|js)$` that set only Cache-Control would have
|
||||||
|
# silently dropped the CSP / HSTS / X-Frame-Options / nosniff headers below for exactly
|
||||||
|
# those files. Computing the value here keeps every header in ONE scope. An empty value
|
||||||
|
# means nginx omits the header entirely, so images and fonts stay freely cacheable.
|
||||||
|
#
|
||||||
|
# Code assets must revalidate on every load: with no Cache-Control at all the browser
|
||||||
|
# applies HEURISTIC freshness (~10% of the file's age), so the least recently changed
|
||||||
|
# file gets the LONGEST lifetime — which is how a page ends up running against a
|
||||||
|
# stylesheet or script from a previous deploy. ETag/Last-Modified keep it a cheap 304.
|
||||||
|
map $uri $wp_cache_control {
|
||||||
|
default "";
|
||||||
|
~*\.(?:html|css|js|webmanifest)$ "no-cache";
|
||||||
|
~*/$ "no-cache"; # directory index → index.html
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name wp.controls.dev;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# ── Security response headers (defense-in-depth) ─────────────────────────
|
||||||
|
# CSP keeps 'unsafe-inline' for now because the app uses inline handlers/styles
|
||||||
|
# heavily; even so, connect-src/img-src/object-src/base-uri/frame-ancestors
|
||||||
|
# sharply limit what injected script could load or exfiltrate. Tighten toward
|
||||||
|
# nonce-based scripts once inline handlers are refactored.
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; form-action 'self'" always;
|
||||||
|
# Empty for anything that isn't code, in which case nginx omits the header.
|
||||||
|
add_header Cache-Control $wp_cache_control always;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ =404;
|
try_files $uri $uri/ =404;
|
||||||
}
|
}
|
||||||
@@ -19,7 +49,10 @@ server {
|
|||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Forwarded-For $remote_addr;
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
# This container is only ever reached via the TLS-terminating external
|
||||||
|
# proxy, so the real client scheme is HTTPS. Hard-set it (a local $scheme
|
||||||
|
# here is always "http") so the API marks the session cookie Secure.
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
client_max_body_size 5m;
|
client_max_body_size 5m;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
scripts/backup-cron.sh
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Entry point for the `backup` sidecar container's periodic loop. Runs
|
||||||
|
# db-backup.sh on a fixed interval (default: daily) -- a sleep loop instead of
|
||||||
|
# a cron daemon, kept deliberately simple so it works in a bare
|
||||||
|
# postgres:16-alpine image.
|
||||||
|
#
|
||||||
|
# Resolves db-backup.sh the same way entrypoint.sh resolves this file: prefer
|
||||||
|
# the live bind-mounted copy at /scripts (so edits don't need a rebuild), fall
|
||||||
|
# back to the copy baked into the image at build time if the mount is
|
||||||
|
# missing, empty, or stale. Resolving fresh on every loop iteration also means
|
||||||
|
# that if the mount comes back healthy later (e.g. someone fixes the host
|
||||||
|
# directory) this container picks it up on the very next run, with no
|
||||||
|
# restart needed.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
resolve() {
|
||||||
|
# $1 = script filename, e.g. db-backup.sh
|
||||||
|
if [ -f "/scripts/$1" ]; then
|
||||||
|
echo "/scripts/$1"
|
||||||
|
else
|
||||||
|
echo "/app/scripts-default/$1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
INTERVAL="${BACKUP_INTERVAL_SECONDS:-86400}" # 86400 = once a day
|
||||||
|
echo "[backup] sidecar started; interval=${INTERVAL}s, keep=${BACKUP_KEEP:-14}, dir=${BACKUP_DIR:-/backups}"
|
||||||
|
|
||||||
|
# Take one backup shortly after start so a freshly-deployed stack has an
|
||||||
|
# immediate restore point instead of waiting a whole interval.
|
||||||
|
sleep 20
|
||||||
|
while true; do
|
||||||
|
DB_BACKUP="$(resolve db-backup.sh)"
|
||||||
|
sh "$DB_BACKUP" || echo "[backup] run failed; will retry next interval" >&2
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
done
|
||||||
19
scripts/backup.Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Backup sidecar image: Postgres client tools (pg_dump/psql) + openssl for
|
||||||
|
# at-rest encryption of dumps.
|
||||||
|
#
|
||||||
|
# The scripts are bind-mounted live at runtime (see the `backup` service in
|
||||||
|
# docker-compose.yml) so they can be edited without a rebuild -- but they're
|
||||||
|
# ALSO baked in here as a fallback default under /app/scripts-default/.
|
||||||
|
# entrypoint.sh prefers the live mount and only falls back to this baked-in
|
||||||
|
# copy if the mount is missing, empty, or stale. That fallback is what keeps
|
||||||
|
# a broken bind mount from crash-looping the container into an unreachable
|
||||||
|
# state (see entrypoint.sh for the full story).
|
||||||
|
FROM postgres:16-alpine
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
|
COPY scripts/backup-cron.sh scripts/db-backup.sh scripts/db-restore.sh /app/scripts-default/
|
||||||
|
COPY scripts/entrypoint.sh /app/entrypoint.sh
|
||||||
|
RUN chmod +x /app/entrypoint.sh /app/scripts-default/*.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
|
CMD []
|
||||||
58
scripts/db-backup.sh
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# One database backup: pg_dump -> gzip [-> openssl AES-256] -> timestamped file in
|
||||||
|
# $BACKUP_DIR, then prune to the newest $BACKUP_KEEP files.
|
||||||
|
#
|
||||||
|
# Encryption: if BACKUP_ENC_PASSPHRASE is set, the dump is encrypted at rest with
|
||||||
|
# AES-256 (openssl, PBKDF2) and written as *.sql.gz.enc. STRONGLY recommended once
|
||||||
|
# the database holds customer IP — otherwise the dump (and every offsite copy) is
|
||||||
|
# plaintext. Keep the passphrase OUT of the backups directory (and off the host if
|
||||||
|
# possible); losing it means the backups are unrecoverable.
|
||||||
|
#
|
||||||
|
# Runs inside a container that has pg_dump + openssl (see scripts/backup.Dockerfile).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/backups}"
|
||||||
|
KEEP="${BACKUP_KEEP:-14}"
|
||||||
|
PGHOST="${PGHOST:-db}"
|
||||||
|
PGPORT="${PGPORT:-5432}"
|
||||||
|
DB="${POSTGRES_DB:?POSTGRES_DB is required}"
|
||||||
|
DB_USER="${POSTGRES_USER:?POSTGRES_USER is required}"
|
||||||
|
export PGPASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
|
||||||
|
ENC="${BACKUP_ENC_PASSPHRASE:-}"
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
ts="$(date -u +%Y%m%d-%H%M%SZ)"
|
||||||
|
if [ -n "$ENC" ]; then
|
||||||
|
out="$BACKUP_DIR/wpsuite-$ts.sql.gz.enc"
|
||||||
|
else
|
||||||
|
out="$BACKUP_DIR/wpsuite-$ts.sql.gz"
|
||||||
|
echo "[db-backup] WARNING: BACKUP_ENC_PASSPHRASE not set — this dump is UNENCRYPTED. Set it to protect data at rest." >&2
|
||||||
|
fi
|
||||||
|
tmp="$out.partial"
|
||||||
|
|
||||||
|
echo "[db-backup] $(date -u) dumping ${DB}@${PGHOST} -> ${out}"
|
||||||
|
if [ -n "$ENC" ]; then
|
||||||
|
if pg_dump -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" --clean --if-exists \
|
||||||
|
| gzip -c \
|
||||||
|
| openssl enc -aes-256-cbc -pbkdf2 -salt -pass env:BACKUP_ENC_PASSPHRASE > "$tmp"; then
|
||||||
|
mv "$tmp" "$out"
|
||||||
|
else
|
||||||
|
echo "[db-backup] FAILED — pg_dump/encrypt error" >&2; rm -f "$tmp"; exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if pg_dump -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" --clean --if-exists | gzip -c > "$tmp"; then
|
||||||
|
mv "$tmp" "$out"
|
||||||
|
else
|
||||||
|
echo "[db-backup] FAILED — pg_dump error" >&2; rm -f "$tmp"; exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "[db-backup] wrote $(du -h "$out" | cut -f1) ${out}"
|
||||||
|
|
||||||
|
# Retention: keep the newest $KEEP dumps (plaintext or encrypted), delete the rest.
|
||||||
|
count="$(ls -1t "$BACKUP_DIR"/wpsuite-*.sql.gz* 2>/dev/null | grep -v '\.partial$' | wc -l | tr -d ' ')"
|
||||||
|
if [ "$count" -gt "$KEEP" ]; then
|
||||||
|
ls -1t "$BACKUP_DIR"/wpsuite-*.sql.gz* 2>/dev/null | grep -v '\.partial$' | tail -n +"$((KEEP + 1))" | while IFS= read -r f; do
|
||||||
|
echo "[db-backup] pruning $f"
|
||||||
|
rm -f "$f"
|
||||||
|
done
|
||||||
|
fi
|
||||||
33
scripts/db-restore.sh
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Restore a pg_dump backup (plaintext *.sql.gz or encrypted *.sql.gz.enc).
|
||||||
|
#
|
||||||
|
# DESTRUCTIVE: dumps are taken with --clean --if-exists, so restoring drops and
|
||||||
|
# recreates objects before loading. Take a fresh backup first if in doubt.
|
||||||
|
#
|
||||||
|
# Usage (from the project root):
|
||||||
|
# docker compose exec backup sh /scripts/db-restore.sh /backups/wpsuite-YYYYMMDD-HHMMSSZ.sql.gz.enc
|
||||||
|
# For an encrypted (.enc) file, BACKUP_ENC_PASSPHRASE must be set (it is, in the
|
||||||
|
# backup container's environment).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
FILE="${1:?usage: db-restore.sh <path-to-.sql.gz[.enc]>}"
|
||||||
|
PGHOST="${PGHOST:-db}"
|
||||||
|
PGPORT="${PGPORT:-5432}"
|
||||||
|
DB="${POSTGRES_DB:?POSTGRES_DB is required}"
|
||||||
|
DB_USER="${POSTGRES_USER:?POSTGRES_USER is required}"
|
||||||
|
export PGPASSWORD="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
|
||||||
|
|
||||||
|
[ -f "$FILE" ] || { echo "[db-restore] no such file: $FILE" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo "[db-restore] restoring ${FILE} -> ${DB}@${PGHOST} (this OVERWRITES current data)"
|
||||||
|
case "$FILE" in
|
||||||
|
*.enc)
|
||||||
|
: "${BACKUP_ENC_PASSPHRASE:?BACKUP_ENC_PASSPHRASE is required to decrypt ${FILE}}"
|
||||||
|
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:BACKUP_ENC_PASSPHRASE -in "$FILE" \
|
||||||
|
| gunzip -c | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" -v ON_ERROR_STOP=1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
gunzip -c "$FILE" | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DB" -v ON_ERROR_STOP=1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
echo "[db-restore] done."
|
||||||
43
scripts/entrypoint.sh
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Entrypoint for the `backup` sidecar. Prefers the live, bind-mounted copy of
|
||||||
|
# backup-cron.sh at /scripts (so it can be edited without a rebuild), and
|
||||||
|
# falls back to the copy baked into this image at build time if that bind
|
||||||
|
# mount is missing, empty, or stale.
|
||||||
|
#
|
||||||
|
# Why this exists: the previous entrypoint ran `/bin/sh /scripts/backup-cron.sh`
|
||||||
|
# directly. If that file wasn't there -- e.g. because the host directory
|
||||||
|
# backing the ./scripts bind mount hadn't been populated by whatever deploy
|
||||||
|
# process manages this stack -- the container failed instantly, and
|
||||||
|
# `restart: unless-stopped` retried in a tight crash loop forever: fast enough
|
||||||
|
# that the container was never "running" long enough for `docker exec` or
|
||||||
|
# Portainer's console to attach. That made the failure itself undiagnosable
|
||||||
|
# from inside the container -- you could only ever see it in the logs, and
|
||||||
|
# only by getting lucky with timing. This wrapper guarantees something always
|
||||||
|
# runs, and that the container always stays reachable, even in the worst case.
|
||||||
|
set -u
|
||||||
|
|
||||||
|
LIVE="/scripts/backup-cron.sh"
|
||||||
|
FALLBACK="/app/scripts-default/backup-cron.sh"
|
||||||
|
|
||||||
|
if [ -f "$LIVE" ]; then
|
||||||
|
echo "[entrypoint] using live scripts from /scripts (bind mount present)"
|
||||||
|
exec /bin/sh "$LIVE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[entrypoint] WARNING: $LIVE not found." >&2
|
||||||
|
echo "[entrypoint] The ./scripts bind mount is missing, empty, or stale on the host." >&2
|
||||||
|
echo "[entrypoint] Check the directory backing that mount (see docker-compose.yml)." >&2
|
||||||
|
|
||||||
|
if [ -f "$FALLBACK" ]; then
|
||||||
|
echo "[entrypoint] Falling back to the scripts baked into this image at build time." >&2
|
||||||
|
echo "[entrypoint] Backups will still run, on whatever version was current when this" >&2
|
||||||
|
echo "[entrypoint] image was last built -- not any newer live edits to ./scripts." >&2
|
||||||
|
exec /bin/sh "$FALLBACK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[entrypoint] FATAL: no backup-cron.sh in the bind mount or the image." >&2
|
||||||
|
echo "[entrypoint] Staying up (idle) instead of crash-looping, so this container" >&2
|
||||||
|
echo "[entrypoint] can still be reached via 'docker exec' / the Portainer console." >&2
|
||||||
|
while true; do
|
||||||
|
sleep 3600
|
||||||
|
done
|
||||||
@@ -10,3 +10,45 @@ DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite
|
|||||||
# Only needed for CROSS-ORIGIN local development (comma-separated). In
|
# Only needed for CROSS-ORIGIN local development (comma-separated). In
|
||||||
# production the site is same-origin via NGINX, so leave this unset.
|
# production the site is same-origin via NGINX, so leave this unset.
|
||||||
# CORS_ORIGINS=http://localhost:5500
|
# CORS_ORIGINS=http://localhost:5500
|
||||||
|
|
||||||
|
# ── Authentication ────────────────────────────────────────────────────────────
|
||||||
|
# Secret used to sign session cookies (JWTs). REQUIRED in production: if unset,
|
||||||
|
# the API falls back to a random per-process key, so logins reset on every
|
||||||
|
# restart and break across multiple gunicorn workers. Generate a strong one:
|
||||||
|
# python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
|
AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
|
||||||
|
|
||||||
|
# How long a login lasts before re-authentication (hours). Default 12.
|
||||||
|
# AUTH_SESSION_HOURS=12
|
||||||
|
|
||||||
|
# ── Email notifications (optional) ─────────────────────────────────────────────
|
||||||
|
# WP-assignment emails are OFF by default and are turned on from the Admin
|
||||||
|
# console (Notifications & email card), where the SMTP host/port/from-address
|
||||||
|
# live. The one secret that must NOT be stored in the database — the SMTP
|
||||||
|
# password — is read from this environment variable instead. Leave it unset
|
||||||
|
# until you have the SMTP details; the toggle stays effectively off (queued
|
||||||
|
# notifications are marked "skipped", nothing is sent) until both the toggle is
|
||||||
|
# on and SMTP is configured.
|
||||||
|
# SMTP_PASSWORD=your-smtp-app-password
|
||||||
|
|
||||||
|
# ── Micron asset catalog (optional) ───────────────────────────────────────────
|
||||||
|
# Backs the searchable asset picker in the work package creator. READ-ONLY: the
|
||||||
|
# app only ever runs the single SELECT in server/assets_db.py, so give it a
|
||||||
|
# db_datareader login and nothing more.
|
||||||
|
#
|
||||||
|
# Leave this unset and the suite works normally — the picker reports that no
|
||||||
|
# catalog is configured and people type asset tags in by hand.
|
||||||
|
#
|
||||||
|
# URL-encode special characters in the password (@ = %40, # = %23, / = %2F …).
|
||||||
|
# MICRON_DB_URL=mssql+pymssql://readonly_user:PASSWORD@sqlhost.example.com:1433/MicronDB
|
||||||
|
#
|
||||||
|
# To use pyodbc instead of pymssql you must also add pyodbc to requirements.txt
|
||||||
|
# and install the Microsoft ODBC driver in the image:
|
||||||
|
# MICRON_DB_URL=mssql+pyodbc://readonly_user:PASSWORD@sqlhost.example.com/MicronDB?driver=ODBC+Driver+18+for+SQL+Server
|
||||||
|
#
|
||||||
|
# Two things to check when the picker says the catalog is unreachable:
|
||||||
|
# 1. The table/column names in ASSET_QUERY (server/assets_db.py) match the real
|
||||||
|
# Micron schema — that one constant is the whole schema contract.
|
||||||
|
# 2. The api container is on the `outbound` network in docker-compose.yml. The
|
||||||
|
# `internal` network has no default gateway, which blocks the VPN as well as
|
||||||
|
# the internet.
|
||||||
|
|||||||
@@ -13,7 +13,14 @@ browser → NGINX ──serves──> static site (index.html, …)
|
|||||||
|
|
||||||
| Method | Path | Purpose |
|
| Method | Path | Purpose |
|
||||||
|--------|------|---------|
|
|--------|------|---------|
|
||||||
| GET | `/api/health` | liveness check |
|
| GET | `/api/health` | liveness check (unauthenticated) |
|
||||||
|
| POST | `/api/auth/login` | sign in (`{username, password}`) — sets the session cookie |
|
||||||
|
| POST | `/api/auth/logout` | clear the session cookie |
|
||||||
|
| GET | `/api/auth/me` | the logged-in user |
|
||||||
|
| POST | `/api/auth/password` | change your own password |
|
||||||
|
| GET | `/api/auth/users` | list accounts (**admin**) |
|
||||||
|
| POST | `/api/auth/users` | create an account (**admin**) |
|
||||||
|
| DELETE | `/api/auth/users/{id}` | delete an account (**admin**) |
|
||||||
| POST | `/api/sops` | create/update a SOP (upsert by `id`) |
|
| POST | `/api/sops` | create/update a SOP (upsert by `id`) |
|
||||||
| GET | `/api/sops` | list SOP summaries |
|
| GET | `/api/sops` | list SOP summaries |
|
||||||
| GET | `/api/sops/latest?complete=true` | most recent (complete) SOP |
|
| GET | `/api/sops/latest?complete=true` | most recent (complete) SOP |
|
||||||
@@ -33,6 +40,53 @@ fields (name, number, status, …) are promoted to columns for listing/filtering
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Login portal (user accounts)
|
||||||
|
|
||||||
|
The suite is gated by a username/password login. Sign-in issues a signed JWT
|
||||||
|
that rides in an **HttpOnly, SameSite=Lax** cookie (`wp_session`); the cookie is
|
||||||
|
marked **Secure** automatically whenever the request arrives over HTTPS (via
|
||||||
|
NGINX's `X-Forwarded-Proto`). There is no server-side session store — each
|
||||||
|
request is validated by checking the cookie's signature and expiry.
|
||||||
|
|
||||||
|
**The real security boundary is the API:** every `/api/` data route is refused
|
||||||
|
with `401` unless a valid session cookie is present (see `auth_gate` in
|
||||||
|
`app.py`). The static pages additionally include `auth-guard.js`, which redirects
|
||||||
|
to `login.html` when there's no session — that's for UX, not protection.
|
||||||
|
|
||||||
|
Passwords are stored only as **bcrypt** hashes (`server/auth.py`). Roles are
|
||||||
|
`admin` (may manage users) and `user`.
|
||||||
|
|
||||||
|
### Set the signing secret
|
||||||
|
|
||||||
|
Add `AUTH_SECRET_KEY` to `.env` (see `.env.example`). **Required in production** —
|
||||||
|
without it the API uses a random per-process key, so logins reset on restart.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create the first admin
|
||||||
|
|
||||||
|
The `/api/auth/users` endpoint needs an existing admin, so bootstrap one from a
|
||||||
|
shell (run from the **project root**, like uvicorn):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||||
|
# prompts for a password (min 8 chars)
|
||||||
|
```
|
||||||
|
|
||||||
|
In Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec api python -m server.manage_users create-admin alice --name "Alice Smith"
|
||||||
|
```
|
||||||
|
|
||||||
|
Other commands: `create <user> --role user`, `list`, `reset-password <user>`,
|
||||||
|
`disable <user>`, `enable <user>`. After that, admins can add users through the
|
||||||
|
API (or you can keep using the CLI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Local dev
|
## Local dev
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -237,14 +291,23 @@ docker compose down -v
|
|||||||
|
|
||||||
## Quick test
|
## Quick test
|
||||||
|
|
||||||
```bash
|
`/api/health` is open; data routes now require a session, so log in first and
|
||||||
curl -X POST http://127.0.0.1:8000/api/comments \
|
reuse the cookie jar:
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"type":"home_feedback","name":"Test","text":"hello"}'
|
|
||||||
|
|
||||||
curl http://127.0.0.1:8000/api/comments
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/api/health # {"ok":true} — no auth needed
|
||||||
|
|
||||||
|
# Sign in, saving the session cookie to a jar
|
||||||
|
curl -c jar.txt -X POST http://127.0.0.1:8000/api/auth/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"username":"alice","password":"<password>"}'
|
||||||
|
|
||||||
|
# Reuse the cookie on protected routes
|
||||||
|
curl -b jar.txt http://127.0.0.1:8000/api/comments
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Without the cookie, protected routes return `401 {"detail":"Not authenticated"}`.
|
||||||
|
|
||||||
Or via the nginx proxy (replace with your hostname):
|
Or via the nginx proxy (replace with your hostname):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
43
server/alembic.ini
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Alembic configuration for the Work Package Suite.
|
||||||
|
# The database URL is NOT hard-coded here — env.py pulls it from the same place
|
||||||
|
# the app does (server/db.py: POSTGRES_* / DATABASE_URL / SQLite fallback), so
|
||||||
|
# migrations always target the same database as the running app.
|
||||||
|
[alembic]
|
||||||
|
script_location = %(here)s/alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
# Use OS-native path separators on Windows dev machines.
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||