diff --git a/packages/runtime-tests/integration/layout/position.test.tsx b/packages/runtime-tests/integration/layout/position.test.tsx
index afecc67..5f13e6f 100644
--- a/packages/runtime-tests/integration/layout/position.test.tsx
+++ b/packages/runtime-tests/integration/layout/position.test.tsx
@@ -59,6 +59,27 @@ test("absolute position with percentage bottom and right offsets", async () => {
expect(lastFrame({ trimLines: true })).toBe("\n X\n\n");
});
+// Ink coerces a STRING position offset to a PERCENT (styles.ts
+// applyPositionStyles: `typeof value === 'string'` →
+// setPositionPercent(edge, parseFloat(value))). So a bare-numeric string like
+// top="50" is 50% of the container height, NOT 50 absolute cells. This mirrors
+// the "absolute position with percentage offsets" test (top="50%" left="50%")
+// but with bare-numeric strings. Without the fix vue forwards "50" raw to
+// setPosition → 50 absolute cells → pushed off-screen.
+test("absolute position with bare numeric string offsets is a percent (Ink parity)", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ X
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe("\n\n X\n");
+});
+
test("relative position offsets visual position while keeping flow", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
diff --git a/packages/runtime-tests/integration/layout/width-height.test.tsx b/packages/runtime-tests/integration/layout/width-height.test.tsx
index aef4c55..630a3b2 100644
--- a/packages/runtime-tests/integration/layout/width-height.test.tsx
+++ b/packages/runtime-tests/integration/layout/width-height.test.tsx
@@ -1,7 +1,7 @@
-import { defineComponent, shallowRef, nextTick } from "vue";
+import { defineComponent, ref, shallowRef, nextTick, watchEffect } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
-import { Box, Text } from "@vue-tui/runtime";
+import { Box, Text, useBoxMetrics } from "@vue-tui/runtime";
test("set width", async () => {
const { lastFrame } = await render(
@@ -33,6 +33,93 @@ test("set width in percent", async () => {
expect(lastFrame({ trimLines: true })).toBe("A B");
});
+// Ink coerces a STRING width to a PERCENT (styles.ts applyDimensionStyles:
+// `typeof style.width === 'string'` → setWidthPercent(parseInt(width, 10))).
+// So a bare-numeric string is a PERCENT, NOT absolute cells: width="50" on a
+// width=10 parent → 5 cells. Without the fix vue forwards "50" raw to
+// setWidth → 50 absolute cells → "A" + 50 spaces + "B".
+test("set width with bare numeric string is a percent (Ink parity)", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ A
+
+ B
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame({ trimLines: true })).toBe("A B");
+});
+
+// Ink uses parseInt(width, 10) which TRUNCATES the fraction: "55.9%" → 55%, NOT
+// parseFloat which would give 55.9% → 56 cells (yoga rounds 55.9% of 100 to 56).
+// Assert the COMPUTED width directly via useBoxMetrics so the test discriminates
+// parseInt(55) from parseFloat(55.9→56): a paint-frame assertion can't, because
+// trimLines collapses both a 55- and 56-cell box to the same column once the
+// child text is left-aligned. RED on the pre-fix parseFloat path (width 56),
+// GREEN after (width 55).
+test("set width with fractional percent string truncates to 55 like Ink parseInt", async () => {
+ const computedWidth = shallowRef(-1);
+ const App = defineComponent(() => {
+ const boxRef = ref(null);
+ const metrics = useBoxMetrics(boxRef);
+ watchEffect(() => {
+ computedWidth.value = metrics.width.value;
+ });
+ return () => (
+
+
+ A
+
+ B
+
+ );
+ });
+ await render(App, { columns: 200 });
+ // useBoxMetrics defers measurement to nextTick after the commit.
+ await nextTick();
+ // parseInt("55.9", 10) → 55 → 55% of 100 = 55 cells (NOT parseFloat → 55.9 → 56).
+ expect(computedWidth.value).toBe(55);
+});
+
+// Ink: parseInt("", 10) → NaN, which yoga accepts via setWidthPercent without
+// throwing. vue forwarded "" raw to setWidth("") which THROWS, crashing render.
+test("set width to empty string does not throw and renders child (Ink parity)", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ X
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame()).toContain("X");
+});
+
+// Ink's applyDimensionStyles else-branch routes a non-number/non-string width to
+// setWidthAuto() (styles.ts:669-671), so a junk value renders fine. vue used to
+// forward it raw to setWidth(false) which THROWS ("Invalid value false for
+// setWidth"), crashing the render. Lock the parity: junk width must not throw and
+// must still render the child (auto sizing). Vue's [Number, String] prop
+// validation only WARNS on `false` and still forwards it, so this path is real.
+test("set width to a junk (non-number/non-string) value does not throw and renders child (Ink parity)", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+
+ X
+
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame()).toContain("X");
+});
+
test("set min width", async () => {
const { lastFrame: smallerFrame } = await render(
defineComponent(() => () => (
diff --git a/packages/runtime/src/host/yoga.ts b/packages/runtime/src/host/yoga.ts
index 9715134..4c0f025 100644
--- a/packages/runtime/src/host/yoga.ts
+++ b/packages/runtime/src/host/yoga.ts
@@ -178,14 +178,55 @@ export function removeYogaChild(parent: TuiContainer, child: TuiNode): void {
// --- prop application ----------------------------------------------------
const YOGA_PROP_SETTERS: Record void> = {
- width: (n, v) =>
- v == null ? n.setWidth("auto") : n.setWidth(v as number | "auto" | `${number}%`),
- height: (n, v) =>
- v == null ? n.setHeight("auto") : n.setHeight(v as number | "auto" | `${number}%`),
- // Ink default: minWidth=0 (yoga default). Reset to 0 on removal. (G19)
- minWidth: (n, v) => n.setMinWidth(v == null ? 0 : (v as number | `${number}%`)),
- // Ink default: minHeight=0 (yoga default). Reset to 0 on removal. (G19)
- minHeight: (n, v) => n.setMinHeight(v == null ? 0 : (v as number | `${number}%`)),
+ // Mirror Ink's applyDimensionStyles width/height branch exactly (styles.ts:664-682):
+ // number → setWidth (absolute cells)
+ // string → setWidthPercent(Number.parseInt(v, 10)) — ANY string is a PERCENT,
+ // so a bare-numeric string like "50" is 50%, NOT 50 absolute cells, and
+ // parseInt TRUNCATES fractions ("55.9%" → 55%). parseInt("") → NaN, which
+ // yoga's percent setter accepts without throwing.
+ // else → setWidthAuto() — this is the load-bearing fallback (like flexBasis's
+ // setFlexBasisAuto): Vue's [Number, String] prop validation only WARNS on a
+ // bad runtime value (e.g. width={false}/{}/[]) and still forwards it, so
+ // without this branch the raw setWidth(false) THROWS ("Invalid value false
+ // for setWidth") and crashes the render where Ink renders fine via auto.
+ // null/undefined also land in the else branch → auto (the G19 removal reset,
+ // equivalent to the prior setWidth("auto")).
+ width: (n, v) => {
+ if (typeof v === "number") {
+ n.setWidth(v);
+ } else if (typeof v === "string") {
+ n.setWidthPercent(Number.parseInt(v, 10));
+ } else {
+ n.setWidthAuto();
+ }
+ },
+ height: (n, v) => {
+ if (typeof v === "number") {
+ n.setHeight(v);
+ } else if (typeof v === "string") {
+ n.setHeightPercent(Number.parseInt(v, 10));
+ } else {
+ n.setHeightAuto();
+ }
+ },
+ // Mirror Ink's applyDimensionStyles minWidth branch exactly (styles.ts:684-690):
+ // string → setMinWidthPercent(Number.parseInt(v, 10))
+ // else → setMinWidth(v ?? 0) — number falls here (= setMinWidth(v)), and so
+ // does a junk value (e.g. minWidth={false} → setMinWidth(false), which THROWS
+ // in yoga exactly as it does in Ink, since `?? 0` only catches null/undefined).
+ // Ink has no auto fallback for min/max, so we faithfully forward junk to the
+ // cell setter and match Ink's behavior, including its throw.
+ // Ink default: minWidth=0 (yoga default) → null/undefined reset to 0 on removal. (G19)
+ minWidth: (n, v) =>
+ typeof v === "string"
+ ? n.setMinWidthPercent(Number.parseInt(v, 10))
+ : n.setMinWidth(v == null ? 0 : (v as number)),
+ // Mirror Ink's minHeight branch (styles.ts:692-698); see minWidth above.
+ // Ink default: minHeight=0 (yoga default) → null/undefined reset to 0 on removal. (G19)
+ minHeight: (n, v) =>
+ typeof v === "string"
+ ? n.setMinHeightPercent(Number.parseInt(v, 10))
+ : n.setMinHeight(v == null ? 0 : (v as number)),
// Ink default: flexGrow=0 (Box.tsx hardcodes flexGrow:0). Reset to 0 on removal. (G19)
flexGrow: (n, v) => n.setFlexGrow(v == null ? 0 : (v as number)),
// Ink default: flexShrink=1 (Box.tsx hardcodes flexShrink:1). Reset to 1 on removal. (G19)
@@ -275,32 +316,56 @@ const YOGA_PROP_SETTERS: Record void> = {
// Yoga does not support per-axis overflow; these are accepted silently.
overflowX: (_n, _v) => {},
overflowY: (_n, _v) => {},
+ // Mirror Ink's applyDimensionStyles maxWidth branch (styles.ts:700-714):
+ // string → setMaxWidthPercent(Number.parseInt(v, 10))
+ // else → setMaxWidth(v) — number falls here; a junk value (maxWidth={false})
+ // forwards to setMaxWidth(false), which THROWS in yoga exactly as in Ink (Ink
+ // has no auto fallback for max). We map null/undefined → NaN here (yoga's "no
+ // max", the G19 removal reset) because Vue's host renderer can deliver raw
+ // null and setMaxWidth(null) throws ("Cannot read properties of null"); NaN is
+ // equivalent to Ink's else with an absent/undefined value.
maxWidth: (n, v) =>
- v == null ? n.setMaxWidth(NaN as never) : n.setMaxWidth(v as number | `${number}%`),
+ typeof v === "string"
+ ? n.setMaxWidthPercent(Number.parseInt(v, 10))
+ : n.setMaxWidth(v == null ? (NaN as never) : (v as number)),
+ // Mirror Ink's maxHeight branch (styles.ts:708-714); see maxWidth above.
maxHeight: (n, v) =>
- v == null ? n.setMaxHeight(NaN as never) : n.setMaxHeight(v as number | `${number}%`),
+ typeof v === "string"
+ ? n.setMaxHeightPercent(Number.parseInt(v, 10))
+ : n.setMaxHeight(v == null ? (NaN as never) : (v as number)),
aspectRatio: (n, v) =>
v == null ? n.setAspectRatio(undefined as never) : n.setAspectRatio(v as number),
alignContent: (n, v) =>
v == null ? n.setAlignContent(Yoga.ALIGN_FLEX_START) : n.setAlignContent(toAlign(v as string)),
// Ink default: position=relative (yoga default). Reset to RELATIVE on removal. (G19)
position: (n, v) => n.setPositionType(toPosition(v as string | undefined)),
+ // Mirror Ink's applyPositionStyles branch exactly (styles.ts:428-441):
+ // string → setPositionPercent(edge, Number.parseFloat(value)) — so a
+ // bare-numeric string like top="50" is 50% of the container, NOT 50 absolute
+ // cells. NOTE: Ink uses parseFloat for positions (preserving fractions) vs
+ // parseInt for dimensions (above) — that distinction is intentional, keep it.
+ // else → setPosition(edge, value) — number falls here; a junk value
+ // (top={false}) forwards to setPosition(edge, false), which THROWS in yoga
+ // exactly as in Ink (Ink has no auto fallback for positions). We map
+ // null/undefined → NaN here (yoga's auto, the G19 removal reset), the
+ // equivalent of Ink's else with an absent value (Vue can deliver raw null,
+ // and setPosition(edge, null) throws).
top: (n, v) =>
- v == null
- ? n.setPosition(Yoga.EDGE_TOP, NaN as never)
- : n.setPosition(Yoga.EDGE_TOP, v as number | `${number}%`),
+ typeof v === "string"
+ ? n.setPositionPercent(Yoga.EDGE_TOP, Number.parseFloat(v))
+ : n.setPosition(Yoga.EDGE_TOP, v == null ? (NaN as never) : (v as number)),
right: (n, v) =>
- v == null
- ? n.setPosition(Yoga.EDGE_RIGHT, NaN as never)
- : n.setPosition(Yoga.EDGE_RIGHT, v as number | `${number}%`),
+ typeof v === "string"
+ ? n.setPositionPercent(Yoga.EDGE_RIGHT, Number.parseFloat(v))
+ : n.setPosition(Yoga.EDGE_RIGHT, v == null ? (NaN as never) : (v as number)),
bottom: (n, v) =>
- v == null
- ? n.setPosition(Yoga.EDGE_BOTTOM, NaN as never)
- : n.setPosition(Yoga.EDGE_BOTTOM, v as number | `${number}%`),
+ typeof v === "string"
+ ? n.setPositionPercent(Yoga.EDGE_BOTTOM, Number.parseFloat(v))
+ : n.setPosition(Yoga.EDGE_BOTTOM, v == null ? (NaN as never) : (v as number)),
left: (n, v) =>
- v == null
- ? n.setPosition(Yoga.EDGE_LEFT, NaN as never)
- : n.setPosition(Yoga.EDGE_LEFT, v as number | `${number}%`),
+ typeof v === "string"
+ ? n.setPositionPercent(Yoga.EDGE_LEFT, Number.parseFloat(v))
+ : n.setPosition(Yoga.EDGE_LEFT, v == null ? (NaN as never) : (v as number)),
};
function toFlexDirection(v: string): FlexDirection {