refactor(runtime): author public components as template SFCs (+ integrate main)

Rewrites Box/Text/Spacer/Static/Newline from h()/render functions to Vue
<script setup> template SFCs (Transform stays a render fn — it inspects its own
child vnodes), with vue-tsc-verified consumer types (template + JSX fixtures),
provide/inject text context, the always-validate Text divergence (color +
backgroundColor), and three renderer fixes the SFCs surfaced (static anchor skip,
transform line-index Ink-parity, useBoxMetrics subtree drill). Integrates the five
main commits landed after the branch point: #163 public-API audit, generic Static
scoped-slot typing, foreground color validation, useWindowSize/divergence docs.

Squashed from the SFC sub-commits + the two main-integration merges to keep a
linear, rebaseable history. See PR #165 for the full breakdown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-14 15:45:22 +08:00
parent f847e17c81
commit 9fe6d7cc44
43 changed files with 1423 additions and 761 deletions
+122
View File
@@ -0,0 +1,122 @@
# Component Authoring: SFC vs Render Function
vue-tui's public components (`Box`, `Text`, `Spacer`, `Static`, `Newline`, `Transform`) are
authored as **Vue `<script setup>` template SFCs by default**. Exactly one — `Transform` —
is a `defineComponent` render function (`.ts`), because it must **inspect its own materialized
slot-child vnodes at render time** (the line every major Vue library draws: naive-ui,
element-plus, ant-design-vue, reka-ui). Every other component is a real `<template>` SFC.
Templates fought this custom renderer in three concrete places (Static, Text, Box). In each
case the friction turned out to be a **renderer/tooling bug or gap that was worth fixing** —
not a reason to abandon the template. Two of the fixes also corrected latent inconsistencies
(one an Ink-parity bug). See "Renderer work the templates required" below.
## The split
| Component | Form | Reason |
| ----------- | --------------------------- | ------------------------------------------------------ |
| `Spacer` | template SFC | one host node |
| `Newline` | template SFC | `"\n".repeat(count)` interpolation + a context branch |
| `Static` | template SFC | reactive cursor + `v-for` scoped slot |
| `Box` | template SFC | root `v-if` validation guard + `<slot/>` |
| `Text` | template SFC | `<slot/>` + `virtual-text`/`text` branch |
| `Transform` | **render function (`.ts`)** | inspects children: all-inert children → render nothing |
## Two questions, two idioms
The deciding distinction — get this right and the split falls out:
- **"What context am I in?"** (is this `<Text>` / `<Newline>` nested inside a text context?)
→ **provide/inject**, never parent-walking or `.name` matching. `Text` and `Transform`
`provide(TextContextKey, true)`; `Text` and `Newline` `inject` it. Template-friendly,
matches vue-tui's existing `AppContextKey` / `FocusContext` style, and is the
maintainer-endorsed Vue idiom (provide/inject outnumbers slot inspection 4–20× in the
libraries surveyed). It replaced the old, duplicated `getCurrentInstance()` parent walk.
- **"What are my children's actual contents?"** (filter inert/`Comment` vnodes, detect
emptiness) → **render function** + `slots.default()`. A `<template>` can't reach the vnode
array; forcing it means calling the slot twice per render — an accepted-but-unsanctioned
escape hatch the Vue core team itself calls "probably not a good idea." Keep it to the one
component that truly needs it (`Transform`).
## Why Transform is the lone render function
`Transform` returns nothing when every child is inert (a `v-if="false"` / `null` materialized
as a `Comment` vnode), mirroring Ink's `children == null` guard. This is a **rendering
decision with a real layout consequence**: a stray empty node would occupy a flex `gap` slot
(`G52` / P13). Rendering a node for `{null}` would be _less_ reasonable, so the child
inspection is doing genuine work. It stays `h()`. Performance is not a factor — a template
version would be marginally _slower_ here (double slot materialization, nothing for the
compiler to hoist), unmeasurable in a ~32ms-throttled TUI.
## Why Text validates `color`/`backgroundColor` eagerly
`Text`'s only render-time child inspection was `wouldRenderNonEmptyText`, which gated **both**
foreground `color` and `backgroundColor` validation so empty text wouldn't throw (mirroring
Ink, which colorizes lazily). That gate is **dissolved**: `Text` now validates `color` and
`backgroundColor` eagerly every render, exactly as `Box` already does for its own colors. An
invalid value (`backgroundColor="bold"` — a chalk _modifier_, not a color; or `color="level"`
— a chalk key that exists but is not a callable color method) is invalid regardless of
content; content-dependent validation is a latent footgun (it throws only once content
appears). This completes the [[ink-divergences]] "Invalid input is validated at the component
layer" principle — `Text` was the inconsistent holdout. Removing the gate is also what let
`Text` stop inspecting children and become a template.
## Renderer work the templates required (run-discovered, all fixed)
Authoring the templates and running the **existing** suites surfaced three custom-renderer
realities. None was reasoned out up front; each was caught green-to-red (run, don't reason)
and fixed at the root:
- **Static — the static paint channel didn't skip inert anchors.** An empty `v-for` leaves a
Vue Fragment placeholder (an empty `text-leaf` anchor), so `stat.children` is `[anchor]`,
not `[]`. `paintStaticNode` filtered only already-written nodes, so the anchor passed the
`fresh.length > 0` gate and `paintIsolated` painted the container's **padding** as stray
blank lines — while `findStatics` in the same file already skipped `text-leaf`/`comment`.
Fix: `paintStaticNode` skips inert anchors too (safe: `node-ops.ts` forbids non-empty bare
text under `<static>`, so the only `text-leaf` there is an empty anchor).
- **Text — `<slot/>` fragment anchors shifted the transform line-index, exposing an Ink-parity
bug.** A `<slot/>` mounts as a Fragment whose boundary anchors are empty `text-leaf`s; the
squash loops that give a nested `<Transform>` its positional line index counted every
non-`comment` child, so the anchors shifted the index (`a<Transform>b` → index 2, not 1).
The anchors are byte-identical to a genuine `{''}` child — and **real Ink v7.0.4 doesn't
count `''`/`null` children in that index either** (`a{''}<Transform>b` → `ab[1]`; the old
render-fn `Text` already diverged to `ab[2]`). Fix: `advancesLineIndex(child)` (in
`host/nodes.ts`) skips `comment` AND empty `text-leaf`, applied to all 8 index-advance loops
across `paint.ts` / `host/text-measure.ts` / `paint/screen-reader.ts` — a parity improvement,
not a divergence.
- **Box — a root `v-if` makes `$el` a Fragment anchor.** `Box` must render **nothing** when
screen-reader-hidden, which needs a conditional root; a root `v-if` makes the component's
`$el` a fragment boundary anchor (empty `text-leaf`, no `.yoga`), so `measureElement` /
`useBoxMetrics` (which read `ref.$el.yoga`) collapsed to 0. Fix: those resolvers drill the
component `subTree` to the first real host node, skipping comment/empty-`text-leaf` anchors
(verified to resolve each ref'd Box to its **own** node, never a sibling's).
## Pitfalls (for adding or editing component SFCs)
- **Bind host-element props in camelCase, or via `v-bind="object"`.** The renderer matches
yoga/style props by exact camelCase key, and Vue passes a custom-element binding name
verbatim — so `:flex-grow="1"` reaches the renderer as `flex-grow` and is rejected. Use
`:flexGrow="1"`, or `v-bind="someObject"` (object keys are preserved). `Box`/`Text`/`Static`
bind a whole props/style object with `v-bind`; `Spacer` uses explicit camelCase.
- **Name a component differently from any host tag it renders** (`BoxImpl`/`TextImpl`/
`StaticImpl` via `defineOptions`). vue-tsc 3.3.4 self-recurses a `<box>` tag to a component
named "Box" (it has no `isCustomElement` at the type layer). Public names (`Box`/`Text`/
`Static`) come from `index.ts`.
- **Don't reintroduce parent-walking or `parent.type.name` matching for context** — use
provide/inject (`.name` is also fragile under minification).
- **Don't force child-vnode inspection into a template** (the double-materialization wart). If
a new component needs it, make it a render function — where the whole ecosystem draws the line.
- The host elements (`box`, `text`, `virtual-text`, `static`, `transform`) compile to raw
element vnodes via the build's `isCustomElement` option and are an **internal** detail.
Consumers use `<Box>` / `<Text>`, never `<box>`. SFC templates may reference the host tags
directly; their loose typing under `vue-tsc` (no `strictTemplates`) is intentional.
- Components export typed props (`ExtractPublicPropTypes` over the runtime props object) and
keep the `WithChildren` shim (`with-children.ts`): Vue's automatic JSX runtime routes
children to a `children` prop that declared slots do NOT provide, so the shim is required for
JSX consumers. It is harmless for templates — a `WithChildren`-wrapped component still
type-checks correctly in consumer `<template>`s under vue-tsc (verified), and no
`GlobalComponents` augmentation is needed because consumers import the components.
- Build/tooling: the `pack` build carries `unplugin-vue/rolldown` (with `isCustomElement` for
the host tags) and emits SFC declarations via `dts: { vue: true }`; the runtime **test** Vite
config also carries `unplugin-vue/vite` because unit tests import the `.vue` components;
`check:type` is `vue-tsc`.
+10 -1
View File
@@ -629,7 +629,8 @@ different runtime behavior, ownership rule, or out-of-contract handling.
- **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.ts` / `text.ts`), not down at the **paint layer**. A bad
**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.
@@ -659,6 +660,14 @@ different runtime behavior, ownership rule, or out-of-contract handling.
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 (2026-06-13, user-blessed):** `<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).
## Non-Behavioral Notes