The launcher, the panel and the avatar — what they can be told to do, and what the user can do to them.
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 avatarSvg —
one 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>`,
});
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.
Three things, none of which need an option:
expandable: false for a
tight embed.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.