chat look: the three-doors mock-up in the bubble layout
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
NPM Lockfile Changes / lockfile_changes (pull_request) Has been cancelled

Pose portrait beside the bubble on wide screens and floating inside it on phones with
shape-outside; asymmetric bubble corner; own messages right-aligned in the accent colour;
first-appearance pop and rise, staggered, off under prefers-reduced-motion; Nunito in the
GameCult theme; package name and README move to Ratatoskr, Cinny credit and AGPL notice kept.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Void 2026-11-05 10:49:54 +00:00
commit 3043690657
12 changed files with 345 additions and 197 deletions

155
README.md
View file

@ -1,128 +1,55 @@
# Cinny
<p>
<a href="https://github.com/ajbura/cinny/releases">
<img alt="GitHub release downloads" src="https://img.shields.io/github/downloads/ajbura/cinny/total?logo=github&style=social"></a>
<a href="https://hub.docker.com/r/ajbura/cinny">
<img alt="DockerHub downloads" src="https://img.shields.io/docker/pulls/ajbura/cinny?logo=docker&style=social"></a>
<a href="https://fosstodon.org/@cinnyapp">
<img alt="Follow on Mastodon" src="https://img.shields.io/mastodon/follow/106845779685925461?domain=https%3A%2F%2Ffosstodon.org&logo=mastodon&style=social"></a>
<a href="https://twitter.com/intent/follow?screen_name=cinnyapp">
<img alt="Follow on Twitter" src="https://img.shields.io/twitter/follow/cinnyapp?logo=twitter&style=social"></a>
<a href="https://cinny.in/#sponsor">
<img alt="Sponsor Cinny" src="https://img.shields.io/opencollective/all/cinny?logo=opencollective&style=social"></a>
</p>
# Ratatoskr
A Matrix client focusing primarily on simple, elegant and secure interface.
The main goal is to have an instant messaging application that is easy on
people and has a modern touch.
- [Roadmap](https://github.com/orgs/cinnyapp/projects/1)
- [Contributing](./CONTRIBUTING.md)
GameCult's own Matrix client: the chat where members and the Minds (GameCult's agent
Personas) talk in the same rooms. It is a fork of [Cinny](https://github.com/cinnyapp/cinny)
4.12.8, kept under the same license, with the engine left alone and the interface rebuilt
estate-first and mobile-first.
> [!IMPORTANT]
We are currently in the process of [replacing] the matrix-js-sdk with our
own SDK. As a result, we will not be accepting any pull requests until
further notice. Thank you for your understanding.
## What is here that Cinny does not have
[replacing]: https://github.com/cinnyapp/cinny/issues/257#issuecomment-3714406704
Live on `main`:
<img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380">
- **Estate navigator**: rooms are listed before you join them, opening one shows its name
and a one-tap Join, and the menu hides behind a hamburger.
`src/app/features/estate/`, the plan in `docs/gamecult/ratatoskr-target.md`.
- **GameCult theme**: navy ground, orange accent, Nunito. `src/colors.css.ts`
(`gamecultTheme`), `src/gamecult.css.ts`.
- **Background scene** (opt-in, off by default): a WebGL2 terrain behind the chat, tiered by
device capability. `src/app/features/scene/`, switched by `backgroundScene.tier` in
`config.json`. The chat itself never depends on it.
- **Soundscape** (opt-in, muted by default): `src/app/features/soundscape/`.
## Getting started
The web app is available at [app.cinny.in] and gets updated on each new
release. The `dev` branch is continuously deployed at [dev.cinny.in]
but keep in mind that it could have things broken.
Planned, not built: file zones with albums, the activity feed, the corner avatar and EXP bar.
`docs/gamecult/ratatoskr-target.md` has the rulings and the cut sequence.
You can also download our desktop app from the [cinny-desktop repository].
## Pose portraits
[app.cinny.in]: https://app.cinny.in
[dev.cinny.in]: https://dev.cinny.in
[cinny-desktop repository]: https://github.com/cinnyapp/cinny-desktop
A Mind's message carries a pose label (`org.gamecult.pose`). With the Pose Portraits setting
on, the sender's portrait stands beside the bubble (inside it, text wrapping the silhouette, on
phones). The art is not committed: `npm run sync:poses` copies it from the cast-art
catalogue. `src/app/components/message/pose/`.
## Contributing and Developing
If you have any ideas, issues, etc. regarding Cinny, or would like to
contribute to Cinny through pull requests, please check out our
["Contributing to Cinny"](CONTRIBUTING.md) document. Those who would like
to get involved with Cinny's development as well should also read the
["Developing Cinny"](HACKING.md) document for more technical details.
## Self-hosting
To host Cinny on your own, simply download the tarball from
[GitHub releases], and serve the files from `dist/` using your preferred
webserver. Alternatively, you can just pull the docker image from
[DockerHub] or [GitHub Container Registry].
* The default homeservers and explore pages are defined in [`config.json`](config.json).
* You need to set up redirects to serve the assests. Example configurations;
[netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf),
[caddy](contrib/caddy/caddyfile).
* If you have trouble configuring redirects you can
[enable hash routing](config.json#L35) — the url in the browser will have
a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/`
instead of `app.cinny.in/home/`) but you won't have to configure your webserver.
* To deploy on subdirectory, you need to rebuild the app youself after
updating the `base` path in [`build.config.ts`](build.config.ts).
* For example, if you want to deploy on `https://cinny.in/app`, then
set `base: '/app'`.
[GitHub releases]: https://github.com/cinnyapp/cinny/releases/latest
[DockerHub]: https://hub.docker.com/r/ajbura/cinny
[GitHub Container Registry]: https://github.com/cinnyapp/cinny/pkgs/container/cinny
<details><summary><b>PGP Public Key to verify tarball</b></summary>
## Run and build
```
-----BEGIN PGP PUBLIC KEY BLOCK-----
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
mxFo+ioe/ABCufSmyqFye0psX3Sp
=WtqZ
-----END PGP PUBLIC KEY BLOCK-----
npm ci
npm start # vite dev server
npm test # vitest
npm run build # dist/
```
</details>
## License
To serve `dist/`, send unknown paths to `index.html`
([netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf),
[caddy](contrib/caddy/caddyfile)) or turn on hash routing in [`config.json`](config.json).
Default homeservers and the featured rooms are in `config.json`. For a sub-path, set
`CINNY_BASE` when building.
## Credit and license
Ratatoskr is free software under the GNU Affero General Public License v3 ([LICENSE](LICENSE)).
It is based on Cinny by Ajay Bura and contributors: Cinny's engine, components and the
copyright below are theirs. The in-app About page credits Cinny and links the source.
Cinny Project
Copyright © 2024–present Ajay Bura
https://cinny.in
Cinny is licensed under the GNU Affero General Public License,
Version 3 of the License (AGPL-3.0-only).
You may obtain a copy of the License at https://www.gnu.org/licenses/agpl-3.0.html
https://cinny.in

10
package-lock.json generated
View file

@ -12,6 +12,7 @@
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource-variable/nunito": "5.3.0",
"@fontsource/ibm-plex-mono": "5.2.5",
"@fontsource/inter": "4.5.14",
"@fontsource/montserrat": "5.2.5",
@ -2192,6 +2193,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@fontsource-variable/nunito": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/nunito/-/nunito-5.3.0.tgz",
"integrity": "sha512-tw0ch7evIDSwoeUy+0XzocYHfbRWLeCRPJy22KgbrLjDLAbNw/fErBiil10Ssrs3Sjr55zaZrr36a975j8cBkQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/ibm-plex-mono": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.5.tgz",

View file

@ -1,5 +1,5 @@
{
"name": "cinny",
"name": "ratatoskr",
"version": "4.12.8",
"description": "Yet another matrix client",
"main": "index.js",
@ -38,7 +38,11 @@
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource-variable/nunito": "5.3.0",
"@fontsource/ibm-plex-mono": "5.2.5",
"@fontsource/inter": "4.5.14",
"@fontsource/montserrat": "5.2.5",
"@fontsource/ubuntu": "5.2.5",
"@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0",
@ -89,10 +93,7 @@
"slate-dom": "0.123.0",
"slate-history": "0.113.1",
"slate-react": "0.123.0",
"ua-parser-js": "1.0.35",
"@fontsource/ubuntu": "5.2.5",
"@fontsource/montserrat": "5.2.5",
"@fontsource/ibm-plex-mono": "5.2.5"
"ua-parser-js": "1.0.35"
},
"devDependencies": {
"@element-hq/element-call-embedded": "0.20.1",

View file

@ -5,12 +5,11 @@ import * as css from './layout.css';
type BubbleArrowProps = {
variant: ContainerColor;
pose?: boolean;
};
function BubbleLeftArrow({ variant, pose }: BubbleArrowProps) {
function BubbleLeftArrow({ variant }: BubbleArrowProps) {
return (
<svg
className={classNames(css.BubbleLeftArrow, pose && css.BubbleArrowPose)}
className={css.BubbleLeftArrow}
width="9"
height="8"
viewBox="0 0 9 8"
@ -29,46 +28,80 @@ function BubbleLeftArrow({ variant, pose }: BubbleArrowProps) {
type BubbleLayoutProps = {
hideBubble?: boolean;
pose?: 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. */
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;
header?: ReactNode;
};
export const BubbleLayout = as<'div', BubbleLayoutProps>(
({ 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">
{header}
{hideBubble ? (
children
) : (
<Box>
<Box
className={
hideBubble
? undefined
: classNames(
css.BubbleContent,
before ? css.BubbleContentArrowLeft : undefined,
pose && css.BubbleContentPoseStacked
)
}
direction="Column"
>
{before ? <BubbleLeftArrow variant="SurfaceVariant" pose={pose} /> : null}
{children}
</Box>
({ hideBubble, own, pose, alt, 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;
const entering = enterDelay !== undefined;
return (
<Box
gap="300"
{...props}
className={classNames(
poseRow && css.BubbleRowPose,
pose && css.BubbleRowPortrait,
own && css.BubbleRowOwn,
props.className
)}
style={entering ? { ...style, ['--enter-delay' as string]: `${enterDelay}ms` } : style}
ref={ref}
>
{!poseRow && !own && (
<Box className={css.BubbleBefore} shrink="No">
{before}
</Box>
)}
<Box
grow={own ? 'No' : 'Yes'}
direction="Column"
className={classNames(poseRow && css.BubbleColumnPose, own && css.BubbleColumnOwn)}
>
{header && (
<div
className={classNames(
pose && css.BubbleHeaderPose,
pose && alt && css.BubbleHeaderPoseAlt,
own && css.BubbleHeaderOwn
)}
>
{header}
</div>
)}
{hideBubble ? (
children
) : (
<Box>
<Box
className={classNames(
css.BubbleContent,
arrow && css.BubbleContentArrowLeft,
poseRow && css.BubbleContentPose,
own && css.BubbleContentOwn,
entering && css.EnterBubble
)}
direction="Column"
>
{arrow ? <BubbleLeftArrow variant="SurfaceVariant" /> : null}
{pose}
{children}
</Box>
</Box>
)}
</Box>
</Box>
</Box>
)
);
}
);

View file

@ -138,39 +138,12 @@ 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,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R500,
position: 'relative',
});
@ -179,30 +152,171 @@ export const BubbleContentArrowLeft = style({
borderTopLeftRadius: 0,
});
export const BubbleContentPoseStacked = style({
// 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).
const PHONE = 'screen and (max-width: 700px)';
const PORTRAIT = toRem(124);
const POSE_GUTTER = toRem(14);
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': {
'screen and (max-width: 750px)': {
borderTopLeftRadius: config.radii.R500,
[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,
marginTop: toRem(58),
},
},
});
export const BubbleRowOwn = style({
justifyContent: 'flex-end',
});
export const BubbleColumnOwn = style({
alignItems: 'flex-end',
maxWidth: toRem(640),
});
export const BubbleHeaderOwn = style({
alignSelf: 'flex-end',
});
// Phone: the name is a pill straddling the bubble's top edge, on the side opposite the portrait.
export const BubbleHeaderPose = style({
'@media': {
[PHONE]: {
position: 'absolute',
zIndex: 2,
top: toRem(-15),
right: toRem(14),
padding: `0 ${toRem(13)}`,
background: color.Secondary.Container,
border: `1px solid ${color.Secondary.ContainerLine}`,
borderRadius: toRem(999),
whiteSpace: 'nowrap',
},
},
});
export const BubbleHeaderPoseAlt = style({
'@media': {
[PHONE]: {
right: 'auto',
left: toRem(14),
},
},
});
export const BubbleColumnPose = style({
position: 'relative',
});
export const BubbleContentPose = style({
borderRadius: config.radii.R500,
borderTopLeftRadius: BUBBLE_CORNER,
'@media': {
[PHONE]: {
display: 'flow-root',
borderTopLeftRadius: config.radii.R500,
padding: `${toRem(24)} ${toRem(14)} ${toRem(14)}`,
},
},
});
export const BubbleContentOwn = style({
backgroundColor: color.Primary.Container,
color: color.Primary.OnContainer,
border: `1px solid ${color.Primary.ContainerLine}`,
borderRadius: config.radii.R500,
borderTopRightRadius: BUBBLE_CORNER,
});
export const PoseButton = style({
position: 'absolute',
top: 0,
left: `calc(-1 * (${PORTRAIT} + ${POSE_GUTTER}))`,
width: PORTRAIT,
height: PORTRAIT,
padding: 0,
border: 'none',
background: 'none',
cursor: 'pointer',
lineHeight: 0,
transformOrigin: '50% 90%',
'@media': {
[PHONE]: {
position: 'static',
float: 'left',
width: 'min(50vw, 210px)',
height: 'auto',
margin: `${toRem(-82)} ${toRem(8)} 0 ${toRem(-12)}`,
shapeMargin: toRem(7),
shapeImageThreshold: 0.35,
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)}`,
},
},
});
export const PosePortrait = style({
width: toRem(64),
height: toRem(64),
width: '100%',
height: '100%',
objectFit: 'contain',
objectPosition: 'top',
'@media': {
'screen and (max-width: 750px)': {
width: toRem(48),
height: toRem(48),
[PHONE]: {
height: 'auto',
},
},
});
const pop = keyframes({
from: { opacity: 0, transform: `translateY(${toRem(34)}) scale(0.42)` },
to: { opacity: 1, transform: 'none' },
});
const rise = keyframes({
from: { opacity: 0, transform: `translateY(${toRem(14)}) scale(0.96)` },
to: { opacity: 1, transform: 'none' },
});
// First appearance of a message: the portrait pops, the bubble rises just behind it. The delay
// (a --enter-delay on the row) staggers messages that arrive together.
export const EnterPortrait = style({
'@media': {
'(prefers-reduced-motion: no-preference)': {
animation: `${pop} 460ms ${POP} var(--enter-delay, 0ms) both`,
},
},
});
export const EnterBubble = style({
'@media': {
'(prefers-reduced-motion: no-preference)': {
animation: `${rise} 380ms ${RISE} calc(var(--enter-delay, 0ms) + 50ms) both`,
},
},
});

View file

@ -1,4 +1,5 @@
import React from 'react';
import classNames from 'classnames';
import * as css from '../layout/layout.css';
import { poseSrc } from './poses';
@ -6,25 +7,26 @@ type PoseAvatarProps = {
mind: string;
pose: string;
userId: string;
/** Float on the right on phones. */
alt?: boolean;
/** Play the first-appearance pop. */
enter?: boolean;
onClick: React.MouseEventHandler<HTMLButtonElement>;
};
export function PoseAvatar({ mind, pose, userId, onClick }: PoseAvatarProps) {
export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvatarProps) {
const src = poseSrc(mind, pose, import.meta.env.BASE_URL);
return (
<button
type="button"
className={css.PoseButton}
className={classNames(css.PoseButton, alt && css.PoseButtonAlt, enter && css.EnterPortrait)}
// On phones the bubble text wraps around the portrait's silhouette.
style={{ shapeOutside: `url(${src})` }}
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}
/>
<img className={css.PosePortrait} src={src} alt={`${mind}, ${pose}`} draggable={false} />
</button>
);
}

View file

@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { claimEntrance } from './entrance';
describe('claimEntrance', () => {
it('animates a message once, never on a remount', () => {
expect(claimEntrance('$once', 1_000_000)).toBe(0);
expect(claimEntrance('$once', 1_000_500)).toBeUndefined();
});
it('staggers messages that mount together, capped so a long backlog still lands fast', () => {
const t = 2_000_000;
const delays = ['$a', '$b', '$c', '$d', '$e', '$f'].map((id) => claimEntrance(id, t));
expect(delays.slice(0, 3)).toEqual([0, 300, 600]);
expect(Math.max(...(delays as number[]))).toBe(900);
});
it('lets a lone later message in at once', () => {
claimEntrance('$x', 3_000_000);
expect(claimEntrance('$y', 3_010_000)).toBe(0);
});
});

View file

@ -0,0 +1,22 @@
import { useState } from 'react';
// A message animates in the first time it is shown, not each time the virtualised timeline
// remounts it. Messages mounting together are staggered so a screenful does not pop at once.
const STEP_MS = 300;
const MAX_DELAY_MS = 900;
const SEEN_CAP = 2000;
const seen = new Set<string>();
let nextSlot = 0;
export const claimEntrance = (id: string, now: number): number | undefined => {
if (seen.has(id)) return undefined;
if (seen.size >= SEEN_CAP) seen.clear();
seen.add(id);
const start = Math.max(nextSlot, now);
nextSlot = Math.min(start + STEP_MS, now + MAX_DELAY_MS);
return Math.min(start - now, MAX_DELAY_MS);
};
export const useEntranceDelay = (id: string | undefined): number | undefined =>
useState(() => (id ? claimEntrance(id, performance.now()) : undefined))[0];

View file

@ -11,6 +11,7 @@ type MindEntry = {
signatures: Record<string, string>;
};
const minds = catalogue.minds as Record<string, MindEntry>;
export const MINDS = Object.keys(minds);
// 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.

View file

@ -61,7 +61,8 @@ import {
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 { POSE_KEY, MINDS, mindOfSender, resolvePose } from '../../../components/message/pose/poses';
import { useEntranceDelay } from '../../../components/message/pose/entrance';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import * as css from './styles.css';
@ -797,6 +798,11 @@ export const Message = as<'div', MessageProps>(
? mindOfSender(getMxIdLocalPart(senderId), senderDisplayName)
: undefined;
const own = senderId === mx.getUserId();
const enterDelay = useEntranceDelay(mEvent.getId());
// Minds alternate sides on phones, each always on the same one.
const poseAlt = !!mind && MINDS.indexOf(mind) % 2 === 1;
const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && (
<AvatarBase
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
@ -827,6 +833,8 @@ export const Message = as<'div', MessageProps>(
mind={mind}
pose={resolvePose(mind, mEvent.getContent()[POSE_KEY])}
userId={senderId}
alt={poseAlt}
enter={enterDelay !== undefined}
onClick={onUserClick}
/>
);
@ -1154,9 +1162,14 @@ export const Message = as<'div', MessageProps>(
)}
{messageLayout === MessageLayout.Bubble && (
<BubbleLayout
pose={!!mind}
before={mind ? poseJSX : avatarJSX}
header={headerJSX} onContextMenu={handleContextMenu}>
own={own}
pose={mind ? poseJSX : undefined}
alt={poseAlt}
enterDelay={enterDelay}
before={avatarJSX}
header={headerJSX}
onContextMenu={handleContextMenu}
>
{msgContentJSX}
</BubbleLayout>
)}

View file

@ -18,3 +18,8 @@ globalStyle(scope, {
[config.lineHeight.D400]: '1.7',
},
});
// Nunito for the GameCult look (the three-doors mock-up); the stock themes keep Inter.
globalStyle(scope, {
fontFamily: "'Nunito Variable', var(--font-secondary)",
});

View file

@ -3,6 +3,7 @@ import React from 'react';
import { createRoot } from 'react-dom/client';
import { enableMapSet } from 'immer';
import '@fontsource/inter/variable.css';
import '@fontsource-variable/nunito/index.css';
import '@fontsource/ubuntu/300.css';
import '@fontsource/ubuntu/400.css';
import '@fontsource/ubuntu/500.css';