From 25114ff52adffc25c79e065e4d318ac30f720878 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Fri, 12 Jun 2026 02:34:52 +0800 Subject: [PATCH] docs(divergences): fix Ink hook-naming claim and reclassify Transform all-comment entry as model-implied (#152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run-based audit against pinned Ink v7.0.4 (40b3a75) found two ledger defects: - The composable-naming entry claimed Ink names hook return types XProps; real Ink 7.0.4 is mixed (XProps for stream/app hooks, UseBoxMetricsResult/AnimationResult/WindowSize for newer ones, and several hooks export nothing). Restate both sides accurately. - The all-comment-children entry sat under Intentional Divergence Choices, but its forcing is the React-only false !== null edge that Vue's comment-vnode materialization cannot see — a model-implied difference per the doc's own classification flow. Move it there, expand the run-verified boundary (empty slot array, ''/0 children, screen-reader label), and reword the Non-Behavioral Notes cross-ref in the same change so it stays true. Co-authored-by: Claude Fable 5 --- .agents/docs/ink-divergences.md | 80 ++++++++++++++++++++------------- 1 file changed, 50 insertions(+), 30 deletions(-) diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md index b400cd1..fd9af9b 100644 --- a/.agents/docs/ink-divergences.md +++ b/.agents/docs/ink-divergences.md @@ -231,6 +231,35 @@ current-props model, or API conventions. - **Why:** this is a React-only concept with no Vue equivalent, so it is N/A rather than a parity gap. +#### `` 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. `` 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 + `` 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. Maintainer + decision (2026-06-07): KEEP. Test: `transform.test.tsx`. + ### Vue-Idiomatic Choices #### Entry point - `createApp()` instead of `render()` @@ -264,20 +293,28 @@ current-props model, or API conventions. #### Public composable naming follows Vue conventions -- **Ink/React:** public APIs are hooks (`useFocus`, `useInput`, ...). Where a hook returns a - public type, Ink names it `XProps` — this holds for the stream/app hooks (`useStdin` → - `StdinProps`, `useStdout` → `StdoutProps`, `useApp` → `AppProps`); the lead examples don't - fit (`useInput`/`usePaste` return `void`, `useFocus` returns an unexported type). -- **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/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`). +- **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. `XProps` stays reserved for component props; composable - returns stay `UseXReturn`. Maintainer decision (2026-06-07): KEEP. + portable across React and Vue. Maintainer decision (2026-06-07): KEEP. #### Function-valued composable inputs use `MaybeRef`, not getters @@ -447,23 +484,6 @@ different runtime behavior, ownership rule, or out-of-contract handling. width and height. The default remains border-box-like, matching Ink's current public sizing model. -### `` treats all-comment children as no children - -- **Ink:** `Transform` returns `null` only for undefined or null children. React's - `false` child is not nullish, so Ink creates an empty `ink-text` node that can consume a - flex-gap slot. -- **vue-tui:** Vue materializes `null` / `false` / `undefined` / `v-if="false"` / - falsy-`&&` children as comment vnodes. `` treats an absent slot or an - all-comment slot as no renderable children and returns `null`, so a literal `false` - child omits the node and does not consume a gap slot. -- **Why:** after Vue slot resolution, vue-tui cannot reliably distinguish a literal - `false` child from the comment anchors Vue uses for ordinary empty branches. Rendering an - all-comment slot as an empty layout node would make framework anchors affect layout, - which is worse than matching Ink's React-only `false !== null` edge. This keeps - `` consistent with how the rest of vue-tui treats false / empty conditional - children: no node. Maintainer decision (2026-06-07): KEEP. Test: - `transform.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: @@ -559,8 +579,8 @@ mechanics so they are not mistaken for parity gaps. 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 `` literal-`false` edge is documented above as an - intentional divergence. + itself. The observable `` literal-`false` edge is documented above as a + **model-implied divergence**. - 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