Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Void
e8b7bdf7bb Poses nest into the bubble corner on desktop and phone
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m59s
The sprite floats in the bubble's top corner (left, or right for the Minds that alternate),
pulled out into the padding on three sides so the bubble edge crops it; text wraps its
silhouette and the bubble grows to hold it. The crop comes from nestView (pure, tested) over
Aqua's measured safe boxes; art without one uses its alpha bounds. Removes the outside-the-bubble
desktop portrait, the phone overhang band, the -82px margin, the bottom mask and the pose header pill rules.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 17:10:08 +00:00
10 changed files with 325 additions and 198 deletions

View file

@ -30,10 +30,8 @@ type BubbleLayoutProps = {
hideBubble?: boolean;
/** The sender is the signed-in user: right-aligned, in the accent colour, no avatar. */
own?: boolean;
/** A Mind's pose portrait; it replaces the avatar and sits beside (wide) or inside (phone) the bubble. */
/** A Mind's pose portrait; it replaces the avatar and nests in a corner of the bubble. */
pose?: ReactNode;
/** On phones the portrait floats on the other side. */
alt?: boolean;
/** Delay in ms before the first-appearance animation; undefined plays none. */
enterDelay?: number;
before?: ReactNode;
@ -41,7 +39,7 @@ type BubbleLayoutProps = {
};
export const BubbleLayout = as<'div', BubbleLayoutProps>(
({ hideBubble, own, pose, alt, enterDelay, before, header, children, style, ...props }, ref) => {
({ hideBubble, own, pose, enterDelay, before, header, children, style, ...props }, ref) => {
// `false` is a Mind's message continuing a run: aligned with the portraits, showing none.
const poseRow = pose !== undefined;
const arrow = !!before && !poseRow && !own;
@ -50,12 +48,7 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
<Box
gap="300"
{...props}
className={classNames(
poseRow && css.BubbleRowPose,
pose && css.BubbleRowPortrait,
own && css.BubbleRowOwn,
props.className
)}
className={classNames(own && css.BubbleRowOwn, props.className)}
style={entering ? { ...style, ['--enter-delay' as string]: `${enterDelay}ms` } : style}
ref={ref}
>
@ -67,19 +60,9 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
<Box
grow={own ? 'No' : 'Yes'}
direction="Column"
className={classNames(poseRow && css.BubbleColumnPose, own && css.BubbleColumnOwn)}
className={classNames(own && css.BubbleColumnOwn)}
>
{header && (
<div
className={classNames(
pose && css.BubbleHeaderPose,
pose && alt && css.BubbleHeaderPoseAlt,
own && css.BubbleHeaderOwn
)}
>
{header}
</div>
)}
{header && <div className={classNames(own && css.BubbleHeaderOwn)}>{header}</div>}
{hideBubble ? (
children
) : (
@ -89,6 +72,7 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
css.BubbleContent,
arrow && css.BubbleContentArrowLeft,
poseRow && css.BubbleContentPose,
pose && css.BubbleContentPortrait,
own && css.BubbleContentOwn,
entering && css.EnterBubble
)}

View file

@ -152,39 +152,21 @@ export const BubbleContentArrowLeft = style({
borderTopLeftRadius: 0,
});
// Look of the three-doors mock-up (chat.gamecult.org/mockups/three-doors): a Mind's pose sits
// beside its bubble on wide screens; on phones it floats inside the bubble, overhanging the top
// edge, and the text wraps around its silhouette (shape-outside on the alpha channel).
// A Mind's pose nests in the corner of its bubble, on phones and wide screens alike: the sprite's
// body sits in the bubble's padding and the bubble's edge cuts it, so nothing hangs outside; the
// text wraps around its silhouette (shape-outside, traced from the alpha channel) and the bubble
// grows to hold it. How much of the art the edge may cut comes from pose/nest.ts.
// Displayed width of the whole sprite image; the crop fractions are multiples of it.
export const portraitWidth = createVar();
const PHONE = 'screen and (max-width: 700px)';
const PORTRAIT = toRem(190);
const PHONE_PORTRAIT = 'min(38vw, 160px)';
const PHONE_TEXT_FLOOR = toRem(170);
const PORTRAIT_OVERHANG = 58;
const PORTRAIT = toRem(124);
const POSE_GUTTER = toRem(14);
const BUBBLE_PAD = toRem(12);
const BUBBLE_CORNER = toRem(6);
const POP = 'cubic-bezier(0.34, 1.7, 0.64, 1)';
const RISE = 'cubic-bezier(0.34, 1.45, 0.64, 1)';
export const BubbleRowPose = style({
position: 'relative',
paddingLeft: `calc(${PORTRAIT} + ${POSE_GUTTER})`,
'@media': {
[PHONE]: {
paddingLeft: 0,
},
},
});
// A row that shows a portrait: room for it to hang above the bubble on phones.
export const BubbleRowPortrait = style({
minHeight: PORTRAIT,
'@media': {
[PHONE]: {
minHeight: 0,
},
},
});
export const BubbleRowOwn = style({
justifyContent: 'flex-end',
});
@ -198,66 +180,31 @@ export const BubbleHeaderOwn = style({
alignSelf: 'flex-end',
});
// Phone: name and time are one plain line in flow above the bubble, on the side opposite the
// portrait. It is as wide as the full name needs, up to the room beside the portrait; only a name
// longer than that wraps (and then the time drops below it). It is capped by the viewport, not by
// the column: a short bubble's column is narrower than its own name, and a percentage cap there
// truncated names to one letter.
export const BubbleHeaderPose = style({
'@media': {
[PHONE]: {
alignSelf: 'flex-end',
// The band above the bubble where the portrait overhangs: the header sits at its foot, beside
// the portrait, so the overhang needs no margin of its own.
minHeight: toRem(PORTRAIT_OVERHANG),
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
margin: `0 ${toRem(14)} ${toRem(2)}`,
width: 'max-content',
maxWidth: `calc(100vw - ${PHONE_PORTRAIT} - ${toRem(64)})`,
textAlign: 'right',
},
},
});
export const BubbleHeaderPoseAlt = style({
'@media': {
[PHONE]: {
alignSelf: 'flex-start',
textAlign: 'left',
},
},
});
// A portrait row with no header (nothing to fill the overhang band) keeps the band as a margin.
globalStyle(`${BubbleRowPortrait}:not(:has(.${BubbleHeaderPose}))`, {
'@media': {
[PHONE]: {
marginTop: toRem(PORTRAIT_OVERHANG),
},
},
});
export const BubbleColumnPose = style({
position: 'relative',
});
// The message body is a flex column, which forms its own block formatting context and would sit
// whole beside the floated portrait; as a plain block its lines wrap around the silhouette and
// then run the bubble's full width below it.
export const BubbleContentPose = style({
borderRadius: config.radii.R500,
borderTopLeftRadius: BUBBLE_CORNER,
display: 'flow-root',
overflow: 'clip',
padding: BUBBLE_PAD,
});
// A bubble that shows a portrait is at least portrait + a readable column wide, or the text beside
// it collapses to a strip.
export const BubbleContentPortrait = style({
vars: { [portraitWidth]: PORTRAIT },
minWidth: `min(100%, calc(${portraitWidth} + ${PHONE_TEXT_FLOOR}))`,
'@media': {
[PHONE]: {
display: 'flow-root',
// A bubble with a portrait is at least portrait + a readable column wide, or the text
// beside the portrait collapses to a strip.
minWidth: `min(100%, calc(${PHONE_PORTRAIT} + ${PHONE_TEXT_FLOOR}))`,
borderTopLeftRadius: config.radii.R500,
padding: `${toRem(24)} ${toRem(14)} ${toRem(14)}`,
vars: { [portraitWidth]: PHONE_PORTRAIT },
},
},
});
globalStyle(`${BubbleContentPose} > div`, {
display: 'block',
});
export const BubbleContentOwn = style({
backgroundColor: color.Primary.Container,
color: color.Primary.OnContainer,
@ -266,51 +213,37 @@ export const BubbleContentOwn = style({
borderTopRightRadius: BUBBLE_CORNER,
});
// The button is the visible part of the sprite (the part inside the bubble's corner); the image
// inside it is offset by the crop. The inline --pose-* fractions (of the image width) come from
// nestView; until the art's size is known the button is simply the whole image.
export const PoseButton = style({
position: 'absolute',
top: 0,
left: `calc(-1 * (${PORTRAIT} + ${POSE_GUTTER}))`,
width: PORTRAIT,
height: PORTRAIT,
float: 'left',
width: `var(--pose-bw, ${portraitWidth})`,
height: 'var(--pose-bh, auto)',
// Out into the padding on the top, bottom and outer side, so the bubble's edge is the crop.
margin: `calc(-1 * ${BUBBLE_PAD}) ${toRem(6)} calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD})`,
padding: 0,
border: 'none',
background: 'none',
cursor: 'pointer',
lineHeight: 0,
overflow: 'hidden',
shapeMargin: toRem(7),
transformOrigin: '50% 90%',
'@media': {
[PHONE]: {
position: 'static',
float: 'left',
width: PHONE_PORTRAIT,
height: 'auto',
margin: `${toRem(-82)} ${toRem(8)} 0 ${toRem(-12)}`,
shapeMargin: toRem(7),
WebkitMaskImage: 'linear-gradient(#000 84%, transparent)',
maskImage: 'linear-gradient(#000 84%, transparent)',
},
},
});
export const PoseButtonAlt = style({
'@media': {
[PHONE]: {
float: 'right',
margin: `${toRem(-82)} ${toRem(-12)} 0 ${toRem(8)}`,
},
},
float: 'right',
margin: `calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD}) ${toRem(6)}`,
});
export const PosePortrait = style({
width: '100%',
height: '100%',
objectFit: 'contain',
objectPosition: 'top',
'@media': {
[PHONE]: {
height: 'auto',
},
},
display: 'block',
width: portraitWidth,
maxWidth: 'none',
height: 'auto',
marginLeft: 'var(--pose-ix, 0)',
marginTop: 'var(--pose-iy, 0)',
});
const pop = keyframes({
@ -395,61 +328,12 @@ export const MessageTextBody = recipe({
export type MessageTextBodyVariants = RecipeVariants<typeof MessageTextBody>;
// The message content is a flex column, which forms its own block formatting context and so sits
// whole beside the floated portrait: the text stays as narrow as the gap beside it for the
// bubble's full height. As a plain block its lines wrap around the silhouette and then run the
// bubble's full width below it.
globalStyle(`${BubbleContentPose} > div`, {
'@media': {
[PHONE]: {
display: 'block',
},
},
});
// The name wraps instead of ellipsising: the header already caps its own width, and Username's own
// nowrap + ellipsis, and the truncate on the Text inside it, clipped long names to "Heimdall o…".
globalStyle(`${BubbleHeaderPose} ${Username}, ${BubbleHeaderPose} ${Username} *`, {
'@media': {
[PHONE]: {
whiteSpace: 'normal',
overflow: 'visible',
textOverflow: 'clip',
overflowWrap: 'break-word',
},
},
});
// A name too long for one line takes its own lines and the time drops below it, instead of the
// name shrinking to a sliver beside the time (the header's one child is the flex row of both). The
// row hugs the side the header sits on, so a dropped time stays under the name's edge.
globalStyle(`${BubbleHeaderPose} > div`, {
'@media': {
[PHONE]: {
flexWrap: 'wrap',
justifyContent: 'flex-end',
},
},
});
globalStyle(`${BubbleHeaderPoseAlt} > div`, {
'@media': {
[PHONE]: {
justifyContent: 'flex-start',
},
},
});
// An outside list marker hangs left of its item's text and ignores the portrait's silhouette, so
// beside a left portrait the number lands under the art. Inside, it is a word on the first line
// and wraps around the silhouette like the rest.
globalStyle(`${BubbleContentPose} ol, ${BubbleContentPose} ul`, {
'@media': {
[PHONE]: {
listStylePosition: 'inside',
paddingLeft: 0,
},
},
listStylePosition: 'inside',
paddingLeft: 0,
});
// While the background scene runs (html[data-scene]) a chat bubble is the one filled surface in

View file

@ -1,53 +1,124 @@
import React, { useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import * as css from '../layout/layout.css';
import { poseSrc } from './poses';
import { poseSrc, safeBox } from './poses';
import { alphaBounds, nestView, type Box } from './nest';
import { silhouette } from './silhouette';
type PoseAvatarProps = {
mind: string;
pose: string;
userId: string;
/** Float on the right on phones. */
/** Nest in the bubble's top-right corner instead of the top-left. */
alt?: boolean;
/** Play the first-appearance pop. */
enter?: boolean;
onClick: React.MouseEventHandler<HTMLButtonElement>;
};
// Safe boxes derived from art's alpha, by source; the art is traced once.
const traced = new Map<string, { box: Box; size: [number, number] }>();
const trace = (img: HTMLImageElement, src: string) => {
const hit = traced.get(src);
if (hit) return hit;
const w = img.naturalWidth;
const h = img.naturalHeight;
const ctx = document.createElement('canvas').getContext('2d');
if (!ctx) return undefined;
ctx.canvas.width = w;
ctx.canvas.height = h;
ctx.drawImage(img, 0, 0);
const box = alphaBounds(ctx.getImageData(0, 0, w, h).data, w, h);
if (!box) return undefined;
const found = { box, size: [w, h] as [number, number] };
traced.set(src, found);
return found;
};
export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvatarProps) {
const src = poseSrc(mind, pose, import.meta.env.BASE_URL);
const imgRef = useRef<HTMLImageElement>(null);
const [shape, setShape] = useState<string>();
const measured = safeBox(mind, pose);
const [derived, setDerived] = useState<{ box: Box; size: [number, number] }>();
const art = measured ?? (derived?.size ? derived : undefined);
// Measured boxes are cropped by the share Aqua's numbers allow; a box traced from the alpha is
// already tight to the art, so the bubble may take all of the transparent margin.
const view = art && nestView(art.box, art.size, !!alt, measured ? undefined : 1);
// On phones the bubble text wraps around the portrait's silhouette. Retraced when the art
// loads and when the portrait's width changes with the viewport.
// Art with no measured box: find its extent when it loads.
useEffect(() => {
setDerived(undefined);
const img = imgRef.current;
if (!img || measured) return undefined;
const find = () => {
if (!img.complete || img.naturalWidth === 0) return;
try {
setDerived(trace(img, src));
} catch {
setDerived(undefined);
}
};
find();
img.addEventListener('load', find);
return () => img.removeEventListener('load', find);
}, [src, measured]);
// The bubble text wraps around the portrait's silhouette. Retraced when the art loads, when its
// crop is known, and when its width changes with the viewport.
useEffect(() => {
const img = imgRef.current;
if (!img) return undefined;
const trace = () => {
const retrace = () => {
if (!img.complete || img.naturalWidth === 0) return;
try {
setShape(silhouette(img, !alt));
const box = img.parentElement;
const style = getComputedStyle(img);
setShape(
silhouette(
img,
!alt,
box
? {
x: 0 - parseFloat(style.marginLeft || '0'),
y: 0 - parseFloat(style.marginTop || '0'),
width: box.clientWidth,
height: box.clientHeight,
}
: undefined
)
);
} catch {
setShape(undefined);
}
};
trace();
img.addEventListener('load', trace);
const resize = new ResizeObserver(trace);
retrace();
img.addEventListener('load', retrace);
const resize = new ResizeObserver(retrace);
resize.observe(img);
return () => {
img.removeEventListener('load', trace);
img.removeEventListener('load', retrace);
resize.disconnect();
};
}, [src, alt]);
}, [src, alt, view?.x, view?.y, view?.width, view?.height]);
// Crop fractions are of the image's width, so one set serves every displayed size.
const frac = (px: number) => `calc(${css.portraitWidth} * ${px / (art?.size[0] ?? 1)})`;
const cropStyle =
art && view
? ({
'--pose-bw': frac(view.width),
'--pose-bh': frac(view.height),
'--pose-ix': frac(-view.x),
'--pose-iy': frac(-view.y),
} as React.CSSProperties)
: undefined;
return (
<button
type="button"
className={classNames(css.PoseButton, alt && css.PoseButtonAlt, enter && css.EnterPortrait)}
style={{ shapeOutside: shape }}
style={{ ...cropStyle, shapeOutside: shape }}
data-user-id={userId}
data-pose={pose}
onClick={onClick}

View file

@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import { alphaBounds, nestView } from './nest';
import boxes from './safeBoxes.json';
describe('nestView', () => {
// Aqua neutral: 560x374, safe box [144,22,492,317] (right margin 68, top 22).
it('crops half the right and top margins for a top-right nest', () => {
expect(nestView([144, 22, 492, 317], [560, 374], true)).toEqual({
x: 0,
y: 11,
width: 526,
height: 363,
});
});
it('crops the left margin instead for a top-left nest', () => {
expect(nestView([144, 22, 492, 317], [560, 374], false)).toEqual({
x: 72,
y: 11,
width: 488,
height: 363,
});
});
it('never crops the top of art already touching it (smug, headband at y=0)', () => {
const v = nestView([140, 0, 487, 241], [560, 374], true);
expect(v.y).toBe(0);
expect(v.height).toBe(374);
});
it('can take the whole margin', () => {
expect(nestView([10, 4, 90, 50], [100, 60], true, 1)).toEqual({
x: 0,
y: 4,
width: 90,
height: 56,
});
});
it('keeps every measured safe box intact at the default share', () => {
const aqua = boxes.aqua as Record<string, number[]>;
expect(Object.keys(aqua)).toHaveLength(15);
Object.values(aqua).forEach(([x0, y0, x1, y1, w, h]) => {
const right = nestView([x0, y0, x1, y1], [w, h], true);
expect(right.x).toBeLessThanOrEqual(x0);
expect(right.x + right.width).toBeGreaterThanOrEqual(x1);
expect(right.y).toBeLessThanOrEqual(y0);
const left = nestView([x0, y0, x1, y1], [w, h], false);
expect(left.x).toBeLessThanOrEqual(x0);
expect(left.x + left.width).toBe(w);
});
});
});
describe('alphaBounds', () => {
it('finds the opaque rectangle', () => {
const w = 6;
const h = 5;
const data = new Uint8ClampedArray(w * h * 4);
[
[2, 1],
[4, 3],
].forEach(([x, y]) => {
data[(y * w + x) * 4 + 3] = 255;
});
expect(alphaBounds(data, w, h)).toEqual([2, 1, 5, 4]);
});
it('reports nothing for an empty image', () => {
expect(alphaBounds(new Uint8ClampedArray(16), 2, 2)).toBeUndefined();
});
});

View file

@ -0,0 +1,58 @@
// A pose nests into the corner of its bubble: the sprite's body sits in the bubble's padding and is
// cut off by the bubble's edge, so nothing hangs outside it.
/** [x0, y0, x1, y1] in image pixels, origin top-left, x1/y1 exclusive. */
export type Box = [number, number, number, number];
// Share of the room between the safe box and the image edge that the bubble edge may cut away.
// Aqua's measurement says all of it can go; half leaves the outline a little air.
export const CROP_SHARE = 0.5;
export type NestView = {
/** The visible region of the image, in pixels. */
x: number;
y: number;
width: number;
height: number;
};
// The part of the image that shows, for a sprite in the bubble's top-right (`right`) or top-left
// corner. Only the corner's two sides are cropped: the side towards the bubble's edge and the top.
// The far side and the bottom stay whole; the bubble's bottom edge cuts the bust.
export const nestView = (
safe: Box,
size: [number, number],
right: boolean,
share = CROP_SHARE
): NestView => {
const [w, h] = size;
const [x0, y0, x1] = safe;
const side = Math.round(share * (right ? w - x1 : x0));
const top = Math.round(share * y0);
const x = right ? 0 : side;
return { x, y: top, width: w - side, height: h - top };
};
// Bounds of the pixels whose alpha exceeds `min`, as a safe box for art with no measured one.
export const alphaBounds = (
data: ArrayLike<number>,
w: number,
h: number,
min = 8
): Box | undefined => {
let x0 = w;
let y0 = h;
let x1 = 0;
let y1 = 0;
for (let y = 0; y < h; y += 1) {
for (let x = 0; x < w; x += 1) {
if (data[(y * w + x) * 4 + 3] > min) {
x0 = Math.min(x0, x);
x1 = Math.max(x1, x + 1);
y0 = Math.min(y0, y);
y1 = y + 1;
}
}
}
return x1 > x0 ? [x0, y0, x1, y1] : undefined;
};

View file

@ -1,4 +1,6 @@
import catalogue from './catalogue.json';
import safeBoxes from './safeBoxes.json';
import type { Box } from './nest';
// Pose labels travel in the event content under POSE_KEY. The catalogue
// (cast-art poses/catalogue.json, synced by `npm run sync:poses`) is the only
@ -40,3 +42,14 @@ export const resolvePose = (mind: string, label: unknown): string => {
export const poseSrc = (mind: string, pose: string, base = '/'): string =>
`${base}public/poses/${artPath(mind, pose) ?? artPath(mind, NEUTRAL)}`;
// Safe-crop boxes measured from the art's pixels (Aqua, `aqua-pose-crop-boxes-20261110`): the
// head and headphones plus outline, which the bubble's edge must not cut. [x0, y0, x1, y1, width,
// height] in image pixels. Art with no entry gets its box from the sprite's alpha at load time.
const boxes = safeBoxes as Record<string, Record<string, number[]>>;
export const safeBox = (
mind: string,
pose: string
): { box: Box; size: [number, number] } | undefined => {
const b = Object.prototype.hasOwnProperty.call(boxes, mind) ? boxes[mind][pose] : undefined;
return b ? { box: [b[0], b[1], b[2], b[3]], size: [b[4], b[5]] } : undefined;
};

View file

@ -0,0 +1,19 @@
{
"aqua": {
"neutral": [144, 22, 492, 317, 560, 374],
"happy": [157, 22, 506, 317, 574, 374],
"amused": [154, 22, 500, 317, 562, 374],
"thinking": [154, 16, 500, 307, 562, 374],
"annoyed": [154, 4, 499, 282, 562, 374],
"surprised": [141, 5, 485, 282, 560, 374],
"concerned": [156, 4, 501, 283, 574, 374],
"fretful": [156, 16, 503, 305, 574, 374],
"weary": [161, 16, 509, 307, 624, 374],
"glare": [160, 5, 508, 282, 624, 374],
"smug": [140, 0, 487, 241, 560, 374],
"sigh": [154, 0, 500, 241, 574, 374],
"charging": [152, 0, 497, 241, 562, 374],
"delighted": [159, 0, 507, 241, 624, 374],
"content": [144, 16, 492, 306, 560, 374]
}
}

View file

@ -53,4 +53,22 @@ describe('silhouette', () => {
silhouette({ clientWidth: 0, clientHeight: 0 } as HTMLImageElement, true)
).toBeUndefined();
});
it('traces only the visible part of a cropped portrait, drawn at its offset', () => {
const drawImage = vi.fn();
const canvas = {
width: 0,
height: 0,
getContext: () => ({
canvas,
drawImage,
getImageData: () => ({ data: new Uint8ClampedArray(40 * 8 * 4) }),
}),
};
vi.stubGlobal('document', { createElement: () => canvas });
const img = { clientWidth: 100, clientHeight: 20 } as HTMLImageElement;
silhouette(img, true, { x: 0, y: 12, width: 40, height: 8 });
expect([canvas.width, canvas.height]).toEqual([40, 8]);
expect(drawImage).toHaveBeenCalledWith(img, 0, -12, 100, 20);
});
});

View file

@ -5,14 +5,23 @@ const ROW = 4;
// `shape-outside: url()` cannot do this: the browser sizes that image at its natural size and
// anchors it at the margin box, which the phone layout's negative top margin shifts down by the
// overhang, so the exclusion neither matches the art's size nor its place.
export const silhouette = (img: HTMLImageElement, textOnRight: boolean): string | undefined => {
const w = Math.round(img.clientWidth);
const h = Math.round(img.clientHeight);
//
// `view` is the part of the displayed image that shows (the button's box, in displayed pixels):
// the portrait is cropped by the bubble, so the polygon is traced over what is left of the art.
export type View = { x: number; y: number; width: number; height: number };
export const silhouette = (
img: HTMLImageElement,
textOnRight: boolean,
view?: View
): string | undefined => {
const w = Math.round(view?.width ?? img.clientWidth);
const h = Math.round(view?.height ?? img.clientHeight);
const ctx = w > 0 && h > 0 ? document.createElement('canvas').getContext('2d') : null;
if (!ctx) return undefined;
ctx.canvas.width = w;
ctx.canvas.height = h;
ctx.drawImage(img, 0, 0, w, h);
ctx.drawImage(img, 0 - (view?.x ?? 0), 0 - (view?.y ?? 0), img.clientWidth, img.clientHeight);
const { data } = ctx.getImageData(0, 0, w, h);
const outer = textOnRight ? 0 : w;
const edge: string[] = [];

View file

@ -1164,7 +1164,6 @@ export const Message = as<'div', MessageProps>(
<BubbleLayout
own={own}
pose={mind ? poseJSX : undefined}
alt={poseAlt}
enterDelay={enterDelay}
before={avatarJSX}
header={headerJSX}