# Field-Safe Colour Scheme

- **Date:** 2026-07-27
- **Status:** Phases 1–3 complete — consolidation (Phase 4) outstanding
- **Status Date:** 2026-07-28
- **Phases:** 4 (was 5 — the colour-independence audit was removed on 2026-07-28)
- **Phases Complete:** 3 (1–3 signed off 2026-07-28 after three fixes: links, Tabulator
  pagination, flash-of-blue on load)
- **Notes:** Chosen from eight candidates prototyped in `www/test.php` (scheme 8, ranked 1st).
  Keeps `#180A2A` as the brand/header colour. Two separate changes bundled: a calmer accent
  (`#6d28d9` → `#5A3D8A`) and a status palette chosen against simulated colour-vision deficiency.
  The blast radius is larger than it looks — Phase 3 touches ~280 Bootstrap call sites through
  variable overrides, and `btn-primary` currently renders Bootstrap blue, not the app accent.
  **Phase 3's mechanism was wrong as planned and has been corrected in place — see the
  2026-07-28 note under Phase 3.** Phases 1–3 were built together because Phase 1 renames the
  tokens, so every consumer has to move in the same commit.

## Plan Phases

1. ✅ Token layer — replace the `--purple-*` ramp with named scheme tokens *(2026-07-28)*
2. ✅ Accent surfaces — repoint everything currently using `#6d28d9` *(2026-07-28)*
3. ✅ Semantic colours — override Bootstrap's `--bs-*` and sweep hard-coded hexes *(2026-07-28)*
4. Consolidation & cleanup

**Removed 2026-07-28: the colour-independence audit** (was Phase 4 — walk every status
rendering and confirm it carries a label or icon, not hue alone). Dropped by decision; status
colours are becoming user-configurable via the Admin "Statuses" management tab item in
`docs/improvement-opportunities.md`, which is where the question now belongs. Note the
consequence: with user-set colours, the shipped palette's 20.7 ΔE separation is a *default*,
not a guarantee — whatever an admin picks is what ships.

**Phase 4 (consolidation) is the only outstanding work.** The backlog item in
`docs/improvement-opportunities.md` was closed on 2026-07-28 once the repaint was signed off,
so this document is now the only tracker for it.

## Problem

Two unrelated accents are live at once:

- **`#6d28d9`** in the app's own CSS (`custom.css` `--purple-700`). This is Tailwind's
  `purple-700` exactly, and the ramp is named after it — it reads as an untouched framework
  default, and it is the highest-chroma thing on a page of greys, so it pulls attention to
  chrome rather than to the record.
- **`#0d6efd`** — Bootstrap's stock blue — via `btn-primary`, used in **56 places**. No
  `--bs-*` overrides exist anywhere, so every Bootstrap component in the app is running
  factory colours.

Separately, the status palette is Bootstrap's default. `#ffc107` warning needs dark text to be
legible at all, and the success/warning/danger set has a worst-case separation of **14.0 ΔE**
under simulated protanopia/deuteranopia. GeoLynx is aimed at utility providers, whose work is
field-heavy and outdoors — the conditions where a red/green pair fails first, for roughly one
man in twelve.

### Chosen values

Accent ramp (from prototype scheme 1, "Deep Violet" — same hue family as the header at a third
of the chroma):

| Token | Value | Note |
|---|---|---|
| `--gl-brand` | `#180A2A` | unchanged — header/navbar |
| `--gl-brand-2` | `#2A1A44` | raised/hover surface on dark chrome |
| `--gl-accent` | `#5A3D8A` | 8.50:1 on white |
| `--gl-accent-hi` | `#452C6E` | hover / active text |
| `--gl-accent-200` | `#D8CDE8` | borders |
| `--gl-accent-100` | `#EAE3F3` | selected rows, tints |
| `--gl-accent-50` | `#F5F2F9` | hover rows, panel heads |
| `--gl-accent-pale` | `#CDBCE8` | accent **on** the dark header (10.71:1 on brand) |

Status set (chosen by simulating protanopia and deuteranopia with the Machado 2009 matrices and
maximising worst-case CIE76 separation, subject to every colour clearing AA on white):

| Token | Value | On white | |
|---|---|---|---|
| `--gl-ok` / `-50` / `-ink` | `#0E6B62` / `#E6F3F1` / `#0A544D` | 6.37:1 | |
| `--gl-warn` / `-50` / `-ink` | `#A2620E` / `#FBF2E3` / `#834E08` | 4.90:1 | takes **white** text, unlike `#ffc107` |
| `--gl-bad` / `-50` / `-ink` | `#9E2A2B` / `#FBECEC` / `#7F2122` | 7.45:1 | |

Worst-case pairwise separation **20.7 ΔE**, against 11.8 for a conventional muted set and 14.0
for Bootstrap's. "Clearly different", not "unmistakable" — this set is a better starting point,
not a guarantee on its own. It is also only the *default* once admin-set status colours land.

## Build

### Phase 1 — Token layer

Replace the `--purple-*` ramp in `custom.css:144-149` with the tokens above. Rename rather than
re-value: four of the eight candidate schemes weren't purple, and `--purple-700: #5A3D8A` would
be actively misleading. Keep the block in `:root` so `admin.css` and `html/404.php` continue to
resolve.

Do not leave the old names as aliases — there are only 22 references and a clean break avoids
two ramps drifting apart.

> **Built 2026-07-28.** As specified, plus four `-rgb` companion tokens (`--gl-accent-rgb`,
> `--gl-ok-rgb`, `--gl-warn-rgb`, `--gl-bad-rgb`). The chosen-values table lists only hexes,
> but both the spinner glow and every Bootstrap `-rgb` override compose colours inside
> `rgba(…)`, which a hex token can't do. No `--purple-*` reference remains outside comments.

### Phase 2 — Accent surfaces

Repoint every current consumer. Verified list:

- `custom.css` — 15 `var(--purple-*)` references: nav tabs (`:316` hover, `:323` active),
  focus ring (`:682-683`), `accent-color` (`:591`), and lines 351, 388, 399, 405, 481, 487-488.
- `custom.css:687-690` — `.gl-spinner` `--node` / `--pulse` / `--line` (raw `#6d28d9` +
  `rgba(109,40,217,.2)`).
- `custom.css:867-875` — `.hx-panel` tokens (`--hx-accent`), driving the hierarchy and
  "details from account" panels.
- `admin.css:14, 49, 58-59, 92`.
- Inline `style="color:#6d28d9"` in `html_body_admin_users.php:7` and
  `html_body_admin_fields.php:16` — the only hard-coded accents left in markup.
- `html/404.php:22, 27, 44`.
- `btn-geolynx-dark` / `btn-geolynx-light` — `#EDE3FA`, `#dabdff`, `#49247A`, duplicated
  verbatim across `project_edit_v2.css:348-436`, `account_edit.css:347-436` and
  `stocklist_edit.css:246-333`. Repoint to the new tokens; consolidation is Phase 4.

> **Built 2026-07-28.** The verified list above was accurate but missed two consumers, both
> raw `rgba(109, 40, 217, …)` rather than `var()`, which is why they escaped the audit:
> `custom.css:713` (the spinner core's glow) and `admin.css:50` (nav-card hover shadow). Both
> now use `rgba(var(--gl-accent-rgb), …)`.
>
> One judgement call: the spinner's `--pulse` was `#a855f7`, *lighter* than its `--node`.
> The new scheme has no mid-light saturated tone — `--gl-accent-pale` is built for the dark
> header and nearly vanishes on a white dashboard card — so the order is now inverted:
> `--node: var(--gl-accent)`, `--pulse: var(--gl-accent-hi)`. Both read clearly on white, but
> it is a visible change to a decorative element; the dashboard is item 1 on the checklist.

### Phase 3 — Semantic colours

The app uses Bootstrap's own classes throughout (~280 call sites: `btn-success` ×60,
`btn-primary` ×56, `btn-outline-danger` ×37, `text-danger` ×30, `bg-success` ×19,
`alert-danger` ×16, plus the rest). Overriding the variables in `custom.css` reaches all of
them from one place — do that rather than touching call sites.

Set, in `:root` after Bootstrap loads:

- `--bs-primary` → `#5A3D8A` and `--bs-primary-rgb: 90,61,138`
- `--bs-success` → `#0E6B62` / `14,107,98`
- `--bs-warning` → `#A2620E` / `162,98,14`
- `--bs-danger` → `#9E2A2B` / `158,42,43`
- `--bs-link-color` / `--bs-link-hover-color` → accent / accent-hi

**`btn-primary` is a real behaviour change, not a tidy-up.** Those 56 buttons are currently
Bootstrap blue and will become the accent. That is the intent — one accent, not two — but it
changes pages nobody is thinking about, so it needs its own pass in testing.

Check the `-rgb` pairs, and the `-subtle` / `-emphasis` triplets if this Bootstrap build has
them: several components compose colours from those rather than the base variable, so a base-only
override leaves stale blues and ambers behind. `text-bg-warning` in particular flips from dark to
white text at the new value.

Then sweep the hard-coded hexes: `#ffc107` ×3, `#0d6efd` ×3, `#dc3545` ×2, `#198754` ×2,
`#20c997` ×1 across `js/` and the custom stylesheets (Chart.js datasets, Tabulator formatters,
map styling).

> **Correction, 2026-07-28 — the variable-override plan above does not work as written.**
> This build is **Bootstrap 5.2.3**, which splits into two groups:
>
> - **Utilities compose from the base tokens.** `.text-*`, `.bg-*` and `.border-*` are
>   `rgba(var(--bs-danger-rgb), …)`, so overriding `--bs-*`/`--bs-*-rgb` does reach them —
>   about 50 call sites, as planned.
> - **Components compile literal hexes.** `.btn-primary` is `--bs-btn-bg: #0d6efd`, not
>   `var(--bs-primary)`. Same for `.btn-outline-*`, `.alert-*`, `.text-bg-*`,
>   `.form-control:focus`, `.form-check-input:checked`, `.pagination`, `.progress`,
>   `.dropdown-item.active`. Overriding `--bs-primary` alone would have left every one of
>   them factory-coloured — roughly **170 of the ~280 call sites**, including all 60
>   `btn-success` and all 56 `btn-primary`. The change would have looked like a near no-op.
>
> Corrected mechanism: base tokens *plus* a per-component block overriding that component's
> own `--bs-<component>-*` properties. The plan's key property survives — everything lives in
> one place in `custom.css` and **no call site was edited** — but it is ~200 lines, not five.
> Two components the plan didn't list are included because they are the most-seen "primary"
> surfaces in a forms-heavy app: `.form-control:focus` / `.form-select:focus` (blue focus ring
> on every input) and `.form-check-input:checked` (every checkbox and radio).
>
> Also resolved: the plan asks to "check the `-subtle`/`-emphasis` triplets if this Bootstrap
> build has them". It does not — those are 5.3+. Nothing to do.
>
> **Deliberately left alone in the sweep** (each is a decision, not an oversight):
>
> - **OpenLayers feature styling** — `map_layer_utils.js:954,960`,
>   `wayleave_edit_v2.js:1026,1037,1064-1066`. Three reasons: OL styles render to canvas and
>   cannot resolve CSS custom properties, so these would become duplicated literals; the
>   colours are *categorical* (UPRN source layers), and the scheme defines no categorical
>   palette; and they must stay legible over aerial imagery, where the muted status set is a
>   downgrade. Same class of problem as the GeoServer SLD carve-out under Risks. The one
>   genuine status among them is `wayleave_edit_v2.js:1037` (`released`).
> - **File-type icon colours** — `.file-icon.pdf/.doc/.excel` across four stylesheets. These
>   encode file type, not state; recolouring the PDF icon to the danger tone would give it a
>   meaning it doesn't have.
> - **`.btn-geolynx:hover` `#2980b9`** (three editor stylesheets) — an off-scheme blue on a
>   class unrelated to `btn-geolynx-dark`/`-light`. Wants its own decision.
> - **`.text-bg-geolynx-dark`** (`custom.css:101`) — named "dark" but set to
>   `RGBA(25,135,84,1)`, the old success green. Looks like a copy-paste bug predating this
>   work; left as found so the repaint isn't carrying an unrelated fix.
> - `.table-success`/`-danger`/`-warning` (2 call sites) and `.list-group-item-*` (0).

> **Testing round 1, 2026-07-28 — two blues survived the sweep.** Both were things the
> `--bs-*` layer structurally cannot reach:
>
> - **Plain `<a>` links.** Reboot is `a { color: var(--bs-link-color) }` and the variable was
>   overridden, but links still rendered blue, so `a` / `a:hover` / `a:focus` are now set
>   directly. Tag-level (0,0,1) is the weakest useful selector, so every class-based
>   Bootstrap component that colours its own links (`.nav-link`, `.dropdown-item`, `.btn`,
>   `.page-link`) still wins and is unaffected.
> - **Tabulator pagination.** `tabulator_bootstrap5.css` is a vendored theme with `#0d6efd`
>   baked in as literal hexes, and it renders its own `.tabulator-page` markup rather than
>   Bootstrap's `.pagination` — so neither the base tokens nor the `.pagination` block
>   reached it. Overridden at the vendor file's own specificity (0,3,0): `.tabulator-page.active`,
>   its `:hover` (which carried a second blue, `#0a58ca`), and the spreadsheet-tab active
>   state. bootstrap-table's pagination is unaffected — it uses Bootstrap's own `.page-link`
>   markup and was already covered.
>
> Worth noting for the rest of the pass: **vendored CSS is the blind spot.** Anything shipping
> its own theme file (Tabulator here; jQuery UI and bootstrap-table are the other candidates)
> carries its own literal colours and needs explicit selectors.

> **Flash of blue on page load, 2026-07-28 — fixed.** Not cache-related: `custom.css` was
> loaded at the *end of `<body>`* (`index.php:332`), so the browser painted using Bootstrap's
> stock colours from `<head>` and then repainted once `custom.css` arrived. The bug predates
> this work; the repaint made it obvious by widening the gap between the two paints.
>
> `custom.css` and the jQuery UI theme are now the last two stylesheets in `<head>`, in that
> order. Their order relative to every other stylesheet is unchanged — they were already the
> final two, just below `</body>` — so nothing in the cascade moves. **That ordering is load-
> bearing:** `custom.css` wins many overrides (Bootstrap components, Tabulator pagination) on
> document order at equal specificity, not on higher specificity, so it must stay last. The
> comment in `index.php` says so at the point of use.

### Phase 4 — Consolidation & cleanup

- Collapse the triplicated `btn-geolynx-*` definitions into `custom.css` and delete them from
  the three editor stylesheets.
- Delete the dead `.top-header` block (`custom.css:171-211`). No markup uses that class — the
  real header is `.navbar` / `.navbar-brand` at `custom.css:53-59`. It is also the only thing
  referencing `--purple-900`/`800`, which is why the ramp looks more used than it is.
- `main.css:76, 116` still set `rgb(55,0,80)` on `.navbar` / `.navbar-brand`, overridden by
  `custom.css`. Remove the losing rule rather than leave two brand colours in the tree.
- ~~Delete `www/test.php` once the scheme is signed off.~~ **Dropped 2026-07-28** — the page
  is being kept and reused as the standing design-prototype surface, next for the admin-set
  status colours under the Admin "Statuses" management item. Its Vision switcher is worth
  keeping regardless: it is the only tool in the repo for checking a palette under simulated
  protanopia/deuteranopia, which is exactly what free-choice status colours will need.

## Testing checklist

Per page, at 1080p and at a narrow window:

- [ ] Dashboard — *not worth a pass: the page is being replaced by
      `docs/2026-07-28-dashboard-redesign.md`, which owns its chart colours from here.*
- [ ] Project list + project editor — every tab, including Map and Audit Log
- [ ] Account list + editor; Stocklist list + editor (check the inherited-account panel)
- [ ] Wayleave list + editor; Opportunity list + editor
- [ ] Map (`map_v5.js`) — controls, sidebars, feature popups over aerial and OS basemaps
- [ ] Admin — home, users & roles, field management, map layers, distance analysis
- [ ] Land Registry, Distance Analysis, Competitor Analysis
- [ ] Login page and 404
- [ ] **`btn-primary` pass** — the 56 buttons that change from blue to accent
- [ ] Toasts and alerts of all four kinds, including a `text-bg-warning`
- [ ] Charts — series still distinguishable in greyscale

## Risks

- ~~**No cache-busting.**~~ **Resolved 2026-07-28** — `asset_url()` now versions every local
  CSS/JS include by `filemtime`, so a deploy can no longer mix old and new colours. This was
  the stated prerequisite and is done.
- **Phase 3's blast radius.** One variable override changes ~280 call sites at once. Cheap to
  apply, cheap to revert, but it cannot be spot-checked — the page-by-page pass is the whole
  safety net.
- **GeoServer layer styles are out of scope.** Map layer colours are SLD held in GeoServer, not
  in this repo, and will not follow the app palette. If layer styling clashes with the new
  accent or status colours that is a separate piece of work.
- **The colour-independence audit was removed** (2026-07-28, by decision). Status renderings
  were not walked, so it is not established that every one carries a label or icon rather than
  hue alone. The concern moves to the Admin "Statuses" management item, which will let admins
  set status colours directly — that makes the shipped palette a default rather than a
  guarantee, so whatever safeguard exists has to live in that admin UI.
- Static PDF/QGIS export templates may carry their own colours; not audited here.
