# Quality bar

> The ship gate for a Lopifi component: baseline UI, motion, accessibility, accounting correctness, edge cases and field performance, every box checked.

Source: https://lopifi.com/docs/craft/03-quality-bar
Part of: Lopifi — US GAAP books on autopilot, reviewed by a CPA (https://lopifi.com)

---

# Quality bar

<!-- description: The ship gate for a Lopifi component: baseline UI, motion, accessibility, accounting correctness, edge cases and field performance, every box checked. -->

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/react` when JS animation is genuinely required
- [ ] `tw-animate-css` for 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-label` on every icon-only button
- [ ] Keyboard and focus behavior **never** hand-rolled

### Interaction
- [ ] `AlertDialog` for destructive or irreversible actions
- [ ] Structural skeletons for loading
- [ ] `h-dvh`, never `h-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](../motion/03-catalog.md)
- [ ] Only `transform` and `opacity` animated
- [ ] No layout properties animated (`width`, `height`, `top`, `left`, `margin`, `padding`)
- [ ] Paint properties only on small, isolated surfaces
- [ ] `ease-out` on entrance
- [ ] Nothing over 300ms for interaction feedback
- [ ] Looping animations pause off-screen
- [ ] `prefers-reduced-motion` respected
- [ ] Only tokenized easing curves — no inline `cubic-bezier`

### Typography
- [ ] `text-balance` on headings, `text-pretty` on body
- [ ] `tabular-nums` on all data
- [ ] `truncate` / `line-clamp` in dense UI — **never on a number**
- [ ] `tracking-*` untouched outside the scale

### Layout
- [ ] Fixed z-index scale, no arbitrary `z-*`
- [ ] `size-*` for square elements, not `w-* h-*`

### Performance
- [ ] No large `blur()` or `backdrop-filter` animation
- [ ] `will-change` only during an active animation
- [ ] No `useEffect` for 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 `requestAnimationFrame` loop without a stop condition
- [ ] No two animation systems mutating the same element's layout

### Mechanism
- [ ] `transform` / `opacity` by 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-change` surgical 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`
- [ ] `Escape` closes 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-invalid` set
- [ ] Disabled submit explains why
- [ ] `aria-live` on critical errors and live data (throttled)
- [ ] `aria-expanded` + `aria-controls` on 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
- [ ] `polarity` respected — 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`, never `NaN` / `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/`](../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
- [ ] `requiredDimensions` enforced 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 − gap` satisfied
- [ ] **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` + `case` on 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; `className` merged via `cn()`

---

## 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
- [ ] Print

---

## 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-adjust` fallback metrics set
- [ ] The LCP element is identified per page, and it is not a chart
- [ ] A value updating shifts nothing — `tabular-nums` plus the reserved paren column ([numerics](../foundations/06-numerics.md))

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

1. **Spec review** — the four layers, before any code. Reject at the earliest failing layer.
2. **Implementation review** — sections 1–5 of this document.
3. **Motion & performance review** — every animation at 4× slowdown, frame-stepped, on a throttled real device; section 7 measured on that same device.
4. **Financial review** — sections 4 and 4b, by someone who has actually closed a month.
5. **A11y review** — keyboard-only, then VoiceOver, then greyscale.
6. **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:

1. **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.
2. **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.
3. **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](02-accessibility.md) 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.
