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)