* refactor(runtime)!: rename AnimationOptions to UseAnimationOptions
Align the useAnimation options type with VueUse's UseXOptions convention, matching its sibling composable options bags (UseInputOptions / UsePasteOptions / UseFocusOptions) and the already-correct UseAnimationReturn. Hard rename, no deprecated alias — done while the package is pre-1.0 (0.0.x), so no stability break.
Recorded under "Public composable naming follows Vue conventions" in .agents/docs/ink-divergences.md. Surfaced by the public-API audit.
BREAKING CHANGE: the exported type AnimationOptions is renamed to UseAnimationOptions; update `import { type AnimationOptions }` to `import { type UseAnimationOptions }`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(runtime)!: tighten public API to Ink + record aria decision & alignment principle
Public-API audit follow-ups. Where vue-tui had drifted from Ink with no real Vue reason, align to Ink; reduce speculative surface; and record decisions in .agents/docs/ink-divergences.md.
- renderToString: drop the public `isScreenReaderEnabled` option (Ink's public renderToString is layout-only). The SR-capable variant moves to `@vue-tui/runtime/internal` as `renderToStringWithScreenReader` for the accessibility test suite; SR output is unchanged.
- useTerminalSize -> useWindowSize: drop the invented name + alias, align to Ink's `useWindowSize`. The reactive ref return shape is unchanged (shallowRef divergence still applies).
- DevState/DevErrorInfo: move from the public barrel to `@vue-tui/runtime/internal` (internal HMR types, no public consumer; Ink exposes no HMR types).
- docs(divergences): add a standing "Why align to Ink — and when not to" principle (alignment is a means to reduce bugs, not an end; Vue idiom + reasonableness outrank parity); record the aria-props camelCase decision with its run-verified type-safety boundary; stamp the rawMode-default and measureElement-$el entries with their KEEP decisions.
BREAKING CHANGE: removed public exports `useTerminalSize`, `DevState`, `DevErrorInfo`, and `renderToString`'s `isScreenReaderEnabled` option. Use `useWindowSize`; import HMR types from `@vue-tui/runtime/internal`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(runtime)!: move renderScreenReaderOutput to /internal-only
The screen-reader linearizer (ported from Ink's internal
`renderNodeToScreenReaderOutput`) was exported from the public barrel, but it
was never usefully public: its only parameter type `TuiNode` and the
node-construction primitives needed to build one are not public, so a public
consumer could not name or construct the argument. Ink keeps its counterpart
module-internal; we match that.
`renderScreenReaderOutput` + `ScreenReaderOptions` now live only in
`@vue-tui/runtime/internal` (already re-exported there). The live SR machinery
(render, the internal renderToStringWithScreenReader, the <Static> channel)
imports from the source module and is unaffected; public SR output is reached
via the mount `isScreenReaderEnabled` option.
public-api.test.ts: drop it from the public-members list; add a runtime guard
(absent from public, present on /internal) plus a compile-time @ts-expect-error
guard that the `ScreenReaderOptions` type cannot be re-added to the public
barrel.
Docs: new .agents/docs/accessibility-api.md (aria + SR design) and
api-contract.md (public surface = exports + their user-consumable types;
/internal is not the contract); resolve the open item and cross-link from
ink-divergences.md.
BREAKING CHANGE: renderScreenReaderOutput and ScreenReaderOptions are no longer
exported from @vue-tui/runtime; import from @vue-tui/runtime/internal if needed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(runtime-tests): snapshot the exact public value-export set
Upgrade public-api.test.ts from "documented members present + targeted
negatives" to an exhaustive snapshot of the exact runtime value-export surface
of `@vue-tui/runtime`: adding, removing, or renaming any value export now fails
the test, so every public-surface change must be a deliberate edit to the list.
Type-only exports are erased at runtime and cannot be enumerated, so the type
surface stays guarded individually (the `@ts-expect-error` ScreenReaderOptions
guard); api-contract.md is updated to state this boundary precisely.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Round-2 test-only locks (behaviors already at parity):
- focus (Ink test/focus.tsx): activeId resets to null on Esc, updates on programmatic
focus(id), resets on unmount of the focused item, is null initially then Tab lands on
first; Esc does NOT clear focus while focus management is disabled; focus(id) targets
a deactivated (isActive=false) item (membership-only, ignoring isActive).
- screen-reader (Ink test/screen-reader.tsx) via the live renderToString component path:
aria-label-only Text/Box, display:none subtree skipped, column>row space-join,
single-box aria-states (checked/selected/multiselectable + multi-state ', '-join),
role-only button, Transform accessibilityLabel replaces children.
Codex-reviewed GENUINE (expected strings match vue output + Ink intent).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the screen-reader locking tests omitted from #105 (the file was not staged):
a childless <Transform accessibilityLabel> emits nothing in SR mode (Ink's null
guard wins over the label), and a control proving the WITH-children label path is
unchanged. The behavior itself shipped in #105.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ink squashes every ink-text via squashTextNodes, which always returns
sanitizeAnsi(text) (squash-text-nodes.ts:45) — stripping cursor/erase CSI while
keeping SGR + OSC. vue's SR squash concatenated raw text-leaf values with no
sanitize, so an embedded control sequence (e.g. \x1b[2J) leaked into screen-reader
output. Wrap the squashed SR text in sanitizeAnsi at squashTextContent and the
standalone <Transform> branch — the SR twin of text-measure.ts:54. The double pass
on nested/transform text is idempotent (sanitizeAnsi is a fixed point), matching
Ink's recursive squashTextNodes.
Tests assert erase stripping in Text/Box/Transform AND that SGR is KEPT (so a
strip-everything regression is caught).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ink's `useApp()` returns `{ exit, waitUntilRenderFlush }`. vue-tui previously
exposed only `exit()` via `useExit()` and kept `waitUntilRenderFlush` on the
`TuiApp` handle alone. Align the public surface with Ink: add `useAppContext()`
returning the same pair, and remove `useExit()`.
- thread `waitUntilRenderFlush` into the injected `AppContext` via a hoisted
impl shared by the `TuiApp` handle and the composable, so both resolve
identically
- add `useAppContext()`; delete `useExit()`; migrate all call sites, PTY
fixtures, examples, READMEs and the public-API surface test
- port Ink's two "useApp waitUntilRenderFlush" tests; Ink's third relies on
React concurrent mode (N/A in Vue)
- rewrite the ink-divergences entry: this is now a *naming* divergence
(`useAppContext` vs `useApp`, mirroring `createApp` vs `render`), not a
surface one — and fix the prior wrong claim that Ink's `useApp` returns
stdin/stdout/stderr
The name is qualified (`useAppContext`, not `useApp`) so it doesn't read as the
Vue application instance (`createApp`/`app.mount`) — the same Vue-native naming
choice vue-tui already makes with `createApp()` vs Ink's `render()`.
BREAKING CHANGE: `useExit()` is removed. Replace `const exit = useExit()` with
`const { exit } = useAppContext()`.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Ink's onRender screen-reader branch (ink.tsx:573-625) writes the wrapped SR
transcript with a RAW stdout.write using manual ansiEscapes.eraseLines(prev) +
(inline static, if any) + the wrapped output, sets lastOutput/lastOutputToRender/
lastOutputHeight, and RETURNS before the normal interactive frame path. It emits
NO clearTerminal, does NOT accumulate/replay fullStaticOutput, does NOT go
through log-update, and does NOT hide the cursor.
vue-tui routed SR frames through renderInteractiveFrame, so a tall/overflowing
SR transcript (outputHeight >= viewportRows, then previousOutputHeight >
viewportRows) hit the clearTerminal branch — wiping the SR user's scrollback,
replaying accumulated fullStaticOutput, and the mount-time hide left the cursor
hidden.
This adds a dedicated `if (isScreenReaderEnabled) { ... return; }` branch in
commit(), before fullStaticOutput accumulation (now gated off for SR) and before
renderInteractiveFrame, mirroring Ink's SR branch byte-for-byte (eraseLines +
inline static + wrapped output, lastOutputToRender = wrapped output with no
trailing "\n", height = split count). It never clears the terminal, never
replays static, never uses the log-update writer, and the mount-time cursor-hide
is now skipped for SR mode. The normal (non-SR) interactive path is unchanged —
clearTerminal-on-tall-frame still applies there. G17/G46 SR behavior preserved.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
In Ink, <Transform> IS an ink-text host node (Transform.tsx renders
<ink-text internal_transform={fn}>), and the reconciler sets isInsideText
for ink-text. So bare-string and <Newline> children of a standalone
<Transform> render inline within that ink-text, with the transform applied
per line by the Output, and a nested <Text> child is squashed inline too.
The canonical README pattern `<Transform transform={fn}>Hello World</Transform>`
(no inner <Text>) works standalone.
vue-tui's transform host was a non-text yoga carrier: its direct text-leaf
children hit the paint no-op leaf branch and were silently dropped, a
<Newline> inside it rendered as a standalone "text" node (name-based
isInsideText saw no <Text> ancestor), and the SR squash ignored bare
text-leaf children. So `<Transform>ab</Transform>` rendered nothing and
`<Transform>a<Newline/>b</Transform>` applied the transform to empty lines.
Fix (treat a standalone <Transform> as a text context, matching Ink):
- paint.ts: when a transform has no yoga-carrying child, squash its inline
children to a string and write it like a text node, pushing the transform
as a per-line Output transformer (applied at paint, never at squash —
matching Ink). Transforms wrapping a yoga child keep the recursion path.
- yoga.ts: bind a text-style measure func on a standalone transform (its
squashed children, WITHOUT its own fn — matching Ink measureTextNode), and
toggle it off/on as yoga children are inserted/removed.
- Newline.ts / Text.ts: treat a <Transform> ancestor as a text context, so a
<Newline>/<Text> directly inside a transform emits inline virtual-text.
- node-ops.ts: allow bare text-leaf children of a transform (text-context
check) and mark a standalone transform dirty on inline child change; the
<Box>-in-<Text> guard still excludes transform (Ink renders Box-in-Transform
empty, no throw).
- screen-reader.ts: include bare text-leaf/virtual-text children of a
transform in SR output (Ink squashTextNodes includes #text).
All transform-in-text (G21/G32/G52), <Transform><Text>…</Text></Transform>,
nested transforms, plain <Text>/<Newline>, and Box-in-Transform cases verified
against Ink 7.0.4. Expected outputs (columns=40) confirmed by running the Ink
reference: `<Transform transform={s=>`<${s}>`}>ab</Transform>` + after →
"<ab>\nafter"; `<Transform>a<Newline/>b</Transform>` → "<a>\n<b>" (per-line);
SR → "ab" / "a\nb".
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Vue materializes a null/v-if/false render as a COMMENT host node that occupies
a positional slot in node.children. React never produces a childNode for such
children, so Ink's squash loop (squash-text-nodes.ts:13) never advances `index`
past them — empirically <Text>A{null}<Transform>(s,i)=>`${i}:${s}`>B</Transform>
yields "A1:B". vue-tui's three squash loops used the raw positional loop counter,
so a preceding comment took a slot and shifted the Transform to "A2:B".
The fix maintains a separate transform index that advances only for children
React would have produced — i.e. skips comment nodes — applied IDENTICALLY in
the paint, measure, and screen-reader paths so all three agree and match Ink.
G21 (which switched these loops from a hardcoded 0 to the positional counter)
introduced the precondition; its real-sibling positional indexing and G32's
transform-in-transform recursion remain intact.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Ink's screen-reader branch (ink.tsx:617-621) writes the wrapped output
verbatim — `stdout.write(erase + wrappedOutput)` with
`lastOutputToRender = wrappedOutput` (NO appended "\n" in ANY case) and
`lastOutputHeight = wrappedOutput === "" ? 0 : wrappedOutput.split("\n").length`.
The earlier G17 fix only suppressed the trailing newline for the EMPTY SR
frame (`output === ""`); a non-empty multi-line SR frame still fell through
to `output + "\n"`, which (1) parked the cursor on a spurious blank line
below the content and (2) made log-update count the previous frame as N+1
lines, emitting `eraseLines(N+1)` instead of `eraseLines(N)` on every
subsequent multi-line SR frame (off-by-one erase).
Broaden the suppression to ALL screen-reader frames so the written output
and its height match Ink's SR branch exactly. Non-SR interactive frames are
untouched — they still append "\n" as before.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
A plain <Box> (no explicit flexDirection) lays out as a row — yoga's Box
default is FLEX_DIRECTION_ROW (host/yoga.ts) — so its screen-reader children
must be joined with a space, matching Ink (which hardcodes flexDirection:'row'
in Box.tsx and derives the SR separator from style).
Root cause: flexDirection is a pure yoga prop. node-ops applies it to the yoga
node but does NOT mirror it into node.props (it is not in STYLE_PROPS), so the
yoga row default was never reflected there. screen-reader.ts read
node.props["flexDirection"], which was undefined for every live-rendered box
(both default AND explicit column/row), wrongly defaulting the separator to
"\n" in all cases.
Fix: resolve the direction from the yoga node (preferring an explicit
props.flexDirection for the direct-built unit fixtures), mirroring
static-channel.ts's resolvedFlexDirection so both SR linearization paths derive
the separator identically. Root keeps undefined → "\n" (Ink's column-default
root). row-reverse/column-reverse reversal is unaffected.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(runtime): concatenate children in screen-reader <Transform> (Ink parity, G23)
The screen-reader transform branch joined children with "\n", producing
newline-separated text where Ink concatenates. In Ink a <Transform> is an
`ink-text` node, so the screen-reader path squashes it via squashTextNodes
(squash-text-nodes.ts:42), which concatenates child text with "". Join with
"" instead of "\n" to match.
The transform node's own fn is intentionally NOT applied: verified empirically
against Ink 7.0.4 that squashTextNodes only applies the internal_transform of
*child* nodes (squash-text-nodes.ts:34-39), never of the top-level node handed
to it. A <Transform> directly under a <Box> is squashed as the top-level node,
so its own transform is skipped — yielding the bare concatenated children.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(parity): ledger — G23 pr-open, reconcile G22 merged, log G23 spec correction
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(runtime): dedup screen-reader role against immediate parent only (Ink parity, G22)
Drop the `?? options.parentRole` grandparent fallback so a role-less intermediate
resets inherited parentRole to undefined for its children, matching Ink's
immediate-parent-only role dedup (render-node-to-output.ts:68-69 passes only
`node.internal_accessibility?.role`, no fallback).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(parity): ledger — G22 pr-open, reconcile G21 merged
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(runtime): pass child sibling index to nested <Transform> (Ink parity, G21)
paint.ts renderTextWithInlineStyles and text-measure.ts flattenLeaves squash
loops now pass the child's position index to child.transform instead of a
hardcoded 0, matching Ink squash-text-nodes.ts:13,38 where internal_transform
receives the plain loop counter over node.childNodes (all siblings). A nested
<Transform> that is the Nth child of a <Text> therefore gets index = N. Both
spots use the same index basis so paint and measurement agree. Refines the
earlier G06 refutation — the inline/squash path was the real gap.
Also adds the `innerText.length > 0` guard in text-measure.ts flattenLeaves so
measurement skips transforms on empty text (matching paint.ts and Ink:34), and
converts screen-reader.ts squashTextContent to use forEach-with-index so the
nested-Transform index is correct in SR mode too (was hardcoded 0).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(parity): ledger — G21 pr-open, reconcile G20 merged
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(runtime): linearize screen-reader static output + drop empty-SR-frame newline (Ink parity, G17)
(a) The live static channel flushed <Static> via the 2D grid painter
(paintIsolated) even in screen-reader mode, so bordered static items leaked
box glyphs. paintStaticNode now takes an isScreenReaderEnabled flag and
linearizes fresh static children via renderScreenReaderOutput
(skipStaticElements:false) instead — matching Ink's renderer.ts:24, which
renders node.staticNode through renderNodeToScreenReaderOutput. Non-SR static
is unchanged. render.ts commit() and render-to-string.ts thread the flag.
(b) Interactive SR frames went through renderInteractiveFrame, which appends
"\n" even for empty output, leaking a spurious blank line. Ink's SR path writes
the wrapped output directly with lastOutputToRender = wrappedOutput (no appended
newline), so an empty SR frame emits zero lines. We now suppress the trailing
newline for EMPTY SR output only — matching ink.tsx:573-626 — leaving non-SR
and non-empty SR frames untouched.
Follow-up fixes (two review findings):
- renderToString in SR mode no longer DROPS <Static> output: the SR return
branch now prepends the captured/linearized static output like the non-SR
path (Ink's SR renderer returns staticOutput when node.staticNode exists,
renderer.ts:24-33).
- The SR static linearization now honors the <Static>'s resolved flexDirection
for separator + child order (read from yoga via getFlexDirection), matching
screen-reader.ts:73-82 (row/row-reverse → space, *-reverse reverses order);
the default column case is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(parity): ledger — G17 pr-open, reconcile G16 merged
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(runtime): render linear screen-reader output in the live commit path (Ink parity, G03)
commit() previously called paint(tuiRoot) (the 2D grid painter) unconditionally
for both the non-interactive and interactive branches, so an app mounted with
isScreenReaderEnabled emitted the visual frame (box-drawing borders, padded
grid) into the live stream instead of flat linearized text. isScreenReaderEnabled
was only consulted to disable commit throttling.
Add a renderFrame(width) helper that branches on isScreenReaderEnabled: when SR
is enabled it linearizes the tree via renderScreenReaderOutput(tuiRoot,
{ skipStaticElements: true }) and wraps it with wrapAnsi(out, width,
{ trim: false, hard: true }), mirroring Ink's onRender SR branch
(ink.tsx:598-603). Both commit branches now call renderFrame() instead of
paint() directly. The non-SR path is byte-for-byte unchanged (renderFrame
returns paint(tuiRoot)). Static output continues to flush through the existing
paintStaticNode path; full SR-static linearization parity is deferred.
render-to-string.ts already used renderScreenReaderOutput and is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(parity): ledger — G03 pr-open, reconcile G02 merged, track G17 (SR edges)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* test: add text ANSI sanitization parity tests from Ink (+15)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add use-animation parity tests from Ink (+43)
Port 43 new tests from Ink's use-animation test suite covering:
- Multiple animations in sync, different rates, sibling unmount
- Timer cleanup/recreation on unmount and remount
- Inactive animations, timer leak prevention
- Edge intervals (NaN, Infinity, -Infinity, oversized, zero, negative)
- isActive toggle resets, pause/resume cycles
- Frame catch-up, time/delta tracking, reset() behavior
- Newly mounted/activated animations don't inherit elapsed time
- Wall clock monotonicity, getter function isActive support
Uses selective fake timers (setInterval + performance only) so that
render()'s internal setImmediate still works on real clocks. Fake timer
tests read refs directly to avoid Vue scheduler flush timing issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add use-box-metrics/measure parity tests from Ink (+16)
Port 16 missing tests from Ink's use-box-metrics, measure-element, and
measure-text test suites. Fix useBoxMetrics to reset metrics to zeros
when the tracked ref detaches (element unmounts or ref switches to null).
3 tests are skipped because vue-tui's useBoxMetrics uses watchPostEffect
(re-runs only when ref.value changes) rather than Ink's layout-commit
listener pattern, so sibling-content and resize-driven re-measurement
is not yet supported.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add screen-reader parity tests from Ink (+11)
Add 11 screen-reader integration tests covering aria-label substitution on
Text/Box, ANSI styling omission, multiple/nested components, null component,
aria-state variants (busy, disabled, expanded), multi-line roles, and
multiselectable listbox.
Also fix component prop bug: Vue normalizes kebab-case prop names to camelCase
at runtime, so props["aria-label"] was always undefined. Switch Box/Text prop
declarations and access to camelCase (ariaLabel, ariaHidden, ariaRole, ariaState).
Add isScreenReaderEnabled option to renderToString() so tests can exercise
screen-reader output through the component pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add render-to-string parity tests from Ink (+18)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add cursor composable parity tests from Ink (+7)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: add 6 missing screen-reader Ink parity tests
Add tests for aria-hidden, select input (list with roles/states/labels),
aria-state.multiline, aria-state.readonly, aria-state.required, and
nested multi-line text rendering in screen-reader mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: fix render-to-string missing Ink parity tests (+10)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: fix cursor composable missing Ink parity tests (+6)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: fix use-box-metrics missing Ink parity tests (+4)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: add layout listener so useBoxMetrics updates on resize and sibling changes
Adds a layout listener mechanism to TuiRoot matching Ink's architecture:
- TuiRoot.layoutListeners Set with addLayoutListener/emitLayoutListeners
- emitLayoutListeners called after every yoga.calculateLayout in commit()
- useBoxMetrics subscribes to layout listeners, diffs values before updating
Enables 4 previously-skipped tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>