Mascot (v0.2.2)
The FAB circle IS the mascot. It emotes with faces, animates with state, greets first-time visitors as a horizontal capsule, and every asset + timing is themable via CSS variables.
Wire it up
The SDK ships seven default duck sprites in dist/duck/, wired via --dddk-*-url defaults on :root in tokens.css. Basic install just works — you get the mascot without hosting any PNGs yourself.
The seven bundled sprites:
| Variable | Default | Where it's used |
|---|---|---|
--dddk-avatar-url |
./duck/neutral.png |
Subtitle bar avatar, Dwell chip, FAB fallback |
--dddk-swim-url |
./duck/swim-side.png |
Proactive swim indicator, subtitle bar swim relay |
--dddk-hero-url |
./duck/hero-greet.png |
HERO capsule big-reveal sprite |
--dddk-chill-url |
./duck/chill-shades.png |
Dwell frame corner mascot (sunglasses duck) |
--dddk-swim-cycle-url |
./duck/swim-cycle.png |
Dwell top-edge swim spritesheet (8 frames) |
--dddk-brand-mark-url |
./duck/logo.png |
Palette "Powered by dotdotduck" mark |
--dddk-cursor-url |
./duck/cursor.png |
WebAgent synthetic cursor (duck riding a paper airplane) |
For FAB face-swap animations (per-state facial expressions), also wire the face URLs on MobileTrigger — these are not :root variables because they're host-controlled per instance:
new MobileTrigger({
fab: {
alwaysVisible: true,
faces: {
neutral: '/duck/neutral.png',
smile: '/duck/smile.png',
thinking: '/duck/thinking.png',
listening: '/duck/listening.png',
done: '/duck/done.png',
error: '/duck/error.png',
wink: '/duck/wink.png',
'side-left': '/duck/swim-side.png', // used by the HERO capsule
},
},
}).attachTo(dddk);
Overriding a bundled sprite is one line on :root:
:root {
--dddk-avatar-url: url('/my-brand/mascot.png');
--dddk-swim-url: url('/my-brand/swim.png');
/* Keep --dddk-brand-mark-url as-is if you want to keep the dotdotduck
attribution on the palette footer. */
}
Sub-path caveat — Chrome resolves url() in CSS custom properties relative to the DOCUMENT (not the source CSS file) when the variable is applied via a JS-injected <style> (Dwell / palette / cursor style shells all load runtime-injected). If your app is served from a subdirectory like /tools/dddk/, the SDK's default ./duck/… breaks. Fix: override with root-absolute paths on your app's :root — --dddk-swim-cycle-url: url('/my-app/duck/swim-cycle.png');. Same rule applies to --dddk-cursor-url and any other sprite variable read from JS-injected CSS.
State → face → animation
mobile.setState(state) swaps the face and toggles a CSS animation class on the FAB.
| State | Face | Motion |
|---|---|---|
idle |
neutral |
Gentle up-down bob + slight rotate |
thinking |
thinking |
Subtle breath scale + orbit progress arc |
listening |
listening |
Two-ring sonar pulse + warm yellow drop-shadow |
voice-hold |
listening |
Balloon breathe (grows + shrinks while held) |
done |
done |
One-shot pop bounce |
error |
error |
One-shot horizontal shake |
mobile.setFace(slug) swaps face without touching the animation. mobile.celebrate() flashes done for 1.2s.
Hero greeting (capsule)
mobile.showHeroGreeting(text, opts) morphs the FAB circle into a horizontal yellow capsule with the greeting text inside — the duck face slides to the right end, text fills the space on the left.
await mobile.showHeroGreeting(
'Hi! I\'m dotdotduck.',
{
bodyHtml: `
<div style="font-weight:700">Hi! I'm dotdotduck.</div>
<div>Tap me → open the palette. Hold me → talk.</div>
<div style="opacity:.7">Or press <kbd>Ctrl</kbd>+<kbd>K</kbd> · hold <kbd>Space</kbd></div>
`,
ariaLabel: 'Hi, I\'m dotdotduck. Tap to open the palette, hold to talk.',
autoDismissMs: 20000,
},
);
Behavior:
- Click the duck face end → fires the FAB's normal
onTap(palette by default) and dismisses. - Click the text area → passes through (users can highlight / read without accidentally dismissing).
- Click anywhere else on the page → dismisses without opening the palette.
- Auto-dismiss after
autoDismissMs.
Theming — every knob
All CSS custom properties. Set them on :root, or on the FAB element for surface-specific overrides.
Capsule shape + colours
| Variable | Default | Effect |
|---|---|---|
--dddk-fab-hero-width |
min(460px, calc(100vw - 40px)) |
Expanded width |
--dddk-fab-hero-height |
88px |
Capsule height |
--dddk-fab-hero-radius |
44px |
End-cap radius |
--dddk-fab-hero-bg |
warm-yellow linear-gradient | Fill |
--dddk-fab-hero-fg |
#1a2a4a |
Text colour |
--dddk-fab-hero-shadow |
0 12px 32px rgba(255,180,40,0.35), 0 4px 12px rgba(0,0,0,0.08) |
Drop shadow |
--dddk-fab-hero-padding |
12px 90px 12px 22px |
Text-panel padding |
--dddk-fab-hero-face-size |
68px |
Face size inside pill |
--dddk-fab-hero-face-inset |
10px |
Face distance from edge |
--dddk-fab-hero-font |
600 14px/1.45 var(--dddk-font, ...) |
Text font shorthand |
--dddk-fab-hero-gap |
4px |
Gap between text lines |
Timing + motion
| Variable | Default | Effect |
|---|---|---|
--dddk-fab-hero-transition-ms |
520ms |
Morph duration |
--dddk-fab-hero-easing |
cubic-bezier(0.34, 1.4, 0.64, 1) |
Morph easing (spring-out) |
--dddk-fab-hero-content-delay |
220ms |
Text fade-in delay |
Example — brand override on :root:
:root {
--dddk-fab-hero-bg: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
--dddk-fab-hero-fg: #ffffff;
--dddk-fab-hero-width: min(520px, calc(100vw - 32px));
--dddk-fab-hero-height: 76px;
--dddk-fab-hero-radius: 38px;
--dddk-fab-hero-transition-ms: 380ms;
}
Swapping the sprites
You don't have to use our duck. Any transparent-PNG set works — square face tiles for the FAB per-state expressions, side-view for swim + hero, an 8-frame horizontal spritesheet for the Dwell top-edge swim, and a square icon for the WebAgent cursor. Just wire the URLs:
:root {
--dddk-avatar-url: url('/mascot/mine-neutral.png');
--dddk-swim-url: url('/mascot/mine-side.png');
--dddk-hero-url: url('/mascot/mine-wave.png');
--dddk-chill-url: url('/mascot/mine-chill.png');
--dddk-swim-cycle-url: url('/mascot/mine-swim-8frames.png');
--dddk-cursor-url: url('/mascot/mine-cursor.png');
--dddk-brand-mark-url: url('/mascot/mine-logo.png');
}
new MobileTrigger({
fab: {
faces: {
neutral: '/mascot/mine-neutral.png',
thinking: '/mascot/mine-thinking.png',
// ...
},
},
}).attachTo(dddk);
The face tiles + all sprite variables must point at alpha-keyed PNGs (transparent bg) so shapes / capsule backgrounds show through cleanly. The reference set was generated via gpt-image-2 i2i and post-processed with sharp in promo-cli/projects/dddk-mascot/.
Cursor sprite tip — WebAgent's pointer mode uses object-position: top left on the <img>, so the sprite's top-left corner is the click origin. Design your sprite with the "tip" (arrow / triangle / airplane point) in the top-left quadrant of the canvas for correct hotspot alignment. The reference cursor.png follows this convention: a paper-airplane nose in the top-left, duck riding the body.
Spritesheet tip — --dddk-swim-cycle-url expects 8 equally-sized frames tiled horizontally. Sheet width = 8 × frame width. The SDK plays it via background-position: -N * frame_width 0 with animation-direction: alternate (ping-pong) so endpoint frames become natural pauses at each end.
Subtitle-bar indicators
dddk.subtitle.showIndicator(state) renders one of three status pips:
| State | Visual |
|---|---|
processing |
1 hero-sized swim duck + 3 pip-sized swim ducks bouncing in relay ("努力游泳中") |
listening |
5 yellow waveform bars pulsing (mic-level look) |
done |
Static ✓ check |
--dddk-swim-url is picked up automatically for the swim relay. --dddk-indicator-wave-color (default #ffd93d) drives the waveform.
Dwell overlay
Long-press dwell adds a swimming duck riding on the outline of the framed element, plus a sunglasses "chill duck" perched at the bottom-right corner. Both are themable + all-CSS.
| Variable | Default | Effect |
|---|---|---|
--dddk-swim-url |
(must set) | Falls back to a static image |
--dddk-chill-url |
(must set) | Corner sunglasses duck; absent = no corner |
--dddk-dwell-swim-eraser |
#ffffff |
"Eraser" strip that breaks the outline gap |
--dddk-dwell-frame-width |
3px |
Outline stroke |
--dddk-dwell-frame-color |
#ffd93d |
Outline colour |
--dddk-dwell-frame-offset |
7px |
Distance from element |
--dddk-dwell-frame-radius |
var(--dddk-radius-sm, 6px) |
Outline corner radius |
The swim animation uses a spritesheet: 8 side-view frames tiled horizontally in swim-cycle.png. Ping-pong direction (animation-direction: alternate) so the endpoint frames become natural pauses at each end. Velocity is constant at ~22 px/s regardless of element width — computed as duration = 2W / 22 seconds (clamped 6s..26s).
Proactive delivery
When a proactive prompt fires, a side-view duck sprite swims out of the FAB toward the subtitle bar's landing point before the bar unfolds. Reads as "the mascot brought a message" rather than "a modal appeared". Automatic — no host wiring needed as long as --dddk-swim-url (or fallback --dddk-avatar-url) is set.
Respects prefers-reduced-motion: reduce — the delivery arc is skipped and the bar just appears.
Synthetic cursor (WebAgent)
When the agent executes click, scroll_to, or navigation actions, a synthetic cursor glides to the target before firing the DOM event — so the user reads "the agent just clicked here" instead of "something happened over there for no reason".
Pointer mode uses a bitmap sprite by default: a yellow rubber duck riding a white paper airplane, sharp origami tip at the top-left as the click origin. Sprite is dist/duck/cursor.png (215×256, transparent). Swap for any square-ish sprite by overriding one variable:
:root {
--dddk-cursor-url: url('/my-brand/cursor.png');
}
Scroll + reading modes stay SVG so they remain themeable via the legacy --webagent-cursor-{fill,stroke} variables.
| Variable | Default | Effect |
|---|---|---|
--dddk-cursor-url |
./duck/cursor.png |
Pointer-mode sprite |
--webagent-cursor-fill |
#111 (scroll/reading) |
SVG fill for scroll + reading modes |
--webagent-cursor-stroke |
#fff (scroll/reading) |
SVG stroke for scroll + reading modes |
--webagent-cursor-glide-ms |
360ms |
Slide duration between targets |
Palette brand mark
The palette footer's "Powered by dotdotduck" strip includes a small bobbing duck icon on the right — the shipped dist/duck/logo.png by default. Hosts overriding the avatar with their own brand mascot don't accidentally lose the palette attribution: the brand mark reads its own variable and falls back to the avatar only when unset.
:root {
/* Swap the brand mark for your own logo — keeps the palette
attribution row without showing our duck. */
--dddk-brand-mark-url: url('/my-brand/logo.png');
}
To completely hide it: [data-dddk-ui="palette-footer-brand-mark"] { display: none; }. (The Powered by dotdotduck text is a separate <span>; hide it via [data-dddk-ui="palette-footer-brand-text"] { display: none; }.)
Animation duration tokens
Every mascot loop that runs while the user is around is tunable via one CSS variable. Throttle for slower brands, amp up for playful ones, or hush completely for reduced-motion custom overrides.
| Variable | Default | Where it fires |
|---|---|---|
--dddk-avatar-swim-duration |
620ms |
Subtitle-bar avatar swim-in entrance |
--dddk-avatar-swim-easing |
cubic-bezier(0.34, 1.4, 0.64, 1) |
Same entrance easing |
--dddk-avatar-bob-duration |
2.6s |
Subtitle-bar avatar idle bob loop |
--dddk-indicator-swim-duration |
1.5s |
Thinking indicator hero swim |
--dddk-indicator-swim-pip-duration |
1s |
Thinking indicator pip bounce |
--dddk-indicator-wave-duration |
0.9s |
Listening waveform bars |
--dddk-brand-mark-bob-duration |
3.2s |
Palette footer brand mark bob |
--dddk-dwell-avatar-bob-duration |
2s |
Dwell corner chill duck bob |
--dddk-dwell-chill-breath-duration |
3s |
Dwell corner chill duck breath |
--dddk-fab-hero-transition-ms |
520ms |
HERO capsule morph timing |
--dddk-swim-duration |
JS-computed | Dwell top-edge swim (JS sets from element width) |
Kill any single animation entirely by targeting the selector directly instead of the duration:
[data-dddk-ui="palette-footer-brand-mark"] { animation: none; }
Under prefers-reduced-motion: reduce the SDK already switches every mascot loop off — these variables are for brand cadence, not accessibility.
Reduced motion
Every animation on the mascot layer respects prefers-reduced-motion: reduce:
- FAB idle bob, sonar rings, orbit ring, sparkle, hero morph → static
- Dwell swim, chill breath, outline ripple → static
- Proactive delivery arc → skipped
- Subtitle content fade-in → skipped
- WebAgent cursor slide → skipped (still fades in/out, no glide)