Skip to content

feat: ease Nino's motion like Blobatar (#23) - #49

Merged
ismail9k merged 10 commits into
mainfrom
feat/23-smooth-motion-for-nino-like-blobatar
Oct 26, 2026
Merged

ismail9k merged 10 commits into
mainfrom
feat/23-smooth-motion-for-nino-like-blobatar

Conversation

@ismail9k

Copy link
Copy Markdown
Member

Summary

  • Every Nino motion is eased instead of stepped, with Blobatar's feel as the reference (feel only, no code or dependency): a 200 ms glide for the caller's look, eased blinks, breathing in every mood, an idle glance that holds and then flicks, eased bob/tremor/hop, and eased one-shot actions. No steps() is left in I9kNino.vue. Resting poses stay on the two-unit grid; parts travel between cells only while moving.
  • A mood change is now a two-phase morph (i9k-nino--beat-shut → --beat-open, NINO_BEAT = { swapAt: 120, duration: 300 }). Face parts squash shut, the new face swaps in behind them, and they open. Parts that mount at the swap (brows, hands, cheeks) start shut. While hiding, arms and hands cross-fade with opacity instead of visibility.
  • One template change: a new glance layer inside eyes (data-nino-part="glance"). It carries the idle glance animation while eyes carries the look transition. A transition cannot hand over to an animation created or cancelled in the same style change, so on one element center → down would snap.
  • play() now re-keys the stage only when an action is on screen, or was on screen until less than a frame ago (double requestAnimationFrame, cancelled on the next action and on unmount, with an SSR guard). An action that starts from rest keeps the stage, so hiding cross-fades both ways. A replay chained from the resolved promise still restarts. This is a script-level change beyond the plan. Please read it closely.
  • Motion stays off when animated is false and under prefers-reduced-motion: reduce, where every animated or transitioned selector is repeated with none. No public API change. Durations in NINO_ACTION_DURATIONS are unchanged, so consumers see the same play() timing. AGENTS.md, the story (new MoodMorph), the showcase registry and the tests are updated.

Closes #23

Follow-ups: 9k.school gets this only after it updates @9klabs/design. Once it does, check the sign-in mascot by hand: focus and submit should glide and morph, and no consumer change is needed. Recordings of the motion (nino-mood-morph, nino-actions, nino-hiding-wave, nino-talking, as GIF and MP4) were captured locally and still need to be attached to this PR by hand. The committed stills are in docs/visual-evidence/i9k-nino-smooth-motion/ (light, dark, and RTL dark).

Verification

Run by the dispatcher in the branch's worktree at 5eb46eb:

  • pnpm run check (test, format:check, lint, typecheck, build, build-storybook, build:showcase): exit 0. 77 test files and 1173 tests passed; the showcase wrote 78 machine-readable files for 74 pages.

Reported by the build agent and not re-run by the dispatcher: browser checks in Chromium against the built Storybook. These covered the idle → happy → hiding morph in light and dark, RTL dark (glance direction flips), the ambient loops in every mood, a single 200 ms look transition, zero animations under reduced motion and on the Still story, talking, and play() replays.

Decisions

Made by the agents on the owner's behalf, with the cost if wrong:

  1. Off-grid only while moving. Parts ease between cells; every resting pose stays on the grid; crispEdges stays. If wrong (every frame must be pixel-perfect), the work becomes frame-by-frame sprite animation.
  2. Every motion is eased, including actions, talking and the hiding peek, not only the four the issue names. If wrong, about a day of keyframe work to revert, rule by rule.
  3. Idle Nino moves continuously: breathing, blinks, antenna, idle glance. If wrong, one shorthand per loop.
  4. Blobatar is the reference for feel only. Its curves and durations are adopted: 0.3 s morph, cubic-bezier(0.23, 1, 0.32, 1) to settle, ease-in-out sways, a linear tremor. If wrong, nothing practical: its parametric poses can't drive Nino's path faces.
  5. No hover response; ambient motion is always on, because the sign-in page is used on phones. If wrong, a small additive prop later.
  6. Maze tile movement is out of scope: I9kNinoMaze here and NinoMaze.vue in 9k.school keep steps(2, end). If wrong, one line in each repo.
  7. The eye layer is split into eyes and glance (see Summary). If wrong (template must stay untouched), go back to one element and accept the snap.
  8. A mood change while talking: the beat wins. The talk flap pauses for 300 ms. If wrong, reorder two stylesheet blocks.
  9. The two-phase beat replaces --beat-even/odd. Those classes are removed; the agents found no use of them in this repo, 9k.school or ismail9k.com. If wrong, they are internal class names only.
  10. The 2-unit hop is kept, now eased. If wrong, one keyframe value.
  11. Hiding arms and hands fade with opacity over 120 ms. If wrong, a brief translucent arm; revert to visibility.
  12. Action durations are unchanged. If wrong, nothing.
  13. Two residual settles of 2 units or less are accepted: a stage re-key restarts breathing at rest, and a beat that replaces the thinking bob starts from rest. If wrong, a later smoothing pass.
  14. play() re-keys only when an action is on screen or was less than a frame ago (see Summary). The final review found that hiding did not cross-fade; the first fix broke promise-chained replays, and a second fix round (beyond the build skill's usual one) settled it, with tests and a browser check. If wrong, a replay within one frame of an action ending fails to restart, or a frame callback is left pending after unmount. Both are pinned by tests.
  15. Nod keyframes are regrouped so the plan's own test holds; the motion is identical. Visual-evidence stills are committed under docs/visual-evidence/. If wrong, cosmetic; drop the three PNGs.
  16. Small rest-window wording is left as is: one template comment and one phrase in the spec understate the window by a frame, and the I9kNinoSky test's requestAnimationFrame spy would also see Nino's rest-frame scheduling if a future Sky test lets an action finish. If wrong, one comment line and one spec phrase.

Spec: docs/superpowers/specs/2026-10-26-nino-smooth-motion-design.md · Plan: docs/superpowers/plans/2026-10-26-nino-smooth-motion.md

🤖 Generated with Claude Code

ismail9k and others added 10 commits October 26, 2026 15:27
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Stills from the built Storybook, sampled at one eighth speed so the
300ms morph can be read frame by frame: idle to happy and happy to
hiding in light and dark, and the idle glance and start/end looks on an
RTL page in dark.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
play() re-keyed the stage on every call, so a play() while hiding
inserted fresh arms and hands whose first computed style already had
--acting applied; a transition has no before-change style to start
from, so the hands vanished and the side arms appeared at once instead
of cross-fading. The stage is now re-keyed only when the DOM currently
shows an action, read from a post-flush record of activeAction: a
replay (the same action again, or a cut-off chain from an action-end
handler) still restarts its keyframes at frame 0, while a play() from
rest keeps every element, so hiding cross-fades both ways and the
ambient loops keep their phase.

From the same review: the reduced-motion twin test is keyed by property
family as well as selector; every held keyframe stop is tested against
the two-unit grid again (framesOf drops custom-property names before
reading numbers, since --i9k-nino-dir carries a 9); a comment pass in
I9kNino.vue names both off-grid exceptions once, the three eye layers,
the jump's three lists and the transitioned reduced-motion twins; and
the design spec's wording follows the re-key rule, records the
blink-into-beat one-frame pop and corrects dizzy's rest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A same-action replay chained from play()'s promise (or from nextTick, or a
timer in the same frame) ran after the post-flush watcher had cleared the
rendered-action record but before the browser's next style pass, so the
stage was not re-keyed and the finished animation stayed finished while the
promise still resolved completed: true. The record now clears only in the
second requestAnimationFrame callback after the action ends, once a style
recalculation has seen the class gone: a play() with an action on screen, or
gone for less than a frame, re-keys the stage, and a play() from rest still
keeps every element. Unmount cancels a pending frame. Without
requestAnimationFrame the record clears at once, as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying 9k-design-system with  Cloudflare Pages  Cloudflare Pages

Latest commit: 5eb46eb
Status: ✅  Deploy successful!
Preview URL: https://915fb642.9k-design-system.pages.dev
Branch Preview URL: https://feat-23-smooth-motion-for-ni.9k-design-system.pages.dev

View logs

@ismail9k ismail9k left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No actionable findings: 0 Critical, 0 Important, 0 Minor. Intent pass: the implementation satisfies issue #23's scope and constraints, including eased motion, motion-off handling, documentation, and committed light/dark/RTL visual evidence. Code pass completed using gpt-6-astra, including action replay scheduling and cleanup. Read-only source review; tests, builds, and browser checks were not rerun.

@ismail9k
ismail9k marked this pull request as ready for review October 26, 2026 18:46
@ismail9k
ismail9k merged commit 709a1b4 into main Oct 26, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Smooth motion for Nino, like Blobatar

1 participant