Commit Graph

152 Commits

Author SHA1 Message Date
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
6fbc5b9735 Wave 4 exit - record the new probes and move the baseline counts
Wave 4's five exit criteria, and what shows each of them:

  counts come from the server, identical across users   aggregates_check 16/16
  work packages addressable by URL, incl. signed-out    url_state_check  23/23
  drafts autosave, unsaved work guarded, save honest    autosave_check   34/34
  toasts announce, helper text legible, focus visible   a11y_check       22/22
  X1 and X5 cleared                                     see below

X1 is cleared: a work package has an address, and WPUrl.absolute() is what
CR-011 and CR-014 will paste into an email in wave 8.

X5 is cleared: /api/wps/metrics returns by_location with its dimensions declared
alongside the groups, so CR-004 and CR-018 can group by building/floor/sector in
wave 6 by changing the dimensions and the keys, not the response shape.

Four metrics moved off their wave 0 baselines and are recorded in the file map so
wave 9 measures against the right numbers. The one that matters most is not in
the plan's list: `outline: none` went from six to one, and the survivor has its
replacement in the rule directly above it.

The file map also now says to run the browser probes ONE AT A TIME. Chained, they
exhaust the headless browser's ports and abort with "browser would not start",
which reads as a code fault and is not one - it cost a confusing red run here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 22:21:42 -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
12c0e5ca74 Wave 3 exit - record what the wave actually changed on screen
wave-3.md asks for "no unintended visual change on any page versus wave 2".
Measured with tests/token_check.py, b105135 against the end of the wave, all 7
pages: 7 elements changed and all 7 are the fixes the wave was for - two
.user-pick selects going white (T3.4) and five green action buttons going blue
(T3.5). The other 3,493 elements compute exactly what they computed at the end of
wave 2, and all 178 wave-2 token names resolve to the same literals.

Three fixes are invisible to a computed-style diff and are recorded with how they
were verified instead: two green buttons that need application state the fixture
does not reach, and the sentence-case pass, which changes text rather than style.

The one exit criterion not met is carried forward explicitly rather than ticked:
T3.2's "no page stylesheet declares a raw spacing or type value" is complete for
colour and open for spacing and type (BL-010).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 19:13:42 -05:00
ce6fb840f0 T3.5 - A5: one button system, and green goes back to being a status
251 buttons across the 7 pages, counted in the browser with every wizard step,
creator section and tool panel forced visible. Two thirds of them are
display:none at load, so a static grep sees about eighty and misses the rest.

FOUR ROLES, defined once in theme-light.css as --wp-btn-*, and no fifth:

  primary    the one action the screen exists for. Filled accent.
  secondary  every other real action. White, --border-strong, accent on hover.
  tertiary   navigating or undoing. No fill, no border, accent text.
  danger     destructive. Outlined red; filled red only where the control is too
             small for an outline to read - the 28px x on a sequence row.

Every button class is mapped to a role in docs/reference/tokens.md section 12.
No value is new: these are the fills the sheets already rendered, given one
definition so that "primary" means one thing.

GREEN IS A STATUS COLOUR AND NO LONGER FILLS A BUTTON. A5 names two green action
buttons; there are four. .use-btn and the launcher's completed-SOP card button
never render green in the default fixture, so the review could not have seen
them - the SOP has to be finished and a suggested value has to be offered first.

  .nav-btn.primary            "SOP complete"    wizard
  .btn.btn-generate           "Save & view"     creator
  .use-btn                                      creator
  .card.complete .card-button                   launcher

The green did not go anywhere. .cstatus button.on-cleared, .toggle-btn.enabled,
.wp-nav-dot.ok, .rb-ready, .badge-R and the launcher card's own left border and
status line all still carry it, and every one of those is a state rather than an
action. The launcher card in particular still says "complete" twice after this
change; it just no longer says it on the button.

SENTENCE CASE, applied to buttons and field labels only, which is the scope A5
sets. First word capitalised, the rest lowercased, acronyms and external proper
nouns left alone (SOP, QC, WP, UPN, PM/APM/CM/QM, PDF, JSON, CSV, BIM, MIMO,
Excel, Acumatica).

  ~30 button labels across launcher, wizard, creator, admin and two scripts
  46 field labels
  text-transform:uppercase removed from 4 rules - .btn and .add-btn (creator
  buttons), label and .cmt-namebar label (creator field labels)

Labels carrying markup - a .req asterisk, a .help-tip chip - had only their text
nodes transformed, so the markup survives and "first word" means the first word
of the label rather than of each fragment. The creator's mono face, 10px size and
tracking are its idiom and are untouched; only the forced uppercase goes.

help.js was updated too. It names "Load Sample" and "SOP Complete" in prose, so
renaming the buttons without it would have left the help centre describing
controls that no longer exist. That coupling is the only place in the app where
button text is referenced by name.

Verified by re-running the inventory: 0 green action buttons, 0 uppercase button
labels, 251 buttons still present - nothing was lost in the rename.

console.css card headers are unchanged, confirmed by diff: the only six lines
this task touches in that file are token substitutions on button/button.primary/
button.danger, none of them within twenty lines of .card h2.

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

Left alone and logged: .step-tab is still uppercase (BL-015) - it is a stepper
tab, neither a button nor a field label, and A4/S9 rebuild the stepper. Table
headers, section eyebrows and headings keep their case throughout. BL-008 and
BL-009 were re-targeted from T3.5 to wave 9: both are colour merges on a field
fill and a status pill, and this task is scoped to buttons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 19:11:56 -05:00
03d24138ed T3.4 - A3/F5 full, BL-002: the wizard's fields look editable and focus is visible
T1.5 stopped editable fields looking disabled by reaching past the wizard's own
token block with a fallback: background: var(--cds-field, var(--bg-card)). T3.2
deleted the local tokens, so the fallback is now dead code and the canonical
token is simply consumed. The interim comment is gone; grep for INTERIM in the
sheet returns nothing, and the sheet declares no field-specific colour.

Measured in the browser rather than asserted - every enabled text control in the
suite, side by side:

  sop .field input        #ffffff on #8d8d8d 1px
  sop .user-pick          #ffffff on #8d8d8d 1px
  creator .field input    #ffffff on #8d8d8d 1px
  console .toolbar input  #ffffff on #8d8d8d 1px
  launcher .proj-row      #ffffff on #8d8d8d 1px

.user-pick was not identical before this commit, and that is the one thing here
beyond the written task. The sign-off name pickers were --bg on a --border
hairline - #f4f4f4 on #e0e0e0, which is precisely the grey-on-grey that F5/A3 is
about. They were missed because the review counted inputs inside .field and these
two sit outside it, which is why they needed their own rule in the first place.
Fixing the token duplication without fixing them would have left the defect on
the page while reporting it closed.

Disabled fields stay distinguishable: f_items F5 reads them at #f4f4f4 on the
same #8d8d8d border, so fill carries locked-vs-editable and the border does not
move.

BL-002 folded in, as that entry asks. All three outline:none sites in this sheet
are gone:

  .field input/select/textarea:focus  had a 3px --primary-light glow
  .user-pick:focus                    had the same glow
  .seq-step input.seq-label:focus     had NO replacement at all

#edf5ff against a #ffffff field is a 1.05:1 edge - a faint halo on the card and
nothing at all on the field. Replaced with the ring console.css:69 and
wp-chrome.css:206 already draw, so this is the app's existing idiom rather than a
fourth one: 2px of --cds-focus, inset by -2px over the control's own edge.

Verified with CDP focus emulation ON. Without it the headless page is not the
focused document, :focus never matches, and every reading comes back as the
unfocused style - which looks like a pass and is not one. It cost a wrong answer
before I noticed, so it is worth saying. .user-pick and .seq-label also live on
later wizard steps, which are display:none and cannot take focus, so the probe
reveals the steps before measuring:

  .field input / select / textarea    2px solid #0f62fe  offset -2px
  .user-pick                          2px solid #0f62fe  offset -2px
  .seq-label                          2px solid #0f62fe  offset -2px

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

Two findings outside this task's files, logged rather than fixed: BL-013, the
creator's inputs have NO outline on focus and rely on the same 1.05:1 glow, which
is BL-002's defect in the sheet next door and belongs to T7.2; and BL-014, four
controls on the launcher and field view fall back to the UA default ring, which
is visible but is a fourth idiom - T9.5 with C1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 19:00:59 -05:00
2485ca7b61 T3.3 - S5: fix .field-hint properly and retire the comment it left behind
The comment at work-package-suite-styles.css:395 recorded the consequence of the
four token systems: .field-hint's only rule lived in wp-creation-styles.css,
which the wizard does not link, so the class rendered as unstyled body text. The
rule was later added to the wizard sheet, but as a SECOND rule with declarations
byte-identical to the .field small rule four lines above it - the same
duplication that caused the original bug, in miniature.

Merged into one rule under both selectors, so they cannot drift apart again, and
deleted the comment. It described a fixed problem, and its account of where the
class is used was wrong anyway: .field-hint appears once on the wizard, not in
"step 2's CM hint, the team-member notices".

The colour is deliberately untouched. --text-dim is #8d8d8d, which measures
3.3:1 on white and which console.css:103 already rejected for exactly this job -
but propagating that fix is T4.6, which is told to re-measure rather than inherit
either published figure. Fixing it here would change a rendered value in a wave
that must produce none, and would take the decision away from T4.6.

THE HUNT for the same class of failure elsewhere, which is the rest of the task.

Statically, per page, over every stylesheet that page actually links plus its own
inline <style>: every bare var(--x) reference where --x is declared by no sheet
the page loads. That is the .field-hint failure exactly, and it is the one that
renders nothing rather than rendering wrong.

  login 32 refs, launcher 54, sop 67, creator 92, admin 100, users 100, field 56
  unresolvable: 0 of 501

Zero, and structurally zero from here on: after T3.2 every token resolves from
theme-light.css, which all seven pages link first. The failure mode is no longer
possible rather than merely absent.

In the browser, on all seven pages: every class on a real element that no rule in
any loaded stylesheet targets, including the sheets help.js and wp-creation-app.js
inject at runtime. One candidate, and it is not a defect - .team-pick on the
wizard's four project-team selects is a JS hook that sits beside data-team, and
the selects are inside .field, so .field select styles them. Its sibling
.user-pick needs its own rule only because those two selects sit OUTSIDE .field,
which the comment there already explains.

Stated as a limitation rather than left implicit: the browser half sees each page
in its load state, so a class applied only on interaction would not appear in it.
The static half has no such gap - it covers every rule in every sheet regardless
of what is on screen - and it is the half that catches this particular bug.

Verification: token_check reports every token and all 3,500 elements identical to
wave 2 on all 7 pages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 18:54:17 -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
0e40e967a0 T3.1 - C3/S5: inventory the token systems, and correct the accent baseline
Produces docs/reference/tokens.md. No stylesheet is touched; T3.1 is inventory.

What the inventory found that the plan did not say:

- It is six stylesheets plus the launcher's inline <style>, not four (D1 again).
  202 custom-property declarations, all listed with file and line.
- The wave 0 accent baseline is wrong: 15 declarations across 5 sheets, not 14
  across 4. console.css:13 packs five declarations onto one line and the
  baseline's `^\s*--` regex only ever matches the first, so console's own
  --accent was never counted. Corrected command is in tokens.md section 10.
  The wave 9 target of one sheet is unchanged; there is one more to remove.
- Three mono stacks, not two. The file map recorded console.css dropping
  ui-monospace and Segoe UI Mono; wp-chrome.css:159,221 is a third stack that
  drops Cascadia Mono and Segoe UI Mono.
- --shadow-lg does not differ by blur, as the file map says. Both are
  0 4px 16px. The difference is the colour: rgba(0,0,0,.16) against
  rgba(20,30,50,.12). That means they can be unified later with no layout
  consequence at all.
- Twelve var() fallbacks can never fire, because the token they fall back from
  is declared at :root on a sheet the page loads. Free deletions for T3.2.
- --shadow: none is a no-op token with 8 consumers. Left for T3.3, which is
  hunting exactly this class of silent nothing.
- 111 var() references live in .js files across 23 token names. A rename there
  fails silently - no build error, no console warning, just an unstyled
  element. Section 9 is the list to grep before deleting any alias.
- There is a second brand blue: #2563d6, filling .sop-inherited at 7% alpha on
  every field a work package inherited from its SOP. Logged as BL-008.

The document states one rule up front, because it is the difference between a
clean wave 3 and a broken one: consolidation is not unification. Where two
sheets declare the same value, T3.2 collapses them. Where they declare
different values for the same role - the two banner greens, the three error
borders, the two shadows - each value gets its own canonical name and the pair
is recorded. Picking a winner between two near-identical greys is a visual
change, which T3.2 forbids.

Section 8 computes the near-duplicates rather than eyeballing them. The one to
watch is the zebra stripe: console's #fafafa sits six points from #f4f4f4, and
collapsing them erases the striping on the nine-column user table.

New backlog entries: BL-004 (help.js ships 52 colours in a different design
language), BL-005 (two modals styled entirely by inline style= attributes),
BL-006 (17 half-pixel font sizes), BL-007 (--radius: 0 contradicted 45 times in
the sheet that declares it), BL-008 (the second blue).

One decision T3.2 needs and this task cannot make: adopting the superset mono
stack changes the rendered face on machines that have Segoe UI Mono or
ui-monospace but not IBM Plex Mono, which is most of the target environment.
That is a real change on admin and users. Either accept it and re-shoot those
two baselines - capturing twice, since they are not byte-stable - or keep
console.css's narrower stack as a second token until T3.5. Written up in
tokens.md section 6d and 8-H; built to neither until it is answered.

Verification: f_items 5 FIXED / F6 REPRODUCES as expected, browser_check
71/71. Screenshots not applicable - this task changes no rendered surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 18:24:52 -05:00
b105135a98 T2.3 - B2: a breakpoint plan for the project switcher, not an ellipsis
"Micron EUV Cleanroom Enable 2667008" did not fit. The switcher capped the name
at 240px inside a 280px button, so the real project name was ellipsised to
"Micron EUV Clean..." on the one control whose job is to tell you which job you
are in - which is the failure this item exists to prevent.

The plan, now written into docs/reference/file-map.md so it is not re-litigated:

  >= 1024px   the full project NAME. Caps raised to 400px / 340px, which is
              enough for a real name with no ellipsis.
  <  1024px   the project NUMBER alone, e.g. 2667008. Dropping the name beats
              shortening it: short, stable, unambiguous.

The number is no longer prefixed to the name at wide widths. Real names already
end with their number, so the old format printed it twice - the test project
rendered as "2667008 - Micron EUV Cleanroom Enable 2667008".

The full name stays reachable at every width by three routes, none hover-only,
because the Field View is a touch surface:
  - the drawer, which now shows the active project under its head, wrapping onto
    as many lines as it needs and never truncated. This is the guaranteed route,
    and it subscribes to ProjectData.onActiveChange so it does not go stale when
    the launcher switches project without reloading.
  - the switcher's title attribute, carrying the full number and name, which
    surfaces on keyboard focus as well as hover.
  - the switcher popover, which lists every project in full.

Crossing the breakpoint re-labels, so a resized window does not keep whichever
form was chosen at load.

Verified with the real long name at 390, 768, 1024 and 1440: nothing truncated
at any width (scrollWidth vs clientWidth, not by eye), and the drawer carries
the whole name at all four. T1.1 correctness holds - the switcher never reads
"Select a project" while a project is active.

f_items: F1-F5 FIXED, F3 still passing at all four widths with the wider
switcher. browser_check 71/71.

Wave 2 complete: one drawer on six pages, no flat strip, F2 resolved for real,
S4 closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 17:32:14 -05:00
05aa67ff32 T2.2 (part 2) - B1/S4/F2: delete the flat strip, unify the brand
Completes T2.2. The flat strip was #wp-usermenu, built in auth-guard.js and
appended to every bar: "Root . Admin . Users . Language & time . Password .
Sign out". It duplicated Admin, Users and Sign out from the drawer, and being
one unbreakable 412px run it was also the thing that clipped the bar at 390px.
Deleting it is what resolves F2 for real, exactly as the wave predicted.

Its two unique items moved into the drawer rather than being lost with it:
Language & time and Password now sit under an Account section. They open dialogs
rather than navigating, so they render as <button data-action>, not links -
which also removes three fake `<a href="#">` entries whose only job was to be
clicked. The creator's jump-link count goes 3 -> 0 for that reason. The drawer
closes before the dialog opens, so its scrim is not left over the thing you just
asked for. wpPreferences and wpChangePassword are untouched, so A7's
localization feature is intact - it moved, it did not go.

buildUserMenu, addLogoutPill, isDarkBg and the floating-pill fallback are all
gone; grep finds no reference to any of them. The fallback is not needed because
every signed-in page now mounts the drawer. The T1.2 interim went with them
rather than being layered on - the element it wrapped no longer exists.

Also removed the Field View's "Home" link, which the drawer's first entry and
the brand beside it both already offered, and replaced the creator's mono
wordmark with the shared logo chip. The creator was the only page in the suite
spelling "Prime Controls" out in monospace instead of showing the mark; its
.header-logo rule and the 480px override for it are deleted.

Deliberately kept: the launcher's Overview / Feedback / Help links. Those are
in-page anchors and a help dialog belonging to that page, not global navigation,
so they are not the strip and the drawer does not duplicate them.

Verified on all 6 pages at 390px and 1440px: no #wp-usermenu anywhere, no bar
control crossing the viewport edge at either width, the logo chip present on
every page including the creator, and the same 10-entry drawer everywhere. The
bar is back to 48px at 1440px on five of six pages. F2's probe reports the bar
at 2 rows instead of 4. Both migrated actions verified to open their real modals
(wp-prefs-modal, wp-pw-modal) with the drawer closed behind them.

browser_check 71/71. f_items: F1-F5 FIXED, F6 untouched.

T2.3 (B2, switcher breakpoints) is still open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 17:26:45 -05:00
d3d2a46922 T2.2 (part 1) - S4: mount the shared drawer on the launcher, SOP wizard and creator
PARTIAL. This is the mounting half of T2.2 and it closes S4. The other half -
deleting the flat nav strip, unifying the brand treatment, and removing the T1.2
interim - is NOT in this commit. See below.

S4 is that global nav was missing on the two pages people actually live in. The
drawer already handled everything needed to fix that: it self-mounts into
.wp-appbar OR .header, gates its links by role, carries the active ?project=,
and returns early inside an iframe. So this is two tags per page, not a port -
the component was built for this and was simply never included.

Verified on all 6 pages at 390px and 1440px: the menu button and the drawer
exist, both carry the same 8 links, the drawer opens fully on screen, its first
link is hit-testable via elementFromPoint rather than merely present, aria-hidden
flips correctly, and Escape closes it. Drawer links measure 44px tall, which is
the tap-target size T2.2 asks for on the Field View.

The embedded creator correctly does NOT get a second drawer: wp-sidenav.js
returns early when framed, and the SOP wizard hosting it already has one.

Deliberately NOT done here, and still open on T2.2:
  - the flat nav strip is not deleted, so the drawer and the strip currently
    duplicate Home, Admin, Users and Sign out
  - the creator's mono wordmark is not yet unified to the logo chip
  - the T1.2 interim is still in place and must be removed, not layered on
Splitting it this way keeps a working app at every commit: mounting is additive
and regression-free, whereas deleting the strip changes where several
destinations live and wants its own diff. T2.3 (B2 breakpoints) is untouched.

browser_check 71/71. f_items unchanged: F1-F5 FIXED, F6 untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 20:46:07 -05:00
0a080be1f8 T2.1 - B1 part 1: the drawer is already one shared component; verified, not rebuilt
No code change. T2.1 asks to lift the drawer out of "three near-copies" into one
shared implementation. There are no copies to lift.

html/wp-sidenav.js and html/wp-sidenav.css are already a single implementation
included by admin, field view and directory. .wp-sidenav, .wp-navscrim and
.wp-navbtn are each declared in exactly one file, and no page defines its own
drawer. The review's sentence was that the drawer "ships on 3 of 6 pages while a
flat strip duplicates it" - the duplication is between the drawer and the flat
strip, not among three drawers. The plan read "3 of 6 pages" as "3 copies".

It already satisfies every T2.1 done-when, including the one that could have
failed: activeProjectId() at wp-sidenav.js:62-68 reads ?project= and falls back
to ProjectData.getActiveId(), which is the single source T1.1 established. It
also self-mounts into .wp-appbar OR .header and returns early inside an iframe,
so it is already built for the pages T2.2 has to put it on.

Refactoring a single shared component into a single shared component would be
churn with regression risk and nothing gained, so nothing was touched. Recorded
as file-map D5 so the next reader does not go looking for the copies either.

The real work of this wave is T2.2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 18:54:32 -05:00
357712e93e T1.6 - S13: seed_demo.py can seed a running instance again
Every /api/ route but /api/health requires a session and the script sent none,
so it could not seed anything. It predates the commit that taught the smoke test
to sign in.

It now signs in the same way, reusing smoketest.py's build_opener rather than
growing a second cookie-jar implementation - one login flow, one place to fix.
Credentials come from WP_SEED_USER / WP_SEED_PASSWORD, falling back to
WP_SMOKE_USER / WP_SMOKE_PASSWORD so one set serves both scripts, and it signs
out in a finally.

No bypass, no debug flag, no unauthenticated seeding route: the diff touches
server/seed_demo.py and nothing else, adds no route decorator anywhere, and the
33 get_current_user dependencies in app.py are untouched. The script
authenticates like a client; the server is not weaker than it was.

Two things found while fixing it:

The failure mode was worse than a refusal. call() swallowed the HTTPError and
returned the error body, so a 401 surfaced as a KeyError on proj["id"] three
lines later - which reads like a broken stack rather than a missing session.
Writes now go through expect(), which stops on the first refusal and prints the
status and detail.

Running it twice used to print a note that scrolled past and then create a
second identical DEMO project, leaving two of everything with no way to tell
them apart. It now refuses, names what exists, and prints the --clean command.

Also corrected the header's own instructions, which said the seeded SOP and Work
Packages would NOT render in the UI because the front end still read them from
localStorage "pending Phase 2 wiring". That stopped being true when the sync
layer landed. Selecting the seeded project now shows 7 Work Package cards in the
Field View, so anyone using the UI to check whether seeding worked is no longer
told to expect nothing.

Verified against a freshly started instance: no credentials aborts cleanly with
exit 2 and no traceback; a first run exits 0 and seeds a project, a complete SOP
and 9 packages; a second run exits 1 without duplicating; the data is visible in
the picker, the hero, the app bar and the Field View; --clean removes it and
exits 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 18:52:31 -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
440f3239a4 T1.2 - log the two backlog entries the commit message referenced
BL-001 updated: its 1440px half was resolved as a side effect of the F2 fix,
not by intent. Left open, scoped to the creator at 390px, so T7.1 still checks
it.

BL-003 added: user-menu links are 16px tap targets. T1.2 made them reachable;
it did not make them comfortable. Deferred to T2.2, which replaces the markup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 18:26:06 -05:00
0f6f91ce2c T1.2 - F2 (interim): the app bar no longer clips at 390px
INTERIM. T2.2 is the real fix: wave 2 replaces this markup with the existing
drawer. Nothing here is meant to survive that, so it is the smallest change
that makes every control reachable, not a redesign - no hamburger, no
responsive menu, no avatar dropdown.

The bar already wrapped at 720px, so the wrap rule was not the problem. The
problem was #wp-usermenu, built in auth-guard.js with an inline
white-space:nowrap on the container: "Root . Admin . Users . Language & time .
Password . Sign out" became one unbreakable 412px run inside a 374px bar. Being
inline and unclassed, no stylesheet media query could reach it. At 390px that
put "Sign out" at x382-432 - half of it past the edge, exactly as the review
described.

The container now wraps and each link carries nowrap instead, so "Language &
time" still breaks as a unit rather than mid-phrase. Bar scrollWidth at 390px
goes 424 -> 374, and "Sign out" moves onto its own row, fully visible.

The truncated search is the other half of F2. The control was always usable -
what was cut was the placeholder - so below 620px, the breakpoint wp-chrome.css
already uses for this element, it reads "Search..." instead of "Search work
packages, projects, SOPs...".

Verified at 390px on all 7 pages: no bar control crosses the viewport edge, and
"Sign out" is fully within it everywhere. At 1440px the screenshot diff against
the wave 0 baseline is byte-identical for login, launcher, SOP wizard and field
view. Three pages differ, all intended: admin and users because T1.1 gave their
bar a project to show, and the creator because this change removed its
horizontal overflow.

That last one is worth flagging: the same unbreakable menu run was the cause of
four of the five overflows recorded in wave 0, including BL-001, the creator
scrolling sideways at 1440px. Overflow at capture is now 1 of 14 shots rather
than 5 - only the creator at 390px remains, which is its own layout and is
T7.1's to resolve. BL-001 is updated rather than closed, so T7.1 still checks
it.

Tap targets in this menu are 16px tall. Not touched here - it is C1's, audited
in wave 9 - and logged as BL-003.

f_items F2 FIXED. browser_check 71/71.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 18:25:33 -05:00
d22834f2f1 T1.1 (cont.) - F1: fill in the id-only stub, and resolve deep links in the bar
Completing T1.1. My first verification primed localStorage before loading each
page, which made both sources of truth agree and hid two remaining cases. Re-run
with genuinely cold storage, the app bar still showed "(unnamed)" on the field
view and "Select a project" on the console pages.

Two causes, both the same F1 shape - a page holding a copy the shared store
does not have:

1. field.js could only write {id} at boot (it needs the id synchronously, for
   the per-project storage namespace), then resolved the full record into a
   local PROJECT variable, rendered "Project: Job A" from it, and never
   published it. The store kept the stub, so the bar read "(unnamed)".

   setActive now fills a nameless record in from the cached project list, or
   from the API when the cache has not loaded yet, and re-checks the id before
   applying a slow response so it cannot overwrite a project the user has since
   switched to. That fixes every caller of this shape rather than the one that
   was caught - work-package-suite-app.js and wp-creation-app.js write the same
   stub. field.js also publishes the record it already fetched, so the common
   path costs no extra request.

2. admin.html and users.html have no project-resolution logic of their own, so
   nothing read ?project= and a deep link left the bar on whatever was last
   stored. The bar is the one component every chromed page has, so it resolves
   the parameter once in wp-chrome.js rather than being taught to five pages.

Verified with localStorage cleared before every navigation: a cold deep link
now shows the project on field, SOP wizard, launcher, admin and users, and the
stored record carries the name rather than a stub.

The creator remains the one page with no app bar - it loads no chrome because
it renders as the iframe child. T7.1.

browser_check 71/71. f_items F1 FIXED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 18:25:06 -05:00
5d5511a458 T1.1 - F1: one source of truth for the active project; the app bar subscribes
The hero, the picker and the create-user card showed the active project while
the app bar still read "Select a project". Three separate causes, all of them
the same shape - a reader with its own copy of the value.

1. Nothing told the bar. wp-chrome.js rendered projectLabel() once at build
   time and refreshed it only when /api/projects came back, so selecting a
   project updated the hero and left the bar behind. ProjectData.setActive now
   notifies, and the bar subscribes through ProjectData.onActiveChange instead
   of holding a copy. A plain array of callbacks - this is one value with a
   handful of readers, not a reason for a state library.

2. admin.html and users.html load wp-chrome.js but never loaded
   project-data.js, so window.ProjectData was undefined and their bar could
   NEVER show a project - it read "Select a project" permanently, whatever was
   selected. Both now load it, ahead of wp-chrome.js.

3. setActive({id}) erased the name. field.js, wp-creation-app.js and
   work-package-suite-app.js all set the id first and the full record second;
   writing that stub verbatim left the bar rendering "(unnamed)". setActive now
   merges onto the stored record when the id matches, so a partial write cannot
   lose fields it did not mean to touch.

Also: index.html never honoured ?project=<id>, though every other page does, so
a deep link on a browser with nothing stored showed "Select a project" while
the URL said otherwise. It now resolves the parameter before reconciling.

setActive is the only code path that writes wp_active_project /
wp_active_project_obj - project-data.js:83-105, noted there in a comment so it
stays that way. A storage listener keeps a second tab from showing a project
the user has since switched away from.

Verified, all at 1440px and against the wave 0 baseline:
  - bar shows the project on launcher, SOP wizard, admin, field, users
  - survives a hard refresh on each of them
  - selecting a project updates hero and bar in one interaction, no reload
  - with nothing selected the bar reads "Select a project" and both the
    launcher picker and the bar's own switcher are reachable
  - deep link ?project= works on a cold browser, hero and bar agree
  - setActive({id}) after a full record keeps the name

The creator is the one page with no app bar to fix: it loads neither
wp-chrome.js nor wp-chrome.css, because it renders as the iframe child of the
SOP wizard. Giving it chrome is T7.1's work once B7 dissolves that boundary -
adding it here would put a second app bar inside the embedded view. This is the
"all 6 pages" wording in the plan meeting the 7 pages that exist; see file-map
D1.

tests/f_items.py F1 now reports FIXED. F2-F6 still reproduce, untouched.
browser_check.py 71/71.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 18:13:32 -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
c2e35b9261 T0.1 - build the file map and verify the plan's line references
Wave 0 exists because the plan's line numbers came from a review of
users/directory-super-user rather than a fresh read. This is the fresh read:
7 pages, 6 stylesheets, 11,867 lines, with each page's stylesheets, scripts and
iframe role recorded, and all seven baseline counts captured with the command
that produced them.

Four discrepancies, one of which matters a great deal:

D2 - CLAUDE.md's "logged-override path for predecessors stays (A1). See
wp-creation-app.js:1962-1972" cites the wrong function. That range is
dashIssue(), which REFUSES to issue and says "open the package to release it
early with a logged reason". The reviewer read that sentence and correctly
inferred an override exists, but cited the mention rather than the code. The
audited path is confirmEarlyRelease() at 967-984 plus seven satellites (state
at 392, call sites at 998 and 1149, persisted at 1117, rendered at 1215,
rehydrated at 1674, reset at 481/488/1744). A T7.3 that preserved only
1962-1972 would delete the business rule while believing it had protected it.

D1 - "6 pages, 4 stylesheets" is 7 and 6; wave-0's own parenthetical lists
seven names. Every "all 6 pages" done-when is off by one.

D3 - four documents the plan reads from are deliverables not yet written.

D4 - the creator overflows horizontally at 1440px, which no F item covers.
Logged as BL-001 rather than fixed, since T7.1 rebuilds that layout anyway.
BL-002 records that outline:none appears three times in the wizard sheet, not
once, so T3.4 fixes all three.

Counts confirmed against the review: 79 dialogs (43 in the creator), 12 div and
2 span onclick, 15 help-tip badges, 0 aria-live, 0 pushState. The "4
declarations of #0f62fe" needed a definition - there are 31 occurrences and 14
custom-property declarations; the 4 is the number of stylesheets declaring
their own accent token, which is the number wave 9 should drive to 1.

No application code changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:55:02 -05:00
3d99d4b9d0 Import the R2 implementation spec into the repo
The plan was delivered as wp-suite-implementation-spec.zip and lived only in
Downloads, so every "read CLAUDE.md first" instruction in it pointed at a file
the repo did not have. Bring it in unchanged: CLAUDE.md, IMPLEMENTATION.md, and
docs/waves/wave-0 through wave-9 plus backlog.md.

UX-REVIEW-2026-08-14.md is committed alongside it. It is the review that
produced F1-F6, S1-S13 and the A/B/C assessments, and item IDs throughout the
wave files cite it, so it belongs under version control rather than sitting
untracked in the working tree.

No application code changes here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 17:42:56 -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
7f831bf1ca Close the browser-verification gap: the front end has now been run
Deletes known issue 3. users.js, wp-sidenav.js and the extracted console.css had
never been executed, because there is no node/deno on the machine they were written
on. Edge is, so the pass was driven through the DevTools Protocol with a hand-rolled
stdlib WebSocket client, signing in by minting a session with the app's own
auth.create_token() rather than scripting the login form.

70 checks, twice, all passing — covering the five steps that entry listed:

  1. users.html as an admin: 9 columns, one-line rows, no sideways scroll, all four
     grantable roles, the project-access dialog opening and closing on Escape, and
     your own permissions cell locked to a tag while your job function stays editable.
  2. As a Project Super User: banner naming the project, only in-scope accounts
     listed, out-of-scope rows read-only with the reason on hover, and exactly the
     two roles they may grant.
  3. As an ordinary project user: 6 columns, no create form, zero controls, emails
     still reachable as mailto links.
  4. field.html: drawer opens, closes on Escape and on the scrim, aria-expanded and
     aria-current correct, focus moves inside, 44px tap targets, Admin Console hidden
     from non-admins, and ?project= carried onto project-scoped links only.
  5. admin.html: console.css loaded, --ctl resolving, cards and headings and sticky
     dense tables intact after the extraction, user administration gone and replaced
     by a link, and the admins-only gate still holding for a non-admin.

Every page boots with no JavaScript errors, which was the actual unknown.

Two things the pass surfaced, neither a defect: a 404 on /api/sops/latest is the
API's designed answer for a project with no SOP ("No SOP found") and the browser
logs every 4xx, so the fixture now seeds one; and role pills only appear where a row
is rendered read-only, since an editable row shows a dropdown instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 15:53:37 -05:00
64a5fd5612 Make the smoke test sign in; enforce SQLite foreign keys
Closes known issue 3. server/smoketest.py predated the login portal and had no
login step at all, so auth_gate refused every route after /api/health and the
documented way to verify a deploy reported a wall of failures against a healthy
stack.

  - Signs in first, holding the session in an http.cookiejar on a shared opener.
    urlopen() has no cookie support, which is why the session was dropped.
  - Credentials from WP_SMOKE_USER / WP_SMOKE_PASSWORD, or --user/--password, so
    a password need not land in shell history. Refuses to start without them
    rather than running headlong into 401s.
  - Checks the signed-in role up front and warns when it cannot archive or delete
    a project, instead of failing six checks later for an unexplained reason.
  - New exit code 2 for "could not run" (unreachable, or credentials missing or
    rejected), kept distinct from 1 "ran and found problems".
  - Also asserts the session is accepted on an authenticated route and refused
    after sign-out; signs out at the end so a run on a shared host leaves none.

The working smoke test immediately caught a real bug: SQLite ships with foreign
keys disabled and the pragma is per-connection, so every ondelete="CASCADE" was
silently a no-op on dev while working on Postgres. Deleting a project orphaned its
SOPs, work packages and membership rows; deleting a user orphaned theirs. db.py
now sets PRAGMA foreign_keys=ON for SQLite, so dev matches production.

Enforcing them exposed two things that had been getting away with it:

  - create_user adds an account and its ProjectMember rows in one flush, and the
    ORM takes flush order from relationship() declarations. models.py has none by
    design, so it emitted the child INSERT first and the database rejected it.
    Fixed with a db.flush() after the account, and documented at the top of
    models.py so the next same-flush pair does not rediscover it. The other three
    call sites already commit the parent first.
  - A write aimed at a since-deleted project used to leave an orphan row; with FKs
    enforced it would have been an IntegrityError surfacing as a 500, which the
    browser outbox retries forever (it only retires 4xx). require_project_writable
    now refuses a vanished project with 409, like the archived case beside it.

Verified: smoke test 27/27 exit 0 against a live server (the cascade assertion now
passes on SQLite, which is what used to fail); credentials missing and credentials
rejected both abort cleanly with exit 2 and no stray PASS lines; a project_user run
warns up front and fails as described. Scope tests 93/93, live HTTP checks 29/29,
static JS checks 33/33. No orphan rows left in the database afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 15:19:36 -05:00
c99ef08cf1 Record the smoketest auth gap and the browser-verification gap
Two things surfaced while building the User Directory that are worth a decision
rather than a mention in a handover.

3. server/smoketest.py has no login step, so auth_gate 401s every check after
   /api/health. DEPLOYMENT.md presents it as the way to prove the stack works
   end-to-end, including a docker compose exec invocation, so the documented
   verification path reports failure on a healthy system -- the failure mode most
   likely to be believed. Rated Medium for that reason. Predates the login
   portal; confirmed unrelated to this branch by stashing it and re-running. The
   Admin Console's in-browser smoke test is the working equivalent today.

4. users.js and wp-sidenav.js have never been executed -- no JS engine on the
   machine they were written on. Logged as a verification gap, not a defect, with
   what WAS checked (server tests, delimiter balance, handler resolution, id
   targets) and what only a browser can settle (layout, transitions, focus trap).
   Includes the five-step manual pass that closes it, and the hard-reload note,
   since sw.js bumped to wp-suite-shell-v6 and a soft reload serves the old
   shell.

Both entries follow the file's existing shape: what is wrong, what it costs, why
it is still open, what closing it takes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 18:06:47 -07:00
4ace2afb1c Move user administration to its own page; add Project Super User
User accounts lived in the Admin Console, which is admins-only. Project admins
need to create the accounts on their own jobs without an app admin on the phone,
so accounts move to a new User Directory page and a new role carries the right.

server/auth.py, server/app.py
  New permissions role `project_super_user`, between admin and project_admin:
  everything a project admin may do, plus user administration SCOPED to the
  projects they hold the role on. Four limits make it safe to hand out, all
  enforced server-side:

    * Scope comes from projects, not the job title. It resolves per membership
      (managed_project_ids), so an ordinary account can hold it on one job via
      ProjectMember.role, and a super user demoted on one job administers
      nobody there. No projects, no authority.
    * Account-level changes (password, disable, rename, permissions, delete)
      require EXCLUSIVE scope: refused when the target is also on a project the
      caller does not administer, because those changes are global. The
      directory renders such rows read-only with the reason.
    * No admin or super-user targets, and neither role can be granted by a
      super user -- that is the line that stops it becoming app-wide control.
    * PUT .../projects rebuilds only the caller's own slice; memberships on
      projects they do not administer are left untouched. A payload that simply
      omits them must not cut someone off a job the caller cannot see.

  Creating requires naming at least one of your own projects: an account with
  none would be one the creator instantly cannot manage.

  /api/auth/users is now scoped rather than admin-only, and carries a per-row
  `manageable` verdict plus the reason. Non-managers get a contact card only --
  a project user has no business reading colleagues' login history. New
  /api/auth/user-scope tells the page what it may offer. Administrative
  password resets are now audited; they were the one account change that left
  no trace. Settings, feature flags and the auto-add rule stay admin-only.

  While here: one definition of "is a user manager", derived from the managed
  set. An account-role-only version disagreed with the scoped one and locked
  per-project super users out of routes they were entitled to.

html/users.html, html/users.js
  The directory: three renderings from one page -- admin (everything), super
  user (controls per row, read-only where scope is shared), everyone else (a
  read-only directory of the people on their own projects).

html/console.css, html/console-util.js
  Extracted from admin.html/admin.js so both console pages share them. A
  divergent jsq() is an XSS and a divergent role list offers permissions the
  server refuses, so neither may exist twice.

html/wp-sidenav.{js,css}
  Global nav drawer, role-gated, carrying ?project= across links. Mounted on
  the field view (which had no way to anywhere) plus both console pages.

No migration: users.role is already String(20) and the new value fits.

Verified: 93 scope/gate tests, 29 live HTTP tests through the real dependency
stack, 33 static JS checks. Not verified in a browser -- no JS engine on this
machine -- so users.html and field.html want one manual load.

server/smoketest.py still fails with 401s. Pre-existing: it has no login code,
so auth_gate refuses it. Confirmed unchanged by stashing this work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:36:14 -07:00
3cccdf1c4b Merge branch 'docs/deploy-runbook': project archiving, default members, admin console rebuild
Brings in the 2026-08-05 work plus the deploy runbook and KNOWN-ISSUES.md.
Carries migration a7c31f9e5b02 (additive, with server defaults). No overlap with
the entrypoint/backup-script changes already on main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:46:27 -07:00
153fe97a31 Record the SOP discipline-name XSS as a known issue
It is a real hole and we are shipping without fixing it, so it needs to be written
down somewhere that outlives the conversation it came up in.

Discipline names are rendered into inline handlers in the WP creator escaped with
esc(), which maps ' to &#39;. That is right for text and wrong here: the browser
decodes entities in an attribute before the JS parser sees it, so the entity
becomes a bare quote and closes the handler's string literal. Escaping for a
handler argument has to go backslash, then quote, then HTML — esc() only does the
last part. Same bug, same ordering, as the two fixed on 2026-08-05 (jsq() in
admin.js, escHandlerArg() in work-package-suite-app.js); this one predates that
work and sits in a file it did not touch.

Left open rather than fixed because the suite is internal, behind a login, with
named employee accounts and no anonymous input path — the likely cost is a
discipline named "Owner's Equipment" silently breaking its own pill, not an attack.
The entry records the conditions that change that judgement (exposure outside the
corporate network, accounts for subcontractors or clients, self-registration), so
the rating cannot go quietly stale if the deployment story changes. Neither CSP nor
the CSRF gate mitigates it, and both are noted so nobody re-derives that hopefully.

Also records the archived-project rough edge from the same day: the server refuses
writes with 409, but the two big apps still present Save and Issue buttons, so the
failure is safe but late. data-wp-archived is already on the document element for
whoever closes it.

Each entry says what closing it takes, and entries get deleted in the commit that
fixes them — otherwise this file becomes a museum instead of a queue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:56:27 -07:00
928ab8c900 Archive projects, auto-add default members, rebuild the admin console
Three things asked for together, plus the migration they share (a7c31f9e5b02 —
additive, with database defaults for existing rows, so unlike the users.role
rewrite it is safe under a code-only rollback).

ARCHIVE A PROJECT. A finished job leaves every picker, switcher and search, and
freezes read-only, without losing anything. Hiding is free: GET /api/projects
defaults to archived=exclude, so the home picker and the app-bar switcher drop it
without either of them changing. Freezing is require_project_writable(), which
every write that lands on a project now goes through — SOP and WP upserts (both
ends, so a package can be moved neither into nor out of an archived job), deletes,
issue, status, WP archive, and comments on its WPs/SOPs. It answers 409, not 403:
nobody lacks a permission, the project's state is the objection, and the browser
outbox in project-data.js retires 4xx ops instead of retrying them against a job
that will never accept them. Unarchive and delete stay allowed on purpose —
unarchive is the one write an archived project must take, and archive-then-delete
is a normal sequence.

DEFAULT MEMBERS ON NEW PROJECTS. users.auto_add_projects / auto_add_role flag the
people who belong on every job, so an admin says it once instead of remembering it
at each project creation. It runs on the is_new branch of upsert_project, which is
the single road into project creation, so the home page, the sample project and the
demo seeder are all covered and an update never re-runs it. Note the interaction
with the existing creator-grant: that row commits first and add_default_members
never overwrites an existing membership, so the creator grant now carries the
creator's own auto_add_role — otherwise someone flagged "Project Admin on every
job" would land as a plain member on the one job they started themselves.

ADMIN CONSOLE. The user table had outgrown .wrap{max-width:860px}: nine columns in
an 860px card meant every cell wrapped, so one user occupied a ~100px band, the
action buttons stacked, and the table spilled outside its own white card. Now
1240px, with wide tables scrolling inside .tscroll so the page itself never scrolls
sideways, and one spacing/control scale across all twelve cards. Truncation hangs
off a span inside the cell rather than max-width on the td, which table-layout:auto
treats as advisory — the usual reason cell ellipsis works in the stylesheet and not
on the page.

Found in review and fixed here rather than later:

- Stored XSS in the new Projects card, reachable by any signed-in user, landing in
  an admin's session. The uesc(v).replace(/'/g,"\'") idiom this file already used
  in eight places escapes in the wrong order — uesc leaves backslashes alone, so a
  stored name containing \' closes the JS string literal and the rest executes.
  jsq() does backslash, then quote, then HTML, and all thirteen handler bindings go
  through it. The same bug, unescaped entirely, was in the SOP builder's custom
  constraint names (escHandlerArg there). Three of seven test payloads escaped the
  literal under the old idiom — one of them a plain name ending in a backslash, so
  it was breaking buttons for innocent input too.
- _save_comment resolved wp_id and sop_id with if/elif but stored both, so a
  payload naming a WP you may touch and a SOP you may not was authorised on the WP
  alone and still wrote into the other project's thread. Both are checked now.
- Promoting an account to admin left its default-member flag set but invisible,
  ready to take effect again on demotion — cleared, as set_user_auto_add already
  does for the role.

smoketest.py and the console's own smoke test both assert the archive round trip:
out of the default list, present with archived=all, writes refused with 409, and
all of it undone by unarchiving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 13:48:43 -07:00
7bb1f66588 Merge pull request 'Add entrypoint fallback for missing bind-mounted scripts' (#2) from scripts/entrypoint into main
Reviewed-on: #2
2026-08-05 01:56:49 +00:00
26f4a9242a Entrypoint static variable removed to allow validation of scripting files before startup 2026-08-04 18:51:08 -07:00
6c1dc7d45f Add entrypoint fallback for missing bind-mounted scripts
- scripts/entrypoint.sh (new): prefers the live bind-mounted backup-cron.sh, but falls back to a copy baked into the image at build time if the mount is missing. If neither exists, it stays up and idle (instead of crash-looping) so the container remains reachable via console/exec for diagnosis.
- scripts/backup-cron.sh (updated): resolves db-backup.sh the same live-or-fallback way, re-checked on every loop iteration, so if the bind mount comes back healthy later, this container picks up the live scripts on its next backup run with no restart needed.
- scripts/backup.Dockerfile (updated): bakes all three scripts into the image under /app/scripts-default/ as the fallback, and sets the new wrapper as ENTRYPOINT.
2026-08-04 18:44:34 -07:00
e5977758c0 Hand-off runbook for the 2026-08-04 deploy
A step-by-step deploy procedure for someone who administers the Docker host
but does not know this app. Two things about this deploy need spelling out for
them, and neither is obvious from DEPLOYMENT.md:

- nginx's config and all of html/ are baked into the image, so the stack has to
  be re-pulled and re-built. A restart deploys nothing and looks like a success.
- the pending users.role rewrite (b41c7ae9) is one-way as far as the app is
  concerned: rolling the API image back after it commits breaks logins, because
  the old code doesn't recognise 'project_user'. So the runbook records
  `alembic current` and both image IDs up front, and splits rollback by symptom
  — an nginx-only failure is a safe code-only rollback they can do alone, a
  failed migration is escalate-don't-improvise.

Backups go through the existing sidecar rather than an ad-hoc pg_dump: it works
from Portainer's console without SSH, writes an encrypted timestamped dump to
backups/ on the host, and prints a success line worth checking. Commands use
`docker exec <name>` throughout, since `docker compose` from an SSH session
can't find a Portainer-managed stack's compose project.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:10:15 -07:00
a9b22f2add NGINX: set Cache-Control via a map, not a nested location, so security headers survive
The Cache-Control rule I added in the previous commit used a nested
`location ~* \.(html|css|js|webmanifest)$`. nginx does NOT inherit add_header into a
block that declares its own add_header, so every HTML, CSS and JS response would have
been served WITHOUT the CSP, HSTS, X-Frame-Options, Referrer-Policy and nosniff headers
from the Phase S hardening — the headers dropped for exactly the files that matter most,
and silently, since the pages would still work.

Now computed by `map $uri $wp_cache_control` at http level and applied with one
server-level add_header alongside the security headers, so nothing is scoped away. An
empty value makes nginx omit the header entirely, so images and fonts stay cacheable.
Applied to both the Docker config (nginx/conf.d/wp-suite.conf) and the bare-metal one
(nginx-wp-suite.conf), which carries the same header set.

Caught while checking whether the stack was safe to redeploy. Not verified with
`nginx -t` — this machine has neither nginx nor docker — so DEPLOYMENT.md now records
the rule and the one-line curl that confirms both headers are present after a deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:37:18 -07:00
e3527a6e1d Act on the fragility audit: boot-order crash, real cache correctness, deep links
A 55-agent audit of the last few commits confirmed 32 findings. The high and medium
ones are fixed here; the ranked leftovers are listed at the end.

Boot-order crash (my regression, wave 2)
- wp-format.js loaded AFTER wp-creation-app.js on every page, but the creator boots
  synchronously at parse time and its comment renderer calls wpFormatDateTime(). With
  any review comment present that threw a ReferenceError and aborted the rest of boot.
  The formatter now parses before the app scripts on all five pages. Verified with a
  comment seeded: the date renders and boot completes.

The network-first fix didn't actually work
- `fetch(req)` inherits the request's default cache mode, so it consults the browser
  HTTP cache — the previous commit's "network-first" still allowed a page to run
  against a stale sibling. Code is now fetched with cache:'no-cache' and precached
  with cache:'reload'.
- Nothing pinned freshness on the wire either: no Cache-Control anywhere, so browsers
  applied heuristic caching (~10% of a file's age) and each file expired at a
  different moment. NGINX and the dev server now send no-cache for html/css/js/
  webmanifest; images stay cacheable. Verified on the wire.
- Non-ok responses were returned verbatim, so a 502 broke pages the cache could have
  served; they now fall back to the cache. Cache keys drop the query string, which
  fixes both the offline miss on every in-app link (?project=…&tab=…) and unbounded
  cache growth. respondWith can no longer resolve to undefined. Cache bumped to v5.

Embedded creator
- Dropped the &t=Date.now() cache-buster and made the frame's identity the PROJECT.
  The view and which package to open are now applied by calling into the loaded
  document, so switching tabs no longer reloads it — that reload discarded unsaved form
  edits, made the creator unreachable offline, and stored a fresh copy per click.
- ?view=dashboard was re-read on every tab switch, so after one deep link the
  "Work Package Creation" tab kept opening the Dashboard for the rest of the session.
  Deep-link params are consumed once now.
- ?wp=<id> — which the global search has been emitting since wave 2 — was read by
  nothing, so picking a work package in search opened a blank one. The creator now
  exposes openWpById() and the shell applies it after a new 'wp-creator-ready' event,
  because the frame's load fires before pullProject() resolves.
- Math.max(320,…) could make the frame taller than the space available while page
  scrolling was disabled, pushing content off a window that couldn't scroll. Full-bleed
  is now only used when at least 460px remains, and the SOP-incomplete gate never runs
  inside it. A ResizeObserver re-measures when wp-chrome.js grows the app bar.

Contract drift
- .field-hint and .user-pick are used on the SOP suite page but their only rules lived
  in wp-creation-styles.css, which that page doesn't link — the CM hint and the
  sign-off pickers had no styling at all. Rules added to the suite's stylesheet.
- The creator's critical floor now also hides modal overlays (a stale stylesheet
  rendered their contents inline in the form) and gives the jump bar a sane sticky top.
- login.js dereferenced ids unguarded where the old version guarded, so a cached older
  login.html would break sign-in itself. Guarded.
- The "Language & time" menu item was added only if wp-format.js had already parsed;
  the check now happens at click time.

Verified: 157 API checks across five suites on a clean database, plus 22 driven UI
checks — boot-with-comment, tab switching with a no-reload probe, short-viewport
fallback, and the search deep link landing on the right package.

Not done, ranked: ~50 dead CSS rules across three stylesheets; dead .team-pick and
.constraint-option contracts; wp-chrome.js's documented '.header' mount branch is
unreachable because the creator loads neither wp-chrome.js nor its CSS; the squeeze
half of the embed layout (.content-area.embed-full) is still CSS-only, which degrades
to the old narrow column rather than breaking; fingerprinted asset URLs would make a
mismatched pair unrepresentable rather than merely unlikely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:07:20 -07:00
917a728399 Rebuild the work-package side panel in the style of MS Planner
The auto-hiding drawer was the wrong model — a list you navigate by shouldn't appear
and disappear under the pointer, and its vertical text tab read as a stray artifact.
Replaced with a persistent side panel following the Planner reference:

- Collapse toggle at the top (the panel glyph, arrow flips), remembered across visits.
  Collapsed leaves a 56px icon rail where the coloured package badges are still
  clickable, rather than hiding the list entirely.
- One primary action: "+ New work package" with a split caret for Duplicate, Split by
  discipline and Export all.
- Icon nav with counts: My packages (owned by you), All packages, Needs attention
  (on hold or not release-ready), Dashboard. These filter the list below.
- Packages as rows with a colour-coded initial badge, number, subject and readiness
  state, still grouped by status, with a left accent bar on the current package.
  The badge colour is hashed from the WP number, so a package keeps its swatch
  instead of shuffling when another is added or deleted.
- The panel sits IN the layout: the form and the full-width chrome shift beside it
  rather than being overlaid.

Also, the reason it appeared as loose unstyled widgets in the middle of the form: the
panel's markup and its stylesheet are cached independently, so a browser can run new
markup against old CSS. Its essential layout (fixed position, width, the row/badge
flex, the collapsed rules) is now injected by wp-creation-app.js as a floor, inserted
first in <head> so the stylesheet still wins on everything it defines. Same lesson as
the iframe: a component whose CSS-missing state is "broken" rather than "plain" must
carry its own critical layout.

Verified with 25 driven checks in headless Chrome: persistence, the four nav links,
badge colours and text, view filtering, collapse/expand, the split menu, row selection
and highlighting — and, with wp-creation-styles.css removed from the page entirely, the
panel is still a fixed 288px side panel with the form shifted beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:47:40 -07:00
40bd19b6cf Stop the embedded creator collapsing to a 300x150 box; serve code network-first
The Creator rendered as a tiny double-scrolling square in the WP tab. My fault, and
the mechanism matters more than the symptom:

I had moved the iframe's sizing (width:100%, border:0, min-height) out of its inline
style attribute and into work-package-suite-styles.css. The service worker cached the
HTML and the stylesheet as INDEPENDENT entries, cache-first — so a browser could hold
the new HTML together with the old CSS. With the inline sizing gone and the new rule
absent, the iframe fell back to the HTML default 300x150 box and the whole tool
collapsed. Moving self-contained markup into a separately-cached file created that
window; nothing about the layout itself was wrong.

Three layers so it cannot recur:
- The iframe's width/border/min-height are inline again, on purpose, with a comment
  saying why. An iframe with no intrinsic size has a catastrophic failure mode, so its
  sizing must not depend on another file being in step.
- applyEmbedLayout() now sets the fill height and width as INLINE styles via
  sizeWPFrame(). Inline beats any stylesheet, including a stale cached one, so the
  class is a refinement rather than a requirement.
- sw.js: HTML/CSS/JS are now fetched NETWORK-FIRST with the cache as offline fallback;
  images/icons/manifest stay stale-while-revalidate. These files reference each other,
  so a page must never run against a stale sibling — this same staleness had already
  masked two other fixes during development. Cache bumped to v4.

Verified: at 2560x1440 the tool spans the window with a single scrollbar; with
work-package-suite-styles.css removed entirely (strictly worse than stale) the frame
still measures 1469x662 instead of 300x150, and re-running the layout pass keeps it
there; 12 checks across sop -> wp -> dashboard -> sop confirm body.embed-full, the
content-area class, the fill class and the inline height are all cleared on the way
out, so the wizard never ends up unscrollable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:27:53 -07:00
6c3098922f Field view: a package waiting on a predecessor is not "Ready"
The field list judged readiness from open constraints alone, so a package whose
predecessor isn't Closed showed a green Ready pill even though the server would
refuse to issue it. It now shows "waits on N", matching the form, the dashboard and
the navigator drawer. A deleted predecessor still doesn't block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 17:26:27 -07:00