Skip to content

fix(text): char_* presets reach rich_text and gradient_text, plus a letter morph - #402

Merged
LeadcodeDev merged 2 commits into
mainfrom
fix/char-presets-and-text-animation
Sep 27, 2026
Merged

LeadcodeDev merged 2 commits into
mainfrom
fix/char-presets-and-text-animation

Conversation

@LeadcodeDev

Copy link
Copy Markdown
Owner

Closes #363 (the silent-ignore half). Refs #380, #388 — #380 is half-done on purpose, see below.

The fault that mattered

A char_* preset on rich_text or gradient_text was accepted and ignored. The same class as #339, #349 and #364, and the reason it was done before the tuning knobs in the same issue.

The four shared helpers moved out of text.rs into intrinsic.rs verbatim, and both other components now call them.

rich_text walks tokens across span boundaries, so the stagger does not restart at each span, and each unit paints with its own span's font and colour — so ink_from converges to that span's colour rather than a shared default.

mid-reveal soft-pixel fraction (0.151) must be clearly higher than the settled
fraction (0.151) — rich_text must actually blur while animating

span 1 should read close to ink_from (red) early on, got rgb=(0,254.99998,0)

gradient_text had been discarding props entirely (_props in paint_content). The gradient shader is built over the whole laid-out run before any per-unit split, so splitting into words cannot restart the ramp — that is structural, not a flag.

One knob is inert there and it is documented, not hidden: ink_from has no visible effect on gradient_text, because a Skia shader always wins over a Paint's flat colour. The other six work.

text.morph — and how it differs from swap

text.states + swap treats each label as one rigid block that rises and blurs. morph pairs identical characters by longest common subsequence, slides each matched glyph from its old position to its new one, and fades the unmatched ones — or cycles them through a deterministic placeholder sequence with unmatched: "scramble".

It is a separate field rather than a swap.mode because TextSwapConfig lives outside the workstream's files.

⚠️ #380's first half is NOT done, deliberately

Animatable letter-spacing needs a new field on AnimatedProperties in animator.rs, which another workstream owned. The agent refused to add "letter_spacing" to KNOWN_MOTION_PROPERTIES on its own — that alone would make it validate and stay inert, reproducing the exact bug this PR is about.

That was the right call, and it is why #380 stays open.

An honest note about one test

char_animation_never_restarts_the_gradient_ramp_per_unit passes with the fix reverted. It asserts a property that also holds trivially when animation is disabled, so it is a real assertion but not a discriminating regression test. The no-restart claim rests on the code structure, not on that test. Flagged by the agent rather than counted as proof.

A first version of the morph interpolation test had the same flaw — window-based ink detection that coincidentally overlapped the hard cut's own glyphs — and was caught and replaced with a comparative assert_ne!.

Verification

11 new tests. cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace (1679) all clean.

Also here

SKILL.md's preset table said "Char (text only)". That was true before this branch, and it is precisely the sentence a generator reads to decide not to try. Corrected.

Written comment-free, per the codebase-wide rule from #345.

The seven char_* presets were accepted by rich_text and gradient_text
with no schema error, and then simply did nothing: both components
painted the full, settled text from frame 0, silently ignoring
props.char_animation entirely (rich_text never read it; gradient_text
took it as a parameter and discarded it under an underscore). That's
exactly the "declared thing that does nothing" defect this repo treats
as the bug (see #339, #349, #364) — closes #363's first half.

The per-preset paint logic (unit_progress/ink_paint/
apply_text_anim_preset/render_char_animation) moves out of text.rs into
intrinsic.rs as shared, unmodified functions so all three text
components can reuse it instead of re-implementing seven animation
curves three times:

- rich_text: word/char units now walk the token stream across span
  boundaries (stagger doesn't reset per span), each unit painting with
  its own span's font and color, so ink_from converges to that span's
  own color rather than a shared default. Pill backgrounds stay static
  underneath the animated glyphs.
- gradient_text: the linear-gradient shader is still built once over
  the whole laid-out run before any per-unit split, so splitting into
  words/chars never restarts the ramp. ink_from has no visible effect
  here (a shader always wins over a flat Paint color) and is documented
  as a real limitation rather than a silent no-op — the other six knobs
  (position/scale/rotation/blur/alpha) all work.

The tuning knobs from #363's second half (rotate_from, per-unit
scale_jitter/baseline_jitter, animated reflow) are intentionally not
added: they'd need ResolvedCharAnimation (engine/animator.rs) to carry
new fields, and that file is owned by a concurrent workstream.
Similarly, letter_spacing as an animatable property (#380 first half)
needs a new AnimatedProperties field plus get/set wiring in the same
file — also left alone rather than half-wired.

Also implements #380's second half: `text.morph`, a letter-by-plus
transition between `states` labels distinct from `swap`. `swap` (see
text-polish.md) treats a label as one rigid block that rises and
blurs; `morph` pairs identical characters between the outgoing and
incoming label by longest common subsequence (left to right, same
algorithm as a text diff) and slides each matched glyph from its old
position to its new one, while unmatched glyphs fade (or, with
`unmatched: "scramble"`, cycle through deterministic placeholder
characters before settling). Exposed as a new `Text.morph` field
rather than a `swap.mode`, because TextSwapConfig lives in
schema/video.rs outside this change's file ownership (only the char_*
preset structs and KNOWN_MOTION_PROPERTIES there); `TextMorphConfig`/
`TextMorphUnmatched` are defined directly in text.rs instead.

Adds two French rule files matching the existing house style:
rules/char-animation-rich-and-gradient-text.md and rules/text-morph.md.

Adding `Text.morph` needed a new field on every exhaustive `Text { .. }`
literal; two of those live in box_builder.rs's own test module (outside
this change's owned files) and got the one-line `morph: None,` addition
needed to keep the crate compiling — no logic there changed.
…tale 'text only'

The preset table claimed char_* was text-only. That was true before this
branch; it is the sentence a generator reads to decide not to try.
@LeadcodeDev LeadcodeDev added the bug Something isn't working label Sep 27, 2026
@LeadcodeDev LeadcodeDev self-assigned this Sep 27, 2026
@LeadcodeDev
LeadcodeDev merged commit 26b7bcd into main Sep 27, 2026
4 checks passed
@LeadcodeDev
LeadcodeDev deleted the fix/char-presets-and-text-animation branch September 29, 2026 18:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

char_* presets are silently ignored on rich_text and gradient_text, and lack rotation, per-unit jitter and reflow

1 participant