From e8b7bdf7bbeeb98d169663c51fc6e6514df84711 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 17:10:08 +0000 Subject: [PATCH] Poses nest into the bubble corner on desktop and phone 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 --- src/app/components/message/layout/Bubble.tsx | 28 +-- .../components/message/layout/layout.css.ts | 208 ++++-------------- .../components/message/pose/PoseAvatar.tsx | 95 +++++++- src/app/components/message/pose/nest.test.ts | 72 ++++++ src/app/components/message/pose/nest.ts | 58 +++++ src/app/components/message/pose/poses.ts | 13 ++ .../components/message/pose/safeBoxes.json | 19 ++ .../message/pose/silhouette.test.ts | 18 ++ src/app/components/message/pose/silhouette.ts | 17 +- src/app/features/room/message/Message.tsx | 1 - 10 files changed, 328 insertions(+), 201 deletions(-) create mode 100644 src/app/components/message/pose/nest.test.ts create mode 100644 src/app/components/message/pose/nest.ts create mode 100644 src/app/components/message/pose/safeBoxes.json diff --git a/src/app/components/message/layout/Bubble.tsx b/src/app/components/message/layout/Bubble.tsx index 066a827f..bd2056b9 100644 --- a/src/app/components/message/layout/Bubble.tsx +++ b/src/app/components/message/layout/Bubble.tsx @@ -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>( @@ -67,19 +60,9 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>( - {header && ( -
- {header} -
- )} + {header &&
{header}
} {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 )} diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index a724241c..1736def5 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -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; -// 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 diff --git a/src/app/components/message/pose/PoseAvatar.tsx b/src/app/components/message/pose/PoseAvatar.tsx index 75a4b333..fc2cb89e 100644 --- a/src/app/components/message/pose/PoseAvatar.tsx +++ b/src/app/components/message/pose/PoseAvatar.tsx @@ -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; }; +// Safe boxes derived from art's alpha, by source; the art is traced once. +const traced = new Map(); +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(null); const [shape, setShape] = useState(); + 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 (