Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
61 changes: 61 additions & 0 deletions docs/hand-written-a11y.md
Original file line number Diff line number Diff line change
@@ -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<R>(
id: string, params?: unknown, ctx?: Context, opts?: DispatchOptions,
): Promise<Result<R>> {
const result = await original<R>(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).
123 changes: 123 additions & 0 deletions docs/hand-written-native-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# 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. 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
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<string, AnyCommandRecord[]>();
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
// `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

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.
35 changes: 35 additions & 0 deletions packages/a11y/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# 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 `"<title> 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.

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.
61 changes: 61 additions & 0 deletions packages/a11y/package.json
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading