pose-aware chat bubble behind a Pose Portraits setting
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled

Reads org.gamecult.pose from event content for senders that are known
Minds; unknown or missing labels fall back to neutral. Portrait sits
beside the bubble on wide screens and stacks above it on phones.
84 downscaled webp portraits (7 Minds x 12 poses, ~415 KB).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
void 2026-11-01 19:22:21 +00:00
commit 6411266092
93 changed files with 224 additions and 9 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
public/poses/nibu/lore.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

BIN
public/poses/weksa/map.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -5,11 +5,12 @@ import * as css from './layout.css';
type BubbleArrowProps = {
variant: ContainerColor;
pose?: boolean;
};
function BubbleLeftArrow({ variant }: BubbleArrowProps) {
function BubbleLeftArrow({ variant, pose }: BubbleArrowProps) {
return (
<svg
className={css.BubbleLeftArrow}
className={classNames(css.BubbleLeftArrow, pose && css.BubbleArrowPose)}
width="9"
height="8"
viewBox="0 0 9 8"
@ -28,14 +29,20 @@ function BubbleLeftArrow({ variant }: BubbleArrowProps) {
type BubbleLayoutProps = {
hideBubble?: boolean;
pose?: boolean;
before?: ReactNode;
header?: ReactNode;
};
export const BubbleLayout = as<'div', BubbleLayoutProps>(
({ hideBubble, before, header, children, ...props }, ref) => (
<Box gap="300" {...props} ref={ref}>
<Box className={css.BubbleBefore} shrink="No">
({ hideBubble, pose, before, header, children, ...props }, ref) => (
<Box
gap="300"
{...props}
className={classNames(pose && css.BubblePose, props.className)}
ref={ref}
>
<Box className={pose ? css.BubbleBeforePose : css.BubbleBefore} shrink="No">
{before}
</Box>
<Box grow="Yes" direction="Column">
@ -48,11 +55,15 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
className={
hideBubble
? undefined
: classNames(css.BubbleContent, before ? css.BubbleContentArrowLeft : undefined)
: classNames(
css.BubbleContent,
before ? css.BubbleContentArrowLeft : undefined,
pose && css.BubbleContentPoseStacked
)
}
direction="Column"
>
{before ? <BubbleLeftArrow variant="SurfaceVariant" /> : null}
{before ? <BubbleLeftArrow variant="SurfaceVariant" pose={pose} /> : null}
{children}
</Box>
</Box>

View file

@ -138,6 +138,34 @@ export const BubbleBefore = style({
minWidth: toRem(36),
});
// Pose portraits: beside the bubble on wide screens; on phones the row stacks, so the
// portrait costs a little height instead of a column of width, and the left arrow goes.
export const BubblePose = style({
'@media': {
'screen and (max-width: 750px)': {
flexDirection: 'column',
gap: config.space.S100,
},
},
});
export const BubbleBeforePose = style({
minWidth: toRem(64),
'@media': {
'screen and (max-width: 750px)': {
minWidth: 0,
},
},
});
export const BubbleArrowPose = style({
'@media': {
'screen and (max-width: 750px)': {
display: 'none',
},
},
});
export const BubbleContent = style({
maxWidth: toRem(800),
padding: config.space.S200,
@ -151,6 +179,34 @@ export const BubbleContentArrowLeft = style({
borderTopLeftRadius: 0,
});
export const BubbleContentPoseStacked = style({
'@media': {
'screen and (max-width: 750px)': {
borderTopLeftRadius: config.radii.R500,
},
},
});
export const PoseButton = style({
padding: 0,
border: 'none',
background: 'none',
cursor: 'pointer',
lineHeight: 0,
});
export const PosePortrait = style({
width: toRem(64),
height: toRem(64),
objectFit: 'contain',
'@media': {
'screen and (max-width: 750px)': {
width: toRem(48),
height: toRem(48),
},
},
});
export const BubbleLeftArrow = style({
width: toRem(9),
height: toRem(8),

View file

@ -0,0 +1,30 @@
import React from 'react';
import * as css from '../layout/layout.css';
import { poseSrc } from './poses';
type PoseAvatarProps = {
mind: string;
pose: string;
userId: string;
onClick: React.MouseEventHandler<HTMLButtonElement>;
};
export function PoseAvatar({ mind, pose, userId, onClick }: PoseAvatarProps) {
return (
<button
type="button"
className={css.PoseButton}
data-user-id={userId}
data-pose={pose}
onClick={onClick}
>
<img
className={css.PosePortrait}
src={poseSrc(mind, pose, import.meta.env.BASE_URL)}
alt={`${mind}, ${pose}`}
loading="lazy"
draggable={false}
/>
</button>
);
}

View file

@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { mindOfSender, poseSrc, resolvePose } from './poses';
describe('resolvePose', () => {
it('keeps shared and own-signature labels', () => {
expect(resolvePose('aqua', 'amused')).toBe('amused');
expect(resolvePose('aqua', 'conduct')).toBe('conduct');
expect(resolvePose('kiko', ' Debug ')).toBe('debug');
});
it('falls back to neutral for missing, unknown, non-string or foreign-signature labels', () => {
expect(resolvePose('aqua', undefined)).toBe('neutral');
expect(resolvePose('aqua', 'smug')).toBe('neutral');
expect(resolvePose('aqua', 7)).toBe('neutral');
expect(resolvePose('aqua', 'probe')).toBe('neutral');
expect(resolvePose('nobody', 'probe')).toBe('neutral');
});
});
describe('mindOfSender', () => {
it('matches localpart or first display-name word', () => {
expect(mindOfSender('Eureka')).toBe('eureka');
expect(mindOfSender('x', 'Libby the Archivist')).toBe('libby');
});
it('ignores humans and Object prototype names', () => {
expect(mindOfSender('alice', 'Alice')).toBeUndefined();
expect(mindOfSender('constructor')).toBeUndefined();
expect(mindOfSender()).toBeUndefined();
});
});
describe('poseSrc', () => {
it('builds the asset path', () => {
expect(poseSrc('nibu', 'glitch', '/app/')).toBe('/app/public/poses/nibu/glitch.webp');
});
});

View file

@ -0,0 +1,48 @@
// Pose labels travel in the event content under POSE_KEY. Art exists per Mind;
// every Mind has the shared set plus four signature poses of its own.
export const POSE_KEY = 'org.gamecult.pose';
export const SHARED_POSES = [
'neutral',
'happy',
'amused',
'thinking',
'concerned',
'annoyed',
'surprised',
'determined',
] as const;
export const MIND_POSES: Record<string, readonly string[]> = {
aqua: ['conduct', 'listen', 'patch', 'flourish'],
eureka: ['probe', 'inspect', 'report', 'hood'],
heimdall: ['badge', 'barrier', 'protect', 'revoke'],
kiko: ['tinker', 'confetti', 'sprint', 'debug'],
libby: ['stamp', 'research', 'shush', 'schema'],
nibu: ['console', 'glitch', 'lore', 'triumph'],
weksa: ['map', 'fieldwork', 'argue', 'interlingua'],
};
export const NEUTRAL = 'neutral';
// A sender is a known Mind when its Matrix localpart or the first word of its
// display name is the Mind's name, case-insensitively.
export const mindOfSender = (localpart?: string, displayName?: string): string | undefined => {
const candidates = [localpart, displayName?.split(/\s+/)[0]];
return candidates
.map((c) => c?.toLowerCase())
.find(
(c): c is string => c !== undefined && Object.prototype.hasOwnProperty.call(MIND_POSES, c)
);
};
// Unknown, missing or non-string labels, and another Mind's signature, fall back to neutral.
export const resolvePose = (mind: string, label: unknown): string => {
if (typeof label !== 'string') return NEUTRAL;
const l = label.trim().toLowerCase();
const known = (SHARED_POSES as readonly string[]).includes(l) || MIND_POSES[mind]?.includes(l);
return known ? l : NEUTRAL;
};
export const poseSrc = (mind: string, pose: string, base = '/'): string =>
`${base}public/poses/${mind}/${pose}.webp`;

View file

@ -58,7 +58,10 @@ import {
isRoomAlias,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { MessageLayout, MessageSpacing } from '../../../state/settings';
import { MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
import { useSetting } from '../../../state/hooks/settings';
import { PoseAvatar } from '../../../components/message/pose/PoseAvatar';
import { POSE_KEY, mindOfSender, resolvePose } from '../../../components/message/pose/poses';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import * as css from './styles.css';
@ -788,6 +791,12 @@ export const Message = as<'div', MessageProps>(
</Box>
);
const [poseBubbles] = useSetting(settingsAtom, 'poseBubbles');
const mind =
poseBubbles && messageLayout === MessageLayout.Bubble
? mindOfSender(getMxIdLocalPart(senderId), senderDisplayName)
: undefined;
const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && (
<AvatarBase
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
@ -813,6 +822,15 @@ export const Message = as<'div', MessageProps>(
</AvatarBase>
);
const poseJSX = !collapse && mind && (
<PoseAvatar
mind={mind}
pose={resolvePose(mind, mEvent.getContent()[POSE_KEY])}
userId={senderId}
onClick={onUserClick}
/>
);
const msgContentJSX = (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
{reply}
@ -1135,7 +1153,10 @@ export const Message = as<'div', MessageProps>(
</CompactLayout>
)}
{messageLayout === MessageLayout.Bubble && (
<BubbleLayout before={avatarJSX} header={headerJSX} onContextMenu={handleContextMenu}>
<BubbleLayout
pose={!!mind}
before={mind ? poseJSX : avatarJSX}
header={headerJSX} onContextMenu={handleContextMenu}>
{msgContentJSX}
</BubbleLayout>
)}

View file

@ -884,6 +884,7 @@ function Messages() {
settingsAtom,
'legacyUsernameColor'
);
const [poseBubbles, setPoseBubbles] = useSetting(settingsAtom, 'poseBubbles');
const [hideMembershipEvents, setHideMembershipEvents] = useSetting(
settingsAtom,
'hideMembershipEvents'
@ -918,6 +919,13 @@ function Messages() {
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Pose Portraits"
description="Show a Mind's pose portrait beside its bubbles (bubble layout only)."
after={<Switch variant="Primary" value={poseBubbles} onChange={setPoseBubbles} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Hide Membership Change"

View file

@ -39,6 +39,7 @@ export interface Settings {
encUrlPreview: boolean;
showHiddenEvents: boolean;
legacyUsernameColor: boolean;
poseBubbles: boolean;
showNotifications: boolean;
isNotificationSounds: boolean;
@ -73,6 +74,7 @@ const defaultSettings: Settings = {
encUrlPreview: false,
showHiddenEvents: false,
legacyUsernameColor: false,
poseBubbles: false,
showNotifications: true,
isNotificationSounds: true,

View file

@ -42,6 +42,10 @@ const copyFiles = {
src: 'public/locales',
dest: 'public/',
},
{
src: 'public/poses',
dest: 'public/',
},
],
};