diff --git a/.agents/docs/accessibility-api.md b/.agents/docs/accessibility-api.md index b44fb03..545ea5a 100644 --- a/.agents/docs/accessibility-api.md +++ b/.agents/docs/accessibility-api.md @@ -1,10 +1,11 @@ # Accessibility (ARIA + screen-reader) API -How vue-tui exposes ARIA and renders screen-reader (SR) output, and why. Companion to the terse -blessed entries in [[ink-divergences]] (aria props are camelCase; `renderToString` is layout-only; -`useWindowSize`); this file keeps the _why_ and the researched / run-verified findings the ledger -entries deliberately omit, so they are not re-derived expensively. The exported aria types are -part of the public contract — see [[api-contract]]. +How vue-tui exposes ARIA and renders screen-reader (SR) output, and why. Companion to the +aria-camelCase blessed entry in [[ink-divergences]]; `renderToString` being layout-only and the +`useWindowSize` name are now Ink parity (not divergences), and the reactive-refs return shape is +covered by the shallowRef entry there. This file keeps the _why_ and the researched / run-verified +findings those entries deliberately omit, so they are not re-derived expensively. The exported aria +types are part of the public contract — see [[api-contract]]. ## The constraint that shapes everything diff --git a/.agents/docs/api-contract.md b/.agents/docs/api-contract.md index 055cc52..bd8537b 100644 --- a/.agents/docs/api-contract.md +++ b/.agents/docs/api-contract.md @@ -31,8 +31,9 @@ Because it is contract, it is **tested, not merely shipped**: `@vue-tui/runtime/internal` is an explicitly internal / advanced surface — host-node types (`TuiNode`, …), test-only helpers (`renderToStringWithScreenReader`), dev/HMR types (`DevState`, -`DevErrorInfo`), kitty-controller internals, etc. It carries **no stability guarantee**, is not -covered by `public-api.test.ts`, and may change freely between releases. +`DevErrorInfo`), kitty-controller internals, etc. It carries **no stability guarantee**: its +surface is not snapshotted by `public-api.test.ts` (a member may be tripwired there to prove it is +internal, but the surface itself carries no contract), and may change freely between releases. Placement rule for any export: diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md index aef09fa..eeba67c 100644 --- a/.agents/docs/ink-divergences.md +++ b/.agents/docs/ink-divergences.md @@ -182,7 +182,7 @@ current-props model, or API conventions. snapshot: it would freeze at setup time. vue-tui returns a **`shallowRef`** whose `.value` updates and re-renders the template; read these as `.value`. Every stateful composable follows this — `useFocusManager().activeId` is a single ref read as `.value`, while a - composable may instead return an **object of refs** (`useTerminalSize()` returns + composable may instead return an **object of refs** (`useWindowSize()` returns `{ columns, rows }`, read as `.columns.value` / `.rows.value`). An empty single-ref state holds `null` (Vue's convention for an empty ref: a template ref is `ref(null)`), where Ink's plain value is `undefined`. @@ -611,7 +611,7 @@ different runtime behavior, ownership rule, or out-of-contract handling. - **Ink:** the hooks read a React context whose **default** value is a no-op object, so calling e.g. `useStdin()` outside an Ink tree returns inert defaults without an error. -- **vue-tui:** `useApp`, `useStdout`, `useStderr`, `useStdin`, `useTerminalSize`, +- **vue-tui:** `useApp`, `useStdout`, `useStderr`, `useStdin`, `useWindowSize`, `useFocus`, `useFocusManager`, `useInput`, `usePaste`, `useCursor`, and `useIsScreenReaderEnabled` **throw** when their context is absent ("... must be called inside a vue-tui render tree"). `useBoxMetrics` and `useAnimation` do **not** throw: diff --git a/README.md b/README.md index 451f94a..5975cc3 100644 --- a/README.md +++ b/README.md @@ -111,20 +111,20 @@ useInput((input) => { ## Composables (Hooks) -| Composable | Description | -| -------------------------- | -------------------------------------------------------------------------------------- | -| `useInput(handler, opts?)` | Handle keyboard input — receives `(input, key)` with modifier and arrow key detection | -| `usePaste(handler, opts?)` | Handle bracketed paste — receives the pasted `text` as a single event | -| `useFocus(opts?)` | Component-level focus — returns `{ isFocused, focus }` | -| `useFocusManager()` | App-level focus control — `focusNext()`, `focusPrevious()`, `focus(id)` | -| `useApp()` | App lifecycle — `{ exit(error?), waitUntilRenderFlush() }` | -| `useTerminalSize()` | Reactive terminal dimensions — `{ columns, rows }` (Ink-compat alias: `useWindowSize`) | -| `useStdin()` | Access stdin stream and raw mode control | -| `useStdout()` | Write directly to stdout | -| `useStderr()` | Write directly to stderr | -| `useBoxMetrics(ref)` | Measure a `` via a template ref — reactive `{ width, height, left, top }` | -| `useCursor()` | Control the terminal cursor — `setCursorPosition(pos)` in output coordinates | -| `useAnimation(opts?)` | Frame-based animation driver — reactive `{ frame, time, delta }` + `reset()` | +| Composable | Description | +| -------------------------- | ------------------------------------------------------------------------------------- | +| `useInput(handler, opts?)` | Handle keyboard input — receives `(input, key)` with modifier and arrow key detection | +| `usePaste(handler, opts?)` | Handle bracketed paste — receives the pasted `text` as a single event | +| `useFocus(opts?)` | Component-level focus — returns `{ isFocused, focus }` | +| `useFocusManager()` | App-level focus control — `focusNext()`, `focusPrevious()`, `focus(id)` | +| `useApp()` | App lifecycle — `{ exit(error?), waitUntilRenderFlush() }` | +| `useWindowSize()` | Reactive terminal dimensions — `{ columns, rows }` | +| `useStdin()` | Access stdin stream and raw mode control | +| `useStdout()` | Write directly to stdout | +| `useStderr()` | Write directly to stderr | +| `useBoxMetrics(ref)` | Measure a `` via a template ref — reactive `{ width, height, left, top }` | +| `useCursor()` | Control the terminal cursor — `setCursorPosition(pos)` in output coordinates | +| `useAnimation(opts?)` | Frame-based animation driver — reactive `{ frame, time, delta }` + `reset()` | ## Testing diff --git a/packages/runtime-tests/integration/composables/terminal-size.sequential.test.tsx b/packages/runtime-tests/integration/composables/window-size.sequential.test.tsx similarity index 100% rename from packages/runtime-tests/integration/composables/terminal-size.sequential.test.tsx rename to packages/runtime-tests/integration/composables/window-size.sequential.test.tsx diff --git a/packages/runtime-tests/integration/composables/terminal-size.test.tsx b/packages/runtime-tests/integration/composables/window-size.test.tsx similarity index 100% rename from packages/runtime-tests/integration/composables/terminal-size.test.tsx rename to packages/runtime-tests/integration/composables/window-size.test.tsx diff --git a/packages/runtime-tests/integration/public-api.test.ts b/packages/runtime-tests/integration/public-api.test.ts index 4598850..6fa8c57 100644 --- a/packages/runtime-tests/integration/public-api.test.ts +++ b/packages/runtime-tests/integration/public-api.test.ts @@ -71,3 +71,6 @@ test("does not expose the screen-reader linearizer publicly (Ink keeps it intern // It DOES type-check from `/internal`, which the runtime guard above already proves is the home. // @ts-expect-error - ScreenReaderOptions is exported only from @vue-tui/runtime/internal export type _ScreenReaderOptionsIsInternalOnly = import("@vue-tui/runtime").ScreenReaderOptions; + +// The parallel guard for the public `renderToString`'s dropped `isScreenReaderEnabled` OPTION lives +// in render-to-string.test.tsx (a call-site `@ts-expect-error`), next to the renderToString tests. diff --git a/packages/runtime-tests/integration/render-to-string.test.tsx b/packages/runtime-tests/integration/render-to-string.test.tsx index 25e4611..c68e945 100644 --- a/packages/runtime-tests/integration/render-to-string.test.tsx +++ b/packages/runtime-tests/integration/render-to-string.test.tsx @@ -42,6 +42,18 @@ describe("renderToString", () => { expect(output).toBe("test"); }); + // Contract guard: `isScreenReaderEnabled` is INTERNAL-only — the public `renderToString` must + // reject it at the type level (SR rendering goes through `renderToStringWithScreenReader` in + // `@vue-tui/runtime/internal`). If the option is ever re-added to the public `RenderToStringOptions`, + // the `@ts-expect-error` below goes unused and `tsc --noEmit` fails. (At runtime the unknown option + // is harmlessly ignored — only `columns` is read — so the frame still renders.) + test("public renderToString rejects the internal isScreenReaderEnabled option (type-level)", () => { + const App = defineComponent(() => () => x); + // @ts-expect-error - isScreenReaderEnabled is internal-only (use renderToStringWithScreenReader from /internal) + const output = renderToString(App, { isScreenReaderEnabled: true }); + expect(output).toBe("x"); + }); + test("rethrows component errors after cleanup", () => { const App = defineComponent(() => { throw new Error("boom"); diff --git a/packages/runtime/README.md b/packages/runtime/README.md index c1eca66..4373e2e 100644 --- a/packages/runtime/README.md +++ b/packages/runtime/README.md @@ -74,7 +74,7 @@ useInput((input) => { | `useFocus(opts?)` | Component-level focus — returns `{ isFocused, focus }` | | `useFocusManager()` | App-level focus — `focusNext()`, `focusPrevious()`, `focus(id)` | | `useApp()` | App lifecycle — `{ exit(error?), waitUntilRenderFlush() }` | -| `useTerminalSize()` | Reactive terminal dimensions — `{ columns, rows }` | +| `useWindowSize()` | Reactive terminal dimensions — `{ columns, rows }` | | `useAnimation(opts?)` | Frame-based animation loop — returns `{ frame, time, delta, reset }` | | `useBoxMetrics(ref)` | Reactive layout metrics — `{ width, height, left, top, hasMeasured }` | | `measureElement(node)` | Imperative read of computed `{ width, height }` from a yoga node |