feat(assistant): show the user's mascot on the "Talk to Tiny" chip (#3582)

This commit is contained in:
Cyrus Gray
2026-06-11 10:17:20 +05:30
committed by GitHub
parent 07e60af3bb
commit 620bfe7b16
10 changed files with 290 additions and 25 deletions
+56 -18
View File
@@ -1,31 +1,69 @@
import React from 'react';
import { shadeHex } from './mascotPalette';
import { BODY_PATH } from './paths';
export const GhostyDefs: React.FC<{ idPrefix: string; bodyColor: string }> = ({
/**
* `shaded` (default) is the original moody, dark-stopped body gradient used by
* the full-size mascot stage. `flat` is a bright, body-colour-dominant gradient
* that mirrors the Rive mascot's look — used for compact avatars (e.g. the
* "Talk to Tiny" chip) so the small mascot matches the big one in the panel
* rather than reading as a dark blob.
*/
export type GhostyVariant = 'shaded' | 'flat';
export interface GhostyDefsProps {
idPrefix: string;
bodyColor: string;
variant?: GhostyVariant;
}
export const GhostyDefs: React.FC<GhostyDefsProps> = ({
idPrefix,
bodyColor,
variant = 'shaded',
}) => {
const id = (k: string) => `${idPrefix}-${k}`;
const flat = variant === 'flat';
// Derive a soft highlight + edge shadow from the body colour so any palette
// (including custom hexes) gets a clean, bright 3D body.
const highlight = shadeHex(bodyColor, 0.32);
const edge = shadeHex(bodyColor, -0.24);
return (
<defs>
<radialGradient id={id('body')} cx="0.32" cy="0.28" r="1.05">
<stop offset="0%" stopColor="#45454a" />
<stop offset="15%" stopColor="#393940" />
<stop offset="30%" stopColor="#2d2d33" />
<stop offset="45%" stopColor={bodyColor} />
<stop offset="60%" stopColor="#1a1a1e" />
<stop offset="75%" stopColor="#121215" />
<stop offset="88%" stopColor="#0a0a0c" />
<stop offset="100%" stopColor="#050506" />
</radialGradient>
<radialGradient id={id('dot')} cx="0.35" cy="0.3" r="1">
<stop offset="0%" stopColor="#45454a" />
<stop offset="20%" stopColor="#363639" />
<stop offset="45%" stopColor={bodyColor} />
<stop offset="70%" stopColor="#15151a" />
<stop offset="100%" stopColor="#050507" />
</radialGradient>
{flat ? (
<radialGradient id={id('body')} cx="0.35" cy="0.3" r="0.95">
<stop offset="0%" stopColor={highlight} />
<stop offset="45%" stopColor={bodyColor} />
<stop offset="100%" stopColor={edge} />
</radialGradient>
) : (
<radialGradient id={id('body')} cx="0.32" cy="0.28" r="1.05">
<stop offset="0%" stopColor="#45454a" />
<stop offset="15%" stopColor="#393940" />
<stop offset="30%" stopColor="#2d2d33" />
<stop offset="45%" stopColor={bodyColor} />
<stop offset="60%" stopColor="#1a1a1e" />
<stop offset="75%" stopColor="#121215" />
<stop offset="88%" stopColor="#0a0a0c" />
<stop offset="100%" stopColor="#050506" />
</radialGradient>
)}
{flat ? (
<radialGradient id={id('dot')} cx="0.35" cy="0.3" r="1">
<stop offset="0%" stopColor={highlight} />
<stop offset="55%" stopColor={bodyColor} />
<stop offset="100%" stopColor={edge} />
</radialGradient>
) : (
<radialGradient id={id('dot')} cx="0.35" cy="0.3" r="1">
<stop offset="0%" stopColor="#45454a" />
<stop offset="20%" stopColor="#363639" />
<stop offset="45%" stopColor={bodyColor} />
<stop offset="70%" stopColor="#15151a" />
<stop offset="100%" stopColor="#050507" />
</radialGradient>
)}
<filter id={id('grain')} x="0%" y="0%" width="100%" height="100%">
<feTurbulence
type="fractalNoise"
+28 -4
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { GhostyDefs } from './Defs';
import { GhostyDefs, type GhostyVariant } from './Defs';
import { ARM_PATH, BODY_PATH, LEFT_LEG_PATH, RIGHT_LEG_PATH, VIEWBOX } from './paths';
import { useMascotClock } from './useMascotClock';
import { visemePath, VISEMES, type VisemeShape } from './visemes';
@@ -67,6 +67,18 @@ export interface GhostyProps {
/** Override SVG element size; defaults to filling the parent. */
size?: number | string;
idPrefix?: string;
/**
* Drive the idle bob/blink/wave animation. Defaults to `true`. Pass `false`
* for a frozen, zero-RAF pose — cheap enough to render many times or in
* compact always-on UI (e.g. the "Talk to Tiny" chip avatar).
*/
animated?: boolean;
/**
* Body shading style. `'shaded'` (default) is the moody full-size look;
* `'flat'` is a bright, body-colour-dominant fill that matches the Rive
* mascot for compact avatars.
*/
variant?: GhostyVariant;
}
interface FacePreset {
@@ -258,8 +270,10 @@ export const Ghosty: React.FC<GhostyProps> = ({
viseme,
size = '100%',
idPrefix = 'mascot',
animated = true,
variant = 'shaded',
}) => {
const t = useMascotClock();
const t = useMascotClock(animated);
const preset = presetFor(face);
// Gentle bob for the whole character.
@@ -297,7 +311,7 @@ export const Ghosty: React.FC<GhostyProps> = ({
viewBox={`0 0 ${VIEWBOX} ${VIEWBOX}`}
style={{ overflow: 'visible', display: 'block' }}
data-face={face}>
<GhostyDefs idPrefix={idPrefix} bodyColor={bodyColor} />
<GhostyDefs idPrefix={idPrefix} bodyColor={bodyColor} variant={variant} />
<g
transform={`translate(500, 970) scale(${1 - bob / 600}, 1)`}
@@ -331,7 +345,17 @@ export const Ghosty: React.FC<GhostyProps> = ({
<g clipPath={`url(#${id('body-clip')})`}>
<g filter={`url(#${id('soft')})`}>
<ellipse cx={340} cy={380} rx={220} ry={160} fill="#ffffff" opacity={0.09} />
<ellipse cx={720} cy={800} rx={280} ry={170} fill="#000000" opacity={0.45} />
{/* Inner edge shadow. Kept subtle in the flat (bright) variant so the
compact mascot stays vivid like the Rive stage; full strength in
the moody full-size variant. */}
<ellipse
cx={720}
cy={800}
rx={280}
ry={170}
fill="#000000"
opacity={variant === 'flat' ? 0.14 : 0.45}
/>
</g>
<rect x={0} y={0} width={1000} height={1000} filter={`url(#${id('grain')})`} />
</g>
@@ -0,0 +1,66 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { MascotChipAvatar } from './MascotChipAvatar';
import { getMascotPalette, shadeHex } from './mascotPalette';
describe('MascotChipAvatar', () => {
it('renders the custom GIF (decorative) when a gifUrl is provided', () => {
render(<MascotChipAvatar color="yellow" gifUrl="https://example.com/avatar.gif" />);
const wrapper = screen.getByTestId('mascot-chip-avatar');
expect(wrapper).toHaveAttribute('data-variant', 'gif');
const img = wrapper.querySelector('img') as HTMLImageElement;
expect(img).toBeTruthy();
expect(img).toHaveAttribute('src', 'https://example.com/avatar.gif');
// Decorative: the chip's <button> already carries the accessible label.
expect(img).toHaveAttribute('aria-hidden', 'true');
});
it('falls back to the lightweight Ghosty SVG when no gifUrl is set', () => {
render(<MascotChipAvatar color="yellow" />);
const wrapper = screen.getByTestId('mascot-chip-avatar');
expect(wrapper).toHaveAttribute('data-variant', 'ghosty');
// The static (non-RAF) mascot is an inline SVG, not the heavy WebGL canvas.
expect(wrapper.querySelector('svg')).toBeTruthy();
expect(wrapper.querySelector('canvas')).toBeNull();
});
it('tints the SVG body with the custom primary colour when color is "custom"', () => {
render(<MascotChipAvatar color="custom" customPrimary="#abcdef" />);
// GhostyDefs paints the body fill from bodyColor; the custom hex must appear
// somewhere in the rendered SVG markup.
const wrapper = screen.getByTestId('mascot-chip-avatar');
expect(wrapper.innerHTML.toLowerCase()).toContain('#abcdef');
});
it('uses the palette body colour for a named theme', () => {
const { bodyFill } = getMascotPalette('navy');
render(<MascotChipAvatar color="navy" />);
const wrapper = screen.getByTestId('mascot-chip-avatar');
expect(wrapper.innerHTML.toLowerCase()).toContain(bodyFill.toLowerCase());
});
it('renders the bright "flat" body so the chip matches the Rive mascot stage', () => {
// The flat variant derives a lightened highlight stop from the body colour;
// its presence proves we are NOT using the dark, moody body gradient.
const highlight = shadeHex(getMascotPalette('yellow').bodyFill, 0.32);
render(<MascotChipAvatar color="yellow" />);
const wrapper = screen.getByTestId('mascot-chip-avatar');
expect(wrapper.innerHTML.toLowerCase()).toContain(highlight.toLowerCase());
// The dark-gradient sentinel stop (#050506) must be absent in flat mode.
expect(wrapper.innerHTML.toLowerCase()).not.toContain('#050506');
});
it('applies the requested pixel size to the avatar box', () => {
render(<MascotChipAvatar color="yellow" size={30} />);
const wrapper = screen.getByTestId('mascot-chip-avatar');
expect(wrapper).toHaveStyle({ width: '30px', height: '30px' });
});
});
@@ -0,0 +1,69 @@
// ---------------------------------------------------------------------------
// MascotChipAvatar
//
// A small, on-brand avatar of the user's mascot for compact, always-on UI
// (e.g. the "Talk to Tiny" face-mode chip). It renders the user's custom GIF
// when one is set, otherwise the lightweight `Ghosty` SVG tinted with their
// chosen palette colour.
//
// It deliberately never uses the heavy WebGL `RiveMascot`: a corner chip should
// not spin up a second GPU animation runtime. The SVG path is rendered with
// `animated={false}` so there is no idle RAF cost — the mascot only comes to
// life via the parent's hover treatment (e.g. `group-hover:animate-wiggle`).
// ---------------------------------------------------------------------------
import { type FC } from 'react';
import { Ghosty } from './Ghosty';
import { getMascotPalette, type MascotColor } from './mascotPalette';
export interface MascotChipAvatarProps {
/** The user's selected mascot colour theme. */
color: MascotColor;
/** Custom primary body colour, used only when `color === 'custom'`. */
customPrimary?: string | null;
/** Custom mascot GIF URL — when set, takes precedence over the SVG mascot. */
gifUrl?: string | null;
/** Diameter of the avatar in pixels. */
size?: number;
}
export const MascotChipAvatar: FC<MascotChipAvatarProps> = ({
color,
customPrimary,
gifUrl,
size = 22,
}) => {
const palette = getMascotPalette(color);
const bodyColor = color === 'custom' && customPrimary ? customPrimary : palette.bodyFill;
if (gifUrl) {
return (
<span
className="inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full"
style={{ width: size, height: size }}
data-testid="mascot-chip-avatar"
data-variant="gif">
<img src={gifUrl} alt="" aria-hidden="true" className="h-full w-full object-cover" />
</span>
);
}
return (
<span
className="inline-flex shrink-0 items-center justify-center"
style={{ width: size, height: size }}
data-testid="mascot-chip-avatar"
data-variant="ghosty">
<Ghosty
bodyColor={bodyColor}
face="idle"
arm="none"
size={size}
animated={false}
variant="flat"
/>
</span>
);
};
export default MascotChipAvatar;
+2
View File
@@ -2,6 +2,8 @@ export { Ghosty } from './Ghosty';
export type { GhostyProps, MascotFace } from './Ghosty';
export { CustomGifMascot } from './CustomGifMascot';
export type { CustomGifMascotProps } from './CustomGifMascot';
export { MascotChipAvatar } from './MascotChipAvatar';
export type { MascotChipAvatarProps } from './MascotChipAvatar';
export { RiveMascot } from './RiveMascot';
export type { RiveMascotProps } from './RiveMascot';
export { lerpViseme, VISEMES, visemePath } from './visemes';
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { getMascotPalette } from './mascotPalette';
import { getMascotPalette, shadeHex } from './mascotPalette';
describe('getMascotPalette', () => {
it.each(['yellow', 'burgundy', 'black', 'navy', 'custom'] as const)(
@@ -18,3 +18,28 @@ describe('getMascotPalette', () => {
}
);
});
describe('shadeHex', () => {
it('lightens toward white for a positive amount', () => {
expect(shadeHex('#000000', 0.5)).toBe('#808080');
expect(shadeHex('#808080', 1)).toBe('#ffffff');
});
it('darkens toward black for a negative amount', () => {
expect(shadeHex('#ffffff', -0.5)).toBe('#808080');
expect(shadeHex('#808080', -1)).toBe('#000000');
});
it('returns the input unchanged for amount 0', () => {
expect(shadeHex('#F7D145', 0)).toBe('#f7d145');
});
it('accepts hex without a leading # and lower-cases output', () => {
expect(shadeHex('234B74', 0)).toBe('#234b74');
});
it('falls back to the raw input on a malformed hex', () => {
expect(shadeHex('not-a-color', 0.3)).toBe('not-a-color');
expect(shadeHex('#abc', 0.3)).toBe('#abc');
});
});
@@ -70,3 +70,22 @@ export function hexToArgbInt(hex: string): number {
const b = parseInt(h.slice(4, 6), 16);
return ((0xff << 24) | (r << 16) | (g << 8) | b) >>> 0;
}
/**
* Lighten (`amount > 0`) or darken (`amount < 0`) a `#rrggbb` colour by a
* fraction in `[-1, 1]`. Used to derive highlight/shadow stops for a flat,
* bright mascot body that mirrors the Rive mascot's look for any palette
* colour (including user custom colours). Falls back to the input on a
* malformed hex so a bad custom value never throws.
*/
export function shadeHex(hex: string, amount: number): string {
const h = hex.replace('#', '');
if (h.length !== 6 || /[^0-9a-fA-F]/.test(h)) return hex;
const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)));
const mix = (channel: number) =>
amount >= 0 ? channel + (255 - channel) * amount : channel * (1 + amount);
const r = clamp(mix(parseInt(h.slice(0, 2), 16)));
const g = clamp(mix(parseInt(h.slice(2, 4), 16)));
const b = clamp(mix(parseInt(h.slice(4, 6), 16)));
return `#${[r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')}`;
}
+17 -2
View File
@@ -7,6 +7,7 @@ import {
CustomGifMascot,
getMascotPalette,
hexToArgbInt,
MascotChipAvatar,
RiveMascot,
} from '../features/human/Mascot';
import { useHumanMascot } from '../features/human/useHumanMascot';
@@ -199,6 +200,12 @@ const Accounts = () => {
const order = useAppSelector(state => state.accounts.order);
const activeAccountId = useAppSelector(state => state.accounts.activeAccountId);
const unreadByAccount = useAppSelector(state => state.accounts.unread);
// Mascot identity — drives the avatar on the "Talk to Tiny" chip so the
// button advertises the user's actual character (colour / custom GIF) rather
// than a generic emoji.
const mascotColor = useAppSelector(selectMascotColor);
const customPrimary = useAppSelector(selectCustomPrimaryColor);
const customMascotGifUrl = useAppSelector(selectCustomMascotGifUrl);
const [addOpen, setAddOpen] = useState(false);
const [ctxMenu, setCtxMenu] = useState<ContextMenuState | null>(null);
@@ -407,13 +414,21 @@ const Accounts = () => {
onClick={toggleFaceMode}
data-testid="face-toggle-button"
aria-pressed={faceMode}
className={`absolute right-4 top-4 z-40 inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium shadow-soft backdrop-blur-sm transition-colors ${
className={`group absolute right-4 top-4 z-40 inline-flex items-center gap-2 rounded-full border py-1.5 pl-1.5 pr-3 text-xs font-medium shadow-soft backdrop-blur-sm transition-colors ${
faceMode
? 'border-primary-300 bg-primary-50/90 text-primary-700 dark:bg-primary-900/40 dark:text-primary-200'
: 'border-stone-300/80 bg-white/90 text-stone-600 hover:border-primary-300 hover:text-primary-600 dark:border-neutral-700/80 dark:bg-neutral-900/90 dark:text-neutral-300 dark:hover:text-primary-300'
}`}
aria-label={faceMode ? t('assistant.faceMode.turnOff') : t('assistant.faceMode.turnOn')}>
<span aria-hidden="true">🙂</span>
{/* The mascot wakes up (wiggles) on hover/focus; static at rest. */}
<span className="origin-bottom transition-transform group-hover:animate-wiggle group-focus-visible:animate-wiggle">
<MascotChipAvatar
color={mascotColor}
customPrimary={customPrimary}
gifUrl={customMascotGifUrl}
size={22}
/>
</span>
{faceMode ? t('assistant.faceMode.on') : t('assistant.faceMode.off')}
</button>
)}
@@ -62,6 +62,7 @@ vi.mock('../../features/human/Mascot', () => ({
CustomGifMascot: ({ src }: { src: string }) => (
<img data-testid="custom-gif-mascot-stub" src={src} alt="" />
),
MascotChipAvatar: () => <span data-testid="mascot-chip-avatar-stub" />,
getMascotPalette: vi.fn(() => ({ bodyFill: '#4A83DD', neckShadowColor: '#2A63BD' })),
hexToArgbInt: vi.fn((_hex: string) => 0xff4a83dd),
}));
+6
View File
@@ -238,6 +238,7 @@ module.exports = {
'glow-pulse': 'glowPulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'float': 'float 3s ease-in-out infinite',
'ticker': 'ticker 30s linear infinite',
'wiggle': 'wiggle 0.5s ease-in-out',
},
keyframes: {
@@ -277,6 +278,11 @@ module.exports = {
'0%': { transform: 'translateX(0)' },
'100%': { transform: 'translateX(-50%)' },
},
wiggle: {
'0%, 100%': { transform: 'rotate(0deg)' },
'25%': { transform: 'rotate(-9deg)' },
'75%': { transform: 'rotate(9deg)' },
},
},
// Backdrop blur for glass morphism