minecraft-inventory 0.1.0

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 (51) hide show
  1. package/README.md +566 -0
  2. package/package.json +37 -0
  3. package/src/InventoryGUI.tsx +108 -0
  4. package/src/cache/textureCache.ts +95 -0
  5. package/src/components/CursorItem/CursorItem.tsx +94 -0
  6. package/src/components/CursorItem/index.ts +1 -0
  7. package/src/components/HUD/HUD.tsx +11 -0
  8. package/src/components/HUD/index.ts +1 -0
  9. package/src/components/Hotbar/Hotbar.tsx +180 -0
  10. package/src/components/Hotbar/index.ts +1 -0
  11. package/src/components/InventoryOverlay/InventoryOverlay.tsx +196 -0
  12. package/src/components/InventoryOverlay/index.ts +2 -0
  13. package/src/components/InventoryWindow/EnchantmentOptions.tsx +109 -0
  14. package/src/components/InventoryWindow/InventoryBackground.tsx +110 -0
  15. package/src/components/InventoryWindow/InventoryWindow.tsx +120 -0
  16. package/src/components/InventoryWindow/ProgressBar.tsx +78 -0
  17. package/src/components/InventoryWindow/VillagerTradeList.tsx +136 -0
  18. package/src/components/InventoryWindow/index.ts +5 -0
  19. package/src/components/ItemCanvas/ItemCanvas.tsx +154 -0
  20. package/src/components/ItemCanvas/index.ts +1 -0
  21. package/src/components/JEI/JEI.module.css +37 -0
  22. package/src/components/JEI/JEI.tsx +303 -0
  23. package/src/components/JEI/index.ts +2 -0
  24. package/src/components/RecipeGuide/RecipeInventoryView.tsx +293 -0
  25. package/src/components/RecipeGuide/index.ts +1 -0
  26. package/src/components/Slot/Slot.module.css +111 -0
  27. package/src/components/Slot/Slot.tsx +363 -0
  28. package/src/components/Slot/index.ts +1 -0
  29. package/src/components/Text/MessageFormatted.css +5 -0
  30. package/src/components/Text/MessageFormatted.tsx +79 -0
  31. package/src/components/Text/MessageFormattedString.tsx +74 -0
  32. package/src/components/Text/chatUtils.ts +172 -0
  33. package/src/components/Tooltip/Tooltip.module.css +56 -0
  34. package/src/components/Tooltip/Tooltip.tsx +130 -0
  35. package/src/components/Tooltip/index.ts +1 -0
  36. package/src/connector/demo.ts +213 -0
  37. package/src/connector/index.ts +4 -0
  38. package/src/connector/mineflayer.ts +113 -0
  39. package/src/connector/types.ts +41 -0
  40. package/src/context/InventoryContext.tsx +157 -0
  41. package/src/context/ScaleContext.tsx +73 -0
  42. package/src/context/TextureContext.tsx +70 -0
  43. package/src/globals.d.ts +4 -0
  44. package/src/hooks/useKeyboardShortcuts.ts +41 -0
  45. package/src/hooks/useMobile.ts +28 -0
  46. package/src/index.tsx +65 -0
  47. package/src/mount.tsx +52 -0
  48. package/src/registry/index.ts +21 -0
  49. package/src/registry/inventories.ts +612 -0
  50. package/src/styles/tokens.css +47 -0
  51. package/src/types.ts +176 -0
@@ -0,0 +1,303 @@
1
+ import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react'
2
+ import type { ItemStack, RecipeGuide, RecipeNavFrame } from '../../types'
3
+ import { useScale } from '../../context/ScaleContext'
4
+ import { useInventoryContext } from '../../context/InventoryContext'
5
+ import { Slot } from '../Slot'
6
+ import styles from './JEI.module.css'
7
+
8
+ export interface JEIItem {
9
+ type: number
10
+ name: string
11
+ displayName: string
12
+ count?: number
13
+ metadata?: number
14
+ }
15
+
16
+ interface JEIProps {
17
+ items?: JEIItem[]
18
+ position?: 'left' | 'right'
19
+ width?: number
20
+ onItemClick?: (item: JEIItem) => void
21
+ onItemMiddleClick?: (item: JEIItem) => void
22
+ /** Called when R is pressed while hovering an item — return list of recipes to display */
23
+ onGetRecipes?: (item: JEIItem) => RecipeGuide[]
24
+ /** Called when U is pressed while hovering an item — return list of usages to display */
25
+ onGetUsages?: (item: JEIItem) => RecipeGuide[]
26
+ /** Called when R/U shortcut should push a new recipe frame (managed externally by InventoryOverlay) */
27
+ onPushRecipeFrame?: (frame: RecipeNavFrame) => void
28
+ className?: string
29
+ style?: React.CSSProperties
30
+ }
31
+
32
+ const ITEMS_PER_ROW = 6
33
+ const PADDING = 4
34
+ const FAV_KEY = 'mc-inv-jei-favorites'
35
+
36
+ function getItemKey(item: JEIItem): string {
37
+ return `${item.type}:${item.metadata ?? 0}:${item.name}`
38
+ }
39
+
40
+ function loadFavorites(): Set<string> {
41
+ try {
42
+ const raw = localStorage.getItem(FAV_KEY)
43
+ return new Set(raw ? (JSON.parse(raw) as string[]) : [])
44
+ } catch {
45
+ return new Set()
46
+ }
47
+ }
48
+
49
+ function saveFavorites(favs: Set<string>) {
50
+ try {
51
+ localStorage.setItem(FAV_KEY, JSON.stringify([...favs]))
52
+ } catch {}
53
+ }
54
+
55
+ export function JEI({
56
+ items = [],
57
+ position = 'right',
58
+ width,
59
+ onItemClick,
60
+ onItemMiddleClick,
61
+ onGetRecipes,
62
+ onGetUsages,
63
+ onPushRecipeFrame,
64
+ className,
65
+ style,
66
+ }: JEIProps) {
67
+ const { scale, contentSize } = useScale()
68
+ const { hoveredSlot } = useInventoryContext()
69
+ const [search, setSearch] = useState('')
70
+ const [page, setPage] = useState(0)
71
+ const [showFavorites, setShowFavorites] = useState(false)
72
+ const [favorites, setFavorites] = useState<Set<string>>(loadFavorites)
73
+ // Map from negative slot index → JEI item (to enable F-key and R/U on hover)
74
+ const slotToItemRef = useRef<Map<number, JEIItem>>(new Map())
75
+
76
+ const pushRecipeFrame = useCallback((targetItem: JEIItem, mode: 'recipes' | 'usages') => {
77
+ const getter = mode === 'recipes' ? onGetRecipes : onGetUsages
78
+ if (!getter || !onPushRecipeFrame) return
79
+ const guides = getter(targetItem)
80
+ if (guides.length === 0) return
81
+ onPushRecipeFrame({ item: targetItem, mode, guides, guideIndex: 0 })
82
+ }, [onGetRecipes, onGetUsages, onPushRecipeFrame])
83
+
84
+ const padding = PADDING * scale
85
+ const itemSize = contentSize
86
+ const cols = ITEMS_PER_ROW
87
+ const panelWidth = width ?? cols * itemSize + padding * 2
88
+ const rows = Math.floor((300 * scale) / itemSize)
89
+ const itemsPerPage = cols * rows
90
+
91
+ const toggleFavorite = useCallback((key: string) => {
92
+ setFavorites((prev) => {
93
+ const next = new Set(prev)
94
+ if (next.has(key)) next.delete(key)
95
+ else next.add(key)
96
+ saveFavorites(next)
97
+ return next
98
+ })
99
+ }, [])
100
+
101
+ // F / R / U keyboard handlers
102
+ useEffect(() => {
103
+ const handler = (e: KeyboardEvent) => {
104
+ // F: toggle favorite for hovered JEI item
105
+ if (e.code === 'KeyF' && hoveredSlot !== null && hoveredSlot < 0) {
106
+ const item = slotToItemRef.current.get(hoveredSlot)
107
+ if (item) toggleFavorite(getItemKey(item))
108
+ return
109
+ }
110
+
111
+ // R / U: show recipes / usages for hovered JEI item slot
112
+ if (e.code !== 'KeyR' && e.code !== 'KeyU') return
113
+ const mode = e.code === 'KeyR' ? 'recipes' : 'usages'
114
+
115
+ if (hoveredSlot !== null && hoveredSlot < 0) {
116
+ const item = slotToItemRef.current.get(hoveredSlot)
117
+ if (item) pushRecipeFrame(item, mode)
118
+ }
119
+ }
120
+ window.addEventListener('keydown', handler)
121
+ return () => window.removeEventListener('keydown', handler)
122
+ }, [hoveredSlot, toggleFavorite, pushRecipeFrame])
123
+
124
+ // Expose hoveredSlot info for parent-level nested navigation
125
+ // (RecipeInventoryView handles its own R/U, JEI only needs to handle its own items)
126
+
127
+ const baseList = useMemo(() => {
128
+ if (showFavorites) return items.filter((i) => favorites.has(getItemKey(i)))
129
+ return items
130
+ }, [items, showFavorites, favorites])
131
+
132
+ const filteredItems = useMemo(() => {
133
+ if (!search.trim()) return baseList
134
+ const q = search.toLowerCase()
135
+ return baseList.filter(
136
+ (item) =>
137
+ item.displayName.toLowerCase().includes(q) ||
138
+ item.name.toLowerCase().includes(q) ||
139
+ String(item.type).includes(q),
140
+ )
141
+ }, [baseList, search])
142
+
143
+ const totalPages = Math.ceil(filteredItems.length / itemsPerPage)
144
+ const visibleItems = filteredItems.slice(page * itemsPerPage, (page + 1) * itemsPerPage)
145
+
146
+ const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
147
+ setSearch(e.target.value)
148
+ setPage(0)
149
+ }, [])
150
+
151
+ const handleWheel = useCallback(
152
+ (e: React.WheelEvent<HTMLDivElement>) => {
153
+ e.preventDefault()
154
+ setPage((prev) => {
155
+ if (e.deltaY > 0) return Math.min(prev + 1, totalPages - 1)
156
+ return Math.max(prev - 1, 0)
157
+ })
158
+ },
159
+ [totalPages],
160
+ )
161
+
162
+ // Build slot→item map on each render
163
+ slotToItemRef.current.clear()
164
+ visibleItems.forEach((jeiItem, i) => {
165
+ const slotIndex = -(page * itemsPerPage + i + 1)
166
+ slotToItemRef.current.set(slotIndex, jeiItem)
167
+ })
168
+
169
+ return (
170
+ <div
171
+ className={['mc-inv-root', styles.jei, className].filter(Boolean).join(' ')}
172
+ style={{
173
+ width: panelWidth,
174
+ background: '#c6c6c6',
175
+ border: `${scale}px solid #555555`,
176
+ display: 'flex',
177
+ flexDirection: 'column',
178
+ ...style,
179
+ }}
180
+ >
181
+ {/* Search + pagination controls */}
182
+ <div style={{ padding: `${padding}px`, borderBottom: `${scale}px solid #555555` }}>
183
+ <input
184
+ type="text"
185
+ value={search}
186
+ onChange={handleSearchChange}
187
+ placeholder="Search items…"
188
+ className={styles.searchInput}
189
+ style={{
190
+ width: '100%',
191
+ padding: `${2 * scale}px`,
192
+ fontSize: 6 * scale,
193
+ background: '#8b8b8b',
194
+ border: `${scale}px solid #373737`,
195
+ color: '#ffffff',
196
+ fontFamily: "'Minecraftia', 'Minecraft', monospace",
197
+ outline: 'none',
198
+ boxSizing: 'border-box',
199
+ }}
200
+ />
201
+ {/* Prev / Next / Favorites below search bar */}
202
+ <div style={{
203
+ display: 'flex',
204
+ alignItems: 'center',
205
+ gap: scale,
206
+ marginTop: 2 * scale,
207
+ fontSize: 6 * scale,
208
+ fontFamily: "'Minecraftia', 'Minecraft', monospace",
209
+ }}>
210
+ <button
211
+ className={styles.pageBtn}
212
+ onClick={() => setPage((p) => Math.max(0, p - 1))}
213
+ disabled={page === 0}
214
+ style={{ fontSize: 6 * scale, padding: `${scale}px ${2 * scale}px` }}
215
+ title="Previous page"
216
+ >
217
+
218
+ </button>
219
+ <span style={{ flex: 1, textAlign: 'center', color: '#404040' }}>
220
+ {page + 1} / {Math.max(1, totalPages)}
221
+ </span>
222
+ <button
223
+ className={styles.pageBtn}
224
+ onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
225
+ disabled={page >= totalPages - 1}
226
+ style={{ fontSize: 6 * scale, padding: `${scale}px ${2 * scale}px` }}
227
+ title="Next page"
228
+ >
229
+
230
+ </button>
231
+ <button
232
+ className={[styles.pageBtn, showFavorites ? styles.pageBtnActive : ''].join(' ')}
233
+ onClick={() => { setShowFavorites((v) => !v); setPage(0) }}
234
+ style={{ fontSize: 6 * scale, padding: `${scale}px ${2 * scale}px` }}
235
+ title={showFavorites ? 'Show all items' : 'Show favorites (F to toggle)'}
236
+ >
237
+
238
+ </button>
239
+ </div>
240
+ </div>
241
+
242
+ {/* Item grid — no slot backgrounds, just raw items */}
243
+ <div
244
+ style={{
245
+ position: 'relative',
246
+ display: 'flex',
247
+ flexWrap: 'wrap',
248
+ padding: `${padding}px`,
249
+ gap: 0,
250
+ flex: 1,
251
+ alignContent: 'flex-start',
252
+ overflowY: 'hidden',
253
+ }}
254
+ onWheel={handleWheel}
255
+ >
256
+ {visibleItems.map((jeiItem, i) => {
257
+ const itemStack: ItemStack = {
258
+ type: jeiItem.type,
259
+ name: jeiItem.name,
260
+ count: jeiItem.count ?? 1,
261
+ metadata: jeiItem.metadata,
262
+ displayName: jeiItem.displayName,
263
+ }
264
+ const slotIndex = -(page * itemsPerPage + i + 1)
265
+ const isFav = favorites.has(getItemKey(jeiItem))
266
+ return (
267
+ <div
268
+ key={`${jeiItem.type}-${jeiItem.metadata ?? 0}-${i}`}
269
+ style={{ position: 'relative' }}
270
+ >
271
+ <Slot
272
+ index={slotIndex}
273
+ item={itemStack}
274
+ size={itemSize}
275
+ noBackground
276
+ onClickOverride={(button, mode) => {
277
+ if (button === 'left' && mode === 'normal' && onItemClick) {
278
+ onItemClick(jeiItem)
279
+ } else if (button === 'middle' && onItemMiddleClick) {
280
+ onItemMiddleClick(jeiItem)
281
+ }
282
+ }}
283
+ />
284
+ {isFav && (
285
+ <span style={{
286
+ position: 'absolute',
287
+ top: 0,
288
+ right: 0,
289
+ fontSize: Math.round(5 * scale),
290
+ color: '#ffcc00',
291
+ lineHeight: 1,
292
+ pointerEvents: 'none',
293
+ textShadow: `0 0 ${scale}px rgba(0,0,0,0.8)`,
294
+ }}>★</span>
295
+ )}
296
+ </div>
297
+ )
298
+ })}
299
+
300
+ </div>
301
+ </div>
302
+ )
303
+ }
@@ -0,0 +1,2 @@
1
+ export { JEI } from './JEI'
2
+ export type { JEIItem } from './JEI'
@@ -0,0 +1,293 @@
1
+ import React, { useState, useCallback, useEffect, useRef } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import type { RecipeNavFrame, ItemStack } from '../../types'
4
+ import { ItemCanvas } from '../ItemCanvas'
5
+ import { Tooltip } from '../Tooltip'
6
+ import { useScale } from '../../context/ScaleContext'
7
+ import { InventoryBackground } from '../InventoryWindow/InventoryBackground'
8
+ import { getInventoryType } from '../../registry'
9
+
10
+ interface RecipeInventoryViewProps {
11
+ navStack: RecipeNavFrame[]
12
+ onBack: () => void
13
+ onGuideIndexChange: (idx: number) => void
14
+ /** Called when R or U is pressed while hovering a recipe item (nested navigation) */
15
+ onPushFrame: (item: RecipeNavFrame['item'], mode: 'recipes' | 'usages') => void
16
+ }
17
+
18
+ /** Slot item with a Tooltip, used inside the recipe view */
19
+ function RecipeItemCell({
20
+ item,
21
+ x,
22
+ y,
23
+ size,
24
+ onHover,
25
+ }: {
26
+ item: ItemStack | null
27
+ x: number
28
+ y: number
29
+ size: number
30
+ onHover: (item: ItemStack | null, mx: number, my: number) => void
31
+ }) {
32
+ const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 })
33
+ const [hovered, setHovered] = useState(false)
34
+
35
+ if (!item) {
36
+ return (
37
+ <div
38
+ style={{
39
+ position: 'absolute',
40
+ left: x,
41
+ top: y,
42
+ width: size,
43
+ height: size,
44
+ }}
45
+ />
46
+ )
47
+ }
48
+
49
+ return (
50
+ <div
51
+ style={{ position: 'absolute', left: x, top: y, width: size, height: size, cursor: 'default' }}
52
+ onMouseEnter={(e) => {
53
+ setTooltipPos({ x: e.clientX, y: e.clientY })
54
+ setHovered(true)
55
+ onHover(item, e.clientX, e.clientY)
56
+ }}
57
+ onMouseLeave={() => {
58
+ setHovered(false)
59
+ onHover(null, 0, 0)
60
+ }}
61
+ onMouseMove={(e) => {
62
+ setTooltipPos({ x: e.clientX, y: e.clientY })
63
+ onHover(item, e.clientX, e.clientY)
64
+ }}
65
+ >
66
+ <ItemCanvas item={item} size={size} style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }} />
67
+ {hovered && createPortal(
68
+ <Tooltip item={item} mouseX={tooltipPos.x} mouseY={tooltipPos.y} visible />,
69
+ document.body
70
+ )}
71
+ </div>
72
+ )
73
+ }
74
+
75
+ /**
76
+ * Renders the current recipe guide using the actual inventory background layout
77
+ * (crafting_table for crafting, furnace for smelting). Replaces the main
78
+ * InventoryWindow while a recipe is being browsed.
79
+ */
80
+ export function RecipeInventoryView({
81
+ navStack,
82
+ onBack,
83
+ onGuideIndexChange,
84
+ onPushFrame,
85
+ }: RecipeInventoryViewProps) {
86
+ const { scale, contentSize } = useScale()
87
+ // Registry coords already point to item area — no border offset needed
88
+ const borderPx = 0
89
+ const frame = navStack[navStack.length - 1]
90
+ const guide = frame.guides[frame.guideIndex]
91
+ const hoveredItemRef = useRef<RecipeNavFrame['item'] | null>(null)
92
+
93
+ const handleHoverItem = useCallback((item: ItemStack | null) => {
94
+ if (!item) { hoveredItemRef.current = null; return }
95
+ hoveredItemRef.current = {
96
+ type: item.type,
97
+ name: item.name ?? '',
98
+ displayName: item.displayName ?? item.name ?? `Item #${item.type}`,
99
+ count: item.count,
100
+ metadata: item.metadata,
101
+ }
102
+ }, [])
103
+
104
+ // R / U for nested navigation on hovered recipe items
105
+ useEffect(() => {
106
+ const handler = (e: KeyboardEvent) => {
107
+ if (e.code !== 'KeyR' && e.code !== 'KeyU') return
108
+ const item = hoveredItemRef.current
109
+ if (!item) return
110
+ onPushFrame(item, e.code === 'KeyR' ? 'recipes' : 'usages')
111
+ }
112
+ window.addEventListener('keydown', handler)
113
+ return () => window.removeEventListener('keydown', handler)
114
+ }, [onPushFrame])
115
+
116
+ // Escape = go back one level
117
+ useEffect(() => {
118
+ const handler = (e: KeyboardEvent) => {
119
+ if (e.code === 'Escape') onBack()
120
+ }
121
+ window.addEventListener('keydown', handler)
122
+ return () => window.removeEventListener('keydown', handler)
123
+ }, [onBack])
124
+
125
+ const isMultiFrame = navStack.length > 1
126
+
127
+ // Determine layout type and get definition
128
+ const layoutType = guide.type === 'smelting' ? 'furnace' : 'crafting_table'
129
+ const def = getInventoryType(layoutType)!
130
+
131
+ // Container-only slots (no player inv / hotbar, group !== 'player' && group !== 'hotbar')
132
+ const containerSlots = def.slots.filter(
133
+ (s) => s.group !== 'player' && s.group !== 'hotbar',
134
+ )
135
+
136
+ // Map recipe items to slot indices
137
+ const slotItems = new Map<number, ItemStack | null>()
138
+
139
+ if (guide.type === 'crafting') {
140
+ // Slot 0 = result, slots 1-9 = 3x3 grid
141
+ slotItems.set(0, guide.result ?? null)
142
+ const ingr = guide.ingredients ?? []
143
+ for (let i = 0; i < 9; i++) {
144
+ slotItems.set(i + 1, ingr[i] ?? null)
145
+ }
146
+ } else if (guide.type === 'smelting') {
147
+ slotItems.set(0, guide.input ?? null)
148
+ slotItems.set(1, guide.fuel ?? null)
149
+ slotItems.set(2, guide.result ?? null)
150
+ }
151
+
152
+ const totalGuides = frame.guides.length
153
+ const navFontSize = Math.max(6, Math.round(6 * scale))
154
+ const navPx = Math.max(1, Math.round(scale))
155
+
156
+ return (
157
+ <div
158
+ className="mc-inv-recipe-view"
159
+ style={{ display: 'inline-flex', flexDirection: 'column', alignItems: 'stretch' }}
160
+ >
161
+ {/* ── Navigation bar ABOVE the inventory background ── */}
162
+ <div
163
+ className="mc-inv-recipe-nav"
164
+ style={{
165
+ display: 'flex',
166
+ alignItems: 'center',
167
+ gap: 4 * scale,
168
+ marginBottom: 3 * scale,
169
+ fontSize: navFontSize,
170
+ fontFamily: "'Minecraftia', 'Minecraft', monospace",
171
+ }}
172
+ >
173
+ <NavBtn scale={scale} onClick={onBack}>
174
+ {isMultiFrame ? '◀ Back' : '✕ Close'}
175
+ </NavBtn>
176
+
177
+ {/* Mode label */}
178
+ <span style={{ color: '#505050', flex: 1 }}>
179
+ {frame.mode === 'recipes' ? 'Recipes for' : 'Usages of'}{' '}
180
+ <span style={{ color: '#205090' }}>{frame.item.displayName}</span>
181
+ {navStack.length > 1 && (
182
+ <span style={{ color: '#aaa', marginLeft: 4 * scale }}>
183
+ {'▸'.repeat(navStack.length - 1)}
184
+ </span>
185
+ )}
186
+ </span>
187
+
188
+ {/* Prev / next for multiple guides */}
189
+ {totalGuides > 1 && (
190
+ <>
191
+ <NavBtn
192
+ scale={scale}
193
+ onClick={() => onGuideIndexChange(Math.max(0, frame.guideIndex - 1))}
194
+ disabled={frame.guideIndex === 0}
195
+ >◀</NavBtn>
196
+ <span style={{ color: '#404040', minWidth: 40 * scale, textAlign: 'center' }}>
197
+ {frame.guideIndex + 1} / {totalGuides}
198
+ </span>
199
+ <NavBtn
200
+ scale={scale}
201
+ onClick={() => onGuideIndexChange(Math.min(totalGuides - 1, frame.guideIndex + 1))}
202
+ disabled={frame.guideIndex >= totalGuides - 1}
203
+ >▶</NavBtn>
204
+ </>
205
+ )}
206
+ </div>
207
+
208
+ {/* ── Inventory background with container slots only ── */}
209
+ <InventoryBackground definition={def} title={guide.title}>
210
+ {containerSlots.map((slotDef) => {
211
+ const item = slotItems.get(slotDef.index) ?? null
212
+ return (
213
+ <RecipeItemCell
214
+ key={slotDef.index}
215
+ item={item}
216
+ x={slotDef.x * scale}
217
+ y={slotDef.y * scale}
218
+ size={slotDef.size ? slotDef.size * scale - 2 * navPx : contentSize}
219
+ onHover={handleHoverItem}
220
+ />
221
+ )
222
+ })}
223
+
224
+ {/* Description for custom-type guides */}
225
+ {guide.description && (
226
+ <div style={{
227
+ position: 'absolute',
228
+ top: 24 * scale,
229
+ left: 8 * scale,
230
+ right: 8 * scale,
231
+ fontSize: navFontSize,
232
+ fontFamily: "'Minecraftia', 'Minecraft', monospace",
233
+ color: '#404040',
234
+ lineHeight: 1.5,
235
+ pointerEvents: 'none',
236
+ whiteSpace: 'pre-wrap',
237
+ wordBreak: 'break-word',
238
+ }}>
239
+ {guide.description}
240
+ </div>
241
+ )}
242
+ </InventoryBackground>
243
+
244
+ {/* ── Hint below ── */}
245
+ <div style={{
246
+ marginTop: 2 * scale,
247
+ textAlign: 'center',
248
+ fontSize: Math.max(5, Math.round(5.5 * scale)),
249
+ fontFamily: "'Minecraftia', 'Minecraft', monospace",
250
+ color: 'rgba(80,80,80,0.7)',
251
+ pointerEvents: 'none',
252
+ }}>
253
+ Hover ingredient + R / U for nested lookup
254
+ </div>
255
+ </div>
256
+ )
257
+ }
258
+
259
+ function NavBtn({
260
+ scale,
261
+ onClick,
262
+ disabled,
263
+ title,
264
+ children,
265
+ }: {
266
+ scale: number
267
+ onClick: () => void
268
+ disabled?: boolean
269
+ title?: string
270
+ children: React.ReactNode
271
+ }) {
272
+ return (
273
+ <button
274
+ onClick={onClick}
275
+ disabled={disabled}
276
+ title={title}
277
+ style={{
278
+ background: disabled ? '#8b8b8b' : '#c6c6c6',
279
+ border: `${Math.max(1, Math.round(scale))}px solid #555`,
280
+ color: disabled ? '#666' : '#404040',
281
+ fontFamily: 'inherit',
282
+ fontSize: 'inherit',
283
+ cursor: disabled ? 'default' : 'pointer',
284
+ padding: `${Math.round(scale)}px ${Math.round(3 * scale)}px`,
285
+ lineHeight: 1,
286
+ outline: 'none',
287
+ flexShrink: 0,
288
+ }}
289
+ >
290
+ {children}
291
+ </button>
292
+ )
293
+ }
@@ -0,0 +1 @@
1
+ export { RecipeInventoryView } from './RecipeInventoryView'
@@ -0,0 +1,111 @@
1
+ /* Bare variant: no background/border (used in JEI grid) */
2
+ .slotBare {
3
+ cursor: default;
4
+ position: relative;
5
+ overflow: visible;
6
+ }
7
+
8
+ .slot {
9
+ cursor: default;
10
+ position: relative;
11
+ overflow: visible;
12
+ }
13
+
14
+ .hovered::after {
15
+ content: '';
16
+ position: absolute;
17
+ inset: 0;
18
+ background: rgba(255, 255, 255, 0.4);
19
+ pointer-events: none;
20
+ }
21
+
22
+ .highlighted::after {
23
+ content: '';
24
+ position: absolute;
25
+ inset: 0;
26
+ background: rgba(255, 255, 128, 0.4);
27
+ pointer-events: none;
28
+ }
29
+
30
+ .dragTarget::after {
31
+ content: '';
32
+ position: absolute;
33
+ inset: 0;
34
+ background: rgba(255, 255, 255, 0.3);
35
+ pointer-events: none;
36
+ }
37
+
38
+ .resultSlot {
39
+ background: #8b8b8b;
40
+ }
41
+
42
+ .disabled {
43
+ cursor: not-allowed;
44
+ opacity: 0.5;
45
+ }
46
+
47
+ .emptyLabel {
48
+ position: absolute;
49
+ inset: 0;
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: center;
53
+ color: rgba(255, 255, 255, 0.3);
54
+ font-family: 'Minecraft', monospace;
55
+ text-align: center;
56
+ pointer-events: none;
57
+ font-size: 0.6em;
58
+ }
59
+
60
+ /* Mobile overlay backdrop */
61
+ .mobileOverlay {
62
+ position: fixed;
63
+ inset: 0;
64
+ z-index: 10000;
65
+ background: rgba(0, 0, 0, 0.3);
66
+ }
67
+
68
+ .mobileMenu {
69
+ display: flex;
70
+ flex-direction: column;
71
+ background: #282828;
72
+ border: 2px solid #555555;
73
+ font-family: 'Minecraft', monospace;
74
+ image-rendering: pixelated;
75
+ z-index: 10001;
76
+ }
77
+
78
+ .mobileMenuTitle {
79
+ color: #ffffff;
80
+ font-weight: bold;
81
+ padding-bottom: 4px;
82
+ border-bottom: 1px solid #555555;
83
+ margin-bottom: 4px;
84
+ }
85
+
86
+ .mobileBtn {
87
+ background: #404040;
88
+ color: #ffffff;
89
+ border: 1px solid #666666;
90
+ cursor: pointer;
91
+ padding: 4px 8px;
92
+ font-family: inherit;
93
+ font-size: inherit;
94
+ text-align: left;
95
+ transition: background 0.1s;
96
+ }
97
+
98
+ .mobileBtn:hover,
99
+ .mobileBtn:active {
100
+ background: #555555;
101
+ }
102
+
103
+ .mobileBtnDanger {
104
+ color: #ff5555;
105
+ border-color: #aa0000;
106
+ }
107
+
108
+ .mobileBtnDanger:hover,
109
+ .mobileBtnDanger:active {
110
+ background: #330000;
111
+ }