Adversarial review found stale bare-host-tag references the per-file sed couldn't reach (they live in comments/docs). Code was clean — no contamination, no public API leakage, root/text-leaf/comment asymmetry consistent. Updated: - vite.config.ts isCustomElement comment (<box>/<text> -> <tui-box>/<tui-text>) - component-authoring.md split-table Text row (virtual-text/text -> tui-*) - box.vue / useBoxMetrics.ts / use-box-metrics.test.tsx "the real `box` host node" comments -> `tui-box` Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.3 KiB
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/> + tui-virtual-text/tui-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.namematching.TextandTransformprovide(TextContextKey, true);TextandNewlineinjectit. Template-friendly, matches vue-tui's existingAppContextKey/FocusContextstyle, and is the maintainer-endorsed Vue idiom (provide/inject outnumbers slot inspection 4–20× in the libraries surveyed). It replaced the old, duplicatedgetCurrentInstance()parent walk. - "What are my children's actual contents?" (filter inert/
Commentvnodes, 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-forleaves a Vue Fragment placeholder (an emptytext-leafanchor), sostat.childrenis[anchor], not[].paintStaticNodefiltered only already-written nodes, so the anchor passed thefresh.length > 0gate andpaintIsolatedpainted the container's padding as stray blank lines — whilefindStaticsin the same file already skippedtext-leaf/comment. Fix:paintStaticNodeskips inert anchors too (safe:node-ops.tsforbids non-empty bare text under<tui-static>, so the onlytext-leafthere 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 emptytext-leafs; the squash loops that give a nested<Transform>its positional line index counted every non-commentchild, 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''/nullchildren in that index either (a{''}<Transform>b→ab[1]; the old render-fnTextalready diverged toab[2]). Fix:advancesLineIndex(child)(inhost/nodes.ts) skipscommentAND emptytext-leaf, applied to all 8 index-advance loops acrosspaint.ts/host/text-measure.ts/paint/screen-reader.ts— a parity improvement, not a divergence. - Box — a root
v-ifmakes$ela Fragment anchor.Boxmust render nothing when screen-reader-hidden, which needs a conditional root; a rootv-ifmakes the component's$ela fragment boundary anchor (emptytext-leaf, no.yoga), someasureElement/useBoxMetrics(which readref.$el.yoga) collapsed to 0. Fix: those resolvers drill the componentsubTreeto the first real host node, skipping comment/empty-text-leafanchors (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 asflex-growand is rejected. Use:flexGrow="1", orv-bind="someObject"(object keys are preserved).Box/Text/Staticbind a whole props/style object withv-bind;Spaceruses explicit camelCase. - Host primitive tags are
tui--prefixed (tui-box/tui-text/tui-virtual-text/tui-static/tui-transform), mirroring Ink'sink-box/ink-text. The prefix keeps the renderer's intrinsic elements in their own namespace, so a template<tui-box>never resolves to the public<Box>component — the components keep their realname(Box/Text/Static) with no vue-tsc self-recursion. (Earlier the tags were barebox/text/…, which collided with the same-named components and forced an*Implinternal rename to dodge it; the prefix removed that workaround. vue-tsc has noisCustomElementat the type layer, so a bare lowercase tag would PascalCase-resolve to the component — the hyphenatedtui-name sidesteps that entirely.) - Don't reintroduce parent-walking or
parent.type.namematching for context — use provide/inject (.nameis 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 (
tui-box,tui-text,tui-virtual-text,tui-static,tui-transform) compile to raw element vnodes via the build'sisCustomElementoption and are an internal detail. Consumers use<Box>/<Text>, never<tui-box>. SFC templates may reference the host tags directly; their loose typing undervue-tsc(nostrictTemplates) is intentional. - Components export typed props (
ExtractPublicPropTypesover the runtime props object) and keep theWithChildrenshim (with-children.ts): Vue's automatic JSX runtime routes children to achildrenprop that declared slots do NOT provide, so the shim is required for JSX consumers. It is harmless for templates — aWithChildren-wrapped component still type-checks correctly in consumer<template>s under vue-tsc (verified), and noGlobalComponentsaugmentation is needed because consumers import the components. - Build/tooling: the
packbuild carriesunplugin-vue/rolldown(withisCustomElementfor the host tags) and emits SFC declarations viadts: { vue: true }; the runtime test Vite config also carriesunplugin-vue/vitebecause unit tests import the.vuecomponents;check:typeisvue-tsc.