================================================================================
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.
