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 handling (v0.2.3+) — 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). Since v0.2.3 the SDK ships a runtime resolver (initSpriteDefaults) that's called from autoInstall() — it uses new URL('./duck/x.png', import.meta.url) to compute reachable absolute URLs based on where the SDK JS was loaded from, then inline-sets each --dddk-*-url on :root.style. Bundlers (Vite / Webpack / esbuild) recognise this pattern as an asset reference; non-bundler setups (esm.sh / raw <script type="module">) also resolve correctly. You should not need to override sprite URLs by hand any more unless you want to swap for your brand. Pass autoInstall({ sprites: { avatar: '/my-brand/mascot.png' } }) — that override wins over the runtime default.
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)
The FAB circle morphs 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.
First-visit auto-fire (v0.2.3+)
Since v0.2.3, greeting content is SDK-provided. Every install shows a coherent bilingual first-visit greeting without the host writing any HTML — MobileTrigger auto-fires on first mount (sessionStorage-gated), renders the bundled template (title + two action rows + keyboard hint), and dismisses on click / auto-timeout.
Three levers on MobileTriggerConfig.heroGreeting:
// 1. Just tweak the title, everything else default
new MobileTrigger({
heroGreeting: {
title: { en: "Hi! I'm Rex", 'zh-TW': '嗨!我是 Rex' },
},
});
// 2. Full body replacement — pass raw HTML
new MobileTrigger({
heroGreeting: {
bodyHtml: (locale) => locale === 'zh-TW'
? '<div style="font-weight:700">你好</div>'
: '<div style="font-weight:700">Hello</div>',
},
});
// 3. Disable it entirely
new MobileTrigger({ heroGreeting: false });
Full config type:
interface HeroGreetingConfig {
autoShow?: boolean; // default true; auto-fire on first mount
autoDismissMs?: number; // default 20000
storageKey?: string; // default 'dddk_hero_greeting_seen_v1'
delayMs?: number; // default 1200
title?: string | Partial<Record<string, string>>;
rows?: HeroGreetingRow[]; // action rows (icon + body per locale)
kbdHint?: string | Partial<Record<string, string>> | false;
ariaLabel?: string | Partial<Record<string, string>>;
bodyHtml?: string | ((locale: string) => string); // full escape hatch
}
interface HeroGreetingRow {
icon?: string; // emoji or HTML for the icon column
body: string | Partial<Record<string, string>>;
}
Manual firing
mobile.showHeroGreetingWithDefaults(overrides?) fires the greeting using the SDK-shipped template on demand — useful for "Replay tour" palette entries. Overrides passed here win over the trigger-level config for this single firing.
mobile.showHeroGreeting(text, opts) is the low-level API — opts.bodyHtml replaces the whole body; no template is applied.
Behaviour
- 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. - Touch devices — the
kbdHintline is auto-hidden (keyboard shortcuts don't apply).
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 |
Default-template inner styling (v0.2.3+) — apply to the auto-generated title / rows / kbd hint:
| Variable | Default | Effect |
|---|---|---|
--dddk-fab-hero-title-size |
17px |
Title font size |
--dddk-fab-hero-title-weight |
700 |
Title font weight |
--dddk-fab-hero-row-size |
15px |
Action-row font size |
--dddk-fab-hero-row-gap |
10px |
Gap between row icon and text |
--dddk-fab-hero-row-margin |
8px |
Margin above each row |
--dddk-fab-hero-icon-size |
22px |
Icon column width |
--dddk-fab-hero-kbd-size |
12.5px |
Keyboard hint font size |
--dddk-fab-hero-kbd-margin |
10px |
Margin above keyboard hint |
--dddk-fab-hero-kbd-opacity |
0.72 |
Keyboard hint opacity |
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).
Opting out of the corner mascots (v0.2.3+)
For enterprise / business-app hosts (accounting, ERP, medical, legal) the playful side-view swim duck + sunglasses "chill" duck can feel out of context. DwellConfig.showCornerMascots: false suppresses both:
new Dwell({
llm: myLLM,
showCornerMascots: false, // just the frame outline, no mascots
});
Long-press-to-activate, Escape-to-clear, the frame outline itself, and the onSelect callback all work identically. Only the two decorative sprites are suppressed.
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)