feat(blender): implement advanced blender features and fixes

This commit addresses several issues and implements new features for the Seed Blender based on user feedback.

- **Flexible QR Scanning**: `QRScanner` is now content-agnostic. `SeedBlender` detects QR content type (Plain Text, Krux, SeedPGP) and triggers the appropriate workflow.
- **Per-Row Decryption**: Replaces the global security panel with a per-row password input for encrypted mnemonics, allowing multiple different encrypted seeds to be used.
- **Data Loss Warning**: Implements a confirmation dialog that warns the user if they try to switch tabs with unsaved data in the blender, preventing accidental data loss.
- **Final Mnemonic Actions**: Adds 'Transfer to Backup' and 'Export as QR' buttons to the final mnemonic display, allowing the user to utilize the generated seed.
- **Refactors `SeedBlender` state management** around a `MnemonicEntry` interface for robustness and clarity.
This commit is contained in:
LC mac
2026-02-04 12:54:17 +08:00
parent b918d88a47
commit c2aeb4ce83
4 changed files with 325 additions and 439 deletions

View File

@@ -45,6 +45,7 @@ function App() {
const [backupMessagePassword, setBackupMessagePassword] = useState(''); const [backupMessagePassword, setBackupMessagePassword] = useState('');
const [restoreMessagePassword, setRestoreMessagePassword] = useState(''); const [restoreMessagePassword, setRestoreMessagePassword] = useState('');
const [isBlenderDirty, setIsBlenderDirty] = useState(false);
const [publicKeyInput, setPublicKeyInput] = useState(''); const [publicKeyInput, setPublicKeyInput] = useState('');
const [privateKeyInput, setPrivateKeyInput] = useState(''); const [privateKeyInput, setPrivateKeyInput] = useState('');
@@ -350,6 +351,18 @@ function App() {
setShowLockConfirm(false); setShowLockConfirm(false);
}; };
const handleRequestTabChange = (newTab: 'backup' | 'restore' | 'seedblender') => {
if (activeTab === 'seedblender' && isBlenderDirty) {
if (window.confirm("You have unsaved data in the Seed Blender. Are you sure you want to leave? All progress will be lost.")) {
setActiveTab(newTab);
setIsBlenderDirty(false); // Reset dirty state on leaving
}
// else: user cancelled, do nothing
} else {
setActiveTab(newTab);
}
};
return ( return (
<div className="min-h-screen bg-slate-800 text-slate-100"> <div className="min-h-screen bg-slate-800 text-slate-100">
@@ -361,7 +374,7 @@ function App() {
events={clipboardEvents} events={clipboardEvents}
onOpenClipboardModal={() => setShowClipboardModal(true)} onOpenClipboardModal={() => setShowClipboardModal(true)}
activeTab={activeTab} activeTab={activeTab}
setActiveTab={setActiveTab} onRequestTabChange={handleRequestTabChange}
encryptedMnemonicCache={encryptedMnemonicCache} encryptedMnemonicCache={encryptedMnemonicCache}
handleLockAndClear={handleLockAndClear} handleLockAndClear={handleLockAndClear}
appVersion={__APP_VERSION__} appVersion={__APP_VERSION__}
@@ -478,151 +491,156 @@ function App() {
)} )}
</> </>
) : ( ) : (
<SeedBlender /> <SeedBlender
onDirtyStateChange={setIsBlenderDirty}
setMnemonicForBackup={setMnemonic}
requestTabChange={handleRequestTabChange}
/>
)} )}
</div> </div>
{/* Security Panel */} {/* Security Panel */}
<div className="space-y-2"> {/* Added space-y-2 wrapper */} {activeTab !== 'seedblender' && (
<label className="text-sm font-semibold text-slate-200 flex items-center gap-2"> <div className="space-y-2"> {/* Added space-y-2 wrapper */}
<Lock size={14} /> SECURITY OPTIONS <label className="text-sm font-semibold text-slate-200 flex items-center gap-2">
</label> <Lock size={14} /> SECURITY OPTIONS
</label>
<div className="p-5 bg-gradient-to-br from-slate-50 to-slate-100 rounded-2xl border-2 border-slate-200 shadow-inner space-y-4"> <div className="p-5 bg-gradient-to-br from-slate-50 to-slate-100 rounded-2xl border-2 border-slate-200 shadow-inner space-y-4">
{/* Removed h3 */} {/* Removed h3 */}
{/* Encryption Mode Toggle */} {/* Encryption Mode Toggle */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Encryption Mode</label> <label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Encryption Mode</label>
<select <select
value={encryptionMode} value={encryptionMode}
onChange={(e) => setEncryptionMode(e.target.value as 'pgp' | 'krux')} onChange={(e) => setEncryptionMode(e.target.value as 'pgp' | 'krux')}
disabled={isReadOnly} disabled={isReadOnly}
className="w-full px-3 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all" className="w-full px-3 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all"
> >
<option value="pgp">PGP (Asymmetric)</option> <option value="pgp">PGP (Asymmetric)</option>
<option value="krux">Krux KEF (Passphrase)</option> <option value="krux">Krux KEF (Passphrase)</option>
</select> </select>
<p className="text-[10px] text-slate-500 mt-1"> <p className="text-[10px] text-slate-500 mt-1">
{encryptionMode === 'pgp' {encryptionMode === 'pgp'
? 'Uses PGP keys or password' ? 'Uses PGP keys or password'
: 'Uses passphrase only (Krux compatible)'} : 'Uses passphrase only (Krux compatible)'}
</p> </p>
</div> </div>
{/* Krux-specific fields */} {/* Krux-specific fields */}
{encryptionMode === 'krux' && activeTab === 'backup' && ( {encryptionMode === 'krux' && activeTab === 'backup' && (
<> <>
<div className="space-y-2 pt-2"> <div className="space-y-2 pt-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Krux Label</label> <label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Krux Label</label>
<div className="relative"> <div className="relative">
<input <input
type="text" type="text"
className={`w-full pl-3 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${ className={`w-full pl-3 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${
isReadOnly ? 'blur-sm select-none' : '' isReadOnly ? 'blur-sm select-none' : ''
}`} }`}
placeholder="e.g., My Seed 2026" placeholder="e.g., My Seed 2026"
value={kruxLabel} value={kruxLabel}
onChange={(e) => setKruxLabel(e.target.value)} onChange={(e) => setKruxLabel(e.target.value)}
readOnly={isReadOnly} readOnly={isReadOnly}
/> />
</div> </div>
<p className="text-[10px] text-slate-500 mt-1">Label for identification (max 252 bytes)</p> <p className="text-[10px] text-slate-500 mt-1">Label for identification (max 252 bytes)</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">PBKDF2 Iterations</label> <label className="text-xs font-bold text-slate-500 uppercase tracking-wider">PBKDF2 Iterations</label>
<div className="relative"> <div className="relative">
<input <input
type="number" type="number"
className={`w-full pl-3 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${ className={`w-full pl-3 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${
isReadOnly ? 'blur-sm select-none' : '' isReadOnly ? 'blur-sm select-none' : ''
}`} }`}
placeholder="e.g., 200000" placeholder="e.g., 200000"
value={kruxIterations} value={kruxIterations}
onChange={(e) => setKruxIterations(Number(e.target.value))} onChange={(e) => setKruxIterations(Number(e.target.value))}
min={10000} min={10000}
step={10000} step={10000}
readOnly={isReadOnly} readOnly={isReadOnly}
/> />
</div> </div>
<p className="text-[10px] text-slate-500 mt-1">Higher = more secure but slower (default: 200,000)</p> <p className="text-[10px] text-slate-500 mt-1">Higher = more secure but slower (default: 200,000)</p>
</div> </div>
</> </>
)} )}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Message Password</label> <label className="text-xs font-bold text-slate-500 uppercase tracking-wider">Message Password</label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-3 text-slate-400" size={16} /> <Lock className="absolute left-3 top-3 text-slate-400" size={16} />
<input <input
type="password" type="password"
className={`w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${ className={`w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all ${
isReadOnly ? 'blur-sm select-none' : '' isReadOnly ? 'blur-sm select-none' : ''
}`} }`}
placeholder={encryptionMode === 'krux' ? "Required for Krux encryption" : "Optional password..."} placeholder={encryptionMode === 'krux' ? "Required for Krux encryption" : "Optional password..."}
value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword} value={activeTab === 'backup' ? backupMessagePassword : restoreMessagePassword}
onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)} onChange={(e) => activeTab === 'backup' ? setBackupMessagePassword(e.target.value) : setRestoreMessagePassword(e.target.value)}
readOnly={isReadOnly} readOnly={isReadOnly}
/> />
</div> </div>
<p className="text-[10px] text-slate-500 mt-1"> <p className="text-[10px] text-slate-500 mt-1">
{encryptionMode === 'krux' {encryptionMode === 'krux'
? 'Required passphrase for Krux encryption' ? 'Required passphrase for Krux encryption'
: 'Symmetric encryption password (SKESK)'} : 'Symmetric encryption password (SKESK)'}
</p> </p>
</div> </div>
{activeTab === 'backup' && ( {activeTab === 'backup' && (
<div className="pt-3 border-t border-slate-300"> <div className="pt-3 border-t border-slate-300">
<label className="flex items-center gap-2 cursor-pointer group"> <label className="flex items-center gap-2 cursor-pointer group">
<input <input
type="checkbox" type="checkbox"
checked={hasBip39Passphrase} checked={hasBip39Passphrase}
onChange={(e) => setHasBip39Passphrase(e.target.checked)} onChange={(e) => setHasBip39Passphrase(e.target.checked)}
disabled={isReadOnly} disabled={isReadOnly}
className="rounded text-teal-600 focus:ring-2 focus:ring-teal-500 transition-all" className="rounded text-teal-600 focus:ring-2 focus:ring-teal-500 transition-all"
/> />
<span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors"> <span className="text-xs font-medium text-slate-700 group-hover:text-slate-900 transition-colors">
BIP39 25th word active BIP39 25th word active
</span> </span>
</label> </label>
</div> </div>
)} )}
</div> </div>
{/* Action Button */} {/* Action Button */}
{activeTab === 'backup' ? ( {activeTab === 'backup' ? (
<button <button
onClick={handleBackup} onClick={handleBackup}
disabled={!mnemonic || loading || isReadOnly} disabled={!mnemonic || loading || isReadOnly}
className="w-full py-4 bg-gradient-to-r from-teal-500 to-cyan-600 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-teal-600 hover:to-cyan-700 transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:from-teal-500 disabled:hover:to-cyan-600" className="w-full py-4 bg-gradient-to-r from-teal-500 to-cyan-600 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-teal-600 hover:to-cyan-700 transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:from-teal-500 disabled:hover:to-cyan-600"
> >
{loading ? ( {loading ? (
<RefreshCw className="animate-spin" size={20} /> <RefreshCw className="animate-spin" size={20} />
) : ( ) : (
<QrCode size={20} /> <QrCode size={20} />
)} )}
{loading ? 'Generating...' : 'Generate QR Backup'} {loading ? 'Generating...' : 'Generate QR Backup'}
</button> </button>
) : ( ) : (
<button <button
onClick={handleRestore} onClick={handleRestore}
disabled={!restoreInput || loading || isReadOnly} disabled={!restoreInput || loading || isReadOnly}
className="w-full py-4 bg-gradient-to-r from-slate-800 to-slate-900 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-slate-900 hover:to-black transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed" className="w-full py-4 bg-gradient-to-r from-slate-800 to-slate-900 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-slate-900 hover:to-black transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
> >
{loading ? ( {loading ? (
<RefreshCw className="animate-spin" size={20} /> <RefreshCw className="animate-spin" size={20} />
) : ( ) : (
<Unlock size={20} /> <Unlock size={20} />
)} )}
{loading ? 'Decrypting...' : 'Decrypt & Restore'} {loading ? 'Decrypting...' : 'Decrypt & Restore'}
</button> </button>
)} )}
</div> </div>
</div> )} </div>
{/* QR Output */} {/* QR Output */}
{qrPayload && activeTab === 'backup' && ( {qrPayload && activeTab === 'backup' && (

View File

@@ -26,7 +26,7 @@ interface HeaderProps {
events: ClipboardEvent[]; events: ClipboardEvent[];
onOpenClipboardModal: () => void; onOpenClipboardModal: () => void;
activeTab: 'backup' | 'restore' | 'seedblender'; activeTab: 'backup' | 'restore' | 'seedblender';
setActiveTab: (tab: 'backup' | 'restore' | 'seedblender') => void; onRequestTabChange: (tab: 'backup' | 'restore' | 'seedblender') => void;
encryptedMnemonicCache: any; encryptedMnemonicCache: any;
handleLockAndClear: () => void; handleLockAndClear: () => void;
appVersion: string; appVersion: string;
@@ -42,7 +42,7 @@ const Header: React.FC<HeaderProps> = ({
events, events,
onOpenClipboardModal, onOpenClipboardModal,
activeTab, activeTab,
setActiveTab, onRequestTabChange,
encryptedMnemonicCache, encryptedMnemonicCache,
handleLockAndClear, handleLockAndClear,
appVersion, appVersion,
@@ -91,19 +91,19 @@ const Header: React.FC<HeaderProps> = ({
)} )}
<button <button
className={`px-4 py-2 rounded-lg ${activeTab === 'backup' ? 'bg-teal-500 hover:bg-teal-600' : 'bg-slate-700 hover:bg-slate-600'}`} className={`px-4 py-2 rounded-lg ${activeTab === 'backup' ? 'bg-teal-500 hover:bg-teal-600' : 'bg-slate-700 hover:bg-slate-600'}`}
onClick={() => setActiveTab('backup')} onClick={() => onRequestTabChange('backup')}
> >
Backup Backup
</button> </button>
<button <button
className={`px-4 py-2 rounded-lg ${activeTab === 'restore' ? 'bg-teal-500 hover:bg-teal-600' : 'bg-slate-700 hover:bg-slate-600'}`} className={`px-4 py-2 rounded-lg ${activeTab === 'restore' ? 'bg-teal-500 hover:bg-teal-600' : 'bg-slate-700 hover:bg-slate-600'}`}
onClick={() => setActiveTab('restore')} onClick={() => onRequestTabChange('restore')}
> >
Restore Restore
</button> </button>
<button <button
className={`px-4 py-2 rounded-lg ${activeTab === 'seedblender' ? 'bg-teal-500 hover:bg-teal-600' : 'bg-slate-700 hover:bg-slate-600'}`} className={`px-4 py-2 rounded-lg ${activeTab === 'seedblender' ? 'bg-teal-500 hover:bg-teal-600' : 'bg-slate-700 hover:bg-slate-600'}`}
onClick={() => setActiveTab('seedblender')} onClick={() => onRequestTabChange('seedblender')}
> >
Seed Blender Seed Blender
</button> </button>

View File

@@ -40,12 +40,11 @@ export default function QRScanner({ onScanSuccess, onClose }: QRScannerProps) {
aspectRatio: 1.0, aspectRatio: 1.0,
}, },
(decodedText) => { (decodedText) => {
if (decodedText.startsWith('SEEDPGP1:')) { // Stop scanning after the first successful detection
if (decodedText) {
setSuccess(true); setSuccess(true);
onScanSuccess(decodedText); onScanSuccess(decodedText);
stopCamera(); stopCamera();
} else {
setError('QR code found, but not a valid SEEDPGP1 frame');
} }
}, },
() => { () => {
@@ -89,13 +88,9 @@ export default function QRScanner({ onScanSuccess, onClose }: QRScannerProps) {
// Try scanning with verbose mode // Try scanning with verbose mode
const decodedText = await html5QrCode.scanFile(file, true); const decodedText = await html5QrCode.scanFile(file, true);
if (decodedText.startsWith('SEEDPGP1:')) { setSuccess(true);
setSuccess(true); onScanSuccess(decodedText);
onScanSuccess(decodedText); html5QrCode.clear();
html5QrCode.clear();
} else {
setError(`Found QR code, but not SEEDPGP format: ${decodedText.substring(0, 30)}...`);
}
} catch (err: any) { } catch (err: any) {
console.error('File scan error:', err); console.error('File scan error:', err);

View File

@@ -1,10 +1,15 @@
/** /**
* @file SeedBlender.tsx * @file SeedBlender.tsx
* @summary Main component for the Seed Blending feature. * @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 } from 'react'; import { useState, useEffect } from 'react';
import { QrCode, X, Plus, CheckCircle2, AlertTriangle, RefreshCw, Sparkles, EyeOff, Lock } from 'lucide-react'; import { QrCode, X, Plus, CheckCircle2, AlertTriangle, RefreshCw, Sparkles, EyeOff, Lock, Key, ArrowRight } from 'lucide-react';
import QRScanner from './QRScanner'; import QRScanner from './QRScanner';
import { decryptFromSeed, detectEncryptionMode } from '../lib/seedpgp';
import { decryptFromKrux } from '../lib/krux';
import { QrDisplay } from './QrDisplay';
import { import {
blendMnemonicsAsync, blendMnemonicsAsync,
checkXorStrength, checkXorStrength,
@@ -18,7 +23,6 @@ import {
mixWithDiceAsync, mixWithDiceAsync,
} from '../lib/seedblend'; } from '../lib/seedblend';
// A simple debounce function
function debounce<F extends (...args: any[]) => any>(func: F, waitFor: number) { function debounce<F extends (...args: any[]) => any>(func: F, waitFor: number) {
let timeout: ReturnType<typeof setTimeout> | null = null; let timeout: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<F>): void => { return (...args: Parameters<F>): void => {
@@ -27,33 +31,53 @@ function debounce<F extends (...args: any[]) => any>(func: F, waitFor: number) {
}; };
} }
export function SeedBlender() { interface MnemonicEntry {
// Step 1 State id: number;
const [mnemonics, setMnemonics] = useState<string[]>(['']); rawInput: string;
const [validity, setValidity] = useState<Array<boolean | null>>([null]); decryptedMnemonic: string | null;
isEncrypted: boolean;
inputType: 'text' | 'seedpgp' | 'krux';
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: 'backup' | 'restore' | 'seedblender') => void;
}
export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestTabChange }: SeedBlenderProps) {
const [entries, setEntries] = useState<MnemonicEntry[]>([createNewEntry()]);
const [showQRScanner, setShowQRScanner] = useState(false); const [showQRScanner, setShowQRScanner] = useState(false);
const [scanTargetIndex, setScanTargetIndex] = useState<number | null>(null); const [scanTargetIndex, setScanTargetIndex] = useState<number | null>(null);
// Step 2 State
const [blendedResult, setBlendedResult] = useState<{ blendedEntropy: Uint8Array; blendedMnemonic12: string; blendedMnemonic24?: string; } | 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 [xorStrength, setXorStrength] = useState<{ isWeak: boolean; uniqueBytes: number; } | null>(null);
const [blendError, setBlendError] = useState<string>(''); const [blendError, setBlendError] = useState<string>('');
const [blending, setBlending] = useState(false); const [blending, setBlending] = useState(false);
// Step 3 State
const [diceRolls, setDiceRolls] = useState(''); const [diceRolls, setDiceRolls] = useState('');
const [diceStats, setDiceStats] = useState<DiceStats | null>(null); const [diceStats, setDiceStats] = useState<DiceStats | null>(null);
const [dicePatternWarning, setDicePatternWarning] = useState<string | null>(null); const [dicePatternWarning, setDicePatternWarning] = useState<string | null>(null);
const [diceOnlyMnemonic, setDiceOnlyMnemonic] = useState<string | null>(null); const [diceOnlyMnemonic, setDiceOnlyMnemonic] = useState<string | null>(null);
// Step 4 State
const [finalMnemonic, setFinalMnemonic] = useState<string | null>(null); const [finalMnemonic, setFinalMnemonic] = useState<string | null>(null);
const [mixing, setMixing] = useState(false); 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]);
// Main clear function
const handleLockAndClear = () => { const handleLockAndClear = () => {
setMnemonics(['']); setEntries([createNewEntry()]);
setValidity([null]);
setBlendedResult(null); setBlendedResult(null);
setXorStrength(null); setXorStrength(null);
setBlendError(''); setBlendError('');
@@ -62,108 +86,65 @@ export function SeedBlender() {
setDicePatternWarning(null); setDicePatternWarning(null);
setDiceOnlyMnemonic(null); setDiceOnlyMnemonic(null);
setFinalMnemonic(null); setFinalMnemonic(null);
} setShowFinalQR(false);
};
// Effect to validate and blend mnemonics (Step 2)
useEffect(() => { useEffect(() => {
const processMnemonics = async () => { const processEntries = async () => {
setBlending(true); setBlending(true);
setBlendError(''); setBlendError('');
const validMnemonics = entries.map(e => e.decryptedMnemonic).filter((m): m is string => m !== null && m.length > 0);
const filledMnemonics = mnemonics.map(m => m.trim()).filter(m => m.length > 0); const validityPromises = entries.map(async (entry) => {
if (filledMnemonics.length === 0) { if (!entry.rawInput.trim()) return null;
setBlendedResult(null); if (entry.isEncrypted && !entry.decryptedMnemonic) return null; // Cannot validate until decrypted
setXorStrength(null); const textToValidate = entry.decryptedMnemonic || entry.rawInput;
setValidity(mnemonics.map(() => null)); try {
setBlending(false); await mnemonicToEntropy(textToValidate.trim());
return; return true;
} } catch {
return false;
const newValidity: Array<boolean | null> = [...mnemonics].fill(null);
const validMnemonics: string[] = [];
await Promise.all(mnemonics.map(async (mnemonic, index) => {
if (mnemonic.trim()) {
try {
await mnemonicToEntropy(mnemonic.trim());
newValidity[index] = true;
validMnemonics.push(mnemonic.trim());
} catch (e) {
newValidity[index] = false;
}
} }
})); });
const newValidity = await Promise.all(validityPromises);
setValidity(newValidity); setEntries(currentEntries => currentEntries.map((e, i) => ({ ...e, isValid: newValidity[i] })));
if (validMnemonics.length > 0) { if (validMnemonics.length > 0) {
try { try {
const result = await blendMnemonicsAsync(validMnemonics); const result = await blendMnemonicsAsync(validMnemonics);
const strength = checkXorStrength(result.blendedEntropy);
setBlendedResult(result); setBlendedResult(result);
setXorStrength(strength); setXorStrength(checkXorStrength(result.blendedEntropy));
} catch (e: any) { } catch (e: any) { setBlendError(e.message); setBlendedResult(null); }
setBlendError(e.message);
setBlendedResult(null);
setXorStrength(null);
}
} else { } else {
setBlendedResult(null); setBlendedResult(null);
setXorStrength(null);
} }
setBlending(false); setBlending(false);
}; };
debounce(processEntries, 300)();
}, [JSON.stringify(entries.map(e => e.decryptedMnemonic))]);
debounce(processMnemonics, 300)();
}, [mnemonics]);
// Effect to process dice rolls (Step 3)
useEffect(() => { useEffect(() => {
const processDice = async () => { const processDice = async () => {
setDiceStats(calculateDiceStats(diceRolls)); setDiceStats(calculateDiceStats(diceRolls));
setDicePatternWarning(detectBadPatterns(diceRolls).message || null);
const pattern = detectBadPatterns(diceRolls);
setDicePatternWarning(pattern.message || null);
if (diceRolls.length >= 50) { if (diceRolls.length >= 50) {
try { try {
const diceBytes = diceToBytes(diceRolls); const outputByteLength = (blendedResult && blendedResult.blendedEntropy.length >= 32) ? 32 : 16;
const outputByteLength = blendedResult?.blendedEntropy.length === 32 ? 32 : 16; const diceOnlyEntropy = await hkdfExtractExpand(diceToBytes(diceRolls), outputByteLength, new TextEncoder().encode('dice-only'));
const diceOnlyEntropy = await hkdfExtractExpand(diceBytes, outputByteLength, new TextEncoder().encode('dice-only')); setDiceOnlyMnemonic(await entropyToMnemonic(diceOnlyEntropy));
const mnemonic = await entropyToMnemonic(diceOnlyEntropy); } catch { setDiceOnlyMnemonic(null); }
setDiceOnlyMnemonic(mnemonic); } else { setDiceOnlyMnemonic(null); }
} catch (e) {
setDiceOnlyMnemonic(null);
}
} else {
setDiceOnlyMnemonic(null);
}
}; };
debounce(processDice, 200)(); debounce(processDice, 200)();
}, [diceRolls, blendedResult]); }, [diceRolls, blendedResult]);
const updateEntry = (index: number, newProps: Partial<MnemonicEntry>) => {
const handleAddMnemonic = () => { setEntries(currentEntries => currentEntries.map((entry, i) => i === index ? { ...entry, ...newProps } : entry));
setMnemonics([...mnemonics, '']);
setValidity([...validity, null]);
}; };
const handleAddEntry = () => setEntries([...entries, createNewEntry()]);
const handleMnemonicChange = (index: number, value: string) => { const handleRemoveEntry = (id: number) => {
const newMnemonics = [...mnemonics]; if (entries.length > 1) setEntries(entries.filter(e => e.id !== id));
newMnemonics[index] = value; else setEntries([createNewEntry()]);
setMnemonics(newMnemonics);
};
const handleRemoveMnemonic = (index: number) => {
if (mnemonics.length > 1) {
const newMnemonics = mnemonics.filter((_, i) => i !== index);
const newValidity = validity.filter((_, i) => i !== index);
setMnemonics(newMnemonics);
setValidity(newValidity);
} else {
setMnemonics(['']);
setValidity([null]);
}
}; };
const handleScan = (index: number) => { const handleScan = (index: number) => {
@@ -172,242 +153,134 @@ export function SeedBlender() {
}; };
const handleScanSuccess = (scannedText: string) => { const handleScanSuccess = (scannedText: string) => {
if (scanTargetIndex !== null) { if (scanTargetIndex === null) return;
handleMnemonicChange(scanTargetIndex, scannedText); const mode = detectEncryptionMode(scannedText);
} const isEncrypted = mode === 'pgp' || mode === 'krux';
updateEntry(scanTargetIndex, {
rawInput: scannedText, isEncrypted, inputType: isEncrypted ? mode : 'text',
passwordRequired: isEncrypted, decryptedMnemonic: isEncrypted ? null : scannedText, error: null,
});
setShowQRScanner(false); setShowQRScanner(false);
setScanTargetIndex(null); };
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({ kefHex: entry.rawInput, passphrase: entry.passwordInput })).mnemonic;
} else { // seedpgp
mnemonic = (await decryptFromSeed({ frameText: entry.rawInput, messagePassword: entry.passwordInput, mode: 'pgp' })).w;
}
updateEntry(index, { decryptedMnemonic: mnemonic, isEncrypted: false, passwordRequired: false, error: null });
} catch(e: any) {
updateEntry(index, { error: e.message || "Decryption failed" });
}
}; };
const handleFinalMix = async () => { const handleFinalMix = async () => {
if (!blendedResult) return; if (!blendedResult) return;
setMixing(true); setMixing(true);
try { try {
const outputBits = blendedResult.blendedEntropy.length === 32 ? 256 : 128; const outputBits = blendedResult.blendedEntropy.length >= 32 ? 256 : 128;
const result = await mixWithDiceAsync(blendedResult.blendedEntropy, diceRolls, outputBits); const result = await mixWithDiceAsync(blendedResult.blendedEntropy, diceRolls, outputBits);
setFinalMnemonic(result.finalMnemonic); setFinalMnemonic(result.finalMnemonic);
} catch(e) { } catch(e) { setFinalMnemonic(null); } finally { setMixing(false); }
// handle error
} finally {
setMixing(false);
}
}; };
const handleClearFinal = () => { const handleTransfer = () => {
setFinalMnemonic(null); if (!finalMnemonic) return;
} setMnemonicForBackup(finalMnemonic);
handleLockAndClear();
requestTabChange('backup');
};
const getBorderColor = (isValid: boolean | null) => { const getBorderColor = (isValid: boolean | null) => {
if (isValid === true) return 'border-green-500 focus:ring-green-500'; if (isValid === true) return 'border-green-500 focus:ring-green-500';
if (isValid === false) return 'border-red-500 focus:ring-red-500'; if (isValid === false) return 'border-red-500 focus:ring-red-500';
return 'border-slate-200 focus:ring-teal-500'; return 'border-slate-200 focus:ring-teal-500';
} };
return ( return (
<> <>
<div className="space-y-6"> <div className="space-y-6 pb-20">
{/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-100">Seed Blender</h2> <h2 className="text-2xl font-bold text-slate-100">Seed Blender</h2>
<button <button onClick={handleLockAndClear} className="flex items-center gap-2 text-sm text-red-400 bg-slate-800/50 px-3 py-1.5 rounded-lg hover:bg-red-900/50"><Lock size={16} /><span>Lock/Clear</span></button>
onClick={handleLockAndClear}
className="flex items-center gap-2 text-sm text-red-400 bg-slate-800/50 px-3 py-1.5 rounded-lg hover:bg-red-900/50 transition-colors"
>
<Lock size={16} />
<span>Lock/Clear</span>
</button>
</div> </div>
{/* Step 1: Input Mnemonics */}
<div className="p-6 bg-slate-700/50 rounded-xl border border-slate-600"> <div className="p-6 bg-slate-700/50 rounded-xl border border-slate-600">
<h3 className="font-semibold text-lg mb-4 text-slate-200">Step 1: Input Mnemonics</h3> <h3 className="font-semibold text-lg mb-4 text-slate-200">Step 1: Input Mnemonics</h3>
<div className="space-y-4"> <div className="space-y-4">
{mnemonics.map((mnemonic, index) => ( {entries.map((entry, index) => (
<div key={index} className="flex flex-col sm:flex-row items-start gap-2"> <div key={entry.id} className="p-3 bg-slate-800/50 rounded-lg">
<div className="relative w-full"> {entry.passwordRequired ? (
<textarea <div className="space-y-2">
value={mnemonic} <div className="flex items-center justify-between"><label className="text-sm font-semibold text-slate-200">Decrypt {entry.inputType.toUpperCase()} Mnemonic</label><button onClick={() => updateEntry(index, createNewEntry())} className="text-xs text-slate-400 hover:text-white">&times; Cancel</button></div>
onChange={(e) => handleMnemonicChange(index, e.target.value)} <p className="text-xs text-slate-400 truncate">Payload: <code className="text-slate-300">{entry.rawInput.substring(0, 40)}...</code></p>
placeholder={`Mnemonic #${index + 1} (12 or 24 words)`} <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-slate-50 border-2 border-slate-200 rounded-lg text-sm font-mono text-slate-900 focus:outline-none focus:ring-2 focus:ring-purple-500" /><button onClick={() => handleDecrypt(index)} className="px-4 bg-purple-600 text-white rounded-lg font-semibold hover:bg-purple-700"><Key size={16}/></button></div>
className={`w-full h-28 sm:h-24 p-3 pr-10 bg-slate-50 border-2 rounded-lg text-sm font-mono text-slate-900 placeholder:text-slate-400 focus:outline-none transition-all resize-none ${getBorderColor(validity[index])}`} {entry.error && <p className="text-xs text-red-400">{entry.error}</p>}
data-sensitive={`Mnemonic #${index + 1}`} </div>
/> ) : (
{validity[index] === true && <CheckCircle2 className="absolute top-3 right-3 text-green-500" />} <div className="flex flex-col sm:flex-row items-start gap-2">
{validity[index] === false && <AlertTriangle className="absolute top-3 right-3 text-red-500" />} <div className="relative w-full">
</div> <textarea value={entry.rawInput} onChange={(e) => updateEntry(index, { rawInput: e.target.value, decryptedMnemonic: e.target.value, isValid: null })} placeholder={`Mnemonic #${index + 1} (12 or 24 words)`} className={`w-full h-28 sm:h-24 p-3 pr-10 bg-slate-50 border-2 rounded-lg text-sm font-mono text-slate-900 placeholder:text-slate-400 ${getBorderColor(entry.isValid)}`} />
{entry.isValid === true && <CheckCircle2 className="absolute top-3 right-3 text-green-500" />}
<div className="flex items-center gap-2 shrink-0"> {entry.isValid === false && <AlertTriangle className="absolute top-3 right-3 text-red-500" />}
<button </div>
onClick={() => handleScan(index)} <div className="flex items-center gap-2 shrink-0">
className="p-3 h-full bg-purple-600/20 text-purple-300 hover:bg-purple-600/50 hover:text-white rounded-md transition-colors flex items-center justify-center" <button onClick={() => handleScan(index)} className="p-3 h-full bg-purple-600/20 text-purple-300 hover:bg-purple-600/50 hover:text-white rounded-md"><QrCode size={20} /></button>
aria-label="Scan QR Code" <button onClick={() => handleRemoveEntry(entry.id)} className="p-3 h-full bg-red-600/20 text-red-400 hover:bg-red-600/50 hover:text-white rounded-md"><X size={20} /></button>
>
<QrCode size={20} />
</button>
<button
onClick={() => handleRemoveMnemonic(index)}
className="p-3 h-full bg-red-600/20 text-red-400 hover:bg-red-600/50 hover:text-white rounded-md transition-colors flex items-center justify-center"
aria-label="Remove Mnemonic"
>
<X size={20} />
</button>
</div>
</div>
))}
<button
onClick={handleAddMnemonic}
className="w-full py-2.5 bg-slate-600/70 hover:bg-slate-600 rounded-lg font-semibold flex items-center justify-center gap-2 transition-colors"
>
<Plus size={16} /> Add Another Mnemonic
</button>
</div>
</div>
{/* Step 2: Blended Preview */}
<div className="p-6 bg-slate-700/50 rounded-xl border border-slate-600 min-h-[10rem]">
<h3 className="font-semibold text-lg mb-4 text-slate-200">Step 2: Blended Preview</h3>
{blending && <p className="text-sm text-slate-400">Blending...</p>}
{blendError && <p className="text-sm text-red-400">{blendError}</p>}
{!blending && !blendError && blendedResult && (
<div className="space-y-4 animate-in fade-in">
{xorStrength?.isWeak && (
<div className="p-3 bg-amber-500/10 border border-amber-500/30 text-amber-300 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> </div>
)} )}
</div>
<div className="space-y-1"> ))}
<label className="text-xs font-semibold text-slate-400">Blended Mnemonic (12-word)</label> <button onClick={handleAddEntry} className="w-full py-2.5 bg-slate-600/70 hover:bg-slate-600 rounded-lg font-semibold flex items-center justify-center gap-2"><Plus size={16} /> Add Another Mnemonic</button>
<p className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words"> </div>
{blendedResult.blendedMnemonic12} </div>
</p>
</div> <div className="p-6 bg-slate-700/50 rounded-xl border border-slate-600 min-h-[10rem]">
<h3 className="font-semibold text-lg mb-4 text-slate-200">Step 2: Blended Preview</h3>
{blendedResult.blendedMnemonic24 && ( {blending ? <p className="text-sm text-slate-400">Blending...</p> : !blendError && blendedResult ? (<div className="space-y-4 animate-in fade-in">{xorStrength?.isWeak && (<div className="p-3 bg-amber-500/10 border border-amber-500/30 text-amber-300 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-slate-400">Blended Mnemonic (12-word)</label><p className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words">{blendedResult.blendedMnemonic12}</p></div>{blendedResult.blendedMnemonic24 && (<div className="space-y-1"><label className="text-xs font-semibold text-slate-400">Blended Mnemonic (24-word)</label><p className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words">{blendedResult.blendedMnemonic24}</p></div>)}</div>) : (<p className="text-sm text-slate-400">{blendError || 'Previews will appear here once you enter one or more valid mnemonics.'}</p>)}
<div className="space-y-1">
<label className="text-xs font-semibold text-slate-400">Blended Mnemonic (24-word)</label>
<p className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words">
{blendedResult.blendedMnemonic24}
</p>
</div>
)}
</div>
)}
{!blending && !blendError && !blendedResult && (
<p className="text-sm text-slate-400">Previews will appear here once you enter one or more valid mnemonics.</p>
)}
</div> </div>
{/* Step 3: Input Dice Rolls */}
<div className="p-6 bg-slate-700/50 rounded-xl border border-slate-600"> <div className="p-6 bg-slate-700/50 rounded-xl border border-slate-600">
<h3 className="font-semibold text-lg mb-4 text-slate-200">Step 3: Input Dice Rolls</h3> <h3 className="font-semibold text-lg mb-4 text-slate-200">Step 3: Input Dice Rolls</h3>
<div className="space-y-4"> <div className="space-y-4">
<textarea <textarea value={diceRolls} onChange={(e) => setDiceRolls(e.target.value.replace(/[^1-6]/g, ''))} placeholder="Enter 99+ dice rolls (e.g., 16345...)" className="w-full h-32 p-3 bg-slate-50 border-2 border-slate-200 rounded-lg text-lg font-mono text-slate-900 placeholder:text-slate-400" />
value={diceRolls} {dicePatternWarning && (<div className="p-3 bg-amber-500/10 border border-amber-500/30 text-amber-300 rounded-lg text-sm flex gap-3"><AlertTriangle /><p><span className="font-bold">Warning:</span> {dicePatternWarning}</p></div>)}
onChange={(e) => setDiceRolls(e.target.value.replace(/[^1-6]/g, ''))} {diceStats && diceStats.length > 0 && (<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center"><div className="p-3 bg-slate-800 rounded-lg"><p className="text-xs text-slate-400">Rolls</p><p className="text-lg font-bold">{diceStats.length}</p></div><div className="p-3 bg-slate-800 rounded-lg"><p className="text-xs text-slate-400">Entropy (bits)</p><p className="text-lg font-bold">{diceStats.estimatedEntropyBits.toFixed(1)}</p></div><div className="p-3 bg-slate-800 rounded-lg"><p className="text-xs text-slate-400">Mean</p><p className="text-lg font-bold">{diceStats.mean.toFixed(2)}</p></div><div className="p-3 bg-slate-800 rounded-lg"><p className="text-xs text-slate-400">Chi-Square</p><p className={`text-lg font-bold ${diceStats.chiSquare > 11.07 ? 'text-amber-400' : ''}`}>{diceStats.chiSquare.toFixed(2)}</p></div></div>)}
placeholder="Enter 99+ dice rolls (e.g., 16345...)" {diceOnlyMnemonic && (<div className="space-y-1 pt-2"><label className="text-xs font-semibold text-slate-400">Dice-Only Preview Mnemonic</label><p className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words">{diceOnlyMnemonic}</p></div>)}
className="w-full h-32 p-3 bg-slate-50 border-2 border-slate-200 rounded-lg text-lg font-mono text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-teal-500 transition-all resize-none"
/>
{dicePatternWarning && (
<div className="p-3 bg-amber-500/10 border border-amber-500/30 text-amber-300 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-slate-800 rounded-lg">
<p className="text-xs text-slate-400">Rolls</p>
<p className="text-lg font-bold">{diceStats.length}</p>
</div>
<div className="p-3 bg-slate-800 rounded-lg">
<p className="text-xs text-slate-400">Entropy (bits)</p>
<p className="text-lg font-bold">{diceStats.estimatedEntropyBits.toFixed(1)}</p>
</div>
<div className="p-3 bg-slate-800 rounded-lg">
<p className="text-xs text-slate-400">Mean</p>
<p className="text-lg font-bold">{diceStats.mean.toFixed(2)}</p>
</div>
<div className="p-3 bg-slate-800 rounded-lg">
<p className="text-xs text-slate-400">Chi-Square</p>
<p className={`text-lg font-bold ${diceStats.chiSquare > 11.07 ? 'text-amber-400' : ''}`}>{diceStats.chiSquare.toFixed(2)}</p>
</div>
</div>
)}
{diceOnlyMnemonic && (
<div className="space-y-1 pt-2">
<label className="text-xs font-semibold text-slate-400">Dice-Only Preview Mnemonic</label>
<p className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words">
{diceOnlyMnemonic}
</p>
</div>
)}
</div> </div>
</div> </div>
{/* Step 4: Final Mnemonic */}
<div className="p-6 bg-slate-900/70 rounded-xl border-2 border-teal-500/50 shadow-lg"> <div className="p-6 bg-slate-900/70 rounded-xl border-2 border-teal-500/50 shadow-lg">
<h3 className="font-semibold text-lg mb-4 text-slate-200">Step 4: Generate Final Mnemonic</h3> <h3 className="font-semibold text-lg mb-4 text-slate-200">Step 4: Generate Final Mnemonic</h3>
{finalMnemonic ? (
{!finalMnemonic ? ( <div className="p-4 bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-300 rounded-2xl shadow-lg">
<> <div className="flex items-center justify-between mb-4"><span className="font-bold text-green-700 flex items-center gap-2 text-lg"><CheckCircle2 size={22} /> Final Mnemonic Generated</span><button onClick={() => setFinalMnemonic(null)} className="p-2.5 hover:bg-green-100 rounded-xl"><EyeOff size={22} /></button></div>
<p className="text-sm text-slate-400 mb-4"> <div className="p-6 bg-white rounded-xl border-2 border-green-200 shadow-sm"><p className="font-mono text-center text-lg text-slate-800 break-words">{finalMnemonic}</p></div>
Once you have entered valid mnemonics and at least 50 dice rolls, you can generate the final, hardened mnemonic. <div className="mt-4 p-3 bg-red-500/10 text-red-300 rounded-lg text-xs flex gap-2"><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>
</p> <div className="grid grid-cols-2 gap-3 mt-4">
<button <button onClick={() => setShowFinalQR(true)} className="w-full py-2.5 bg-slate-700 text-white rounded-lg font-semibold flex items-center justify-center gap-2"><QrCode size={16}/> Export as QR</button>
onClick={handleFinalMix} <button onClick={handleTransfer} className="w-full py-2.5 bg-teal-600 text-white rounded-lg font-semibold flex items-center justify-center gap-2"><ArrowRight size={16}/> Transfer to Backup</button>
disabled={!blendedResult || !diceRolls || diceRolls.length < 50 || mixing}
className="w-full py-3 bg-gradient-to-r from-teal-500 to-cyan-600 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:from-teal-600 hover:to-cyan-700 transition-all shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{mixing ? (
<RefreshCw className="animate-spin" size={20} />
) : (
<Sparkles size={20} />
)}
{mixing ? 'Generating...' : 'Mix Mnemonic + Dice'}
</button>
</>
) : (
<div className="p-4 bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-300 rounded-2xl shadow-lg animate-in zoom-in-95">
<div className="flex items-center justify-between mb-4">
<span className="font-bold text-green-700 flex items-center gap-2 text-lg">
<CheckCircle2 size={22} /> Final Mnemonic Generated
</span>
<button
onClick={handleClearFinal}
className="p-2.5 hover:bg-green-100 rounded-xl transition-all text-green-700 hover:shadow"
>
<EyeOff size={22} /> Hide & Clear
</button>
</div>
<div className="p-6 bg-white rounded-xl border-2 border-green-200 shadow-sm">
<p className="font-mono text-center text-lg text-slate-800 tracking-wide leading-relaxed break-words">
{finalMnemonic}
</p>
</div>
<div className="mt-4 p-3 bg-red-500/10 text-red-300 rounded-lg text-xs flex gap-2">
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
<span>
<strong>Security Warning:</strong> Write this mnemonic down immediately on paper or metal. Do not save it digitally. Clear when done.
</span>
</div> </div>
</div> </div>
) : (
<><p className="text-sm text-slate-400 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-teal-500 to-cyan-600 text-white rounded-xl font-bold flex items-center justify-center gap-2 disabled:opacity-50">{mixing ? <RefreshCw className="animate-spin" size={20} /> : <Sparkles size={20} />}{mixing ? 'Generating...' : 'Mix Mnemonic + Dice'}</button></>
)} )}
</div> </div>
</div> </div>
{/* QR Scanner Modal */} {showQRScanner && <QRScanner onScanSuccess={handleScanSuccess} onClose={() => setShowQRScanner(false)} />}
{showQRScanner && ( {showFinalQR && finalMnemonic && (
<QRScanner <div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onClick={() => setShowFinalQR(false)}>
onScanSuccess={handleScanSuccess} <div className="bg-white rounded-2xl p-4" onClick={e => e.stopPropagation()}>
onClose={() => setShowQRScanner(false)} <QrDisplay value={finalMnemonic} />
/> </div>
</div>
)} )}
</> </>
); );