Accessibility
Settld targets WCAG 2.1 AA compliance as a minimum bar. Most key color pairs exceed AA — several reach AAA. The warm cream palette was chosen partly because it achieves better contrast with ink than cold white does at equivalent visual warmth.
Color contrast
Keyboard navigation
Tab order follows DOM order. Avoid using tabindex values other than 0 and -1. If the visual order doesn't match the DOM order, fix the DOM — don't patch it with tabindex.
Modals and sheets trap focus: the first focusable element receives focus on open; Tab wraps within the modal; Esc returns focus to the trigger element. Use aria-modal="true" and set the background to inert while the modal is open.
Focus rings
All interactive elements use a consistent focus ring: outline: 2px solid var(--teal) with outline-offset: 2px. This applies globally via:
:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; } /* Reset for mouse users (browser default behavior) */ :focus:not(:focus-visible) { outline: none; }
outline-color: var(--s-paper) on dark backgrounds.
Screen reader support
| Component | ARIA pattern | Notes |
|---|---|---|
| Button | <button> with text label |
Icon-only buttons require aria-label. Loading state adds aria-busy="true" and keeps original label in aria-label. |
| Segmented control | role="radiogroup" with role="radio" children |
Active segment has aria-checked="true". Label the group: aria-label="Split mode". |
| Tab bar | role="tablist" with role="tab" children |
Active tab has aria-selected="true". Badge count in aria-label: "Activity, 3 unread". |
| Modal / sheet | role="dialog" with aria-modal="true" |
Add aria-labelledby pointing to the modal title. Trap focus. Set background to inert. |
| Person chip | role="checkbox" or role="option" |
Use aria-checked for toggleable chips. In a listbox context, use aria-selected. |
| Split bar | role="img" with aria-label |
Example: aria-label="Split: You ₹1,400 (33%), Aarav ₹1,400 (33%), Priya ₹1,400 (34%)" |
| Cap meter | role="progressbar" |
Add aria-valuemin="0", aria-valuemax="50", aria-valuenow="47", aria-label="Monthly expenses: 47 of 50". |
| Avatar | aria-label on container |
The letter inside the avatar is decorative. Provide aria-label="Aarav's avatar" on the container. Use aria-hidden="true" on the letter. |
| Toast / notification | role="status" or role="alert" |
Use role="alert" for errors (forces immediate announcement). Use role="status" for confirmations (polite). Never use role="alert" for success states. |
| Empty state | Plain HTML, no special role | The glyph is aria-hidden="true". The title and body are semantic heading + paragraph. The action button is a standard <button>. |
Motion and animation
@media (prefers-reduced-motion: reduce) query. This includes: confetti fall, card tilt on hover, skeleton shimmer, split bar transitions, rolling ledger queue. Essential animations (focus ring, button press scale) are retained even in reduced motion — they're interaction feedback, not decoration.
@media (prefers-reduced-motion: reduce) { /* Disable non-essential animations */ .confetti-particle { animation: none; opacity: 0.3; } .split-segment { transition: none; } .ledger-track { transition: none; } .cap-meter-fill { transition: none; } /* Keep essential feedback */ .btn:active { transform: scale(0.97); } /* Retain — it's tactile feedback */ :focus-visible { outline: 2px solid var(--teal); } /* Always visible */ }
Touch targets
44×44 minimum
36px height min
40×40 minimum
44px height min
The minimum touch target is 44×44px, per Apple HIG and WCAG 2.5.5. For elements that are visually smaller than 44px (chips at 28px height, icon badges at 20px), expand the tap area using padding or ::after pseudoelement — never the visual bounds.
The ledger row minimum height is 44px. On dense displays where vertical space is tight, never go below 40px — financial data requires tap precision.
/* Expanding tap area without visual change */ .small-chip { position: relative; } .small-chip::after { content: ""; position: absolute; inset: -8px; /* expands tap target by 8px on each side */ }
Text sizing and zoom
All font sizes use px units, which scale with browser text size settings and OS-level font scaling. Never use vw-only fluid type for body text — it breaks when text zoom is applied. Use clamp() only for display sizes (headlines), always with a px minimum that passes at 200% text zoom.
The app must be usable at 200% browser zoom without horizontal scrolling on a 375px viewport. Test with Chrome's DevTools or the browser's built-in zoom. Horizontal overflow is a failure, not an edge case.
The minimum font size for body text is 13px (.body-sm). Never render data labels, captions, or form helper text below 12px. The JetBrains Mono eyebrow labels at 10px are intentionally below this threshold — they are supplementary, never the primary carrier of information.
| Text role | Min size | Scale token | Notes |
|---|---|---|---|
| Body copy | 15px | body-md | Primary reading size |
| Secondary body | 14px | body-sm + 1px | Activity feed, list meta |
| Captions | 13px | body-sm | Timestamps, helper text |
| Labels / eyebrows | 10px | mono-sm | JetBrains Mono only. Always uppercase. Never primary. |
| Amount display | 16px | Context-dependent | JetBrains Mono. Financial data must be readable at a glance. |