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 kbdHint line 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)