Rhetorical Mirror

See how the same persuasion techniques can argue opposite positions.

code.tsx

import React, { useState } from "react";
import { createRoot } from "react-dom/client";

type Segment = { text: string; deviceId?: string };
type Device = { id: string; name: string; color: string; description: string };
type Panel = { label: string; author: string; segments: Segment[] };

interface MirrorData {
  title: string;
  original: Panel;
  mirrored: Panel;
  devices: Device[];
}

interface TooltipState {
  visible: boolean;
  x: number;
  y: number;
  device: Device | null;
}

// Helper for active/dimmed class names
const stateClasses = (id: string, activeId: string | null) => 
  activeId === id ? 'active' : activeId ? 'dimmed' : '';

function Header() {
  return (
    <>
      <h1>Rhetorical Mirror</h1>
      <p className="app-summary">
        See how the same persuasion techniques can argue opposite positions.
      </p>
    </>
  );
}

function Tooltip({ state }: { state: TooltipState }) {
  if (!state.visible || !state.device) return null;
  const { color } = state.device;

  return (
    <div 
      className="device-tooltip"
      style={{ left: state.x, top: state.y, borderColor: color, '--tooltip-color': color } as React.CSSProperties}
    >
      <div className="tooltip-header">
        <span className="tooltip-dot" style={{ backgroundColor: color }} />
        <span className="tooltip-name">{state.device.name}</span>
      </div>
      <div className="tooltip-desc">{state.device.description}</div>
    </div>
  );
}

function HighlightedText({ 
  segments, 
  devices,
  activeDevice,
  onHover,
  onTooltip
}: { 
  segments: Segment[]; 
  devices: Device[];
  activeDevice: string | null;
  onHover: (id: string | null) => void;
  onTooltip: (e: React.MouseEvent, device: Device | null) => void;
}) {
  const deviceMap = new Map(devices.map(d => [d.id, d]));

  return (
    <div className="highlighted-text">
      {segments.map((seg, i) => {
        const device = seg.deviceId ? deviceMap.get(seg.deviceId) : null;
        if (!device) return <span key={i}>{seg.text}</span>;

        const { color } = device;
        return (
          <span
            key={i}
            className={`highlight ${stateClasses(seg.deviceId!, activeDevice)}`}
            style={{ 
              backgroundColor: `${color}30`,
              borderBottom: `3px solid ${color}`,
              '--highlight-color': color 
            } as React.CSSProperties}
            onMouseEnter={(e) => { onHover(seg.deviceId!); onTooltip(e, device); }}
            onMouseMove={(e) => onTooltip(e, device)}
            onMouseLeave={(e) => { onHover(null); onTooltip(e, null); }}
          >
            {seg.text}
          </span>
        );
      })}
    </div>
  );
}

function TextPanel({ 
  panel, devices, activeDevice, onHover, onTooltip 
}: { 
  panel: Panel;
  devices: Device[];
  activeDevice: string | null;
  onHover: (id: string | null) => void;
  onTooltip: (e: React.MouseEvent, device: Device | null) => void;
}) {
  return (
    <div className="text-panel">
      <div className="panel-header">
        <span className="panel-label">{panel.label}</span>
        <span className="panel-author">{panel.author}</span>
      </div>
      <HighlightedText 
        segments={panel.segments} 
        devices={devices}
        activeDevice={activeDevice}
        onHover={onHover}
        onTooltip={onTooltip}
      />
    </div>
  );
}

function DeviceLegend({ devices, activeDevice, onHover }: { 
  devices: Device[];
  activeDevice: string | null;
  onHover: (id: string | null) => void;
}) {
  return (
    <div className="device-legend">
      {devices.map(({ id, color, name, description }) => (
        <div 
          key={id}
          className={`device-tag ${stateClasses(id, activeDevice)}`}
          style={{ borderColor: color, backgroundColor: activeDevice === id ? `${color}20` : undefined }}
          onMouseEnter={() => onHover(id)}
          onMouseLeave={() => onHover(null)}
        >
          <span className="device-dot" style={{ backgroundColor: color }} />
          <span className="device-name">{name}</span>
          <span className="device-desc">{description}</span>
        </div>
      ))}
    </div>
  );
}

function App() {
  const [data, setData] = useState<MirrorData | undefined>(tb.insight<MirrorData>());
  const [isAnalyzing, setIsAnalyzing] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [activeDevice, setActiveDevice] = useState<string | null>(null);
  const [tooltip, setTooltip] = useState<TooltipState>({ visible: false, x: 0, y: 0, device: null });

  const handleAnalyze = async () => {
    setIsAnalyzing(true);
    setError(null);
    try {
      const result = await tb.infer<MirrorData>();
      if (result) setData(result);
    } catch (err: any) {
      setError(err.message || 'Analysis failed');
    } finally {
      setIsAnalyzing(false);
    }
  };

  const handleTooltip = (e: React.MouseEvent, device: Device | null) => {
    if (!device) return setTooltip(prev => ({ ...prev, visible: false }));

    const { clientX, clientY } = e;
    const vw = window.visualViewport?.width ?? window.innerWidth;
    const vh = window.visualViewport?.height ?? window.innerHeight;
    const [w, h, pad, margin] = [280, 80, 12, 8];

    let x = clientX + pad, y = clientY + pad;
    if (x + w > vw - margin) x = clientX - w - pad;
    if (y + h > vh - margin) y = clientY - h - pad;

    setTooltip({ visible: true, x: Math.max(margin, Math.min(x, vw - w - margin)), y: Math.max(margin, Math.min(y, vh - h - margin)), device });
  };

  const sharedProps = { devices: data?.devices || [], activeDevice, onHover: setActiveDevice, onTooltip: handleTooltip };

  if (!data) {
    return (
      <div className="container" style={{ textAlign: 'center', paddingTop: '10vh' }}>
        <div className="mirror-icon" style={{ fontSize: '4rem', marginBottom: '1rem' }}>
          <span className="emoji">🪞</span>
        </div>
        <Header />
        {isAnalyzing && <p className="subtitle">Mapping persuasive techniques and generating the counter-mirror.</p>}
        {error && <p className="error-message">{error}</p>}
        {!isAnalyzing && (
          <button className="analyze-btn" onClick={handleAnalyze}>
            <span className="emoji">✨</span> Analyze with AI
          </button>
        )}
      </div>
    );
  }

  return (
    <div className="container">
      <Tooltip state={tooltip} />

      <header>
        <Header />
        <h2 className="dynamic-title">{data.title}</h2>
        <p className="subtitle">Original Statement — {data.original.author}</p>
      </header>

      <div className="action-bar">
        <button className="analyze-btn" onClick={handleAnalyze} disabled={isAnalyzing}>
          {isAnalyzing ? 'Analyzing...' : '🔄 Analyze New Quote'}
        </button>
        {error && <p className="error-message">{error}</p>}
      </div>

      <section className="mirror-section">
        <div className="mirror-grid">
          <TextPanel panel={data.original} {...sharedProps} />
          <div className="mirror-divider">
            <span className="mirror-icon"><span className="emoji">🪞</span></span>
          </div>
          <TextPanel panel={data.mirrored} {...sharedProps} />
        </div>
      </section>

      <section className="legend-section">
        <h2>🏷️ Rhetorical Devices</h2>
        <p className="legend-intro">Hover over any device to highlight it in both texts above</p>
        <DeviceLegend devices={data.devices} activeDevice={activeDevice} onHover={setActiveDevice} />
      </section>

      <section className="intro-section">
        <h2>About the Mirror</h2>
        <p>
          Skilled arguers use tricks: loaded words, implied threats, us-vs-them. 
          This tool finds them, then shows what they look like arguing for the opposing POV.
        </p>
      </section>
    </div>
  );
}

createRoot(document.getElementById("root")!).render(<App />);

Markdown source · More bulbs by samples · Typebulb home