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:
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user