Commit Graph

7 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
6d27285c03 fix(charts): restyle hover tooltips with soft shadow + larger radius (#2029)
* fix(charts): restyle hover tooltips with soft shadow + larger radius

Match the elevation pattern of DS::Select dropdown
(shadow-lg + shadow-border-xs) and increase radius (rounded-lg →
rounded-xl) + padding (p-2 → p-3) for better breathing. Drops the
hard border in favour of the soft drop shadow.

time_series_chart_controller:
- p-2 → p-3, rounded-lg → rounded-xl
- remove border border-secondary
- add shadow-lg shadow-border-xs (theme-aware drop + border-edge)
- add explicit text-primary (was missing per #2011's drift note)
- add z-50 (matches goal_projection + sankey controllers)

sankey_chart_controller:
- bg-gray-700 text-white → bg-container text-primary (theme-aware;
  was broken in light mode after dark-bg flip)
- p-2 rounded → p-3 rounded-xl
- add shadow-lg shadow-border-xs
- add font-sans for consistency with the other chart tooltips
- add privacy-sensitive class (was missing — sankey money values
  were rendered in the clear with privacy mode on)

DS::Tooltip (icon-trigger help, bg-inverse) is intentionally a
different primitive and is not touched.

Refs #2011 — className consolidation into a shared module is tracked
separately and intentionally not closed by this PR; the new string
applies to two of the three call sites today (time_series, sankey).
The third (goal_projection_chart_controller) lives on the
feat/goals-v2-architecture branch and will adopt the same string when
goals v2 merges.

* fix(charts): align every chart tooltip on the borderless soft-shadow card

One visual contract for all three D3 tooltip surfaces, matching the
design reference: p-4, rounded-2xl, shadow-xl, no edge ring in light
mode. Dark mode keeps a 1px alpha-white ring since a shadow alone
disappears against dark surfaces.

- goal_projection_chart_controller drops its hand-copied class string
  (it still carried the old bordered recipe — the drift this util
  exists to prevent) and builds its two lines through the shared
  factory: secondary date line, tabular value line.
- New content conventions exported alongside the container contract:
  context line = text-xs text-secondary, values = font-medium
  tabular-nums. Time-series and sankey adopt them.
- Sankey node titles now escape before .html(); user-named categories
  were previously interpolated raw into the tooltip markup.

* fix(charts): match the tooltip surface to the design reference exactly

The previous pass approximated the reference with utility guesses
(rounded-2xl, p-4, shadow-xl, dark ring). The actual spec is a hairline
border ring composed with a soft 0 8px 24px drop shadow, 10px radius,
12x14 padding, and an 80ms left/top glide. Tailwind shadow utilities
can't compose a ring with a custom drop shadow, so the surface moves
into the design system as .chart-tooltip (theme-aware: dark swaps the
ring to alpha-white and lets it carry the edge).

Money/numeric figures also pick up the reference's mono treatment:
font-mono + tabular-nums on every value across time-series, sankey,
and goal-projection, so digits don't jitter while the scrubber moves.

* fix(charts): tighten tooltip padding to 10x12

* feat(charts): give sankey and goal tooltips their missing context rows

Chart tooltips answer three stacked questions: context (what am I
looking at), value (how much), relation (vs what). Time-series already
had all three; the other two were missing rows.

- Sankey links showed a bare "$X (Y%)" with no indication of which
  flow was hovered. Links now lead with a swatch-dotted
  "Source → Target" context line; nodes get the same dot + name
  treatment, tying the card to the ribbon color. The context builder
  escapes node names centrally (they're user-named categories).
- Goal projection adds a tertiary relation line — "52% of $20K
  target" — computed from the payload the chart already carries, for
  both the saved and projected segments. Hidden when the goal has no
  positive target. Template is i18n-wired like the existing tooltip
  strings (goals.show.projection.tooltip_target_relation).

Verified with Playwright against the running app: all three surfaces
pass computed-style and content assertions.

* fix(charts): drop the color dot from sankey tooltip context

The hover highlight on the diagram already identifies the ribbon; the
swatch repeated it inside the card. Names alone keep the context line
quieter.

* fix(charts): use the app's sans money treatment in tooltips, not mono

font-mono in this codebase marks code, keys, and admin surfaces; money
is sans + tabular-nums everywhere else (cards, KPIs, tables). Keep the
tabular figures for scrub stability, drop the mono.

* fix(charts): address review — glide opt-in, no shadowed var, truncate cap

- The 80ms left/top transition moved out of .chart-tooltip: it eased the
  snap-positioned goal tooltip but made cursor-following tooltips
  (sankey, time-series) trail the pointer by a frame. Goal projection
  opts back in via inline style; the component comment documents the
  split.
- setRelation reuses _draw()'s targetAmount const instead of declaring
  a local 'target' that shadowed the target-date const.
- Sankey context line gets max-w-64 so truncate has a constraint to
  fire against on deep flows.
- Component comment now says 10x12 padding, matching the declaration.

* Revert `schema.rb` changes

---------

Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-06-08 21:58:37 +02:00
Guillem Arias Fauste
0fe81cefe6 feat(ds): DS::SegmentedControl — fix invisible dark selected pill (#2145)
* feat(ds): DS::SegmentedControl; fix invisible dark selected pill (#2137)

Ships DS::SegmentedControl — a single-select pill group (filters, mode
switches) — plus a Lookbook preview, tests, and an exemplar migration of the
budget filter tabs.

The audit flagged the dark selected pill as invisible: the bespoke controls
paired a container-inset track (gray-800) with a gray-700 active pill, barely a
step apart. The primitive uses the DS tab-token values (tab-bg-group -> a
near-black alpha-black-700 track in dark), against which the gray-700 active
pill clearly reads. Values are inlined with @variant theme-dark because
@apply-ing the custom tab utilities drops their dark override.

- app/components/DS/segmented_control.rb: slot-based with_segment(label,
  active:, href:, **opts); link or button; full_width: for equal footprint;
  selected style isolated in .segmented-control__segment--active so a controller
  can toggle it as one class.
- .segmented-control recipe in components.css.
- Migrate budgets/_budget_tabs; budget_filter_controller now toggles the single
  --active class instead of five raw utility classes.

Verified in-browser: dark active pill reads (gray-700 on near-black track);
filter toggle still works. Tests + rubocop clean.

Deferred (follow-up): auth sign-in/up switch, transaction-type tabs, and other
bespoke segmented controls — same primitive, one migration each.

* fix(a11y): expose segmented-control selection + derive active from filter param

- DS::SegmentedControl: set aria-current (links) / aria-pressed (buttons) from
  the segment's active state so screen readers announce the selection.
- budget_filter_controller: mirror aria-pressed when it toggles the active class.
- _budget_tabs: compute each segment's initial active: from params[:filter] so
  a ?filter=over_budget request server-renders the correct pill (no flash before
  Stimulus runs). Addresses CodeRabbit reviews on #2145.

* fix(ds): close unterminated segmented-control CSS rule

The --active rule swallowed the table-scroll block's comment opener and
never closed, so the Tailwind build died with 'Missing closing } at
@layer components' and both test jobs failed at boot. Restore the
closing brace and the /* opener.

Also add the budgets.show.filter.aria_label locale key the budget tabs
view referenced only through its inline default.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-06-06 16:54:15 +02:00
Guillem Arias Fauste
f499a3db01 fix(ds): .table-scroll — wide tables scroll instead of clipping (#2148)
* fix(ds): .table-scroll — wide tables scroll instead of clipping (#2137)

The LLM-usage "Recent Usage" table wrapped in `overflow-hidden`, so on narrow
viewports it pushed the Tokens/Cost columns off-screen with no way to reach them
(the audit's "no scroll affordance"). Ship a reusable `.table-scroll`:
`overflow-x: auto` plus a pure-CSS theme-aware scroll-shadow (cover gradients
scroll with the content and reveal an edge shadow only when there is more to
see), giving the affordance with no JS. Migrate the LLM-usage table as exemplar.

Verified: at a mobile width the table now scrolls (content 591px in a 329px
wrapper) with a scrollbar + edge shadow, instead of clipping.

Deferred: roll `.table-scroll` out to the other overflow tables — reports use a
nested container-inset / container wrapper, so the cover bg needs per-use tuning
via `--table-scroll-bg`.

* fix(a11y): make .table-scroll region keyboard-focusable

Add tabindex=0 + role=region + aria-label so keyboard-only users can focus the
horizontal scroll container and reach overflowed columns with arrow keys.
Addresses CodeRabbit review on #2148.
2026-06-04 22:10:21 +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
Ang Wei Feng (Ted)
5e558fa3ab feat(transactions): add inline tag creation and search in txn form (#1719)
* feat(transactions): add inline tag creation and search in transaction forms

* fix(transactions): add tag-only update endpoint for edit drawer

* feat(transactions): implement TagSelectComponent for improved tag selection and management

* feat(tag-select): refactor tag selection component for improved functionality and accessibility

* feat(tag-select): implement inline tag rendering and error handling in tag selection component

* refactor(tag-select): remove unused list target from tag select controller

* fix: return forbidden JSON for denied tag updates

* fix: lock transaction tags when clearing them

* refactor: move tag select into DS namespace

* refactor: add multiselect trigger form field style

* fix: auto-position tag select dropdowns

* feat: add keyboard navigation to tag select

* feat: add create tag and search placeholder to transaction forms in multiple languages

* style: tighten tag select option spacing

* fix: align tag select spacing and focus behavior

* refactor: render tag badges with DS pill

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-06-01 21:46:32 +02:00
Guillem Arias Fauste
e250d266e8 refactor(design-system): single-source design tokens via DTCG JSON (#1604)
* refactor(css): rename maybe-design-system → sure-design-system

Rename design system CSS file and directory to match the project name
post-rebrand. Update internal imports plus references in CLAUDE.md,
copilot instructions, and Junie guidelines. No CSS rules change; Tailwind
compiled output is byte-identical.

* build(tokens): introduce single-source tokens.json + build script

Make the design system a tool-agnostic single source of truth.

- tokens/sure.tokens.json: every primitive, semantic alias, and Tailwind
  utility token in one W3C DTCG-flavored file.
- tools/tokens/build.mjs: ~120 LOC plain Node script (zero deps) that
  resolves token references and emits Tailwind v4 source CSS.
- app/assets/tailwind/sure-design-system/_generated.css: build output —
  the @theme block, dark-mode overrides, and 50 @utility blocks.
- Hand-written CSS split into base.css (element resets), components.css
  (form-field/checkbox/tooltip/qrcode), and prose.css (prose dark
  overrides). The 5 maybe-design-system/*-utils.css files are removed —
  their contents now live inside _generated.css.
- application.css gains `@source not "../../../tokens"` so Tailwind's
  content scanner ignores the JSON file (it would otherwise treat token
  keys like `bg-surface` as "used" classes and skip tree-shaking).
- package.json: `npm run tokens:build` and `npm run tokens:check`.
- .gitattributes: _generated.css marked linguist-generated.

Functional parity verified: compiled `tailwind.css` has the same 378 CSS
variables and byte-identical non-:root rules as before. The only diff is
which of Tailwind's internal `:root,:host` blocks each variable lands in,
which is invisible to the browser.

* build(tokens): wire tokens build into bin/setup

Run `npm install && npm run tokens:build` after bundle so a fresh
checkout reaches a runnable state with one command.

* docs(css): explain @source not exclusion of tokens dir

Adds a comment so future readers know why tokens/ is excluded from
Tailwind's content scanner (utility keys in the JSON would otherwise
be treated as used classes and bypass tree-shaking).

* docs(tokens): add tokens/README

Schema overview, workflow, custom $extensions reference, and a list of
the edge cases the build script handles. Lands as a follow-up commit on
the same branch so reviewers landing on the diff have something to read
before opening sure.tokens.json.

* Update tokens/README.md

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Signed-off-by: Guillem Arias Fauste <gariasf@proton.me>

* docs(tokens): swap em-dashes for colons in README

* refactor(tokens): move tokens to design/, build script to bin/

Per PR review feedback (jjmata):
- tokens/ → design/tokens/ — top-level design/ namespace leaves room for
  future design assets (Figma exports, design docs, etc.) without
  cluttering the repo root.
- tools/tokens/build.mjs → bin/tokens.mjs — keeps all developer-facing
  scripts in one place (bin/) regardless of language.

Path references updated in:
- bin/tokens.mjs (TOKENS / OUT / generated header)
- package.json (tokens:build, tokens:check)
- app/assets/tailwind/application.css (@source not directive)
- app/assets/tailwind/sure-design-system.css (comment)
- app/assets/tailwind/sure-design-system/_generated.css (regenerated)
- design/tokens/README.md (workflow examples)

bin/tokens.mjs gains a +x bit. Tailwind compile verified.

* docs(tokens): normalize README paths to repo-root style

Files section was mixing relative-to-README paths (`../../bin/...`)
with repo-root paths (`design/tokens/...`) used elsewhere in the same
README. Switching everything to repo-root style for consistency.

* fix(tokens): validate {ref} placeholders against the known token set

CodeRabbit caught: resolveTemplate() and refToClass() would happily emit
var(--foo-bar) or bg-foo-bar for any {foo.bar} input, so a typo in
design/tokens/sure.tokens.json would silently ship broken CSS.

Now build() pre-computes the set of valid token paths from the walker,
and resolveTemplate() / refToClass() throw a clean "[tokens] Unknown
token reference ..." error when a placeholder doesn't match. Top-level
catch surfaces just the message and exits 1, no Node stack trace noise.

Smoke-tested both directions:
- Valid JSON: builds.
- {color.gray.NONEXISTENT|5%}: fails with clear message, exit 1.

* docs(tokens): humanize README prose

* One more refenrece to `maybe-design-system`

---------

Signed-off-by: Guillem Arias Fauste <gariasf@proton.me>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
2026-05-01 14:46:33 +02:00