mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(assistant): show the user's mascot on the "Talk to Tiny" chip (#3582)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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,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('')}`;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user