From a575e27fb14ae12286100e7b0f1bbd011232346a Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:35:13 +0000 Subject: [PATCH 1/2] a11y: acture-a11y (unpublished) with ARIA projection, aria-keyshortcuts and dispatch announcer; native-menu and checked-state docs Part of #64. Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 2 +- docs/hand-written-a11y.md | 61 ++++++ docs/hand-written-native-menu.md | 121 +++++++++++ packages/a11y/README.md | 33 +++ packages/a11y/package.json | 61 ++++++ packages/a11y/src/announcer.test.ts | 114 ++++++++++ packages/a11y/src/announcer.ts | 132 ++++++++++++ packages/a11y/src/aria.test.ts | 78 +++++++ packages/a11y/src/aria.ts | 132 ++++++++++++ packages/a11y/src/index.ts | 22 ++ packages/a11y/tsconfig.json | 10 + packages/a11y/tsup.config.ts | 13 ++ packages/a11y/vitest.config.ts | 8 + pnpm-lock.yaml | 315 +++++++++++++++------------- 14 files changed, 959 insertions(+), 143 deletions(-) create mode 100644 docs/hand-written-a11y.md create mode 100644 docs/hand-written-native-menu.md create mode 100644 packages/a11y/README.md create mode 100644 packages/a11y/package.json create mode 100644 packages/a11y/src/announcer.test.ts create mode 100644 packages/a11y/src/announcer.ts create mode 100644 packages/a11y/src/aria.test.ts create mode 100644 packages/a11y/src/aria.ts create mode 100644 packages/a11y/src/index.ts create mode 100644 packages/a11y/tsconfig.json create mode 100644 packages/a11y/tsup.config.ts create mode 100644 packages/a11y/vitest.config.ts diff --git a/AGENTS.md b/AGENTS.md index 8270757..a1e2cf9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,7 +114,7 @@ Full discussion is in `docs/redesign_takeaways.md` §3 and the `acture-hard-dont - Testing: `acture-e2e-playwright` (v1.7) — sequence engine + Playwright fixture; substrate for macros + e2e + property tests. `acture-test-property` (v1.12) — fast-check arbitraries replayed through the sequence engine. - Python: `acture` on PyPI (v1.13) — thin MCP client, `ActureClient` as `Mapping[str, Command]`. ~300 LoC, depends only on the official `mcp` SDK. -Hand-written equivalents (the dev-tool-first promise made reproducible) live at `docs/hand-written-{registry,command-sequence,telemetry,undo,test-property,python-client}.md` and `docs/ai-codemod-recipe.md`. +Hand-written equivalents (the dev-tool-first promise made reproducible) live at `docs/hand-written-{registry,command-sequence,telemetry,undo,test-property,python-client,a11y,native-menu}.md` and `docs/ai-codemod-recipe.md`. Three remaining post-v1 candidates require explicit user direction: `acture-state-jotai`, `acture-state-valtio`, `acture-sandbox`. The last is also gated on `docs/research/acture_research_prompts.md` Prompt 7 (drafted, not launched). diff --git a/docs/hand-written-a11y.md b/docs/hand-written-a11y.md new file mode 100644 index 0000000..8c34f24 --- /dev/null +++ b/docs/hand-written-a11y.md @@ -0,0 +1,61 @@ +# The hand-written accessibility projections: a reproducible reference + +**Status:** reference artifact. A developer can make a command registry accessible with **zero `acture-*` dependency** by hand-writing the two small pieces below. `packages/a11y/src/` (`acture-a11y`, currently unpublished) is the tested implementation; read it for the full version, with edge cases (`$mod++`, de-duplication, throwing formatters). Read [`docs/positioning.md`](positioning.md) first. + +Why these two: [research-12](research/acture_research_12%20--%20Accessibility%20APIs%20and%20Command%20Dispatch.md) §3 found that `CommandRecord` already matches the accessibility action shape, so only projections are missing. Neither needs a new record field. + +## 1. The record to ARIA projection (~20 lines) + +```ts +import type { AnyCommandRecord, Context } from 'acture'; +// `evaluateWhen` is exported by acture core. Hand-written registries use their own. + +export function toAriaProps(cmd: AnyCommandRecord, ctx: Context = {}, apple = false) { + const available = evaluateWhen(cmd.when, ctx); + const keys = [cmd.keybinding ?? []].flat() + .filter((k) => !/\s/.test(k)) // ARIA cannot express chords + .map((k) => k.split('+').map((p, i, all) => + p === '$mod' ? (apple ? 'Meta' : 'Control') + : i === all.length - 1 && p.length === 1 ? p.toUpperCase() : p).join('+')); + return { + 'aria-label': cmd.title, // WCAG 2.5.3: name contains the visible label + ...(cmd.description ? { 'aria-description': cmd.description } : {}), + ...(available ? {} : { 'aria-disabled': 'true' }), // not the `disabled` attribute: it leaves the tab order + ...(available && keys.length ? { 'aria-keyshortcuts': keys.join(' ') } : {}), + }; +} +``` + +Emit `aria-keyshortcuts` only where the shortcut is live (buttons, menu items). On palette options it is misleading: pressing the shortcut does not activate the option. + +## 2. The dispatch-result announcer (~25 lines) + +```ts +import type { Context, DispatchOptions, Registry, Result } from 'acture'; + +export function announceDispatches(registry: Registry): () => void { + const region = document.createElement('div'); + region.setAttribute('role', 'status'); // polite live region + region.setAttribute('aria-live', 'polite'); + region.setAttribute('aria-atomic', 'true'); + region.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)'; + document.body.appendChild(region); + + let flip = false; + const original = registry.dispatch.bind(registry); + (registry as { dispatch: Registry['dispatch'] }).dispatch = async function announced( + id: string, params?: unknown, ctx?: Context, opts?: DispatchOptions, + ): Promise> { + const result = await original(id, params, ctx, opts); + const name = registry.get(id)?.title ?? id; + flip = !flip; // identical text twice must still change the DOM + region.textContent = (result.ok ? `${name} done` : `${name} failed: ${result.error.message}`) + (flip ? '' : ' '); + return result; + }; + return () => { (registry as { dispatch: Registry['dispatch'] }).dispatch = original; region.remove(); }; +} +``` + +Why each piece: the region must exist *before* it is written to, or some screen readers miss the first message, so create it once at install. `aria-atomic` makes the whole message be read. Wrapping `dispatch` (not the palette) is what makes every surface announce: hotkeys, menus, macros and AI included. Dispose in reverse install order when other instrumenters wrap the same registry. + +For the native-menu projection and the checked-state design, see [`hand-written-native-menu.md`](hand-written-native-menu.md). diff --git a/docs/hand-written-native-menu.md b/docs/hand-written-native-menu.md new file mode 100644 index 0000000..b0a1bc0 --- /dev/null +++ b/docs/hand-written-native-menu.md @@ -0,0 +1,121 @@ +# Projecting the registry into a native application menu + +**Status:** reference artifact, hand-written path (no `acture-*` dependency beyond the core registry you already have). Follows [`docs/positioning.md`](positioning.md); see [research-12](research/acture_research_12%20--%20Accessibility%20APIs%20and%20Command%20Dispatch.md) §4.1 for why. + +## Why + +An Electron or Tauri app draws its UI in a webview, so the operating system's accessibility tree sees one big web page. A **native menu** is the one place the OS sees real, named, enabled-or-not, shortcut-bearing items. Project the registry into it and your commands become visible to VoiceOver, the macOS Help-menu search, Raycast's *Search Menu Items*, Paletro, and every other client that reads menus, with no per-app work. The native menu is the app's accessibility surface for commands. + +## The mapping + +| `CommandRecord` | Native menu item | Notes | +| --- | --- | --- | +| `title` | `label` | Append `…` when the command takes parameters (the OS convention for "needs more input"); this is acture's handoff split. | +| `category` | top-level submenu | Order is registry order; reorder in the host. | +| `keybinding` | `accelerator` | `$mod` becomes `CommandOrControl`. Chords (`"g i"`) have no accelerator: leave it off. | +| `when` (evaluated) | `enabled` | Menus **show** unavailable commands, disabled. Build from the unfiltered `registry.list()` (see the hidden-vs-disabled rule in the `acture-palette-design` skill). | +| checked state | `checked`, `type: 'checkbox'` | From a separate state projection, below. | +| dispatch | `click` | `registry.dispatch(id, undefined, ctx)`. Parameterised commands call back to the host, which opens its palette or form. | + +## Electron + +The code below builds the template. It has no Electron import: `MenuItemOptions` is the subset of `MenuItemConstructorOptions` used, so you can pass the result to `Menu.buildFromTemplate`. + +```ts +import { evaluateWhen } from 'acture'; +import type { AnyCommandRecord, Context, Registry } from 'acture'; + +interface MenuItemOptions { + label?: string; + type?: 'normal' | 'checkbox' | 'separator' | 'submenu'; + accelerator?: string; + enabled?: boolean; + checked?: boolean; + click?: () => void; + submenu?: MenuItemOptions[]; +} + +interface MenuOptions { + /** Current when-clause context. Read at build time. */ + ctx: () => Context; + /** Checked state per command id, or undefined for a plain item. */ + checked?: (id: string, ctx: Context) => boolean | undefined; + /** Called for a command that needs parameters; the host opens its form. */ + onParameterized: (cmd: AnyCommandRecord) => void; +} + +/** tinykeys token to an Electron accelerator; undefined for chords. */ +function toAccelerator(keybinding: AnyCommandRecord['keybinding']): string | undefined { + const first = typeof keybinding === 'string' ? keybinding : keybinding?.[0]; + if (!first || /\s/.test(first)) return undefined; // Electron has no key sequences + return first.replace(/\$mod/gi, 'CommandOrControl').replace(/\bmeta\b/gi, 'Command'); +} + +export function toMenuTemplate(registry: Registry, options: MenuOptions): MenuItemOptions[] { + const ctx = options.ctx(); + const byCategory = new Map(); + for (const cmd of registry.list()) { // unfiltered: disabled items stay visible + const key = cmd.category ?? 'Commands'; + byCategory.set(key, [...(byCategory.get(key) ?? []), cmd]); + } + return [...byCategory].map(([category, cmds]) => ({ + label: category, + submenu: cmds.map((cmd): MenuItemOptions => { + const checked = options.checked?.(cmd.id, ctx); + const needsParams = cmd.params !== undefined; + const accelerator = toAccelerator(cmd.keybinding); + return { + label: needsParams ? `${cmd.title}…` : cmd.title, // macOS/Windows convention + type: checked === undefined ? 'normal' : 'checkbox', + ...(checked !== undefined ? { checked } : {}), + ...(accelerator ? { accelerator } : {}), + enabled: evaluateWhen(cmd.when, ctx), + click: () => { + if (needsParams) options.onParameterized(cmd); + else void registry.dispatch(cmd.id, undefined, options.ctx()); + }, + }; + }), + })); +} +``` + +Install it in the main process (or in the renderer and send the template over IPC; `click` functions do not cross IPC, so in practice send `{ id }` and dispatch on the renderer side): + +```ts +import { Menu } from 'electron'; + +const rebuild = () => + Menu.setApplicationMenu( + Menu.buildFromTemplate(toMenuTemplate(registry, { ctx: getCtx, onParameterized: openPalette })), + ); +rebuild(); +registry.onCommandsChanged(rebuild); // new or removed commands +// Electron menu items are static: call rebuild() whenever the context that +// `when` reads changes (selection, focus, mode), or the enabled flags go stale. +``` + +## Tauri + +Same mapping, different constructors. In Tauri 2 build the menu with `Menu.new`, `Submenu.new`, `MenuItem.new({ id, text, accelerator, enabled, action })` and `CheckMenuItem.new({ id, text, checked, ... })` from `@tauri-apps/api/menu`, then `menu.setAsAppMenu()`. Tauri accelerators use the same `CommandOrControl+B` spelling. Check the current Tauri docs for exact option names; the Electron code above is the reference; the mapping table is what carries over. + +## Checked state (toggle commands) + +Checkable commands ("Toggle sidebar", "Word wrap") are `menuitemcheckbox` / `AXMenuItemMarkChar` / UIA Toggle on every platform, and the record has nowhere to say "currently on". **Decision: do not add a `CommandRecord` field.** A record is data about what a command *is*; whether it is on is state, and it changes. Keep it in a projection keyed by command id: + +```ts +type CheckedState = (id: string, ctx: Context) => boolean | 'mixed' | undefined; +// undefined: not a toggle (plain item). 'mixed': indeterminate. +``` + +The host implements it from the state it already owns (`(id, ctx) => id === 'app.view.sidebar' ? ctx.ui.sidebarOpen : undefined`). Each surface consumes it: + +- native menu: `checked`, `type: 'checkbox'` (Electron has no indeterminate state; render `'mixed'` as unchecked); +- web button: `aria-pressed`; menu item: `role="menuitemcheckbox"` + `aria-checked` (`"mixed"` allowed); +- announcer: a custom `formatResult` can speak "Sidebar on" from the same projection. + +This is *design only*: the native menu above is the named consumer, and it uses the `checked` option. Revisit a shared helper in `acture-a11y` when a second surface needs it. + +## What this does not do + +It does not read *other* apps' menus (that is the universal palette of research-12 §5, not an acture task), and it does not make the in-webview UI accessible: pair it with `acture-a11y`'s `toAriaProps` and `announceDispatches` for that. diff --git a/packages/a11y/README.md b/packages/a11y/README.md new file mode 100644 index 0000000..22d0764 --- /dev/null +++ b/packages/a11y/README.md @@ -0,0 +1,33 @@ +# acture-a11y + +> **acture is a development tool first.** This package is an *optional accelerator*: an agent can hand-write these projections into your project with no `acture-*` dependency. See [`docs/hand-written-a11y.md`](../../docs/hand-written-a11y.md) and [`docs/positioning.md`](../../docs/positioning.md). +> +> **Status: unpublished (`private`).** Flip `private` off in `package.json` and add a changeset to release it. + +Accessibility projections for a command registry (research-12). A `CommandRecord` already has the shape of a cross-platform accessibility action (label, description, key binding, enabled, invoke); this package renders it as ARIA and announces what happened. + +## Announce dispatch results (WCAG 4.1.3) + +```ts +import { createAnnouncer, announceDispatches } from 'acture-a11y'; + +const announcer = createAnnouncer(); // hidden polite live region +const stop = announceDispatches(registry, { announcer }); +// every dispatch, from any surface, now speaks: "Export failed: disk full" +``` + +Default messages are `" done"` and `"<title> failed: <message>"`. Pass `formatResult` to say something better (`"3 nodes deleted"`) or return `null` to stay silent when the effect is already visible. + +## ARIA attributes for a button, menu item or toolbar item + +```tsx +import { toAriaProps } from 'acture-a11y'; + +<button {...toAriaProps(cmd, { ctx })} onClick={() => registry.dispatch(cmd.id, undefined, ctx)}> + {cmd.title} +</button> +``` + +You get `aria-label` (the title), `aria-description`, `aria-disabled="true"` when `when` is false, and `aria-keyshortcuts` (`$mod` resolved to `Control` or `Meta`; chords are omitted because ARIA cannot express sequences). A disabled command advertises no shortcut. Pass `keys` (for example `resolveKeys(cmd, userKeymap)` from `acture-hotkeys`) so the attribute reflects the shortcut that is really bound. Choosing the element and role is the surface's job. + +Menus show unavailable commands as disabled; the palette hides them. See the hidden-vs-disabled rule in the `acture-palette-design` skill, and [`docs/hand-written-native-menu.md`](../../docs/hand-written-native-menu.md) for the Electron/Tauri menu projection and the checked-state design. diff --git a/packages/a11y/package.json b/packages/a11y/package.json new file mode 100644 index 0000000..580e021 --- /dev/null +++ b/packages/a11y/package.json @@ -0,0 +1,61 @@ +{ + "name": "acture-a11y", + "version": "0.0.0", + "description": "acture accessibility projections: ARIA props and aria-keyshortcuts from a CommandRecord, and a polite live-region announcer for dispatch results (WCAG 4.1.3).", + "license": "Apache-2.0", + "author": "Thor Whalen", + "repository": { + "type": "git", + "url": "git+https://github.com/i2mint/acture.git", + "directory": "packages/a11y" + }, + "homepage": "https://github.com/i2mint/acture#readme", + "bugs": "https://github.com/i2mint/acture/issues", + "type": "module", + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist *.tsbuildinfo" + }, + "peerDependencies": { + "acture": "^1.0.0" + }, + "devDependencies": { + "acture": "workspace:*", + "tsup": "^8.3.0", + "typescript": "^5.7.0", + "vitest": "^2.1.0", + "zod": "^4.0.0", + "jsdom": "^25.0.0" + }, + "keywords": [ + "acture", + "accessibility", + "a11y", + "aria", + "wcag", + "command-dispatch" + ], + "publishConfig": { + "access": "public" + }, + "private": true +} diff --git a/packages/a11y/src/announcer.test.ts b/packages/a11y/src/announcer.test.ts new file mode 100644 index 0000000..1649f13 --- /dev/null +++ b/packages/a11y/src/announcer.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { createRegistry, defineCommand, ok, err } from 'acture'; +import { createAnnouncer, announceDispatches } from './announcer.js'; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +function setup() { + const registry = createRegistry(); + registry.registerAll([ + defineCommand({ id: 'app.t.good', title: 'Zoom to fit', execute: () => ok(1) }), + defineCommand({ id: 'app.t.bad', title: 'Export', execute: () => err('io', 'disk full') }), + ]); + return registry; +} + +describe('createAnnouncer', () => { + it('mounts a visually hidden polite status region', () => { + const a = createAnnouncer(); + const region = document.querySelector('[data-acture-announcer]')!; + expect(region.getAttribute('role')).toBe('status'); + expect(region.getAttribute('aria-live')).toBe('polite'); + expect(region.getAttribute('aria-atomic')).toBe('true'); + a.announce('hello'); + expect(region.textContent).toBe('hello'); + a.dispose(); + expect(document.querySelector('[data-acture-announcer]')).toBeNull(); + }); + + it('changes the DOM text when the same message repeats', () => { + const a = createAnnouncer(); + const region = document.querySelector('[data-acture-announcer]')!; + a.announce('same'); + const first = region.textContent; + a.announce('same'); + expect(region.textContent).not.toBe(first); + expect(region.textContent!.trim()).toBe('same'); + }); + + it('uses role=alert for assertive', () => { + createAnnouncer({ politeness: 'assertive' }); + const region = document.querySelector('[data-acture-announcer]')!; + expect(region.getAttribute('role')).toBe('alert'); + expect(region.getAttribute('aria-live')).toBe('assertive'); + }); +}); + +describe('announceDispatches', () => { + it('announces success and failure, returning the Result unchanged', async () => { + const registry = setup(); + const messages: string[] = []; + announceDispatches(registry, { announcer: { announce: (m) => messages.push(m), dispose() {} } }); + const good = await registry.dispatch('app.t.good'); + const bad = await registry.dispatch('app.t.bad'); + expect(good).toEqual({ ok: true, value: 1 }); + expect(bad.ok).toBe(false); + expect(messages).toEqual(['Zoom to fit done', 'Export failed: disk full']); + }); + + it('announces unknown commands by id', async () => { + const registry = setup(); + const messages: string[] = []; + announceDispatches(registry, { announcer: { announce: (m) => messages.push(m), dispose() {} } }); + const r = await registry.dispatch('nope'); + expect(r.ok).toBe(false); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatch(/^nope failed: /); + }); + + it('honours a custom formatter, and null stays silent', async () => { + const registry = setup(); + const messages: string[] = []; + announceDispatches(registry, { + announcer: { announce: (m) => messages.push(m), dispose() {} }, + formatResult: ({ result }) => (result.ok ? null : 'Something went wrong'), + }); + await registry.dispatch('app.t.good'); + await registry.dispatch('app.t.bad'); + expect(messages).toEqual(['Something went wrong']); + }); + + it('never breaks dispatch when the formatter or announcer throws', async () => { + const registry = setup(); + announceDispatches(registry, { + announcer: { announce: () => { throw new Error('boom'); }, dispose() {} }, + }); + expect((await registry.dispatch('app.t.good')).ok).toBe(true); + announceDispatches(registry, { + announcer: { announce() {}, dispose() {} }, + formatResult: () => { throw new Error('boom'); }, + }); + expect((await registry.dispatch('app.t.good')).ok).toBe(true); + }); + + it('dispose restores the previous dispatch', async () => { + const registry = setup(); + const messages: string[] = []; + const stop = announceDispatches(registry, { + announcer: { announce: (m) => messages.push(m), dispose() {} }, + }); + stop(); + await registry.dispatch('app.t.good'); + expect(messages).toEqual([]); + }); + + it('works end to end with the DOM announcer', async () => { + const registry = setup(); + const announcer = createAnnouncer(); + announceDispatches(registry, { announcer }); + await registry.dispatch('app.t.bad'); + expect(document.querySelector('[data-acture-announcer]')!.textContent).toContain('disk full'); + }); +}); diff --git a/packages/a11y/src/announcer.ts b/packages/a11y/src/announcer.ts new file mode 100644 index 0000000..6f4da24 --- /dev/null +++ b/packages/a11y/src/announcer.ts @@ -0,0 +1,132 @@ +/** + * Dispatch-result announcer (WCAG 4.1.3 Status Messages, research-12 §3.4). + * + * A command that succeeds or fails after the palette closes changes nothing a + * screen reader can see. `announceDispatches` observes every `registry.dispatch` + * and routes the `Result` to a polite `aria-live` region, so one install covers + * every surface (palette, hotkeys, menus, AI, macros). + * + * const announcer = createAnnouncer(); + * const stop = announceDispatches(registry, { announcer }); + * + * Like `acture-telemetry`, this wraps `registry.dispatch` (dispatch observation + * belongs in opt-in adapter packages, not core). Dispose in reverse install + * order if other instrumenters wrap the same registry. + */ + +import type { AnyCommandRecord, Context, DispatchOptions, Registry, Result } from 'acture'; + +/** A sink for status messages. `createAnnouncer` is the DOM implementation. */ +export interface Announcer { + /** Speak `message` without moving focus. */ + announce(message: string): void; + /** Remove the live region. */ + dispose(): void; +} + +export interface CreateAnnouncerOptions { + /** `'polite'` (default) waits for the user to be idle; `'assertive'` interrupts. */ + readonly politeness?: 'polite' | 'assertive'; + /** Where to mount the region. Default: `document.body`. */ + readonly root?: HTMLElement; +} + +/** Visually hidden but still exposed to assistive technology. */ +const VISUALLY_HIDDEN = + 'position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;' + + 'clip:rect(0,0,0,0);white-space:nowrap;border:0'; + +/** + * Mount a visually hidden `role="status"` live region and return an + * {@link Announcer} for it. Announcing the same text twice in a row still + * speaks twice (the text alternates a trailing no-break space, so the DOM + * actually changes). + */ +export function createAnnouncer(options: CreateAnnouncerOptions = {}): Announcer { + const root = options.root ?? document.body; + const politeness = options.politeness ?? 'polite'; + const region = root.ownerDocument.createElement('div'); + region.setAttribute('role', politeness === 'assertive' ? 'alert' : 'status'); + region.setAttribute('aria-live', politeness); + region.setAttribute('aria-atomic', 'true'); + region.setAttribute('data-acture-announcer', ''); + region.style.cssText = VISUALLY_HIDDEN; + root.appendChild(region); + + let flip = false; + return { + announce(message: string): void { + flip = !flip; + region.textContent = flip ? message : `${message} `; + }, + dispose(): void { + region.remove(); + }, + }; +} + +/** What `formatResult` receives for each dispatch. */ +export interface AnnounceInfo { + /** The registered record, or `undefined` for an unknown id. */ + readonly command: AnyCommandRecord | undefined; + readonly commandId: string; + readonly result: Result<unknown>; +} + +/** Return the message to announce, or `null`/`''` to stay silent. */ +export type FormatResult = (info: AnnounceInfo) => string | null; + +/** + * Default message: a failure reads `"<title> failed: <message>"`; a success + * reads `"<title> done"`. Pass your own `formatResult` to speak the outcome + * instead (`"3 nodes deleted"`) or to silence successes whose effect is + * already visible. + */ +export const defaultFormatResult: FormatResult = ({ command, commandId, result }) => { + const name = command?.title ?? commandId; + return result.ok ? `${name} done` : `${name} failed: ${result.error.message}`; +}; + +export interface AnnounceDispatchesOptions { + /** Where messages go. Required. */ + readonly announcer: Announcer; + /** Message per dispatch. Default {@link defaultFormatResult}. */ + readonly formatResult?: FormatResult; +} + +/** + * Wrap `registry.dispatch` so every call's `Result` is announced. A throwing + * `formatResult` or announcer is swallowed: announcing must never break + * dispatch. Returns a disposer that restores the previous `dispatch`. + */ +export function announceDispatches( + registry: Registry, + options: AnnounceDispatchesOptions, +): () => void { + const { announcer, formatResult = defaultFormatResult } = options; + const originalDispatch = registry.dispatch.bind(registry); + + (registry as { dispatch: Registry['dispatch'] }).dispatch = async function announcedDispatch<R>( + id: string, + params?: unknown, + ctx?: Context, + opts?: DispatchOptions, + ): Promise<Result<R>> { + const result = (await originalDispatch<R>(id, params, ctx, opts)) as Result<R>; + try { + const message = formatResult({ + command: registry.get(id), + commandId: id, + result: result as Result<unknown>, + }); + if (message) announcer.announce(message); + } catch { + // intentionally swallow — see doc comment + } + return result; + }; + + return () => { + (registry as { dispatch: Registry['dispatch'] }).dispatch = originalDispatch; + }; +} diff --git a/packages/a11y/src/aria.test.ts b/packages/a11y/src/aria.test.ts new file mode 100644 index 0000000..7883322 --- /dev/null +++ b/packages/a11y/src/aria.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { defineCommand, ok } from 'acture'; +import { toAriaProps, toAriaKeyShortcuts, isAvailable } from './aria.js'; + +const cmd = (spec: Partial<Parameters<typeof defineCommand>[0]> = {}) => + defineCommand({ id: 'app.t.x', title: 'Do it', execute: () => ok(0), ...spec }); + +describe('toAriaKeyShortcuts', () => { + it('resolves $mod to Control or Meta per platform', () => { + expect(toAriaKeyShortcuts(['$mod+Shift+p'], { apple: false })).toBe('Control+Shift+P'); + expect(toAriaKeyShortcuts(['$mod+Shift+p'], { apple: true })).toBe('Meta+Shift+P'); + }); + + it('uses UI Events key names', () => { + expect(toAriaKeyShortcuts(['Esc'], { apple: false })).toBe('Escape'); + expect(toAriaKeyShortcuts(['Ctrl+Alt+ArrowUp', 'cmd+option+k'], { apple: false })).toBe( + 'Control+Alt+ArrowUp Meta+Alt+K', + ); + }); + + it('keeps a literal + key and uppercases single letters', () => { + expect(toAriaKeyShortcuts(['$mod++'], { apple: false })).toBe('Control++'); + expect(toAriaKeyShortcuts(['alt+a'], { apple: false })).toBe('Alt+A'); + }); + + it('drops chords (ARIA cannot express key sequences) and de-duplicates', () => { + expect(toAriaKeyShortcuts(['g i'], { apple: false })).toBeUndefined(); + expect(toAriaKeyShortcuts(['g i', '$mod+k', 'Ctrl+k'], { apple: false })).toBe('Control+K'); + }); + + it('returns undefined for nothing', () => { + expect(toAriaKeyShortcuts([])).toBeUndefined(); + }); +}); + +describe('toAriaProps', () => { + it('projects label, description and shortcut', () => { + const props = toAriaProps( + cmd({ title: 'Save', description: 'Save the file', keybinding: '$mod+s' }), + { apple: false }, + ); + expect(props).toEqual({ + 'aria-label': 'Save', + 'aria-description': 'Save the file', + 'aria-keyshortcuts': 'Control+S', + }); + }); + + it('omits absent attributes instead of writing undefined', () => { + expect(toAriaProps(cmd())).toEqual({ 'aria-label': 'Do it' }); + }); + + it('maps a false when to aria-disabled and drops the shortcut', () => { + const c = cmd({ when: 'editor.focused', keybinding: '$mod+s' }); + expect(toAriaProps(c, { ctx: { editor: { focused: false } }, apple: false })).toEqual({ + 'aria-label': 'Do it', + 'aria-disabled': 'true', + }); + expect(toAriaProps(c, { ctx: { editor: { focused: true } }, apple: false })).toEqual({ + 'aria-label': 'Do it', + 'aria-keyshortcuts': 'Control+S', + }); + }); + + it('supports function when-clauses', () => { + const c = cmd({ when: (ctx) => ctx['on'] === true }); + expect(isAvailable(c, { on: false })).toBe(false); + expect(isAvailable(c, { on: true })).toBe(true); + }); + + it('uses the live keys passed in, not the record default', () => { + const c = cmd({ keybinding: '$mod+s' }); + expect(toAriaProps(c, { keys: ['$mod+Shift+s'], apple: true })['aria-keyshortcuts']).toBe( + 'Meta+Shift+S', + ); + expect(toAriaProps(c, { keys: [] })['aria-keyshortcuts']).toBeUndefined(); + }); +}); diff --git a/packages/a11y/src/aria.ts b/packages/a11y/src/aria.ts new file mode 100644 index 0000000..10d2f50 --- /dev/null +++ b/packages/a11y/src/aria.ts @@ -0,0 +1,132 @@ +/** + * Pure projections from a `CommandRecord` to the ARIA attributes a rendering + * surface (button, menu item, toolbar item) puts on its element + * (research-12 §3, §4.1). No `CommandRecord` field is added: every attribute is + * derived from fields the record already has. + * + * <button {...toAriaProps(cmd, { ctx })} onClick={() => dispatch(cmd.id)}> + * {cmd.title} + * </button> + * + * The surface still chooses the element and the role (`button`, `menuitem`, + * `option`); this module never does. + */ + +import { evaluateWhen } from 'acture'; +import type { AnyCommandRecord, Context } from 'acture'; + +/** The ARIA attributes projected from a command. A key is absent when the + * record has nothing to say, so spreading the object never writes `undefined`. */ +export interface AriaProps { + readonly 'aria-label': string; + readonly 'aria-description'?: string; + /** `'true'` when the command's `when` clause is false in `ctx`; absent otherwise. */ + readonly 'aria-disabled'?: 'true'; + /** The live keyboard shortcut(s), ARIA syntax. Absent when disabled or when + * the shortcut is a chord (ARIA cannot express key sequences). */ + readonly 'aria-keyshortcuts'?: string; +} + +export interface ToAriaPropsOptions { + /** Context for evaluating `when`. Omitted: the command counts as available. */ + readonly ctx?: Context; + /** The tinykeys tokens that are actually bound, e.g. from `acture-hotkeys`' + * `resolveKeys(cmd, userKeymap)`. Default: the record's own `keybinding`. + * Pass `[]` when the shortcut is not live on this surface. */ + readonly keys?: readonly string[]; + /** Resolve `$mod` to `Meta` (true) or `Control` (false). Default: detected + * from `navigator.platform`. */ + readonly apple?: boolean; +} + +/** True when the command is available in `ctx` (its `when` is true or absent). */ +export function isAvailable(cmd: AnyCommandRecord, ctx: Context = {}): boolean { + return evaluateWhen(cmd.when, ctx); +} + +/** + * Project a command to ARIA attributes: `aria-label` (the title, so the + * accessible name contains the visible label, WCAG 2.5.3), `aria-description`, + * `aria-disabled` from `when`, and `aria-keyshortcuts` from the live keys. + * A disabled command advertises no shortcut. + */ +export function toAriaProps(cmd: AnyCommandRecord, options: ToAriaPropsOptions = {}): AriaProps { + const available = isAvailable(cmd, options.ctx); + const shortcuts = available + ? toAriaKeyShortcuts(options.keys ?? normalizeKeybinding(cmd.keybinding), { + ...(options.apple !== undefined ? { apple: options.apple } : {}), + }) + : undefined; + return { + 'aria-label': cmd.title, + ...(cmd.description ? { 'aria-description': cmd.description } : {}), + ...(available ? {} : { 'aria-disabled': 'true' as const }), + ...(shortcuts ? { 'aria-keyshortcuts': shortcuts } : {}), + }; +} + +const KEY_NAMES: Readonly<Record<string, string>> = { + esc: 'Escape', + escape: 'Escape', + control: 'Control', + ctrl: 'Control', + alt: 'Alt', + option: 'Alt', + shift: 'Shift', + meta: 'Meta', + cmd: 'Meta', + command: 'Meta', +}; + +/** + * Convert tinykeys tokens (`"$mod+Shift+p"`) to an `aria-keyshortcuts` value + * (`"Meta+Shift+P"`): `+`-joined UI Events key names, modifiers first, + * alternatives space-separated. `$mod` becomes `Meta` on Apple, `Control` + * elsewhere. Chords (`"g i"`) are dropped: ARIA cannot express key sequences. + * Returns `undefined` when nothing remains. + */ +export function toAriaKeyShortcuts( + keys: readonly string[], + options: { readonly apple?: boolean } = {}, +): string | undefined { + const apple = options.apple ?? isApplePlatform(); + const out: string[] = []; + for (const token of keys) { + const trimmed = token.trim(); + if (trimmed === '' || /\s/.test(trimmed)) continue; // chord + const parts = splitCombo(trimmed); + const key = parts.pop(); + if (key === undefined) continue; + const modifiers = parts.map((m) => modifierName(m, apple)); + const combo = [...new Set(modifiers), keyName(key)].join('+'); + if (!out.includes(combo)) out.push(combo); + } + return out.length > 0 ? out.join(' ') : undefined; +} + +/** Split `"$mod+Shift+="` on `+`, keeping a trailing literal `+` as the key. */ +function splitCombo(combo: string): string[] { + if (combo.endsWith('++')) return [...combo.slice(0, -2).split('+'), '+']; + return combo.split('+'); +} + +function modifierName(raw: string, apple: boolean): string { + if (raw === '$mod') return apple ? 'Meta' : 'Control'; + return KEY_NAMES[raw.toLowerCase()] ?? raw; +} + +function keyName(raw: string): string { + const named = KEY_NAMES[raw.toLowerCase()]; + if (named) return named; + return raw.length === 1 ? raw.toUpperCase() : raw; +} + +function normalizeKeybinding(kb: AnyCommandRecord['keybinding']): readonly string[] { + if (kb === undefined) return []; + return typeof kb === 'string' ? [kb] : kb; +} + +function isApplePlatform(): boolean { + const nav = (globalThis as { navigator?: { platform?: string } }).navigator; + return /Mac|iPhone|iPod|iPad/.test(nav?.platform ?? ''); +} diff --git a/packages/a11y/src/index.ts b/packages/a11y/src/index.ts new file mode 100644 index 0000000..ea704fb --- /dev/null +++ b/packages/a11y/src/index.ts @@ -0,0 +1,22 @@ +/** + * `acture-a11y` — accessibility projections for a command registry. + * + * - `toAriaProps(cmd, { ctx })` — `aria-label`, `aria-description`, + * `aria-disabled` (from `when`) and `aria-keyshortcuts` (from `keybinding`). + * - `toAriaKeyShortcuts(keys)` — tinykeys tokens to an ARIA value. + * - `createAnnouncer()` + `announceDispatches(registry, { announcer })` — + * route every dispatch `Result` to a polite live region (WCAG 4.1.3). + * + * The hand-written equivalent is `docs/hand-written-a11y.md`. + */ + +export { toAriaProps, toAriaKeyShortcuts, isAvailable } from './aria.js'; +export type { AriaProps, ToAriaPropsOptions } from './aria.js'; +export { createAnnouncer, announceDispatches, defaultFormatResult } from './announcer.js'; +export type { + Announcer, + CreateAnnouncerOptions, + AnnounceInfo, + FormatResult, + AnnounceDispatchesOptions, +} from './announcer.js'; diff --git a/packages/a11y/tsconfig.json b/packages/a11y/tsconfig.json new file mode 100644 index 0000000..b49fd23 --- /dev/null +++ b/packages/a11y/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/a11y/tsup.config.ts b/packages/a11y/tsup.config.ts new file mode 100644 index 0000000..130f77d --- /dev/null +++ b/packages/a11y/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true, + treeshake: true, + target: 'es2022', + outDir: 'dist', + external: ['acture'], +}); diff --git a/packages/a11y/vitest.config.ts b/packages/a11y/vitest.config.ts new file mode 100644 index 0000000..a0d5710 --- /dev/null +++ b/packages/a11y/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + include: ['src/**/*.test.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afc7578..bf09b93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,7 @@ importers: dependencies: '@modelcontextprotocol/sdk': specifier: ^1.0.4 - version: 1.29.0(zod@4.4.3) + version: 1.29.0(supports-color@7.2.0)(zod@4.4.3) acture: specifier: workspace:* version: link:../../packages/core @@ -65,10 +65,10 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.2(@types/node@22.19.19)(tsx@4.21.0)) + version: 4.7.0(supports-color@7.2.0)(vite@6.4.2(@types/node@22.19.19)(tsx@4.21.0)) jsdom: specifier: ^25.0.0 - version: 25.0.1 + version: 25.0.1(supports-color@7.2.0) tsx: specifier: ^4.19.0 version: 4.21.0 @@ -80,13 +80,13 @@ importers: version: 6.4.2(@types/node@22.19.19)(tsx@4.21.0) vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) examples/greenfield/graph-editor: dependencies: '@modelcontextprotocol/sdk': specifier: ^1.0.4 - version: 1.29.0(zod@4.4.3) + version: 1.29.0(supports-color@7.2.0)(zod@4.4.3) acture: specifier: workspace:* version: link:../../../packages/core @@ -153,10 +153,10 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.2(@types/node@22.19.19)(tsx@4.21.0)) + version: 4.7.0(supports-color@7.2.0)(vite@6.4.2(@types/node@22.19.19)(tsx@4.21.0)) jsdom: specifier: ^25.0.0 - version: 25.0.1 + version: 25.0.1(supports-color@7.2.0) tsx: specifier: ^4.19.0 version: 4.21.0 @@ -168,7 +168,7 @@ importers: version: 6.4.2(@types/node@22.19.19)(tsx@4.21.0) vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) examples/migration/redux-wrap: dependencies: @@ -193,7 +193,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) examples/migration/zustand-wrap/after: dependencies: @@ -242,10 +242,10 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.2(@types/node@22.19.19)(tsx@4.21.0)) + version: 4.7.0(supports-color@7.2.0)(vite@6.4.2(@types/node@22.19.19)(tsx@4.21.0)) jsdom: specifier: ^25.0.0 - version: 25.0.1 + version: 25.0.1(supports-color@7.2.0) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -254,7 +254,7 @@ importers: version: 6.4.2(@types/node@22.19.19)(tsx@4.21.0) vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) examples/migration/zustand-wrap/before: dependencies: @@ -276,10 +276,10 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.2(@types/node@22.19.19)(tsx@4.21.0)) + version: 4.7.0(supports-color@7.2.0)(vite@6.4.2(@types/node@22.19.19)(tsx@4.21.0)) jsdom: specifier: ^25.0.0 - version: 25.0.1 + version: 25.0.1(supports-color@7.2.0) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -288,7 +288,28 @@ importers: version: 6.4.2(@types/node@22.19.19)(tsx@4.21.0) vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) + + packages/a11y: + devDependencies: + acture: + specifier: workspace:* + version: link:../core + jsdom: + specifier: ^25.0.0 + version: 25.0.1(supports-color@7.2.0) + tsup: + specifier: ^8.3.0 + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^2.1.0 + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) + zod: + specifier: ^4.0.0 + version: 4.4.3 packages/ai-vercel: devDependencies: @@ -300,13 +321,13 @@ importers: version: 7.0.22(zod@4.4.3) tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) zod: specifier: ^4.0.0 version: 4.4.3 @@ -327,13 +348,13 @@ importers: version: 25.0.1 tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) zod: specifier: ^4.0.0 version: 4.4.3 @@ -354,13 +375,13 @@ importers: version: link:../e2e-playwright tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) zod: specifier: ^4.0.0 version: 4.4.3 @@ -375,7 +396,7 @@ importers: version: link:../core tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) tsx: specifier: ^4.19.0 version: 4.21.0 @@ -384,7 +405,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) zod: specifier: ^4.0.0 version: 4.4.3 @@ -400,13 +421,13 @@ importers: version: 22.19.19 tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) packages/core: devDependencies: @@ -415,13 +436,13 @@ importers: version: 4.8.0 tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) zod: specifier: ^4.0.0 version: 4.4.3 @@ -436,13 +457,13 @@ importers: version: 19.2.14 '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.2(@types/node@22.19.19)(tsx@4.21.0)) + version: 4.7.0(supports-color@7.2.0)(vite@6.4.2(@types/node@22.19.19)(tsx@4.21.0)) acture: specifier: workspace:* version: link:../core jsdom: specifier: ^25.0.0 - version: 25.0.1 + version: 25.0.1(supports-color@7.2.0) react: specifier: ^19.0.0 version: 19.2.6 @@ -451,13 +472,13 @@ importers: version: 19.2.6(react@19.2.6) tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) zod: specifier: ^4.0.0 version: 4.4.3 @@ -472,13 +493,13 @@ importers: version: link:../core tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) zod: specifier: ^4.0.0 version: 4.4.3 @@ -493,16 +514,16 @@ importers: version: 22.19.19 eslint: specifier: ^9.17.0 - version: 9.39.4 + version: 9.39.4(supports-color@7.2.0) tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) packages/forms-autoform: devDependencies: @@ -514,13 +535,13 @@ importers: version: 19.2.14 '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.2(@types/node@22.19.19)(tsx@4.21.0)) + version: 4.7.0(supports-color@7.2.0)(vite@6.4.2(@types/node@22.19.19)(tsx@4.21.0)) acture: specifier: workspace:* version: link:../core jsdom: specifier: ^25.0.0 - version: 25.0.1 + version: 25.0.1(supports-color@7.2.0) react: specifier: ^19.0.0 version: 19.2.6 @@ -529,13 +550,13 @@ importers: version: 19.2.6(react@19.2.6) tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) zod: specifier: ^4.0.0 version: 4.4.3 @@ -547,7 +568,7 @@ importers: version: 6.8.0(@rjsf/utils@6.8.0(react@19.2.6))(react@19.2.6) '@rjsf/shadcn': specifier: ^6.8.0 - version: 6.8.0(@rjsf/core@6.8.0(@rjsf/utils@6.8.0(react@19.2.6))(react@19.2.6))(@rjsf/utils@6.8.0(react@19.2.6))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 6.8.0(@rjsf/core@6.8.0(@rjsf/utils@6.8.0(react@19.2.6))(react@19.2.6))(@rjsf/utils@6.8.0(react@19.2.6))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.3) '@rjsf/utils': specifier: ^6.8.0 version: 6.8.0(react@19.2.6) @@ -562,13 +583,13 @@ importers: version: 19.2.14 '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.2(@types/node@22.19.19)(tsx@4.21.0)) + version: 4.7.0(supports-color@7.2.0)(vite@6.4.2(@types/node@22.19.19)(tsx@4.21.0)) acture: specifier: workspace:* version: link:../core jsdom: specifier: ^25.0.0 - version: 25.0.1 + version: 25.0.1(supports-color@7.2.0) react: specifier: ^19.0.0 version: 19.2.6 @@ -577,13 +598,13 @@ importers: version: 19.2.6(react@19.2.6) tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) zod: specifier: ^4.0.0 version: 4.4.3 @@ -598,7 +619,7 @@ importers: version: link:../core jsdom: specifier: ^25.0.0 - version: 25.0.1 + version: 25.0.1(supports-color@7.2.0) react: specifier: ^19.0.0 version: 19.2.6 @@ -607,13 +628,13 @@ importers: version: 3.0.0 tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) zod: specifier: ^4.0.0 version: 4.4.3 @@ -622,19 +643,19 @@ importers: devDependencies: '@modelcontextprotocol/sdk': specifier: ^1.0.4 - version: 1.29.0(zod@4.4.3) + version: 1.29.0(supports-color@7.2.0)(zod@4.4.3) acture: specifier: workspace:* version: link:../core tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) zod: specifier: ^4.0.0 version: 4.4.3 @@ -646,16 +667,16 @@ importers: version: link:../core jsdom: specifier: ^25.0.0 - version: 25.0.1 + version: 25.0.1(supports-color@7.2.0) tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) zod: specifier: ^4.0.0 version: 4.4.3 @@ -670,7 +691,7 @@ importers: version: 19.2.14 '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.2(@types/node@22.19.19)(tsx@4.21.0)) + version: 4.7.0(supports-color@7.2.0)(vite@6.4.2(@types/node@22.19.19)(tsx@4.21.0)) acture: specifier: workspace:* version: link:../core @@ -679,7 +700,7 @@ importers: version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) jsdom: specifier: ^25.0.0 - version: 25.0.1 + version: 25.0.1(supports-color@7.2.0) react: specifier: ^19.0.0 version: 19.2.6 @@ -688,13 +709,13 @@ importers: version: 19.2.6(react@19.2.6) tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) zod: specifier: ^4.0.0 version: 4.4.3 @@ -706,13 +727,13 @@ importers: version: link:../core tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) zod: specifier: ^4.0.0 version: 4.4.3 @@ -730,13 +751,13 @@ importers: version: 11.1.8 tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) packages/state-zustand: devDependencies: @@ -748,13 +769,13 @@ importers: version: 11.1.8 tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) zustand: specifier: ^5.0.0 version: 5.0.13(@types/react@19.2.14)(immer@11.1.8)(react@19.2.6) @@ -766,13 +787,13 @@ importers: version: link:../core tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) zod: specifier: ^4.0.0 version: 4.4.3 @@ -796,13 +817,13 @@ importers: version: 4.8.0 tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) zod: specifier: ^4.0.0 version: 4.4.3 @@ -820,13 +841,13 @@ importers: version: 11.1.8 tsup: specifier: ^8.3.0 - version: 8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^2.1.0 - version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1) + version: 2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0) zod: specifier: ^4.0.0 version: 4.4.3 @@ -2841,6 +2862,7 @@ packages: eslint@9.39.4: resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -3793,6 +3815,9 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders' + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -4187,20 +4212,20 @@ snapshots: '@babel/compat-data@7.29.3': {} - '@babel/core@7.29.0': + '@babel/core@7.29.0(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helpers': 7.29.2 '@babel/parser': 7.29.3 '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@7.2.0) '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -4225,19 +4250,19 @@ snapshots: '@babel/helper-globals@7.28.0': {} - '@babel/helper-module-imports@7.28.6': + '@babel/helper-module-imports@7.28.6(supports-color@7.2.0)': dependencies: - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@7.2.0) '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 + '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/helper-module-imports': 7.28.6(supports-color@7.2.0) '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -4258,14 +4283,14 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0(supports-color@7.2.0))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0(supports-color@7.2.0))': dependencies: - '@babel/core': 7.29.0 + '@babel/core': 7.29.0(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.28.6 '@babel/runtime@7.29.2': {} @@ -4276,7 +4301,7 @@ snapshots: '@babel/parser': 7.29.3 '@babel/types': 7.29.0 - '@babel/traverse@7.29.0': + '@babel/traverse@7.29.0(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 @@ -4284,7 +4309,7 @@ snapshots: '@babel/parser': 7.29.3 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -4681,17 +4706,17 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(supports-color@7.2.0))': dependencies: - eslint: 9.39.4 + eslint: 9.39.4(supports-color@7.2.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.2': + '@eslint/config-array@0.21.2(supports-color@7.2.0)': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -4704,10 +4729,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.5': + '@eslint/eslintrc@3.3.5(supports-color@7.2.0)': dependencies: ajv: 6.15.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -4806,7 +4831,7 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + '@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.18) ajv: 8.20.0 @@ -4816,8 +4841,8 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.0.8 - express: 5.2.1 - express-rate-limit: 8.5.1(express@5.2.1) + express: 5.2.1(supports-color@7.2.0) + express-rate-limit: 8.5.1(express@5.2.1(supports-color@7.2.0)) hono: 4.12.18 jose: 6.2.3 json-schema-typed: 8.0.2 @@ -5374,7 +5399,7 @@ snapshots: - solid-js - vue - '@rjsf/shadcn@6.8.0(@rjsf/core@6.8.0(@rjsf/utils@6.8.0(react@19.2.6))(react@19.2.6))(@rjsf/utils@6.8.0(react@19.2.6))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@rjsf/shadcn@6.8.0(@rjsf/core@6.8.0(@rjsf/utils@6.8.0(react@19.2.6))(react@19.2.6))(@rjsf/utils@6.8.0(react@19.2.6))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.3)': dependencies: '@radix-ui/react-checkbox': 1.3.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -5397,7 +5422,7 @@ snapshots: lucide-react: 1.34.0(react@19.2.6) react: 19.2.6 tailwind-merge: 3.6.0 - tailwindcss-animate: 1.0.7 + tailwindcss-animate: 1.0.7(tailwindcss@4.3.3) uuid: 14.0.2 transitivePeerDependencies: - '@types/react' @@ -5577,11 +5602,11 @@ snapshots: '@vercel/oidc@3.2.0': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@22.19.19)(tsx@4.21.0))': + '@vitejs/plugin-react@4.7.0(supports-color@7.2.0)(vite@6.4.2(@types/node@22.19.19)(tsx@4.21.0))': dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0(supports-color@7.2.0)) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0(supports-color@7.2.0)) '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 @@ -5717,11 +5742,11 @@ snapshots: dependencies: is-windows: 1.0.2 - body-parser@2.2.2: + body-parser@2.2.2(supports-color@7.2.0): dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -5867,9 +5892,11 @@ snapshots: whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - debug@4.4.3: + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 decimal.js@10.6.0: {} @@ -6028,14 +6055,14 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.39.4: + eslint@9.39.4(supports-color@7.2.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(supports-color@7.2.0)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 + '@eslint/config-array': 0.21.2(supports-color@7.2.0) '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 + '@eslint/eslintrc': 3.3.5(supports-color@7.2.0) '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.8 @@ -6045,7 +6072,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -6101,25 +6128,25 @@ snapshots: expect-type@1.3.0: {} - express-rate-limit@8.5.1(express@5.2.1): + express-rate-limit@8.5.1(express@5.2.1(supports-color@7.2.0)): dependencies: - express: 5.2.1 + express: 5.2.1(supports-color@7.2.0) ip-address: 10.2.0 - express@5.2.1: + express@5.2.1(supports-color@7.2.0): dependencies: accepts: 2.0.0 - body-parser: 2.2.2 + body-parser: 2.2.2(supports-color@7.2.0) content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1 + finalhandler: 2.1.1(supports-color@7.2.0) fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -6130,9 +6157,9 @@ snapshots: proxy-addr: 2.0.7 qs: 6.15.1 range-parser: 1.2.1 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 + router: 2.2.0(supports-color@7.2.0) + send: 1.2.1(supports-color@7.2.0) + serve-static: 2.2.1(supports-color@7.2.0) statuses: 2.0.2 type-is: 2.0.1 vary: 1.1.2 @@ -6181,9 +6208,9 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@2.1.1: + finalhandler@2.1.1(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -6322,17 +6349,17 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-proxy-agent@7.0.2: + http-proxy-agent@7.0.2(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -6398,15 +6425,15 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@25.0.1: + jsdom@25.0.1(supports-color@7.2.0): dependencies: cssstyle: 4.6.0 data-urls: 5.0.0 decimal.js: 10.6.0 form-data: 4.0.5 html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + http-proxy-agent: 7.0.2(supports-color@7.2.0) + https-proxy-agent: 7.0.6(supports-color@7.2.0) is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.23 parse5: 7.3.0 @@ -6823,9 +6850,9 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.3 fsevents: 2.3.3 - router@2.2.0: + router@2.2.0(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -6853,9 +6880,9 @@ snapshots: semver@7.8.0: {} - send@1.2.1: + send@1.2.1(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -6869,12 +6896,12 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@2.2.1: + serve-static@2.2.1(supports-color@7.2.0): dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1 + send: 1.2.1(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -6963,7 +6990,11 @@ snapshots: tailwind-merge@3.6.0: {} - tailwindcss-animate@1.0.7: {} + tailwindcss-animate@1.0.7(tailwindcss@4.3.3): + dependencies: + tailwindcss: 4.3.3 + + tailwindcss@4.3.3: {} term-size@2.2.1: {} @@ -7023,13 +7054,13 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(postcss@8.5.14)(tsx@4.21.0)(typescript@5.9.3): + tsup@8.5.1(postcss@8.5.14)(supports-color@7.2.0)(tsx@4.21.0)(typescript@5.9.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) esbuild: 0.27.7 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 @@ -7107,10 +7138,10 @@ snapshots: vary@1.1.2: {} - vite-node@2.1.9(@types/node@22.19.19): + vite-node@2.1.9(@types/node@22.19.19)(supports-color@7.2.0): dependencies: cac: 6.7.14 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@22.19.19) @@ -7147,7 +7178,7 @@ snapshots: fsevents: 2.3.3 tsx: 4.21.0 - vitest@2.1.9(@types/node@22.19.19)(jsdom@25.0.1): + vitest@2.1.9(@types/node@22.19.19)(jsdom@25.0.1(supports-color@7.2.0))(supports-color@7.2.0): dependencies: '@vitest/expect': 2.1.9 '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.19)) @@ -7157,7 +7188,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -7167,11 +7198,11 @@ snapshots: tinypool: 1.1.1 tinyrainbow: 1.2.0 vite: 5.4.21(@types/node@22.19.19) - vite-node: 2.1.9(@types/node@22.19.19) + vite-node: 2.1.9(@types/node@22.19.19)(supports-color@7.2.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.19 - jsdom: 25.0.1 + jsdom: 25.0.1(supports-color@7.2.0) transitivePeerDependencies: - less - lightningcss From 787a79fc2e2ee83f89e280599355a12579035ff7 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:38:20 +0000 Subject: [PATCH 2/2] a11y: review fixes (Plus key, ctx-less availability, event.code tokens, SSR note, menu doc accuracy) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- docs/hand-written-native-menu.md | 8 +++++--- packages/a11y/README.md | 2 ++ packages/a11y/src/aria.test.ts | 11 ++++++++++- packages/a11y/src/aria.ts | 12 +++++++++--- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/docs/hand-written-native-menu.md b/docs/hand-written-native-menu.md index b0a1bc0..fe9624c 100644 --- a/docs/hand-written-native-menu.md +++ b/docs/hand-written-native-menu.md @@ -44,7 +44,8 @@ interface MenuOptions { onParameterized: (cmd: AnyCommandRecord) => void; } -/** tinykeys token to an Electron accelerator; undefined for chords. */ +/** tinykeys token to an Electron accelerator; undefined for chords. Partial: + * key names differ (Electron wants `Up`, `Plus`, ...), so extend for your bindings. */ function toAccelerator(keybinding: AnyCommandRecord['keybinding']): string | undefined { const first = typeof keybinding === 'string' ? keybinding : keybinding?.[0]; if (!first || /\s/.test(first)) return undefined; // Electron has no key sequences @@ -91,8 +92,9 @@ const rebuild = () => ); rebuild(); registry.onCommandsChanged(rebuild); // new or removed commands -// Electron menu items are static: call rebuild() whenever the context that -// `when` reads changes (selection, focus, mode), or the enabled flags go stale. +// `enabled`/`checked` are read when the menu is built: rebuild() whenever the +// context that `when` reads changes (selection, focus, mode), or mutate the +// MenuItem's `enabled`/`checked` in place. ``` ## Tauri diff --git a/packages/a11y/README.md b/packages/a11y/README.md index 22d0764..8efd463 100644 --- a/packages/a11y/README.md +++ b/packages/a11y/README.md @@ -31,3 +31,5 @@ import { toAriaProps } from 'acture-a11y'; You get `aria-label` (the title), `aria-description`, `aria-disabled="true"` when `when` is false, and `aria-keyshortcuts` (`$mod` resolved to `Control` or `Meta`; chords are omitted because ARIA cannot express sequences). A disabled command advertises no shortcut. Pass `keys` (for example `resolveKeys(cmd, userKeymap)` from `acture-hotkeys`) so the attribute reflects the shortcut that is really bound. Choosing the element and role is the surface's job. Menus show unavailable commands as disabled; the palette hides them. See the hidden-vs-disabled rule in the `acture-palette-design` skill, and [`docs/hand-written-native-menu.md`](../../docs/hand-written-native-menu.md) for the Electron/Tauri menu projection and the checked-state design. + +Server rendering: pass `apple` explicitly to `toAriaProps` / `toAriaKeyShortcuts`. Left to detection, a server emits `Control` and a Mac browser emits `Meta`, which is a hydration mismatch. diff --git a/packages/a11y/src/aria.test.ts b/packages/a11y/src/aria.test.ts index 7883322..f32a37d 100644 --- a/packages/a11y/src/aria.test.ts +++ b/packages/a11y/src/aria.test.ts @@ -19,7 +19,7 @@ describe('toAriaKeyShortcuts', () => { }); it('keeps a literal + key and uppercases single letters', () => { - expect(toAriaKeyShortcuts(['$mod++'], { apple: false })).toBe('Control++'); + expect(toAriaKeyShortcuts(['$mod++'], { apple: false })).toBe('Control+Plus'); expect(toAriaKeyShortcuts(['alt+a'], { apple: false })).toBe('Alt+A'); }); @@ -28,6 +28,10 @@ describe('toAriaKeyShortcuts', () => { expect(toAriaKeyShortcuts(['g i', '$mod+k', 'Ctrl+k'], { apple: false })).toBe('Control+K'); }); + it('maps event.code tokens to key names', () => { + expect(toAriaKeyShortcuts(['$mod+KeyP', 'Alt+Digit1'], { apple: false })).toBe('Control+P Alt+1'); + }); + it('returns undefined for nothing', () => { expect(toAriaKeyShortcuts([])).toBeUndefined(); }); @@ -62,6 +66,11 @@ describe('toAriaProps', () => { }); }); + it('does not evaluate when without a context', () => { + expect(isAvailable(cmd({ when: 'editor.focused' }))).toBe(true); + expect(toAriaProps(cmd({ when: 'editor.focused' }))['aria-disabled']).toBeUndefined(); + }); + it('supports function when-clauses', () => { const c = cmd({ when: (ctx) => ctx['on'] === true }); expect(isAvailable(c, { on: false })).toBe(false); diff --git a/packages/a11y/src/aria.ts b/packages/a11y/src/aria.ts index 10d2f50..8161c6d 100644 --- a/packages/a11y/src/aria.ts +++ b/packages/a11y/src/aria.ts @@ -28,7 +28,7 @@ export interface AriaProps { } export interface ToAriaPropsOptions { - /** Context for evaluating `when`. Omitted: the command counts as available. */ + /** Context for evaluating `when`. Omitted: `when` is not evaluated and the command counts as available. */ readonly ctx?: Context; /** The tinykeys tokens that are actually bound, e.g. from `acture-hotkeys`' * `resolveKeys(cmd, userKeymap)`. Default: the record's own `keybinding`. @@ -40,7 +40,8 @@ export interface ToAriaPropsOptions { } /** True when the command is available in `ctx` (its `when` is true or absent). */ -export function isAvailable(cmd: AnyCommandRecord, ctx: Context = {}): boolean { +export function isAvailable(cmd: AnyCommandRecord, ctx?: Context): boolean { + if (ctx === undefined) return true; // no context: `when` is not evaluated (as in `registry.list()`) return evaluateWhen(cmd.when, ctx); } @@ -83,7 +84,9 @@ const KEY_NAMES: Readonly<Record<string, string>> = { * (`"Meta+Shift+P"`): `+`-joined UI Events key names, modifiers first, * alternatives space-separated. `$mod` becomes `Meta` on Apple, `Control` * elsewhere. Chords (`"g i"`) are dropped: ARIA cannot express key sequences. - * Returns `undefined` when nothing remains. + * `+` becomes `Plus`; `KeyP`/`Digit1` codes become `P`/`1`. Shift plus a symbol + * (`Shift+?`) is passed through, but ARIA wants the physical key (`Shift+/`): write + * such bindings that way. Returns `undefined` when nothing remains. */ export function toAriaKeyShortcuts( keys: readonly string[], @@ -116,6 +119,9 @@ function modifierName(raw: string, apple: boolean): string { } function keyName(raw: string): string { + if (raw === '+') return 'Plus'; // ARIA spells the plus key out + const code = /^(?:Key([A-Z])|Digit(\d))$/.exec(raw); // tinykeys event.code tokens + if (code) return code[1] ?? code[2] ?? raw; const named = KEY_NAMES[raw.toLowerCase()]; if (named) return named; return raw.length === 1 ? raw.toUpperCase() : raw;