Skip to content

OpenConceptLab/ocl_issues#2970 | Focused buttons show the two-tone ring, not the focus ripple - #118

Merged
paynejd merged 5 commits into
mainfrom
OpenConceptLab/ocl_issues#2970-focus-ring
Oct 27, 2026
Merged

paynejd merged 5 commits into
mainfrom
OpenConceptLab/ocl_issues#2970-focus-ring

Conversation

@paynejd

@paynejd paynejd commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Part of OpenConceptLab/ocl_issues#2970, and the link-button part of OpenConceptLab/ocl_issues#2969

Merge after, or together with, oclmap#117 (OpenConceptLab/ocl_issues#2969). This PR gives contained link-buttons the same white text as every other filled button on hover. With today's primary.dark (#c3c0ff), that's 1.71:1, the same as every filled <button> shows on hover now. oclmap#117 makes primary.dark #2d00e5, and then hover measures 9.36:1.

What changed and why

While a button has keyboard focus, MUI pulses a focus ripple behind the label: a circle of the text color at 30% opacity. Over it the label falls below WCAG 1.4.3's 4.5:1:

Button Text over the focus ripple
Contained primary, white text 3.64:1
Contained error, white text 3.86:1
Outlined or text, primary.main text 3.91:1

In the theme, buttons now drop the focus ripple and show the community site's two-tone ring on .Mui-focusVisible: 0 0 0 2px #FFF, 0 0 0 4px #2d00e5 (WHITE, then primary.30). Text keeps its resting contrast (6.47:1 on primary.main). The click ripple stays.

  • MuiButton: defaultProps.disableFocusRipple: true, and the ring in styleOverrides.root. Theme overrides come after MUI's own styles, so the ring replaces a contained button's focus elevation.

  • MuiButtonGroup: styleOverrides.grouped['&.Mui-focusVisible'] sets the same ring with z-index: 1. This is the form the design system's theme uses.

  • Contained link-buttons: a contained Button with href renders as <a>. Bootstrap 3's a:hover, a:focus { color: #23527c; text-decoration: underline } (an element and a class) outranks MUI's contained color: var(--variant-containedColor) (one class). So "New map project" in MapProjects.jsx (2 places) and the action button in PreviewLimitDialog.jsx showed dark-blue underlined text on focus, 1.26:1 on primary, and on hover. MuiButton.styleOverrides.root now re-asserts MUI's own declaration on a&.MuiButton-contained:hover and :focus, without the underline. Focused, they measure 6.47:1, or 6.46:1 on error.

    • The rule sets the value MUI's own rule sets. For color="inherit", or a color with no palette main (oclmap's color='default'), the variable is unset, and the text inherits as before.
    • It's scoped to <a>, because Bootstrap's rule only matches anchors. A contained <button> with its own sx color keeps that color on hover and focus. No contained button in the app sets its own text color today, except the QuotaDialog action, which sets the same contrast text.

The settings live in a new src/common/buttonFocus.js, which returns both entries. src/index.jsx merges them in as a third createTheme argument, so the theme's existing MuiButton.variants stay as they are.

Buttons in a ButtonGroup

  • The ripple default reaches grouped buttons. In MUI 9, ButtonGroup passes its raw disableFocusRipple prop through context, which is undefined unless set. Button resolves that context before the theme defaults, so MuiButton.defaultProps.disableFocusRipple applies inside a group too, and no MuiButtonGroup.defaultProps is needed. MUI 5 passed a defaulted false here. The test proves it: with the default removed, the grouped button mounts a pulsating ripple.
  • z-index: 1 is needed today. On the concept Associations panel, the outlined scope group fills its selected button. Without the z-index, that fill covered the right edge of a focused neighbour's ring. With it, the ring shows in full. The same holds for a contained group's neighbour.
  • The group's box-shadow is a safeguard. A contained group sets box-shadow: none on its buttons, as specific as the button's ring rule, so the stylesheet loaded last wins. Once any contained button has rendered first, a focused grouped button loses the ring. No screen reaches a contained group today (MapButton's split-button branch is unused), but the rule covers it. Focused and hovered, a grouped button keeps the ring: the group's :hover reset doesn't win.

How it was tested

Integration tests, src/common/__integration__/buttonFocus.test.js, run by npm run test:integration:

  • It renders a contained, an outlined, a text and a grouped button under a theme built from buttonFocusComponents, then focuses each.
  • For each, it checks that the button is focus-visible, that its computed box-shadow is the ring, and that no .MuiTouchRipple-childPulsate is mounted. For the grouped button it also checks z-index: 1.
  • Before the change, all four failed. Contained buttons showed MUI's elevation shadow, outlined and text buttons showed none, and a pulsing ripple was mounted.
  • Each test fails if disableFocusRipple is removed (1 pulsating ripple), and the button tests fail if the ring is removed.
  • The group test fails without the group rule (box-shadow: none), whether it runs alone or with the others: it renders a lone contained button first. It also fails without the z-index.
  • A fifth test adds Bootstrap's anchor rule, read from public/bootstrap.min.css. It renders a contained link-button and a contained <button> with its own sx color. On focus and on hover, the link's text must be the contrast text at 4.5:1 or more against its fill, and the <button> must keep its own color. happy-dom can't match :hover, so the test adds a removable stylesheet that repeats each :hover rule for a class.
  • Before the fix, the link's text was #23527c. The test fails if the rule is dropped, if either the :hover or the :focus half is dropped, or if the a scope is dropped.

Why this approach:

  • index.jsx builds the theme and also renders the app, so a test can't import it.
  • Moving the whole theme into a module wasn't possible either: it imports colors.jsx, and Node's test runner can't load .jsx.
  • So only the focus settings moved, into a plain .js module that takes the two ring colors. index.jsx passes WHITE and COLORS.primary['30'].
  • happy-dom matches :focus-visible, so a plain focus() takes MUI's real focus-visible path. No new test dependencies.

Rendered check: I built a throwaway harness (not committed) with webpack, using the theme built the same way plus Bootstrap and index.scss, and tabbed through it in headless Chromium.

  • Every contained, outlined, text and grouped button showed the ring, with no pulsating ripple.
  • Text contrast on focus: 6.47:1 on primary, 6.46:1 on error.
  • Grouped buttons got z-index: 1. I checked an Associations-style outlined group with and without it, as described above.
  • A mouse press still drew the click ripple, and no ring.
  • Contained link-buttons (primary, error), on focus, before and after this PR:
    • before: #23527c underlined, 1.26:1;
    • after: white, 6.47:1 (6.46:1 on error).
  • The same buttons on hover:
    • before: 4.79:1, and only because Bootstrap's dark text lands on today's light hover fill;
    • after, with today's palette: 1.71:1, the same as a plain filled <button>;
    • after, with oclmap#117's primary.dark: 9.36:1.

CI gate, as .github/workflows/pr.yml runs it, after npm ci:

  • ./node_modules/eslint/bin/eslint.js src --ext .jsx,.js: exit 0.
  • npm run test:all: unit tests 603 of 603 pass, integration tests 11 of 11 pass.

Acceptance criteria this PR covers

The oclweb3, community site and design-system criteria are separate PRs.

Buttons with their own focus styles

No button's own styles set a box-shadow, so every MuiButton in the Mapper now shows the ring on keyboard focus. None loses all visible focus.

  • OwnerButton.jsx, RepoVersionButton.jsx: on focus these set outline: none, a transparent background and inherited color. The focus ripple used to be their only sign of focus; now they show the ring. With a link they render as <a> and pick up Bootstrap's underline on focus. Text is 9.30:1.
  • common/Link.jsx (a text Button as <a>) and the no-anchor-styles sign-in button in UserMenu.jsx: these set color, decoration and outline: none on focus. They show the ring.
  • QuotaDialog.jsx action button (contained, <a>): it already kept white text on focus, and it hides Bootstrap's a:focus outline only for mouse focus. On keyboard focus it shows the ring with Bootstrap's 5px auto outline inside it. Text is 6.47:1.
  • .MuiButtonBase-root rules in ColumnMapTable.jsx and MapProject.jsx: these set color only, on Autocomplete and DataGrid header icon buttons, not on MuiButton. Unchanged.
  • Candidates.jsx's ListItemButtons: these aren't MuiButton, so they're out of scope and unchanged.

Review

  • Codex pass 1 on cad14f5 was clean.
  • The simplification review cut one repeated test (S1, 6d9b73a).
  • Codex checks of the two later commits, cad14f5..6d9b73a and 6d9b73a..aeaad0e, were both clean.
  • The Codex check of aeaad0e..57d6e11 (the link-button fix) found two things:
    • FC.1 (P1), the hover contrast with today's palette, stays open until oclmap#117 merges (see the note at the top).
    • FC.2 (P2), a stylesheet leak in the hover simulation, was fixed in 2a53a16.
  • The check of 57d6e11..2a53a16 was clean.

🤖 Generated with Claude Code

…ng, not the focus ripple

While a button had keyboard focus, MUI pulsed a circle of the text color
at 30% behind the label. Over it the label fell below 4.5:1: 3.64:1 on
contained primary, 3.86:1 on contained error and 3.91:1 for primary text
on outlined and text buttons.

The theme now sets disableFocusRipple on MuiButton and draws the
community site's ring on .Mui-focusVisible instead: 2px white, then 2px
primary 30. Theme overrides come after MUI's own styles, so the ring
replaces a contained button's focus elevation. A contained ButtonGroup
sets box-shadow: none on its buttons at the same specificity, so the
group gets the ring too, raised above its neighbours. The click ripple
stays.

The settings live in src/common/buttonFocus.js, because index.jsx renders
the app and can't be imported by a test. The new integration test renders
contained, outlined, text, contained-error and grouped buttons under a
theme built from it, focuses each, and checks for the ring and for no
pulsating ripple.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@paynejd paynejd left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex review, pass 1 (codex-cli 0.164.0, commit cad14f5)

Verdict: clean
Outcome: No findings, so nothing to fix. Next: the simplification review.

findings.json
{
  "reviewer": "codex-cli 0.164.0",
  "perspective": "defects",
  "mode": "full",
  "commit": "cad14f5bf8f9c3fbc477ad4f07197ee944052091",
  "verdict": "clean",
  "prior": [],
  "findings": []
}

The ring and disableFocusRipple don't depend on the button's color, and
MUI's focus elevation on a contained button is the same for every color,
so the error case repeated the contained one (simplification review S1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@paynejd paynejd left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplification review (claude subagent, commit cad14f5)

Verdict: findings (1 finding)
Outcome: S1 applied in 6d9b73a. Lint and tests rerun: ESLint clean, unit 603/603, integration 10/10.

  • S1 (dup-test, medium) src/common/__integration__/buttonFocus.test.js:60: The 'contained error button' test repeats the contained variant test. The ring rule and disableFocusRipple are set on MuiButton root regardless of color, and no acceptance criterion names the error color.

Responses:

  • S1: agreed and cut. MUI puts a contained button's focus elevation in the contained variant for every color, so the error case only repeated the contained test.
findings.json
{
  "reviewer": "claude subagent",
  "perspective": "simplify",
  "mode": "full",
  "commit": "cad14f5bf8f9c3fbc477ad4f07197ee944052091",
  "verdict": "findings",
  "prior": [],
  "findings": [
    {
      "id": "S1",
      "severity": null,
      "tag": "dup-test",
      "file": "src/common/__integration__/buttonFocus.test.js",
      "line": 60,
      "claim": "The 'contained error button' test repeats the contained variant test. The ring rule and disableFocusRipple are set on MuiButton root regardless of color, and no acceptance criterion names the error color.",
      "scenario": null,
      "action": "Delete the test. The contained, outlined, text and group tests still cover both criteria. Keep it only if a color-specific MUI focus rule is a known concern.",
      "lines_delta": -5,
      "confidence": "medium"
    }
  ]
}

… design system's form

The ButtonGroup rule moves from a root selector to
styleOverrides.grouped['&.Mui-focusVisible'], as in the design system's
theme. MUI emits it as the same selector, so the result doesn't change.

The test now also checks the focused grouped button's z-index. In an
outlined group whose selected button is filled, as on the Associations
panel, that fill covered the right edge of a focused neighbour's ring
until the button was raised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@paynejd paynejd left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex check of commits cad14f5..6d9b73a (codex-cli 0.164.0)

Verdict: clean
Outcome: Clean: the simplification cut broke nothing. A later commit (aeaad0e, from a cross-repo check of the ButtonGroup case) gets its own check below.

findings.json
{
  "reviewer": "codex-cli 0.164.0",
  "perspective": "defects",
  "mode": "final",
  "commit": "6d9b73a3bcdb58ada0b0578689b05e191e2426cb",
  "verdict": "clean",
  "prior": [],
  "findings": []
}

@paynejd paynejd left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex check of commits 6d9b73a..aeaad0e (codex-cli 0.164.0)

Verdict: clean
Outcome: Clean. aeaad0e moves the grouped rule into the form the design system uses, which MUI emits as the same selector, and adds the z-index assertion.

findings.json
{
  "reviewer": "codex-cli 0.164.0",
  "perspective": "defects",
  "mode": "final",
  "commit": "aeaad0e2e87f918b695de4f3d8112a9b5206cf4c",
  "verdict": "clean",
  "prior": [],
  "findings": []
}

paynejd and others added 2 commits October 27, 2026 14:46
…xt color under Bootstrap's a:hover and a:focus

A contained Button with href renders as <a>. Bootstrap 3's
`a:hover, a:focus { color: #23527c; text-decoration: underline }` (an
element and a class) outranks MUI's contained `color:
var(--variant-containedColor)` (one class). So "New map project" and
other link-buttons showed dark-blue underlined text on the filled
button: 1.26:1 on primary while focused.

The theme now re-asserts MUI's own declaration on contained anchors on
:hover and :focus. Wherever MUI's rule applied before, it gives the same
value, including colors without a palette entry, where the variable is
unset and the text inherits as before. Buttons rendered as <button> are
untouched. This also covers the link-buttons' share of
OpenConceptLab/ocl_issues#2969's hover criterion; the hover fill itself
is #2969's palette change.

The integration test loads Bootstrap's rule from public/bootstrap.min.css
and checks the link-button's text is the contrast text, at 4.5:1 or more,
when focused and when hovered (happy-dom can't match :hover, so the test
copies :hover rules to a class).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es cleanly and covers a button's own color

The test simulated hover by inserting copies of the :hover rules into
Emotion's stylesheet and deleting them afterwards. happy-dom 15's
deleteRule leaves holes in cssRules, and a later computed-style read in
the same file threw (Codex final check FC.2). The copies now go in a
separate <style> that the test removes.

The test also renders a contained <button> with its own sx color. The
link-button rule is scoped to <a>, so this button keeps its color on
focus and hover. The test fails if the scope is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@paynejd paynejd left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex check of commits aeaad0e..57d6e11 (codex-cli 0.164.0)

Verdict: findings (2 findings)
Outcome: FC.2 fixed in 2a53a16. FC.1 depends on oclmap#117 and is open until that merges; see the response.

  • FC.1 (P1, high) src/common/buttonFocus.js:25: The new text-color override regresses hover contrast with the application's palette.
  • FC.2 (P2, high) src/common/__integration__/buttonFocus.test.js:118: Hover-rule cleanup corrupts shared stylesheet state in the installed happy-dom version.

Responses:

  • FC.1 (P1): confirmed in Chromium, and not fixable in this PR without changing the palette. With today's primary.dark (#c3c0ff), every filled <button> already shows white on #c3c0ff on hover (1.71:1); link-buttons only scored 4.79:1 because Bootstrap's dark-blue text happened to land on that light fill. The fix is oclmap#117 (OpenConceptLab/ocl_issues#2969), which makes primary.dark #2d00e5. With it, link-buttons on hover measure white on #2d00e5, 9.36:1, and on focus 6.47:1. Merge oclmap#117 before or together with this PR. The test uses MUI's default palette because colors.jsx can't be loaded by the test runner; the palette's own hover contrast is #2969's test.
  • FC.2 (P2): reproduced (a later computed-style read threw) and fixed in 2a53a16. The hover copies now go in a separate <style> that the test removes, and a follow-up read succeeds.
findings.json
{
  "reviewer": "codex-cli 0.164.0",
  "perspective": "defects",
  "mode": "final",
  "commit": "57d6e1117c2c222e8bb56adedb22b972eae85ac4",
  "verdict": "findings",
  "prior": [],
  "findings": [
    {
      "id": "FC.1",
      "severity": "P1",
      "tag": null,
      "file": "src/common/buttonFocus.js",
      "line": 25,
      "claim": "The new text-color override regresses hover contrast with the application's palette.",
      "scenario": "On desktop, hovering the 'New map project' link-button uses primary.dark (#c3c0ff) as its background. This rule changes its text from Bootstrap's #23527c to white, reducing contrast from 4.80:1 to 1.71:1. The new test misses this because it uses MUI's default palette.",
      "action": "Ensure the hover foreground/background pair meets 4.5:1 with the application palette, and exercise that palette in the regression test.",
      "lines_delta": null,
      "confidence": "high"
    },
    {
      "id": "FC.2",
      "severity": "P2",
      "tag": null,
      "file": "src/common/__integration__/buttonFocus.test.js",
      "line": 118,
      "claim": "Hover-rule cleanup corrupts shared stylesheet state in the installed happy-dom version.",
      "scenario": "happy-dom 15.11.7 implements deleteRule with array deletion, leaving undefined entries in cssRules. After this test completes, a subsequent test in the same process that reads getComputedStyle(element).color throws 'Cannot read properties of undefined (reading type)'. This was reproduced by registering a follow-up test.",
      "action": "Restore stylesheet snapshots using replaceSync or otherwise remove the temporary rules without leaving sparse cssRules arrays; verify a subsequent computed-style read succeeds.",
      "lines_delta": null,
      "confidence": "high"
    }
  ]
}

@paynejd paynejd left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex check of commits 57d6e11..2a53a16 (codex-cli 0.164.0)

Verdict: clean
Outcome: Clean. FC.2 is resolved. FC.1 stays open until oclmap#117 merges, as planned.

Earlier findings: FC.1 unresolved, FC.2 resolved

findings.json
{
  "reviewer": "codex-cli 0.164.0",
  "perspective": "defects",
  "mode": "final",
  "commit": "2a53a16e606930dd18e20f2a2f12e6438acfe60a",
  "verdict": "clean",
  "prior": [
    {
      "id": "FC.1",
      "status": "unresolved"
    },
    {
      "id": "FC.2",
      "status": "resolved"
    }
  ],
  "findings": []
}

@paynejd
paynejd merged commit 10551de into main Oct 27, 2026
2 checks passed
@paynejd
paynejd deleted the OpenConceptLab/ocl_issues#2970-focus-ring branch October 27, 2026 19:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant