fix(runtime): nest Text ANSI styles per-style in Ink's order (Ink parity, G68) (#65)
Ink's Text.tsx transform applies each enabled style as a SEPARATE nested chalk wrap, in the exact order dim -> color -> backgroundColor -> bold -> italic -> underline -> strikethrough -> inverse. vue-tui's applyChalk built ONE chained ChalkInstance (color -> bg -> dim -> bold -> ...) and invoked it once, producing a different, non-Ink byte sequence for any multi-style Text: e.g. color+bold emitted [31m[1mX[22m[39m vs Ink's [1m[31mX[39m[22m, and dim+bold dropped the bold re-open after dim's SGR-22 reset. Rewrite applyChalk to mirror Ink: apply each style as its own nested chalk(...) call in Ink's order, reusing the existing color/background resolution. Chalk level handling (FORCE_COLOR / level 0 -> no codes) is preserved and ANSI codes remain zero-width, so styled-text measurement is unchanged. Multi-style Text now produces byte-identical ANSI to Ink. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,17 +6,18 @@
|
||||
|
||||
## Sweep history
|
||||
|
||||
| sweep | Ink SHA | candidates → confirmed | status |
|
||||
| -------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
|
||||
| sweep-1 (2026-05-29) | `40b3a75` | 16 verified → 14 confirmed (2 refuted) | recorded |
|
||||
| sweep-2 (2026-05-30) | `40b3a75` | 8 confirmed (re-audit after 16 fixes merged) → 6 new gaps (G18-G23) + 1 candidate (G24) | recorded |
|
||||
| sweep-3 (2026-05-30) | `40b3a75` | 8 confirmed (re-audit, 22 fixes merged) → 7 new gaps (G25-G31, all LOW) + 1 refuted | recorded |
|
||||
| sweep-4 (2026-05-30) | `40b3a75` | confirmation re-audit → 7 new (2 MEDIUM: G32-G33; 5 low: G34-G38) + 2 refuted-reversed | recorded |
|
||||
| sweep-5 (2026-05-30) | `40b3a75` | final re-audit (G32+G33 merged) → 6 cand → 5 confirmed: 1 MEDIUM (G39 SR default-Box separator) + 4 LOW (G40-G43) | recorded |
|
||||
| sweep-6 (2026-05-30) | `40b3a75` | re-audit (G39 merged) → 9 cand → 8 confirmed: **3 MEDIUM (G44 Static layout-style dropped, G45 programmatic focus-while-disabled, G46 non-empty SR-frame trailing newline) + 5 LOW (G47-G51)** | recorded |
|
||||
| sweep-7 (2026-05-30) | `40b3a75` | re-audit (G44/G45/G46 merged) → 6 confirmed: **1 MEDIUM (G52 Transform index shift from comment-node siblings) + 5 LOW (G53-G57)** | recorded |
|
||||
| sweep-8 (2026-05-30) | `40b3a75` | re-audit (G52 merged) → 5 confirmed (3 refuted): **2 MEDIUM (G58 standalone-Transform bare-text dropped, G59 SR frame routes through interactive clearTerminal/static path) + 3 LOW (G60-G62)** | recorded |
|
||||
| sweep-9 (2026-05-30) | `40b3a75` | re-audit (G58/G59 merged) → 8 cand → 4 confirmed (4 refuted): **2 MEDIUM (G63 transform-then-clip order, G64 Static forces terminal width vs Ink content-size) + 2 LOW (G65-G66)** | recorded |
|
||||
| sweep | Ink SHA | candidates → confirmed | status |
|
||||
| --------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- |
|
||||
| sweep-1 (2026-05-29) | `40b3a75` | 16 verified → 14 confirmed (2 refuted) | recorded |
|
||||
| sweep-2 (2026-05-30) | `40b3a75` | 8 confirmed (re-audit after 16 fixes merged) → 6 new gaps (G18-G23) + 1 candidate (G24) | recorded |
|
||||
| sweep-3 (2026-05-30) | `40b3a75` | 8 confirmed (re-audit, 22 fixes merged) → 7 new gaps (G25-G31, all LOW) + 1 refuted | recorded |
|
||||
| sweep-4 (2026-05-30) | `40b3a75` | confirmation re-audit → 7 new (2 MEDIUM: G32-G33; 5 low: G34-G38) + 2 refuted-reversed | recorded |
|
||||
| sweep-5 (2026-05-30) | `40b3a75` | final re-audit (G32+G33 merged) → 6 cand → 5 confirmed: 1 MEDIUM (G39 SR default-Box separator) + 4 LOW (G40-G43) | recorded |
|
||||
| sweep-6 (2026-05-30) | `40b3a75` | re-audit (G39 merged) → 9 cand → 8 confirmed: **3 MEDIUM (G44 Static layout-style dropped, G45 programmatic focus-while-disabled, G46 non-empty SR-frame trailing newline) + 5 LOW (G47-G51)** | recorded |
|
||||
| sweep-7 (2026-05-30) | `40b3a75` | re-audit (G44/G45/G46 merged) → 6 confirmed: **1 MEDIUM (G52 Transform index shift from comment-node siblings) + 5 LOW (G53-G57)** | recorded |
|
||||
| sweep-8 (2026-05-30) | `40b3a75` | re-audit (G52 merged) → 5 confirmed (3 refuted): **2 MEDIUM (G58 standalone-Transform bare-text dropped, G59 SR frame routes through interactive clearTerminal/static path) + 3 LOW (G60-G62)** | recorded |
|
||||
| sweep-9 (2026-05-30) | `40b3a75` | re-audit (G58/G59 merged) → 8 cand → 4 confirmed (4 refuted): **2 MEDIUM (G63 transform-then-clip order, G64 Static forces terminal width vs Ink content-size) + 2 LOW (G65-G66)** | recorded |
|
||||
| sweep-10 (2026-05-30) | `40b3a75` | re-audit (G63/G64 merged) → 8 cand → 7 confirmed (1 refuted): **3 actionable MEDIUM (G68 Text ANSI nesting order, G69 Static throttled vs immediate, G70 non-interactive SR hard-wrap) + G67 (refuted on codex review — unavoidable Vue-vs-React semantic) + 3 LOW (G71-G73)** | recorded |
|
||||
|
||||
Refuted (NOT gaps): ~~`exit()` second-wins — vue-tui is already guarded~~ **REVERSED by sweep-4: it IS last-wins vs Ink first-wins → now tracked as G33**; kitty key-release printable-text suppression — Ink behaves the same.
|
||||
|
||||
@@ -25,6 +26,8 @@ Refuted (NOT gaps): ~~`exit()` second-wins — vue-tui is already guarded~~ **RE
|
||||
Non-obvious calls made while fixing gaps, recorded for review in the final report.
|
||||
|
||||
- **2026-05-29 — Conflict policy (set by maintainer):** when an audit gap conflicts with an existing vue-tui test that deliberately asserts non-Ink behavior, align to Ink and rewrite the conflicting test — and record the decision here so it can be reviewed against expectations later.
|
||||
- **2026-05-30 — G68 (Text ANSI nesting order):** Rewrote `applyChalk` (`packages/runtime/src/paint/text-style.ts`) to mirror Ink's `Text.tsx` transform — each enabled style applied as its OWN nested chalk call in Ink's exact order (dim→color→bg→bold→italic→underline→strikethrough→inverse) instead of one chained `ChalkInstance`. Byte sequences verified against /tmp/ink-40b3a75 @ chalk level 1 (color+bold `[1m[31mX[39m[22m`, dim+bold `[1m[2mX[22m[1m[22m`, color+bg `[44m[31mX[39m[49m`). Conflict-policy rewrites (align to Ink): the `text-style.test.ts` "multiple modifiers chain" case asserted the old chained order (`chalk.bold.underline`) → rewritten to nested `chalk.underline(chalk.bold("x"))`; and the `borders.test.tsx` "borderDimColor does not dim styled child Text touching left edge" inline snapshot asserted old chained bytes `[34m[1m…[22m[39m` for `bold color="blue"` → updated to Ink's nested `[1m[34m…[39m[22m` (verified vs Ink). Single-style Text, level-0 (no codes), and styled-text width (ANSI is zero-width) all unchanged.
|
||||
- **2026-05-30 — G67 REFUTED (FLAG for maintainer):** sweep-10 flagged that vue-tui resets `flexDirection`/`flexWrap` to row/nowrap on dynamic prop removal while Ink's host-level `applyFlexStyles` keeps the stale value. On codex review this was REFUTED: Ink's `<Box>` injects `flexDirection:'row'`/`flexWrap:'nowrap'` defaults BEFORE the style spread (Box.tsx), so an OMITTED prop resolves to row/nowrap (the common case) — which vue-tui's G19 reset already matches. Ink only keeps the stale value for an explicit `flexDirection={undefined}` (rare). **Vue cannot distinguish an omitted prop from an explicit `undefined`** (both collapse to the prop default), so vue-tui must pick ONE behavior: it keeps G19's reset-to-default, which matches Ink's COMMON omitted case and is Vue-idiomatic. The residual divergence (explicit `={undefined}` → vue resets, Ink keeps stale) is an UNAVOIDABLE Vue-vs-React semantic, left as-is. Decision: do NOT "fix" G67; keep G19. Surfaced here for maintainer review.
|
||||
- **2026-05-29 — Sequencing:** the P1 gaps G01 (Static unmount), G02 (useAnimation throttle coalescing), G03 (screen-reader render path) are architecturally invasive — they hinge on timing between Vue's reactivity and the scheduler's commit, where a mistake silently drops output. Deferred until the end-to-end pipeline (PR → codex → CI → auto-merge) is validated on a low-risk fix. Started with G04.
|
||||
- **2026-05-29 — G04 (border background):** maintainer confirmed Ink behavior — border glyphs carry NO background unless `borderBackgroundColor` (or a per-edge variant) is set. Removed the `?? bgColor` fallback in `paint.ts` `colorizeEdge`. Tests rewritten to match Ink:
|
||||
- _"wrapped text preserves backgroundColor on every line"_ → _"…on every content line"_: now asserts inner content rows carry the bg while the pure-border first/last rows do not (box height 4→5 so the wrapped text fits without overflowing onto the bottom border).
|
||||
@@ -44,74 +47,81 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor
|
||||
|
||||
`status` ∈ `todo · in-progress · pr-open · merged · blocked · refuted · candidate`. Priority: correctness/behavior first, omissions next.
|
||||
|
||||
| id | area | summary | priority | status | branch | PR |
|
||||
| --- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------- | ---------------------------------------------- | --- |
|
||||
| G01 | static-newline-spacer | Static keeps every already-written item permanently mounted instead of unmounting it | P1 | merged | `fix/parity-static-unmount` | #34 |
|
||||
| G02 | app-exit-instances-animation-sr | useAnimation does not coalesce ticks within the render-throttle window — delta does not 'account for throttled renders' | P1 | merged | `fix/parity-useanimation-throttle` | #35 |
|
||||
| G03 | render-lifecycle-reconciler | Live screen-reader render path is missing; commit() always paints the visual grid | P1 | merged | `fix/parity-sr-render` | #36 |
|
||||
| G04 | box-layout-border | Border edges incorrectly inherit the Box backgroundColor | P2 | merged | `fix/parity-border-bg` | #30 |
|
||||
| G05 | box-layout-border | Borders skipped when content area is 1 cell tall or wide (w<2 / h<2 guard) | P2 | merged | `fix/parity-border-1cell` | #37 |
|
||||
| G06 | text-wrap-transform | Nested <Transform>/<Text> transform fn receives hardcoded index 0 instead of childNode index | P2 | refuted | — | — |
|
||||
| G07 | input-keypress-kitty-paste | Kitty-protocol Ctrl+C triggers app exit in vue-tui but only suppresses the handler in Ink | P2 | kept | — (see ink-parity.md) | — |
|
||||
| G08 | focus | useFocus does not react to changes in the id prop | P2 | merged | `fix/parity-usefocus-id` | #31 |
|
||||
| G09 | stdout-stderr-stdin-size-cursor | External stdout/stderr writes are not wrapped in synchronized-update (BSU/ESU) markers | P2 | merged | `fix/parity-external-bsu` | #39 |
|
||||
| G10 | stdout-stderr-stdin-size-cursor | setRawMode silently no-ops in unsupported environments instead of throwing a descriptive error | P2 | merged | `fix/parity-setrawmode-throw` | #40 |
|
||||
| G11 | render-lifecycle-reconciler | Resize handler does not clear+reset on terminal-width decrease | P2 | merged | `fix/parity-resize-clear` | #41 |
|
||||
| G12 | render-lifecycle-reconciler | Renderer frame width/rows lack terminal-size fallback (only ?? defaults) | P2 | merged | `fix/parity-renderer-size` | #33 |
|
||||
| G13 | box-layout-border | Custom border style objects (BoxStyle) not supported | P3 | merged | `fix/parity-custom-border` | #42 |
|
||||
| G14 | app-exit-instances-animation-sr | No per-stdout instance reuse/guard — two concurrent renderers can compete for the same stdout | P3 | merged | `fix/parity-instance-reuse` | #43 |
|
||||
| G15 | box-layout-border | Vertical border sides not shifted up when borderTop=false (Ink offsetY) — left/right rails mispositioned | P2 | merged | `fix/parity-border-1cell` | #37 |
|
||||
| G16 | box-layout-border | Per-edge borderDimColor=false cannot override general borderDimColor (`\|\| dimAll` vs Ink's `??`) | P3 | merged | `fix/parity-border-dim` | #44 |
|
||||
| G17 | render-lifecycle-reconciler | Screen-reader live-path edges: <Static> still grid-painted (Ink linearizes, skipStaticElements:false) + empty SR frame gets a trailing newline (Ink writes wrapped output directly) | P3 | merged | `fix/parity-sr-edges` | #45 |
|
||||
| G18 | render-lifecycle-reconciler | No signal-based teardown — terminal corrupted on SIGINT/SIGTERM/SIGHUP (Ink signal-exit at mount) | P1 | merged | `fix/parity-signal-teardown` | #47 |
|
||||
| G19 | box-layout-border | Dynamic removal of most yoga style props does not reset to default (stale layout) | P2 | merged | `fix/parity-yoga-reset` | #48 |
|
||||
| G20 | stdout-stderr-stdin-size-cursor | writeToStdout/writeToStderr lack an isUnmounted/teardown guard (post-teardown writes corrupt terminal) | P2 | merged | `fix/parity-write-after-unmount` | #49 |
|
||||
| G21 | text-wrap-transform | Nested <Transform> in <Text> gets hardcoded index 0 vs child sibling position (squash path) | P3 | merged | `fix/parity-transform-index` | #50 |
|
||||
| G22 | app-exit-instances-animation-sr | SR role dedup inherits grandparent role; Ink dedups only vs immediate parent | P3 | merged | `fix/parity-sr-role-dedup` | #51 |
|
||||
| G23 | app-exit-instances-animation-sr | <Transform> under <Box> SR-joins children with newline; Ink concatenates | P3 | pr-open | `fix/parity-sr-transform-concat` | #52 |
|
||||
| G24 | render-lifecycle-reconciler | Renders ALL <Static> nodes; Ink renders only the most-recent staticNode (multi-Static support — kept ) | P3 | kept | — (see ink-parity.md) | — |
|
||||
| G25 | text-wrap-transform | truncate wrap keeps multi-line when each line fits but whole string overflows (Ink collapses via cliTruncate on whole string) | P3 | todo | — | — |
|
||||
| G26 | input-keypress-kitty-paste | useInput suppresses input text on kitty key-RELEASE events; Ink delivers the char on release (REVERSES the sweep-1 refutation) | P3 | todo | — | — |
|
||||
| G27 | stdout-stderr-stdin-size-cursor | releaseRawMode defers input detach + parser reset; Ink stops input synchronously (clearInputState) | P3 | todo | — | — |
|
||||
| G28 | stdout-stderr-stdin-size-cursor | useStdin() leaks internal members; Ink narrows the public return to PublicProps {stdin,setRawMode,isRawModeSupported} | P3 | todo | — | — |
|
||||
| G29 | render-lifecycle-reconciler | useCursor()/setCursorPosition never applied during normal render commits (only after console writes) | P3 | todo | — | — |
|
||||
| G30 | app-exit-instances-animation-sr | SR: nested <Transform> inside a box-level <Transform> drops the INNER transform fn (refines G23) | P3 | todo | — | — |
|
||||
| G31 | app-exit-instances-animation-sr | useAnimation `interval` option is not reactive; Ink re-subscribes+resets when interval changes (cf. G08 id-reactivity) | P3 | todo | — | — |
|
||||
| G32 | text-wrap-transform | <Transform> nested directly inside another <Transform> (in <Text>) is silently DROPPED — paint+measure lose all content | P1 | merged | `fix/parity-transform-nesting` | #54 |
|
||||
| G33 | app-exit-instances-animation-sr | exit() resolves last-call-wins; Ink is first-call-wins (REVERSES the sweep-1 exit()-second-wins refutation) | P1 | merged | `fix/parity-exit-first-wins` | #55 |
|
||||
| G34 | box-layout-border | Box border glyphs + bg fill are subject to ancestor <Transform> transformers; Ink renders chrome with empty transformer list | P3 | todo | — | — |
|
||||
| G35 | static-newline-spacer | <Static> container's own borderStyle/backgroundColor (non-yoga visual style) not painted | P3 | todo | — | — |
|
||||
| G36 | focus | useFocus autoFocus prop not reactive (captured once); Ink re-registers on autoFocus change | P3 | todo | — | — |
|
||||
| G37 | render-lifecycle-reconciler | onRender renderTime metric includes stdout write + static capture, not just paint | P3 | todo | — | — |
|
||||
| G38 | app-exit-instances-animation-sr | useAnimation/scheduler quantizes interval via Math.round; Ink preserves fractional intervals | P3 | todo | — | — |
|
||||
| G39 | app-exit-instances-animation-sr | SR linearization: a default `<Box>` (no flexDirection prop) joins children with `\n`; Ink defaults flexDirection to `row` so it joins with a space (MEDIUM, found sweep-5) | P1 | merged | `fix/parity-sr-default-box-separator` | #56 |
|
||||
| G40 | render-lifecycle-reconciler | Output write CLAMPS chars at grid width (paint.ts offsetX>=width break/continue); Ink writes overflow past the grid (unbounded output.ts loop) — sweep-5 LOW | P3 | todo | — | — |
|
||||
| G41 | render-lifecycle-reconciler | onRender renderTime INCLUDES synchronous stdout-write time; Ink calls onRender before the write — sweep-5 LOW (overlaps G37) | P3 | todo | — | — |
|
||||
| G42 | render-lifecycle-reconciler | Debug mode does NOT re-emit accumulated `<Static>` history each frame — sweep-5 LOW | P3 | todo | — | — |
|
||||
| G43 | render-lifecycle-reconciler | Non-interactive unmount SKIPS the trailing newline when the final frame is empty — sweep-5 LOW | P3 | todo | — | — |
|
||||
| G44 | static-newline-spacer | `<Static style>` layout props (flexDirection/padding/justify/align/gap/margin/width) dropped from isolated paint — only column-default + visual border/color survive (MEDIUM, sweep-6) | P1 | pr-open | `fix/parity-static-layout-style` | #59 |
|
||||
| G45 | focus | Programmatic focusNext()/focusPrevious() are no-ops while focus is disabled; Ink keeps them live (guard belongs only in Tab listener) (MEDIUM, sweep-6) | P1 | pr-open | `fix/parity-programmatic-focus-while-disabled` | #57 |
|
||||
| G46 | render-lifecycle-reconciler | Non-empty multi-line screen-reader frames append a spurious trailing `\n` + off-by-one erase that Ink does not (G17 only handled the empty case) (MEDIUM, sweep-6) | P1 | pr-open | `fix/parity-nonempty-sr-frame-newline` | #58 |
|
||||
| G47 | box-layout-border | Per-edge border toggles (borderTop/Bottom/Left/Right) reserve yoga border space even with no borderStyle set (only on reactive patch) — sweep-6 LOW | P3 | todo | — | — |
|
||||
| G48 | text-wrap-transform | Measure path omits sanitizeAnsi that Ink applies in squashTextNodes (diverges only for private-param/intermediate-byte CSI) — sweep-6 LOW | P3 | todo | — | — |
|
||||
| G49 | stdout-stderr-stdin-size-cursor | restoreLastOutput() uses nullish-coalescing instead of Ink's falsy-OR fallback, dropping the restored trailing newline before the first frame — sweep-6 LOW | P3 | todo | — | — |
|
||||
| G50 | app-exit-instances-animation-sr | useApp's `waitUntilRenderFlush()` not reachable from inside the render tree (intentional minimal surface — cf. allowlist) — sweep-6 LOW | P3 | todo | — | — |
|
||||
| G51 | app-exit-instances-animation-sr | Screen-reader text squash does not sanitize ANSI (transform-injected control sequences leak) — sweep-6 LOW (same family as the squashTextContent note) | P3 | todo | — | — |
|
||||
| G52 | text-wrap-transform | `<Transform>` `index` arg shifts when null/false/v-if comment-node siblings precede it inside `<Text>` — Vue comment nodes occupy positional slots; Ink (React) skips null children (MEDIUM, sweep-7; G21's positional-index fix introduced the precondition) | P1 | merged | `fix/parity-transform-index-comment-skip` | #60 |
|
||||
| G53 | box-layout-border | Removing an explicit display='flex' keeps the Box visible; Ink resolves a removed display to DISPLAY_NONE — sweep-7 LOW | P3 | todo | — | — |
|
||||
| G54 | focus | Duplicate explicit focus id: Ink keeps duplicate focusable entries; vue-tui dedups on add and removes only the first on remove — sweep-7 LOW | P3 | todo | — | — |
|
||||
| G55 | render-lifecycle-reconciler | Debug-mode frame writer suppresses identical consecutive frames; Ink re-emits every debug frame unconditionally — sweep-7 LOW | P3 | todo | — | — |
|
||||
| G56 | app-exit-instances-animation-sr | exit() misclassifies cross-realm Error objects (no [object Error] fallback) → resolves waitUntilExit() instead of rejecting — sweep-7 LOW | P3 | todo | — | — |
|
||||
| G57 | app-exit-instances-animation-sr | useAnimation reset() while isActive=false zeros frame/time/delta; Ink keeps the frozen (paused) values — sweep-7 LOW | P3 | todo | — | — |
|
||||
| G58 | static-newline-spacer | Standalone `<Transform>` with DIRECT text/Newline children (not wrapped in `<Text>`) renders nothing — text silently dropped; Ink's Transform IS an ink-text host so bare-string children render inline (MEDIUM, sweep-8; canonical README pattern) | P1 | pr-open | `fix/parity-standalone-transform-text` | #61 |
|
||||
| G59 | render-lifecycle-reconciler | Interactive SR frames route through renderInteractiveFrame → a tall/overflowing SR frame emits clearTerminal + replays fullStaticOutput + hides cursor; Ink's dedicated SR branch never clears/accumulates/hides (MEDIUM, sweep-8) | P1 | pr-open | `fix/parity-sr-dedicated-write-path` | #62 |
|
||||
| G60 | box-layout-border | String-typed dimension/position values (bare numeric string e.g. width="50") treated as POINT not PERCENT; Ink applies all string dims as percent — sweep-8 LOW | P3 | todo | — | — |
|
||||
| G61 | stdout-stderr-stdin-size-cursor | Bracketed-paste disable write during stdin dispose() lacks Ink's destroyed/writableEnded (canWriteToStdout) guard — sweep-8 LOW | P3 | todo | — | — |
|
||||
| G62 | render-lifecycle-reconciler | resolveExit()/teardown() writable-stream checks omit the writableLength fallback + stdout.writable flag that Ink's getWritableStreamState uses — sweep-8 LOW | P3 | todo | — | — |
|
||||
| G63 | text-wrap-transform | Transform applied to the FULL line THEN horizontally clipped; Ink clips THEN transforms (output.ts), so a width-sensitive transform (gradient / OSC-8 hyperlink) inside an overflowX:hidden box gets the wrong char span (MEDIUM, sweep-9) | P1 | pr-open | `fix/parity-clip-then-transform` | #63 |
|
||||
| G64 | static-newline-spacer | `<Static>` isolated paint forces terminal width; Ink content-sizes the static box (position:absolute, auto width) so flex-fill children (Spacer/flexGrow/justify/align/percent) collapse to content — refines G44's over-correction (MEDIUM, sweep-9) | P1 | pr-open | `fix/parity-static-content-size` | #64 |
|
||||
| G65 | app-exit-instances-animation-sr | SR unchanged-frame skip compares WRAPPED output, not Ink's UNWRAPPED linearization — diverges on resize with identical content — sweep-9 LOW | P3 | todo | — | — |
|
||||
| G66 | app-exit-instances-animation-sr | app.waitUntilRenderFlush() lacks Ink's unmount/unmounting short-circuit to awaitExit() — sweep-9 LOW | P3 | todo | — | — |
|
||||
| id | area | summary | priority | status | branch | PR |
|
||||
| --- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------- | ---------------------------------------------- | --- |
|
||||
| G01 | static-newline-spacer | Static keeps every already-written item permanently mounted instead of unmounting it | P1 | merged | `fix/parity-static-unmount` | #34 |
|
||||
| G02 | app-exit-instances-animation-sr | useAnimation does not coalesce ticks within the render-throttle window — delta does not 'account for throttled renders' | P1 | merged | `fix/parity-useanimation-throttle` | #35 |
|
||||
| G03 | render-lifecycle-reconciler | Live screen-reader render path is missing; commit() always paints the visual grid | P1 | merged | `fix/parity-sr-render` | #36 |
|
||||
| G04 | box-layout-border | Border edges incorrectly inherit the Box backgroundColor | P2 | merged | `fix/parity-border-bg` | #30 |
|
||||
| G05 | box-layout-border | Borders skipped when content area is 1 cell tall or wide (w<2 / h<2 guard) | P2 | merged | `fix/parity-border-1cell` | #37 |
|
||||
| G06 | text-wrap-transform | Nested <Transform>/<Text> transform fn receives hardcoded index 0 instead of childNode index | P2 | refuted | — | — |
|
||||
| G07 | input-keypress-kitty-paste | Kitty-protocol Ctrl+C triggers app exit in vue-tui but only suppresses the handler in Ink | P2 | kept | — (see ink-parity.md) | — |
|
||||
| G08 | focus | useFocus does not react to changes in the id prop | P2 | merged | `fix/parity-usefocus-id` | #31 |
|
||||
| G09 | stdout-stderr-stdin-size-cursor | External stdout/stderr writes are not wrapped in synchronized-update (BSU/ESU) markers | P2 | merged | `fix/parity-external-bsu` | #39 |
|
||||
| G10 | stdout-stderr-stdin-size-cursor | setRawMode silently no-ops in unsupported environments instead of throwing a descriptive error | P2 | merged | `fix/parity-setrawmode-throw` | #40 |
|
||||
| G11 | render-lifecycle-reconciler | Resize handler does not clear+reset on terminal-width decrease | P2 | merged | `fix/parity-resize-clear` | #41 |
|
||||
| G12 | render-lifecycle-reconciler | Renderer frame width/rows lack terminal-size fallback (only ?? defaults) | P2 | merged | `fix/parity-renderer-size` | #33 |
|
||||
| G13 | box-layout-border | Custom border style objects (BoxStyle) not supported | P3 | merged | `fix/parity-custom-border` | #42 |
|
||||
| G14 | app-exit-instances-animation-sr | No per-stdout instance reuse/guard — two concurrent renderers can compete for the same stdout | P3 | merged | `fix/parity-instance-reuse` | #43 |
|
||||
| G15 | box-layout-border | Vertical border sides not shifted up when borderTop=false (Ink offsetY) — left/right rails mispositioned | P2 | merged | `fix/parity-border-1cell` | #37 |
|
||||
| G16 | box-layout-border | Per-edge borderDimColor=false cannot override general borderDimColor (`\|\| dimAll` vs Ink's `??`) | P3 | merged | `fix/parity-border-dim` | #44 |
|
||||
| G17 | render-lifecycle-reconciler | Screen-reader live-path edges: <Static> still grid-painted (Ink linearizes, skipStaticElements:false) + empty SR frame gets a trailing newline (Ink writes wrapped output directly) | P3 | merged | `fix/parity-sr-edges` | #45 |
|
||||
| G18 | render-lifecycle-reconciler | No signal-based teardown — terminal corrupted on SIGINT/SIGTERM/SIGHUP (Ink signal-exit at mount) | P1 | merged | `fix/parity-signal-teardown` | #47 |
|
||||
| G19 | box-layout-border | Dynamic removal of most yoga style props does not reset to default (stale layout) | P2 | merged | `fix/parity-yoga-reset` | #48 |
|
||||
| G20 | stdout-stderr-stdin-size-cursor | writeToStdout/writeToStderr lack an isUnmounted/teardown guard (post-teardown writes corrupt terminal) | P2 | merged | `fix/parity-write-after-unmount` | #49 |
|
||||
| G21 | text-wrap-transform | Nested <Transform> in <Text> gets hardcoded index 0 vs child sibling position (squash path) | P3 | merged | `fix/parity-transform-index` | #50 |
|
||||
| G22 | app-exit-instances-animation-sr | SR role dedup inherits grandparent role; Ink dedups only vs immediate parent | P3 | merged | `fix/parity-sr-role-dedup` | #51 |
|
||||
| G23 | app-exit-instances-animation-sr | <Transform> under <Box> SR-joins children with newline; Ink concatenates | P3 | pr-open | `fix/parity-sr-transform-concat` | #52 |
|
||||
| G24 | render-lifecycle-reconciler | Renders ALL <Static> nodes; Ink renders only the most-recent staticNode (multi-Static support — kept ) | P3 | kept | — (see ink-parity.md) | — |
|
||||
| G25 | text-wrap-transform | truncate wrap keeps multi-line when each line fits but whole string overflows (Ink collapses via cliTruncate on whole string) | P3 | todo | — | — |
|
||||
| G26 | input-keypress-kitty-paste | useInput suppresses input text on kitty key-RELEASE events; Ink delivers the char on release (REVERSES the sweep-1 refutation) | P3 | todo | — | — |
|
||||
| G27 | stdout-stderr-stdin-size-cursor | releaseRawMode defers input detach + parser reset; Ink stops input synchronously (clearInputState) | P3 | todo | — | — |
|
||||
| G28 | stdout-stderr-stdin-size-cursor | useStdin() leaks internal members; Ink narrows the public return to PublicProps {stdin,setRawMode,isRawModeSupported} | P3 | todo | — | — |
|
||||
| G29 | render-lifecycle-reconciler | useCursor()/setCursorPosition never applied during normal render commits (only after console writes) | P3 | todo | — | — |
|
||||
| G30 | app-exit-instances-animation-sr | SR: nested <Transform> inside a box-level <Transform> drops the INNER transform fn (refines G23) | P3 | todo | — | — |
|
||||
| G31 | app-exit-instances-animation-sr | useAnimation `interval` option is not reactive; Ink re-subscribes+resets when interval changes (cf. G08 id-reactivity) | P3 | todo | — | — |
|
||||
| G32 | text-wrap-transform | <Transform> nested directly inside another <Transform> (in <Text>) is silently DROPPED — paint+measure lose all content | P1 | merged | `fix/parity-transform-nesting` | #54 |
|
||||
| G33 | app-exit-instances-animation-sr | exit() resolves last-call-wins; Ink is first-call-wins (REVERSES the sweep-1 exit()-second-wins refutation) | P1 | merged | `fix/parity-exit-first-wins` | #55 |
|
||||
| G34 | box-layout-border | Box border glyphs + bg fill are subject to ancestor <Transform> transformers; Ink renders chrome with empty transformer list | P3 | todo | — | — |
|
||||
| G35 | static-newline-spacer | <Static> container's own borderStyle/backgroundColor (non-yoga visual style) not painted | P3 | todo | — | — |
|
||||
| G36 | focus | useFocus autoFocus prop not reactive (captured once); Ink re-registers on autoFocus change | P3 | todo | — | — |
|
||||
| G37 | render-lifecycle-reconciler | onRender renderTime metric includes stdout write + static capture, not just paint | P3 | todo | — | — |
|
||||
| G38 | app-exit-instances-animation-sr | useAnimation/scheduler quantizes interval via Math.round; Ink preserves fractional intervals | P3 | todo | — | — |
|
||||
| G39 | app-exit-instances-animation-sr | SR linearization: a default `<Box>` (no flexDirection prop) joins children with `\n`; Ink defaults flexDirection to `row` so it joins with a space (MEDIUM, found sweep-5) | P1 | merged | `fix/parity-sr-default-box-separator` | #56 |
|
||||
| G40 | render-lifecycle-reconciler | Output write CLAMPS chars at grid width (paint.ts offsetX>=width break/continue); Ink writes overflow past the grid (unbounded output.ts loop) — sweep-5 LOW | P3 | todo | — | — |
|
||||
| G41 | render-lifecycle-reconciler | onRender renderTime INCLUDES synchronous stdout-write time; Ink calls onRender before the write — sweep-5 LOW (overlaps G37) | P3 | todo | — | — |
|
||||
| G42 | render-lifecycle-reconciler | Debug mode does NOT re-emit accumulated `<Static>` history each frame — sweep-5 LOW | P3 | todo | — | — |
|
||||
| G43 | render-lifecycle-reconciler | Non-interactive unmount SKIPS the trailing newline when the final frame is empty — sweep-5 LOW | P3 | todo | — | — |
|
||||
| G44 | static-newline-spacer | `<Static style>` layout props (flexDirection/padding/justify/align/gap/margin/width) dropped from isolated paint — only column-default + visual border/color survive (MEDIUM, sweep-6) | P1 | pr-open | `fix/parity-static-layout-style` | #59 |
|
||||
| G45 | focus | Programmatic focusNext()/focusPrevious() are no-ops while focus is disabled; Ink keeps them live (guard belongs only in Tab listener) (MEDIUM, sweep-6) | P1 | pr-open | `fix/parity-programmatic-focus-while-disabled` | #57 |
|
||||
| G46 | render-lifecycle-reconciler | Non-empty multi-line screen-reader frames append a spurious trailing `\n` + off-by-one erase that Ink does not (G17 only handled the empty case) (MEDIUM, sweep-6) | P1 | pr-open | `fix/parity-nonempty-sr-frame-newline` | #58 |
|
||||
| G47 | box-layout-border | Per-edge border toggles (borderTop/Bottom/Left/Right) reserve yoga border space even with no borderStyle set (only on reactive patch) — sweep-6 LOW | P3 | todo | — | — |
|
||||
| G48 | text-wrap-transform | Measure path omits sanitizeAnsi that Ink applies in squashTextNodes (diverges only for private-param/intermediate-byte CSI) — sweep-6 LOW | P3 | todo | — | — |
|
||||
| G49 | stdout-stderr-stdin-size-cursor | restoreLastOutput() uses nullish-coalescing instead of Ink's falsy-OR fallback, dropping the restored trailing newline before the first frame — sweep-6 LOW | P3 | todo | — | — |
|
||||
| G50 | app-exit-instances-animation-sr | useApp's `waitUntilRenderFlush()` not reachable from inside the render tree (intentional minimal surface — cf. allowlist) — sweep-6 LOW | P3 | todo | — | — |
|
||||
| G51 | app-exit-instances-animation-sr | Screen-reader text squash does not sanitize ANSI (transform-injected control sequences leak) — sweep-6 LOW (same family as the squashTextContent note) | P3 | todo | — | — |
|
||||
| G52 | text-wrap-transform | `<Transform>` `index` arg shifts when null/false/v-if comment-node siblings precede it inside `<Text>` — Vue comment nodes occupy positional slots; Ink (React) skips null children (MEDIUM, sweep-7; G21's positional-index fix introduced the precondition) | P1 | merged | `fix/parity-transform-index-comment-skip` | #60 |
|
||||
| G53 | box-layout-border | Removing an explicit display='flex' keeps the Box visible; Ink resolves a removed display to DISPLAY_NONE — sweep-7 LOW | P3 | todo | — | — |
|
||||
| G54 | focus | Duplicate explicit focus id: Ink keeps duplicate focusable entries; vue-tui dedups on add and removes only the first on remove — sweep-7 LOW | P3 | todo | — | — |
|
||||
| G55 | render-lifecycle-reconciler | Debug-mode frame writer suppresses identical consecutive frames; Ink re-emits every debug frame unconditionally — sweep-7 LOW | P3 | todo | — | — |
|
||||
| G56 | app-exit-instances-animation-sr | exit() misclassifies cross-realm Error objects (no [object Error] fallback) → resolves waitUntilExit() instead of rejecting — sweep-7 LOW | P3 | todo | — | — |
|
||||
| G57 | app-exit-instances-animation-sr | useAnimation reset() while isActive=false zeros frame/time/delta; Ink keeps the frozen (paused) values — sweep-7 LOW | P3 | todo | — | — |
|
||||
| G58 | static-newline-spacer | Standalone `<Transform>` with DIRECT text/Newline children (not wrapped in `<Text>`) renders nothing — text silently dropped; Ink's Transform IS an ink-text host so bare-string children render inline (MEDIUM, sweep-8; canonical README pattern) | P1 | pr-open | `fix/parity-standalone-transform-text` | #61 |
|
||||
| G59 | render-lifecycle-reconciler | Interactive SR frames route through renderInteractiveFrame → a tall/overflowing SR frame emits clearTerminal + replays fullStaticOutput + hides cursor; Ink's dedicated SR branch never clears/accumulates/hides (MEDIUM, sweep-8) | P1 | pr-open | `fix/parity-sr-dedicated-write-path` | #62 |
|
||||
| G60 | box-layout-border | String-typed dimension/position values (bare numeric string e.g. width="50") treated as POINT not PERCENT; Ink applies all string dims as percent — sweep-8 LOW | P3 | todo | — | — |
|
||||
| G61 | stdout-stderr-stdin-size-cursor | Bracketed-paste disable write during stdin dispose() lacks Ink's destroyed/writableEnded (canWriteToStdout) guard — sweep-8 LOW | P3 | todo | — | — |
|
||||
| G62 | render-lifecycle-reconciler | resolveExit()/teardown() writable-stream checks omit the writableLength fallback + stdout.writable flag that Ink's getWritableStreamState uses — sweep-8 LOW | P3 | todo | — | — |
|
||||
| G63 | text-wrap-transform | Transform applied to the FULL line THEN horizontally clipped; Ink clips THEN transforms (output.ts), so a width-sensitive transform (gradient / OSC-8 hyperlink) inside an overflowX:hidden box gets the wrong char span (MEDIUM, sweep-9) | P1 | merged | `fix/parity-clip-then-transform` | #63 |
|
||||
| G64 | static-newline-spacer | `<Static>` isolated paint forces terminal width; Ink content-sizes the static box (position:absolute, auto width) so flex-fill children (Spacer/flexGrow/justify/align/percent) collapse to content — refines G44's over-correction (MEDIUM, sweep-9) | P1 | merged | `fix/parity-static-content-size` | #64 |
|
||||
| G65 | app-exit-instances-animation-sr | SR unchanged-frame skip compares WRAPPED output, not Ink's UNWRAPPED linearization — diverges on resize with identical content — sweep-9 LOW | P3 | todo | — | — |
|
||||
| G66 | app-exit-instances-animation-sr | app.waitUntilRenderFlush() lacks Ink's unmount/unmounting short-circuit to awaitExit() — sweep-9 LOW | P3 | todo | — | — |
|
||||
| G67 | box-layout-border | ~~flexDirection/flexWrap reset on dynamic removal vs Ink keep-stale~~ **REFUTED (codex, sweep-10):** Ink's `<Box>` injects row/nowrap defaults BEFORE the style spread, so OMITTING the prop → row (common case, which vue-tui's G19 reset already matches); Ink keeps stale ONLY for explicit `flexDirection={undefined}` (rare), which Vue CANNOT distinguish from omission. Keeping stale would break the common case. UNAVOIDABLE Vue-vs-React semantic — KEEP G19's reset-to-default. **FLAG for maintainer.** | P1 | refuted | — | — |
|
||||
| G68 | text-wrap-transform | Text style ANSI nesting/order: vue-tui builds ONE chained chalk (color→bg→dim→bold…); Ink applies each style as a SEPARATE nested wrap in a different order (dim→color→bg→bold…) — bytes diverge for multi-style Text (MEDIUM, sweep-10) | P1 | pr-open | `fix/parity-text-ansi-order` | #65 |
|
||||
| G69 | render-lifecycle-reconciler | `<Static>` output is throttled, not immediate; Ink has an isStaticDirty/onImmediateRender escape hatch that paints freshly-appended Static children synchronously at commit before they can be sliced out (MEDIUM, sweep-10) | P1 | todo | — | — |
|
||||
| G70 | app-exit-instances-animation-sr | Non-interactive (and debug) screen-reader output is hard-wrapped at terminal width; Ink keeps it unwrapped (wrap only in the interactive SR branch) — diverges from vue-tui's own renderToString (MEDIUM, sweep-10) | P1 | todo | — | — |
|
||||
| G71 | focus | useFocusManager().activeId is null (not undefined) when nothing is focused — sweep-10 LOW | P3 | todo | — | — |
|
||||
| G72 | render-lifecycle-reconciler | Layout computation + layout-listener (measureElement/useBoxMetrics) callbacks are throttled, not immediate — sweep-10 LOW | P3 | todo | — | — |
|
||||
| G73 | render-lifecycle-reconciler | Show-cursor escape (\x1b[?25h) written twice on normal interactive unmount — sweep-10 LOW | P3 | todo | — | — |
|
||||
|
||||
## Gap details
|
||||
|
||||
@@ -390,9 +400,10 @@ _area:_ `render-lifecycle-reconciler` · _kind:_ behavior · _severity:_ HIGH ·
|
||||
|
||||
_area:_ `box-layout-border` · _kind:_ behavior · _severity:_ medium · _priority:_ P2 · _(sweep-2)_
|
||||
|
||||
- **Ink:** reconciler diff emits `key: undefined` on removal; styles.ts applies the yoga default (margin/padding/minWidth/flexGrow→0, flexShrink→1, flexBasis→auto, flexDirection→ROW, position→RELATIVE, align/justify→defaults).
|
||||
- **Ink:** reconciler diff emits `key: undefined` on removal; styles.ts applies the yoga default (margin/padding/minWidth/flexGrow→0, flexShrink→1, flexBasis→auto, position→RELATIVE, align/justify→defaults).
|
||||
- **vue-tui:** host/yoga.ts:312 early-returns on `value===undefined` except RESETTABLE_PROPS (width/height/max*/aspectRatio/alignContent/top/right/bottom/left). All margin/padding/minWidth/minHeight/gap/flex*/justify/align/flexDirection/flexWrap/position keep their STALE value when the prop is removed across renders.
|
||||
- **Fix sketch:** when value===undefined for these props, apply the documented yoga default instead of early-returning (extend RESETTABLE_PROPS + a per-prop default map). Tests: remove marginTop/paddingTop/minWidth/gap/flexGrow/justifyContent/position across renders.
|
||||
- **CORRECTION (G67, sweep-10):** `flexDirection`/`flexWrap` are SPECIAL — Ink's `applyFlexStyles` has NO undefined branch for them, so at the host level a `key: undefined` keeps the STALE value (unlike the other flex props which reset). The G19 fix listed `flexDirection→ROW`/flexWrap→NO_WRAP as resets — technically the wrong host-level mechanism, BUT the net behavior still matches Ink for the COMMON case: Ink's `<Box>` injects `flexDirection:'row'`/`flexWrap:'nowrap'` defaults before the style spread, so an OMITTED prop yields `row`/`nowrap` (which vue-tui's reset also yields). Ink only keeps-stale for an explicit `flexDirection={undefined}` (rare), which Vue CANNOT distinguish from omission. So G67 ("make them keep-stale") was REFUTED — it would break the common omitted-prop case. vue-tui keeps G19's reset-to-default for flexDirection/flexWrap; the explicit-`={undefined}` divergence is an unavoidable Vue-vs-React semantic. See G67 row + Decisions log. **FLAG for maintainer.**
|
||||
|
||||
### G20 — writeToStdout/writeToStderr lack an isUnmounted guard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user