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
@@ -1022,22 +1022,48 @@ test("non-string border edge background does not suppress invalid general fallba
).rejects.toThrow(/borderTopBackgroundColor/i);
});
// Empty <Text backgroundColor="bold">{""}</Text>: Ink's Text returns null for
// empty children BEFORE attaching its colorizing transform, so colorize never
// runs. vue-tui validates AFTER the empty early-return, so this must NOT throw.
test('empty <Text backgroundColor="bold">{""}</Text> does NOT throw (Ink: returns null first)', async ({
// Divergence (user-blessed 2026-06-13): vue-tui validates an invalid backgroundColor
// (a chalk-MODIFIER name) on EVERY render regardless of content, exactly as <Box>
// already does for its own bg (box-validate.ts). Ink colorizes lazily so it happens not to
// throw for empty text — an incidental artifact, not a design choice. An invalid value is
// invalid regardless of content; content-gated validation is a latent footgun. See
// .agents/docs/component-authoring.md and the ink-divergences "Invalid input is
// validated at the component layer" entry.
test('empty <Text backgroundColor="bold">{""}</Text> THROWS (always-validate divergence)', async ({
expect,
}) => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box alignSelf="flex-start">
<Text backgroundColor="bold">{""}</Text>
</Box>
)),
{ columns: 100 },
);
// Empty text renders nothing and never colorizes.
expect(lastFrame()).toBe("");
await expect(
render(
defineComponent(() => () => (
<Box alignSelf="flex-start">
<Text backgroundColor="bold">{""}</Text>
</Box>
)),
{ columns: 100 },
),
).rejects.toThrow(/backgroundColor/i);
});
// The always-validate divergence covers FOREGROUND color too: text.vue's validate() runs
// assertValidForegroundColor(props.color) before the content gate, so a childless <Text>
// with an invalid color (a chalk key that exists but is not a callable color method, like
// "level") throws regardless of content — the foreground half of the same divergence. The
// pre-refactor text.ts gated both color and backgroundColor behind wouldRenderNonEmptyText;
// this pins that the foreground gate is gone for good. (Control: color="red" with no content
// does NOT throw — see the foreground non-throw cases above.)
test('empty <Text color="level">{""}</Text> THROWS (always-validate divergence, foreground)', async ({
expect,
}) => {
await expect(
render(
defineComponent(() => () => (
<Box alignSelf="flex-start">
<Text color="level">{""}</Text>
</Box>
)),
{ columns: 100 },
),
).rejects.toThrow(/color/i);
});
// A screen-reader-hidden <Box> / <Text> with a modifier-name bg must NOT throw:
@@ -40,3 +40,15 @@ test("Newline count=2 adds two blank lines standalone", async () => {
// "above", 2 blank lines, "below" = at least 4 lines
expect(lines.length).toBeGreaterThanOrEqual(4);
});
test("Newline inside Text renders inline (virtual-text), not a standalone line", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Text>
a<Newline />b
</Text>
)),
{ columns: 100 },
);
expect(lastFrame()!.split("\n")).toEqual(["a", "b"]);
});
@@ -900,3 +900,28 @@ test("Static container vertical padding adds blank rows to the painted static fr
expect(staticFrame).toBeDefined();
expect(staticFrame).toBe("\n\nX\n\n");
});
// Regression: an EMPTY padded <Static> must emit NO stray blank-line frame.
//
// A template `v-for` over an empty `items` leaves an inert text-leaf anchor in
// `stat.children`, so `fresh` is anchor-only (length 1) rather than empty. Before
// the fix, that anchor-only `fresh` passed the `fresh.length > 0` gate and
// paintIsolated painted the container's copied PADDING (paddingTop/Bottom) as
// stray blank lines. The static channel must skip inert anchors so an anchor-only
// child set paints nothing — mirroring findStatics' own text-leaf/comment skip.
test("empty Static with padding emits no stray blank-line frame (template anchor skip)", async () => {
const { frames } = await render(
defineComponent(() => () => (
<Static items={[] as string[]} style={{ paddingTop: 2, paddingBottom: 1 }}>
{{ default: ({ item }: { item: string }) => <Text>{item}</Text> }}
</Static>
)),
{ columns: 20 },
);
// The static channel must not paint the container padding for an anchor-only
// child set. A stray padding frame is NON-empty (e.g. "\n\n\n") yet blank once
// trimmed; the empty "" frames are fine. So assert no frame is blank-but-
// non-empty — that is exactly the stray blank-line frame the bug produces.
const strayBlank = frames.filter((f) => f !== "" && f.trim() === "");
expect(strayBlank).toEqual([]);
});
@@ -12,6 +12,21 @@ test("Transform uppercases descendant text", async () => {
expect(lastFrame()).toContain("ABC");
});
test("Newline inside Transform renders inline (text context via provide)", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Transform transform={(line) => line.toUpperCase()}>
x<Newline />y
</Transform>
)),
{ columns: 100 },
);
// Newline injects Transform's TextContextKey → inline virtual-text → "x","y" on
// two lines (both uppercased by the transform). Without Transform's provide,
// Newline would render as a standalone yoga `text` node and the output differs.
expect(lastFrame()).toBe("X\nY");
});
// --- Ink transform tests ---
test("transform children — <Transform> inside <Text>", async () => {
@@ -381,6 +396,30 @@ test("G52: null sibling does not shift measured width (measurement)", async () =
expect(lastFrame()).toBe("A1:B|");
});
// G52 sibling case — an EMPTY-STRING child (`{''}`) is not a counted childNode in
// Ink either: React renders neither `null` nor `''` as a DOM childNode, so an empty
// `''` sibling must NOT shift a following <Transform>'s positional line index. Vue
// materializes `''` as an EMPTY text-leaf host node that DOES occupy a positional
// slot, so the squash/transform-index loops must skip it exactly like a comment.
// This is the same anchor a template `<slot/>` boundary inserts, which is why a
// template-authored <Text> needs this fix. Verified against real Ink v7.0.4:
// `a{''}<Transform>b` → "a1:b" (index 1, after "a"), NOT "a2:b".
test("empty-string child does not shift a sibling Transform's line index (Ink parity)", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Text>
{"a"}
{""}
<Transform transform={(line, i) => `${i}:${line}`}>b</Transform>
</Text>
)),
{ columns: 40 },
);
// Ink v7.0.4: the empty "" is not a counted child, so the Transform sees line
// index 1 (after "a"), giving "1:b" → "a1:b".
expect(lastFrame()).toBe("a1:b");
});
// G52 (recursive twin): the comment-skip must also apply to the RECURSIVE
// grandchild loop that recurses transform-in-transform (G32's domain). A
// `{null}`/comment inside an OUTER <Transform> must NOT shift an INNER
@@ -1,7 +1,15 @@
import { defineComponent, nextTick, ref, shallowRef, watchEffect, watchPostEffect } from "vue";
import { describe, expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Box, Text, useBoxMetrics, measureElement, useWindowSize } from "@vue-tui/runtime";
import {
Box,
Text,
useBoxMetrics,
measureElement,
useWindowSize,
createApp,
} from "@vue-tui/runtime";
import { makeFakeStdin, makeFakeWritable } from "../lifecycle/test-streams.ts";
describe("useBoxMetrics", () => {
test("returns layout dimensions after render", async () => {
@@ -801,3 +809,181 @@ describe("useBoxMetrics - resize and dynamic layout", () => {
expect(lastFrame()).toContain("Metrics: 0,0,0,0,false");
});
});
// The template-authored `<Box>` SFC has a root `v-if`, so it renders as a Vue
// Fragment whose `$el` is the fragment's BOUNDARY anchor (an empty `text-leaf`
// with no `.yoga`), NOT the real `box` host node. Resolving a ref to that anchor
// would collapse metrics to 0 — or, worse, drill the wrong node. The subTree
// drill in useBoxMetrics (commit 801739d) walks the component's vnode subTree to
// the first genuine host node. These tests guard the tricky drill cases the
// basic single-Box tests above do NOT cover: each is a genuine RED if the drill
// is reverted to the old `$el`-only resolver.
describe("useBoxMetrics - subtree drill (fragment-rooted Box resolution)", () => {
// Sibling isolation: two ref'd <Box>es with DIFFERENT explicit sizes must each
// resolve to their OWN box, not the first box found by a naive tree walk.
// A "first host node in the tree" resolver would silently pass both refs to
// box A's node and report 10x2 twice.
test("two sibling ref'd Boxes each resolve to their own dimensions", async () => {
const a = shallowRef({ width: -1, height: -1 });
const b = shallowRef({ width: -1, height: -1 });
const App = defineComponent(() => {
const aRef = ref(null);
const bRef = ref(null);
watchPostEffect(() => {
void nextTick(() => {
a.value = measureElement(aRef.value);
b.value = measureElement(bRef.value);
});
});
return () => (
<Box flexDirection="column">
<Box ref={aRef} width={10} height={2}>
<Text>A</Text>
</Box>
<Box ref={bRef} width={30} height={6}>
<Text>B</Text>
</Box>
</Box>
);
});
await render(App, { columns: 100 });
await nextTick();
await nextTick();
// Each ref resolves to ITS OWN box, not the first box in the tree.
expect(a.value).toEqual({ width: 10, height: 2 });
expect(b.value).toEqual({ width: 30, height: 6 });
});
// Same isolation via the reactive useBoxMetrics path (not just imperative
// measureElement), so both code paths through resolveYogaNode are guarded.
test("two sibling ref'd Boxes report distinct useBoxMetrics dimensions", async () => {
const a = shallowRef({ w: -1, h: -1 });
const b = shallowRef({ w: -1, h: -1 });
const App = defineComponent(() => {
const aRef = ref(null);
const bRef = ref(null);
const ma = useBoxMetrics(aRef);
const mb = useBoxMetrics(bRef);
watchEffect(() => {
a.value = { w: ma.width.value, h: ma.height.value };
b.value = { w: mb.width.value, h: mb.height.value };
});
return () => (
<Box flexDirection="column">
<Box ref={aRef} width={10} height={2}>
<Text>A</Text>
</Box>
<Box ref={bRef} width={30} height={6}>
<Text>B</Text>
</Box>
</Box>
);
});
await render(App, { columns: 100 });
await nextTick();
await nextTick();
expect(a.value).toEqual({ w: 10, h: 2 });
expect(b.value).toEqual({ w: 30, h: 6 });
});
// Deep nesting: a ref'd <Box> wrapped a couple of component levels deep still
// resolves. The drill must descend through nested component subTrees, not just
// the immediate one.
test("a Box nested two component levels deep still resolves", async () => {
const dims = shallowRef({ width: -1, height: -1 });
const Inner = defineComponent({
props: { boxRef: { type: Object, default: null } },
setup(props) {
return () => (
<Box ref={props.boxRef as never} width={13} height={7}>
<Text>deep</Text>
</Box>
);
},
});
const Middle = defineComponent({
props: { boxRef: { type: Object, default: null } },
setup(props) {
return () => (
<Box>
<Inner boxRef={props.boxRef} />
</Box>
);
},
});
const App = defineComponent(() => {
const boxRef = ref(null);
watchPostEffect(() => {
void nextTick(() => {
dims.value = measureElement(boxRef.value);
});
});
return () => <Middle boxRef={boxRef} />;
});
await render(App, { columns: 100 });
await nextTick();
await nextTick();
expect(dims.value).toEqual({ width: 13, height: 7 });
});
// SR-hidden returns clean zeros: a ref'd <Box ariaHidden> under screen-reader
// mode renders NOTHING (the root `v-if="!srHidden && ..."` is false), so its
// subTree has no host `box` node. measureElement must return {width:0,height:0}
// WITHOUT crashing and WITHOUT drilling to a visible sibling's node.
//
// Uses the createApp + app.mount({ isScreenReaderEnabled: true }) pattern
// (the repo's working SR-enable path for a live, ref-measurable mount; the
// testing `render()` helper does not expose isScreenReaderEnabled).
test("SR-hidden Box returns clean zero metrics without crashing", async () => {
const hidden = shallowRef({ width: -1, height: -1 });
const visible = shallowRef({ width: -1, height: -1 });
const App = defineComponent(() => {
const hiddenRef = ref(null);
const visibleRef = ref(null);
watchPostEffect(() => {
void nextTick(() => {
hidden.value = measureElement(hiddenRef.value);
visible.value = measureElement(visibleRef.value);
});
});
return () => (
<Box flexDirection="column">
<Box ref={hiddenRef} ariaHidden width={25} height={9}>
<Text>secret</Text>
</Box>
<Box ref={visibleRef} width={12} height={4}>
<Text>shown</Text>
</Box>
</Box>
);
});
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 100 });
const stderr = makeFakeWritable({ columns: 100 });
const { stream: stdin } = makeFakeStdin();
app.mount({
stdout,
stdin,
stderr,
exitOnCtrlC: false,
isScreenReaderEnabled: true,
});
try {
await nextTick();
await nextTick();
await nextTick();
// The aria-hidden Box rendered nothing under SR → no host node → clean zeros,
// NOT the hidden Box's 25x9 and NOT the visible sibling's dimensions.
expect(hidden.value).toEqual({ width: 0, height: 0 });
// Sanity: the visible sibling still resolves to its own node (proves the
// hidden ref returning 0 isn't because measurement was globally broken).
expect(visible.value).toEqual({ width: 12, height: 4 });
} finally {
app.unmount();
}
});
});
@@ -0,0 +1,25 @@
<script setup lang="ts">
// Type-only fixture (not run): pins that consumer TEMPLATES type-check vue-tui
// components under vue-tsc — props validated, slot children accepted — with the
// components exported as WithChildren-wrapped defineComponents.
//
// Scope note: without `strictTemplates`, vue-tsc catches WRONG-TYPE and
// MISSING-REQUIRED prop errors in templates (exercised below) but NOT excess/unknown
// prop NAMES — a fat-fingered `<Box :bogusprop="1">` is not flagged here. That gap is
// intentional (strictTemplates off); the `.tsx` JSX fixture does catch excess props.
import { Box, Text, Static, Transform } from "@vue-tui/runtime";
</script>
<template>
<!-- Valid: slot children + typed props -->
<Box flex-direction="row"><Text color="green">ok</Text></Box>
<Static :items="[1, 2, 3]"><Text>x</Text></Static>
<Transform :transform="(line: string) => line"><Text>x</Text></Transform>
<!-- @vue-expect-error display accepts "flex" | "none", not a number -->
<Box :display="123">x</Box>
<!-- @vue-expect-error bold accepts a boolean, not a string -->
<Text :bold="'yes'">x</Text>
<!-- @vue-expect-error items is required -->
<Static>x</Static>
</template>
@@ -8,5 +8,6 @@
"strict": true,
"skipLibCheck": true,
"types": ["node"]
}
},
"include": ["./*.tsx", "./*.vue"]
}
+3 -2
View File
@@ -8,7 +8,7 @@
"test:integration": "vp test",
"test:pty": "vp test run --config vitest.pty.config.ts --passWithNoTests",
"check:type": "tsc --noEmit && vp run check:fixtures",
"check:fixtures": "tsc -p integration/pty/fixtures/tsconfig.json --noEmit"
"check:fixtures": "vue-tsc -p integration/pty/fixtures/tsconfig.json --noEmit"
},
"devDependencies": {
"@types/node": "^25.9.1",
@@ -24,6 +24,7 @@
"tsx": "catalog:",
"typescript": "^6.0.3",
"vite-plus": "^0.1.22",
"vue": "^3.5.34"
"vue": "^3.5.34",
"vue-tsc": "catalog:"
}
}