Commit Graph

5 Commits

Author SHA1 Message Date
Guillem Arias Fauste
77dda53ffb feat(ds): one canonical focus ring across primitives (#2140)
* feat(ds): one canonical focus ring across primitives (#2136)

Replaces the grab-bag of per-primitive focus indicators (neutral
ring-alpha-black/white, outline-gray-900/white, faint form-field ring-4)
with a single recipe — the #1737 accessibility follow-up.

- New --color-focus-ring token: blue-600 (light) / blue-500 (dark),
  >=4:1 against both surfaces.
- Canonical .focus-ring / .focus-ring-within in components.css: a 2px
  outline + 2px offset on :focus-visible only. Outline (not a box-shadow
  ring) so the offset gap is transparent on any surface with no layout
  shift; :focus-visible so it never shows for mouse/touch.
- Applied to every focusable DS primitive: Button (had none), Link,
  Disclosure summary, Tabs nav, MenuItem (replaces the browser-default
  box), SearchInput, Tooltip trigger, Popover trigger, Select panel
  (focus-within), Toggle (peer-driven outline-focus-ring). .form-field
  adopts it via :focus-within, replacing the ~1:1 ring-4.
- Dialog close button is a DS::Button icon variant, so it inherits the
  focus-visible-only ring and keeps no resting border (fixes "stuck ring").

Verified in-browser, light+dark: focus-visible ring on button, input, and
full-width menu row — consistent blue 2px+offset, legible on both surfaces.

Remaining follow-up: >=44px touch targets (disclosure trigger, composer
send); bespoke notification / account-new close buttons that still carry a
permanent border.

* fix(ds): #2136 interactive-state follow-ups — touch target + close-button chrome

- Disclosure default trigger: add min-h-11 (44px) so the standalone disclosure
  summary clears the touch-target minimum (was px-3 py-2 ~36px). Composer send +
  the coming-soon icons are already DS::Button icon/md (w-11 h-11).
- Notification close buttons (sync_toast, notice): drop the resting
  border-alpha-black-50 box ("frame shouts, glyph muted"); keep a bg-container +
  shadow-xs chip so the corner control stays visible over the page, and brighten
  the muted glyph on hover (text-subdued -> hover:text-primary).

* refactor(ds): focus ring -> neutral hugging box-shadow (was blue outline)

Per design feedback: the blue 2px outline + 2px offset read as a loud,
detached frame on the otherwise-neutral UI. Switch the canonical .focus-ring
to a soft box-shadow ring that hugs the control (follows border-radius, no
gap), in the theme-aware neutral focus-ring token (alpha-black/white-400).
Transparent outline kept as a forced-colors fallback; toggle peer-driver
switched from outline-* to ring-* to match. Still one token, :focus-visible
only. Strength is tunable (currently subtle ~1.5:1).

* fix(ds): focus ring vanished on shadowed controls — outline, not box-shadow

The neutral box-shadow ring lived in the components layer, so any utility-layer
shadow-* (or .form-field's focus-within:shadow-none) on the same element
overrode it and the ring silently disappeared on shadowed buttons/inputs. Draw
the same subtle neutral ring with a hugging `outline` (outline-offset: 0)
instead — a separate property with no box-shadow conflict, and it doubles as the
forced-colors indicator. Toggle peer-driver switched ring-* -> outline-* to
match. Look is unchanged (neutral, hugging, subtle); it just no longer vanishes.

* fix(a11y): enlarge sync-toast close-button touch target (p-0.5 -> p-1.5)

The hover-revealed close button had ~2px padding around a 20px icon (~24px
total), at the WCAG 2.5.8 AAA boundary. p-1.5 brings the interactive area to
~32px. Addresses CodeRabbit review on #2140.

* fix(ds): keep form-field's resting halo; stop the outline color flash

Two testing findings:

- .form-field reverts to its original always-on soft ring
  (focus-within ring-4 at low alpha, theme-aware) instead of adopting
  the keyboard-only outline. It's a resting decoration, not a focus
  indicator, and the lower-opacity halo was the better look. The
  canonical block's comment documents the deliberate opt-out.

- .focus-ring/.focus-ring-within now carry a base transparent 2px
  outline so consumers with transition-all (form-field had it) animate
  transparent -> token on focus instead of passing through
  currentColor, which flashed as a black border appearing and then
  fading out.

* feat(ds): focus-ring token clears WCAG 3:1 non-text contrast

alpha-black-400 (20%) measured ~1.6:1 against white — visible but below
the AA bar for focus indicators. Bump to the 700 stop (50%): ~3.95:1 on
light containers, ~4.6:1 on dark. Recipe unchanged; one token edit via
tokens:build.

* fix(ds): ring the hand-rolled privacy toggle too

The header pair showed two different focus treatments: panel-right (a
DS::Button) got the new token ring while the hand-rolled privacy
toggle next to it fell back to the browser-default ring — the sweep
covered DS primitives but not bespoke buttons. Both privacy toggles
(mobile + desktop) now carry .focus-ring.

Also documents the transition interplay on the focused-state rule:
consumers with transition-colors fade the ring in over 150ms because
Tailwind v4's color transition list includes outline-color. Verified
settled value at the intended 50% alpha via Playwright.

* fix(ds): ring the sidebar and settings nav links

The reshoot caught both nav species falling back to the browser's blue
default ring — main sidebar items and settings nav items are bespoke
link_to markup the primitive sweep missed, and they're the primary
keyboard path in the app. Both adopt .focus-ring (main nav adds
rounded-lg so the outline follows a shape).

* fix(ds): retire the legacy base-layer button ring for the canonical outline

The @layer base button rule still painted a ring-2 ring-offset-2
box-shadow on :focus-visible. Box-shadow and outline are independent
properties, so .focus-ring (an outline) could never clear it and every
button-tag primitive double-painted both indicators on keyboard focus.

Apply the canonical recipe to the base button rule itself: every
<button> now gets the transparent resting outline + focus-ring token on
:focus-visible by default.

Two bespoke buttons suppressed the outline with focus:outline-none and
relied on the base ring for their keyboard indicator (category dropdown
rows, the sign-up password toggle). Drop the suppression so they pick
up the canonical outline — :focus-visible keeps it keyboard-only, which
is what the suppression was protecting against anyway.

* fix(ds): segmented control adopts the canonical focus recipe

The segment rule inlined its own focus-visible outline (offset 2,
alpha-400 colors) with a comment noting it was temporary until the
canonical token landed — this branch is that token. Drop the inlined
utilities: button segments get the outline from the base button rule,
and link segments now carry .focus-ring.
2026-06-11 16:05:13 +02:00
Guillem Arias Fauste
5abf9cb537 fix(ds): dark-mode token parity — contrast & state fixes (#2139)
* fix(ds): dark-mode token parity — text, borders, checkbox, toggle, over-budget badge

Dark-mode contrast/parity pass (design-review epic #2134, follow-up to #1736):

- text-subdued (dark): gray-500 -> gray-400; faint/eyebrow text ~3.6:1 -> ~6.7:1
  (clears AA), staying below text-secondary so hierarchy holds.
- border-primary/secondary/subdued (dark): +1 alpha-white step each; restores
  group-card edges and in-card row hairlines (border-primary 1.86:1 -> 2.8:1).
  Alpha keeps them surface-relative across any dark bg.
- Dark checkbox: unchecked was a solid white square (read as already-selected) —
  now a transparent outlined box; checked/indeterminate use a white fill with a
  #171717 glyph (was #808080, ~2:1); added an explicit indeterminate dash;
  disabled muted to gray-700.
- Toggle (light off-state): track gray-100 -> gray-300 plus a thumb shadow; the
  white-thumb-on-white-track invisible off-state now reads (dark off-track was
  already hardened to gray-700).
- Over-budget badge: text-red-500 -> text-destructive (theme-aware, matching the
  on-track/near-limit siblings); in-situ dark contrast 4.18:1 -> 4.55:1 (AA).
- Correct invalid icon color keys (red/yellow/green -> destructive/warning/success)
  on the three budget status badges.

Verified in-browser, light+dark: isolated checkbox states, /accounts card borders,
/reports muted text, toggle off-state, /budgets over-budget badge (in-situ 4.55:1).

* fix(ds): reconcile destructive color + fix filled-pill contrast

Continues the dark-parity pass (#2134):

- Reconcile destructive: border-destructive and button-bg-destructive were
  red-500 while the destructive text/icon token was red-600. Unify on
  red-600 (light) / red-400 (dark) across text, border, and button — the text
  token can't drop to red-500 (3.96:1 on white, fails AA), so border/button
  move up instead. White-on-destructive-button 3.96:1 -> 4.36:1 (AA-large);
  hover red-600 -> red-700.
- DS::Pill filled style: deepen the fill tone-500 -> tone-700. White label text
  on tone-500 failed AA on nearly every tone (amber 2.35:1, green 2.62:1,
  red 3.95:1); tone-700 clears it (amber 5.43, green 4.30, red 5.86, others
  6.4-12) in both themes and removes the dark-surface glare.

Date-input calendar glyph in dark verified already-correct (existing invert(1)
rules; color-scheme is normal, so no conflict) — no change needed.

Verified in-browser: real .button-bg-destructive (red-600) + filled pills,
all tones, light and dark.

* fix(ds): destructive button consumes the reconciled red-600

Follow-up to the destructive reconcile in this branch: DS::Buttonish's
destructive variant still used raw bg-red-500 / hover:bg-red-600, so
destructive *buttons* didn't match the reconciled destructive text/border
(red-600). Align to red-600 / hover red-700 (light); dark unchanged
(red-400 / red-500). White-on-red-600 = 4.37:1 (AA-large), consistent
with the rest of the destructive family.

* refactor(ds): tokenize budget-category badge + bar backgrounds

Status-badge foregrounds already used semantic tokens (text-destructive/
warning/success) but backgrounds + progress-bar fills stayed on the raw
palette (bg-red-500/10, bg-yellow-500, ...). Switch to the matching semantic
tokens (bg-destructive/10, bg-warning, bg-success) — same value in light, now
theme-aware in dark. Mirrors DS::Alert. Addresses CodeRabbit/Codex on #2139.
2026-06-03 12:02:50 +02:00
Guillem Arias Fauste
f0e270f578 fix(design-system): restore dark-mode contrast on Toggle + destructive borders (#1932)
Two regressions from the recent token sweep, both producing low-contrast
results in dark mode.

## DS::Toggle off-track

PR #1843 (DS::Toggle a11y + token swaps) replaced the raw
`bg-gray-100 theme-dark:bg-gray-700` off-track with `bg-surface-inset`
for semantic alignment. `bg-surface-inset` resolves to gray-800 in
dark mode, but the toggle typically sits inside `bg-container`
(gray-900). The contrast ratio dropped from ~2.45:1 (gray-700 vs
gray-900) to ~1.5:1 (gray-800 vs gray-900) — visibly worse than the
pre-#1843 baseline and below WCAG 1.4.11 (3:1 for UI components).

Most visible inside the transaction-edit modal SETTINGS section
(`Exclude`, `One-time Expense`) where the off-state switches nearly
vanished into the modal chrome.

Introduce `--color-toggle-track` (light: gray-100, dark: gray-700) and
swap `bg-surface-inset` → `bg-toggle-track` in DS::Toggle. Restores the
pre-#1843 off-track contrast while keeping a semantic token (instead
of the raw palette references the migration was trying to remove).

## border-destructive subtle borders

PR #1849 (single-color tokens to @theme) flagged that
`border-destructive/N` rendered the wrong shade (the `@utility
border-destructive` block defined red-500 light, while
`--color-destructive` in `@theme` is red-600 — `/N` resolves from
@theme), and swapped a couple of callsites to solid `border-destructive`.
Solid renders red-500/red-400 at full saturation in both modes, which
reads as a loud error border on contexts that were meant to be subtle
(left-rule on the provider-sync "view error details" pane, error-message
box in SimpleFIN settings, alert-component border, provider connection
error rows).

Two callsites (`DS::Alert`, settings/providers/_connection_row) still
carried the broken `border-destructive/20` / `/25` modifier — same
off-shade footgun #1849 was meant to retire.

Introduce `--color-destructive-subtle` (light: red-200, dark: red-800)
and swap the four subtle-by-intent callsites to `border-destructive-subtle`:

- app/components/DS/alert.rb (destructive variant)
- app/views/settings/providers/_connection_row.html.erb (err status)
- app/components/provider_sync_summary.html.erb (error-details left rule)
- app/views/simplefin_items/edit.html.erb (error-message box)

The handful of intentionally-loud `border-destructive` callsites
(split-transaction over-allocation, blank-name account labels, etc.)
keep the solid token.

Regenerated `_generated.css` via `npm run tokens:build`.
2026-05-23 09:21:46 +02:00
Guillem Arias Fauste
e56ad3de42 fix(design-system): DS::Toggle focus ring, role=switch, and semantic tokens (#1843)
* fix(design-system): DS::Toggle a11y + token swaps

Closes #1746. Four fixes on the toggle primitive (visual switch
backed by a sr-only checkbox).

1. **Focus ring (WCAG 2.4.7)** — the `<input>` is `sr-only`, so the
   browser-default focus ring lands on an invisible 0px element.
   The label (the track) had no focus styling, meaning the
   component had **no visible focus indicator at all**. Add
   `peer-focus-visible:ring-2 ring-offset-2 ring-gray-900` with
   `theme-dark:peer-focus-visible:ring-white` so the ring appears
   on the visible track when the underlying checkbox receives
   keyboard focus.

2. **Role semantics** — visual is a switch, but the element was
   announced as "checkbox, checked" because the native input is
   a checkbox. Add `role="switch"` so AT users hear "switch, on"
   / "switch, off". `aria-checked` is inherited from the
   checkbox's checked state, no manual wiring needed.

3. **Token swaps** — replace raw palette references with semantic
   tokens:
   - Track `bg-gray-100 theme-dark:bg-gray-700` → `bg-surface-inset`
   - Checked `peer-checked:bg-green-600` → `peer-checked:bg-success`
   Picks up the contrast bump from #1735 automatically.

4. **Motion safety (WCAG 2.3.3)** — gate the bg color +
   thumb-translate transitions behind `motion-safe:`. Reduced-motion
   users see an instant state snap; everyone else gets the
   existing 300ms ease.

API unchanged. Existing 8 callsites (settings/preferences,
settings/appearances, account_sharings, budgets/edit,
recurring_transactions, styled_form_builder bridge) work without
changes.

* fix(review): use alpha tokens for Toggle focus ring

Swap raw palette (ring-gray-900 / theme-dark:ring-white) on the
DS::Toggle focus ring to ring-alpha-black-300 / ring-alpha-white-300
to match the focus-ring token pattern already used by
form-field, provider_card, and shared/_badge.

Closes AI review feedback on #1843.
2026-05-20 18:08:46 +02:00
Zach Gollwitzer
ab6fdbbb68 Component namespacing (#2463)
* [claudesquad] update from 'component-namespacing' on 18 Jul 25 07:23 EDT

* [claudesquad] update from 'component-namespacing' on 18 Jul 25 07:30 EDT

* Update stimulus controller references to use namespace

* Fix remaining tests
2025-07-18 08:30:00 -04:00