jaml-ui 0.21.2 → 0.21.4

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 (119) hide show
  1. package/DESIGN.md +36 -6
  2. package/dist/components/JamlAnalyzerFullscreen.d.ts +1 -1
  3. package/dist/components/JamlAnalyzerFullscreen.js +5 -81
  4. package/dist/components/JamlCurator.js +1 -1
  5. package/dist/components/JamlSpeedometer.d.ts +7 -2
  6. package/dist/components/JamlSpeedometer.js +8 -15
  7. package/dist/components/jamlMap/JamlMapEditor.js +42 -38
  8. package/dist/components/jamlMap/JokerPicker.js +2 -2
  9. package/dist/components/jamlMap/MysterySlot.js +4 -4
  10. package/dist/hooks/useSearch.d.ts +2 -1
  11. package/dist/hooks/useSearch.js +111 -8
  12. package/dist/lib/SpriteMapper.d.ts +10 -0
  13. package/dist/lib/SpriteMapper.js +48 -0
  14. package/dist/lib/cardParser.d.ts +8 -0
  15. package/dist/lib/cardParser.js +65 -0
  16. package/dist/lib/classes/BuyMetaData.d.ts +11 -0
  17. package/dist/lib/classes/BuyMetaData.js +1 -0
  18. package/dist/lib/config.d.ts +13 -0
  19. package/dist/lib/config.js +15 -0
  20. package/dist/lib/const.d.ts +61 -0
  21. package/dist/lib/const.js +521 -0
  22. package/dist/lib/data/constants.d.ts +11 -0
  23. package/dist/lib/data/constants.js +17 -0
  24. package/dist/lib/hooks/useDragScroll.d.ts +4 -0
  25. package/dist/lib/hooks/useDragScroll.js +48 -0
  26. package/dist/lib/hooks/useJamlFilter.d.ts +48 -0
  27. package/dist/lib/hooks/useJamlFilter.js +219 -0
  28. package/dist/lib/hooks/useSeedAnalyzer.d.ts +6 -0
  29. package/dist/lib/hooks/useSeedAnalyzer.js +48 -0
  30. package/dist/lib/jaml/jamlCompletion.d.ts +12 -0
  31. package/dist/lib/jaml/jamlCompletion.js +13 -0
  32. package/dist/lib/jaml/jamlData.d.ts +3 -0
  33. package/dist/lib/jaml/jamlData.js +8 -0
  34. package/dist/lib/jaml/jamlObjectives.d.ts +13 -0
  35. package/dist/lib/jaml/jamlObjectives.js +97 -0
  36. package/dist/lib/jaml/jamlParser.d.ts +14 -0
  37. package/dist/lib/jaml/jamlParser.js +47 -0
  38. package/dist/lib/jaml/jamlPresets.d.ts +8 -0
  39. package/dist/lib/jaml/jamlPresets.js +61 -0
  40. package/dist/lib/jaml/jamlSchema.d.ts +54 -0
  41. package/dist/lib/jaml/jamlSchema.js +91 -0
  42. package/dist/lib/parseDailyRitual.d.ts +45 -0
  43. package/dist/lib/parseDailyRitual.js +69 -0
  44. package/dist/lib/tts/getRevealPos.d.ts +5 -0
  45. package/dist/lib/tts/getRevealPos.js +16 -0
  46. package/dist/lib/tts/splitTtsDisplay.d.ts +19 -0
  47. package/dist/lib/tts/splitTtsDisplay.js +35 -0
  48. package/dist/lib/types.d.ts +121 -0
  49. package/dist/lib/types.js +1 -0
  50. package/dist/lib/utils.d.ts +2 -0
  51. package/dist/lib/utils.js +5 -0
  52. package/dist/ui/JimboIconButton.d.ts +10 -0
  53. package/dist/ui/JimboIconButton.js +28 -0
  54. package/dist/ui/JimboInputModal.d.ts +13 -0
  55. package/dist/ui/JimboInputModal.js +60 -0
  56. package/dist/ui/JimboSelect.d.ts +18 -0
  57. package/dist/ui/JimboSelect.js +43 -0
  58. package/dist/ui/PanelSplitter.d.ts +7 -0
  59. package/dist/ui/PanelSplitter.js +76 -0
  60. package/dist/ui/ide/AgnosticSeedCard.d.ts +19 -0
  61. package/dist/ui/ide/AgnosticSeedCard.js +48 -0
  62. package/dist/ui/ide/DeckSprite.d.ts +1 -0
  63. package/dist/ui/ide/DeckSprite.js +2 -0
  64. package/dist/ui/ide/JamlBuilder.d.ts +1 -0
  65. package/dist/ui/ide/JamlBuilder.js +112 -0
  66. package/dist/ui/ide/JamlEditor.d.ts +7 -0
  67. package/dist/ui/ide/JamlEditor.js +496 -0
  68. package/dist/ui/ide/JamlEditorMonaco.d.ts +8 -0
  69. package/dist/ui/ide/JamlEditorMonaco.js +78 -0
  70. package/dist/ui/ide/WasmStatus.d.ts +1 -0
  71. package/dist/ui/ide/WasmStatus.js +42 -0
  72. package/dist/ui/jimbo.css +336 -31
  73. package/dist/ui/jimboApp.d.ts +12 -0
  74. package/dist/ui/jimboApp.js +15 -0
  75. package/dist/ui/jimboInfoCard.d.ts +31 -0
  76. package/dist/ui/jimboInfoCard.js +26 -0
  77. package/dist/ui/jimboInset.d.ts +9 -0
  78. package/dist/ui/jimboInset.js +9 -0
  79. package/dist/ui/jimboSectionHeader.d.ts +11 -0
  80. package/dist/ui/jimboSectionHeader.js +9 -0
  81. package/dist/ui/jimboStatGrid.d.ts +13 -0
  82. package/dist/ui/jimboStatGrid.js +9 -0
  83. package/dist/ui/jimboWordmark.d.ts +10 -0
  84. package/dist/ui/jimboWordmark.js +9 -0
  85. package/dist/ui/mascot/JammySpeechBox.d.ts +9 -0
  86. package/dist/ui/mascot/JammySpeechBox.js +30 -0
  87. package/dist/ui/mascot/SeedMascot.d.ts +37 -0
  88. package/dist/ui/mascot/SeedMascot.js +17 -0
  89. package/dist/ui/mascot/index.d.ts +3 -0
  90. package/dist/ui/mascot/index.js +3 -0
  91. package/dist/ui/mascot/menuConfig.d.ts +102 -0
  92. package/dist/ui/mascot/menuConfig.js +12 -0
  93. package/dist/ui/panel.d.ts +1 -1
  94. package/dist/ui/panel.js +3 -21
  95. package/dist/ui/radial/RadialBadge.d.ts +17 -0
  96. package/dist/ui/radial/RadialBadge.js +43 -0
  97. package/dist/ui/radial/RadialBreadcrumb.d.ts +12 -0
  98. package/dist/ui/radial/RadialBreadcrumb.js +18 -0
  99. package/dist/ui/radial/RadialButton.d.ts +61 -0
  100. package/dist/ui/radial/RadialButton.js +102 -0
  101. package/dist/ui/radial/RadialMenu.d.ts +38 -0
  102. package/dist/ui/radial/RadialMenu.js +168 -0
  103. package/dist/ui/radial/RadialPill.d.ts +18 -0
  104. package/dist/ui/radial/RadialPill.js +15 -0
  105. package/dist/ui/radial/index.d.ts +16 -0
  106. package/dist/ui/radial/index.js +18 -0
  107. package/dist/ui/radial/radialMenuStore.d.ts +31 -0
  108. package/dist/ui/radial/radialMenuStore.js +122 -0
  109. package/dist/ui/radial/radialMenuViewport.d.ts +6 -0
  110. package/dist/ui/radial/radialMenuViewport.js +59 -0
  111. package/dist/ui/radial/useRadialMenu.d.ts +35 -0
  112. package/dist/ui/radial/useRadialMenu.js +107 -0
  113. package/dist/ui/showcase.d.ts +14 -6
  114. package/dist/ui/showcase.js +13 -21
  115. package/dist/ui/tokens.d.ts +5 -19
  116. package/dist/ui/tokens.js +5 -21
  117. package/dist/ui.d.ts +14 -0
  118. package/dist/ui.js +15 -0
  119. package/package.json +145 -146
package/DESIGN.md CHANGED
@@ -124,7 +124,7 @@ components:
124
124
 
125
125
  Jimbo is the design system for Balatro seed finder tools (JAML-UI, WeeJoker, Seed Finder). It recreates the cozy, tactile, chunky feel of LocalThunk's Balatro — dark panels with silver borders, 3D-press buttons, pixel typography, juice animations. Everything feels like a physical object you can poke.
126
126
 
127
- The system is built **Mobile First**. The absolute minimum viewport width is **375px**. All components must be accessible and usable at 375px without breaking layouts or horizontal scrolling. No fat padding, no bloated margins — every pixel earns its place.
127
+ The system is built **Mobile First** for the **iPhone SE viewport: 375×667px**. This is a HARD constraint — not a minimum, it is THE target. All app screens must fit within 375×667 with **NO SCROLLING**. Content must be designed to fit, not overflow. No fat padding, no bloated margins — every pixel earns its place. If content doesn't fit, redesign it to be more compact or split it into a separate view.
128
128
 
129
129
  ## Colors
130
130
 
@@ -155,12 +155,40 @@ Contrast is critical. NEVER make grey text on top of a grey background. If using
155
155
 
156
156
  ## Layout
157
157
 
158
- Target: Minimum 375px portrait width. Components must scale gracefully using relative units and flexible layouts. Avoid fixed widths that break at 375px.
158
+ Hard target: **375×667px (iPhone SE portrait)**. No scrolling on app-level screens. Components must fit within the viewport. Snap-scrolling is ONLY allowed for internal content areas that are explicitly paginated (e.g., ante pages within a fullscreen analyzer). Top-level app views must never scroll.
159
159
 
160
- Vertical snap-scroll for ante pages using magnetic scroll-snapping (`scroll-snap-type: y mandatory`, `scroll-snap-align: start`). Horizontal swipe for seed navigation. NO visible scrollbars globallyuse `::-webkit-scrollbar { display: none; }` and `-ms-overflow-style: none`.
160
+ **Thumb Zone Rule:** Back buttons, navigation buttons, and primary actions ALWAYS go at the **bottom** of the screen. Users hold their phone with one hand and tap with their thumb bottom-positioned buttons are always in reach. NEVER put back/nav buttons at the top of a mobile screen. Back buttons are ALWAYS orange and labeled "Back".
161
+
162
+ **Button Consistency:** All footer buttons use `lg` size across every screen. No mixing sizes in footers.
163
+
164
+ Horizontal swipe for seed navigation. NO visible scrollbars globally — use `::-webkit-scrollbar { display: none; }` and `-ms-overflow-style: none`.
161
165
 
162
166
  Panels use 2px solid borders with border-silver on top/sides and border-south on bottom, creating a subtle 3D card effect. Inner shadow: `inset 0 0 0 1px rgba(255,255,255,0.04)`. Outer shadow: `0 2px 0 #000`.
163
167
 
168
+ ### Responsive Breakpoints (Container Queries)
169
+
170
+ The j-app shell uses CSS **container queries** (not media queries) because it may live inside a mobile browser, a Claude MCP artifact, or a centered desktop preview. The container determines the layout, not the viewport.
171
+
172
+ | Name | Container Width | Height | Scroll | Usage |
173
+ |---|---|---|---|---|
174
+ | `compact` | ≤ 400px | Fixed 667px | Never | iPhone SE. Default. |
175
+ | `cozy` | 401–750px | Flexible | Vertical OK | MCP inline artifacts, wider phones. |
176
+ | `wide` | 751px+ | Flexible | Vertical OK | Tablet/desktop (future). |
177
+
178
+ Default is `compact` (375×667 locked). Add `<JimboApp fluid>` or `.j-app--fluid` to unlock for MCP/desktop contexts. This lets the container stretch to fill its parent (up to 750px) and activates `@container jimbo` queries in CSS.
179
+
180
+ **What changes compact → cozy:**
181
+ - Height constraint lifts — content determines height
182
+ - Vertical scroll becomes OK (host manages scroll)
183
+ - Padding bumps from `--j-space-lg` to `--j-space-xl`
184
+ - Stat grid values bump to 20px
185
+ - Info card titles bump to 14px
186
+ - Section header tags bump to 12px
187
+ - Buttons STAY `lg` — thumb zone rule still applies
188
+ - Footer STAYS bottom-anchored
189
+
190
+ **What does NOT change:** Colors, fonts, components, spacing tokens, button behavior, animation. The design language is identical — only density shifts.
191
+
164
192
  ## Elevation & Depth
165
193
 
166
194
  Buttons have a colored "underside" via box-shadow (not blur). On press, translateY increases by 2-3px and the shadow collapses — the button physically sinks. On hover, apply a tiny brightness bump (no lift).
@@ -169,9 +197,9 @@ Panels sit on a dark south-shadow (`0 3px 0 rgba(0,0,0,0.55)`). Translucent pane
169
197
 
170
198
  JAML-hit items get a GlowRing: `box-shadow: 0 0 0 2px [color], 0 0 10px [color]` with a 1.6s pulse animation. Must = blue glow, should = gold/green glow.
171
199
 
172
- ## Components
200
+ **Button:** Chunky 3D press. DO NOT ADD A COLORED EDGE/BORDER. Buttons rely entirely on a solid `box-shadow` to create the colored "underside" depth. Hover brightness bump. Press sinks +2-3px and the box-shadow collapses to 0. Variants: primary (red), secondary (blue), back (orange), default (grey). NO GOLD BUTTONS. Sizes via padding, not font-size. Easing: `cubic-bezier(0.34, 1.56, 0.64, 1)`.
173
201
 
174
- **Button:** Chunky 3D press. Colored underside via box-shadow. Hover brightness bump. Press sinks +2-3px + shadow collapse. Variants: primary (red), secondary (blue), back (orange). Sizes via padding, not font-size. Easing: `cubic-bezier(0.34, 1.56, 0.64, 1)`.
202
+ **Badge (JimboBadge):** Badges indicate status and DO NOT CLICK. They are strictly flat. They have a colored background and flat borders, but NO 3D BOX-SHADOW and NO PRESS ANIMATIONS. Variants: dark, blue, red, green, orange, purple, grey. NO GOLD BADGES.
175
203
 
176
204
  **Panel:** Dark grey (#3a5055) background, 2px solid border (silver top/sides, south bottom), border-radius 6px. Inner highlight: `inset 0 0 0 1px rgba(255,255,255,0.04)`. Drop: `0 2px 0 #000`.
177
205
 
@@ -194,8 +222,9 @@ JAML-hit items get a GlowRing: `box-shadow: 0 0 0 2px [color], 0 0 10px [color]`
194
222
  ## Do's and Don'ts
195
223
 
196
224
  - DO use m6x11plus for everything except code/monospace.
197
- - DO design for 375px portrait.
225
+ - DO design for 375×667px (iPhone SE). Everything must fit — no scrolling.
198
226
  - DO use translateY + box-shadow for button depth. Not CSS 3D transforms.
227
+ - DO put back buttons and nav actions at the **bottom** of the screen (thumb zone).
199
228
  - DON'T use font-weight bold or heavy. m6x11plus is single-weight.
200
229
  - DON'T use ALL CAPS. It is considered an embellishment and ruins the aesthetic.
201
230
  - DON'T put grey text on top of a grey background.
@@ -203,4 +232,5 @@ JAML-hit items get a GlowRing: `box-shadow: 0 0 0 2px [color], 0 0 10px [color]`
203
232
  - DON'T add visible scrollbars. Vertical magnetic snap-scroll + horizontal swipe only.
204
233
  - DON'T use rounded corners larger than 10px. Balatro is chunky, not bubbly.
205
234
  - DON'T use blur-based shadows for depth. Use solid colored box-shadows 80% opaque.
235
+ - DON'T put back/nav buttons at the top of a screen. They go at the BOTTOM.
206
236
  - DON'T use redundant JS wrappers for `motely-wasm`. Import globally and `motely.boot()` once. Use `?worker&inline` for search workers rather than blob strings, and do not prop-drill `motelyWasmUrl`.
@@ -25,6 +25,6 @@ export interface JamlAnalyzerFullscreenProps {
25
25
  /** Custom top page to render as Slide 0 */
26
26
  topPage?: React.ReactNode;
27
27
  }
28
- export declare function JamlAnalyzerFullscreen({ antes, live, jaml, tallyColumns, tallyLabels, enabledStreams, onEnabledStreamsChange, hidePicker, chunkSize, className, topPage, }: JamlAnalyzerFullscreenProps): import("react/jsx-runtime").JSX.Element;
28
+ export declare function JamlAnalyzerFullscreen({ antes, live, jaml, tallyColumns, tallyLabels, enabledStreams, chunkSize, className, topPage, }: JamlAnalyzerFullscreenProps): import("react/jsx-runtime").JSX.Element;
29
29
  export type { AnalyzerItem };
30
30
  export { ANALYZER_STREAM_META, type AnalyzerStreamKey } from "../hooks/analyzerStreamRegistry.js";
@@ -1,6 +1,6 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { useCallback, useMemo, useRef, useState } from "react";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useCallback, useMemo, useRef } from "react";
4
4
  import { JamlBoss, JamlGameCard, JamlTag, JamlVoucher, resolveAnalyzerShopItem } from "./GameCard.js";
5
5
  import { useMotelyStream } from "../hooks/useShopStream.js";
6
6
  import { useInfiniteScroll } from "../hooks/useIntersectionObserver.js";
@@ -17,16 +17,10 @@ const TONE_COLORS = {
17
17
  default: C.GOLD_TEXT,
18
18
  };
19
19
  import { JamlMapPreview } from "./JamlMapPreview.js";
20
- export function JamlAnalyzerFullscreen({ antes, live, jaml, tallyColumns, tallyLabels, enabledStreams, onEnabledStreamsChange, hidePicker = false, chunkSize = 12, className = "", topPage, }) {
21
- const [internalEnabled, setInternalEnabled] = useState(enabledStreams ?? DEFAULT_ENABLED_STREAMS);
22
- const effectiveEnabled = enabledStreams ?? internalEnabled;
23
- const setEnabled = useCallback((next) => {
24
- setInternalEnabled(next);
25
- onEnabledStreamsChange?.(next);
26
- }, [onEnabledStreamsChange]);
20
+ export function JamlAnalyzerFullscreen({ antes, live, jaml, tallyColumns, tallyLabels, enabledStreams, chunkSize = 12, className = "", topPage, }) {
21
+ const effectiveEnabled = enabledStreams ?? DEFAULT_ENABLED_STREAMS;
27
22
  const { currentAnte, scrollRef, scrollToAnte, registerAnteRef } = useAnteTracker(antes);
28
- const [pickerOpen, setPickerOpen] = useState(false);
29
- return (_jsxs("div", { className: className, style: styles.root, children: [_jsxs("div", { ref: scrollRef, style: styles.scroller, children: [topPage ? topPage : jaml && (_jsxs("section", { style: { ...styles.section, scrollSnapAlign: "start", justifyContent: 'center' }, children: [_jsxs("div", { style: { marginBottom: 20 }, children: [_jsx("div", { style: styles.anteLabel, children: "JAML" }), _jsx("div", { style: styles.anteNumber, children: "MAP" })] }), _jsx(JamlMapPreview, { jaml: jaml, tallyColumns: tallyColumns, tallyLabels: tallyLabels }), _jsx("div", { style: { marginTop: 24, textAlign: 'center', opacity: 0.6 }, children: _jsx(JimboText, { size: "xs", tone: "grey", children: "Scroll down to explore seed details" }) })] })), antes.map((ante) => (_jsx(AnteSection, { ante: ante, live: live, enabledStreams: effectiveEnabled, chunkSize: chunkSize, registerRef: (el) => registerAnteRef(ante.ante, el) }, ante.ante)))] }), _jsx(SideRail, { antes: antes.map((a) => a.ante), currentAnte: currentAnte, onJump: scrollToAnte }), !hidePicker && (_jsx(StreamPicker, { enabled: effectiveEnabled, onChange: setEnabled, open: pickerOpen, onToggle: () => setPickerOpen((v) => !v) }))] }));
23
+ return (_jsxs("div", { className: className, style: styles.root, children: [_jsxs("div", { ref: scrollRef, style: styles.scroller, children: [topPage ? topPage : jaml && (_jsxs("section", { style: { ...styles.section, scrollSnapAlign: "start", justifyContent: 'center' }, children: [_jsxs("div", { style: { marginBottom: 20 }, children: [_jsx("div", { style: styles.anteLabel, children: "JAML" }), _jsx("div", { style: styles.anteNumber, children: "MAP" })] }), _jsx(JamlMapPreview, { jaml: jaml, tallyColumns: tallyColumns, tallyLabels: tallyLabels }), _jsx("div", { style: { marginTop: 24, textAlign: 'center', opacity: 0.6 }, children: _jsx(JimboText, { size: "xs", tone: "grey", children: "Scroll down to explore seed details" }) })] })), antes.map((ante) => (_jsx(AnteSection, { ante: ante, live: live, enabledStreams: effectiveEnabled, chunkSize: chunkSize, registerRef: (el) => registerAnteRef(ante.ante, el) }, ante.ante)))] }), _jsx(SideRail, { antes: antes.map((a) => a.ante), currentAnte: currentAnte, onJump: scrollToAnte })] }));
30
24
  }
31
25
  function AnteSection({ ante, live, enabledStreams, chunkSize, registerRef }) {
32
26
  return (_jsxs("section", { ref: registerRef, "data-ante": ante.ante, style: styles.section, children: [_jsxs("header", { style: styles.header, children: [_jsxs("div", { children: [_jsx("div", { style: styles.anteLabel, children: "Ante" }), _jsx("div", { style: styles.anteNumber, children: ante.ante })] }), ante.voucher && (_jsxs("div", { style: styles.voucherBlock, children: [_jsx(JamlVoucher, { voucherName: ante.voucher, scale: 0.85 }), _jsx("div", { style: styles.voucherCaption, children: ante.voucher })] }))] }), _jsxs("div", { style: styles.blindRow, children: [_jsx(BlindCell, { label: "Small", tag: ante.smallBlindTag }), _jsx(BlindCell, { label: "Big", tag: ante.bigBlindTag }), ante.boss && (_jsxs("div", { style: styles.bossCell, children: [_jsx("div", { style: styles.cellLabel, children: "Boss" }), _jsx(JamlBoss, { bossName: ante.boss, scale: 0.7 }), _jsx("div", { style: styles.cellCaption, children: ante.boss })] }))] }), ante.packs && ante.packs.length > 0 && (_jsxs("div", { style: styles.streamLane, children: [_jsx("div", { style: styles.streamLabel, children: "Packs" }), _jsx("div", { style: styles.packRow, children: ante.packs.map((pack, i) => (_jsx("div", { style: styles.packPill, children: pack }, `${ante.ante}-pack-${i}`))) })] })), enabledStreams.map((key) => {
@@ -99,28 +93,6 @@ function SideRail({ antes, currentAnte, onJump }) {
99
93
  } }, ante));
100
94
  }) }));
101
95
  }
102
- function StreamPicker({ enabled, onChange, open, onToggle }) {
103
- const enabledSet = new Set(enabled);
104
- const all = Object.values(ANALYZER_STREAM_META);
105
- function toggle(key) {
106
- const next = new Set(enabledSet);
107
- if (next.has(key))
108
- next.delete(key);
109
- else
110
- next.add(key);
111
- onChange(all.map((m) => m.key).filter((k) => next.has(k)));
112
- }
113
- return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", onClick: onToggle, style: styles.pickerButton, "aria-label": "Toggle stream picker", children: open ? "✕" : "≡" }), open && (_jsxs("div", { style: styles.pickerPanel, children: [_jsx("div", { style: styles.pickerHeader, children: "Streams" }), all.map((meta) => {
114
- const isOn = enabledSet.has(meta.key);
115
- const tone = TONE_COLORS[meta.tone] ?? TONE_COLORS.default;
116
- return (_jsxs("button", { type: "button", onClick: () => toggle(meta.key), style: {
117
- ...styles.pickerChip,
118
- borderColor: isOn ? tone : withAlpha(C.WHITE, 0.15),
119
- color: isOn ? tone : C.GREY,
120
- background: isOn ? withAlpha(tone, 0.1) : "transparent",
121
- }, children: [isOn ? "●" : "○", " ", meta.label] }, meta.key));
122
- })] }))] }));
123
- }
124
96
  const styles = {
125
97
  root: {
126
98
  position: "relative",
@@ -287,53 +259,5 @@ const styles = {
287
259
  padding: 0,
288
260
  transition: "transform 0.15s ease, background 0.15s ease, box-shadow 0.15s ease",
289
261
  },
290
- pickerButton: {
291
- position: "absolute",
292
- top: 12,
293
- right: 12,
294
- width: 32,
295
- height: 32,
296
- borderRadius: 4,
297
- border: `1px solid ${withAlpha(C.WHITE, 0.2)}`,
298
- background: withAlpha(C.DARK_GREY, 0.85),
299
- color: C.WHITE,
300
- fontSize: 16,
301
- cursor: "pointer",
302
- zIndex: 6,
303
- fontFamily: "inherit",
304
- },
305
- pickerPanel: {
306
- position: "absolute",
307
- top: 50,
308
- right: 12,
309
- width: 220,
310
- maxHeight: "70vh",
311
- overflowY: "auto",
312
- padding: 10,
313
- background: withAlpha(C.DARK_GREY, 0.95),
314
- border: `1px solid ${withAlpha(C.WHITE, 0.15)}`,
315
- borderRadius: 6,
316
- display: "flex",
317
- flexDirection: "column",
318
- gap: 4,
319
- zIndex: 6,
320
- backdropFilter: "blur(4px)",
321
- },
322
- pickerHeader: {
323
- fontSize: 10,
324
- color: C.GREY,
325
- letterSpacing: "0.16em",
326
- marginBottom: 4,
327
- },
328
- pickerChip: {
329
- padding: "6px 10px",
330
- border: "1px solid",
331
- borderRadius: 4,
332
- fontSize: 11,
333
- fontFamily: "inherit",
334
- cursor: "pointer",
335
- textAlign: "left",
336
- transition: "all 0.12s ease",
337
- },
338
262
  };
339
263
  export { ANALYZER_STREAM_META } from "../hooks/analyzerStreamRegistry.js";
@@ -59,5 +59,5 @@ export function JamlCurator({}) {
59
59
  padding: "16px 12px 24px",
60
60
  boxSizing: "border-box",
61
61
  borderBottom: `2px solid ${C.GOLD}`,
62
- }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' }, children: [_jsx(JimboText, { size: "lg", tone: "gold", children: "JAML Curator" }), _jsx(JimboButton, { tone: isSearching ? "red" : "green", size: "sm", onClick: handleSearch, children: isSearching ? "STOP" : "SEARCH" })] }), _jsx("div", { style: { flex: 1, minHeight: 0, overflowY: 'auto' }, className: "hide-scrollbar", children: _jsx(JamlMapEditor, { onChange: handleMapChange }) }), _jsx("div", { style: { flexShrink: 0 }, children: _jsx(JamlSpeedometer, { status: search.status, seedsPerSecond: search.seedsPerSecond, totalSearched: search.totalSearched, matchingSeeds: search.matchingSeeds }) }), _jsx("div", { style: { flexShrink: 0 }, children: _jsx(JimboPanel, { children: search.results.length === 0 ? (_jsx(JimboText, { size: "sm", tone: "grey", className: "j-text-center", children: isSearching ? "Searching..." : "No results yet." })) : (_jsxs("div", { className: "j-flex-col j-gap-sm", children: [_jsxs("div", { className: "j-flex j-items-center j-justify-between", children: [_jsx(JimboText, { size: "xs", tone: "grey", children: "SEED MATCHES" }), _jsxs(JimboText, { size: "xs", tone: "gold", children: [search.matchingSeeds, " FOUND"] })] }), _jsx(JimboFlankNav, { canPrev: resultIndex > 0, canNext: resultIndex < search.results.length - 1, onPrev: () => setResultIndex(i => Math.max(0, i - 1)), onNext: () => setResultIndex(i => Math.min(search.results.length - 1, i + 1)), children: _jsxs("div", { className: "j-flex-col j-items-center j-gap-xs", children: [_jsx(JimboText, { size: "lg", tone: "gold", style: { letterSpacing: 2 }, children: currentSeed }), _jsx(JimboButton, { tone: "grey", size: "xs", children: "Copy Seed" })] }) }), _jsx(JimboText, { size: "micro", tone: "grey", className: "j-text-center", style: { opacity: 0.7, marginTop: 8 }, children: "\u25BC SWIPE DOWN FOR ANTES \u25BC" })] })) }) })] }) }) }));
62
+ }, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' }, children: [_jsx(JimboText, { size: "lg", tone: "gold", children: "JAML Curator" }), _jsx(JimboButton, { tone: isSearching ? "red" : "green", size: "sm", onClick: handleSearch, children: isSearching ? "STOP" : "SEARCH" })] }), _jsx("div", { style: { flex: 1, minHeight: 0, overflowY: 'auto' }, className: "hide-scrollbar", children: _jsx(JamlMapEditor, { onChange: handleMapChange }) }), _jsx("div", { style: { flexShrink: 0 }, children: _jsx(JamlSpeedometer, { status: search.status, seedsPerSecond: search.seedsPerSecond, totalSearched: search.totalSearched, matchingSeeds: search.matchingSeeds }) }), _jsx("div", { style: { flexShrink: 0 }, children: _jsx(JimboPanel, { children: search.results.length === 0 ? (_jsx(JimboText, { size: "sm", tone: "grey", className: "j-text-center", children: isSearching ? "Searching..." : "No results yet." })) : (_jsxs("div", { className: "j-flex-col j-gap-sm", children: [_jsxs("div", { className: "j-flex j-items-center j-justify-between", children: [_jsx(JimboText, { size: "xs", tone: "grey", children: "SEED MATCHES" }), _jsxs(JimboText, { size: "xs", tone: "gold", children: [search.matchingSeeds, " FOUND"] })] }), _jsx(JimboFlankNav, { canPrev: resultIndex > 0, canNext: resultIndex < search.results.length - 1, onPrev: () => setResultIndex(i => Math.max(0, i - 1)), onNext: () => setResultIndex(i => Math.min(search.results.length - 1, i + 1)), children: _jsxs("div", { className: "j-flex-col j-items-center j-gap-xs", children: [_jsx(JimboText, { size: "lg", tone: "gold", style: { letterSpacing: 2 }, children: currentSeed }), _jsx(JimboButton, { tone: "blue", size: "xs", children: "Copy Seed" })] }) }), _jsx(JimboText, { size: "micro", tone: "grey", className: "j-text-center", style: { opacity: 0.7, marginTop: 8 }, children: "\u25BC SWIPE DOWN FOR ANTES \u25BC" })] })) }) })] }) }) }));
63
63
  }
@@ -1,11 +1,16 @@
1
+ import React from "react";
1
2
  export type JamlSpeedometerStatus = "idle" | "booting" | "running" | "completed" | "cancelled" | "error";
2
3
  export interface JamlSpeedometerProps {
3
4
  seedsPerSecond: number;
4
5
  totalSearched: bigint | number;
5
6
  matchingSeeds: bigint | number;
6
7
  status: JamlSpeedometerStatus;
8
+ className?: string;
9
+ style?: React.CSSProperties;
7
10
  }
8
11
  /**
9
- * Compact live-search stats strip for MCP/app chrome.
12
+ * Compact live-search stats strip NOT a car speedometer.
13
+ * Three stat cells in a row: speed | searched | matches.
14
+ * Uses j-stat-grid CSS class from jimbo.css.
10
15
  */
11
- export declare function JamlSpeedometer({ seedsPerSecond, totalSearched, matchingSeeds, status, }: JamlSpeedometerProps): import("react/jsx-runtime").JSX.Element;
16
+ export declare function JamlSpeedometer({ seedsPerSecond, totalSearched, matchingSeeds, status, className, style, }: JamlSpeedometerProps): import("react/jsx-runtime").JSX.Element;
@@ -1,13 +1,12 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { JimboColorOption } from "../ui/tokens.js";
4
- const C = JimboColorOption;
3
+ import { JimboText } from "../ui/jimboText.js";
5
4
  function formatCount(value) {
6
5
  return Number(value).toLocaleString();
7
6
  }
8
7
  function formatSpeed(value) {
9
8
  if (!Number.isFinite(value) || value <= 0)
10
- return "0/s";
9
+ return "";
11
10
  if (value >= 1_000_000)
12
11
  return `${(value / 1_000_000).toFixed(1)}M/s`;
13
12
  if (value >= 1_000)
@@ -15,18 +14,12 @@ function formatSpeed(value) {
15
14
  return `${Math.round(value)}/s`;
16
15
  }
17
16
  /**
18
- * Compact live-search stats strip for MCP/app chrome.
17
+ * Compact live-search stats strip NOT a car speedometer.
18
+ * Three stat cells in a row: speed | searched | matches.
19
+ * Uses j-stat-grid CSS class from jimbo.css.
19
20
  */
20
- export function JamlSpeedometer({ seedsPerSecond, totalSearched, matchingSeeds, status, }) {
21
+ export function JamlSpeedometer({ seedsPerSecond, totalSearched, matchingSeeds, status, className = "", style, }) {
21
22
  const active = status === "running" || status === "booting";
22
- const tone = status === "error" ? C.RED : active ? C.GOLD : C.GREY;
23
- return (_jsxs("div", { style: {
24
- display: "flex",
25
- alignItems: "center",
26
- gap: 8,
27
- color: tone,
28
- fontSize: 11,
29
- fontFamily: "var(--font-sans, m6x11plus), monospace",
30
- whiteSpace: "nowrap",
31
- }, children: [_jsx("span", { children: status }), _jsx("span", { children: formatSpeed(seedsPerSecond) }), _jsxs("span", { children: [formatCount(totalSearched), " searched"] }), _jsxs("span", { children: [formatCount(matchingSeeds), " matches"] })] }));
23
+ const statusTone = status === "error" ? "red" : active ? "green" : "grey";
24
+ return (_jsxs("div", { className: `j-stat-grid ${className}`, style: style, children: [_jsxs("div", { children: [_jsx("div", { className: "j-stat-grid__value", children: _jsx(JimboText, { size: "md", tone: active ? "gold" : "grey", children: formatSpeed(seedsPerSecond) }) }), _jsx("div", { className: "j-stat-grid__label", children: "speed" })] }), _jsxs("div", { children: [_jsx("div", { className: "j-stat-grid__value", children: _jsx(JimboText, { size: "md", tone: "white", children: formatCount(totalSearched) }) }), _jsx("div", { className: "j-stat-grid__label", children: "searched" })] }), _jsxs("div", { children: [_jsx("div", { className: "j-stat-grid__value", children: _jsx(JimboText, { size: "md", tone: Number(matchingSeeds) > 0 ? "green" : "grey", children: formatCount(matchingSeeds) }) }), _jsx("div", { className: "j-stat-grid__label", children: "matches" })] })] }));
32
25
  }
@@ -92,15 +92,15 @@ export function JamlMapEditor({ zone: initialZone = "must", onChange, }) {
92
92
  const handleOverlayClose = useCallback(() => {
93
93
  setActiveSlot(null);
94
94
  }, []);
95
- const jsonTree = useMemo(() => buildJsonTree(antesState), [antesState]);
95
+ const jamlText = useMemo(() => buildJamlText(antesState), [antesState]);
96
96
  useEffect(() => {
97
- onChange?.(JSON.stringify(jsonTree, null, 2));
98
- }, [jsonTree, onChange]);
97
+ onChange?.(jamlText);
98
+ }, [jamlText, onChange]);
99
99
  const renderSlot = (anteIndex, id, width, sheetType, forceCategory) => {
100
100
  const sel = (antesState[anteIndex] || {})[id];
101
101
  return (_jsx(MysterySlot, { zone: sel ? sel.zone : currentZone, sheetType: sheetType, selection: sel, width: width, onTap: () => handleSlotTap(anteIndex, id, forceCategory), onClear: sel ? () => handleSlotClear(anteIndex, id) : undefined, style: { flexShrink: 0 } }, id));
102
102
  };
103
- return (_jsxs("div", { style: { width: "100%", height: "100%", display: "flex", flexDirection: "column" }, children: [_jsxs("div", { style: { position: "sticky", top: 0, zIndex: 10, background: C.DARKEST, padding: "max(32px, env(safe-area-inset-top, 32px)) 0 8px 0", borderBottom: `2px solid ${C.PANEL_EDGE}` }, children: [_jsx(JimboText, { size: "md", tone: "white", dance: true, style: { textAlign: "center", marginBottom: 12 }, children: "JAML VISUAL BUILDER" }), _jsx("div", { className: "j-flex j-gap-sm", style: { justifyContent: "center" }, children: ["must", "should", "mustnot"].map((z) => (_jsx(JimboButton, { tone: currentZone === z ? ZONE_TONE[z] : "blue", size: "sm", onClick: () => setCurrentZone(z), style: { opacity: currentZone === z ? 1 : 0.4 }, children: ZONE_LABEL[z] }, z))) })] }), _jsx("div", { className: "hide-scrollbar", style: {
103
+ return (_jsxs("div", { style: { width: "100%", height: "100%", display: "flex", flexDirection: "column" }, children: [_jsxs("div", { style: { position: "sticky", top: 0, zIndex: 10, background: C.DARKEST, padding: "max(32px, env(safe-area-inset-top, 32px)) 0 8px 0", borderBottom: `2px solid ${C.PANEL_EDGE}` }, children: [_jsx(JimboText, { size: "md", tone: "white", style: { textAlign: "center", marginBottom: 12 }, children: "JAML VISUAL BUILDER" }), _jsx("div", { className: "j-flex j-gap-sm", style: { justifyContent: "center" }, children: ["must", "should", "mustnot"].map((z) => (_jsx(JimboButton, { tone: currentZone === z ? ZONE_TONE[z] : "blue", size: "sm", onClick: () => setCurrentZone(z), style: { opacity: currentZone === z ? 1 : 0.4 }, children: ZONE_LABEL[z] }, z))) })] }), _jsx("div", { className: "hide-scrollbar", style: {
104
104
  flex: 1,
105
105
  overflowY: "auto",
106
106
  scrollSnapType: "y mandatory",
@@ -113,7 +113,7 @@ export function JamlMapEditor({ zone: initialZone = "must", onChange, }) {
113
113
  flexDirection: "column",
114
114
  gap: 24,
115
115
  borderBottom: `2px solid ${C.DARK_GREY}`
116
- }, children: [_jsxs(JimboText, { size: "md", tone: "white", dance: true, style: { textAlign: "center", marginBottom: 8 }, children: ["ANTE ", a] }), _jsxs("div", { className: "j-flex j-justify-between j-items-end", children: [_jsxs("div", { className: "j-flex-col j-items-center j-gap-xs", children: [_jsx(JimboText, { size: "micro", tone: "grey", children: "VOUCHER" }), renderSlot(a, `ante_${a}_voucher`, 42, "Vouchers", "voucher")] }), _jsxs("div", { className: "j-flex-col j-items-center j-gap-xs", children: [_jsx(JimboText, { size: "micro", tone: "grey", children: "SMALL" }), renderSlot(a, `ante_${a}_tag_small`, 42, "tags", "tag")] }), _jsxs("div", { className: "j-flex-col j-items-center j-gap-xs", children: [_jsx(JimboText, { size: "micro", tone: "grey", children: "BIG" }), renderSlot(a, `ante_${a}_tag_big`, 42, "tags", "tag")] }), _jsxs("div", { className: "j-flex-col j-items-center j-gap-xs", children: [_jsx(JimboText, { size: "micro", tone: "grey", children: "BOSS" }), renderSlot(a, `ante_${a}_boss`, 42, "BlindChips", "boss")] })] }), _jsxs("div", { className: "j-flex-col j-gap-xs", children: [_jsx(JimboText, { size: "xs", tone: "grey", style: { letterSpacing: 1 }, children: "SHOP ITEMS" }), _jsx("div", { className: "j-flex hide-scrollbar j-gap-sm", style: { overflowX: "auto", paddingBottom: 8 }, children: [1, 2, 3, 4, 5, 6, 7, 8].map(i => renderSlot(a, `ante_${a}_shop_${i}`, 52, "Jokers")) })] }), _jsxs("div", { className: "j-flex-col j-gap-xs", children: [_jsx(JimboText, { size: "xs", tone: "grey", style: { letterSpacing: 1 }, children: "PACKS" }), _jsx("div", { className: "j-flex j-gap-sm", style: { flexWrap: "wrap" }, children: [1, 2, 3, 4, 5, 6].map(i => renderSlot(a, `ante_${a}_pack_${i}`, 64, "Boosters", "pack")) })] })] }, a))) }), _jsx(JimboModal, { open: activeSlot !== null, onClose: handlePickerCancel, title: pickerFlow === "category" ? "Select Category" : undefined, className: "j-picker-modal", children: activeSlot !== null && (pickerFlow === "category" ? (_jsx(CategoryMenu, { onSelect: handleCategorySelect })) : pickerFlow === "joker" ? (_jsx(JokerPicker, { onSelect: handleItemSelect, onCancel: handlePickerCancel })) : (_jsx(CategoryPicker, { config: CATEGORY_CONFIG_MAP[pickerFlow], onSelect: handleItemSelect, onCancel: handlePickerCancel }))) })] }));
116
+ }, children: [_jsxs(JimboText, { size: "md", tone: "white", style: { textAlign: "center", marginBottom: 8 }, children: ["ANTE ", a] }), _jsxs("div", { className: "j-flex j-justify-between j-items-end", children: [_jsxs("div", { className: "j-flex-col j-items-center j-gap-xs", children: [_jsx(JimboText, { size: "micro", tone: "grey", children: "VOUCHER" }), renderSlot(a, `ante_${a}_voucher`, 42, "Vouchers", "voucher")] }), _jsxs("div", { className: "j-flex-col j-items-center j-gap-xs", children: [_jsx(JimboText, { size: "micro", tone: "grey", children: "SMALL" }), renderSlot(a, `ante_${a}_tag_small`, 42, "tags", "tag")] }), _jsxs("div", { className: "j-flex-col j-items-center j-gap-xs", children: [_jsx(JimboText, { size: "micro", tone: "grey", children: "BIG" }), renderSlot(a, `ante_${a}_tag_big`, 42, "tags", "tag")] }), _jsxs("div", { className: "j-flex-col j-items-center j-gap-xs", children: [_jsx(JimboText, { size: "micro", tone: "grey", children: "BOSS" }), renderSlot(a, `ante_${a}_boss`, 42, "BlindChips", "boss")] })] }), _jsxs("div", { className: "j-flex-col j-gap-xs", children: [_jsx(JimboText, { size: "xs", tone: "grey", style: { letterSpacing: 1 }, children: "SHOP ITEMS" }), _jsx("div", { className: "j-flex hide-scrollbar j-gap-sm", style: { overflowX: "auto", paddingBottom: 8 }, children: [1, 2, 3, 4, 5, 6, 7, 8].map(i => renderSlot(a, `ante_${a}_shop_${i}`, 52, "Jokers")) })] }), _jsxs("div", { className: "j-flex-col j-gap-xs", children: [_jsx(JimboText, { size: "xs", tone: "grey", style: { letterSpacing: 1 }, children: "PACKS" }), _jsx("div", { className: "j-flex j-gap-sm", style: { flexWrap: "wrap" }, children: [1, 2, 3, 4, 5, 6].map(i => renderSlot(a, `ante_${a}_pack_${i}`, 64, "Boosters", "pack")) })] })] }, a))) }), _jsx(JimboModal, { open: activeSlot !== null, onClose: handlePickerCancel, title: pickerFlow === "category" ? "Select Category" : undefined, className: "j-picker-modal", children: activeSlot !== null && (pickerFlow === "category" ? (_jsx(CategoryMenu, { onSelect: handleCategorySelect })) : pickerFlow === "joker" ? (_jsx(JokerPicker, { onSelect: handleItemSelect, onCancel: handlePickerCancel })) : (_jsx(CategoryPicker, { config: CATEGORY_CONFIG_MAP[pickerFlow], onSelect: handleItemSelect, onCancel: handlePickerCancel }))) })] }));
117
117
  }
118
118
  // ─── Category Selection Menu ─────────────────────────────────────────────────
119
119
  function CategoryMenu({ onSelect, }) {
@@ -126,45 +126,49 @@ function CategoryMenu({ onSelect, }) {
126
126
  overflowY: "auto",
127
127
  }, children: CATEGORIES.map((cat) => (_jsx(JimboButton, { tone: cat.tone, size: "sm", fullWidth: true, onClick: () => onSelect(cat.key), children: _jsxs("div", { style: { display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left" }, children: [_jsx(JimboSprite, { name: cat.sprite, sheet: cat.sheet, width: 24 }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 1 }, children: [_jsx("span", { style: { fontSize: 11 }, children: cat.label }), _jsx("span", { style: { fontSize: 8, opacity: 0.7, letterSpacing: "0.04em", lineHeight: 1, whiteSpace: "normal" }, children: cat.hint })] })] }) }, cat.key))) }));
128
128
  }
129
- // ─── Build JSON tree from slots ──────────────────────────────────────────────
130
- function buildJsonTree(antes) {
131
- const must = [];
132
- const should = [];
133
- const mustNot = [];
129
+ // ─── Build JAML text from slots ──────────────────────────────────────────────
130
+ function buildJamlText(antes) {
131
+ const byZone = {
132
+ must: {}, should: {}, mustnot: {}
133
+ };
134
134
  for (const [anteStr, selections] of Object.entries(antes)) {
135
135
  const anteNum = parseInt(anteStr, 10);
136
- // Group by zone
137
- const byZone = {
138
- must: {}, should: {}, mustnot: {}
139
- };
140
136
  for (const sel of Object.values(selections)) {
141
- if (!byZone[sel.zone][sel.clauseKey]) {
142
- byZone[sel.zone][sel.clauseKey] = [];
137
+ const zone = sel.zone;
138
+ const key = sel.clauseKey;
139
+ if (!byZone[zone][key]) {
140
+ byZone[zone][key] = [];
141
+ }
142
+ const existing = byZone[zone][key].find(item => item.value === sel.value);
143
+ if (existing) {
144
+ if (!existing.antes.includes(anteNum))
145
+ existing.antes.push(anteNum);
146
+ }
147
+ else {
148
+ byZone[zone][key].push({ value: sel.value, antes: [anteNum] });
143
149
  }
144
- byZone[sel.zone][sel.clauseKey].push(sel.value);
145
150
  }
146
- for (const z of ["must", "should", "mustnot"]) {
147
- const clauseList = Object.entries(byZone[z]);
148
- if (clauseList.length === 0)
149
- continue;
150
- const obj = { ante: anteNum };
151
- for (const [key, values] of clauseList) {
152
- obj[key] = values.length === 1 ? values[0] : values;
151
+ }
152
+ let lines = [];
153
+ lines.push("name: My Custom Seed Map");
154
+ lines.push("author: JamlBuilder");
155
+ lines.push("description: Auto-generated from the visual editor.");
156
+ lines.push("deck: Red");
157
+ lines.push("stake: White");
158
+ for (const [zone, label] of [["must", "must"], ["should", "should"], ["mustnot", "mustNot"]]) {
159
+ const clauses = byZone[zone];
160
+ if (Object.keys(clauses).length === 0)
161
+ continue;
162
+ lines.push(`${label}:`);
163
+ for (const [key, items] of Object.entries(clauses)) {
164
+ for (const item of items) {
165
+ lines.push(` - ${key}: ${item.value}`);
166
+ // Only emit `antes:` if it's not all 8 antes (simplification, or just emit it)
167
+ if (item.antes.length < 8) {
168
+ lines.push(` antes: [${item.antes.sort((a, b) => a - b).join(", ")}]`);
169
+ }
153
170
  }
154
- if (z === "must")
155
- must.push(obj);
156
- else if (z === "should")
157
- should.push(obj);
158
- else if (z === "mustnot")
159
- mustNot.push(obj);
160
171
  }
161
172
  }
162
- const result = {};
163
- if (must.length > 0)
164
- result.must = must;
165
- if (should.length > 0)
166
- result.should = should;
167
- if (mustNot.length > 0)
168
- result.mustNot = mustNot;
169
- return result;
173
+ return lines.join("\n") + "\n";
170
174
  }
@@ -60,7 +60,7 @@ const RARITY_META = {
60
60
  common: { label: "Common", tone: "blue", hint: "Found in shops and Buffoon Packs" },
61
61
  uncommon: { label: "Uncommon", tone: "green", hint: "Found in shops and Buffoon Packs" },
62
62
  rare: { label: "Rare", tone: "red", hint: "Found in shops and Buffoon Packs" },
63
- legendary: { label: "Legendary", tone: "gold", hint: "Spawns from The Soul only!" },
63
+ legendary: { label: "Legendary", tone: "tarot", hint: "Spawns from The Soul only!" },
64
64
  };
65
65
  export function JokerPicker({ onSelect, onCancel }) {
66
66
  const [step, setStep] = useState("rarity");
@@ -112,7 +112,7 @@ export function JokerPicker({ onSelect, onCancel }) {
112
112
  justifyContent: "space-between",
113
113
  padding: "8px 10px",
114
114
  borderBottom: `2px solid ${C.PANEL_EDGE}`,
115
- }, children: [_jsx(JimboButton, { tone: "grey", size: "xs", onClick: () => setStep("rarity"), children: "\u2190 Back" }), _jsxs(JimboText, { size: "md", children: [RARITY_META[selectedRarity].label, " Jokers"] }), _jsx("div", { style: { width: 44 } })] }), _jsxs("div", { className: "j-flex j-gap-sm", style: { padding: "8px 10px 4px" }, children: [_jsx("input", { className: "j-seed-input__field", type: "text", placeholder: "Search jokers...", value: search, onChange: (e) => setSearch(e.target.value), style: { fontSize: 13, padding: "6px 10px", textTransform: "none", letterSpacing: "0.04em" } }), _jsx(JimboButton, { tone: "gold", size: "sm", onClick: handleAnySelect, children: "Any" })] }), selectedRarity === "legendary" && (_jsx("div", { className: "j-inner-panel", style: { margin: "4px 10px 6px", padding: "6px 10px" }, children: _jsx(JimboText, { size: "xs", tone: "purple", children: "Legendary jokers spawn from The Soul. Find it in Arcana Pack, Spectral Pack, Charm Tag, or Ethereal Tag only!" }) })), _jsxs("div", { style: {
115
+ }, children: [_jsx(JimboButton, { tone: "orange", size: "xs", onClick: () => setStep("rarity"), children: "\u2190 Back" }), _jsxs(JimboText, { size: "md", children: [RARITY_META[selectedRarity].label, " Jokers"] }), _jsx("div", { style: { width: 44 } })] }), _jsxs("div", { className: "j-flex j-gap-sm", style: { padding: "8px 10px 4px" }, children: [_jsx("input", { className: "j-seed-input__field", type: "text", placeholder: "Search jokers...", value: search, onChange: (e) => setSearch(e.target.value), style: { fontSize: 13, padding: "6px 10px", textTransform: "none", letterSpacing: "0.04em" } }), _jsx(JimboButton, { tone: "orange", size: "sm", onClick: handleAnySelect, children: "Any" })] }), selectedRarity === "legendary" && (_jsx("div", { className: "j-inner-panel", style: { margin: "4px 10px 6px", padding: "6px 10px" }, children: _jsx(JimboText, { size: "xs", tone: "purple", children: "Legendary jokers spawn from The Soul. Find it in Arcana Pack, Spectral Pack, Charm Tag, or Ethereal Tag only!" }) })), _jsxs("div", { style: {
116
116
  display: "grid",
117
117
  gridTemplateColumns: "repeat(auto-fill, minmax(64px, 1fr))",
118
118
  gap: 6,
@@ -51,10 +51,10 @@ export function MysterySlot({ zone, sheetType, selection, width = 56, onTap, onC
51
51
  const nx = Math.max(-1, Math.min(1, ((e.clientX - rect.left) / rect.width - 0.5) * 2));
52
52
  const ny = Math.max(-1, Math.min(1, ((e.clientY - rect.top) / rect.height - 0.5) * 2));
53
53
  setTilt({
54
- rx: ny * -20, // max 20deg tilt
55
- ry: nx * 20,
56
- tx: nx * -4, // subtle shift
57
- ty: ny * -4,
54
+ rx: ny * -8, // subtle max 8deg tilt
55
+ ry: nx * 8,
56
+ tx: nx * -2, // subtle shift
57
+ ty: ny * -2,
58
58
  });
59
59
  };
60
60
  const handleMouseLeave = () => {
@@ -13,11 +13,12 @@ export interface UseSearchState {
13
13
  seedsPerSecond: number;
14
14
  tallyLabels: string[];
15
15
  }
16
- export declare function useSearch(): {
16
+ export declare function useSearch(motelyWasmUrl?: string): {
17
17
  start: (jaml: string, count: number) => void;
18
18
  startAesthetic: (jaml: string, aesthetic: number) => void;
19
19
  startSeedList: (jaml: string, seeds: string[]) => void;
20
20
  startKeyword: (jaml: string, keywords: string, padding?: string) => void;
21
+ startSequential: (jaml: string, startSeed: string, endSeed?: string) => void;
21
22
  cancel: () => void;
22
23
  clearError: () => void;
23
24
  fetchTallyLabels: (jaml: string) => void;
@@ -1,9 +1,5 @@
1
1
  "use client";
2
2
  import { useState, useCallback, useRef, useEffect } from "react";
3
- import SearchWorker from "./searchWorker.ts?worker&inline";
4
- function createWorker() {
5
- return new SearchWorker();
6
- }
7
3
  const INITIAL_STATE = {
8
4
  results: [],
9
5
  totalSearched: 0n,
@@ -13,15 +9,107 @@ const INITIAL_STATE = {
13
9
  seedsPerSecond: 0,
14
10
  tallyLabels: [],
15
11
  };
16
- export function useSearch() {
12
+ const SEARCH_WORKER_CODE = `
13
+ let MotelyWasm = null;
14
+ let MotelyWasmEvents = null;
15
+ let activeSearch = null;
16
+
17
+ self.addEventListener('message', async function(e) {
18
+ const msg = e.data;
19
+
20
+ if (msg.type === 'init') {
21
+ try {
22
+ const mod = await import(msg.url);
23
+ await mod.default.boot();
24
+ MotelyWasm = mod.MotelyWasm;
25
+ MotelyWasmEvents = mod.MotelyWasmEvents;
26
+ self.postMessage({ type: 'ready' });
27
+ } catch (err) {
28
+ self.postMessage({ type: 'error', message: String(err) });
29
+ }
30
+ return;
31
+ }
32
+
33
+ if (msg.type === 'start') {
34
+ if (!MotelyWasm) { self.postMessage({ type: 'error', message: 'Not initialized' }); return; }
35
+ const validation = MotelyWasm.validateJaml(msg.jaml);
36
+ if (validation !== 'valid') { self.postMessage({ type: 'error', message: validation }); return; }
37
+
38
+ function cleanup() {
39
+ MotelyWasmEvents.notifyResult = () => {};
40
+ MotelyWasmEvents.notifyProgress = () => {};
41
+ MotelyWasmEvents.notifyComplete = () => {};
42
+ activeSearch = null;
43
+ }
44
+
45
+ MotelyWasmEvents.notifyResult = function(seed, score, tallyColumns) {
46
+ self.postMessage({ type: 'result', seed, score, tallyColumns: Array.from(tallyColumns) });
47
+ };
48
+ MotelyWasmEvents.notifyProgress = function(searched, matching) {
49
+ self.postMessage({ type: 'progress', searched: searched.toString(), matching: matching.toString() });
50
+ };
51
+ MotelyWasmEvents.notifyComplete = function(status, searched, matched) {
52
+ cleanup();
53
+ self.postMessage({ type: 'complete', status, searched: searched.toString(), matched: matched.toString() });
54
+ };
55
+
56
+ try {
57
+ const mode = msg.mode || 'random';
58
+
59
+ if (mode === 'random') {
60
+ activeSearch = MotelyWasm.startRandomSearch(msg.jaml, msg.count);
61
+ } else if (mode === 'aesthetic') {
62
+ activeSearch = MotelyWasm.startAestheticSearch(msg.jaml, msg.aesthetic);
63
+ } else if (mode === 'seedList') {
64
+ activeSearch = MotelyWasm.startSeedListSearch(msg.jaml, msg.seeds);
65
+ } else if (mode === 'keyword') {
66
+ activeSearch = MotelyWasm.startKeywordSearch(msg.jaml, msg.keywords, msg.padding || '');
67
+ } else if (mode === 'sequential') {
68
+ activeSearch = MotelyWasm.startSequentialSearch(msg.jaml, msg.batchCharCount, BigInt(msg.startBatch), BigInt(msg.endBatch));
69
+ } else {
70
+ self.postMessage({ type: 'error', message: 'Unknown search mode: ' + mode });
71
+ cleanup();
72
+ return;
73
+ }
74
+ } catch (err) {
75
+ cleanup();
76
+ self.postMessage({ type: 'error', message: String(err) });
77
+ }
78
+ return;
79
+ }
80
+
81
+ if (msg.type === 'stop') {
82
+ if (activeSearch) { activeSearch.cancel(); activeSearch = null; }
83
+ self.postMessage({ type: 'cancelled' });
84
+ }
85
+
86
+ if (msg.type === 'get_tally_labels') {
87
+ if (!MotelyWasm) { self.postMessage({ type: 'error', message: 'Not initialized' }); return; }
88
+ try {
89
+ const labels = MotelyWasm.getTallyLabels(msg.jaml);
90
+ self.postMessage({ type: 'tally_labels', labels: Array.from(labels) });
91
+ } catch (err) {
92
+ self.postMessage({ type: 'error', message: String(err) });
93
+ }
94
+ }
95
+ });
96
+ `;
97
+ function createWorker() {
98
+ const blob = new Blob([SEARCH_WORKER_CODE], { type: "application/javascript" });
99
+ return new Worker(URL.createObjectURL(blob), { type: "module" });
100
+ }
101
+ export function useSearch(motelyWasmUrl) {
17
102
  const [state, setState] = useState(INITIAL_STATE);
18
103
  const workerRef = useRef(null);
19
- const readyRef = useRef(true); // Worker is ready implicitly since boot is handled by import
104
+ const readyRef = useRef(false); // Worker is NOT implicitly ready, must wait for 'ready' message
20
105
  const speedRef = useRef({ lastSearched: 0n, lastTime: 0, ema: 0 });
21
106
  useEffect(() => {
22
107
  setState((s) => ({ ...s, status: "idle" }));
23
108
  const worker = createWorker();
24
109
  workerRef.current = worker;
110
+ if (motelyWasmUrl) {
111
+ worker.postMessage({ type: 'init', url: motelyWasmUrl });
112
+ }
25
113
  worker.onmessage = (e) => {
26
114
  const msg = e.data;
27
115
  if (msg.type === "ready") {
@@ -83,7 +171,7 @@ export function useSearch() {
83
171
  worker.terminate();
84
172
  workerRef.current = null;
85
173
  };
86
- }, []);
174
+ }, [motelyWasmUrl]);
87
175
  const sendStart = useCallback((payload) => {
88
176
  const worker = workerRef.current;
89
177
  if (!worker)
@@ -117,6 +205,21 @@ export function useSearch() {
117
205
  const startKeyword = useCallback((jaml, keywords, padding) => {
118
206
  sendStart({ type: "start", mode: "keyword", jaml, keywords, padding });
119
207
  }, [sendStart]);
208
+ const startSequential = useCallback((jaml, startSeed, endSeed) => {
209
+ // Sequential search: single-threaded, deterministic order.
210
+ // batchCharCount = length of start seed, startBatch/endBatch = numeric range.
211
+ const charCount = startSeed.length || 1;
212
+ const startNum = parseInt(startSeed, 36) || 0;
213
+ const endNum = endSeed ? parseInt(endSeed, 36) : startNum + 10_000_000;
214
+ sendStart({
215
+ type: "start",
216
+ mode: "sequential",
217
+ jaml,
218
+ batchCharCount: charCount,
219
+ startBatch: startNum.toString(),
220
+ endBatch: endNum.toString(),
221
+ });
222
+ }, [sendStart]);
120
223
  const cancel = useCallback(() => {
121
224
  workerRef.current?.postMessage({ type: "stop" });
122
225
  }, []);
@@ -126,5 +229,5 @@ export function useSearch() {
126
229
  const fetchTallyLabels = useCallback((jaml) => {
127
230
  workerRef.current?.postMessage({ type: "get_tally_labels", jaml });
128
231
  }, []);
129
- return { ...state, start, startAesthetic, startSeedList, startKeyword, cancel, clearError, fetchTallyLabels };
232
+ return { ...state, start, startAesthetic, startSeedList, startKeyword, startSequential, cancel, clearError, fetchTallyLabels };
130
233
  }