Skip to content

Screen-reader pass for the two needs-runtime-check a11y items from #1080 #1245

Description

@vivek7405

Problem

Two findings from the full @webjsdev/ui accessibility audit (#1080) were marked needs-runtime-check: verify against a real computed accessibility tree and a real screen reader BEFORE changing anything, explicitly do not fix blind. PR #1230 closed the other nine findings and deliberately left these two untouched, so they are still open.

All line anchors below were re-verified against HEAD 105372de ("refactor(framework): overhaul WebJs framework architecture following SOLID, KISS, and DRY principles", #1376). #1376 barrelled ten framework monoliths into sibling directories, but it did NOT touch packages/ui/packages/registry/: the last commit to reach dialog.ts, alert-dialog.ts and sonner.ts is f4a6e0cd (#1230). So every registry anchor in the earlier revision of this body still holds, and the anchors that moved are all outside the registry. The corrections are listed at the end of this section.

1. Double-role nesting in dialog and alert-dialog. Both components render a native <dialog> wrapping an inner <div> that carries the ARIA.

packages/ui/packages/registry/components/dialog.ts render() at L518-546:

    return html`<dialog
      data-slot="dialog-native"            // L522, implicit role=dialog from the platform
      class=${NATIVE_DIALOG_CLASS}
      ${ref(this.#dialog)}
      @close=${this._onNativeClose}
      @click=${this._onNativeBackdropClick}
    ><div
      data-slot="dialog-content"           // L528
      role="dialog"                        // L529, a SECOND dialog role
      aria-modal="true"                    // L530
      tabindex="-1"                        // L531
      data-state=${parentOpen ? 'open' : 'closed'}
      class=${dialogContentClass()}
    >

packages/ui/packages/registry/components/alert-dialog.ts render() at L471-488 is the same shape, with data-slot="alert-dialog-native" at L474 and the inner role="alertdialog" at L481.

The worry recorded in #1080 was that the outer implicit role=dialog might be the one assistive technology reports, so the alertdialog role never reaches the user. That matters because alert-dialog also BLOCKS Escape by design (the native cancel handler at alert-dialog.ts:490-492), so a user told they are in a plain dialog is told they can dismiss it in a way they cannot.

This has now been MEASURED against Chrome's computed accessibility tree (see Design / approach for the probe and its output). Chrome exposes BOTH nodes, and the inner role is NOT masked. The real defect is the redundant nesting rather than a lost role.

2. Nested live regions in sonner. The toast viewport at packages/ui/packages/registry/components/sonner.ts L253-261 is a persistent live region:

    return html`<div
      data-slot="sonner"
      role="region"                        // L255
      aria-label="Notifications"           // L256
      aria-live="polite"                   // L257
      aria-relevant="additions text"       // L258
      aria-atomic="false"                  // L259

and each toast inside it carries its own role at L269:

          role=${item.type === 'error' ? 'alert' : 'status'}

Both alert and status are implicit live regions, so a default toast sits in a live region inside a live region. Some screen readers double-announce that shape. The tree half of this has now been measured too: a role="status" toast really does become its own live root nested inside the viewport's, and a toast with no role does not.

Neither finding can be settled by reading the code or by a DOM-attribute assertion, which is why #1080 fenced them off, and both are cases where the obvious change makes things worse. Flattening the dialog nesting the wrong way loses the accessible name that demonstrably works today, including the generic-name fallback added in #1230. Removing a live region from sonner the wrong way silences a toast instead of de-duplicating it, since it is the nested role="alert" that makes an error toast assertive inside a polite viewport.

Corrections to the previous revision of this body

Everything in packages/ui/packages/registry/components/ verified UNCHANGED and correct: dialog.ts render() L518-546, the native/inner pair at L521-522 and L527-530, showModal() L507-511, _native() L503-505, wireDialogLabels() L121-161 with the generic fallback at L158-160, wireDialogDescription() L108-119, the A11y block L40-49; alert-dialog.ts render() L471-488, the role pair L473-474 and L479-482, _wireLabels() L427-464 with the panel lookup at L428 and the fallback at L461-463, _wireDescription() L414-425, showModal() L399-403 (the _wireLabels() call is L400), _native() L395-397, the cancel block L490-492, the A11y block L47-57; sonner.ts viewport L253-261 with the ARIA at L255-259, the per-toast role at L269, the render comment L248-252, the A11y block L33-42. packages/ui/test/ssr-aria.test.js exists (126 lines) and its header comment does document the nullish-hole divergence. packages/ui/test/registry-contents.test.js asserts the A11y block at L359-366 (present) and L372-379 (above @example). packages/ui/test/e2e/touch.e2e.mjs is unchanged at 159 lines. packages/ui/package.json declares exactly two scripts, test and test:e2e:touch.

Stale, now corrected:

  • .github/workflows/ci.yml: the "Run ui touch e2e" step is at L487-488, not L364-365. L364 is now the browser: job. The e2e: job runs L378-488, and the touch step is its last step.
  • Playwright is a root devDependency at package.json:43, not :41. L41 is concurrently.
  • Every packages/ui/AGENTS.md anchor shifted up by 3. The alert-dialog inventory row is L185 (was cited as L188), the sonner row L190 (was L193), the ## Accessibility heading L192 (was L195), the dialog naming sentence L210-212 (was L213), the sonner live-region sentence L216-217 (was L219). The dialog inventory row is L184.
  • packages/ui/test/components/browser/ui-a11y.test.js: the two naming suites are L933-1155, not L936-1158. suite('ui-dialog a11y') opens at L933 and suite('ui-alert-dialog a11y') closes at L1155; suite('ui-tooltip a11y') opens at L1157. The sonner assertion is L1467-1477, not L1467-1481 (L1479-1482 is the comment belonging to the NEXT test).
  • "the same five naming paths per component" was wrong. Dialog has five naming tests (L936, L965, L991, L1021, L1043). Alert-dialog has FOUR (L1064, L1092, L1110, L1139); it has no separate aria-labelledby-beats-a-title test. Preserve five and four respectively, not five and five.
  • The axe-core rejection reason was factually wrong. axe-core is ALREADY a root devDependency (package.json:40) and is already wired into the framework as the opt-in assertNoA11yViolations() helper in packages/core/src/testing.js (L155-205), tested in packages/core/test/testing/browser/a11y.test.js and emitted by the scaffold in packages/cli/templates/test/hello/browser/hello.test.js. Adding it here would add no dependency. The rejection still stands, on the second ground only (it models neither nested-role masking nor live-region nesting), and that is how it is now written below.
  • Orca and Firefox are NOT installed on this machine. The previous revision asserted Orca on Firefox "is reachable on the dev machine so it actually happens". At HEAD, which orca and which firefox both fail. Chromium is at /usr/bin/chromium and the Playwright Chromium is at ~/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome. Step 9 now states the real starting position and the install command.

Design / approach

Decision: the automated proof is an assertion against Chrome's computed accessibility tree, read over CDP from a new Playwright-driven e2e file, with a single bounded manual screen-reader pass reserved for the one question a tree cannot express.

What settles it is that the two findings live at different layers. Finding 1 asks which node the platform exposes, and with what role and name, and that IS the accessibility tree, the artifact Chrome hands to the platform accessibility APIs. A screen reader answers it once; the tree answers it on every CI run. Finding 2 asks whether the reader SPEAKS the toast twice, which is the reader's announcement queue, one layer above the tree. The tree can prove which live root each toast resolves to and with what politeness, but the utterance count is not a browser artifact and never will be. So the tree carries the contract for both findings and the whole regression guard for finding 1, and exactly one question, duplicate speech, goes to a real reader.

The harness cost is near zero. packages/ui/test/e2e/touch.e2e.mjs already boots the marketing site and drives real Chromium via Playwright, CI already installs Chromium and runs that file (.github/workflows/ci.yml:487-488), and page.context().newCDPSession(page) plus Accessibility.getFullAXTree adds no dependency, which matters because packages/ui invariant 2 forbids third-party runtime deps.

The CDP surface, MEASURED (not guessed)

Run from a throwaway Playwright script against a static HTML string in headless Chromium 1223, so the implementer needs no discovery phase. Reproduce with the two probes described under Implementation plan step 0.

Top-level keys on an AXNode returned by Accessibility.getFullAXTree:

["backendDOMNodeId","childIds","chromeRole","frameId","ignored","ignoredReasons","name","nodeId","parentId","properties","role"]

role and name shape. role is { type: 'role', value: 'dialog' }. name is { type: 'computedString', value: 'Edit profile', sources: [...] }, where sources lists aria-labelledby, aria-label and title in accname order and marks the losers superseded: true. So the assertions read node.role.value and node.name.value.

properties is an ARRAY of { name, value: { type, value } }, not an object. The property name strings actually observed, exhaustively, across both probes:

["atomic","focusable","focused","labelledby","level","live","modal","relevant","url"]

There is no busy and no root in this corpus. The live-region trio is live / atomic / relevant, and their value types are token (a string such as "polite" or "assertive"), boolean, and tokenList (a space-joined string such as "additions text") respectively. Real excerpt, the sonner viewport:

{
  "nodeId": "7", "ignored": false,
  "role": { "type": "role", "value": "region" },
  "name": { "type": "computedString", "value": "Notifications", "sources": [ ... ] },
  "properties": [
    { "name": "live",     "value": { "type": "token",     "value": "polite" } },
    { "name": "atomic",   "value": { "type": "boolean",   "value": false } },
    { "name": "relevant", "value": { "type": "tokenList", "value": "additions text" } }
  ],
  "parentId": "5", "childIds": ["8","10","12"], "backendDOMNodeId": 7
}

Walking the chain uses parentId / nodeId, NOT backendDOMNodeId. nodeId and parentId are AX-tree-local STRINGS ("7", "5"); backendDOMNodeId is a NUMBER and is only how you cross from a DOM node to its AX node. So the helper is: build new Map(nodes.map(n => [n.nodeId, n])), find the start node by matching backendDOMNodeId, then follow parentId to the root.

To get a backendDOMNodeId for a CSS selector, send DOM.enable, then DOM.getDocument with { depth: -1 }, then DOM.querySelector with the returned root.nodeId, then DOM.describeNode, whose node.backendNodeId is the number to match. That is four round trips per element and it is the only reliable route; there is no selector-based accessibility command.

Landmine, and it invalidates the naive probe: an open modal DROPS the rest of the page from the tree entirely. With a <dialog> open via showModal(), every node outside the top layer has NO AX node at all. It is not returned with ignored: true; it is absent. The first probe run put a modal and the sonner viewport on one page and got (NO AX NODE) for every sonner node. So the e2e file must probe sonner with no dialog open, and probe a dialog only while that dialog is open.

Finding 1, the measurement

Today's markup, <dialog> opened with showModal() wrapping <div role="dialog" aria-modal="true" aria-labelledby>, produces TWO dialog-family nodes in the chain, parent and child, neither ignored:

outer <dialog>: role=dialog        name=""             properties=[focusable=true, modal=true]
inner <div>:    role=dialog        name="Edit profile" properties=[focusable=true, focused=true, modal=true, labelledby=...]

The alert-dialog shape gives the same nesting with the inner node's role intact:

outer <dialog>: role=dialog        name=""             properties=[focusable=true, modal=true]
inner <div>:    role=alertdialog   name="Alert dialog" properties=[focusable=true, focused=true, modal=true]

So the #1080 fear as literally stated does not reproduce in Chrome: alertdialog IS exposed and is not masked by the outer node. The defect that DOES reproduce is the redundant duplicate, an unnamed dialog wrapping the real one, which is not what any of the prior art produces and is a shape a reader can legitimately announce twice.

Note modal: true on the outer node comes from showModal(), not from aria-modal, so aria-modal="true" on the inner div is buying nothing the platform is not already supplying.

The fix shape, PRE-VERIFIED

Because a fix that does not work is worse than no fix, the proposed post-fix markup was measured before being prescribed. Moving the role, the name and tabindex onto the native <dialog> and dropping the inner role gives exactly one dialog-family node, correctly named, still modal:

=== dialog, role on the native element ===
  generic       name=""              ignored=false
  dialog        name="Edit profile"  props=[focusable=true, focused=true, modal=true, labelledby]
  none          (ignored: the inert body)
  RootWebArea   name="probe3"

=== alert dialog, role="alertdialog" OVERRIDING the element's implicit dialog role ===
  generic       name=""              ignored=false
  alertdialog   name="Alert dialog"  props=[focusable=true, focused=true, modal=true]
  none          (ignored)
  RootWebArea   name="probe3"

Two things this settles. role="alertdialog" on a native <dialog> really does override the implicit dialog role in Chrome, which is what ARIA-in-HTML allows but which browsers have historically been uneven about. And modal: true survives with aria-modal removed, confirming it is showModal() that supplies it.

A third probe with NO explicit role on the native element (<dialog tabindex="-1" aria-labelledby>) produced an identical role=dialog node, so writing role="dialog" in dialog.ts is redundant with the implicit role. It is kept anyway, deliberately: it is self-documenting next to alert-dialog.ts's load-bearing override, and it is what the browser and SSR assertions read.

Finding 2, the measurement

Inside the real aria-live="polite" viewport:

toast markup its own AX node live roots in the chain
role="status" (today's default toast) role=status, live=polite, atomic=true, relevant="additions text" TWO, itself and the viewport
role="alert" (today's error toast) role=alert, live=assertive, atomic=true, relevant="additions text" TWO, itself (assertive) and the viewport (polite)
no role attribute role=generic, properties: [] ONE, the viewport

So the nesting is real for a default toast, and dropping role from a non-error toast removes it. The error toast keeps its nesting, which is the accepted cost of the urgency: role="alert" is the only way to make one item assertive inside a polite viewport, and losing that would silence the urgency the component promises in its A11y block.

One cost of the fix, checked rather than waved through. role="status" carries an implicit aria-atomic="true", so a role-less toast inherits the viewport's aria-atomic="false" instead. That would matter if a toast's text ever changed in place, because a non-atomic region announces only the changed part. It does not: toast.promise at sonner.ts:137-147 calls toast.dismiss(id) and then toast.success(...), so it REPLACES the toast rather than mutating it, and _remove / addToast (sonner.ts:236 and :242-244) only ever add and remove whole items. Every announcement on this component is an addition of a whole subtree, which aria-relevant="additions text" on the viewport already covers. So the atomicity loss is inert here.

Rejected

  • DOM-attribute assertions as the proof. That is exactly what ui-a11y.test.js already does, and fix(ui): close accessibility gaps found in the full kit a11y audit #1080 fenced these two findings off precisely because attribute assertions re-check the input and never the computed output. The measurements above are the proof of that gap: nothing in the markup says how many dialog nodes Chrome will build.
  • A manual-only checklist for both findings. Finding 1 is machine-checkable, so a manual-only answer buys no regression guard and decays the moment anyone touches the nesting.
  • page.accessibility.snapshot() instead of raw CDP. Playwright's snapshot API is deprecated and prunes nodes it deems uninteresting, which is precisely the nested wrapper the test has to see. Accessibility.getFullAXTree keeps the full chain, as the excerpts above show.
  • axe-core or another automated scanner. NOT rejected for adding a dependency: axe-core is already a root devDependency (package.json:40) behind assertNoA11yViolations() in packages/core/src/testing.js. It is rejected because it models neither nested-role masking nor live-region nesting, so it cannot answer either finding. Running it here would produce a green result that means nothing.
  • Firefox and WebKit accessibility trees. Playwright exposes CDP for Chromium only, so a cross-engine tree assertion needs tooling this repo does not have. The cross-engine question stays in the manual pass.
  • Putting the tree assertions in ui-a11y.test.js. Those tests execute INSIDE the page under Web Test Runner, and CDP is a driver-side protocol, so they cannot reach it. The tree assertions need their own Playwright file.
  • Flattening the native <dialog> out and hand-rolling modality Radix-style. Out of scope and against the component's design. See Out of scope.

Prior art, re-confirmed at HEAD

  • /home/vivek/Documents/Projects/frameworks/shadcn/apps/v4/registry/new-york-v4/ui/dialog.tsx exists and its DialogContent renders a portal, an overlay, and a single DialogPrimitive.Content. alert-dialog.tsx in the same directory has the identical shape. Exactly ONE dialog-role node.
  • /home/vivek/Documents/Projects/crisp/node_modules/@radix-ui/react-dialog/dist/index.mjs exists, and the single role: "dialog" sits on the DismissableLayer alongside aria-labelledby and aria-describedby. Radix has no native <dialog> at all: it hand-rolls the focus trap, the Escape handling, and the modality.

WebJs deliberately went the other way and drove everything through showModal(), so the top layer, the focus trap, Escape, and focus restoration are platform-provided (packages/ui/AGENTS.md:184-185). The nested implicit role=dialog is the direct cost of that trade. So the precedent argues for one exposed dialog node, and the WebJs-shaped way to get there is to move the role ONTO the native element, which the pre-verification above proves works, not to hand-roll modality the way Radix does.

Implementation plan

All anchors are against HEAD 105372de. Re-run git log -1 --format=%h before starting; if HEAD moved, re-verify each anchor by grepping the quoted code rather than trusting the number.

Step 0. Reproduce the CDP measurements (optional, 2 minutes)

Everything in Design / approach was measured, so this is confirmation rather than discovery. The probes live only in a scratchpad and are not committed. Two facts they establish that you must not re-derive by trial and error: properties is an ARRAY of { name, value: { type, value } } with key names live / atomic / relevant / modal / focusable / focused / labelledby, and an open modal DROPS every other node from the tree entirely rather than marking it ignored.

Headless works. Both probes ran under pw.chromium.launch({ headless: true }) and returned a fully populated tree, so the new e2e file runs headless and CI needs no display server. Do NOT switch to headed.

Step 1. Add packages/ui/test/e2e/a11y-tree.e2e.mjs

Model it on packages/ui/test/e2e/touch.e2e.mjs, whose lifecycle is currently L20-79 and L150-159. Copy that shape verbatim in structure:

  • Resolve WEBSITE as resolve(HERE, '../../../../website') and the CLI as resolve(WEBSITE, '../node_modules/@webjsdev/cli/bin/webjs.js').
  • spawn website/scripts/copy-registry.mjs first, sleep 800ms, then spawn the CLI with ['start', '--port', String(PORT)] and cwd: WEBSITE.
  • Poll GET /__webjs/ready up to 40 times at 500ms.
  • Guard the Playwright import in a try and console.log('SKIP a11y-tree e2e: playwright not installed.') then process.exit(0) on failure; do the same for pw.chromium.launch failure, after tearing the server down.
  • Push [name, boolean] pairs into a results array and print PASS: / FAIL: per row at the end, exiting non-zero on any failure.

Port: 5182. touch.e2e.mjs:28 uses 5181 and the e2e CI job also boots the website on 5001 for test/e2e/form-submission-and-race.test.mjs (.github/workflows/ci.yml:478-482). 5000, 5001, 5004, 5005 and 5181 are the only ports any test in this repo binds, so 5182 is free and adjacent to its sibling. Read it as Number(process.env.WEBJS_E2E_A11Y_PORT || 5182) so a local run can move it.

Use a desktop context, not the iPhone descriptor. touch.e2e.mjs:78 passes pw.devices['iPhone 13'] because it is testing touch. This file is testing the accessibility tree, so await browser.newContext() with no descriptor.

Then open the CDP session once, after the first page.goto:

const cdp = await page.context().newCDPSession(page);
await cdp.send('Accessibility.enable');
await cdp.send('DOM.enable');

Add two helpers, since every assertion below is a question about a chain.

/** backendDOMNodeId for a CSS selector, or null. */
async function backendIdFor(selector) {
  const { root } = await cdp.send('DOM.getDocument', { depth: -1 });
  const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector });
  if (!nodeId) return null;
  const { node } = await cdp.send('DOM.describeNode', { nodeId });
  return node.backendNodeId;
}

/** AX chain from the element matching `selector` up to the root, nearest first. */
async function axChain(selector) {
  const backendId = await backendIdFor(selector);
  if (backendId == null) return null;
  const { nodes } = await cdp.send('Accessibility.getFullAXTree');
  const byId = new Map(nodes.map((n) => [n.nodeId, n]));
  let cur = nodes.find((n) => n.backendDOMNodeId === backendId);
  if (!cur) return null;
  const chain = [];
  while (cur) { chain.push(cur); cur = cur.parentId ? byId.get(cur.parentId) : null; }
  return chain;
}

/** A named property's raw value off an AXNode, or undefined. */
const propOf = (node, name) => (node.properties || []).find((p) => p.name === name)?.value?.value;

const DIALOG_ROLES = new Set(['dialog', 'alertdialog']);

A null chain is a FAILED check, never a skipped one. Push [name, false] and say the selector missed, so a gallery change that removes the element reds the file instead of silently passing.

Step 2. Dialog, titled path

Navigate to /ui/dialog, wait for hydration (touch.e2e.mjs uses page.waitForTimeout(1800) after domcontentloaded; use the same), then click the trigger and wait for the open state.

The gallery example is website/modules/ui/utils/examples.ts:474-495. Its trigger button reads "Open dialog" and its title is an <h2 class="${dialogTitleClass()}">Edit profile</h2>. Note it carries NO data-slot="dialog-title", so the name resolves through wireDialogLabels()'s host.querySelector('h1, h2, h3') fallback at dialog.ts:148. That is the path being asserted, and it is the common one.

Scope the selector to the OPEN dialog, because the page renders more than one example:

const PANEL = 'dialog[data-slot="dialog-native"][open] [data-slot="dialog-content"]';

Assertions:

  1. chain.filter((n) => DIALOG_ROLES.has(n.role?.value) && !n.ignored).length === 1
  2. that single node's name.value === 'Edit profile'
  3. propOf(thatNode, 'modal') === true

Assertion 3 is the guard that a fix which removes aria-modal did not accidentally remove modality; it passes both before and after the fix, which is the point.

Step 3. Alert dialog, titled path

Same against /ui/alert-dialog. The gallery example is website/modules/ui/utils/examples.ts:496-512: trigger "Delete account", title <h2>Are you sure?</h2>, again with no data-slot.

const ALERT_PANEL = 'dialog[data-slot="alert-dialog-native"][open] [data-slot="alert-dialog-content"]';

Assertions: exactly one non-ignored dialog-family node in the chain; its role.value === 'alertdialog'; its name.value === 'Are you sure?'; modal is true. The role assertion is this finding's headline question.

Close the alert dialog by calling .hide() on the host through page.evaluate, never by pressing Escape. alert-dialog.ts:490-492 blocks the native cancel event by design, so Escape does nothing and the next navigation would run with a modal still open, which (per step 0) would empty the tree.

Step 4. Both title-less paths

Build these in the page with page.evaluate, appending a <ui-dialog> / <ui-alert-dialog> whose content has no heading and no authored name, then opening it. Do NOT add a gallery example: no registry or website surface changes, per the invariants below. The components are already registered on their own gallery pages, so run each on the page that already imported it.

await page.evaluate(() => {
  const host = document.createElement('ui-dialog');
  host.innerHTML = '<ui-dialog-content><p>No title here.</p></ui-dialog-content>';
  host.id = 'untitled-probe';
  document.body.appendChild(host);
  host.show();
});

Assertions per component: exactly one non-ignored dialog-family node in the chain from that host's content panel; its name.value is exactly 'Dialog' / 'Alert dialog' (the #1230 generic fallback at dialog.ts:158-160 and alert-dialog.ts:461-463); its role matches the titled path's role. Scope the panel selector under #untitled-probe so it cannot match the gallery's own dialog.

Remove the probe host after each check so it cannot leak into a later navigation.

Step 5. Sonner

Navigate to /ui/sonner. Do not have any dialog open.

Fire the toasts with page.evaluate importing the served module, not by matching button text. The gallery's own buttons do exactly this (website/modules/ui/utils/examples.ts:645 and :1066), so the module URL is a path the gallery already depends on, while the labels are template-generated strings that a copy edit can change.

await page.evaluate(async () => {
  const m = await import('/modules/ui/components/sonner.ts');
  m.toast('Default toast probe');
});
await page.waitForTimeout(600);

Then the error toast with m.toast.error('Error toast probe').

Locate each toast by its own text rather than by index, since the page mounts several <ui-sonner> viewports and the last one to connect wins (examples.ts:1034-1045). Give the toast a stable handle by reading back the [data-slot="sonner-toast"] whose text contains the probe string, and set a unique id on it from page.evaluate so axChain has a selector.

Assertions per toast, using the chain from the toast node upward:

  • chain.filter((n) => propOf(n, 'live') !== undefined).length === 1 for the DEFAULT toast (exactly one live root, the viewport)
  • that root's live is 'polite'
  • for the ERROR toast, the live roots are counted the same way, and the NEAREST one's live is 'assertive'

Write the error-toast root count as an assertion on the observed number with a comment naming what it means, rather than asserting === 1: before the fix it is 2 and after the fix it is still 2, because role="alert" is deliberately kept. Assert >= 1 and nearest live === 'assertive', which is the contract that actually holds in both worlds, and record the observed count in the PASS line so a change is visible.

Step 6. Take the measurement and record it

Run the file against unmodified main and capture the output. This is the "do not fix blind" gate, and steps 7 and 8 are decided by it mechanically.

The decision procedure, with no judgment left over:

Observation from step 2, 3 or 4 Action
More than one non-ignored dialog-family node in either chain, OR the alert dialog's exposed role is dialog rather than alertdialog Apply step 7 in full
Exactly one dialog-family node in BOTH chains AND the alert dialog's role is alertdialog Do NOT touch dialog.ts or alert-dialog.ts. Skip step 7
Observation from step 5 Action
The DEFAULT toast resolves under more than one node carrying a live property Apply step 8
The DEFAULT toast resolves under exactly one Do NOT touch sonner.ts. Skip step 8

The error toast's root count NEVER triggers a change, in either direction.

Per the pre-verification in Design / approach, the isolated-markup probe predicts two dialog-family nodes and two live roots for a default toast, so both fixes are expected to trigger. If the real gallery measurement disagrees with that prediction, STOP and re-read: a difference means the real component wiring differs from the markup probed, and the plan's fix may be aimed at the wrong node.

Step 7. The dialog fix (conditional on step 6)

dialog.ts render() L518-546. Move the role, the name target and the tab stop onto the native element and drop aria-modal (the platform supplies modal: true from showModal(), measured above). Keep data-state and the classes on the inner div for styling.

Today:

    return html`<dialog
      data-slot="dialog-native"
      class=${NATIVE_DIALOG_CLASS}
      ${ref(this.#dialog)}
      @close=${this._onNativeClose}
      @click=${this._onNativeBackdropClick}
    ><div
      data-slot="dialog-content"
      role="dialog"
      aria-modal="true"
      tabindex="-1"
      data-state=${parentOpen ? 'open' : 'closed'}
      class=${dialogContentClass()}
    >

After:

    return html`<dialog
      data-slot="dialog-native"
      role="dialog"
      tabindex="-1"
      class=${NATIVE_DIALOG_CLASS}
      ${ref(this.#dialog)}
      @close=${this._onNativeClose}
      @click=${this._onNativeBackdropClick}
    ><div
      data-slot="dialog-content"
      data-state=${parentOpen ? 'open' : 'closed'}
      class=${dialogContentClass()}
    >

dialog.ts showModal() L507-511. Point the wiring at the node that now owns the role.

  showModal(): void {
    wireDialogLabels(this, 'dialog[data-slot="dialog-native"]');
    const native = this._native();
    if (native && !native.open) native.showModal();
  }

wireDialogLabels() (L121-161) itself needs NO change: it is already selector-parameterised at L122, and all four of its branches write to the same resolved panel, which is the invariant that must not break.

alert-dialog.ts render() L471-488. The same move, with role="alertdialog" on the native <dialog>, which the pre-verification proves overrides the element's implicit dialog role in Chrome.

    return html`<dialog
      data-slot="alert-dialog-native"
      role="alertdialog"
      tabindex="-1"
      class=${NATIVE_DIALOG_CLASS}
      ${ref(this.#dialog)}
      @cancel=${this._onNativeCancel}
      @close=${this._onNativeClose}
    ><div
      data-slot="alert-dialog-content"
      data-size=${this.size}
      data-state=${parentOpen ? 'open' : 'closed'}
      class=${alertDialogContentClass()}
    ><slot></slot></div></dialog>`;

alert-dialog.ts _wireLabels() L428. One line, the panel lookup:

    const panel = this.querySelector('dialog[data-slot="alert-dialog-native"]');

_wireDescription() (L414-425) receives the panel as an argument, so it needs no edit, and showModal() (L399-403) calls _wireLabels() with no arguments, so it needs none either. That is the whole diff for alert-dialog beyond render().

Do not touch _native() in either file (dialog.ts:503-505, alert-dialog.ts:395-397). It resolves the native <dialog> by querySelector rather than the #dialog ref because the ref came back null through SSR hydration on iOS WebKit (#730). Keep it a query.

Step 8. The sonner fix (conditional on step 6)

sonner.ts render, the per-toast role at L269. Drop role from a non-error toast and keep role="alert" on an error toast. The reasoning is settled by the measurement: the viewport is already polite, so role="status" on a toast inside it adds no politeness and only creates the second live root, whereas role="alert" is load-bearing because it is the only way to make one item assertive inside a polite viewport. An error toast still resolving under two roots is the accepted cost of that urgency and is recorded as such, not fixed.

BRANCH the whole attribute. Do NOT emit a nullish hole. role=${item.type === 'error' ? 'alert' : null} serves role="" from the server renderer while the client renderer removes it, which is precisely the divergence packages/ui/test/ssr-aria.test.js was written for (see its header comment at L1-17). And role="" is worse than role="status": an empty role is not a role, so the toast falls back to generic in a way no test would notice.

Today, L265-270:

        (item) => html`<div
          data-slot="sonner-toast"
          class=${TOAST_ITEM_BASE}
          data-type=${item.type}
          role=${item.type === 'error' ? 'alert' : 'status'}
        >

After, hoisting the shared body so the branch does not duplicate the whole toast template. Extract the toast body into a local const body = html\...`above the branch and interpolate it into both arms, or branch only the opening tag using two completehtml` templates. Either is fine; what is NOT fine is a nullish hole. Worked form:

        (item) => (item.type === 'error'
          ? html`<div
              data-slot="sonner-toast"
              class=${TOAST_ITEM_BASE}
              data-type=${item.type}
              role="alert"
            >${toastBody(item)}</div>`
          : html`<div
              data-slot="sonner-toast"
              class=${TOAST_ITEM_BASE}
              data-type=${item.type}
            >${toastBody(item)}</div>`),

where toastBody(item) is a module-level function returning the existing L271-L(close-button) contents unchanged. It must be a FUNCTION, not a module-scope value, per the packages/ui/AGENTS.md rule that a registry module does NO work at module scope. sonner.ts is already on the flagged list in packages/ui/test/utils-purity.test.js, so do not make it worse and do not accidentally clear it either; that test pins the set as an EQUALITY, so a module that stops being flagged also fails.

Update the render comment at L248-252 in the same edit. It currently states that an error toast's "innermost live region wins for that item", which is the claim being measured. Restate it to match the recorded result, naming the measurement.

Step 9. The manual screen-reader pass

Real starting position, checked at HEAD: neither Orca nor Firefox is installed on this machine. which orca and which firefox both fail. Chromium is present at /usr/bin/chromium, and Playwright's Chromium at ~/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome. So this step begins with an install. On Arch, which this machine runs:

sudo pacman -S --needed orca firefox
orca --version && firefox --version     # confirm before starting
orca --replace &                        # start the reader, then launch firefox

Required pairing is Orca on Firefox, Linux, because Orca consumes the AT-SPI announcement queue where nested-live duplication surfaces, and because Firefox plus Orca is the pairing whose live-region handling differs most from Chrome's tree. VoiceOver on macOS Safari and NVDA on Windows Firefox are recorded additionally when such a machine is available; their absence does not block this issue, because the tree assertions pin the contract and the Orca run answers the duplication question on one real reader. State explicitly on this issue which of the three ran.

Drive it against the live gallery: cd website && npm run dev, then /ui/dialog, /ui/alert-dialog, /ui/sonner. What to listen for, per component:

  • sonner: fire one default toast and count utterances of its text, one or two. Then fire an error toast and note whether it interrupts or queues. Then fire two toasts inside roughly 300ms and note whether both are spoken and whether either repeats.
  • dialog, titled: open it and transcribe the announcement. The question is whether the word "dialog" is spoken once or twice, and whether the title is used as the name.
  • dialog, title-less: open it and confirm the name is spoken as "Dialog".
  • alert-dialog, titled: open it and note whether it is announced as an alert dialog or a plain dialog, and whether the reader states or implies that Escape dismisses it, which it does not.
  • alert-dialog, title-less: confirm the name is spoken as "Alert dialog".

Record the transcript verbatim on this issue. If step 7 or step 8 landed, run the pass AFTER the fix and note that the pre-fix behaviour is documented by the step 6 measurement.

Step 10. Wire the new file into CI

Add to packages/ui/package.json scripts, beside the existing test:e2e:touch:

"test:e2e:a11y-tree": "node test/e2e/a11y-tree.e2e.mjs"

Add a step to .github/workflows/ci.yml in the e2e job, immediately after the "Run ui touch e2e" step at L487-488, with a comment in the same style as its neighbours:

      # Computed-accessibility-tree e2e for dialog / alert-dialog / sonner (#1245):
      # reads Chrome's AX tree over CDP against the live gallery, because the
      # nested-role and nested-live-region questions are only answerable in the
      # tree the browser hands the platform, never from a DOM attribute.
      - name: Run ui a11y-tree e2e
        run: npm run test:e2e:a11y-tree --workspace=@webjsdev/ui

That job already installs Chromium (ci.yml:388-389) and Playwright is already a root devDependency (package.json:43), so no CI setup changes.

Step 11. Post the outcome on this issue

Per finding, one of "no change needed" with the tree excerpt as evidence, or "changed" with the diff and the before-and-after tree excerpts. A no-change outcome is a result, not a non-result, and the new e2e file is the durable form of it either way.

Landmines

  • alert-dialog.ts blocking Escape (the native cancel handler at L490-492) is INTENTIONAL and documented. Do not "fix" it. It is relevant here only as something the announcement may misdescribe, and as the reason step 3 closes the dialog through .hide() rather than Escape.
  • The generic accessible-name fallbacks added in fix(ui): close the accessibility gaps found in the kit a11y audit #1230 (aria-label="Dialog" at dialog.ts:158-160, "Alert dialog" at alert-dialog.ts:461-463) only ever apply when nothing else named the panel. Moving the role changes which element the name is written to, so re-check that a titled dialog still takes its name from the title and does NOT fall through to the generic label. Steps 2 and 4 assert exactly that pair.
  • wireDialogLabels() early-returns after forwarding an authored name (dialog.ts:133-146) so that aria-labelledby from a title node cannot outrank an authored aria-label. Any change to the panel selector must keep all four branches pointed at the SAME element, or a stale attribute from an earlier open lands on one node while the name lands on another. The same holds for alert-dialog.ts _wireLabels() L437-451.
  • Both components are driven through showModal(), so the top layer, focus trap, Escape and focus restoration are platform-provided. Moving ARIA between the two elements must not disturb that, which is what the modal: true assertion in steps 2 and 3 guards.
  • The gallery examples use a plain <h2> and no data-slot="dialog-title", so the name resolves through the first-heading fallback. Do not "fix" the gallery to add the slot while here; it would change what the assertion is proving.
  • An open modal empties the AX tree of everything else. Close every dialog before probing sonner, and probe a dialog only while it is open.
  • website/components/ui/ and website/lib/ui/ are GITIGNORED mirrors regenerated by website/scripts/copy-registry.mjs (run via webjs.dev.before). Never hand-edit them, and re-run that script if the registry changed. The new e2e file runs it itself before booting, exactly as touch.e2e.mjs:43 does.
  • The dialog scroll lock is a deliberate divergence from shadcn (fix(ui): opening a dialog shifts a fixed header right by half the scrollbar #1144) and unrelated to this. Leave it alone.
  • Sonner's bus lives on globalThis, not module scope (dogfood: fix hover-card, dropdown submenu, sonner on iOS/touch #745), because the module can load under two URLs. Irrelevant to the roles, but do not refactor it while here.
  • A conditional ARIA attribute must be BRANCHED into the template, never written as a nullish hole. packages/ui/test/ssr-aria.test.js:1-17 explains the mechanism.

Invariants to respect

No third-party runtime deps (packages/ui invariant 2; Playwright is a devDependency at package.json:43 and is not shipped, and no new dependency is added at all). Light DOM plus Tailwind, no shadow root (invariant 4). shadcn API parity (invariant 5): every tag, variant and data-* attribute is unchanged by this work, since only ARIA moves between two elements the author never addresses. Tier-2 owns its ARIA (packages/ui/AGENTS.md:192-218). Registry wire format unchanged, and no new gallery example, which is why the title-less cases are built with page.evaluate. packages/ is plain .js with JSDoc and the new e2e file is .mjs, consistent with touch.e2e.mjs; the registry components under packages/ui/packages/registry/components/ are the established .ts exception and no NEW .ts file is added anywhere.

Prior art

#1080 (the audit that fenced these two off), PR #1230 (the nine findings that shipped, including the name fallbacks on these same two components), #655 (the original Tier-2 ARIA wiring), #1078 / PR #1079 (the a11y browser-test patterns), #730 (why _native() is a query), #1144 (the scroll-lock divergence), #745 (the globalThis bus), and packages/ui/test/e2e/touch.e2e.mjs (the Playwright-drives-the-live-site harness this new file copies).

Tests

e2e (the headline layer). packages/ui/test/e2e/a11y-tree.e2e.mjs, NEW, carrying every accessibility-tree assertion from steps 2 through 5. The path and the .e2e.mjs suffix follow its only sibling, packages/ui/test/e2e/touch.e2e.mjs. This is the automated proof, and it is the only layer that can see a computed role at all.

Unit (SSR). packages/ui/test/ssr-aria.test.js, EXTEND. It currently holds eight tests (L36, L44, L56, L68, L77, L86, L96, L104) covering toggle, dropdown-menu and toggle-group, and none for dialog, alert-dialog or sonner. Add, following the existing ssr(load, build) helper at L29-34 and the { skip } guard at L26:

  • If step 7 lands: renderToString serves the dialog-family role on the native <dialog> element and serves NO role attribute on the inner content div, for both components, and serves role="alertdialog" rather than role="dialog" for alert-dialog.
  • If step 8 lands: a default toast serves NO role attribute at all (assert on the absence of the substring role=, not on role=""), and an error toast serves role="alert". This is the layer that catches a nullish hole, and it is the reason the branch in step 8 is mandatory.

If NEITHER step lands, still add the assertions for the CURRENT shape, so the SSR layer states what these three components serve rather than leaving them the only Tier-2 components with no SSR ARIA coverage. That is a small same-file addition, so it goes in this PR rather than anywhere else.

Unit (source shape). packages/ui/test/registry-contents.test.js, RE-RUN, do not extend. Its two A11y assertions (L359-366 for presence, L372-379 for sitting above @example) already cover the JSDoc edits in the Docs section; adding a case would duplicate them. Keep each edited A11y block above @example so extractDocHeader() does not drop it.

Unit (purity). packages/ui/test/utils-purity.test.js, RE-RUN, do not extend. It pins the flagged module set as an EQUALITY, and sonner is already on it, so a step-8 refactor that accidentally removes or adds module-scope work fails there. This is why toastBody must be a function.

Browser. packages/ui/test/components/browser/ui-a11y.test.js, EDIT only if step 7 or 8 lands.

  • If step 7 lands, the naming assertions in the two suites (dialog L933-1059 with five tests at L936, L965, L991, L1021, L1043; alert-dialog L1061-1155 with four tests at L1064, L1092, L1110, L1139) read the panel via root.querySelector('[data-slot="dialog-content"]') / '[data-slot="alert-dialog-content"]' and must move to the native <dialog>. Keep the SAME five and four naming paths respectively (title, no title, authored name beating a title, authored aria-labelledby, authored aria-label on the content host; alert-dialog has no separate authored-aria-labelledby test and should not gain one here). Treat each edited assertion as a signal to re-read what it protected.
  • If step 8 lands, the sonner assertion at L1467-1477 currently does region.querySelector('[role="alert"]') for the error toast and asserts the viewport's role and aria-live. Add an assertion that a DEFAULT toast carries no role attribute, and keep the error-toast assertion exactly as it is.

Smoke. Not applicable. test/examples/*/smoke/* covers the scaffolded example apps, and neither examples/blog nor gallery mounts <ui-dialog>, <ui-alert-dialog> or <ui-sonner>. The website is the only consumer, and the new e2e file boots it.

Bun parity: N/A, and here is the reason rather than an assertion. The gate is .claude/hooks/require-bun-parity-with-runtime-src.sh, which matches staged paths under packages/*/src/** against a runtime-sensitive pattern list (serializer, listener, ssr, action, csrf, crypto, streams, ts-strip, auth, session, cors). Nothing in this change is under any package's src/: the registry components live at packages/ui/packages/registry/components/, the tests at packages/ui/test/, and the CI file at .github/. So the hook does not fire, and more importantly the change is not runtime-sensitive in substance: it moves ARIA attributes between two elements in a component template and adds a Chromium-only CDP probe. The renderToString path these components ride is already covered cross-runtime by the core suite, and @webjsdev/ui registry components are app-copied source rather than framework runtime. Do NOT add a test/bun/** file for this.

For the same reason, neither .claude/hooks/require-tests-with-src.sh nor .claude/hooks/require-docs-with-src.sh fires, since both key on packages/*/src/** (and packages/cli/lib/). The tests and docs below are owed on merit, not because a hook demands them.

The counterfactual

Every assertion must be seen RED before it ships, matching the loop #1230's commits used.

  • If step 7 landed: revert dialog.ts and alert-dialog.ts, keep the new e2e file, and confirm the "exactly one dialog-family node in the chain" assertion reds for BOTH components. The alert-dialog role assertion will still pass on the reverted code (the measurement shows alertdialog is exposed either way), so it is the COUNT assertion that is the counterfactual, not the role one. Say so in the PR rather than claiming the role assertion is discriminating when it is not.
  • If step 8 landed: revert the sonner.ts branch, restoring role="status" on a default toast, and confirm the "exactly one live root for a default toast" assertion reds with an observed count of 2. Separately, revert only the branch shape (write role=${cond ? 'alert' : null}) and confirm the new ssr-aria.test.js assertion reds on the served role="", which is the divergence that layer exists for.
  • If a finding measured clean and no fix landed: the counterfactual is instead to hand-edit the component into the shape the finding feared (add a second dialog-family role node, or add role="status" back onto a default toast), confirm the new assertion reds, and restore. Either way no assertion ships without being seen red.

Running it

npm run test:e2e:a11y-tree --workspace=@webjsdev/ui     # the new file
npm test --workspace=@webjsdev/ui                        # ssr-aria + registry-contents + utils-purity
npm run test:browser                                     # ui-a11y.test.js
( cd website && npx webjs check && npx webjs doctor )     # the app whose pages the e2e drives

webjs check must be run from INSIDE the app, never the repo root (#1301). webjs doctor is run too because the required conventions CI job runs it over the in-repo apps and gates on UNMARKED_ASSET_LINKS in website (#1257), so a clean webjs check alone does not predict that job.

Docs

No WEBJS_NO_DOC_GATE=1 escape hatch is needed, and it must not be used. The doc gate keys on packages/*/src/**, which this change does not touch, so it never fires. The surfaces below are owed because the change alters statements that are currently written as fact, not because a hook asks.

  • packages/ui/AGENTS.md, five places, all re-anchored above: the dialog inventory row L184, the alert-dialog row L185, the sonner row L190, and inside the ## Accessibility section (heading L192) the dialog naming sentence at L210-212 and the sonner live-region sentence at L216-217. These state the current wiring as fact, so they change if the wiring changes AND gain the verified statement if it does not. In particular L185 says "Like dialog, role=alertdialog", which after step 7 is true of a different element, and L216-217 says sonner "is a persistent aria-live region whose every toast carries a labelled close button", which after step 8 is still true but no longer tells the reader which toasts carry their own role.
  • packages/ui/packages/registry/components/dialog.ts, the A11y JSDoc block at L40-49, and alert-dialog.ts, its A11y block at L47-57. Both describe where the role and the name live, so both change with step 7. Keep each block above @example (L62 and the alert-dialog equivalent) so extractDocHeader() does not drop it, which registry-contents.test.js:372-379 enforces.
  • packages/ui/packages/registry/components/sonner.ts, the A11y JSDoc block at L33-42 and the inline render comment at L248-252. Both currently assert that an error toast's innermost live region "wins for that item". That claim is one of the things being measured, so it is restated to match the recorded result either way, naming the measurement rather than the intuition.
  • .github/workflows/ci.yml, the e2e job, adding the new step after L488 with its explanatory comment.
  • packages/ui/package.json scripts, adding test:e2e:a11y-tree.
  • The website /ui/<name> pages need no edit. They render the A11y block out of the registry JSDoc through packages/ui/src/registry/extract.js, so they follow the JSDoc edits automatically. Boot the site and confirm /ui/dialog, /ui/alert-dialog and /ui/sonner rather than assuming.
  • The docs site (website/app/docs/**) and the skill (.agents/skills/webjs/) need no edit. Neither documents these component roles: the only skill mention of these components is .agents/skills/webjs/references/styling.md:227, which is about the fix(ui): opening a dialog shifts a fixed header right by half the scrollbar #1144 scroll lock. Verified by grep at HEAD. Do not invent a page for this.
  • The scaffold templates need no edit. webjs create no longer pre-copies ui components (the gallery cards style with plain Tailwind), so no generated file carries this markup.
  • README.md needs no edit. This is not a headline capability change.

Acceptance criteria

  • packages/ui/test/e2e/a11y-tree.e2e.mjs exists, boots the marketing site on port 5182, reads Chrome's computed accessibility tree over CDP, runs headless, and skips cleanly with a printed message when Playwright or Chromium is unavailable.
  • It asserts that dialog and alert-dialog each expose exactly ONE non-ignored dialog-family node in the chain from the content panel to the document, that the alert dialog's is alertdialog, that the computed name comes from the title node, and that modal is still true.
  • The titled and title-less (generic-fallback) paths are both covered for dialog and alert-dialog, with the title-less cases built in-page via page.evaluate rather than added to the gallery.
  • It asserts that a default toast resolves under exactly one live-region root and that root is polite, and that an error toast's nearest live root is assertive.
  • The measurement from step 6 is recorded on this issue BEFORE any component edit, and the step 7 / step 8 decision follows the two tables mechanically with no judgment call.
  • The new file runs in CI from the e2e job via a test:e2e:a11y-tree script in packages/ui/package.json, added after the "Run ui touch e2e" step.
  • Every new assertion has been seen RED before shipping, either against the pre-fix source or against a hand-made regression, and the loop is described in the PR, including the explicit note that the alert-dialog ROLE assertion is not itself discriminating.
  • packages/ui/test/ssr-aria.test.js carries assertions for dialog, alert-dialog and sonner, and a default toast is asserted to serve no role attribute rather than role="" if step 8 landed.
  • The manual pass is recorded verbatim on this issue for dialog, alert-dialog and sonner, covering Orca on Firefox at minimum (installing both first, since neither is on the machine today), with VoiceOver on macOS Safari and NVDA on Windows Firefox added when a machine is available and their absence stated explicitly when not.
  • For sonner: a stated answer to whether a nested role="alert" / role="status" inside the aria-live="polite" viewport is double-announced, per reader, including two toasts fired inside roughly 300ms.
  • A decision recorded per finding: changed (with the before-and-after tree excerpts) or no change needed (with the tree excerpt as evidence).
  • If a change landed, packages/ui/AGENTS.md (L184, L185, L190, L210-212, L216-217) and the three components' A11y blocks match the new wiring; if no change landed, they carry the verified statement instead of the unverified claim.
  • npm test --workspace=@webjsdev/ui, npm run test:browser, and ( cd website && npx webjs check && npx webjs doctor ) are green, and the website still serves /ui/dialog, /ui/alert-dialog and /ui/sonner.

Out of scope

  • Flattening the native <dialog> out, or hand-rolling modality Radix-style. WebJs deliberately drives both components through showModal() so the top layer, focus trap, Escape and focus restoration are platform-provided. The fix here moves ARIA onto that element; it does not replace it.
  • The intentional Escape block in alert-dialog.ts:490-492. Do not remove or soften it. It is only referenced here as something a wrong announcement may misdescribe.
  • The fix(ui): opening a dialog shifts a fixed header right by half the scrollbar #1144 dialog scroll-lock divergence from shadcn. Unrelated, deliberate, leave it.
  • The dogfood: fix hover-card, dropdown submenu, sonner on iOS/touch #745 globalThis sonner bus, and the deferred multi-viewport toast routing noted at website/modules/ui/utils/examples.ts:1044-1045. Do not refactor either while here.
  • Adding a gallery example for the title-less dialog cases. They are built in-page precisely so no registry or website surface changes.
  • Cross-engine tree assertions. Playwright exposes CDP for Chromium only. Firefox and WebKit stay in the manual pass.
  • Adding axe-core to this test path. It is already available and it cannot answer either finding.
  • Filing follow-up issues. If something genuinely separate turns up, report it in the PR and let the owner decide. Fold any small tweak in a file this PR already touches into this PR.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions