377 lines
10 KiB
TypeScript
377 lines
10 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';
|
|
import { MOBILE_BREAKPOINT } from '../../../hooks/useScreenSize';
|
|
|
|
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),
|
|
});
|
|
|
|
// minWidth 0 down the column: a flex item never shrinks below its content (a reply quote's unbroken text),
|
|
// so without it a long quote pushes the column past the row, and a right-aligned row pushes it off the left edge.
|
|
export const BubbleColumn = style({
|
|
minWidth: 0,
|
|
});
|
|
|
|
// The row around a bubble sizes to its content in a right-aligned (own) column, and a quote's nowrap text
|
|
// makes that content as wide as the quote: cap the row at the column so the bubble can shrink inside it.
|
|
export const BubbleContentRow = style({
|
|
minWidth: 0,
|
|
maxWidth: '100%',
|
|
});
|
|
|
|
export const BubbleContent = style({
|
|
minWidth: 0,
|
|
maxWidth: `min(100%, ${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,
|
|
});
|
|
|
|
// 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 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 BubbleRowOwn = style({
|
|
justifyContent: 'flex-end',
|
|
});
|
|
|
|
export const BubbleColumnOwn = style({
|
|
alignItems: 'flex-end',
|
|
maxWidth: toRem(640),
|
|
});
|
|
|
|
export const BubbleHeaderOwn = style({
|
|
alignSelf: 'flex-end',
|
|
});
|
|
|
|
// 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({
|
|
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]: {
|
|
vars: { [portraitWidth]: PHONE_PORTRAIT },
|
|
},
|
|
},
|
|
});
|
|
|
|
globalStyle(`${BubbleContentPose} > div`, {
|
|
display: 'block',
|
|
});
|
|
|
|
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,
|
|
});
|
|
|
|
// 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({
|
|
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%',
|
|
});
|
|
|
|
export const PoseButtonAlt = style({
|
|
float: 'right',
|
|
margin: `calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD}) ${toRem(6)}`,
|
|
});
|
|
|
|
export const PosePortrait = style({
|
|
display: 'block',
|
|
width: portraitWidth,
|
|
maxWidth: 'none',
|
|
height: 'auto',
|
|
marginLeft: 'var(--pose-ix, 0)',
|
|
marginTop: 'var(--pose-iy, 0)',
|
|
});
|
|
|
|
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,
|
|
});
|
|
|
|
// Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the
|
|
// negative margin takes the growth back out of the layout. Sideways it cannot borrow from the layout the
|
|
// same way (a negative inline margin makes the button shrink inside its shrink-to-fit parent and clip the name), so
|
|
// a name under 44 px is 44 px wide, text kept at the start: real growth of at most 16 px, in a row with room for it.
|
|
// No background, border or outline moves.
|
|
const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
|
|
export const Username = style({
|
|
overflow: 'hidden',
|
|
whiteSpace: 'nowrap',
|
|
textOverflow: 'ellipsis',
|
|
selectors: {
|
|
'button&': {
|
|
cursor: 'pointer',
|
|
'@media': {
|
|
[SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11, minWidth: 44, textAlign: 'start' },
|
|
},
|
|
},
|
|
'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>;
|
|
|
|
// 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`, {
|
|
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} ${BubbleLeftArrow}`, {
|
|
display: 'none',
|
|
});
|