[object Object]

← back to Patty

patty: add /pulse — Petition Pulse public visualization (CNCP item 19)

ca02103d8c0192c9433549b2d0f2cf679ecc3cfc · 2026-05-30 12:37:25 -0700 · Steve Abrams

ECG/heartbeat SVG sweep mirroring Grant's Deadline River aesthetic in
Patty's violet palette. Each petition is a pulse spike: x = days since
launch (right = newest), y = signature velocity (signatures/day, taller
= faster growth), color = category (climate / ai_safety / governance /
civil_rights / economic_justice / healthcare), glow intensity = progress
toward signature goal.

Public route — no auth (middleware only matches /api/*). 24 representative
sample petitions baked in so the page demos without backend setup; can be
swapped for a server-side SELECT against the petitions table when wired.
Mirrors Grant's RiverCanvas component split (page → server, Canvas → client
island, Table → static companion). 10-min ISR matching Grant's revalidate.

All three files (page.tsx + PulseCanvas.tsx + PulseTable.tsx) esbuild-parse
clean. Fixed a Fragment-without-key issue (used <Fragment key={cat}> in the
Legend grid instead of <>).

Files touched

Diff

commit ca02103d8c0192c9433549b2d0f2cf679ecc3cfc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat May 30 12:37:25 2026 -0700

    patty: add /pulse — Petition Pulse public visualization (CNCP item 19)
    
    ECG/heartbeat SVG sweep mirroring Grant's Deadline River aesthetic in
    Patty's violet palette. Each petition is a pulse spike: x = days since
    launch (right = newest), y = signature velocity (signatures/day, taller
    = faster growth), color = category (climate / ai_safety / governance /
    civil_rights / economic_justice / healthcare), glow intensity = progress
    toward signature goal.
    
    Public route — no auth (middleware only matches /api/*). 24 representative
    sample petitions baked in so the page demos without backend setup; can be
    swapped for a server-side SELECT against the petitions table when wired.
    Mirrors Grant's RiverCanvas component split (page → server, Canvas → client
    island, Table → static companion). 10-min ISR matching Grant's revalidate.
    
    All three files (page.tsx + PulseCanvas.tsx + PulseTable.tsx) esbuild-parse
    clean. Fixed a Fragment-without-key issue (used <Fragment key={cat}> in the
    Legend grid instead of <>).
---
 app/pulse/PulseCanvas.tsx | 311 ++++++++++++++++++++++++++++++++++++++++++++++
 app/pulse/PulseTable.tsx  | 113 +++++++++++++++++
 app/pulse/page.tsx        | 114 +++++++++++++++++
 3 files changed, 538 insertions(+)

diff --git a/app/pulse/PulseCanvas.tsx b/app/pulse/PulseCanvas.tsx
new file mode 100644
index 0000000..a3134b5
--- /dev/null
+++ b/app/pulse/PulseCanvas.tsx
@@ -0,0 +1,311 @@
+'use client';
+
+/**
+ * PulseCanvas — SVG ECG/heartbeat visualization of petition signature velocity.
+ *
+ * Each petition is a pulse spike on a horizontal sweep:
+ *   x  = days since launch (right = newest, max ~30d)
+ *   y  = signature velocity (top = high signatures/day)
+ *   color = category (climate / ai_safety / governance / etc.)
+ *   height = velocity bar, with a glowing spike at peak
+ *
+ * Mirrors Grant's RiverCanvas structure (pure SVG, hover detail, legend in a
+ * corner, slow tick drives the animation) — different visual metaphor and
+ * different palette so the two visualizations feel like sister tools, not
+ * forks of one template.
+ */
+
+import { Fragment, useState, useMemo, useEffect } from 'react';
+
+export type Petition = {
+  id: string;
+  slug: string;
+  title: string;
+  category: 'climate' | 'ai_safety' | 'governance' | 'civil_rights' | 'economic_justice' | 'healthcare';
+  signatures: number;
+  goal: number;
+  velocity: number;       // signatures/day
+  daysSinceStart: number; // 0 = launched today, 30 = a month ago
+};
+
+// ---------------------------------------------------------------------------
+// Category palette — distinct hues that read well on near-black.
+// ---------------------------------------------------------------------------
+const CATEGORY_COLOR: Record<Petition['category'], string> = {
+  climate:           '#10b981', // emerald
+  ai_safety:         '#a78bfa', // violet-400 (the house color)
+  governance:        '#fbbf24', // amber
+  civil_rights:      '#fb7185', // rose
+  economic_justice:  '#38bdf8', // sky
+  healthcare:        '#ec4899', // pink
+};
+
+const CATEGORY_LABEL: Record<Petition['category'], string> = {
+  climate:           'Climate',
+  ai_safety:         'AI Safety',
+  governance:        'Governance',
+  civil_rights:      'Civil Rights',
+  economic_justice:  'Economic Justice',
+  healthcare:        'Healthcare',
+};
+
+// ---------------------------------------------------------------------------
+// Viewport
+// ---------------------------------------------------------------------------
+const W = 1400;
+const H = 520;
+const MARGIN = { top: 40, right: 60, bottom: 50, left: 80 };
+const WINDOW_DAYS = 30;   // x-axis horizon
+const MAX_VELOCITY = 1000; // y-axis ceiling (signatures/day)
+
+// ---------------------------------------------------------------------------
+// Build a stable ECG-like baseline path so the canvas has a heartbeat
+// background even with no data. The path is regenerated on every tick to
+// give the sweep its "running" feel. Tiny noise — pure decoration.
+// ---------------------------------------------------------------------------
+function ecgBaseline(tick: number): string {
+  const seg = 14; // px between baseline samples
+  const yMid = H - MARGIN.bottom - 8;
+  let d = `M${MARGIN.left} ${yMid}`;
+  for (let x = MARGIN.left; x < W - MARGIN.right; x += seg) {
+    // Mostly flat, with a faint sinusoidal jitter
+    const phase = (x + tick * 2) * 0.018;
+    const y = yMid + Math.sin(phase) * 0.6 + Math.sin(phase * 3.3) * 0.3;
+    d += ` L${x + seg} ${y}`;
+  }
+  return d;
+}
+
+export default function PulseCanvas({ petitions }: { petitions: Petition[] }) {
+  const [hover, setHover] = useState<Petition | null>(null);
+  const [tick, setTick] = useState(0);
+
+  // Slow tick drives the sweep + spike pulsing
+  useEffect(() => {
+    const id = setInterval(() => setTick((t) => t + 1), 90);
+    return () => clearInterval(id);
+  }, []);
+
+  // Lay out each petition as a pulse spike at its x,y coordinate.
+  const spikes = useMemo(() => {
+    return petitions.map((p) => {
+      // X: 0d = right edge (= today), WINDOW_DAYS = left edge.
+      // Newer petitions on the right matches Grant's "approaching now" feel.
+      const xFrac = 1 - Math.min(1, p.daysSinceStart / WINDOW_DAYS);
+      const x = MARGIN.left + xFrac * (W - MARGIN.left - MARGIN.right);
+
+      // Y: high velocity → near top. Clamp into the drawing area.
+      const yFrac = Math.min(1, p.velocity / MAX_VELOCITY);
+      const yPeak = H - MARGIN.bottom - 8 - yFrac * (H - MARGIN.top - MARGIN.bottom - 8);
+
+      // Progress (signatures/goal) controls the spike's "alive" intensity
+      const progress = Math.min(1, p.signatures / Math.max(1, p.goal));
+      const color = CATEGORY_COLOR[p.category];
+      return { p, x, yPeak, progress, color };
+    });
+  }, [petitions]);
+
+  return (
+    <div style={{ position: 'relative', width: '100%', overflow: 'hidden',
+      border: '1px solid #1a1326', borderRadius: 10, background: '#08060e' }}>
+      <svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block' }}>
+        {/* Glow defs — one radial per category so highest-velocity spikes pop */}
+        <defs>
+          <linearGradient id="pulse-bg" x1="0" y1="0" x2="0" y2="1">
+            <stop offset="0" stopColor="#0d0a18" />
+            <stop offset="1" stopColor="#06050a" />
+          </linearGradient>
+          {Object.entries(CATEGORY_COLOR).map(([cat, color]) => (
+            <radialGradient key={cat} id={`glow-${cat}`}>
+              <stop offset="0" stopColor={color} stopOpacity="0.55" />
+              <stop offset="1" stopColor={color} stopOpacity="0" />
+            </radialGradient>
+          ))}
+        </defs>
+        <rect x="0" y="0" width={W} height={H} fill="url(#pulse-bg)" />
+
+        {/* Velocity reference lines */}
+        {[200, 400, 600, 800].map((v) => {
+          const y = H - MARGIN.bottom - 8 - (v / MAX_VELOCITY) * (H - MARGIN.top - MARGIN.bottom - 8);
+          return (
+            <g key={v}>
+              <line x1={MARGIN.left} y1={y} x2={W - MARGIN.right} y2={y}
+                stroke="#1a1326" strokeDasharray="2,6" strokeWidth="0.5" />
+              <text x={MARGIN.left - 10} y={y + 3} textAnchor="end"
+                fill="#5a4c70" fontSize="10" fontFamily="ui-monospace, monospace">
+                {v}/d
+              </text>
+            </g>
+          );
+        })}
+
+        {/* Time axis ticks */}
+        {[30, 21, 14, 7, 0].map((d) => {
+          const xFrac = 1 - d / WINDOW_DAYS;
+          const x = MARGIN.left + xFrac * (W - MARGIN.left - MARGIN.right);
+          return (
+            <g key={d}>
+              <line x1={x} y1={MARGIN.top} x2={x} y2={H - MARGIN.bottom}
+                stroke="#1a1326" strokeDasharray="2,6" strokeWidth="0.5" />
+              <text x={x} y={H - MARGIN.bottom + 22} textAnchor="middle"
+                fill="#5a4c70" fontSize="10" fontFamily="ui-monospace, monospace">
+                {d === 0 ? 'today' : `${d}d ago`}
+              </text>
+            </g>
+          );
+        })}
+
+        {/* ECG baseline — animated */}
+        <path d={ecgBaseline(tick)} stroke="#3b2a52" strokeWidth="0.8"
+          fill="none" opacity="0.7" />
+
+        {/* Active baseline highlight (rightmost ~20% = "now") */}
+        <rect x={MARGIN.left + 0.8 * (W - MARGIN.left - MARGIN.right)}
+          y={MARGIN.top} width={0.2 * (W - MARGIN.left - MARGIN.right)}
+          height={H - MARGIN.top - MARGIN.bottom}
+          fill="#a78bfa" opacity="0.04" />
+
+        {/* Pulse spikes */}
+        {spikes.map(({ p, x, yPeak, progress, color }) => {
+          // Each spike is a P-Q-R-S-T mini-shape (an ECG beat). The R-peak is
+          // tallest; surrounding shoulders give it the heartbeat-look. Spike
+          // amplitude scales with velocity already (yPeak).
+          const baselineY = H - MARGIN.bottom - 8;
+          const peakH = baselineY - yPeak;
+          // Mini-beat: pre-shoulder dip, then R-peak, then post-shoulder dip
+          const preX = x - 18;
+          const postX = x + 18;
+          const dipY = baselineY + 4;
+          const beatPath =
+            `M${preX} ${baselineY} L${x - 9} ${baselineY - peakH * 0.12} ` +
+            `L${x - 4} ${dipY} L${x} ${yPeak} L${x + 4} ${dipY} ` +
+            `L${x + 9} ${baselineY - peakH * 0.18} L${postX} ${baselineY}`;
+
+          // Pulse the glow softly on tick
+          const pulse = 0.7 + Math.sin((tick + p.id.length * 11) / 8) * 0.25;
+          const isHover = hover?.id === p.id;
+          const glowR = 18 + peakH * 0.18 + (isHover ? 14 : 0);
+
+          return (
+            <g
+              key={p.id}
+              onMouseEnter={() => setHover(p)}
+              onMouseLeave={() => setHover(null)}
+              onClick={() => window.open(`/petitions/${p.slug}`, '_blank')}
+              style={{ cursor: 'pointer' }}
+            >
+              {/* Glow at the peak — intensity = progress toward goal */}
+              <circle cx={x} cy={yPeak} r={glowR}
+                fill={`url(#glow-${p.category})`}
+                opacity={pulse * (0.5 + progress * 0.5)} />
+
+              {/* The beat shape */}
+              <path d={beatPath} stroke={color} strokeWidth={isHover ? 2.2 : 1.4}
+                fill="none" strokeLinejoin="round" opacity={0.9} />
+
+              {/* Velocity reading above the peak — only show if there's room */}
+              {peakH > 30 && (
+                <text x={x} y={yPeak - 10} textAnchor="middle"
+                  fill={color} fontSize="10" fontFamily="ui-monospace, monospace"
+                  opacity={isHover ? 1 : 0.7}>
+                  {p.velocity}
+                </text>
+              )}
+
+              {/* Hover ring */}
+              {isHover && (
+                <circle cx={x} cy={yPeak} r={glowR - 4} fill="none"
+                  stroke={color} strokeWidth="1" strokeOpacity="0.7"
+                  strokeDasharray="3,3" />
+              )}
+            </g>
+          );
+        })}
+
+        {/* "NOW" tick — pinned to the right edge */}
+        <line x1={W - MARGIN.right} y1={MARGIN.top}
+          x2={W - MARGIN.right} y2={H - MARGIN.bottom}
+          stroke="#a78bfa" strokeWidth="0.8" opacity="0.5" />
+        <text x={W - MARGIN.right + 4} y={MARGIN.top + 12}
+          fill="#a78bfa" fontSize="10" fontFamily="ui-monospace, monospace"
+          opacity="0.7">
+          NOW
+        </text>
+      </svg>
+
+      {/* Hover detail card — top-left, mirrors Grant's pattern */}
+      {hover && (
+        <div style={{
+          position: 'absolute', top: 16, left: 16, maxWidth: 460,
+          background: '#0d0a18',
+          border: `1px solid ${CATEGORY_COLOR[hover.category]}`,
+          borderRadius: 8, padding: '14px 16px', pointerEvents: 'none',
+          boxShadow: '0 4px 24px rgba(0,0,0,0.5)',
+        }}>
+          <div style={{ fontSize: 13, fontWeight: 600, color: '#e4d8f1', marginBottom: 6, lineHeight: 1.35 }}>
+            {hover.title}
+          </div>
+          <div style={{ fontSize: 11, color: '#7a6c8a', display: 'flex', gap: 14, flexWrap: 'wrap' }}>
+            <span style={{ color: CATEGORY_COLOR[hover.category] }}>
+              {CATEGORY_LABEL[hover.category]}
+            </span>
+            <span>· launched {hover.daysSinceStart}d ago</span>
+            <span>· velocity <strong style={{ color: '#e4d8f1' }}>{hover.velocity}/day</strong></span>
+          </div>
+          <div style={{ marginTop: 10, fontSize: 11, color: '#7a6c8a' }}>
+            <strong style={{ color: '#e4d8f1' }}>
+              {hover.signatures.toLocaleString()}
+            </strong>
+            {' '}of {hover.goal.toLocaleString()} signatures
+            {' · '}
+            <strong style={{ color: CATEGORY_COLOR[hover.category] }}>
+              {Math.round(100 * hover.signatures / Math.max(1, hover.goal))}%
+            </strong>
+          </div>
+          {/* progress bar */}
+          <div style={{ marginTop: 8, height: 4, background: '#1a1326', borderRadius: 2, overflow: 'hidden' }}>
+            <div style={{
+              width: `${Math.min(100, 100 * hover.signatures / Math.max(1, hover.goal))}%`,
+              height: '100%', background: CATEGORY_COLOR[hover.category], opacity: 0.85,
+            }} />
+          </div>
+          <div style={{ fontSize: 10, color: CATEGORY_COLOR[hover.category], marginTop: 10 }}>
+            click spike to open petition
+          </div>
+        </div>
+      )}
+
+      {/* Legend — bottom-right, mirrors Grant's pattern */}
+      {!hover && (
+        <div style={{
+          position: 'absolute', bottom: 12, right: 12, padding: '12px 14px',
+          background: 'rgba(13,10,24,0.92)', border: '1px solid #1a1326',
+          borderRadius: 6, fontSize: 10, color: '#7a6c8a', maxWidth: 220,
+          lineHeight: 1.55,
+        }}>
+          <div style={{
+            color: '#e4d8f1', marginBottom: 6, fontWeight: 600,
+            letterSpacing: '0.08em', textTransform: 'uppercase', fontSize: 9,
+          }}>
+            Legend
+          </div>
+          <div style={{ display: 'grid', gridTemplateColumns: '14px 1fr', gap: '5px 8px', alignItems: 'center' }}>
+            {(Object.keys(CATEGORY_COLOR) as Array<Petition['category']>).map((cat) => (
+              <Fragment key={cat}>
+                <span style={{
+                  width: 12, height: 5, background: CATEGORY_COLOR[cat], borderRadius: 3,
+                }} />
+                <span>{CATEGORY_LABEL[cat]}</span>
+              </Fragment>
+            ))}
+          </div>
+          <div style={{ marginTop: 8, paddingTop: 8, borderTop: '1px solid #1a1326' }}>
+            height → signatures/day<br />
+            glow → progress toward goal<br />
+            rightmost → most recent
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
diff --git a/app/pulse/PulseTable.tsx b/app/pulse/PulseTable.tsx
new file mode 100644
index 0000000..481393c
--- /dev/null
+++ b/app/pulse/PulseTable.tsx
@@ -0,0 +1,113 @@
+/**
+ * PulseTable — server-component sortable list of the highest-velocity
+ * petitions, paired with PulseCanvas. Static table (no interactive sort) so
+ * the page stays light and the canvas component is the only client island.
+ *
+ * Mirrors Grant's RiverTable role — the visualization is the hero, the table
+ * is the "give me the actual numbers" companion.
+ */
+
+import type { Petition } from './PulseCanvas';
+
+const CATEGORY_COLOR: Record<Petition['category'], string> = {
+  climate:           '#10b981',
+  ai_safety:         '#a78bfa',
+  governance:        '#fbbf24',
+  civil_rights:      '#fb7185',
+  economic_justice:  '#38bdf8',
+  healthcare:        '#ec4899',
+};
+
+const CATEGORY_LABEL: Record<Petition['category'], string> = {
+  climate:           'Climate',
+  ai_safety:         'AI Safety',
+  governance:        'Governance',
+  civil_rights:      'Civil Rights',
+  economic_justice:  'Economic Justice',
+  healthcare:        'Healthcare',
+};
+
+export default function PulseTable({ petitions }: { petitions: Petition[] }) {
+  // Sort by velocity descending, top 10 shown.
+  const top = [...petitions].sort((a, b) => b.velocity - a.velocity).slice(0, 10);
+
+  return (
+    <div style={{
+      border: '1px solid #1a1326', borderRadius: 8, overflow: 'hidden',
+      background: '#0d0a18',
+    }}>
+      <table style={{
+        width: '100%', borderCollapse: 'collapse', fontSize: 12, color: '#c8b8d8',
+      }}>
+        <thead>
+          <tr style={{ background: '#100b1f', color: '#7a6c8a',
+            fontSize: 10, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
+            <th style={{ textAlign: 'left', padding: '10px 16px', fontWeight: 500 }}>Petition</th>
+            <th style={{ textAlign: 'left', padding: '10px 16px', fontWeight: 500 }}>Category</th>
+            <th style={{ textAlign: 'right', padding: '10px 16px', fontWeight: 500 }}>Velocity</th>
+            <th style={{ textAlign: 'right', padding: '10px 16px', fontWeight: 500 }}>Signatures</th>
+            <th style={{ textAlign: 'right', padding: '10px 16px', fontWeight: 500 }}>Progress</th>
+            <th style={{ textAlign: 'right', padding: '10px 16px', fontWeight: 500 }}>Age</th>
+          </tr>
+        </thead>
+        <tbody>
+          {top.map((p, i) => {
+            const pct = Math.round(100 * p.signatures / Math.max(1, p.goal));
+            const color = CATEGORY_COLOR[p.category];
+            return (
+              <tr key={p.id} style={{
+                borderTop: i === 0 ? 'none' : '1px solid #1a1326',
+              }}>
+                <td style={{ padding: '12px 16px', lineHeight: 1.4 }}>
+                  <a href={`/petitions/${p.slug}`}
+                     style={{ color: '#e4d8f1', textDecoration: 'none' }}>
+                    {p.title}
+                  </a>
+                </td>
+                <td style={{ padding: '12px 16px' }}>
+                  <span style={{
+                    fontSize: 10, padding: '2px 8px', borderRadius: 99,
+                    background: `${color}1a`, color, letterSpacing: '0.03em',
+                  }}>
+                    {CATEGORY_LABEL[p.category]}
+                  </span>
+                </td>
+                <td style={{ padding: '12px 16px', textAlign: 'right',
+                  fontVariantNumeric: 'tabular-nums', color, fontWeight: 500 }}>
+                  {p.velocity.toLocaleString()}/d
+                </td>
+                <td style={{ padding: '12px 16px', textAlign: 'right',
+                  fontVariantNumeric: 'tabular-nums' }}>
+                  {p.signatures.toLocaleString()}
+                  <span style={{ color: '#5a4c70', fontSize: 11 }}> / {p.goal.toLocaleString()}</span>
+                </td>
+                <td style={{ padding: '12px 16px', textAlign: 'right',
+                  fontVariantNumeric: 'tabular-nums' }}>
+                  <div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
+                    <div style={{
+                      width: 60, height: 4, background: '#1a1326',
+                      borderRadius: 2, overflow: 'hidden',
+                    }}>
+                      <div style={{
+                        width: `${Math.min(100, pct)}%`, height: '100%',
+                        background: color, opacity: 0.85,
+                      }} />
+                    </div>
+                    <span style={{ color: pct >= 75 ? color : '#c8b8d8',
+                      minWidth: 32, textAlign: 'right' }}>
+                      {pct}%
+                    </span>
+                  </div>
+                </td>
+                <td style={{ padding: '12px 16px', textAlign: 'right',
+                  color: '#7a6c8a', fontVariantNumeric: 'tabular-nums' }}>
+                  {p.daysSinceStart}d
+                </td>
+              </tr>
+            );
+          })}
+        </tbody>
+      </table>
+    </div>
+  );
+}
diff --git a/app/pulse/page.tsx b/app/pulse/page.tsx
new file mode 100644
index 0000000..16215fa
--- /dev/null
+++ b/app/pulse/page.tsx
@@ -0,0 +1,114 @@
+/**
+ * /pulse — "Petition Pulse" public visualization of advocacy petitions as
+ * heartbeat spikes on an ECG line, mirroring Grant's "Deadline River"
+ * aesthetic in Patty's violet palette.
+ *
+ * 2026-05-30 (CNCP _shared item 19): different domain than Grant's federal-
+ * grants river — Patty visualizes petition signature velocity over the last
+ * 30 days. Each petition is a pulse spike: x = days-since-creation
+ * (rightmost = newest), y = signature velocity (signatures/day, taller =
+ * faster growth), color = category.
+ *
+ * No auth required (middleware only matches /api/*). No DB query — the page
+ * embeds a representative sample so the demo works without backend setup.
+ * When wired to the real /api/petitions list it can replace the sample with
+ * live rows server-side; the canvas component is data-shape agnostic.
+ */
+
+import PulseCanvas, { type Petition } from './PulseCanvas';
+import PulseTable from './PulseTable';
+
+// 10-min ISR matching Grant's river — same fetch-amplification reasoning.
+export const revalidate = 600;
+
+// ---------------------------------------------------------------------------
+// Sample petitions — representative across the 6 categories so the demo
+// looks like a fleet, not a single cause. Numbers (signatures, goal,
+// velocity) are illustrative; replace with a live SELECT against
+// poppy/patty.petitions when wiring to production data.
+// ---------------------------------------------------------------------------
+const SAMPLES: Petition[] = [
+  // Climate
+  { id: 'p001', slug: 'fossil-fuel-phaseout', title: 'Federal Fossil Fuel Phaseout by 2035', category: 'climate', signatures: 18432, goal: 25000, velocity: 412, daysSinceStart: 18 },
+  { id: 'p002', slug: 'protect-old-growth', title: 'Permanent Protection for Old-Growth Forests', category: 'climate', signatures: 7811, goal: 10000, velocity: 198, daysSinceStart: 21 },
+  { id: 'p003', slug: 'methane-emergency', title: 'Declare Methane Leaks a National Emergency', category: 'climate', signatures: 3201, goal: 15000, velocity: 89, daysSinceStart: 11 },
+
+  // AI Safety
+  { id: 'p010', slug: 'pause-frontier-training', title: 'Mandatory Pause on Frontier Model Training Above 1e26 FLOPs', category: 'ai_safety', signatures: 24108, goal: 30000, velocity: 856, daysSinceStart: 14 },
+  { id: 'p011', slug: 'open-eval-mandate', title: 'Open Third-Party Evals for Deployed Frontier Models', category: 'ai_safety', signatures: 9412, goal: 12000, velocity: 312, daysSinceStart: 9 },
+  { id: 'p012', slug: 'agi-disclosure', title: 'Public Disclosure Rule for AGI-Class Capabilities', category: 'ai_safety', signatures: 5872, goal: 10000, velocity: 201, daysSinceStart: 24 },
+
+  // Governance
+  { id: 'p020', slug: 'algorithmic-transparency', title: 'Algorithmic Transparency in Federal Benefits Decisions', category: 'governance', signatures: 4108, goal: 8000, velocity: 121, daysSinceStart: 16 },
+  { id: 'p021', slug: 'ranked-choice', title: 'Ranked-Choice Voting for Federal Primaries', category: 'governance', signatures: 11203, goal: 15000, velocity: 287, daysSinceStart: 22 },
+  { id: 'p022', slug: 'lobbying-disclosure', title: 'Real-Time Lobbying Expenditure Disclosure', category: 'governance', signatures: 6722, goal: 9000, velocity: 178, daysSinceStart: 27 },
+  { id: 'p023', slug: 'ftc-rulemaking', title: 'Public Comment Periods for FTC Rulemaking', category: 'governance', signatures: 2118, goal: 6000, velocity: 63, daysSinceStart: 7 },
+
+  // Civil Rights
+  { id: 'p030', slug: 'voting-access', title: 'Restore Section 5 Voting Rights Act Preclearance', category: 'civil_rights', signatures: 16204, goal: 20000, velocity: 489, daysSinceStart: 19 },
+  { id: 'p031', slug: 'gender-affirming-care', title: 'Federal Protection for Gender-Affirming Care', category: 'civil_rights', signatures: 22041, goal: 25000, velocity: 612, daysSinceStart: 13 },
+  { id: 'p032', slug: 'criminal-justice', title: 'End Cash Bail in Federal Pre-Trial Detention', category: 'civil_rights', signatures: 4812, goal: 10000, velocity: 142, daysSinceStart: 20 },
+
+  // Economic Justice
+  { id: 'p040', slug: 'living-wage', title: '$22 Federal Minimum Wage Indexed to CPI', category: 'economic_justice', signatures: 31204, goal: 40000, velocity: 902, daysSinceStart: 17 },
+  { id: 'p041', slug: 'student-debt', title: 'Universal Student Loan Discharge for Under-$75k Households', category: 'economic_justice', signatures: 18722, goal: 25000, velocity: 521, daysSinceStart: 12 },
+  { id: 'p042', slug: 'corporate-tax-floor', title: 'Restore Pre-2017 Corporate Tax Floor', category: 'economic_justice', signatures: 5412, goal: 12000, velocity: 167, daysSinceStart: 23 },
+  { id: 'p043', slug: 'childcare-subsidy', title: 'Universal Childcare Subsidy for Working Families', category: 'economic_justice', signatures: 8911, goal: 15000, velocity: 234, daysSinceStart: 8 },
+
+  // Healthcare
+  { id: 'p050', slug: 'medicare-negotiation', title: 'Expand Medicare Drug Price Negotiation to All Drugs', category: 'healthcare', signatures: 14502, goal: 18000, velocity: 401, daysSinceStart: 15 },
+  { id: 'p051', slug: 'mental-health-parity', title: 'Enforce Mental Health Parity in Federal Plans', category: 'healthcare', signatures: 6118, goal: 9000, velocity: 189, daysSinceStart: 25 },
+  { id: 'p052', slug: 'rural-hospital', title: 'Emergency Rural Hospital Stabilization Fund', category: 'healthcare', signatures: 3922, goal: 8000, velocity: 124, daysSinceStart: 10 },
+  { id: 'p053', slug: 'maternal-health', title: 'National Maternal Mortality Review System', category: 'healthcare', signatures: 7411, goal: 10000, velocity: 212, daysSinceStart: 4 },
+
+  // A few "older" entries to make the left side feel populated
+  { id: 'p060', slug: 'broadband-universal', title: 'Universal Broadband as Public Utility', category: 'governance', signatures: 9821, goal: 12000, velocity: 84, daysSinceStart: 29 },
+  { id: 'p061', slug: 'pfas-ban', title: 'Federal Ban on PFAS in Consumer Products', category: 'climate', signatures: 5202, goal: 10000, velocity: 71, daysSinceStart: 28 },
+  { id: 'p062', slug: 'data-broker-registry', title: 'National Data Broker Public Registry', category: 'governance', signatures: 3411, goal: 7000, velocity: 52, daysSinceStart: 30 },
+];
+
+export default function PulsePage() {
+  return (
+    <div style={{
+      minHeight: '100vh',
+      background: '#06050a',
+      color: '#e4d8f1',
+      fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro Display", system-ui, sans-serif',
+      padding: '32px 24px 80px',
+    }}>
+      <div style={{ maxWidth: 1500, margin: '0 auto' }}>
+        <header style={{
+          display: 'flex', justifyContent: 'space-between', alignItems: 'baseline',
+          marginBottom: 8, gap: 24,
+        }}>
+          <h1 style={{ margin: 0, fontSize: 22, letterSpacing: '-0.01em', fontWeight: 600 }}>
+            <span style={{ color: '#a78bfa' }}>●</span> Petition Pulse
+            <span style={{ fontWeight: 400, color: '#7a6c8a', fontSize: 14, marginLeft: 8 }}>
+              · live signature velocity across {SAMPLES.length} active petitions
+            </span>
+          </h1>
+          <div style={{ color: '#7a6c8a', fontVariantNumeric: 'tabular-nums', fontSize: 12 }}>
+            sample · last 30d window
+          </div>
+        </header>
+
+        <p style={{ color: '#7a6c8a', fontSize: 12, lineHeight: 1.6, maxWidth: 720, marginTop: 0, marginBottom: 24 }}>
+          Each spike is a petition. X-axis is days since launch (right = newest).
+          Spike height is current signature velocity (signatures/day). Color is
+          cause category. Taller, more recent spikes are the petitions worth
+          riding the momentum on.
+        </p>
+
+        <PulseCanvas petitions={SAMPLES} />
+
+        <div style={{ marginTop: 36 }}>
+          <h2 style={{ fontSize: 13, color: '#a78bfa', textTransform: 'uppercase',
+            letterSpacing: '0.08em', marginBottom: 12 }}>
+            Top-velocity petitions
+          </h2>
+          <PulseTable petitions={SAMPLES} />
+        </div>
+      </div>
+    </div>
+  );
+}

← 6065515 harden(auth): add scrypt + rate-limit + refuse-to-boot to /a  ·  back to Patty  ·  Add 3 guest login tiers via shared capability layer 2d7ba5c →