Files
seedpgp-web/src/components/SeedBlender.tsx
LC mac 185efe454f feat: mobile-first redesign and layout improvements
## Major Changes

### Mobile-First Responsive Design
- Converted entire app to mobile-first single-column layout
- Constrained max-width to 448px (mobile phone width)
- Black margins on desktop, centered content
- Removed all multi-column grids (md:grid-cols-3)

### Header Reorganization (3-Row Layout)
- Row 1: App logo + title + version
- Row 2: Security badges + action buttons (Empty, Reset)
- Row 3: Navigation tabs (Create, Backup, Restore, Blender)
- Replaced text buttons with emoji icons (📋 clipboard, 🙈 privacy mask)
- Consistent button sizing across all tabs

### Font Size Reductions
- Reduced all button text sizes for mobile density
- Main buttons: py-4 → py-3, added text-sm
- Labels: text-xs → text-[10px]
- Placeholders: consistent text-[10px] across all inputs
- Input fields: text-sm → text-xs, p-4 → p-3

### Create Tab Improvements
- Changed "GENERATE NEW SEED" from button-style to banner
- Left-aligned banner with gradient background
- Equal-width button grid (12/24 Words, Backup/Seed Blender)
- Used grid-cols-2 for consistent sizing

### Backup Tab Improvements
- Simplified drag-drop area with 📎 emoji
- Reduced padding and text sizes
- Cleaner, shorter copy
- PGP label font size: text-xs → text-[12px]

### SeedBlender Component
- Reorganized mnemonic input cards: textarea on row 1, buttons on row 2
- QR button (left) and X button (right) alignment
- Consistent placeholder text sizing (text-[10px])
- Shortened dice roll placeholder text

### HTTPS Development Server
- Added @vitejs/plugin-basic-ssl for HTTPS in dev mode
- Configured server to listen on 0.0.0.0:5173
- Fixed Web Crypto API issues on mobile (requires secure context)
- Enables testing on iPhone via local network

## Technical Details
- All changes maintain cyberpunk theme and color scheme
- Improved mobile usability and visual consistency
- No functionality changes, pure UI/UX improvements
2026-02-09 21:58:18 +08:00

414 lines
26 KiB
TypeScript

/**
* @file SeedBlender.tsx
* @summary Main component for the Seed Blending feature.
* @description This component provides a full UI for the multi-step seed blending process,
* handling various input formats, per-row decryption, and final output actions.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { QrCode, X, Plus, CheckCircle2, AlertTriangle, RefreshCw, Sparkles, EyeOff, Lock, Key, ArrowRight } from 'lucide-react';
import QRScanner from './QRScanner';
import { decryptFromSeed, detectEncryptionMode } from '../lib/seedpgp';
import { decryptFromKrux } from '../lib/krux';
import { decodeSeedQR } from '../lib/seedqr'; // New import
import { QrDisplay } from './QrDisplay';
import {
blendMnemonicsAsync,
checkXorStrength,
mnemonicToEntropy,
DiceStats,
calculateDiceStats,
detectBadPatterns,
diceToBytes,
hkdfExtractExpand,
entropyToMnemonic,
mixWithDiceAsync,
} from '../lib/seedblend';
function debounce<F extends (...args: any[]) => any>(func: F, waitFor: number) {
let timeout: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<F>): void => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => func(...args), waitFor);
};
}
interface MnemonicEntry {
id: number;
rawInput: string;
decryptedMnemonic: string | null;
isEncrypted: boolean;
inputType: 'text' | 'seedpgp' | 'krux' | 'seedqr';
passwordRequired: boolean;
passwordInput: string;
error: string | null;
isValid: boolean | null;
}
let nextId = 0;
const createNewEntry = (): MnemonicEntry => ({
id: nextId++, rawInput: '', decryptedMnemonic: null, isEncrypted: false,
inputType: 'text', passwordRequired: false, passwordInput: '', error: null, isValid: null,
});
interface SeedBlenderProps {
onDirtyStateChange: (isDirty: boolean) => void;
setMnemonicForBackup: (mnemonic: string) => void;
requestTabChange: (tab: 'create' | 'backup' | 'restore' | 'seedblender') => void;
incomingSeed?: string; // NEW: seed from Create tab
onSeedReceived?: () => void; // NEW: callback after seed added
}
export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestTabChange, incomingSeed, onSeedReceived }: SeedBlenderProps) {
const processedSeedsRef = useRef<Set<string>>(new Set());
const [entries, setEntries] = useState<MnemonicEntry[]>([createNewEntry()]);
const [showQRScanner, setShowQRScanner] = useState(false);
const [scanTargetIndex, setScanTargetIndex] = useState<number | null>(null);
const [blendedResult, setBlendedResult] = useState<{ blendedEntropy: Uint8Array; blendedMnemonic12: string; blendedMnemonic24?: string; } | null>(null);
const [xorStrength, setXorStrength] = useState<{ isWeak: boolean; uniqueBytes: number; } | null>(null);
const [blendError, setBlendError] = useState<string>('');
const [blending, setBlending] = useState(false);
const [diceRolls, setDiceRolls] = useState('');
const [diceStats, setDiceStats] = useState<DiceStats | null>(null);
const [dicePatternWarning, setDicePatternWarning] = useState<string | null>(null);
const [diceOnlyMnemonic, setDiceOnlyMnemonic] = useState<string | null>(null);
const [finalMnemonic, setFinalMnemonic] = useState<string | null>(null);
const [mixing, setMixing] = useState(false);
const [showFinalQR, setShowFinalQR] = useState(false);
useEffect(() => {
const isDirty = entries.some(e => e.rawInput.length > 0) || diceRolls.length > 0;
onDirtyStateChange(isDirty);
}, [entries, diceRolls, onDirtyStateChange]);
const addSeedEntry = (seed: string) => {
setEntries(currentEntries => {
const emptyEntryIndex = currentEntries.findIndex(e => !e.rawInput.trim());
if (emptyEntryIndex !== -1) {
return currentEntries.map((entry, index) =>
index === emptyEntryIndex
? { ...entry, rawInput: seed, decryptedMnemonic: seed, isValid: null, error: null }
: entry
);
} else {
const newEntry = createNewEntry();
newEntry.rawInput = seed;
newEntry.decryptedMnemonic = seed;
return [...currentEntries, newEntry];
}
});
};
useEffect(() => {
if (incomingSeed && incomingSeed.trim()) {
// Check if we've already processed this exact seed
if (!processedSeedsRef.current.has(incomingSeed)) {
const isDuplicate = entries.some(e => e.decryptedMnemonic === incomingSeed);
if (!isDuplicate) {
addSeedEntry(incomingSeed);
processedSeedsRef.current.add(incomingSeed);
}
}
// Always notify parent to clear the incoming seed
onSeedReceived?.();
}
}, [incomingSeed]);
const handleLockAndClear = () => {
setEntries([createNewEntry()]);
setBlendedResult(null);
setXorStrength(null);
setBlendError('');
setDiceRolls('');
setDiceStats(null);
setDicePatternWarning(null);
setDiceOnlyMnemonic(null);
setFinalMnemonic(null);
setShowFinalQR(false);
};
useEffect(() => {
const processEntries = async () => {
setBlending(true);
setBlendError('');
const validMnemonics = entries.map(e => e.decryptedMnemonic).filter((m): m is string => m !== null && m.length > 0);
const validityPromises = entries.map(async (entry) => {
if (!entry.rawInput.trim()) return { isValid: null, error: null };
if (entry.isEncrypted && !entry.decryptedMnemonic) return { isValid: null, error: null };
const textToValidate = entry.decryptedMnemonic || entry.rawInput;
try {
await mnemonicToEntropy(textToValidate.trim());
return { isValid: true, error: null };
} catch (e: any) {
return { isValid: false, error: e.message || "Invalid mnemonic" };
}
});
const newValidationResults = await Promise.all(validityPromises);
setEntries(currentEntries => currentEntries.map((e, i) => ({
...e,
isValid: newValidationResults[i]?.isValid ?? e.isValid,
error: newValidationResults[i]?.error ?? e.error,
})));
if (validMnemonics.length > 0) {
try {
const result = await blendMnemonicsAsync(validMnemonics);
setBlendedResult(result);
setXorStrength(checkXorStrength(result.blendedEntropy));
} catch (e: any) { setBlendError(e.message); setBlendedResult(null); }
} else {
setBlendedResult(null);
}
setBlending(false);
};
debounce(processEntries, 300)();
}, [JSON.stringify(entries.map(e => e.decryptedMnemonic))]);
useEffect(() => {
const processDice = async () => {
setDiceStats(calculateDiceStats(diceRolls));
setDicePatternWarning(detectBadPatterns(diceRolls).message || null);
if (diceRolls.length >= 50) {
try {
const outputByteLength = (blendedResult && blendedResult.blendedEntropy.length >= 32) ? 32 : 16;
const diceOnlyEntropy = await hkdfExtractExpand(diceToBytes(diceRolls), outputByteLength, new TextEncoder().encode('dice-only'));
setDiceOnlyMnemonic(await entropyToMnemonic(diceOnlyEntropy));
} catch { setDiceOnlyMnemonic(null); }
} else { setDiceOnlyMnemonic(null); }
};
debounce(processDice, 200)();
}, [diceRolls, blendedResult]);
const updateEntry = (index: number, newProps: Partial<MnemonicEntry>) => {
setEntries(currentEntries => currentEntries.map((entry, i) => i === index ? { ...entry, ...newProps } : entry));
};
const handleAddEntry = () => setEntries([...entries, createNewEntry()]);
const handleRemoveEntry = (id: number) => {
if (entries.length > 1) setEntries(entries.filter(e => e.id !== id));
else setEntries([createNewEntry()]);
};
const handleScan = (index: number) => {
setScanTargetIndex(index);
setShowQRScanner(true);
};
const handleScanSuccess = useCallback(async (scannedData: string | Uint8Array) => {
if (scanTargetIndex === null) return;
const scannedText = typeof scannedData === 'string'
? scannedData
: Array.from(scannedData).map(b => b.toString(16).padStart(2, '0')).join('');
const mode = detectEncryptionMode(scannedText);
let mnemonic = scannedText;
let error: string | null = null;
let inputType: 'text' | 'seedpgp' | 'krux' | 'seedqr' = 'text';
try {
if (mode === 'seedqr') {
mnemonic = await decodeSeedQR(scannedText);
inputType = 'seedqr';
updateEntry(scanTargetIndex, {
rawInput: mnemonic,
decryptedMnemonic: mnemonic,
isEncrypted: false,
passwordRequired: false,
inputType,
error: null,
});
} else if (mode === 'pgp' || mode === 'krux') {
inputType = (mode === 'pgp' ? 'seedpgp' : mode);
updateEntry(scanTargetIndex, {
rawInput: scannedText,
decryptedMnemonic: null,
isEncrypted: true,
passwordRequired: true,
inputType,
error: null,
});
} else { // text or un-recognized
updateEntry(scanTargetIndex, {
rawInput: scannedText,
decryptedMnemonic: scannedText,
isEncrypted: false,
passwordRequired: false,
inputType: 'text',
error: null,
});
}
} catch (e: any) {
error = e.message || "Failed to process QR code";
updateEntry(scanTargetIndex, { rawInput: scannedText, error });
}
setShowQRScanner(false);
}, [scanTargetIndex]);
const handleScanClose = useCallback(() => {
setShowQRScanner(false);
}, []);
const handleScanError = useCallback((errMsg: string) => {
if (scanTargetIndex !== null) {
updateEntry(scanTargetIndex, { error: errMsg });
}
}, [scanTargetIndex]);
const handleDecrypt = async (index: number) => {
const entry = entries[index];
if (!entry.isEncrypted || !entry.passwordInput) return;
try {
let mnemonic: string;
if (entry.inputType === 'krux') {
mnemonic = (await decryptFromKrux({ kefData: entry.rawInput, passphrase: entry.passwordInput })).mnemonic;
} else { // seedpgp
mnemonic = (await decryptFromSeed({ frameText: entry.rawInput, messagePassword: entry.passwordInput, mode: 'pgp' })).w;
}
updateEntry(index, { rawInput: mnemonic, decryptedMnemonic: mnemonic, isEncrypted: false, passwordRequired: false, error: null });
} catch (e: any) {
updateEntry(index, { error: e.message || "Decryption failed" });
}
};
const handleFinalMix = async () => {
if (!blendedResult) return;
setMixing(true);
try {
const outputBits = blendedResult.blendedEntropy.length >= 32 ? 256 : 128;
const result = await mixWithDiceAsync(blendedResult.blendedEntropy, diceRolls, outputBits);
setFinalMnemonic(result.finalMnemonic);
} catch (e) { setFinalMnemonic(null); } finally { setMixing(false); }
};
const handleTransfer = () => {
if (!finalMnemonic) return;
// Set mnemonic for backup
setMnemonicForBackup(finalMnemonic);
// Switch to backup tab
requestTabChange('backup');
// DON'T auto-clear - user can use "Reset All" button if they want to start fresh
// This preserves the blended seed in case user wants to come back and export QR
};
const getBorderColor = (isValid: boolean | null) => {
if (isValid === true) return 'border-[#39ff14] focus:ring-[#39ff14]';
if (isValid === false) return 'border-[#ff006e] focus:ring-[#ff006e]';
return 'border-[#00f0ff]/50 focus:ring-[#00f0ff]';
};
return (
<>
<div className="space-y-6 pb-20">
<div className="mb-6">
<h2 className="text-lg font-bold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>
Seed Blender
</h2>
</div>
<div className="p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30">
<h3 className="font-semibold text-lg mb-4 text-[#00f0ff]" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Step 1: Input Mnemonics</h3>
<div className="space-y-4">
{entries.map((entry, index) => (
<div key={entry.id} className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/20">
{entry.passwordRequired ? (
<div className="space-y-2">
<div className="flex items-center justify-between"><label className="text-sm font-semibold text-[#00f0ff]">Decrypt {entry.inputType.toUpperCase()} Mnemonic</label><button onClick={() => updateEntry(index, createNewEntry())} className="text-xs text-[#6ef3f7] hover:text-[#00f0ff]">&times; Cancel</button></div>
<p className="text-xs text-[#6ef3f7] truncate">Payload: <code className="text-[#9d84b7]">{entry.rawInput.substring(0, 40)}...</code></p>
<div className="flex gap-2"><input type="password" placeholder="Enter passphrase to decrypt..." value={entry.passwordInput} onChange={(e) => updateEntry(index, { passwordInput: e.target.value })} className="w-full p-2 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg text-sm font-mono text-[#00f0ff] placeholder-[#9d84b7] focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)]" /><button onClick={() => handleDecrypt(index)} className="px-4 bg-[#ff006e] text-white rounded-lg font-semibold hover:bg-[#ff4d8f] hover:shadow-[0_0_15px_rgba(255,0,110,0.5)]"><Key size={16} /></button></div>
{entry.error && <p className="text-xs text-[#ff006e]">{entry.error}</p>}
</div>
) : (
<div className="space-y-2">
{/* Row 1: Textarea only */}
<textarea
value={entry.rawInput}
onChange={(e) => updateEntry(index, { rawInput: e.target.value, decryptedMnemonic: e.target.value, isValid: null, error: null })}
placeholder={`Mnemonic #${index + 1} (12 or 24 words)`}
className={`w-full h-24 p-3 bg-[#0a0a0f] border-2 rounded-lg font-mono text-xs placeholder:text-[10px] placeholder:text-[#6ef3f7] focus:outline-none focus:border-[#ff006e] focus:shadow-[0_0_20px_rgba(255,0,110,0.5)] transition-all ${getBorderColor(entry.isValid)}`}
/>
{/* Row 2: QR button (left) and X button (right) */}
<div className="flex items-center justify-between">
<button
onClick={() => handleScan(index)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-[#16213e] border border-[#00f0ff] text-[#00f0ff] text-xs rounded-lg hover:bg-[#00f0ff20] transition-all"
title="Scan QR code"
>
<QrCode size={14} />
<span>Scan QR</span>
</button>
<button
onClick={() => handleRemoveEntry(entry.id)}
className="p-1.5 bg-[#16213e] border border-[#ff006e] text-[#ff006e] rounded-lg hover:bg-[#ff006e20] transition-all"
title="Remove mnemonic"
>
<X size={14} />
</button>
</div>
{entry.error && <p className="text-xs text-[#ff006e] px-1">{entry.error}</p>}
</div>
)}
</div>
))}
<button onClick={handleAddEntry} className="w-full py-2.5 bg-[#1a1a2e] hover:bg-[#16213e] text-[#00f0ff] rounded-lg font-semibold flex items-center justify-center gap-2 border-2 border-[#00f0ff]/50"><Plus size={16} /> Add Another Mnemonic</button>
</div>
</div>
<div className="p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30 min-h-[10rem]">
<h3 className="font-semibold text-lg mb-4 text-[#00f0ff]" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Step 2: Blended Preview</h3>
{blending ? <p className="text-sm text-[#6ef3f7]">Blending...</p> : !blendError && blendedResult ? (<div className="space-y-4 animate-in fade-in">{xorStrength?.isWeak && (<div className="p-3 bg-[#ff006e]/10 border-2 border-[#ff006e]/30 text-[#ff006e] rounded-lg text-sm flex gap-3"><AlertTriangle /><div><span className="font-bold">Weak XOR Result:</span> Detected only {xorStrength.uniqueBytes} unique bytes. This can happen if seeds are identical or too similar.</div></div>)}<div className="space-y-1"><label className="text-xs font-semibold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Blended Mnemonic (12-word)</label><p data-sensitive="Blended Mnemonic (12-word)" className="p-3 bg-[#1a1a2e] rounded-md font-mono text-sm text-[#00f0ff] break-words border-2 border-[#00f0ff]/30">{blendedResult.blendedMnemonic12}</p></div>{blendedResult.blendedMnemonic24 && (<div className="space-y-1"><label className="text-xs font-semibold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Blended Mnemonic (24-word)</label><p data-sensitive="Blended Mnemonic (24-word)" className="p-3 bg-[#1a1a2e] rounded-md font-mono text-sm text-[#00f0ff] break-words border-2 border-[#00f0ff]/30">{blendedResult.blendedMnemonic24}</p></div>)}</div>) : (<p className="text-sm text-[#6ef3f7]">{blendError || 'Previews will appear here once you enter one or more valid mnemonics.'}</p>)}
</div>
<div className="p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/30">
<h3 className="font-semibold text-lg mb-4 text-[#00f0ff]" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Step 3: Input Dice Rolls</h3>
<div className="space-y-4">
<textarea value={diceRolls} onChange={(e) => setDiceRolls(e.target.value.replace(/[^1-6]/g, ''))} placeholder="99+ dice rolls (e.g., 16345...)" className="w-full h-32 p-3 bg-[#16213e] border-2 border-[#00f0ff]/50 rounded-lg font-mono text-xs placeholder:text-[10px] placeholder:text-[#6ef3f7]" />
{dicePatternWarning && (<div className="p-3 bg-[#ff006e]/10 border-2 border-[#ff006e]/30 text-[#ff006e] rounded-lg text-sm flex gap-3"><AlertTriangle /><p><span className="font-bold">Warning:</span> {dicePatternWarning}</p></div>)}
{diceStats && diceStats.length > 0 && (<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center"><div className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/30"><p className="text-xs text-[#6ef3f7]">Rolls</p><p className="text-lg font-bold text-[#00f0ff]">{diceStats.length}</p></div><div className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/30"><p className="text-xs text-[#6ef3f7]">Entropy (bits)</p><p className="text-lg font-bold text-[#00f0ff]">{diceStats.estimatedEntropyBits.toFixed(1)}</p></div><div className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/30"><p className="text-xs text-[#6ef3f7]">Mean</p><p className="text-lg font-bold text-[#00f0ff]">{diceStats.mean.toFixed(2)}</p></div><div className="p-3 bg-[#1a1a2e] rounded-lg border-2 border-[#00f0ff]/30"><p className="text-xs text-[#6ef3f7]">Chi-Square</p><p className={`text-lg font-bold ${diceStats.chiSquare > 11.07 ? 'text-[#ff006e]' : 'text-[#00f0ff]'}`}>{diceStats.chiSquare.toFixed(2)}</p></div></div>)}
{diceOnlyMnemonic && (<div className="space-y-1 pt-2"><label className="text-xs font-semibold text-[#00f0ff] uppercase tracking-widest" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Dice-Only Preview Mnemonic</label><p data-sensitive="Dice-Only Preview Mnemonic" className="p-3 bg-[#1a1a2e] rounded-md font-mono text-sm text-[#00f0ff] break-words border-2 border-[#00f0ff]/30">{diceOnlyMnemonic}</p></div>)}
</div>
</div>
<div className="p-6 bg-[#16213e] rounded-xl border-2 border-[#00f0ff]/50 shadow-[0_0_20px_rgba(0,240,255,0.3)]">
<h3 className="font-semibold text-lg mb-4 text-[#00f0ff]" style={{ textShadow: '0 0 10px rgba(0,240,255,0.7)' }}>Step 4: Generate Final Mnemonic</h3>
{finalMnemonic ? (
<div className="p-4 bg-[#0a0a0f] border-2 border-[#39ff14] rounded-2xl shadow-[0_0_20px_rgba(57,255,20,0.3)]">
<div className="flex items-center justify-between mb-4"><span className="font-bold text-[#39ff14] flex items-center gap-2 text-lg" style={{ textShadow: '0 0 10px rgba(57,255,20,0.8)' }}><CheckCircle2 size={22} /> Final Mnemonic Generated</span><button onClick={() => setFinalMnemonic(null)} className="p-2.5 hover:bg-[#16213e] rounded-xl transition-all text-[#39ff14] hover:shadow-[0_0_15px_rgba(57,255,20,0.5)] flex items-center gap-2"><EyeOff size={22} /> Hide</button></div>
<div className="p-6 bg-[#0a0a0f] rounded-xl border-2 border-[#39ff14] shadow-[0_0_20px_rgba(57,255,20,0.3)]"><p data-sensitive="Final Blended Mnemonic" className="font-mono text-center text-lg break-words text-[#39ff14]">{finalMnemonic}</p></div>
<div className="mt-4 p-3 bg-[#ff006e]/10 text-[#ff006e] rounded-lg text-xs flex gap-2 border-2 border-[#ff006e]/30"><AlertTriangle size={16} className="shrink-0 mt-0.5" /><span><strong>Security Warning:</strong> Write this down immediately. Do not save it digitally.</span></div>
<div className="grid grid-cols-2 gap-3 mt-4">
<button onClick={() => setShowFinalQR(true)} className="w-full py-2.5 bg-[#1a1a2e] text-[#00f0ff] rounded-lg font-semibold flex items-center justify-center gap-2 border-2 border-[#00f0ff]/50 hover:bg-[#16213e] hover:shadow-[0_0_15px_rgba(0,240,255,0.3)]"><QrCode size={16} /> Export as QR</button>
<button
onClick={handleTransfer}
className="w-full py-2.5 bg-gradient-to-r from-[#ff006e] to-[#ff4d8f] text-white rounded-lg font-bold uppercase tracking-wider flex items-center justify-center gap-2 hover:shadow-[0_0_30px_rgba(255,0,110,0.8)] active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed border border-[#ff006e]"
style={{ textShadow: '0 0 10px rgba(255,255,255,0.8)' }}
disabled={!finalMnemonic}
>
<ArrowRight size={20} />
Send to Backup Tab
</button>
</div>
</div>
) : (
<><p className="text-sm text-[#6ef3f7] mb-4">Once you have entered valid mnemonics and at least 50 dice rolls, you can generate the final mnemonic.</p><button onClick={handleFinalMix} disabled={!blendedResult || !diceRolls || diceRolls.length < 50 || mixing} className="w-full py-3 bg-gradient-to-r from-[#00f0ff] to-[#0066ff] text-[#16213e] rounded-xl font-bold flex items-center justify-center gap-2 disabled:opacity-50 hover:shadow-[0_0_20px_rgba(0,240,255,0.5)]">{mixing ? <RefreshCw className="animate-spin" size={20} /> : <Sparkles size={20} />}{mixing ? 'Generating...' : 'Mix Mnemonic + Dice'}</button></>
)}
</div>
</div>
{showQRScanner && <QRScanner
onScanSuccess={handleScanSuccess}
onClose={handleScanClose}
onError={handleScanError}
/>}
{showFinalQR && finalMnemonic && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onClick={() => setShowFinalQR(false)}>
<div className="bg-[#16213e] rounded-2xl p-4 border-2 border-[#00f0ff]/50" onClick={e => e.stopPropagation()}>
<QrDisplay value={finalMnemonic} />
</div>
</div>
)}
</>
);
}