From 691652fe2a1f1a72619e1fee3cf2e281716b55f1 Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Fri, 13 Nov 2026 20:53:48 +1100 Subject: [PATCH 1/2] Show plan type icon tooltips and drop the red tint In workspace rows, the title link's stretched ::after covered the type icon, so hovering it never showed the type name in its title tooltip. The icon is now its own link to the plan, which lifts it above the overlay like the row's other links. Clicking it still opens the plan. Tint 4 was a pinkish red (#e0637a). Types get their tint from a hash of their name, so "Research" landed on red by chance and read as an error. It is now an olive green. Co-authored-by: Albert <8b5bc7cae3946a84a2d793d1b962c7e0142083a01805919e342a8848161d9479@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 --- .../assets/stylesheets/coplan/controls.css | 6 ++++-- .../app/views/coplan/plans/_plan_row.html.erb | 9 +++++++-- spec/requests/plans_spec.rb | 12 +++++++++++ spec/system/folders_workspace_spec.rb | 20 +++++++++++++++++++ 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/engine/app/assets/stylesheets/coplan/controls.css b/engine/app/assets/stylesheets/coplan/controls.css index 163cdd9d..a4185413 100644 --- a/engine/app/assets/stylesheets/coplan/controls.css +++ b/engine/app/assets/stylesheets/coplan/controls.css @@ -181,12 +181,14 @@ .plan-type-icon--lg { width: 36px; height: 36px; border-radius: 9px; } .plan-type-icon--lg svg { width: 19px; height: 19px; } -/* Saturated mid-tones read in both modes (like language dots). */ +/* Saturated mid-tones read in both modes (like language dots). No red + or pink: a type's color is hashed from its name, not chosen, so a red + tint would read as an error on a type that means nothing of the sort. */ .plan-type-icon--0 { background: color-mix(in srgb, #5b8def 16%, transparent); color: #5b8def; } .plan-type-icon--1 { background: color-mix(in srgb, #3fa876 16%, transparent); color: #3fa876; } .plan-type-icon--2 { background: color-mix(in srgb, #d99a2b 16%, transparent); color: #d99a2b; } .plan-type-icon--3 { background: color-mix(in srgb, #9c6fe0 16%, transparent); color: #9c6fe0; } -.plan-type-icon--4 { background: color-mix(in srgb, #e0637a 16%, transparent); color: #e0637a; } +.plan-type-icon--4 { background: color-mix(in srgb, #8a9c2a 16%, transparent); color: #8a9c2a; } .plan-type-icon--5 { background: color-mix(in srgb, #2fa8a0 16%, transparent); color: #2fa8a0; } /* Avatars */ diff --git a/engine/app/views/coplan/plans/_plan_row.html.erb b/engine/app/views/coplan/plans/_plan_row.html.erb index c759e88b..0fa3d070 100644 --- a/engine/app/views/coplan/plans/_plan_row.html.erb +++ b/engine/app/views/coplan/plans/_plan_row.html.erb @@ -19,8 +19,13 @@ data-current-folder-id="<%= folder_id %>" data-action="dragstart->coplan--folder-dnd#dragStart dragend->coplan--folder-dnd#dragEnd"> <%# The file icon spans both text rows — a Drive-style file entry, not an - inline glyph. %> - <%= plan_type_icon(plan, size: :lg) %> + inline glyph. It is its own link so it rises above the stretched + title link: otherwise that overlay swallows the hover and the type's + tooltip never shows. Out of the tab order — the title is the + keyboard target for the same plan. %> + <%= link_to plan_type_icon(plan, size: :lg), plan_browse_path(plan), + class: "plan-row__icon", draggable: false, tabindex: -1, + data: { turbo_frame: "_top", turbo_prefetch: true } %>
<%# Stretched link: the title's ::after covers the whole card, so a diff --git a/spec/requests/plans_spec.rb b/spec/requests/plans_spec.rb index e62896b4..627c69cd 100644 --- a/spec/requests/plans_spec.rb +++ b/spec/requests/plans_spec.rb @@ -515,6 +515,18 @@ expect(response.body).not_to include("plan-type-chip") end + it "lifts the row's icon above the stretched title link so its tooltip shows" do + rfc = create(:plan_type, name: "RFC", icon: "scroll") + create(:plan, :published, created_by_user: alice, plan_type: rfc, title: "Typed Plan") + get library_page_path(alice) + row = Nokogiri::HTML(response.body).at_css(".plan-row") + icon_link = row.at_css("a.plan-row__icon") + expect(icon_link).to be_present + expect(icon_link["href"]).to eq(row.at_css("a.plan-row__title")["href"]) + expect(icon_link["tabindex"]).to eq("-1") + expect(icon_link.at_css(".plan-type-icon")["title"]).to eq("RFC") + end + it "colors the icon with a stable per-name tint" do rfc = create(:plan_type, name: "RFC", icon: "scroll") create(:plan, :published, created_by_user: alice, plan_type: rfc) diff --git a/spec/system/folders_workspace_spec.rb b/spec/system/folders_workspace_spec.rb index 25df1849..75165c4c 100644 --- a/spec/system/folders_workspace_spec.rb +++ b/spec/system/folders_workspace_spec.rb @@ -66,6 +66,26 @@ def sign_in(user) expect(page).to have_css(".workspace-crumbs__crumb--current", text: "Q3") end + it "keeps the row icon hoverable for its type tooltip and still opens the plan" do + visit library_page_path(author) + row = find(".plan-row[data-plan-id='#{developing_plan.id}']") + + # The stretched title link covers the card; the icon must be the + # topmost element under the pointer or its title tooltip never shows. + on_top = page.evaluate_script(<<~JS) + (() => { + const icon = document.querySelector(".plan-row[data-plan-id='#{developing_plan.id}'] .plan-type-icon") + const box = icon.getBoundingClientRect() + return icon.contains(document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2)) + })() + JS + expect(on_top).to eq(true) + expect(row.find(".plan-type-icon")[:title]).to eq(developing_plan.plan_type.name) + + row.find(".plan-row__icon").click + expect(page).to have_css("h1", text: "Payments Plan") + end + it "falls back to the plan's folder on Backspace after a cold open" do # Direct visit = no in-app history. Backspace should land on the # folder the plan lives in — at its readable address, not the From bdc86f553eeb8be1282b53cde3b04fc3bdc2a1e9 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 13 Nov 2026 05:23:00 -0500 Subject: [PATCH 2/2] Name plan icon links for screen readers Signed-off-by: Hampton Lintorn-Catlin --- engine/app/views/coplan/plans/_plan_row.html.erb | 1 + spec/requests/plans_spec.rb | 1 + spec/system/folders_workspace_spec.rb | 14 ++++++++++++++ 3 files changed, 16 insertions(+) diff --git a/engine/app/views/coplan/plans/_plan_row.html.erb b/engine/app/views/coplan/plans/_plan_row.html.erb index 0fa3d070..bee9a004 100644 --- a/engine/app/views/coplan/plans/_plan_row.html.erb +++ b/engine/app/views/coplan/plans/_plan_row.html.erb @@ -25,6 +25,7 @@ keyboard target for the same plan. %> <%= link_to plan_type_icon(plan, size: :lg), plan_browse_path(plan), class: "plan-row__icon", draggable: false, tabindex: -1, + aria: { label: "#{plan.title} (#{plan.plan_type.name})" }, data: { turbo_frame: "_top", turbo_prefetch: true } %>
diff --git a/spec/requests/plans_spec.rb b/spec/requests/plans_spec.rb index 627c69cd..a53201a9 100644 --- a/spec/requests/plans_spec.rb +++ b/spec/requests/plans_spec.rb @@ -524,6 +524,7 @@ expect(icon_link).to be_present expect(icon_link["href"]).to eq(row.at_css("a.plan-row__title")["href"]) expect(icon_link["tabindex"]).to eq("-1") + expect(icon_link["aria-label"]).to eq("Typed Plan (RFC)") expect(icon_link.at_css(".plan-type-icon")["title"]).to eq("RFC") end diff --git a/spec/system/folders_workspace_spec.rb b/spec/system/folders_workspace_spec.rb index 75165c4c..8660a127 100644 --- a/spec/system/folders_workspace_spec.rb +++ b/spec/system/folders_workspace_spec.rb @@ -86,6 +86,20 @@ def sign_in(user) expect(page).to have_css("h1", text: "Payments Plan") end + it "keeps the title as the row's keyboard navigation target" do + visit library_page_path(author) + row = find(".plan-row[data-plan-id='#{developing_plan.id}']") + title = row.find(".plan-row__title") + title.send_keys([ :shift, :tab ]) + expect(page.evaluate_script("document.activeElement.classList.contains('plan-row__icon')")).to eq(false) + page.driver.browser.action.send_keys(:tab).perform + expect(page.evaluate_script("document.activeElement.classList.contains('plan-row__title')")).to eq(true) + expect(page.evaluate_script("document.activeElement.textContent")).to eq("Payments Plan") + + page.driver.browser.action.send_keys(:enter).perform + expect(page).to have_css("h1", text: "Payments Plan") + end + it "falls back to the plan's folder on Backspace after a cold open" do # Direct visit = no in-app history. Backspace should land on the # folder the plan lives in — at its readable address, not the