# Privacy

2 on-device blocks in the Privacy category — each installs and runs on its own, entirely in the browser.

## PII Redactor

Find and hide personal details in text, such as names, emails, phone numbers, and card numbers. It detects sensitive info, replaces it with labeled placeholders you can toggle by type, and lets you copy or export the cleaned text. Everything runs in your browser, and models load only when you press Scan.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/privacy/pii-redactor
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/privacy/pii-redactor.json

```tsx
'use client';

/**
 * @file pii-redactor.tsx
 * @description PII Redactor block (`/blocks/privacy/pii-redactor`) — on-device NER PII detection + typed-placeholder redaction + a differential-privacy demonstration.
 */
import { useRef, useState } from 'react';
import { Copy, Download, FileText, Loader2, ScanLine, Check } from 'lucide-react';

import {
  createPrivacyBudget,
  dpEmbeddingMiddleware,
  wrapEmbeddingModel,
  embed,
  redactPII,
  type PrivacyBudget,
} from '@localmode/core';
import { transformers } from '@localmode/transformers';
import { useExtractEntities } from '@localmode/react';

import { RedactedTextDisplay } from '@/components/redacted-text-display';
import { EntityStatsBar } from '@/components/entity-stats-bar';
import {
  EntityRelationshipGraph,
  type NodeTypeRegistry,
} from '@/components/entity-relationship-graph';
import {
  DifferentialPrivacyControls,
  DpAppliedBadge,
  type PrivacyBudgetState,
} from '@/components/differential-privacy-controls';
import { cn } from '@/lib/utils';

interface DetectedEntity {
  text: string;
  type: string;
  start: number;
  end: number;
  score: number;
}

const CONFIDENCE_THRESHOLD = 0.5;

function mapEntities(
  entities: { text: string; type: string; start: number; end: number; score: number }[],
): DetectedEntity[] {
  return entities
    .map((e) => ({
      text: e.text,
      type: e.type.replace(/^[BI]-/, ''),
      start: e.start,
      end: e.end,
      score: e.score,
    }))
    .filter((e) => e.score >= CONFIDENCE_THRESHOLD && e.end > e.start);
}

function redactText(
  text: string,
  entities: DetectedEntity[],
  includedTypes: ReadonlySet<string>,
): string {
  const sorted = entities
    .filter((e) => includedTypes.has(e.type))
    .sort((a, b) => b.start - a.start);
  let redacted = text;
  for (const entity of sorted) {
    redacted = redacted.slice(0, entity.start) + `[${entity.type}]` + redacted.slice(entity.end);
  }
  return redactPII(redacted, { emails: true, phones: true, ssn: true, creditCards: true });
}

function countByType(entities: DetectedEntity[]): Record<string, number> {
  const counts: Record<string, number> = {};
  for (const e of entities) counts[e.type] = (counts[e.type] ?? 0) + 1;
  return counts;
}

const NODE_REGISTRY: NodeTypeRegistry = {
  PER: { color: 'var(--color-sky-500, #0ea5e9)' },
  LOC: { color: 'var(--color-emerald-500, #10b981)' },
  ORG: { color: 'var(--color-violet-500, #8b5cf6)' },
  MISC: { color: 'var(--color-amber-500, #f59e0b)' },
};

function buildEntityGraph(entities: DetectedEntity[]) {
  const nodeMap = new Map<string, { id: string; label: string; type: string; weight: number }>();
  for (const e of entities) {
    const id = `${e.type}:${e.text.toLowerCase()}`;
    const existing = nodeMap.get(id);
    if (existing) existing.weight += 1;
    else nodeMap.set(id, { id, label: e.text, type: e.type, weight: 1 });
  }

  const ordered = [...entities].sort((a, b) => a.start - b.start);
  const edgeSet = new Set<string>();
  const edges: { source: string; target: string }[] = [];
  for (let i = 0; i < ordered.length - 1; i++) {
    const a = `${ordered[i].type}:${ordered[i].text.toLowerCase()}`;
    const b = `${ordered[i + 1].type}:${ordered[i + 1].text.toLowerCase()}`;
    if (a === b) continue;
    const key = a < b ? `${a}|${b}` : `${b}|${a}`;
    if (edgeSet.has(key)) continue;
    edgeSet.add(key);
    edges.push({ source: a, target: b });
  }
  return { nodes: [...nodeMap.values()], edges };
}

function embeddingSignature(vec: Float32Array | number[]): string {
  let sum = 0;
  for (const v of vec) sum += v;
  return sum.toFixed(6);
}

const NER_MODEL_ID = 'Xenova/bert-base-NER';
const EMBEDDING_MODEL_ID = 'Xenova/all-MiniLM-L6-v2';

const MIN_EPSILON = 0.5;
const MAX_EPSILON = 10.0;
const EPSILON_STEP = 0.5;
const DEFAULT_EPSILON = 1.0;
const MAX_BUDGET_EPSILON = 10.0;

const SAMPLE_DOCUMENT =
  'John Smith met with Dr. Sarah Johnson at the Microsoft headquarters in Seattle, Washington on March 15th. They discussed the upcoming partnership with Google and the European Union regulations. Contact John at john.smith@example.com or call +1-555-0123. His social security number is 123-45-6789.';

let nerSingleton: ReturnType<typeof transformers.ner> | null = null;
function getNERModel() {
  if (!nerSingleton) nerSingleton = transformers.ner(NER_MODEL_ID);
  return nerSingleton;
}

let embeddingModel: ReturnType<typeof transformers.embedding> | null = null;
function getEmbeddingModel() {
  if (!embeddingModel) embeddingModel = transformers.embedding(EMBEDDING_MODEL_ID);
  return embeddingModel;
}

type Phase = 'idle' | 'scanning' | 'ready' | 'error';

interface DpResult {
  epsilon: number;
  dims: number;
  plainSig: string;
  noisySig: string;
}

const ENTITY_LABELS: Record<string, string> = {
  PER: 'Person',
  LOC: 'Location',
  ORG: 'Organization',
  MISC: 'Misc',
};

export function PiiRedactorBlock() {
  const nerModel = useState(() => getNERModel())[0];
  const { execute, cancel } = useExtractEntities({ model: nerModel });

  const [input, setInput] = useState('');
  const [phase, setPhase] = useState<Phase>('idle');
  const [error, setError] = useState<string | null>(null);

  const [scannedText, setScannedText] = useState('');
  const [entities, setEntities] = useState<DetectedEntity[]>([]);
  const [includedTypes, setIncludedTypes] = useState<ReadonlySet<string>>(() => new Set());
  const [copied, setCopied] = useState(false);

  const [dpEnabled, setDpEnabled] = useState(false);
  const [epsilon, setEpsilon] = useState(DEFAULT_EPSILON);
  const [dpResult, setDpResult] = useState<DpResult | null>(null);
  const [budgetState, setBudgetState] = useState<PrivacyBudgetState | null>(null);
  const budgetRef = useRef<PrivacyBudget | null>(null);

  const hasResult = phase === 'ready' && scannedText.length > 0;
  const detectedTypes = Object.keys(countByType(entities));
  const redacted = hasResult ? redactText(scannedText, entities, includedTypes) : '';
  const graph = hasResult ? buildEntityGraph(entities) : { nodes: [], edges: [] };

  const loadSample = () => setInput(SAMPLE_DOCUMENT);

  const readBudget = (budget: PrivacyBudget): PrivacyBudgetState => ({
    consumed: budget.consumed(),
    maxEpsilon: MAX_BUDGET_EPSILON,
  });

  const runDpDemo = async (redactedText: string) => {
    if (!budgetRef.current) {
      budgetRef.current = await createPrivacyBudget({
        maxEpsilon: MAX_BUDGET_EPSILON,
        onExhausted: 'warn',
        persistKey: 'privacy-vault-dp-budget',
      });
    }
    const budget = budgetRef.current;
    const baseModel = getEmbeddingModel();
    const plain = await embed({ model: baseModel, value: redactedText });
    const wrapped = wrapEmbeddingModel({
      model: baseModel,
      middleware: dpEmbeddingMiddleware({ epsilon, mechanism: 'gaussian' }, budget),
    });
    const noisy = await embed({ model: wrapped, value: redactedText });
    setDpResult({
      epsilon,
      dims: noisy.embedding.length,
      plainSig: embeddingSignature(plain.embedding),
      noisySig: embeddingSignature(noisy.embedding),
    });
    setBudgetState(readBudget(budget));
  };

  const scan = async () => {
    if (!input.trim() || phase === 'scanning') return;
    setPhase('scanning');
    setError(null);
    setDpResult(null);
    try {
      const data = await execute(input);
      if (!data) {
        setPhase((p) => (p === 'scanning' ? 'idle' : p));
        return;
      }
      const mapped = mapEntities(data.entities);
      setScannedText(input);
      setEntities(mapped);
      setIncludedTypes(new Set(mapped.map((e) => e.type)));
      setPhase('ready');
      if (dpEnabled) {
        const redactedText = redactText(input, mapped, new Set(mapped.map((e) => e.type)));
        await runDpDemo(redactedText);
      }
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Scan failed');
      setPhase('error');
    }
  };

  const toggleType = (type: string) => {
    setIncludedTypes((prev) => {
      const next = new Set(prev);
      if (next.has(type)) next.delete(type);
      else next.add(type);
      return next;
    });
  };

  const copyRedacted = async () => {
    try {
      await navigator.clipboard.writeText(redacted);
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    } catch {
    }
  };

  const exportClean = () => {
    const blob = new Blob([redacted], { type: 'text/plain' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'redacted.txt';
    document.body.appendChild(a);
    a.click();
    a.remove();
    URL.revokeObjectURL(url);
  };

  const modelStatus = phase === 'scanning' ? 'loading' : phase === 'ready' ? 'ready' : phase;

  return (
    <div className="flex flex-col gap-4 p-4">
      <p className="text-xs text-muted-foreground">
        Detect and redact PII on-device with bert-base-NER, then demonstrate differential privacy.
      </p>
      <div className="flex flex-col gap-2">
        <div className="flex items-center justify-between gap-2">
          <label htmlFor="redact-input" className="text-sm font-medium">
            Document to scan
          </label>
          <button
            type="button"
            onClick={loadSample}
            className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1 text-xs font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            <FileText className="size-3.5" aria-hidden="true" /> Load sample
          </button>
        </div>
        <textarea
          id="redact-input"
          value={input}
          disabled={phase === 'scanning'}
          onChange={(e) => setInput(e.target.value)}
          rows={5}
          placeholder="Paste text containing names, organizations, locations, emails, phone numbers, or SSNs…"
          className="w-full resize-y rounded-lg border border-input bg-background p-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50"
        />
        <div className="flex flex-wrap items-center gap-2">
          <button
            type="button"
            onClick={scan}
            disabled={!input.trim() || phase === 'scanning'}
            className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
          >
            {phase === 'scanning' ? (
              <Loader2 className="size-4 animate-spin" aria-hidden="true" />
            ) : (
              <ScanLine className="size-4" aria-hidden="true" />
            )}
            {phase === 'scanning' ? 'Scanning…' : 'Scan for PII'}
          </button>
          {phase === 'scanning' && (
            <button
              type="button"
              onClick={cancel}
              className="rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              Cancel
            </button>
          )}
          <span
            role="status"
            data-status={modelStatus}
            className="basis-full text-xs text-muted-foreground sm:basis-auto"
          >
            {phase === 'idle' && 'Model loads on Scan (bert-base-NER ~110 MB).'}
            {phase === 'scanning' && 'Running on-device NER…'}
            {phase === 'ready' && `${entities.length} entities detected.`}
            {phase === 'error' && 'Scan failed.'}
          </span>
        </div>
        {error && (
          <p
            role="alert"
            className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive"
          >
            {error}
          </p>
        )}
      </div>

      {(phase === 'scanning' || hasResult) && (
        <>
          {hasResult && (
            <>
              {}
              <ul aria-label="Detected PII entities" className="sr-only">
                {entities.map((e, i) => (
                  <li key={i} data-entity-text={e.text} data-entity-type={e.type}>
                    {e.text} ({e.type})
                  </li>
                ))}
              </ul>
              <EntityStatsBar entities={entities} />

              {detectedTypes.length > 0 && (
                <div className="flex flex-wrap items-center gap-2">
                  <span className="text-xs font-medium text-muted-foreground">Redact:</span>
                  {detectedTypes.map((type) => {
                    const on = includedTypes.has(type);
                    return (
                      <button
                        key={type}
                        type="button"
                        data-included={on}
                        aria-pressed={on}
                        onClick={() => toggleType(type)}
                        className={cn(
                          'rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
                          on
                            ? 'border-primary/40 bg-primary/10 text-primary'
                            : 'border-border bg-background text-muted-foreground line-through',
                        )}
                      >
                        {ENTITY_LABELS[type] ?? type}
                      </button>
                    );
                  })}
                </div>
              )}
            </>
          )}

          <div className="flex flex-col gap-2">
            <div className="flex items-center justify-between gap-2">
              <span className="text-sm font-medium">Redacted output</span>
              {hasResult && (
                <div className="flex items-center gap-1.5">
                  <button
                    type="button"
                    onClick={copyRedacted}
                    className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1 text-xs font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                  >
                    {copied ? (
                      <Check className="size-3.5" aria-hidden="true" />
                    ) : (
                      <Copy className="size-3.5" aria-hidden="true" />
                    )}
                    {copied ? 'Copied' : 'Copy'}
                  </button>
                  <button
                    type="button"
                    onClick={exportClean}
                    className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1 text-xs font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                  >
                    <Download className="size-3.5" aria-hidden="true" /> Export Clean
                  </button>
                </div>
              )}
            </div>
            <div role="group" aria-label="Redacted output">
              <RedactedTextDisplay
                text={redacted}
                entities={[]}
                isScanning={phase === 'scanning'}
                emptyState="Scan a document to see the redacted output."
              />
            </div>
          </div>

          {hasResult && graph.nodes.length > 0 && (
            <div className="flex flex-col gap-1">
              <span className="text-sm font-medium">Entity relationships</span>
              <EntityRelationshipGraph
                nodes={graph.nodes}
                edges={graph.edges}
                registry={NODE_REGISTRY}
                height={280}
                showExport={false}
              />
            </div>
          )}
        </>
      )}

      {}
      <div className="flex flex-col gap-2">
        <DifferentialPrivacyControls
          enabled={dpEnabled}
          onEnabledChange={setDpEnabled}
          epsilon={epsilon}
          onEpsilonChange={setEpsilon}
          minEpsilon={MIN_EPSILON}
          maxEpsilon={MAX_EPSILON}
          step={EPSILON_STEP}
          budget={budgetState ?? undefined}
        />
        {budgetState && (
          <span
            data-consumed={budgetState.consumed.toFixed(1)}
            data-max={budgetState.maxEpsilon.toFixed(1)}
            className="text-xs text-muted-foreground"
          >
            Privacy budget: {budgetState.consumed.toFixed(1)} / {budgetState.maxEpsilon.toFixed(1)} ε
            consumed
          </span>
        )}
        {dpResult && (
          <div className="flex flex-col gap-1">
            <DpAppliedBadge epsilon={dpResult.epsilon} dimensions={dpResult.dims} />
            {}
            <span
              aria-label="Differential privacy embedding signatures"
              data-plain-sig={dpResult.plainSig}
              data-noisy-sig={dpResult.noisySig}
              data-dims={dpResult.dims}
              data-epsilon={dpResult.epsilon}
              className="font-mono text-[10px] text-muted-foreground"
            >
              baseline {dpResult.plainSig} · noised {dpResult.noisySig}
            </span>
          </div>
        )}
        {dpEnabled && !dpResult && (
          <p className="text-xs text-muted-foreground">
            Scan with DP enabled to noise the redacted text&apos;s embedding
            (all-MiniLM-L6-v2 ~23&#160;MB).
          </p>
        )}
      </div>
    </div>
  );
}
```

## Encrypted Vault

Keep private notes and documents in a vault locked by a passphrase you choose. Everything is encrypted before it is saved, decrypted only when you view it, stays locked after a reload, and keeps a tamper-evident history you can verify and export. Everything runs in your browser using built-in encryption, and nothing downloads.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/privacy/encrypted-vault
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/privacy/encrypted-vault.json

```tsx
'use client';

/**
 * @file encrypted-vault.tsx
 * @description Encrypted Vault block (`/blocks/privacy/encrypted-vault`) — passphrase-locked AES-GCM item store with a tamper-evident hash-chained audit log.
 */
import { useEffect, useRef, useState } from 'react';
import { FileUp, Lock, Plus, ShieldCheck, Download, ListChecks } from 'lucide-react';

import {
  createAuditLog,
  exportAuditLog,
  type AuditLog,
} from '@localmode/core';
import { useEncryptedVault, useAuditLog } from '@localmode/react';

import { PassphraseGate } from '@/components/passphrase-gate';
import { VaultItemCard } from '@/components/vault-item-card';
import { LockStatusBadge } from '@/components/lock-status-badge';
import { cn } from '@/lib/utils';
import type { StrengthColor } from '@/components/password-strength-bar';

const MIN_PASSPHRASE_LENGTH = 8;

const VAULT_NAME = 'privacy-vault';

interface PassphraseStrength {
  value: number;
  label?: string;
  color: StrengthColor;
}

function estimateStrength(passphrase: string): PassphraseStrength {
  const classes =
    (/[a-z]/.test(passphrase) ? 1 : 0) +
    (/[A-Z]/.test(passphrase) ? 1 : 0) +
    (/[0-9]/.test(passphrase) ? 1 : 0) +
    (/[^a-zA-Z0-9]/.test(passphrase) ? 1 : 0);
  const lengthScore = Math.min(passphrase.length, 16) / 16;
  const value = Math.round(lengthScore * 60 + (classes / 4) * 40);
  return {
    value,
    label:
      passphrase.length === 0 ? undefined : value < 40 ? 'Weak' : value < 70 ? 'Good' : 'Strong',
    color: value < 40 ? 'error' : value < 70 ? 'warning' : 'success',
  };
}

function formatTimestamp(ms: number): string {
  return new Date(ms).toLocaleString();
}

interface VaultData {
  title: string;
  content: string;
  kind: 'note' | 'document';
}

export function EncryptedVaultBlock() {
  const vault = useEncryptedVault<VaultData>({ name: VAULT_NAME });
  const { status, items, error, isBusy, unlock, lock, createItem, deleteItem } = vault;

  const [auditLog, setAuditLog] = useState<AuditLog | null>(null);
  const audit = useAuditLog(auditLog);
  useEffect(() => {
    let live = true;
    let created: AuditLog | null = null;
    createAuditLog({ name: VAULT_NAME })
      .then((log) => {
        if (live) {
          created = log;
          setAuditLog(log);
        } else {
          void log.close();
        }
      })
      .catch(() => {});
    return () => {
      live = false;
      void created?.close();
    };
  }, []);

  const logChainRef = useRef<Promise<unknown>>(Promise.resolve());
  const logEvent = (kind: string, payload: unknown): Promise<unknown> => {
    logChainRef.current = logChainRef.current
      .then(() => (auditLog ? audit.append(kind, payload) : undefined))
      .catch(() => {});
    return logChainRef.current;
  };

  const [strength, setStrength] = useState<PassphraseStrength>({ value: 0, color: 'error' });
  const [gateError, setGateError] = useState<string | undefined>(undefined);
  const [revealedId, setRevealedId] = useState<string | null>(null);
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');
  const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | null>(null);
  const fileRef = useRef<HTMLInputElement>(null);

  const mode: 'create' | 'unlock' = status === 'uninitialized' ? 'create' : 'unlock';
  const lockStatus = status === 'unlocked' ? 'unlocked' : status === 'locked' ? 'locked' : 'no-vault';

  const handleSubmitPassphrase = async (passphrase: string) => {
    setGateError(undefined);
    const wasUninitialized = status === 'uninitialized';
    const ok = await unlock(passphrase);
    if (ok) {
      if (wasUninitialized) logEvent('vault.created', { name: VAULT_NAME });
      logEvent('vault.unlocked', { name: VAULT_NAME });
    } else {
      setGateError('Incorrect passphrase. Please try again.');
      logEvent('vault.unlock_failed', { name: VAULT_NAME });
    }
  };

  const handleLock = () => {
    lock();
    setRevealedId(null);
    logEvent('vault.locked', { name: VAULT_NAME });
  };

  const handleReveal = (id: string, itemTitle: string) => {
    setRevealedId(id);
    logEvent('item.viewed', { itemId: id, title: itemTitle });
  };

  const handleAddNote = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!title.trim() || !content.trim()) return;
    const item = await createItem({ title: title.trim(), content: content.trim(), kind: 'note' });
    if (item) {
      logEvent('item.added', { itemId: item.id, title: item.data.title });
      setTitle('');
      setContent('');
    }
  };

  const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;
    const text = await file.text();
    const item = await createItem({ title: file.name, content: text, kind: 'document' });
    if (item) logEvent('item.added', { itemId: item.id, title: item.data.title });
    if (fileRef.current) fileRef.current.value = '';
  };

  const handleDelete = async (id: string, itemTitle: string) => {
    const ok = await deleteItem(id);
    if (ok) {
      if (revealedId === id) setRevealedId(null);
      logEvent('item.deleted', { itemId: id, title: itemTitle });
    }
    setPendingDelete(null);
  };

  const verifyChainResult = async () => {
    await audit.verify();
  };

  const exportAudit = async () => {
    if (!auditLog) return;
    const chunks: string[] = [];
    for await (const line of exportAuditLog(auditLog)) chunks.push(line);
    const blob = new Blob(chunks, { type: 'application/x-ndjson' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'privacy-vault-audit.jsonl';
    document.body.appendChild(a);
    a.click();
    a.remove();
    URL.revokeObjectURL(url);
  };

  const wrongPassphrase =
    error?.name === 'VaultPassphraseError' ? 'Incorrect passphrase. Please try again.' : gateError;

  return (
    <div className="flex flex-col gap-4 p-4">
      <p className="text-xs text-muted-foreground">
        A passphrase-locked, end-to-end encrypted item store with a hash-chained audit log (Web Crypto only).
      </p>
      <div className="flex items-center justify-between gap-2">
        <span className="text-sm font-medium">Encrypted vault</span>
        <LockStatusBadge status={lockStatus} />
      </div>

      {status !== 'unlocked' ? (
        <PassphraseGate
          className="mx-auto"
          mode={mode}
          isBusy={isBusy}
          minLength={MIN_PASSPHRASE_LENGTH}
          strength={strength}
          error={wrongPassphrase}
          onPassphraseChange={(v) => setStrength(estimateStrength(v))}
          onSubmit={handleSubmitPassphrase}
          title={mode === 'create' ? 'Create your vault' : 'Unlock your vault'}
          description={
            mode === 'create'
              ? 'Choose a passphrase. It derives the encryption key (in memory only) - it is never stored.'
              : 'Enter your passphrase to decrypt your items.'
          }
        />
      ) : (
        <>
          {}
          <form onSubmit={handleAddNote} className="flex flex-col gap-2 rounded-xl border border-border bg-card p-4">
            <label htmlFor="vault-note-title" className="sr-only">
              Note title
            </label>
            <input
              id="vault-note-title"
              value={title}
              onChange={(e) => setTitle(e.target.value)}
              placeholder="Title"
              className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
            />
            <label htmlFor="vault-note-content" className="sr-only">
              Secret content
            </label>
            <textarea
              id="vault-note-content"
              value={content}
              onChange={(e) => setContent(e.target.value)}
              rows={3}
              placeholder="Secret content: encrypted with AES-GCM before it is stored"
              className="w-full resize-y rounded-md border border-input bg-background p-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
            />
            <div className="flex items-center gap-2">
              <button
                type="submit"
                disabled={!title.trim() || !content.trim() || isBusy}
                className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
              >
                <Plus className="size-4" aria-hidden="true" /> Add note
              </button>
              <button
                type="button"
                onClick={() => fileRef.current?.click()}
                className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                <FileUp className="size-4" aria-hidden="true" /> Import .txt
              </button>
              <label htmlFor="vault-import-file" className="sr-only">
                Import a text document
              </label>
              <input
                ref={fileRef}
                id="vault-import-file"
                type="file"
                accept=".txt,text/plain"
                onChange={handleImport}
                className="hidden"
              />
              <button
                type="button"
                onClick={handleLock}
                className="ml-auto inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                <Lock className="size-4" aria-hidden="true" /> Lock
              </button>
            </div>
          </form>

          {}
          <div className="flex flex-col gap-3">
            {items.length === 0 ? (
              <p className="rounded-lg border border-dashed border-border px-4 py-8 text-center text-sm text-muted-foreground">
                No items yet. Add an encrypted note or import a text document.
              </p>
            ) : (
              items.map((item) => (
                <div key={item.id} role="group" aria-label={item.data.title} data-item-id={item.id}>
                  <VaultItemCard
                    title={item.data.title}
                    kind={item.data.kind}
                    createdAt={formatTimestamp(item.createdAt)}
                    locked={false}
                    revealed={revealedId === item.id}
                    content={revealedId === item.id ? item.data.content : undefined}
                    onReveal={() => handleReveal(item.id, item.data.title)}
                    onHide={() => setRevealedId(null)}
                    onDelete={() => setPendingDelete({ id: item.id, title: item.data.title })}
                  />
                  {pendingDelete?.id === item.id && (
                    <div
                      role="alertdialog"
                      aria-label={`Delete ${item.data.title}`}
                      className="mt-2 flex flex-col gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3"
                    >
                      <p className="text-sm text-foreground">
                        Permanently delete <span className="font-medium">{item.data.title}</span>? The
                        encrypted contents are destroyed and cannot be recovered.
                      </p>
                      <div className="flex items-center gap-2">
                        <button
                          type="button"
                          onClick={() => void handleDelete(item.id, item.data.title)}
                          className="inline-flex items-center gap-1.5 rounded-md bg-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2 focus-visible:ring-offset-background"
                        >
                          Delete permanently
                        </button>
                        <button
                          type="button"
                          autoFocus
                          onClick={() => setPendingDelete(null)}
                          className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                        >
                          Cancel
                        </button>
                      </div>
                    </div>
                  )}
                </div>
              ))
            )}
          </div>
        </>
      )}

      {}
      <div className="flex flex-col gap-2 rounded-xl border border-border bg-card p-4">
        <div className="flex flex-wrap items-center justify-between gap-2">
          <h2 className="flex items-center gap-1.5 whitespace-nowrap text-sm font-medium">
            <ListChecks className="size-4 text-muted-foreground" aria-hidden="true" /> Audit log
            <span className="text-muted-foreground">({audit.entries.length})</span>
          </h2>
          <div className="flex items-center gap-1.5">
            <button
              type="button"
              onClick={verifyChainResult}
              disabled={audit.entries.length === 0}
              className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1 text-xs font-medium transition-colors hover:bg-accent disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              <ShieldCheck className="size-3.5" aria-hidden="true" /> Verify chain
            </button>
            <button
              type="button"
              onClick={exportAudit}
              disabled={audit.entries.length === 0}
              className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1 text-xs font-medium transition-colors hover:bg-accent disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              <Download className="size-3.5" aria-hidden="true" /> Export JSONL
            </button>
          </div>
        </div>

        {audit.lastVerification && (
          <p
            role="status"
            data-ok={audit.lastVerification.ok}
            className={cn(
              'text-xs font-medium',
              audit.lastVerification.ok
                ? 'text-emerald-600 dark:text-emerald-400'
                : 'text-red-600 dark:text-red-400',
            )}
          >
            {audit.lastVerification.ok
              ? `Chain valid: ${audit.lastVerification.entriesChecked} entries verified.`
              : `Chain broken at entry ${audit.lastVerification.brokenAt} (${audit.lastVerification.reason}).`}
          </p>
        )}

        <ol aria-label="Audit log entries" className="flex flex-col gap-1 text-xs">
          {audit.entries.length === 0 ? (
            <li className="text-muted-foreground">No audit entries yet.</li>
          ) : (
            audit.entries.map((entry) => (
              <li
                key={entry.id}
                data-audit-kind={entry.kind}
                className="flex items-center gap-2 font-mono"
              >
                <span className="text-muted-foreground">{formatTimestamp(entry.timestamp)}</span>
                <span className="font-medium">{entry.kind}</span>
              </li>
            ))
          )}
        </ol>
      </div>
    </div>
  );
}
```
