Project

General

Profile

Feature #3250 » design-consistency-audit-3250.txt

Bot AI, 09/01/2026 12:50 PM

 
================================================================================
GATE 6 — DESIGN CONSISTENCY AUDIT (node W15_gate_design_consistency_audit)
Repo: oms-web (Next.js 14 / Tailwind 3 / Prisma+Postgres, standalone)
Branch: feat/3250-oms-ui-bug-fixes-chips-s
HEAD: 2fa4249 "4. Dead Code (oms-bug-fixes)"
Gate: gates/nextjs-tailwind-standalone/design-consistency-audit-gate.md
Ticket: #3250
Result: FAIL — 3 unresolved P1 findings (all [accessibility]; WCAG blockers)
4 findings auto-fixed and re-verified (see "AUTO-FIXES APPLIED")
================================================================================

FEATURE FILES AUDITED (12)
app/(app)/admin/items/page.tsx
app/(app)/admin/projects/page.tsx
app/(app)/admin/suppliers/page.tsx
app/(app)/admin/uom/page.tsx
app/(app)/admin/users/page.tsx
app/(app)/expense-summary/[project]/page.tsx
app/(app)/expense-summary/page.tsx
app/(app)/expense-summary/ExpenseSummaryRowActions.tsx
app/(app)/expenses/page.tsx
app/(app)/orders/page.tsx
components/forms/ExpenseForm.tsx
components/forms/ResetPasswordForm.tsx

Shared components the feature files render through, read as supporting evidence
and cited where a finding's root cause lives there (three of them are also
where an auto-fix landed): tailwind.config.ts, app/globals.css,
components/ui/{DataTable,StatusBadge,RowActions,RepeaterTable,Toast,table}.tsx,
components/ui/icons/SortIcon.tsx,
components/ui/fields/{DatePicker,Listbox,TextField,SelectField}.tsx,
components/ui/FilterBar.tsx, app/(app)/expenses/ExpenseRowActions.tsx.

--------------------------------------------------------------------------------
SKILL RESOLUTION (gate file "Resolution order", per-skill, as required)
--------------------------------------------------------------------------------
No slash-command layer was available for either skill, so resolution-order
steps 1 and 2 were both unavailable. Both skills resolved via step 3 — read as
FILES, and followed directly. Neither was skipped or degraded.

As already recorded by Gate 5 (W14), the gate file's stated skill path
(.claude/skills/nextjs-tailwind-<name>/SKILL.md) does not exist in this
checkout; the real location is one level up, outside the repo:

1. nextjs-tailwind-design-system-audit .... step 3 (file) OK, fully readable
/opt/drcarrot-deploy/cross-repo-orchestration/skills/
nextjs-tailwind-design-system-audit/SKILL.md
2. nextjs-tailwind-accessibility-audit .... step 3 (file) OK, fully readable
/opt/drcarrot-deploy/cross-repo-orchestration/skills/
nextjs-tailwind-accessibility-audit/SKILL.md

--------------------------------------------------------------------------------
DESIGN-SPEC INPUT — RESOLVED, WITH A CAVEAT
--------------------------------------------------------------------------------
The node input names the design spec as `docs/specs/ticket-3250-design.md`.
That file DOES NOT EXIST in this checkout — `docs/specs/` contains only
ticket-3239-design.md and ticket-3248-design.md, and `grep -rn 3250 --include=*.md`
over the whole repo returns nothing. (Gate 5 recorded the same absence.)

The audit was NOT run blind: the design_spec node input carried the spec's full
substance inline (views, form fields, unmapped tokens, R1–R6 findings), and the
underlying design bundle it was decoded from is present on disk
(/tmp/design_template.html, 128767 bytes, and the /tmp/dev-hub-spec-*.html
family). Token claims below were checked against tailwind.config.ts, which
itself carries per-token provenance comments back to those bundles.

Consequence: this gate could verify "does the code use the tokens the spec
says exist" and "are the same visual concepts implemented consistently". It
could NOT re-derive measured pixel values from the design, because there is no
committed spec artifact to diff against. That is a process gap worth closing
before Gate 7 (responsive), not a defect in this branch's code.

--------------------------------------------------------------------------------
COMMANDS ACTUALLY RUN (real output, not predicted)
--------------------------------------------------------------------------------
1. Repo's own lint, before any change:
npx next lint --dir app --dir components
-> "✔ No ESLint warnings or errors"

2. Which a11y rules that lint actually enforces (the reason 1. proves little):
npx eslint --print-config components/forms/ExpenseForm.tsx
-> jsx-a11y rules enabled: 6 of ~35
alt-text, aria-props, aria-proptypes, aria-unsupported-elements,
role-has-required-aria-props, role-supports-aria-props
eslint-plugin-jsx-a11y 6.10.2 is installed (via eslint-config-next), so
the remaining rules are available but off under next/core-web-vitals.

3. Expanded jsx-a11y sweep over the 12 feature files (15 additional rules:
label-has-associated-control, control-has-associated-label,
click-events-have-key-events, no-static-element-interactions,
no-noninteractive-element-interactions, interactive-supports-focus,
anchor-is-valid, anchor-has-content, heading-has-content,
no-redundant-roles, tabindex-no-positive, mouse-events-have-key-events,
no-noninteractive-tabindex, scope, aria-activedescendant-has-tabindex):

components/forms/ExpenseForm.tsx:135 control-has-associated-label
components/forms/ExpenseForm.tsx:177 control-has-associated-label
components/forms/ExpenseForm.tsx:269 control-has-associated-label
components/forms/ResetPasswordForm.tsx:55 control-has-associated-label
4 problems

Triaged by hand against the source:
:135 TRUE POSITIVE -> finding A11Y-6 below.
:177 false positive -> the <input type="file"> is WRAPPED by the <label>
at :174 whose text is "Upload"/"Uploading…"; the
rule does not see through the wrap.
:269 false positive -> <textarea id="remarks"> + <label htmlFor="remarks">
at :266.
RPF:55 false positive -> <input id={name}> + <label htmlFor={name}> at :51.

4. Arbitrary-colour sweep over the 12 feature files:
grep -nE '\[#[0-9a-fA-F]{3,8}\]|\[rgb\(|\[hsl\(' <each feature file>
-> 11 of 12 files: CLEAN, zero arbitrary colour values.
-> components/forms/ResetPasswordForm.tsx only: lines 154, 160, 176, 183.
(Repo-wide across app/ + components/ the same pattern hits 21 lines, so
the branch is not an outlier — but 4 of them are in a feature file.)

5. outline-none sweep:
grep -rn "outline-none" app components --include=*.tsx
-> 6 hits, EVERY ONE paired with a replacement focus style
(focus-visible:ring-* or focus-visible:bg-*) or on a non-focusable
element. No bare focus-suppression anywhere. Checked-and-clean.

6. Clickable-non-interactive sweep:
grep -rnE '<(div|span|li|td|tr)[^>]*onClick' app components --include=*.tsx
-> 1 hit: components/ui/Modal.tsx:35, the backdrop, which is correctly
aria-hidden="true" and duplicated by a real Escape handler.
Checked-and-clean.

7. WCAG contrast computed (not eyeballed) with the WCAG 2.x relative-luminance
formula over the exact token hexes in tailwind.config.ts:

2.25:1 rgb(163,163,163) 13px on accent.subtle #f7f1e9 FAIL (need 4.5)
3.09:1 status.success #229e4b 13px on #f7f1e9 FAIL (need 4.5)
3.47:1 status.success #229e4b 12px on surface #ffffff FAIL (need 4.5)
4.19:1 text.muted on accent.subtle (DISABLED control) exempt, 1.4.3
4.55:1 status.danger .field-error on surface pass
4.70:1 text.muted placeholder on surface pass
7.34:1 text.subtle #565656 on surface pass
11.79:1 accent on accent.subtle (table header/sort link) pass
16.69:1 text.DEFAULT on accent.subtle (row hover) pass
18.73:1 text.DEFAULT on surface pass
Non-text (WCAG 1.4.11, 3:1) — every StatusBadge dot tone and every row-action
icon: success 3.47, warning 3.34, neutral 4.83, info 3.55, danger 4.55.
ALL PASS. The dots are additionally aria-hidden with adjacent text, so they
never carry meaning by colour alone.

8. Post-fix re-verification (see AUTO-FIXES APPLIED):
npx tsc --noEmit -> exit 0, no output
npx next lint --dir app --dir components -> "✔ No ESLint warnings or errors"
npx vitest run -> 94 passed / 6 failed files
732 passed / 8 failed tests
The 8 failures were then proven PRE-EXISTING, not caused by these fixes:
the 4 touched files were `git stash`ed and the same 6 test files re-run at
the untouched HEAD — identical result, 8 failed / 57 passed. The stash was
then popped. Failing tests (all pre-existing, Gate-9 territory, not design):
expense-form.test.tsx "selects an expense type via the row's Listbox"
expense-summary-page.test.tsx "…a View Details link" <- asserts the OLD
text link that #3250 deliberately replaced with the Eye icon action
expenses-page.test.tsx "…total, and status" <- asserts the Status
column that #3250 deliberately removed (see DS-5)
item-form.test.tsx "pre-populates fields from the given item"
order-form.test.tsx "renders the Add Supplier trigger…"
user-form.test.tsx 3 tests, all Listbox getByLabelText
Two of those six are stale assertions against #3250's own intended changes
and should be updated by the unit-test gate, not by this one.

================================================================================
MERGED FINDING LIST — 16 findings
[design-system] 9 (P1 0 / P2 4 / P3 5)
[accessibility] 7 (P1 3 / P2 4 / P3 0)
totals (P1 3 / P2 8 / P3 5)
auto-fixed 3 findings (DS-1, A11Y-5, A11Y-6) via 4 code edits —
A11Y-6 needed two (ExpenseForm + RepeaterTable) and is
fixed only in part; its Listbox half stays reported
unresolved P1 3 (all accessibility)
Ordered per the accessibility skill's rule: barrier severity first, then
design-system domain groups.
================================================================================

--------------------------------------------------------------------------------
[accessibility] A11Y-1 — P1 — CONTRAST — UNRESOLVED
--------------------------------------------------------------------------------
File: components/forms/ResetPasswordForm.tsx:176, 183
Component: PASSWORD POLICY card, unmet-rule labels
Category: contrast
Problem: The five policy rules and the static "Not last 3 passwords" row
render at text-[13px] in text-[rgb(163,163,163)] on the card's
bg-accent-subtle (#f7f1e9). Measured 2.25:1 — WCAG 2.1 AA 1.4.3
requires 4.5:1 for text under 18.66px. This is roughly HALF the
required ratio, not a borderline miss.
Evidence: computed via the WCAG relative-luminance formula, command 7 above.
:176 className={clsx("text-[13px]", rulesMet[i] ? … : "text-[rgb(163,163,163)]")}
:183 className="text-[13px] text-[rgb(163,163,163)]"
Why it matters: low-vision users cannot read the password rules — which on this
screen ARE the instructions. The unmet state is precisely the state
a user needs to read; once a rule passes it turns green and the user
no longer needs it. The contrast is worst exactly when the content
matters most.
Recommended fix: darken the unmet-rule grey to at least #6b6b6b on #f7f1e9
(4.5:1) and give it a real token. NOT auto-fixed: the design_spec
input lists rgb(163,163,163) as an explicitly Unmapped Token with no
source in either bundle, so picking a replacement is a design
decision, not a token swap. Needs the same operator call as R1.
Risk of change: low technically, but it visibly changes the card, so it needs
design sign-off rather than a silent gate edit.

--------------------------------------------------------------------------------
[accessibility] A11Y-2 — P1 — CONTRAST — UNRESOLVED
--------------------------------------------------------------------------------
File: components/forms/ResetPasswordForm.tsx:164, 176
Component: strength label + met-rule labels
Category: contrast
Problem: status.success (#229e4b) is used as small TEXT in two places and
fails 1.4.3 in both:
:176 text-[13px] text-status-success on #f7f1e9 = 3.09:1
:164 text-xs (12px) text-status-success on #fcfcfd = 3.47:1
Both need 4.5:1.
Evidence: command 7 above. tailwind.config.ts:77 sources #229e4b from the
design bundle as a STATUS DOT / pill-fill colour — where the 3:1
non-text threshold applies and it passes comfortably. The failure is
caused by reusing a non-text colour as body text, not by the token
being wrong.
Why it matters: the "Strong"/"Weak" strength word and the satisfied-rule labels
are unreadable for low-vision users on the exact screen where a user
must judge whether their new password is acceptable.
Recommended fix: add a text-weight companion token (e.g. status.successText
≈ #1a7a39, 4.6:1 on #f7f1e9) and use it wherever #229e4b is TEXT,
leaving #229e4b for dots/fills. NOT auto-fixed: inventing a new
colour value is exactly the fabrication the design_spec's R1 finding
refuses to do — same operator decision.
Risk of change: low; scoped to two spans, no layout impact.

--------------------------------------------------------------------------------
[accessibility] A11Y-3 — P1 — FORMS / ARIA — UNRESOLVED — REGRESSION FROM #3250
--------------------------------------------------------------------------------
File: components/forms/ExpenseForm.tsx:203-212
root cause components/ui/fields/DatePicker.tsx:194-206, 169, 180-182
Component: Expense Date field (ticket requirement R3)
Category: forms / aria
Problem: R3 rewired the Date field from display-only to user-editable and
added `<label className="field-label" htmlFor="expenseDate">Date</label>`
+ `<DatePicker id="expenseDate" …>`. But DatePicker puts the `id` on
its CALENDAR TRIGGER BUTTON (DatePicker.tsx:197), and that button
already carries `aria-label="Open calendar"` (:198). Per the ARIA
accessible-name computation, aria-label OUTRANKS a host-language
<label>, so the button announces as "Open calendar, button" and the
word "Date" is never spoken. The htmlFor association is inert.
Worse, the field's CURRENT VALUE is rendered only in a
non-focusable <span> (:180-182) plus a hidden input (:169) — so a
screen-reader user has no labelled control that exposes either the
field's name or its value.
Evidence: DatePicker.tsx:194-206
<button ref={triggerRef} type="button" id={id}
aria-label="Open calendar" aria-haspopup="dialog" …>
ExpenseForm.tsx:203-212 (the new label + id wiring)
jsx-a11y could not catch this: label-has-associated-control passed,
because it only checks that htmlFor EXISTS, never that the target's
computed name matches. Confirmed by command 3 returning no warning
for these lines.
Why it matters: screen-reader users filling the expense form hit an unnamed
control on a REQUIRED field. This is not a cosmetic gap — the field
is unidentifiable and its value unreadable. Before #3250 the field
was disabled and auto-filled, so it was inert; making it editable is
what turned a dormant defect into a live barrier. That is why this is
P1 and attributed to this branch.
Same defect exists at components/ui/FilterBar.tsx:140-159, which uses
the identical htmlFor -> DatePicker id pattern. ExpenseForm is the
second adopter, so this is now the established pattern and will
propagate.
Recommended fix: in DatePicker, stop putting the caller's `id` on the trigger.
Give the control a real accessible name and value — e.g. render the
trigger as role="combobox" with aria-haspopup="dialog", move `id` to
it, DROP the hard-coded aria-label so the <label> wins, and expose
the current value as the control's content instead of a sibling span.
NOT auto-fixed: this changes DatePicker's public contract and the
announced name of every date field in the app (ExpenseForm,
OrderForm:147, FilterBar:155). Needs a deliberate change plus a
re-run of the DatePicker/FilterBar tests, not a gate-time patch.
Risk of change: medium — three call sites, and OrderForm:147 passes no id at
all, so the fallback naming path must be handled too.

--------------------------------------------------------------------------------
[accessibility] A11Y-4 — P2 — FORMS / ARIA — UNRESOLVED
--------------------------------------------------------------------------------
File: components/forms/ExpenseForm.tsx:213
root cause components/ui/fields/DatePicker.tsx:171-178
Component: Expense Date validation error
Category: forms / aria
Problem: #3250 added per-field error rendering for the date
(`{state?.fieldErrors?.expenseDate && <p className="field-error">…}`)
and an `invalid` prop on DatePicker. But `invalid` only applies a
`border-status-danger` CLASS (DatePicker.tsx:176) — it sets no
`aria-invalid` — and the error <p> has no `id`, so nothing is
connected to the control via `aria-describedby`.
Net effect: the error state is conveyed by BORDER COLOUR ALONE
(WCAG 1.4.1) and the message is never announced (WCAG 3.3.1).
Evidence: grep -rn "aria-describedby|aria-invalid" app components --include=*.tsx
-> exactly ONE hit in the entire app:
components/ui/fields/Listbox.tsx:209 aria-invalid={invalid || undefined}
So Listbox handles invalid correctly and its sibling DatePicker does
not — an inconsistency between two custom controls used side by side
in the same form row. aria-describedby is used nowhere at all;
TextField.tsx:58 and SelectField.tsx:54 render the same unassociated
`<p className="field-error">`.
Why it matters: a screen-reader user submitting an invalid date gets no
indication of which field failed or why; they are returned to a form
that looks unchanged to them.
Recommended fix: add `describedById`/`aria-invalid` support to DatePicker
(mirroring Listbox:209), give the error <p> an id, and wire
aria-describedby. Then lift the same pattern into TextField/
SelectField so the app has one error-association convention.
NOT auto-fixed: app-wide pattern change across four field components.
Risk of change: low per component, but it is a cross-cutting edit that belongs
in a dedicated change with test coverage.

--------------------------------------------------------------------------------
[accessibility] A11Y-5 — P2 — ARIA — AUTO-FIXED
--------------------------------------------------------------------------------
File: components/ui/DataTable.tsx:51-63 (the component all 9 list screens
render through; exercised by 8 of the 12 feature files)
Component: sortable column header
Category: aria
Problem: #3250's headline change makes ~14 previously-static columns sortable
across 9 screens (items, projects, suppliers, uom, users, orders,
expenses, expense-summary, expense-summary/[project]). The sort
STATE, however, was rendered only as a glyph: SortIcon.tsx:12 wraps
the whole thing in `aria-hidden="true"`, and the <th> carried no
`aria-sort`. A screen-reader user could therefore activate sorting
but never perceive which column was sorted or in which direction —
a state conveyed purely visually (WCAG 1.3.1 / 4.1.2).
Evidence: grep -rn "aria-sort" app components --include=*.tsx -> zero hits
before this fix. SortIcon.tsx:12 <span … aria-hidden="true">
Why it matters: sorting is the ticket's primary new capability. Shipping it
announced-to-nobody would mean the feature that #3250 exists to add
is the feature assistive-tech users cannot use.
Fix applied: additive `aria-sort` on the <th> — "ascending"/"descending" on the
active column, "none" on other sortable columns, absent on
non-sortable ones. Zero visual change, zero API change, no new
element. This is the "unambiguous ARIA addition" case the gate's
Step 3 authorises.
Residual (NOT fixed, reported): the sort link's accessible name is still the
bare header text ("Mobile", "Address") with no indication it sorts.
Adding "Sort by X" changes the announced name of every header in the
app and interacts with WCAG 2.5.3 label-in-name, so it is a design
decision rather than a safe patch.
Risk of change: none observed — tsc clean, lint clean, no test delta
(command 8).

--------------------------------------------------------------------------------
[accessibility] A11Y-6 — P2 — FORMS — AUTO-FIXED (partially)
--------------------------------------------------------------------------------
File: components/forms/ExpenseForm.tsx:135 (Amount input),
:127 (Type Listbox); root cause components/ui/RepeaterTable.tsx:60
Component: Expense Items repeater row controls
Category: forms
Problem: Repeater cells contain bare form controls with no <label> and no
aria-label. The only name a sighted user gets is the column header,
and that association was weak even implicitly: RepeaterTable.tsx:60
rendered its <th> WITHOUT `scope="col"` (unlike DataTable.tsx:53,
which sets it). Screen-reader users navigating the Expense Items
table hit unnamed number/select controls repeated once per row.
Evidence: real eslint run, command 3:
components/forms/ExpenseForm.tsx:135 A control must be associated
with a text label. [jsx-a11y/control-has-associated-label]
The Type Listbox at :127 has the same defect; the rule cannot see it
because Listbox is a custom component, not a host element.
Fix applied (2 of 3):
(a) ExpenseForm.tsx:135 — added `aria-label="Amount"`, mirroring the
column header verbatim. Unambiguous accessible name; the gate's
Step 3 names this exact case.
(b) RepeaterTable.tsx:60 — added `scope="col"` to the header cell,
matching DataTable.tsx:53. Additive, no visual change, and it
strengthens implicit column-header association for every control
in the table (Order Items as well as Expense Items).
Residual (NOT fixed, reported): the Type Listbox at ExpenseForm.tsx:127 still
has no accessible name. components/ui/fields/Listbox.tsx:38-51 has NO
`aria-label` prop, so naming it requires widening Listbox's public
API — beyond "clearly safe", and it would also affect OrderForm's
repeater. Same applies to the CreatableSelect cells.
Risk of change: none observed — tsc clean, lint clean, no test delta
(command 8).

--------------------------------------------------------------------------------
[accessibility] A11Y-7 — P2 — SEMANTIC / CONTRAST — UNRESOLVED (BY OPERATOR
DECISION)
--------------------------------------------------------------------------------
File: app/globals.css:48-50; used at app/(app)/orders/page.tsx:31
Component: .link
Category: semantic-html / contrast
Problem: `.link { @apply text-text hover:text-text; }` — a link with the same
colour as body text, no underline, and no hover change. Inside a
table cell the Order No link is visually indistinguishable from the
plain text in neighbouring cells (WCAG 1.4.1: no non-colour cue, and
here not even a colour cue).
Evidence: globals.css:48-50; orders/page.tsx:31 renders the Order No through it.
#3250 REMOVED the app's other `.link` usage —
expense-summary/page.tsx dropped its "View Details" text link in
favour of the Eye icon action (R5) — so orders/page.tsx:31 is now
essentially the last consumer.
Why it matters: sighted users cannot tell the cell is actionable; the underlying
element is a real <a>, so keyboard and screen-reader users are fine.
This is a sighted-discoverability defect, not an AT barrier — hence
P2 not P1.
Recommended fix: give .link a non-colour affordance (hover:underline at minimum).
DELIBERATELY NOT FIXED: globals.css:42-47 documents that the blue+
underlined treatment was previously added to fix a "rows don't look
clickable" report and then "reverted on explicit request". Re-applying
it at gate time would silently undo a recorded operator decision.
Escalating, not patching.
Risk of change: none technically; the risk is process — it contradicts a
standing instruction.

--------------------------------------------------------------------------------
[design-system] DS-1 — P2 — COLOURS — AUTO-FIXED
--------------------------------------------------------------------------------
File: components/forms/ResetPasswordForm.tsx:154
Component: strength-meter "Strength" caption
Problem: `text-[rgb(86,86,86)]` is an arbitrary value that EXACTLY duplicates
an existing theme token: tailwind.config.ts:28 defines
text.subtle = #565656 = rgb(86,86,86). Same colour, two spellings.
Why it matters: the whole point of tailwind.config.ts:23-29's provenance
comments is that text.subtle is a named, sourced design decision
(filter-row labels, info-card labels). An arbitrary duplicate means
a future retune of text.subtle silently misses this caption.
Fix applied: `text-[rgb(86,86,86)]` -> `text-text-subtle`. Byte-identical
rendered colour; this is the gate's canonical "swap a raw value for
the matching token" case.
Expected impact: zero visual change; one fewer arbitrary value in the codebase.
Risk of change: none — tsc clean, lint clean, no test delta (command 8).

--------------------------------------------------------------------------------
[design-system] DS-2 — P2 — COLOURS — UNRESOLVED
--------------------------------------------------------------------------------
File: components/forms/ResetPasswordForm.tsx:173, 180
Component: PASSWORD POLICY tick icons
Problem: The inline SVGs hard-code `stroke="rgb(34,158,75)"` — which is
EXACTLY status.success (#229e4b, tailwind.config.ts:77) — and
`stroke="rgb(163,163,163)"`. The sibling <span> on line 176 styles
the very same met/unmet state with `text-status-success` vs
`text-[rgb(163,163,163)]`. One visual concept, two implementations,
three lines apart.
Why it matters: exactly the drift this skill exists to catch — if
status.success is retuned, the label changes colour and the tick
beside it does not.
Recommended fix: drop the `stroke` attribute and drive the icon from the text
colour (`stroke="currentColor"` + the existing conditional text
class), or move to `className={clsx(rulesMet[i] ? "stroke-status-success" : …)}`.
NOT auto-fixed: it is a markup restructure (attribute -> conditional
className), not a literal token swap, and the unmet branch has no
token to swap TO (see DS-3 / A11Y-1).
Expected impact: none visually today; removes a future drift source.
Risk of change: low, but touches the same lines A11Y-1/A11Y-2 must change —
best done in one pass once the operator settles the grey.

--------------------------------------------------------------------------------
[design-system] DS-3 — P2 — COLOURS / UNMAPPED TOKENS — UNRESOLVED
--------------------------------------------------------------------------------
File: components/forms/ResetPasswordForm.tsx:160, 173, 176, 180, 183
Component: PASSWORD POLICY card + strength meter
Problem: rgb(163,163,163) appears 4x and rgb(230,230,230) 1x with no token
behind either. These are not one-off decorative values: 163,163,163
IS the "unmet policy rule" state — a recurring design decision
applied to six rows and their six icons.
Evidence: grep sweep, command 4 — these five lines are the ONLY arbitrary
colours in all 12 feature files.
Why it matters: the design_spec input names these two values explicitly under
"Unmapped tokens" and defers them ("pre-existing, out of #3250's
scope, flagged for a future pass"). This gate CONFIRMS that call is
still accurate — and records that the deferral now also carries a
WCAG P1 (A11Y-1), which raises its priority above "future pass".
Recommended fix: resolve together with A11Y-1 — one operator decision produces
both the accessible value and the token name (e.g. text.disabled +
border.track).
Expected impact: n/a until decided.
Risk of change: n/a.

--------------------------------------------------------------------------------
[design-system] DS-4 — P2 — COMPONENTS — UNRESOLVED (KNOWN BLOCKER, CORRECTLY
NOT PAPERED OVER)
--------------------------------------------------------------------------------
File: components/ui/StatusBadge.tsx (unchanged on this branch)
Component: StatusBadge — ticket requirement R1
Problem: R1 asks for a bordered/padded status CHIP with per-tone backgrounds.
It is NOT implemented. Verified: StatusBadge.tsx is not in
feature_files, does not appear in `git diff 4e6b259..HEAD`, and
still renders the 6px dot + text treatment (a 1.5x1.5 rounded-full
span + label).
Why it matters: the design_spec input and implementation_plan.F2 both classify
R1 as BLOCKED — only status.successSubtle (#e6f7ec) is a measured
tint; warning/danger/neutral/info subtles have no source in either
bundle, so building the chip means inventing 4 colours.
From a pure design-CONSISTENCY standpoint the branch is CORRECT: the
dot+text treatment is applied uniformly by all 9 list screens and 1
detail screen, and the audit confirms no screen has quietly
hand-rolled a chip with ad-hoc utility classes to satisfy R1. This is
recorded as an open requirement, not as inconsistency in what shipped.
Recommended fix: operator decision — either source the 4 missing tints or
formally descope R1. Nothing for this gate to fix.
Expected impact: n/a.
Risk of change: fabricating the 4 tones would be exactly the failure mode the
design_spec refused; do not let a later gate "helpfully" do it.

--------------------------------------------------------------------------------
[design-system] DS-5 — P3 — COMPONENTS — UNRESOLVED (NEEDS A PRODUCT CALL)
--------------------------------------------------------------------------------
File: app/(app)/expenses/page.tsx:23-28 (column set) vs :55-63 (filters)
Component: Expenses list
Problem: #3250 deleted the Status column and the StatusBadge import from the
Expenses list, but LEFT the Status filter in the toolbar. A user can
filter expenses to "Rejected" and then see no indication anywhere in
the resulting table that the rows are rejected. Every other list
screen (Orders + all 5 admin masters) still renders a Status column.
Evidence: git diff 4e6b259..HEAD shows both the `{ key: "status", … StatusBadge }`
column and the `import { StatusBadge }` line removed; the
`{ name: "status", label: "Status", type: "select", options: [PENDING,
APPROVED, …] }` filter entry is untouched.
tests/expenses-page.test.tsx still asserts the removed column and
fails as a result (command 8) — evidence the removal was intentional
in the code but not carried through the rest of the change.
Why it matters: a filter control whose effect is invisible is a UX dead end, and
Expenses is now the only list that hides a status it can filter on.
Recommended fix: either restore the Status column or remove the Status filter —
whichever matches the ticket's actual intent. NOT auto-fixed: the
design_spec lists R1–R6 and none of them covers this removal, so the
intent cannot be read off the spec.
Expected impact: restores cross-screen consistency.
Risk of change: low, but it is a product decision either way.

--------------------------------------------------------------------------------
[design-system] DS-6 — P3 — COMPONENTS — UNRESOLVED (LOW VALUE)
--------------------------------------------------------------------------------
File: app/(app)/expense-summary/ExpenseSummaryRowActions.tsx:9
vs components/ui/RowActions.tsx:33 vs
app/(app)/expenses/ExpenseRowActions.tsx:12
Component: row icon-action link — three variants of one pattern
Problem: The same "icon-only row action" concept is spelled three ways:
new (this ticket): "inline-flex p-1.5 -m-1.5 text-text"
RowActions view: "p-1.5 -m-1.5 text-text" (no inline-flex)
ExpenseRowActions: "inline-flex p-1.5 -m-1.5 text-status-info"
Colour differences are intentional and sourced (view=text,
edit=status.info, delete=status.danger, per the #3248 spec comments).
The `inline-flex` presence/absence is not.
Why it matters: marginal — the new file otherwise reuses the exact documented
pattern, the correct token, and the same lucide Eye icon, which is
precisely what the design_spec asked R5 to do.
Recommended fix: add `inline-flex` to RowActions.tsx:33 for uniformity, or
extract the shared class string. Genuinely optional.
Expected impact: cosmetic consistency only.
Risk of change: `inline-flex` alters the anchor's box model slightly; not worth
churning three files at gate time.

--------------------------------------------------------------------------------
[design-system] DS-7 — P3 — TYPOGRAPHY — UNRESOLVED
--------------------------------------------------------------------------------
File: components/forms/ResetPasswordForm.tsx:51
Component: PasswordField label
Problem: The label is hand-rolled as `text-sm font-semibold text-text` inside
a `gap-1.5` flex column, instead of the shared `.field-label`
(globals.css:34-36: `text-sm font-medium text-text mb-1`) that every
other form label in the app uses — including ExpenseForm.tsx:203 and
:266 in this same feature-file set. Same UI role, two weights
(semibold vs medium) and two spacing mechanisms (gap vs mb).
Why it matters: form labels are the highest-frequency repeated element in the
app; a second variant is how a type scale starts to drift.
Recommended fix: use `.field-label`, OR — if the design reference genuinely
specifies semibold for this screen — add a documented
`.field-label-strong` rather than an inline one-off.
NOT auto-fixed: the file header comment (:79-83) says the layout was
matched against the design reference's "RESET PASSWORD" block, so the
semibold may be deliberate and sourced. Cannot confirm without the
missing docs/specs/ticket-3250-design.md.
Expected impact: one fewer label variant.
Risk of change: low, but it is a visible weight change if the design did
specify semibold.

--------------------------------------------------------------------------------
[design-system] DS-8 — P3 — TYPOGRAPHY — UNRESOLVED
--------------------------------------------------------------------------------
File: components/forms/ResetPasswordForm.tsx:154, 170, 176, 183;
also components/forms/ExpenseForm.tsx:174
Component: 13px body/caption text
Problem: `text-[13px]` is an arbitrary size used 5x in one file and again in
ExpenseForm's attachment empty state. Tailwind's scale has no 13px
step and tailwind.config.ts extends colours only — there is no
fontSize extension at all.
Why it matters: 13px is clearly a recurring design decision here (card body
copy, captions), not a one-off tweak, which is the skill's own test
for when a token is warranted.
Recommended fix: add a `fontSize` extension, e.g. `xs2: ["13px", "18px"]`, and
replace the arbitrary values. NOT auto-fixed: adding a scale step is
a design-system change with app-wide reach, and it needs a line-height
decision that no available source specifies.
Expected impact: makes the 13px step retunable in one place.
Risk of change: low if the line-height matches what the arbitrary value
currently inherits; needs a visual check.

--------------------------------------------------------------------------------
[design-system] DS-9 — P3 — COMPONENTS — NO ACTION (INFORMATIONAL)
--------------------------------------------------------------------------------
File: app/(app)/expense-summary/page.tsx:21;
app/(app)/expenses/page.tsx:29
Component: sortable-column coverage
Problem: #3250 made essentially every data column sortable, with two
exceptions: "Total Expenses" on Expense Summary and "Total Amount"
on Expenses. Within a single table this reads as an inconsistent
affordance (most headers show a sort glyph, one does not).
Assessment: both exceptions are computed aggregates
(`r.total.toFixed(2)` / `r.totalAmount.toFixed(2)`) derived in the
query layer, not sortable DB columns, so the omission is coherent
and the two screens agree with each other. Reported for completeness,
NOT flagged as a defect — the alternative (a sort link that silently
does nothing) would be strictly worse.
Recommended fix: none. If aggregate sorting is wanted later it is a data-layer
change, not a design one.
Expected impact: n/a.
Risk of change: n/a.

================================================================================
CHECKED AND CLEAN (recorded per both skills' "note what was handled correctly")
================================================================================
- COLOURS: 11 of the 12 feature files contain ZERO arbitrary colour values
(command 4). All status tones, borders, accents and text greys resolve to
named tokens in tailwind.config.ts, each carrying a provenance comment back
to a measured design source. This is a genuinely well-tokenised codebase; the
colour findings above are confined to one file.
- FOCUS: all 6 `outline-none` occurrences are paired with a replacement
focus-visible style (command 5). DatePicker.tsx:272 in particular pairs
`focus:outline-none` with `focus-visible:ring-2 focus-visible:ring-accent`.
The new ExpenseSummaryRowActions link removes no outline, so it keeps the UA
default focus ring. No focus-suppression defects.
- KEYBOARD: exactly one onClick on a non-interactive element (Modal backdrop),
correctly aria-hidden and duplicated by an Escape handler (command 6).
DatePicker implements full roving-tabindex grid navigation, Escape-to-close
and focus-return-to-trigger (DatePicker.tsx:98-151) — well above the usual bar.
- NON-TEXT CONTRAST: every StatusBadge dot tone and every row-action icon meets
WCAG 1.4.11 3:1 (command 7). The dots are additionally aria-hidden with an
adjacent text label, so status is never colour-only.
- DISABLED STATES: DatePicker sets a real `disabled` attribute on the trigger
(:201) rather than only styling it, so it leaves the tab order correctly.
Its 4.19:1 disabled-state text contrast is exempt under WCAG 1.4.3's
inactive-component exception.
- LIVE REGIONS: Toast.tsx:28-30 correctly uses role="status" + aria-live="polite"
with a properly labelled dismiss button. #3250's ResetPasswordForm change
removed only the SUCCESS toast path, which is correct — success now redirects
to /login server-side, so no announcement is lost.
- ICON-ONLY CONTROLS: every icon button/link in the audited paths has an
accessible name (aria-label) and an aria-hidden icon —
ExpenseSummaryRowActions.tsx:9, RowActions.tsx:33/38/44,
ExpenseRowActions.tsx:12, RepeaterTable.tsx:98, Toast.tsx:38,
ResetPasswordForm.tsx:68, DatePicker.tsx:186/198/221/229.
- TABLE SEMANTICS: DataTable renders real <table>/<th scope="col"> markup
(DataTable.tsx:51-53); RepeaterTable now matches after the DS/A11Y-6 fix.
- DARK MODE: not applicable. `dark:` appears nowhere in app/ or components/ and
there is no theme toggle, so the absence of dark-mode token variants is not a
gap.

================================================================================
AUTO-FIXES APPLIED (gate Step 3) — 4
================================================================================
1. components/forms/ResetPasswordForm.tsx:154 [design-system DS-1]
text-[rgb(86,86,86)] -> text-text-subtle
2. components/ui/DataTable.tsx:51-63 [accessibility A11Y-5]
+ aria-sort={isActive ? (currentDir === "desc" ? "descending"
: "ascending")
: col.sortable ? "none" : undefined}
3. components/forms/ExpenseForm.tsx:135 [accessibility A11Y-6a]
+ aria-label="Amount"
4. components/ui/RepeaterTable.tsx:60 [accessibility A11Y-6b]
+ scope="col"

All four are additive or value-identical: zero rendered-pixel change, zero
public-API change, zero behaviour change.

Verification after the fixes (command 8):
npx tsc --noEmit exit 0, clean
npx next lint --dir app --dir components "✔ No ESLint warnings or errors"
npx vitest run 732 passed / 8 failed (100 files)
stash-and-rerun control at untouched HEAD same 8 failures
=> the 8 failures are pre-existing on this branch and unrelated to this gate.

Everything else was left as a reported finding, per Step 3, because it needs a
design judgment (a colour value that does not exist yet: A11Y-1, A11Y-2, DS-3),
a component API change (A11Y-3, A11Y-4, A11Y-6 residual), a product decision
(DS-5), or contradicts a recorded operator instruction (A11Y-7).

================================================================================
VERDICT
================================================================================
gate_status = FAIL.

Per the gate's rule, PASS requires zero unresolved P1 findings across BOTH
audits, and any unresolved accessibility P1 blocks regardless of the
design-system result. Three remain:

A11Y-1 2.25:1 contrast on the password-policy rules (WCAG 2.1 AA 1.4.3)
A11Y-2 3.09:1 / 3.47:1 contrast on success-state text (WCAG 2.1 AA 1.4.3)
A11Y-3 Expense Date field has no accessible name (WCAG 4.1.2) —
a regression this branch introduced by making the field editable

The design-system audit on its own would PASS: zero P1s, the token system is
clean and consistently applied across 11 of 12 feature files, R5 reuses the
documented pattern exactly, and R1 was correctly left blocked rather than
satisfied with invented colours. It is the accessibility half that fails —
which is the reason the gate keeps the two audits merged but forbids
downgrading contrast/ARIA findings on visual-match grounds.

A11Y-3 is the finding to action first: it is the only P1 caused by this
branch's own change, and it makes a required field on the Expense form
unnameable to screen readers.
(5-5/7)