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