# Admin Status Management + Per-Status Styling

- **Date:** 2026-08-03
- **Status:** Complete — shipped and tested; one palette check outstanding (CVD separability)
- **Status Date:** 2026-08-04
- **Phases:** 5
- **Phases Complete:** 5. Committed as `2395b51` (Admin Status Management); migration 023's
  default `Created` status went with `43ed6fe` (Admin Layout Update).
  - *Phase 0 — absorbed. The token decision (a fixed, code-controlled registry with each
    entry declaring its own ink) removed almost everything this design gate existed to
    de-risk: no free colour means no unvetted contrast and no CVD check needed per choice.
    What remained was done inline — every token's ink verified by computing WCAG relative
    luminance, which reproduced the ratios already recorded in `custom.css` exactly.*
  - *Phase 1 ✅ `db/022_status_table_styling.sql` applied and verified on dev 2026-08-03.
    Primary keys added (audit showed all three tables clean), order backfilled 1..n, active
    defaulted true, style null. Wayleave untouched and confirmed so.*
  - *Phase 2 ✅ tested by Dave 2026-08-03. Statuses tab with drag-reorder, add/edit modal,
    style picker, usage counts, and the removal guard. Backend `status_list` / `status_save`
    reuse the field-manager patterns. Also delivered here: the retired-option behaviour
    (see Build log) and the `$moduleConfig` lift out of both admin endpoints.*
  - *Phase 3 ✅ tested by Dave 2026-08-03. Editor badge on all three modules, driven by
    `initStatusBadge()` in `main.js` off the status select. Accounts un-commented, stocklists
    un-hidden. The retired-status badge failed the first pass and was fixed — see the
    `.val()` note in the Build log.*
  - *Phase 4 ✅ tested by Dave 2026-08-03. Dashboard stage bar and key take the admin style
    where one is set; unstyled statuses keep their ramp step; the strip now orders by the
    admin display order.*
- **Notes:** Scope confirmed with Dave 2026-08-03. Promotes the backlog item
  *Admin "Statuses" management tab* with its per-status styling sub-item.
  **WAYLEAVE IS EXCLUDED — no changes to `wayleave.agreement_status`, the wayleave list,
  its pill CSS or its editor.** That module is pending a rewrite and will be brought in
  line as a separate task. Scope is **projects, accounts, stocklists** only.
  Reuses the add / reorder / disable / usage-guard pattern proven in
  `docs/2026-08-02-admin-field-meta-management.md`.

## Confirmed scope

1. Extra columns on the three status tables: **active**, **display order**, **style**.
2. Admin UI to create and edit statuses, **protected against removal** the same way fields
   are — an in-use status cannot be deleted, only deactivated.
3. Admin UI to assign a style to each status.
4. Styles flow down to **projects / accounts / stocklists**, styling the status badge in
   the editor's `project-title` header.
5. Styles flow down to the **dashboard**, styling each module card's stages bar.

Out of scope: anything wayleave, and the map (verified — `map_v5.js` has no status-based
styling, so there is nothing to change).

## Plan Phases

0. Prototype the badge and stage-bar styling in `www/test.php` (design gate)
1. Migration — active / display order / style columns on the three status tables
2. Backend + Admin UI — Statuses tab per module, with the removal guard
3. Styling flows down — editor badges on all three modules
4. Styling flows down — dashboard stages bar

## Problem

Statuses are the last piece of field metadata admin cannot touch. They are already wired
into the editors via `field_options_source` (migration 009) and joined by every list view,
but adding, renaming, reordering or retiring one still needs a migration.

### The three tables have nothing to work with

```sql
projects.project_status      (project_status_id integer, project_status_desc varchar(100))
accounts.account_status      (account_status_id  integer, account_status_desc varchar(100))
stocklists.stocklist_status  (stocklist_status_id integer, stocklist_status_desc varchar(100))
```

Two nullable columns each. **No primary key, no NOT NULL, no ordering, no active flag** —
nothing to order by, nothing to retire, and no guarantee the ids are even unique.

*(For reference only: `wayleave.agreement_status` already has `display_order` and
`is_active`. It is excluded from this plan and must not be touched.)*

### Status styling today is inconsistent and mostly absent

- **Editor badge** — `#summaryCardProjectStatus` carries a hardcoded
  `class="badge bg-primary"` (`html_body_projectedit.php:12`), so every status is the same
  blue. On accounts the badge is **commented out** (`html_body_account_edit_v2.php:12`) and
  on stocklists it is **`d-none`** (`html_body_stocklistedit_v2.php:12`) — so two of three
  modules show no status in the header at all.
- **List views** — plain text, no styling (`project_list.js:76`, `:157`).
- **Dashboard** — `dashboard.js:215 stageColours()` derives an *ordinal ramp*, giving the
  configured complete status the success token and spreading a gradient across the rest.
  This is the only place status colour is currently meaningful, and it encodes **direction
  of travel**, not identity. Reconciled by decision 2 below rather than replaced.

## Build

### Phase 0 — Prototype the styling (design gate, no production code)

In `www/test.php`, which already carries the Vision switcher (simulated protanopia /
deuteranopia) built for the colour-scheme work — the tool for checking whether an
admin-chosen set stays separable.

**Much reduced by the token decision** — with a small vetted registry and per-token inks,
most of what this gate existed to de-risk is gone. What remains is worth ten minutes:
render the token swatches with the real scheme variables, confirm the declared inks read
correctly on each, and check the set under the Vision switcher.

Show side by side:
- the editor header badge,
- a dashboard stage bar built from the same set,
- and the same set under simulated CVD.

Decisions this gate produces, recorded back here before Phase 3:
- the final token list and each token's declared ink, checked against the live scheme
  variables rather than the illustrative values in Phase 2,
- final badge treatment.

**Colour is never the only channel** — the badge and the stage-bar key both keep their
label, which is what makes admin-chosen styling safe.

### Phase 1 — Migration: active, display order, style

New `db/022_status_table_styling.sql`, covering **projects, accounts, stocklists only**.

Per table, using each module's own column prefix to match the existing naming:

- `ADD COLUMN IF NOT EXISTS <m>_status_display_order integer NOT NULL DEFAULT 0`,
  backfilled `row_number() OVER (ORDER BY <m>_status_id)`.
- `ADD COLUMN IF NOT EXISTS <m>_status_active boolean NOT NULL DEFAULT true`.
- `ADD COLUMN IF NOT EXISTS <m>_status_style varchar(32)` — stores a **token key** from
  the code-side registry (see Phase 2), never a colour. Nullable, and **null means "no style
  set", with every consumer falling back to exactly today's appearance** — so the migration
  changes nothing visually until an admin chooses something.
- **Primary key on `<m>_status_id`** — gated on the audit below, since these columns are
  currently nullable with no uniqueness guarantee.

**Audit query to run before the migration is finalised:**

```sql
SELECT 'projects' AS m, count(*) AS rows,
       count(*) FILTER (WHERE project_status_id IS NULL) AS null_ids,
       count(*) - count(DISTINCT project_status_id)      AS dup_ids
FROM projects.project_status
UNION ALL SELECT 'accounts', count(*),
       count(*) FILTER (WHERE account_status_id IS NULL),
       count(*) - count(DISTINCT account_status_id)
FROM accounts.account_status
UNION ALL SELECT 'stocklists', count(*),
       count(*) FILTER (WHERE stocklist_status_id IS NULL),
       count(*) - count(DISTINCT stocklist_status_id)
FROM stocklists.stocklist_status;
```

If `null_ids` or `dup_ids` is non-zero anywhere, the PK is dropped from this migration and
the data is cleaned first — do not force it.

### Phase 2 — Backend and Admin UI

Mirrors the dropdown-option manager: same transactional save, same guard model.

**Style registry** — `statusStyleRegistry()` in `global_functions.php`, the single source
of truth, following the same convention as `fieldTypeCombinations()`:

```php
// Status styles available to the client. GeoLynx-controlled: widen the set by
// adding entries here, never from admin. Each entry declares the ink that reads
// correctly on its background, decided once at definition time, so no consumer
// has to compute contrast.
//
// Inks verified against the live scheme (www/css/custom.css:158-179): every
// status colour is dark enough to carry white text — --gl-warn is the tightest
// at 4.90:1 and its own comment specifies white. --gl-neutral is new and added
// alongside this registry; the palette has no neutral today.
function statusStyleRegistry()
{
    return [
        'neutral' => ['label' => 'Neutral',     'css' => 'var(--gl-neutral)', 'ink' => '#ffffff'],
        'accent'  => ['label' => 'In Progress', 'css' => 'var(--gl-accent)',  'ink' => '#ffffff'],
        'ok'      => ['label' => 'Complete',    'css' => 'var(--gl-ok)',      'ink' => '#ffffff'],
        'warn'    => ['label' => 'At Risk',     'css' => 'var(--gl-warn)',    'ink' => '#ffffff'],
        'bad'     => ['label' => 'Blocked',     'css' => 'var(--gl-bad)',     'ink' => '#ffffff'],
    ];
}
```

Labels above are the *style* names an admin picks from, deliberately generic — a client
may well call their status something else and still want the "At Risk" styling.
Served to the admin UI and the editors so nothing duplicates it client-side, exactly as
`type_combinations` is served today. An unrecognised stored key falls back to unstyled
rather than erroring — the same forgiving behaviour as an unregistered
`field_options_source`.

**Backend**
- `admin_load.php` → `status_list`: every status for the module with a `usage_count`.
  **One grouped query per module**, not one per status:
  `SELECT <status_col>, COUNT(*) FROM <entity_table> GROUP BY 1`, plus the same against
  `<entity_table>_history` so a status used and later moved off still counts as used.
- `admin_save.php` → `status_save`: takes the full ordered list. Insert new, update
  label / order / active / style, **delete only when unused**, refuse an in-use delete with
  a named count ("Cannot delete 'In Build' — 42 projects use it. Set it inactive instead."),
  reject duplicate labels within a module.
- **The id is the identity** — records store it, so it never changes. Labels always can.
  That is why Phase 3 keys styling on **id**, never on label.
- `$moduleConfig` gains `status_table` / `status_id_col` / `status_desc_col`. That array is
  already duplicated across both endpoints (standing backlog item) — **lift it into
  `global_functions.php` as part of this phase** rather than duplicating a fourth key set.

**Admin UI** — a Statuses tab on `?do=admin_fields` alongside Fields | Sections |
Categories, reusing the existing Tabulator + `movableRows` setup so drag-reorder comes free
and matches the page. Columns: handle, Label, Style swatch, Active, Usage, Actions.
Delete disabled with a count tooltip when in use, matching the field delete guard.

Closes a loose end from the previous plan: fields with `field_options_source` currently
show "Managed in the Statuses area" and no list, because `get_update_form.php`'s
`$optionsSources` whitelist is unreachable from admin. Lift it into `global_functions.php`
and render those options read-only with a pointer to this tab.

### Phase 3 — Styling flows down to the editor badges

*(Built differently from the first bullet below — the style is served with the dropdown
options rather than with the record. See "Phase 3 — the badge reads the select" in the
Build log for why.)*

- Serve the style with the status on the editor load endpoints, keyed on **status id**.
- `#summaryCardProjectStatus` — replace the hardcoded `bg-primary` with the status's style.
- **Un-hide the badge on accounts and stocklists**: uncomment
  `html_body_account_edit_v2.php:12`, drop `d-none` from
  `html_body_stocklistedit_v2.php:12`, and populate both the way projects does. Without
  this, "flows down to all three modules" only actually shows on one.
- **Text ink comes from the registry entry**, not from a runtime calculation — each token
  declares the ink that reads correctly on it, decided once when the token is defined.
- Null style → today's `bg-primary`, so nothing changes until an admin sets one.

### Phase 4 — Styling flows down to the dashboard stages bar

`dashboard.js:215 stageColours()` currently returns `var(--gl-ok)` for the complete stage
and an ordinal ramp step for the rest; `stageStrip()` consumes those as inline
`background:` on both the bar and its key. Substituting an admin style is therefore a
clean, contained change to one function.

**Fallback behaviour (see open question):** a status with no style set keeps its ramp step,
so a partially-styled module still renders sensibly rather than half-grey.

## Decisions (confirmed 2026-08-03)

1. **A style is a named token from a code-controlled registry.** No free colour picker.
   GeoLynx defines the available styles in code; the client chooses from that set. Widening
   the set is a code change by Dave, exactly like adding to `fieldTypeCombinations()` or
   `SECTION_ICONS` — the same pattern, so the page stays internally consistent.
   - Column stores the **token key** (`varchar(32)`), not a colour. A scheme repaint then
     propagates everywhere for free, and no stored value can ever be an inaccessible colour.
   - **Contrast stops being a runtime problem.** Because the set is ours and small, each
     registry entry declares its own text ink alongside its background, chosen once when
     the token is added. No WCAG luminance computation in the consumers.
2. **Dashboard: style overrides the ramp only where one is set.** A status without a style
   keeps its current ordinal ramp step, so today's dashboard is unchanged until something
   is styled, and a partially-styled module still reads sensibly.
3. **The status badge is switched on for all three modules** — including accounts
   (currently commented out) and stocklists (currently `d-none`).

## Build log — decisions and findings (2026-08-03)

Things settled during the build that aren't obvious from the code, and the reasoning
behind them.

### The style registry has fourteen tokens, not five

Dave asked for the scheme's purples plus darker/lighter variants of red, amber and green —
the driving case being **two inactive statuses that mean different things and must look
different on the dashboard**. Final set: Neutral, Purple ×4, Green ×3, Amber ×3, Red ×3.

**Separation matters more than count, and the variants are not equal:**

| Pairing | Separation | Verdict |
|---|---|---|
| base vs **Light** (e.g. Red / Red Light) | 4.4–6.5:1 | clearly distinct |
| base vs **Dark** (e.g. Red / Red Dark) | 1.3–1.4:1 | same hue, subtle — weak at stage-bar size |

So when two statuses genuinely must be told apart, pair a base with its **Light** variant.
This is recorded in the registry comment too, because it is the sort of thing that gets
lost and then re-litigated.

**Purples deliberately omitted:** `--gl-accent-200` (1.16:1 from `accent-pale`),
`--gl-accent-100` and `--gl-accent-50` (near-white tints), `--gl-brand` (1.19:1 from
`brand-2`). Each is within 1.2:1 of a token already in the set, so as badges they would be
indistinguishable duplicates padding the picker. Dave asked for "all the purples"; these
four were left out on those grounds and flagged at the time. One line each to add back.

**Inks are declared, not computed.** Because the set is ours and fixed, each entry states
the text colour that reads on it. Verified by computing WCAG relative luminance for all
fourteen — the results reproduced the ratios already written in `custom.css` exactly, which
validated the method. The four lightest take dark ink at ≥8.4:1; the rest take white at
≥4.9:1. **A token added with a lighter background must have its ink changed with it.**

`--gl-neutral` (`#4B5563`, 7.56:1) was added to `custom.css` — the scheme had ok/warn/bad/
accent but nothing for a status carrying no signal.

### Renaming a registry key is a data migration

Widening the palette renamed the original keys (`ok`→`green`, `accent`→`purple`,
`warn`→`amber`, `bad`→`red`). Styles already saved still held the old keys, and because
`status_save` receives **every** status on **every** write, one stale value made all edits
to that module fail. It presented as "Status name is required" on a perfectly valid name.

Two fixes, both of which should survive:

1. **`statusStyleLegacyMap()` / `statusStyleNormalise()`** map old keys forward, applied on
   read *and* write.
2. **An unrecognised style is never an error** — it normalises to null (unstyled). For an
   endpoint that receives the whole list on every write, hard-failing on one stale row locks
   the module. Validation strictness is not free when the payload is the entire collection.

### Retired statuses stay visible on records that use them

Filtering inactive statuses out of the editor dropdown left records holding one showing a
**blank required field** — which quietly invites someone to rewrite an old record's history.

Chosen behaviour (Dave's option, and the right one): `get_update_form.php` serves retired
statuses flagged `dropdown_active: false`; the editors render them `disabled` and labelled
`(retired)`, then **delete every retired option that isn't the selected one** once the
record has loaded. Net effect: visible where used, absent everywhere else, never newly
selectable, and self-retiring once the record moves on.

**The trap here was timing.** The record loads over AJAX, so pruning synchronously after
calling `projectLoad()` deleted the options *before* the value was set — leaving the field
blank, the very bug being fixed. The prune now waits on `$(document).one('ajaxStop', …)`.
That is a coarse hook (it waits for all in-flight requests, not specifically the record);
the precise fix, if it ever misbehaves, is to call the prune at the end of each editor's
load success handler.

### `$moduleConfig` lifted out of both admin endpoints

The array was byte-identical in `admin_load.php` and `admin_save.php` — verified before
merging — and every new key had to be added twice in lockstep. Now `fieldMetaModuleConfig()`
in `global_functions.php`. Closes the standing backlog item.

`statusModuleConfig()` then derives from **`dashboardModuleMap()`**, which already mapped
these exact three modules with their status tables and already excluded wayleave. Building
its own copy would have recreated the duplication just removed. Wayleave is therefore
excluded *by construction*: no entry in that map means `statusModuleConfig()` returns null
and the Statuses tab reports itself unavailable, rather than relying on a check someone can
forget.

### Phase 3 — the badge reads the select, not the record payload

The obvious route was the one the plan wrote down: join the style onto each editor's
summary query and serve it with the record. Building it showed a better source. The status
`<select>` the field-meta loop already renders carries **both** halves — the label and, once
`get_update_form.php` serves `dropdown_style`, the style — for **every** status, keyed on
the id. Reading the badge from there instead of the record means:

- **One backend change, not three.** `get_update_form.php` already runs a per-module status
  query; adding one column to each covers all three editors. The alternative touched three
  `*_load.php` summary queries, two of which don't select a status at all today
  (`account_load.php` q12 counts children; stocklist's summary is premises).
- **The badge follows an unsaved change.** Pick a new status in the dropdown and the header
  repaints immediately, instead of after a save and a reload.
- **One writer.** `project_edit_v2.js` was setting the badge text from `data_summary` while
  something else would have set its colour. That line is gone; `initStatusBadge()` owns the
  element, so the label and the colour cannot disagree.

`initStatusBadge()` lives in `main.js` (loaded on every page) and takes the
`field_options_source` key plus the badge selector, so the three editors differ by one
string. It finds the field by options source rather than by hard-coded form id, which keeps
it honest if a module's status field is ever renamed.

**`bg-primary` cannot be overridden inline.** Bootstrap 5 declares it
`background-color: … !important`, so an inline colour loses. The helper removes the class
when a style is set and adds it back when there isn't one — which is also the null-style
fallback, unchanged from today.

**All three badges now start `d-none` and empty.** Previously projects showed a blue
"Loading" pill and stocklists carried `d-none` permanently. Hidden-until-known means a
record with no status shows no badge at all rather than an empty blue pill, and the three
templates are finally identical. The helper reveals it once it has a label.

### `$select.val()` returns undefined when the selected option is disabled

The badge worked everywhere except the one case the retired-status behaviour exists for: a
record holding a withdrawn status showed no badge at all.

jQuery's select val-getter skips disabled options — `!option.disabled` is a condition in the
loop — so a `select-one` whose selected option is disabled returns `undefined`, not its
value. Retired statuses are rendered `disabled` on purpose, so the badge went looking for
`byId["undefined"]` on exactly those records. The helper now reads
`el.options[el.selectedIndex].value` directly. Native `select.value` has no such rule; only
jQuery's wrapper does.

**The same quirk is in the save path, benignly.** All three editors serialise with
`.serialize()`, which routes through `.val()`, so a record on a retired status omits its
status from the POST entirely. Today that is harmless and even desirable — the save
endpoints only write keys they receive, so the stored value survives untouched, which is
what "don't rewrite an old record's history" wants. But it is accidental rather than
designed, and it would turn into data loss the day a save path starts nulling whitelisted
fields that are missing from the payload. Logged in `improvement-opportunities.md`.
Validation is unaffected: the editors call native `form.checkValidity()`.

### A throwing load handler silently disables `ajaxStop`

Switching the account badge on surfaced a pre-existing crash in `account_edit_v2.js`: a
premises total was reduced over `dataProjects` from inside the **stocklists** branch, and
`account_load.php` only sets `data_projects` when the account has projects. An account with
stocklists and no projects therefore threw on `undefined.reduce`. Its only consumer had
been commented out, so it was dead code that could only do harm — removed. (The same block's
heading read `response.results` on an array, printing "(undefined Stocklists)"; fixed while
there.)

**The part worth remembering is the failure mode.** jQuery does not catch exceptions thrown
from an ajax `success` callback, so the throw unwinds before `--jQuery.active` runs. The
counter never returns to zero, and `ajaxStop` **never fires again for the life of the page**.
Both things that hang off it — the retired-option prune and now the status badge — stop
dead, with no error of their own. So: *badge stays hidden and retired options aren't pruned*
is a symptom of an exception somewhere earlier in that editor's load handler, not of the
badge wiring. Check the console first.

This is an argument for the precise alternative already noted above (call both at the end of
each editor's own load success handler) if it bites again — though note that an exception
before that point would break those too, and would break far more besides.

The other two editors were checked for the same shape: every `data*` array in
`stocklist_edit.js` and `project_edit_v2.js` is used inside its own `results_*` guard.

### Phase 4 — the dashboard strip now reads the admin order

Two changes in `dashboard_load.php`, one in `dashboard.js`.

**A styled stage leaves the ramp** exactly the way the complete stage already did, so the
gradient re-spreads across what's left instead of leaving a gap. Precedence is
style → complete → ramp: an admin style is an explicit statement about that status, where
the ramp is only an inference from position, and `is_complete` sits between the two.

**The strip is ordered by `<m>_status_display_order`, not by status id.** This is the change
that makes the outstanding admin reorder mean something — while the two orders agree (022
backfilled order from id) it is a no-op, and the moment `Cancelled` / `Not Viable` are
dragged after `Prospecting` the ramp follows. Without it the reorder would have moved the
editor dropdown and left the dashboard painting dead-end states as "earliest".

### A default "Created" status, so nothing is ever statusless (2026-08-04)

Found while testing the dashboard config move: the module card's headline count and its
stage bar disagreed. The cause was records with **no status at all** — they count toward the
headline but belong to no segment, so the bar silently sums to less. Dave's call, and the
right one: give every module a `Created` status that new records start in, fixing it at
source rather than teaching each consumer to cope with NULL.

`db/023_default_created_status.sql` — projects, accounts and stocklists only; wayleave picks
it up in its own rewrite.

**Not id 0**, which was the original suggestion. Migration 022 added `display_order`
specifically so ordering stops being implied by id, and the cost of that old coupling is
already on the books — 022 backfilled order from id and put `Cancelled` and `Not Viable`
ahead of `Prospecting`. Choosing id 0 to sort first reintroduces exactly that. Zero is also
falsy in both PHP and JavaScript, so every `if (status_id)` becomes a place a real status
reads as "no status". `Created` gets an ordinary id and `display_order = 0`.

**No application change was needed.** The create paths (`project_create_boundary.php`,
`stocklist_create.php`, the account create) all *omit* the status column rather than passing
NULL, so a column `DEFAULT` covers every one of them. `NOT NULL` after the backfill is what
stops the hole reopening.

**The backfill disables triggers, deliberately.** `update_modified_fields_trigger` and
`trg_*_history` both fire on UPDATE. Left enabled, the backfill would claim every affected
record was modified today and that its status changed today — neither true, since the status
did not change; we are recording a state the record was always in but could not express. It
would also float old untouched records to the top of the project, account and stocklist
lists, which all sort by `modified_datetime`.

**The guard is `HAVING`, not `WHERE`.** An aggregate with no `GROUP BY` returns a row even
when its `WHERE` matches nothing, so a `WHERE NOT EXISTS` guard would still fire on a re-run
— `MAX()` over the empty set is NULL, `COALESCE` makes it 0, and a second `Created` lands
with id 5. Caught before the migration was applied; worth remembering for any
insert-if-absent written this way.

**The default status is protected from deletion and deactivation.** Deleting it leaves the
column default pointing at a missing row and the next insert fails its foreign key;
deactivating it labels every brand-new record `(retired)`. `statusDefaultId()` reads the
default **from the catalog** rather than from configuration or a label match, so renaming
`Created` keeps it protected and dropping the default lifts the protection. Delete
hard-fails with a message; the active flag is silently forced true instead, because this
endpoint receives the whole list on every write and hard-failing there would block unrelated
edits — the same reasoning as `statusStyleNormalise()`.

### Follow-ups raised during the build

- **Status order is wrong out of the box.** The migration backfilled order from id, which
  put `Cancelled` and `Not Viable` at positions 1–2, ahead of `Prospecting`. Harmless until
  something reads the order — but Phase 4's dashboard ramp does, and would paint two
  dead-end states as "earliest". Fix by reordering in admin; no code change needed.
- **Admin-defined dropdown options have the identical retired-value problem.** An option set
  inactive still vanishes from a record using it. The `dropdown_active` flag now rides on the
  generic option payload, so routing those through the same path is a small change — not done,
  since that behaviour was already tested and signed off separately.
- **`getDashboardStatusOptions()` still orders by status id.** It feeds the admin dashboard
  config pickers (complete status, inactive set). Everything else that lists statuses now
  honours the admin display order; this one didn't get changed because it isn't part of the
  styling flow-down. One line, logged in `improvement-opportunities.md`.
- **The list views still render status as plain text** (`project_list.js:76`, `:157`). Now
  that a style is available per status id, the pill is a small addition — but it needs a
  decision about how heavy colour reads in a dense table, which is a design question rather
  than a wiring one.

## Testing checklist

Phases 1–2 ✅ *(2026-08-03)*

- [X] Audit query run; PK either added cleanly or deliberately deferred with data cleaned
- [X] Migration 022 applies clean on dev; **nothing in wayleave touched**
- [X] Before any style is set, editors / lists / dashboard look exactly as they do today
- [X] Add a status; it appears in the editor dropdown and the list view
- [X] Rename a status label; existing records keep their status and show the new label
- [X] Reorder statuses; editor dropdown order matches
- [X] Set a status inactive; gone from new selections, existing records unaffected
- [X] Delete an unused status works; deleting an in-use one is refused with a count
- [X] Duplicate status names refused, inline on the form rather than by alert
- [X] Base and Light variants of a hue are clearly distinct in the picker
- [X] A record on a retired status shows it greyed and `(retired)`, not blank
- [X] That retired status does **not** appear on records not using it
- [X] Statuses styled before the palette widened still show a style (legacy key map)
- [X] Wayleave reports the Statuses tab unavailable, with no error
Phases 3–4 ✅ *(2026-08-03)*

- [X] Style set in admin appears on the project editor badge *(Phase 3)*
- [X] Badge now visible and correctly styled on **accounts** and **stocklists**
- [X] A status with no style still shows the default `bg-primary` badge
- [X] A record with no status set shows no badge at all, not an empty pill
- [X] Changing the status dropdown repaints the badge before the record is saved
- [X] A record on a **retired** status still shows that status on its badge
      *(failed the first pass 2026-08-03 — jQuery `.val()` and disabled options, see Build
      log; fixed and confirmed same day)*
- [X] Dashboard stages bar and its key both pick up the styles
- [X] Dashboard: an **unstyled** status keeps its existing ramp step
- [X] Dashboard strip order follows the admin display order once statuses are reordered
- [X] Text stays readable on a very light and a very dark style (contrast rule)
      *(Dave 2026-08-03: fine across all fourteen tokens — the declared inks hold up in
      situ, so the decide-once-at-definition approach is validated in practice as well as
      by the luminance figures)*
- [ ] Chosen set stays separable under the `www/test.php` Vision switcher
      **— blocked on restoring the tool.** `www/test.php` has since been rewritten twice
      (dashboard redesign, then the admin organisation gate), so the protanopia /
      deuteranopia switcher this check needs is no longer in the file. Recover it with
      `git show 7956985:www/test.php` before attempting this. Everything else about the
      palette has been verified: the declared inks were checked against WCAG relative
      luminance at build time and confirmed readable in situ by Dave 2026-08-03, so what
      remains is separability between two chosen styles, not legibility of any one.

Migration 023 — default "Created" status ✅ *(applied and tested by Dave 2026-08-04)*

- [X] Migration applies clean; wayleave untouched
- [X] Every module gains a `Created` status, first in the list
- [X] Records that had no status now show `Created`, and their `modified_datetime`
      is **unchanged** (triggers were disabled for the backfill)
- [X] No new rows in the `*_history` tables from the backfill
- [X] Creating a new project / account / stocklist gives it `Created` with no code change
- [X] Dashboard headline count and stage bar reconcile
      *(2026-08-04: 10 live = Prospecting 6 + Approved to plan 2 + In build 2, with
      Complete 1 shown in the bar on top — the by-design month window, cause 2. Before
      023 these could not have reconciled at all. What remains is a labelling problem,
      logged in `improvement-opportunities.md`, not a counting one.)*
- [X] `Created` cannot be deleted — button disabled with a reason
- [X] `Created` cannot be deactivated — toggle locked on, with a reason
- [X] Renaming `Created` keeps it protected (protection is keyed on the column default)
- [ ] ~~Re-running the migration inserts no second `Created`~~ — **not testable this way**
      *(2026-08-04: `migrate.php` tracks applied migrations in `public.schema_migrations`
      and refuses to re-run one, which is the real guard. The `HAVING` idempotence in the
      file only matters for a hand-run — `psql -f`, or a restore path that replays the
      file — so it is worth keeping but there is nothing to check through the runner.)*
- [ ] ~~Wayleave list, wayleave editor and its status pills are **unchanged**~~ — *not
      checked, at Dave's direction 2026-08-03: the module is pending a rewrite, so a
      regression there costs nothing. Nothing in this work touches it by construction
      (`statusModuleConfig()` returns null for wayleave).*

## Risks

- **Adding a primary key can fail on existing data** — the audit query gates it.
- **Two editor badges are being switched on for the first time** (accounts, stocklists).
  They were hidden deliberately or by accident; worth confirming with Dave that showing
  them is wanted, not just that styling them is.
- **The token registry must stay small and vetted.** Its whole value is that every option
  is known-accessible; adding entries casually erodes that. The Vision switcher in
  `www/test.php` is how a widened set gets checked before it ships.
- **The dashboard ramp carries meaning** (direction of travel) that per-status identity
  colour does not. Mitigated by the fallback: unstyled statuses keep their ramp step.
- **No local runtime** — verified by reading; migration applied by Dave on dev.
