docs(ux): task-by-task plan for UX Polish Pass

Five tasks: (1) docs/design-tokens.md as the canonical colour
reference; (2) CLAUDE.md UI-vs-DB naming-drift note (ships BEFORE
the rename so it's searchable from minute one); (3) display-only
TYPE_CHOICES rename + auto-migration + template visible-text swap
to get_type_display; (4) badge colour unification on Pending +
History tabs + loan-flag recolor; (5) CSS root-cause fix for the
group-summary narrow-wrap bug (move display:flex from <tr> to <td>).

Execute via subagent-driven-development. Auto mode — no mid-execution
checkpoints.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-04-24 09:36:51 +02:00
parent 9aba9b8fb8
commit 84e9d247be

View File

@ -0,0 +1,742 @@
# UX Polish Pass — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Auto mode is active — execute continuously, no mid-execution checkpoints.
**Goal:** Shorter adjustment-type labels in tables (display-only, DB untouched), uniform semantic badge colours across all payroll tabs, a CSS bug fix on the group-summary row, and two new documentation artifacts to prevent future confusion.
**Architecture:** Path A — display-only rename. Every DB value stays exactly as it is today; only the human-readable labels change via the second tuple element of `TYPE_CHOICES`. Unifies badge colours by replacing ~3 branches of Bootstrap-state-class conditionals with the existing `.badge-type-*` semantic palette. Moves one CSS property (`display: flex`) from a `<tr>` (where it silently breaks table rendering) to a `<td>` (where it does what the original author intended).
**Tech Stack:** Django 5.2.7 (TYPE_CHOICES + get_type_display + auto-generated AlterField migration); custom.css semantic palette (already in place); Django template filter `type_slug` (already in place). No new libraries.
**Design doc:** `docs/plans/2026-04-24-ux-polish-design.md` (committed as `9aba9b8`).
**Starting HEAD:** `9aba9b8` on branch `ai-dev`.
**Expected net change:** ~120-180 lines across 6 files + 1 new doc + 1 auto-generated migration.
---
## Critical context for every task
**The naming drift:** After this pass, what the user SEES and what the DATABASE stores for `PayrollAdjustment.type` will diverge permanently. Learn this before touching anything:
| DB value (CANONICAL) | Display label (new) |
|---|---|
| `'New Loan'` | `Loan` |
| `'Advance Payment'` | `Advance` |
| `'Advance Repayment'` | `Advance Repaid` |
**Never change the left column.** All logic, constants (`ADDITIVE_TYPES`), tests, CSS class slugs (`.badge-type-new-loan`), and `data-type="..."` attributes use the left column.
**Two kinds of template usage of `adj.type`:**
1. **Visible text** (renders letters on the page): `{{ adj.type }}` → change to `{{ adj.get_type_display }}`
2. **Identifier** (feeds a CSS class via `|type_slug`, or a `data-type=` attribute, or a control-flow `{% if %}`): keep as `{{ adj.type }}` — the raw DB value is the right thing to emit
The implementer for Tasks 3 and 4 MUST grep both patterns before editing and make the call case-by-case.
---
## Task 1: Create `docs/design-tokens.md` (canonical colour reference)
**Goal:** A single doc that lists every semantic colour token in the app, where it's used, and how to add a new one. Do this BEFORE any colour code-changes so the reference exists first.
**Files:**
- Create: `docs/design-tokens.md`
**Step 1: Create the file with the full content**
Write the following to `docs/design-tokens.md` (verbatim):
````markdown
# Design Tokens — Semantic Colour Palette
_Last reviewed: 24 Apr 2026._
## How colours are structured
The app has TWO colour categories — they MUST NOT share colours:
1. **Type-of-adjustment** — 7 types × 2 themes. Used wherever a
`PayrollAdjustment` is shown as a badge or a group-header accent.
Token naming: `--badge-<type>-bg` / `--badge-<type>-fg`.
2. **Transactional state** — Bootstrap's `bg-success` /
`bg-warning` / `bg-danger`. Used for Paid, Unpaid, Overdue —
the payment lifecycle, not the kind of adjustment.
Mixing the two would make a green badge mean both "this is a Bonus"
AND "this is Paid" — the user would lose the ability to read the
colour as a signal. Keep the categories separate.
## Type-of-adjustment tokens
| DB type (canonical) | Displayed as | Dark BG | Dark FG | Light BG | Light FG | CSS class |
|---|---|---|---|---|---|---|
| Bonus | Bonus | `#5b8260` | `#e8f3ea` | `#d7e8d9` | `#385640` | `.badge-type-bonus` |
| Overtime | Overtime | `#a16881` | `#fce4ec` | `#f3d1dd` | `#703347` | `.badge-type-overtime` |
| Deduction | Deduction | `#5b4f8c` | `#e0daf3` | `#d8d0ef` | `#3b2f6d` | `.badge-type-deduction` |
| New Loan | Loan | `#9b7f39` | `#fef4d1` | `#f0dc9d` | `#6a5320` | `.badge-type-new-loan` |
| Loan Repayment | Loan Repayment | `#b48a1a` | `#fef4d1` | `#f7d873` | `#5a4418` | `.badge-type-loan-repayment` |
| Advance Payment | Advance | `#3e5c7b` | `#d7e5f2` | `#bccee0` | `#243b56` | `.badge-type-advance-payment` |
| Advance Repayment | Advance Repaid | `#2f679a` | `#d7e5f2` | `#9ec1dd` | `#1d3550` | `.badge-type-advance-repayment` |
Token definitions live in `static/css/custom.css`:
- Dark theme: `:root { ... }` block around lines 85-91
- Light theme: `[data-theme="light"] { ... }` block around lines 149-155
## Where each colour appears
| Semantic | Used by |
|---|---|
| `--badge-bonus-*` (green) | Adjustments tab type badge; By-Type group-header left-border accent |
| `--badge-overtime-*` (mauve) | Adjustments tab type badge; By-Type group-header accent |
| `--badge-deduction-*` (purple) | Adjustments tab type badge; By-Type group-header accent |
| `--badge-loan-*` (amber/yellow) | Adjustments tab type badge; By-Type group-header accent; Pending tab "Loan" worker flag (`.loan-flag-badge`) |
| `--badge-loan-rep-*` (deeper amber, +15% saturation) | Adjustments tab type badge for Loan Repayment; By-Type group-header accent |
| `--badge-advance-*` (blue) | Adjustments tab type badge; By-Type group-header accent |
| `--badge-advance-rep-*` (deeper blue, +15% saturation) | Adjustments tab type badge for Advance Repayment; By-Type group-header accent |
## Transactional-state colours (Bootstrap — unchanged)
| Use | Class |
|---|---|
| Paid payslip badge | `bg-success` |
| Unpaid status badge | `bg-warning` |
| Overdue worker flag (Pending tab) | `bg-danger` |
## How to add a new colour token
1. Define in BOTH the `:root` and `[data-theme="light"]` blocks in
`static/css/custom.css`. Choose colours that retain enough contrast
against the card background in both themes.
2. Add a row to the mapping table in this doc.
3. Reference via `var(--badge-*-bg)` in CSS — never hard-code hex
anywhere else.
4. If it's a new adjustment type, add:
- A `.badge-type-<slug>` class in the `.badge-type-*` block
(around line 1935 of `custom.css`)
- An entry in the `.adj-group-header[data-type="..."]` block
(around line 1994)
- The new TYPE_CHOICES entry in `core/models.py::PayrollAdjustment`
(and run `makemigrations`)
## Maintenance
This doc is the single source of truth for app-wide colour semantics.
When CSS tokens are added / removed / renamed in `custom.css`, update
this doc in the SAME commit.
````
**Step 2: Commit**
```bash
git add docs/design-tokens.md
git commit -m "$(cat <<'EOF'
docs(tokens): add canonical design-tokens reference
New doc covering the semantic colour palette: every badge token, its
hex values in both themes, its CSS class, and where it's used across
the app. Categorises tokens into "type-of-adjustment" (custom semantic
palette) vs "transactional state" (Bootstrap defaults) and explains
why the two must not share colours.
Intended to be the single source of truth for UI colour decisions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
No tests, no code change. File-only addition.
---
## Task 2: CLAUDE.md — the "UI-vs-DB naming drift" section
**Goal:** Document the permanent gap between display labels and DB values so future Claude (and Konrad, on a tired day) don't chase ghosts. This goes in BEFORE any rename work — so it's searchable the moment the rename ships.
**Files:**
- Modify: `CLAUDE.md` (insert a new section near the existing "Schema name-drifts to remember" section)
**Step 1: Find the insertion point**
Use Grep to find the exact line:
```
grep -n "Schema name-drifts to remember" CLAUDE.md
```
Expected: one match, around line 52 (give or take).
**Step 2: Read the next ~20 lines to see the end of that block**
Use the Read tool at that line, limit=30, to see where the "Schema name-drifts" section ends. Note the line of the last bullet + the blank line after it.
**Step 3: Insert the new section**
Insert the following block AFTER the last `- ` bullet of the "Schema name-drifts to remember" section (before the next `##` header):
```markdown
## UI-vs-DB naming drift (Apr 2026) — READ BEFORE WRITING FORMULAS
`PayrollAdjustment.type` is DISPLAYED to users with short labels,
but the raw string stored in the database is always the long
legacy value:
| What the user SEES | What the DATABASE stores |
|---|---|
| Bonus | `'Bonus'` |
| Overtime | `'Overtime'` |
| Deduction | `'Deduction'` |
| Loan Repayment | `'Loan Repayment'` |
| Loan | `'New Loan'` ← mismatch |
| Advance | `'Advance Payment'` ← mismatch |
| Advance Repaid | `'Advance Repayment'` ← mismatch |
When writing ANY formula, filter, comparison, ORM query, test
fixture, CSS class name, or `data-type=` attribute: use the
DATABASE value (left column of the model).
- `ADDITIVE_TYPES = ['Bonus', 'Overtime', 'New Loan', 'Advance Payment']`
in `views.py` uses DB values.
- `if adj.type == 'New Loan':` checks the DB value.
- `<span class="badge-type-{{ adj.type|type_slug }}">` produces
`.badge-type-new-loan` from the DB value.
- `<tr data-type="{{ adj.type }}">` emits the DB value.
- Tests use `PayrollAdjustment.objects.create(type='New Loan', ...)`.
Only user-facing template TEXT uses the short label — via
`{{ adj.get_type_display }}`, Django's built-in choices lookup.
The label mapping lives in `PayrollAdjustment.TYPE_CHOICES`
(`core/models.py`).
**How this happened:** originally the adjustment-creation dropdown
said "New Loan" because that's what the action meant (_"log a new
loan"_). That label then propagated into every other view — tables,
badges, reports. On 24 Apr 2026 we renamed the user-visible labels
to be shorter and cleaner BUT deliberately kept the database values
untouched — to avoid breaking historic rows, tests, and hardcoded
string comparisons across ~30 source locations.
**Symptom of getting this wrong:** code that filters for
`type='Loan'` returns zero rows. Fix: use `type='New Loan'`.
```
**Step 4: Verify the insert didn't break anything**
```
grep -c "^## " CLAUDE.md # section-count should have increased by exactly 1
```
Run the test suite as a sanity check (CLAUDE.md isn't code, but any accidental wholesale rewrite of the file would show up elsewhere):
```
set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py test core.tests -v 0
```
Expected: 69/69 pass (no code changed).
**Step 5: Commit**
```bash
git add CLAUDE.md
git commit -m "$(cat <<'EOF'
docs(claude): UI-vs-DB naming drift note (pre-rename)
Adds a new CLAUDE.md section documenting the display/DB gap that
Path A of the UX Polish Pass creates: user sees 'Loan' / 'Advance'
/ 'Advance Repaid' while DB stores 'New Loan' / 'Advance Payment'
/ 'Advance Repayment'. Includes a lookup table, the rule for when
to use which (DB for logic, display for templates), and the failure
symptom so future Claude sessions don't chase ghost filters.
Ships BEFORE the rename so the doc is searchable from minute one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: Display-only rename (TYPE_CHOICES + visible templates + migration)
**Goal:** The three rename labels take effect everywhere a user sees a type. DB stays untouched. `makemigrations` generates the required no-op `AlterField`.
**Files:**
- Modify: `core/models.py` — the `TYPE_CHOICES` list in `PayrollAdjustment`
- Create: `core/migrations/0012_alter_payrolladjustment_type.py` (auto-generated — do NOT hand-write)
- Modify: `core/templates/core/payroll_dashboard.html` — lines 370, 454 (visible `{{ adj.type }}` emissions)
- Modify: `core/templates/core/_adjustment_row.html` — the type-cell render (uses `badge-type-{{ adj.type|type_slug }}` and also emits `{{ adj.type }}` as visible text — only the visible-text copy changes)
- Grep-audit: other templates (`payslip_detail.html`, PDF templates, any other `{{ adj.type }}` emission) — audit & fix where visible
**Step 1: Edit `core/models.py`**
Read the current `TYPE_CHOICES` block first:
```
grep -n "TYPE_CHOICES = \[" core/models.py
```
Expected: one match around line 190. Read lines 190-210 to confirm current state matches:
```python
TYPE_CHOICES = [
('Bonus', 'Bonus'),
('Overtime', 'Overtime'),
('Deduction', 'Deduction'),
('Loan Repayment', 'Loan Repayment'),
('New Loan', 'New Loan'),
('Advance Payment', 'Advance Payment'),
('Advance Repayment', 'Advance Repayment'),
]
```
Replace with:
```python
# === PayrollAdjustment TYPE_CHOICES — canonical DB value | display label ===
# Path A rename (24 Apr 2026): DB values are PRESERVED as-is. Only the
# second tuple element (the human label) changes for three types, so
# users see shorter labels in tables while every historic row, formula,
# constant, test fixture, CSS class, and data-attribute KEEP WORKING
# UNCHANGED because they all key off the DB value on the left.
# See CLAUDE.md "UI-vs-DB naming drift" section for the full rule.
TYPE_CHOICES = [
('Bonus', 'Bonus'),
('Overtime', 'Overtime'),
('Deduction', 'Deduction'),
('Loan Repayment', 'Loan Repayment'),
('New Loan', 'Loan'),
('Advance Payment', 'Advance'),
('Advance Repayment', 'Advance Repaid'),
]
```
**Step 2: Generate the migration**
```
set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py makemigrations core --name alter_payrolladjustment_type_display_labels
```
Expected output: creates `core/migrations/0012_alter_payrolladjustment_type_display_labels.py` with a single `AlterField` operation changing `choices`. The migration is a no-op at the database level — Django tracks choices in its model metadata, not in the DB schema.
Open the generated file and confirm the operation is EXACTLY `AlterField` with `choices=[...]` — no `RunPython`, no `RunSQL`, no schema-altering operation. If you see anything more, stop and ask — something is wrong.
**Step 3: Run the migration locally**
```
set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py migrate
```
Expected: `Applying core.0012_alter_payrolladjustment_type_display_labels... OK` — 1 operation, < 1 second.
**Step 4: Grep-audit visible `{{ adj.type }}` template usages**
```
grep -rn "{{ adj\.type }}" core/templates/
grep -rn "{{ adjustment\.type }}" core/templates/
grep -rn "{{ a\.type }}" core/templates/
```
For EACH match, decide:
- Is the surrounding context VISIBLE text (e.g., `>{{ adj.type }}<` inside a badge span, or in a table cell)? → change to `{{ adj.get_type_display }}` (or `{{ adjustment.get_type_display }}` / `{{ a.get_type_display }}`)
- Is it a DATA ATTRIBUTE (`data-adj-type="{{ adj.type }}"`) or a CSS CLASS slug (`badge-type-{{ adj.type|type_slug }}`)? → LEAVE AS-IS. This is an identifier feed, not a display text.
Known sites (from the brainstorm grep — verify each):
1. `core/templates/core/payroll_dashboard.html:370` — inside `<span class="badge ...">{{ adj.type }}</span>` → SWAP to `{{ adj.get_type_display }}`
2. `core/templates/core/payroll_dashboard.html:454``{{ adj.type }}: R {{ adj.amount }}` → SWAP
3. `core/templates/core/payroll_dashboard.html:363``data-adj-type="{{ adj.type }}"` → LEAVE (identifier, consumed by JS)
4. `core/templates/core/_adjustment_row.html` — grep in that file; type appears both as a CSS class slug AND potentially as visible text. Only the visible-text copy swaps.
Also audit PDF + other detail pages:
```
grep -rn "{{ adj\.type }}\|{{ adjustment\.type }}\|{{ a\.type }}" core/templates/core/pdf/ core/templates/core/payslip_detail.html core/templates/core/report.html
```
Apply the same visible-vs-identifier call.
**Step 5: Run the full test suite**
```
set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py test core.tests -v 2
```
Expected: 69/69 pass. Tests use DB values in fixtures (`type='New Loan'`) so NONE should break. If any test fails, something in Step 4 went wrong — revert and re-audit.
**Step 6: Visual smoke test via `manage.py shell`**
Quick sanity check that `get_type_display` returns the new labels:
```
set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py shell -c "from core.models import PayrollAdjustment; choices = dict(PayrollAdjustment.TYPE_CHOICES); print('New Loan displays as:', choices.get('New Loan')); print('Advance Payment displays as:', choices.get('Advance Payment')); print('Advance Repayment displays as:', choices.get('Advance Repayment'))"
```
Expected output:
```
New Loan displays as: Loan
Advance Payment displays as: Advance
Advance Repayment displays as: Advance Repaid
```
**Step 7: Commit**
```bash
git add core/models.py core/migrations/0012_*.py core/templates/
git commit -m "$(cat <<'EOF'
ux(labels): shorter adjustment type labels (display-only rename)
Path A rename — DB values untouched, only TYPE_CHOICES display
labels change:
'New Loan' → shown as 'Loan'
'Advance Payment' → shown as 'Advance'
'Advance Repayment' → shown as 'Advance Repaid'
Templates that render the type as visible text switched from
{{ adj.type }} to {{ adj.get_type_display }}. Data attributes and
CSS class slugs keep the raw DB value (identifiers, not labels).
Zero data migration. Zero changes to ADDITIVE_TYPES / DEDUCTIVE_TYPES
constants, hardcoded string comparisons, CSS class names, test
fixtures, or any other code that references the canonical DB value.
Every historic PayrollAdjustment row keeps type='New Loan' /
'Advance Payment' / 'Advance Repayment' as stored.
Django's makemigrations generated a no-op AlterField migration to
record the choices-metadata change.
Tests: 69/69.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: Badge colour unification + loan-flag recolor
**Goal:** Replace the three occurrences of the 4-branch Bootstrap-state conditional with a one-liner `badge-type-{{ adj.type|type_slug }}` that reuses the existing semantic palette. Recolour the Pending-tab "Loan" worker flag to the loan type colour.
**Files:**
- Modify: `core/templates/core/payroll_dashboard.html` — lines 360, 453 (type-badge blocks) + line 345 (loan flag)
- Modify: `static/css/custom.css` — add `.loan-flag-badge` class near the `.badge-type-*` block
**Step 1: Preconditions**
Confirm the existing context-variable `additive_types` is threaded to the Pending + History tab contexts. The Adjustments tab already uses it (see `_adjustment_row.html` line 42). For Pending + History, grep:
```
grep -n "additive_types" core/views.py
```
If the Pending/History branch of `payroll_dashboard()` does NOT set `additive_types` in its context, we need to add it. The constant is already defined at `views.py:45` — it's just a matter of adding one key to the context dict. Look for the `context = { ... }` block in the `payroll_dashboard` view that handles the default/pending branch (grep for `'workers_data'` as a nearby key — the same context dict).
If `additive_types` is already set for those branches, no view change is needed. Only the templates change.
**Step 2: Replace the type-badge block at line 360 (Pending tab)**
Current (lines 356-372):
```django
{% for adj in wd.adjustments %}
{# Badge colour logic: #}
{# GREEN = earned money (Bonus, Overtime) or debt recovery (Loan/Advance Repayment) #}
{# YELLOW = loan-related outflow (New Loan, Advance Payment) — matches the Loan tag #}
{# RED = deductions (Deduction) #}
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'Loan Repayment' or adj.type == 'Advance Repayment' %}bg-success{% elif adj.type == 'New Loan' or adj.type == 'Advance Payment' %}bg-warning{% else %}bg-danger{% endif %} mb-1 me-1 adjustment-badge"
style="cursor: pointer;"
data-adj-id="{{ adj.id }}"
data-adj-type="{{ adj.type }}"
data-adj-amount="{{ adj.amount }}"
data-adj-date="{{ adj.date|date:'Y-m-d' }}"
data-adj-description="{{ adj.description }}"
data-adj-project="{{ adj.project_id|default:'' }}"
data-adj-worker="{{ adj.worker.name }}">
{% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' or adj.type == 'Advance Payment' %}+{% else %}-{% endif %}R{{ adj.amount|floatformat:2 }}
{{ adj.type }}
{% if adj.project %}({{ adj.project.name }}){% endif %}
</span>
{% endfor %}
```
Note: `{{ adj.type }}` at the old line 370 will already have been changed to `{{ adj.get_type_display }}` by Task 3. If Task 3 shipped correctly, the current text at line 370 is `{{ adj.get_type_display }}`. Don't revert it.
Replace the badge block with:
```django
{% for adj in wd.adjustments %}
{# Type badge uses the semantic palette: colour = type (Bonus, Loan, etc.). #}
{# Sign + / reflects additive-vs-deductive (orthogonal to the colour). #}
<span class="badge badge-type-{{ adj.type|type_slug }} mb-1 me-1 adjustment-badge"
style="cursor: pointer;"
data-adj-id="{{ adj.id }}"
data-adj-type="{{ adj.type }}"
data-adj-amount="{{ adj.amount }}"
data-adj-date="{{ adj.date|date:'Y-m-d' }}"
data-adj-description="{{ adj.description }}"
data-adj-project="{{ adj.project_id|default:'' }}"
data-adj-worker="{{ adj.worker.name }}">
{% if adj.type in additive_types %}+{% else %}-{% endif %}R{{ adj.amount|floatformat:2 }}
{{ adj.get_type_display }}
{% if adj.project %}({{ adj.project.name }}){% endif %}
</span>
{% endfor %}
```
Three things happened in this change:
- `class="badge ...multi-line conditional..."``class="badge badge-type-{{ adj.type|type_slug }}"`
- Sign logic refactored from a long `{% if %}` chain to `{% if adj.type in additive_types %}` (cleaner, single source of truth for additive set)
- Outdated comment block removed; new comment describes the current semantic scheme
**Step 3: Replace the type-badge block at line 453 (History tab)**
Current:
```django
{% for adj in record.adjustments.all %}
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'Loan Repayment' or adj.type == 'Advance Repayment' %}bg-success{% elif adj.type == 'New Loan' or adj.type == 'Advance Payment' %}bg-warning{% else %}bg-danger{% endif %} me-1">
{{ adj.type }}: R {{ adj.amount|floatformat:2 }}
</span>
{% empty %}
```
(The `{{ adj.type }}` on the inner line was already swapped to `{{ adj.get_type_display }}` by Task 3.)
Replace with:
```django
{% for adj in record.adjustments.all %}
<span class="badge badge-type-{{ adj.type|type_slug }} me-1">
{{ adj.get_type_display }}: R {{ adj.amount|floatformat:2 }}
</span>
{% empty %}
```
**Step 4: Recolour the loan flag at line 345 (Pending tab)**
Current line 345:
```django
<span class="badge bg-warning" style="font-size: 0.6rem;" title="Has active loan or advance">Loan</span>
```
Replace with:
```django
<span class="badge loan-flag-badge" style="font-size: 0.6rem;" title="Has active loan or advance">Loan</span>
```
Line 342 (Overdue flag) stays `bg-danger` — it's transactional / urgency, not type. Don't touch it.
**Step 5: Add the `.loan-flag-badge` CSS class**
In `static/css/custom.css`, find the `.badge-type-advance-repayment` line (around line 1941) and append a new block right after the type-badge definitions:
```css
/* --- Status flags that borrow a type's colour for semantic consistency.
"Has an active loan or advance" → Loan-type amber/yellow, so the
worker flag on the Pending tab visually matches the Adjustments
type badge for Loan. Keeps the Loan colour family unified across
the app regardless of which tab you're looking at. --- */
.loan-flag-badge {
background: var(--badge-loan-bg);
color: var(--badge-loan-fg);
}
```
**Step 6: Run the full test suite**
```
set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py test core.tests -v 2
```
Expected: 69/69. Template changes don't hit any test assertion.
**Step 7: Visual smoke-test checklist**
The implementer must mentally walk through these with the template open in an editor (no browser needed if Django check passes):
- [ ] Pending tab: `{% for adj in wd.adjustments %}` block — the badge has exactly one class besides `badge`: `badge-type-{{ adj.type|type_slug }}`. No `bg-success`/`bg-warning`/`bg-danger` left.
- [ ] Pending tab: the "Loan" worker flag uses `loan-flag-badge` class, NOT `bg-warning`.
- [ ] Pending tab: the "Overdue" worker flag still uses `bg-danger`. (Don't "helpfully" change this.)
- [ ] History tab: `{% for adj in record.adjustments.all %}` block — same check as Pending.
- [ ] Paid #N / Unpaid badges elsewhere — still use Bootstrap state classes (`bg-success`/`bg-warning`). NOT touched.
Run `python manage.py check` as a final sanity check:
```
set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py check
```
Expected: no errors (the pre-existing `staticfiles.W004` warning is fine).
**Step 8: Commit**
```bash
git add core/templates/core/payroll_dashboard.html static/css/custom.css
git commit -m "$(cat <<'EOF'
ux(colors): unify badge colours across all payroll tabs
Replaces the 4-branch Bootstrap-state conditional on the Pending
and History tabs with the semantic .badge-type-{{ adj.type|type_slug }}
palette that the Adjustments tab has been using. Now "Loan" badges
are the same colour in every tab instead of Pending=yellow /
Adjustments=amber.
Also recolours the Pending-tab "Loan" worker flag to the same amber
(.loan-flag-badge class). "Overdue" flag stays red — it's an urgency
signal, not a type signal, and we deliberately keep transactional
state colours (Bootstrap bg-success/bg-warning/bg-danger) separate
from the type palette so a green badge can only mean "Bonus" and
never ambiguously "Paid".
Tests: 69/69.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 5: Fix `.adj-group-header` CSS (narrow-wrap bug)
**Goal:** Make the group-summary row span the full table width and push the "N row · +R X net" meta to the right, as originally intended. Root cause: `display: flex` was applied to the `<tr>`, which breaks table rendering (a flexed `<tr>` ignores `colspan` and shrinks to intrinsic content).
**Files:**
- Modify: `static/css/custom.css` — the `.adj-group-header` block around lines 1972-1988
**Step 1: Read the current block**
```
grep -n "\.adj-group-header {" static/css/custom.css
```
Expected: one match at line 1972. Read lines 1972-2000 to see the full current state.
**Step 2: Rewrite the block**
Replace the CURRENT content of lines 1972-1988 (note: preserve line 1984's `:hover` rule and everything below it):
```css
/* --- Group header (collapsible section divider for group-by mode) ---
NOTE: display: flex MUST be on the <td>, NOT on the <tr>. Setting
display on a <tr> removes it from table row/column participation
(colspan is ignored, the row shrinks to intrinsic content width),
which caused the "narrow wrap" screenshot bug in Apr 2026. The td
is an ordinary block box and flexes fine. --- */
.adj-group-header {
cursor: pointer;
background: var(--bg-inset);
border-top: 1px solid var(--border-default);
border-bottom: 1px solid var(--border-default);
user-select: none;
transition: background-color 120ms;
}
.adj-group-header > td {
padding: 0.75rem 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.adj-group-header:hover { background: var(--bg-card-hover); }
.adj-group-header .fa-chevron-down,
.adj-group-header .fa-chevron-right { opacity: 0.7; width: 0.8rem; }
.adj-group-header .adj-group-label { font-weight: 600; }
.adj-group-header .adj-group-meta { color: var(--text-secondary); font-size: 0.875rem; margin-left: auto; white-space: nowrap; }
```
Diff from before:
- REMOVED `display: flex; align-items: center; gap: 0.75rem;` from `.adj-group-header` (it was on the `<tr>`, which was the bug)
- REMOVED `padding: 0.75rem 1rem;` from `.adj-group-header` (moved to the `<td>` where it belongs)
- ADDED `.adj-group-header > td { ... }` with the flex stuff now applied to the `<td>` — where it actually works
- ADDED `white-space: nowrap;` on `.adj-group-meta` so the meta text can overflow or fit on one line, never wrap into an ugly stub even in narrow viewports
- ADDED the explanatory comment documenting why this looks slightly unusual
Leave lines 1994-2007 (the `[data-type="X"]` border-left rules + chevron-rotation rules) alone — they were already correctly scoped and don't need to change.
**Step 3: Run the test suite**
```
set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py test core.tests -v 2
```
Expected: 69/69. CSS-only change; no test should react.
**Step 4: Visual confirmation** (walk the template + CSS mentally)
- [ ] `<tr class="adj-group-header">` no longer gets `display: flex`, so the `<tr>` participates in table layout again. `colspan="10"` on the `<td>` is now honoured — the row spans all 10 columns.
- [ ] `<td>` has `display: flex`, so the icon / label / meta are flex children. `align-items: center` vertically centres them. `gap: 0.75rem` puts space between them.
- [ ] `.adj-group-meta { margin-left: auto; }` now works (it's a flex child of a flex container).
- [ ] `.adj-group-meta { white-space: nowrap; }` prevents the "Bonus 1 / row · +R / 444" stutter wrap even if something downstream tries to squeeze the cell.
- [ ] The `[data-type="X"]` border-left accent still paints the left edge of the row (it targets `.adj-group-header`, which is the `<tr>`; the `<tr>` is once again a normal table row).
**Step 5: Commit**
```bash
git add static/css/custom.css
git commit -m "$(cat <<'EOF'
fix(css): move display:flex from <tr> to <td> on adj-group-header
Root cause of Konrad's narrow-wrap screenshot: display:flex was set
on .adj-group-header (a <tr>), which causes the browser to remove
the row from table layout. A flex-mode <tr> ignores colspan and
shrinks to intrinsic content width — which is why a row with
colspan=10 ended up rendering at ~80-100px and wrapping the meta
text into a 5-char column.
Moved display:flex, align-items, gap, and padding onto the single
<td> child. The td is a normal block box and flexes correctly,
putting icon + label + meta in a horizontal row with the meta
pushed to the right via margin-left:auto (now working since its
parent is a real flex container).
Also added white-space:nowrap on .adj-group-meta so the meta never
wraps mid-phrase even if a narrow viewport squeezes the cell.
Inline comment documents the <tr> vs <td> distinction so future
sessions don't re-introduce the bug.
Tests: 69/69.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Final acceptance checklist
Before declaring the pass complete, the controller verifies:
- [ ] Every commit's prefix is appropriate (`docs(...)`, `ux(...)`, `fix(css)`)
- [ ] `set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py test core.tests -v 2` → 69/69 passing
- [ ] `python manage.py check` → only the pre-existing `staticfiles.W004` warning
- [ ] `python manage.py migrate` ran cleanly in local dev (one new migration 0012)
- [ ] `grep -n "New Loan" core/models.py` still finds the DB value in TYPE_CHOICES (left tuple element) → confirms Path A discipline held
- [ ] `grep -rn "{{ adj.type }}" core/templates/` → only identifier contexts remain (data-attrs, CSS class slugs), no visible-text usages
- [ ] `grep -n "bg-success\|bg-warning\|bg-danger" core/templates/core/payroll_dashboard.html` → still finds the "Paid"/"Unpaid"/"Overdue" occurrences (transactional state, correct), but NONE on adjustment-type badges
- [ ] Working tree clean, branch ready to push
---
## What's NOT in this plan (explicit non-goals)
- Any change to DB values of `PayrollAdjustment.type`
- Any edit to `ADDITIVE_TYPES` / `DEDUCTIVE_TYPES` constants
- Any hardcoded-string comparison in `core/views.py`
- Any test fixture or assertion
- Any badge class rename (`.badge-type-new-loan` stays)
- Any `data-type="..."` attribute value change
- Bootstrap state colours being replaced anywhere (we explicitly keep `bg-success`/`bg-warning`/`bg-danger` for transactional badges)
- Adjustments tab layout or filter changes (Pass A for that shipped earlier)
Rollback: `git revert <sha>` on any individual commit. No data, schema, or URL-contract impact in any task.
---
## Execution
Plan complete and saved to `docs/plans/2026-04-24-ux-polish-plan.md`.
Per the arguments this plan was generated with, auto mode is active and the execution path is:
**Sub-skill: `superpowers:subagent-driven-development`**
Controller stays in-session and dispatches fresh subagents per task with spec-compliance + code-quality review after each. Expected ~5 task commits + ~2 fix commits if reviewers find anything = ~7 commits on `ai-dev`.