* fix(runtime): margin/padding edge removal falls back to the surviving shorthand
Withdrawing a per-edge/axis margin or padding override from a box that still
has a broader shorthand collapsed the edge to 0 instead of falling back. E.g.
`margin={5} marginTop={8}` with marginTop later removed: the setter ran
setMargin(EDGE_TOP, 0), and per yoga edge precedence EDGE_TOP=0 overrides the
surviving EDGE_ALL=5, so the top margin became 0 (the box jumps 5 cells) when
the declarative model (render = f(current props), current = {margin:5}) says 5.
A single per-prop yoga setter can't reconcile an edge that depends on the
specific edge + axis + all-edges shorthand together.
Fix mirrors the existing reconcileBorderEdges pattern: the 14 margin/padding
setters become no-ops, and reconcileMarginEdges/reconcilePaddingEdges recompute
all four physical edges from the box's full el.props with most-specific-wins
precedence (top = marginTop ?? marginY ?? margin ?? 0, ...), zeroing the
composite edges so nothing layers on top. A present-but-non-finite value
(NaN/Infinity) or a withdrawn prop falls THROUGH to the next precedence level,
preserving yoga's prior setMargin(NaN)->fallback behavior; an explicit 0 is
finite and still overrides. margin keeps EDGE_START/END and padding keeps
EDGE_LEFT/RIGHT for left/right, matching the prior setters.
Verified against real yoga-layout@3.2.1 that the SET path produces identical
computed edges as the old per-setter code (no layout regression) across all
combinations and patch orders, and the correct fallback on removal.
This is NOT an Ink-parity item: run against Ink v7.0.4, Ink and pre-fix vue-tui
both collapse to 0 (the identical bug). The fix diverges from Ink by being
declaratively correct under the already-documented G19 reset principle;
recorded in ink-divergences.md alongside the display / flexDirection entries.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(runtime): pin margin/padding spacing to a finite-number contract (Codex review)
The family recompute resolves an edge from a prop only when it is a finite
number (matching the `number` prop type + Ink's number-only spacing); numeric
strings (`margin="5"`) are coerced for Vue static-template ergonomics, but other
non-numeric values (`"50%"`, junk, `""`) are treated as not-set and fall through
to the surviving shorthand instead of being forwarded to yoga.
This makes intentional the behavior change the final review flagged: the OLD
per-setter code incidentally forwarded off-contract strings to yoga (so
`marginTop="50%"` became a percent and `marginTop="foo"` threw). That was
undocumented and non-Ink. Also excludes "" from the present() check so all
non-numeric strings fall through uniformly (Number("")===0 would otherwise
resolve to 0). The numeric/numeric-string SET path is unchanged (re-verified
across all 5040 patch orders).
Tests pin the contract (numeric, numeric-string, "50%"/"foo"/"" fall-through,
NaN/withdrawn fall-through, explicit 0), and ink-divergences.md records it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
calculateLayoutWithContentGuards hides zero-content nodes (setDisplay
DISPLAY_NONE, prior display recorded in `guarded`) inside its for(;;)
loop, but only returns the restore closure on the normal path. If a
later loop iteration's calculateLayout — or a measure func it invokes —
throws after an earlier iteration already hid one or more nodes, the
throw propagated before the closure was handed back, leaving those nodes
DISPLAY_NONE on the live yoga tree. On the next commit
applyZeroContentGuards short-circuits any already-DISPLAY_NONE node, so
they were never un-hidden and the subtree stayed permanently invisible
even after the offending input was removed. The callers wrap the
RETURNED closure in try/finally, which cannot help because the closure
was never returned.
Wrap the loop so any exception restores everything currently in
`guarded` (reverse order, same as the success closure) before
re-throwing, leaving the live yoga tree clean. The original error
propagates unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each vite:beforeUpdate scheduled an unconditional setTimeout to reset the dev
status from "update" back to "ok" after 2s, but never stored or cleared the
handle. Rapid successive updates stacked independent timers; an earlier
update's timer firing while a later update was still showing would reset the
newer status line early (its guard only checked type === "update", which is
still true for the newer update).
Track the pending timer in a module-level variable, clear it at the top of
vite:beforeUpdate before scheduling a new one (so only the latest update's
timer is ever live), and clear it on vite:error (an error supersedes a pending
update->ok reset). Also unref() the timer so it doesn't hold the event loop
open; .unref is optional since the DOM number handle lacks it.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
initHmrBridge registered three Vite HMR listeners (vite:error,
vite:beforeUpdate, vite:beforeFullReload) with no idempotency guard and is
called once per createApp() (dev block in render.ts). createApp() can run
multiple times in one dev process — two apps, an app that unmounts and is
re-created, a tool that restarts the UI, or a test run — and Vite's Node HMR
runtime APPENDS listeners with no dedup, so N calls leaked N copies of every
handler permanently. Every later HMR event then ran each handler N times.
Add a module-level boolean guard so the listeners register at most once for
the module's lifetime, regardless of how many times initHmrBridge is called.
Also parameterize the hot context (defaulting to import.meta.hot) so the body
is reachable under vitest, where import.meta.hot is undefined. HotContext is a
local structural type and import.meta.hot is read via a structural cast so the
module type-checks even when imported directly from runtime-tests, whose
tsconfig doesn't pick up env.d.ts's ambient ImportMeta.hot augmentation.
Out of scope: the setTimeout stale-timer in the vite:beforeUpdate handler is a
separate bug left exactly as-is.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Regression tests for behaviors the audit found correct-but-unpinned, so the
suite is a strict superset of Ink:
- A07: two <Static> regions both render (the additive divergence)
- B04: Static render-prop index = absolute index across appends; container
vertical padding adds blank rows to the static frame
- B11: lazy raw-mode acquire/release under rawMode:'auto' (the path the
'always' default masks)
- B19: child useCursor unmount emits the cursor-hide escape (stream-level)
- B20: animation interval 0/negative clamps to 1ms (normalizeInterval unit) and
advances without busy-hang
- B21/B28: INK_SCREEN_READER env auto-detection + useIsScreenReaderEnabled
true-path (env tests isolated in a *.sequential file per the global-state rule)
- B29: renderToString serves useCursor/usePaste/useTerminalSize/useAnimation/
useBoxMetrics as inert no-ops (don't throw)
- B30: dedicated columnGap/rowGap props + their removal-reset
Test-only; no production changes. Codex-reviewed for non-vacuousness, Ink
correctness, and process-global isolation.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
is-in-ci feeds the `interactive` default (render.ts:503) and shouldSynchronize
(write-synchronized.ts:9). vue pinned ^1.0.0 vs Ink's ^2.0.0, whose CI-detection
formula differs: v1 scans for any `CI_*`-prefixed var and gates the whole expression
on CI not being falsy; v2 independently checks `CI` and `CONTINUOUS_INTEGRATION` and
drops the prefix scan. The common cases (local CI=false, GitHub CI=true) are identical
on both, so the suite and CI are unaffected; only edge env configs diverge. Bumping to
^2.0.0 makes vue's CI detection byte-identical to Ink's. Locked by a test
(CI=false + CONTINUOUS_INTEGRATION=true → true, which v1 would report false).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
normalizeInterval rounded the interval (Math.round), so a 60fps interval (16.67ms)
became 17ms and 8.4ms became 8ms — drifting frame=floor(elapsed/interval) and the
scheduler's nextDueTime over time. Ink's normalizeAnimationInterval
(use-animation.ts:147-151) does not round. Removed Math.round; the clamp
(>=1, <=MAX_TIMER_INTERVAL) is unchanged and the scheduler already ceil()s the
setTimeout delay so a fractional interval doesn't busy-loop.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vue-tui left `display` out of RESETTABLE_PROPS, so a removed/undefined `display` persisted
its prior value (a removed display="none" stayed hidden). Adds `display` to RESETTABLE_PROPS
-- the setter already maps undefined -> DISPLAY_FLEX -- so a withdrawn `display` returns to
the Box default (visible), per render = f(current props), like flexDirection/flexWrap (G19).
Deliberate, documented divergence from Ink (which hides on a present-undefined `display` via
DISPLAY_NONE, and persists on omitted) -- recorded in ink-divergences.md. The reset is
consistent across the visual and screen-reader paths (both read yoga's display state).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(runtime): reset yoga props to default on dynamic removal (Ink parity, G19)
Removed style props now reset to the yoga default (margin/padding/min/gap/flexGrow→0, flexShrink→1, flexBasis→auto, flexDirection→ROW, flexWrap→NO_WRAP, alignItems→STRETCH, alignSelf→AUTO, justifyContent→FLEX_START, position→RELATIVE) instead of keeping a stale value — matches Ink's reconciler diff + styles.ts.
The fix threads the previous prop value (prev) from patchProp into applyYogaProp so that resets only fire on genuine removals (prev is a real value, not null/undefined from Vue's initial-mount or never-set patches). RESETTABLE_PROPS is extended with all newly resettable keys.
Follow-up blocker fixes:
- marginX/marginY/paddingX/paddingY now map to Yoga.EDGE_HORIZONTAL/EDGE_VERTICAL (matching Ink styles.ts) instead of concrete EDGE_START/END/TOP/BOTTOM. They compose with the specific edges per yoga precedence, so removing an axis shorthand no longer clobbers a surviving marginLeft/etc.
- applyYogaProp and all setters now treat null the same as undefined (value == null) for the removal/reset path. Vue's host renderer passes next=null (not undefined) when a key disappears from a spread props object (e.g. Static spreads style into host props), which previously bypassed the reset and forwarded raw null into yoga (NaN/0 corruption).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(parity): ledger — G19 pr-open, reconcile G18 merged
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Enable sequence.concurrent: true in vite.config.ts so the non-PTY suite runs
concurrently like the PTY suite. Stress-verified stable (8/8 at maxForks=4);
the suite drops from ~13s to ~4-5s.
Three test patterns were incompatible with concurrency; handled per cause:
- Inline snapshots (background-color, borders): the module-level `expect`
loses snapshot test context under concurrency. Fixed in place by using the
context-local `expect` (async ({ expect }) => ...), so they stay concurrent.
- Process-global state (throttle/animation-scheduler use fake timers; leak
asserts on process exit/SIGINT listener counts and live yoga nodes): a
concurrent sibling clobbers the shared global mid-test. These genuinely
require serial execution, so they move to *.sequential.test.* files with
it.sequential / describe.sequential and a header explaining why.
`vp run ready` passes.