Quality bar
The ship gate. A component is not stable until every box is checked.
Sections 1–3 are adapted from ibelick/ui-skills (baseline-ui, fixing-motion-performance, fixing-accessibility), section 7 from addyosmani/web-quality-skills (core-web-vitals). Sections 4–6 are ours.
1. Baseline UI
Stack
- Tailwind defaults unless a token exists or a deviation is explicitly justified
-
motion/reactwhen JS animation is genuinely required -
tw-animate-cssfor entrance micro-animations -
cn()(clsx+tailwind-merge) for all class logic
Components
- Accessible primitives (Base UI preferred, Radix accepted) for anything with keyboard or focus behavior
- Existing project primitives used before new ones are created
- Never two primitive systems in one interaction surface
-
aria-labelon every icon-only button - Keyboard and focus behavior never hand-rolled
Interaction
-
AlertDialogfor destructive or irreversible actions - Structural skeletons for loading
-
h-dvh, neverh-screen -
env(safe-area-inset-*)on every fixed element - Errors shown next to where the action happened
- Paste never blocked
Animation
- No animation that isn't specified in the motion catalog
- Only
transformandopacityanimated - No layout properties animated (
width,height,top,left,margin,padding) - Paint properties only on small, isolated surfaces
-
ease-outon entrance - Nothing over 300ms for interaction feedback
- Looping animations pause off-screen
-
prefers-reduced-motionrespected - Only tokenized easing curves — no inline
cubic-bezier
Typography
-
text-balanceon headings,text-prettyon body -
tabular-numson all data -
truncate/line-clampin dense UI — never on a number -
tracking-*untouched outside the scale
Layout
- Fixed z-index scale, no arbitrary
z-* -
size-*for square elements, notw-* h-*
Performance
- No large
blur()orbackdrop-filteranimation -
will-changeonly during an active animation - No
useEffectfor anything expressible as render logic
Design
- No gradients (except a single-hue area-chart fill)
- No purple, no multicolor
- No glow as a primary affordance
- Empty states have exactly one clear next action
- One accent per view
- Existing tokens used before new ones are introduced
2. Motion performance
Never patterns
- No interleaved layout reads and writes in a frame
- No continuous layout animation on a large surface
- No animation driven by
scrollTop/scrollY/ scroll events - No
requestAnimationFrameloop without a stop condition - No two animation systems mutating the same element's layout
Mechanism
-
transform/opacityby default - JS animation only where interaction requires interruption
- Paint/layout animation only on small, isolated surfaces
- One-shot preferred over continuous
Measurement
- Measure once, then animate with
transform - All DOM reads batched before writes
- FLIP for layout-like effects
Scroll
- Scroll/View Timelines or
IntersectionObserver, never scroll polling - Animations pause off-screen
Paint, layers, blur
- No paint animation on a large container
- No animated CSS variables for transform/opacity/position
- No inherited animated CSS variables
-
will-changesurgical and temporary - Blur ≤ 8px, one-shot, small surface only
3. Accessibility
- Every control has an accessible name
- Everything interactive is
Tab-reachable - Focus is always visible; no removed outline without a replacement
- No
tabindex > 0 -
Escapecloses overlays - Dialogs trap focus; focus returns to the trigger on close
- Native elements over roles; required ARIA present where roles are used
- Heading levels don't skip
-
<th scope>on every data table - Errors linked with
aria-describedby;aria-invalidset - Disabled submit explains why
-
aria-liveon critical errors and live data (throttled) -
aria-expanded+aria-controlson disclosures - Contrast passes — 7:1 for monetary values
- Hover-only interactions have keyboard and touch equivalents
- Disabled state doesn't rely on color alone
- Correct
alt(empty for decorative illustration)
4. Financial correctness
The section no generic checklist has.
- Money stored in minor units — no floats anywhere in the path
- Decimal places resolved from the currency, not hard-coded
- Negative renders correctly: parentheses in statements,
−(U+2212) in deltas - Negative values don't break decimal alignment — paren column reserved
-
zero/null/n/a/ redacted / loading are five distinct renders -
polarityrespected — up is not assumed good - Sign readable in greyscale ← the ship gate
- No abbreviation in tables, statements, totals, inputs, receipts
- Abbreviated values expose the exact value on hover/tooltip
- Percentage points vs. percent explicitly discriminated
- Division by zero →
n/a, neverNaN/Infinity/100% - Rounded parts that must sum use largest-remainder allocation
- Multi-currency never silently summed
- Converted values marked (
≈) with rate and timestamp - Every figure has an "as of"
- FX rates carry a timestamp; transactable rates carry a lock
- Fiscal periods respected — January is not assumed
- Period labels state resolved dates
- Statement rule lines correct: single above subtotals, double below totals, numeric column only
- Copy-to-clipboard yields the raw value, not the formatted string
- No monetary amount in any analytics event or log
- Screen reader announces the sign on negative values
4b. Accounting correctness
For anything in catalog families 9–13. Reviewed against docs/domain/ clause by clause.
- Debit and credit are two columns; the balance check is displayed
- No delete path on a posted entry — the action is absent, not disabled
- Reversal creates a linked offsetting entry; both remain visible
- A reversal in a closed period is dated in the current open period
- The ledger sign is never negated at render time
- Contra accounts render in natural position, netting to a subtotal
- Non-postable accounts are blocked in every picker; normal balance shown
-
requiredDimensionsenforced from the account; entity always required - Period state respected; cutoff warnings name both remedies
- Roll-forward asserts opening = prior closing and closing = GL
- Every BS account has a tie-out; unsupported renders explicitly
- Accruals default to auto-reverse, with the reversal date shown
- Schedules preview before posting; posted as one audited batch
- Subledger totals tie to control accounts (AR, AP, fixed assets)
- Aging is by due date; credit balances shown, not hidden
- Unapplied cash is never revenue; a customer's net credit balance is flagged as a liability
- Credit note, refund, and write-off are distinct actions
- Every cash-application difference requires an explicit disposition
- Duplicate bill detection is blocking and runs before save
- Three-way match compares line by line, not header totals
- Segregation of duties enforced — preparer cannot approve
- Approvals bind to a version; edits void them and show the change
- Evidence required on bills, expenses, adjusting entries, reconciliations
- Vendor bank details versioned; changes flagged before payment runs
- Payment runs show resulting cash and use hold-to-confirm
- Statements carry the full header (entity, period, basis, framework, currency, scale, provenance)
- Rule lines span the numeric column only; double rule under grand totals
- Statement articulations asserted: NI → RE, cash flow → BS cash, A = L + E
- Realized and unrealized FX are separate lines
- Only monetary items revalued; translation → equity, remeasurement → P&L
- Missing FX rate blocks; rate type required
- Intercompany matrix nets to zero; break types named
- Materiality: both thresholds required, threshold displayed
- Non-GAAP measures reconciled, adjustments named
- Every metric ships a
MetricDefinition - Audit trail records field-level diffs with server UTC time and actor
5. Design system conformance
- All spacing on the 8px grid (4px only inside ≤32px controls; table cells the one documented exception)
- Radius from the scale; nesting formula
r_inner = r_outer − gapsatisfied - Max 3 font sizes, max 3 font weights — counted in the spec
- Two families only (Inter, Geist Mono); the display family only on a solitary non-aligning figure
-
tnum+caseon every numeric element - Currency symbol subordinated to the value
- Resting surfaces use a border, not a shadow
- Shadows ≥ 2 layers, hue-tinted, only on floating surfaces
- Dark mode uses the top highlight
- Icons at 16/20/24, stroke 1.5, optically aligned
- Third-party logos contained at radius 8; avatars circular
- Gaps from the proximity ladder only
- Every token defined for both themes
- Component owns padding, never margin
- Enum props, not booleans, for visual variants
-
forwardRef+displayName; named export;classNamemerged viacn()
6. Edge cases
- Negative · zero · null · redacted
- Very large value · very long label
- JPY (0dp) · KWD (3dp) · multi-currency
- RTL
- Loading · error · empty · offline/stale
- 320px · 390px · 768px · 1024px · 1280px
- 200% and 400% zoom
- Reduced motion · forced colors · signal palette · greyscale
- Light · dark
- Touch · mouse · keyboard-only · screen reader
- Real iPhone SE · real mid-range Android · 13" MacBook at 125%
- 4× CPU throttle · Fast 3G
7. Field performance
Sections 1 and 2 govern the frame. This one governs the field — what the user's machine actually delivers.
- INP under 200ms on every interaction the component owns — measured, not assumed
- Measured on a mid-range Android at 4× CPU throttle, not on the machine that wrote the code
- No single event handler over 50ms; long work broken up or moved off the main thread
- Filter, sort and search yield to paint before doing the work — the input reflects the keystroke first, the rows follow
- Virtualization above ~200 rows, with the virtualizer measured under throttle rather than trusted
- CLS zero on data load — the skeleton reserves the exact final height, including the numeric column width
- Font swap shifts nothing:
size-adjustfallback metrics set - The LCP element is identified per page, and it is not a chart
- A value updating shifts nothing —
tabular-numsplus the reserved paren column (numerics)
Why INP and not frame rate. A table can hold a clean 60fps while taking 400ms to acknowledge a click, and the second number is the one the user feels. Sections 1–2 are about the frames during a transition; INP is about the delay before one starts. A component can pass every box above and still fail here.
The finance-specific trap: filtering ten thousand rows synchronously inside an onChange. It never surfaces in a motion review, because nothing is animating — the interface is simply frozen, and frozen has no frames to step through. Yield first, filter second.
The deslop list
Specific markers of generic, machine-generated UI. Any one of these fails review on sight.
| Tell | Fix |
|---|---|
transition: all |
Name the properties |
scale(0) entrance |
scale(0.97) + opacity: 0 |
ease-in on a UI element |
--ease-out |
| 20–40px entrance offset | 8px |
transform-origin: center on a popover |
var(--transform-origin) |
| Animation on a keyboard action | Remove it |
| Duration > 300ms on interaction | 140–240ms |
Hover effect with no @media (hover: hover) |
Gate it |
| Keyframes on a rapidly re-triggered element | Transitions |
| Same enter and exit duration | Exit = enter × 0.7 |
| Everything appearing at once | Stagger 40ms, cap 240ms |
shadow-md on a resting card |
border: 1px solid var(--border) |
| Purple / multicolor gradient | One accent, no gradient |
| Glow as an affordance | Border, weight, or position |
| Emoji in a UI label | An icon, or nothing |
| Uppercase table headers | label at --text-secondary |
| Centered numbers | Right-aligned, tabular-nums |
toFixed(2) |
Intl.NumberFormat |
value ?? 0 |
Three explicit branches |
| Count-up number animation | Flash the background, swap the digits |
| Chart re-animating on filter change | First paint only |
| Shimmer sweep skeleton | Structural skeleton, slow breathe |
h-screen |
h-dvh |
w-6 h-6 |
size-6 |
Arbitrary z-[999] |
The z scale |
tracking-tight on body text |
Nothing |
| 5 font sizes in one card | 3 |
| "Oops! Something went wrong 😅" | "Couldn't load transactions." |
| A tooltip carrying essential information | Inline it |
| One signed amount column for a ledger | Two columns: debit, credit |
| A delete button on a posted entry | Remove it. Offer reverse. |
Accumulated depreciation as a positive asset |
Contra, indented, parenthesized, netting to a subtotal |
| Aging computed from the invoice date | Aging is from the due date |
| Unapplied cash booked to revenue, or a customer's net credit left inside AR | Shown separately within AR; a net credit is reclassified to a liability |
| "Balance" with no qualifier | Available or current — say which |
| A metric with no definition | MetricDefinition, or don't ship the metric |
| EBITDA inside the P&L | Below net income, reconciled, adjustments named |
| A statement with no basis or period in its header | StatementToolbar — basis and resolved period always visible |
overflow-x: auto as the mobile table strategy |
Column priority + card list |
backdrop-filter: blur() on a scrim |
A tinted scrim |
useEffect to set mounted for an entrance |
@starting-style |
Review process
- Spec review — the four layers, before any code. Reject at the earliest failing layer.
- Implementation review — sections 1–5 of this document.
- Motion & performance review — every animation at 4× slowdown, frame-stepped, on a throttled real device; section 7 measured on that same device.
- Financial review — sections 4 and 4b, by someone who has actually closed a month.
- A11y review — keyboard-only, then VoiceOver, then greyscale.
- Next-day review — look at it again with fresh eyes. You will find things.
Step 6 is not optional and it is not padding. It catches more real problems than any of the automated checks.
The audit pass
Everything above gates a component on the way in. This gates a surface that already shipped — a dashboard, a close flow, a payments screen — against the system that is supposed to govern it. Adapted from ibelick/ui-skills (improve-ui).
Different job, different rules, and the first one is structural: the audit never modifies product source. It reads, it proves, and it writes plans to design-plans/. Someone else executes them. An auditor who can edit starts fixing what is easy to fix rather than what is worth fixing.
The three proofs
A difference you noticed is a candidate. It becomes a finding only when all three hold:
- Contract — cite the binding decision. A line in
foundations/, a box in this document, or a direct contradiction between two surfaces inside the same task. "Prefer", "generally", a suggestive token name, and the mere absence of a documented exception are not contracts. - Runtime — prove the cited token, variant or component actually reaches this surface through the rendered path: imports, props, resolved config, CSS inheritance. Shared names, similar values and repo proximity prove nothing.
- Correction — state the one change the evidence requires, naming the exact existing token, variant or primitive. If the evidence supports two possible corrections, or the right answer requires inventing product intent, drop the candidate.
Then try to break it
Re-open every cited source and attempt to falsify each surviving finding. Delete it when the rule does not govern that property on that surface, when the difference turns out to be deliberate, when another finding has the same root cause, or when the correction is a preference wearing a citation.
Stop at three. An audit that returns twenty findings is a search-and-replace with a narrative attached, and nobody acts on it. Rank by evidence strength × leverage, report three, and name the one to fix first.
Scope
In scope: tokens, typography, color, spacing, layout, component variants, responsive presentation, copy, and conformance to a documented rule.
Out of scope unless explicitly requested: behavior, state, data wiring, tests, performance, architecture. Accessibility too — it has its own document and its own review step, and folding it in here buries it under spacing notes.
Zero findings beats one unsupported finding. The audit's credibility is the entire product; one invented finding costs more than ten real ones earn.