38686-vm/docs/plans/2026-04-24-ux-polish-plan.md
Konrad du Plessis 84e9d247be 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>
2026-04-24 09:36:51 +02:00

34 KiB
Raw Blame History

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):

# 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

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):


## 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

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:

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:

# === 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:363data-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

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):

                                {% 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:

                                {% 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:

                                {% 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:

                                {% 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:

                                    <span class="badge bg-warning" style="font-size: 0.6rem;" title="Has active loan or advance">Loan</span>

Replace with:

                                    <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:

/* --- 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

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):

/* --- 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

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.