@a11yfred/neighbor 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-05-12
4
+
5
+ ### New rule
6
+
7
+ | Rule | What it catches |
8
+ |---|---|
9
+ | `ulam/no-forced-colors-none` | `forced-color-adjust: none` inside `@media (forced-colors)` — actively opts out of Windows High Contrast Mode |
10
+
11
+ ### Severity changes
12
+
13
+ 10 rules moved from `warn` to `off` in the recommended config — they flag real problems but are too noisy for most codebases by default. All remain available to opt in individually:
14
+
15
+ `no-application-role`, `no-grid-role`, `no-aria-roledescription`, `no-aria-readonly`, `no-tab-without-controls`, `no-href-hash`, `warn-role-alert`, `prefer-aria-disabled`, `no-target-blank-without-label`, `no-dialog-without-close`
16
+
17
+ `no-tooltip-role-misuse` and `no-menu-role-on-nav` remain on as warns.
18
+
19
+ ### Docs
20
+
21
+ - WCAG SC and HTML spec links added throughout README and RULES.md
22
+ - CONTRIBUTING.md, PR template, and issue templates added
23
+ - README table of contents added
24
+ - @ulam described as a JavaScript framework (not React-based)
25
+
26
+ ---
27
+
3
28
  ## 0.2.0 — 2026-05-12
4
29
 
5
30
  ### New rules
@@ -0,0 +1,115 @@
1
+ # Contributing to @a11yfred/neighbor
2
+
3
+ Neighbor is maintained by [@a11yfred](https://github.com/a11yfred). Contributions are welcome from the accessibility community — practitioners, AT users, spec readers, and people who have found a gap in existing tooling.
4
+
5
+ ## What belongs here
6
+
7
+ A rule belongs in neighbor if it meets all three criteria:
8
+
9
+ 1. **Statically detectable** — the violation can be identified from markup/code alone, without a browser or AT. Runtime-only failures (color contrast, focus order in the DOM) belong in axe-core.
10
+ 2. **Not already covered** — jsx-a11y, vuejs-accessibility, @angular-eslint/template, or axe-core doesn't already flag it in a recommended config.
11
+ 3. **Expert-backed** — there's a WCAG SC, ARIA spec citation, or clear consensus from accessibility practitioners (Roselli, O'Hara, Lauke, Sutton, Pickering, Groves, Eggert, etc.).
12
+
13
+ If you're unsure, open an issue before writing a rule. A brief description and a source is enough to start a conversation.
14
+
15
+ ## What doesn't belong here
16
+
17
+ - Rules that require runtime information (computed styles, DOM layout, AT output)
18
+ - Rules already in jsx-a11y recommended — neighbor extends it, not replaces it
19
+ - Opinionated style rules without a clear accessibility impact
20
+ - Rules with very high false-positive rates on real codebases (see the rejected rules list in [RULES.md](RULES.md))
21
+
22
+ ## Setup
23
+
24
+ ```bash
25
+ git clone https://github.com/a11yfred/neighbor.git
26
+ cd neighbor
27
+ npm install
28
+ ```
29
+
30
+ No build step. Rules are plain ES modules — edit and run ESLint directly.
31
+
32
+ ## How rules are structured
33
+
34
+ All ESLint rules live in [`lib/rules.js`](lib/rules.js) as factory functions:
35
+
36
+ ```js
37
+ export function makeMyNewRule(h) {
38
+ return {
39
+ meta: {
40
+ type: 'problem', // 'problem' | 'suggestion'
41
+ docs: { description: '...' },
42
+ messages: { myMessage: '...' },
43
+ schema: [],
44
+ },
45
+ create(context) {
46
+ return {
47
+ [h.elementVisitor](node) {
48
+ // h adapts the rule to React/Vue/Angular ASTs
49
+ },
50
+ }
51
+ },
52
+ }
53
+ }
54
+ ```
55
+
56
+ The `h` adapter gives you a uniform interface across all three frameworks:
57
+
58
+ | Helper | Returns |
59
+ |---|---|
60
+ | `h.getAttr(node, name)` | attribute node or `null` |
61
+ | `h.getAttrStringValue(attr)` | string or `null` (null for dynamic expressions) |
62
+ | `h.getElementName(node)` | lowercase tag name, or `null` for custom components |
63
+ | `h.hasAttr(node, name)` | boolean |
64
+ | `h.getRoleValue(node)` | role string or `null` |
65
+ | `h.hasAccessibleName(node)` | boolean — checks `aria-label` / `aria-labelledby` |
66
+ | `h.isInteractiveElement(node)` | boolean |
67
+ | `h.getParent(node)` | parent element node or `null` |
68
+ | `h.getAncestors(node)` | iterable of ancestor element nodes, root-ward |
69
+ | `h.getChildOpeningElements(node)` | iterable of direct child element nodes |
70
+ | `h.getInnerHtmlAttr(node)` | `dangerouslySetInnerHTML` / `v-html` / `[innerHTML]` node or `null` |
71
+ | `h.elementVisitor` | AST node type string for `create()` visitor key |
72
+ | `h.elementWithChildrenVisitor` | visitor key for rules that need child access |
73
+
74
+ **Angular caveat:** `getParent()` and `getAncestors()` return `null`/nothing for Angular — the template parser doesn't attach parent pointers. Rules that require ancestor walking should degrade gracefully (skip the check, don't throw).
75
+
76
+ After writing your factory:
77
+
78
+ 1. Add it to `RULE_FACTORIES` at the bottom of `lib/rules.js`
79
+ 2. Add it to `buildRecommendedRules()` at the appropriate severity (`'error'` / `'warn'` / `'off'`)
80
+ 3. If it's Vue/Angular-only (porting a jsx-a11y gap), add it to `buildPortabilityRules()` instead
81
+
82
+ Stylelint rules live in [`neighbor-stylelint.mjs`](neighbor-stylelint.mjs) and use the PostCSS AST directly. See the existing rules for the pattern.
83
+
84
+ ## Severity guidance
85
+
86
+ | Severity | When to use |
87
+ |---|---|
88
+ | `error` | Unambiguous AT breakage — a phantom control, broken name computation, HTML spec violation. No legitimate override. |
89
+ | `warn` | Strong guidance with a clear accessibility basis, but real codebases occasionally have justified exceptions. |
90
+ | `off` | Real problem, but fires too often on legitimate patterns to be on by default. Make it available; let teams opt in. |
91
+
92
+ When in doubt, start at `warn`. It's easier to promote a rule to `error` than to demote it after people have already configured it.
93
+
94
+ ## Commit style
95
+
96
+ ```
97
+ feat: add no-my-new-rule (short description)
98
+ fix: correct false positive in no-existing-rule
99
+ docs: update RULES.md for no-my-new-rule
100
+ ```
101
+
102
+ No ticket numbers required.
103
+
104
+ ## Opening a PR
105
+
106
+ Use the PR template. The key things:
107
+
108
+ - **What problem does this flag?** Link a WCAG SC, ARIA spec section, or expert source.
109
+ - **Why can't axe-core catch it at runtime instead?** (If it can, it probably belongs there.)
110
+ - **What are the false-positive cases?** Be honest — we'd rather move a rule to `off` than reject it.
111
+ - **Does it degrade gracefully for Angular?** (Parent walking unavailable.)
112
+
113
+ ## Questions
114
+
115
+ Open an issue or reach out in the [Web A11y Slack](https://web-a11y.slack.com). The `#tools` channel is a good place to discuss rule ideas before writing code.
package/README.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  Neighbor is an accessibility linting plugin for ESLint and Stylelint that builds on jsx-a11y. It looks to cover gaps: bad ARIA patterns, live region misuse, missing names on roles, and CSS that removes focus indicators. It also brings that coverage to Vue and Angular, where jsx-a11y does not apply.
4
4
 
5
+ Some rules are specific to **@ulam** — an upcoming JavaScript framework by the same author. Those rules are prefixed `no-announce-in-render`, `no-hash-router-in-remix`, and `no-use-page-title-in-remix`. They activate only when @ulam-related imports are detected and are harmless in non-@ulam projects.
6
+
7
+ ## Contents
8
+
9
+ - [Install](#install)
10
+ - [Entry points](#entry-points)
11
+ - [Setup](#setup)
12
+ - [React / JSX](#react--jsx)
13
+ - [Remix 2](#remix-2)
14
+ - [Remix 3](#remix-3)
15
+ - [Vue](#vue)
16
+ - [Angular](#angular)
17
+ - [Stylelint](#stylelint)
18
+ - [Peer dependencies](#peer-dependencies)
19
+ - [What neighbor adds](#what-neighbor-adds)
20
+ - [ESLint — React / JSX](#eslint--react--jsx)
21
+ - [ESLint — Remix 2](#eslint--remix-2)
22
+ - [ESLint — Vue SFCs](#eslint--vue-sfcs)
23
+ - [ESLint — Angular templates](#eslint--angular-templates)
24
+ - [Stylelint — CSS](#stylelint--css)
25
+ - [Rule severity](#rule-severity)
26
+ - [Contributing](CONTRIBUTING.md)
27
+ - [See also](#see-also)
28
+ - [License](#license)
29
+
5
30
  ## Install
6
31
 
7
32
  ```bash
@@ -165,53 +190,53 @@ Base: `eslint-plugin-jsx-a11y`
165
190
 
166
191
  | What it checks | Rule | WCAG SC |
167
192
  | --- | --- | --- |
168
- | `aria-disabled` keeps element reachable | `prefer-aria-disabled` | 2.1.1 |
169
- | `aria-disabled` must block click handler | `no-unblocked-aria-disabled` | 2.1.1 |
170
- | `aria-label` on a generic element with no role | `no-aria-label-on-generic` | 1.3.1 |
171
- | `role="alert"` overuse | `warn-role-alert` | 4.1.3 |
172
- | `aria-live="assertive"` outside `role="alert"` | `no-assertive-live-overuse` | 4.1.3 |
173
- | `role="dialog"` requires accessible name | `no-roles-without-name` | 4.1.2 |
174
- | `role="group"` with form controls requires name | `no-group-without-name` | 1.3.1 |
175
- | `role="tooltip"` requires `id` on the tooltip | `no-tooltip-role-misuse` | 4.1.2 |
193
+ | `aria-disabled` keeps element reachable | `prefer-aria-disabled` | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
194
+ | `aria-disabled` must block click handler | `no-unblocked-aria-disabled` | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
195
+ | `aria-label` on a generic element with no role | `no-aria-label-on-generic` | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
196
+ | `role="alert"` overuse | `warn-role-alert` | [4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
197
+ | `aria-live="assertive"` outside `role="alert"` | `no-assertive-live-overuse` | [4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
198
+ | `role="dialog"` requires accessible name | `no-roles-without-name` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
199
+ | `role="group"` with form controls requires name | `no-group-without-name` | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
200
+ | `role="tooltip"` requires `id` on the tooltip | `no-tooltip-role-misuse` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
176
201
  | `role="application"` disables AT browse mode | `no-application-role` | — |
177
202
  | `role="grid"` almost always wrong | `no-grid-role` | — |
178
- | `role="menu"` on nav triggers wrong AT mode | `no-menu-role-on-nav` | 2.1.1 |
179
- | `role="presentation"` on a focusable element | `no-presentation-on-focusable` | 2.1.1 |
180
- | `role="log"` must not contain interactive children | `no-log-with-interactive-children` | 4.1.2 |
181
- | `role="img"` requires accessible name | `no-image-role-without-name` | 4.1.2 |
182
- | `role="tab"` requires `aria-selected` | `no-tabs-without-structure` | 4.1.2 |
183
- | `role="tab"` should declare `aria-controls` | `no-tab-without-controls` | 4.1.2 |
184
- | `role="combobox"` requires `aria-expanded` | `no-combobox-without-expanded` | 4.1.2 |
185
- | `role="slider"` requires value range attributes | `no-slider-without-range` | 4.1.2 |
186
- | `role="spinbutton"` requires value range attributes | `no-spinbutton-without-range` | 4.1.2 |
187
- | `role="listbox"` requires `role="option"` children | `no-listbox-without-option` | 4.1.2 |
188
- | `role="tree"` requires `role="treeitem"` children | `no-tree-without-treeitem` | 4.1.2 |
189
- | `role="feed"` requires `role="article"` children | `no-feed-without-article` | 4.1.2 |
203
+ | `role="menu"` on nav triggers wrong AT mode | `no-menu-role-on-nav` | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
204
+ | `role="presentation"` on a focusable element | `no-presentation-on-focusable` | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
205
+ | `role="log"` must not contain interactive children | `no-log-with-interactive-children` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
206
+ | `role="img"` requires accessible name | `no-image-role-without-name` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
207
+ | `role="tab"` requires `aria-selected` | `no-tabs-without-structure` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
208
+ | `role="tab"` should declare `aria-controls` | `no-tab-without-controls` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
209
+ | `role="combobox"` requires `aria-expanded` | `no-combobox-without-expanded` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
210
+ | `role="slider"` requires value range attributes | `no-slider-without-range` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
211
+ | `role="spinbutton"` requires value range attributes | `no-spinbutton-without-range` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
212
+ | `role="listbox"` requires `role="option"` children | `no-listbox-without-option` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
213
+ | `role="tree"` requires `role="treeitem"` children | `no-tree-without-treeitem` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
214
+ | `role="feed"` requires `role="article"` children | `no-feed-without-article` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
190
215
  | `aria-hidden="true"` + `role="none"` is redundant | `no-redundant-aria-hidden-with-presentation` | — |
191
216
  | `aria-roledescription` does not translate | `no-aria-roledescription` | — |
192
217
  | `aria-readonly` has poor AT support | `no-aria-readonly` | — |
193
- | `aria-owns` on a void element | `no-aria-owns-on-void` | 4.1.2 |
194
- | `aria-activedescendant` requires a non-empty static ID | `no-aria-activedescendant-without-id` | 4.1.2 |
195
- | `aria-required` only valid on form-control roles | `no-aria-required-on-non-form` | 4.1.2 |
196
- | `<a>` with only aria-hidden children | `no-aria-hidden-in-link` | 4.1.2 |
197
- | `<button>` with only aria-hidden children | `no-empty-button` | 4.1.2 |
198
- | `<input>` placeholder used as sole label | `no-placeholder-only` | 1.3.1 |
199
- | `<input>` with invalid type value | `no-input-type-invalid` | 1.3.5 |
200
- | `<button>` in a form missing explicit type | `no-button-type-missing` | HTML spec |
201
- | `<summary>` outside `<details>` | `no-summary-without-details` | 2.1.1 |
202
- | `<a href="#">` used as a button | `no-href-hash` | 2.1.1 |
203
- | `target="_blank"` without new-tab disclosure | `no-target-blank-without-label` | 3.2.2 |
204
- | Duplicate `id` breaks ARIA relationships | `no-duplicate-id` | 1.3.1 |
205
- | Positive `tabIndex` breaks tab order | `no-positive-tabindex` | 2.4.3 |
206
- | Heading inside an interactive element | `no-heading-inside-interactive` | 4.1.2 |
207
- | `title` attribute as the only accessible name | `no-title-as-label` | 2.4.6 |
208
- | `<video>` or `<audio autoplay>` without controls | `no-autoplay-without-controls` | 1.4.2 |
209
- | Mouse-only events without keyboard equivalents | `no-mouse-only-events` | 2.1.1 |
210
- | `aria-labelledby`/`describedby` references missing `id` | `no-labelledby-missing-target` | 4.1.2 |
211
- | `dangerouslySetInnerHTML` outside a live region | `no-dynamic-content-without-live` | 4.1.3 |
212
- | Multiple `<label>` elements for the same control | `form-field-multiple-labels` | 1.3.1 |
213
- | `<th>` or header role with no accessible text | `no-empty-table-header` | 1.3.1 |
214
- | `announce()` called in component render body | `no-announce-in-render` | 4.1.3 |
218
+ | `aria-owns` on a void element | `no-aria-owns-on-void` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
219
+ | `aria-activedescendant` requires a non-empty static ID | `no-aria-activedescendant-without-id` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
220
+ | `aria-required` only valid on form-control roles | `no-aria-required-on-non-form` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
221
+ | `<a>` with only aria-hidden children | `no-aria-hidden-in-link` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
222
+ | `<button>` with only aria-hidden children | `no-empty-button` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
223
+ | `<input>` placeholder used as sole label | `no-placeholder-only` | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
224
+ | `<input>` with invalid type value | `no-input-type-invalid` | [1.3.5](https://www.w3.org/WAI/WCAG21/Understanding/identify-input-purpose) |
225
+ | `<button>` in a form missing explicit type | `no-button-type-missing` | [HTML spec](https://html.spec.whatwg.org/multipage/form-elements.html#the-button-element) |
226
+ | `<summary>` outside `<details>` | `no-summary-without-details` | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
227
+ | `<a href="#">` used as a button | `no-href-hash` | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
228
+ | `target="_blank"` without new-tab disclosure | `no-target-blank-without-label` | [3.2.2](https://www.w3.org/WAI/WCAG21/Understanding/on-input) |
229
+ | Duplicate `id` breaks ARIA relationships | `no-duplicate-id` | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
230
+ | Positive `tabIndex` breaks tab order | `no-positive-tabindex` | [2.4.3](https://www.w3.org/WAI/WCAG21/Understanding/focus-order) |
231
+ | Heading inside an interactive element | `no-heading-inside-interactive` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
232
+ | `title` attribute as the only accessible name | `no-title-as-label` | [2.4.6](https://www.w3.org/WAI/WCAG21/Understanding/headings-and-labels) |
233
+ | `<video>` or `<audio autoplay>` without controls | `no-autoplay-without-controls` | [1.4.2](https://www.w3.org/WAI/WCAG21/Understanding/audio-control) |
234
+ | Mouse-only events without keyboard equivalents | `no-mouse-only-events` | [2.1.1](https://www.w3.org/WAI/WCAG21/Understanding/keyboard) |
235
+ | `aria-labelledby`/`describedby` references missing `id` | `no-labelledby-missing-target` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
236
+ | `dangerouslySetInnerHTML` outside a live region | `no-dynamic-content-without-live` | [4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
237
+ | Multiple `<label>` elements for the same control | `form-field-multiple-labels` | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
238
+ | `<th>` or header role with no accessible text | `no-empty-table-header` | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
239
+ | `announce()` called in component render body | `no-announce-in-render` | [4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
215
240
 
216
241
  ### ESLint — Remix 2
217
242
 
@@ -230,16 +255,16 @@ Neighbor adds everything in the React table above, adapted for Vue's AST (`v-htm
230
255
 
231
256
  | What it checks | Rule | WCAG SC |
232
257
  | --- | --- | --- |
233
- | Ambiguous link text ("click here", "read more") | `no-anchor-ambiguous-text` | 2.4.4 |
234
- | `<a>` with no content and no accessible name | `no-anchor-no-content` | 4.1.2 |
235
- | Invalid ARIA attribute values | `no-invalid-aria-prop-value` | 4.1.2 |
236
- | Invalid `autocomplete` token | `no-autocomplete-invalid` | 1.3.5 |
237
- | Heading with no content | `no-heading-no-content` | 2.4.6 |
238
- | `<iframe>` without `title` | `no-iframe-no-title` | 4.1.2 |
239
- | Alt text contains "image", "photo" | `no-img-redundant-alt` | 1.1.1 |
240
- | `accessKey` attribute | `no-access-key` | 2.1.4 |
241
- | `scope` on `<td>` (only valid on `<th>`) | `no-scope-on-td` | 1.3.1 |
242
- | `announce()` called outside `onMounted`/`watch`/handler | `no-announce-in-render` | 4.1.3 |
258
+ | Ambiguous link text ("click here", "read more") | `no-anchor-ambiguous-text` | [2.4.4](https://www.w3.org/WAI/WCAG21/Understanding/link-purpose-in-context) |
259
+ | `<a>` with no content and no accessible name | `no-anchor-no-content` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
260
+ | Invalid ARIA attribute values | `no-invalid-aria-prop-value` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
261
+ | Invalid `autocomplete` token | `no-autocomplete-invalid` | [1.3.5](https://www.w3.org/WAI/WCAG21/Understanding/identify-input-purpose) |
262
+ | Heading with no content | `no-heading-no-content` | [2.4.6](https://www.w3.org/WAI/WCAG21/Understanding/headings-and-labels) |
263
+ | `<iframe>` without `title` | `no-iframe-no-title` | [4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value) |
264
+ | Alt text contains "image", "photo" | `no-img-redundant-alt` | [1.1.1](https://www.w3.org/WAI/WCAG21/Understanding/non-text-content) |
265
+ | `accessKey` attribute | `no-access-key` | [2.1.4](https://www.w3.org/WAI/WCAG21/Understanding/character-key-shortcuts) |
266
+ | `scope` on `<td>` (only valid on `<th>`) | `no-scope-on-td` | [1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships) |
267
+ | `announce()` called outside `onMounted`/`watch`/handler | `no-announce-in-render` | [4.1.3](https://www.w3.org/WAI/WCAG21/Understanding/status-messages) |
243
268
 
244
269
  ### ESLint — Angular templates
245
270
 
@@ -253,8 +278,9 @@ Neighbor adds the same rule set as Vue, adapted for Angular's template AST (`[in
253
278
 
254
279
  | Rule | What it checks |
255
280
  | --- | --- |
256
- | `ulam/user-preferences` | Warns when motion, transparency, or alpha colors are used without `@media (prefers-*)` fallbacks |
257
- | `ulam/no-outline-none` | Disallows bare `outline: none` or `outline: 0` outside `:focus` selectors |
281
+ | `ulam/user-preferences` | Warns when motion, transparency, or alpha colors are used without `@media (prefers-*)` fallbacks — [SC 1.4.3](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum) / [SC 2.3.3](https://www.w3.org/WAI/WCAG21/Understanding/animation-from-interactions) |
282
+ | `ulam/no-outline-none` | Disallows bare `outline: none` or `outline: 0` outside `:focus` selectors — [SC 2.4.7](https://www.w3.org/WAI/WCAG21/Understanding/focus-visible) |
283
+ | `ulam/no-forced-colors-none` | Disallows `forced-color-adjust: none` inside `@media (forced-colors)` — opts out of Windows High Contrast Mode — [SC 1.4.11](https://www.w3.org/WAI/WCAG21/Understanding/non-text-contrast) |
258
284
 
259
285
  ## Rule severity
260
286
 
@@ -262,6 +288,7 @@ Neighbor adds the same rule set as Vue, adapted for Angular's template AST (`[in
262
288
  | --- | --- |
263
289
  | `error` | Definite AT breakage or HTML spec violation |
264
290
  | `warn` | Strong guidance, occasional legitimate overrides exist |
291
+ | `off` | Available but disabled — too noisy for most codebases, enable if it fits your project |
265
292
 
266
293
  All rules can be overridden in your config.
267
294
 
package/lib/rules.js CHANGED
@@ -2376,17 +2376,19 @@ export function buildRecommendedRules(ns) {
2376
2376
  [`${ns}/no-button-type-missing`]: 'warn',
2377
2377
  // warnings — strong guidance, occasional legitimate overrides
2378
2378
  [`${ns}/no-tooltip-role-misuse`]: 'warn',
2379
- [`${ns}/no-application-role`]: 'warn',
2380
- [`${ns}/no-grid-role`]: 'warn',
2381
2379
  [`${ns}/no-menu-role-on-nav`]: 'warn',
2382
- [`${ns}/no-aria-roledescription`]: 'warn',
2383
- [`${ns}/no-aria-readonly`]: 'warn',
2384
- [`${ns}/no-tab-without-controls`]: 'warn',
2385
- [`${ns}/no-href-hash`]: 'warn',
2386
- [`${ns}/warn-role-alert`]: 'warn',
2387
- [`${ns}/prefer-aria-disabled`]: 'warn',
2388
- [`${ns}/no-target-blank-without-label`]: 'warn',
2389
- [`${ns}/no-dialog-without-close`]: 'warn',
2380
+ // off by default — available to opt in, but noisy in real codebases
2381
+ // enable individually if the pattern applies to your project
2382
+ [`${ns}/no-application-role`]: 'off',
2383
+ [`${ns}/no-grid-role`]: 'off',
2384
+ [`${ns}/no-aria-roledescription`]: 'off',
2385
+ [`${ns}/no-aria-readonly`]: 'off',
2386
+ [`${ns}/no-tab-without-controls`]: 'off',
2387
+ [`${ns}/no-href-hash`]: 'off',
2388
+ [`${ns}/warn-role-alert`]: 'off',
2389
+ [`${ns}/prefer-aria-disabled`]: 'off',
2390
+ [`${ns}/no-target-blank-without-label`]: 'off',
2391
+ [`${ns}/no-dialog-without-close`]: 'off',
2390
2392
  }
2391
2393
  }
2392
2394
 
@@ -193,4 +193,64 @@ const noOutlineNone = {
193
193
  meta: noOutlineNoneMeta,
194
194
  };
195
195
 
196
- export default [userPreferences, noOutlineNone];
196
+ // ─── Rule: ulam/no-forced-colors-none ────────────────────────────────────────
197
+ // forced-color-adjust: none inside @media (forced-colors) actively opts out of
198
+ // Windows High Contrast Mode and other forced-colors user settings. For users
199
+ // who depend on forced colors this is their last resort for viewing content —
200
+ // overriding it is a serious accessibility regression.
201
+ //
202
+ // Legitimate narrow exceptions exist (e.g. color pickers where all swatches
203
+ // would collapse to CanvasText). Those should be scoped tightly to the specific
204
+ // element, not a whole section, and are typically few enough to suppress inline.
205
+ //
206
+ // Ref: Sarah Higley — forced-color-adjust: none (sarahmhigley.com)
207
+ // Adrian Roselli — WHCM and System Colors (adrianroselli.com)
208
+ // WCAG SC 1.4.11 Non-text Contrast; SC 1.4.3 Contrast (Minimum)
209
+
210
+ const noForcedColorsNoneRuleName = 'ulam/no-forced-colors-none';
211
+
212
+ const noForcedColorsNoneMessages = {
213
+ none: (selector) =>
214
+ `forced-color-adjust: none on "${selector}" inside @media (forced-colors) opts out of ` +
215
+ `Windows High Contrast Mode, removing all forced-color overrides for these elements. ` +
216
+ `Users who depend on forced colors lose visibility entirely. ` +
217
+ `Remove forced-color-adjust: none, or scope it to the narrowest possible element ` +
218
+ `(e.g. a color-picker swatch) and add a comment explaining why. ` +
219
+ `(Higley / Roselli — WCAG SC 1.4.11 / SC 1.4.3)`,
220
+ };
221
+
222
+ const noForcedColorsNoneMeta = { url: 'https://github.com/a11yfred/neighbor' };
223
+
224
+ /** Returns true if the node is directly inside a @media (forced-colors) block. */
225
+ function insideForcedColorsMedia(node) {
226
+ let current = node.parent;
227
+ while (current) {
228
+ if (
229
+ current.type === 'atrule' &&
230
+ current.name === 'media' &&
231
+ /forced-colors/.test(current.params)
232
+ ) return true;
233
+ current = current.parent;
234
+ }
235
+ return false;
236
+ }
237
+
238
+ /** @type {import('stylelint').Rule} */
239
+ function noForcedColorsNoneRule(_primaryOption) {
240
+ return (root, result) => {
241
+ root.walkDecls(/^forced-color-adjust$/i, (decl) => {
242
+ if (decl.value.trim().toLowerCase() !== 'none') return;
243
+ if (!insideForcedColorsMedia(decl)) return;
244
+ const selector = decl.parent?.selector ?? decl.parent?.name ?? '(unknown)';
245
+ decl.warn(result, noForcedColorsNoneMessages.none(selector), { rule: noForcedColorsNoneRuleName });
246
+ });
247
+ };
248
+ }
249
+
250
+ const noForcedColorsNone = {
251
+ ruleName: noForcedColorsNoneRuleName,
252
+ rule: noForcedColorsNoneRule,
253
+ meta: noForcedColorsNoneMeta,
254
+ };
255
+
256
+ export default [userPreferences, noOutlineNone, noForcedColorsNone];
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@a11yfred/neighbor",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Accessibility linting plugin for a11yfred — Stylelint (user-preference fallbacks) and ESLint (bad ARIA patterns). Won't you be my neighbor?",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
- "files": ["lib", "*.mjs", "LICENSE", "README.md", "CHANGELOG.md"],
7
+ "files": ["lib", "*.mjs", "LICENSE", "README.md", "CHANGELOG.md", "CONTRIBUTING.md"],
8
8
  "main": "./neighbor-stylelint.mjs",
9
9
  "repository": {
10
10
  "type": "git",