Files
vue-tui/.agents/docs/ink-divergences.md
T
Yunfei He c4b001c12f docs(divergences): broaden into an Ink relationship record; strengthen the alignment-is-a-means principle (#204)
ink-divergences.md used to record only divergences from Ink. Broaden it into the
single record point for the *whole* Ink relationship: it now also records deliberate
alignments (intentional sameness) as first-class entries, not just differences.

- Reframe the title + intro: three relationship kinds (deliberate alignments,
  intentional divergences, non-behavioral notes), each a conscious decision.
- Elevate + strengthen the governing principle: aligning to Ink is only a means to
  reduce bugs, never the goal — correctness and Vue philosophy outrank parity, and
  "because Ink does it" is never on its own a justification.
- Add a first-class "Deliberate Alignments" section; promote the two deliberate-
  sameness records (commit-timing Ink-alignment, literal-tab measure-vs-paint, the
  latter keeping its existing [VOUCHED @hyf0]) out of Non-Behavioral Notes into it.
- Restructure the classification flow to split deliberate match vs deliberate
  divergence first ("How to Classify an Entry").
- Update AGENTS.md guidance and the two stale cross-references to the renamed
  headings (accessibility-api.md + the in-file ARIA entry).

No existing divergence entry's substance changed; no [VOUCHED] stamp was added or
removed (AI cannot self-bless). Reviewed by Codex + an independent reviewer.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:08:32 +08:00

853 lines
60 KiB
Markdown

# vue-tui ↔ Ink — Relationship Record
vue-tui started as a Vue 3 port of [Ink](https://github.com/vadimdemedes/ink), and it
still tracks Ink closely. It is no longer only a port, though: it has its own design
decisions, additive features, and Vue-native choices.
This document is the single record point for **how vue-tui relates to Ink** — not only
where it differs. It records three kinds of relationship, each treated as a conscious
decision:
- **Deliberate alignments** — places vue-tui consciously _matches_ Ink (including where it
could easily have diverged, or where it keeps an Ink quirk on purpose) because matching is
the most reasonable behavior. A load-bearing alignment is worth recording so a later
"improvement" knows the match was a choice, not an accident.
- **Intentional divergences** — places vue-tui deliberately _differs_ from Ink, each with a
real reason; a kept (human-blessed) divergence also carries an explicit `[VOUCHED @handle]`
stamp.
- **Non-behavioral notes** — Vue-facing conventions and internal mechanics that are not
behavioral claims but are easy to mistake for parity gaps.
A behavioral difference that is **not** recorded here as a deliberate divergence is treated
as a bug, or as simply unverified behavior — never as an implicit design choice. An
alignment, conversely, earns an entry only when it is load-bearing or non-obvious; the vast,
unremarkable majority of parity needs no record.
Reference baseline: Ink **v7.0.4** (commit
`40b3a7578811fd616341ca4e31cc7748aeeff12f`). When bumping the target Ink version,
re-validate every entry below against the new source.
## The governing principle: correctness first, alignment is only a means
**Aligning to Ink is a means, never the goal.** The goal is the _most correct, most
Vue-idiomatic behavior_. Ink is a mature, battle-tested implementation, so wherever Ink is
already right, matching it is simply the **cheapest way to be correct** — vue-tui inherits years
of bug-fixes and edge-case handling for free. Reducing bugs by reusing proven behavior is the
_entire_ reason alignment has any value here.
It follows directly that **parity never outranks correctness.** When Ink's behavior is itself a
defect, is unreasonable, or is un-idiomatic for Vue, **the plain correctness/reasonableness of the
behavior and conformance to Vue's philosophy win — and vue-tui deliberately diverges.** "Ink does
it this way" is never, on its own, a justification; it is only shorthand for "Ink is already
correct here, so matching is the cheap path to correctness."
This priority ordering is _why this file records alignments too_. If alignment were the goal,
matching Ink would need no record — it would be "the right thing" by definition. Because alignment
is only a _means_, a deliberate match is a genuine decision ("Ink is correct here, so we match")
exactly as much as a deliberate divergence is — and a load-bearing one deserves the same written
rationale, so a future change that would break it knows it was chosen, not stumbled into.
The principle guards against two opposite failure modes:
- **Blind alignment** — copying Ink even where Ink is wrong, or where matching would force
un-Vue machinery, merely to match. (Rejected e.g. in the `useCursor` corner-zombie, the
resolve-on-throw exit, and the paint-time invalid-input crash — Ink behaviors vue-tui treats
as defects, not contracts.)
- **Lazy divergence** — inventing a different behavior and rationalizing it as "Vue's way is
better" with no genuine Vue-philosophy or correctness reason. Mere presence in this file is
**not** a vouch; every kept divergence needs a real reason and an explicit `[VOUCHED @handle]` stamp.
So the test for any behavior is never "does it match Ink?" but: **"is this the most reasonable,
most Vue-idiomatic behavior?"** — and then, separately, "is the relationship to Ink (a match or a
divergence) a conscious, recorded decision?" Correctness and Vue idiom come first; alignment is
just the cheapest route to them whenever Ink is already right.
## How to Classify an Entry
Classify each entry by the first rule that applies; the order matters.
1. If it is **not a behavioral claim** — a Vue-facing naming convention or an internal
mechanic that is only easy to mistake for a parity gap — put it in **Non-Behavioral
Notes**.
2. If it is a **deliberate decision to _match_ Ink** that is worth recording — a load-bearing
parity point, a place vue-tui could easily have diverged but consciously did not, or an
Ink quirk kept on purpose — put it in **Deliberate Alignments**.
3. Otherwise it is a **deliberate _divergence_**. Classify it by the first sub-rule that
applies (earlier sections are narrower, later ones 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 → **Additive Supersets**.
2. If the primary reason is alignment with Vue's API shape, framework model, mental model,
or user expectations → **Vue API and Mental Model Divergences**.
- **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.
- **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 neither additive nor primarily Vue-aligned →
**Intentional Divergence Choices**.
Each **divergence** entry states what Ink does, what vue-tui does, and why the difference is
deliberate; a kept divergence also carries an explicit `[VOUCHED @handle]` stamp. Each
**alignment** entry states what is shared, that the match is deliberate, and why matching is the
most reasonable behavior — not merely "because Ink does it". Some entries also record
consequences, costs, tests, or the reasoning behind a vouch where those details aid understanding.
---
## Deliberate Alignments
These are places vue-tui consciously **matches** Ink — recorded not because every parity point
needs an entry (the unremarkable majority does not), but because each is a _decision_: a behavior
vue-tui could plausibly have done differently, or an Ink quirk kept on purpose, where matching is
the most reasonable choice. Recording it means a later change that breaks the match knows it was
chosen, not accidental. Per the governing principle, the justification is always "this is the
correct/reasonable behavior and Ink already has it," never "because Ink does it." Alignment
carve-outs that are tightly bound to a specific divergence are noted inline within that divergence
entry instead (e.g. the global screen-reader carve-out under "Invalid input is validated at the
component layer").
### Commit timing (throttle cadence, FPS, synchronous resize)
Commit timing is deliberately Ink-aligned: leading+trailing throttle at
`Math.max(1, Math.ceil(1000/maxFps))` ms behind a `maxFps > 0` guard (34ms at the default
`maxFps=30` — both engines compute exactly this), synchronous resize. The scheduler mirrors the
observable timing of Ink's es-toolkit throttle (run-verified vs v7.0.4): the trailing timer
re-arms on every deferred call, so the trailing commit fires at `lastCall+wait` (not
`windowStart+wait`), and a call arriving a full window after the first deferral commits
synchronously (es-toolkit's `maxWait`), keeping a ~`wait` cadence under sustained updates. This
remains true even though re-renders come from Vue's fine-grained reactivity, not a React subtree
re-render. Matching Ink's well-tuned cadence buys the same perceived responsiveness and flush
guarantees without re-deriving them. One deliberate exception: resize cancels the pending trailing
commit — see the divergence "Resize unconditionally cancels the pending trailing commit".
### Literal tabs in `<Text>` are not normalized (measure vs paint width)
Tabs in `<Text>` aren't normalized — measured width can disagree with painted width (shared with
Ink, KEEP). `string-width` counts `\t` as 0 columns, but paint expands it to the next 8-column tab
stop (`wrap-ansi` / terminal), so a `<Text>` with a literal tab reserves fewer columns than it
draws (`ab\tcd` measures 4, paints ~10). Ink v7.0.4 does the same, so vue-tui is aligned here;
KEEP — literal tabs in TUI text are vanishingly rare, so inheriting the quirk costs less than
re-deriving tab handling for input that essentially never occurs. If ever fixed, expand tabs to
spaces at the shared squash step (the only place with the column context an isolated tab lacks),
upstream of `string-width` — and that fix would then become a divergence entry. [VOUCHED @hyf0]
## 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. KEEP. [VOUCHED @hyf0]
### 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`. KEEP. [VOUCHED @hyf0] 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).
### `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`. Because `<Box>` is a `defineComponent`, the `$el` path is in
fact the **primary** path a normal `ref` on `<Box>` takes — the bare host-node ref is the
rarer raw-host case. Supporting both is a strict superset that matches how Vue refs behave;
a bare host-node ref still works identically to Ink. KEEP
— a reasonable Vue-idiomatic adoption (the component-instance ref is the natural Vue path;
the bare host-node ref stays Ink-identical). [VOUCHED @hyf0]
### 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..."). KEEP. [VOUCHED @hyf0]
## Vue API and Mental Model Divergences
These divergences come from choosing Vue's API shape, framework model, mental 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 — `useFocusManager().activeId` is a single ref read as `.value`, while a
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<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. This follows Vue's philosophy: changing state is exposed as a reactive source,
not as a one-time snapshot. KEEP. [VOUCHED @hyf0]
#### 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 on stderr (verified: stderr stays
empty).
- **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. While console patching is active (the default;
disabled by `patchConsole: false` or `debug`, independent of interactive mode), vue-tui
treats the `[Vue warn]` prefix as Vue's framework-diagnostics channel and drops those
stderr lines. The patch is installed before the first mount (matching Ink, which patches
before the first render), so a `setup()` throw during the **initial** mount is filtered
too. With patching off, every `[Vue warn]` surfaces.
- **Why:** these warnings come from Vue itself and are **dev-only** (stripped in production
builds); they never enter the stdout frame and do not change the exit path. Documented so
the stray warn is not mistaken for vue-tui behavior: it is Vue's framework diagnostics.
The prefix filter may also drop user-authored stderr logs that intentionally reuse the
reserved `[Vue warn]` prefix; use a different application prefix when that output must be
preserved. KEEP. [VOUCHED @hyf0]
#### React concurrent mode
- **Ink:** built on React; `useTransition` / `useDeferredValue` work as ordinary React
hooks. Ink v7 also exposes a `concurrent?: boolean` render option (default `false`)
with two distinct halves (run-verified vs v7.0.4): the root-tag half is inert — under
the pinned reconciler (react-reconciler 0.33.0 / React 19) every root is overwritten to
ConcurrentRoot, and hook/preemption probes behave identically in both modes — but the
dispatch half is live: the default commits the first frame synchronously inside
`render()` / `rerender()` (bytes reach stdout before the call returns), while
`concurrent: true` schedules the commit asynchronously on a later tick.
- **vue-tui:** no equivalent — no such composables, and `MountOptions` has no
`concurrent` flag. `mount()` commits the first frame synchronously, matching Ink's
default dispatch.
- **Why:** React scheduling concepts with no Vue counterpart; N/A rather than a parity
gap. The absent `MountOptions.concurrent` is this entry, not an unlisted difference:
the one observable behavior the flag adds (deferring the first paint past the mount
call) has no Vue-side demand, and vue-tui already matches Ink's default.
#### `<Transform>` treats all-comment children as no children
- **Ink:** `Transform` returns `null` only for `undefined` / `null` children. React's
`false` child and a literal `[]` child are not nullish, so each creates an empty
`ink-text` node that consumes a flex-gap slot, and in screen-reader mode that node
still announces `accessibilityLabel` (a `false` or `[]` child with a label reads the
label).
- **vue-tui:** after slot resolution, `null` / `false` / `undefined` / `v-if="false"` /
a `false`-yielding `&&` all materialize as the same Comment vnode — React's
`false !== null` edge has no Vue equivalent. `<Transform>` treats an absent slot, an
all-comment slot, or an empty slot array (`() => []`) as no renderable children and
returns `null`: the node is omitted, no gap slot is consumed, and a
`<Transform accessibilityLabel>` whose children all resolve this way announces nothing
in screen-reader mode (the guard runs before label substitution, as in Ink). Boundary
parity: `''` and `0` children are text vnodes, not comments — both engines render a
node (`''` takes a gap slot; `0` prints `0`) — and a Vue JSX `{[]}` child is a
Fragment vnode that still renders a node, matching Ink; only the bare `() => []` slot
collapses.
- **Why:** a child set that renders nothing equals omitting the child — letting
framework anchors occupy layout slots would be worse than matching Ink's React-only
`false !== null` edge, which Vue cannot see. The same forcing covers the screen-reader
case: Ink announces the label for `false` but not `null` children; vue-tui sees
identical comments, cannot honor both, and consistently takes the `null` side. The
`() => []` collapse alone is **not** model-forced (Vue can see the empty array); it is
a deliberate consistency rider — in each engine `() => []` and `() => [false]` behave
alike (Ink renders a node for both, vue-tui omits both), and aligning only `[]` would
create an asymmetry that exists in neither engine without reaching parity. KEEP. [VOUCHED @hyf0]
Test: `transform.test.tsx`.
### 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`). Do not add Ink-compatible aliases here: aliases would
make the public API look render-shaped while the actual runtime contract is app-shaped.
KEEP. [VOUCHED @hyf0]
#### Removing `display` resets to the default (visible)
- **Ink:** `applyDisplayStyles` (`styles.ts`) calls `setDisplay(DISPLAY_NONE)` whenever the
prop diff carries a `display` that is not `'flex'`, and Ink's reconciler diff emits a
withdrawn key as `display: undefined`. So clearing a previously-set `display` (`'none'` or
`'flex'` → removed) **hides** the box: Ink treats the withdrawn prop as `none`, neither
keeping the prior value nor restoring the default. (A box that simply **omits** `display`
stays visible — `'display' in style` is false, so no `setDisplay` runs; but an explicit
`display={undefined}` is itself applied as `DISPLAY_NONE` and hides, like any non-`'flex'`
value.) In the common toggle `display={hidden ? 'none' : undefined}`, Ink stays hidden on
the `undefined` branch; you must set `display="flex"` to show it again.
- **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:** 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. KEEP.
[VOUCHED @hyf0]
#### Nullish `flexDirection` / `flexWrap` reset to Box defaults
- **Ink:** the public `<Box>` injects `flexDirection:'row'` and `flexWrap:'nowrap'` before
spreading user style. If a previously-set prop is **truly omitted**, that default reaches
the host and both engines reset (`column` -> omitted renders `"A\nB"` -> `"AB"`; `wrap`
-> omitted stops wrapping). But an explicit `flexDirection={undefined}` / `{null}` or
`flexWrap={undefined}` / `{null}` overwrites the default before the host layer. Ink's
`applyFlexStyles` has no reset branch for these two props, so a dynamic nullish value
preserves the previous Yoga value. On first mount, nullish `flexDirection` leaves Yoga's
column default; nullish `flexWrap` happens to match nowrap.
- **vue-tui:** nullish current values reset to the public Box defaults (`row` / `nowrap`)
in the same way as true omission (G19). A conditional spread that removes the key remains
parity with Ink; a live binding whose value becomes `null` or `undefined` intentionally
resets instead of preserving prior Yoga state.
- **Why:** render = f(current props): a Vue binding with no current `flexDirection` /
`flexWrap` value means "use the Box default", not "keep whatever Yoga had last render".
Preserving the prior value would make layout depend on history rather than current props.
The cost is limited to explicit nullish public bindings; true omission remains Ink-parity.
Tests: `prop-reset.test.tsx`.
#### Withdrawing a `margin`/`padding` edge override falls back to the surviving shorthand
- **Ink:** when a box has both a shorthand and a more-specific override of the same family
(`margin={5} marginTop={8}`, `margin={5} marginX={2}`, padding equivalents) and the override
is later withdrawn, the edge **collapses to 0**, not back to the surviving shorthand
(run-verified vs v7.0.4, both spread-removal and explicit `marginTop={undefined}`: with
`margin:5 marginTop:8` the top margin renders 8 cells, and after removing `marginTop` it
renders **0**, not 5). The cause is yoga edge precedence — a per-edge value (`EDGE_TOP`)
overrides the all-edges shorthand (`EDGE_ALL`) **even when reset to 0** — combined with
Ink's `applyMarginStyles`/`applyPaddingStyles` emitting one yoga setter per prop, so a
withdrawn `marginTop` becomes `setMargin(EDGE_TOP, 0)` that still beats the surviving
`EDGE_ALL=5`.
- **vue-tui:** the withdrawn override falls back to whatever shorthand still applies
(`marginTop` removed from `margin={5} marginTop={8}` → top margin = 5). On any margin/padding
prop change, `reconcileMarginEdges`/`reconcilePaddingEdges` recompute **all four physical
edges** from the box's full current props with most-specific-wins precedence
(`top = marginTop ?? marginY ?? margin ?? 0`, etc.) and zero the composite edges, so no stale
per-edge value can shadow the shorthand. This mirrors the existing `reconcileBorderEdges`
pattern (an edge that depends on several props can't be set correctly by a single
per-prop yoga setter).
- **Spacing value contract:** an edge resolves from a prop only when it is a **finite number**
(matching the `number` prop type + Ink's number-only margin/padding); a numeric **string**
(`margin="5"`) is coerced for Vue **static-template attribute** ergonomics, but any other
non-numeric value (`"50%"`, junk, `""`) is treated as **not-set** and falls through to the
surviving shorthand rather than being forwarded to yoga. So the family recompute drops the
OLD per-setter code's incidental, off-contract string forwarding — `marginTop="50%"` no longer
becomes a yoga percent and `marginTop="foo"` no longer throws.
- **Why:** render = f(current props): with current props `{margin: 5}` the top margin is 5, full
stop — a value that is no longer set must not linger via yoga's edge layering (G19, the same
declarative-reset principle as the `display` and `flexDirection`/`flexWrap` entries above).
This is NOT an Ink-parity item: Ink and pre-fix vue-tui both collapsed to 0 (the identical
bug); the fix diverges from Ink by being declaratively correct. Verified against
yoga-layout@3.2.1 that the recompute produces identical computed edges as the old per-setter
code for the SET path (no layout regression), and the correct fallback on removal.
Tests: `prop-reset.test.tsx`, `unit/yoga-prop-reset.test.ts`.
#### Public composable naming follows Vue conventions
- **Ink/React:** public APIs are hooks (`useFocus`, `useInput`, ...), but return-type naming
is mixed: the stream/app hooks return exported context types named `XProps` (`useStdin`
`StdinProps`, `useStdout``StdoutProps`, `useApp``AppProps`), newer hooks return
exported non-`XProps` types — result-named (`useBoxMetrics``UseBoxMetricsResult`,
`useAnimation``AnimationResult`) or a bare data name (`useWindowSize``WindowSize`) —
and the rest fit neither: `useInput`/`usePaste` return `void`, `useFocus`/`useFocusManager`
return an unexported type, `useCursor` an inline shape, `useIsScreenReaderEnabled` a bare
`boolean`.
- **vue-tui:** public APIs are Vue **composables** (`useFocus`, `useInput`, ...). Where a
composable's return type is exported under a name, the name always follows VueUse's
`UseXReturn` convention (`UseAppReturn`, `UseStdinReturn`, `UseStdoutReturn`,
`UseStderrReturn`, `UseAnimationReturn`, `UseBoxMetricsReturn`); the remaining composables
return `void`, plain `boolean`, or small unexported inline shapes — never an `XProps`
type. `XProps` is reserved for component props (`BoxProps`/`TextProps`, derived via
`ExtractPublicPropTypes`).
- **Options types follow the same principle:** Ink names a composable's options type locally
`Options` / `Props` and usually does **not** export it (e.g. `useAnimation`'s `Options` is
internal — only the return `AnimationResult` is exported, `use-animation.ts:14,30`). vue-tui
exports each composable's options type under VueUse's `UseXOptions` name: `UseInputOptions`,
`UsePasteOptions`, `UseFocusOptions`, `UseAnimationOptions`. `useAnimation`'s options type
originally shipped as `AnimationOptions` — the lone holdout — and was renamed to
`UseAnimationOptions` (a hard rename, no alias) while the package is pre-1.0 (`0.0.x`, no
stability promise yet). **Export composable options types
as `UseXOptions`; renamed `AnimationOptions``UseAnimationOptions`.** [VOUCHED @hyf0]
- **Why:** the public surface should read like Vue code: named composable return types get a
single convention (`UseXReturn`) instead of Ink's mix of `XProps`, result names, and bare
names, and `XProps` keeps its Vue meaning (component props). 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. Do not export Ink-compatible alias
names for these types: Vue-first naming is more important than making type imports look
portable across React and Vue. KEEP. [VOUCHED @hyf0]
#### Function-valued composable inputs use `MaybeRef`, not getters
- **Ink/React:** `useInput` and `usePaste` use React's current-props model: a hook can keep
a stable event listener and still call the latest handler after a re-render.
- **vue-tui:** `setup()` runs once, so passing a function prop's current value directly
(`useInput(props.onInput)`) captures a one-time snapshot. When a composable should follow
a function-valued prop, pass a live prop ref instead:
`useInput(toRef(props, "onInput"))` / `usePaste(toRef(props, "onPaste"))`. A wrapper
closure that reads `props.onInput(...)` at event time is also correct.
- **Why:** this is Vue's standard reactive-source boundary: pass the source, not a value
read from it in setup. The handler parameter accepts `MaybeRef<Handler>` and resolves it
with `unref()` when input/paste occurs. It deliberately does **not** accept
`MaybeRefOrGetter<Handler>` because a handler is itself a function:
`useInput(() => {})` must remain an input handler, not be reinterpreted as a getter that
returns one. KEEP. [VOUCHED @hyf0]
#### `<Static>` uses a scoped slot object instead of positional render arguments
- **Ink/React:** `<Static>` receives a function-as-children render callback and calls it as
`render(item, absoluteIndex)`.
- **vue-tui:** `<Static>` exposes a Vue scoped slot with `{ item, index }`, so template
users write `v-slot="{ item, index }"` and TSX users pass a slot function that receives
one props object.
- **Why:** this is the framework-native match for React render children. Vue scoped slots
pass one props object, not multiple positional arguments, and that object form is what
Vue users expect for slot payloads. The rendered item/index values remain equivalent.
KEEP. [VOUCHED @hyf0]
#### ARIA props are typed camelCase; kebab still works but is not type-checked
Full design, type-safety findings, and precedent survey: [accessibility-api](./accessibility-api.md).
- **Ink:** kebab string-literal prop keys (`'aria-label'`, `'aria-hidden'`, `'aria-role'` union,
`'aria-state'` object); JSX keys never camelize.
- **vue-tui:** the same vocabulary as typed **camelCase** props (`ariaLabel`/`ariaHidden`/
`ariaRole`/`ariaState`; `AriaRole`/`AriaState` exported, identical to Ink's). Ink's kebab still
works at runtime (Vue camelizes onto the declared prop), so `aria-role` ports unchanged.
- **Why (Vue idiom + reasonableness > parity — see "The governing principle"):** Vue's `prop-name-casing`
mandates camelCase, and — run-verified with `tsc`/`vue-tsc` — **camelCase is the only spelling
type-checked** (value/typo/compound mistakes compile-error in both TSX and templates), while
kebab `aria-*` is not (Vue/Volar treat it as a global attr). So `ariaRole` is the type-safe
spelling and `aria-role` the runtime-only porting escape; the rejected kebab-only `$attrs`
alternative loses typing + Boolean coercion for nothing the checker doesn't already give.
KEEP. [VOUCHED @hyf0]
- **Edges:** a future compound aria word must be declared as its mechanical camelize
(`ariaHaspopup`, not `ariaHasPopup`) or folded into `ariaState`; `aria-hidden` is modeled
boolean (bare → true), but the string `aria-hidden="false"` wrongly hides (recorded edge).
## 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.
### Non-`Error` thrown values: uniform show-the-error-and-reject
- **Ink:** accepts the throw, but its handling is **non-uniform** (run-verified vs v7.0.4):
for a **truthy** non-`Error` it renders an `ErrorOverview` showing a string `.message`
(**blank** for a bare string, since `'boom'.message` is `undefined`) **and RESOLVES**
`waitUntilExit()` with the **raw** thrown value — a throw looks like a clean exit. For a
**falsy** throw (`0` / `''` / `null`) it renders **no** overview and leaves
`waitUntilExit()` **PENDING** (recoverable — a later unmount resolves it with `undefined`).
- **vue-tui:** **any** thrown value renders an `ErrorOverview` (message = a string `.message`
if present, else `String(value)`) **and REJECTS** `waitUntilExit()` with an `Error` whose
`.message` **EQUALS the displayed message** — one `messageForNonError(value)` helper feeds
both the overview header and the reject-wrap, so display and reject can't drift (e17). No
synthetic stack (a value with no `.stack` renders only the header).
- **Why:** aligning to Ink reduces bugs only where Ink is correct. Ink resolving the exit
promise with a thrown value, and silently hanging on a falsy throw, are abnormal, so
vue-tui deliberately diverges to one uniform contract: show the error, reject the exit. Same
recover-vs-crash family as the invalid-input-validation divergence. Showing a real message
(string `.message` else `String(value)`) is useful for the lint-discouraged non-`Error`
throw, and matching the rejected message to it removes a confusing internal inconsistency
(`throw {message:'x'}` once displayed `x` but rejected `[object Object]`). Introduced
2026-05-31; consistency fixed 2026-06-12. KEEP. [VOUCHED @hyf0]
### Re-measure text when the `wrap` prop changes at runtime
- **Ink:** a runtime `wrap` (style `textWrap`) change goes through `commitUpdate`
`applyStyles`, but `applyStyles` **ignores `textWrap` entirely** (styles.ts) and never
calls `yogaNode.markDirty()`. Only `setTextNodeValue` (a text-CONTENT change) dirties the
measure func. So when ONLY `wrap` toggles, yoga keeps the previously-measured height while
paint renders with the new wrap mode → layout and paint disagree. Run-verified vs v7.0.4
(`/tmp/ink-verify`, debug-mode frame capture): a width-6 column `<Box>` with
`<Text wrap>` over `"aaaa bbbb cccc"` and a `ZZZZ` sentinel below, toggled wrap→truncate,
yields `"aaaa …\n\n\nZZZZ"` — the truncated text paints on row 1 but yoga still reserves 3
rows, stranding `ZZZZ` on row 4 with blank rows. Toggling text content alongside `wrap`
(which DOES `markDirty`) gives the correct `"aaaa …\nZZZZ"`, proving the cause.
- **vue-tui:** the host `patchProp` (`node-ops.ts`) calls `markTextDirty(el)` when the changed
STYLE_PROP is `wrap` on a `tui-text` node, so yoga re-measures and layout matches paint:
wrap→truncate collapses to `"aaaa …\nZZZZ"`, truncate→wrap grows to
`"aaaa\nbbbb\ncccc\nZZZZ"`. `wrap` is the only STYLE_PROP that affects measured height (the
measure func reads `text.props.wrap`); the rest (color/bold/border colors/…) are paint-only,
so this is the sole case.
- **Why:** the correct behavior is the declarative invariant — a runtime `wrap` change must
produce the EXACT SAME frame as a fresh mount with that `wrap` (measure == paint, render =
f(current props)). This is VERIFIED across the full 6-mode transition matrix (`wrap`, `hard`,
`truncate`, `truncate-end`, `truncate-middle`, `truncate-start` → all 30 ordered transitions):
each toggled frame equals the fresh-mount frame for the target mode. Ink v7.0.4 diverges from
this correct behavior — a run-verified latent bug where `applyStyles` ignores `textWrap` and
never `markDirty`s, leaving a stale cached measure that contradicts paint. Aligning to Ink
reduces bugs only where Ink is correct; here Ink is buggy, so vue-tui keeps the correct
invariant. The fix is minimal (one `markDirty`) and matches the layout Ink ALREADY produces
whenever its measure func happens to be invalidated. KEEP. [VOUCHED @hyf0] Tests:
`text-wrap-remeasure.test.tsx` (both directions; RED without the fix, reproducing Ink's stale
frame) and `text-wrap-remeasure-matrix.test.tsx` (full 6-mode / 30-transition matrix proving
the invariant; 16 transitions go RED without the fix).
### 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, wires no second
renderer, renders nothing, and returns an empty placeholder object (the real controls —
`unmount()`, `waitUntilExit()` — live on the app, not on `mount()`'s return value). The
first app's tree stays on screen. The skip is scoped to that one guarded call — derived
from what the app actually wired, never sticky: a guarded _different_ app's `unmount()`
settles only its own exit promise and never touches the owner's stream or registry entry;
the _owner_ double-firing `mount()` on its own stdout keeps a fully working `unmount()`
(the warning's recovery path); an app that once hit the guard can later mount — and
cleanly unmount — on a free stdout; and a live app that merely targeted another app's
busy stream stays fully killable.
- **Why:** a second `mount()` on a live stdout is a misuse (forgot to `unmount()`, a
re-render glitch fired `mount()` twice, or expecting `mount()` to re-render — it doesn't;
update reactive state for that). Ink treats it as unsupported and warns too. vue-tui fails
safe: it ignores the second mount, keeps the live app rendering, and warns with the two
recovery paths. It deliberately doesn't copy Ink's reuse-and-rerender: there's no clean
public path to it (`createApp` binds the tree to the app, so an Ink-style rerender would
mean reaching into the live app's container or tearing it down first), and on a misuse path
keeping the running app stable beats auto-tearing it down (which would churn on a re-render
glitch). KEEP. [VOUCHED @hyf0] Test: `instance-reuse-guard.test.tsx`.
### 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). KEEP. [VOUCHED @hyf0]
### `useCursor()` re-asserts the declared caret every commit (persistent declaration)
- **Ink:** `useCursor`'s no-deps `useInsertionEffect` (`use-cursor.ts:27-32`) re-marks the
cursor dirty only when **the cursor's React component re-renders**, and log-update emits
the caret only on a dirty commit. React's render cascade re-renders the child on an
**ancestor**-driven commit (so Ink re-asserts there), but on an unrelated **sibling/leaf**
repaint the cursor component does not re-render — Ink does **not** re-assert and the caret
is dropped, **zombieing** to the bottom-left corner (run-verified vs v7.0.4: a sibling
spinner tick ends `…> hello\n` with no caret suffix, leaving the caret at row 2 col 0).
- **vue-tui:** the runtime re-emits the **last-declared** caret at the **end of every
commit** (until the declaration changes or is cleared), so a focused input's caret stays
at its edit point across unrelated repaints (spinner / log line / progress bar) in **all**
component topologies. The hide-before-erase / show-at-resolved-position flicker discipline
is preserved (no corner streak), and the re-emitted position is clamped to the visible
region (y to the line count, x to the width) so a post-resize/shrink stale coordinate
never moves out of range.
- **Why:** this is a **deliberate divergence FROM Ink toward correct terminal-app
behavior**, not Ink-alignment. Real terminal programs that own an edit point re-place the
caret at that point **every frame** (vim emits `\e[<row>;<col>H` after each repaint;
readline re-lands the buffer offset on SIGWINCH; nano homes to its edit cell) — they never
leave the caret where the repaint dragged it. Aligning to Ink exists to reduce bugs, not to
preserve abnormal behavior; matching Ink's topology-conditional zombie would preserve
abnormal behavior, so vue-tui diverges. Per the classification flow this is **not** a
Model-Implied difference (Vue is not _forced_ here — fine-grained reactivity could also be
made to re-run the child; the runtime simply chooses to be more correct than Ink at the
commit level), and it is not Vue-API-shaped, so it lands in **Intentional Divergence
Choices**. The `{x,y}` `setCursorPosition` surface is **unchanged** — it remains the right
low-level IME primitive (a composing glyph offset deliberately decoupled from the buffer
point); the fix is an internal per-commit re-emit, so the public API stays compatible. A
cleared declaration (`setCursorPosition(undefined)`, e.g. `useCursor`'s `onScopeDispose`
on unmount) re-emits no caret, so teardown still ends with the cursor shown and handed back
(`\x1b[?25h`); the persistent re-emit runs **before** the unmount clear, so it cannot
resurrect a torn-down caret. The one accepted residue is a stale-but-in-range absolute
position if an app declares a fixed `{x,y}` and then shrinks content without re-declaring
(it parks at a plausible spot, strictly better than a corner-zombie); a future **Stage 2**
focus-owned, content-tracking caret (recomputed from the focused widget's layout each
frame) would dissolve that residue. Tests: PTY `cursor-sibling-repaint.test.ts` (a
sibling-topology spinner tick re-asserts the caret, not the corner) and unit
`frame-writer.test.ts` (a non-dirty changed-output re-render re-emits the declared suffix;
D5 clamp). **OVERRIDE prior KEEP — adopt per-commit
re-assert.** [VOUCHED @hyf0] The prior KEEP (2026-06-01) had kept the reactivity-tied behavior to avoid
diverging from Ink in the sibling direction; that rationale was overturned when running
real terminal apps showed Ink itself zombies the caret there, so matching Ink was matching
a defect, not parity.
### Resize unconditionally cancels the pending trailing commit
- **Ink:** `resized()` paints synchronously via `onRender()` but does **not** cancel a
pending throttled `onRender`; when that trailing commit re-runs and
`shouldClearTerminalForFrame` clears (because the previous frame overflowed), Ink emits a
**second** `clearTerminal`.
- **vue-tui:** `onResize` calls `scheduler.cancel()` as its **first, unconditional** step on
**every** resize (not only narrowing ones), dropping any pending throttled commit before
its synchronous commit, so the redundant trailing clear never fires.
- **Why:** the synchronous resize commit already reflects the current tree, so the pending
commit would repeat the same clear. The dedup is triggered by an overflowing frame plus a
pending commit — not by narrowing specifically; a widening resize cancels the pending
commit too. Emitting one clear instead of two has no visible behavior difference
(issue #26). The separate narrowing-only `writer.clear()` frame reset is a distinct
mechanism.
### Degenerate boxes do not lay out or paint children when the content area is gone
- **Ink:** its size model is border-box-like: `width`/`height` are handed to Yoga as the
element's outer size, while border and padding consume space inside that size. Ink has no
`boxSizing` / `content-box` prop. During paint, though, Ink only clips children when
`overflow` is hidden. With default visible overflow, children can leak into border rows
or outside the box when border/padding squeeze the content area to zero, and a bare
`width={0}` Box can still let zero-width text wrapping create extra visible rows
(`B\nA` beside a sibling). Examples in Ink v7.0.4 include:
`width={3} height={2} borderStyle="single"` painting the child on the bottom border row;
`width={2} height={3}` leaking the child past the right border;
`width={4} height={3} paddingX={1} borderStyle="single"` leaking into the bottom
border; and bare `width={0}` text reserving rows through wrap-ansi's width-0 layout.
- **vue-tui:** layout computes each Box's inner content size by subtracting computed border
and padding from the outer box size, clamps it to `{width >= 0, height >= 0}`, and
temporarily removes that Box's **in-flow** yoga children from the layout when either
dimension is zero. Paint applies the same inner-content gate, so the in-flow child subtree
neither reserves invisible rows nor writes glyphs outside a nonexistent content area.
Border and background are still painted as far as the outer area permits.
**Absolutely-positioned children are exempt** — their containing block is the padding box
(inside the borders), not the content rect, so they still lay out and paint (clipped only
by `overflow:hidden`), matching Ink. Positive-size content areas keep the
existing overflow behavior; this is not a blanket `overflow:hidden`.
- **Layout model guidance:** primitive `Box` should preserve the Yoga/flexbox model rather
than paper over it with ad-hoc layout corrections. Defaults such as `flexShrink: 1` are
part of that model, and a child resolving to zero width or height can be a valid layout
result. Higher-level components are where stronger user intent belongs: scroll, list, and
viewport abstractions should keep their content at natural size (`flexShrink: 0`, or an
equivalent encapsulated default) and let a bounded viewport clip or offset what is visible.
Paint containment is the renderer invariant underneath both cases: whatever Yoga resolves,
**in-flow** children may only paint inside their owning Box's content rectangle; if that
rectangle has no positive width or height, the in-flow child subtree does not paint.
Absolutely-positioned children are the exception — they paint against the containing block
and are suppressed only by `overflow:hidden`, matching Ink.
- **Why:** children need a real content rectangle to lay out and paint into. If the
resolved content width or height is zero, rendering child text or nested borders on top
of the frame, outside the box, or on later rows is an implementation artifact, not useful
output. This follows the common TUI box model: Ratatui renders child widgets into an
inner `Rect`, Textual reduces content space from the assigned box, and Rich panels render
children with child width/height after subtracting the border. Bubble Tea's viewport
follows the same separation at the component level: content keeps its natural size while
the viewport exposes a bounded visible window and offsets. The behavior also prevents
negative repeat/count math and paint crashes in tiny legal boxes. KEEP. [VOUCHED @hyf0]
Tests: `text-wrap-width.test.tsx`, `flex.test.tsx`,
`text.test.tsx`, `absolute-in-degenerate-box.test.tsx`.
- **Future `content-box`:** this does not block adding an explicit content-box option
later. That option would change how a requested size is expanded into an outer box size
before layout. Once an outer box exists, the paint invariant remains the same: a child
subtree only lays out and paints when the resolved inner content rectangle has positive
width and height. The default remains border-box-like, matching Ink's current public
sizing model.
### `measureElement` coerces a non-finite (pre-layout) dimension to `0`, not `NaN`
- **Ink:** `measureElement` returns `{ width: node.yogaNode.getComputedWidth() ?? 0, height: ... ?? 0 }`.
Before the first layout pass yoga's `getComputedWidth()`/`getComputedHeight()` return **`NaN`**, and
`?? 0` does **not** catch `NaN` (`NaN ?? 0 === NaN`), so a pre-layout / mis-timed read returns
`{ width: NaN, height: NaN }`.
- **vue-tui:** coerces a non-finite computed dimension to `0` (`Number.isFinite(v) ? v : 0`), so the same
pre-layout read returns a finite `{ width: 0, height: 0 }`. (The detached-ref case already returns
`{0,0}`; this extends the same safe fallback to the attached-but-not-yet-laid-out case.)
- **Why:** `0` is a **safe sentinel** meaning "not yet computed", not the box's true size — the genuinely
correct usage is to read _after_ layout (the JSDoc already steers callers to defer via `nextTick`). It is
chosen because it is Ink's clear intent (`?? 0`) and it matches the **DOM precedent**:
`getBoundingClientRect()` on a `display:none` element and `img.naturalWidth` before load both return `0`,
not `NaN`. Leaking `NaN` instead poisons user layout math (`terminalWidth - measured.width``NaN` → a
`NaN` width prop), so a mis-timed read degrades gracefully rather than corrupting the layout. A low-risk
robustness divergence from Ink's `NaN`-leaking `?? 0`. KEEP. [VOUCHED @hyf0] Tests: "returns finite
{ width: 0, height: 0 } for a node not yet laid out (no NaN leak)" in `use-box-metrics.test.tsx`.
### 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-*`/`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.
### 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`, `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:
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. Required app,
terminal, focus, and input context should fail fast when absent; no-op defaults hide bugs.
KEEP. [VOUCHED @hyf0]
### Invalid input is validated at the component layer, not the paint layer
- **Principle:** vue-tui validates the covered invalid render inputs — a
chalk-**modifier** `backgroundColor` like `"bold"`, a foreground color key that exists
on chalk but is not callable like `"level"`, and an unknown `borderStyle` — at the
**component-render layer** (`box-validate.ts` for `<Box>`, `text.vue` for `<Text>`), 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 covered 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. This is
a deliberately chosen fail-safe given a constraint that is symmetric across React and Vue
(recover-vs-crash), not a Vue-model-forced difference — hence an intentional choice rather
than a model-implied one. Provenance: the wedge claim rests on the earlier paint-throw
investigation; the 2026-06-12 audit could not reach a paint throw from public or raw-host
input (paint's `if (!chars) return;` border fallback intercepts an invalid `borderStyle`
that bypasses component validation), so it stands on that prior record, not an in-audit
reproduction.
- **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. For the
covered public inputs in normal reachable cases, both libraries error; only the channel
(recoverable reject vs crash) differs. KEEP. [VOUCHED @hyf0] vue-tui
makes the more reliable library choice here: reject the same invalid input with a
recoverable, prop-specific error instead of preserving Ink's lower-level paint crash and
chalk implementation message. Tests: `background-color.test.tsx`, plus the `borderStyle`
validation tests.
- **Text validates regardless of content:** `<Text>` now
validates `color` and `backgroundColor` on every render, not only when its content is
non-empty — matching `<Box>`, which already validates its own colors unconditionally. Ink
does not throw for empty text only because its colorize call is lazy (an incidental
implementation artifact, not a design choice); an invalid value is invalid regardless of
content, and content-gated validation is a latent footgun. Principle: reasonable behavior
over incidental Ink parity. The former `wouldRenderNonEmptyText` gate was removed.
Screen-reader-hidden Text still returns before validation (matches Box). [VOUCHED @hyf0]
- **Global screen-reader mode is carved out (skipped) — ALIGNS to Ink, not a new
divergence:** all of the above validation is paint-time VISUAL input (color / bg /
border), and under GLOBAL screen-reader mode (`isScreenReaderEnabled`;
`INK_SCREEN_READER=true`) vue-tui — like Ink — linearizes the whole tree to PLAIN TEXT
and never colorizes / draws borders for any node. Ink's colorize path is bypassed
entirely under SR, so it never throws on an invalid color (run-verified against Ink
v7.0.4: `<Box backgroundColor="bold">` with `INK_SCREEN_READER=true` renders plain text
and does NOT throw; without it Ink throws in `colorize.js`). vue-tui previously still ran
the eager validation for non-`ariaHidden` boxes under SR and threw — crashing a
screen-reader user out of accessible content over a paint-only prop value. The validation
is now skipped when global SR is on (`box.vue` / `text.vue` v-if gate on `srEnabled`),
matching Ink. This removes a vue-tui over-throw and so is an alignment fix, not a new
divergence. Tests: the "GLOBAL SR" cases in `background-color.test.tsx`.
## Non-Behavioral Notes
These notes are neither divergence nor alignment entries. They document Vue-facing conventions
or internal mechanics so they are not mistaken for parity gaps.
- The exported host-node type is **`TuiNode`** (`TuiContainer | TuiTextLeaf | TuiComment`,
from `@vue-tui/runtime/internal`), not Ink's DOM-emulation `DOMElement`
(`nodeName` / `attributes` / `childNodes`). vue-tui keeps a native host tree, so the
exported type names that tree; `measureElement` and template refs accept it. The type
rename itself does not imply a runtime behavior difference from Ink's DOM-emulation node.
- `null` / `false` / `undefined` / `v-if="false"` children are materialized by Vue as
comment vnodes, which vue-tui's host renderer turns into an inert `TuiComment`: no yoga
node, paints nothing, never shifts a sibling, and skipped when counting child positions
(the `child.type !== "comment"` guards in `paint.ts`, `text-measure.ts`, and
`screen-reader.ts`; `G52`). This is renderer mechanics, not a divergence entry by
itself. The observable `<Transform>` literal-`false` edge is documented above as a
**model-implied divergence**.