@happyvertical/animation
v0.5.0
Published
GSAP-native animated components — expressive SVG characters for GSAP landing pages
Readme
@happyvertical/animation
GSAP-native animated components: expressive SVG characters (Happy — the =>
smiley: expressions, lip-synced speech), the "Happy Vertical" living-wordmark
lockup, a floating chat widget with pluggable conversation backends, a
consent-aware control registry, a spotlight overlay for guided tours, and
scroll stories (pinned scene sequences with a chapter rail and deep links).
Framework-agnostic: gsap (+ gsap/MorphSVGPlugin, gsap/ScrollTrigger) are peer dependencies
resolved from the host page, injected styles live under @layer hv.animation
so plain host CSS always wins, and conversation backends are pluggable
(ChatBackend) — smrt-service backends ship separately in
@happyvertical/smrt-animations.
import { createHappy, createHappyChat } from '@happyvertical/animation';
const happy = createHappy({ target: '#happy', size: 180 });
happy.express('heart');
createHappyChat({ target: document.body }); // mock backend, no server neededPortable photo-cutout rigs
Photo cutouts are a separate, GSAP-free API. A rig is pure JSON with numeric polygon clips and asset ids; it never includes image URLs, SVG/CSS markup, or callbacks. Exported bundles carry sanitized PNG bytes, which lets an editor save a portable configuration without retaining an expiring object URL.
import { mountPhotoCutout, validatePhotoCutoutRig } from '@happyvertical/animation';
validatePhotoCutoutRig(rig);
const character = mountPhotoCutout(rig, {
target: '#character',
resolveAsset: async (assetId, { signal }) => localAssets.get(assetId, signal),
});
character.setMouthOpen(0.35); // host-driven real audio state onlySetup workflows can validate an untrusted silhouette independently of a full
rig: validatePhotoCutoutOutline(points, { width, height, minPoints? })
returns safe plain points and rejects duplicate vertices, zero-area,
self-intersecting, non-finite, and out-of-bounds geometry. For a lower-face
segment, clipPhotoCutoutOutlineBelowLine(points, { width, height, y,
minPoints? }) clips at a horizontal mouth line, preserving eligible contour
vertices and adding only line intersections. It rejects a result that cannot
remain one valid polygon; callers must not replace it with a rectangle.
jaw.maxOpenOffset?: { x, y } adds bounded canvas-coordinate translation at
full mouth-open and composes with the existing maxOpenDegrees rotation. A
split-head rig can use maxOpenDegrees: 0 with a positive y offset. Leave
enough transparent lower canvas/image-frame padding for that translation.
The jaw may share the head silhouette boundary, which preserves a real lower
face contour. Every jaw segment is still checked across concave head geometry,
so a segment that leaves the head is rejected.
Hosts may apply a bounded visual pose with
character.setExpression({ headTiltDegrees, jawTiltDegrees }). Both numeric
values clamp to -20 through 20 degrees. Head tilt carries the head, jaw, mouth,
and accessory layers around the canvas centre; body layers remain anchored.
Jaw tilt applies only while the mouth is open. This is host-driven presentation,
not inferred emotion or lip-sync, and reduced-motion mounts keep the neutral pose.
For example, a speaking pose can combine the rig's jaw.maxOpenDegrees with
character.setExpression({ headTiltDegrees: 4, jawTiltDegrees: 7 }); omitted
fields return to zero. Non-finite values throw.
Version 1 accepts static PNG, JPEG, and WebP source assets up to 10 MiB each; import
is bounded to 32 MiB encoded data and 16 million decoded pixels in aggregate.
It permits at most 16 layers and 256 points per layer. Head and jaw layers are
required; an optional mouth-interior renders behind the jaw. Omit it when an
opened split should reveal the host background transparently. The jaw clip is
removed from the head using an even-odd numeric clip and rotates around its
declared pivot. destroy() aborts pending asset work and revokes all object
URLs. Mounts retain the fallback and hide every mouth interior, including a
solid fill, until every required image texture has sanitized successfully.
A mouth-interior layer may instead use pure solid-color data:
{ kind: 'solid', id: 'mouth', role: 'mouth-interior',
color: { r: 75, g: 31, b: 36 }, clip: mouthPolygon, opacity: 1 }Only mouth interiors accept kind: 'solid'; its RGB channels are integers
from 0 through 255, with no assetId, CSS, or additional fields. It renders as
a clipped fill without allocating texture pixels. Existing image layers keep
their assetId shape and aspect-preserving placement; bundle assets contain
only references required by image layers. This additive form is part of the
unreleased v1 photo-cutout schema.
Image layers optionally carry imageFrame: { x, y, width, height }, a numeric
viewport using the same xMidYMid meet placement as the default canvas. This
lets crops translate compact or shared textures without upsampling their
pixels. Coordinates may be negative; each magnitude is bounded to 16 million,
width/height are positive, and their product is at most 16 million. Unknown
frame fields and non-finite values are rejected; solid layers have no frame.
The codec inspects bounded PNG/JPEG/WebP headers and the remaining aggregate pixel budget before calling the decoder, then verifies decoded dimensions. APNG, animated WebP, conflicting WebP canvas/payload dimensions and unsupported JPEG frame forms fail closed. Both source and sanitized PNG byte totals must fit the aggregate budget. Runtime loading uses this same boundary, resolves and sanitizes each distinct asset ID once, and displays textures only when the whole set succeeds. Failed loads retain the text fallback and release URLs.
Docs, demos, and source: https://github.com/happyvertical/animation
External voice conversations
A host supplying its own realtime audio can use chat.setInputHandler(text => { voice.sendText(text); return true; }) to consume composer and programmatic input. Return false, or remove the handler with setInputHandler(), to use the existing chat backend. A consumed input does not independently add history or invoke the backend: feed the transport's transcript events back through chat.receiveTurn({ id, role, content, final }).
receiveTurn upserts one visible, plain-text bubble and history entry per id. Partial updates replace its content; a final update emits the existing send/reply callback and event once. Late duplicates are ignored. It does not synthesize audio, evaluate inline navigation cues, or call a backend. clear() releases external ids; destroy() removes the input handler and makes late updates fail explicitly. The host owns transport cancellation and reconnect/history policy.
Use chat.happy.setSpeaking(true) while external audio plays and setSpeaking(false) when playback ends or is interrupted. This animates a continuous mouth using the host's GSAP instance, never invokes speech synthesis, and is an activity indicator rather than phoneme alignment. Expression changes preserve the talking mouth. Scripted say() timelines remain unchanged; stopping external playback does not stop a normal scripted sentence. Always stop external playback activity before starting a scripted tour.
