@electerm/electerm-react 2.3.191 → 2.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/client/common/clipboard.js +1 -1
  2. package/client/common/constants.js +2 -2
  3. package/client/common/download.jsx +3 -2
  4. package/client/common/error-handler.jsx +5 -9
  5. package/client/common/fetch-from-server.js +1 -1
  6. package/client/common/fetch.jsx +5 -5
  7. package/client/common/icon-helpers.jsx +16 -0
  8. package/client/common/parse-json-safe.js +1 -1
  9. package/client/common/pre.js +0 -7
  10. package/client/common/sftp.js +1 -1
  11. package/client/common/terminal-theme.js +1 -1
  12. package/client/common/transfer.js +2 -2
  13. package/client/common/upgrade.js +2 -2
  14. package/client/components/ai/ai-chat.jsx +10 -1
  15. package/client/components/auth/login.jsx +1 -1
  16. package/client/components/bg/css-overwrite.jsx +1 -1
  17. package/client/components/bookmark-form/common/fields.jsx +3 -0
  18. package/client/components/bookmark-form/common/ssh-agent.jsx +33 -0
  19. package/client/components/bookmark-form/config/common-fields.js +2 -4
  20. package/client/components/bookmark-form/config/serial.js +1 -1
  21. package/client/components/bookmark-form/config/ssh.js +1 -0
  22. package/client/components/bookmark-form/form-renderer.jsx +3 -2
  23. package/client/components/common/input-auto-focus.jsx +1 -1
  24. package/client/components/common/message.jsx +131 -0
  25. package/client/components/common/message.styl +58 -0
  26. package/client/components/common/modal.jsx +176 -0
  27. package/client/components/common/modal.styl +22 -0
  28. package/client/components/common/notification-with-details.jsx +1 -1
  29. package/client/components/common/notification.jsx +94 -0
  30. package/client/components/common/notification.styl +51 -0
  31. package/client/components/main/connection-hopping-warnning.jsx +1 -3
  32. package/client/components/main/error-wrapper.jsx +3 -2
  33. package/client/components/main/main.jsx +4 -11
  34. package/client/components/main/upgrade.jsx +6 -4
  35. package/client/components/profile/profile-form-elem.jsx +1 -1
  36. package/client/components/quick-commands/quick-commands-box.jsx +5 -2
  37. package/client/components/quick-commands/quick-commands-form-elem.jsx +1 -1
  38. package/client/components/rdp/rdp-session.jsx +2 -2
  39. package/client/components/session/session.jsx +4 -9
  40. package/client/components/setting-panel/deep-link-control.jsx +4 -3
  41. package/client/components/setting-panel/keyword-input.jsx +60 -0
  42. package/client/components/setting-panel/keywords-form.jsx +2 -7
  43. package/client/components/setting-panel/setting-common.jsx +1 -1
  44. package/client/components/setting-panel/setting-terminal.jsx +1 -1
  45. package/client/components/setting-panel/tab-settings.jsx +1 -1
  46. package/client/components/setting-sync/setting-sync-form.jsx +53 -3
  47. package/client/components/setting-sync/setting-sync.jsx +2 -1
  48. package/client/components/sftp/owner-list.js +6 -6
  49. package/client/components/sftp/sftp-entry.jsx +6 -4
  50. package/client/components/shortcuts/shortcut-editor.jsx +2 -2
  51. package/client/components/ssh-config/ssh-config-load-notify.jsx +3 -2
  52. package/client/components/tabs/tab.jsx +1 -1
  53. package/client/components/tabs/workspace-save-modal.jsx +2 -1
  54. package/client/components/terminal/attach-addon-custom.js +142 -26
  55. package/client/components/terminal/command-tracker-addon.js +164 -53
  56. package/client/components/terminal/highlight-addon.js +84 -43
  57. package/client/components/terminal/shell.js +138 -0
  58. package/client/components/terminal/term-search.styl +1 -0
  59. package/client/components/terminal/terminal-command-dropdown.jsx +3 -0
  60. package/client/components/terminal/terminal.jsx +166 -104
  61. package/client/components/theme/theme-form.jsx +2 -1
  62. package/client/components/tree-list/bookmark-transport.jsx +27 -5
  63. package/client/components/vnc/vnc-session.jsx +1 -1
  64. package/client/components/widgets/widget-notification-with-details.jsx +1 -1
  65. package/client/store/common.js +5 -2
  66. package/client/store/db-upgrade.js +1 -1
  67. package/client/store/init-state.js +2 -1
  68. package/client/store/load-data.js +2 -2
  69. package/client/store/mcp-handler.js +9 -56
  70. package/client/store/setting.js +1 -3
  71. package/client/store/store.js +2 -1
  72. package/client/store/sync.js +14 -8
  73. package/client/store/system-menu.js +2 -1
  74. package/client/store/tab.js +1 -1
  75. package/client/store/widgets.js +1 -3
  76. package/package.json +1 -1
  77. package/client/common/track.js +0 -7
  78. package/client/components/batch-op/batch-op-entry.jsx +0 -13
@@ -2,7 +2,7 @@
2
2
  * clipboard related
3
3
  */
4
4
 
5
- import { message } from 'antd'
5
+ import message from '../components/common/message'
6
6
 
7
7
  const fileRegWin = /^(remote:)?\w:\\.+/
8
8
  const fileReg = /^(remote:)?\/.+/
@@ -145,6 +145,7 @@ export const terminalLocalType = 'local'
145
145
  export const terminalFtpType = 'ftp'
146
146
  export const openedSidebarKey = 'opened-sidebar'
147
147
  export const sidebarPinnedKey = 'sidebar-pinned'
148
+ export const pinnedQuickCommandBarKey = 'pinned-quick-command-bar'
148
149
  export const leftSidebarWidthKey = 'left-sidebar-width'
149
150
  export const rightSidebarWidthKey = 'right-sidebar-width'
150
151
  export const addPanelWidthLsKey = 'addPanelWidth'
@@ -270,8 +271,7 @@ export const instSftpKeys = [
270
271
  'readFile',
271
272
  'writeFile'
272
273
  ]
273
- export const cwdId = '=__+__'
274
- export const zmodemTransferPackSize = 1024 * 1024 * 2
274
+ export const zmodemTransferPackSize = 1024 * 8
275
275
  export const splitMap = {
276
276
  c1: 'c1',
277
277
  c2: 'c2',
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * simulate download
3
3
  */
4
- import { notification } from 'antd'
4
+ import { notification } from '../components/common/notification'
5
5
  import ShowItem from '../components/common/show-item'
6
6
  import { chooseSaveDirectory } from './choose-save-folder'
7
+ import { DownloadOutlined } from '@ant-design/icons'
7
8
 
8
9
  function downloadForBrowser (filename, text) {
9
10
  const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
@@ -32,7 +33,7 @@ export default async function download (filename, text) {
32
33
  return
33
34
  }
34
35
  notification.success({
35
- title: '',
36
+ message: <DownloadOutlined />,
36
37
  description: (
37
38
  <ShowItem
38
39
  to={filePath}
@@ -2,25 +2,21 @@
2
2
  * common error handler
3
3
  */
4
4
 
5
- import { notification } from 'antd'
5
+ import { notification } from '../components/common/notification'
6
6
 
7
7
  export default (e) => {
8
8
  const { message = 'error', stack } = e
9
- log.error(e)
10
- const msg = (
11
- <div className='mw240 elli wordbreak' title={message}>
12
- {message}
13
- </div>
14
- )
9
+ console.error(e)
15
10
  const description = (
16
11
  <div
17
- className='mw300 elli common-err-desc wordbreak'
12
+ className='common-err-desc'
13
+ title={stack}
18
14
  >
19
15
  {stack}
20
16
  </div>
21
17
  )
22
18
  notification.error({
23
- title: msg,
19
+ message,
24
20
  description,
25
21
  duration: 55
26
22
  })
@@ -35,7 +35,7 @@ const wsFetch = async (data) => {
35
35
  return new NewPromise((resolve, reject) => {
36
36
  window.et.commonWs.once((arg) => {
37
37
  if (arg.error) {
38
- log.error('fetch error', arg.error)
38
+ console.error('fetch error', arg.error)
39
39
  return reject(new Error(arg.error.message))
40
40
  }
41
41
  resolve(arg.data)
@@ -1,6 +1,6 @@
1
1
  // the final fetch wrapper
2
2
  import { isString, isFunction } from 'lodash-es'
3
- import { notification } from 'antd'
3
+ import { notification } from '../components/common/notification'
4
4
 
5
5
  function jsonHeader () {
6
6
  return {
@@ -17,7 +17,7 @@ function parseResponse (response) {
17
17
  }
18
18
 
19
19
  export async function handleErr (res) {
20
- log.debug(res)
20
+ console.debug(res)
21
21
  let text = res.message || res.statusText
22
22
  if (!isString(text)) {
23
23
  try {
@@ -25,12 +25,12 @@ export async function handleErr (res) {
25
25
  ? await res.text()
26
26
  : isFunction(res.json) ? await res.json() : ''
27
27
  } catch (e) {
28
- log.error('fetch response parse fails', e)
28
+ console.error('fetch response parse fails', e)
29
29
  }
30
30
  }
31
- log.debug(text, 'fetch err info')
31
+ console.debug(text, 'fetch err info')
32
32
  notification.error({
33
- title: 'error',
33
+ message: 'http request error',
34
34
  description: (
35
35
  <div className='common-err'>
36
36
  {text}
@@ -0,0 +1,16 @@
1
+ import React from 'react'
2
+ import {
3
+ InfoCircleFilled,
4
+ CheckCircleFilled,
5
+ ExclamationCircleFilled,
6
+ CloseCircleFilled
7
+ } from '@ant-design/icons'
8
+
9
+ export const messageIcons = {
10
+ info: <InfoCircleFilled className='msg-icon info' />,
11
+ success: <CheckCircleFilled className='msg-icon success' />,
12
+ warning: <ExclamationCircleFilled className='msg-icon warning' />,
13
+ error: <CloseCircleFilled className='msg-icon error' />
14
+ }
15
+
16
+ export const getMessageIcon = (type) => messageIcons[type] || messageIcons.info
@@ -5,7 +5,7 @@ export default str => {
5
5
  try {
6
6
  return JSON.parse(str)
7
7
  } catch (e) {
8
- log.error('JSON.parse fails', e.stack)
8
+ console.error('JSON.parse fails', e.stack)
9
9
  return str
10
10
  }
11
11
  }
@@ -38,13 +38,6 @@ function decodeBase64String (base64String) {
38
38
  return uint8Array
39
39
  }
40
40
 
41
- window.log = {
42
- debug: (...args) => runSync('debug', ...args),
43
- log: (...args) => runSync('log', ...args),
44
- error: (...args) => runSync('error', ...args),
45
- info: (...args) => runSync('info', ...args)
46
- }
47
-
48
41
  window.pre = {
49
42
  requireAuth: runSync('shouldAuth'),
50
43
  readClipboard: () => {
@@ -52,7 +52,7 @@ class Sftp {
52
52
  })
53
53
  ws.once((arg) => {
54
54
  if (arg.error) {
55
- log.debug('sftp error', arg.error.message)
55
+ console.debug('sftp error', arg.error.message)
56
56
  return reject(new Error(arg.error.message))
57
57
  }
58
58
  resolve(arg.data)
@@ -141,7 +141,7 @@ export const exportTheme = (themeId) => {
141
141
  const themes = window.store.getSidebarList(settingMap.terminalThemes)
142
142
  const theme = themes.find(d => d.id === themeId)
143
143
  if (!theme) {
144
- log.error('export error', themeId)
144
+ console.error('export error', themeId)
145
145
  return
146
146
  }
147
147
  const text = convertThemeToText(theme, true)
@@ -57,8 +57,8 @@ class Transfer {
57
57
  th.onDestroy(ws)
58
58
  }, 'transfer:end:' + id)
59
59
  ws.once((arg) => {
60
- log.debug('sftp transfer error')
61
- log.debug(arg.error.stack)
60
+ console.debug('sftp transfer error')
61
+ console.debug(arg.error.stack)
62
62
  onError(new Error(arg.error.message))
63
63
  th.onDestroy(ws)
64
64
  }, 'transfer:err:' + id)
@@ -49,8 +49,8 @@ class Upgrade {
49
49
  onEnd(arg)
50
50
  }, 'upgrade:end:' + id)
51
51
  ws.once((arg) => {
52
- log.debug('upgrade error')
53
- log.debug(arg.error.stack)
52
+ console.debug('upgrade error')
53
+ console.debug(arg.error.stack)
54
54
  onError(new Error(arg.error.message))
55
55
  }, 'upgrade:err:' + id)
56
56
  }
@@ -185,7 +185,7 @@ export default function AIChat (props) {
185
185
  <SendOutlined
186
186
  onClick={handleSubmit}
187
187
  className='mg1l pointer icon-hover send-to-ai-icon'
188
- title='Ctrl+Enter'
188
+ title='Enter to send, Shift+Enter for new line'
189
189
  />
190
190
  )
191
191
  }
@@ -207,6 +207,14 @@ export default function AIChat (props) {
207
207
  return null
208
208
  }
209
209
 
210
+ const handleKeyPress = (e) => {
211
+ if (!e.shiftKey) {
212
+ e.preventDefault()
213
+ handleSubmit()
214
+ }
215
+ // If Shift+Enter, allow default behavior (new line)
216
+ }
217
+
210
218
  return (
211
219
  <Flex vertical className='ai-chat-container'>
212
220
  <Flex className='ai-chat-history' flex='auto'>
@@ -217,6 +225,7 @@ export default function AIChat (props) {
217
225
  <TextArea
218
226
  value={prompt}
219
227
  onChange={handlePromptChange}
228
+ onPressEnter={handleKeyPress}
220
229
  placeholder='Enter your prompt here'
221
230
  autoSize={{ minRows: 3, maxRows: 10 }}
222
231
  disabled={isLoading}
@@ -2,9 +2,9 @@ import React, { useState, useEffect } from 'react'
2
2
  import LogoElem from '../common/logo-elem.jsx'
3
3
  import store from '../../store'
4
4
  import {
5
- message,
6
5
  Spin
7
6
  } from 'antd'
7
+ import message from '../common/message'
8
8
  import {
9
9
  ArrowRightOutlined
10
10
  } from '@ant-design/icons'
@@ -81,7 +81,7 @@ export default class CssOverwrite extends Component {
81
81
  st = 'text'
82
82
  } else if (imagePath && !isWebImg) {
83
83
  content = await fs.readFileAsBase64(imagePath)
84
- .catch(log.error)
84
+ .catch(console.error)
85
85
  if (content) {
86
86
  st = `url(data:image;base64,${content})`
87
87
  }
@@ -10,6 +10,7 @@ import InputAutoFocus from '../../common/input-auto-focus.jsx'
10
10
  import ProxyField from './proxy.jsx'
11
11
  import X11Field from './x11.jsx'
12
12
  import SshTunnels from './ssh-tunnels.jsx'
13
+ import SshAgent from './ssh-agent.jsx'
13
14
  import ConnectionHopping from './connection-hopping.jsx'
14
15
  import TerminalBackgroundField from './terminal-background.jsx'
15
16
  import useQuickCmds from './quick-commands.jsx'
@@ -145,6 +146,8 @@ export function renderFormItem (item, formItemLayout, form, ctxProps, index) {
145
146
  return <X11Field key={name} form={form} />
146
147
  case 'sshTunnels':
147
148
  return <SshTunnels key={name} form={form} formData={ctxProps.formData} />
149
+ case 'sshAgent':
150
+ return <SshAgent key={name} />
148
151
  case 'connectionHopping':
149
152
  return (
150
153
  <ConnectionHopping
@@ -0,0 +1,33 @@
1
+ import React from 'react'
2
+ import { Form, Input, Switch, Space } from 'antd'
3
+ import HelpIcon from '../../common/help-icon'
4
+ import { formItemLayout } from '../../../common/form-layout'
5
+
6
+ const FormItem = Form.Item
7
+ const e = window.translate
8
+
9
+ export default function SshAgent () {
10
+ return (
11
+ <FormItem
12
+ {...formItemLayout}
13
+ label={e('useSshAgent')}
14
+ >
15
+ <Space align='center'>
16
+ <FormItem
17
+ name='useSshAgent'
18
+ valuePropName='checked'
19
+ noStyle
20
+ >
21
+ <Switch />
22
+ </FormItem>
23
+ <FormItem
24
+ name='sshAgent'
25
+ noStyle
26
+ >
27
+ <Input placeholder={e('SSH Agent Path')} />
28
+ </FormItem>
29
+ <HelpIcon link='https://github.com/electerm/electerm/wiki/ssh-agent' />
30
+ </Space>
31
+ </FormItem>
32
+ )
33
+ }
@@ -272,10 +272,8 @@ export const sshAuthFields = [
272
272
  { type: 'sshAuthSelector', name: '__auth__', label: '', formItemName: 'password' },
273
273
  commonFields.port,
274
274
  {
275
- type: 'switch',
276
- name: 'useSshAgent',
277
- label: () => e('useSshAgent'),
278
- valuePropName: 'checked'
275
+ type: 'sshAgent',
276
+ name: 'useSshAgent'
279
277
  },
280
278
  commonFields.runScripts,
281
279
  commonFields.description,
@@ -40,7 +40,7 @@ const serialConfig = {
40
40
  type: 'autocomplete',
41
41
  name: 'baudRate',
42
42
  label: 'baudRate',
43
- options: commonBaudRates.map(d => ({ value: d })),
43
+ options: commonBaudRates.map(d => ({ value: d.toString(), label: d.toString() })),
44
44
  normalize: (value) => {
45
45
  if (value === '' || value == null) {
46
46
  return undefined
@@ -21,6 +21,7 @@ const sshConfig = {
21
21
  sshTunnels: [],
22
22
  connectionHoppings: [],
23
23
  useSshAgent: true,
24
+ sshAgent: '',
24
25
  serverHostKey: [],
25
26
  cipher: [],
26
27
  ...getTerminalDefaults(store),
@@ -2,7 +2,8 @@
2
2
  * Generic form renderer driven by config (flattened path)
3
3
  */
4
4
  import React, { useEffect, useState, useRef } from 'react'
5
- import { Form, Tabs, message } from 'antd'
5
+ import { Form, Tabs } from 'antd'
6
+ import message from '../common/message'
6
7
  import { renderFormItem } from './common/fields'
7
8
  import SubmitButtons from './common/submit-buttons'
8
9
  import { uniq } from 'lodash-es'
@@ -260,7 +261,7 @@ export default function FormRenderer ({ config, props }) {
260
261
  }
261
262
  const ips = await window.pre.runGlobalAsync('lookup', value)
262
263
  .catch(err => {
263
- log.debug(err)
264
+ console.debug(err)
264
265
  })
265
266
  setIps(ips || [])
266
267
  }
@@ -24,7 +24,7 @@ export default function InputAutoFocus (props) {
24
24
  inputRef.current.focus()
25
25
  }
26
26
  }
27
- }, [props.value, props.selectall])
27
+ }, [props.selectall])
28
28
 
29
29
  let InputComponent
30
30
  switch (type) {
@@ -0,0 +1,131 @@
1
+ import React, { useState, useEffect } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import {
4
+ CloseOutlined
5
+ } from '@ant-design/icons'
6
+ import classnames from 'classnames'
7
+ import generateId from '../../common/uid'
8
+ import { messageIcons } from '../../common/icon-helpers.jsx'
9
+ import './message.styl'
10
+
11
+ let messageContainerRoot = null
12
+ const messageObservers = []
13
+
14
+ const subscribe = (onUpdate) => {
15
+ messageObservers.push(onUpdate)
16
+ return () => {
17
+ const index = messageObservers.indexOf(onUpdate)
18
+ if (index > -1) messageObservers.splice(index, 1)
19
+ }
20
+ }
21
+
22
+ const notify = (messages) => {
23
+ messageObservers.forEach(onUpdate => onUpdate([...messages]))
24
+ }
25
+
26
+ let activeMessages = []
27
+
28
+ function MessageItem ({ id, type, content, duration, onRemove, timestamp }) {
29
+ useEffect(() => {
30
+ if (duration !== 0) {
31
+ const timer = setTimeout(onRemove, duration * 1000)
32
+ return () => clearTimeout(timer)
33
+ }
34
+ }, [duration, onRemove, timestamp])
35
+
36
+ return (
37
+ <div className={classnames('message-item', type)} id={`message-${id}`}>
38
+ <div className='message-content-wrap'>
39
+ {messageIcons[type]}
40
+ <div className='message-content'>{content}</div>
41
+ <CloseOutlined className='message-close' onClick={onRemove} />
42
+ </div>
43
+ </div>
44
+ )
45
+ }
46
+
47
+ function MessageContainer () {
48
+ const [messages, setMessages] = useState(activeMessages)
49
+
50
+ useEffect(() => {
51
+ return subscribe(setMessages)
52
+ }, [])
53
+
54
+ const removeMessage = (id, onClose) => {
55
+ activeMessages = activeMessages.filter(m => m.id !== id)
56
+ notify(activeMessages)
57
+ if (typeof onClose === 'function') {
58
+ onClose()
59
+ }
60
+ }
61
+
62
+ return (
63
+ <div className='message-container'>
64
+ {messages.map(msg => {
65
+ const { key, ...props } = msg
66
+ return (
67
+ <MessageItem key={msg.id} {...props} onRemove={() => removeMessage(msg.id, msg.onClose)} />
68
+ )
69
+ })}
70
+ </div>
71
+ )
72
+ }
73
+
74
+ const init = () => {
75
+ if (messageContainerRoot || typeof document === 'undefined') return
76
+ const div = document.createElement('div')
77
+ div.id = 'message-root'
78
+ document.body.appendChild(div)
79
+ messageContainerRoot = createRoot(div)
80
+ messageContainerRoot.render(<MessageContainer />)
81
+ }
82
+
83
+ const addMessage = (type, content, duration = 3, onClose) => {
84
+ let config = {
85
+ content,
86
+ duration,
87
+ onClose,
88
+ type
89
+ }
90
+ if (typeof content === 'object' && content !== null && !React.isValidElement(content)) {
91
+ config = {
92
+ ...config,
93
+ ...content,
94
+ type: content.type || type
95
+ }
96
+ }
97
+ init()
98
+ const id = config.key || generateId()
99
+ const existingIndex = activeMessages.findIndex(m => m.id === id)
100
+ const newMessage = {
101
+ ...config,
102
+ id,
103
+ timestamp: Date.now()
104
+ }
105
+ if (existingIndex > -1) {
106
+ activeMessages[existingIndex] = newMessage
107
+ } else {
108
+ activeMessages = [...activeMessages, newMessage]
109
+ }
110
+ notify(activeMessages)
111
+ return {
112
+ id,
113
+ destroy: () => {
114
+ activeMessages = activeMessages.filter(m => m.id !== id)
115
+ notify(activeMessages)
116
+ }
117
+ }
118
+ }
119
+
120
+ const message = {
121
+ info: (content, duration) => addMessage('info', content, duration),
122
+ success: (content, duration) => addMessage('success', content, duration),
123
+ warning: (content, duration) => addMessage('warning', content, duration),
124
+ error: (content, duration) => addMessage('error', content, duration),
125
+ destroy: () => {
126
+ activeMessages = []
127
+ notify(activeMessages)
128
+ }
129
+ }
130
+
131
+ export default message
@@ -0,0 +1,58 @@
1
+ .message-container
2
+ position fixed
3
+ top 20px
4
+ left 50%
5
+ transform translateX(-50%)
6
+ z-index 10000
7
+ pointer-events none
8
+
9
+ .message-item
10
+ pointer-events all
11
+ margin-bottom 16px
12
+ padding 8px 16px
13
+ border-radius 4px
14
+ background var(--main-lighter)
15
+ color var(--text)
16
+ box-shadow 0 4px 12px rgba(0,0,0,0.15)
17
+ display flex
18
+ align-items center
19
+ animation message-fade-in 0.3s ease
20
+ position relative
21
+ justify-content center
22
+ &:hover
23
+ .message-close
24
+ opacity 1
25
+
26
+ .message-content-wrap
27
+ display flex
28
+ align-items center
29
+
30
+ .msg-icon
31
+ margin-right 8px
32
+ font-size 16px
33
+ &.info
34
+ color var(--info)
35
+ &.success
36
+ color var(--success)
37
+ &.warning
38
+ color var(--warn)
39
+ &.error
40
+ color var(--error)
41
+
42
+ .message-close
43
+ margin-left 8px
44
+ cursor pointer
45
+ font-size 12px
46
+ color var(--text-dark)
47
+ opacity 0
48
+ transition opacity 0.2s
49
+ &:hover
50
+ color var(--text-light)
51
+
52
+ @keyframes message-fade-in
53
+ from
54
+ opacity 0
55
+ transform translateY(-20px)
56
+ to
57
+ opacity 1
58
+ transform translateY(0)