Files
vue-tui/.agents/docs/ink-divergences.md
T

467 lines
29 KiB
Markdown
Raw Normal View History

# vue-tui - Intentional Divergences from Ink
vue-tui started as a Vue 3 port of [Ink](https://github.com/vadimdemedes/ink), and it
still tracks Ink closely: the aim is behavioral parity except where a difference is
deliberate. It is no longer only a port, though. It has its own design decisions,
additive features, and Vue-native choices.
This document records the places vue-tui intentionally differs from Ink by design. A
difference that is not listed here is treated as a bug, or simply unverified behavior,
not a design choice.
Reference baseline: Ink **v7.0.4** (commit
`40b3a7578811fd616341ca4e31cc7748aeeff12f`). When bumping the target Ink version,
re-validate every entry below against the new source.
## How to Classify a Divergence
Classify each divergence by the first rule that applies. The order matters: earlier
sections are narrower, while later sections are broader fallbacks.
1. If Ink's supported subset still behaves the same and vue-tui only accepts more inputs,
supports more contexts, or exposes an extra capability, put it in **Additive
Supersets**.
2. If the primary reason is alignment with Vue's framework model, philosophy, or user
expectations, put it in **Vue-Aligned Design**.
- Use **Model-Implied Differences** when the difference comes from the React/Vue
framework-model boundary. Matching Ink would require React-shaped machinery inside
Vue, changing a core Vue-facing contract, or dealing with a React-only concept that
has no Vue equivalent.
- Use **Vue-Idiomatic Choices** when Ink could be copied, but vue-tui chooses the
behavior or public surface that better fits Vue's reactivity, lifecycle, component
boundaries, current-props model, or API conventions.
3. If the divergence is intentional but is not additive and is not primarily Vue-aligned,
put it in **Intentional Divergence Choices**.
4. If the note is not a divergence, put it in **Non-Behavioral Notes**.
Each divergence entry states what Ink does, what vue-tui does, and why the difference is
deliberate. Some entries also record consequences, costs, tests, or maintainer decisions
where those details are needed to understand the decision.
---
## Additive Supersets
vue-tui supports more than Ink in these cases. Ink-supported inputs and common use cases
remain compatible; vue-tui only adds accepted inputs, contexts, or capabilities.
### Multiple `<Static>` regions
- **Ink:** keeps a single `staticNode`; only one `<Static>` is honored.
- **vue-tui:** `findStatics(root)` renders **every** `<Static>` in the tree.
- **Why:** a tree with two `<Static>` regions renders both. Maintainer decision
(2026-05-30): KEEP.
### Ctrl+C exits under the kitty protocol too
- **Ink:** exits only on the legacy `\x03` byte (in `App`), so a kitty-protocol Ctrl+C
(`\x1b[99;5u`) parses fine but never exits. Its guard is byte-specific, not
Ctrl+C-specific.
- **vue-tui:** one encoding-agnostic exit in the always-on stdin controller (`emitInput`),
via `parseKeypress`. It matches Ctrl+C in both the legacy and kitty forms (but not
Ctrl+Shift+C), so it fires no matter which composable holds raw mode (`useInput` /
`useFocus` / `usePaste`, or none).
- **Why:** `exitOnCtrlC` is defined in terms of Ctrl+C, not one byte encoding. Keeping the
exit at the single always-on layer avoids splitting the behavior across two places. Opt
out with `exitOnCtrlC: false`. Maintainer decision (2026-05-30): KEEP. Tests:
`usePaste-only app exits on {legacy,kitty} Ctrl+C` in `input-kitty.test.ts`.
### `parseKeypress` filters kitty query-responses
- **Ink:** filters kitty keyboard-protocol query-responses (`ESC[?Nu`) in exactly **one**
place: the auto-detection lifecycle in `ink.tsx`
(`stripKittyQueryResponsesAndTrailingPartial` on a private `onData` buffer). Its
`parse-keypress.ts` has no query-response branch.
- **vue-tui:** mirrors that detection layer (in `kitty-keyboard.ts`) **and** adds a
parser-level filter: `parseKeypress` returns `{ ignore: true }` for `ESC[?Nu`, which
`useInput` then drops.
- **Why:** the detection layer does **not** cover the runtime input pipeline (`stdin 'data'`
-> `inputParser` -> `emitInput` -> `useInput` -> `parseKeypress`). In `enabled` mode it
never runs; in `auto` mode its `onData` listener and the stdin controller's
`handleData` both subscribe to the same `'data'` event, so stripping its private buffer
cannot stop the chunk reaching `handleData`; and after detection settles the listener is
gone. Empirically (Layer 2 removed, rebuilt) a stray query-response reaches a `useInput`
handler as spurious `"[?1u"` input in all of those cases, including a response split
across two reads, which `inputParser` reassembles before dispatch. The parser-level
filter is therefore intentional, not redundant. Introduced 2026-05-31. Tests: "kitty
query-response - end-to-end filtering" in `kitty-lifecycle.test.ts` (RED without it).
### Non-`Error` thrown values keep their message in the error overview
- **Ink:** `ErrorOverview` renders `error.message`; a thrown non-`Error` (`throw 'boom'`)
has no `.message`, so the overview shows a blank message.
- **vue-tui:** the error boundary keeps the **raw** thrown value and `ErrorOverview` shows
`String(value)` as the message, so `throw 'boom'` renders ` ERROR boom`, not a blank
`ERROR`. Like Ink, no stack block is rendered when the value carries no stack.
- **Why:** this gives a useful message for the (lint-discouraged) non-`Error` throw, and it
keeps the message vue-tui already surfaced before: when the boundary wrapped such throws
in `new Error(String(value))`, which also produced a misleading synthetic stack pointing
at framework internals. That synthetic stack is now gone. Introduced 2026-05-31.
### RGB `[r, g, b]` tuples on every color prop
- **Ink:** all color props (`<Text>` color/backgroundColor, `<Box>` backgroundColor, and
every border color/background prop) are **string-only**. `colorize`/`stylePiece` call
`color.startsWith('#')`, so passing an array **throws** (`.startsWith` is not a
function).
- **vue-tui:** the public `Color` type is `string | [number, number, number]`; `applyColor`
handles an array via `chalk.rgb(...)` / `chalk.bgRgb(...)`. Accepted uniformly on Text
color, Text/Box backgroundColor, and all border color/background props.
- **Why:** a strict superset. Every string Ink accepts still works, plus an ergonomic RGB
tuple. The tuple is part of the typed surface (not a TS-bypass), so it is a supported
input, not undefined behavior. Tested.
### `useAnimation()` outside a render tree drives a standalone animation
- **Ink:** the default `AnimationContext.subscribe()` is a no-op subscription with
`startTime: 0`, so a `useAnimation` rendered outside an Ink tree never ticks.
- **vue-tui:** `useAnimation` falls back to a freshly created standalone scheduler
(`inject(AnimationSchedulerKey, null) ?? createAnimationScheduler()`), so `frame`/`time`/
`delta` advance even with no surrounding app.
- **Why:** the composable still does useful work in isolation, such as a unit test or a
non-rendered driver. Additive; inside a tree the injected scheduler is used exactly as
Ink's. Contrast with the terminal-bound composables in the outside-render-tree entry,
which throw because they have no meaningful standalone mode.
### `measureElement` / `useBoxMetrics` also accept a Vue component-instance ref
- **Ink:** `measureElement(node: DOMElement)` and `useBoxMetrics(...)` read
`node.yogaNode` directly: a host `DOMElement` only.
- **vue-tui:** the ref is resolved through `$el` as well: a `ref` bound to a **Vue
component** (whose root host node is on `$el`), not just a host-node ref, resolves to the
underlying yoga node.
- **Why:** in Vue a template ref on a component yields the component instance, and its host
node is reached via `$el`. Supporting both shapes is a strict superset that matches how
Vue refs behave; a bare host-node ref still works identically to Ink.
### `renderToString` supports screen-reader mode
- **Ink:** `renderToString` has only a `columns` option; it always renders the non-SR
(ANSI) frame.
- **vue-tui:** `renderToString` accepts `isScreenReaderEnabled?: boolean`. In SR mode it
returns the linearized accessibility text (`renderScreenReaderOutput`) and prepends the
linearized `<Static>` output, just as the non-SR path prepends the painted static frame.
- **Why:** vue-tui already has a parity SR renderer for the live path. Surfacing it through
the string API is a strict superset (default `false` is byte-identical to Ink) and keeps
`<Static>` content in generated SR snapshots. Additive.
### Two apps sharing one stdin both receive input
- **Ink:** raw-mode count and the input listener are **per-`App`** (`useRef`), and Ink reads
via the `'readable'` event + `stdin.read()` (pull, `App.tsx:278-313`). Two `render()`s to
different stdout but one stdin each attach a `readable` listener, but the first-registered
listener's `read()` loop drains the buffer every tick, so the second app receives no
input until the first unmounts. And because counts are per-`App`, the first app's
unmount calls `stdin.setRawMode(false)`, dropping raw mode while the second still needs
it.
- **vue-tui:** the terminal raw-mode toggle is refcounted **per-stdin** (a shared
`WeakMap`), so one app's unmount cannot drop raw mode while another holds it; and the
`'data'` input listener is **per-controller**. Each app attaches its own `handleData` ->
own parser -> own emitter. Since `'data'` (push) broadcasts to **every** listener, both
apps receive every keystroke, and the second keeps receiving after the first unmounts.
- **Why:** this covers a combination vue-tui already allows: two `createApp`s to different
stdout. The same-stdout no-op is keyed on stdout, not stdin. The push model has no drain
race, and a shared raw-mode refcount matches the ownership model when several renderers
share one input. The common one-app-to-terminal flow is unchanged: one controller's
`localRefs` equals the shared `refs`. Test: `raw-mode-lifecycle.test.tsx` ("two apps
sharing one stdin both receive input...").
## Vue-Aligned Design
These divergences come from choosing Vue's framework model and user expectations as the
source of truth while tracking Ink. Some are model-implied: matching Ink would require
React-shaped machinery inside Vue, changing a core Vue-facing contract, or handling a
React-only concept that has no Vue equivalent. Others are idiomatic choices: Ink could be
copied, but vue-tui chooses the behavior or public surface that better fits Vue's
reactivity, lifecycle, component boundaries, current-props model, or API conventions.
### Model-Implied Differences
#### Reactive composable state is a `shallowRef`, not a plain snapshot
- **Ink/React:** a hook re-runs on every render of its component, so it can return a plain
value and the caller always reads the latest one. `useFocusManager().activeId`, for
instance, is a bare `string | undefined`, re-read fresh each render.
- **vue-tui:** a composable's `setup()` runs **once**, so reactive state cannot be a plain
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, including `useTerminalSize()` and `useFocusManager().activeId`. An empty
one holds `null` (Vue's convention for an empty ref: a template ref is
`ref<T | null>(null)`), where Ink's plain value is `undefined`.
- **Why:** the two frameworks track a changing value differently. React reads the newest
value by re-running the hook; Vue wraps it in a ref the template subscribes to. This is
the general rule, not a per-API choice. `useFocusManager().activeId` is just one
instance.
#### `useCursor()` re-assertion follows fine-grained reactivity, not React's render cascade
- **Ink:** `useCursor`'s no-deps `useInsertionEffect` (`use-cursor.ts:27-32`) re-runs on
every render **of the cursor component**, re-marking the cursor dirty
(`ink.tsx:494-497`); log-update resets `cursorDirty` each commit. React re-renders a
whole subtree when an ancestor commits, so if the cursor component is in that subtree it
re-renders and the cursor is re-asserted, even when only an ancestor's unrelated state
changed. If an unrelated sibling owns the changing state, the cursor component does
**not** re-render, so Ink does **not** re-assert and the cursor is dropped that commit.
- **vue-tui:** `useCursor` propagates via `watch(positionRef, ..., {flush:'sync'})`. It
re-asserts when the position **reference changes** (or the owning component re-renders
and re-sets it). Vue's fine-grained reactivity re-runs only components whose own deps
changed, so an ancestor-driven commit does **not** re-run a cursor child that did not
depend on the changed value, and a set-once cursor is dropped that commit.
- **Why:** the two agree for the **recommended** usage: set the position reactively (in the
render body / from a ref the component reads), as Ink's apps and vue-tui's parity tests
do. They also agree in the unrelated-sibling case (both drop). They differ only in the
narrow edge of a **set-once** cursor plus an **ancestor-driven** commit: React's render
cascade re-asserts it, Vue's fine-grained reactivity does not. This is a consequence of
React's cascade vs Vue's fine-grained re-render model. A global per-commit re-assert
would make vue-tui diverge from Ink in the opposite (unrelated-sibling) direction, where
Ink drops the cursor. Keep the reactivity-tied behavior. Maintainer decision
(2026-06-01): KEEP.
#### Invalid input is validated at the component layer, not the paint layer
- **Principle:** vue-tui validates invalid render input (a chalk-**modifier**
`backgroundColor` like `"bold"`, an unknown `borderStyle`) at the
**component-render layer** (`Box.ts` / `Text.ts`), not down at the **paint layer**. A bad
value therefore throws where the **error boundary** catches it -> `ErrorOverview` -> a
clean `reject` of `waitUntilExit()`, exactly like any other component error. The app
reports the error instead of crashing.
- **Ink:** validates the same inputs **lazily at paint** (`colorize` / `render-border`, run
from the reconciler's commit hook): **outside** React's ErrorBoundary, so a bad value is
an uncaught crash, not a recoverable error.
- **Why:** the key constraint is where paint runs. vue-tui's paint runs in a Vue
**post-flush callback** (`queuePostFlushCb`, decoupled from render), so a throw there
escapes `onErrorCaptured` and wedges the scheduler. Unlike a component error, it cannot
be made recoverable. The escape itself is symmetric, not a Vue weakness: a component
error boundary (React `ErrorBoundary`; vue-tui's `onErrorCaptured` wrapper) covers
framework-managed component work, never the renderer's paint callbacks, so a paint-layer
throw is uncatchable in **both** engines. Validating in `Box` / `Text` keeps a bad value
on that boundary-driven recoverable path; Ink's paint-time check can only crash.
- **Cost:** the component-layer check is eager (no paint-time layout/squash info), so it
over-throws in a few degenerate, invalid-input-only cases Ink never reaches. Realistic
inputs match Ink; both error on bad input. Only the channel (recoverable reject vs crash)
differs. Tests: `background-color.test.tsx`, plus the `borderStyle` validation tests.
#### A `setup()`-throwing component emits a dev-only `[Vue warn]` on stderr
- **Ink:** a component that throws during render surfaces only through the error overview /
exit path; React emits no extra framework warning.
- **vue-tui:** in a **development** build, a component whose `setup()` throws additionally
produces Vue's own `[Vue warn]` lines on stderr (for example, the missing-render-function
warning) that Ink has no analog for. In interactive mode `patchConsole` filters
`[Vue warn]` out of the frame; outside that path (debug, non-patched stderr) it surfaces.
- **Why:** these warnings come from Vue itself and are **dev-only** (stripped in production
builds); they have no effect on stdout output or the exit code. Documented so the stray
warn is not mistaken for vue-tui behavior: it is Vue's framework diagnostics.
#### Vue comment placeholders are inert host nodes, with one residual `false`-child divergence
- **Ink:** React emits no host node for `null` / `false` / `undefined` children in the
ordinary cases, so those children do not affect layout or transform indexing.
- **vue-tui:** a `null` / `false` / `undefined` child or a `v-if="false"` branch is
materialized by `@vue/runtime-core` as a **comment vnode**. That comment is the position
anchor Vue uses to refill its slot when the condition flips back. vue-tui's host
renderer creates a `TuiComment` for it, and makes that node inert: no yoga node, paints
nothing, never shifts a sibling's yoga index, and is skipped when counting the positional
`<Transform>` index (the `if (child.type !== "comment") index++` guards across all three
squash paths: top-level paint, nested transform, screen-reader; `G52`).
- **Why:** comment anchors are part of Vue's update model. The renderer must preserve the
anchor while making it output-inert, so the terminal result equals omitting the element in
the common `null` / `v-if` cases. `<Transform>` follows the same model: a slot that is
empty or all-comments renders **no node** (`return null`), matching Ink's
`children == null` guard for common `{null}` / `{cond ? x : null}` idioms.
- **Residual divergence:** a literal `{false}` / `{cond && x}`-false child differs. React
keeps `false !== null`, so Ink renders an empty node (a gap slot in a flex-gap container).
Vue collapses `false` and `null` into the same `TuiComment` and omits it. That gap-slot
mismatch is the documented cost of using one comment-anchor model everywhere.
#### React concurrent mode
- **Ink:** built on React; Suspense / `useTransition` are React features.
- **vue-tui:** no equivalent.
- **Why:** this is a React-only concept with no Vue equivalent, so it is N/A rather than a
parity gap.
### Vue-Idiomatic Choices
#### Entry point - `createApp()` instead of `render()`
- **Ink:** `render(<App/>, options?)`: `options` is `RenderOptions`; returns an `Instance`.
- **vue-tui:** `createApp(App)` returns a `TuiApp`; `app.mount(options?)` takes
`MountOptions`.
- **Why:** mirrors Vue's own `createApp` mental model. A Vue developer expects an app
object (`TuiApp`) they mount, not a one-shot render call. The mount-options bag and the
app handle are therefore Vue-shaped (`MountOptions` / `TuiApp`), not `render()`-shaped
(`RenderOptions` / `Instance`).
#### Second `mount()` on a live stdout is an inert no-op
- **Ink:** `render()` keeps one instance per stdout (`WeakMap<WriteStream, Ink>`); a second
`render(node, {stdout})` on a stream that already has a live instance warns on stderr but
**reuses** that instance and `rerender`s the new tree into it.
- **vue-tui:** a second `mount()` on a still-live stdout warns on stderr and returns an
**inert handle**. It wires no second renderer and renders nothing; the first app's tree
stays on screen. `unmount()`/`teardown()` on that handle are complete no-ops (they never
touch the owner's stream or registry entry).
- **Why:** an app is an object you `mount()`, not a one-shot call that doubles as a
re-render. "Re-render the live instance" has no place to land when the second call is a
separate `TuiApp`; the correct path is `unmount()` then mount again (or keep one app and
update its reactive state). Returning an inert handle avoids adding a competing renderer
on the shared stream. Test: `instance-reuse-guard.test.tsx`.
#### Host-node type - `DOMElement` -> `TuiNode`
- **Ink:** exports `DOMElement`, a DOM-emulation node (`nodeName` / `attributes` /
`childNodes`).
- **vue-tui:** the host tree is a different representation
(`TuiContainer | TuiTextLeaf | TuiComment`), exported as **`TuiNode`** from
`@vue-tui/runtime/internal`.
- **Why:** vue-tui's renderer keeps a native host-node tree rather than a DOM emulation, so
the exported node type names that tree, not a DOM node.
#### Removing `flexDirection` / `flexWrap` resets to the default
- **Ink:** these two props have no reset branch in `applyFlexStyles` (every _other_ flex
prop does), so an explicit `flexDirection={undefined}` leaves the previous value in
place.
- **vue-tui:** resets to the Box default (`row` / `nowrap`): the same state as if the prop
had never been set.
- **Why:** render is a function of the current props. With no value set, you get the
default, and (absent a special contract) dropping or changing a prop changes the output.
Keeping a previous render's value does not match that current-props model, and Ink resets
every other flex prop. Maintainer decision (2026-05-30): KEEP.
#### Removing `display` resets to the default (visible)
- **Ink:** `applyDisplayStyles` (`styles.ts`) sets `DISPLAY_NONE` whenever an explicit
`display` is present and not `'flex'`, so a present-but-undefined
`display={undefined}` **hides** the box, and an omitted `display` **persists** the prior
value.
- **vue-tui:** a removed/undefined `display` resets to the Box default `DISPLAY_FLEX`
(visible): the same state as if the prop had never been set.
- **Why:** same reasoning as the `flexDirection`/`flexWrap` reset above: render =
f(current props). No `display` set means the default (visible). Persisting a withdrawn
prop, or flipping it to hidden, does not match that model. Maintainer decision
(2026-05-31): KEEP.
#### Public composable naming follows Vue conventions
- **Ink/React:** public APIs are hooks (`useFocus`, `useInput`, ...) and the equivalent
hook-return types are named `XProps` (`StdinProps`, `AppProps`, ...).
- **vue-tui:** public APIs are Vue **composables** (`useFocus`, `useInput`, ...), and
composable return types follow VueUse's `UseXReturn` convention (`UseStdinReturn`,
`UseAppReturn`, ...). In vue-tui, `XProps` is reserved for component props (`BoxProps`,
derived via `ExtractPublicPropTypes`).
- **Why:** the public surface should read like Vue code. The return shapes still mirror
Ink field-for-field where the same public state exists; reactive state is represented as
refs for the model-implied reason documented above.
## Intentional Divergence Choices
These divergences are deliberate, but they are not strict supersets and are not primarily
driven by Vue's framework model or API conventions. vue-tui intentionally chooses a
different runtime behavior, ownership rule, or out-of-contract handling.
### Raw mode is owned for the interactive lifetime by default (`rawMode` option)
- **Ink:** raw mode is **lazy / reference-counted to input hooks**. `useInput` /
`useFocus` / `usePaste` enable it on mount and release it when the last one unmounts, so
a screen with no input handler falls back to cooked mode. There is no option to hold it.
- **vue-tui:** the `rawMode` mount option defaults to **`'always'`**. Raw mode is enabled at
mount and held for the whole interactive run (when `interactive` and stdin is a TTY),
regardless of which input composables are mounted. `rawMode: 'auto'` opts back into Ink's
exact lazy behavior.
- **Why:** for a long-running interactive app (a full-screen TUI, a coding agent), Ink's
lazy model makes raw mode **toggle** as the user moves between input and no-input
screens. The main consequence is **echo**: on a no-input / streaming screen the terminal
is back in cooked mode, so typed keys echo into the half-drawn frame (and line-buffer).
Ctrl+C also changes path: on a no-input screen it is a kernel SIGINT rather than the
app's own `\x03` intercept. Note `exitOnCtrlC` defaults to `true` in both Ink and
vue-tui, so by default Ctrl+C exits either way; the divergence is only the exit path/code
(a clean exit `0` vs a re-raised SIGINT `130`). It matters for an app that sets
`exitOnCtrlC: false` to handle Ctrl+C itself: under the lazy model its opt-out is
bypassed on a no-input screen (the SIGINT still exits). Holding raw for the
lifetime keeps echo and Ctrl+C handling identical on every screen. This matches the
cross-framework norm: Bubble Tea, Textual, Ratatui, and prompt_toolkit all own the
terminal for the program lifetime. Ink's hook-driven model differs: its "cooked on a
no-input screen" behavior follows from refcounting input hooks rather than from an
explicit no-input-screen contract.
- **Consequence:** owning raw mode `ref()`s stdin, so an `'always'` app stays alive until
you explicitly `unmount()` / `exit()`. It does **not** auto-exit when idle (the same way
an Ink app holding a `useInput` already does not). The "render and auto-exit" pattern
(Ink's inline-output use) is `rawMode: 'auto'`. Tests: `raw-mode-lifecycle.test.tsx`
(`'always'` holds raw with no input hook; `'auto'` stays cooked; no mid-session
oscillation).
### Narrowing resize cancels the redundant trailing `clearTerminal`
- **Ink:** `resized()` paints synchronously via `onRender()` but does **not** cancel a
pending throttled `onRender`; on a narrowing resize that trailing commit re-runs and,
because `shouldClearTerminalForFrame` clears whenever the previous frame overflowed, Ink
emits a **second** `clearTerminal`.
- **vue-tui:** `onResize` calls `scheduler.cancel()` before its synchronous commit,
dropping the now-redundant trailing commit. The screen is cleared **once** per narrowing
resize.
- **Why:** the synchronous resize commit already reflects the current tree, so the pending
commit repeats the same clear. Emitting one clear instead of two has no visible behavior
difference (issue #26).
### Out-of-type style values are forwarded, not defensively coerced
- **Ink:** several flex/align setters coerce an invalid runtime value to a default:
`flexShrink` non-number -> `1`; `alignItems`/`alignSelf`/`alignContent`/
`justifyContent` falsy (`""`) -> their default (STRETCH / AUTO / FLEX_START); and an
out-of-set value matches none of Ink's `if`-chain branches, so no setter runs and the
previous/default value persists.
- **vue-tui:** these setters trust the typed prop surface and forward the raw value to
yoga: a non-number `flexShrink` is passed through; `toAlign("")`/`toJustify("")` look up
`""` and pass `undefined` to the setter; and out-of-set values that yoga happens to
accept (`space-*`/`baseline`/`auto` on `alignItems`) reach yoga rather than being ignored.
- **Why:** every one of these is reachable **only** via a TS-bypass. The public prop types
forbid them. Within the typed contract Ink and vue-tui are identical. Ink's per-value
coercion is defensive code for runtime values vue-tui's types already exclude. Duplicating
those `typeof`/falsy guards would add checks for inputs the public types reject.
(`flexGrow` is not in this set: both only coerce null/undefined -> `0`.) If a reviewer
shows any case is reachable in-type, it becomes a bug to fix, not a divergence.
### Duplicate explicit-`id` `useFocus` calls dedup to one registry entry
- **Ink:** `addFocusable` unconditionally appends, so two `useFocus({id: 'x'})` create
**two** focusables with the same id. Tab visits "x" twice, and unmounting one calls
`removeFocusable` which filters by id and removes **both**.
- **vue-tui:** `add(id)` is id-keyed (`if (!focusables.some(f => f.id === id))`), so a
duplicate explicit id registers **one** entry.
- **Why:** the registry treats an id as identifying one focusable. With duplicate explicit
ids, Ink visits the same id twice and one unmount removes both entries. Auto-generated
ids never collide, so this only differs for an explicit duplicate id (already a user
error).
### Composables throw outside a render tree
- **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`,
`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:
they fall back. `useBoxMetrics` reports zero metrics, and `useAnimation` drives a
standalone scheduler. See the additive entry.
- **Why:** a composable used in the wrong place is a bug, and a thrown error names it at
the call site instead of returning a context that quietly does nothing. The two
exceptions fall back because they have a meaningful standalone behavior (zero metrics / a
working animation), so throwing would remove a useful capability.
## Non-Behavioral Notes
These notes are not divergence entries. They document Vue-facing conventions or internal
mechanics so they are not mistaken for parity gaps.
- Vue SFCs use `<script setup>`, and component definitions use `defineComponent()`.
- Filenames use kebab-case.
- Files use `.ts` over `.tsx` where there is no JSX.
- `shallowRef` is the default for reactive state. Use `ref` only when deep reactivity is
intentional and documented.
- Commit timing is deliberately Ink-aligned: leading+trailing throttle at
`ceil(1000/maxFps)` ms (34ms at the default `maxFps=30`, matching Ink's
`renderThrottleMs`), synchronous resize. This remains true even though re-renders come
from Vue's fine-grained reactivity, not a React subtree re-render.