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>
This commit is contained in:
Yunfei He
2026-06-15 14:08:32 +08:00
committed by GitHub
parent 19c475ae33
commit c4b001c12f
3 changed files with 114 additions and 65 deletions
+2 -1
View File
@@ -29,7 +29,8 @@ to receive them and no browser to read them.
`aria-role` ports from Ink/HTML unchanged — it is the runtime-compatible escape, not the
type-safe path.
- This is a Vue-idiom + reasonableness choice, **not parity** (Ink is kebab). See the
"Why align to Ink — and when not to" principle in [ink-divergences](./ink-divergences.md).
"The governing principle: correctness first, alignment is only a means" section in
[ink-divergences](./ink-divergences.md).
## Type-safety boundary (run-verified: `tsc` + `vue-tsc`)
+108 -60
View File
@@ -1,31 +1,53 @@
# vue-tui - Intentional Divergences from Ink
# 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: 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.
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 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.
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.
## Why align to Ink — and when not to
## The governing principle: correctness first, alignment is only a means
Aligning to Ink is a **means, not an end**. Ink is a mature, battle-tested implementation, so
matching its public surface and behavior lets vue-tui inherit years of bug-fixes and edge-case
handling for free. That — reducing bugs by reusing proven behavior — is the entire point of
alignment.
**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 that **alignment is not the top priority**. When Ink's behavior is itself a defect,
is unreasonable, or is un-idiomatic for Vue, **conformance to Vue's philosophy and the plain
reasonableness/correctness of the behavior outrank parity.** There vue-tui deliberately diverges,
and records it here so the choice is conscious and vouched, not drift.
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 guards against two opposite failure modes:
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
@@ -35,38 +57,82 @@ This guards against two opposite failure modes:
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 difference is never just "does it match Ink?" but "is this the most
reasonable, most Vue-idiomatic behavior — and where it diverges from Ink, is that because Ink is
wrong or un-Vue, recorded with a `[VOUCHED @handle]` stamp?" Reasonableness and Vue idiom come first;
alignment is simply the cheapest way to get there whenever Ink is already right.
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 a Divergence
## How to Classify an Entry
Classify each divergence by the first rule that applies. The order matters: earlier
sections are narrower, while later sections are broader fallbacks.
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, put it in **Additive
Supersets**.
2. If the primary reason is alignment with Vue's API shape, framework model, mental
model, or user expectations, put it in **Vue API and Mental Model Divergences**.
- Use **Model-Implied Differences** when the difference comes from the React/Vue
framework-model boundary. Matching Ink would require React-shaped machinery inside
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.
- 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**.
- **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. Some entries also record consequences, costs, tests, or the reasoning behind a vouch
where those details are needed to understand the decision.
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
@@ -416,7 +482,7 @@ Full design, type-safety findings, and precedent survey: [accessibility-api](./a
- **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 "Why align to Ink"):** Vue's `prop-name-casing`
- **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
@@ -769,8 +835,8 @@ different runtime behavior, ownership rule, or out-of-contract handling.
## 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.
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`
@@ -784,21 +850,3 @@ mechanics so they are not mistaken for parity gaps.
`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**.
- 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. One deliberate
exception: resize cancels the pending trailing commit — see the divergence entry
"Resize unconditionally cancels the pending trailing commit".
- **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. 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`. [VOUCHED @hyf0]
+1 -1
View File
@@ -24,7 +24,7 @@
- Never commit anything under `docs/`. That directory is for local working notes and specs — it must stay out of git.
- To read Ink's source (parity / divergence work), clone it once to a fixed local path and read from there — `git clone https://github.com/vadimdemedes/ink /tmp/ink` only when `/tmp/ink` is missing, otherwise reuse the existing clone (Ink isn't an npm dependency, so it's not in `node_modules`). Before trusting anything you read, check out and confirm the pinned baseline — `.agents/docs/ink-divergences.md` records the exact version/commit (currently v7.0.4); a claim read against the wrong Ink version is worse than none.
- Behavior claims must be **run, not reasoned**: a parity assertion or `ink-divergences.md` "what Ink does" line is a hypothesis until a real harness against the pinned version (real frames/stderr/exit) confirms it. Source-reading and memory can mislead; when a run contradicts the code you read, trust the run.
- When documenting an intentional Ink divergence, update `.agents/docs/ink-divergences.md` and follow its "How to Classify a Divergence" flow. Do not add placeholder or unsorted entries; if the classification is unclear, state that uncertainty in the entry's rationale.
- `.agents/docs/ink-divergences.md` is the single record point for how vue-tui relates to Ink — deliberate divergences AND deliberate alignments (load-bearing or non-obvious parity), plus non-behavioral notes. When you make or discover such a decision, record it there and follow its "How to Classify an Entry" flow. Its governing principle: aligning to Ink is only a means to reduce bugs, never the goal — correctness and Vue philosophy outrank parity, so "because Ink does it" is never on its own a justification. Do not add placeholder or unsorted entries; if the classification is unclear, state that uncertainty in the entry's rationale. (Adding a `[VOUCHED @handle]` stamp still requires explicit human say-so.)
## Project Context Records (PCR)