@codestrategie/a11y-widget
v1.0.1
Published
Framework-agnostic accessibility widget as a web component (Lit): text size, contrast, read aloud, reading mask, page structure and more – fully themeable, no tracking.
Maintainers
Readme
@codestrategie/a11y-widget
Framework-agnostic accessibility widget as a web component (<cs-a11y-widget>, built with Lit 3 and TypeScript).
It gives visitors aids such as bigger text, contrast modes, read aloud, a reading mask and a page-structure navigation,
and can be fully customised in colour, font, size and position. Default branding: codestrategie.de
(dark surface #1D1D1B, accent #8AE631).
Features
| Group | Feature | |---|---| | Text | Bigger text (1.25x – 2x), line height (1.3x – 2x), text alignment, readable font | | Display | Contrast (dark / light / high), grayscale, hide images, pause animations | | Orientation | Read aloud (3 speeds), highlight links, reading mask, highlight focus, sitemap, page structure |
Highlights:
- The launcher is keyboard accessible and uses correct ARIA attributes (
aria-expanded,aria-controls,aria-haspopup). - Real buttons (
role="switch",aria-pressed) instead ofdivcheckboxes; clickable step bars with arrow-key support. - Centred positions are truly centred; 8 positions, a separate mobile position,
hideOnMobile/hideOnDesktop. - The focus trap is released on close and focus returns to the launcher.
- "Hide widget" remembers the choice (session / 24 h / 1 week) and hides the launcher too; active tools stay active.
- The page structure is collected when the panel opens, not on page load.
- Grayscale without a filter on
<html>(does not breakposition: fixed, the widget stays in colour). - Bigger text / line height restore inline styles exactly and also handle content inserted later.
- Settings are persisted (
localStorage);prefers-reduced-motionand RTL are respected. - Everything lives in a Shadow DOM: page CSS and page tools do not affect the widget.
- No external requests, no tracking.
Installation
Plain HTML (<script>)
<script src="https://example.com/a11y/cs-a11y-widget.iife.js"></script>
<cs-a11y-widget statement-url="/accessibility"></cs-a11y-widget>Or mount automatically (no element of your own needed):
<script>
window.CS_A11Y_CONFIG = { primaryColor: '#2563eb', position: 'bottom-left' };
</script>
<script src="https://example.com/a11y/cs-a11y-widget.iife.js"></script>Alternatively via script attributes: <script src="…iife.js" data-auto-mount data-primary-color="#2563eb" data-position="bottom-left"></script>.
The IIFE build loads the language files at runtime (
i18n/<lang>.json), so the folderdist/i18n/must be served next tocs-a11y-widget.iife.js(see "Internationalisation").
ES module / npm
npm install @codestrategie/a11y-widgetimport '@codestrategie/a11y-widget'; // registers <cs-a11y-widget>
// or programmatically:
import { mount } from '@codestrategie/a11y-widget';
mount({ statementUrl: '/accessibility', languages: ['en', 'de', 'fr'] });Integration
The widget runs anywhere a <script> or a bundler is available. Two variants:
- IIFE build (
dist/cs-a11y-widget.iife.js) for classic websites and CMSs: one script tag, no bundler. The folderdist/i18n/must sit next to the file (the language is fetched at start-up). - ES module (
import '@codestrategie/a11y-widget') for frameworks: the bundler packs the languages as lazy chunks automatically; nothing extra has to be served.
The examples set only a few options; all others are listed under "Configuration".
HTML
Put
dist/cs-a11y-widget.iife.jsand the folderdist/i18n/on your server, e.g. under/a11y/:/a11y/cs-a11y-widget.iife.js /a11y/i18n/en.json /a11y/i18n/de.json /a11y/i18n/… (all 14 languages)Add the script and the element (before
</body>):
<script src="/a11y/cs-a11y-widget.iife.js" defer></script>
<cs-a11y-widget
statement-url="/accessibility"
skip-link-target="#main"
languages="en,de,fr"
></cs-a11y-widget>If the language files are not next to the script, set i18n-base-url="/path/to/i18n/".
WordPress
If another accessibility widget or plugin is already active, deactivate it, otherwise two widgets appear.
Preparation: copy cs-a11y-widget.iife.js and i18n/ into the (child) theme folder:
wp-content/themes/my-child-theme/a11y/cs-a11y-widget.iife.js
wp-content/themes/my-child-theme/a11y/i18n/*.jsona) Via the (child) theme's functions.php
<?php
// Load the script in the footer (strategy => defer requires WordPress >= 6.3).
add_action( 'wp_enqueue_scripts', function () {
$dir = get_stylesheet_directory() . '/a11y/cs-a11y-widget.iife.js';
$uri = get_stylesheet_directory_uri() . '/a11y/cs-a11y-widget.iife.js';
wp_enqueue_script(
'cs-a11y-widget',
$uri,
array(),
file_exists( $dir ) ? (string) filemtime( $dir ) : null,
array(
'in_footer' => true,
'strategy' => 'defer',
)
);
} );
// Output the element at body level (position: fixed!).
add_action( 'wp_footer', function () {
// Accessibility statement: the page with the slug "accessibility".
$statement_page = get_page_by_path( 'accessibility' );
$statement_url = $statement_page ? get_permalink( $statement_page ) : '';
$options = array(
'primary-color' => '#8AE631',
'statement-url' => $statement_url,
'skip-link-target' => '#main',
'languages' => 'en,de,fr', // leave empty = all 14 languages
);
$attrs = '';
foreach ( $options as $name => $value ) {
if ( '' !== $value ) {
$attrs .= sprintf( ' %s="%s"', esc_attr( $name ), esc_attr( $value ) );
}
}
echo '<cs-a11y-widget' . $attrs . '></cs-a11y-widget>' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput
}, 20 );The language follows the visitor's browser and falls back to <html lang> (WordPress sets e.g. de-DE → de).
With a language plugin (WPML, Polylang) you can also set lang per page to follow the site language explicitly.
b) Alternative: must-use plugin (independent of the theme)
Place the files:
wp-content/mu-plugins/cs-a11y-widget.php
wp-content/mu-plugins/cs-a11y-widget/cs-a11y-widget.iife.js
wp-content/mu-plugins/cs-a11y-widget/i18n/*.jsonwp-content/mu-plugins/cs-a11y-widget.php:
<?php
/**
* Plugin Name: CS A11y Widget
* Description: Accessibility widget (cs-a11y-widget).
*/
defined( 'ABSPATH' ) || exit;
add_action( 'wp_enqueue_scripts', function () {
$file = __DIR__ . '/cs-a11y-widget/cs-a11y-widget.iife.js';
wp_enqueue_script(
'cs-a11y-widget',
plugins_url( 'cs-a11y-widget/cs-a11y-widget.iife.js', __FILE__ ),
array(),
file_exists( $file ) ? (string) filemtime( $file ) : null,
array( 'in_footer' => true, 'strategy' => 'defer' )
);
} );
add_action( 'wp_footer', function () {
$page = get_page_by_path( 'accessibility' );
printf(
'<cs-a11y-widget statement-url="%s" skip-link-target="#main"></cs-a11y-widget>' . "\n",
esc_attr( $page ? get_permalink( $page ) : '' )
);
}, 20 );Must-use plugins are loaded automatically (only the PHP file directly in mu-plugins/; subfolders just hold the assets).
React
React 19 supports custom elements natively: props are set as properties if the element knows them, otherwise as attributes.
The component only has to appear once in the tree (e.g. in App).
// CsA11yWidget.tsx
import { useEffect, useRef } from 'react';
import '@codestrategie/a11y-widget'; // registers <cs-a11y-widget>
import type { CsA11yWidget as Widget, ActiveTools } from '@codestrategie/a11y-widget';
// JSX types (React 19: React.JSX)
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'cs-a11y-widget': React.DetailedHTMLProps<React.HTMLAttributes<Widget>, Widget> & {
'primary-color'?: string;
'secondary-color'?: string;
'text-color'?: string;
position?: string;
'statement-url'?: string;
'skip-link-target'?: string;
languages?: string;
'language-selector'?: string;
'i18n-base-url'?: string;
};
}
}
}
export function CsA11yWidget({ onChange }: { onChange?: (tools: ActiveTools) => void }) {
const ref = useRef<Widget>(null);
useEffect(() => {
const el = ref.current;
if (!el || !onChange) return;
const handler = (e: Event) => onChange((e as CustomEvent<{ tools: ActiveTools }>).detail.tools);
el.addEventListener('cs-a11y-change', handler);
return () => el.removeEventListener('cs-a11y-change', handler);
}, [onChange]);
return <cs-a11y-widget ref={ref} statement-url="/accessibility" languages="en,de,fr" />;
}Complex values (tools, languages as an array, translations) can be set as properties via ref, e.g.
useEffect(() => { ref.current!.translations = { en: { 'widget.title': 'Accessibility tools' } }; }, []).
Next.js / SSR: the widget needs window / customElements and must only run in the browser. Load a client
component and import the package inside an effect:
'use client';
import { useEffect } from 'react';
export default function A11yWidget() {
useEffect(() => {
void import('@codestrategie/a11y-widget'); // registers <cs-a11y-widget> in the browser only
}, []);
return <cs-a11y-widget statement-url="/accessibility" />;
}Include the component e.g. in app/layout.tsx at the end of <body>. No hydration error occurs because the element is
rendered empty on the server (it only renders after registration).
Vue 3
// vite.config.ts: vue({ template: { compilerOptions: { isCustomElement: (t) => t === 'cs-a11y-widget' } } })
import '@codestrategie/a11y-widget';<cs-a11y-widget primary-color="#2563eb" :tools="['contrast', 'bigger-text']" />Angular
main.ts:
import { bootstrapApplication } from '@angular/platform-browser';
import '@codestrategie/a11y-widget'; // registers <cs-a11y-widget>
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent);app.component.ts:
import { CUSTOM_ELEMENTS_SCHEMA, Component } from '@angular/core';
import type { ActiveTools } from '@codestrategie/a11y-widget';
@Component({
selector: 'app-root',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<router-outlet />
<cs-a11y-widget
[attr.primary-color]="primaryColor"
[attr.statement-url]="'/accessibility'"
[attr.languages]="'en,de,fr'"
[tools]="tools"
(cs-a11y-change)="onChange($event)"
></cs-a11y-widget>
`,
})
export class AppComponent {
primaryColor = '#8AE631';
tools = ['bigger-text', 'contrast', 'screen-reader']; // property binding for arrays
onChange(e: Event) {
console.log((e as CustomEvent<{ tools: ActiveTools }>).detail.tools);
}
}With Angular Universal / SSR, keep the import in main.ts (browser entry), not in main.server.ts.
Svelte
<script>
import '@codestrategie/a11y-widget';
</script>
<cs-a11y-widget primary-color="#2563eb" statement-url="/accessibility"></cs-a11y-widget>Astro
A <script> without attributes is bundled by Astro and runs in the browser only. In the layout file
(src/layouts/Layout.astro):
---
// …
---
<html lang="en">
<body>
<slot />
<cs-a11y-widget statement-url="/accessibility" skip-link-target="#main"></cs-a11y-widget>
<script>
import '@codestrategie/a11y-widget';
</script>
</body>
</html>Alternative without bundling: copy the IIFE and i18n/ to public/a11y/ and include it with is:inline:
<cs-a11y-widget statement-url="/accessibility"></cs-a11y-widget>
<script is:inline src="/a11y/cs-a11y-widget.iife.js" defer></script>Note: the widget uses
position: fixed. Ancestors withtransform,filterorperspectivechange the containing block. Render the element at body level or usemount().
Configuration
Every value can be set as an attribute (kebab-case), as a JS property (camelCase), and via mount({…}) / window.CS_A11Y_CONFIG.
| Attribute | Property | Default | Description |
|---|---|---|---|
| primary-color | primaryColor | #8AE631 | Accent: launcher, header, active states |
| secondary-color | secondaryColor | #1D1D1B | Panel background |
| text-color | textColor | #FFFFFF | Text colour on the panel |
| primary-text-color | primaryTextColor | '' (automatic) | Text/icons on the primary colour; empty = automatic: secondary or text colour if contrast is ≥ 4.5:1 (e.g. #1D1D1B on green), otherwise black/white |
| font-family | fontFamily | "Open Sans", system-ui, … | UI font |
| heading-font-family | headingFontFamily | "Funnel Display", "Open Sans", … | Heading font |
| size | size | medium | small 36 px, medium 44 px, large 64 px |
| radius | radius | null (round) | Corner radius of the launcher in px |
| icon | icon | person | person, eye, controls, text; custom icon: <svg slot="icon"> |
| position | position | bottom-right | top/center/bottom × left/center/right (without center-center) |
| mobile-position | mobilePosition | '' (= position) | Position at viewport ≤ 530 px |
| offset-x / offset-y | offsetX / offsetY | 24px | Distance from the edge (any CSS length) |
| hide-on-mobile | hideOnMobile | false | Hide the launcher at ≤ 530 px |
| hide-on-desktop | hideOnDesktop | false | Hide the launcher at > 530 px |
| z-index | zIndex | 2147483000 | Base z-index |
| lang | lang | '' | Fixed UI language (en de fr es it nl pt pl cs da sv tr uk ar); empty = automatic detection, see "Internationalisation" |
| language-selector | languageSelector | true | Language button (globe + code) in the header; turn off with language-selector="false" (special case: boolean defaulting to true; an absent attribute means on) |
| languages | languages | [] (all) | Comma-separated list of allowed languages, e.g. languages="en,de,fr"; unknown codes are ignored |
| i18n-base-url | i18nBaseUrl | '' | Folder with <lang>.json (own hosting / own texts) |
| statement-url | statementUrl | '' | Link to the accessibility statement (footer, only if set) |
| sitemap-url | sitemapUrl | '' | Enables the sitemap tool |
| tools | tools | [] (all) | Comma-separated list, e.g. tools="bigger-text,contrast"; unknown IDs are ignored |
| readable-font-family | readableFontFamily | Verdana, Tahoma, "Atkinson Hyperlegible", … | Font used by "Readable font" |
| skip-link-target | skipLinkTarget | '' | CSS selector, e.g. #main – adds a "Skip to content" link |
| storage-key | storageKey | cs-a11y | Prefix for storage keys |
| no-persist | noPersist | false | Do not persist active tools |
| – | translations | {} | Override texts: { en: { 'widget.title': '…' } } (per language, all 14 possible) |
Tool IDs: bigger-text, bigger-line-height, text-align, readable-font, contrast, grayscale, hide-images,
pause-animations, screen-reader, highlight-links, reading-mask, focus-outline, sitemap, page-structure.
Internationalisation (i18n)
The widget speaks 14 languages: English (default), German, French, Spanish, Italian, Dutch, Portuguese, Polish,
Czech, Danish, Swedish, Turkish, Ukrainian and Arabic (right-to-left). All texts are JSON files in src/i18n/<lang>.json.
Lazy per language: only the language actually needed is loaded (plus English as a fallback, see below); until it is available the widget renders nothing. ES build: each language is its own chunk (
dist/i18n/*.js). IIFE build: loaded viafetchfromi18n/<lang>.jsonnext to the script, so servedist/i18n/as well.Language detection, in this order (the first match that is supported and allowed wins):
- the language the visitor picked in the widget (persisted; only if the language selector is on and the language is allowed),
- the
langattribute on<cs-a11y-widget>, - the browser's preferred languages (
navigator.languages, in order; region tags such aspt-BRmap topt); re-evaluated on thelanguagechangeevent, <html lang>of the page,- English.
languages="en,de,fr"restricts which languages are allowed at every step.Language switcher: by default the header shows a button with a globe and the language code (e.g. "EN"). It opens a searchable list (by name and code); languages are shown in their own language. Arrow keys move the selection, Enter / Space / click apply it; the choice is saved. With
language-selector="false"or exactly one language inlanguages, the button is omitted.Right-to-left: for Arabic the panel gets
dir="rtl"(independent of the page); the panel'slangmatches the UI language.Per-key fallback: if a text is missing in a language file (e.g. a custom file or a new, not yet translated key), the English text is shown, and as a last resort the key itself – never
undefined. If a language fails to load entirely, English is used.Custom translations:
Host the files yourself: copy
dist/i18n/, adjust the texts and point to them withi18n-base-url="/path/to/i18n/"(<base>/<lang>.jsonis expected, at leasten.jsonas fallback). Language codes remain the 14 supported ones.Override single texts with the
translationsproperty (takes precedence over the files):document.querySelector('cs-a11y-widget').translations = { en: { 'widget.title': 'Accessibility' }, fr: { 'widget.title': 'Accessibilité' }, };
CSS custom properties
All colours are derived from the configuration and can be overridden from outside:
cs-a11y-widget {
--cs-primary: #2563eb;
--cs-surface: #ffffff;
--cs-text: #1d1d1b;
}| Variable | Meaning |
|---|---|
| --cs-primary / --cs-on-primary | Accent / text on it (automatically a brand colour with ≥ 4.5:1 contrast, otherwise black/white) |
| --cs-surface | Panel background (secondaryColor) |
| --cs-text | Text on the panel |
| --cs-surface-2, --cs-surface-3, --cs-border, --cs-muted, --cs-primary-tint | derived via color-mix |
| --cs-accent-on-surface | --cs-primary, or --cs-text if contrast to the surface is < 3:1 (focus, bars) |
| --cs-font, --cs-heading-font | Fonts |
Note: if you override --cs-primary from outside, also set a matching --cs-on-primary.
Methods and events
const w = document.querySelector('cs-a11y-widget');
w.open(); w.close(); w.toggle();
w.reset(); // turn all tools off
w.setTool('contrast', 'high'); // true | step ID | null
w.getActiveTools(); // { contrast: 'high', … }
w.hide('week'); // 'session' | 'day' | 'week'
w.show();| Event | detail |
|---|---|
| cs-a11y-open, cs-a11y-close | – |
| cs-a11y-change | { tools: Record<ToolId, true \| string> } |
| cs-a11y-hide | { duration } |
All events bubble and are composed.
Theming examples
<!-- Light panel with a blue accent -->
<cs-a11y-widget primary-color="#2563eb" secondary-color="#ffffff" text-color="#1d1d1b"></cs-a11y-widget>
<!-- Square text launcher bottom left, bottom centre on mobile -->
<cs-a11y-widget icon="text" size="large" radius="12" position="bottom-left" mobile-position="bottom-center"></cs-a11y-widget>
<!-- Custom icon -->
<cs-a11y-widget>
<svg slot="icon" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="8"/></svg>
</cs-a11y-widget>Comparison with other accessibility widgets
Overview
Only facts that could be verified on official sources are listed (checked on 2026-10-13; prices and plans change – see the linked sources).
| | Delivery | Licence | Account needed | Public pricing | |---|---|---|---|---| | cs-a11y-widget | Self-hosted web component, any website or framework, no external requests | MIT | No | Free | | Elementor Ally | WordPress plugin; widget script loaded from the Elementor CDN | Plugin: GPLv2+ | Yes (Elementor connection / API key) | Free plugin, paid plans for additional features | | UserWay | Hosted script (vendor CDN), WordPress plugin available | Commercial | Yes (subscription) | From 490 per year (annual plans) | | accessiBe | Hosted script (vendor CDN) | Commercial | Yes (subscription) | From USD 59 per month or USD 490 per year | | AudioEye | Hosted script | Commercial | Yes | On request (free site scan) | | EqualWeb, Eye-Able, Recite Me | Hosted scripts | Commercial | Yes | Not publicly listed / on request | | Sienna | Self-contained JS bundle (copy & paste) | MIT | No | Free |
What sets cs-a11y-widget apart from these: self-hosted without an account or third-party requests (GDPR-friendly by design), full theming via primary, secondary and text colour, fonts and CSS custom properties, a framework-agnostic web component, and 14 lazy-loaded languages with browser detection. Unlike the commercial services it includes no scanner, monitoring or automated remediation.
Detailed comparison with Elementor Ally
The feature set is comparable to the visitor-facing widget of the WordPress plugin "Ally – Web Accessibility" by Elementor. Differences (as of Ally widget 4.1.4, October 2026):
| | cs-a11y-widget | Elementor Ally (widget) |
|---|---|---|
| Platform | Any website and framework (web component) | WordPress plugin, widget script served from the Elementor CDN |
| Account / API key | Not required | Elementor connection and API key required |
| External requests, tracking | None (everything self-hosted) | Script and translations from the CDN, optional analytics |
| Colours | Primary, secondary and text colour, fonts, CSS variables | One accent colour |
| Tools | 14, incl. read aloud and a page structure with headings, landmarks and links | 14 (read aloud only in paid plans), page structure with headings only |
| Languages | 14, loaded individually, custom language files possible | Around 37, from the CDN |
| Keyboard | Launcher reachable via Tab, focus returns after closing | Launcher has tabindex="-1" and is not reachable via Tab |
| Grayscale | Page grey, widget stays in colour, position: fixed stays intact | Filter on <html>, widget is tinted as well |
| Read aloud | Tab key stays usable, texts with separators | Intercepts the Tab key |
| Hiding | Default choice is saved, launcher disappears too | Default choice "this session only" is not saved |
| Accessibility scanner, AI fixes, statement generator | Not included | Included |
All product names are trademarks of their respective owners. This project is not affiliated with any of the vendors listed above and contains no code from their products.
Read aloud: browser support
"Read aloud" uses the browser's built-in speech synthesis (Web Speech API); no audio is sent to a server by the widget. Voices come from the operating system or the browser, so quality and available languages differ per device.
| Browser | Support | Notes |
|---|---|---|
| Chrome, Edge, Opera (desktop) | Yes | Uses OS voices plus, in Chrome, Google online voices (these may send text to Google). |
| Firefox (desktop) | Yes | On Linux only with speech-dispatcher installed. |
| Safari (macOS, iOS, iPadOS) | Yes | Speech must be started by a tap/click; on iPhone it may be muted while the ring/silent switch is on. |
| Chrome, Samsung Internet, Firefox (Android) | Yes | Voices from the Android text-to-speech engine. |
| In-app browsers on Android (Instagram, Facebook, LinkedIn … – Android WebView) | No | The tool is hidden automatically. |
| Chromium/Firefox on Linux without voices | Partly | The API exists but has no voices; the widget announces that read aloud is unavailable. |
Behaviour that applies everywhere:
- Browsers only allow speech after a user interaction. When the setting is restored on page load, the widget stays quiet until the visitor clicks or focuses something.
- The tool reads what is clicked or focused and supports
Alt+↑/Alt+↓to move through the page;Escstops speaking. On touch devices, tapping a link reads it and follows it at the same time. - It complements, but does not replace, real screen readers (NVDA, JAWS, VoiceOver, TalkBack).
Accessibility and limitations
- The widget itself targets WCAG 2.2 AA: contrast, visible focus indicators, target sizes ≥ 44 px, keyboard operation, reduced motion, RTL.
- Overlay widgets do not make a website accessible. They add personalisation options but replace neither semantically correct HTML, sufficient contrast, nor testing with assistive technologies. An accessibility statement is still needed. Using this widget does not by itself make a site compliant with any law or standard. Do not market it as a compliance solution: in 2025 the US FTC ordered an overlay vendor to pay USD 1 million over claims that its AI widget could make any website WCAG-compliant.
- Text enlargement, reading mask etc. work with inline styles and overlays and can collide with heavily script-driven layouts.
- "Read aloud" uses the browser's Web Speech API – see Read aloud: browser support.
- GIFs are not affected by "Pause animations"; CSS background images stay visible with "Hide images".
- Sites with a strict CSP: the widget injects
<style>elements; adjuststyle-srcor use a nonce concept if necessary. The IIFE build needsconnect-srcfor the language folder.
Development
npm install
npm run dev # demo with configurator: http://localhost:5173/demo/
npm run typecheck
npm run build # dist/cs-a11y-widget.js (ES), dist/cs-a11y-widget.iife.js + dist/i18n/*.json, dist/typesVersioning
Semantic Versioning; commits follow Conventional Commits
(feat:, fix:, docs:, chore: …). Breaking changes are marked with ! or BREAKING CHANGE:.
See CHANGELOG.md.
Releasing
Releases are prepared by the CI pipeline and published after a manual 2FA approval (npm staged publishing):
- Bump the version:
npm version <patch|minor|major> --no-git-tag-versionand add an entry toCHANGELOG.md. - Commit:
git commit -am "chore(release): X.Y.Z". - Tag and push:
git tag -a vX.Y.Z -m vX.Y.Z && git push --follow-tags– or create a release with tagvX.Y.Zin the GitLab UI. - The pipeline builds the package, checks that the tag matches
package.jsonand stages it on npm. - A maintainer approves the staged version with 2FA on npmjs.com (Staged Packages) or with
npm stage approve <stage-id>(list withnpm stage list @codestrategie/a11y-widget). Only then is it installable.
Pre-release versions (1.1.0-beta.1) are staged under the next dist-tag, stable versions under latest.
The pipeline needs a masked, protected CI/CD variable NPM_TOKEN: an npm granular access token with
"Read and write (stage only)" for the @codestrategie scope and "Bypass 2FA" disabled. Protect the tag
pattern v* so only maintainers can start a release.
