Files
airi/docs
蓝莓🫐 d3a51672b0 feat(stage-ui): show a dynamic presence bubble beside the character (#2657)
The main view says nothing about what the model is doing. A turn can
run, or fail to
run, and the stage looks the same either way. This draws a comic-style
bubble beside
the character's head: three dots while a chat turn is in flight, and a
panel with a
count when there are unread messages.

## Visual changes

| Before | After |
|---|---|
|
![](https://github.com/user-attachments/assets/62389b46-1bca-4b42-8832-260af93dbc8d)
|
![](https://github.com/user-attachments/assets/06b86593-8638-491c-92d4-006ca47e7390)
|
| Stage / Live2D (Pixi) | Stage / Live2D (Pixi) |
|
![](https://github.com/user-attachments/assets/ea7703c6-d8cd-4685-9233-813eac0ce870)
|
![](https://github.com/user-attachments/assets/aebedcb6-eabd-485e-aa9c-bc2332833893)
|
| Stage / VRM (Three) | Stage / VRM (Three) |

Both captured through Settings → Developer → Presence Bubble, which
drives the state
directly so the shot does not depend on a live model turn.

## It is drawn in the canvas

A DOM overlay follows `requestAnimationFrame`. The Live2D canvas
repaints at whatever
`settings/live2d/max-fps` allows. Under a capped frame rate the two run
at different
rates and the bubble slides off the head while the character moves. So
the bubble is
painted into the same canvas as the character, by the same ticker.

`packages/stage-shared/src/presence-bubble/` owns everything that is not
a renderer:

- `painter.ts` traces the panel and its tail as **one** outline,
parameterised by arc
length around the rounded rectangle. The tail is a stretch of the
boundary, not a
second shape welded on, so it cannot come apart from the panel at any
angle.
- `placement.ts` splits the choice of side from the position. The side
is chosen from
the settled box with hysteresis, so a bubble near a boundary does not
flip back and
forth; the position is then resolved against the measured box each
frame.
- `follow.ts` is a critically damped spring, which gives the inertia the
character
  drags the bubble with.
- `content.ts` steps the dots through a fixed number of phases, so they
read as
limited animation rather than a smooth fade, and drops to a single frame
under
  `prefers-reduced-motion`.
- `clock.ts` hands out the frame delta once, so a resize landing between
two ticks
  does not advance the spring twice over the same stretch of time.

Two adapters consume it: a Pixi `Sprite` for Live2D, a Three `Sprite`
for VRM. Both
run the same placement and the same painter.

## The head is asked for, not guessed

Both formats state where the head is, so nothing here is measured
against the sample
model in front of me:

- **VRM** — `humanoid.getNormalizedBoneNode('head')`. The head bone is
required by the
  format, so this holds for every VRM.
- **Live2D** — the head hit area first. When a model declares none, the
tracker turns
the `ParamAngleX/Y/Z` parameters to their far end with physics running
and takes the
drawables that moved. Across the seven official Cubism SDK samples,
three declare a
head hit area and six answer the parameter probe; Mark answers the probe
alone.

A model that states neither gets no bubble, rather than a bubble over
its chest.

## What is not here

- The spoken TTS line. Deferred by request — it comes after this.
- A real unread source. The count is wired to the developer control
only; the
`presenceBubbleIdle` / `presenceBubbleThinking` states and the store are
in place
  for a producer to fill.
- Sound. Deferred.

## How tested

```bash
pnpm -F @proj-airi/stage-shared exec vitest run src/presence-bubble
pnpm -F @proj-airi/stage-ui-live2d exec vitest run src/composables/live2d/head-anchor.test.ts
pnpm -F @proj-airi/stage-ui exec vitest run src/stores/presence-bubble.browser.test.ts
pnpm typecheck
pnpm lint
```

The head-anchor tests drive the real tracker through the narrow
`Live2DHeadSource`
contract with a fake rig, with no mocks or casts. The store test covers
cross-window
replication and asserts a remote snapshot produces no proposal back.

Screenshots captured with `vishot capture --target electron` against the
merge base
and this branch, same scenario, same viewport, same two preset models.

## Left open

- Parameter coverage across community models is unknown beyond the seven
official
samples. A model with no hit area and no angle parameters shows no
bubble.
- Tachie has no bubble; only Live2D and VRM are wired.
- stage-web and stage-pocket are untested. The shared code is
renderer-agnostic but
  neither surface mounts the adapters yet.

## ADR

`docs/ai/adr/2026-09-24-presence-bubble-in-canvas.md`

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-09-30 00:26:01 +07:00
..
2026-08-26 20:13:10 +08:00
2025-06-16 01:52:54 +08:00
2026-08-26 19:01:04 +08:00

Please refer to README.md for the main documentation.