Ratatoskr/src/app/components/message/layout/layout.css.ts
Void 6110292e3b Pose pill grows upward when its name wraps, clear of the bubble text
Anchored by its bottom edge 13px inside the bubble instead of its top edge 15px above it,
so a wrapped name and time never cover the first line of text.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 10:50:24 +00:00

459 lines
11 KiB
TypeScript

import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
export const StickySection = style({
position: 'sticky',
top: config.space.S100,
});
const SpacingVar = createVar();
const SpacingVariant = styleVariants({
'0': {
vars: {
[SpacingVar]: config.space.S0,
},
},
'100': {
vars: {
[SpacingVar]: config.space.S100,
},
},
'200': {
vars: {
[SpacingVar]: config.space.S200,
},
},
'300': {
vars: {
[SpacingVar]: config.space.S300,
},
},
'400': {
vars: {
[SpacingVar]: config.space.S400,
},
},
'500': {
vars: {
[SpacingVar]: config.space.S500,
},
},
});
const highlightAnime = keyframes({
'0%': {
backgroundColor: color.Primary.Container,
},
'25%': {
backgroundColor: color.Primary.ContainerActive,
},
'50%': {
backgroundColor: color.Primary.Container,
},
'75%': {
backgroundColor: color.Primary.ContainerActive,
},
'100%': {
backgroundColor: color.Primary.Container,
},
});
const HighlightVariant = styleVariants({
true: {
animation: `${highlightAnime} 2000ms ease-in-out`,
animationIterationCount: 'infinite',
},
});
const SelectedVariant = styleVariants({
true: {
backgroundColor: color.Surface.ContainerActive,
},
});
const AutoCollapse = style({
selectors: {
[`&+&`]: {
marginTop: 0,
},
},
});
export const MessageBase = recipe({
base: [
DefaultReset,
{
marginTop: SpacingVar,
padding: `${config.space.S100} ${config.space.S200} ${config.space.S100} ${config.space.S400}`,
borderRadius: `0 ${config.radii.R400} ${config.radii.R400} 0`,
},
],
variants: {
space: SpacingVariant,
collapse: {
true: {
marginTop: 0,
},
},
autoCollapse: {
true: AutoCollapse,
},
highlight: HighlightVariant,
selected: SelectedVariant,
},
defaultVariants: {
space: '400',
},
});
export type MessageBaseVariants = RecipeVariants<typeof MessageBase>;
export const CompactHeader = style([
DefaultReset,
StickySection,
{
maxWidth: toRem(170),
width: '100%',
},
]);
export const AvatarBase = style({
paddingTop: toRem(4),
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
display: 'flex',
alignSelf: 'start',
selectors: {
'&:hover': {
transform: `translateY(${toRem(-2)})`,
},
},
});
export const ModernBefore = style({
minWidth: toRem(36),
});
export const BubbleBefore = style({
minWidth: toRem(36),
});
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',
});
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).
const PHONE = 'screen and (max-width: 700px)';
const PHONE_PORTRAIT = 'min(38vw, 160px)';
const PHONE_TEXT_FLOOR = toRem(170);
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': {
[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,
// Anchored by its bottom edge, 13px inside the bubble: a name that wraps grows upward, out of
// the bubble, instead of down over the first line of text.
bottom: `calc(100% - ${toRem(13)})`,
right: toRem(14),
padding: `0 ${toRem(13)}`,
background: color.Secondary.Container,
border: `1px solid ${color.Secondary.ContainerLine}`,
// The pill is as wide as the full name needs, up to the room beside the portrait, then the
// name wraps. 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.
width: 'max-content',
maxWidth: `calc(100vw - ${PHONE_PORTRAIT} - ${toRem(64)})`,
borderRadius: toRem(14),
},
},
});
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',
// 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)}`,
},
},
});
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: 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)}`,
},
},
});
export const PosePortrait = style({
width: '100%',
height: '100%',
objectFit: 'contain',
objectPosition: 'top',
'@media': {
[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`,
},
},
});
export const BubbleLeftArrow = style({
width: toRem(9),
height: toRem(8),
position: 'absolute',
top: 0,
left: toRem(-8),
zIndex: 1,
});
export const Username = style({
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
selectors: {
'button&': {
cursor: 'pointer',
},
'button&:hover, button&:focus-visible': {
textDecoration: 'underline',
},
},
});
export const UsernameBold = style({
fontWeight: 550,
});
export const MessageTextBody = recipe({
base: {
wordBreak: 'break-word',
},
variants: {
preWrap: {
true: {
whiteSpace: 'pre-wrap',
},
},
jumboEmoji: {
true: {
fontSize: '1.504em',
lineHeight: '1.4962em',
},
},
emote: {
true: {
color: color.Success.Main,
fontStyle: 'italic',
},
},
},
});
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 inside the pill wraps instead of ellipsising: the pill 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…" inside it.
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 pill's one child is the flex row of both).
globalStyle(`${BubbleHeaderPose} > div`, {
'@media': {
[PHONE]: {
flexWrap: 'wrap',
},
},
});
// 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,
},
},
});
// While the background scene runs (html[data-scene]) a chat bubble is the one filled surface in
// the app: its own colour at half strength over a blur of the scene, so the words stay readable
// and the ridge shows through. The arrow wedge is opaque, so it goes.
const scene = ':root[data-scene]';
const half = (c: string) => `color-mix(in srgb, ${c} 50%, transparent)`;
globalStyle(`${scene} ${BubbleContent}`, {
backgroundColor: [color.SurfaceVariant.Container, half(color.SurfaceVariant.Container)],
backdropFilter: 'blur(12px)',
});
globalStyle(`${scene} ${BubbleContentOwn}`, {
backgroundColor: [color.Primary.Container, half(color.Primary.Container)],
});
globalStyle(`${scene} ${BubbleHeaderPose}`, {
'@media': {
[PHONE]: {
backgroundColor: [color.Secondary.Container, half(color.Secondary.Container)],
backdropFilter: 'blur(12px)',
},
},
});
globalStyle(`${scene} ${BubbleLeftArrow}`, {
display: 'none',
});