Fix Biome lint errors on frontend

Description

Summary

Re-enables full Biome lint enforcement in src/main/frontend/. CI's validate step ran biome check --linter-enabled=false, so nothing was checked and the recommended ruleset had accumulated violations unnoticed.

Clears 271 errors to 0, and settles the warnings too under one policy: a rule either gates the build or is not enabled. Warning is a migration state, not a resting state — a warning nobody actions only teaches people to ignore linter output, which is how the previous ESLint config stayed broken here without anyone noticing.

Final: 0 errors, 0 infos, 261 warnings (all one deliberately-kept rule). npm run validate green, tsc --noEmit clean. 670 files changed.

Scope correction

The counts in the original description were stale — Biome had been upgraded since (schema 2.3.8):

original description

actual

errors

657

271

files

622

111

noControlCharactersInRegex

240

0 — no longer reported in that form

noStaticElementInteractions

413

34

Eight rules that error today were missing from the original breakdown (noLabelWithoutControl 37, useKeyWithClickEvents 26, noSwitchDeclarations 7, useAriaPropsSupportedByRole 6, and four single-occurrence rules). Also: only errors fail the build — a warnings-only file exits 0 — so the proposed per-phase CI ratchet was unnecessary.

Delivered as one PR rather than the four proposed phases, because the judgement-call errors sit inside the same files as the mechanical ones (10 of their 30 files carry both, including VoteStepNode, GroupStepNode, PathRenderer, AdfRenderer, ParallelGroup). Splitting by rule family would edit those files twice and make a tester re-check the same screen twice — 38 UI-surface passes against 18.

Rule configuration

25 rules at error. useSemanticElements sits at its recommended default; the other 24 are promoted above their Biome defaults so they gate rather than advise: useImportType, useConst, useTemplate, noExplicitAny, noGlobalIsNan, useParseIntRadix, noEvolvingTypes, noAccumulatingSpread, noUnusedVariables, noUnusedFunctionParameters,noUselessFragments, useOptionalChain, noThisInStatic, noUselessEscapeInRegex, noUselessUndefinedInitialization, useArrowFunction, noUselessSwitchCase, noUselessCatch, useLiteralKeys, noInvalidUseBeforeDeclaration, noUndeclaredVariables, noNodejsModules, noProcessGlobal, noControlCharactersInRegex.

4 rules off, each because the rule does not fit this codebase:

rule

n

why off

noNonNullAssertion

40

banning ! is a null-safety policy the team has not adopted; every removal changes control flow

noTemplateCurlyInString

10

all 10 are documentation UI listing the ${variable} placeholders available in message templates

noNestedComponentDefinitions

5

4 of 5 are react-select components/formatOptionLabel closing over state; react-select fixes those signatures, so satisfying it means threading state through selectProps for stateless leaves where a remount is invisible

noStaticOnlyClass

2

a static-only service class is a deliberate namespace; converting churns every call site

1 rule at warn, the only exception: useExhaustiveDependencies (261). It finds stale closures, cannot be fixed in bulk — its own fix adds dependencies that break compilation — and its value is as a review aid. Anything promoted later starts here.

Formatter and organizeImports unchanged. Considered and rejected: enabling rule groups wholesale adds ~9,700 diagnostics, including 1,290 noSolidDestructuredProps and 90 noReactSpecificProps (SolidJS rules, wrong for React), 2,504 noUndeclaredDependencies (cannot see Parcel's path aliases) and ~1,400 style-only findings. nursery stays off.

Interactive elements: native control where the markup allows one

useSemanticElements gates the build, so role="button" is a documented exception rather than the default fix for the interaction rules. role + tabIndex is not equivalent to a <button>: it carries no accessible name of its own, and it nests inside another control without complaint.

Seven elements are native <button>s — the sort control and the column-options trigger in ColumnHeader (siblings, not nested), the three PathRenderer result badges, the StepTypeChooser step-type cards, the StepsContainer add-step control, and the built-in template card in AiTemplatesContent. Each carries an explicit UA reset (margin, background, border, font, text-align) so the rendered result matches the div it replaces, and ColumnHeader's sort button takes draggable={false} so the drag gesture resolves to the pragmatic-drag-and-drop <th> behind it. The reassign avatars in EmailStepNode and UserStepNode pass onClick to Atlaskit's AvatarItem, which renders a <button> itself.

Ten elements keep role under a per-site biome-ignore, because a <button> there is invalid HTML or the wrong element:

element

why not a <button>

NoAccessPage, NotificationDispatchIndicator, chartStates

role="status" live regions; <output> is for calculation results

RangeFilter

role="group" popup body; <fieldset> demands a legend and brings UA chrome

FilterOptionList (both rows)

the row wraps a Checkbox

ApprovalsListPanel

card body is Atlaskit block primitives; a <button> takes phrasing content only

AiTemplatesContent user card

holds its own Edit/Delete buttons

StepItem

holds its own floating action buttons

RemoveFilterButton

renders inside the filter chip's own <button>

The rule does not see a role passed through a JSX spread, so a clean run is not proof on its own — audit with a grep for role in both attribute and object-literal form.

common/utils/keyboardActivation.ts (activateOnKey) supplies the Enter/Space handler for the elements that stay role="button". Wrappers whose handler is a propagation guard or a hover measurement take role="none" instead, so they gain no phantom tab stop and claim no semantics they do not have.

Decorative SVGs take aria-hidden, not <title> — all 22 get their accessible name from the surrounding control, and <title>would add a native hover tooltip.

Other notable changes

  • Three href-less anchors became buttons — "Select all"/"Clear all" in the API key privileges form, the open-definition icon link in Condition config usage history, and the Manage Approvals link. Each carries the UA button reset plus token('color.link') and a hover underline, matching what @atlaskit/css-reset gave the anchors. Verified against the originals in a headless render: computed styles match except display: inline-block, which browsers force on form controls and which is not visible.

  • renderExpandButtonCell — its IconButton carried no handler, so it was a focusable dead control. The handler now lives on the button (native keyboard support, stopPropagation so the cell's own click cannot toggle twice); the cell keeps role="none" and its click, preserving the full-cell target.

  • Keys use a real identity where the item carries one (label text, component name, step id, action label, viewer type+id, option.text). Where position is the identity, or no stable id exists, the index key is kept with the reason recorded rather than faked — drop zones and connectors are positions, filter conditions are addressed by index through editingIndex/handleRemove, palette slots are numbered, parsed markdown and text segments have none, and step.id is optional outside the editor so the list view guards with step.id || index.

  • Hook fixes are reorders. useMemo/useCallback/useState have no side effects, so hoisting them above an early return cannot change behaviour. InstanceErrorsPage is the exception: useUniversalPageLayout registers a header action, so it moved above the guard with enabled: !collectionId, leaving the denied path registering nothing exactly as before.

  • Dead code removed in Approval.tsx: onMouseOver={() => clearBorderColor} returned the function without calling it, so the highlight border has never cleared on hover. Restoring that behaviour is a deliberate UX change and is out of scope here.

  • TestRenderPanel's dangerouslySetInnerHTML is suppressed, not solved. It renders a tenant-authored email template for an admin, which is the panel's purpose. A dependency-free hardening option is a sandboxed iframe (<iframe sandbox srcDoc=...>) — preserves fidelity, changes preview sizing, wants its own decision.

  • Five files carry a file-wide biome-ignore-all for noArrayIndexKey (DateFilterManager, StepsFilterManager, StepsList, ResponsiveOverflowContainer, GroupStepNode). Biome anchors that rule on the key attribute and no comment can sit inside a JSX opening tag, so per-line suppression is impossible. Cost: a genuinely wrong index key added to those files later is not caught.

Enforcement

  • validate changed from biome check --linter-enabled=false . to biome check .

  • pom.xml binds npm run build to generate-resources, and build runs validate, so mvn verify --projects approval-path — the PR gate — enforces type-check and lint.

  • .husky/pre-commit now lints staged frontend files, check-only. It must not write: node_format ends in xargs git add, so anything the hook fixes is re-staged wholesale — fine for formatting, wrong for a linter fix that can change behaviour. It reports and aborts, pointing at npm run lint:fix and --no-verify.

  • Confirmed the gate bites: a file with a fresh useTemplate or noEvolvingTypes violation exits 1, where before it produced only an info or a warning.

Manual testing

One regression pass. Everything outside this list is either provably inert or additive keyboard support (tab to it, press Enter).

  1. List page column headers — each sortable header is a tab stop, on top of the column-options trigger. Intended, and the most visible change in the ticket. Sort by clicking the header, open the column menu, and drag a column to reorder starting the drag on the header label.

  2. The seven converted controlsPathRenderer's success/rejection/expiration badges (click to expand), the StepTypeChooserstep-type cards, StepsContainer's add-step and add-parallel-group controls, the built-in template cards in Settings → AI, and the reassign avatars in EmailStepNode / UserStepNode. Visual parity plus Tab/Enter.

  3. The three converted links — "Select all"/"Clear all" in the API key privileges form, the open-definition icon link in Condition config usage history, the Manage Approvals link.

  4. Details panel resize handle (Approvals) — drag to resize, then collapse and click to expand.

  5. Definition editor — reorder steps by drag-and-drop, including inside a parallel group, and click a step (its keyboard path calls onClick directly rather than the mouse handler).

  6. Instance errors page in a collection context — still denied, header action absent.

  7. Message template preview (Settings → Messages) — templates still render.

  8. Approval panel (Jira + Confluence) and the list pages — visual sanity, plus Tab/Enter on elements that were previously click-only.

autoFocus in the bulk decide dialog was kept (suppressed with a reason), so there is no UX change to sign off there.

Known gaps and follow-ups

gap

note

Forge deploy steps skip validate

bitbucket-pipelines.yml:136-139 and 166-169 call npm run build:forge directly. A lint error cannot reach the default branch but cannot block a deploy either. One-line fix: use npm run build.

lib/atlassian-bridge/atlassian-forge-bridge

TypeScript project with no Biome or ESLint config at all

e2e/biome.json has linter.enabled: false

the whole Playwright suite is unlinted; left alone deliberately

files.includes is src/**/*, *.js, *.json

root-level TS config and scripts are not linted

useExhaustiveDependencies261 warnings

the standing backlog; triage opportunistically, never auto-fix