Each lever (retire age / target spend / save per mo / real return) now
pairs a numeric input with a range slider; retirement_what_if_controller
mirrors the value across the pair (data-lever) and debounces the live
forecast preview. Birth year stays a plain numeric input.
(Skinned delete confirmations already render via Sure's global
Turbo.config.forms.confirm → DS::Dialog override, so the PR2 turbo_confirm
buttons are already styled — no change needed.)
DS::SelectableCard — a checkbox rendered as a selectable card (whole card
toggles; brand-accent border + bg-surface when selected via peer-checked
on the sibling). Submits like a normal checkbox, so the bucket's
replace-all form is unchanged. Lookbook preview + component test.
Retirement bucket now renders each account as a DS::SelectableCard
(name · type · balance) instead of a bare checkbox row. Money stays
privacy-sensitive.
- IncomeStatement#trimmed_mean_expense(months:, trim_pct:) — trailing-N-
month mean monthly expense with the top/bottom trim_pct% of months
dropped, so one-off spikes don't skew the anchor. Family#retirement_
spending_baseline now uses it (was median).
- Goal::Retirement#fi_number — 25× the annual target (4% rule).
- "Why this target?" card on the show page: Last-12-months anchor →
Target → FI number (25×), with a "Use my average" button that sets
target_spend to the trimmed-mean baseline. Money is privacy-sensitive.
- Header gains a green-dot "Active plan" DS::Pill badge when projectable.
Tests: trimmed_mean returns non-negative; fi_number = 25× annual target;
baseline returns Money. Rubocop + erb_lint clean.
The dashboard centerpiece. Goal::Retirement#glide_payload derives, from
the forecast, the active-plan series + a zero-savings (Walletburst)
shadow + a ±1pp real-return band + the per-age income breakdown for the
hover tooltip + lump markers + the retire/Coast crossover points (three
extra deterministic Forecast runs; cheap).
retirement_glide_chart_controller (D3, mirrors goal_projection_chart's
import / ResizeObserver / theme-observer idiom): portfolio-by-age line +
area, accumulation/drawdown phase shading, the ±1pp band cone, the
dashed Walletburst shadow, a "Retire · age N / $X" chip on the retire
line, a blue Coast crossover ring, purple lump bars, and a hover tooltip
(PR #2029 bg-container/rounded-xl/shadow style) showing the monthly
State / Workplace / Drawdown breakdown + Total-vs-target with a Covered
badge. Wired into the show page above the what-if; container is
privacy-sensitive.
Browser-verified: renders the band, shading, retire chip ($571K), Coast
dot, and shadow against the demo plan. glide_payload + lump_markers
unit-tested. Rubocop + erb_lint + biome clean.
Remaining for PR4: DS::SelectableCard bucket, "Why this target?" anchor
card, skinned DS::Dialog deletes, DE locale, demo seed, system test.
Surfaces the forecast on the page and makes the levers live.
- KPI cards (_kpis): Freedom date, Coast FIRE, Money-lasts-to + terminal
value, with a "set your birth year" prompt until a plan is projectable.
Wrapped in #retirement_kpis for Turbo Stream replacement; money carries
privacy-sensitive.
- What-if form: birth_year / retire_age / target_spend / monthly_savings /
real_return_pct. On input, retirement_what_if_controller debounces and
POSTs the current values to PATCH /retirement/forecast, which recomputes
against transient inputs and streams the KPI cards back WITHOUT
persisting. "Save plan" submits to #update to persist retirement_params.
- RetirementController gains #update (persist) and #forecast (transient
recompute → turbo_stream). Both reuse merged_plan_params, which drops
blank fields so a partial what-if doesn't clobber stored values.
Tests: KPI section renders; update persists params; forecast streams
#retirement_kpis without writing the slider value back. Rubocop +
erb_lint + biome clean.
PR4 replaces this minimal form with the designed slider rail + glide
chart; the #forecast endpoint and the engine stay.
Functional data-entry surface on the (still preview) /retirement page.
The polished combined-page UI is PR4; this ships plain forms + lists so
a preview user can populate a plan end to end.
- RetirementScoped concern: tier-1 preview gate + tier-2 family
killswitch + per-owner plan bootstrap (Goal::Retirement.for_owner
find-or-creates, so children always have a parent). RetirementController
now uses it.
- Nested controllers under Retirement::: PensionSources (full CRUD),
Statements (new/create + soft-delete destroy — append-only audit),
Adjustments (full CRUD), Buckets (replace-all account selection,
same-family filtered). All scoped to the current user's own plan, so
cross-user access is impossible by construction.
- Routes nested under `resource :retirement` via `scope module:`.
- Views: show page rewritten into management sections (sources,
adjustments, bucket checkboxes, statement journal) + plain
styled_form_with forms. Money carries privacy-sensitive.
- Goal gains a target_amount_required? hook (true); Goal::Retirement
overrides it false — the forecast owns the target (PR3), so a plan
can exist before any target is set.
- EN locale for the new surface. 111 controller+model tests green.
Note: delete uses Turbo confirm for now; PR4 swaps in the skinned
DS::Dialog per the design.
Lays the foundation for Retirement v2 as a preview feature stacked on
Goals v2. Math, lens UI, pension sources and bucket all defer to later
PRs; this PR ships only the data-model spine and a placeholder landing.
- STI on goals: add `type` (default "Goal") + `user_id` columns;
partial index for `Goal::Retirement` rows; check constraint
requiring an owner on retirement rows. Existing goals backfill to
`type='Goal'`; base `Goal#editable_by?` stays family-scoped.
- `Goal::Retirement` subclass with single-user owner and
`editable_by?` narrowed to owner-only. Parent depository-only
linked-account validations no-op'd; PR2 introduces
`RetirementBucketEntry`.
- `families.retirement_disabled` killswitch (default false) +
`Family#retirement_enabled?(user)` helper as tier 2 of the gate.
Tier 1 is the existing `PreviewGateable` flow.
- `RetirementController#show`: `require_preview_features!` then
`ensure_module_enabled!` then a placeholder body. Unknown to users
without preview features; 404 when the family killswitch is on (the
feature behaves as if it does not exist).
- Sidebar: new `sun`-icon entry after Goals, hidden unless the user has
preview features AND the family has retirement enabled, so the
killswitch hides the nav rather than leaving a link that 404s.
- Locales: EN copy for nav, breadcrumb, page header, placeholder body,
and the new `owner.must_belong_to_family` validation message under
the goal model. DE deferred to PR4.
- Tests: STI roundtrip, owner presence + family-membership
validations, `editable_by?` on both Goal and Goal::Retirement, gate
matrix on the controller, nav-item visibility under both preview and
family flags, base-row STI backfill.
Stack ahead: PR2 ships the data plane (PensionSource, statements,
adjustments, bucket entries); PR3 wires the `Retirement::Fire::*`
forecast engine + WHAT-IF Turbo Stream slider loop; PR4 lands the
single combined-page UI per Claude's 2026-05-29 design (glide chart
with hover-tooltip income breakdown, no separate stacked-area chart).
The 2-step stepper on the create modal carried a review step whose only
real signal was a derived "Save $X/mo to hit it on time" hint. Name,
amount, and date are all visible in step 1, so the review step was
re-displaying form values the user just typed.
Collapses both flows into a single panel:
- `_form_stepper.html.erb` + `_form_edit.html.erb` → single
`_form.html.erb` driven by `goal.persisted?` for URL / method /
submit label.
- `goal_stepper_controller.js` → `goal_form_controller.js`. Drops the
step1Panel / step2Panel / step1Indicator / step2Indicator /
step1Circle / step2Circle / stepperLine / reviewName / reviewSummary
/ reviewSuggested / footerLeftButton / footerRightButton / submitButton
target plumbing and the next / back / blockEnter / updateStepperState
/ updateFooter / updateReview methods. Keeps name-validation,
amount-validation, accounts-required validation, avatar-preview-from-
name, and the suggested-pace computation — that one now writes into
an inline `<p data-goal-form-target="suggested">` below the
target_date field instead of the review card.
- `new.html.erb`: drops the `Step 1 of 2 · Goal details` subtitle
target. New `goals.new.subtitle` replaces the two step subtitles.
- `edit.html.erb`: renders the same `form` partial.
- `_color_picker.html.erb`: `data-goal-stepper-target="avatarPreview"`
→ `data-goal-form-target="avatarPreview"` (same Stimulus target,
renamed for the new controller scope).
- `funding_accounts_breakdown_component.rb`: i18n key path moves to
`goals.form.subtypes.*` matching the locale restructure.
- `en.yml`: `goals.form_stepper.step1.fields.*` → `goals.form.fields.*`.
`step2.*` and the `back` / `continue` / `cancel` keys drop. New
`goals.form.create` ("Create goal") + `goals.form.save` ("Save
changes") drive the submit-button label.
UX delta: the user no longer sees a "Step 1 of 2 / Step 2 of 2" beat.
The form is short enough that everything fits in one panel; the only
value-add from the old step 2 — the suggested-pace hint — now updates
live inline as the amount / date / account-count changes.
All 20 `test/controllers/goals_controller_test.rb` tests still pass.
`bundle exec erb_lint` clean on the touched templates.
Resolves sure-design DS drift patrol findings (raw <details> on
goals/_color_picker and categories/_form). The color-icon-picker's
<summary> is a 24/28px pencil button absolutely positioned next to
the avatar — none of DS::Disclosure's existing variants
(default / card / card_inset / inline) match that trigger shape, so
the bot's suggested swap would regress the visual.
- DS::Disclosure: add optional `summary_class:` kwarg. When set, the
caller's class string replaces the variant's hard-coded summary
chrome; otherwise the existing variant logic is preserved (verified
against the 8 existing callsites — none pass summary_class, all
fall through to current behavior).
- goals/_color_picker + categories/_form: swap raw <details> for
DS::Disclosure with summary_class carrying the pencil-button
positioning. Stimulus data attributes (`color-icon-picker-target`
and the outside-click handler) forwarded via **opts to tag.details
so the controller still finds its target.
The DS::Disclosure-rendered popover content now sits inside the
component's `<div class="mt-2">` wrapper, but the popups themselves
are `position: absolute` / `position: fixed`, so the wrapper is
out-of-flow neutral.
Audit-driven sweep. The class was already on the obvious surfaces (KPI
strip, ring center, card balance, funding-accounts breakdown); these
were the secondary surfaces missed in the initial PR — money interpolated
into descriptive prose, account-picker balances, live previews, and the
projection chart tooltip.
- card_component: target divisor next to the masked balance, pace line,
and behind-status footer (`footer_has_money?` helper keeps non-money
branches unmasked so paused / archived / "Goal reached" copy stays
readable in privacy mode).
- show: header_summary (target + date subtitle), to_go remaining,
inactive recap body, celebration body, catch_up body.
- _status_callout: conditional on `goal.status == :behind` — only that
branch carries an amount; on_track / no_target_date have date or
static copy.
- _form_edit + _form_stepper: account balance shown in the linked-
account picker rows.
- _form_stepper review section: reviewSummary + reviewSuggested ps
(Stimulus injects target / suggested $X/mo into both).
- _pending_pledge_banner: banner title span (amount + account + days).
- goal_pledges/new: live preview p (Stimulus injects "Reaches X%, $A of
$B" / "Hits your $B target").
- goal_projection_chart_controller: tooltip was inline-styled with
hard-coded gray-900 + white (DS drift) and had no privacy class.
Replaced cssText with className using bg-container + text-primary +
border-secondary + rounded-lg + privacy-sensitive — mirrors the
pattern in time_series_chart_controller and the post-#1996 sankey
fix. Tooltip now respects theme and privacy mode.
* fix: Avoid overlay in provider section on mobile
* feat: Reduce gap between divs
* fix: keep all the elements inside a dedicated container to avoid accessibility issues with the summary node
* fix(settings): preserve OpenAI form input on validation failure
Fixes#1824.
The OpenAI settings form auto-submits on blur, so typing the URI base
before the model triggers cross-field validation. The rescue re-renders
the page with values read from Setting.openai_*, which is still blank
because the failed save was rejected — so the user's input disappears
and they see 'OpenAI model is required' with no value to fix.
Stash the submitted uri_base and model on rescue and prefer them over
the saved Setting when rendering, so the user can finish typing the
missing field and re-submit.
* test(settings): cover openai_model preservation on validation fail (#1862)
jjmata asked for symmetric coverage of the model field. Add a test where
the user changes the URI base and clears the model in the same submit:
the cross-field validation fails and the re-rendered model input must
reflect the submitted (cleared) value rather than reverting to the saved
model. Complements the existing uri_base preservation test.
* fix(views): clear Rule 2 + Rule 5 findings from weekly DS drift (#1951)
Token swaps + i18n cleanup across the three files flagged in the
weekly merged-commit drift scan.
**`app/views/admin/users/index.html.erb`**
- `bg-green-100 text-green-800` → `bg-success/10 text-success` (2 callsites — active-subscription badge + super_admin role legend)
- `bg-surface-default` → `bg-surface` (`--color-surface-default` isn't defined; canonical token is `--color-surface`)
- `bg-red-50/30 dark:bg-red-950/20` → `bg-destructive/5` (pending-invitation row highlight; functional token resolves correctly in both themes via `--color-destructive`)
- Hand-rolled destructive button classes (`text-red-600`, `border-red-300`, `hover:bg-red-50`) → functional tokens (`text-destructive`, `border-destructive`, `hover:bg-destructive/10`)
- Drop redundant `default:` args from `t(".roles.member", default: "Member")` and `t(".role_descriptions.member", default: "Basic user access…")` — the locale keys exist in `config/locales/views/admin/users/en.yml`
**`app/views/imports/new.html.erb`**
- `icon_bg_class: "bg-gray-tint-5"` → `"bg-surface-inset"` (`gray-tint-5` isn't a defined utility; `bg-surface-inset` carries the same muted-background intent and theme-swaps correctly)
**`app/views/settings/profiles/show.html.erb`**
- Drop redundant `default:` args from `t(".group_title", default: "Group")`, `t(".group_form_label", default: "Group name")`, and `t(".group_form_input_placeholder", default: "Enter group name")` — all three keys exist in `config/locales/views/settings/en.yml`
**Deferred** to a separate PR (Rule 1 findings on admin/users):
- `<details>` block (lines 54–180) → `DS::Disclosure(:card)` — bigger refactor with custom summary content + Stimulus controller attributes; warrants its own diff.
- Destructive button shell → `DS::Button(:destructive)` — same reason; the class-token swap in this PR clears the immediate violation without changing the form-with structure or visual.
Refs #1951.
* fix(profiles): restore i18n default: args for group_* keys
@jjmata + @codex correctly flagged: `settings.profiles.show.group_title`,
`group_form_label`, and `group_form_input_placeholder` are defined in
en.yml + 4 other locales (de, es, pl, pt-BR), but missing from 8
locales (ca, fr, nb, nl, ro, tr, zh-CN, zh-TW).
With `config.i18n.fallbacks = true` those locales currently fall
back to en values, so end-users see English copy rather than a
translation-missing marker. The `default:` arg makes the fallback
explicit at the call site without depending on the Rails fallback
chain being configured a particular way — restores the original
defensive behavior from before #1955.
Admin/users role keys keep their `default:` removal — verified that
`roles.member` and `role_descriptions.member` exist in all 8
admin/users locales (`grep -c "^\s*member:"` returns 2 for every
locale file).
* fix(enable_banking): match bank list search against BIC, not just name
Bank-search filter on the Enable Banking bank-selection modal only indexed
`aspsp[:name]`, so users searching by BIC code (e.g. `INGDDEFF`) got no
results even when the bank was rendered in the list. Switch the per-item
data attribute to a `name + BIC` haystack and read from it in the Stimulus
controller, so either token matches.
Refs #1814
* style(bank_search): apply Biome formatting to forEach callback (#1874 review)
Follow-up to #1917 — the responsive label-swap pair in
`_transfer_match.html.erb` was deferred because DS::Pill has no
caller-controlled `class:` arg yet. Wrapping each `DS::Pill` in a
`<span>` with the responsive visibility classes (`hidden lg:inline` /
`inline lg:hidden`) gets the same effect without expanding the
component API — the parent span's `display` controls visibility, the
child pill keeps its own `inline-flex` chrome when visible.
Closes the last open callsite from #1917's deferred-list. Same tone
(`:neutral`) and shape (`marker: false` rounded-full) as the other
neutral status badges migrated in PR B.
The admin users page wraps four top-level sibling sections inside a
single `bg-container rounded-xl shadow-border-xs p-4` card:
1. description paragraph
2. filter form
3. trials-expiring summary grid
4. families/groups list
5. role descriptions (`settings_section` collapsible → DS::Disclosure :card)
The first three carried their own `mb-6`; the families list and the
role descriptions section had no margin at all, so the families card
sat flush against the role-descriptions card with zero gap — clearly
broken next to the well-spaced upper sections.
Apply spacing at the **layout** level: hoist `space-y-6` onto the
outer container and drop the per-child `mb-6`. All five siblings now
get a consistent 24px gap.
No other admin or settings pages match this exact pattern (single
outer card + multiple sibling sections without parent space-y) — the
settings layout already wraps `<%= yield %>` in `space-y-4`, and other
pages with outer cards (`api_keys/show`, `llm_usages/show`, etc.)
either rely on that layout or carry their own internal `space-y-N`.
* refactor(views): migrate 6 residual inline alerts to DS::Alert
PR #1731 extended DS::Alert and migrated 9 inline alert blocks. Six
hand-rolled alert blocks slipped through that sweep and stayed on raw
palette tokens with no `theme-dark:` variants:
- `app/views/settings/llm_usages/show.html.erb` — "About Cost Estimates"
blue info block. Most visible offender: `bg-blue-50 border border-blue-200`
+ `text-blue-900 / text-blue-700 / text-blue-600` rendered as a bright
white-blue island in dark mode (the bug spotted on the LLM usage page).
- `app/views/accounts/confirm_unlink.html.erb` — yellow warning with
bullet list.
- `app/views/oidc_accounts/new_user.html.erb` — blue info heading.
- `app/views/oidc_accounts/link.html.erb` — two blocks (yellow verify
warning + blue create info). Also flips the file's pre-existing
`text-gray-600` hint paragraph to `text-secondary` (caught by the
`DeprecatedClasses` erb_lint rule on save).
- `app/views/rules/confirm.html.erb` — AI cost notice.
- `app/views/rules/confirm_all.html.erb` — AI cost notice.
All six migrate to `DS::Alert.new(title:, variant:)` (with a block content
slot for the rich/conditional bodies). DS::Alert resolves `bg-info/10`,
`border-info/20`, etc. from the `@theme` semantic tokens, so dark mode
now renders a subtle blue/yellow tint over the page surface instead of
a hardcoded light-mode pill.
Out of scope (left as-is, not alert-shaped):
- `app/views/assistant_messages/_tool_calls.html.erb` — a tool-call
display panel (not an alert; needs its own token sweep).
- `app/views/import/rows/_form.html.erb` — inline cell-error tooltip
(`bg-red-50 border border-red-200`) — also not alert-shaped; a future
PR can swap it to `bg-destructive/10 border-destructive-subtle` once
#1932 lands.
Surfaced while scanning DS drift for the LLM usage page bug. Tracking
issue: #1715 (closed but conceptually relevant) / #1911 (active drift
patrol).
* fix(oidc): keep alert description in <p>, retarget tests for DS::Alert title
CI on #1933 caught three test failures introduced by migrating the
two OIDC link alerts and the verify-redirect copy from hand-rolled
`<h3>` / `<p>` markup to `DS::Alert`:
1. `OidcAccountsControllerTest#test_should_show_create_account_option_for_new_user`
2. `OidcAccountsControllerTest#test_does_not_show_create_account_button_when_JIT_link-only_mode`
3. `SessionsControllerTest#test_redirects_to_account_linking_when_no_OIDC_identity_exists`
DS::Alert renders its `title:` slot as a `<p>` (semantically the alert
heading lives on the container's `aria-labelledby`, not on a heading
tag) and renders block / message content directly inside a `<div>`,
not a `<p>`. The pre-migration markup used `<h3>` for the heading and
`<p class="...text-blue-700">` for the description, so the tests
above asserted those specific tags.
Two fixes:
- `app/views/oidc_accounts/link.html.erb` — wrap the html_safe
description bodies in explicit `<p>` tags inside the DS::Alert
block. Restores the `<p>` element the session-redirect test asserts
on, and keeps the description as a semantic paragraph rather than
a bare text node inside the alert container.
- `test/controllers/oidc_accounts_controller_test.rb` — flip the two
`assert_select "h3", text: "Create New Account"` calls to match the
DS::Alert title `<p>`. The test was asserting an implementation
detail of the pre-migration markup; switching to the new tag keeps
the assertion meaningful (the heading text still has to render)
without re-introducing an `<h3>` outside of DS::Alert.
* fix(test): match Create New Account title with regex (sr-only "Info:" prefix)
DS::Alert prepends `<span class="sr-only">Info:</span>` inside the
title `<p>`, so the full text content is "Info: Create New Account",
not "Create New Account". `assert_select "p", text: "Create New Account"`
requires an exact text match and rejected the prefixed string. Switch
to a regex match — keeps the heading-text assertion meaningful without
coupling to the screen-reader prefix.
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`.
PR #1840 bumped DS::Button icon-only `:md` size from `w-9 h-9` (36×36)
to `w-11 h-11` (44×44) for WCAG 2.5.5 enhanced touch target. DS::Menu's
`:icon` variant uses DS::Button at the default `:md` size, so every
row-level "..." action-list trigger grew from 36×36 to 44×44.
For dense lists where each row has a trigger — most visibly the
transaction category dropdown (`category/dropdowns/_row.html.erb`) —
the per-row height bump (+8px) compounds: a 5-category panel that
used to fit in ~220px now wants ~260px, the badges look smaller
relative to the row chrome, and the overall density that made the
dropdown scannable regresses visibly.
Add an `:icon_sm` variant that renders the trigger as DS::Button at
`size: :sm` (32×32). Meets WCAG 2.5.8 AA (24×24) — appropriate for
compact in-row triggers where 44×44 isn't required. Standalone
toolbar / row-action `...` triggers should keep `:icon` for AAA.
Migrate `category/dropdowns/_row.html.erb` to `:icon_sm` to restore
the pre-#1840 dropdown density.
* refactor(transactions): migrate 5 transaction badges to DS::Pill (#1751 PR B)
Migrates the hand-rolled "Pending" / "Review recommended" / "Potential
duplicate" / "Split" badges across the transaction views to the
extended DS::Pill primitive from #1902.
**Visual contract for badge mode**
In #1902 the badge mode (`marker: false`) used `rounded-md` (chip shape)
because the marker mode does. But every existing pill / status badge
in the codebase uses `rounded-full` — see
`settings/providers/_status_pill.html.erb`,
`settings/providers/_maturity_badge.html.erb`, and the inline
transaction badges this PR is migrating. To keep the visual contract
consistent, this PR shifts `DS::Pill`'s badge mode to `rounded-full`
(marker mode stays `rounded-md`, unchanged from #1829). The shape
distinction now reads: markers are tags, badges are pills.
**Callsites migrated** (5):
- `app/views/transactions/_transaction.html.erb` — Pending,
Review-recommended, Possible-duplicate, Split badges
- `app/views/transactions/_header.html.erb` — Pending badge
- `app/views/transactions/_split_parent_row.html.erb` — Split badge
**Tone mapping**
| Badge | Tone | Notes |
|---|---|---|
| Pending | `:neutral` | unchanged copy/icon, gains subtle DS-controlled bg |
| Review recommended | `:neutral` | matches existing `bg-surface-inset` look |
| Possible duplicate | `:warning` | DS semantic alias for the existing `text-warning` |
| Split | `:neutral` | matches existing `bg-surface-inset` look |
**Deferred to follow-up PRs**
- `app/views/transactions/_transfer_match.html.erb` — uses two
responsive-visibility variants (`hidden lg:inline-flex` for long
copy, `inline-flex lg:hidden` for short). DS::Pill currently has no
`class:` arg for caller-controlled wrapper classes; deferring until
that lands.
- `app/views/transactions/searches/filters/_badge.html.erb` — has a
close button alongside the label (`button_to clear_filter_*`) and
uses `rounded-3xl p-1.5` instead of a true pill. Closer to a
removable filter chip — better fit for a separate `DS::FilterChip`
primitive than for `DS::Pill`.
Refs #1751.
* refactor(misc): migrate misc badges to DS::Pill (#1751 PR D)
Replaces five misc badge callsites with `DS::Pill` (badge mode:
`marker: false`, `show_dot: false`) so the long-tail badges share the
same shape, padding, and dark-mode tokens as the rest of the design
system. No raw palette classes remain in the migrated files.
Migrated:
- app/views/shared/_badge.html.erb — converted to a thin shim that
renders `DS::Pill`; preserves the block-content API and the
`pulse: true` option (wraps the pill in `animate-pulse`). Maps
`success`/`error`/`warning`/default → `:success`/`:error`/`:warning`/`:neutral`.
- app/views/accounts/_tax_treatment_badge.html.erb — maps tax
treatments to DS tones: `:tax_exempt → :green`,
`:tax_deferred → :indigo` (was raw blue-500/10),
`:tax_advantaged → :violet` (was raw purple-500/10), default → `:neutral`.
- app/views/reports/_investment_performance.html.erb (line ~121,
inline twin of the tax-treatment badge) — uses the same mapping via
a new `tax_treatment_pill_tone` helper.
- app/helpers/reports_helper.rb — replaces `tax_treatment_badge_classes`
with `tax_treatment_pill_tone` (the old helper had no other callers).
- app/views/import/qif_category_selections/show.html.erb (~line 86) —
inline split badge → `tone: :warning`.
- app/views/investment_activity/_badge.html.erb — fixed activity
enum mapped to DS tones: Buy/Reinvestment → :indigo,
Sell → :red, Dividend/Interest → :green, Contribution → :violet,
Withdrawal → :amber, others → :gray.
Skipped (true mismatches, not extendable without changing DS::Pill):
- app/views/shared/_color_badge.html.erb — takes an arbitrary
user-supplied color via `color-mix(in oklab, #{color} ...)`. DS::Pill
only supports the fixed tone enum, so this would lose information.
- app/views/categories/_badge.html.erb — same reason; renders
`category.color` (arbitrary hex per record).
- app/views/investment_activity/_quick_edit_badge.html.erb — interactive
button with a Stimulus controller, click action, hover state, and
dropdown anchor. DS::Pill renders a `<span>`; converting would
destroy the interactive surface.
Stack: based on `feat/ds-pill-transactions-1751-b` (PR #1917), which
ships the `marker: false` → `rounded-full` badge shape this PR depends on.
Refs #1751.
* refactor(transactions): migrate 5 transaction badges to DS::Pill (#1751 PR B)
Migrates the hand-rolled "Pending" / "Review recommended" / "Potential
duplicate" / "Split" badges across the transaction views to the
extended DS::Pill primitive from #1902.
**Visual contract for badge mode**
In #1902 the badge mode (`marker: false`) used `rounded-md` (chip shape)
because the marker mode does. But every existing pill / status badge
in the codebase uses `rounded-full` — see
`settings/providers/_status_pill.html.erb`,
`settings/providers/_maturity_badge.html.erb`, and the inline
transaction badges this PR is migrating. To keep the visual contract
consistent, this PR shifts `DS::Pill`'s badge mode to `rounded-full`
(marker mode stays `rounded-md`, unchanged from #1829). The shape
distinction now reads: markers are tags, badges are pills.
**Callsites migrated** (5):
- `app/views/transactions/_transaction.html.erb` — Pending,
Review-recommended, Possible-duplicate, Split badges
- `app/views/transactions/_header.html.erb` — Pending badge
- `app/views/transactions/_split_parent_row.html.erb` — Split badge
**Tone mapping**
| Badge | Tone | Notes |
|---|---|---|
| Pending | `:neutral` | unchanged copy/icon, gains subtle DS-controlled bg |
| Review recommended | `:neutral` | matches existing `bg-surface-inset` look |
| Possible duplicate | `:warning` | DS semantic alias for the existing `text-warning` |
| Split | `:neutral` | matches existing `bg-surface-inset` look |
**Deferred to follow-up PRs**
- `app/views/transactions/_transfer_match.html.erb` — uses two
responsive-visibility variants (`hidden lg:inline-flex` for long
copy, `inline-flex lg:hidden` for short). DS::Pill currently has no
`class:` arg for caller-controlled wrapper classes; deferring until
that lands.
- `app/views/transactions/searches/filters/_badge.html.erb` — has a
close button alongside the label (`button_to clear_filter_*`) and
uses `rounded-3xl p-1.5` instead of a true pill. Closer to a
removable filter chip — better fit for a separate `DS::FilterChip`
primitive than for `DS::Pill`.
Refs #1751.
* refactor(providers): migrate provider badges to DS::Pill (#1751 PR C)
Migrates the provider-bucket pill/badge callsites to the extended
DS::Pill primitive (badge mode, rounded-full) from #1917.
Callsites migrated (3):
- app/views/settings/providers/_status_pill.html.erb — provider
connection status pill. Status → tone mapping:
:ok → :success, :warn → :warning, :err → :error,
else → :neutral.
- app/views/settings/providers/_maturity_badge.html.erb — alpha/beta
label. Tone :neutral, no dot.
- app/views/sophtron_items/_sophtron_item.html.erb (line 27) —
"manual sync" warning. Tone :warning, no dot.
The settings/providers/_status_pill partial wraps DS::Pill rather
than being deleted, since _connection_row still calls it via
`render "settings/providers/status_pill", status: status` — keeping
the partial preserves the seam without a wider refactor.
Dead code removed: SettingsHelper#status_pill_classes (no remaining
callers after the migration).
Skipped:
- app/views/simplefin_items/_activity_badge.html.erb — not actually
a pill/badge. It renders <p> text with `text-warning` plus an
inline icon below the heading; no rounded-full shape and no chip
semantics. Migrating it would change the layout, not consolidate
a pill pattern.
Refs #1751. Stacks on #1917.
Migrates the hand-rolled "Pending" / "Review recommended" / "Potential
duplicate" / "Split" badges across the transaction views to the
extended DS::Pill primitive from #1902.
**Visual contract for badge mode**
In #1902 the badge mode (`marker: false`) used `rounded-md` (chip shape)
because the marker mode does. But every existing pill / status badge
in the codebase uses `rounded-full` — see
`settings/providers/_status_pill.html.erb`,
`settings/providers/_maturity_badge.html.erb`, and the inline
transaction badges this PR is migrating. To keep the visual contract
consistent, this PR shifts `DS::Pill`'s badge mode to `rounded-full`
(marker mode stays `rounded-md`, unchanged from #1829). The shape
distinction now reads: markers are tags, badges are pills.
**Callsites migrated** (5):
- `app/views/transactions/_transaction.html.erb` — Pending,
Review-recommended, Possible-duplicate, Split badges
- `app/views/transactions/_header.html.erb` — Pending badge
- `app/views/transactions/_split_parent_row.html.erb` — Split badge
**Tone mapping**
| Badge | Tone | Notes |
|---|---|---|
| Pending | `:neutral` | unchanged copy/icon, gains subtle DS-controlled bg |
| Review recommended | `:neutral` | matches existing `bg-surface-inset` look |
| Possible duplicate | `:warning` | DS semantic alias for the existing `text-warning` |
| Split | `:neutral` | matches existing `bg-surface-inset` look |
**Deferred to follow-up PRs**
- `app/views/transactions/_transfer_match.html.erb` — uses two
responsive-visibility variants (`hidden lg:inline-flex` for long
copy, `inline-flex lg:hidden` for short). DS::Pill currently has no
`class:` arg for caller-controlled wrapper classes; deferring until
that lands.
- `app/views/transactions/searches/filters/_badge.html.erb` — has a
close button alongside the label (`button_to clear_filter_*`) and
uses `rounded-3xl p-1.5` instead of a true pill. Closer to a
removable filter chip — better fit for a separate `DS::FilterChip`
primitive than for `DS::Pill`.
Refs #1751.
#1858's :inline variant landed (commit 8de14ed2), unblocking the third
sure-design drift finding on this file (#1895 / #1898).
The :inline variant is the right shape for an in-table-cell metadata
expander — no surface, no padding, no shadow; the summary reads as plain
text-link copy. The bot recommended this exact variant when filing the
issues; previous PR (#1903) covered the two token findings but deferred
the <details> migration until the variant was available.
Closes#1895. Closes#1898.
#1858's :inline variant landed (commit 8de14ed2), so the goals index
archived-section toggle can move off the raw <details> and onto the
shared primitive.
Behaviour and look unchanged — the custom summary content (chevron +
uppercase heading + tabular count) passes through the `summary_content`
slot; the `:inline` variant adds nothing on top besides the
focus-visible ring shared with the other DS::Disclosure variants.
Addresses the third sure-design DS Drift finding on #1798 (the other
two — DS::SearchInput + ring-gray-500 — already landed earlier).
Pulls in #1857 (DS::Disclosure :card_inset), #1858 (:inline variant),
#1902 (DS::Pill marker:false + semantic tones + :red palette), #1903
(settings/debugs token fix), plus #1878 (entry.date guard) and other
minor fixes that landed.
Resolves one conflict in app/components/DS/pill.rb: takes main's new
extended API (marker: flag, SEMANTIC_TONE_ALIASES, :red tone, updated
docstring) and preserves the goals-branch color-mix(...30% black)
text treatment that was added for light-mode contrast. Applies the
same color-mix to the new :red tone for consistency.
`app/views/settings/debugs/show.html.erb` had two non-functional Tailwind
classes flagged by sure-design's weekly merged-commit scan (#1895, #1898):
- `bg-surface-default` → `bg-surface`. `bg-surface-default` doesn't map
to any DS color variable (`--color-surface-default` isn't defined);
`--color-surface` is the canonical token, auto-generates `bg-surface`.
- `divide-gray-100` → `divide-alpha-black-200 theme-dark:divide-alpha-white-200`.
Matches the existing pattern used by `admin/sso_providers/index.html.erb`,
`admin/users/index.html.erb`, and `settings/preferences/show.html.erb`
for tbody dividers. No `divide-primary` utility exists yet, so the
bot's suggestion gets the same effect via the alpha tokens.
The third drift finding on this file — the in-cell `<details>`
metadata expander — is deferred until #1858's `DS::Disclosure :inline`
variant lands on `main`. The `:default` variant renders a
`bg-surface px-3 py-2 rounded-xl` card chrome that's wrong for an
in-table-cell trigger; the `:inline` variant in #1858 is the right
shape and will get a follow-up PR once that lands.
Closes#1895 partially. Closes#1898 partially. Both bot issues stay
open until the `<details>` migration also lands.
* feat(design-system): add :inline variant + migrate indexa_capital + snaptrade panels
Adds an `:inline` variant to `DS::Disclosure` for plain text-link-style
toggles that have no surface, no padding, no shadow — the disclosure
reads as a clickable summary text + revealed content, nothing more.
Use case: "Alternative auth" form section toggle in the Indexa Capital
provider panel; "Manage connections" lazy-loaded toggle in the
Snaptrade provider panel. Both were the last raw-`<details>` callsites
in `app/views/settings/providers/`.
Migrations:
- `_indexa_capital_panel.html.erb` — single inline `<details>` revealing
username / document / password form fields under an "Alternative auth"
summary text.
- `_snaptrade_panel.html.erb` — lazy-load `<details>` with
`data-controller="lazy-load"` etc. The new `tag.details ... **opts`
forwarding from #1857 lets the Stimulus controller attrs flow
through cleanly via DS::Disclosure's `data:` keyword.
Chevron rotation on snaptrade gets the standard
`motion-safe:transition-transform motion-safe:duration-150` treatment
(was `transition-transform` without the motion-safe gate).
Variant summary now:
| Variant | Details surface | Use case |
|---|---|---|
| `:default` | none / bg-surface summary | inline expander inside parent card |
| `:card` | `bg-container shadow-border-xs rounded-xl p-4` | provider rows, settings sections |
| `:card_inset` | `bg-surface-inset rounded-xl p-4` | inset sub-panels |
| `:inline` | no surface | text-link-style toggles |
* fix(review): guard variant.to_sym against nil in DS::Disclosure
CodeRabbit on #1858 flagged that `variant: nil` crashed with
`NoMethodError` at `variant.to_sym` before the explicit `VARIANTS`
check could run. Use safe navigation (`variant&.to_sym`) so nil
falls through to the validation, and inspect `@variant` in the
error message so nil / non-symbol inputs render readably.
Verified manually via runner: `DS::Disclosure.new(variant: nil)` now
raises `ArgumentError: Invalid variant: nil. Must be one of
[:default, :card, :card_inset, :inline]`.
* feat(design-system): add :card_inset variant + migrate ibkr_panel and settings/_section
Wraps up the disclosure migration cluster from #1715 §6:
1. **New `:card_inset` variant** on `DS::Disclosure`. Same contract
as `:card` but uses `bg-surface-inset rounded-xl p-4` (no shadow)
for inset sub-panels embedded inside a parent card surface.
2. **Migrate `_ibkr_panel.html.erb`** — the "flex query details"
disclosure (`<details class="group bg-surface-inset rounded-xl p-4">`)
was the one panel skipped from #1856 because it used the inset
surface. Now uses `DS::Disclosure(variant: :card_inset)`. Chevron
gets the `motion-safe:transition-transform motion-safe:duration-150`
treatment along the way.
3. **Migrate `settings/_section.html.erb`** — the global "collapsible
settings card" primitive backing 19 callsites via the
`settings_section(...)` helper. The collapsible branch's
`<details class="group bg-container shadow-border-xs rounded-xl p-4">`
becomes `DS::Disclosure(variant: :card, open: open, data: ...)`.
While here:
- Update `disclosure.html.erb` to spread `**opts` onto the `<details>`
element via `tag.details`. Previously opts were captured but never
applied; the `settings/_section` migration needs `data-controller`
+ `data-auto-open-param-value` to flow through to the rendered
`<details>`.
- Non-collapsible branch in `settings/_section.html.erb` stays as
raw `<section>` — different semantics (not expandable), DS::Disclosure
can't replace because it always renders `<details>`.
API:
DS::Disclosure.new(
variant: :card | :card_inset | :default,
open: bool,
data: { controller: "...", ... } # forwarded to <details>
)
* fix(review): merge caller class in DS::Disclosure + i18n plaid deletion
- DS::Disclosure: extract caller class: from opts and merge via class_names
before forwarding to tag.details. Prevents the latent duplicate keyword
arg error when callers pass class: alongside the variant-derived classes.
- plaid_items/_plaid_item: localize "(deletion in progress...)" via
t('.deletion_in_progress') + add en locale key, matching lunchflow /
mercury / sophtron / coinstats convention.
* fix(panels): replace text-white and bg-gray-tint-10 with semantic tokens
`text-white` → `text-inverse` on the EnableBanking reauthorize button
(`bg-warning` background); `bg-gray-tint-10` → `bg-container-inset` on
the IndexaCapital item avatar wrapper. Both flagged by sure-design as
non-functional palette tokens.
Pre-existing on main; surfaced by the re-indentation that this PR
applied during the disclosure migration.
#1853 just landed on `main` (`8e444ff9`), so the goals index search
input can move off the hand-rolled markup and onto the new
`DS::SearchInput` primitive. Behaviour unchanged — the
`data-goals-filter-target="input"` and
`data-action="input->goals-filter#filter"` hooks pass through via the
component's `data:` option, and the wrapper's
`flex-1 min-w-[200px]` carries through via the component's
`class:` arg.
Drops the broken `focus:ring-gray-500` lookalike (the hand-rolled
class used `focus:ring-alpha-black-100` which is fine, but the new
primitive uses the canonical
`focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900`
pattern from DS::Button — same focus contract everywhere).
Addresses sure-design's DS Drift Patrol finding on #1798.
Pulls in #1853 (DS::SearchInput), #1855 (DS::Disclosure :card), #1856
(3 provider panels migration) so the goals views can migrate to the
new primitives.
The colour-picker swatches were using the raw Tailwind palette utility
`peer-checked:ring-gray-500`. Swap for the design-system functional
token `peer-checked:ring-alpha-black-500` so the focus ring inherits
the same alpha-on-surface treatment used elsewhere in the system and
respects dark mode.
Flagged by sure-design's DS Drift Patrol on #1798.
`Goals::CardComponent#ring_color` and `goals/_status_callout` reached
into the Tailwind palette directly (`text-yellow-700`,
`var(--color-green-600)`, etc.) for status-coded colors. The
sure-design-system already exposes the matching semantic tokens
(`text-warning`, `text-success`, `--color-success`, `--color-warning`),
which theme-swap correctly in dark mode and survive palette renames
without view edits.
- `ring_color`: collapse `:reached` / `:on_track` to `--color-success`
(the status pill already differentiates them via icon — completed star
vs check) and `:behind` to `--color-warning`. The `:no_target_date`
fallback keeps `--color-gray-400` for now since there's no semantic
neutral token; that gets cleaned up alongside the DS::ProgressRing
extraction.
- `_status_callout`: drop `text-yellow-700 theme-dark:text-yellow-300`
and `text-green-700 theme-dark:text-green-300` for the equivalent
semantic `text-warning` / `text-success` utilities.
No visual regression in light mode (success collapses two adjacent
greens into one); dark mode now properly inverts via the design
system's theme variants instead of hand-rolled overrides.
The `stroke="var(--budget-unused-fill)"` track on the inline card ring
stays for now — that's a token-rename refactor that touches budget
code outside this PR's scope and lands cleanest with the DS::ProgressRing
primitive that consolidates the three ring implementations.
* feat(design-system): DS::Disclosure :card variant + migrate 14 provider items
Resolves part of #1715 §6. The provider-item view templates
(binance, brex, coinbase, coinstats, enable_banking, ibkr,
indexa_capital, kraken, lunchflow, mercury, plaid, simplefin,
snaptrade, sophtron — 14 in total) all hand-rolled the same
`<details open class="group bg-container p-4 shadow-border-xs
rounded-xl">` shell with a custom summary inside and content below.
Extend `DS::Disclosure` with a `:card` variant that bakes the card
chrome onto the `<details>` element itself; the summary becomes
slot-driven via the existing `summary_content` slot. Provider items
keep their custom summary content (logos, brand colors, status copy)
unchanged — they just hand it to the slot instead of writing it
between `<summary>` tags.
API:
DS::Disclosure.new(variant: :card, open: true) do |d|
d.with_summary_content do
<div class="flex items-center gap-2">
chevron + custom summary markup
</div>
end
body content
end
While here:
- Drop the no-op `group-open:transform` from the default chevron
(Tailwind v4 applies `rotate-90` directly).
- Add `motion-safe:transition-transform motion-safe:duration-150`
to chevron rotation for reduced-motion respect (matches the
pattern landing in #1841).
- Extract `summary_classes` / `details_classes` helpers so the
default and card surfaces stay readable side-by-side.
Note: this PR touches `DS::Disclosure` and will textually conflict
with #1841 (focus-ring + reduced-motion polish). Both changes are
compatible — when #1841 merges first, the resolution is just
preserving both edits (the focus-ring classes are already merged
into `summary_classes` here).
* feat(design-system): migrate 3 provider panels to DS::Disclosure :card variant
Resolves the panel slice of #1715 §6. Continuation of the
DS::Disclosure :card variant work — same migration pattern, applied
to the 3 provider-PANEL templates that share the card shape with the
provider-item templates landing on the parent branch.
Migrated `<details class="group bg-container p-4 shadow-border-xs
rounded-xl">` → `DS::Disclosure.new(variant: :card)` in:
- `app/views/settings/providers/_kraken_panel.html.erb` — 1 details
in the items-each loop.
- `app/views/settings/providers/_mercury_panel.html.erb` — 1 details
in the items-each loop.
- `app/views/settings/providers/_brex_panel.html.erb` — 2 details:
one in the items-each loop, one standalone "add connection" panel
that opened by default when no active items existed. The
conditional `<%= "open" unless active_items.any? %>` becomes
`open: active_items.none?` on the `:card` disclosure.
Panels do NOT show a chevron in their summary (different UX from
the per-item rows in #1855), so the migration preserves that — no
chevron inserted.
NOT migrated (intentionally — different shapes):
- `_ibkr_panel.html.erb` — `<details class="group bg-surface-inset
rounded-xl p-4">`. Uses bg-surface-inset, not bg-container — needs
a `:card-inset` variant we haven't built. Deferred.
- `_indexa_capital_panel.html.erb` — `<details class="group">` with
no card chrome. Inline expander; doesn't fit either disclosure
variant.
- `_snaptrade_panel.html.erb` — same inline pattern as indexa_capital.
* fix(review): use ring-alpha-black-300 focus token in DS::Disclosure
CodeRabbit P2: switch the focus-visible outline from raw
gray-900/white palette values to the alpha-black-300 ring token,
matching the established focus pattern on settings/provider_card.html.erb.
This keeps theme behavior centralized in the design system tokens
instead of branching on theme-dark: in the component.
Applies to both :default and :card summary variants.
* fix(review): stretch DS::Disclosure summary_content to full width
Codex P2 follow-up on the disclosure-migration stack: \`<summary>\` is
\`display: list-item\`, so a flex inner div inside the slot
shrink-wraps to content width — any \`justify-between\` the caller
adds has nothing to distribute, and the right-side admin actions
collapse toward the title across every provider-item partial migrated
to \`DS::Disclosure variant: :card\` in #1855 (and the panels in
#1856 / #1857 / #1858 that inherit this component).
Wrap the slot in \`<div class=\"w-full\">\` so caller-supplied flex
rows stretch across the card. \`:default\` variant is unchanged
(it never uses \`summary_content\`).
* fix(review): stretch :card summary flex row to full width
Codex P2 follow-up on #1856: the migrated kraken / mercury / brex
panel summary rows wrap their content in
\`<div class=\"flex items-center justify-between gap-X\">\`, but a
flex container inside \`<summary>\` (\`display: list-item\`)
shrink-wraps to content size, so \`justify-between\` had nothing to
distribute and the right-side admin actions collapsed toward the
title.
Add \`w-full\` so the flex row stretches across the card. The deeper
component-level fix lands in #1855 (wraps \`summary_content\` in a
\`w-full\` block); this commit makes #1856 self-contained against the
merge order.
* feat(design-system): DS::Disclosure :card variant + migrate 14 provider items
Resolves part of #1715 §6. The provider-item view templates
(binance, brex, coinbase, coinstats, enable_banking, ibkr,
indexa_capital, kraken, lunchflow, mercury, plaid, simplefin,
snaptrade, sophtron — 14 in total) all hand-rolled the same
`<details open class="group bg-container p-4 shadow-border-xs
rounded-xl">` shell with a custom summary inside and content below.
Extend `DS::Disclosure` with a `:card` variant that bakes the card
chrome onto the `<details>` element itself; the summary becomes
slot-driven via the existing `summary_content` slot. Provider items
keep their custom summary content (logos, brand colors, status copy)
unchanged — they just hand it to the slot instead of writing it
between `<summary>` tags.
API:
DS::Disclosure.new(variant: :card, open: true) do |d|
d.with_summary_content do
<div class="flex items-center gap-2">
chevron + custom summary markup
</div>
end
body content
end
While here:
- Drop the no-op `group-open:transform` from the default chevron
(Tailwind v4 applies `rotate-90` directly).
- Add `motion-safe:transition-transform motion-safe:duration-150`
to chevron rotation for reduced-motion respect (matches the
pattern landing in #1841).
- Extract `summary_classes` / `details_classes` helpers so the
default and card surfaces stay readable side-by-side.
Note: this PR touches `DS::Disclosure` and will textually conflict
with #1841 (focus-ring + reduced-motion polish). Both changes are
compatible — when #1841 merges first, the resolution is just
preserving both edits (the focus-ring classes are already merged
into `summary_classes` here).
* fix(review): use ring-alpha-black-300 focus token in DS::Disclosure
CodeRabbit P2: switch the focus-visible outline from raw
gray-900/white palette values to the alpha-black-300 ring token,
matching the established focus pattern on settings/provider_card.html.erb.
This keeps theme behavior centralized in the design system tokens
instead of branching on theme-dark: in the component.
Applies to both :default and :card summary variants.
* fix(review): stretch DS::Disclosure summary_content to full width
Codex P2 follow-up on the disclosure-migration stack: \`<summary>\` is
\`display: list-item\`, so a flex inner div inside the slot
shrink-wraps to content width — any \`justify-between\` the caller
adds has nothing to distribute, and the right-side admin actions
collapse toward the title across every provider-item partial migrated
to \`DS::Disclosure variant: :card\` in #1855 (and the panels in
#1856 / #1857 / #1858 that inherit this component).
Wrap the slot in \`<div class=\"w-full\">\` so caller-supplied flex
rows stretch across the card. \`:default\` variant is unchanged
(it never uses \`summary_content\`).
* feat(design-system): add DS::SearchInput + migrate 2 broken-focus callsites
Resolves#1715 §3.
Two standalone search-field callsites — `/settings/preferences`
currency filter and `/settings/providers` filter row — had a hand-
rolled markup that ended in `focus:ring-gray-500`. That utility has
no backing token in the design system (`ring-gray-500` isn't in
Tailwind's default + Sure doesn't register a gray ring color), so
the input rendered with zero focus indicator on a bordered
bg-container surface. Keyboard users couldn't tell when the field
was focused.
Introduce `DS::SearchInput` — icon-on-left, bordered, token-backed
focus ring matching the DS::Button pattern landing in #1840
(`outline-2 outline-offset-2 outline-gray-900` with the dark-mode
override). API:
DS::SearchInput.new(
name: "...",
placeholder: "...",
value: ...,
aria_label: "...", # defaults to placeholder
class: "...", # passed to the wrapper
**opts # spread onto the <input>, e.g. data-*
)
Migrate the two broken callsites. Three other "search" patterns
stay as-is (out of scope for this PR):
- `form.search_field :search` inside `styled_form_with` blocks
(accounts/show/_activity.html.erb, UI::Account::ActivityFeed) —
already routes through StyledFormBuilder's form-field CSS.
- Embedded-dropdown search input inside DS::Select, DS::Menu, and
the splits/category-select panels — uses a different shape
(no border, no ring) because the parent panel provides the chrome.
- Category dropdown's combobox search input
(app/views/category/dropdowns/show.html.erb) — has a custom
`role=combobox` flow and stays intentionally distinct.
* feat(design-system): add embedded variant to DS::SearchInput, migrate 2 more callsites
Adds `variant: :embedded` to `DS::SearchInput` for search inputs that
live *inside* another DS panel (DS::Select dropdown, splits category
filter, future DS::Popover-hosted filters). No own border / no own
focus ring — the parent panel provides the chrome, so adding ring
+ outline competes with its `focus-within` state.
API:
DS::SearchInput.new(variant: :embedded, placeholder: "...", data: {...})
The `:standalone` default (from the previous commit) stays unchanged
and remains the right choice for top-of-list filter inputs.
Migrated:
- `app/components/DS/select.html.erb` — the in-dropdown search input
for `DS::Select.new(searchable: true)`. Was the only remaining
internal raw <input type="search"> markup in the component.
- `app/views/splits/_category_select.html.erb` — split-transaction
category picker filter. Same shape as DS::Select's search but
hand-rolled because the picker isn't a vanilla DS::Select.
Three other search patterns stay out of scope (intentionally, per
the previous commit):
- `form.search_field :search` inside `styled_form_with` — uses
form-field CSS, different visual contract.
- `app/views/category/dropdowns/show.html.erb` — bespoke
`role="combobox"` flow with `aria-expanded` / `aria-autocomplete`
semantics that don't belong in this primitive.
* fix(review): mobile font + embedded variant focus-within ring
- DS::SearchInput: switch text-sm -> text-base sm:text-sm on both
variants so the input keeps its 16px base size on mobile. iOS
Safari zooms the viewport when a focused input is below 16px,
which the unconditional text-sm was triggering on the Settings
Preferences currency search and Settings Bank Sync provider
search.
- DS::Select (searchable variant) + splits/_category_select:
add focus-within:ring-4 focus-within:ring-alpha-black-200
(with theme-dark variant) on the wrapper around the embedded
search input. The embedded variant intentionally has no own
focus ring so it inherits chrome from its parent panel — but
the two current parent panels were not providing one, so
keyboard focus on the dropdown search box rendered with no
visible indicator. Ring matches the .form-field token used
across the design system.
* fix(merge): repair DS::Select search input merge resolution
The previous merge of main left invalid Ruby inside the DS::SearchInput
`data:` hash:
aria-label="<%= t("helpers.select.search_placeholder") %>"
This is an ERB string assignment masquerading as a hash entry — it does
not parse and would have raised SyntaxError at render. Two follow-ups:
- Drop the `aria-label` entry entirely. `DS::SearchInput` already
defaults `aria_label` to `placeholder`, and `placeholder` is set
on the call, so the resulting <input> already carries
`aria-label="<%= t(...) %>"`.
- Restore the `input->select#syncTabindex` action that main #1848
added on the embedded search input. It keeps the roving tabindex
on the listbox in sync as filtered results change. Original PR
branch had only `list-filter#filter`; reintegrate both with
explicit `input->` event prefixes for parity with main.
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Remote branch added a beta_gated_nav_item helper + 'Gating the main nav'
docs section. Main concurrently renamed the beta-features gate to
preview-features (concern, predicate, JSONB key, locale flash). Rename
the new helper / partial local / pill marker to match preview naming and
port the nav-gating docs into gating-a-preview-feature.md so the
improvement survives the rename.
Resolved conflicts:
- db/schema.rb: take the later schema version (2026_05_19_100000).
- docs/llm-guides/gating-a-beta-feature.md: accept main's deletion;
port the 'Gating the main nav' section into the preview guide.
Renames carried through to keep the gate wired end-to-end:
- application_helper.rb: beta_gated_nav_item → preview_gated_nav_item;
beta_features_enabled? → preview_features_enabled?; beta: → preview:.
- _nav_item.html.erb: beta: local → preview: local; shared.beta i18n
key → shared.preview.
- application.html.erb: caller renamed to preview_gated_nav_item.
- goals/index.html.erb: pill label uses shared.preview.
- shared/en.yml: 'beta: Beta' → 'preview: Preview'.
- goals_controller, goal_pledges_controller: require_beta_features! →
require_preview_features!.
- goals_controller_test, goal_pledges_controller_test: flip the
preference key, flash matcher, and test names to 'preview'.
* feat(dashboard): zoom into cashflow sankey categories
Click a category node on the dashboard cashflow Sankey to focus on it and
its descendants only; a back button restores the full view. Clicking the
Cash Flow node zooms to the expense (outbound) side.
- Pure utility (app/javascript/utils/sankey_zoom.js) computes the
descendant subgraph from a clicked node, with direction inferred by
reachability from the cash flow node (outbound for expense, inbound
for income).
- Stable node ids emitted from the controller so the JS can identify
nodes across re-renders.
- Stimulus controller adds chart + zoomOutButton targets, fade
transition, and only sets a pointer cursor when a node has children.
- Node:test coverage for expense, income, cash-flow, and malformed-data
cases; \"type\": \"module\" added to package.json so the .js util is
ESM-compatible under Node.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(dashboard): extract cashflow sankey chart partial
Deduplicate sankey chart markup between inline and expanded dialog views,
and reset zoom state when chart data changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(js): rename sankey_zoom util to .mjs to drop project-wide ESM flag
Removes "type": "module" from package.json to avoid implicitly switching
every .js file in the project to ESM (a future footgun for any .js config
file added by Biome, Vite, etc.). Renames the utility to .mjs so node --test
can import the ES module directly, and adds an explicit importmap pin since
pin_all_from only globs .js/.jsm.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(assets): register .mjs MIME type for Propshaft
Propshaft derives Content-Type from Mime::Type.lookup_by_extension, which
returns nil for :mjs by default. Browsers refuse to execute ES modules
served with an empty Content-Type, breaking the sankey_zoom util loaded
via importmap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(design-system): split DS::Menu into strict action-list + new DS::Popover for mixed content
Closes#1743.
DS::Menu used to absorb both action-list dropdowns (row context menus,
"more actions") AND mixed-content panels (user-account dropdown,
filter forms, picker pop-ups). The two shapes carry incompatible a11y
contracts:
- **Action list**: `role="menu"` container, `role="menuitem"` children,
Up/Down arrow nav per WAI-ARIA APG.
- **Mixed content**: NO menu role — `role="menu"` restricts AT users
to menuitem-only navigation and breaks any panel with forms,
headings, or generic groupings.
This PR splits the component:
## DS::Menu (tightened)
Strict action-list primitive. Variants reduced to `:icon` and
`:button` (no `:avatar`). `custom_content` slot removed. Bakes in:
- `role="menu"` on the panel, `aria-haspopup="menu"` +
`aria-expanded` + `aria-controls` on the trigger.
- `role="menuitem"` + `tabindex="-1"` on every DS::MenuItem; the
controller installs roving tabindex (first item gets `tabindex="0"`
when the menu opens) and handles ArrowUp/Down/Home/End +
Escape + Enter/Space activation.
- `role="separator"` on the divider variant.
- Stable per-instance `menu-<8-char hex>` id so the trigger's
`aria-controls` resolves correctly.
`DS::Menu.new(variant: :avatar, ...)` now raises ArgumentError
pointing at DS::Popover.
## DS::Popover (new)
Positioned panel for **mixed**, **non-action-list** content: account
menus, picker forms, filter forms, embedded controls. Slots: `button`,
`header`, `custom_content`. Variants: `:icon`, `:button`, `:avatar`.
NO `role="menu"` — the panel announces as a generic dialog-popup
(`aria-haspopup="dialog"`, `aria-expanded`, `aria-controls`).
Mirrors DS::Menu's floating-ui positioning + Escape/outside-click
lifecycle in its own Stimulus controller (`DS--popover`). Avatar
variant ships a focus ring + bumped touch target (44×44 via `w-11
h-11` per #1738).
## Migrated callsites (7 → DS::Popover)
- `app/views/users/_user_menu.html.erb` — avatar trigger + profile
header + nav links (items kept as DS::MenuItem inside
`custom_content` for visual parity)
- `app/views/categories/_menu.html.erb` — turbo-framed category picker
- `app/views/budgets/_budget_header.html.erb` — budget picker
- `app/views/reports/index.html.erb` — period picker
- `app/views/holdings/_cost_basis_cell.html.erb` — cost-basis edit form
- `app/views/transactions/searches/_form.html.erb` — filter form
- `app/components/UI/account/activity_feed.html.erb:70` — status
checkboxes (the row-level "new" menu on line 9 stays as DS::Menu)
The other 33 DS::Menu callsites stay as-is — pure action lists.
Locale: `ds.popover.avatar_default_label` + `users.user_menu.aria_label`
keys added (en only; other locales handled in a separate i18n pass).
* fix(test): update sidebar user-menu selector for Menu→Popover migration
The user-menu now renders as `DS::Popover` (variant: :avatar) instead
of `DS::Menu` after the menu split, so its trigger carries
`data-DS--popover-target="button"` rather than the old
`data-DS--menu-target`. Update the sidebar-driven settings test helper
to match — every system test that drives Settings via the sidebar
gates on this selector.
* fix(review): DS::Popover/Menu trigger a11y + caller-attr preservation
- popover.rb / menu.rb: button slot now merges (not overwrites) caller-
provided data and aria hashes, sets aria-haspopup/expanded/controls on
the :button variant, defaults type="button" on block-rendered buttons.
- menu.rb / menu.html.erb: drop renders_one :header (strict-menu API
shouldn't expose an arbitrary-markup escape hatch); preview updated.
- menu_controller.js: handle Enter/Space activation on focused menuitem
so keyboard navigation matches the ARIA menu pattern.
- cost_basis_cell / transactions/searches/_menu: retarget cancel button
data-action from DS--menu#close to DS--popover#close (host controller
changed in the migration).
* fix: apply CodeRabbit auto-fixes
Fixed 1 file(s) based on 1 unresolved review comment.
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
* fix(review): MenuItem roving: false for DS::Popover usage
Codex P1 on #1850: \`DS::MenuItem\` hard-codes \`tabindex=\"-1\"\` and
\`role=\"menuitem\"\` for both link and button variants — correct
inside \`DS::Menu\` (which provides arrow-key roving and announces
\`role=\"menu\"\`), but breaks every \`DS::MenuItem\` rendered inside
\`DS::Popover\` (\`app/views/users/_user_menu.html.erb\`). Popover has
no roving handler, so Tab skips every item — Settings, Changelog,
Feedback, Contact, Log out become keyboard-unreachable.
Add a \`roving:\` keyword (default \`true\`) to \`DS::MenuItem\` that
gates both \`tabindex=\"-1\"\` and \`role=\"menuitem\"\`. \`DS::Menu\`
callers keep the default (roving menu semantics intact). Pass
\`roving: false\` from \`_user_menu.html.erb\` so user-menu items land
in the normal Tab order. Existing \`menu.with_item(...)\` callers in
the design system still default to \`true\`, so no behavior change for
\`DS::Menu\` consumers.
* fix(review): make menuitem_attrs authoritative on roving
CodeRabbit Major on #1850: \`merged_opts\` was splatted AFTER
\`menuitem_attrs\` in \`DS::MenuItem#wrapper\`, so a stray
\`role: :button\` or \`tabindex: 0\` from a \`menu.with_item(..., role: …)\`
caller could silently downgrade the \`DS::Menu\` ARIA contract that
\`menuitem_attrs\` enforces.
Strip \`:role\` and \`:tabindex\` from \`merged_opts\` whenever
\`roving\` is enabled, then splat \`menuitem_attrs\` last. When
\`roving: false\` (popover usage in \`_user_menu.html.erb\`) callers
keep full control — Tab order and explicit ARIA stay tunable by the
caller.
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* refactor(design-system): migrate 9 hand-rolled buttons with orphan btn-- classes to DS::Button / DS::Link
Part of #1715 §5. The `btn`, `btn--primary`, `btn--outline`, `btn--ghost`,
`btn--sm` CSS classes have no backing styles anywhere in the codebase
(no .btn definition in app/assets/, no Bootstrap dependency). These
callsites have been rendering unstyled buttons / links since the
underlying CSS was last removed.
Migrate the 9 broken callsites:
- `app/views/transactions/show.html.erb` — duplicate-merge action
buttons (×2): `button_to ... class: "btn btn--primary btn--sm"` /
`class: "btn btn--outline btn--sm"` → DS::Button with href +
variant + size + `data: { turbo_method: :post }`.
- `app/views/snaptrade_items/select_existing_account.html.erb` —
"Go to Provider Settings" link → DS::Link primary sm.
- `app/views/indexa_capital_items/select_existing_account.html.erb` —
same pattern → DS::Link primary sm.
- `app/views/import/confirms/show.html.erb` — Publish button +
Cancel link → DS::Button primary full-width + DS::Link ghost
full-width.
- `app/views/simplefin_items/new.html.erb` — Cancel link
(`class: "btn"` only) + Connect submit → DS::Link secondary +
bare `f.submit` (already routes to DS::Button via
StyledFormBuilder).
- `app/views/settings/providers/_ibkr_panel.html.erb`,
`_snaptrade_panel.html.erb`,
`_indexa_capital_panel.html.erb` — strip the orphan
`class: "btn btn--primary"` from `f.submit` callers; the submit
is already a styled DS::Button via the form builder.
The next PR in this chain (Phase B) will tackle the larger inline-
button cluster (~29 files, 38 instances) — provider panels and
provider-item flows hand-rolling the same
`inline-flex items-center justify-center rounded-lg px-4 py-2
text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover
focus:outline-none focus:ring-2 focus:ring-primary transition-colors`
string.
* fix(review): render DS::Button for unstyled submits in PR #1859
- simplefin_items/new.html.erb uses plain form_with (not
styled_form_with), so f.submit was rendering a bare browser submit
input. Render DS::Button with type: :submit explicitly.
- _indexa_capital_panel.html.erb already uses styled_form_with;
strip the orphan Tailwind class string from f.submit so
StyledFormBuilder fully owns the DS::Button styling (matches the
IBKR and SnapTrade panel pattern).
Addresses Codex and CodeRabbit feedback on #1859.
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
* refactor(design-system): migrate single-color semantic tokens to @theme + lint @utility /N footgun
Closes#1653. Tailwind v4 auto-generates the `/N` opacity-modifier
pipeline (`color-mix(in oklab, var(--color-X) N%, transparent)`)
only for colors declared in `@theme`. Tokens emitted as
`@utility name { @apply ... }` bypass that pipeline entirely, so
`text-link/70`, `bg-surface/50`, etc. silently compile to nothing —
the workaround from #1626 was `text-inverse opacity-70`.
Migrate the 11 single-color semantic tokens whose class names match
Tailwind's color-utility convention (`bg-X`, `text-X`, `border-X`)
and have no cross-prefix collision:
bg-surface, bg-surface-hover, bg-surface-inset, bg-surface-inset-hover
bg-container, bg-container-hover, bg-container-inset, bg-container-inset-hover
bg-nav-indicator
text-link
border-tertiary
After migration, `--color-surface`, `--color-container`, etc. live
in `@theme` and Tailwind auto-generates every prefix variant
(`bg-surface`, `text-surface`, `border-surface`, plus
`/10`..`/100`). The original utility class names are preserved
(now via auto-generation instead of `@utility` blocks), so every
existing callsite continues to work.
NOT migrated, by design:
- **inverse family** (`bg-inverse`, `text-inverse`, `bg-inverse-hover`,
`border-inverse`): bg- and text- variants have *different* colors,
cannot share one `--color-inverse`. Renaming the family
(`bg-strong-surface` + `text-on-strong-surface`) would touch
~61 view files and trade one footgun for semantic loss; deferred
until a concrete `bg-inverse/N` use case appears.
- **primary/secondary/subdued/destructive** (cross-prefix collision):
`text-primary` (gray.900) and `border-primary` (alpha-black.300)
carry deliberately distinct values, can't share `--color-primary`.
Same for the secondary/subdued pairs. Migrating either alone
would force a rename of the other.
- **button-bg-*, tab-item-*, tab-bg-group**: class names don't
follow Tailwind's `<prefix>-<name>` convention, so
auto-generation would emit `bg-button-bg-primary` not
`button-bg-primary`.
- **composites** (`bg-loader`, `bg-overlay`, `shadow-border-*`,
`border-divider`): compile to multiple properties or
alias-reference other utilities — must stay as @utility.
Add an `erb_lint` DeprecatedClasses rule covering the
@utility-only tokens with `\d+` regex modifiers so any future
`text-inverse/70` etc. fails CI with the explanation that
`opacity-N` is the workaround and #1653 is the tracking issue.
Verified the rule fires on synthetic input; verified zero new
violations on the existing app.
Stats: `@utility` blocks dropped from 45 → 34; @theme primitives
grew from 183 → 194.
* fix(review): cover remaining @utility /N footgun tokens in erb_lint
CodeRabbit flagged that the new DeprecatedClasses /N rule missed
seven still-defined @utility color tokens: border-destructive,
border-solid, button-bg-secondary-strong, button-bg-secondary-strong-hover,
button-bg-disabled, button-bg-ghost-hover, button-bg-outline-hover.
Without them, classes like button-bg-disabled/50 pass lint while
Tailwind silently drops the class.
Adding the patterns surfaced two pre-existing offenders
(border-destructive/30, border-destructive/20). Swap both to solid
border-destructive — the @utility override defines red-500 (light)
while --color-destructive in @theme is red-600, so the /N modifier
was rendering an off-shade rather than the intended faded variant.
Verified the rule fires on synthetic input for all seven new
patterns, then verified zero remaining violations on the new
patterns across app/**/*.erb.
* chore(erb_lint): add trailing newline to .erb_lint.yml
Per review feedback on #1849. Some editors flag the missing newline;
keeps style consistent with the rest of the codebase.
* fix(design-system): DS::Tooltip a11y — focusable trigger, keyboard parity, Esc dismiss
Closes#1747. Five fixes on the tooltip primitive.
1. **Tooltip anchor not in a11y tree.** The trigger was a bare
Lucide icon, which Lucide renders with `aria-hidden="true"`.
The tooltip target had `role="tooltip"` but nothing referenced
it, so AT users had no way to discover the description. Wrap
the icon in a focusable `<button type="button">` with
`aria-describedby="<tooltip-id>"` so the underlying icon stays
`aria-hidden` and the button picks up the description binding.
2. **Stable per-instance id.** Each DS::Tooltip now mints a
`tooltip-<8-char hex>` id wired between the trigger's
`aria-describedby` and the tooltip's `id`.
3. **Keyboard parity.** Hover-only triggers locked keyboard-only
users out. Add `focusin` / `focusout` listeners on the
controller element so Tab onto the trigger reveals the
tooltip, Tab away dismisses it.
4. **Esc-to-dismiss.** Matches the WAI-ARIA tooltip pattern.
`Escape` while the tooltip is open closes it without removing
focus from the trigger.
5. **Resize-safe width cap.** Replace the hard-coded
`max-w-[200px]` with `max-w-[20rem]` so the tooltip scales
with the user's root font-size setting (large-text accessibility
pref). Slightly wider visual cap (320px @ default) but no longer
clips on text-zoom.
Plus: docstring note that tooltip content must be non-interactive
(no buttons / links / form controls inside) — `aria-describedby`
exposes content as a description, not as an interactive subtree.
Callers needing actions should reach for a popover/menu primitive.
API unchanged. Existing 30+ DS::Tooltip callsites work without
modification — they all pass `text:`-only payloads, which still
render correctly under the new markup.
* fix(review): as: option + alpha focus-ring on DS::Tooltip
Addresses two AI review findings on #1845:
1. **Button-inside-summary spec violation.** Wrapping the icon in
`<button>` regressed keyboard/AT behavior at 13 callsites where
DS::Tooltip lives inside a `<summary>` (8 provider items, lunchflow
disclosure, activity_date, 4 simplefin badges). HTML's content
model forbids interactive content inside `<summary>`; browsers
and AT can drop focus or conflate activation with the disclosure
toggle. Add `as:` parameter — default `:button` preserves the
standalone a11y wrap; `:span` renders a non-focusable wrapper for
summary-nested usage. `focusin` bubbles up to the controller from
the ancestor `<summary>`, so keyboard tooltips still appear on
tab. Migrate the 13 in-summary callsites to `as: :span`.
2. **Raw palette focus ring → alpha tokens.** Swap
`outline-gray-900 theme-dark:focus-visible:outline-white` to the
established focus-ring pattern `focus-visible:ring-2
focus-visible:ring-alpha-black-300
theme-dark:focus-visible:ring-alpha-white-300` — matches the
DS::Toggle fix landed in #1843 review and provider_card /
form-field tokens.
* fix(review): bind tooltip focus on ancestor <summary>
Codex P2 follow-up on #1845: \`as: :span\` renders a non-focusable
trigger inside the disclosure \`<summary>\`. Keyboard users hit Tab
and focus lands on the summary itself; \`focusin\` fires on the
summary and bubbles UP — never down to a descendant span — so the
existing listener on \`this.element\` never fires and the tooltip
stays hidden for keyboard-only users on every in-summary row
(provider _item partials, lunchflow disclosure, activity_date,
simplefin badges). My earlier reply that the focusin "bubbles up to
the Stimulus controller on the outer span" was wrong about the
direction; \`focusin\` only bubbles upward.
In \`addEventListeners\`, resolve \`this.element.closest("summary")\`
and bind \`focusin\` / \`focusout\` / \`keydown\` on it too. Track the
ancestor on the controller and undo the bindings in
\`removeEventListeners\` so reconnect-on-Turbo cycles don't leak.
Update the template comment to reflect the actual mechanism.
* docs(ds-tooltip): correct as=:span comment to match controller mechanism
---------
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>