The chrome

The launcher, the panel and the avatar — what they can be told to do, and what the user can do to them.

An animated avatar, in pure SVG

The mark in the corner of this page is the one shown here: flat slabs, one off-centre eye and a red lamp. It ships from the package as CHROME_MARK and is passed as avatarSvgone inline SVG string, no images, no library, no requests.

The animation lives in a <style> block inside the SVG, so it travels with the mark. The eye uses currentColor and takes the widget's accent, while the shell keeps its own colour — a mark can mix both.

The asymmetry is the point. One eye, off centre: the symmetry of the earlier mark is what made it read as an icon, and a single element out of line reads as attention instead. Everything here snaps — steps(), never easing — which is why it asks for markMotionMode: "stepped". Easing belongs to a curved, glossy mark with a specular to slide across it; on flat slabs the same easing reads as sliding on ice rather than as weight.

The eye carries a pl class, which is how mark-motion finds it and moves it independently of the body. Rename it and nothing breaks loudly: the mark still renders, the body still tilts, and only the eye stops following — a robot that has stopped paying attention.

A tall mark — one with an antenna, where the art has headroom inside its own viewBox — can set --aiw-launcher-lift and rise past the disc's edge, so it reads as a character peeking over rather than a shape printed on. The default is 0%: the built-in mark was measured at 34 and then 48 in a 56px disc and read flat and then cropped, so the ratio is tuned to the mark and cannot be a global.

It ends with a prefers-reduced-motion guard that stops every animation. A permanently moving thing in the corner of someone's screen is an accessibility problem, not a flourish — if you animate a mark, carry that block with it.

createAiChatWidget({
  avatarSvg: `<svg viewBox="0 0 48 48">
    <style>
      .eye { animation: blink 4.4s ease-in-out infinite }
      @keyframes blink { 0%,92%,100% { transform: scaleY(1) }
                         95% { transform: scaleY(.12) } }
      @media (prefers-reduced-motion: reduce) { .eye { animation: none } }
    </style>
    …
  </svg>`,
});

The launcher has states, and they are drivable

Not decoration — the launcher is how the widget speaks when it is shut. setLauncher() takes a partial, so you change one thing and the rest stays. Each button below closes the panel, because the launcher is only visible when the panel is shut.

chat.setLauncher({ state: "unread", count: 3 });
chat.setLauncher({ state: "working" });   // a turn is running in the background
chat.setLauncher({ state: "offline" });   // the backend is unreachable
chat.setLauncher({ variant: "pill" });    // a word on a first visit, a pebble after
chat.setLauncher({ ground: "ink" });      // invert against a dark surface

parked shrinks and fades it — for a page where the assistant should stay available without competing with the content. ground: "auto" reads the host's own surface and inverts the mark accordingly.

What the user can do to the panel

Three things, none of which need an option:

The dragged position is remembered per layoutScope. That scope exists because a position keyed too broadly follows the user into contexts where it makes no sense, and one keyed too narrowly resets on every switch — they drag it once and it jumps as they navigate.

Below a narrow breakpoint the panel stops being a floating card and becomes a full-screen sheet, where dragging would be nonsense — so it is disabled there rather than left to fight the layout.