feat: fix CompactSeedQR binary QR code scanning with jsQR library

- Replace BarcodeDetector with jsQR for raw binary byte access
- BarcodeDetector forced UTF-8 decoding which corrupted binary data
- jsQR's binaryData property preserves raw bytes without text conversion
- Fix regex bug: use single backslash \x00 instead of \x00 for binary detection
- Add debug logging for scan data inspection
- QR generation already worked (Krux-compatible), only scanning was broken

Resolves binary QR code scanning for 12/24-word CompactSeedQR format.
Tested with Krux device - full bidirectional compatibility confirmed.
This commit is contained in:
LC mac
2026-02-07 04:22:56 +08:00
parent 49d73a7ae4
commit aa06c9ae27
39 changed files with 4664 additions and 777 deletions

View File

@@ -1,218 +1,157 @@
import { useState, useRef } from 'react';
import { Camera, Upload, X, CheckCircle2, AlertCircle, Info } from 'lucide-react';
import { Html5Qrcode } from 'html5-qrcode';
import { useState, useRef, useEffect } from 'react';
import { Camera, X, CheckCircle2, AlertCircle } from 'lucide-react';
import jsQR from 'jsqr';
interface QRScannerProps {
onScanSuccess: (scannedText: string) => void;
onScanSuccess: (data: string | Uint8Array) => void;
onClose: () => void;
onError?: (error: string) => void;
}
export default function QRScanner({ onScanSuccess, onClose }: QRScannerProps) {
const [scanMode, setScanMode] = useState<'camera' | 'file' | null>(null);
const [scanning, setScanning] = useState(false);
const [error, setError] = useState<string>('');
export default function QRScanner({ onScanSuccess, onClose, onError }: QRScannerProps) {
const [internalError, setInternalError] = useState<string>('');
const [success, setSuccess] = useState(false);
const html5QrCodeRef = useRef<Html5Qrcode | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const startCamera = async () => {
setError('');
setScanMode('camera');
setScanning(true);
useEffect(() => {
let stream: MediaStream | null = null;
let scanInterval: number | null = null;
let isCancelled = false;
// Wait for DOM to render the #qr-reader div
await new Promise(resolve => setTimeout(resolve, 100));
const stopScanning = () => {
if (scanInterval) clearInterval(scanInterval);
if (stream) stream.getTracks().forEach(track => track.stop());
};
try {
// Check if we're on HTTPS or localhost
if (window.location.protocol !== 'https:' && !window.location.hostname.includes('localhost')) {
throw new Error('Camera requires HTTPS or localhost. Use: bun run dev');
}
const html5QrCode = new Html5Qrcode('qr-reader');
html5QrCodeRef.current = html5QrCode;
await html5QrCode.start(
{ facingMode: 'environment' },
{
fps: 10,
qrbox: { width: 250, height: 250 },
aspectRatio: 1.0,
},
(decodedText) => {
// Stop scanning after the first successful detection
if (decodedText) {
setSuccess(true);
onScanSuccess(decodedText);
stopCamera();
}
},
() => {
// Ignore frequent scanning errors
}
);
} catch (err: any) {
console.error('Camera error:', err);
setError(`Camera failed: ${err.message || 'Permission denied or not available'}`);
setScanning(false);
setScanMode(null);
}
};
const stopCamera = async () => {
if (html5QrCodeRef.current) {
const startScanning = async () => {
try {
await html5QrCodeRef.current.stop();
html5QrCodeRef.current.clear();
} catch (err) {
console.error('Error stopping camera:', err);
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' }
});
if (isCancelled) return stopScanning();
setHasPermission(true);
const video = videoRef.current;
const canvas = canvasRef.current;
if (video && canvas) {
video.srcObject = stream;
await video.play();
if (isCancelled) return stopScanning();
const ctx = canvas.getContext('2d');
if (!ctx) {
setInternalError('Canvas context not available');
return stopScanning();
}
scanInterval = window.setInterval(() => {
if (isCancelled || !video || video.paused || video.ended) return;
if (!video.videoWidth || !video.videoHeight) return;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'dontInvert'
});
if (code) {
isCancelled = true;
stopScanning();
setSuccess(true);
// jsQR gives us raw bytes!
const rawBytes = code.binaryData;
console.log('🔍 Raw QR bytes:', rawBytes);
console.log(' - Length:', rawBytes.length);
console.log(' - Hex:', Array.from(rawBytes).map((b: number) => b.toString(16).padStart(2, '0')).join(''));
// Detect binary (16 or 32 bytes with non-printable chars)
const isBinary = (rawBytes.length === 16 || rawBytes.length === 32) &&
/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\xFF]/.test(String.fromCharCode(...Array.from(rawBytes)));
console.log('📊 Is binary?', isBinary);
if (isBinary) {
console.log('✅ Passing Uint8Array');
onScanSuccess(new Uint8Array(rawBytes));
} else {
// Text QR - use the text property
console.log('✅ Passing string:', code.data.slice(0, 50));
onScanSuccess(code.data);
}
setTimeout(() => onClose(), 1000);
}
}, 300);
}
} catch (err: any) {
console.error('Camera error:', err);
setHasPermission(false);
const errorMsg = 'Camera access was denied.';
setInternalError(errorMsg);
onError?.(errorMsg);
}
html5QrCodeRef.current = null;
}
setScanning(false);
setScanMode(null);
};
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
startScanning();
setError('');
setScanMode('file');
setScanning(true);
try {
const html5QrCode = new Html5Qrcode('qr-reader-file');
// Try scanning with verbose mode
const decodedText = await html5QrCode.scanFile(file, true);
setSuccess(true);
onScanSuccess(decodedText);
html5QrCode.clear();
} catch (err: any) {
console.error('File scan error:', err);
// Provide helpful error messages
if (err.message?.includes('No MultiFormat')) {
setError('Could not detect QR code in image. Try: 1) Taking a clearer photo, 2) Ensuring good lighting, 3) Screenshot from the Backup tab');
} else {
setError(`Scan failed: ${err.message || 'Unknown error'}`);
}
} finally {
setScanning(false);
// Reset file input so same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleClose = async () => {
await stopCamera();
onClose();
};
return () => {
isCancelled = true;
stopScanning();
};
}, [onScanSuccess, onClose, onError]);
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-in fade-in">
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full overflow-hidden animate-in zoom-in-95">
{/* Header */}
<div className="bg-gradient-to-r from-slate-900 to-slate-800 p-4 text-white flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-slate-800 rounded-xl border border-slate-700 p-6 max-w-md w-full mx-4 shadow-2xl">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Camera size={20} />
<h2 className="font-bold text-lg">Scan QR Code</h2>
</div>
<button
onClick={handleClose}
className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"
>
<X size={20} />
Scan QR Code
</h3>
<button onClick={onClose} className="p-2 hover:bg-slate-700 rounded-lg transition-colors">
<X size={20} className="text-slate-400" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-4">
{/* Error Display */}
{error && (
<div className="p-3 bg-red-50 border-l-4 border-red-500 rounded-r-lg flex gap-2 text-red-800 text-xs leading-relaxed">
<AlertCircle size={16} className="shrink-0 mt-0.5" />
<p>{error}</p>
</div>
)}
{internalError && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/50 rounded-lg flex items-start gap-2 text-red-400 text-sm">
<AlertCircle size={16} className="shrink-0 mt-0.5" />
<span>{internalError}</span>
</div>
)}
{/* Success Display */}
{success && (
<div className="p-3 bg-green-50 border-l-4 border-green-500 rounded-r-lg flex gap-2 text-green-800 text-sm">
<CheckCircle2 size={16} className="shrink-0 mt-0.5" />
<p>QR code scanned successfully!</p>
</div>
)}
{success && (
<div className="mb-4 p-3 bg-green-500/10 border border-green-500/50 rounded-lg flex items-center gap-2 text-green-400 text-sm">
<CheckCircle2 size={16} />
<span>QR Code detected!</span>
</div>
)}
{/* Mode Selection */}
{!scanMode && (
<div className="space-y-3">
<button
onClick={startCamera}
className="w-full py-4 bg-gradient-to-r from-teal-500 to-cyan-600 text-white rounded-xl font-semibold flex items-center justify-center gap-2 hover:from-teal-600 hover:to-cyan-700 transition-all shadow-lg"
>
<Camera size={20} />
Use Camera
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="w-full py-4 bg-gradient-to-r from-slate-700 to-slate-800 text-white rounded-xl font-semibold flex items-center justify-center gap-2 hover:from-slate-800 hover:to-slate-900 transition-all shadow-lg"
>
<Upload size={20} />
Upload Image
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileUpload}
className="hidden"
/>
{/* Info Box */}
<div className="pt-4 border-t border-slate-200">
<div className="flex gap-2 text-xs text-slate-600 leading-relaxed">
<Info size={14} className="shrink-0 mt-0.5 text-teal-600" />
<div>
<p><strong>Camera:</strong> Requires HTTPS or localhost</p>
<p className="mt-1"><strong>Upload:</strong> Screenshot QR from Backup tab for testing</p>
</div>
</div>
</div>
</div>
)}
{/* Camera View */}
{scanMode === 'camera' && scanning && (
<div className="space-y-3">
<div id="qr-reader" className="rounded-lg overflow-hidden border-2 border-slate-200"></div>
<button
onClick={stopCamera}
className="w-full py-3 bg-red-600 text-white rounded-lg font-semibold hover:bg-red-700 transition-colors"
>
Stop Camera
</button>
</div>
)}
{/* File Processing View */}
{scanMode === 'file' && scanning && (
<div className="py-8 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-slate-200 border-t-teal-600"></div>
<p className="mt-3 text-sm text-slate-600">Processing image...</p>
</div>
)}
{/* Hidden div for file scanning */}
<div id="qr-reader-file" className="hidden"></div>
<div className="relative bg-black rounded-lg overflow-hidden">
<video ref={videoRef} className="w-full h-64 object-cover" playsInline muted />
<canvas ref={canvasRef} className="hidden" />
</div>
{!hasPermission && !internalError && (
<p className="text-sm text-slate-400 mt-3 text-center">Requesting camera access...</p>
)}
<button
onClick={onClose}
className="w-full mt-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg text-white font-medium transition-colors"
>
Cancel
</button>
</div>
</div>
);

View File

@@ -3,39 +3,85 @@ import { Download } from 'lucide-react';
import QRCode from 'qrcode';
interface QrDisplayProps {
value: string;
value: string | Uint8Array;
}
export const QrDisplay: React.FC<QrDisplayProps> = ({ value }) => {
const [dataUrl, setDataUrl] = useState<string>('');
const [dataUrl, setDataUrl] = useState('');
const [debugInfo, setDebugInfo] = useState('');
useEffect(() => {
if (value) {
QRCode.toDataURL(value, {
errorCorrectionLevel: 'M',
type: 'image/png',
width: 512,
margin: 4,
color: {
dark: '#000000',
light: '#FFFFFF'
}
})
.then(setDataUrl)
.catch(console.error);
if (!value) {
setDataUrl('');
return;
}
const generateQR = async () => {
try {
console.log('🎨 QrDisplay generating QR for:', value);
console.log(' - Type:', value instanceof Uint8Array ? 'Uint8Array' : typeof value);
console.log(' - Length:', value.length);
if (value instanceof Uint8Array) {
console.log(' - Hex:', Array.from(value).map(b => b.toString(16).padStart(2, '0')).join(''));
// Create canvas manually for precise control
const canvas = document.createElement('canvas');
// Use the toCanvas method with Uint8Array directly
await QRCode.toCanvas(canvas, [{
data: value,
mode: 'byte'
}], {
errorCorrectionLevel: 'L',
width: 512,
margin: 4,
color: {
dark: '#000000',
light: '#FFFFFF'
}
});
const url = canvas.toDataURL('image/png');
setDataUrl(url);
setDebugInfo(`Binary QR: ${value.length} bytes`);
console.log('✅ Binary QR generated successfully');
} else {
// For string data
console.log(' - String data:', value.slice(0, 50));
const url = await QRCode.toDataURL(value, {
errorCorrectionLevel: 'L',
type: 'image/png',
width: 512,
margin: 4,
color: {
dark: '#000000',
light: '#FFFFFF'
}
});
setDataUrl(url);
setDebugInfo(`String QR: ${value.length} chars`);
console.log('✅ String QR generated successfully');
}
} catch (err) {
console.error('❌ QR generation error:', err);
setDebugInfo(`Error: ${err}`);
}
};
generateQR();
}, [value]);
const handleDownload = () => {
if (!dataUrl) return;
// Generate filename: SeedPGP_YYYY-MM-DD_HHMMSS.png
const now = new Date();
const date = now.toISOString().split('T')[0]; // YYYY-MM-DD
const time = now.toTimeString().split(' ')[0].replace(/:/g, ''); // HHMMSS
const date = now.toISOString().split('T')[0];
const time = now.toTimeString().split(' ')[0].replace(/:/g, '');
const filename = `SeedPGP_${date}_${time}.png`;
// Create download link
const link = document.createElement('a');
link.href = dataUrl;
link.download = filename;
@@ -47,25 +93,27 @@ export const QrDisplay: React.FC<QrDisplayProps> = ({ value }) => {
if (!dataUrl) return null;
return (
<div className="flex flex-col items-center gap-4">
<div className="flex items-center justify-center p-4 bg-white rounded-xl border-2 border-slate-200">
<img
src={dataUrl}
alt="SeedPGP QR Code"
className="w-80 h-80"
/>
<div className="space-y-4">
<div className="bg-white p-6 rounded-lg inline-block shadow-lg">
<img src={dataUrl} alt="QR Code" className="w-full h-auto" />
</div>
{debugInfo && (
<div className="text-xs text-slate-500 font-mono">
{debugInfo}
</div>
)}
<button
onClick={handleDownload}
className="inline-flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-lg font-semibold hover:from-green-700 hover:to-green-800 transition-all shadow-lg hover:shadow-xl"
className="flex items-center gap-2 px-4 py-2 bg-teal-600 hover:bg-teal-700 text-white rounded-lg transition-colors"
>
<Download size={18} />
<Download size={16} />
Download QR Code
</button>
<p className="text-xs text-slate-500 text-center max-w-sm">
Downloads as: SeedPGP_2026-01-28_231645.png
<p className="text-xs text-slate-500">
Downloads as: SeedPGP_{new Date().toISOString().split('T')[0]}_HHMMSS.png
</p>
</div>
);

View File

@@ -4,11 +4,12 @@
* @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, useCallback } 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,
@@ -36,7 +37,7 @@ interface MnemonicEntry {
rawInput: string;
decryptedMnemonic: string | null;
isEncrypted: boolean;
inputType: 'text' | 'seedpgp' | 'krux';
inputType: 'text' | 'seedpgp' | 'krux' | 'seedqr';
passwordRequired: boolean;
passwordInput: string;
error: string | null;
@@ -157,17 +158,68 @@ export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestT
setShowQRScanner(true);
};
const handleScanSuccess = (scannedText: string) => {
const handleScanSuccess = useCallback(async (scannedData: string | Uint8Array) => {
if (scanTargetIndex === null) return;
// Convert binary data to hex string if necessary
const scannedText = typeof scannedData === 'string'
? scannedData
: Array.from(scannedData).map(b => b.toString(16).padStart(2, '0')).join('');
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,
});
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;
@@ -178,7 +230,7 @@ export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestT
} else { // seedpgp
mnemonic = (await decryptFromSeed({ frameText: entry.rawInput, messagePassword: entry.passwordInput, mode: 'pgp' })).w;
}
updateEntry(index, { decryptedMnemonic: mnemonic, isEncrypted: false, passwordRequired: false, error: null });
updateEntry(index, { rawInput: mnemonic, decryptedMnemonic: mnemonic, isEncrypted: false, passwordRequired: false, error: null });
} catch(e: any) {
updateEntry(index, { error: e.message || "Decryption failed" });
}
@@ -251,7 +303,7 @@ export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestT
<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 && 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>)}
{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 data-sensitive="Blended Mnemonic (12-word)" 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 data-sensitive="Blended Mnemonic (24-word)" 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>
<div className="p-6 bg-slate-700/50 rounded-xl border border-slate-600">
@@ -260,7 +312,7 @@ export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestT
<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" />
{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>)}
{diceOnlyMnemonic && (<div className="space-y-1 pt-2"><label className="text-xs font-semibold text-slate-400">Dice-Only Preview Mnemonic</label><p data-sensitive="Dice-Only Preview Mnemonic" className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words">{diceOnlyMnemonic}</p></div>)}
</div>
</div>
@@ -269,7 +321,7 @@ export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestT
{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>
<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>
<div className="p-6 bg-white rounded-xl border-2 border-green-200 shadow-sm"><p data-sensitive="Final Blended Mnemonic" className="font-mono text-center text-lg text-slate-800 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 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-slate-700 text-white rounded-lg font-semibold flex items-center justify-center gap-2"><QrCode size={16}/> Export as QR</button>
@@ -282,7 +334,11 @@ export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestT
</div>
</div>
{showQRScanner && <QRScanner onScanSuccess={handleScanSuccess} onClose={() => setShowQRScanner(false)} />}
{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-white rounded-2xl p-4" onClick={e => e.stopPropagation()}>