Commit Graph

36 Commits

Author SHA1 Message Date
b9d5f8ef92 T8.4 - CR-012: the delivery location is the shared vocabulary plus fifty feet
Staging is not the pain; the last fifty feet are - the correct floor lay-down,
shark cage or conduit tree instead of material picked at will by whoever is
closest. The Kitting & MIMO section gains:

- Delivery Building / Floor / Sector: the SAME dependent pickers CR-004 built,
  through the same fillLocSelect (which learned an optional field-map instead
  of being copied), reading the same project location lists, storing PATHS.
  A parallel free-text location vocabulary is exactly what CR-004 removed;
  none was added.
- A free-text detail field for the specifics ("Shark cage 7, conduit tree C"),
  persisted as delivDetail.
- deliveryLoc, the composed display string (labels off the shared lists, then
  the detail after a dash) - which is what the CR-011 email already reads
  (kitting_body preferred deliveryLoc from day one, with mimoLoc as the
  pre-CR-012 fallback) and what the package printout now carries as its own
  Delivery Location row.

Verification (each probe run alone): kitting_check.py extended to 26/26 (the
delivery selects are asserted to offer the SAME option list as the CR-004
trio, values persist as paths, the printout carries the composed value);
kitting_notify_check 17/17 now asserting the mail carries CR-012's composed
value, not the fallback. Regression: locations_check 58/58.

Items: CR-012

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 12:11:05 -07:00
829fa22236 T8.3 - CR-011: material moves, the field hears about it once
A kitting status change (detected on the upsert, which is how the browser and
the offline outbox both save) emails the package's distribution list
(distributionIds) plus its warehouse owner (kitOwnerId - CR-010's default
recipient), minus the actor, deduplicated. The mail matches the house
convention - greeting, one line of what happened, the deep link, the
automated-message footer - and says old status, new status, who, and the
delivery location (deliveryLoc when CR-012 lands at T8.4; mimoLoc today).
The link opens THAT package (X1), same wp_link as every other mail.

No burst: an unsent notification for the same package and recipient is
REWRITTEN to the newest transition instead of joined by a sibling - three
rapid changes leave one row per recipient saying where kitting ended up,
while the audit history keeps all three, uncoalesced. Found by the probe and
fixed: a row held while email was OFF stayed 'skipped' forever; the change
that finds email ON now promotes it to pending and schedules it - otherwise
turning the gate on silently orphaned everything coalesced before it.

The gate is T7.6's gate, reused - the probe greps that no second email flag
exists anywhere. Off by default; admin-only (403 for anyone else); every send
terminates at the in-process SMTP sink with count and recipients asserted; no
real mail leaves this branch. Send failures ride the shared notify.deliver
path whose failure handling qa_gate_check pins.

Verification (each probe run alone): NEW tests/kitting_notify_check.py 17/17
(the sink is imported from qa_gate_check - one sink implementation, not two).
Regression: qa_gate_check 40/40.

Items: CR-011, D10 (X1 honored)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 12:05:24 -07:00
96387105f4 T8.2 - CR-010: the warehouse owner is an account, on the package
A named person owns fulfillment of the kit - today that is Paul Coonrod,
informally, and everything bottlenecks through him. The package now records
it explicitly:

- The Warehouse owner control is a dropdown of project members (the same
  roster the Owner picker reads). Picking someone stores BOTH the display
  name (kitOwner - exports and old renderers keep working) and the account id
  (kitOwnerId - the routing CR-011's notifications will read at T8.3).
- Confirmed Aug 18: the field lives ON the work package, not the project - a
  package retargeted to a different warehouse notifies the right person
  without touching the project. The wizard gets no field.
- A stored name with no matching account - typed before the field was
  account-backed, or someone since removed from the project - is KEPT as a
  selected "(no account)" option and round-trips unchanged. Removing someone
  from the project breaks nothing.
- The dashboard filters by warehouse owner, options drawn from the owners
  actually present in the data - a filter offering people with nothing to
  fulfill is noise.

Verification (each probe run alone): kitting_check.py extended to 21/21
(T8.2 section: picker, id+name persistence, orphan survival, board filter).
Regression: generalinfo_check 49/49.

Items: CR-010

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 12:00:14 -07:00
79c4a36c70 T8.1 - CR-009: kitting statuses are a set, and Micron EUV is not kitting
The statuses: the proposed five, adopted as proposed - Not Started, Picking,
Staged, In Transit, Delivered - as one named constant (KIT_STATUSES) building
the select. They describe fulfillment; the old four ('Open', 'In Progress',
'Kitted', 'Delivered') mixed fulfillment with workflow. A value stored before
the set existed is kept, selected, and shown as "(legacy)" - CR-016's rule
that renamed vocabularies must not orphan recorded data - and round-trips
through collect unchanged.

Micron EUV: the sample SOP is the Micron configuration on record (CR-016 /
T5.7), and it now names kitting:false beside assets:false - off by CR-006
TOGGLE, in both the creator sample and the wizard sample. The section leaves
the form, the rail and the export; its data and model stay exactly where they
are (the probe loads the example package under the Micron sample and finds
its kitting values intact through collect). Any other SOP turns the section
on and it works fully - driven against sopA with everything enabled.

sections_check re-pointed, not relaxed: its sample-map pin said "naming only
assets"; it now says "naming exactly assets (CR-016) and kitting (CR-009)" -
still refusing any section that goes off without a recorded item behind it.

Verification (each probe run alone): NEW tests/kitting_check.py 14/14.
Regression: sections_check 95/95.

Items: CR-009

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 11:55:39 -07:00
7f712b7e00 T7.9 - S1 (creator): errors at the field, and the last 40 native dialogs gone
wp-creation-app.js:1144 said "Subject and WP Type are required" in an alert()
on a form ten cards deep, naming nothing, focusing nothing. The creator
carried 40 native call sites in all (43 at the wave 0 count; three had
already left with D5 and T5.8's wizard work).

Inline validation, the T5.8 wizard pattern applied to the creator:
- WP_REQUIRED is one table: field id, owning section, label. The error box,
  aria-describedby, aria-invalid and the role=alert announcement all follow
  from a row. The conditional IFF rule folded in beside them.
- Submit marks every failing field, marks the rail entry of each section
  holding one (a "!" chip - a character, not only a colour), switches to the
  section of the FIRST error, scrolls to and focuses the field, and announces
  the failure through the role=alert toast.

One modal replaced confirm() and prompt(): promise-based wpConfirmDialog()/
wpPromptDialog() with an optional input whose validation renders AT the input
(a bad answer keeps the dialog open and says why - no round-trip through a
second dialog). Escape cancels; callers read like the natives they replaced,
awaited. Pure notifications became role-differentiated toasts. The modal
validation errors for the hold log and the QA rejection render inline in
their own modals.

The A1 path: confirmEarlyRelease() keeps its name and contract - truthy means
proceed with the reason recorded - and became async; every caller awaits it
(status control, hold release, urgent override, save).

App-wide native dialog count, recorded per the done-when: the probe prints it
against the wave 0 baseline of 79 and asserts the creator contributes 0. The
probe also replaces the natives with throwing stubs for the whole run, so any
path that still reached one would fail loudly.

hold_check re-pointed, not relaxed: three flows it drove through native
stubs now drive the modal - same propositions (the release-ready offer, the
named-constraints override prompt, the hard block), new surface.

Verification (each probe run alone): NEW tests/creator_dialogs_check.py
20/20. Regressions: hold_check 50/50 (re-pointed), warning_check 17/17,
qa_gate_check 40/40, triage_check 16/16, files_check 36/36, frame_check
39/39, generalinfo_check 49/49, form_structure_check 50/51 (the standing F6
height check - see the wave exit).

Items: S1 (creator half)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 11:46:25 -07:00
82a8f30074 T7.10 - D5: one analytics implementation, and its report on the admin console
Usage analytics existed as five of the nine colliding globals creator-frame.md
counted (ANALYTICS_KEY, analyticsLoad, analyticsSave, downloadAnalytics,
showAnalytics), twice - and the wizard's copy had no caller, because the
button lived on the creator. The admin console had a THIRD private reader
(usageLoad/downloadUsage) that only saw the wizard's key.

Now: ONE core, html/wp-usage.js (window.WPUsage: load/save/track/download +
the two pre-move storage keys, verbatim). The creator and wizard keep only a
thin track() wrapper - page state like the creator's dev-mode pause belongs
to the page - and record exactly what they recorded before, under the same
keys, so everything captured before this task still reads (probe plants a
legacy-format event and finds it in the report). The "Usage data" button left
the creator toolbar; the report lives in admin.html's usage card, covering
BOTH tools with a download each, behind the same admin gate as the rest of
the console (a non-admin sees the denied card and nothing else), usable at
390px.

Two probes re-pointed, both with the reason in the code:
- cards_check pinned admin.js byte-identical to HEAD - right for T6.5, but as
  a standing probe it would fail every legitimate later edit; D5 targets
  admin.js by name. A7's localization is protected by the feature checks and
  the end-to-end drive, plus a wiring assertion on the block itself.
- frame_check listed "Usage data" among the toolbar buttons that must be
  visible; it now asserts the button is GONE, so the duplicate cannot quietly
  return.

Verification (each probe run alone): NEW tests/usage_check.py 15/15 (grep
half: WPUsage defined once, no page touches the keys directly, none of the
five globals survives anywhere). Regressions: cards_check ALL PASS,
frame_check 39/39.

Items: D5

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 11:29:27 -07:00
e3de3c7c00 T7.8 - B6: the wizard's actions ride the viewport, not the page
On the Constraints and Sequence steps the proposal's beside-the-fields actions
meant scrolling to save. The wizard's .step-navigation bar is now
position:sticky at the viewport bottom - the creator's sticky-bar pattern,
adapted rather than duplicated: sticky (not the creator's fixed) because the
bar lives inside the wizard's grid column, keeps its slot in the flow, and
therefore CANNOT obscure a field at any width - no padding arithmetic to get
wrong. Opaque background, top border and the shared --wp-shadow-sticky token
so content scrolling beneath it reads as beneath it.

The T4.4/B5 save-state indicator already mounted in this bar; it now rides the
viewport with the buttons, which is the "shows the save state" criterion.

Verification (each probe run alone): NEW tests/sticky_bar_check.py 12/12 - a
primary action inside the viewport on all 12 steps unscrolled at a 700px
viewport (short on purpose: both named steps genuinely overflow, asserted),
still visible fully scrolled, nothing obscured at 390px. Regression:
stepper_check ALL PASS.

Items: B6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 11:14:33 -07:00
c084f730b3 T7.7 - CR-007/D8: the sheet travels with the package, and opens offline
The field wants the specific PDF attached, not a link to a Bluebeam session.

Storage: a new wp_files table (Alembic f3a9d2c1e8b7, additive only) holding
the BYTES in the same database as everything else - the Aug 18 decision: a
backup that excludes the drawings is a backup you cannot restore from. The
D8 numbers bound the cost and are enforced ON THE SERVER as well as in the
browser: 5MB a file (413, naming the limit), PDF and image mimes only (400,
naming what is accepted), 2GB a project (413 naming the ceiling; response
flags the 80% warning). The ceiling is env-overridable for tests; the shipped
default is the decision, asserted from source.

The package record carries a server-owned meta mirror (data.files): the
upload/patch/delete routes rewrite it, and the upsert re-asserts the stored
copy over whatever a client sends - a save from a browser that had not seen
an upload land cannot erase the list.

Creator: uploads live beside the links (links still work), the limits and the
running project total sit ABOVE the picker (amber from 80%, red at full), a
refused file costs nothing but a toast and never leaves the browser (the
probe counts fetch calls), and each drawing has a description ("Tray section,
Level 3 east only") editable inline and persisted server-side. Uploads attach
to the saved record, so T4.3's autosave keeps the surrounding form safe (X8).

Export: uploads print with the package - name, size tag, description on the
attachments table, images inline as the sheet itself, PDFs as links.

Offline (D8): the service worker gains a drawings cache (cache-first on
/api/files/), and field.js prefetches ONLY the requesting user's assigned
packages - assignment-scoped by decision, not project-wide. The probe's first
offline check used CDP network emulation and PASSED FOR THE WRONG REASON: the
emulation binds to the page's session and the service worker fetches on its
own target, straight past it. The shipped check kills the server instead -
my drawing opens, the other package's does not, against a genuinely dead
network.

Field View: a Drawings section on the package detail, 44px rows, description
inline, inside the 390px screen.

Verification (each probe run alone): NEW tests/files_check.py 36/36; the
Alembic chain applied end-to-end to a scratch DB and the table verified.
Regressions: form_structure_check 50/51 (the standing F6 height gap),
frame_check 39/39.

Items: CR-007, D8 (X8 honored)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 11:09:35 -07:00
2486f87010 T7.6 - CR-014/D2/D9/D10: the Ready for QA gate, notification only, shipped off
Marlena's ask: QA sees inbound work ahead of time, not after the fact.

The rung: 'Ready for QA' sits between In Progress and QC in BOTH ladders
(wp-creation-app.js STATUS_ORDER, server/app.py STATUS_ORDER) and in Field
View's list - inside the T7.3 transition model, not beside it: entering it
from an unreleased state crosses the release gates, and 'Issue' (hold) stays
a branch. The dashboard filter and the navigator grouping learned the state
from the ladder without their own edits.

Who hears (D2): the QA GROUP, a multi-pick of project members on the SOP
wizard's team step, stored as account ids at data.sop.project.qaGroupIds.
Entering Ready for QA emails that list and nobody else. A rejection emails
the owner AND the same list (amended answer), returns the package to In
Progress, and REQUIRES a fresh comment - server-enforced on both write paths
(the first version accepted any old comment already on the record, which made
every rejection after the first one free; the gate now demands a new entry).
Accept and reject are real buttons on the release banner; the comment modal
enforces its field; qa_ready / qa_rejected / status_changed all land in the
audit history.

The link (X1): wp_link() now opens THE package - wp-creation-index.html
?project&wp=<id>, which the creator boots directly and login.html?next=
round-trips for a signed-out recipient. It previously pointed at the suite
root, which is exactly the failure X1 names; assignment mail inherits the fix.

Email discipline (D10 + standing rules): ships OFF (the stored setting the
admin console already owns; PUT /api/settings is admin-only, 403 for anyone
else, and audited). With it off, transitions write outbox rows marked
'skipped' and the sink receives nothing. With it on, the probe runs a REAL
SMTP conversation against an in-process capture sink and asserts the count
and the exact recipient set. A dead SMTP host leaves a 'failed' outbox row
with the error recorded. The SMTP password exists only in the environment.

DEVIATION, stated: the task's Do-paragraph asks the email to include location
and a scope summary; the done-when list (and CLAUDE.md) says no customer IP in
a message body. The done-when wins: bodies carry the WP number, who moved it,
and the deep link. A location canary planted on the package is asserted absent
from every captured message. If the fuller body is wanted, that is a product
call - needs Nick.

Found while building, logged not fixed (BL-021): project_sop_team() reads
sop.data['project'], a path pushSOP never writes - the critical-reopen email
has never actually reached the PM/CM. One-line fix, owned by T9.9.

Field View (D9): 'Ready for QA' is carried by TEXT on the card at 390px.

Verification (each probe run alone): NEW tests/qa_gate_check.py 40/40.
Regressions: hold_check 50/50, pipeline_check 44/44, aggregates_check 16/16,
frame_check 39/39, validation_check 83/83.

Items: CR-014, D2, D9, D10 (X1, X3 honored)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:54:07 -07:00
5d27a1e086 T7.5 - A6: the sidebar answers the stand-up question
Use case from the task: someone is asked in a stand-up why a package has not
moved; they open it on a phone and need the answer without scrolling or
clicking. The navigator row now carries:

- a triage line: status - priority - due date - P6 activity, with an em dash
  for anything unset (a placeholder is information; a gap is a question)
- the open-constraint count (already in the state chip; on a held row it moves
  into the hold line so it is never displaced by "on hold")
- the hold reason INLINE, from the newest live entry in data.holds - the modal
  captured it at T7.3, so this is display work, exactly as the task said. A
  held package with no recorded entry (legacy data) says "no reason recorded -
  log it from the status control" rather than rendering an empty red slot.

The row's title attribute keeps its hover summary, but hover stops being the
only path to any of this (C1 - Field View runs on tablets). Triage and reason
lines WRAP instead of ellipsizing - an ellipsis would hide exactly the data
the row exists to show; the reason clamps at three lines so one essay cannot
swallow the panel. Rows align flex-start to take the extra height.

At 390px the panel is the existing overlay drawer; the row fits it with no
sideways overflow and stays a >= 44px tap target.

Verification (each probe run alone): NEW tests/triage_check.py 16/16 covering
the held/plain/legacy row matrix at 1440px and 390px. Regression:
frame_check 39/39.

Items: A6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:28:02 -07:00
60434d452c T7.4 - A2: one warning, said once, visible from anywhere
The same not-release-ready warning rendered three times on the creator:
1. the release banner under the context bar   - STAYS, and is now the only one
2. updateStickyStatus() in the sticky save bar - removed
3. a static field-hint under the status radios - removed

The count moved to a badge on the Constraints rail entry (D3's rail replaced
the tabs A2's "tab count badge" referred to). The rail is position:sticky at
BOTH widths, so the badge is on screen from any section at 390px and 1440px -
measured with the constraint table AND the banner both scrolled out of view.
The badge is a number, not a colour: the count is the content, and the rail
entry carries an aria-label saying it ("Constraints - 3 open").

The banner is now role="status" (the login.html aria-live pattern, per C1) and
only rewrites when its message actually changes - a live region that repaints
on every save announces on every save.

Duplicate 2 was not just noise. It wrote the warning with textContent into
the SAME span the B5 autosave indicator mounts into, destroying the indicator
on every count change. Removing the duplicate is what fixes that; the probe
pins the indicator's survival across banner updates.

Verification (each probe run alone): NEW tests/warning_check.py 17/17.
Regressions: hold_check 50/50, form_structure_check 50/51 (the standing F6
height gap, re-measured after T7.5 as recorded at T7.2).

Items: A2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:20:12 -07:00
2b597e68d8 T7.3 - CR-015/A1/D4: the hold clears when the constraints do
ROOT CAUSE, exactly (the done-when asks for it):
Hold state was stored, twice, and derived nowhere.
1) Client: submitHold() wrote prevStatus='Issue', destroying the status the
   hold interrupted at the moment it was placed - there was never anything to
   return to. Clearing the last constraint then fell into the "Mark it as
   Issued now?" confirm, because STATUS_ORDER.indexOf('Issue') is -1 and -1
   reads as "before Issued". Decline it and the package stayed on hold with
   zero open constraints, forever - the exact state reproduced live in front
   of the Micron team.
2) Server: server/app.py's STATUS_ORDER put "Issue" at index 4, so
   _released('Issue') was true and every transition OUT of hold skipped
   enforce_release_gates() as "already released". POST /api/wps/{id}/status
   could walk a held package to Issued past its open constraint. The comment
   claimed the ladder was "mirrored in the front end"; the front end's ladder
   has no 'Issue' in it at all.

What changed:
- setConstraint() recalculates hold state on EVERY constraint change: clearing
  the last open constraint on a held package releases it immediately - no
  refresh, no dialog - back to the status recorded on the hold entry (`from`),
  which now rides on data.holds and survives save/reload.
- Every hold and release is history: pkgHolds entries carry ts, by, from/to,
  reason; the exported Hold Log gained a By column; the server writes
  hold_logged / hold_released audit rows (with the reason from data.holds) on
  both the upsert and the /status endpoint.
- _released() no longer counts the hold: 'Issue' is a branch, not a rung.
  Leaving hold to a field state re-runs the gates; entering hold never did and
  still does not. The critical-reopen email keeps its old reach ("has been in
  the field" includes on-hold).
- A1 preserved by name and by test: confirmEarlyRelease() still the one place
  a gate override is written (comment-stripped grep asserts exactly one
  pkgGateOverride assignment), still reason-first, still logged server-side.

D4 - what Urgent does (amended Aug 18): surface the audited path, add no new
one. confirmEarlyRelease() now also covers open constraints, but only for an
Urgent package, and the override must NAME every constraint it crosses - the
server refuses coverage by an old reason. The release banner gives an Urgent
package the override as its primary action (a real <button>); Normal and High
see nothing new and keep the same hard refusal, asserted per priority.
Banner button styled from tokens only; the banner now wraps at narrow widths.

Product question raised, not decided (per CLAUDE.md "asking versus assuming"):
Issue (hold) remains selectable from Draft and Scheduled, as it was before.
The done-when names no state list, so nothing was restricted. If a pre-release
hold is meaningless, closing it off is a one-line follow-up - needs Nick.

Verification (each probe run alone): NEW tests/hold_check.py 50/50, including
the clear-last-constraint regression specifically, the D4 priority matrix
against the server (six 409/200 cases), hold_logged/hold_released audit rows,
and an AST sweep proving every wp.status assignment in server/app.py sits in
a function that runs enforce_release_gates. Regressions: frame_check 39/39,
aggregates_check 16/16.

Items: CR-015, A1, D4 (X2 correction already recorded Aug 18)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:05:57 -07:00
755c976841 T7.2 - F6/D3: the form gets structure - a section rail, one section open
F6 as amended by D3 (Aug 18): one page, persistent side navigation, sections
collapsible, only the current one open by default, plus Expand all. Tabs were
rejected in D3 because they hide sections a first-time author does not know
exist.

What changed:
- The jump-chip strip (#section-nav, span onclick) is gone. In its place a
  <nav> section rail of real <button> entries, aria-current on the current
  section, 44px tap targets, above the form at 390px and beside it at 1440px.
- Every section heading is now a disclosure <button> with aria-expanded and
  aria-controls. One section open at rest; Expand all (aria-pressed) opens
  everything and is remembered per browser.
- Sections are URL-addressable (?section=, T4.2 machinery) and a deep link to
  a collapsed section expands it. Positional-id fallback removed: a card
  without an id gets a console.error and no rail entry, never an invented
  sec-N id that would ride into shareable URLs and move between visits.
- General Information (1,288px on its own) split into #general-card and
  #assign-card (Assignment & Schedule). The split is presentational: both
  cards are the ONE CR-006 section `general` (WP_SECTION_NODES lists both),
  so wp-sections.js and the SOP wizard are untouched. CR-001's adjacency
  (P6 activity beside due date) is preserved and asserted.
- gotoSection() flushes autosave, which the deleted chips used to do.
- secMakeToggle() preserves every element child of a heading - help tips go
  outside the button, everything else inside the label. The first version
  cleared textContent and destroyed #saved-count, which killed boot one line
  short of wpCreatorReady with the page still visibly rendered.
- BL-013 folded in per the task: the T3.4 focus ring on the rebuilt form.
  frame_check reports outline solid 2px on creator inputs.

Height, measured not asserted: 5,399px before; 1,995px at rest at 1440x900.

DONE-WHEN NOT FULLY MET - stated per CLAUDE.md rather than marked complete:
"no single view exceeds roughly two screen heights at rest" reads 2.22
screens (1995/900). The remaining gap is page chrome this wave reworks:
.ctx-bar (67px, T7.4) and .release-banner (45px, T7.5). The criterion was
already amended once (D3, "at rest") and is not being moved again to fit;
tests/form_structure_check.py keeps the check red and it is re-measured at
the end of wave 7. Every other done-when entry passes.

Backlog: BL-001's cause corrected a third time - at rest the overflow is
help.js's .help-tip::after tooltip (481 vs 390), the S8 component T9.5
rebuilds; the tables still overflow only when expanded. Deliberately not
fixed here - a fix would be thrown away with the component at T9.5.

Verification (each probe run alone): form_structure_check 50/51 (the height
check above), sections_check 95/95, generalinfo_check 49/49, frame_check
39/39 regression pass.

Items: F6, D3, BL-013, BL-001 (re-measured)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 09:38:31 -07:00
12d19446d5 T7.1 - B7: dissolve the creator iframe, and D1 give it back its sample data
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 SOP wizard; the two tabs that
used to swap a frame are links between them.

DEVIATION, stated rather than smuggled. The wave file says "remove the iframe
boundary so the creator renders in the parent document". It renders as its own
document instead. Every done-when is met - no iframe, no cross-frame messaging,
F4 resolved structurally, CR-006 toggles with no special-casing, back and
forward intact with T4.2's URL state - but the route is the other one, and the
reason is in creator-frame.md's own numbers:

                                    merge into parent    make it a page
  selector collisions to resolve                   21                 0
  script global collisions                          9                 0
  cross-frame call sites to remove                 28                28
  probe entry points needing rework               ~29                 2

The 21 and the 9 were never the cost of dissolving the boundary. They are the
cost of MERGING TWO DOCUMENTS, which is a different change the boundary was
hiding. And 29 probe call sites address wp-creation-index.html directly, so a
route that keeps that address keeps all of them. creator-frame.md section 5
records this in full.

What went, and what replaced it:

  #wp-frame, applyEmbedLayout, sizeWPFrame, viewportMinusChrome, chromeHeight,
  renderWPTab, the resize handler, the ResizeObserver, --wp-chrome-h,
  .content-area.embed-full, body.embed-full   ->  the window sizes the page

  ?embedded=1, body.embedded, .embed-hide, .embed-first   ->  nothing. An old
  link carrying the param is ignored rather than half-obeyed.

  openWpById / showDashboard / showForm / dashApplyFlag / applySopSections
  called across the frame   ->  the URL. ?project= ?view= ?wp= ?flag= were
  already read at the creator's own boot (T4.2), which is exactly why those four
  could be DELETED rather than migrated. X4 is closed: the surviving path is the
  one T5.5 built and proved.

  inIframe in auth-guard.js, wp-chrome.js, wp-sidenav.js, help.js and _isTop in
  project-data.js   ->  gone. help.js now reads the explicit WP_HELP_NO_FAB flag
  both tool pages set, instead of inferring intent from where it is rendered.

  .main-nav / .nav-tab in work-package-suite-styles.css   ->  wp-chrome.css,
  because a tab row only one of two documents can style is the shape that put
  the tabs in the parent and the toolbar in the child to begin with.

The three questions creator-frame.md section 4 said no count could answer:

  1. The creator gets the app bar. It was the only page loading neither
     wp-chrome file. Its header is now the .header-left / .header-right pair the
     wizard uses, so the switcher lands in the same place on both.
  2. Two sequence components, scoped not merged - confirmed Aug 18 that the
     sequence is authored in the SOP and adjustable per package. BL-015 stays.
  3. body.embedded is gone. The header it hid is replaced by the app bar; the
     sample controls are visible in a new package toolbar (D1); the analytics
     button is visible there until T7.10 moves it. The Dashboard BUTTON in that
     row became a TAB, which is the one place B7's "fold the toolbar into the
     tab row" actually happened.

Old addresses still resolve. ?tab=wp, ?view=dashboard and ?wp=<id> are in
bookmarks, in wp-sidenav's link map, and they are the shape CR-011 and CR-014
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. frame_check.py section 4 pins
all three.

BEHAVIOUR CHANGE, deliberate. The live cross-frame hand-off showed the creator a
section 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
5b pins both halves - an unsaved toggle does not travel, a saved one does.

BEHAVIOUR CHANGE, not deliberate, logged as BL-020. 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
product decision with its own downside. Logged, not quietly handled here.

tests/frame_check.py, 39 checks, new. Two of them exist because of failures
during this task rather than in it:

  - "both documents parse and boot". A const shadowing a function parameter is a
    SyntaxError, and work-package-suite-app.js did not parse at all for one run.
    Four checks in url_state_check went red and not one said "the script did not
    load". Asserting a page's own entry points exist costs nothing.
  - "focus emulation is on, so a focus reading means something". An earlier draft
    called page.call instead of page.ws.call inside a try/except and measured
    nothing, reporting no focus ring anywhere - which looks exactly like a
    finding. Trap 5 in reverse, for the second time in this project.

The four backlog entries logged against this file, re-measured rather than
assumed:

  BL-001  still reproduces (485px in a 390px viewport) but its RECORDED CAUSE IS
          WRONG. --nav-w now computes to 56px, so the injected-style explanation
          is spent. The overflow is the creator's data tables - #asset-body's
          lays out at 520px with no scroll container. frame_check reports the
          offending boxes by selector and skips position:fixed subtrees, because
          the comments drawer parked off-screen at right:844 made the first
          measurement blame the drawer. Pinned, not fixed: T7.2 lays out the form.
  BL-013  CLOSED. It was fixed by S12 in WAVE 4 - wp-creation-styles.css:209
          carries the comment naming this entry - and nobody updated it. It was
          quoted as a live CLAUDE.md violation while planning wave 7 and had not
          been true for four waves. a11y_check walks 120 focusable elements on
          the creator and every one rings at >= 3:1.
  BL-006  15 by the probe's measure, unchanged; different denominator, stated.
  BL-007  68 raw radii by the probe's measure. Nothing has reduced it in four
          waves; it is measured every run now instead of once.
  BL-018  cost a FOURTH probe. frame_check imports set_sop from sections_check
          rather than writing a fifth copy of the workaround. T9.9 owns it.

Probes re-pointed, with reasons in the files: sections_check 5b (drove the live
hand-off), pipeline_check check 2 (read through contentDocument), f_items F4
(drove standalone and embedded; there is one mode now), validation_check
(lost "the wrong tab", gained the SOP gate).

Verified: frame_check 39/39, sections_check 95/95, pipeline_check 44/44,
url_state_check 23/23, validation_check 83/83, a11y_check 22/22,
autosave_check 34/34, aggregates_check 16/16, stepper_check 71/71,
browser_check 71/71, launcher_check 58/58, generalinfo_check 49/49,
rollup_check 63/63, cards_check 44/44, locations_check 58/58.
f_items: F1-F5 fixed, F6 reproduces (T7.2).
Metrics: iframes 1 -> 0, colour literals in rules outside theme-light.css 0,
dialogs 64, <div onclick> 2, .help-tip 18.

Items: B7 D1
Task: T7.1

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:26:27 -05:00
815f266039 T6.5 - A7: cards say their state, the footer says what it is showing
A7's "do not" is louder than its "do", so that first: admin.js:484-517 handles
language and time, it is a shipped feature, the review specifically endorsed
keeping it, and if the proposal reads as removing it that reading is wrong. It
is UNTOUCHED. The probe checks that by diffing the file against HEAD as well as
by driving the feature — switching locale, saving, reloading, and confirming the
saved value came back from the server.

Card status lines

  Two things were doing one job badly. The SOP card said "SOP complete" when it
  was and NOTHING when it was not — so the commonest state on a live project was
  the one with no status line at all. And the Work Package card carried its state
  in its BUTTON ("Complete SOP first", "Checking..."), which is a button
  describing a situation instead of naming what pressing it does.

  Now every card says its state in its own line, in all three states, and no
  button changes text to report one:

    complete    green, the canonical success token
    not yet     secondary text - a real answer, and neither green nor a warning
    unknown     the suite's amber, and it names the failure

  Each carries a glyph and a word as well as a colour. The line is replaced in
  place rather than removed and re-added, because a card that briefly has no
  status line reads as "no status" and that is one of the three real answers.
  role="status" on it: the text is written by a fetch that lands after the page
  has settled, which is what aria-live is for (S10).

Footer

  Was "Work Package Suite v1.0 | Prime Controls - Business Technology Group |
  Pilot Use Only", which leaves three questions open: v1.0 of what, who Business
  Technology Group is to this page, and what Pilot Use Only actually restricts.

  Now two sentences. The first names the product and who maintains it. The
  second says what "pilot" means in the only terms that matter to somebody about
  to type a real work package into it: the work is real and is kept, the tools
  around it are still changing. The bare version string is gone rather than left
  claiming to be a version of something unspecified.

  html/index.html          three-state card status, the footer
  tests/cards_check.py     new - 44 checks
  tests/aggregates_check.py  two assertions re-pointed (see below)

Done when
  [x] card status lines read clearly and use the canonical status colours
  [x] the footer is unambiguous about what it is showing
  [x] localization still functions - verified by switching language, saving,
      reloading and reading the value back off the server
  [x] admin.js:484-517 behaviour is unchanged - and the file is byte-identical

Two probes needed re-pointing, and both were asserting wording rather than
behaviour

  aggregates_check waited for the card's status line to be non-empty and then
  matched the phrase "SOP complete". The wait is now wrong for a second reason:
  the line is non-empty from the moment the page loads, because it says
  "Checking the SOP...". It waits for the answer instead, and matches the
  ANSWER rather than the sentence - which is what that check was ever about,
  since it exists to prove the answer came from the server and not the cache.

  A probe that breaks when wording changes is a probe that will be edited
  carelessly the next time wording changes. Both are now written so that only a
  behaviour change can fail them.

Verified one at a time
  cards_check    44/44  new
  aggregates     16/16  (two assertions re-pointed)
  browser_check  71/71
  a11y           22/22
  launcher       58/58
  f_items        F1-F5 FIXED, F6 REPRODUCES (T7.2)

No colour literal added: the three status colours are --cds-support-success,
--cds-text-secondary and --wp-status-warning-text, all already in
theme-light.css.

Question for the PR, per CLAUDE.md: the footer now says work packages created in
the pilot are kept. That is true of the database and it is the thing people
actually want to know, but it is a promise, and whoever owns the pilot should
confirm it is one we are making.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 00:24:27 -05:00
4d186ea6a0 T6.3/T6.4 - CR-004 and CR-018: picked not typed, and totals that add up
CR-004 and CR-018 are the same change seen from two ends. CR-018 is why the
Acumatica cost code came out rather than being relabelled — the tracking
dimension the team wants is floor and area, not an accounting code — and CR-004
is what makes that dimension exist. Committed together because a rollup keyed on
free text is not a rollup, and structured location with nothing rolling up by it
is a form change nobody asked for.

CR-004 - three dependent dropdowns

  Building filters Floor filters Sector, off the project's own taxonomy from
  T5.4. Clearing a parent clears its children: not doing that is how a package
  ends up filed under a floor that is not in the building it claims.

  PATHS are stored, not names and not bare codes. A floor's own code is not
  unique across buildings; `B-ONE/L1` is. That is what lets the dashboard filter
  by a building and match everything beneath it with a prefix test, and it is
  what CR-018 groups on.

  The list is fetched with include_inactive=true, which is not a contradiction of
  CR-005's "deactivating hides it from new work packages" — they are two
  questions. What may be CHOSEN is active only. What may be SHOWN is everything,
  because a package already referencing a deactivated value still has to render
  its label, and blanking it on open would write the blank back on the next save.
  A deactivated value that IS on the package is offered, labelled "(no longer
  offered)"; on a fresh package it is not offered at all. Both checked.

  X5, checked the way aggregates_check checks its own: localStorage is poisoned
  with a fake building and the dropdown is required to ignore it.

  wp_location survives as a hidden field. A package written before this keeps
  what it said, and the form says so rather than dropping it.

CR-018 - the rollup

  LOCATION_DIMENSIONS is now ("building", "floor", "sector"). T4.1's note said
  "only this tuple and the keys inside each group change - the response shape
  does not", and that held exactly.

  Rolled up at EVERY level, server-side, not just at the leaf. "How many on
  floor 2" is the question CR-018 asks and it is a level above the leaf groups;
  summing them in the browser would be the same per-browser arithmetic B4
  removed. Actual Hours rolls up along the same dimensions - that is the field
  CR-017 retained, and this is why that decision mattered.

  Packages with no location are an explicit "(unassigned)" row, not a gap. The
  reason is arithmetic: a group set that silently omits them does not add up to
  the project total, and a rollup that does not reconcile is decoration. The
  probe checks every level sums to the project total, and to the estimated and
  actual hour totals, using distinct primes so a mis-sum cannot land on the
  right number by luck.

  A package with a building but no floor lands in the floor-level unassigned row
  alongside the one with no location at all - which is the honest answer, and is
  asserted by its hours rather than by its count.

  Free text captured before CR-004 groups under itself as a building rather than
  collapsing into unassigned, one level deep. Pretending free text is a
  hierarchy would file "FAB / LVL 1" under a building called "FAB / LVL 1".

  server/app.py                 dimensions, _location_levels, hours per group
  html/wp-creation-index.html   three selects where the text box was
  html/wp-creation-app.js       the pickers, the filters, the rollup panel
  html/wp-creation-styles.css   .loc-picker, .loc-rollup
  tests/rollup_check.py         new - 63 checks

Done when — CR-004
  [x] all three render as dropdowns populated from project configuration
  [x] dependent filtering works, and clearing a parent clears its children
  [x] values persist as codes; confirmed by reading what collectPackage stored
  [x] the dashboard filters by each of the three
  [x] a work package referencing a deactivated value still renders correctly
  [x] all option data comes from the server - proved by poisoning the cache

Done when — CR-018
  [x] the dashboard groups and totals by Building, Floor and Sector
  [x] totals reconcile against an unfiltered count, at every level
  [x] Actual Hours rolls up along the same dimensions
  [x] grouping is computed server-side - proved by putting nine fake packages in
      localStorage and requiring the panel to show none of them
  [x] work packages with no location appear in an explicit unassigned group

No migration: location lives in the work package's JSON data blob like every
other per-package field. No colour literal added.

Verified one at a time
  rollup_check      63/63  new
  generalinfo_check 49/49
  browser_check     71/71
  pipeline          43/43
  a11y              22/22
  aggregates        16/16
  f_items           F1-F5 FIXED, F6 REPRODUCES (T7.2)

Question for the PR, per CLAUDE.md: the dashboard's location filters and the
rollup both key on the path, so a package saved with free text and no codes is
unreachable by any location filter and sits in its own building-level row. That
is correct and it is also a migration question - whether the existing free-text
locations should be mapped onto the taxonomy once the B100 list arrives, or left
as history. Nothing here decides it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 00:24:00 -05:00
ce2d008897 T6.1/T6.2 - CR-001 and CR-003: the schedule driver and the urgency
Two fields and one board column mechanism, committed together because the second
is only there for the first: the board had NO sorting at all, CR-001 asks for a
sortable column, and CR-003 asks for another. Building the mechanism twice, or
building it once and pretending the second task got it free, are both worse than
saying so.

CR-001 - P6 activity ID and description

  Every work package traces back to the schedule activity that drives it, so a
  date on a package is anchored rather than floating. Placed beside the due
  date, which is where the meeting put it and the reason it is there.

  Free text. A validated lookup against an imported activity list is deferred
  (BL-000a) partly because the Micron schedule is being reworked - importing it
  now would import churn.

  Both fields live inside General Information, so CR-006's toggle governs them
  without any further wiring. The probe checks that by turning the section off
  and reading the rendered document, rather than by asserting they are in the
  right <div>.

CR-003 - Priority

  Three levels, agreed live in the meeting, and no fourth. Normal is the
  baseline default, and a package saved before today reads as Normal rather than
  blank - blank would sort and filter as an invisible fourth level.

  Sorted by ESCALATION, not alphabetically. High/Normal/Urgent would put the
  most urgent last, which is the one thing the column exists to prevent. The
  probe asserts the order AND that it is not the sorted order.

  Colour is never the only signal. The label is always rendered; the three
  differ by fill as well as by hue (outline / amber / red). Every value is a
  canonical token - X7's warning is that without one source of truth for colour,
  Normal/High/Urgent gets four implementations. 0 colour literals in the
  creator's stylesheet, asserted rather than assumed.

  Independent of status: the probe changes priority and checks the status radio
  did not move, then checks collectPackage reports the new priority with the old
  status.

Sorting, and what "including with empty values" had to decide

  EMPTIES LAST, in both directions. Ascending by P6 activity means "the ones
  with an activity, in order, then the ones without", because nobody sorts by a
  column in order to look at the rows that have nothing in it. Reversing the
  direction reverses the filled rows and leaves the blanks where they are. The
  probe checks both directions and that no row is lost either way.

  A non-numeric value in a numeric column is neither empty nor a number; it
  sorts after the numbers rather than as NaN, which compares false against
  everything and leaves the order undefined.

  Every sortable header is a real <button> inside its <th>, so it is in the tab
  order and Enter/Space work without being wired up. The direction is exposed
  through aria-sort on the th as well as drawn as an arrow, and the sorted
  column is bold - three channels (C1). Gates and the actions column are not
  sortable and therefore are not offered as buttons.

  html/wp-creation-index.html   two P6 fields, the priority select
  html/wp-creation-app.js       DASH_COLUMNS, dashSortRows, dashHeaderCells,
                                WP_PRIORITIES, wpPriorityOf, priorityPill
  html/wp-creation-styles.css   .dash-sort, .prio
  tests/generalinfo_check.py    new - 49 checks

Done when — CR-001
  [x] both fields exist, persist, and survive a reload (saved, reloaded, reopened)
  [x] Activity ID renders next to Due Date on the detail view
  [x] the column sorts correctly, including with empty values
  [x] both fields appear on the PDF export
  [x] the fields respect the CR-006 section toggles

Done when — CR-003
  [x] exactly three values; Normal is the default on a new work package
  [x] the dashboard filters and sorts by priority
  [x] priority colours come from canonical tokens; no raw hex added
  [x] colour is not the only signal - the label is always present
  [x] priority prints on the PDF export
  [x] changing priority does not alter status

No migration. Both fields live in the work package's JSON data blob, which is
where every other per-package field lives; nothing in server/models.py changed.

Verified one at a time
  generalinfo_check 49/49  new
  browser_check     71/71
  sections_check    88/88
  a11y              22/22
  pipeline          43/43
  url_state         23/23
  aggregates        16/16
  f_items           F1-F5 FIXED, F6 REPRODUCES (T7.2)

One note on running these: two of the runs above aborted with "browser would not
start after 3 attempts". That is the documented back-to-back port exhaustion,
not a code fault - both passed after a pause. The brief warns about it and it is
real.

Question for the PR, per CLAUDE.md: priority has no effect on anything yet - it
does not sort the board by default, does not affect release readiness, and does
not appear on the field view. It is a label the planner sets and a filter the
dashboard offers. If Urgent is meant to DO something, that is a separate item.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 14:21:57 -05:00
55caefb099 T5.8 - S1 (wizard): errors at the field, and the last dialog is gone
S1 has two halves and they are easy to conflate.

One is that validation was a native dialog: "Please complete all required
fields: Project Name, Number, Client, Division, and Site Location." names five
fields at once, highlights none of them, and scrolls nowhere. The other is that
validateStep guarded steps 1, 5 and 6 while the MARKUP marks required fields on
1, 3, 5, 6 and 7 - so two steps' asterisks meant nothing at all, which is worse
than no asterisk.

Both fixed, and the second is the one worth measuring: the probe reads the
required-field list out of work-package-suite.html rather than out of STEP_GATES,
because a probe that read the table would agree with whatever the table says and
prove nothing. Both notations count - an asterisk in a <label>, and the red span
beside step 3's role titles.

Now: an error per FIELD, rendered at it, associated by aria-describedby, marked
aria-invalid, announced through role="alert", and the first one focused and
scrolled into view. The error boxes are BUILT from the gate table rather than
written into the markup twelve times - adding a required field is one row, and
its error element, its association and its announcement all follow. A
markup-side error box somebody forgets to add is an error nobody ever sees.

An error clears as you type rather than on the next submit. An error still
showing over a field you have just corrected teaches people to ignore errors.

And nothing paints a step you have not tried to leave: the rail asks
stepGateMet(), which reads the same fields and marks none of them.

The thirteen dialogs

  Every one is now the thing it should have been - an error at the field it is
  about, or an announcement in a live region with the role T4.5 established:
  errors interrupt, confirmations do not.

  A dialog is not merely ugly. It blocks the page, cannot be placed or styled, a
  screen reader can present it only as a modal interruption, and it is one OK
  button whatever it says - so "sample data loaded" and "you cannot do that"
  arrived identically.

  Two deserve naming. The empty-comment alert became an inline error on the
  feedback textarea. And showAnalytics() was a confirm() carrying the entire
  usage summary as its body - a wall of text in a dialog whose only dismissal
  was also the download button. The summary is the useful part, so it is shown,
  with the download offered as an action beside it. That function has no caller
  in the wizard's markup (the "Usage data" button is the creator's, calling the
  creator's own showAnalytics), and it was converted rather than deleted:
  deleting a feature is not what this task was asked to do, and its dialog
  counted toward the number this task has to drive to zero.

  html/work-package-suite.html       #wp-toast, an error box on the textarea
  html/work-package-suite-app.js     STEP_GATES widened; per-field messages;
                                     ensureErrorBoxes; wizardToast; 13 removals
  html/work-package-suite-styles.css .wp-toast
  tests/validation_check.py          new - 81 checks
  tests/stepper_check.py             its "the alert T5.8 still owns" check now
                                     asserts the opposite, by name

Done when
  [x] every step with required fields validates them - 5 steps, from the markup
  [x] each error renders at its field and is associated via aria-describedby
  [x] submitting an invalid step focuses AND scrolls to the first error
      (scroll checked by bounding box, not by trusting scrollIntoView)
  [x] errors announce to screen readers
  [x] the wizard's native dialog count is 0

The count, recorded both ways because BL-017 says the metric counts prose

  work-package-suite-app.js   0 raw, 0 with comments stripped
  app-wide                   64 raw, 64 stripped, against wave 0's 79
                             wp-creation-app.js 43 (wave 7), users.js 10 and
                             admin.js 6 and index.html 5 (wave 9)

The wizard contributes none of what is left, which the probe asserts rather
than leaving to the total.

Verified one at a time
  validation_check 81/81  new
  stepper_check    71/71
  sections_check   88/88
  browser_check    71/71
  a11y             22/22
  url_state        23/23
  autosave         34/34
  locations        58/58
  aggregates       16/16
  pipeline         43/43
  launcher         58/58
  f_items          F1-F5 FIXED, F6 REPRODUCES (T7.2)

No colour literal added. The toast says "error" by a red rule AND by staying
until dismissed where a confirmation times out - two channels, not one (C1).

Question for the PR, per CLAUDE.md: step 3's two role TITLES are validated
because the markup marks them required, but the two role NAME pickers beside
them are not marked and so are not gated. A sign-off role with nobody in it is
arguably the more useful thing to catch. The markup is what was built to; if the
intent was the names, that is two rows in STEP_GATES.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 12:30:32 -05:00
3bc3abe209 T5.7 - CR-016: Assets off for Micron EUV, by configuration
Asset content duplicates the database Clinton's team maintains, and that
integration is deferred, so the section comes out in the interim.

This is a configuration task, and the wave file says so - "Surface:
configuration". There is no Micron EUV SOP in this repository; it is data on a
running system. What the repository holds are two representations of the Micron
configuration, and both now carry the toggle:

  wp-creation-app.js SAMPLE_SOP        what the standalone creator shows when
                                       there is no project SOP to read
  work-package-suite-app.js loadSample  the Micron FMCS SOP the wizard loads

For a real project the mechanism is step 12 and needs no code: untick Assets,
complete the SOP. That is what T5.5 built and what "another SOP can enable
Assets and it works" means.

Both write a PARTIAL map - {assets:false} - rather than a full one.
WPSections.normalize fills the rest in as ON, so a section added after today is
not silently off for this SOP. The probe checks that specifically: exactly one
key is false, not nine true and one false frozen at today's list.

  html/wp-creation-app.js         SAMPLE_SOP.sections
  html/work-package-suite-app.js  loadSampleData sets state.sections
  tests/sections_check.py         +13 checks (75 -> 88)

Done when
  [x] Assets does not render for Micron EUV in the form, detail view or export -
      and not in the section chip strip either, which is the form's contents page
  [x] the section and its model remain in the codebase - #asset-card and
      pkgAssets are both still there, checked in the browser rather than assumed
  [x] another SOP can enable Assets and it works - the fixture project renders it

seed_demo.py was deliberately NOT changed. Its SOP row stores a bare
{governance: ...} blob rather than the {sop, state} shape pullProject reads, so
its data never reaches the creator's SOP object at all - adding sections there
would be a toggle nothing consults. That shape mismatch is BL-018 and it should
be fixed as one thing, not worked around here.

What the probe caught
  The first run reported on "Job A" while claiming Micron. activeProjectId falls
  back to ProjectData.getActiveId(), so a project left in localStorage by an
  earlier navigation sent bootSOP() down the real-project branch and the sample
  was never loaded - every CR-016 assertion was then measuring the wrong SOP and
  four of them happened to fail, which is the only reason it was noticed. The
  probe clears storage first and says why.

Verified one at a time
  sections_check  88/88  (75 + 13 for CR-016)
  browser_check   71/71
  f_items         F1-F5 FIXED, F6 REPRODUCES (T7.2)

Question for the PR, per CLAUDE.md: CR-009's decision also says Kitting ships
toggled off for Micron EUV via these same toggles. That is wave 8's to apply
(CR-009/CR-010/CR-012), so the sample still has Kitting on. If the intent was
for both to go off together, the second line is one word.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 12:14:45 -05:00
c453e50412 T5.6 - CR-002: Acumatica cost code and task, hidden by toggle
The team concluded these two are noise on a field work package: cost codes are
effectively constant on a job 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.

Hidden, not removed. CLAUDE.md: "Removed fields are hidden, not deleted (CR-002,
CR-016). Retain the data and the model." So this is a second, narrower toggle
list beside T5.5's sections - two fields inside General Information rather than
two more sections, because a section is a block of the document and these are
two rows in one.

  no migration           the values live in the work package's JSON data blob,
                         which nothing here writes to. The probe greps every
                         migration for a drop_column touching either.
  no model change        server/models.py is untouched by this task
  no code change to      the toggles are SOP data. Another project turns them
  re-enable              back on from step 12 and both fields return, values
                         included

A field is on only if its own toggle is on AND the section holding it is. Asked
as one question (WPSections.fieldOn) so no caller has to remember to ask both -
a field showing inside a hidden section is not a state worth reasoning about,
and the probe checks that case explicitly.

  html/wp-sections.js            FIELDS, fieldOn, normalizeFields
  html/work-package-suite-app.js field rows nested under their section
  html/work-package-suite-styles.css .field-toggle
  html/wp-creation-index.html    ids on the two .field wrappers
  html/wp-creation-app.js        WP_FIELD_NODES; both document rows conditional
  tests/sections_check.py        +22 checks (53 -> 75)

Done when
  [x] neither field appears in the form, detail view or PDF export when off
  [x] existing records still hold their values - a package EDITED while both are
      off comes back through collectPackage() with both intact
  [x] the fields can be re-enabled for another SOP without a code change
  [x] no schema migration drops data - checked against every migration in the
      tree, not just the ones this wave added

The whole .field wrapper is hidden, not the input: a bare label over nothing is
worse than either state.

Raised, not fixed
  BL-019  A cost code that has left COST_CODES is silently blanked on edit.
          wp_cost is a <select>, and setting .value to something with no matching
          <option> does nothing at all - so opening such a package clears the
          field and the next save writes the blank back. The same bug was fixed
          once already for gov_wosize (work-package-suite-app.js:490-495) by
          adding the stored value as an option; cost code never got it.

          Found the honest way: a probe here used an invented cost code to prove
          hiding a field does not delete its value, and the value came back
          empty. That looked exactly like the toggle eating data. It was not, and
          the probe now uses a real code and says why in a comment - a probe that
          fails for a reason other than the one it names is worse than no probe.

Verified one at a time
  sections_check  75/75  (53 + 22 for CR-002)
  browser_check   71/71
  stepper_check   70/70
  a11y            22/22
  url_state       23/23
  autosave        34/34
  locations_check 58/58

Question for the PR, per CLAUDE.md: BL-000b asks whether General Information
wants per-field toggles generally. This is not that - it is the two fields
CR-002 names, and the list is deliberately closed. If a third field wants one,
that is the general question and it needs the product answer BL-000b is holding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 12:10:28 -05:00
ae30c58337 T5.5 - CR-006: section toggles, and X4 resolved rather than deferred
X4 first, because the brief asks for it explicitly.

IMPLEMENTATION.md sequences CR-006 after B7 on the grounds that the toggles must
suppress sections inside the creator, which is an iframe child until T7.1. The
wave file puts it in wave 5 anyway, and its last done-when is written to
accommodate exactly that: "toggle state propagates into the creator, OR the PR
documents exactly where it does not and why".

It propagates. Both ways, by two separate paths, because they fail differently:

  ON THE CREATOR'S OWN BOOT the flags ride on the SOP, which the creator already
  reads - ProjectData.pullProject hydrates it from the server. Nothing crosses
  the frame boundary at all, so this path is unaffected by B7 either way. It
  covers a reload, a fresh tab, the standalone creator page, and a colleague
  opening the project on another machine.

  WHILE THE FRAME IS OPEN the wizard hands the change straight across
  (pushSectionsToCreator -> cw.applySopSections), the same shape T5.3 used for
  the dashboard filter. Without it, flipping a toggle would appear to do nothing
  until a reload.

T7.1 removes the second path, not the first. That is the whole of the X4
exposure and it is one function, commented as such. Building CR-006 after B7
would not have made the SOP-borne path any different; it would only have saved
writing the hand-off.

What it does

  Ten sections, one shared list (html/wp-sections.js) read by the wizard, the
  creator's form and the creator's rendered document. Three surfaces meant three
  chances to drift, which is how "Assets is off, except in the export" happens.

  Off means NOT RENDERED - form, detail view and PDF export. It never means
  deleted. renderPackage() was rebuilt from one long string into a list of
  (section, html) blocks so a suppressed section leaves no empty heading and the
  survivors renumber 1.0, 2.0, 3.0 instead of leaving a hole. The print window
  reuses that same HTML, so the export needed no separate change.

  Absent means ON. A SOP saved before today mentions no sections, and reading
  that as "all off" would blank every project in the estate the moment this
  shipped. WPSections.normalize is the one place that decision lives.

  html/wp-sections.js               new - the shared list, defaults, normalize
  html/work-package-suite.html      step 12, a 12th rail button
  html/work-package-suite-app.js    the toggles, state.sections, the hand-off
  html/work-package-suite-styles.css the toggle rows
  html/wp-creation-index.html       stable ids on the five unnamed cards
  html/wp-creation-app.js           WP_SECTION_NODES, applySopSections,
                                    renderPackage rebuilt as blocks
  tests/sections_check.py           new - 53 checks

Done when
  [x] all 10 sections are toggleable - each one driven off and back on
  [x] a section toggled off is absent from the form, the detail view and the
      PDF export - checked by content marker, not by heading, so "the section
      is gone" and "the section was empty" cannot be confused
  [x] toggling off then on restores prior data with no loss - and the sharper
      version: a package EDITED while Assets is off still carries its assets
      through collectPackage(), which is what Save uses
  [x] new SOPs default to all sections on
  [x] toggle state propagates into the creator - both paths, separately

Two things worth arguing with

  General Information is toggleable, because CR-006 enumerates it. Turning it
  off leaves nothing to identify a package by. The row says so in its own note
  rather than being quietly excluded from the list.

  Location has no card of its own - it is a field inside General Information's
  grid, and its toggle governs that one row. CR-004 gives it structured
  building/floor/sector fields in wave 6, at which point one line of
  WP_SECTION_NODES changes. Written down because "the toggle does nothing" and
  "the toggle governs one row" look identical from outside.

  Approvals & Sign-offs is NOT toggleable and is not one of the ten. A package
  nobody signed is not a shorter package.

Verified one at a time
  sections_check  53/53  new
  stepper_check   70/70  (STEP_COUNT 11 -> 12)
  locations_check 58/58  (its "step 11 is last" check now asserts the thing that
                          is actually invariant - the wizard's navigation follows)
  browser_check   71/71
  a11y            22/22
  url_state       23/23
  autosave        34/34
  aggregates      16/16
  pipeline        43/43
  launcher        58/58
  f_items         F1-F5 FIXED, F6 REPRODUCES (T7.2)

No colour literal added: still 0 across all page sheets and inline blocks. Each
toggle says its state three ways - the checkbox, the words "In use" / "Not used",
and the rule down its left edge (C1).

Question for the PR, per CLAUDE.md: the toggles are SOP-wide, so a project
cannot use Kitting on install packages and not on BIM ones. BL-000b already
records the field-level version of this question; the per-WP-type version is the
same shape and is not recorded anywhere yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 11:25:05 -05:00
2081c1ad3c T5.4 - CR-005: a per-project location taxonomy, stored as codes
CLAUDE.md lists CR-005 among the change requests that get "silently half-built if
you treat them as frontend-only". This is the server half and the wizard half
together: a new table, four routes, an Alembic revision, and step 11.

CODES, NOT DISPLAY STRINGS, because CR-018 rolls cost up by these values and a
rollup keyed on a label breaks the day somebody fixes a typo in it. Two columns
carry that: `code` is a node's own slug, derived once at import and never
recomputed; `path` is the full slug path, unique per project, and is what a work
package will store. Renaming a value changes `name` alone - the probe renames a
floor and demands its path comes back byte-identical, with its children's paths
intact.

DEACTIVATE, NEVER DELETE. There is no DELETE route, and the probe checks for its
absence (405) rather than trusting that nobody added one. Deactivating hides a
value from new work packages and cascades DOWN, because a floor nobody can pick
must not keep offering its sectors. Reactivating walks UP only - a sector may
have been switched off for its own reasons, and silently resurrecting it would
undo a decision nobody made twice. That asymmetry is deliberate and is pinned by
a named check so it does not get "fixed" into a surprise.

Import reports rather than merges. Rejected rows come back with the SOURCE line
number and a reason; duplicates are listed as duplicates, separated into "already
in this project" and "already on line N of this import". Reusing a parent is not
a duplicate - B1/L2/1P and B1/L2/2P share a building and a floor by design, and
only the full path repeating counts. Re-importing a deactivated value brings the
same row back rather than creating a second one; the probe checks the id.

One parser, on the server. A CSV is read in the browser and posted as text
exactly as a paste is, so "what does a blank column mean" has one answer.
Comma, semicolon and tab all work - a paste out of a spreadsheet is tab
separated and a saved CSV is not, and which one somebody has is a question the
machine can answer.

No guessed floor names. IMPLEMENTATION.md section 8 says the B100 list has not
been supplied. The seeded sample has "Sample" inside every string, and the probe
greps html/ and server/ for a location-shaped assignment containing any of the
review's real names.

  server/models.py                    LocationNode
  server/alembic/versions/e2a4c7d91b30_location_taxonomy.py
  server/app.py                       GET/POST/PATCH + import, parser, slug
  html/work-package-suite.html        step 11, an 11th rail button
  html/work-package-suite-app.js      the step's logic; LAST_STEP replaces 10
  html/work-package-suite-styles.css  the list, the report
  html/theme-light.css                .field-error, now declared once
  tests/locations_check.py            new - 58 checks
  tests/stepper_check.py              STEP_COUNT 10 -> 11

Done when
  [x] CSV upload and paste both work and report rejected rows with reasons
  [x] duplicates are detected and reported rather than silently merged
  [x] values are editable after import - rename, add, deactivate
  [x] deactivating hides it from new work packages; an existing package
      referencing it still resolves, because the row is retained
  [x] values are stored as codes suitable for grouping
  [x] no guessed real-world floor names exist anywhere in the code

Two decisions worth disagreeing with

  Step 11, appended, not step 2, inserted. Locations belong beside Project by
  subject. Renumbering 2-10 would touch every sop-step-N id, every
  collectStepData case, every gate key and the analytics history - a large
  silent-mismatch surface for an ordering change. The count now lives in one
  place (LAST_STEP), so reordering later is cheap.

  Any project member may edit the list, not only a Project Admin. It matches how
  the SOP baseline itself is authored: the Project Admin gate is on CHANGING a
  completed SOP, not on writing one. If the location list should be tighter than
  the SOP it belongs to, that is a product call.

Verified one at a time
  locations_check  58/58  new
  stepper_check    70/70  (11 steps)
  browser_check    71/71
  a11y             22/22  sop now rings 38 focusable elements
  url_state        23/23
  autosave         34/34
  aggregates       16/16
  pipeline         43/43
  launcher         58/58
  f_items          F1-F5 FIXED, F6 REPRODUCES (T7.2)
  alembic          upgrade / downgrade / upgrade all clean on a throwaway SQLite
                   file, and the migrated schema matches Base.metadata.create_all
                   column for column - dev auto-creates and production migrates,
                   so a divergence between the two is invisible until it ships

.field-error was declared in two page sheets by the end of T5.2 and would have
been three by T5.8, so it moved to theme-light.css. No colour literal added
anywhere: still 0 across all page sheets and inline blocks.

Question for the PR, per CLAUDE.md: the levels are fixed at building / floor /
sector. Micron's floors behave like buildings, which this handles by letting a
project use whichever levels it needs - but a job that wants a fourth level, or
different names for the three, cannot say so. Whether that is worth a
per-project level vocabulary is a product question; the schema would take it
without a migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 11:06:27 -05:00
6088ef17e8 T5.3 - B4 surface: a pipeline strip, four server counts, four shareable links
Four cells on the launcher - Work packages, Release ready, On hold, Overdue -
every number from /api/wps/metrics, which T4.1 built. There is deliberately no
cache fallback anywhere in renderPipeline(): a remembered number sitting beside
three live ones is the failure B4 exists to remove, wearing a different hat.

The cells are the four the endpoint already computes and the dashboard already
filters on, so they map 1:1 onto its flags rather than inventing a fifth slice
nothing downstream understands. wp-creation-app.js now names them (DASH_FLAGS)
for the same reason: a cell linking to a filter the board does not recognise is
a dead link that still looks live.

"Links to a filtered view via a shareable URL" needed the filter to BE URL state,
which it was not - the dashboard kept its flag in a variable. So:

  - dashToggleFlag pushes ?flag=<f>, and clears it on the way out of the board
  - the creator applies ?flag= before its first render, not after (applying it
    after paints the whole board and throws it away)
  - Back and Forward move through filters like any other state
  - work-package-suite-app.js forwards the flag ACROSS the iframe boundary, since
    the creator's src carries only the project. B7/T7.1 dissolves that frame and
    this hand-off goes with it; it is commented as such.

Zero is a real answer for one slice of a project that has work in it. Four zeros
on a project with none is not a reading, it is a strip that looks broken - that
case gets a sentence and a way into the creator instead. A failed request gets an
explicit error naming the failure, and no cells at all.

  html/index.html            the strip, its states, PIPE_CELLS
  html/wp-creation-app.js    flag as URL state; DASH_FLAGS; dashApplyFlag
  html/work-package-suite-app.js  forward the flag into the frame; clear on exit
  tests/pipeline_check.py    new - 43 checks

Done when
  [x] every number comes from a server endpoint - proved by poisoning localStorage
      with 99 fake packages and demanding the strip still read the server's 4
  [x] each cell links to a filtered view via a shareable URL - and the probe
      FOLLOWS the link and reads the filter inside the frame rather than trusting
      that a correct-looking URL was built
  [x] a project with zero work packages renders a sensible empty state
  [x] the strip announces updates via aria-live (polite - a count is not an
      interruption) and reports aria-busy while it is counting

What the probe caught

  The link landed on "Complete the SOP Configuration first". Not the strip's
  fault: browser_check.py's fixture stores a bare {governance: …} blob as the SOP
  data, where production stores {sop, state}. restoreSavedSOP() needs `state` and
  bails without it, so sopComplete stays false and the WP tab shows its gate.
  pipeline_check seeds the production shape. The underlying wart is real and is
  logged rather than fixed - see BL-018.

Verified one at a time
  pipeline_check  43/43   new
  launcher_check  58/58
  stepper_check   70/70
  url_state       23/23   the dashboard's new flag state did not disturb it
  aggregates      16/16
  browser_check   71/71
  a11y            22/22
  autosave        34/34
  f_items         F1-F5 FIXED, F6 REPRODUCES (T7.2)

No colour literal added: still 0 across all page sheets and inline blocks. The
four cells are told apart by a label, a sentence and an accent - three channels,
so colour is not carrying it alone (C1).

Raised, not fixed
  BL-018  The WP tab's gate is the last localStorage-derived status in the app.
          T4.1 moved the launcher's card to the server; the wizard page still
          decides gate-or-creator from wp_suite_sop_complete plus a state blob.
          pullProject refreshes both on load so a connected user is fine, but the
          two answers come from different places and the fallback is silent.
          Includes a second, sharper edge: project-data.js:210 writes that flag
          for ANY row returned, including one with no `state` to restore - so the
          flag is written and never read consistently. T7.1 owns it.

Question for the PR, per CLAUDE.md: the strip counts Overdue against `data.due`,
which is free text today. CR-004/CR-018 restructure location but not dates. If
"overdue" is going to drive anything beyond a launcher tile, that field needs a
type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:38:58 -05:00
77c043c3db T5.2 - B3: a first-run empty state, then the picker card comes out
B3's warning is about ORDER, and it is the whole item: the proposal removes the
project-picker card, and the first-run empty state was built inside it. Remove
the card first and every brand-new account lands on a page whose only
instruction is to choose from a list with nothing in it.

So the empty state was built first, as its own thing rather than a branch inside
a control that is going away, and only then did the card go.

The launcher now shows exactly one of three states:

  no projects at all   what a project is for, the create form already open (it
                       is the only thing to do on this page, so hiding it behind
                       a button is one click of ceremony in front of the only
                       way forward), and the sample offered underneath it
  none chosen          point at the app bar's switcher, which is on every page,
                       plus New project
  one active           the tool cards, as before

Switching moved to the app bar's switcher entirely. Its popover footer used to
read "All projects / new project" and link to index.html - half of that promise
moved into the popover itself and the other half needs a form, so the link now
says New project and carries #new-project, which the launcher opens on.

The create form was rebuilt, so per C1 it ships accessible: a real <form> with
requestSubmit, every input labelled, and its validation inline at the field with
aria-describedby and role="alert" - the same shape T5.8 gives the wizard. That
retires the "Project name is required." alert (index.html 6 -> 5).

  html/index.html    three states, rebuilt create form, picker card removed
  html/wp-chrome.js  popover footer link (one line - it named the card)
  tests/launcher_check.py  new - 58 checks, two seeded databases
  tests/f_items.py   F1 rewritten to drive the controls that replaced the select

Done when
  [x] a brand-new account with zero projects sees a clear path to create one
  [x] the sample project remains discoverable from the empty state
  [x] the picker card is removed only after the empty state ships
  [x] switching projects still works from the header for users who have projects

Two things the probes caught that I would have shipped

  F1 went INCONCLUSIVE, not FAIL. Its probe drove `document.querySelector
  ('select')` on the launcher - the picker card's dropdown. It refused to guess
  rather than reporting a silent pass, which is the behaviour f_items was
  written for. Rewritten to drive both replacements, because they fail
  differently: the switcher RELOADS with ?project=<id>, so its two labels cannot
  drift apart whatever subscribes to what; creating a project changes the active
  project IN PAGE, and that is the interaction F1's mechanism actually applies
  to. It is now the only in-page change on the launcher, so it is the arm that
  matters. Both pass - the bar subscribes through ProjectData.onActiveChange.

  launcher_check reported "no focus ring" on the rebuilt form's inputs. That was
  trap 5 in reverse: without CDP focus emulation the headless document is not
  the focused one, :focus-visible never matches, and every control reports NO
  ring - a false red where a11y_check would get a false green. With emulation on
  they draw 2px --cds-focus from T4.7's app-wide floor.

Verified one at a time
  launcher_check  58/58   new (38 empty-account + 20 populated)
  stepper_check   70/70
  browser_check   71/71
  aggregates      16/16
  a11y            22/22   launcher 29 focusable elements, all >= 3:1
  url_state       23/23
  autosave        34/34
  f_items         F1-F5 FIXED, F6 REPRODUCES (T7.2)

No colour literal added: 0 across all five page sheets and all seven inline
<style> blocks.

Raised, not fixed
  BL-014 updated rather than left stale: two of its four sites (.proj-row select,
  .link-like) went with the picker card, and the third (.proj-form-grid input)
  was measured rather than assumed - it draws T4.7's ring, which post-dates that
  entry. What survives is field.html's .fld-search, which T9.5 should measure the
  same way instead of inheriting the wording.

Question for the PR, per CLAUDE.md: with the picker gone, an account whose only
project is archived sees the choose-a-project prompt plus the archived note, and
the switcher lists nothing. That is honest but bleak. Whether an archived project
should stay switchable read-only is a product call, not an implementation one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:24:41 -05:00
5e1f6e75ba T5.1 - A4/S9: a vertical stepper, ten real buttons, states in words
The step rail was ten div elements carrying onclick inside a horizontal
scroller. Not in the tab order, not operable by keyboard, and silent about
progress - the only thing on the page that said where you were was a "1 / 10"
pill in the app bar, detached from the control it described.

The rail is now a vertical column beside the form: ten <button> elements in an
<ol> inside a named <nav>, with arrow keys, Home and End on top of the Enter and
Space a button gives for free. All ten stay in the tab order; a roving tabindex
would have satisfied "arrow keys" by breaking "tab", which the done-when asks
for both of.

Four states, each carrying a word and a marker shape as well as a colour (C1):
Complete (green disc, tick), Current step (blue disc, aria-current="step"),
Locked (dashed outline) and a plain default. Locked steps keep aria-disabled
rather than disabled, so a keyboard user can reach one and be told what is in
the way instead of finding a control that has vanished from the tab order.

Reachability is the guard's own rule, deliberately not a stricter one: you may
leave the step you are on once its required fields are filled. The tempting rule
- lock everything after the first unmet gate anywhere - is not what
validateStep() enforces, and a padlock the Next button walks straight past is
the drift this change exists to remove. validateStep() and the rail now read one
STEP_GATES table, so they cannot disagree; T5.8 widens that table rather than
editing four functions.

Clicking a step you cannot reach announces why through a role="alert" region and
puts the cursor in the field that is missing. Saying "no" and leaving you where
you were, with no idea which of five inputs was empty, is what the dialog did.

Below 900px the rail collapses to a disclosure naming the step you are on -
ten vertical rows above the fields is most of a 390px screen before you reach an
input. 44px tap targets, since Field View is the gloved-hands surface.

Also: going backwards is no longer gated. previousStep() never validated, so a
rail that did would have trapped you on an incomplete step.

  html/work-package-suite.html        rail markup, counter removed
  html/work-package-suite-styles.css  #tool-sop grid, .step-rail*, 899px collapse
  html/work-package-suite-app.js      STEP_GATES, renderStepRail, keyboard, watcher
  tests/stepper_check.py              new - 70 checks

Done when
  [x] all 10 steps are <button> elements
  [x] keyboard: tab, arrow keys, Home/End, Enter and Space
  [x] aria-current on the current step, exactly one
  [x] complete / current / unavailable told apart without colour
  [x] the "1 / 10" counter is gone - no .step-counter, no N/10 in the app bar
  [x] app-wide <div onclick> 12 -> 2, down exactly 10

Verified one at a time
  stepper_check   70/70   new
  browser_check   71/71
  url_state       23/23
  a11y            22/22   sop now rings 37 focusable elements, all >= 3:1
  autosave        34/34
  aggregates      16/16
  f_items         F1-F5 FIXED, F6 REPRODUCES (T7.2)
  baseline_shots  14 shots; only the sop pair changed. The beforeunload log on
                  sop@1440 and creator@1440 is present at HEAD too - captured
                  both sides to check rather than assume.

No colour literal was added: all five page sheets and all seven inline <style>
blocks still hold zero. New spacing consumes --wp-s*; three raw font sizes were
added and three removed, so BL-010 is unchanged in kind.

Raised, not fixed
  BL-016  Back to a URL with no `step` leaves the wizard where it was. T4.2's
          popstate handler parses NaN and ignores it; its own probe never took
          that branch. stepper_check pins the current behaviour by name so the
          fix has a test waiting.
  BL-017  The native-dialog baseline counts the word `alert(` in comments. Four
          comments written here - all of them about removing a dialog - moved
          the number from 80 to 82 while two real calls were being deleted. They
          were reworded; the metric still needs a comment-stripped variant, which
          T5.8 owns.

Question for the PR, per CLAUDE.md: BL-015 leaves the creator's .step-tab
uppercase as the last forced-uppercase interactive text in the suite, on the
grounds that A5 scopes sentence case to buttons and field labels. The wizard's
rail is now buttons, so its labels are sentence case ("Sign-offs", "WP types").
The two are consistent by rule and inconsistent on screen until T7.x.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 23:19:58 -05:00
c024cba844 T4.5/T4.6/T4.7 - S10/S11/S12: it announces, it is legible, focus is visible
Three small accessibility items, done together because they share one probe and
one measurement method. tests/a11y_check.py, 22 checks, all passing.

S10 — ANNOUNCEMENTS. The app had zero aria-live regions; login.html's
role="alert" / role="status" pair was the only correct example in the codebase.
Both toasts now take an optional kind and set the role BEFORE the text, because
assistive technology announces on the content change and a role applied after
describes the next message rather than this one. The sync badge announces
politely.

Admin banners are handled by a rule rather than by editing thirteen assignment
sites: a MutationObserver in console-util.js marks anything carrying `.bad` as
role=alert and everything else role=status. Thirteen edits is thirteen chances to
get it wrong, and any banner added later would have missed out. The probe checks
a banner created after load, which is the case that would have regressed.

S11 — CONTRAST. Re-measured rather than quoting either published figure, as the
file map asked. #8d8d8d is 3.32:1 on white, not "about 2.9:1" as the plan says;
console.css:103's 3.3:1 was right. On the shaded surfaces it is worse - 3.01:1 on
a success banner. --cds-text-helper (#6f6f6f) clears 4.5:1 on all seven
backgrounds the app actually paints, tightest 4.56:1.

Fixed once, in the token, so all three sheets inherit it. console.css's local
override is gone, as the task requires. Disabled text was repointed to
--cds-text-disabled rather than darkened with everything else: making disabled
text MORE legible makes a disabled control look enabled.

The probe measures against the background actually painted behind each element,
walking ancestors for the first non-transparent one - not an assumed white, which
is how "passes on paper" and "fails on the page" come to disagree.

S12 — FOCUS. An app-wide :focus-visible floor in theme-light.css at zero
specificity, so any component can still draw its own. Filled controls get an
explicit rule at class specificity: a blue ring inside a blue button measures
1.00:1, which is not a subtle problem but no indicator at all. console.css's
inset ring had exactly that defect on button.primary.

`outline: none` is down from six to one, and that one (.wpc-search-input) has its
replacement in the rule above it - the shell rings on :focus-within, and ringing
both would draw two rectangles.

TWO REAL DEFECTS THE PROBE FOUND that reading would not have:

  - .wp-navbtn's ring is white, which is right on the near-black app bar and
    invisible on the creator's white header. Same button, same class, two hosts,
    1.00:1 on one of them.
  - The comment drawer is translated off-screen when closed, and a transform
    moves a thing without removing it from the tab order. Its name field,
    textarea, Add button and close button were all still focusable: a keyboard
    user could tab into a panel they could not see and could not tell they were
    in. Now visibility:hidden while closed, with the transition delayed so it
    still animates both ways.

The probe itself needed three corrections, each of which was a wrong answer
before it was a right one, and each worth knowing:

  - focus emulation must be ON, or :focus-visible never matches in headless and
    every element reports clean - a pass that means nothing.
  - which surface a ring is drawn against depends on the offset the BROWSER uses,
    not the one the stylesheet asked for. Chromium redraws a low-contrast author
    ring in white at offset 0 on a filled control, which is more contrast than was
    requested; measuring that against the parent scores it 1.00:1 and calls a
    correct ring a defect.
  - focus() on a hidden control does nothing, so the probe has to ask whether the
    focus actually landed. A closed drawer still has layout; a bounding box is not
    evidence that anyone can reach it.

Metric 7, aria-live regions: was 0 at wave 0, now 13 role/aria-live sites across
7 files.

browser_check 71/71, f_items 5 FIXED / F6 REPRODUCES.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 22:20:57 -05:00
0cce0b4191 T4.4 - B5: two save indicators, two sentences, neither readable as the other
The app already showed "✓ All changes saved". That badge belongs to the OUTBOX -
it reports whether saved records have reached the project - and it went green
when the queue emptied, whether or not anything in the form had been saved at
all. So the promise B5 says the app does not keep was being made by a component
that could not know whether it was true.

Two indicators now, each speaking for one thing:

  DRAFT   .wp-draft-status, mounted in the creator's sticky save bar and the
          wizard's step navigation. Driven by WPAutosave's status: "No unsaved
          changes" / "Unsaved changes" / "Saving draft…" / "Draft saved at HH:MM"
          / "Draft not saved on this device — <reason>" with a Retry.
  OUTBOX  the existing badge, reworded so every state names the project:
          "Sending N changes to the project…", "Everything sent to the project",
          "N changes not yet sent to the project — retrying", "rejected by the
          project".

"No unsaved changes" rather than "Saved" for an untouched form: those are
different statements and only the first is true before anything is typed. The
component was getting that wrong in the same way the outbox badge was.

Announced per S10 (T4.5's pattern, arriving one task early because this indicator
needs it to exist): role="status" while things are going well, swapping to
role="alert" on failure. A failed autosave means the safety net is not there, and
waiting for a pause in the screen reader's queue to mention that is too late.

The retry button is only rendered in the failed state - a retry offered when
nothing has failed is a button that does nothing.

Styles live 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.

VERIFICATION. tests/autosave_check.py grew to 34 checks, all passing. The B5 ones:

  - the indicator reports "No unsaved changes" untouched, then a real save with a
    timestamp, and is visually distinct in each state
  - a simulated storage failure is visually distinct, names the reason, offers a
    retry, and switches to role=alert
  - the sync badge no longer RENDERS "All changes saved", and every state it does
    render names the project

That last check is deliberately scoped to what the badge renders rather than to
the file text: the old phrase still appears in the comment explaining why it was
changed, and asserting on that would be asserting that the reason cannot be
written down.

Note for wave 9: the outbox badge is styled with inline hexes, including #8a6d00
- the ninth amber from BL-009, independently confirming that entry. It is
BL-005's territory, not this task's.

browser_check 71/71.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 22:03:16 -05:00
ab3c9e52d1 T4.3 - S2: autosave, an unsaved-work guard that means it, and draft recovery
The work package form is ~4,700px tall and had no autosave and no unsaved-work
guard. The only beforeunload listener in the app was analytics dwell tracking, so
a mis-click, a closed tab or a crash lost everything typed since the last Save.

html/wp-autosave.js separates three things this app was conflating:

  THE DRAFT   what you have typed. Saved locally, continuously, by this file.
  THE RECORD  what you explicitly Saved, which goes to the project.
  THE OUTBOX  project-data.js, which gets the RECORD to the server reliably.

This module owns the first only and never writes to the server. A draft is
"unfinished work this browser is holding for you"; pushing unfinished work into a
shared project is a different feature with different consequences.

The guard fires only when the form differs from what was loaded. "Do not fire the
guard when nothing has changed" is in the task because a dialog that appears on
every exit gets clicked through within a day, and is then worse than no dialog.

WIRED: the creator's package form and the SOP wizard's state. Both autosave on a
1200ms debounce, on section/step change, and on visibilitychange - the last being
what makes recovery survive a killed tab, since a crash never fires beforeunload.
The wizard's guard is ADDED alongside trackStepDwell, not in place of it; both
fire and the analytics one does not preventDefault.

THREE BUGS FOUND WHILE BUILDING THIS, all by the probe rather than by reading:

  - Dirtiness cannot be "does the form match savedPackages". Those records come
    back from the server through serverToPkg() in a LEANER shape - 264 characters
    against the form's 1,820 - so a freshly loaded, untouched form differed from
    its own record and every single exit would have prompted. Dirtiness is now
    measured against a baseline snapshot taken when the form is populated.
  - currentView is 'Work Package Form', not 'Form'. My first guard compared
    against 'Form' and therefore returned false always: autosave was wired,
    registered, and quietly dead. T4.2 had also introduced currentView='Form' in
    its popstate handler; that is fixed here too, since it would have broken this
    and anything else keyed off the view.
  - settled() has to cancel the pending debounce. A save follows typing, so there
    is nearly always a write already scheduled; without cancelling it the write
    lands a second later and resurrects the draft that was just settled - and the
    next load offers to recover work that is already saved.

VERIFICATION. tests/autosave_check.py, 23 checks, all passing:

  - typing autosaves unprompted; the draft holds what was typed; it is scoped to
    project AND package; and it does NOT appear in the outbox
  - an untouched form is not dirty and arms no guard; a typed-in one does
  - the draft survives a killed tab and is OFFERED back rather than applied
    silently, saying plainly that nothing reached the project, via role=status
  - restoring puts the work back in the form
  - an explicit save settles the draft, and the probe asserts the save actually
    landed first - otherwise the rest of that section proves nothing
  - a simulated QuotaExceededError is reported as 'failed' with its reason, not
    swallowed; a silent autosave failure is a safety net that is not there
  - trackStepDwell still records an event

Two notes for later waves. The fixture's SOP defines no WP types, so
savePackage() legitimately refuses until the probe supplies one - worth knowing
before someone reads that as a bug. And native dialogs hung the headless browser
twice more in this task; with 79 of them in the app, any restore or save path
that reaches one will hang a test rather than fail visibly. S6/S7 in wave 9.

browser_check 71/71, f_items 5 FIXED / F6 REPRODUCES, url_state 23/23.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 21:56:40 -05:00
b54034db04 T4.2 - S3: the app's state has an address; X1 is unblocked
Wave 0 counted pushState across html/ and found 0. Every page read its query
string once at boot and never wrote one again, so you could not send anyone a
link to WP07, a refresh dropped you back at the default view, and Back left the
app entirely because the app had never added a history entry.

CR-011 and CR-014 both promise an email carrying a direct link to a work package.
That is X1, and it was blocked on this. It is not blocked now.

html/wp-url.js is the whole mechanism, and it is deliberately NOT a router.
Nothing in it 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 rather than a hash, because the server
already serves these paths and a hash is never sent to the server - which matters
the day a link has to be resolved before the page boots.

The merge behaviour is the part that earns its place: WPUrl.push({wp:id}) keeps
the active project, and WPUrl.push({wp:''}) clears one key without needing to know
what else is in the URL. Hand-built URLs losing ?project= is the usual way this
goes wrong.

WIRED: the creator (open package, dashboard view), the SOP wizard (tool, step),
the launcher (project). Each records a history entry only when the user chose the
change - restoring from the URL uses replace, or Back would immediately add an
entry and appear to do nothing.

WPUrl.absolute() is what CR-011/CR-014 will paste into an email in wave 8.

TWO BUGS THIS TASK CREATED AND FIXED, both found by the probe rather than by
reading:

  - bootSOP() calls newPackage() during boot, and newPackage() cleared ?wp=. A
    deep link therefore worked and then erased its own parameter, leaving Back
    with nothing to return to. Now guarded on wpCreatorReady.
  - goToStep() runs validateStep(), which ends in alert() when a required field
    is empty - always true on a freshly loaded page. So restoring ?step=3 from a
    shared link opened a modal dialog mid-boot, and hung the browser under CDP.
    Restoring a view is not a forward navigation and no longer runs the
    forward-navigation guard.

The second one is worth keeping in mind for the rest of wave 4: this app has 79
native dialogs, and any of them firing during a restore path will hang a headless
browser rather than fail visibly.

VERIFICATION. tests/url_state_check.py, 23 checks, all passing, covering every
done-when on the task:

  - a URL identifying a work package opens that package
  - the same URL for a SIGNED-OUT user goes to login, carries the target through
    ?next=, and lands on the work package itself after signing in
  - refresh preserves project, package, tab and view
  - Back and Forward move through states, verified as still-initialised rather
    than reloaded, and with the dashboard actually rendered rather than only the
    URL changed
  - a different user opening the same URL reaches the same view
  - nothing credential-shaped appears in the query string

Metric 8, pushState: was 0 at wave 0, now 2 in html/ (one pushState and one
replaceState, both in wp-url.js) behind 6 call sites across 4 files. The raw
count stays low by design - one place writes history, which is the same reason
the token work put one place in charge of colour.

browser_check 71/71, f_items 5 FIXED / F6 REPRODUCES, aggregates 16/16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 19:32:31 -05:00
b670ae719d T4.1 - B4: the counts come from the server, and disagreement is said out loud
The defect B4 names is not that the numbers were wrong. On one browser with one
cache they were right. It is that they were derived from the caller's own
localStorage, so two people on the same project saw different numbers and neither
was told.

SERVER. /api/wps/metrics now returns everything the creator's dashboard shows -
total, mine, release-ready, on hold, overdue, est/actual hours, by_status,
by_discipline, progress (overall and per discipline) and the gating list. It
already existed for a subset; the rest was being summed in the browser.

Two things moved to the server rather than being duplicated there:

  - PROGRESS_WEIGHT, the status-to-percentage table. It was PROGRESS_W in
    wp-creation-app.js; the JS copy is deleted rather than left in place, because
    two copies of a weighting table is how the two drift apart.
  - "release-ready" now counts `waitingOn` predecessors as blocking, which the
    browser did and the old endpoint did not. Without that the phrase would have
    changed meaning the moment the dashboard stopped computing it locally.

New GET /api/projects/{id}/summary gives the launcher the SOP state it was
reading out of localStorage.

by_location is shaped for CR-018 in wave 6, per the task's instruction not to
build a shape that cannot group by building/floor/sector. It reports its own
dimensions alongside the groups:

  {"dimensions": ["location"], "groups": [{"key": {...}, "total": n,
    "release_ready": n, "on_hold": n, "overdue": n, "by_status": {...}}]}

Today a package carries one free-text `location`, so that is the one dimension.
_location_key() already prefers structured building/floor/sector when present, so
CR-004 changes the dimensions and the keys and leaves the response shape alone.

CLIENT. The launcher's SOP card and the dashboard's tiles, chips, progress bars
and gating panel all read the server. There is deliberately no cache fallback: a
silently-stale number that looks authoritative is the thing being removed, so a
failed request renders an explicit error and a retry.

Writes flush through the outbox before the counts are re-read (dashRefreshAfterWrite).
Without that the refresh races the push and shows pre-write totals - the same
stale number arriving by a different route.

THE ONE COUNT STILL COMPUTED LOCALLY, stated rather than skipped: the board table
is a LIST of the packages this browser holds, which is what keeps the field view
working offline, and its header counts rows. Rather than pretend otherwise, it is
reconciled against the server's total and shows "this browser has N of M" when
they differ - usually a save that has not finished syncing. Nobody now sees a
number that disagrees with the project without being told, which is what B4 asks
for.

VERIFICATION. tests/aggregates_check.py, 16 checks, all passing. It tests what was
broken rather than whether the totals are right - the latter passed before this
change:

  - root and pat get byte-identical aggregates for the same project
  - the dashboard shows the server's total after localStorage is POISONED with a
    different package list; it cannot do that if it is summing the cache
  - a simulated outage renders "Counts unavailable", names the failure, offers a
    retry, renders no tiles beside it, and announces via role=alert
  - the launcher reports the SOP complete against a cache that says otherwise,
    and says "Could not check" when the request fails
  - by_location's groups are keyed by its declared dimensions, carry their own
    rollups, and sum to the project total

browser_check 71/71, f_items 5 FIXED / F6 REPRODUCES.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 19:24:12 -05:00
9ab7b48de2 T3.2 - C3/S5: one source of truth for colour; page sheets alias only
theme-light.css is now the only file in html/ that contains a colour literal.
The five page stylesheets and all four inline <style> blocks declare names and
nothing else.

  theme-light.css                 191 declarations, 175 with a literal value
  console.css                      28 declarations,   0
  work-package-suite-styles.css    16 declarations,   0
  wp-chrome.css                    14 declarations,   0
  wp-creation-styles.css           24 declarations,   0
  wp-sidenav.css                    0 declarations,   0

#0f62fe is declared in one sheet, down from five. The eleven occurrences left
inside theme-light.css are Carbon's own v10-to-v11 alias layer, which the
inventory records as deliberate and not the S5 defect.

Names were kept, because 111 var() references live in .js files across 23 token
names and a rename there fails silently - no build error, no console warning,
just an unstyled element.

The rule the refactor was built on: consolidation is not unification. Where two
sheets declared the same value, they collapse. Where they declared DIFFERENT
values for one role - the two shadows, the eight status borders doing four jobs,
the three mono stacks - each value got its own canonical name and the pair is
recorded for T3.5. Picking a winner between two near-identical greys is a
rendered change, which this task forbids. The console's zebra stripe is the one
that would have bitten: #fafafa is six points from #f4f4f4, and merging them
erases the striping on the nine-column user table.

Collecting the one-offs in one place made two things countable that were not
before: twelve distinct shadows, and a ninth amber (#8a6d00 on the field view,
four points from #8e6a00 and doing the same job - BL-009).

VERIFICATION - the screenshot done-when could not do the job, so it was replaced.

Captured against wave 2, 11 of 14 shots were pixel-identical and 3 were not.
Capturing wave 2 against ITSELF produced the same 3 differences at the same
bounding box, so those shots cannot distinguish a regression from the clock.
Trap 2 in the brief is half wrong: users.html is stable at both widths; the
unstable third is the creator at 1440px, and admin's captured page height varies
by ~600px between runs (BL-012).

So tests/token_check.py was added. It checks what wave 3 actually claims: that
every custom property resolves to the same literal, and every element computes
the same colours, shadows and type. That is stronger than a screenshot - it
covers the hover, focus and disabled rules a screenshot never exercises, and it
is deterministic.

  wave 2 vs T3.2, all 7 pages:
  178/178 wave-2 token names resolve identically, +213 new
  3,500 elements compute identically, zero added, zero removed
  16 tokens differ in notation only (#fff -> #ffffff), which is the duplicate
  class this task existed to collapse

Two detours worth not repeating: the element walk was first keyed by sibling
index and reported 55 phantom differences on the SOP page, where three
JS-injected overlays append in whichever order their async work finishes
(BL-011); and the comparator now normalises notation before reporting, because
otherwise it fails on its own success.

f_items 5 FIXED / F6 REPRODUCES as expected. browser_check 71/71.

ONE DONE-WHEN NOT MET, recorded rather than skipped: "no page stylesheet
declares a raw color, spacing or type value". The colour half is met in full.
483 raw spacing values, 281 font-sizes and 65 radii remain inside rules, 492 of
them in the creator. That is arithmetic, not effort: the creator's spacing is
every integer from 1px to 14px, so no token exists that padding:9px 11px maps to
without changing one of the numbers - and this task forbids changing a rendered
value. The two requirements are mutually exclusive. Logged as BL-010 for T5.x
and T7.1, where those pages are re-laid-out and the values get chosen again.

New backlog: BL-009 (ninth amber), BL-010 (raw spacing/type in rules),
BL-011 (overlay append race), BL-012 (unstable screenshot targets).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 18:50:30 -05:00
5f3141e2a3 T1.5 - F5 (interim): wizard fields stop looking disabled
INTERIM. T3.4 removes the duplicate token underneath this; the job here is only
the appearance, and no token consolidation is started.

The wizard filled its inputs with var(--bg) - which in this sheet is the PAGE
BACKGROUND, #f4f4f4 - on a #e0e0e0 border. An empty required field was
indistinguishable from a locked one, which is why people were not typing in
them. The cause is the one the review named: this sheet redeclares its own
tokens, so it never saw --cds-field: #ffffff, even though theme-light.css has
been supplying that to this page all along.

Fields now consume --cds-field, and take the same --border-strong the creator's
inputs already use, so a field looks like a field on both pages. No new value is
introduced - both tokens already existed.

That inverts a signal if left there, so it needed the other half: there was no
disabled rule at all on this page, meaning locked fields would have turned white
too. Disabled and readonly fields now take --cds-field-02, the theme's own
secondary field surface, matching .locked-field in the creator. Enabled #ffffff
against disabled #f4f4f4, verified by computed style rather than by eye.

The border is deliberately the same on both states. I first wrote
`border-color: var(--border)` on the disabled rule and could not demonstrate it
taking effect - the rule matches, is more specific than the base rule, and its
background applies, but the computed border stayed --border-strong. Rather than
ship a declaration whose effect I cannot show, it is gone: a consistent border
is what "consistent with inputs elsewhere" asks for, and the fill is what
carries the state.

Screenshot diff is limited to the wizard, but establishing that took a control
run. admin and users appeared to change too, until capturing twice with NO code
change showed they differ from themselves - the console pages render live
timestamps and are not byte-stable. login, launcher, sop, creator and field are.
Recorded in the baseline README so the next task with a "no layout change"
done-when does not chase it.

The F5 probe now also fails if enabled and disabled fields become identical,
which is the way this fix could silently go wrong.

f_items: F1-F5 FIXED, F6 untouched as wave 1 requires. browser_check 71/71.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 18:48:47 -05:00
4d3258113a T1.4 - F4: the comments drawer opens below the header, not under it
Containing block first, as the task asks. The drawer is a body child with no
transformed ancestor, so its containing block was already the viewport - the
positioning context was never wrong. What was wrong was `top: 0` with
`height: 100vh`: the drawer started at the very top of the viewport, and the
creator's .header is sticky with z-index:100 against the drawer's 61. The
header won, so the drawer's own head - its title and its close button - was
roofed over and unreachable. It read as "off-screen" because the part you
needed was covered, not because the box had escaped the viewport.

That is why raising z-index would have been the wrong move: it does not remove
the collision, it just swaps which element is on top, and then the drawer
covers the header instead. The fix is to stop them occupying the same band.
The drawer now starts at var(--rail-top) and is that much shorter. --rail-top
is the header's measured height, set by wp-creation-app.js:1328 and already
used by .wp-nav for exactly this purpose, so "below the header" has one
definition on this page rather than two.

The iframe boundary is NOT implicated. position:fixed inside the embedded
creator resolves against the iframe's own viewport, which is self-consistent,
and the drawer behaves identically framed and unframed. T7.1 can dissolve the
boundary without revisiting this.

The probe was checking one width, one mode, and placement only. It now checks
390 and 1440, standalone and embedded, that the close button is genuinely
hit-testable via elementFromPoint rather than merely present, that the drawer
reopens after closing, and that opening it does not move the page's scroll
position. All pass.

One honest caveat, attributed rather than hidden. At 390px the drawer sits at
the right edge of a 485px layout viewport while the screen is 390px, so 95px of
it is off-screen. That is not the drawer: the creator forces its containing
block to 485px, and while chasing it I found BL-001's root cause -
wp-creation-app.js:1389 injects `body{--nav-w:288px}` with no media query,
which lands after wp-creation-styles.css:815's
`@media (max-width:860px){body{--nav-w:56px}}` and overrides it, so the page
reserves 288px of rail that is not there at any width. Every `right: 0` fixed
element on the page is displaced by it, not only this one.

Left unfixed on purpose - it is the creator's layout, T7.1 rebuilds it, and
CLAUDE.md is explicit about not fixing things noticed in passing. BL-001 now
carries the exact cause and the five rules that consume the token, so T7.1 does
not have to find it again. The probe reports it as an attributed note naming
BL-001, so nobody is sent to the wrong file.

browser_check 71/71. f_items: F1, F2, F3, F4 FIXED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 18:40:28 -05:00
d9f96f20e1 T1.3 - F3: give the SOP header's groups a defined relationship
.header-left and the injected chrome were in a dead tie. Both were
`flex: 1 1 auto` with `min-width: 0`, so both claimed the same run of the bar
and both were allowed to shrink to nothing. The chrome's content is wider, so
it won every time: .header-left computed to clientWidth 0 while its
flex-shrink:0 logo kept its 106px and overflowed underneath the project
switcher. With the real project name that meant "2667008" rendered on top of
the PRIME wordmark and the name itself clipped to "on EUV Cleanroom En...".

The bar now has an order of giving way rather than a tie:

  .header-left    flex: 0 1 auto, min-width: auto   sizes to content, floors at
                                                     the logo plus the gap
  .wp-chrome      flex: 1 1 auto (unchanged)         the only one that grows
  .header-right   flex: 0 0 auto                     keeps its buttons

min-width:auto restores the content-based floor the explicit `min-width: 0` had
removed. The inner title block keeps its own min-width:0, so the project name
still gives way first, through the ellipsis .header-subtitle already carries -
truncation policy stays B2's, and nothing here silently truncates.

Two things the review did not name were colliding on the same bar and are fixed
with it. .header-right was being squeezed below its buttons, so "Load Sample"
ran underneath "Feedback". And the header was a fixed 48px holding FOUR groups,
not two - the markup's two plus what wp-chrome.js and auth-guard.js inject - so
at 1024px the overflow had nowhere to go but on top of its neighbours, and
T1.2's user-menu wrap turned that into three rows spilling onto the tab row
below. min-height plus flex-wrap lets the bar grow instead.

Header height at 1440px with a normal project name is still exactly 48px, so
desk layout is unchanged; sop-1440 differs from the wave 0 baseline only
because T1.1 gave the switcher a name to show in place of "(unnamed)". With the
long name it grows to 62px at 1440 and 82px at 1024 - wrapping rather than
overlapping, which is the point.

The F3 probe was too narrow to have caught the right-hand collisions: it
compared the logo against the chrome and nothing else. It now checks every pair
of groups sharing the bar, plus anything spilling out of it, and still reports
FIXED at 390, 768, 1024 and 1440 with the long name.

Verified at all four widths with "Micron EUV Cleanroom Enable 2667008": no
overlapping pair, nothing spilling, logo fully visible.

browser_check 71/71. f_items: F1, F2, F3 FIXED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 18:33:15 -05:00
fe8a27e022 T0.2 - baseline captured; all six rendering defects confirmed present
Runs the app from a clean database, captures the before images, and records
which of F1-F6 actually still reproduce. All six do.

Rather than eyeball screenshots, each defect is measured in a browser by
tests/f_items.py, which reports REPRODUCES / FIXED / INCONCLUSIVE and never a
silent pass. That makes it both the wave 0 record and the wave 1-3 regression
check: an item is done when its probe flips to FIXED.

  F1  hero says "Job A", app bar still says "Select a project", no reload
  F2  "Sign out" spans x382-432 in a 390px viewport - cut in half, 3 rows
  F3  chrome paints over the logo by 106x32px; .header-left collapses to 0
  F4  comments drawer overlaps the header by 380x91px in the standalone creator
  F5  5 of 5 ENABLED wizard inputs compute #f4f4f4 on #e0e0e0
  F6  11 cards in one 5,017px scroll, 0 tabs (review said ~4,700px; it grew)

Three probes needed care to avoid reporting a false pass, and the traps are
worth knowing before anyone verifies a fix:

  F1 disappears if localStorage is primed first, because then both sources of
  truth agree. The probe clears it and drives the real picker.
  F3 needs a long project name that is long IN THE DATABASE - any page reached
  with ?project= re-pulls it and overwrites a locally-faked one. It also cannot
  be measured by comparing .header-left to the chrome: under the long name
  .header-left (flex:1, min-width:0) collapses to clientWidth 0, so that
  comparison reports a tidy zero gap while the chrome paints across the logo.
  It measures against .logo, which is flex-shrink:0. My first two attempts at
  this probe both reported FIXED for those reasons; the screenshot did not.
  F5 must ignore genuinely disabled inputs or a fix looks done while real
  fields stay grey.

14 screenshots, not the 12 the plan asks for, because there are 7 pages
(file-map D1). Capture also measures horizontal overflow, which is how BL-001
was found.

Tooling: cdp.py gains viewport() and screenshot() - it could do neither, and
T0.2 requires 390px and 1440px images. 390px sets the mobile flag rather than
just narrowing the window, since every page declares width=device-width and
Chrome otherwise lays out at 980px and no media query under test fires. Both
new scripts reuse browser_check.py's seed() and start_server() instead of
growing a second fixture. Existing browser_check still passes 71/71.

No application code changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 18:06:36 -05:00
9459e76a6c Add a front-end browser check, so the pages are testable and not just readable
server/smoketest.py proves the API works; nothing proved the PAGES work. That gap
is why users.js, wp-sidenav.js and the extracted console.css shipped unexecuted and
had to be written up as a known issue instead of verified. This closes the gap with
a tool rather than a one-off, so the next front-end change is cheap to check.

  tests/cdp.py           a minimal DevTools Protocol client — hand-rolled stdlib
                         WebSocket (handshake, masked frames), browser discovery for
                         Edge/Chrome across platforms, and process teardown.
  tests/browser_check.py the fixture and 71 assertions.

Stdlib only, matching smoketest.py's rule: these have to run on a plain Python
install on whatever machine is to hand. No pip, no Selenium, no node.

Self-contained — it builds a throwaway database, seeds a fixture, starts its own
uvicorn on a free port, drives the browser, and tears everything down. The real
database is never touched. Sessions come from minting a token with the app's own
auth.create_token() rather than scripting the login form.

What it asserts, beyond "no JavaScript errors on boot" (the thing that actually
went unverified): the three role-dependent renderings of the directory, one-line
rows and no sideways scroll, the roles each caller may grant, the project-access
dialog opening and closing, the drawer's open/Escape/scrim/focus/aria behaviour and
its role gating, ?project= carried only onto project-scoped links, and — the reason
this matters most — that admin.html still has its tokens, cards, headings and dense
sticky tables after console.css was lifted out of its inline <style>.

Three things the build had to get right, each learned the hard way:

  - Teardown kills the browser's whole process tree AND sweeps anything still
    holding the unique temp profile, matched on that path so a browser window the
    user has open is never touched. proc.kill() alone left 98 strays.
  - Launching retries with a fresh profile and port: a browser can hand off to
    another instance and exit rc=0 without ever binding the debugging port.
  - Cleanup waits for the server to exit and disposes the harness's own SQLAlchemy
    engine before removing the temp directory, or the open SQLite file blocks the
    delete and ignore_errors hides it.

The fixture includes an account on a project the super user cannot see, without
which the admin and the super user would see the same number of rows and the
scoping assertion would prove nothing.

Documented in DEPLOYMENT.md next to the smoke test. 71/71 across repeated runs,
leaving no stray processes or temp directories.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 16:09:04 -05:00