@rpg-engine/long-bow 0.8.235 → 0.8.236

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.
@@ -1,11 +1,11 @@
1
- import { IShortcut, ISpell } from '@rpg-engine/shared';
2
- import React, { Fragment, useMemo, useState } from 'react';
3
- import styled from 'styled-components';
4
- import { uiFonts } from '../../constants/uiFonts';
5
- import { DraggableContainer } from '../DraggableContainer';
6
- import { Input } from '../Input';
7
- import { RPGUIContainerTypes } from '../RPGUI/RPGUIContainer';
1
+ import { IShortcut, ISpell, ItemRarities } from '@rpg-engine/shared';
2
+ import React, { useMemo, useRef, useState } from 'react';
3
+ import Draggable from 'react-draggable';
4
+ import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
5
+ import styled, { css } from 'styled-components';
6
+ import { uiColors } from '../../constants/uiColors';
8
7
  import { ShortcutsSetter } from '../Shortcuts/ShortcutsSetter';
8
+ import BookImg from '../StaticBook/img/0.png';
9
9
  import { Spell } from './Spell';
10
10
 
11
11
  export interface ISpellbookProps {
@@ -26,129 +26,425 @@ export interface ISpellbookProps {
26
26
  iconAtlasJSON?: any;
27
27
  scale?: number;
28
28
  spellCooldowns?: Record<string, number>;
29
+ // Rarity each learned spell was learned at, keyed by spell key (the `learnedSpellRarities` of the
30
+ // learned-spells event). Spells without an entry have no known rarity and show no badge.
31
+ learnedSpellRarities?: Partial<Record<string, ItemRarities>>;
29
32
  }
30
33
 
34
+ // Two rows of four cards: two per physical page on the art book.
35
+ const PAGE_SIZE = 8;
36
+
31
37
  export const Spellbook: React.FC<ISpellbookProps> = ({
32
- onClose,
33
- onInputFocus,
34
- onInputBlur,
35
- spells,
36
- magicLevel,
37
- mana,
38
- charSkillLevels,
39
- onSpellClick,
40
- setSpellShortcut,
41
- shortcuts,
42
- removeShortcut,
43
- atlasIMG,
44
- atlasJSON,
45
- iconAtlasIMG,
46
- iconAtlasJSON,
47
- scale,
48
- spellCooldowns,
38
+ onClose, onInputFocus, onInputBlur, spells, magicLevel, mana, charSkillLevels,
39
+ onSpellClick, setSpellShortcut, shortcuts, removeShortcut, atlasIMG, atlasJSON,
40
+ iconAtlasIMG, iconAtlasJSON, scale = 1, spellCooldowns, learnedSpellRarities,
49
41
  }) => {
50
42
  const [search, setSearch] = useState('');
43
+ const [currentPage, setCurrentPage] = useState(0);
51
44
  const [settingShortcutIndex, setSettingShortcutIndex] = useState(-1);
45
+ const bookRef = useRef<HTMLDivElement>(null);
46
+ const filteredSpells = useMemo(() => {
47
+ const query = search.trim().toLocaleLowerCase();
48
+ return [...spells]
49
+ .sort((a, b) => (a.minSkillLevelRequired ?? a.minMagicLevelRequired) - (b.minSkillLevelRequired ?? b.minMagicLevelRequired))
50
+ .filter(spell => `${spell.name} ${spell.magicWords}`.toLocaleLowerCase().includes(query));
51
+ }, [spells, search]);
52
+ const pageCount = Math.max(1, Math.ceil(filteredSpells.length / PAGE_SIZE));
53
+ const page = Math.min(currentPage, pageCount - 1);
54
+ const visibleSpells = filteredSpells.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
55
+ const assigning = settingShortcutIndex !== -1;
52
56
 
53
- const spellsToDisplay = useMemo(() => {
54
- return spells
55
- .sort((a, b) => {
56
- if (a.minMagicLevelRequired > b.minMagicLevelRequired) return 1;
57
- if (a.minMagicLevelRequired < b.minMagicLevelRequired) return -1;
58
- return 0;
59
- })
60
- .filter(
61
- spell =>
62
- spell.name.toLocaleLowerCase().includes(search.toLocaleLowerCase()) ||
63
- spell.magicWords
64
- .toLocaleLowerCase()
65
- .includes(search.toLocaleLowerCase())
66
- );
67
- }, [search, spells]);
68
-
69
- const setShortcut = (spellKey: string) => {
70
- setSpellShortcut?.(spellKey, settingShortcutIndex);
71
- setSettingShortcutIndex(-1);
57
+ const selectSpell = (key: string): void => {
58
+ if (assigning) {
59
+ setSpellShortcut(key, settingShortcutIndex);
60
+ setSettingShortcutIndex(-1);
61
+ } else {
62
+ onSpellClick(key);
63
+ }
72
64
  };
73
65
 
74
66
  return (
75
- <DraggableContainer
76
- type={RPGUIContainerTypes.Framed}
77
- onCloseButton={onClose}
78
- width="inherit"
79
- height="inherit"
80
- cancelDrag="#spellbook-search, #shortcuts_list, .spell"
81
- scale={scale}
82
- >
83
- <Container>
84
- <Title>Learned Spells</Title>
85
-
86
- <ShortcutsSetter
87
- setSettingShortcutIndex={setSettingShortcutIndex}
88
- settingShortcutIndex={settingShortcutIndex}
89
- shortcuts={shortcuts}
90
- removeShortcut={removeShortcut}
91
- atlasIMG={atlasIMG}
92
- atlasJSON={atlasJSON}
93
- />
94
-
95
- <Input
96
- placeholder="Search for spell"
97
- value={search}
98
- onChange={e => setSearch(e.target.value)}
99
- onFocus={onInputFocus}
100
- onBlur={onInputBlur}
101
- id="spellbook-search"
102
- />
103
-
104
- <SpellList>
105
- {spellsToDisplay.map(spell => (
106
- <Fragment key={spell.key}>
107
- <Spell
108
- atlasIMG={iconAtlasIMG}
109
- atlasJSON={iconAtlasJSON}
110
- charMana={mana}
111
- charMagicLevel={magicLevel}
112
- charSkillLevels={charSkillLevels}
113
- onPointerUp={
114
- settingShortcutIndex !== -1 ? setShortcut : onSpellClick
115
- }
116
- spellKey={spell.key}
117
- isSettingShortcut={settingShortcutIndex !== -1}
118
- spell={spell}
119
- activeCooldown={
120
- spellCooldowns?.[spell.magicWords.replaceAll(' ', '_')]
121
- }
122
- {...spell}
123
- />
124
- </Fragment>
125
- ))}
126
- </SpellList>
127
- </Container>
128
- </DraggableContainer>
67
+ <Draggable nodeRef={bookRef} handle=".spellbook-handle" cancel="button, input" scale={scale}>
68
+ <Book ref={bookRef} aria-label="Spellbook">
69
+ <Pages>
70
+ <Header className="spellbook-handle">
71
+ <HeaderLeft>
72
+ <div><Eyebrow>Your grimoire</Eyebrow><Title>Spellbook</Title></div>
73
+ <SearchLabel>
74
+ <span>Find a spell</span>
75
+ <Search type="search" aria-label="Search spells" placeholder="Name or magic words…"
76
+ value={search} onFocus={onInputFocus} onBlur={onInputBlur}
77
+ onChange={event => { setSearch(event.target.value); setCurrentPage(0); }} />
78
+ </SearchLabel>
79
+ </HeaderLeft>
80
+ <HeaderRight>
81
+ <Count aria-live="polite">
82
+ {search.trim() ? `${filteredSpells.length} of ${spells.length} spells` : `${spells.length} learned spells`}
83
+ </Count>
84
+ <Close type="button" aria-label="Close spellbook" onClick={onClose}>×</Close>
85
+ </HeaderRight>
86
+ </Header>
87
+ <Spread aria-label="Learned spells">
88
+ {visibleSpells.map(spell => (
89
+ <Spell key={spell.key} atlasIMG={iconAtlasIMG} atlasJSON={iconAtlasJSON}
90
+ charMana={mana} charMagicLevel={magicLevel} charSkillLevels={charSkillLevels}
91
+ onClick={selectSpell} spellKey={spell.key} spell={spell} isSettingShortcut={assigning}
92
+ rarity={learnedSpellRarities?.[spell.key]}
93
+ activeCooldown={spellCooldowns?.[spell.magicWords.trim().toLowerCase().replace(/\s+/g, '_')]} />
94
+ ))}
95
+ {!visibleSpells.length && <Empty role="status">
96
+ <strong>{spells.length ? 'No matching spells' : 'Your spellbook is empty'}</strong>
97
+ <span>{spells.length ? `Nothing matches “${search.trim()}”. Try another name or magic phrase.` : 'Learn a spell to start filling these pages.'}</span>
98
+ {search && <SmallButton type="button" onClick={() => { setSearch(''); setCurrentPage(0); }}>Clear search</SmallButton>}
99
+ </Empty>}
100
+ </Spread>
101
+ <Pagination aria-label="Spellbook pages">
102
+ <BaseNote>Mana and cooldown shown are base values.</BaseNote>
103
+ <Pager>
104
+ <PageTurn type="button" aria-label="Previous page" disabled={page === 0}
105
+ onClick={() => setCurrentPage(page - 1)}><FaChevronLeft aria-hidden="true" size={10} />Previous</PageTurn>
106
+ <PageLabel aria-live="polite">Page {page + 1} of {pageCount}</PageLabel>
107
+ <PageTurn type="button" aria-label="Next page" disabled={page === pageCount - 1}
108
+ onClick={() => setCurrentPage(page + 1)}>Next<FaChevronRight aria-hidden="true" size={10} /></PageTurn>
109
+ </Pager>
110
+ </Pagination>
111
+ </Pages>
112
+ <ShortcutArea>
113
+ <Assignment role="status">
114
+ <span>{assigning ? `Choose a spell for shortcut ${settingShortcutIndex + 1}` : 'Choose an empty shortcut, then select a spell to assign it.'}</span>
115
+ {assigning && <SmallButton type="button" onClick={() => setSettingShortcutIndex(-1)}>Cancel assignment</SmallButton>}
116
+ </Assignment>
117
+ <ShortcutsSetter setSettingShortcutIndex={setSettingShortcutIndex} settingShortcutIndex={settingShortcutIndex}
118
+ shortcuts={shortcuts} removeShortcut={removeShortcut} atlasIMG={atlasIMG} atlasJSON={atlasJSON} />
119
+ </ShortcutArea>
120
+ </Book>
121
+ </Draggable>
129
122
  );
130
123
  };
131
124
 
132
- const Title = styled.h1`
133
- font-size: ${uiFonts.size.large} !important;
134
- margin-bottom: 0 !important;
125
+ // 0.png is 778x538 with transparent margins; the opaque book spans x 40-716, y 38-463.
126
+ // Percent size/position crop those margins so the box edge is the book edge. The page text
127
+ // area is x 73-687 (spine rings x 370-390 sit in the GUTTER) and y 72-428 of that art.
128
+ // The book has no fixed ratio: its height is the header, the card rows and the pager, and the art
129
+ // is stretched over whatever that comes to. Every desktop card is as tall as the tallest (see Spread).
130
+ const GUTTER = '5.2%';
131
+ const FLAT = '@media (max-width: 960px)';
132
+ const PHONE = '@media (max-width: 640px)';
133
+ // Mouse-driven desktop book: controls are drawn compact (34px) and keep a 44px hit area through ::after.
134
+ // Flat, phone and touch (coarse pointer) books keep the 44px targets.
135
+ const DESKTOP = '@media (min-width: 961px) and (pointer: fine)';
136
+ const hitArea = '&::after { content: ""; position: absolute; top: -5px; right: -5px; bottom: -5px; left: -5px; }';
137
+ // 12 shortcut slots plus label need ~720px of tray; below that the slots wrap to 6 per row.
138
+ const TRAY = '@media (max-width: 860px)';
139
+ const twoPages = `display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: ${GUTTER};`;
140
+ const PARCHMENT = '#392718';
141
+ const focusRing = '3px solid #3a2110';
142
+ const INK = '#140c1c';
143
+
144
+ // The client BaseUI `.rpgui-button` plank (rpgui img/button.png) as CSS: uiColors.orange fill, 1px
145
+ // near-black outline, pale top bevel, uiColors.brown bottom bevel. Dark ink keeps ~6:1 on the orange.
146
+ const plank = css`
147
+ position: relative;
148
+ display: inline-flex;
149
+ align-items: center;
150
+ justify-content: center;
151
+ gap: 8px;
152
+ box-sizing: border-box;
153
+ min-width: 44px;
154
+ min-height: 44px;
155
+ padding: 6px 12px;
156
+ background: ${uiColors.orange};
157
+ border: 1px solid ${INK};
158
+ border-radius: 2px;
159
+ box-shadow: inset 0 1px 0 #dad45e, inset 0 -2px 0 ${uiColors.brown};
160
+ color: ${INK};
161
+ font: inherit;
162
+ font-size: 10px;
163
+ line-height: 1.4;
164
+ cursor: pointer;
165
+ &:hover:not(:disabled) { background: #e08f38; }
166
+ &:active:not(:disabled) { background: ${uiColors.brown}; box-shadow: inset 0 2px 0 ${uiColors.darkRed}; color: #fff1d6; }
167
+ &:focus-visible { outline: ${focusRing}; outline-offset: 2px; }
168
+ /* RPGUI greys and recolors every :disabled (0,2,0): keep a flat, readable parchment-toned button */
169
+ &&:disabled {
170
+ cursor: default;
171
+ background: transparent;
172
+ box-shadow: none;
173
+ border-color: rgba(89, 56, 28, .45);
174
+ color: #5a3f27;
175
+ filter: none;
176
+ }
177
+ &:disabled svg { opacity: .55; }
178
+ ${DESKTOP} {
179
+ min-width: 34px;
180
+ min-height: 34px;
181
+ padding: 4px 10px;
182
+ gap: 6px;
183
+ ${hitArea}
184
+ }
135
185
  `;
136
186
 
137
- const Container = styled.div`
138
- width: 100%;
139
- height: 100%;
140
- color: white;
187
+ const Book = styled.div`
188
+ box-sizing: border-box;
189
+ position: relative;
190
+ width: min(100%, 1040px);
191
+ margin: 0 auto;
192
+ color: ${PARCHMENT};
193
+ font-family: 'Press Start 2P', monospace;
194
+ `;
195
+ const Pages = styled.div`
196
+ box-sizing: border-box;
141
197
  display: flex;
142
198
  flex-direction: column;
199
+ /* the stitched frame art already touches the header, so the height cut comes from the bottom padding */
200
+ padding: 38px 4.29% 38px 4.88%;
201
+ background: url(${BookImg}) 39.2157% 33.6283% / 115.0888% 126.5882% no-repeat;
202
+ image-rendering: pixelated;
203
+ filter: drop-shadow(0 14px 20px rgba(0, 0, 0, .5));
204
+ ${FLAT} {
205
+ padding: 22px 20px 18px;
206
+ background: #eec28d;
207
+ border: 4px double #80512f;
208
+ border-radius: 4px;
209
+ box-shadow: inset 0 0 0 7px #eec28d, inset 0 0 0 8px rgba(89, 56, 28, .35);
210
+ }
211
+ ${PHONE} { padding: 20px 16px 16px; }
143
212
  `;
144
-
145
- const SpellList = styled.div`
146
- width: 100%;
147
- min-height: 0;
213
+ const Header = styled.div`
214
+ ${twoPages}
215
+ align-items: center;
216
+ padding-bottom: 8px;
217
+ cursor: move;
218
+ ${FLAT} { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; padding-bottom: 14px; }
219
+ `;
220
+ const HeaderLeft = styled.div`
221
+ display: flex;
222
+ align-items: center;
223
+ gap: 16px;
224
+ min-width: 0;
225
+ ${FLAT} { display: contents; }
226
+ `;
227
+ // The game client wraps the UI in RPGUI's `.rpgui-content`, whose span/p/h1/label/input rules
228
+ // (specificity 0,1,1) force white outlined text. The doubled `&&` class (0,2,0) keeps this book's type.
229
+ const Eyebrow = styled.span`
230
+ display: block;
231
+ margin-bottom: 4px;
232
+ && { font-size: 9px; line-height: 1.3; color: #6b4d32; text-shadow: none; }
233
+ `;
234
+ const Title = styled.h1`
235
+ && {
236
+ margin: 0;
237
+ font-size: 18px;
238
+ line-height: 1.3;
239
+ text-align: left;
240
+ color: ${PARCHMENT};
241
+ text-shadow: none;
242
+ ${PHONE} { font-size: 16px; }
243
+ }
244
+ `;
245
+ const HeaderRight = styled.div`
246
+ display: flex;
247
+ justify-self: end;
248
+ align-items: center;
249
+ gap: 12px;
250
+ `;
251
+ const Count = styled.span`
252
+ text-align: right;
253
+ max-width: 190px;
254
+ ${PHONE} { max-width: 96px; }
255
+ && { font-size: 9px; line-height: 1.7; color: #4a3220; text-shadow: none; }
256
+ `;
257
+ const Close = styled.button`
258
+ ${plank}
259
+ flex: none;
260
+ width: 44px;
261
+ padding: 0;
262
+ font-size: 22px;
263
+ line-height: 1;
264
+ ${DESKTOP} { width: 34px; padding: 0; font-size: 18px; }
265
+ `;
266
+ const SearchLabel = styled.label`
267
+ flex: 1;
268
+ min-width: 0;
269
+ align-items: center;
270
+ /* RPGUI makes label display:inline and span white: keep the flex row and caption type */
271
+ && { display: flex; font-size: 9px; line-height: 1.4; color: #4a3220; text-shadow: none; }
272
+ /* the input keeps its aria-label; the visible caption only appears once the book is flat */
273
+ && span {
274
+ position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
275
+ font-size: inherit; line-height: inherit; color: inherit; text-shadow: none;
276
+ }
277
+ /* a name or a magic phrase is short: the field stays a field instead of filling the page */
278
+ ${DESKTOP} { flex: 0 1 240px; }
279
+ ${FLAT} {
280
+ order: 3;
281
+ flex: 1 0 100%;
282
+ flex-direction: column;
283
+ align-items: stretch;
284
+ gap: 6px;
285
+ && span { position: static; width: auto; height: auto; clip: auto; }
286
+ }
287
+ `;
288
+ const Search = styled.input`
148
289
  flex: 1;
149
- overflow-y: auto;
290
+ min-width: 0;
291
+ border: 1px solid #7b5836;
292
+ border-radius: 3px;
293
+ font: inherit;
294
+ /* RPGUI gives every input a dark box, white outlined text, 32px line and 30px min height */
295
+ && {
296
+ box-sizing: border-box;
297
+ width: 100%;
298
+ min-height: 44px;
299
+ padding: 10px;
300
+ color: ${PARCHMENT};
301
+ background: rgba(255, 250, 229, .72);
302
+ text-shadow: none;
303
+ font-size: 10px;
304
+ line-height: 1.4;
305
+ ${DESKTOP} { min-height: 34px; padding: 6px 10px; }
306
+ }
307
+ &::placeholder { color: #6b4d32; opacity: 1; font-size: 9px; }
308
+ &:focus { outline: ${focusRing}; outline-offset: 1px; }
309
+ `;
310
+ const PageLabel = styled.span`
311
+ white-space: nowrap;
312
+ && { line-height: normal; color: ${PARCHMENT}; text-shadow: none; }
313
+ `;
314
+ const BaseNote = styled.span`
315
+ text-wrap: balance;
316
+ && { font-size: 9px; line-height: 1.7; color: #5a3f27; text-shadow: none; }
317
+ `;
318
+ // Art book: [card][card] spine [card][card], two rows. DOM order is row-major so the first four spells
319
+ // share a row; the spine track keeps the page gap the old two-page grid had (GUTTER = 10 + track + 10).
320
+ // Every populated row is 1fr, so all cards on a page share one width and one height: the tallest card sets it
321
+ // and each card is stretched to it (its wrapper is a flex row so the button fills the cell). A shorter card
322
+ // keeps its content at the top, facts right under the description, and leaves the spare height at the bottom.
323
+ // Rows are implicit, so only populated rows exist (1-4 cards make one row, 5-8 two) and a sparse page still
324
+ // shrinks the book; a row never goes below CARD_MIN. Flat book: two columns, phone: one, rows grow with their
325
+ // text and cards are equal within a row.
326
+ const CARD_MIN = '150px';
327
+ const Spread = styled.div`
328
+ display: grid;
329
+ align-items: stretch;
330
+ grid-template-columns: repeat(2, minmax(0, 1fr)) calc(${GUTTER} - 20px) repeat(2, minmax(0, 1fr));
331
+ grid-auto-rows: minmax(${CARD_MIN}, 1fr);
332
+ gap: 8px 10px;
333
+ > div { display: flex; min-width: 0; }
334
+ > :nth-child(4n + 1) { grid-column: 1; }
335
+ > :nth-child(4n + 2) { grid-column: 2; }
336
+ > :nth-child(4n + 3) { grid-column: 4; }
337
+ > :nth-child(4n) { grid-column: 5; }
338
+ ${FLAT} {
339
+ grid-template-columns: repeat(2, minmax(0, 1fr));
340
+ grid-auto-rows: auto;
341
+ column-gap: 14px;
342
+ > :nth-child(n) { grid-column: auto; }
343
+ }
344
+ ${PHONE} { grid-template-columns: minmax(0, 1fr); }
345
+ `;
346
+ const Empty = styled.div`
347
+ /* && outranks the card placement rules on Spread's children (0,2,0) */
348
+ && { grid-column: 1 / span 2; grid-row: 1 / span 2; }
150
349
  display: flex;
350
+ align-items: center;
351
+ justify-content: center;
151
352
  flex-direction: column;
152
- gap: 1.5rem;
153
- margin-top: 1rem;
353
+ gap: 14px;
354
+ padding: 24px 16px;
355
+ text-align: center;
356
+ overflow-wrap: anywhere;
357
+ border: 2px dashed rgba(89, 56, 28, .45);
358
+ border-radius: 3px;
359
+ font-size: 12px;
360
+ line-height: 1.7;
361
+ color: ${PARCHMENT};
362
+ && span { font-size: 10px; line-height: 1.8; color: #4a3220; text-shadow: none; }
363
+ ${FLAT} { && { grid-column: 1 / -1; grid-row: auto; } }
364
+ ${PHONE} { padding: 32px 12px; }
365
+ `;
366
+ const SmallButton = styled.button`
367
+ ${plank}
368
+ `;
369
+ // Page turners share one fixed size so Previous and Next stay identical whatever the label length.
370
+ const PageTurn = styled(SmallButton)`
371
+ flex: none;
372
+ width: 124px;
373
+ ${DESKTOP} { width: 108px; padding: 0 5px; }
374
+ ${PHONE} { flex: 1 1 0; width: auto; min-width: 0; padding: 0 6px; gap: 6px; font-size: 9px; }
375
+ `;
376
+ // Same Previous / Page n of N / Next order as the shared Pagination; the base-value footnote takes the
377
+ // left page so the page turners sit together at the outer corner of the right page.
378
+ const Pager = styled.div`
379
+ display: flex;
380
+ align-items: center;
381
+ gap: 8px;
382
+ ${FLAT} { justify-content: space-between; gap: 10px; }
383
+ `;
384
+ const Pagination = styled.nav`
385
+ ${twoPages}
386
+ align-items: center;
387
+ margin-top: 10px;
388
+ font-size: 10px;
389
+ color: ${PARCHMENT};
390
+ > :first-child { justify-self: start; }
391
+ > :last-child { justify-self: end; }
392
+ ${FLAT} {
393
+ display: flex;
394
+ flex-direction: column-reverse;
395
+ align-items: stretch;
396
+ gap: 10px;
397
+ padding-top: 14px;
398
+ border-top: 1px dashed rgba(89, 56, 28, .4);
399
+ }
400
+ ${PHONE} { ${PageLabel} { font-size: 9px; } }
401
+ `;
402
+ const ShortcutArea = styled.div`
403
+ display: grid;
404
+ grid-template-columns: minmax(0, 1fr) auto;
405
+ align-items: center;
406
+ gap: 10px 20px;
407
+ margin-top: 10px;
408
+ padding: 10px 16px;
409
+ color: #f4e2bb;
410
+ background: linear-gradient(#4b2f19, #38220f);
411
+ border: 2px solid #1f130a;
412
+ border-radius: 4px;
413
+ box-shadow: inset 0 0 0 1px rgba(255, 224, 160, .18), 0 10px 18px rgba(0, 0, 0, .35);
414
+ ${SmallButton}:focus-visible { outline-color: #ffd98a; }
415
+ > div:last-child { display: flex; align-items: center; gap: 12px; width: auto; }
416
+ && p { flex: none; margin: 0; font-size: 10px; line-height: 1.7; color: #f4e2bb; text-shadow: none; }
417
+ #shortcuts_list { display: grid; grid-template-columns: repeat(12, 44px); gap: 6px; width: auto; padding: 0; }
418
+ #shortcuts_list button {
419
+ width: 44px;
420
+ height: 44px;
421
+ background-color: #ecc78f;
422
+ color: #2d1b0c;
423
+ font: inherit;
424
+ font-size: 11px;
425
+ }
426
+ #shortcuts_list button:hover:not(:disabled) { background-color: #fbe3b0; }
427
+ #shortcuts_list button:focus-visible { outline: 3px solid #ffd98a; outline-offset: 2px; }
428
+ #shortcuts_list button:disabled { opacity: 1; background-color: #5a3a20; color: #ecd6aa; filter: none; }
429
+ ${DESKTOP} {
430
+ padding: 8px 14px;
431
+ #shortcuts_list { grid-template-columns: repeat(12, 36px); gap: 8px; }
432
+ #shortcuts_list button { position: relative; width: 36px; height: 36px; font-size: 10px; }
433
+ #shortcuts_list button::after { content: ''; position: absolute; top: -4px; right: -4px; bottom: -4px; left: -4px; }
434
+ }
435
+ ${FLAT} { grid-template-columns: minmax(0, 1fr); padding: 12px 14px; }
436
+ ${TRAY} { #shortcuts_list { grid-template-columns: repeat(6, 44px); } }
437
+ ${PHONE} {
438
+ > div:last-child { flex-direction: column; align-items: stretch; gap: 8px; }
439
+ #shortcuts_list { justify-content: center; }
440
+ }
441
+ `;
442
+ const Assignment = styled.div`
443
+ display: flex;
444
+ justify-content: space-between;
445
+ align-items: center;
446
+ gap: 10px;
447
+ font-size: 9px;
448
+ line-height: 1.8;
449
+ && span { font-size: inherit; line-height: inherit; color: inherit; text-shadow: none; }
154
450
  `;
@@ -0,0 +1,102 @@
1
+ import React from 'react';
2
+ import { cleanup, fireEvent, render, screen, act } from '@testing-library/react';
3
+ import { BasicAttribute, CharacterClass, ElementalType, LifeBringerRaces, UserAccountTypes } from '@rpg-engine/shared';
4
+ import { SpellInfo } from '../cards/SpellInfo';
5
+ import { MagicTooltip } from '../cards/SpellTooltip';
6
+ import { SpellInfoWrapper } from '../cards/SpellInfoWrapper';
7
+ import { mockSpells } from '../mockSpells';
8
+
9
+ jest.mock('../cards/MobileSpellTooltip', () => ({ MobileSpellTooltip: () => <div>Spell details</div> }));
10
+ jest.mock('../../Abstractions/ModalPortal', () => ({
11
+ __esModule: true, default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
12
+ }));
13
+ afterEach(() => { cleanup(); jest.useRealTimers(); });
14
+
15
+ it('shows mobile details on hold without casting on release', () => {
16
+ jest.useFakeTimers();
17
+ const cast = jest.fn();
18
+ render(<SpellInfoWrapper spell={mockSpells[0]}><button onClick={cast}>Cast</button></SpellInfoWrapper>);
19
+ fireEvent.touchStart(screen.getByRole('button'));
20
+ act(() => { jest.advanceTimersByTime(501); });
21
+ expect(screen.getByText('Spell details')).toBeTruthy();
22
+ fireEvent.touchEnd(screen.getByRole('button'));
23
+ fireEvent.click(screen.getByRole('button'));
24
+ expect(cast).not.toHaveBeenCalled();
25
+ });
26
+
27
+ it('cancels a pending hold when scrolling or unmounting', () => {
28
+ jest.useFakeTimers();
29
+ const { unmount } = render(<SpellInfoWrapper spell={mockSpells[0]}><button>Cast</button></SpellInfoWrapper>);
30
+ fireEvent.touchStart(screen.getByRole('button'));
31
+ fireEvent.touchMove(screen.getByRole('button'));
32
+ act(() => { jest.advanceTimersByTime(501); });
33
+ expect(screen.queryByText('Spell details')).toBeNull();
34
+ fireEvent.touchStart(screen.getByRole('button'));
35
+ unmount();
36
+ expect(jest.getTimerCount()).toBe(0);
37
+ });
38
+
39
+ const row = (label: string) => screen.getByText(label).nextSibling?.textContent;
40
+
41
+ it('tooltip lists payload metadata with readable labels beside the existing rows', () => {
42
+ render(<SpellInfo spell={{
43
+ ...mockSpells[0], minLevelRequired: 12, attribute: BasicAttribute.MagicResistance, minSkillLevelRequired: 4,
44
+ elementalType: ElementalType.Fire, canSelfTarget: true, isPVPOnly: true,
45
+ characterClass: [CharacterClass.Druid, CharacterClass.Sorcerer], characterRace: [LifeBringerRaces.Elf],
46
+ onlyPremiumAccountType: [UserAccountTypes.Free, UserAccountTypes.PremiumGold],
47
+ }} />);
48
+ expect(row('Requires Level:')).toBe('12');
49
+ expect(row('Requires Magic Resistance Level:')).toBe('4');
50
+ expect(row('Element:')).toBe('Fire');
51
+ expect(row('Can target self:')).toBe('Yes');
52
+ expect(row('PvP only:')).toBe('Yes');
53
+ expect(row('Requires Class:')).toBe('Druid, Sorcerer');
54
+ expect(row('Requires Race:')).toBe('Elf');
55
+ expect(row('Eligible account types:')).toBe('Free, Gold');
56
+ expect(row('Casting Type:')).toBe('Ranged Casting');
57
+ expect(row('Max Distance Grid:')).toBe('2');
58
+ expect(screen.getByText(mockSpells[0].description)).toBeTruthy();
59
+ });
60
+
61
+ it('tooltip never invents metadata the payload does not carry', () => {
62
+ render(<SpellInfo spell={mockSpells[0]} />);
63
+ expect(row('Requires Level:')).toBe('1');
64
+ expect(row('Requires Magic Level:')).toBe('3');
65
+ ['Element:', 'Can target self:', 'PvP only:', 'Requires Class:', 'Requires Race:', 'Eligible account types:']
66
+ .forEach(label => expect(screen.queryByText(label)).toBeNull());
67
+ expect(screen.queryByText(/rarity|tier/i)).toBeNull();
68
+ });
69
+
70
+ it('opens keyboard details and dismisses them without casting or closing the book', () => {
71
+ const cast = jest.fn();
72
+ const bookKey = jest.fn();
73
+ render(<div onKeyDown={bookKey}><SpellInfoWrapper spell={mockSpells[0]}>
74
+ <button onClick={cast}>Cast</button>
75
+ </SpellInfoWrapper></div>);
76
+ const button = screen.getByRole('button');
77
+ act(() => button.focus());
78
+ expect(screen.getByRole('tooltip').style.opacity).toBe('1');
79
+ fireEvent.keyDown(button, { key: 'Escape' });
80
+ expect(screen.queryByRole('tooltip')).toBeNull();
81
+ expect(bookKey).not.toHaveBeenCalled();
82
+ expect(cast).not.toHaveBeenCalled();
83
+ act(() => button.blur());
84
+ fireEvent.touchStart(button);
85
+ fireEvent.touchEnd(button);
86
+ act(() => button.focus());
87
+ expect(screen.getByRole('tooltip')).toBeTruthy();
88
+ act(() => button.blur());
89
+ expect(screen.queryByRole('tooltip')).toBeNull();
90
+ });
91
+
92
+ it('positions focused details visibly without waiting for a mouse movement', () => {
93
+ const anchor = document.createElement('button');
94
+ jest.spyOn(anchor, 'getBoundingClientRect').mockReturnValue({
95
+ x: 100, y: 100, left: 100, right: 150, top: 100, bottom: 140,
96
+ width: 50, height: 40, toJSON: () => ({}),
97
+ });
98
+ render(<MagicTooltip spell={mockSpells[0]} anchor={anchor} />);
99
+ const tooltip = screen.getByRole('tooltip');
100
+ expect(tooltip.style.opacity).toBe('1');
101
+ expect(tooltip.style.transform).toBe('translate(170px, 120px)');
102
+ });