claude-buddy-reroll 1.9.1 → 2.0.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-buddy-reroll",
3
- "version": "1.9.1",
3
+ "version": "2.0.1",
4
4
  "description": "Speaki-styled npm terminal for buddy rerolls, dex replay, and SALT patching.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/dex-ui.mjs CHANGED
@@ -41,28 +41,31 @@ function pushUniqueVariant(ordered, seen, variant) {
41
41
 
42
42
  function makeBox(title, lines, width = 28) {
43
43
  const innerWidth = width - 4;
44
+ const DIM = '\x1b[2m';
45
+ const BOLD = '\x1b[1m';
46
+ const GRAY = '\x1b[90m';
47
+ const R = '\x1b[0m';
44
48
  const output = [
45
- `+${'-'.repeat(width - 2)}+`,
46
- `| ${fitLine(title, innerWidth)} |`,
47
- `+${'-'.repeat(width - 2)}+`,
49
+ `${GRAY}${BOLD}${title}${R}`,
50
+ `${GRAY}${'─'.repeat(width - 2)}${R}`,
48
51
  ];
49
52
 
50
53
  for (const line of lines) {
51
- output.push(`| ${fitLine(line, innerWidth)} |`);
54
+ output.push(`${DIM} ${fitLine(line, innerWidth)}${R}`);
52
55
  }
53
56
 
54
- output.push(`+${'-'.repeat(width - 2)}+`);
57
+ output.push('');
55
58
  return output;
56
59
  }
57
60
 
58
- function joinColumns(leftLines, rightLines, leftWidth = 28, gap = ' ') {
61
+ function joinColumns(leftLines, rightLines, leftWidth = 28, gap = ' ') {
59
62
  const totalRows = Math.max(leftLines.length, rightLines.length);
60
63
  const output = [];
61
64
 
62
65
  for (let row = 0; row < totalRows; row++) {
63
66
  const left = leftLines[row] || ' '.repeat(leftWidth);
64
67
  const right = rightLines[row] || '';
65
- output.push(`${left}${gap}${right}`);
68
+ output.push(` ${left}${gap}${right}`);
66
69
  }
67
70
 
68
71
  return output;
package/src/display.mjs CHANGED
@@ -1,151 +1,309 @@
1
- // Display utilities for buddy cards — v2 redesign
1
+ // Display utilities for buddy cards — v3: rarity = structure
2
+ // Each rarity tier has a fundamentally different card layout.
2
3
 
3
4
  import { RARITY_STARS, STAT_FLOORS, STATS } from './engine.mjs';
4
5
  import { renderSprite } from './sprites.mjs';
5
6
  import { formatEye, formatShinyTag, formatStars, isAsciiOnlyTerminal, toTerminalSafeText } from './terminal.mjs';
6
7
  import { padAnsiEnd, visibleLength } from './ansi.mjs';
7
8
 
8
- const RESET = '\x1b[0m';
9
- const DIM = '\x1b[2m';
9
+ const R = '\x1b[0m';
10
10
  const BOLD = '\x1b[1m';
11
- const GRAY = '\x1b[90m';
11
+ const DIM = '\x1b[2m';
12
12
  const ITALIC = '\x1b[3m';
13
- const BG_RESET = '\x1b[49m';
14
-
15
- const RARITY_STYLE = {
16
- common: { color: '\x1b[37m', label: '\x1b[37m', bg: '', badge: 'COMMON', accent: '\x1b[90m', tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' },
17
- uncommon: { color: '\x1b[32m', label: '\x1b[32m', bg: '', badge: 'UNCOMMON', accent: '\x1b[32m', tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' },
18
- rare: { color: '\x1b[36m', label: '\x1b[36m', bg: '', badge: 'RARE', accent: '\x1b[36m', tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' },
19
- epic: { color: '\x1b[35m', label: '\x1b[35m', bg: '\x1b[48;5;53m', badge: 'EPIC', accent: '\x1b[35;1m', tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' },
20
- legendary: { color: '\x1b[33;1m', label: '\x1b[33;1m', bg: '\x1b[48;5;58m', badge: 'LEGENDARY', accent: '\x1b[33;1m', tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' },
21
- };
13
+ const GRAY = '\x1b[90m';
22
14
 
23
- const ASCII_STYLE = {
24
- common: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' },
25
- uncommon: { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' },
26
- rare: { tl: '+', tr: '+', bl: '+', br: '+', h: '=', v: '|' },
27
- epic: { tl: '#', tr: '#', bl: '#', br: '#', h: '=', v: '#' },
28
- legendary: { tl: '#', tr: '#', bl: '#', br: '#', h: '#', v: '#' },
15
+ const RARITY = {
16
+ common: { color: '\x1b[90m', accent: '\x1b[37m', bg: '' },
17
+ uncommon: { color: '\x1b[32m', accent: '\x1b[32;1m', bg: '' },
18
+ rare: { color: '\x1b[34m', accent: '\x1b[34;1m', bg: '' },
19
+ epic: { color: '\x1b[35m', accent: '\x1b[35;1m', bg: '\x1b[48;5;53m' },
20
+ legendary: { color: '\x1b[33;1m', accent: '\x1b[33;1m', bg: '\x1b[48;5;58m' },
29
21
  };
30
22
 
31
- const CARD_WIDTH = 38;
32
- const INNER = CARD_WIDTH - 4; // content area (minus border + padding)
33
-
34
- function box(style, ascii) {
35
- const s = ascii ? { ...style, ...ASCII_STYLE[Object.keys(RARITY_STYLE).find(k => RARITY_STYLE[k] === style)] } : style;
36
- return {
37
- top: `${style.color}${s.tl}${s.h.repeat(CARD_WIDTH - 2)}${s.tr}${RESET}`,
38
- bottom: `${style.color}${s.bl}${s.h.repeat(CARD_WIDTH - 2)}${s.br}${RESET}`,
39
- mid: `${style.color}${s.v}${s.h.repeat(CARD_WIDTH - 2)}${s.v}${RESET}`,
40
- row: (content) => {
41
- const padded = padAnsiEnd(content, CARD_WIDTH - 4);
42
- return `${style.color}${s.v}${RESET} ${padded} ${style.color}${s.v}${RESET}`;
43
- },
44
- empty: () => `${style.color}${s.v}${RESET}${' '.repeat(CARD_WIDTH - 2)}${style.color}${s.v}${RESET}`,
45
- };
23
+ function statBar(value, color) {
24
+ const w = 12;
25
+ const filled = Math.round((value / 100) * w);
26
+ return `${color}${'█'.repeat(filled)}${GRAY}${'░'.repeat(w - filled)}${R}`;
46
27
  }
47
28
 
48
- function centerPad(text, width) {
29
+ function centerText(text, width) {
49
30
  const vis = visibleLength(text);
50
31
  if (vis >= width) return text;
51
32
  const left = Math.floor((width - vis) / 2);
52
- const right = width - vis - left;
53
- return ' '.repeat(left) + text + ' '.repeat(right);
33
+ return ' '.repeat(left) + text;
54
34
  }
55
35
 
56
- function statBar(value, color) {
57
- const width = 12;
58
- const filled = Math.round((value / 100) * width);
59
- const bar = `${color}${'█'.repeat(filled)}${GRAY}${'░'.repeat(width - filled)}${RESET}`;
60
- return bar;
36
+ // ─── COMMON: compact, no frame, disposable feel ─────────
37
+
38
+ function renderCommon(result, { index, frame }) {
39
+ const { bones } = result;
40
+ const { species, eye } = bones;
41
+ const c = RARITY.common;
42
+ const idx = index !== null ? `${GRAY}#${index + 1}${R} ` : '';
43
+
44
+ // Extract just the "face" — second or third line of sprite
45
+ const spriteLines = renderSprite(species, formatEye(eye), 'none', frame).split('\n');
46
+ const faceLine = spriteLines.find(l => l.includes(formatEye(eye))) || spriteLines[1] || '';
47
+
48
+ return [
49
+ '',
50
+ ` ${idx}${c.color}★ ${species}${R} ${GRAY}${formatEye(eye)}${R}`,
51
+ ` ${c.color}${toTerminalSafeText(faceLine.trim())}${R}`,
52
+ '',
53
+ ].join('\n');
61
54
  }
62
55
 
63
- export function renderCard(result, { showSalt = false, index = null, frame = 0 } = {}) {
56
+ // ─── UNCOMMON: light frame, adds stats ──────────────────
57
+
58
+ function renderUncommon(result, { index, frame, showSalt }) {
64
59
  const { bones } = result;
65
- const { rarity, species, eye, hat, shiny, stats } = bones;
66
- const style = RARITY_STYLE[rarity];
67
- const ascii = isAsciiOnlyTerminal();
68
- const b = box(style, ascii);
69
- const stars = formatStars(RARITY_STARS[rarity]);
70
- const shinyTag = shiny ? formatShinyTag(' ✨') : '';
71
- const sprite = toTerminalSafeText(renderSprite(species, formatEye(eye), hat, frame));
72
- const floor = STAT_FLOORS[rarity] || 5;
60
+ const { species, eye, hat, stats } = bones;
61
+ const c = RARITY.uncommon;
62
+ const W = 32;
63
+ const idx = index !== null ? `#${index + 1} ` : '';
64
+ const stars = formatStars(RARITY_STARS.uncommon);
73
65
 
74
- const header = index !== null ? `#${index + 1} ` : '';
75
- const badgeText = `${style.accent}${BOLD}[ ${style.badge} ]${RESET}`;
76
- const nameText = `${style.label}${BOLD}${header}${species.toUpperCase()}${RESET} ${style.color}${stars}${shinyTag}${RESET}`;
66
+ const spriteLines = renderSprite(species, formatEye(eye), hat, frame).split('\n');
67
+ const faceLine = spriteLines.find(l => l.includes(formatEye(eye))) || spriteLines[1] || '';
77
68
 
78
69
  const lines = [];
79
70
  lines.push('');
80
- lines.push(b.top);
71
+ lines.push(` ${c.color}╭${'─'.repeat(W)}╮${R}`);
72
+ lines.push(` ${c.color}│${R} ${c.accent}${BOLD}${idx}${species.toUpperCase()}${R} ${c.color}${stars}${R}${' '.repeat(Math.max(0, W - visibleLength(`${idx}${species.toUpperCase()} ${stars}`) - 1))}${c.color}│${R}`);
73
+ lines.push(` ${c.color}│${R} ${toTerminalSafeText(padAnsiEnd(faceLine.trim(), W - 1))}${c.color}│${R}`);
74
+ lines.push(` ${c.color}│${R} ${DIM}Eye${R} ${formatEye(eye)} ${DIM}Hat${R} ${toTerminalSafeText(hat === 'none' ? '—' : hat)}${' '.repeat(Math.max(0, W - 20))}${c.color}│${R}`);
81
75
 
82
- // Badge row (centered)
83
- lines.push(b.row(centerPad(badgeText, INNER)));
76
+ // Compact stats
77
+ if (stats) {
78
+ const top = STATS.filter(s => stats[s] !== undefined)
79
+ .sort((a, b) => (stats[b] ?? 0) - (stats[a] ?? 0))
80
+ .slice(0, 3);
81
+ const statStr = top.map(s => `${s.slice(0, 3)}:${stats[s]}`).join(' ');
82
+ lines.push(` ${c.color}│${R} ${GRAY}${statStr}${R}${' '.repeat(Math.max(0, W - visibleLength(statStr) - 1))}${c.color}│${R}`);
83
+ }
84
84
 
85
- // Name + stars row (centered)
86
- lines.push(b.row(centerPad(nameText, INNER)));
85
+ if (showSalt && result.salt) {
86
+ lines.push(` ${c.color}│${R} ${DIM}${ITALIC}${result.salt}${R}${' '.repeat(Math.max(0, W - result.salt.length - 1))}${c.color}│${R}`);
87
+ }
88
+ lines.push(` ${c.color}╰${'─'.repeat(W)}╯${R}`);
89
+ lines.push('');
90
+ return lines.join('\n');
91
+ }
87
92
 
88
- // Separator
89
- lines.push(b.mid);
93
+ // ─── RARE: double frame, full sprite, stat bars ─────────
90
94
 
91
- // Sprite (centered)
92
- const spriteLines = sprite.split('\n');
93
- for (const sl of spriteLines) {
94
- lines.push(b.row(centerPad(`${style.color}${sl}${RESET}`, INNER)));
95
- }
95
+ function renderRare(result, { index, frame, showSalt }) {
96
+ const { bones } = result;
97
+ const { species, eye, hat, shiny, stats, rarity } = bones;
98
+ const c = RARITY.rare;
99
+ const W = 36;
100
+ const inner = W - 2;
101
+ const idx = index !== null ? `#${index + 1} ` : '';
102
+ const stars = formatStars(RARITY_STARS[rarity]);
103
+ const floor = STAT_FLOORS[rarity] || 25;
96
104
 
97
- // Separator
98
- lines.push(b.mid);
105
+ const v = (content) => {
106
+ const padded = padAnsiEnd(content, inner);
107
+ return ` ${c.color}║${R} ${padded}${c.color}║${R}`;
108
+ };
99
109
 
100
- // Traits row — compact
101
- const eyeStr = formatEye(eye);
102
- const hatStr = toTerminalSafeText(hat === 'none' ? '—' : hat);
103
- lines.push(b.row(`${DIM}Eye${RESET} ${eyeStr} ${DIM}Hat${RESET} ${hatStr} ${DIM}Shiny${RESET} ${shiny ? `${style.accent}YES${RESET}` : `${GRAY}no${RESET}`}`));
110
+ const lines = [];
111
+ lines.push('');
112
+ lines.push(` ${c.color}╔${''.repeat(W)}╗${R}`);
113
+ lines.push(v(`${c.accent}${BOLD}${rarity.toUpperCase()}${R} ${c.color}${stars}${R}`));
114
+ lines.push(v(`${c.accent}${BOLD}${idx}${species.toUpperCase()}${R}${shiny ? ` ${formatShinyTag('✨')}` : ''}`));
115
+ lines.push(` ${c.color}╠${'═'.repeat(W)}╣${R}`);
104
116
 
105
- // Separator
106
- lines.push(b.mid);
117
+ // Full sprite
118
+ const sprite = toTerminalSafeText(renderSprite(species, formatEye(eye), hat, frame));
119
+ for (const sl of sprite.split('\n')) {
120
+ lines.push(v(centerText(`${c.color}${sl}${R}`, inner)));
121
+ }
122
+
123
+ lines.push(` ${c.color}╠${'═'.repeat(W)}╣${R}`);
124
+ lines.push(v(`${DIM}Eye${R} ${formatEye(eye)} ${DIM}Hat${R} ${toTerminalSafeText(hat === 'none' ? '—' : hat)}`));
107
125
 
108
- // Stats — compact bars with value
109
- if (stats && Object.keys(stats).length > 0) {
126
+ // Stat bars
127
+ if (stats) {
128
+ lines.push(v(''));
110
129
  for (const name of STATS) {
111
130
  if (stats[name] === undefined) continue;
112
131
  const val = stats[name];
113
132
  const isHigh = val >= floor + 40;
114
133
  const isLow = val <= floor;
134
+ const bar = statBar(val, c.color);
135
+ const marker = isHigh ? `${c.accent}▲${R}` : isLow ? `\x1b[31m▼${R}` : ' ';
136
+ lines.push(v(`${GRAY}${name.slice(0, 5).padEnd(5)}${R} ${bar} ${String(val).padStart(3)} ${marker}`));
137
+ }
138
+ }
139
+
140
+ if (showSalt && result.salt) {
141
+ lines.push(` ${c.color}╠${'═'.repeat(W)}╣${R}`);
142
+ lines.push(v(`${DIM}${ITALIC}${result.salt}${R}`));
143
+ }
144
+ lines.push(` ${c.color}╚${'═'.repeat(W)}╝${R}`);
145
+ lines.push('');
146
+ return lines.join('\n');
147
+ }
148
+
149
+ // ─── EPIC: bg tint, wide padding, decorative ────────────
150
+
151
+ function renderEpic(result, { index, frame, showSalt }) {
152
+ const { bones } = result;
153
+ const { species, eye, hat, shiny, stats, rarity } = bones;
154
+ const c = RARITY.epic;
155
+ const W = 38;
156
+ const inner = W - 2;
157
+ const floor = STAT_FLOORS[rarity] || 35;
158
+ const idx = index !== null ? `#${index + 1} ` : '';
159
+ const stars = formatStars(RARITY_STARS[rarity]);
160
+
161
+ const v = (content) => {
162
+ const padded = padAnsiEnd(content, inner);
163
+ return ` ${c.color}║${R}${c.bg} ${padded}${R} ${c.color}║${R}`;
164
+ };
165
+ const blank = () => v('');
115
166
 
116
- const label = name.slice(0, 5).padEnd(5);
117
- const bar = statBar(val, style.color);
118
- const valStr = String(val).padStart(3);
119
- const marker = isHigh ? `${style.accent}▲${RESET}` : isLow ? `\x1b[31m▼${RESET}` : ' ';
167
+ const decorChar = isAsciiOnlyTerminal() ? '*' : '✦';
168
+ const decor = ` ${c.color}╠${'─'.repeat(Math.floor(W / 2 - 2))}${decorChar}${'─'.repeat(Math.ceil(W / 2 - 1))}╣${R}`;
120
169
 
121
- lines.push(b.row(`${GRAY}${label}${RESET} ${bar} ${valStr} ${marker}`));
170
+ const lines = [];
171
+ lines.push('');
172
+ lines.push(` ${c.color}╔${'═'.repeat(W)}╗${R}`);
173
+ lines.push(blank());
174
+ lines.push(v(centerText(`${c.accent}${BOLD}${formatStars(RARITY_STARS[rarity])} ${rarity.toUpperCase()}${R}`, inner)));
175
+ lines.push(v(centerText(`${c.accent}${BOLD}${idx}${species.toUpperCase()}${R}${shiny ? ` ${formatShinyTag('✨ SHINY')}` : ''}`, inner)));
176
+ lines.push(blank());
177
+ lines.push(decor);
178
+ lines.push(blank());
179
+
180
+ // Full sprite (colored)
181
+ const sprite = toTerminalSafeText(renderSprite(species, formatEye(eye), hat, frame));
182
+ for (const sl of sprite.split('\n')) {
183
+ lines.push(v(centerText(`${c.accent}${sl}${R}`, inner)));
184
+ }
185
+
186
+ lines.push(blank());
187
+ lines.push(decor);
188
+ lines.push(v(`${DIM}Eye${R} ${formatEye(eye)} ${DIM}Hat${R} ${toTerminalSafeText(hat === 'none' ? '—' : hat)} ${DIM}Shiny${R} ${shiny ? `${c.accent}YES${R}` : `${GRAY}no${R}`}`));
189
+ lines.push(blank());
190
+
191
+ // Stat bars with wide format
192
+ if (stats) {
193
+ for (const name of STATS) {
194
+ if (stats[name] === undefined) continue;
195
+ const val = stats[name];
196
+ const isHigh = val >= floor + 40;
197
+ const isLow = val <= floor;
198
+ const bar = statBar(val, c.color);
199
+ const marker = isHigh ? `${c.accent}▲${R}` : isLow ? `\x1b[31m▼${R}` : ' ';
200
+ lines.push(v(` ${GRAY}${name.padEnd(9)}${R} ${bar} ${String(val).padStart(3)} ${marker}`));
122
201
  }
123
202
  }
124
203
 
125
- // Salt line
204
+ lines.push(blank());
126
205
  if (showSalt && result.salt) {
127
- lines.push(b.mid);
128
- lines.push(b.row(`${DIM}${ITALIC}${result.salt}${RESET}`));
206
+ lines.push(v(`${DIM}${ITALIC}${result.salt}${R}`));
129
207
  }
208
+ lines.push(` ${c.color}╚${'═'.repeat(W)}╝${R}`);
209
+ lines.push('');
210
+ return lines.join('\n');
211
+ }
212
+
213
+ // ─── LEGENDARY: sparkle frame, maximum presence ─────────
130
214
 
131
- lines.push(b.bottom);
215
+ function renderLegendary(result, { index, frame, showSalt }) {
216
+ const { bones } = result;
217
+ const { species, eye, hat, shiny, stats, rarity } = bones;
218
+ const c = RARITY.legendary;
219
+ const W = 40;
220
+ const inner = W - 2;
221
+ const floor = STAT_FLOORS[rarity] || 50;
222
+ const idx = index !== null ? `#${index + 1} ` : '';
223
+
224
+ const v = (content) => {
225
+ const padded = padAnsiEnd(content, inner);
226
+ return ` ${c.color}║${R}${c.bg} ${padded}${R} ${c.color}║${R}`;
227
+ };
228
+ const blank = () => v('');
229
+
230
+ const sp = isAsciiOnlyTerminal() ? '*' : '✦';
231
+ const sparkleTop = ` ${c.color}${sp}${'═'.repeat(W)}${sp}${R}`;
232
+ const sparkleBot = ` ${c.color}${sp}${'═'.repeat(W)}${sp}${R}`;
233
+ const decor = ` ${c.color}║${'─'.repeat(Math.floor(W / 2 - 3))} ${sp} ${sp} ${sp} ${'─'.repeat(Math.ceil(W / 2 - 4))}║${R}`;
234
+
235
+ const lines = [];
132
236
  lines.push('');
237
+ lines.push(sparkleTop);
238
+ lines.push(blank());
239
+ lines.push(v(centerText(`${c.accent}${BOLD}${formatStars('★ ★ ★ ★ ★')} L E G E N D A R Y${R}`, inner)));
240
+ lines.push(blank());
241
+ lines.push(v(centerText(`${c.accent}${BOLD}${sp} ${idx}${species.toUpperCase()} ${sp}${R}`, inner)));
242
+ lines.push(blank());
243
+ lines.push(decor);
244
+ lines.push(blank());
133
245
 
246
+ // Full sprite (golden)
247
+ const sprite = toTerminalSafeText(renderSprite(species, formatEye(eye), hat, frame));
248
+ for (const sl of sprite.split('\n')) {
249
+ lines.push(v(centerText(`${c.accent}${sl}${R}`, inner)));
250
+ }
251
+
252
+ lines.push(blank());
253
+ lines.push(decor);
254
+ lines.push(blank());
255
+ lines.push(v(` ${DIM}Eye${R} ${formatEye(eye)} ${DIM}Hat${R} ${toTerminalSafeText(hat === 'none' ? '—' : hat)} ${shiny ? `${c.accent}${BOLD}✨ SHINY!${R}` : ''}`));
256
+ lines.push(blank());
257
+
258
+ // Full stat bars
259
+ if (stats) {
260
+ for (const name of STATS) {
261
+ if (stats[name] === undefined) continue;
262
+ const val = stats[name];
263
+ const isHigh = val >= floor + 40;
264
+ const isLow = val <= floor;
265
+ const bar = statBar(val, c.color);
266
+ const marker = isHigh ? `${c.accent}▲${R}` : isLow ? `\x1b[31m▼${R}` : ' ';
267
+ lines.push(v(` ${c.accent}${name.padEnd(9)}${R} ${bar} ${String(val).padStart(3)} ${marker}`));
268
+ }
269
+ }
270
+
271
+ lines.push(blank());
272
+ if (showSalt && result.salt) {
273
+ lines.push(v(`${DIM}${ITALIC}${result.salt}${R}`));
274
+ lines.push(blank());
275
+ }
276
+ lines.push(sparkleBot);
277
+ lines.push('');
134
278
  return lines.join('\n');
135
279
  }
136
280
 
281
+ // ─── Public API ─────────────────────────────────────────
282
+
283
+ export function renderCard(result, { showSalt = false, index = null, frame = 0 } = {}) {
284
+ const { rarity } = result.bones;
285
+ const opts = { index, frame, showSalt };
286
+
287
+ switch (rarity) {
288
+ case 'common': return renderCommon(result, opts);
289
+ case 'uncommon': return renderUncommon(result, opts);
290
+ case 'rare': return renderRare(result, opts);
291
+ case 'epic': return renderEpic(result, opts);
292
+ case 'legendary': return renderLegendary(result, opts);
293
+ default: return renderCommon(result, opts);
294
+ }
295
+ }
296
+
137
297
  export function renderMiniCard(result, index) {
138
298
  const { bones } = result;
139
299
  const { rarity, species, eye, hat, shiny } = bones;
140
- const style = RARITY_STYLE[rarity];
300
+ const c = RARITY[rarity] || RARITY.common;
141
301
  const stars = formatStars(RARITY_STARS[rarity]);
142
302
  const shinyTag = shiny
143
303
  ? (isAsciiOnlyTerminal() ? ' [S]' : ` ${formatShinyTag('✨')}`)
144
304
  : '';
305
+ const hatTag = hat !== 'none' ? ` ${DIM}+${toTerminalSafeText(hat)}${R}` : '';
145
306
 
146
307
  const idx = String(index + 1).padStart(2, ' ');
147
- const badge = style.badge.slice(0, 3);
148
- const hatTag = hat !== 'none' ? ` ${DIM}+${toTerminalSafeText(hat)}${RESET}` : '';
149
-
150
- return `${style.color}${idx}. ${formatEye(eye)} ${BOLD}${species.padEnd(10)}${RESET}${style.color} ${stars.padEnd(5)} ${badge}${shinyTag}${hatTag}${RESET}`;
308
+ return `${c.color}${idx}. ${formatEye(eye)} ${BOLD}${species.padEnd(10)}${R}${c.color} ${stars.padEnd(5)} ${rarity.slice(0, 3).toUpperCase()}${shinyTag}${hatTag}${R}`;
151
309
  }
package/src/selector.mjs CHANGED
@@ -1,212 +1,216 @@
1
- // Terminal UI selector — zero dependencies, arrow key navigation
2
- import { emitKeypressEvents } from 'readline';
3
- import { padAnsiEnd, stripAnsi } from './ansi.mjs';
4
-
5
- export function getSelectorRenderLineCount(bodyRows) {
6
- return bodyRows + 5;
7
- }
8
-
9
- export function getFullscreenBodyRows({ gridRows, previewHeight = 0, previewLinesLength = 0, footerLinesLength = 1, terminalRows = 24 }) {
10
- const desiredBodyRows = Math.max(gridRows, previewHeight, previewLinesLength);
11
- const reservedRows = 2 + 1 + Math.max(1, footerLinesLength);
12
- const availableBodyRows = Math.max(gridRows, terminalRows - reservedRows);
13
- return Math.min(desiredBodyRows, availableBodyRows);
14
- }
15
-
16
- /**
17
- * Show an interactive list selector with arrow key navigation.
18
- * @param {Object} options
19
- * @param {string} options.title - Title shown above the list
20
- * @param {Array<{label: string, description?: string, value: any}>} options.items
21
- * @param {number} [options.columns=2] - Number of columns
22
- * @param {number} [options.selected=0] - Initial selection index
23
- * @param {(item: any, meta: { cursor: number, tick: number }) => string} [options.preview] - Optional preview panel renderer
24
- * @param {number} [options.previewHeight=0] - Reserved preview panel height
25
- * @param {boolean} [options.fullscreen=false] - Render as fullscreen screen instead of in-place widget
26
- * @param {number} [options.animationIntervalMs=0] - Optional preview animation interval
27
- * @param {(item: any, meta: { cursor: number, tick: number, state: any }) => string[]} [options.footer] - Optional footer renderer
28
- * @param {any} [options.state] - Mutable selector-local state passed to preview/footer handlers
29
- * @param {(str: string, key: any, meta: { cursor: number, item: any, items: any[], tick: number, state: any }) => {handled?: boolean, state?: any, patch?: object, tick?: number}|void} [options.onKey] - Optional custom key handler
30
- * @returns {Promise<{index: number, value: any} | null>} Selected item or null if cancelled
31
- */
32
- export async function select({ title, items, columns = 2, selected = 0, preview = null, previewHeight = 0, fullscreen = false, animationIntervalMs = 0, footer = null, state = {}, onKey = null }) {
33
- if (!process.stdin.isTTY) return null;
34
-
35
- const RESET = '\x1b[0m';
36
- const BOLD = '\x1b[1m';
37
- const DIM = '\x1b[2m';
38
- const CYAN = '\x1b[36m';
39
- const BG_CYAN = '\x1b[46m\x1b[30m';
40
- const HIDE_CURSOR = '\x1b[?25l';
41
- const SHOW_CURSOR = '\x1b[?25h';
42
- const CLEAR = '\x1b[2J\x1b[H';
43
-
44
- let cursor = selected;
45
- let tick = 0;
46
- let selectorState = state;
47
- const rows = Math.ceil(items.length / columns);
48
- const colWidth = 22;
49
- let renderedBodyRows = rows;
50
- let didFullscreenInit = false;
51
-
52
- function render() {
53
- const meta = { cursor, tick, state: selectorState };
54
- const previewText = typeof preview === 'function' ? preview(items[cursor], meta) : '';
55
- const previewLines = previewText ? String(previewText).split('\n') : [];
56
- const footerLines = typeof footer === 'function' ? footer(items[cursor], meta) : null;
57
- const resolvedFooter = Array.isArray(footerLines) && footerLines.length > 0
58
- ? footerLines
59
- : [`${CYAN}▶${RESET} ${items[cursor].label}${items[cursor].description ? ` ${DIM}${items[cursor].description}${RESET}` : ''}`];
60
- const bodyRows = fullscreen
61
- ? getFullscreenBodyRows({
62
- gridRows: rows,
63
- previewHeight,
64
- previewLinesLength: previewLines.length,
65
- footerLinesLength: resolvedFooter.length,
66
- terminalRows: process.stdout.rows || 24,
67
- })
68
- : Math.max(rows, previewHeight, previewLines.length);
69
- const totalLines = getSelectorRenderLineCount(Math.max(renderedBodyRows, bodyRows)) + (resolvedFooter.length - 1);
70
-
71
- if (fullscreen) {
72
- if (!didFullscreenInit) {
73
- process.stdout.write(CLEAR);
74
- for (let i = 0; i < totalLines; i++) process.stdout.write('\n');
75
- didFullscreenInit = true;
76
- }
77
- process.stdout.write(`\x1b[${totalLines}A`);
78
- } else {
79
- process.stdout.write(`\x1b[${totalLines}A`);
80
- }
81
-
82
- // Title
83
- process.stdout.write(`\x1b[2K ${BOLD}${title}${RESET}\n`);
84
- process.stdout.write(`\x1b[2K\n`);
85
-
86
- for (let row = 0; row < bodyRows; row++) {
87
- let gridLine = ' ';
88
-
89
- if (row < rows) {
90
- for (let col = 0; col < columns; col++) {
91
- const idx = row * columns + col;
92
- if (idx >= items.length) {
93
- gridLine += ' '.repeat(colWidth);
94
- continue;
95
- }
96
- const item = items[idx];
97
- const num = String(idx + 1).padStart(2);
98
- const text = `${num}. ${item.label}`;
99
- const padded = padAnsiEnd(text, colWidth - 1);
100
-
101
- if (idx === cursor) {
102
- const inverted = stripAnsi(text).padEnd(colWidth - 1);
103
- gridLine += `${BG_CYAN} ${inverted}${RESET}`;
104
- } else {
105
- gridLine += ` ${padded}`;
106
- }
107
- }
108
- } else {
109
- gridLine += ' '.repeat(columns * colWidth);
110
- }
111
-
112
- const previewLine = previewLines[row] ? ` ${previewLines[row]}` : '';
113
- process.stdout.write(`\x1b[2K${gridLine}${previewLine}\n`);
114
- }
115
-
116
- process.stdout.write(`\x1b[2K\n`);
117
- for (const line of resolvedFooter) {
118
- process.stdout.write(`\x1b[2K ${line}\n`);
119
- }
120
- renderedBodyRows = bodyRows;
121
- }
122
-
123
- return new Promise((resolve) => {
124
- process.stdout.write(HIDE_CURSOR);
125
-
126
- let animationTimer = null;
127
-
128
- if (!fullscreen) {
129
- const initialPreviewText = typeof preview === 'function' ? preview(items[cursor], { cursor, tick, state: selectorState }) : '';
130
- const initialPreviewLines = initialPreviewText ? String(initialPreviewText).split('\n') : [];
131
- const initialFooter = typeof footer === 'function' ? footer(items[cursor], { cursor, tick, state: selectorState }) : null;
132
- const resolvedFooter = Array.isArray(initialFooter) && initialFooter.length > 0 ? initialFooter : [''];
133
- const totalLines = getSelectorRenderLineCount(Math.max(rows, previewHeight, initialPreviewLines.length)) + (resolvedFooter.length - 1);
134
- for (let i = 0; i < totalLines; i++) process.stdout.write('\n');
135
- }
136
-
137
- render();
138
-
139
- if (animationIntervalMs > 0) {
140
- animationTimer = setInterval(() => {
141
- tick++;
142
- render();
143
- }, animationIntervalMs);
144
- }
145
-
146
- emitKeypressEvents(process.stdin);
147
- if (process.stdin.setRawMode) process.stdin.setRawMode(true);
148
- process.stdin.resume();
149
-
150
- function cleanup() {
151
- if (process.stdin.setRawMode) process.stdin.setRawMode(false);
152
- process.stdin.pause();
153
- process.stdin.removeListener('keypress', handleKeypress);
154
- if (animationTimer) clearInterval(animationTimer);
155
- if (fullscreen) process.stdout.write(CLEAR);
156
- process.stdout.write(SHOW_CURSOR);
157
- }
158
-
159
- function handleKeypress(str, key) {
160
- if (!key) return;
161
-
162
- if (typeof onKey === 'function') {
163
- const custom = onKey(str, key, {
164
- cursor,
165
- item: items[cursor],
166
- items,
167
- tick,
168
- state: selectorState,
169
- });
170
- if (custom) {
171
- if (Object.prototype.hasOwnProperty.call(custom, 'state')) {
172
- selectorState = custom.state;
173
- } else if (custom.patch && selectorState && typeof selectorState === 'object') {
174
- selectorState = { ...selectorState, ...custom.patch };
175
- }
176
- if (typeof custom.tick === 'number') tick = custom.tick;
177
- if (custom.handled) {
178
- tick++;
179
- render();
180
- return;
181
- }
182
- }
183
- }
184
-
185
- if (key.name === 'up') {
186
- cursor = (cursor - columns + items.length) % items.length;
187
- tick++;
188
- render();
189
- } else if (key.name === 'down') {
190
- cursor = (cursor + columns) % items.length;
191
- tick++;
192
- render();
193
- } else if (key.name === 'left') {
194
- cursor = (cursor - 1 + items.length) % items.length;
195
- tick++;
196
- render();
197
- } else if (key.name === 'right') {
198
- cursor = (cursor + 1) % items.length;
199
- tick++;
200
- render();
201
- } else if (key.name === 'return') {
202
- cleanup();
203
- resolve({ index: cursor, value: items[cursor].value });
204
- } else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
205
- cleanup();
206
- resolve(null);
207
- }
208
- }
209
-
210
- process.stdin.on('keypress', handleKeypress);
211
- });
212
- }
1
+ // Terminal UI selector — v2: clean layout, subtle focus, smooth feel
2
+ import { emitKeypressEvents } from 'readline';
3
+ import { padAnsiEnd, stripAnsi, visibleLength } from './ansi.mjs';
4
+
5
+ const R = '\x1b[0m';
6
+ const BOLD = '\x1b[1m';
7
+ const DIM = '\x1b[2m';
8
+ const CYAN = '\x1b[36m';
9
+ const GRAY = '\x1b[90m';
10
+ const WHITE = '\x1b[37m';
11
+ const HIDE_CURSOR = '\x1b[?25l';
12
+ const SHOW_CURSOR = '\x1b[?25h';
13
+ const CLEAR = '\x1b[2J\x1b[H';
14
+
15
+ // Focus style: subtle arrow + bold text, no heavy BG inversion
16
+ const FOCUS_POINTER = `${CYAN}›${R}`;
17
+ const UNFOCUS_SPACE = ' ';
18
+
19
+ export function getSelectorRenderLineCount(bodyRows) {
20
+ return bodyRows + 5;
21
+ }
22
+
23
+ export function getFullscreenBodyRows({ gridRows, previewHeight = 0, previewLinesLength = 0, footerLinesLength = 1, terminalRows = 24 }) {
24
+ const desiredBodyRows = Math.max(gridRows, previewHeight, previewLinesLength);
25
+ const reservedRows = 2 + 1 + Math.max(1, footerLinesLength);
26
+ const availableBodyRows = Math.max(gridRows, terminalRows - reservedRows);
27
+ return Math.min(desiredBodyRows, availableBodyRows);
28
+ }
29
+
30
+ /**
31
+ * Show an interactive list selector with arrow key navigation.
32
+ * @param {Object} options
33
+ * @param {string} options.title
34
+ * @param {Array<{label: string, description?: string, value: any}>} options.items
35
+ * @param {number} [options.columns=2]
36
+ * @param {number} [options.selected=0]
37
+ * @param {(item: any, meta: { cursor: number, tick: number }) => string} [options.preview]
38
+ * @param {number} [options.previewHeight=0]
39
+ * @param {boolean} [options.fullscreen=false]
40
+ * @param {number} [options.animationIntervalMs=0]
41
+ * @param {(item: any, meta: { cursor: number, tick: number, state: any }) => string[]} [options.footer]
42
+ * @param {any} [options.state]
43
+ * @param {(str: string, key: any, meta: { cursor: number, item: any, items: any[], tick: number, state: any }) => {handled?: boolean, state?: any, patch?: object, tick?: number}|void} [options.onKey]
44
+ * @returns {Promise<{index: number, value: any} | null>}
45
+ */
46
+ export async function select({ title, items, columns = 2, selected = 0, preview = null, previewHeight = 0, fullscreen = false, animationIntervalMs = 0, footer = null, state = {}, onKey = null }) {
47
+ if (!process.stdin.isTTY) return null;
48
+
49
+ let cursor = selected;
50
+ let tick = 0;
51
+ let selectorState = state;
52
+ const rows = Math.ceil(items.length / columns);
53
+ const colWidth = Math.min(24, Math.floor(((process.stdout.columns || 80) - 6) / columns));
54
+ let renderedBodyRows = rows;
55
+ let didFullscreenInit = false;
56
+
57
+ function render() {
58
+ const meta = { cursor, tick, state: selectorState };
59
+ const previewText = typeof preview === 'function' ? preview(items[cursor], meta) : '';
60
+ const previewLines = previewText ? String(previewText).split('\n') : [];
61
+ const footerLines = typeof footer === 'function' ? footer(items[cursor], meta) : null;
62
+ const resolvedFooter = Array.isArray(footerLines) && footerLines.length > 0
63
+ ? footerLines
64
+ : [` ${FOCUS_POINTER} ${items[cursor].label}${items[cursor].description ? ` ${DIM}${items[cursor].description}${R}` : ''}`];
65
+ const bodyRows = fullscreen
66
+ ? getFullscreenBodyRows({
67
+ gridRows: rows,
68
+ previewHeight,
69
+ previewLinesLength: previewLines.length,
70
+ footerLinesLength: resolvedFooter.length,
71
+ terminalRows: process.stdout.rows || 24,
72
+ })
73
+ : Math.max(rows, previewHeight, previewLines.length);
74
+ const totalLines = getSelectorRenderLineCount(Math.max(renderedBodyRows, bodyRows)) + (resolvedFooter.length - 1);
75
+
76
+ if (fullscreen) {
77
+ if (!didFullscreenInit) {
78
+ process.stdout.write(CLEAR);
79
+ for (let i = 0; i < totalLines; i++) process.stdout.write('\n');
80
+ didFullscreenInit = true;
81
+ }
82
+ process.stdout.write(`\x1b[${totalLines}A`);
83
+ } else {
84
+ process.stdout.write(`\x1b[${totalLines}A`);
85
+ }
86
+
87
+ // Title
88
+ process.stdout.write(`\x1b[2K ${BOLD}${title}${R}\n`);
89
+ process.stdout.write(`\x1b[2K\n`);
90
+
91
+ for (let row = 0; row < bodyRows; row++) {
92
+ let gridLine = ' ';
93
+
94
+ if (row < rows) {
95
+ for (let col = 0; col < columns; col++) {
96
+ const idx = row * columns + col;
97
+ if (idx >= items.length) {
98
+ gridLine += ' '.repeat(colWidth);
99
+ continue;
100
+ }
101
+ const item = items[idx];
102
+ const isFocused = idx === cursor;
103
+
104
+ if (isFocused) {
105
+ const text = `${FOCUS_POINTER} ${BOLD}${stripAnsi(item.label)}${R}`;
106
+ gridLine += padAnsiEnd(text, colWidth);
107
+ } else {
108
+ const text = `${UNFOCUS_SPACE} ${DIM}${stripAnsi(item.label)}${R}`;
109
+ gridLine += padAnsiEnd(text, colWidth);
110
+ }
111
+ }
112
+ } else {
113
+ gridLine += ' '.repeat(columns * colWidth);
114
+ }
115
+
116
+ const previewLine = previewLines[row] ? ` ${previewLines[row]}` : '';
117
+ process.stdout.write(`\x1b[2K${gridLine}${previewLine}\n`);
118
+ }
119
+
120
+ process.stdout.write(`\x1b[2K\n`);
121
+ for (const line of resolvedFooter) {
122
+ process.stdout.write(`\x1b[2K ${line}\n`);
123
+ }
124
+ renderedBodyRows = bodyRows;
125
+ }
126
+
127
+ return new Promise((resolve) => {
128
+ process.stdout.write(HIDE_CURSOR);
129
+
130
+ let animationTimer = null;
131
+
132
+ if (!fullscreen) {
133
+ const initialPreviewText = typeof preview === 'function' ? preview(items[cursor], { cursor, tick, state: selectorState }) : '';
134
+ const initialPreviewLines = initialPreviewText ? String(initialPreviewText).split('\n') : [];
135
+ const initialFooter = typeof footer === 'function' ? footer(items[cursor], { cursor, tick, state: selectorState }) : null;
136
+ const resolvedFooter = Array.isArray(initialFooter) && initialFooter.length > 0 ? initialFooter : [''];
137
+ const totalLines = getSelectorRenderLineCount(Math.max(rows, previewHeight, initialPreviewLines.length)) + (resolvedFooter.length - 1);
138
+ for (let i = 0; i < totalLines; i++) process.stdout.write('\n');
139
+ }
140
+
141
+ render();
142
+
143
+ if (animationIntervalMs > 0) {
144
+ animationTimer = setInterval(() => {
145
+ tick++;
146
+ render();
147
+ }, animationIntervalMs);
148
+ }
149
+
150
+ emitKeypressEvents(process.stdin);
151
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
152
+ process.stdin.resume();
153
+
154
+ function cleanup() {
155
+ if (process.stdin.setRawMode) process.stdin.setRawMode(false);
156
+ process.stdin.pause();
157
+ process.stdin.removeListener('keypress', handleKeypress);
158
+ if (animationTimer) clearInterval(animationTimer);
159
+ if (fullscreen) process.stdout.write(CLEAR);
160
+ process.stdout.write(SHOW_CURSOR);
161
+ }
162
+
163
+ function handleKeypress(str, key) {
164
+ if (!key) return;
165
+
166
+ if (typeof onKey === 'function') {
167
+ const custom = onKey(str, key, {
168
+ cursor,
169
+ item: items[cursor],
170
+ items,
171
+ tick,
172
+ state: selectorState,
173
+ });
174
+ if (custom) {
175
+ if (Object.prototype.hasOwnProperty.call(custom, 'state')) {
176
+ selectorState = custom.state;
177
+ } else if (custom.patch && selectorState && typeof selectorState === 'object') {
178
+ selectorState = { ...selectorState, ...custom.patch };
179
+ }
180
+ if (typeof custom.tick === 'number') tick = custom.tick;
181
+ if (custom.handled) {
182
+ tick++;
183
+ render();
184
+ return;
185
+ }
186
+ }
187
+ }
188
+
189
+ if (key.name === 'up') {
190
+ cursor = (cursor - columns + items.length) % items.length;
191
+ tick++;
192
+ render();
193
+ } else if (key.name === 'down') {
194
+ cursor = (cursor + columns) % items.length;
195
+ tick++;
196
+ render();
197
+ } else if (key.name === 'left') {
198
+ cursor = (cursor - 1 + items.length) % items.length;
199
+ tick++;
200
+ render();
201
+ } else if (key.name === 'right') {
202
+ cursor = (cursor + 1) % items.length;
203
+ tick++;
204
+ render();
205
+ } else if (key.name === 'return') {
206
+ cleanup();
207
+ resolve({ index: cursor, value: items[cursor].value });
208
+ } else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
209
+ cleanup();
210
+ resolve(null);
211
+ }
212
+ }
213
+
214
+ process.stdin.on('keypress', handleKeypress);
215
+ });
216
+ }
package/src/ui.mjs CHANGED
@@ -1,106 +1,136 @@
1
- import { RARITIES, RARITY_STARS, RARITY_WEIGHTS } from './engine.mjs';
2
- import { padAnsiEnd, visibleLength } from './ansi.mjs';
3
-
4
- const RESET = '\x1b[0m';
5
- const BOLD = '\x1b[1m';
6
- const DIM = '\x1b[2m';
7
- const CYAN = '\x1b[36m';
8
- const GREEN = '\x1b[32m';
9
- const MAGENTA = '\x1b[35m';
10
- const YELLOW = '\x1b[33m';
11
- const RED = '\x1b[31m';
12
- const WHITE = '\x1b[37m';
13
- const GRAY = '\x1b[90m';
14
-
15
- const RARITY_TONE = {
16
- common: WHITE,
17
- uncommon: GREEN,
18
- rare: CYAN,
19
- epic: MAGENTA,
20
- legendary: '\x1b[33;1m',
21
- };
22
-
23
- export function renderBanner(title = 'SPEAKI BUDDY LAB', subtitle = 'npm-only buddy reroll terminal') {
24
- const border = '═'.repeat(54);
25
- return [
26
- '',
27
- `${MAGENTA}${border}${RESET}`,
28
- `${MAGENTA} ${BOLD}${title}${RESET}`,
29
- `${DIM} ${subtitle}${RESET}`,
30
- `${DIM} collectible-rpg terminal companion${RESET}`,
31
- `${MAGENTA}${border}${RESET}`,
32
- '',
33
- ].join('\n');
34
- }
35
-
36
- export function renderPanel(title, lines = [], tone = CYAN) {
37
- const width = Math.max(visibleLength(title) + 6, ...lines.map((line) => visibleLength(String(line))), 28);
38
- const content = lines.map((line) => ` ${tone}│${RESET} ${padAnsiEnd(String(line), width - 4)} ${tone}│${RESET}`);
39
- return [
40
- `${tone}┌${''.repeat(width - 2)}┐${RESET}`,
41
- `${tone}│${RESET} ${tone}${BOLD}${title}${RESET}${' '.repeat(Math.max(0, width - visibleLength(title) - 4))}${tone}│${RESET}`,
42
- `${tone}├${'─'.repeat(width - 2)}┤${RESET}`,
43
- ...content,
44
- `${tone}└${'─'.repeat(width - 2)}┘${RESET}`,
45
- '',
46
- ].join('\n');
47
- }
48
-
49
- export function renderQuotaSummary({ used = 0, limit = 0, starred = false, eventRemaining = 0 } = {}) {
50
- const remaining = Math.max(0, limit - used);
51
- return [
52
- `${BOLD}Quota${RESET} ${remaining}/${limit} left${starred ? '' : ''}`,
53
- `${DIM}Used today: ${used}${RESET}`,
54
- `${DIM}Event rerolls left: ${eventRemaining}${RESET}`,
55
- ];
56
- }
57
-
58
- export function renderRarityOverview() {
59
- return RARITIES.map((rarity) => {
60
- const pct = `${RARITY_WEIGHTS[rarity]}%`.padStart(3);
61
- const color = RARITY_TONE[rarity] || WHITE;
62
- return `${color}${RARITY_STARS[rarity].padEnd(6)}${RESET} ${color}${rarity.padEnd(10)}${RESET} ${GRAY}${pct}${RESET}`;
63
- });
64
- }
65
-
66
- export function renderHomeScreen() {
67
- return [
68
- renderBanner(),
69
- renderPanel('What You Can Do', [
70
- `${GREEN}bdy check${RESET} current buddy + install state`,
71
- `${GREEN}bdy gacha 10${RESET} run a 10-pull session`,
72
- `${GREEN}bdy reroll${RESET} preview and patch a chosen buddy`,
73
- `${GREEN}bdy dex${RESET} browse collected buddies`,
74
- `${GREEN}bdy restore${RESET} restore original salt`,
75
- ]),
76
- renderPanel('Design Direction', [
77
- 'Speaki-first terminal UX',
78
- 'npm install is the primary path',
79
- 'dex replays discovered buddies deterministically',
80
- 'forms, rarity, and flavor presented as card sections',
81
- ], YELLOW),
82
- renderPanel('Rarity Table', renderRarityOverview(), MAGENTA),
83
- ].join('\n');
84
- }
85
-
86
- export function renderCheckScreen({ accountLabel, installLabel, salt, patched, quotaLines, buddyCard, profileLine = '' }) {
87
- return [
88
- renderBanner('SPEAKI BUDDY STATUS', 'current companion, install, and quota'),
89
- renderPanel('Runtime', [
90
- `${BOLD}Account${RESET} ${accountLabel}`,
91
- `${BOLD}Install${RESET} ${installLabel}`,
92
- `${BOLD}Salt${RESET} ${salt}${patched ? ` ${YELLOW}(patched)${RESET}` : ` ${DIM}(original)${RESET}`}`,
93
- ...(profileLine ? [profileLine] : []),
94
- ]),
95
- renderPanel('Quota', quotaLines, GREEN),
96
- buddyCard,
97
- ].join('\n');
98
- }
99
-
100
- export function renderSearchStatus({ targetSpecies, criteria, attempts }) {
101
- const rarityText = criteria?.rarity ? ` @ ${criteria.rarity}` : '';
102
- const sourceText = criteria?.source ? `${DIM}${criteria.source}${RESET}` : '';
103
- return `${CYAN} Searching ${targetSpecies}${rarityText}${RESET} ${sourceText} ${DIM}(attempts: ${attempts})${RESET}`;
104
- }
105
-
106
- export { RESET, BOLD, DIM, CYAN, GREEN, MAGENTA, YELLOW, RED };
1
+ // UI layout primitives v2: clean, consistent, Speaki-branded
2
+ import { RARITIES, RARITY_STARS, RARITY_WEIGHTS } from './engine.mjs';
3
+ import { padAnsiEnd, visibleLength } from './ansi.mjs';
4
+ import { formatStars } from './terminal.mjs';
5
+
6
+ const R = '\x1b[0m';
7
+ const BOLD = '\x1b[1m';
8
+ const DIM = '\x1b[2m';
9
+ const ITALIC = '\x1b[3m';
10
+ const CYAN = '\x1b[36m';
11
+ const GREEN = '\x1b[32m';
12
+ const MAGENTA = '\x1b[35m';
13
+ const YELLOW = '\x1b[33;1m';
14
+ const RED = '\x1b[31m';
15
+ const WHITE = '\x1b[37m';
16
+ const GRAY = '\x1b[90m';
17
+
18
+ const RARITY_TONE = {
19
+ common: GRAY,
20
+ uncommon: GREEN,
21
+ rare: '\x1b[34m',
22
+ epic: MAGENTA,
23
+ legendary: YELLOW,
24
+ };
25
+
26
+ // ─── Layout primitives ──────────────────────────────────
27
+
28
+ function hr(width = 48, char = '─', color = GRAY) {
29
+ return `${color}${char.repeat(width)}${R}`;
30
+ }
31
+
32
+ function heading(text, color = MAGENTA) {
33
+ return `${color}${BOLD}${text}${R}`;
34
+ }
35
+
36
+ function label(key, value) {
37
+ return ` ${GRAY}${key}${R} ${value}`;
38
+ }
39
+
40
+ function bullet(text, icon = '', color = GREEN) {
41
+ return ` ${color}${icon}${R} ${text}`;
42
+ }
43
+
44
+ // ─── Banner ─────────────────────────────────────────────
45
+
46
+ export function renderBanner(title = 'BUDDY LAB', subtitle = 'collectible companion terminal') {
47
+ return [
48
+ '',
49
+ ` ${MAGENTA}${BOLD}${title}${R}`,
50
+ ` ${DIM}${subtitle}${R}`,
51
+ ` ${hr(40)}`,
52
+ '',
53
+ ].join('\n');
54
+ }
55
+
56
+ // ─── Section panel ──────────────────────────────────────
57
+
58
+ export function renderPanel(title, lines = [], color = GRAY) {
59
+ const out = [];
60
+ out.push(` ${color}${BOLD}${title}${R}`);
61
+ for (const line of lines) {
62
+ out.push(` ${line}`);
63
+ }
64
+ out.push('');
65
+ return out.join('\n');
66
+ }
67
+
68
+ // ─── Quota summary ──────────────────────────────────────
69
+
70
+ export function renderQuotaSummary({ used = 0, limit = 0, starred = false, eventRemaining = 0 } = {}) {
71
+ const remaining = Math.max(0, limit - used);
72
+ const bar = renderQuotaBar(used, limit);
73
+ return [
74
+ `${bar} ${DIM}${remaining}/${limit} left${starred ? ' ⭐' : ''}${R}`,
75
+ `${DIM}사용: ${used} 이벤트: ${eventRemaining}회 남음${R}`,
76
+ ];
77
+ }
78
+
79
+ function renderQuotaBar(used, limit) {
80
+ const w = 10;
81
+ const filled = Math.min(w, Math.round((used / Math.max(limit, 1)) * w));
82
+ const remaining = w - filled;
83
+ return `${GREEN}${'█'.repeat(remaining)}${GRAY}${''.repeat(filled)}${R}`;
84
+ }
85
+
86
+ // ─── Rarity table ───────────────────────────────────────
87
+
88
+ export function renderRarityOverview() {
89
+ return RARITIES.map((rarity) => {
90
+ const pct = `${RARITY_WEIGHTS[rarity]}%`.padStart(3);
91
+ const color = RARITY_TONE[rarity] || WHITE;
92
+ const stars = formatStars(RARITY_STARS[rarity]);
93
+ return `${color}${stars.padEnd(6)}${R} ${color}${rarity.padEnd(10)}${R} ${GRAY}${pct}${R}`;
94
+ });
95
+ }
96
+
97
+ // ─── Home screen ────────────────────────────────────────
98
+
99
+ export function renderHomeScreen() {
100
+ return [
101
+ renderBanner(),
102
+ renderPanel('Commands', [
103
+ `${GREEN}bdy check${R} ${DIM}현재 버디 + 설치 상태${R}`,
104
+ `${GREEN}bdy gacha 10${R} ${DIM}10연차 가챠${R}`,
105
+ `${GREEN}bdy reroll${R} ${DIM}후보 뽑고 바로 적용${R}`,
106
+ `${GREEN}bdy dex${R} ${DIM}도감 탐색${R}`,
107
+ `${GREEN}bdy restore${R} ${DIM}원래 버디로 복원${R}`,
108
+ ]),
109
+ renderPanel('Rarity', renderRarityOverview(), MAGENTA),
110
+ ].join('\n');
111
+ }
112
+
113
+ // ─── Check screen ───────────────────────────────────────
114
+
115
+ export function renderCheckScreen({ accountLabel, installLabel, salt, patched, quotaLines, buddyCard, profileLine = '' }) {
116
+ return [
117
+ renderBanner('BUDDY STATUS', '현재 버디 + 설치 상태'),
118
+ renderPanel('Runtime', [
119
+ label('Account', accountLabel),
120
+ label('Install', installLabel),
121
+ label('Salt', `${salt}${patched ? ` ${YELLOW}(patched)${R}` : ` ${DIM}(original)${R}`}`),
122
+ ...(profileLine ? [label('Profile', profileLine)] : []),
123
+ ]),
124
+ renderPanel('Quota', quotaLines, GREEN),
125
+ buddyCard,
126
+ ].join('\n');
127
+ }
128
+
129
+ // ─── Search status ──────────────────────────────────────
130
+
131
+ export function renderSearchStatus({ targetSpecies, criteria, attempts }) {
132
+ const rarityText = criteria?.rarity ? ` ${RARITY_TONE[criteria.rarity] || ''}${criteria.rarity}${R}` : '';
133
+ return ` ${CYAN}›${R} ${targetSpecies}${rarityText} ${GRAY}(${attempts} attempts)${R}`;
134
+ }
135
+
136
+ export { R as RESET, BOLD, DIM, CYAN, GREEN, MAGENTA, YELLOW, RED };