diff --git a/.agents/docs/component-authoring.md b/.agents/docs/component-authoring.md index d08a887..b040da6 100644 --- a/.agents/docs/component-authoring.md +++ b/.agents/docs/component-authoring.md @@ -73,7 +73,7 @@ and fixed at the root: `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 ``, so the only `text-leaf` there is an empty anchor). + text under ``, so the only `text-leaf` there is an empty anchor). - **Text — `` fragment anchors shifted the transform line-index, exposing an Ink-parity bug.** A `` mounts as a Fragment whose boundary anchors are empty `text-leaf`s; the squash loops that give a nested `` its positional line index counted every @@ -98,18 +98,23 @@ and fixed at the root: 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 `` tag to a component - named "Box" (it has no `isCustomElement` at the type layer). Public names (`Box`/`Text`/ - `Static`) come from `index.ts`. +- **Host primitive tags are `tui-`-prefixed** (`tui-box`/`tui-text`/`tui-virtual-text`/ + `tui-static`/`tui-transform`), mirroring Ink's `ink-box`/`ink-text`. The prefix keeps the + renderer's intrinsic elements in their own namespace, so a template `` never + resolves to the public `` component — the components keep their real `name` + (`Box`/`Text`/`Static`) with no vue-tsc self-recursion. (Earlier the tags were bare + `box`/`text`/…, which collided with the same-named components and forced an `*Impl` internal + rename to dodge it; the prefix removed that workaround. vue-tsc has no `isCustomElement` at + the type layer, so a bare lowercase tag would PascalCase-resolve to the component — the + hyphenated `tui-` name sidesteps that entirely.) - **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 `` / ``, never ``. SFC templates may reference the host tags - directly; their loose typing under `vue-tsc` (no `strictTemplates`) is intentional. +- The host elements (`tui-box`, `tui-text`, `tui-virtual-text`, `tui-static`, `tui-transform`) + compile to raw element vnodes via the build's `isCustomElement` option and are an **internal** + detail. Consumers use `` / ``, never ``. 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 diff --git a/packages/runtime-tests/integration/components/background-color.test.tsx b/packages/runtime-tests/integration/components/background-color.test.tsx index 6457202..deacd43 100644 --- a/packages/runtime-tests/integration/components/background-color.test.tsx +++ b/packages/runtime-tests/integration/components/background-color.test.tsx @@ -809,8 +809,10 @@ test("non-string host Box backgroundColor does not override inherited background const { lastFrame } = await render( defineComponent( () => () => - h("box", { backgroundColor: "red", width: 5, height: 2 }, [ - h("box", { backgroundColor: [0, 0, 255], width: 5, height: 2 }, [h("text", null, "Hi")]), + h("tui-box", { backgroundColor: "red", width: 5, height: 2 }, [ + h("tui-box", { backgroundColor: [0, 0, 255], width: 5, height: 2 }, [ + h("tui-text", null, "Hi"), + ]), ]), ), { columns: 100 }, @@ -828,8 +830,8 @@ test("non-string host Text backgroundColor does not override inherited backgroun const { lastFrame } = await render( defineComponent( () => () => - h("box", { backgroundColor: "red", alignSelf: "flex-start" }, [ - h("text", { backgroundColor: [0, 0, 255] }, "Hi"), + h("tui-box", { backgroundColor: "red", alignSelf: "flex-start" }, [ + h("tui-text", { backgroundColor: [0, 0, 255] }, "Hi"), ]), ), { columns: 100 }, diff --git a/packages/runtime-tests/integration/components/borders.test.tsx b/packages/runtime-tests/integration/components/borders.test.tsx index 8b2f6ac..8f285b5 100644 --- a/packages/runtime-tests/integration/components/borders.test.tsx +++ b/packages/runtime-tests/integration/components/borders.test.tsx @@ -884,7 +884,7 @@ test("non-string host borderTopColor falls back to general borderColor", async ( const { lastFrame } = await render( defineComponent( () => () => - h("box", { + h("tui-box", { borderStyle: "single", borderColor: "red", borderTopColor: [0, 0, 255], @@ -1189,7 +1189,7 @@ test("non-string host borderTopBackgroundColor falls back to general borderBackg const { lastFrame } = await render( defineComponent( () => () => - h("box", { + h("tui-box", { borderStyle: "single", borderBackgroundColor: "red", borderTopBackgroundColor: [0, 0, 255], diff --git a/packages/runtime-tests/integration/kitty-lifecycle.test.ts b/packages/runtime-tests/integration/kitty-lifecycle.test.ts index 28548ad..3f06952 100644 --- a/packages/runtime-tests/integration/kitty-lifecycle.test.ts +++ b/packages/runtime-tests/integration/kitty-lifecycle.test.ts @@ -455,7 +455,7 @@ function mountWithInput(kittyKeyboard: { mode: "auto" | "enabled" }) { useInput((input) => { inputs.push(input); }); - return () => h("text", null, "x"); + return () => h("tui-text", null, "x"); }); const app = createApp(App); diff --git a/packages/runtime/src/components/box-validate.ts b/packages/runtime/src/components/box-validate.ts index 92b1822..6b503ec 100644 --- a/packages/runtime/src/components/box-validate.ts +++ b/packages/runtime/src/components/box-validate.ts @@ -101,7 +101,7 @@ export function assertBoxValid(props: BoxProps): true { } // NOTE: this component-level validation covers the public ``/`` - // API only. A raw host-op call (`h("box", { backgroundColor: "bold" })`) + // API only. A raw host-op call (`h("tui-box", { backgroundColor: "bold" })`) // bypasses it; the paint layer keeps its silent degrade-to-bare-text there // rather than throwing (a throw in the post-flush paint pass wedges Vue's // scheduler). Same accepted limitation as the borderStyle fix (#124). diff --git a/packages/runtime/src/components/box.vue b/packages/runtime/src/components/box.vue index 0b24e67..eb9f02d 100644 --- a/packages/runtime/src/components/box.vue +++ b/packages/runtime/src/components/box.vue @@ -4,9 +4,10 @@ import { AppContextKey } from "../context.ts"; import { boxProps } from "./box-props.ts"; import { assertBoxValid } from "./box-validate.ts"; -// Internal name != "Box" to avoid vue-tsc self-recursion on the `` host tag. -// The public export name "Box" comes from index.ts. -defineOptions({ name: "BoxImpl" }); +// Renders the `` host primitive. The host tag's `tui-` prefix keeps it out +// of the component namespace, so the component can take its real name "Box" with no +// vue-tsc self-recursion on the tag. Public export wired in index.ts. +defineOptions({ name: "Box" }); const props = defineProps(boxProps); defineSlots<{ default?: () => unknown }>(); const appCtx = inject(AppContextKey, null); @@ -21,8 +22,8 @@ const srHidden = computed(() => srEnabled.value && props.ariaHidden); colorizes). Under a screen reader with an ariaLabel, render the label text instead of the slot. The root `v-if` makes this a fragment, but $el still resolves to the real `box` host node, so measureElement/useBoxMetrics work. --> - - {{ props.ariaLabel }} + + {{ props.ariaLabel }} - + diff --git a/packages/runtime/src/components/newline.vue b/packages/runtime/src/components/newline.vue index 0ac4a5f..bbb0d57 100644 --- a/packages/runtime/src/components/newline.vue +++ b/packages/runtime/src/components/newline.vue @@ -12,6 +12,6 @@ const content = computed(() => "\n".repeat(props.count)); diff --git a/packages/runtime/src/components/spacer.vue b/packages/runtime/src/components/spacer.vue index 0072f13..f5fb41f 100644 --- a/packages/runtime/src/components/spacer.vue +++ b/packages/runtime/src/components/spacer.vue @@ -3,5 +3,5 @@ defineOptions({ name: "Spacer" }); diff --git a/packages/runtime/src/components/static.vue b/packages/runtime/src/components/static.vue index 5edc59c..f5601b7 100644 --- a/packages/runtime/src/components/static.vue +++ b/packages/runtime/src/components/static.vue @@ -2,10 +2,10 @@ import { computed, shallowRef, watch } from "vue"; import { staticProps } from "./static-props.ts"; -// Internal name deliberately != "Static": vue-tsc 3.3.4 would bind the `` -// host tag below to this component (self-recursion) if they matched. Public export -// name is "Static" (index.ts). -defineOptions({ name: "StaticImpl" }); +// Renders the `` host primitive. The host tag's `tui-` prefix keeps it out +// of the component namespace, so the component can take its real name "Static" with no +// vue-tsc self-recursion on the tag. Public export wired in index.ts. +defineOptions({ name: "Static" }); const props = defineProps(staticProps); defineSlots<{ default?: (slotProps: { item: unknown; index: number }) => unknown }>(); @@ -36,9 +36,9 @@ const itemsToRender = computed(() => (props.items as unknown[]).slice(cursor.val diff --git a/packages/runtime/src/components/text.vue b/packages/runtime/src/components/text.vue index 15fb65a..66a2255 100644 --- a/packages/runtime/src/components/text.vue +++ b/packages/runtime/src/components/text.vue @@ -4,9 +4,10 @@ import { AppContextKey, TextContextKey } from "../context.ts"; import { assertValidBackgroundColor, assertValidForegroundColor } from "../paint/text-style.ts"; import { textProps } from "./text-props.ts"; -// Internal name != "Text" to avoid vue-tsc self-recursion on the `` host tag. -// The public export name "Text" comes from index.ts. -defineOptions({ name: "TextImpl" }); +// Renders the `` / `` host primitives. The `tui-` prefix +// keeps the host tags out of the component namespace, so the component can take its +// real name "Text" with no vue-tsc self-recursion. Public export wired in index.ts. +defineOptions({ name: "Text" }); const props = defineProps(textProps); const slots = defineSlots<{ default?: () => unknown }>(); @@ -39,15 +40,15 @@ function validate(): true { diff --git a/packages/runtime/src/components/transform.ts b/packages/runtime/src/components/transform.ts index dbff9ca..9bcf03e 100644 --- a/packages/runtime/src/components/transform.ts +++ b/packages/runtime/src/components/transform.ts @@ -71,10 +71,10 @@ const TransformImpl = defineComponent({ // When screen reader is enabled and accessibilityLabel is set, // render the label text instead of children. if (isScreenReaderEnabled && props.accessibilityLabel) { - return h("transform", { transform: props.transform }, props.accessibilityLabel); + return h("tui-transform", { transform: props.transform }, props.accessibilityLabel); } - return h("transform", { transform: props.transform }, children); + return h("tui-transform", { transform: props.transform }, children); }; }, }); diff --git a/packages/runtime/src/host/layout-guards.ts b/packages/runtime/src/host/layout-guards.ts index 57dcbbe..6efcc22 100644 --- a/packages/runtime/src/host/layout-guards.ts +++ b/packages/runtime/src/host/layout-guards.ts @@ -8,20 +8,20 @@ type ContainerWithChildren = TuiRoot | TuiBox | TuiText | TuiStatic | TuiTransfo function hasYoga(node: TuiNode): node is YogaCarrier { return ( node.type === "root" || - node.type === "box" || - node.type === "text" || - node.type === "static" || - node.type === "transform" + node.type === "tui-box" || + node.type === "tui-text" || + node.type === "tui-static" || + node.type === "tui-transform" ); } function hasChildren(node: TuiNode): node is ContainerWithChildren { return ( node.type === "root" || - node.type === "box" || - node.type === "text" || - node.type === "static" || - node.type === "transform" + node.type === "tui-box" || + node.type === "tui-text" || + node.type === "tui-static" || + node.type === "tui-transform" ); } @@ -60,7 +60,7 @@ function applyZeroContentGuards(node: TuiNode, guarded: Map): if (hasYoga(node) && node.yoga.getDisplay() === Yoga.DISPLAY_NONE) return false; let changed = false; - if (node.type === "box") { + if (node.type === "tui-box") { const inner = getBoxInnerSize(node); if (inner.width === 0 || inner.height === 0) { for (const child of node.children) { diff --git a/packages/runtime/src/host/node-ops.ts b/packages/runtime/src/host/node-ops.ts index fb1f079..33be098 100644 --- a/packages/runtime/src/host/node-ops.ts +++ b/packages/runtime/src/host/node-ops.ts @@ -97,7 +97,11 @@ function findRoot(node: TuiNode): TuiRoot | null { function isInsideTextOrTransformContext(node: TuiContainer): boolean { let current: TuiContainer | null = node; while (current) { - if (current.type === "text" || current.type === "virtual-text" || current.type === "transform") + if ( + current.type === "tui-text" || + current.type === "tui-virtual-text" || + current.type === "tui-transform" + ) return true; current = current.parent; } @@ -121,8 +125,8 @@ function isInsideTextOrTransformContext(node: TuiContainer): boolean { function findMeasureOwner(start: TuiNode | null): TuiNode | null { let p: TuiNode | null = start; while (p) { - if (p.type === "text") return p; - if (p.type === "transform") { + if (p.type === "tui-text") return p; + if (p.type === "tui-transform") { const inlineInTextContext = p.parent != null && isContainer(p.parent) && @@ -144,12 +148,16 @@ function findMeasureOwner(start: TuiNode | null): TuiNode | null { * directly, no measure func to invalidate). */ function dirtyTextMeasureOwner(parent: TuiNode): void { - if (parent.type !== "text" && parent.type !== "virtual-text" && parent.type !== "transform") { + if ( + parent.type !== "tui-text" && + parent.type !== "tui-virtual-text" && + parent.type !== "tui-transform" + ) { return; } const owner = findMeasureOwner(parent); - if (owner?.type === "transform") markTransformDirty(owner); - else if (owner?.type === "text") markTextDirty(owner); + if (owner?.type === "tui-transform") markTransformDirty(owner); + else if (owner?.type === "tui-text") markTextDirty(owner); } export function buildNodeOps(options: TtyRendererOptions): RendererOptions { @@ -157,25 +165,25 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions line); // overwritten by patchProp attachYoga(n); return n; @@ -208,9 +216,9 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions or another ) does NOT, so we must keep // climbing to the enclosing /standalone-transform measure owner. (G58) const owner = findMeasureOwner(node.parent as TuiNode | null); - if (owner?.type === "text") { + if (owner?.type === "tui-text") { markTextDirty(owner); - } else if (owner?.type === "transform") { + } else if (owner?.type === "tui-transform") { markTransformDirty(owner); } onCommit(); @@ -221,7 +229,7 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions is a text context here (G58), so we use the transform-aware // context check to mirror Ink exactly. (G58 should-fix) - if (child.type === "box" && isInsideTextOrTransformContext(parentC)) { + if (child.type === "tui-box" && isInsideTextOrTransformContext(parentC)) { throw new Error(" can’t be nested inside component"); } @@ -248,7 +256,7 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions component`); @@ -288,7 +296,7 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions string; } onCommit(); return; } - if (el.type === "static" && key === "internal_onWritten") { + if (el.type === "tui-static" && key === "internal_onWritten") { // Callback the renderer invokes post-commit to advance the // component's cursor so written items unmount. Not styling/layout. el.onWritten = typeof next === "function" ? (next as () => void) : undefined; onCommit(); return; } - if (el.type === "box" || el.type === "text" || el.type === "static" || el.type === "root") { + if ( + el.type === "tui-box" || + el.type === "tui-text" || + el.type === "tui-static" || + el.type === "root" + ) { if (isYogaProp(key)) { applyYogaProp(el, key, next, prev); // Some yoga props also need to be stored in el.props for the paint pass. @@ -402,12 +415,12 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions }).props[key] = next; } else if (key === "aria-role" || key === "ariaRole") { - if (el.type === "box") { + if (el.type === "tui-box") { el.internal_accessibility ??= {}; el.internal_accessibility.role = next as string; } } else if (key === "aria-state" || key === "ariaState") { - if (el.type === "box") { + if (el.type === "tui-box") { el.internal_accessibility ??= {}; el.internal_accessibility.state = next as Record; } @@ -429,7 +442,7 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions)[key] = next; onCommit(); } diff --git a/packages/runtime/src/host/nodes.test.ts b/packages/runtime/src/host/nodes.test.ts index 72d4904..55ab510 100644 --- a/packages/runtime/src/host/nodes.test.ts +++ b/packages/runtime/src/host/nodes.test.ts @@ -4,7 +4,7 @@ import { buildNodeOps } from "./node-ops.ts"; test("createBox returns shape with empty children + paintDirty true", () => { const box = createBox(); - expect(box.type).toBe("box"); + expect(box.type).toBe("tui-box"); expect(box.children).toEqual([]); expect(box.paintDirty).toBe(true); expect(box.parent).toBe(null); @@ -36,7 +36,7 @@ test("setText coerces a non-string value to a string (Ink setTextNodeValue)", () test("createTransform stores its transform function", () => { const fn = (line: string) => line.toUpperCase(); const node = createTransform(fn); - expect(node.type).toBe("transform"); + expect(node.type).toBe("tui-transform"); expect(node.transform).toBe(fn); }); diff --git a/packages/runtime/src/host/nodes.ts b/packages/runtime/src/host/nodes.ts index e1880c0..bd5111d 100644 --- a/packages/runtime/src/host/nodes.ts +++ b/packages/runtime/src/host/nodes.ts @@ -40,7 +40,7 @@ export interface TuiRoot extends NodeBase { } export interface TuiBox extends NodeBase { - type: "box"; + type: "tui-box"; children: TuiNode[]; yoga: YogaNodeRef; props: BoxProps; @@ -52,7 +52,7 @@ export interface TuiBox extends NodeBase { } export interface TuiText extends NodeBase { - type: "text"; + type: "tui-text"; children: TuiInlineNode[]; yoga: YogaNodeRef; props: TextProps; @@ -60,7 +60,7 @@ export interface TuiText extends NodeBase { } export interface TuiVirtualText extends NodeBase { - type: "virtual-text"; + type: "tui-virtual-text"; // A / directly inside a standalone renders inline, // so a virtual-text can also be parented by a transform (G58). parent: TuiText | TuiVirtualText | TuiTransform | null; @@ -83,7 +83,7 @@ export interface TuiComment extends NodeBase { } export interface TuiStatic extends NodeBase { - type: "static"; + type: "tui-static"; children: TuiNode[]; yoga: YogaNodeRef; props: BoxProps; @@ -109,7 +109,7 @@ export interface TuiStatic extends NodeBase { } export interface TuiTransform extends NodeBase { - type: "transform"; + type: "tui-transform"; children: TuiNode[]; yoga: YogaNodeRef; transform: (line: string, lineIndex: number) => string; @@ -154,7 +154,7 @@ export function emitLayoutListeners(root: TuiRoot): void { export function createBox(): TuiBox { return { - type: "box", + type: "tui-box", parent: null, children: [], yoga: UNATTACHED_YOGA, @@ -165,7 +165,7 @@ export function createBox(): TuiBox { export function createText(): TuiText { return { - type: "text", + type: "tui-text", parent: null, children: [], yoga: UNATTACHED_YOGA, @@ -175,7 +175,7 @@ export function createText(): TuiText { export function createVirtualText(): TuiVirtualText { return { - type: "virtual-text", + type: "tui-virtual-text", parent: null, children: [], props: {}, @@ -197,7 +197,7 @@ export function createTextLeaf(value: string): TuiTextLeaf { export function createStatic(): TuiStatic { return { - type: "static", + type: "tui-static", parent: null, children: [], yoga: UNATTACHED_YOGA, @@ -208,7 +208,7 @@ export function createStatic(): TuiStatic { export function createTransform(fn: (line: string, lineIndex: number) => string): TuiTransform { return { - type: "transform", + type: "tui-transform", parent: null, children: [], yoga: UNATTACHED_YOGA, diff --git a/packages/runtime/src/host/text-measure.ts b/packages/runtime/src/host/text-measure.ts index a1d96c9..7db30f4 100644 --- a/packages/runtime/src/host/text-measure.ts +++ b/packages/runtime/src/host/text-measure.ts @@ -69,10 +69,10 @@ function squashTransformChild(child: TuiNode, index: number): string { if (child.type === "text-leaf") { return child.value; } - if (child.type === "virtual-text" || child.type === "text") { + if (child.type === "tui-virtual-text" || child.type === "tui-text") { return flattenLeaves(child); } - if (child.type === "transform") { + if (child.type === "tui-transform") { let innerText = ""; // Recursive twin of the G52 fix in flattenLeaves: a grandchild's positional // index must skip Vue comment nodes (null/v-if/false renders) so a `{null}` diff --git a/packages/runtime/src/host/yoga.ts b/packages/runtime/src/host/yoga.ts index f5ed00a..4632f30 100644 --- a/packages/runtime/src/host/yoga.ts +++ b/packages/runtime/src/host/yoga.ts @@ -52,10 +52,10 @@ export const yogaNodeTracker = { function hasYoga(node: TuiNode): node is YogaCarrier { return ( node.type === "root" || - node.type === "box" || - node.type === "text" || - node.type === "static" || - node.type === "transform" + node.type === "tui-box" || + node.type === "tui-text" || + node.type === "tui-static" || + node.type === "tui-transform" ); } @@ -63,7 +63,7 @@ export function attachYoga(node: YogaCarrier): void { node.yoga = createYogaNode(); // Static nodes are painted via a separate channel (paintIsolated), so they // must not occupy space in the dynamic frame's yoga layout. - if (node.type === "static") { + if (node.type === "tui-static") { (node.yoga as YogaNode).setDisplay(Yoga.DISPLAY_NONE); } // Box nodes match Ink's defaults: row direction, shrinkable, no wrap. @@ -71,7 +71,7 @@ export function attachYoga(node: YogaCarrier): void { // are passed through Vue's reactive system (which may include undefined // overrides or border defaults). User-provided props override these via // patchProp which runs after attachYoga. - if (node.type === "box") { + if (node.type === "tui-box") { (node.yoga as YogaNode).setFlexDirection(Yoga.FLEX_DIRECTION_ROW); (node.yoga as YogaNode).setFlexShrink(1); (node.yoga as YogaNode).setFlexWrap(Yoga.WRAP_NO_WRAP); @@ -80,7 +80,7 @@ export function attachYoga(node: YogaCarrier): void { // Text nodes match Ink's defaults: row direction, shrinkable. // Although text nodes rarely have yoga-carrying children, this ensures // consistent layout behavior matching Ink. - if (node.type === "text") { + if (node.type === "tui-text") { (node.yoga as YogaNode).setFlexDirection(Yoga.FLEX_DIRECTION_ROW); (node.yoga as YogaNode).setFlexShrink(1); (node.yoga as YogaNode).setFlexGrow(0); @@ -88,7 +88,7 @@ export function attachYoga(node: YogaCarrier): void { // Transform nodes match Ink's Transform which renders as ink-text: // flexShrink=1, flexDirection='row'. This makes transform a yoga carrier // so it participates in layout (multi-line text gets proper height). - if (node.type === "transform") { + if (node.type === "tui-transform") { (node.yoga as YogaNode).setFlexDirection(Yoga.FLEX_DIRECTION_ROW); (node.yoga as YogaNode).setFlexShrink(1); (node.yoga as YogaNode).setFlexGrow(0); @@ -120,13 +120,15 @@ function yogaIndexFor(parent: TuiContainer, child: TuiNode): number { // ink-text), so a transform child of it is inline and excluded from yoga — // same as for a text/virtual-text parent. (G58 MF2) const isTextParent = - parent.type === "text" || parent.type === "virtual-text" || parent.type === "transform"; + parent.type === "tui-text" || + parent.type === "tui-virtual-text" || + parent.type === "tui-transform"; let yIdx = 0; for (const sibling of parent.children) { if (sibling === child) return yIdx; if (hasYoga(sibling)) { // Transform nodes inside text/transform parents are not in the yoga tree. - if (isTextParent && sibling.type === "transform") continue; + if (isTextParent && sibling.type === "tui-transform") continue; yIdx++; } } @@ -145,14 +147,17 @@ export function insertYogaChild(parent: TuiContainer, child: TuiNode, _domIndex: // because a transform-in-transform inside a was already excluded as a // child of an inline transform here.) // (VirtualText parents are already excluded by the hasYoga check above.) - if (child.type === "transform" && (parent.type === "text" || parent.type === "transform")) { + if ( + child.type === "tui-transform" && + (parent.type === "tui-text" || parent.type === "tui-transform") + ) { return; } // A transform with a yoga-carrying child (e.g. …) lays out // from that child, not from a measure func. Yoga forbids a node having both a // measure func and children, so clear the standalone-transform measure func // before inserting the child. (G58) - if (parent.type === "transform") { + if (parent.type === "tui-transform") { (parent.yoga as YogaNode).unsetMeasureFunc(); } const yIdx = yogaIndexFor(parent, child); @@ -163,14 +168,17 @@ export function removeYogaChild(parent: TuiContainer, child: TuiNode): void { if (!hasYoga(parent) || !hasYoga(child)) return; // Transform nodes inside a text/transform parent were never inserted into yoga // (mirror of insertYogaChild's inline-transform skip). (G58 MF2) - if (child.type === "transform" && (parent.type === "text" || parent.type === "transform")) { + if ( + child.type === "tui-transform" && + (parent.type === "tui-text" || parent.type === "tui-transform") + ) { return; } (parent.yoga as YogaNode).removeChild(child.yoga as YogaNode); // If removing the last yoga child from a transform, restore the inline-text // measure func so the transform can still size its direct text-leaf children // (it has become a standalone inline-text transform again). (G58) - if (parent.type === "transform" && (parent.yoga as YogaNode).getChildCount() === 0) { + if (parent.type === "tui-transform" && (parent.yoga as YogaNode).getChildCount() === 0) { bindTransformMeasure(parent as TuiTransform); } } diff --git a/packages/runtime/src/paint/paint.ts b/packages/runtime/src/paint/paint.ts index a2453d6..c295573 100644 --- a/packages/runtime/src/paint/paint.ts +++ b/packages/runtime/src/paint/paint.ts @@ -404,7 +404,7 @@ function squashInlineChildren(children: readonly TuiNode[], inheritedBg: unknown // with no yoga-carrying children) as if it were an inline text node — its // direct text-leaf / virtual-text / children are squashed into a // string. The transform's OWN fn is intentionally NOT applied here: it is pushed -// as a line-transformer onto the Output write (paintNode "transform" case), so +// as a line-transformer onto the Output write (paintNode "tui-transform" case), so // it applies per LINE at paint time, matching Ink where internal_transform runs // in the Output, never in squashTextNodes for the node it lives on. (G58) function renderTransformAsText(node: TuiTransform, inheritedBg?: unknown): string { @@ -435,10 +435,10 @@ function squashTransformChild(child: TuiNode, index: number, inheritedBg: unknow if (child.type === "text-leaf") { return child.value; } - if (child.type === "virtual-text" || child.type === "text") { + if (child.type === "tui-virtual-text" || child.type === "tui-text") { return renderTextWithInlineStyles(child, inheritedBg); } - if (child.type === "transform") { + if (child.type === "tui-transform") { let innerText = ""; // Recursive twin of the G52 fix in renderTextWithInlineStyles: a grandchild's // positional index must skip Vue comment nodes (null/v-if/false renders), @@ -631,7 +631,7 @@ function paintNode( for (const child of node.children) paintNode(child, output, x0, y0, transformers); return; } - case "box": { + case "tui-box": { const layout = node.yoga.getComputedLayout(); const x = x0 + layout.left; const y = y0 + layout.top; @@ -709,7 +709,7 @@ function paintNode( if (clipped) output.unclip(); return; } - case "text": { + case "tui-text": { const layout = node.yoga.getComputedLayout(); // Thread the INHERITED Box bg (NOT a pre-computed effective bg) into the // squash. The Text's own backgroundColor — including an explicit "" opt-out — @@ -753,12 +753,12 @@ function paintNode( output.write(x0 + layout.left, y0 + layout.top, wrapped, transformers); return; } - case "static": { + case "tui-static": { // Static is rendered through the static channel (written before frame), so // it does not contribute to the dynamic frame paint. return; } - case "transform": { + case "tui-transform": { const layout = node.yoga.getComputedLayout(); const x = x0 + layout.left; const y = y0 + layout.top; @@ -787,7 +787,7 @@ function paintNode( for (const child of node.children) paintNode(child, output, x, y, next, inheritedBg); return; } - case "virtual-text": + case "tui-virtual-text": case "text-leaf": case "comment": // virtual-text and text-leaf are handled inside renderTextWithInlineStyles. diff --git a/packages/runtime/src/paint/screen-reader.ts b/packages/runtime/src/paint/screen-reader.ts index fbcdf81..d77ed48 100644 --- a/packages/runtime/src/paint/screen-reader.ts +++ b/packages/runtime/src/paint/screen-reader.ts @@ -18,10 +18,10 @@ function squashChildSR(child: TuiNode, index: number): string { if (child.type === "text-leaf") { return child.value; } - if (child.type === "virtual-text" || child.type === "text") { + if (child.type === "tui-virtual-text" || child.type === "tui-text") { return squashTextContent(child); } - if (child.type === "transform") { + if (child.type === "tui-transform") { let innerText = ""; // Recurse into the transform's children (each may itself be a transform, // recursed to any depth), skipping Vue comment nodes and empty text-leaves @@ -119,16 +119,16 @@ export interface ScreenReaderOptions { */ export function renderScreenReaderOutput(node: TuiNode, options: ScreenReaderOptions = {}): string { // Skip static elements if requested - if (options.skipStaticElements && node.type === "static") { + if (options.skipStaticElements && node.type === "tui-static") { return ""; } // If display: none, return empty if ( - (node.type === "box" || - node.type === "text" || + (node.type === "tui-box" || + node.type === "tui-text" || node.type === "root" || - node.type === "transform") && + node.type === "tui-transform") && node.yoga.getDisplay() === Yoga.DISPLAY_NONE ) { return ""; @@ -136,14 +136,15 @@ export function renderScreenReaderOutput(node: TuiNode, options: ScreenReaderOpt let output = ""; - if (node.type === "text") { + if (node.type === "tui-text") { output = squashTextContent(node); - } else if (node.type === "box" || node.type === "root") { + } else if (node.type === "tui-box" || node.type === "root") { // Determine separator based on flex direction (resolved from yoga so the // Box default of row yields a space separator, matching Ink — see // resolveBoxFlexDirection / G39). Root keeps undefined → "\n" (Ink's column // default root). - const flexDirection = node.type === "box" ? resolveBoxFlexDirection(node as TuiBox) : undefined; + const flexDirection = + node.type === "tui-box" ? resolveBoxFlexDirection(node as TuiBox) : undefined; const separator = flexDirection === "row" || flexDirection === "row-reverse" ? " " : "\n"; @@ -170,7 +171,7 @@ export function renderScreenReaderOutput(node: TuiNode, options: ScreenReaderOpt ) .filter(Boolean) .join(separator); - } else if (node.type === "transform") { + } else if (node.type === "tui-transform") { // Transform nodes: CONCATENATE children with "" (not newline-join), matching // Ink's squashTextNodes (squash-text-nodes.ts:42, `text += nodeText`). In Ink // a is an `ink-text` node, so the SR path squashes it via @@ -209,7 +210,7 @@ export function renderScreenReaderOutput(node: TuiNode, options: ScreenReaderOpt } // Add accessibility annotations - if (node.type === "box") { + if (node.type === "tui-box") { const accessibility = node.internal_accessibility; if (accessibility) { const { role, state } = accessibility; diff --git a/packages/runtime/src/paint/static-channel.ts b/packages/runtime/src/paint/static-channel.ts index 572e4c4..ee23827 100644 --- a/packages/runtime/src/paint/static-channel.ts +++ b/packages/runtime/src/paint/static-channel.ts @@ -26,7 +26,7 @@ function resolvedFlexDirection(stat: TuiStatic): string { } export function findStatics(root: TuiNode, out: TuiStatic[] = []): TuiStatic[] { - if (root.type === "static") out.push(root); + if (root.type === "tui-static") out.push(root); if (root.type !== "text-leaf" && root.type !== "comment") { const containerChildren = (root as { children: TuiNode[] }).children; for (const child of containerChildren) findStatics(child, out); diff --git a/packages/runtime/vite.config.ts b/packages/runtime/vite.config.ts index a8a53fa..5f762e8 100644 --- a/packages/runtime/vite.config.ts +++ b/packages/runtime/vite.config.ts @@ -3,7 +3,11 @@ import vueJsx from "@vitejs/plugin-vue-jsx"; import Vue from "unplugin-vue/rolldown"; import VueVite from "unplugin-vue/vite"; -const HOST_TAGS = ["box", "text", "virtual-text", "static", "transform"]; +// Host primitive tags carry a `tui-` prefix (mirroring Ink's `ink-box`/`ink-text`): +// it keeps the renderer's intrinsic elements out of the component namespace so a +// template `` never collides with the public `` component (no vue-tsc +// self-recursion). The hyphen also makes them valid custom-element names. +const HOST_TAGS = ["tui-box", "tui-text", "tui-virtual-text", "tui-static", "tui-transform"]; export default defineConfig({ // `VueVite` parses `.vue` SFCs in the TEST/dev graph (unit tests may import the