# Map Project Polygons Styled by Status

- **Date:** 2026-10-07
- **Status:** Complete — built and tested 2026-10-08
- **Status Date:** 2026-10-08
- **Phases:** 5
- **Phases Complete:** 5
- **Notes:** The project boundaries on `?do=map` take their colour from a status-id `CASE`
  hardcoded inside `projects.vw_projects_geom`, so the styles an admin assigns in Module
  Management → Statuses never reach the map, and a client whose status ids differ from the
  dev database's gets every boundary in blue. This plan makes the map read the same style
  token the editor badge, the dashboard stage bar and the plan view already read.
  All three decisions confirmed (1 and 3 on 2026-10-07, 2 on 2026-10-08) — see
  **Decisions**. Ready for Phase 0.

## Plan Phases

0. ✅ Prototype — the fourteen tokens as polygons on the real basemap (`www/test.php`) *(2026-10-08)*
1. ✅ Registry — each token gains its polygon rendering, in `statusStyleRegistry()` *(2026-10-08)*
2. ✅ Client — `polyStyleManager.statusStyles` built from the registry at map init *(2026-10-08)*
3. ✅ Migration 116 — the view emits the token; the layer row keys on it *(2026-10-08)*
4. ✅ Documentation *(2026-10-08)*

## Problem

### What the map draws today

Layer 18 *Project Boundary* on `?do=map` reads `projects.vw_projects_geom` through
`fn/map_get_v2.php` (which serves `SELECT *`, so every view column reaches the client) with
`vector_style = 'boundary_styles'` and `vector_style_field = 'polygon_style'`. The view
(last recreated by migration 062, verbatim from the baseline) computes `polygon_style` as:

```sql
CASE
    WHEN p.project_status_id = 100 AND NULLIF(vt.value, '') IS NOT NULL THEN 'purple'
    WHEN p.project_status_id = 100 THEN 'green'
    WHEN p.project_status_id = 0   THEN 'black'
    WHEN p.project_status_id = 5   THEN 'red'
    ELSE 'blue'
END AS polygon_style
-- vt = projects.project_field_values_text WHERE field_id = 21
```

and `polyStyleManager.boundary_styles` in `map_layer_utils.js` holds five hand-picked
colours (`blue`, `purple`, `green`, `red`, `black`) for those five values.

Four things are wrong with it:

- **The status ids are client data, written into the schema.** 100, 0 and 5 are rows of
  `projects.project_status` on one database. A client whose statuses were created in admin
  has none of them, so every project on their map is blue whatever its status.
- **Dynamic field 21 is likewise a per-database id.** A text field whose presence turns a
  status-100 project purple — a "complete, and something recorded" distinction that only
  that one deployment can have. Nothing in the repo says what field 21 is; the baseline
  carries no `project_fields` data.
- **Admin styling is ignored.** Since migration 022 every status carries a style token
  (`project_status_style`) chosen in Module Management → Statuses, and the editor header
  badge, the dashboard stage bar and `?do=projectplan` all render it. The map is the one
  status surface that does not — `docs/2026-08-03-admin-status-management.md` scoped it out
  on the grounds that `map_v5.js` had no status-based styling, which was true of the JS and
  not of the view it draws.
- **`map_v5.js`'s own `getPolyStyle()` has no fallback.** It indexes
  `polyStyleManager[layerCategory][type]` and calls `.getText()` on the result, so a value
  the bucket does not hold throws inside the style function and the layer stops rendering.
  It has never been hit because the `CASE` has an `ELSE`. The moment a status can carry no
  style (NULL token), it will be. `MapLayerUtils.getPolyStyle()` and the project editor's
  copy both fall through to `defaultStyles.grey`; the map page's copy predates them.

### Where the status styles live

`statusStyleRegistry()` in `global_functions.php`: fourteen colour-named tokens, each
declaring a `css` background (a `var(--gl-*)` reference into `custom.css`) and the `ink`
that reads on it. The stored value is always the **key**, never a colour, so a scheme
repaint propagates everywhere. `statusStyleLegacyMap()` maps the four original semantic
keys forward and `statusStyleNormalise()` turns anything unrecognised into "unstyled",
which every consumer renders as exactly what it drew before styling existed.

The registry is PHP-only today. Its consumers get it in a JSON payload
(`get_update_form.php`, `dashboard_load.php`, `project_plan_load.php`, `admin_load.php`)
or server-side in a template (`html_body_admin_planner.php`), and apply it as CSS.

### What is different about a map

Two things the badge consumers never had to think about:

- **A canvas cannot read a CSS variable.** `ol.style.Fill` and `ol.style.Stroke` take a
  colour string; `var(--gl-ok)` is not one. The JS has to resolve each token's variables
  from the document's computed style once at init, or the registry has to carry hex. This
  plan resolves at runtime so `custom.css` stays the one place a colour value is written.
- **A badge is a filled rectangle on white; a boundary is a line on a beige basemap, with a
  fill that must not hide what is under it.** The four `-lite` tokens are near-white tints
  (`#E6F3F1`, `#FBF2E3`, `#FBECEC`, `#CDBCE8`) chosen for dark ink on top — as a 2 px
  stroke on Ordnance Survey beige they would vanish. So a token's polygon rendering is not
  "its background colour as a stroke"; each token needs its own stroke and fill decided
  against the basemap, which is what Phase 0 is for and why the registry entry gains a
  `map` member rather than the client deriving one from `css`.

### The decision this sits beside

On 2026-10-04 the wayleave ↔ project map layers rejected status-token styling in favour of
a binary `complete` / `incomplete` drawn from `dashboard_config` — "seven statuses in seven
styles lose their meaning at map scale" (`docs/2026-10-04-wayleave-project-map-layers.md`,
decision 3). That decision was about **cross-module layers on an editor**: a wayleave officer
looking at projects, or a planner looking at wayleaves, wants one answer about the other
side's records. `?do=map` is the **projects module's own overview**, the page whose purpose
is to show the estate's lifecycle at a glance, and the view already tries to colour it by
status — four colours by hardcoded id. What this plan changes is who chooses the mapping:
the admin, in the same pane that styles the badge, instead of a migration. Two statuses an
admin gives the same token draw the same, so the "seven in seven" risk is theirs to take or
not; and the dashboard already lets them.

Nothing here touches the binary layers, which stay keyed on `map_state`.

## Decisions

1. ✅ **Token styling on `?do=map`; binary styling stays on the editor cross-layers**
   (Dave, 2026-10-07). The binary was the right call for viewing wayleaves within a
   project, to save on confusion; on the project map that restraint is not needed — the
   detail can be seen and understood in that context. The two are not in tension.
2. ✅ **The whole hand-coded rule goes, field 21 included** (Dave, 2026-10-08). The
   `CASE` was written ad hoc to show what was possible, not to encode a distinction anyone
   relies on. This work removes all of it and moves polygon styling to statuses only: the
   token on the status row is the one input, and anything a client wants to tell apart on
   the map becomes a status styled in admin. A dynamic field cannot drive a style token.
3. ✅ **An unstyled status draws grey dashed** (Dave, 2026-10-07). NULL token → the
   existing `defaultStyles.grey` (1 px grey dashed, 10 % grey fill), which is already what
   every layer draws for a value its bucket does not hold. It is visibly "nobody has styled
   this" and cannot be confused with the `neutral` token (solid slate). The dashboard's
   ordinal ramp for unstyled stages is not copied: a ramp needs an ordering and the map has
   none.

## Build

### Phase 0 — Prototype on the real basemap

`www/test.php` already carries an OpenLayers harness (lib/ol 10.1.0, loaded at line 479).
Add a design page to it, following the switcher-plus-ugly-cases shape used for earlier
design gates:

- Base layers loaded through `fn/map_layer_manager.php` exactly as `map_v5.js` loads them,
  so the colours are judged on the OS basemap and not on white — the lesson of
  `--gl-ok` reading as plain green on beige (migration 063).
- Fourteen polygons, one per token, drawn from the registry served by a `<?php
  json_encode(statusStyleRegistry()) ?>` block, with the CSS variables resolved in the
  browser the way Phase 2 will do it (`getComputedStyle(document.documentElement)
  .getPropertyValue('--gl-ok')`), so the prototype exercises the exact mechanism.
- Scenarios: (a) a base token and its `-lite` variant adjacent and sharing an edge;
  (b) a parent and child project overlapping, in different tokens; (c) the whole estate at
  low zoom where boundaries are slivers and only the stroke survives; (d) a grey-dashed
  unstyled polygon beside a `neutral` one; (e) the label on a dark fill.
- Design switcher over the open variables: fill alpha (try 0.10 / 0.18 / 0.25), stroke
  width (1.5 / 2 / 2.5), and the `-lite` treatment — the two candidates are **stroke in the
  base hue, fill in the lite tint** (so the lite token reads as "the pale one of its
  family"), or **stroke in the `-ink` shade at reduced width**. Memory from the premises
  layer work applies: one hue at three weights, and let the stroke carry the meaning.
- Fits / Reads / Cost / Watch notes under each variant.

Output of the phase: a `map` member per token, written down, and the three decisions above
answered. Nothing outside `test.php` changes.

**Decided on the prototype (Dave, 2026-10-08):**

| Variable | Value | What it means for the registry |
|---|---|---|
| Fill | 0.25 | Base and dark tokens fill in their own hue at alpha 0.25 |
| Stroke | 2 | Every token strokes at 2 px, lite included |
| Lite stroke | base hue | A lite token strokes in its family's base colour (`--gl-ok`, not `--gl-ok-50`); lite and base are told apart by fill alone |
| Lite fill | 0.45 | A lite token fills in its tint at alpha 0.45 — heavier than the base alpha because the tint is near-white |

So the `map` member Phase 1 writes is `['stroke' => <var>, 'fill' => <var>, 'alpha' => 0.25]`
for ten tokens and `['stroke' => <base var>, 'fill' => <tint var>, 'alpha' => 0.45]` for the
four lite ones — one shape, no `lite` flag and no width field, because the chosen lite
treatment differs from the base one only in which variables it names and what alpha it
carries. *Built 2026-10-08, tested the same day.* The page reads `statusStyleRegistry()` and
`statusStyleLegacyMap()` live via `require_once 'fn/global_functions.php'` (function
definitions only, so no database or session is needed), resolves every `var(--gl-*)` through
`getComputedStyle` and parses the hex to compose `rgba()` fills — which means the `-rgb`
triples Phase 1 proposed adding to `custom.css` are not needed; three lines of hex parsing
do the same with nothing added to the stylesheet. Phase 1 should take that route. A fourth
control, **Lite fill** (0.40 / 0.55 / 0.70), was added beyond the three the plan named: a
near-white tint at the base alpha is invisible, so the lite tokens' fill alpha is its own
variable. The *Own tint* option under Lite stroke is the naive answer, included so its
failure is seen rather than described. Basemap is chosen on the bar; OS Light needs a key
pasted in and is the one to judge on.

### Phase 1 — The registry carries the polygon rendering

`global_functions.php`, `statusStyleRegistry()`: each entry gains

```php
// base and dark tokens — stroke and fill in the badge colour
'green'      => [..., 'map' => ['stroke' => 'var(--gl-ok)', 'fill' => 'var(--gl-ok)',    'alpha' => 0.25]],
// lite tokens — stroke in the family's base hue, fill in the tint, laid on heavier
'green-lite' => [..., 'map' => ['stroke' => 'var(--gl-ok)', 'fill' => 'var(--gl-ok-50)', 'alpha' => 0.45]],
```

with the values Phase 0 settled (fill 0.25, stroke 2 px, lite stroke in the base hue, lite
fill 0.45). Stroke width is not in the member: it is 2 for every token, so it is a constant
in the client's builder, and becomes a field only if a token ever needs a different one.
`fill` names a hex variable, and the client parses the hex to compose `rgba(r,g,b,alpha)` —
three lines, exercised on the prototype — rather than the `-rgb` triple route first
proposed, which would have added four variables to `custom.css` for nothing. Keeping the
references as variables rather than hex is the point: a repaint of `custom.css` repaints
the map.

The header comment on the registry gains a paragraph: the `map` member is the polygon
rendering, the stroke is what reads at low zoom, the fill is what reads when a polygon fills
the screen, and a token added with a `-lite` background must have its `map.stroke` set to a
hue that survives the basemap, not to its own background.

Served to the page from `html_body_map_v3.php`, the way `html_body_admin_planner.php`
already reads the registry in a template:

```php
<script>
window.GL_STATUS_STYLES = <?= json_encode([
    'styles' => statusStyleRegistry(),
    'legacy' => statusStyleLegacyMap(),
]) ?>;
</script>
```

Inline rather than a new endpoint because it is static config with no database behind it,
and because `map_v5.js` builds every layer's style function synchronously at layer
construction — an async fetch would need the race handled that `loadPlannerStyles()` handles
with a promise, for no benefit. The block sits above the route's JS so the global exists
before `map_layer_utils.js` runs.

*Built 2026-10-08, awaiting testing.* The fourteen `map` members are in
`statusStyleRegistry()` with the Phase 0 values, under a header paragraph saying why a light
token strokes in its family's base hue. The payload is wrapped in a new
`statusStyleMapPayload()` beside the registry (styles plus the legacy key map), and
`html_body_map_v3.php` opens with the inline `<script>` that emits it — the route's JS is
emitted with `defer`, so an inline block in the template runs first. The other registry
consumers (`get_update_form.php`, `dashboard_load.php`, `project_plan_load.php`,
`admin_load.php`, the planner admin template) serve the entries whole and read `label`,
`css` and `ink` by name, so the extra member rides along unread. Nothing consumes it until
Phase 2, so the test is that the page carries it and nothing else changed.

### Phase 2 — The client builds the bucket

`map_layer_utils.js`:

- `buildStatusStyles(payload)` — for each registry key, resolve `map.stroke` and `map.fill`
  through `getComputedStyle(document.documentElement).getPropertyValue()` (strip the
  `var(` … `)` wrapper, trim), compose the fill as `rgba(<rgb>, <alpha>)`, and construct one
  `ol.style.Style` with the stroke, the fill and the standard label `ol.style.Text` (12 px
  Calibri, black on a white halo — the same text style every polygon bucket carries, since
  `getPolyStyle()` mutates it per feature). Register under `polyStyleManager.statusStyles`
  keyed on the token. Then for each legacy key, alias it to its current token's style
  object, so a database still holding `ok` draws as `green` — the client-side equivalent of
  `statusStyleNormalise()`, and the reason the legacy map is in the payload.
- A variable that resolves to an empty string (the stylesheet not loaded, a token naming a
  variable that does not exist) logs once and skips the token, so it falls to grey dashed
  rather than drawing with an empty colour string, which OpenLayers renders as black.
- Called from `map_v5.js` at `$(document).ready` before `loadLayersFromAPI()`, guarded on
  `window.GL_STATUS_STYLES` being present so the other pages that share
  `map_layer_utils.js` are unaffected.
- Exported on the `MapLayerUtils` API like `loadPlannerStyles` is.

`map_v5.js`:

- `getPolyStyle()` gets the fallback chain the other two copies have —
  `polyStyleManager[cat]?.[type] || polyStyleManager.defaultStyles[cat] ||
  polyStyleManager.defaultStyles.grey` — so an unstyled status, an unknown token, and a
  typo'd `vector_style` on a layer row all draw grey dashed instead of throwing. This is a
  fix the plan needs rather than the consolidation `docs/map_style_audit.md` describes;
  collapsing the three copies stays in the backlog.
- `polygon_style` leaves the popup's `excludedProps` list (it is in commented-out code, so
  this is tidiness, not behaviour).

`boundary_styles` stays in `map_layer_utils.js`: migration 041 pointed the wayleave boundary
layer at `defaultStyles.blue`, and nothing else is known to use it, but `map_layers` is data
and cannot be grepped — removing a bucket is a separate, verified step.

*Built 2026-10-08, awaiting testing.* `buildStatusStyles(payload)` sits beside
`loadPlannerStyles()` in `map_layer_utils.js` and is exported; it resolves each `map.stroke`
and `map.fill` through `getComputedStyle`, parses the hex into an `rgba()` fill, builds one
style per token at the constant 2 px stroke, aliases the legacy keys, and warns once with the
list of any token it skipped. `map_v5.js` calls it inside `document.ready`, guarded on
`window.GL_STATUS_STYLES`, immediately before `loadLayersFromAPI('map')`; its `getPolyStyle`
now carries the same fallback chain as the other two copies. The popup's `excludedProps`
was left alone — it is inside commented-out code and not worth a diff. The `edited` label
suffix and the `uid: label` text are unchanged.

### Phase 3 — Migration 116: the view emits the token, the layer row keys on it

`db/116_project_map_status_style.sql`. Read `docs/table-dependency-chains.md` first —
`projects.projects` (the view is a reader; it names its columns, so no star-select risk) and
`public.map_layers` (an UPDATE, not an INSERT, so `is_system` is already set by 089 on a
layer that existed then; the verify block asserts it anyway).

1. **Guards.** 062 has run (`pg_get_viewdef('projects.vw_projects_geom')` contains
   `is_deleted`); 022 has run (`projects.project_status.project_status_style` exists); the
   view still carries `polygon_style` (otherwise 116 has run).
2. **`DROP VIEW projects.vw_projects_geom; CREATE VIEW …`** — DROP rather than REPLACE
   because `polygon_style` is going and `CREATE OR REPLACE VIEW` cannot remove a column
   (041's note). Every existing column kept in order, then:
   - `ps.project_status_desc` — the status name, so a future label or popup has it; the
     view today exposes only the id, which is the number the map would otherwise show.
   - `ps.project_status_style AS status_style` — the raw stored token, via
     `LEFT JOIN projects.project_status ps ON ps.project_status_id = p.project_status_id`.
     Raw, not normalised: the view cannot call PHP, so legacy keys are aliased client-side
     (Phase 2) and anything else falls to grey dashed. A status with no row (a project whose
     status id no longer resolves) yields NULL and draws the same.
   - The `project_field_values_text` join and the field-21 branch go (decision 2).
   - `WHERE p.is_deleted = false` stays, with 062's comment.
   `COMMENT ON VIEW` stating: map layer source for `?do=map`; `status_style` is a token key
   from `statusStyleRegistry()`, never a colour, admin-set in Module Management → Statuses;
   NULL draws unstyled; company scoping is `map_get_v2.php`'s.
3. **Layer row.** `UPDATE public.map_layers SET vector_style = 'statusStyles',
   vector_style_field = 'status_style', tooltip = …` matched on the URL's `geotable`
   (`url ~ 'geotable=projects\.vw_projects_geom(&|$)'`) — never on `layer_id`, which is
   per-database, and **not on `database_schema` / `database_table`**, which the first cut
   used and which are NULL on this row: layer 18 predates those columns. The URL is what
   `map_get_v2.php` reads, so it is the one column guaranteed to name the view. The update
   sets the two columns while it is there, so the next migration can match on them. The tooltip is the layer's only legend (064's lesson), so it says:
   *Boundary colour is the status style set in Module Management → Statuses. A grey dashed
   boundary is a status with no style.* The migration cannot check that `statusStyles`
   exists in JS (113's warning applies unchanged): a polygon whose bucket does not resolve
   falls to grey dashed under Phase 2's fallback, visibly rather than invisibly.
4. **Verify block.** The view has `status_style` and `project_status_desc` and no
   `polygon_style`; exactly one `map_layers` row was updated and it has `is_system = true`;
   `SELECT status_style FROM projects.vw_projects_geom LIMIT 1` runs. The verify is a
   `SELECT` Dave can repeat — `migrate.php` reports OK or an error only.

*Built 2026-10-08, awaiting testing.* `db/116_project_map_status_style.sql`, to the shape
above: five guards (022, the view exists, 062, not already run, the layer row exists, plus
089's `is_system` column), `DROP VIEW` then `CREATE VIEW` with the sixteen original columns
in order followed by `project_status_desc` and `status_style`, a `COMMENT ON VIEW`, the row
update inside a `DO` block that raises unless exactly one row changed, and a verify block
that checks the two new columns, the absence of `polygon_style`, the surviving `is_deleted`
filter, a `PERFORM` against the view, and the row's three values. Nothing depends on the
view (checked across `db/`, the baseline and `www/fn/`), so the DROP has no cascade.
`migrate.php` executes the file whole, so the `DO` blocks are fine.

*First application failed on the guard, 2026-10-08* — "no public.map_layers row reads
projects.vw_projects_geom" — because the row's `database_schema` / `database_table` are
NULL; it is named only by its URL. Rolled back with nothing recorded, so the file was
corrected in place (guard, update and verify all match on the URL's `geotable`, and the
update fills the two columns). The same query turned up layer 43, the stocklist editor's
copy of this layer, keyed on a column its view does not have; logged in the backlog.

**GeoServer is not touched.** Layer 33 *Projects (All)* is a WMS over `projects.projects`
styled by `qgis/ProjectAllStyle.sld`, a single colour keyed on nothing; it is a different
layer answering a different question and is left as it is. Layer 35 *Project Neighbours*
(`project_nearest_neighbours`, an editor layer) carries the status name but no token and is
out of scope; once the bucket exists, bringing it in is a view column and a row update, and
is logged in the backlog.

### Phase 4 — Documentation

- This doc: status, phases complete, the Phase 0 findings, and the three decisions as
  answered.
- `CLAUDE.md`: a short **Map project status styles** note under the map/`map_layers`
  material — the map reads the same token as the badge; the view emits it raw and the
  client aliases legacy keys; grey dashed means unstyled; the `map` member on a registry
  entry is the polygon rendering and a `-lite` token must not stroke in its own tint.
- `docs/table-dependency-chains.md`, *public.map_layers*: a line that `vector_style` names
  a bucket in `map_layer_utils.js`, and the `statusStyles` bucket is built from
  `statusStyleRegistry()` — so a token added to the registry needs no JS change, but a token
  whose `map` member is missing draws grey.
- `docs/2026-08-03-admin-status-management.md`: the "map out of scope" line gets a pointer
  here, since its premise (no status styling on the map) turned out to be a view matter.
- `docs/improvement-opportunities.md`: tick nothing; the three side-findings from this
  survey are already logged there (2026-10-07).

## Testing checklist

1. Open `?do=map` on dev with the Project Boundary layer on. Every project whose status has
   a style in Module Management → Statuses draws in that token's stroke and fill; no
   boundary is blue, black, red or purple from the old rule.
2. A project whose status has no style draws grey dashed, and the layer keeps rendering
   (no console error from `getPolyStyle`).
3. In Module Management → Statuses, change one status's style and reload the map. The
   projects on that status change colour; nothing else does.
4. Set a status's style to one of the four `-lite` tokens. Its boundaries are still visible
   on the OS basemap at the estate-wide zoom, not only when zoomed in.
5. Two overlapping projects in different tokens: both boundaries are distinguishable where
   they cross, and the fill of the upper one does not hide the basemap beneath.
6. The layer's tooltip (the question-mark icon in the layer's settings panel) names where
   the colour is set and what grey dashed means.
7. The feature popup for a project still shows the project name, manager, account and the
   Edit link; nothing new appears and nothing is lost.
8. Run `SELECT status_style, project_status_desc FROM projects.vw_projects_geom LIMIT 5;`
   — the columns exist; `polygon_style` does not.
9. Run `SELECT vector_style, vector_style_field, is_system FROM public.map_layers WHERE
   database_table = 'vw_projects_geom';` — `statusStyles`, `status_style`, `true`.
10. The project editor (`?do=projectedit`), the stocklist editor and the opportunity editor
    open and draw their maps as before — they share `map_layer_utils.js` and must not be
    affected by the new bucket.
11. The wayleave editor's Projects layer and the project editor's Wayleave layers still draw
    green `/` and red `X` hatch — the binary layers are untouched.
12. A deleted project does not appear on the map (062's filter survived the DROP / CREATE).
13. The browser console on `?do=map` carries no warning about skipped status tokens.

**All thirteen confirmed by Dave, 2026-10-08.** Test 2 was confirmed by reasoning rather
than by observation: every project has had a status since migration 023's `Created`
default, so the unstyled case is a status with no style rather than a project with no
status, and none of the dev statuses is unstyled. The fallback was exercised on the Phase 0
prototype's unstyled polygon instead. Test 9 also shows `projects` / `vw_projects_geom` in
the schema and table columns, which 116 set.

## Risks

- **`map_layers` is data.** The layer row is matched on schema and table, but a database
  where the Project Boundary layer was re-pointed or duplicated by hand updates a different
  number of rows; the verify block raises on anything but exactly one.
- **A dev database may hold the four legacy keys** (`accent` / `ok` / `warn` / `bad`) from
  before 2026-08-03. Phase 2's aliasing handles them; if it is skipped, those projects draw
  grey dashed and look unstyled when they are not.
- **CSS variable resolution depends on `custom.css` having loaded** before the map builds
  its styles. It is a stylesheet in the head and the build runs at `document.ready`, so this
  holds; a future move of the token block to a lazily loaded sheet would break it silently
  (every token skipped, every boundary grey). The one-line log in Phase 2 is the only signal.
- **Losing the field-21 distinction** on the dev database (decision 2). If it mattered, the
  fix is a status, made in admin.
- **Shared style objects.** Each token is one `ol.style.Style` whose text is mutated per
  feature, the same pattern every bucket uses; it is correct only because OpenLayers calls
  the style function synchronously per feature. Not new, but worth knowing when reading
  Phase 2's code.
