From 52a1488be6bf91a0b40990e63e53cd039b4cdfea Mon Sep 17 00:00:00 2001 From: gia Date: Wed, 29 Jul 2026 02:19:13 +0000 Subject: [PATCH 01/28] docs(casadesign): open part 2 with the follow-ups carried over from #7051 Starting point for the part-2 tracking PR. Five items, each verified still open against main rather than copied forward from notes: - the 3 xit'd flaky case-contact system specs, which need a tracking issue before they can be re-enabled - two view specs still raising SystemStackError from the settings rail calling edit_casa_org_path(current_organization) unstubbed (pre-existing, still red) - sort still living on the filterrific form, which is why Clear filters has to carry sorted_by by hand - mailers naming the chapter inconsistently (@casa_organization vs @casa_org vs nothing on Devise) - contact types on the case-contact form staying an exposed checkbox fieldset, with the reason it is deliberate Dropped one item while checking: the read-notification icon is already slate-500, so its contrast was fixed in the merged work and is not outstanding. Co-Authored-By: Claude Opus 5 (1M context) --- design-todo.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/design-todo.md b/design-todo.md index f4de5a3057..a9baaa06e0 100644 --- a/design-todo.md +++ b/design-todo.md @@ -572,3 +572,32 @@ CSV/XLSX exports, `preference_sets`, `android_app_associations`, and the `api/*` court date the right thing to surface on the main roster, or do they want the upcoming hearing's type/judge (sourced from court dates)? The answer decides whether to delete the dead `CasaCase` associations. + +## Part 2 — carried over from the casadesign merge (#7051) + +Verified still open against `main` at the time of writing. + +- [ ] **Re-enable 3 flaky case-contact system specs.** `edit_spec` "successfully edits case contact", + `contact_topic_answers` "removes an answer when its topic is unchecked", and `case_contacts_new_design` + "hides drafts when Hide drafts is checked" are `xit`'d with a FLAKY comment. They are non-deterministic + Selenium timing, not order-dependent state: the same seed passes and fails across runs, and each passes + in isolation. Per the repo policy they need a **GitHub tracking issue** whose number goes into the three + comments, then `xit` -> `it` once the race is fixed. +- [ ] **Two view specs raise `SystemStackError`.** `spec/views/mileage_rates/index.html.erb_spec.rb:13` + and `spec/views/casa_admins/admins_table.html.erb_spec.rb:4`. Both templates render + `casa_org/_settings_frame` -> `_settings_rail`, which calls `edit_casa_org_path(current_organization)`; + `current_organization` is not stubbed in a view spec and recurses. Pre-existing, unrelated to the + design work, and still red on `main`. +- [ ] **Sort is not a filter, but still lives on the filterrific form.** `Clear filters` therefore has + to carry `sorted_by` through by hand (`clear_filters_path`) so clearing does not silently reorder the + list. Moving the sort control off the filterrific form would remove that coupling — it is the last + standards gap in the filter bar. +- [ ] **Emails do not show the chapter name consistently.** `layouts/mailer` prints it only when a + mailer sets `@casa_organization`. Devise mailers set nothing and `learning_hours_mailer` sets + `@casa_org`, so those fall back to the generic "Court Appointed Special Advocate (CASA)". Either set + `@casa_organization` everywhere or have the layout also read `@casa_org` / the resource's org. + Whether Devise emails *should* name the org is a product call. +- [ ] **Contact types on the case-contact form are still an exposed grouped checkbox fieldset**, while + the filter and `casa_cases` new/edit use the searchable multiselect. Deliberate: it is that page's + primary required input and each row carries a per-type recency hint that does not survive collapsing + into chips. Revisit only if the recency hint can move into option subtext (as `casa_cases` does). From 13e959c8301517db14d56998f26dbe81acfd826e Mon Sep 17 00:00:00 2001 From: gia Date: Wed, 29 Jul 2026 13:16:58 +0000 Subject: [PATCH 02/28] fix(casadesign): autosave the whole form, not three fields Editing a case contact could silently lose work. Autosave was bound per field, to notes, topic answers and expense descriptions only, so an edit to duration or medium was dropped on navigation -- UNLESS the user also happened to touch one of those three, because an autosave posts the entire form and therefore committed everything. Whether your work persisted depended on which field you touched last, with no feedback either way. Measured before: edit the duration, wait past the debounce, click Back -> old value; edit the duration then type one character in notes -> both saved. Bound once on the
instead: `input` bubbles and fires for every control type, so text, number, date, select, checkbox and radio all save, and a field added later cannot be forgotten. The three per-field triggers are now redundant and removed. Verified each control type saves on its own and survives leaving: duration 60 -> 210 with nothing else touched and still 210 after Back; a radio choice; a contact- type checkbox. Also verified checking a topic -- which now triggers an autosave too -- still yields exactly one ContactTopicAnswer, i.e. it does not race the create that stores the answer's id. This is why the form has no Cancel and no unsaved-changes warning: with everything autosaved there is nothing unsaved to warn about. The two coherent models are full autosave (Google Docs / Notion: navigation is the exit) and explicit save (GitHub / Jira: Cancel plus a dirty guard). A partly-autosaving form was neither. One measurement trap, documented: the "Saved!" alert lingers ~3s, so a second interaction in one example matches the previous save's alert and reads the DB before its own debounce elapses -- which made checkboxes look broken when they were not. The new specs do one interaction each and wait on the alert. case_contacts 148 examples 0 failures; the 3 new examples pass 3/3 repeat runs. standardrb + erb_lint clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../form/_contact_topic_answer.html.erb | 1 - app/views/case_contacts/form/details.html.erb | 13 ++++- .../shared/_additional_expense_form.html.erb | 1 - design.md | 21 ++++++++ spec/system/case_contacts/edit_spec.rb | 50 +++++++++++++++++++ 5 files changed, 82 insertions(+), 4 deletions(-) diff --git a/app/views/case_contacts/form/_contact_topic_answer.html.erb b/app/views/case_contacts/form/_contact_topic_answer.html.erb index d96f3da294..aca28c5d7d 100644 --- a/app/views/case_contacts/form/_contact_topic_answer.html.erb +++ b/app/views/case_contacts/form/_contact_topic_answer.html.erb @@ -18,7 +18,6 @@ rows: 4, disabled: answer.nil?, class: "contact-topic-answer-input block w-full rounded-lg border border-slate-300 px-3.5 py-2.5 text-sm text-slate-900 placeholder:text-slate-500 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 focus:outline-none", - data: {action: "input->autosave#save"}, "aria-label": "Notes for #{topic.question}", placeholder: "Enter discussion notes here to be included in the court report. Refer to individuals by role, not by name." ) %> diff --git a/app/views/case_contacts/form/details.html.erb b/app/views/case_contacts/form/details.html.erb index 03e761c50d..fc31689aab 100644 --- a/app/views/case_contacts/form/details.html.erb +++ b/app/views/case_contacts/form/details.html.erb @@ -56,7 +56,16 @@ novalidate: true, # case-contact-id marks the form as backed by a saved record, so the autosave PATCHes it # instead of creating. Absent on a new contact until the first save adopts an id. - data: {controller: "case-contact-form", "autosave-target": "form", "case-contact-id": @case_contact.id}, + # + # The autosave is bound HERE, on the form, not on individual fields. `input` bubbles and fires + # for every control type -- text, number, date, select, checkbox, radio -- so one action covers + # the whole form and cannot be forgotten when a field is added. Per-field triggers meant only + # notes, topic answers and expense descriptions saved themselves, while an edit to duration or + # medium was silently dropped on navigation UNLESS the user happened to also touch one of those + # three (an autosave posts the entire form, so it committed everything). Whether your work + # persisted depended on which field you touched last. + data: {controller: "case-contact-form", action: "input->autosave#save", + "autosave-target": "form", "case-contact-id": @case_contact.id}, local: true ) do |form| %> @@ -248,7 +257,7 @@ <% if form.object.notes.present? || @contact_topics.empty? %>
<%= form.label :notes, "Additional notes", class: label_class %> - <%= form.text_area(:notes, data: {action: "input->autosave#save"}, rows: 5, class: input_class) %> + <%= form.text_area(:notes, rows: 5, class: input_class) %>
<% end %> diff --git a/app/views/shared/_additional_expense_form.html.erb b/app/views/shared/_additional_expense_form.html.erb index 7c9577a860..76c8c5d915 100644 --- a/app/views/shared/_additional_expense_form.html.erb +++ b/app/views/shared/_additional_expense_form.html.erb @@ -39,7 +39,6 @@ placeholder: "Enter other expense details", rows: 2, class: "expense-describe-input block w-full rounded-lg border border-slate-300 px-3.5 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-500 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 focus:outline-none", - data: {action: "input->autosave#save"}, **field_error_attrs(form.object, :other_expenses_describe, id: describe_error_id) ) %> <%= field_error(form.object, :other_expenses_describe, id: describe_error_id) %> diff --git a/design.md b/design.md index 7b79d226ed..53dcf68d90 100644 --- a/design.md +++ b/design.md @@ -596,6 +596,27 @@ select + Copy button with a Dialog confirm (the `copy-court-orders` controller P `copy_court_orders`, then reloads so the copied orders and the flash show). ### Autosave wizard form (case-contact) + +**Bind the autosave on the FORM, never per field.** `data-action="input->autosave#save"` on the +``: `input` bubbles and fires for every control type -- text, number, date, select, checkbox, +radio -- so one action covers the whole form and cannot be forgotten when a field is added. Per-field +triggers produced a genuinely confusing form: only notes, topic answers and expense descriptions saved +themselves, so an edit to duration or medium was **silently dropped** when the user navigated away -- +*unless* they also happened to touch one of those three, because an autosave posts the **entire** form +and therefore committed everything. Whether your work persisted depended on which field you touched +last. Measured before the fix: edit the duration, wait past the debounce, leave -> the old value; edit +the duration then type one character in notes -> both saved. + +Because the form autosaves in full, it needs **no Cancel and no unsaved-changes warning** -- the two +coherent models are "everything autosaves" (Google Docs / Notion / Linear: navigation is the exit) and +"explicit save" (GitHub / Jira: Cancel plus a `beforeunload` + `turbo:before-visit` guard when dirty). +This form is the first. A partially-autosaving form is neither, and is the state to avoid. + +**Testing an autosave: one interaction per example.** The "Saved!" alert lingers ~3s, so a second +interaction in the same example will match the PREVIOUS save's alert and let the assertion read the +database before its own 2s debounce has elapsed -- which reads as "checkboxes don't autosave" when they +do. Wait on the alert (`within "#contact-form-notes" { find 'small[role=alert]', text: "Saved!" }`), +then assert the record. The case-contact form (`case_contacts/form/details`, a Wicked single-step wizard) is the reference for a long **autosave** form on the shell. Render it by setting `layout "casa_app"` on the controller — `render_wizard` / `render step` pick it up, while the autosave JSON responses diff --git a/spec/system/case_contacts/edit_spec.rb b/spec/system/case_contacts/edit_spec.rb index 55899e1d15..db40d1dcb3 100644 --- a/spec/system/case_contacts/edit_spec.rb +++ b/spec/system/case_contacts/edit_spec.rb @@ -166,6 +166,56 @@ expect(case_contact.reload.notes).to eq "Hello world" end + # Autosave used to be bound to three fields only -- notes, topic answers, expense descriptions -- so + # an edit to duration or medium was silently dropped on navigation UNLESS the user also happened to + # touch one of those three (an autosave posts the WHOLE form, so it then committed everything). + # Whether your work persisted depended on which field you touched last. It is now bound once on the + # form, where `input` bubbles from every control type. + # + # ONE interaction per example on purpose: the "Saved!" alert lingers for 3s, so a second interaction + # can match the previous save's alert and read the DB before its own debounce has elapsed. + describe "autosave covers the whole form", :js do + let(:autosave_alert_div) { "#contact-form-notes" } + let(:autosave_alert_css) { 'small[role="alert"]' } + + def wait_for_autosave + within(autosave_alert_div) { find(autosave_alert_css, text: "Saved!", wait: 5) } + end + + it "saves an edit to a field that has no trigger of its own, and keeps it after leaving" do + visit edit_case_contact_path(case_contact) + + fill_in "case_contact_duration_hours", with: 3 + fill_in "case_contact_duration_minutes", with: 30 + wait_for_autosave + + expect(case_contact.reload.duration_minutes).to eq(210) + + click_on "Back" + + expect(page).to have_no_text("Editing existing case contact") + expect(case_contact.reload.duration_minutes).to eq(210) + end + + it "saves a radio choice" do + visit edit_case_contact_path(case_contact) + + choose "Letter" + wait_for_autosave + + expect(case_contact.reload.medium_type).to eq("letter") + end + + it "saves a checkbox" do + visit edit_case_contact_path(case_contact) + + check contact_types.second.name + wait_for_autosave + + expect(case_contact.reload.contact_types).to include(contact_types.second) + end + end + it "offers a way back out of the edit form" do visit edit_case_contact_path(case_contact) From 067bb859165ad5ddd1db72e01f85e69fb41c2c6e Mon Sep 17 00:00:00 2001 From: gia Date: Wed, 29 Jul 2026 13:57:01 +0000 Subject: [PATCH 03/28] fix(mileage rates): repair the 500 on edit and scope the record to the org `MileageRatesController` authorized the CasaAdmin *class* for new/create/edit/ update. Two problems: - `GET /mileage_rates/:id/edit` raised NoMethodError: undefined method 'casa_org' for class CasaAdmin. Pundit resolved the class to CasaAdminPolicy, whose #edit? is the one method there that reaches record.casa_org -- fine for a CasaAdmin instance, fatal for the class. The page 500'd for every admin in every org. #update survived only because CasaAdminPolicy aliases update? to index? (is_admin?, never touches the record). - Authorizing a class skipped the org check, and `set_mileage_rate` found by id unscoped, so an admin could edit another org's rate. Authorize the record against a new MileageRatePolicy (is_admin_same_org?) and scope the finder to current_organization. Behaviour is otherwise unchanged. The edit action had no request spec -- only a system spec asserting the "Edit" link's text, never following it -- which is why this shipped. Adds GET /edit coverage plus cross-org rejection for edit and update, and puts the update block's rate in the admin's org (those examples were passing *because* of the missing org check). Found while running a whole-app WCAG audit: the page's axe run reported document-title/html-has-lang/landmark-one-main/region, which is the signature of a layout-less Rails error page rather than an accessibility defect. Behind the crash the page audits clean. Co-Authored-By: Claude Opus 5 (1M context) --- app/controllers/mileage_rates_controller.rb | 12 ++++---- app/policies/mileage_rate_policy.rb | 13 ++++++++ spec/requests/mileage_rates_spec.rb | 33 +++++++++++++++++++-- 3 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 app/policies/mileage_rate_policy.rb diff --git a/app/controllers/mileage_rates_controller.rb b/app/controllers/mileage_rates_controller.rb index f584b16ad2..58b0c6ad79 100644 --- a/app/controllers/mileage_rates_controller.rb +++ b/app/controllers/mileage_rates_controller.rb @@ -11,13 +11,13 @@ def index end def new - authorize CasaAdmin @mileage_rate = current_organization.mileage_rates.build + authorize @mileage_rate end def create - authorize CasaAdmin @mileage_rate = MileageRate.new(mileage_rate_params.merge(casa_org: current_organization)) + authorize @mileage_rate if @mileage_rate.save redirect_to mileage_rates_path else @@ -26,11 +26,11 @@ def create end def edit - authorize CasaAdmin + authorize @mileage_rate end def update - authorize CasaAdmin + authorize @mileage_rate if @mileage_rate.update(mileage_rate_params) redirect_to mileage_rates_path @@ -45,7 +45,9 @@ def mileage_rate_params params.require(:mileage_rate).permit(:effective_date, :amount, :is_active) end + # Scoped to the org: an unscoped find let an admin reach another org's rate, and a 404 is the + # right answer there rather than leaving it to the policy alone. def set_mileage_rate - @mileage_rate = MileageRate.find(params[:id]) + @mileage_rate = current_organization.mileage_rates.find(params[:id]) end end diff --git a/app/policies/mileage_rate_policy.rb b/app/policies/mileage_rate_policy.rb new file mode 100644 index 0000000000..8e8a6da0c8 --- /dev/null +++ b/app/policies/mileage_rate_policy.rb @@ -0,0 +1,13 @@ +class MileageRatePolicy < ApplicationPolicy + # A mileage rate belongs_to :casa_org, so every action can -- and must -- check the record's org. + # The controller used to `authorize CasaAdmin`, passing the *class*: that skipped the org check + # entirely (any admin could edit any org's rate) and raised NoMethodError in `same_org?`, because + # CasaAdminPolicy#edit? is the one method there that reaches record.casa_org. + def new? + is_admin_same_org? + end + + alias_method :create?, :new? + alias_method :edit?, :new? + alias_method :update?, :new? +end diff --git a/spec/requests/mileage_rates_spec.rb b/spec/requests/mileage_rates_spec.rb index 77071e4715..9c06b60654 100644 --- a/spec/requests/mileage_rates_spec.rb +++ b/spec/requests/mileage_rates_spec.rb @@ -111,8 +111,26 @@ end end + describe "GET /edit" do + let!(:mileage_rate) { create(:mileage_rate, casa_org: casa_org) } + + before { sign_in admin } + + it "renders a successful response" do + get edit_mileage_rate_path(mileage_rate) + + expect(response).to be_successful + end + + it "does not expose another org's mileage rate" do + get edit_mileage_rate_path(create(:mileage_rate)) + + expect(response).to have_http_status(:not_found) + end + end + describe "PATCH /update" do - let(:mileage_rate) { create(:mileage_rate, amount: 10.11, effective_date: DateTime.parse("01-01-2021")) } + let(:mileage_rate) { create(:mileage_rate, amount: 10.11, effective_date: DateTime.parse("01-01-2021"), casa_org: casa_org) } before { sign_in admin } @@ -149,8 +167,19 @@ end end + context "when the mileage rate belongs to another organization" do + let(:other_org_mileage_rate) { create(:mileage_rate, amount: 10.11) } + + it "does not update it" do + patch mileage_rate_path(other_org_mileage_rate), params: {mileage_rate: {amount: "22.87"}} + + expect(response).to have_http_status(:not_found) + expect(other_org_mileage_rate.reload.amount).to eq 10.11 + end + end + context "when updating the mileage rate effective date and already exists one" do - let(:another_mileage_rate) { create(:mileage_rate) } + let(:another_mileage_rate) { create(:mileage_rate, casa_org: casa_org) } let(:params) do { mileage_rate: { From 02b134ee7baa2d21a6c5b8e6d7c245fbadff8086 Mon Sep 17 00:00:00 2001 From: gia Date: Wed, 29 Jul 2026 14:26:13 +0000 Subject: [PATCH 04/28] fix(a11y): clear every WCAG violation found by the whole-app axe sweep An axe-core (WCAG 2.1 A/AA) sweep of all 80 pages across the four roles found violations on 12 admin pages; the volunteer, all-casa and signed-out surfaces were already clean. Fixes, by rule: heading-order (7 pages) -- an
was used as the subtitle directly under the page

, skipping h2-h5, in court_dates/_form, placements/_form and bulk_court_dates/new; those are captions, so they are now

. In court_dates/show the fact labels were

Judge:
: a
is already the term, so the nested heading is gone and "Court orders:" -- a real section heading -- is an

. case_contacts/_case_contact now takes a heading_level local (default 3): #index nests cards under an

case-number section, #drafts has no grouping level and passes 2. label (critical) -- the three import file inputs had no accessible name. Their ids are overridden for the import JS, so the

<%= k[:contacts_this_month] %>
Contacts this month
<% d = k[:contacts_delta] %> -
"> +
"> <% if d > 0 %>+<%= d %> vs last month<% elsif d < 0 %><%= d %> vs last month<% else %>No change vs last month<% end %>
diff --git a/app/views/banners/_form.html.erb b/app/views/banners/_form.html.erb index 9c1428d59f..a5c576a994 100644 --- a/app/views/banners/_form.html.erb +++ b/app/views/banners/_form.html.erb @@ -32,7 +32,7 @@
<%= form.label :content, class: label_class do %>Content <%= req %><% end %> - <%= form.rich_text_area :content, class: "trix-content rounded-lg border border-slate-300 shadow-sm" %> + <%= form.rich_text_area :content, class: "trix-content rounded-lg border border-slate-300 shadow-sm", aria: {label: "Content"} %>
<%= button_tag type: "submit", class: button_classes(:primary) do %> diff --git a/app/views/bulk_court_dates/new.html.erb b/app/views/bulk_court_dates/new.html.erb index 922a114da8..988cc380d5 100644 --- a/app/views/bulk_court_dates/new.html.erb +++ b/app/views/bulk_court_dates/new.html.erb @@ -13,7 +13,7 @@ <%= form_with(model: @court_date, url: bulk_court_dates_path, method: :post, local: true, data: {controller: "court-order-form", nested_form_wrapper_selector_value: ".nested-form-wrapper"}, class: "space-y-6") do |form| %> <%= render "shared/form_errors", resource: @court_date %> -
Create a court date for all cases in a group.
+

Create a court date for all cases in a group.

<%= form.label :case_group_id, "Case group", class: label_class %>
diff --git a/app/views/casa_org/_contact_topics.html.erb b/app/views/casa_org/_contact_topics.html.erb index 9becfca6ba..d1ed677597 100644 --- a/app/views/casa_org/_contact_topics.html.erb +++ b/app/views/casa_org/_contact_topics.html.erb @@ -16,9 +16,9 @@

<%= contact_topic.question %>

<% if contact_topic.active %> - Active + Active <% else %> - Inactive + Inactive <% end %> <%= link_to edit_contact_topic_path(contact_topic), class: ghost_class do %> Edit<% end %> <%= render "shared/confirm_button", url: soft_delete_contact_topic_path(contact_topic), method: :delete, title: "Delete contact topic?", message: "This topic and its related questions will be deleted and will no longer be presented while filling out case contacts. This will not affect case contacts that have already been created.", confirm_label: "Delete court report topic", trigger_label: "Delete", trigger_icon: "bi bi-trash", trigger_class: ghost_class(:danger) %> diff --git a/app/views/case_contacts/_case_contact.html.erb b/app/views/case_contacts/_case_contact.html.erb index 39c02e3a5b..bec10fdce6 100644 --- a/app/views/case_contacts/_case_contact.html.erb +++ b/app/views/case_contacts/_case_contact.html.erb @@ -5,7 +5,9 @@ space-y-4 (a child y-margin overrides the zero-specificity space-y and collapses the gap). Topic answers + notes live in ONE native
"Show details" disclosure (full text, no per-line truncation) so reading a note is one click, not many. Edit/Delete/ - undelete keep data-turbo:false. %> + undelete keep data-turbo:false. The card title's heading level is a local + (heading_level, default 3): #index nests cards under an

case-number section heading, + but #drafts has no grouping level and h1 -> h3 skips a level (axe heading-order). %> <% pill = "inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium" %>
@@ -13,7 +15,7 @@ <%# Header row: contact groups as the title + status pills. The medium (how the contact happened) is a plain-text fact in the meta line below (subheading), not an icon. %>
-

<%= "[DELETE] " if policy(contact).restore? && contact.deleted? %><%= contact.decorate.contact_groups %>

+ <%= content_tag "h#{local_assigns.fetch(:heading_level, 3)}", class: "text-base font-semibold text-slate-900" do %><%= "[DELETE] " if policy(contact).restore? && contact.deleted? %><%= contact.decorate.contact_groups %><% end %> <% unless contact.active? %> Draft <% end %> diff --git a/app/views/case_contacts/drafts.html.erb b/app/views/case_contacts/drafts.html.erb index 348dc15275..eeb665bac3 100644 --- a/app/views/case_contacts/drafts.html.erb +++ b/app/views/case_contacts/drafts.html.erb @@ -6,7 +6,7 @@ <% if @case_contacts.any? %>
- <%= render partial: "case_contacts/case_contact", collection: @case_contacts, as: :contact %> + <%= render partial: "case_contacts/case_contact", collection: @case_contacts, as: :contact, locals: {heading_level: 2} %>
<% else %>
No draft case contacts.
diff --git a/app/views/court_dates/_form.html.erb b/app/views/court_dates/_form.html.erb index ebe9533686..6c4d026242 100644 --- a/app/views/court_dates/_form.html.erb +++ b/app/views/court_dates/_form.html.erb @@ -6,7 +6,7 @@ <%= form_with(model: court_date, url: [casa_case, court_date], local: true, data: {controller: "court-order-form", nested_form_wrapper_selector_value: ".nested-form-wrapper"}, class: "space-y-6") do |form| %> <%= render "shared/form_errors", resource: court_date %> -
Case number: <%= link_to casa_case.case_number, casa_case, class: "font-medium text-brand-600 hover:text-brand-700" %>
+

Case number: <%= link_to casa_case.case_number, casa_case, class: "font-medium text-brand-600 hover:text-brand-700" %>

<%= render "court_dates/fields", court_date: court_date, form: form, casa_case: casa_case %>
<%= button_tag type: "submit", class: button_classes(:primary) do %> diff --git a/app/views/court_dates/show.html.erb b/app/views/court_dates/show.html.erb index 7a00385f52..e74242cc69 100644 --- a/app/views/court_dates/show.html.erb +++ b/app/views/court_dates/show.html.erb @@ -1,6 +1,8 @@ <% content_for :page_title, "Court date" %> -<%# court_dates#show on casa_app. The
Label:
structure + "Court orders:" - h6/

are preserved verbatim (view + system specs assert them by xpath / substring); the +<%# court_dates#show on casa_app. Fact labels are bare

s -- they used to wrap an
, + which skipped h2-h5 after the page

(axe heading-order) and added a heading level a +
already conveys. "Court orders:" is a real section heading, so it is an

. View + + system specs assert these by xpath, so keep the dt/dd adjacency. The Title-Case-with-colon fact labels ride the deferred sentence-case sweep. %> <% card = "rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6" %> <% label = "text-sm font-medium text-slate-500" %> @@ -21,17 +23,17 @@
-
Case number:
+
Case number:
<%= link_to @casa_case.case_number, casa_case_path(@casa_case), class: "font-medium text-brand-600 hover:text-brand-700" %>
-
Court report due date:
+
Court report due date:
<%= I18n.l(@court_date.court_report_due_date, format: :full, default: "None") %>
-
Judge:
+
Judge:
<%= @court_date.judge&.name || "None" %>
-
Hearing type:
+
Hearing type:
<%= @court_date.hearing_type&.name || "None" %>
-
Court orders:
+

Court orders:

<% if @court_date.case_court_orders.any? %>
diff --git a/app/views/emancipations/show.html.erb b/app/views/emancipations/show.html.erb index 830ab037f2..f93f0d7278 100644 --- a/app/views/emancipations/show.html.erb +++ b/app/views/emancipations/show.html.erb @@ -24,27 +24,29 @@
<% @emancipation_form_data.each do |category| %> -
"> +

">
- <%= tag.input(type: "checkbox", class: "emancipation-category-check-box #{check_class}", value: category.id, checked: emancipation_category_checkbox_checked(@current_case, category)) %> + <%= tag.input(type: "checkbox", class: "emancipation-category-check-box #{check_class}", value: category.id, checked: emancipation_category_checkbox_checked(@current_case, category), aria: {label: category.name}) %>
<%= category.name %>
<% if category.emancipation_options.count > 0 %> <%= emancipation_category_collapse_icon(@current_case, category) %> <% end %> -

+

<% category.emancipation_options.each do |option| %>
<% if category.mutually_exclusive %> - <%= tag.input(type: "radio", id: "O#{option.id}", class: "emancipation-radio-button #{radio_class}", name: "C#{category.id}", value: option.id, checked: emancipation_option_checkbox_checked(@current_case, option)) %> + <%= tag.input(type: "radio", id: "O#{option.id}", class: "emancipation-radio-button #{radio_class}", name: "C#{category.id}", value: option.id, checked: emancipation_option_checkbox_checked(@current_case, option), aria: {label: option.name}) %> <% else %> - <%= tag.input(type: "checkbox", id: "O#{option.id}", class: "emancipation-option-check-box #{check_class}", value: option.id, checked: emancipation_option_checkbox_checked(@current_case, option)) %> + <%= tag.input(type: "checkbox", id: "O#{option.id}", class: "emancipation-option-check-box #{check_class}", value: option.id, checked: emancipation_option_checkbox_checked(@current_case, option), aria: {label: option.name}) %> <% end %>
<% end %> - <%# The labels intentionally have no for= matching the inputs: JS sets checkbox state after the AJAX save succeeds. %> + <%# The labels intentionally have no for= matching the inputs: JS sets checkbox state after + the AJAX save succeeds, and a real label/control pair would toggle natively on top of + that. The inputs carry aria-label instead, so they still have an accessible name. %>
<% end %>

diff --git a/app/views/imports/_cases.html.erb b/app/views/imports/_cases.html.erb index 1c46f372c0..61beb5bb5d 100644 --- a/app/views/imports/_cases.html.erb +++ b/app/views/imports/_cases.html.erb @@ -1,5 +1,6 @@ <%# Case import panel (casa_app). Cases have no phone numbers, so no SMS opt-in; the button is enabled on file-select by an inline script (src/import.js only wires volunteer/supervisor). %> +<% label_class = "mb-1 block text-xs font-medium text-slate-500" %> <% file_input = "block w-full rounded-lg border border-slate-300 text-sm text-slate-900 shadow-sm file:mr-4 file:border-0 file:border-r file:border-slate-200 file:bg-slate-50 file:px-4 file:py-2.5 file:text-sm file:font-medium file:text-slate-700 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 focus:outline-none" %>
@@ -17,7 +18,10 @@
  • Do not change any of the values in the first line of the example CSV file.
  • Then click "Import cases CSV" to import your cases.
  • - <%= f.file_field :file, id: "case-file", accept: "text/csv", class: file_input %> +
    + <%= f.label :file, "CSV file", for: "case-file", class: label_class %> + <%= f.file_field :file, id: "case-file", accept: "text/csv", class: file_input %> +
    <%= button_tag id: "case-import-button", data: {disable_with: "Importing\u2026"}, class: button_classes(:primary) do %> Import cases CSV diff --git a/app/views/imports/_supervisors.html.erb b/app/views/imports/_supervisors.html.erb index d1cbc1ac41..1da3eec458 100644 --- a/app/views/imports/_supervisors.html.erb +++ b/app/views/imports/_supervisors.html.erb @@ -1,5 +1,6 @@ <%# Supervisor import panel (casa_app). Same JS hooks as volunteers, keyed on supervisor-file / supervisor-import-button. %> +<% label_class = "mb-1 block text-xs font-medium text-slate-500" %> <% file_input = "block w-full rounded-lg border border-slate-300 text-sm text-slate-900 shadow-sm file:mr-4 file:border-0 file:border-r file:border-slate-200 file:bg-slate-50 file:px-4 file:py-2.5 file:text-sm file:font-medium file:text-slate-700 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 focus:outline-none" %>
    @@ -17,7 +18,10 @@
  • Do not change any of the values in the first line of the example CSV file.
  • Then click "Import supervisors CSV" to import your supervisors.
  • - <%= f.file_field :file, id: "supervisor-file", accept: "text/csv", class: file_input %> +
    + <%= f.label :file, "CSV file", for: "supervisor-file", class: label_class %> + <%= f.file_field :file, id: "supervisor-file", accept: "text/csv", class: file_input %> +
    <%= render "sms_opt_in_modal", form: f if @sms_opt_in_warning == "supervisor" %>
    <%= button_tag id: "supervisor-import-button", data: {disable_with: "Importing\u2026"}, class: button_classes(:primary) do %> diff --git a/app/views/imports/_volunteers.html.erb b/app/views/imports/_volunteers.html.erb index 3e226c3617..13d464fda9 100644 --- a/app/views/imports/_volunteers.html.erb +++ b/app/views/imports/_volunteers.html.erb @@ -1,6 +1,7 @@ <%# Volunteer import panel (casa_app). File id volunteer-file + button id volunteer-import-button are driven by the global src/import.js (enable-on-select + localStorage persistence across the SMS-opt-in reload). %> +<% label_class = "mb-1 block text-xs font-medium text-slate-500" %> <% file_input = "block w-full rounded-lg border border-slate-300 text-sm text-slate-900 shadow-sm file:mr-4 file:border-0 file:border-r file:border-slate-200 file:bg-slate-50 file:px-4 file:py-2.5 file:text-sm file:font-medium file:text-slate-700 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30 focus:outline-none" %>
    @@ -19,7 +20,10 @@
  • Do not change any of the values in the first line of the example CSV file.
  • Then click "Import volunteers CSV" to import your volunteers. This will email the new volunteers asking them to log in.
  • - <%= f.file_field :file, id: "volunteer-file", accept: "text/csv", class: file_input %> +
    + <%= f.label :file, "CSV file", for: "volunteer-file", class: label_class %> + <%= f.file_field :file, id: "volunteer-file", accept: "text/csv", class: file_input %> +
    <%= render "sms_opt_in_modal", form: f if @sms_opt_in_warning == "volunteer" %>
    <%= button_tag id: "volunteer-import-button", data: {disable_with: "Importing\u2026"}, class: button_classes(:primary) do %> diff --git a/app/views/placements/_form.html.erb b/app/views/placements/_form.html.erb index ce94bb1c41..3cccb1c5e7 100644 --- a/app/views/placements/_form.html.erb +++ b/app/views/placements/_form.html.erb @@ -3,9 +3,9 @@
    <%= form_with(model: placement, url: [casa_case, placement], local: true, class: "space-y-5") do |form| %> <%= render "shared/form_errors", resource: placement %> -
    +

    Case number: <%= link_to casa_case.case_number, casa_case, class: "font-medium text-brand-600 hover:text-brand-700" %> -

    +

    <%= render "placements/fields", placement: placement, form: form, casa_case: casa_case %>
    <%= button_tag type: "submit", class: button_classes(:primary) do %> diff --git a/app/views/placements/index.html.erb b/app/views/placements/index.html.erb index 5bebd018a5..15c3a7df25 100644 --- a/app/views/placements/index.html.erb +++ b/app/views/placements/index.html.erb @@ -13,7 +13,7 @@

    Placement history for - <%= link_to @casa_case.case_number, casa_case_path(@casa_case), class: "text-brand-600 hover:text-brand-700" %> + <%= link_to @casa_case.case_number, casa_case_path(@casa_case), class: "text-brand-600 underline underline-offset-2 hover:text-brand-700" %>

    <%= link_to new_casa_case_placement_path(@casa_case), class: button_classes(:primary) do %> New placement diff --git a/design.md b/design.md index 53dcf68d90..4b391c34ca 100644 --- a/design.md +++ b/design.md @@ -132,6 +132,61 @@ Everything ships to **WCAG 2.1 AA** — it's part of "done", not a follow-up. `aria-hidden`. - **Motion**: respect `prefers-reduced-motion` (`motion-reduce:` variants). +**Measured token contrast on white** (computed from the built oklch tokens and cross-checked +against axe's own numbers — do not eyeball these, and do not assume a `-600` is safe): + +| token | ratio | text (4.5:1) | icon/border (3:1) | +|---|---|---|---| +| `slate-400` | 2.63:1 | no | no | +| `slate-500` | 4.77:1 | yes | yes | +| `amber-600` | 3.19:1 | **no** | yes | +| `amber-700` | 5.05:1 | yes | yes | +| `emerald-600` | 3.67:1 | **no** | yes | +| `emerald-700` | 5.37:1 | yes | yes | +| `rose-600` | 4.51:1 | yes (barely) | yes | +| `rose-700` | 6.06:1 | yes | yes | + +So `emerald-600`/`amber-600` are fine on a **decorative `aria-hidden` icon** but fail as +**text**: use `-700` for any status word ("Active", "+3 vs last month"). Watch the mixed +pattern ` Active` — the span colours the word +too, so it is text, not an icon. Keep a +/- pair on the same step so the two read at the +same weight. + +**Heading order** (axe `heading-order`, and one `h1` per page above): +- A **subtitle/caption under the page `h1` is a `

    `**, never a small heading. An `

    ` + used for "Case number: X" or "Create a court date for all cases in a group." skips h2–h5 + and fails. Small-and-grey is a type decision (`text-sm text-slate-500`), not a level. +- A **`
    ` is already the term** — never nest a heading inside it. `
    Judge:
    ` + both skipped levels and doubled the semantics. +- A **card partial shared by a grouped index and a flat list needs a caller-controlled + level**. `case_contacts/_case_contact` takes `heading_level` (default 3): `#index` nests + cards under an `

    ` case-number section, `#drafts` has no grouping level and passes 2. + +**A `