11// The Botloft mascot in the bot's own color (spec 15.3). The color only
22// tells bots apart; state is shown elsewhere with an icon and a label.
33//
4- // A bot's avatar has no background: just the mascot, with a thin edge so
5- // light colors keep their shape on the light theme. `framed` puts the
6- // mascot on the black square of the app icon, for Botloft itself (the
7- // title bar, messages from the daemon), whose white mascot needs it.
4+ // The artwork (mascotArt.ts) is a flat body with soft shading layers clipped
5+ // to its outline, re-tinted as a whole to the bot's color. A bot's avatar
6+ // has no background: just the mascot, with a thin edge so light colors keep
7+ // their shape on the light theme. `framed` puts the mascot on the black
8+ // square of the app icon, for Botloft itself (the title bar, messages from
9+ // the daemon), whose white mascot needs it.
810//
9- // Given a `mood`, the mascot moves (motion.css): it blinks now and then,
10- // flickers while the bot works, hops while it waits for the owner and
11- // sleeps while paused. Without one it stays still, as in the chat history.
11+ // Given a `mood`, the mascot comes alive (mascot.css): its flame burns,
12+ // gently when idle and wildly, throwing embers, while it works; it hops
13+ // while it waits for the owner, burns low with heavy eyes when tired and
14+ // sleeps with its eyes shut while paused. Without one it stays still, as in
15+ // the chat history.
1216
13- import type { CSSProperties } from "react" ;
17+ import { type CSSProperties , useId , useMemo } from "react" ;
1418import type { Bot } from "../../lib/protocol.gen" ;
15-
16- const BODY =
17- "M468 58C600 105 730 180 800 290C830 340 845 400 870 440C885 465 915 475 930 455C948 430 945 400 938 378C1060 440 1170 600 1210 800C1240 950 1235 1120 1190 1254L70 1254L60 1254C10 1130 0 950 60 800C100 700 150 650 180 590C210 520 220 440 260 385C290 340 330 312 372 292C350 330 340 370 348 390C355 410 380 412 395 398C440 355 500 300 510 220C518 150 495 100 468 58Z" ;
19+ import {
20+ BODY ,
21+ CENTER ,
22+ type Ellipse ,
23+ EMBER ,
24+ EYE_GLOW ,
25+ EYES ,
26+ HIGHLIGHTS ,
27+ OUTLINE ,
28+ SHADES ,
29+ SPARKS ,
30+ VIEW_BOX ,
31+ } from "./mascotArt" ;
32+ import { retint } from "./retint" ;
1833
1934export type Mood = "idle" | "working" | "waiting" | "tired" | "sleeping" ;
2035
@@ -45,6 +60,14 @@ function blinkDelay(color: string): string {
4560 return `-${ hash } ms` ;
4661}
4762
63+ /** The glints that sit on `eye`. */
64+ function glintsOn ( eye : Ellipse ) : Ellipse [ ] {
65+ const reach = Math . max ( eye . rx , eye . ry ) * 1.3 ;
66+ return HIGHLIGHTS . filter ( ( glint ) => Math . hypot ( glint . x - eye . x , glint . y - eye . y ) < reach ) ;
67+ }
68+
69+ const rotate = ( { x, y, turn } : Ellipse ) => `rotate(${ turn } ${ x } ${ y } )` ;
70+
4871export function BotAvatar ( {
4972 color,
5073 size = 28 ,
@@ -56,43 +79,121 @@ export function BotAvatar({
5679 framed ?: boolean ;
5780 mood ?: Mood | undefined ;
5881} ) {
82+ // Every avatar on the page needs its own clip, blur and gradients.
83+ const unique = useId ( ) . replace ( / [ ^ \w - ] / g, "" ) ;
84+ const id = ( name : string ) => `avatar-${ name } -${ unique } ` ;
85+ const paint = useMemo (
86+ ( ) => ( {
87+ body : retint ( BODY , color ) ,
88+ shades : SHADES . map ( ( shade ) => retint ( shade . fill , color ) ) ,
89+ emberLight : retint ( EMBER . light , color ) ,
90+ emberBase : retint ( EMBER . base , color ) ,
91+ glow : retint ( EYE_GLOW , color ) ,
92+ } ) ,
93+ [ color ] ,
94+ ) ;
5995 const style = mood ? ( { "--blink-delay" : blinkDelay ( color ) } as CSSProperties ) : undefined ;
96+ const box = framed ? [ CENTER . x - 500 , CENTER . y - 470 , 1000 , 1000 ] : VIEW_BOX ;
6097 return (
6198 < svg
6299 aria-hidden
63100 width = { size }
64101 height = { size }
65- // A little room around the body, so its edge is not cut at the sides
66- // and a hop stays inside.
67- viewBox = { framed ? "0 0 1254 1254" : "-24 -24 1302 1302" }
102+ viewBox = { box . join ( " " ) }
68103 className = { `shrink-0 overflow-visible ${ framed ? "rounded-lg bg-[#0b0b0b]" : "" } ` }
69104 data-mood = { mood }
70105 style = { style }
71106 >
72- < g className = "avatar-flame" >
73- < path
74- d = { BODY }
75- fill = { color }
76- stroke = { framed ? undefined : "var(--avatar-edge)" }
77- strokeWidth = { framed ? undefined : 1 }
78- vectorEffect = "non-scaling-stroke"
79- />
80- < g className = "avatar-eye" >
81- < ellipse cx = "537" cy = "862" rx = "112" ry = "110" fill = "#0b0b0b" />
82- < ellipse cx = "546" cy = "811" rx = "33" ry = "29" fill = "#ffffff" />
83- </ g >
84- < g className = "avatar-eye" >
85- < ellipse
86- cx = "945"
87- cy = "768"
88- rx = "98"
89- ry = "104"
90- transform = "rotate(-14 945 768)"
91- fill = "#0b0b0b"
107+ < defs >
108+ < clipPath id = { id ( "clip" ) } >
109+ < path className = "avatar-fire" d = { OUTLINE } />
110+ </ clipPath >
111+ < filter id = { id ( "soft" ) } x = "-10%" y = "-10%" width = "120%" height = "120%" >
112+ < feGaussianBlur stdDeviation = "4" />
113+ </ filter >
114+ < linearGradient id = { id ( "ember" ) } x1 = "0" y1 = "0" x2 = "0" y2 = "1" >
115+ < stop offset = "0" stopColor = { paint . emberLight } />
116+ < stop offset = "1" stopColor = { paint . emberBase } />
117+ </ linearGradient >
118+ < radialGradient id = { id ( "eye" ) } cx = "0.36" cy = "0.95" r = "0.75" >
119+ < stop offset = "0" stopColor = { paint . glow } />
120+ < stop offset = "0.3" stopColor = "#3a0f05" />
121+ < stop offset = "1" stopColor = "#120503" />
122+ </ radialGradient >
123+ </ defs >
124+ < g className = "avatar-hop" >
125+ { ! framed && (
126+ < path
127+ className = "avatar-fire"
128+ d = { OUTLINE }
129+ fill = "none"
130+ stroke = "var(--avatar-edge)"
131+ strokeWidth = { 2 }
132+ vectorEffect = "non-scaling-stroke"
92133 />
93- < ellipse cx = "934" cy = "720" rx = "30" ry = "26" fill = "#ffffff" />
134+ ) }
135+ < g clipPath = { `url(#${ id ( "clip" ) } )` } >
136+ < rect x = { - 200 } y = { 0 } width = { 1000 } height = { 900 } fill = { paint . body } />
137+ < g filter = { `url(#${ id ( "soft" ) } )` } >
138+ { SHADES . map ( ( shade , index ) => (
139+ < path key = { shade . fill } d = { shade . d } fill = { paint . shades [ index ] } />
140+ ) ) }
141+ </ g >
94142 </ g >
143+ { mood === "working" &&
144+ SPARKS . map ( ( spark ) => (
145+ < g key = { spark . x } transform = { `translate(${ spark . x } ${ spark . y } ) scale(1.5)` } >
146+ < path
147+ className = "avatar-spark"
148+ d = { EMBER . d }
149+ fill = { `url(#${ id ( "ember" ) } )` }
150+ style = {
151+ {
152+ "--spark-delay" : spark . delay ,
153+ "--spark-drift" : spark . drift ,
154+ "--spark-turn" : spark . turn ,
155+ } as CSSProperties
156+ }
157+ />
158+ </ g >
159+ ) ) }
160+ { EYES . map ( ( eye , index ) => (
161+ < Eye key = { eye . x } eye = { eye } side = { index === 0 ? "l" : "r" } fill = { `url(#${ id ( "eye" ) } )` } />
162+ ) ) }
95163 </ g >
96164 </ svg >
97165 ) ;
98166}
167+
168+ /** One eye: open with its glints, or shut into a soft arc while asleep. */
169+ function Eye ( { eye, side, fill } : { eye : Ellipse ; side : "l" | "r" ; fill : string } ) {
170+ const { x, y, rx, ry } = eye ;
171+ return (
172+ < g >
173+ < g className = { `avatar-eye avatar-eye-${ side } ` } >
174+ < ellipse cx = { x } cy = { y } rx = { rx } ry = { ry } transform = { rotate ( eye ) } fill = { fill } />
175+ < g className = "avatar-hl" >
176+ { glintsOn ( eye ) . map ( ( glint ) => (
177+ < ellipse
178+ key = { glint . x }
179+ cx = { glint . x }
180+ cy = { glint . y }
181+ rx = { glint . rx }
182+ ry = { glint . ry }
183+ transform = { rotate ( glint ) }
184+ fill = "#ffffff"
185+ />
186+ ) ) }
187+ </ g >
188+ </ g >
189+ < path
190+ className = "avatar-closed"
191+ d = { `M${ x - rx * 0.8 } ${ y - 6 } Q${ x } ${ y + ry * 0.62 } ${ x + rx * 0.8 } ${ y - 6 } ` }
192+ fill = "none"
193+ stroke = "#2a0d05"
194+ strokeWidth = { 22 }
195+ strokeLinecap = "round"
196+ />
197+ </ g >
198+ ) ;
199+ }
0 commit comments