diff --git a/apps/web/src/features/synergies/components/SynergyDetailModal.tsx b/apps/web/src/features/synergies/components/SynergyDetailModal.tsx
index 3a5a69a..ef7fd84 100644
--- a/apps/web/src/features/synergies/components/SynergyDetailModal.tsx
+++ b/apps/web/src/features/synergies/components/SynergyDetailModal.tsx
@@ -12,6 +12,8 @@ import {
LOCATION_ROLE_DESCRIPTIONS,
} from 'inkweave-synergy-engine';
import {getStrengthTier} from '../utils';
+import {QuickVoteControl} from '../../voting/components';
+import {useQuickVote} from '../../voting/hooks';
import {CardImage, CardLightbox, RenderProfiler} from '../../../shared/components';
import {useDialogFocus} from '../../../shared/hooks/useDialogFocus';
import {useScrollLock, useTransitionPresence, useResponsive} from '../../../shared/hooks';
@@ -54,6 +56,7 @@ export function SynergyDetailModal({
const {cardA, cardB, connections, aggregateScore} = pair;
const tier = getStrengthTier(aggregateScore);
+ const quickVote = useQuickVote(cardA.id, cardB.id);
if (!mounted) return null;
@@ -151,6 +154,17 @@ export function SynergyDetailModal({
+ {/* Quick vote */}
+
+
+
+
{/* Connections list */}
{connections.length > 0 && (
diff --git a/apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx b/apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx
index b6c6d0e..601c797 100644
--- a/apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx
+++ b/apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx
@@ -3,20 +3,35 @@ import {render, screen, fireEvent} from '@testing-library/react';
import {SynergyDetailModal} from '../SynergyDetailModal';
import {createCard, createConnection, createPairSynergy} from '../../../../shared/test-utils';
-vi.mock('../../../shared/components', () => ({
+vi.mock('../../../voting/hooks', () => ({
+ useQuickVote: vi.fn().mockReturnValue({
+ state: 'ready',
+ vote: vi.fn(),
+ distribution: null,
+ userChoice: null,
+ error: null,
+ }),
+}));
+
+vi.mock('../../../../shared/components', () => ({
CardImage: ({alt}: {alt: string}) => {alt}
,
+ CardLightbox: () => null,
+ RenderProfiler: ({children}: {children: React.ReactNode}) => <>{children}>,
+ StrengthBadge: ({score}: {score: number}) => {score},
}));
-vi.mock('../../../shared/hooks/useDialogFocus', () => ({
+vi.mock('../../../../shared/hooks/useDialogFocus', () => ({
useDialogFocus: () => ({handleKeyDown: vi.fn()}),
}));
-vi.mock('../../../shared/hooks', () => ({
+vi.mock('../../../../shared/hooks', () => ({
useTransitionPresence: (isOpen: boolean) => ({
mounted: isOpen,
visible: isOpen,
onTransitionEnd: vi.fn(),
}),
+ useScrollLock: vi.fn(),
+ useResponsive: () => ({isMobile: false}),
}));
vi.mock('../../../cards', () => ({
@@ -135,4 +150,16 @@ describe('SynergyDetailModal', () => {
fireEvent.click(screen.getByTestId('synergy-detail-cta'));
expect(onViewSynergies).toHaveBeenCalledWith('elsa-base');
});
+
+ it('renders quick vote control below tier label', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('Do you agree with this score?')).toBeInTheDocument();
+ });
});
diff --git a/apps/web/src/features/voting/components/DistributionBar.stories.tsx b/apps/web/src/features/voting/components/DistributionBar.stories.tsx
new file mode 100644
index 0000000..c1930b6
--- /dev/null
+++ b/apps/web/src/features/voting/components/DistributionBar.stories.tsx
@@ -0,0 +1,39 @@
+import type {Meta, StoryObj} from '@storybook/react-vite';
+import {DistributionBar} from './DistributionBar';
+
+const meta = {
+ title: 'Voting/DistributionBar',
+ component: DistributionBar,
+ parameters: {layout: 'centered'},
+ tags: ['autodocs'],
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Balanced: Story = {
+ args: {lower: 15, right: 68, higher: 17, animate: false},
+};
+
+export const Animated: Story = {
+ args: {lower: 15, right: 68, higher: 17, animate: true},
+};
+
+export const Unanimous: Story = {
+ args: {lower: 0, right: 25, higher: 0, animate: false},
+};
+
+export const Controversial: Story = {
+ args: {lower: 12, right: 5, higher: 13, animate: false},
+};
+
+export const SingleVote: Story = {
+ args: {lower: 0, right: 1, higher: 0, animate: false},
+};
diff --git a/apps/web/src/features/voting/components/DistributionBar.tsx b/apps/web/src/features/voting/components/DistributionBar.tsx
new file mode 100644
index 0000000..8d3f666
--- /dev/null
+++ b/apps/web/src/features/voting/components/DistributionBar.tsx
@@ -0,0 +1,105 @@
+import {useState, useEffect} from 'react';
+import {COLORS, FONT_SIZES} from '../../../shared/constants';
+
+interface DistributionBarProps {
+ lower: number;
+ right: number;
+ higher: number;
+ animate: boolean;
+ showLabels?: boolean;
+}
+
+const SEGMENT_COLORS = {
+ lower: {bg: 'rgba(245, 144, 144, 0.25)', text: '#f59090'},
+ right: {bg: 'rgba(110, 231, 160, 0.2)', text: '#6ee7a0'},
+ higher: {bg: 'rgba(96, 181, 245, 0.2)', text: '#60b5f5'},
+} as const;
+
+const LABELS = {
+ lower: 'Should be lower',
+ right: 'About right',
+ higher: 'Should be higher',
+} as const;
+
+export function DistributionBar({lower, right, higher, animate, showLabels = true}: DistributionBarProps) {
+ const total = lower + right + higher;
+ const [mounted, setMounted] = useState(!animate);
+
+ useEffect(() => {
+ if (!animate) return;
+ const id = requestAnimationFrame(() => setMounted(true));
+ return () => cancelAnimationFrame(id);
+ }, [animate]);
+
+ if (total === 0) return null;
+
+ const pct = {
+ lower: Math.round((lower / total) * 100),
+ right: Math.round((right / total) * 100),
+ higher: Math.round((higher / total) * 100),
+ };
+
+ // Fix rounding to sum to 100
+ const diff = 100 - pct.lower - pct.right - pct.higher;
+ if (diff !== 0) {
+ const largest = Object.entries(pct).sort(([, a], [, b]) => b - a)[0][0] as keyof typeof pct;
+ pct[largest] += diff;
+ }
+
+ const segments = (['lower', 'right', 'higher'] as const).filter((key) => pct[key] > 0);
+
+ return (
+
+
+ {segments.map((key, i) => (
+
1
+ ? '6px 0 0 6px'
+ : i === segments.length - 1 && segments.length > 1
+ ? '0 6px 6px 0'
+ : segments.length === 1
+ ? '6px'
+ : undefined,
+ transition: animate ? 'width 500ms ease-out' : undefined,
+ transitionDelay: animate ? `${i * 50}ms` : undefined,
+ }}>
+ {pct[key]}%
+
+ ))}
+
+ {showLabels && (
+
+ {LABELS.lower}
+ {LABELS.right}
+ {LABELS.higher}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/features/voting/components/QuickVoteControl.stories.tsx b/apps/web/src/features/voting/components/QuickVoteControl.stories.tsx
new file mode 100644
index 0000000..ee16728
--- /dev/null
+++ b/apps/web/src/features/voting/components/QuickVoteControl.stories.tsx
@@ -0,0 +1,70 @@
+import type {Meta, StoryObj} from '@storybook/react-vite';
+import {fn} from 'storybook/test';
+import {QuickVoteControl} from './QuickVoteControl';
+
+const meta = {
+ title: 'Voting/QuickVoteControl',
+ component: QuickVoteControl,
+ parameters: {layout: 'centered'},
+ tags: ['autodocs'],
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ args: {onVote: fn()},
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Ready: Story = {
+ args: {state: 'ready', distribution: null, userChoice: null, error: null},
+};
+
+export const Submitting: Story = {
+ args: {state: 'submitting', distribution: null, userChoice: 0, error: null},
+};
+
+export const Result: Story = {
+ args: {
+ state: 'result',
+ distribution: {lower: 15, right: 68, higher: 17, total: 100},
+ userChoice: 0,
+ error: null,
+ },
+};
+
+export const ResultFirstVoter: Story = {
+ args: {
+ state: 'result',
+ distribution: {lower: 0, right: 1, higher: 0, total: 1},
+ userChoice: 0,
+ error: null,
+ },
+};
+
+export const Error: Story = {
+ args: {state: 'error', distribution: null, userChoice: null, error: 'submission_failed'},
+};
+
+export const RateLimited: Story = {
+ args: {state: 'error', distribution: null, userChoice: null, error: 'rate_limited'},
+};
+
+export const Mobile: Story = {
+ args: {state: 'ready', distribution: null, userChoice: null, error: null},
+ parameters: {viewport: {defaultViewport: 'mobile1'}},
+};
+
+export const MobileResult: Story = {
+ args: {
+ state: 'result',
+ distribution: {lower: 8, right: 30, higher: 12, total: 50},
+ userChoice: 1,
+ error: null,
+ },
+ parameters: {viewport: {defaultViewport: 'mobile1'}},
+};
diff --git a/apps/web/src/features/voting/components/QuickVoteControl.tsx b/apps/web/src/features/voting/components/QuickVoteControl.tsx
new file mode 100644
index 0000000..502cbd2
--- /dev/null
+++ b/apps/web/src/features/voting/components/QuickVoteControl.tsx
@@ -0,0 +1,208 @@
+import {COLORS, FONT_SIZES, FONTS, RADIUS, SPACING} from '../../../shared/constants';
+import {useResponsive} from '../../../shared/hooks';
+import type {AccuracyDistribution} from '../../../shared/lib/supabase';
+import type {QuickVoteError, QuickVoteState, Accuracy} from '../hooks/useQuickVote';
+import {DistributionBar} from './DistributionBar';
+
+interface QuickVoteControlProps {
+ state: QuickVoteState;
+ onVote: (accuracy: Accuracy) => void;
+ distribution: AccuracyDistribution | null;
+ userChoice: Accuracy | null;
+ error: QuickVoteError;
+}
+
+const CHOICE_LABELS: Record = {
+ [-1]: 'Should be lower',
+ [0]: 'About right',
+ [1]: 'Should be higher',
+};
+
+const CHOICE_COLORS: Record = {
+ [-1]: {border: '#f59090', glow: 'rgba(245, 144, 144, 0.15)'},
+ [0]: {border: '#6ee7a0', glow: 'rgba(110, 231, 160, 0.15)'},
+ [1]: {border: '#60b5f5', glow: 'rgba(96, 181, 245, 0.15)'},
+};
+
+const VOTES = [-1, 0, 1] as const;
+
+const CONTAINER_STYLE: React.CSSProperties = {
+ background: 'rgba(212, 175, 55, 0.04)',
+ border: '1px solid rgba(212, 175, 55, 0.12)',
+ borderRadius: 10,
+ padding: '14px 16px',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: SPACING.sm,
+ fontFamily: FONTS.body,
+};
+
+const QUESTION_STYLE: React.CSSProperties = {
+ fontSize: `${FONT_SIZES.base}px`,
+ color: COLORS.textMuted,
+ margin: 0,
+};
+
+const BASE_BUTTON_STYLE: React.CSSProperties = {
+ background: 'rgba(255,255,255,0.03)',
+ border: `1px solid ${COLORS.surfaceBorder}`,
+ borderRadius: RADIUS.lg,
+ minHeight: 44,
+ cursor: 'pointer',
+ fontFamily: 'inherit',
+ fontSize: `${FONT_SIZES.base}px`,
+ color: COLORS.text,
+ padding: `${SPACING.xs}px ${SPACING.md}px`,
+ transition: 'all 0.2s',
+ flex: 1,
+};
+
+const TEASER_STYLE: React.CSSProperties = {
+ fontSize: `${FONT_SIZES.sm}px`,
+ color: COLORS.textDim,
+ background: 'none',
+ border: 'none',
+ padding: 0,
+ cursor: 'not-allowed',
+ fontFamily: 'inherit',
+ textAlign: 'left',
+};
+
+function VoteButtons({
+ disabled,
+ submitting,
+ selectedChoice,
+ onVote,
+ isMobile,
+}: {
+ disabled: boolean;
+ submitting: boolean;
+ selectedChoice: Accuracy | null;
+ onVote: (accuracy: Accuracy) => void;
+ isMobile: boolean;
+}) {
+ return (
+
+ {VOTES.map((vote) => {
+ const isSelected = submitting && selectedChoice === vote;
+ const isDimmed = submitting && selectedChoice !== vote;
+ const colors = CHOICE_COLORS[vote];
+
+ return (
+
+ );
+ })}
+
+ );
+}
+
+function TeaserButton() {
+ return (
+
+ );
+}
+
+export function QuickVoteControl({
+ state,
+ onVote,
+ distribution,
+ userChoice,
+ error,
+}: QuickVoteControlProps) {
+ const {isMobile} = useResponsive();
+
+ if (state === 'hidden') return null;
+
+ if (state === 'result') {
+ const isFirstVoter = distribution?.total === 1;
+
+ return (
+
+
+ ✦ Thanks! You voted:{' '}
+
+ {userChoice !== null ? CHOICE_LABELS[userChoice] : ''}
+
+
+ {isFirstVoter ? (
+
+ ✦
+ First to rate this pair!
+
+ ) : distribution ? (
+
+ ) : (
+
+ Loading community votes…
+
+ )}
+ {distribution && (
+
+ {distribution.total} votes on this pair
+
+ )}
+
+
+ );
+ }
+
+ const isSubmitting = state === 'submitting';
+ const isError = state === 'error';
+ const buttonsDisabled = isSubmitting || (isError && error === 'rate_limited');
+
+ return (
+
+
Do you agree with this score?
+
+ {isError && (
+
+ {error === 'rate_limited'
+ ? "You're voting fast! Try again in a bit."
+ : 'Something went wrong, try again'}
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/features/voting/components/__tests__/DistributionBar.test.tsx b/apps/web/src/features/voting/components/__tests__/DistributionBar.test.tsx
new file mode 100644
index 0000000..25a33c7
--- /dev/null
+++ b/apps/web/src/features/voting/components/__tests__/DistributionBar.test.tsx
@@ -0,0 +1,47 @@
+import {describe, it, expect} from 'vitest';
+import {render, screen} from '@testing-library/react';
+import {DistributionBar} from '../DistributionBar';
+
+describe('DistributionBar', () => {
+ it('renders three segments with percentages', () => {
+ render();
+ expect(screen.getAllByText('15%')).toHaveLength(2);
+ expect(screen.getByText('70%')).toBeInTheDocument();
+ });
+
+ it('renders labels below the bar', () => {
+ render();
+ expect(screen.getByText('Should be lower')).toBeInTheDocument();
+ expect(screen.getByText('About right')).toBeInTheDocument();
+ expect(screen.getByText('Should be higher')).toBeInTheDocument();
+ });
+
+ it('handles all votes in one bucket', () => {
+ render();
+ expect(screen.getByText('100%')).toBeInTheDocument();
+ });
+
+ it('rounds percentages to sum to exactly 100', () => {
+ // 1/3 each = 33.33% → rounds to 33+33+33=99, correction bumps one to 34
+ render();
+ expect(screen.getAllByText('33%')).toHaveLength(2);
+ expect(screen.getByText('34%')).toBeInTheDocument();
+ });
+
+ it('handles single vote', () => {
+ render();
+ expect(screen.getByText('100%')).toBeInTheDocument();
+ });
+
+ it('returns null when total is zero', () => {
+ const {container} = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('hides labels when showLabels is false', () => {
+ render();
+ expect(screen.queryByText('Should be lower')).not.toBeInTheDocument();
+ expect(screen.queryByText('About right')).not.toBeInTheDocument();
+ expect(screen.queryByText('Should be higher')).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx b/apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx
new file mode 100644
index 0000000..4dde822
--- /dev/null
+++ b/apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx
@@ -0,0 +1,122 @@
+import {describe, it, expect, vi} from 'vitest';
+import {render, screen, fireEvent} from '@testing-library/react';
+import {QuickVoteControl} from '../QuickVoteControl';
+import type {AccuracyDistribution} from '../../../../shared/lib/supabase';
+
+const mockVote = vi.fn();
+
+beforeEach(() => {
+ mockVote.mockClear();
+});
+
+describe('QuickVoteControl', () => {
+ it('renders nothing when state is hidden', () => {
+ const {container} = render(
+ ,
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders three vote buttons in ready state', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('Do you agree with this score?')).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'Should be lower'})).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'About right'})).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'Should be higher'})).toBeInTheDocument();
+ });
+
+ it('calls onVote with -1 when "Should be lower" is clicked', () => {
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', {name: 'Should be lower'}));
+ expect(mockVote).toHaveBeenCalledWith(-1);
+ });
+
+ it('calls onVote with 0 when "About right" is clicked', () => {
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', {name: 'About right'}));
+ expect(mockVote).toHaveBeenCalledWith(0);
+ });
+
+ it('calls onVote with 1 when "Should be higher" is clicked', () => {
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', {name: 'Should be higher'}));
+ expect(mockVote).toHaveBeenCalledWith(1);
+ });
+
+ it('disables buttons during submitting state', () => {
+ render(
+ ,
+ );
+ const buttons = screen.getAllByRole('button');
+ buttons.forEach((btn) => expect(btn).toBeDisabled());
+ });
+
+ it('shows distribution bar and confirmation in result state', () => {
+ const dist: AccuracyDistribution = {lower: 3, right: 14, higher: 3, total: 20};
+ render(
+ ,
+ );
+ expect(screen.getByText(/You voted:/)).toBeInTheDocument();
+ expect(screen.getByText('About right')).toBeInTheDocument();
+ expect(screen.getByText('20 votes on this pair')).toBeInTheDocument();
+ });
+
+ it('shows first voter badge when total is 1', () => {
+ const dist: AccuracyDistribution = {lower: 0, right: 1, higher: 0, total: 1};
+ render(
+ ,
+ );
+ expect(screen.getByText('First to rate this pair!')).toBeInTheDocument();
+ });
+
+ it('shows error message and re-enables buttons on error', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('Something went wrong, try again')).toBeInTheDocument();
+ // Vote buttons should be enabled (not the teaser)
+ expect(screen.getByRole('button', {name: 'Should be lower'})).not.toBeDisabled();
+ });
+
+ it('disables vote buttons when rate limited', () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole('button', {name: 'Should be lower'})).toBeDisabled();
+ expect(screen.getByRole('button', {name: 'About right'})).toBeDisabled();
+ expect(screen.getByRole('button', {name: 'Should be higher'})).toBeDisabled();
+ });
+
+ it('shows loading text when distribution is null in result state', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText(/You voted:/)).toBeInTheDocument();
+ expect(screen.getByText('About right')).toBeInTheDocument();
+ expect(screen.getByText('Loading community votes…')).toBeInTheDocument();
+ expect(screen.queryByText(/votes on this pair/)).not.toBeInTheDocument();
+ });
+
+ it('shows rate limit message on rate_limited error', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("You're voting fast! Try again in a bit.")).toBeInTheDocument();
+ });
+
+ it('shows disabled "Rate in detail" teaser', () => {
+ render(
+ ,
+ );
+ const teaser = screen.getByText(/Rate in detail/);
+ expect(teaser).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/features/voting/components/index.ts b/apps/web/src/features/voting/components/index.ts
index 8f8f9c6..bd2bafd 100644
--- a/apps/web/src/features/voting/components/index.ts
+++ b/apps/web/src/features/voting/components/index.ts
@@ -7,3 +7,5 @@ export type {VoteToastData} from './VoteToast';
export {VoteStatusBanner} from './VoteStatusBanner';
export {VoteProgress} from './VoteProgress';
export {PairStack} from './PairStack';
+export {QuickVoteControl} from './QuickVoteControl';
+export {DistributionBar} from './DistributionBar';
diff --git a/apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts b/apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts
new file mode 100644
index 0000000..94ba324
--- /dev/null
+++ b/apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts
@@ -0,0 +1,181 @@
+import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
+import {renderHook, act} from '@testing-library/react';
+import {useQuickVote} from '../useQuickVote';
+import {getSupabase, submitVote, getAccuracyDistribution} from '../../../../shared/lib/supabase';
+
+vi.mock('../../../../shared/lib/supabase', () => ({
+ getSupabase: vi.fn(),
+ submitVote: vi.fn(),
+ getAccuracyDistribution: vi.fn(),
+}));
+
+const CARD_A = 'card-aaa';
+const CARD_B = 'card-bbb';
+const STORAGE_KEY = `inkweave:vote:${CARD_A}:${CARD_B}`;
+
+describe('useQuickVote', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ it('returns hidden state when Supabase is unavailable', () => {
+ vi.mocked(getSupabase).mockReturnValue(null);
+ const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B));
+ expect(result.current.state).toBe('hidden');
+ });
+
+ it('returns ready state when Supabase is available and no prior vote', () => {
+ vi.mocked(getSupabase).mockReturnValue({} as ReturnType);
+ const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B));
+ expect(result.current.state).toBe('ready');
+ });
+
+ it('returns result state when pair found in localStorage', async () => {
+ vi.mocked(getSupabase).mockReturnValue({} as ReturnType);
+ vi.mocked(getAccuracyDistribution).mockResolvedValue({
+ lower: 3, right: 10, higher: 2, total: 15,
+ });
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({accuracy: 0, timestamp: Date.now()}));
+
+ const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B));
+ expect(result.current.state).toBe('result');
+ expect(result.current.userChoice).toBe(0);
+ });
+
+ it('submits vote, stores in localStorage, and fetches distribution', async () => {
+ vi.mocked(getSupabase).mockReturnValue({} as ReturnType);
+ vi.mocked(submitVote).mockResolvedValue({error: null});
+ vi.mocked(getAccuracyDistribution).mockResolvedValue({
+ lower: 1, right: 5, higher: 0, total: 6,
+ });
+
+ const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B));
+ expect(result.current.state).toBe('ready');
+
+ await act(async () => {
+ await result.current.vote(0);
+ });
+
+ expect(submitVote).toHaveBeenCalledWith({
+ cardA: CARD_A,
+ cardB: CARD_B,
+ accuracy: 0,
+ });
+ expect(result.current.state).toBe('result');
+ expect(result.current.userChoice).toBe(0);
+ expect(result.current.distribution).toEqual({
+ lower: 1, right: 5, higher: 0, total: 6,
+ });
+ expect(localStorage.getItem(STORAGE_KEY)).toBeTruthy();
+ });
+
+ it('transitions to error state on submission failure', async () => {
+ vi.mocked(getSupabase).mockReturnValue({} as ReturnType);
+ vi.mocked(submitVote).mockResolvedValue({error: 'Network error'});
+
+ const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B));
+
+ await act(async () => {
+ await result.current.vote(1);
+ });
+
+ expect(result.current.state).toBe('error');
+ expect(result.current.error).toBe('submission_failed');
+ expect(result.current.userChoice).toBeNull();
+ expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
+ });
+
+ it('retries successfully from error state', async () => {
+ vi.mocked(getSupabase).mockReturnValue({} as ReturnType);
+ vi.mocked(submitVote).mockResolvedValueOnce({error: 'Network error'});
+ vi.mocked(submitVote).mockResolvedValueOnce({error: null});
+ vi.mocked(getAccuracyDistribution).mockResolvedValue({
+ lower: 0, right: 3, higher: 1, total: 4,
+ });
+
+ const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B));
+
+ // First attempt fails
+ await act(async () => {
+ await result.current.vote(0);
+ });
+ expect(result.current.state).toBe('error');
+
+ // Retry succeeds
+ await act(async () => {
+ await result.current.vote(0);
+ });
+ expect(result.current.state).toBe('result');
+ expect(submitVote).toHaveBeenCalledTimes(2);
+ expect(localStorage.getItem(STORAGE_KEY)).toBeTruthy();
+ });
+
+ it('finds stored vote regardless of card argument order', () => {
+ vi.mocked(getSupabase).mockReturnValue({} as ReturnType);
+ vi.mocked(getAccuracyDistribution).mockResolvedValue({
+ lower: 1, right: 5, higher: 0, total: 6,
+ });
+ // Store under canonical key (card-aaa comes first alphabetically)
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({accuracy: 1, timestamp: Date.now()}));
+
+ // Pass cards in REVERSED order
+ const {result} = renderHook(() => useQuickVote(CARD_B, CARD_A));
+ expect(result.current.state).toBe('result');
+ expect(result.current.userChoice).toBe(1);
+ });
+
+ it('transitions to rate-limited state', async () => {
+ vi.mocked(getSupabase).mockReturnValue({} as ReturnType);
+ vi.mocked(submitVote).mockResolvedValue({error: 'rate_limited'});
+
+ const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B));
+
+ await act(async () => {
+ await result.current.vote(-1);
+ });
+
+ expect(result.current.state).toBe('error');
+ expect(result.current.error).toBe('rate_limited');
+ expect(result.current.userChoice).toBeNull();
+ });
+
+ it('sets userChoice optimistically during submitting', async () => {
+ vi.mocked(getSupabase).mockReturnValue({} as ReturnType);
+ let resolveVote: (v: {error: null}) => void;
+ vi.mocked(submitVote).mockReturnValue(new Promise((r) => { resolveVote = r; }));
+
+ const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B));
+
+ // Start voting but don't resolve yet
+ act(() => { result.current.vote(1); });
+ expect(result.current.state).toBe('submitting');
+ expect(result.current.userChoice).toBe(1);
+
+ // Resolve and clean up
+ vi.mocked(getAccuracyDistribution).mockResolvedValue({lower: 0, right: 0, higher: 1, total: 1});
+ await act(async () => { resolveVote!({error: null}); });
+ expect(result.current.state).toBe('result');
+ });
+
+ it('ignores vote() calls from result state (double-submit guard)', async () => {
+ vi.mocked(getSupabase).mockReturnValue({} as ReturnType);
+ vi.mocked(submitVote).mockResolvedValue({error: null});
+ vi.mocked(getAccuracyDistribution).mockResolvedValue({lower: 0, right: 1, higher: 0, total: 1});
+
+ const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B));
+
+ await act(async () => { await result.current.vote(0); });
+ expect(result.current.state).toBe('result');
+
+ // Try voting again from result state — should be a no-op
+ await act(async () => { await result.current.vote(-1); });
+ expect(submitVote).toHaveBeenCalledTimes(1);
+ expect(result.current.state).toBe('result');
+ expect(result.current.userChoice).toBe(0);
+ });
+});
diff --git a/apps/web/src/features/voting/hooks/index.ts b/apps/web/src/features/voting/hooks/index.ts
index b523cdf..d5ff8c2 100644
--- a/apps/web/src/features/voting/hooks/index.ts
+++ b/apps/web/src/features/voting/hooks/index.ts
@@ -2,3 +2,5 @@ export {usePairQueue} from './usePairQueue';
export type {UsePairQueueReturn, PairPreview} from './usePairQueue';
export {useVoteSession} from './useVoteSession';
export type {UseVoteSessionReturn} from './useVoteSession';
+export {useQuickVote} from './useQuickVote';
+export type {UseQuickVoteReturn, QuickVoteState, QuickVoteError, Accuracy} from './useQuickVote';
diff --git a/apps/web/src/features/voting/hooks/useQuickVote.ts b/apps/web/src/features/voting/hooks/useQuickVote.ts
new file mode 100644
index 0000000..28af227
--- /dev/null
+++ b/apps/web/src/features/voting/hooks/useQuickVote.ts
@@ -0,0 +1,147 @@
+import {useState, useEffect, useCallback, useMemo, useRef} from 'react';
+import {
+ getSupabase,
+ submitVote,
+ getAccuracyDistribution,
+ type AccuracyDistribution,
+ type Accuracy,
+} from '../../../shared/lib/supabase';
+
+export type {Accuracy};
+export type QuickVoteState = 'hidden' | 'ready' | 'submitting' | 'result' | 'error';
+export type QuickVoteError = 'submission_failed' | 'rate_limited' | null;
+
+function storageKey(cardA: string, cardB: string): string {
+ const [a, b] = [cardA, cardB].sort();
+ return `inkweave:vote:${a}:${b}`;
+}
+
+function getStoredVote(cardA: string, cardB: string): Accuracy | null {
+ const key = storageKey(cardA, cardB);
+ let raw: string | null;
+ try {
+ raw = localStorage.getItem(key);
+ } catch (e) {
+ console.warn('[getStoredVote] localStorage access denied:', e);
+ return null;
+ }
+ if (!raw) return null;
+ try {
+ const parsed = JSON.parse(raw);
+ const accuracy = parsed?.accuracy;
+ if (accuracy === -1 || accuracy === 0 || accuracy === 1) return accuracy;
+ console.error('[getStoredVote] Stored value has unexpected shape, clearing:', parsed);
+ try { localStorage.removeItem(key); } catch { /* storage cleanup failed */ }
+ return null;
+ } catch (e) {
+ console.error('[getStoredVote] Corrupted JSON in storage, clearing key:', key, e);
+ try { localStorage.removeItem(key); } catch { /* storage cleanup failed */ }
+ return null;
+ }
+}
+
+function storeVote(cardA: string, cardB: string, accuracy: Accuracy): void {
+ try {
+ localStorage.setItem(
+ storageKey(cardA, cardB),
+ JSON.stringify({accuracy, timestamp: Date.now()}),
+ );
+ } catch (e) {
+ console.error('[storeVote] Failed to persist vote to localStorage:', e);
+ }
+}
+
+export interface UseQuickVoteReturn {
+ state: QuickVoteState;
+ vote: (accuracy: Accuracy) => Promise;
+ distribution: AccuracyDistribution | null;
+ userChoice: Accuracy | null;
+ error: QuickVoteError;
+}
+
+export function useQuickVote(cardA: string, cardB: string): UseQuickVoteReturn {
+ const isAvailable = useMemo(() => getSupabase() !== null, []);
+ const storedChoice = useMemo(() => getStoredVote(cardA, cardB), [cardA, cardB]);
+
+ const [state, setState] = useState(() => {
+ if (!isAvailable) return 'hidden';
+ if (storedChoice !== null) return 'result';
+ return 'ready';
+ });
+ const [userChoice, setUserChoice] = useState(storedChoice);
+ const [distribution, setDistribution] = useState(null);
+ const [error, setError] = useState(null);
+ const submittingRef = useRef(false);
+
+ // Fetch distribution for returning voters
+ useEffect(() => {
+ if (state !== 'result' || distribution) return;
+ let cancelled = false;
+ getAccuracyDistribution(cardA, cardB)
+ .then((dist) => {
+ if (!cancelled && dist) setDistribution(dist);
+ })
+ .catch((err) => {
+ if (!cancelled) {
+ console.error('[useQuickVote] Failed to fetch accuracy distribution:', err);
+ }
+ });
+ return () => { cancelled = true; };
+ }, [state, distribution, cardA, cardB]);
+
+ // Auto-recover from rate limit after 30s
+ useEffect(() => {
+ if (error !== 'rate_limited') return;
+ const timer = setTimeout(() => {
+ setState('ready');
+ setError(null);
+ }, 30_000);
+ return () => clearTimeout(timer);
+ }, [error]);
+
+ const vote = useCallback(
+ async (accuracy: Accuracy) => {
+ if (state !== 'ready' && state !== 'error') return;
+ if (submittingRef.current) return;
+ submittingRef.current = true;
+
+ setState('submitting');
+ setUserChoice(accuracy);
+ setError(null);
+
+ try {
+ const result = await submitVote({cardA, cardB, accuracy});
+
+ if (result.error === null) {
+ storeVote(cardA, cardB, accuracy);
+ setState('result');
+ try {
+ const dist = await getAccuracyDistribution(cardA, cardB);
+ if (dist) setDistribution(dist);
+ } catch (distErr) {
+ console.error('[useQuickVote] Failed to fetch distribution after vote:', distErr);
+ }
+ } else if (result.error === 'rate_limited') {
+ setUserChoice(null);
+ setError('rate_limited');
+ setState('error');
+ } else {
+ console.error('[useQuickVote] Vote submission failed:', result.error, {cardA, cardB});
+ setUserChoice(null);
+ setError('submission_failed');
+ setState('error');
+ }
+ } catch (err) {
+ console.error('[useQuickVote] Unexpected error during vote submission:', err);
+ setUserChoice(null);
+ setError('submission_failed');
+ setState('error');
+ } finally {
+ submittingRef.current = false;
+ }
+ },
+ [cardA, cardB, state],
+ );
+
+ return {state, vote, distribution, userChoice, error};
+}
diff --git a/apps/web/src/shared/lib/__tests__/supabase.test.ts b/apps/web/src/shared/lib/__tests__/supabase.test.ts
index 2062d5c..82e7be8 100644
--- a/apps/web/src/shared/lib/__tests__/supabase.test.ts
+++ b/apps/web/src/shared/lib/__tests__/supabase.test.ts
@@ -3,6 +3,7 @@ import {
getSupabase,
submitVote,
getPairScore,
+ getAccuracyDistribution,
_resetClient,
} from '../supabase';
@@ -239,3 +240,106 @@ describe('getPairScore', () => {
vi.unstubAllEnvs();
});
});
+
+describe('getAccuracyDistribution', () => {
+ it('returns null when Supabase is not configured', async () => {
+ const result = await getAccuracyDistribution('a', 'b');
+ expect(result).toBeNull();
+ expect(mockFrom).not.toHaveBeenCalled();
+ });
+
+ it('queries pair_scores with canonical pair order', async () => {
+ vi.stubEnv('VITE_SUPABASE_URL', 'https://test.supabase.co');
+ vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-key');
+ mockSingle.mockResolvedValue({
+ data: {accuracy_lower: 2, accuracy_right: 8, accuracy_higher: 1},
+ });
+
+ // Pass in reverse order — should sort to (aaa, zzz)
+ await getAccuracyDistribution('zzz', 'aaa');
+
+ expect(mockFrom).toHaveBeenCalledWith('pair_scores');
+ expect(mockSelect).toHaveBeenCalledWith('accuracy_lower, accuracy_right, accuracy_higher');
+ expect(mockEq1).toHaveBeenCalledWith('card_a_id', 'aaa');
+ expect(mockEq2).toHaveBeenCalledWith('card_b_id', 'zzz');
+
+ vi.unstubAllEnvs();
+ });
+
+ it('returns distribution with computed total', async () => {
+ vi.stubEnv('VITE_SUPABASE_URL', 'https://test.supabase.co');
+ vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-key');
+ mockSingle.mockResolvedValue({
+ data: {accuracy_lower: 3, accuracy_right: 10, accuracy_higher: 2},
+ });
+
+ const result = await getAccuracyDistribution('a', 'b');
+ expect(result).toEqual({lower: 3, right: 10, higher: 2, total: 15});
+
+ vi.unstubAllEnvs();
+ });
+
+ it('handles null column values (nullable database columns)', async () => {
+ vi.stubEnv('VITE_SUPABASE_URL', 'https://test.supabase.co');
+ vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-key');
+ mockSingle.mockResolvedValue({
+ data: {accuracy_lower: null, accuracy_right: null, accuracy_higher: null},
+ });
+
+ const result = await getAccuracyDistribution('a', 'b');
+ expect(result).toEqual({lower: 0, right: 0, higher: 0, total: 0});
+
+ vi.unstubAllEnvs();
+ });
+
+ it('returns null for PGRST116 (no rows found)', async () => {
+ vi.stubEnv('VITE_SUPABASE_URL', 'https://test.supabase.co');
+ vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-key');
+ mockSingle.mockResolvedValue({
+ data: null,
+ error: {code: 'PGRST116', message: 'No rows found'},
+ });
+
+ const result = await getAccuracyDistribution('a', 'b');
+ expect(result).toBeNull();
+
+ vi.unstubAllEnvs();
+ });
+
+ it('returns null and logs on query error', async () => {
+ vi.stubEnv('VITE_SUPABASE_URL', 'https://test.supabase.co');
+ vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-key');
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ mockSingle.mockResolvedValue({
+ data: null,
+ error: {code: '42P01', message: 'relation does not exist'},
+ });
+
+ const result = await getAccuracyDistribution('a', 'b');
+ expect(result).toBeNull();
+ expect(consoleSpy).toHaveBeenCalledWith(
+ '[getAccuracyDistribution] Supabase query failed:',
+ expect.objectContaining({code: '42P01'}),
+ );
+
+ consoleSpy.mockRestore();
+ vi.unstubAllEnvs();
+ });
+
+ it('catches network exceptions', async () => {
+ vi.stubEnv('VITE_SUPABASE_URL', 'https://test.supabase.co');
+ vi.stubEnv('VITE_SUPABASE_ANON_KEY', 'test-key');
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ mockSingle.mockRejectedValue(new TypeError('Failed to fetch'));
+
+ const result = await getAccuracyDistribution('a', 'b');
+ expect(result).toBeNull();
+ expect(consoleSpy).toHaveBeenCalledWith(
+ '[getAccuracyDistribution] Network error:',
+ expect.any(TypeError),
+ );
+
+ consoleSpy.mockRestore();
+ vi.unstubAllEnvs();
+ });
+});
diff --git a/apps/web/src/shared/lib/database.types.ts b/apps/web/src/shared/lib/database.types.ts
index a8279e6..2b74b7a 100644
--- a/apps/web/src/shared/lib/database.types.ts
+++ b/apps/web/src/shared/lib/database.types.ts
@@ -60,6 +60,9 @@ export type Database = {
Views: {
pair_scores: {
Row: {
+ accuracy_higher: number | null
+ accuracy_lower: number | null
+ accuracy_right: number | null
accuracy_sentiment: number | null
accuracy_votes: number | null
avg_difficulty: number | null
diff --git a/apps/web/src/shared/lib/supabase.ts b/apps/web/src/shared/lib/supabase.ts
index f5f1abf..8d58a45 100644
--- a/apps/web/src/shared/lib/supabase.ts
+++ b/apps/web/src/shared/lib/supabase.ts
@@ -38,10 +38,12 @@ export function _resetClient(): void {
// --- Vote types ---
+export type Accuracy = -1 | 0 | 1;
+
export type QuickVote = {
cardA: string;
cardB: string;
- accuracy: -1 | 0 | 1;
+ accuracy: Accuracy;
};
// Fix #6: Constrain score to 1-10
@@ -50,7 +52,7 @@ export type Score = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
export type InDepthVote = {
cardA: string;
cardB: string;
- accuracy?: -1 | 0 | 1;
+ accuracy?: Accuracy;
isReal?: boolean;
score?: Score;
wouldPlay?: boolean;
@@ -140,3 +142,50 @@ export async function getPairScore(
return null;
}
}
+
+// --- Accuracy distribution ---
+
+export type AccuracyDistribution = {
+ lower: number;
+ right: number;
+ higher: number;
+ total: number;
+};
+
+export async function getAccuracyDistribution(
+ cardA: string,
+ cardB: string,
+): Promise {
+ const supabase = getSupabase();
+ if (!supabase) return null;
+
+ const [a, b] = [cardA, cardB].sort();
+
+ try {
+ const { data, error } = await supabase
+ .from('pair_scores')
+ .select('accuracy_lower, accuracy_right, accuracy_higher')
+ .eq('card_a_id', a)
+ .eq('card_b_id', b)
+ .single();
+
+ if (error) {
+ if (error.code === 'PGRST116') return null;
+ console.error('[getAccuracyDistribution] Supabase query failed:', {
+ code: error.code,
+ message: error.message,
+ pair: `${a} / ${b}`,
+ });
+ return null;
+ }
+
+ const lower = data.accuracy_lower ?? 0;
+ const right = data.accuracy_right ?? 0;
+ const higher = data.accuracy_higher ?? 0;
+
+ return { lower, right, higher, total: lower + right + higher };
+ } catch (e) {
+ console.error('[getAccuracyDistribution] Network error:', e);
+ return null;
+ }
+}
diff --git a/docs/superpowers/plans/2026-04-06-quick-vote.md b/docs/superpowers/plans/2026-04-06-quick-vote.md
new file mode 100644
index 0000000..350c2e1
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-06-quick-vote.md
@@ -0,0 +1,961 @@
+# Quick Vote Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a three-button quick vote control to the synergy detail modal so users can give directional feedback on synergy scores, with an animated distribution bar reveal after voting.
+
+**Architecture:** New `useQuickVote` hook manages the vote state machine (hidden/ready/submitting/result/error). New `QuickVoteControl` component renders the UI and `DistributionBar` sub-component handles the animated bar. A small Supabase migration adds per-bucket accuracy columns to the `pair_scores` view. localStorage tracks which pairs the user has voted on.
+
+**Tech Stack:** React 19, TypeScript, Vitest + RTL, Supabase RPC, CSS transitions, Storybook
+
+**Spec:** `docs/superpowers/specs/2026-04-06-quick-vote-design.md`
+
+---
+
+## File Map
+
+| Action | Path | Responsibility |
+|--------|------|---------------|
+| Create | `supabase/migrations/20260406000001_accuracy_distribution.sql` | Add accuracy_lower/right/higher columns to pair_scores view |
+| Create | `apps/web/src/features/voting/hooks/useQuickVote.ts` | State machine hook: localStorage check, submit, fetch distribution |
+| Create | `apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts` | Hook unit tests |
+| Create | `apps/web/src/features/voting/components/QuickVoteControl.tsx` | Vote UI component (all states, responsive) |
+| Create | `apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx` | Component tests |
+| Create | `apps/web/src/features/voting/components/DistributionBar.tsx` | Animated three-segment bar |
+| Create | `apps/web/src/features/voting/components/__tests__/DistributionBar.test.tsx` | Bar component tests |
+| Create | `apps/web/src/features/voting/components/QuickVoteControl.stories.tsx` | Storybook stories for all states |
+| Create | `apps/web/src/features/voting/components/DistributionBar.stories.tsx` | Storybook stories for bar |
+| Modify | `apps/web/src/shared/lib/supabase.ts` | Add `getAccuracyDistribution()` function |
+| Modify | `apps/web/src/shared/lib/database.types.ts` | Regenerate after migration |
+| Modify | `apps/web/src/features/voting/hooks/index.ts` | Export useQuickVote |
+| Modify | `apps/web/src/features/voting/components/index.ts` | Export QuickVoteControl, DistributionBar |
+| Modify | `apps/web/src/features/synergies/components/SynergyDetailModal.tsx` | Integrate QuickVoteControl below tier label |
+| Modify | `apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx` | Add integration tests |
+
+---
+
+### Task 1: Supabase Migration — accuracy distribution columns
+
+**Files:**
+- Create: `supabase/migrations/20260406000001_accuracy_distribution.sql`
+
+- [ ] **Step 1: Write the migration**
+
+```sql
+-- Replace pair_scores view to add per-bucket accuracy breakdown
+create or replace view pair_scores as
+select
+ card_a_id,
+ card_b_id,
+ count(*) as total_votes,
+ count(score) as score_votes,
+ count(accuracy) as accuracy_votes,
+
+ internal.trimmed_mean(
+ array_agg(score::numeric) filter (where score is not null)
+ )::numeric(4,2) as avg_score,
+
+ avg(accuracy)::numeric(3,2) as accuracy_sentiment,
+ count(*) filter (where accuracy = -1) as accuracy_lower,
+ count(*) filter (where accuracy = 0) as accuracy_right,
+ count(*) filter (where accuracy = 1) as accuracy_higher,
+ avg(is_real::int)::numeric(3,2) as pct_real,
+ avg(would_play::int)::numeric(3,2) as pct_would_play,
+ count(*) filter (where who_carries = 'a') as carries_a,
+ count(*) filter (where who_carries = 'b') as carries_b,
+ count(*) filter (where who_carries = 'both') as carries_both,
+ avg(difficulty)::numeric(3,2) as avg_difficulty
+
+from votes
+group by card_a_id, card_b_id;
+```
+
+- [ ] **Step 2: Apply the migration via Supabase MCP**
+
+Use `apply_migration` MCP tool with the SQL above. Then verify with `execute_sql`:
+
+```sql
+select column_name from information_schema.columns
+where table_name = 'pair_scores'
+and column_name like 'accuracy_%'
+order by column_name;
+```
+
+Expected: `accuracy_higher`, `accuracy_lower`, `accuracy_right`, `accuracy_sentiment`, `accuracy_votes`
+
+- [ ] **Step 3: Regenerate TypeScript types**
+
+Use Supabase MCP `generate_typescript_types` tool. Save output to `apps/web/src/shared/lib/database.types.ts`. Verify the `pair_scores` Row type now includes `accuracy_lower: number | null`, `accuracy_right: number | null`, `accuracy_higher: number | null`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add supabase/migrations/20260406000001_accuracy_distribution.sql apps/web/src/shared/lib/database.types.ts
+git commit -m "feat(supabase): add accuracy distribution columns to pair_scores view (#212)"
+```
+
+---
+
+### Task 2: Supabase client — getAccuracyDistribution
+
+**Files:**
+- Modify: `apps/web/src/shared/lib/supabase.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Create a test inline in the existing supabase test file or a new one. Since supabase.ts doesn't have tests yet (it's a thin client), we'll test `getAccuracyDistribution` through the `useQuickVote` hook tests in Task 4. For now, add the function.
+
+- [ ] **Step 2: Add AccuracyDistribution type and function**
+
+Add to `apps/web/src/shared/lib/supabase.ts` after the `getPairScore` function:
+
+```typescript
+export type AccuracyDistribution = {
+ lower: number;
+ right: number;
+ higher: number;
+ total: number;
+};
+
+export async function getAccuracyDistribution(
+ cardA: string,
+ cardB: string,
+): Promise {
+ const supabase = getSupabase();
+ if (!supabase) return null;
+
+ const [a, b] = [cardA, cardB].sort();
+
+ try {
+ const { data, error } = await supabase
+ .from('pair_scores')
+ .select('accuracy_lower, accuracy_right, accuracy_higher')
+ .eq('card_a_id', a)
+ .eq('card_b_id', b)
+ .single();
+
+ if (error) {
+ if (error.code === 'PGRST116') return null;
+ console.error('[getAccuracyDistribution] Supabase query failed:', {
+ code: error.code,
+ message: error.message,
+ pair: `${a} / ${b}`,
+ });
+ return null;
+ }
+
+ const lower = data.accuracy_lower ?? 0;
+ const right = data.accuracy_right ?? 0;
+ const higher = data.accuracy_higher ?? 0;
+
+ return { lower, right, higher, total: lower + right + higher };
+ } catch (e) {
+ console.error('[getAccuracyDistribution] Network error:', e);
+ return null;
+ }
+}
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add apps/web/src/shared/lib/supabase.ts
+git commit -m "feat(supabase): add getAccuracyDistribution query (#212)"
+```
+
+---
+
+### Task 3: DistributionBar component
+
+**Files:**
+- Create: `apps/web/src/features/voting/components/DistributionBar.tsx`
+- Create: `apps/web/src/features/voting/components/__tests__/DistributionBar.test.tsx`
+- Create: `apps/web/src/features/voting/components/DistributionBar.stories.tsx`
+
+- [ ] **Step 1: Write failing tests**
+
+```typescript
+// DistributionBar.test.tsx
+import {describe, it, expect} from 'vitest';
+import {render, screen} from '@testing-library/react';
+import {DistributionBar} from '../DistributionBar';
+
+describe('DistributionBar', () => {
+ it('renders three segments with percentages', () => {
+ render();
+
+ expect(screen.getByText('15%')).toBeInTheDocument();
+ expect(screen.getByText('70%')).toBeInTheDocument();
+ // Two segments show 15%
+ expect(screen.getAllByText('15%')).toHaveLength(2);
+ });
+
+ it('renders labels below the bar', () => {
+ render();
+
+ expect(screen.getByText('Should be lower')).toBeInTheDocument();
+ expect(screen.getByText('About right')).toBeInTheDocument();
+ expect(screen.getByText('Should be higher')).toBeInTheDocument();
+ });
+
+ it('handles all votes in one bucket', () => {
+ render();
+
+ expect(screen.getByText('100%')).toBeInTheDocument();
+ });
+
+ it('handles single vote', () => {
+ render();
+
+ expect(screen.getByText('100%')).toBeInTheDocument();
+ });
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `pnpm --filter inkweave-web vitest run src/features/voting/components/__tests__/DistributionBar.test.tsx`
+Expected: FAIL — module not found
+
+- [ ] **Step 3: Implement DistributionBar**
+
+```typescript
+// DistributionBar.tsx
+import {COLORS, FONT_SIZES} from '../../../shared/constants';
+
+interface DistributionBarProps {
+ lower: number;
+ right: number;
+ higher: number;
+ animate: boolean;
+}
+
+const SEGMENT_COLORS = {
+ lower: {bg: 'rgba(245, 144, 144, 0.25)', text: '#f59090'},
+ right: {bg: 'rgba(110, 231, 160, 0.2)', text: '#6ee7a0'},
+ higher: {bg: 'rgba(96, 181, 245, 0.2)', text: '#60b5f5'},
+} as const;
+
+const LABELS = {
+ lower: 'Should be lower',
+ right: 'About right',
+ higher: 'Should be higher',
+} as const;
+
+export function DistributionBar({lower, right, higher, animate}: DistributionBarProps) {
+ const total = lower + right + higher;
+ if (total === 0) return null;
+
+ const pct = {
+ lower: Math.round((lower / total) * 100),
+ right: Math.round((right / total) * 100),
+ higher: Math.round((higher / total) * 100),
+ };
+
+ // Fix rounding to sum to 100
+ const diff = 100 - pct.lower - pct.right - pct.higher;
+ if (diff !== 0) {
+ const largest = Object.entries(pct).sort(([, a], [, b]) => b - a)[0][0] as keyof typeof pct;
+ pct[largest] += diff;
+ }
+
+ const segments = (['lower', 'right', 'higher'] as const).filter((key) => pct[key] > 0);
+
+ return (
+
+
+ {segments.map((key, i) => (
+
1
+ ? '6px 0 0 6px'
+ : i === segments.length - 1 && segments.length > 1
+ ? '0 6px 6px 0'
+ : segments.length === 1
+ ? '6px'
+ : undefined,
+ transition: animate ? 'width 500ms ease-out' : undefined,
+ transitionDelay: animate ? `${i * 50}ms` : undefined,
+ }}>
+ {pct[key]}%
+
+ ))}
+
+
+ {LABELS.lower}
+ {LABELS.right}
+ {LABELS.higher}
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `pnpm --filter inkweave-web vitest run src/features/voting/components/__tests__/DistributionBar.test.tsx`
+Expected: PASS (4 tests)
+
+- [ ] **Step 5: Write stories**
+
+```typescript
+// DistributionBar.stories.tsx
+import type {Meta, StoryObj} from '@storybook/react-vite';
+import {DistributionBar} from './DistributionBar';
+
+const meta = {
+ title: 'Voting/DistributionBar',
+ component: DistributionBar,
+ parameters: {layout: 'centered'},
+ tags: ['autodocs'],
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Balanced: Story = {
+ args: {lower: 15, right: 68, higher: 17, animate: false},
+};
+
+export const Animated: Story = {
+ args: {lower: 15, right: 68, higher: 17, animate: true},
+};
+
+export const Unanimous: Story = {
+ args: {lower: 0, right: 25, higher: 0, animate: false},
+};
+
+export const Controversial: Story = {
+ args: {lower: 12, right: 5, higher: 13, animate: false},
+};
+
+export const SingleVote: Story = {
+ args: {lower: 0, right: 1, higher: 0, animate: false},
+};
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add apps/web/src/features/voting/components/DistributionBar.tsx \
+ apps/web/src/features/voting/components/__tests__/DistributionBar.test.tsx \
+ apps/web/src/features/voting/components/DistributionBar.stories.tsx
+git commit -m "feat(voting): add DistributionBar component (#212)"
+```
+
+---
+
+### Task 4: useQuickVote hook
+
+**Files:**
+- Create: `apps/web/src/features/voting/hooks/useQuickVote.ts`
+- Create: `apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts`
+
+- [ ] **Step 1: Write failing tests**
+
+```typescript
+// useQuickVote.test.ts
+import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
+import {renderHook, act} from '@testing-library/react';
+import {useQuickVote} from '../useQuickVote';
+import {getSupabase, submitVote, getAccuracyDistribution} from '../../../../shared/lib/supabase';
+
+vi.mock('../../../../shared/lib/supabase', () => ({
+ getSupabase: vi.fn(),
+ submitVote: vi.fn(),
+ getAccuracyDistribution: vi.fn(),
+}));
+
+const CARD_A = 'card-aaa';
+const CARD_B = 'card-bbb';
+// Canonical key: sorted
+const STORAGE_KEY = `inkweave:vote:${CARD_A}:${CARD_B}`;
+
+describe('useQuickVote', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ it('returns hidden state when Supabase is unavailable', () => {
+ vi.mocked(getSupabase).mockReturnValue(null);
+ const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B));
+ expect(result.current.state).toBe('hidden');
+ });
+
+ it('returns ready state when Supabase is available and no prior vote', () => {
+ vi.mocked(getSupabase).mockReturnValue({} as ReturnType);
+ const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B));
+ expect(result.current.state).toBe('ready');
+ });
+
+ it('returns result state when pair found in localStorage', async () => {
+ vi.mocked(getSupabase).mockReturnValue({} as ReturnType);
+ vi.mocked(getAccuracyDistribution).mockResolvedValue({
+ lower: 3, right: 10, higher: 2, total: 15,
+ });
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({accuracy: 0, timestamp: Date.now()}));
+
+ const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B));
+ expect(result.current.state).toBe('result');
+ expect(result.current.userChoice).toBe(0);
+ });
+
+ it('submits vote, stores in localStorage, and fetches distribution', async () => {
+ vi.mocked(getSupabase).mockReturnValue({} as ReturnType);
+ vi.mocked(submitVote).mockResolvedValue({error: null});
+ vi.mocked(getAccuracyDistribution).mockResolvedValue({
+ lower: 1, right: 5, higher: 0, total: 6,
+ });
+
+ const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B));
+ expect(result.current.state).toBe('ready');
+
+ await act(async () => {
+ await result.current.vote(0);
+ });
+
+ expect(submitVote).toHaveBeenCalledWith({
+ cardA: CARD_A,
+ cardB: CARD_B,
+ accuracy: 0,
+ });
+ expect(result.current.state).toBe('result');
+ expect(result.current.userChoice).toBe(0);
+ expect(result.current.distribution).toEqual({
+ lower: 1, right: 5, higher: 0, total: 6,
+ });
+ expect(localStorage.getItem(STORAGE_KEY)).toBeTruthy();
+ });
+
+ it('transitions to error state on submission failure', async () => {
+ vi.mocked(getSupabase).mockReturnValue({} as ReturnType);
+ vi.mocked(submitVote).mockResolvedValue({error: 'Network error'});
+
+ const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B));
+
+ await act(async () => {
+ await result.current.vote(1);
+ });
+
+ expect(result.current.state).toBe('error');
+ expect(result.current.error).toBe('error');
+ expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
+ });
+
+ it('transitions to rate-limited state', async () => {
+ vi.mocked(getSupabase).mockReturnValue({} as ReturnType);
+ vi.mocked(submitVote).mockResolvedValue({error: 'rate_limited'});
+
+ const {result} = renderHook(() => useQuickVote(CARD_A, CARD_B));
+
+ await act(async () => {
+ await result.current.vote(-1);
+ });
+
+ expect(result.current.state).toBe('error');
+ expect(result.current.error).toBe('rate_limited');
+ });
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `pnpm --filter inkweave-web vitest run src/features/voting/hooks/__tests__/useQuickVote.test.ts`
+Expected: FAIL — module not found
+
+- [ ] **Step 3: Implement useQuickVote**
+
+```typescript
+// useQuickVote.ts
+import {useState, useEffect, useCallback, useMemo} from 'react';
+import {
+ getSupabase,
+ submitVote,
+ getAccuracyDistribution,
+ type AccuracyDistribution,
+} from '../../../shared/lib/supabase';
+
+export type QuickVoteState = 'hidden' | 'ready' | 'submitting' | 'result' | 'error';
+export type QuickVoteError = 'error' | 'rate_limited' | null;
+type Accuracy = -1 | 0 | 1;
+
+function storageKey(cardA: string, cardB: string): string {
+ const [a, b] = [cardA, cardB].sort();
+ return `inkweave:vote:${a}:${b}`;
+}
+
+function getStoredVote(cardA: string, cardB: string): Accuracy | null {
+ try {
+ const raw = localStorage.getItem(storageKey(cardA, cardB));
+ if (!raw) return null;
+ const parsed = JSON.parse(raw);
+ return parsed.accuracy ?? null;
+ } catch {
+ return null;
+ }
+}
+
+function storeVote(cardA: string, cardB: string, accuracy: Accuracy): void {
+ localStorage.setItem(
+ storageKey(cardA, cardB),
+ JSON.stringify({accuracy, timestamp: Date.now()}),
+ );
+}
+
+export interface UseQuickVoteReturn {
+ state: QuickVoteState;
+ vote: (accuracy: Accuracy) => Promise;
+ distribution: AccuracyDistribution | null;
+ userChoice: Accuracy | null;
+ error: QuickVoteError;
+}
+
+export function useQuickVote(cardA: string, cardB: string): UseQuickVoteReturn {
+ const isAvailable = useMemo(() => getSupabase() !== null, []);
+ const storedChoice = useMemo(() => getStoredVote(cardA, cardB), [cardA, cardB]);
+
+ const [state, setState] = useState(() => {
+ if (!isAvailable) return 'hidden';
+ if (storedChoice !== null) return 'result';
+ return 'ready';
+ });
+ const [userChoice, setUserChoice] = useState(storedChoice);
+ const [distribution, setDistribution] = useState(null);
+ const [error, setError] = useState(null);
+
+ // Fetch distribution for returning voters
+ useEffect(() => {
+ if (state === 'result' && !distribution) {
+ getAccuracyDistribution(cardA, cardB).then((dist) => {
+ if (dist) setDistribution(dist);
+ });
+ }
+ }, [state, distribution, cardA, cardB]);
+
+ const vote = useCallback(
+ async (accuracy: Accuracy) => {
+ if (state !== 'ready' && state !== 'error') return;
+
+ setState('submitting');
+ setError(null);
+
+ const result = await submitVote({cardA, cardB, accuracy});
+
+ if (result.error === null) {
+ storeVote(cardA, cardB, accuracy);
+ setUserChoice(accuracy);
+ setState('result');
+ const dist = await getAccuracyDistribution(cardA, cardB);
+ if (dist) setDistribution(dist);
+ } else if (result.error === 'rate_limited') {
+ setError('rate_limited');
+ setState('error');
+ } else {
+ setError('error');
+ setState('error');
+ }
+ },
+ [cardA, cardB, state],
+ );
+
+ return {state, vote, distribution, userChoice, error};
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `pnpm --filter inkweave-web vitest run src/features/voting/hooks/__tests__/useQuickVote.test.ts`
+Expected: PASS (6 tests)
+
+- [ ] **Step 5: Export from hooks index**
+
+Add to `apps/web/src/features/voting/hooks/index.ts`:
+
+```typescript
+export {useQuickVote} from './useQuickVote';
+export type {UseQuickVoteReturn, QuickVoteState, QuickVoteError} from './useQuickVote';
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add apps/web/src/features/voting/hooks/useQuickVote.ts \
+ apps/web/src/features/voting/hooks/__tests__/useQuickVote.test.ts \
+ apps/web/src/features/voting/hooks/index.ts
+git commit -m "feat(voting): add useQuickVote hook (#212)"
+```
+
+---
+
+### Task 5: QuickVoteControl component
+
+**Files:**
+- Create: `apps/web/src/features/voting/components/QuickVoteControl.tsx`
+- Create: `apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx`
+- Create: `apps/web/src/features/voting/components/QuickVoteControl.stories.tsx`
+
+- [ ] **Step 1: Write failing tests**
+
+```typescript
+// QuickVoteControl.test.tsx
+import {describe, it, expect, vi} from 'vitest';
+import {render, screen, fireEvent} from '@testing-library/react';
+import {QuickVoteControl} from '../QuickVoteControl';
+import type {AccuracyDistribution} from '../../../../shared/lib/supabase';
+
+const mockVote = vi.fn();
+
+describe('QuickVoteControl', () => {
+ it('renders nothing when state is hidden', () => {
+ const {container} = render(
+ ,
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders three vote buttons in ready state', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('Do you agree with this score?')).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'Should be lower'})).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'About right'})).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'Should be higher'})).toBeInTheDocument();
+ });
+
+ it('calls onVote with -1 when "Should be lower" is clicked', () => {
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', {name: 'Should be lower'}));
+ expect(mockVote).toHaveBeenCalledWith(-1);
+ });
+
+ it('calls onVote with 0 when "About right" is clicked', () => {
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', {name: 'About right'}));
+ expect(mockVote).toHaveBeenCalledWith(0);
+ });
+
+ it('calls onVote with 1 when "Should be higher" is clicked', () => {
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', {name: 'Should be higher'}));
+ expect(mockVote).toHaveBeenCalledWith(1);
+ });
+
+ it('disables buttons during submitting state', () => {
+ render(
+ ,
+ );
+ const buttons = screen.getAllByRole('button');
+ buttons.forEach((btn) => expect(btn).toBeDisabled());
+ });
+
+ it('shows distribution bar and confirmation in result state', () => {
+ const dist: AccuracyDistribution = {lower: 3, right: 14, higher: 3, total: 20};
+ render(
+ ,
+ );
+ expect(screen.getByText(/You voted:/)).toBeInTheDocument();
+ expect(screen.getByText('About right')).toBeInTheDocument();
+ expect(screen.getByText('20 votes on this pair')).toBeInTheDocument();
+ });
+
+ it('shows first voter badge when total is 1', () => {
+ const dist: AccuracyDistribution = {lower: 0, right: 1, higher: 0, total: 1};
+ render(
+ ,
+ );
+ expect(screen.getByText('First to rate this pair!')).toBeInTheDocument();
+ });
+
+ it('shows error message and re-enables buttons on error', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('Something went wrong, try again')).toBeInTheDocument();
+ const buttons = screen.getAllByRole('button').filter((b) => !b.textContent?.includes('Rate'));
+ buttons.forEach((btn) => expect(btn).not.toBeDisabled());
+ });
+
+ it('shows rate limit message on rate_limited error', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("You're voting fast! Try again in a bit.")).toBeInTheDocument();
+ });
+
+ it('shows disabled "Rate in detail" teaser', () => {
+ render(
+ ,
+ );
+ const teaser = screen.getByText(/Rate in detail/);
+ expect(teaser).toBeInTheDocument();
+ });
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `pnpm --filter inkweave-web vitest run src/features/voting/components/__tests__/QuickVoteControl.test.tsx`
+Expected: FAIL — module not found
+
+- [ ] **Step 3: Implement QuickVoteControl**
+
+This is a TODO(human) step — see the Learn by Doing request below.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `pnpm --filter inkweave-web vitest run src/features/voting/components/__tests__/QuickVoteControl.test.tsx`
+Expected: PASS (10 tests)
+
+- [ ] **Step 5: Write stories**
+
+```typescript
+// QuickVoteControl.stories.tsx
+import type {Meta, StoryObj} from '@storybook/react-vite';
+import {fn} from 'storybook/test';
+import {QuickVoteControl} from './QuickVoteControl';
+
+const meta = {
+ title: 'Voting/QuickVoteControl',
+ component: QuickVoteControl,
+ parameters: {layout: 'centered'},
+ tags: ['autodocs'],
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ args: {onVote: fn()},
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Ready: Story = {
+ args: {state: 'ready', distribution: null, userChoice: null, error: null},
+};
+
+export const Submitting: Story = {
+ args: {state: 'submitting', distribution: null, userChoice: 0, error: null},
+};
+
+export const Result: Story = {
+ args: {
+ state: 'result',
+ distribution: {lower: 15, right: 68, higher: 17, total: 100},
+ userChoice: 0,
+ error: null,
+ },
+};
+
+export const ResultFirstVoter: Story = {
+ args: {
+ state: 'result',
+ distribution: {lower: 0, right: 1, higher: 0, total: 1},
+ userChoice: 0,
+ error: null,
+ },
+};
+
+export const Error: Story = {
+ args: {state: 'error', distribution: null, userChoice: null, error: 'error'},
+};
+
+export const RateLimited: Story = {
+ args: {state: 'error', distribution: null, userChoice: null, error: 'rate_limited'},
+};
+
+export const Mobile: Story = {
+ args: {state: 'ready', distribution: null, userChoice: null, error: null},
+ parameters: {viewport: {defaultViewport: 'mobile1'}},
+};
+
+export const MobileResult: Story = {
+ args: {
+ state: 'result',
+ distribution: {lower: 8, right: 30, higher: 12, total: 50},
+ userChoice: 1,
+ error: null,
+ },
+ parameters: {viewport: {defaultViewport: 'mobile1'}},
+};
+```
+
+- [ ] **Step 6: Export from components index**
+
+Add to `apps/web/src/features/voting/components/index.ts`:
+
+```typescript
+export {QuickVoteControl} from './QuickVoteControl';
+export {DistributionBar} from './DistributionBar';
+```
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add apps/web/src/features/voting/components/QuickVoteControl.tsx \
+ apps/web/src/features/voting/components/__tests__/QuickVoteControl.test.tsx \
+ apps/web/src/features/voting/components/QuickVoteControl.stories.tsx \
+ apps/web/src/features/voting/components/index.ts
+git commit -m "feat(voting): add QuickVoteControl component (#212)"
+```
+
+---
+
+### Task 6: Integrate into SynergyDetailModal
+
+**Files:**
+- Modify: `apps/web/src/features/synergies/components/SynergyDetailModal.tsx`
+- Modify: `apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx`
+
+- [ ] **Step 1: Write failing integration test**
+
+Add to the existing `SynergyDetailModal.test.tsx`:
+
+```typescript
+// Add mock at top of file with other mocks
+vi.mock('../../../features/voting/hooks/useQuickVote', () => ({
+ useQuickVote: vi.fn().mockReturnValue({
+ state: 'ready',
+ vote: vi.fn(),
+ distribution: null,
+ userChoice: null,
+ error: null,
+ }),
+}));
+
+// Add test
+it('renders quick vote control below tier label', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('Do you agree with this score?')).toBeInTheDocument();
+});
+```
+
+- [ ] **Step 2: Run tests to verify it fails**
+
+Run: `pnpm --filter inkweave-web vitest run src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx`
+Expected: FAIL — "Do you agree with this score?" not found
+
+- [ ] **Step 3: Add QuickVoteControl to SynergyDetailModal**
+
+In `SynergyDetailModal.tsx`, add import at top:
+
+```typescript
+import {QuickVoteControl} from '../../voting/components';
+import {useQuickVote} from '../../voting/hooks';
+```
+
+Inside the `SynergyDetailModal` function, after destructuring `pair`, add the hook call:
+
+```typescript
+const quickVote = useQuickVote(cardA.id, cardB.id);
+```
+
+Then insert `QuickVoteControl` between the tier label `` (line 152) and the connections section (line 154):
+
+```tsx
+ {/* Quick vote */}
+
+
+
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `pnpm --filter inkweave-web vitest run src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx`
+Expected: PASS
+
+- [ ] **Step 5: Run full test suite**
+
+Run: `pnpm test`
+Expected: All tests pass (engine 195 + web 541+ new tests)
+
+- [ ] **Step 6: Visual verification**
+
+Start dev server (`pnpm dev`), navigate to a card with synergies, click a synergy card to open the modal. Verify:
+- Vote section appears below "Strong Synergy" label
+- Three buttons render correctly
+- Clicking a button submits (check Network tab for Supabase RPC call)
+- Distribution bar appears after voting
+- On mobile viewport, buttons stack vertically
+
+Use Chrome DevTools MCP screenshot to verify.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add apps/web/src/features/synergies/components/SynergyDetailModal.tsx \
+ apps/web/src/features/synergies/components/__tests__/SynergyDetailModal.test.tsx
+git commit -m "feat(voting): integrate quick vote into synergy detail modal (#212)"
+```
+
+---
+
+## Summary
+
+| Task | What | Tests | Commit |
+|------|------|-------|--------|
+| 1 | Supabase migration: accuracy distribution columns | Verified via SQL query | `feat(supabase): add accuracy distribution columns` |
+| 2 | `getAccuracyDistribution()` in supabase.ts | Tested via hook tests | `feat(supabase): add getAccuracyDistribution query` |
+| 3 | DistributionBar component + stories | 4 unit tests | `feat(voting): add DistributionBar component` |
+| 4 | useQuickVote hook | 6 unit tests | `feat(voting): add useQuickVote hook` |
+| 5 | QuickVoteControl component + stories | 10 unit tests | `feat(voting): add QuickVoteControl component` |
+| 6 | Integrate into SynergyDetailModal | 1 integration test + visual | `feat(voting): integrate quick vote into synergy detail modal` |
diff --git a/docs/superpowers/specs/2026-04-06-quick-vote-design.md b/docs/superpowers/specs/2026-04-06-quick-vote-design.md
new file mode 100644
index 0000000..4f08df6
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-06-quick-vote-design.md
@@ -0,0 +1,264 @@
+# Synergy Modal Quick Vote — Design Spec (#212)
+
+**Issue**: [#212](https://github.com/Doberjohn/inkweave/issues/212)
+**Epic**: Community Voting (#205)
+**Date**: 2026-04-06
+
+## Overview
+
+Add a lightweight three-button voting control to the `SynergyDetailModal`. Users give directional feedback on the algorithm's synergy score — "Should be lower" / "About right" / "Should be higher". After voting, an animated distribution bar reveals how the community voted, creating a social feedback loop.
+
+**Scope**: Modal UI component, new hook, Supabase migration (distribution columns), localStorage persistence for voted pairs.
+
+**Out of scope**: "Rate in Detail" navigation (disabled teaser only — enabled by #213), user authentication, vote editing UI.
+
+## UI Layout
+
+### Placement
+
+Below the tier label ("Strong Synergy"), above the connections list. Wrapped in a subtle gold-bordered container (`rgba(212, 175, 55, 0.04)` background, `rgba(212, 175, 55, 0.12)` border).
+
+### Desktop
+
+Three buttons in a horizontal row. Full labels:
+- "Should be lower" → `accuracy: -1`
+- "About right" → `accuracy: 0`
+- "Should be higher" → `accuracy: 1`
+
+Question text above: "Do you agree with this score?"
+
+Disabled "Rate in detail →" teaser below the buttons.
+
+### Mobile
+
+Three buttons in a vertical stack. Same labels, same wording. Full-width, `min-height: 44px` touch targets. To be validated during implementation.
+
+## State Machine
+
+```
+ ┌──────────────────┐
+ │ Hidden │ ← Supabase unavailable
+ └──────────────────┘
+
+ Modal opens ──→ localStorage check
+ │
+ ┌───────┴────────┐
+ │ │
+ not found found
+ │ │
+ ▼ ▼
+ ┌──────────┐ ┌──────────────┐
+ │ Ready │ │ Result │ ← fetch fresh distribution
+ └────┬─────┘ └──────────────┘
+ │ click
+ ▼
+ ┌──────────┐
+ │Submitting│
+ └────┬─────┘
+ │
+ ┌────┴────┐
+ │ │
+ success error
+ │ │
+ ▼ ▼
+┌────────┐ ┌──────┐
+│ Result │ │Error │ → buttons re-enable → Ready
+└────────┘ └──────┘
+```
+
+### State details
+
+| State | Trigger | UI |
+|-------|---------|-----|
+| **Hidden** | `getSupabase() === null` | Vote section not rendered |
+| **Ready** | Modal opens, pair not in localStorage | Question + 3 buttons + disabled "Rate in detail" |
+| **Submitting** | User clicks a vote button | Selected button: scale(1.05) + glow + checkmark. Others: opacity 0.3, disabled |
+| **Result** | Vote succeeds OR pair in localStorage | "Thanks! You voted: {choice}" + distribution bar + vote count + disabled "Rate in detail" |
+| **Error** | Submission fails | Inline "Something went wrong, try again" — buttons re-enable |
+| **Rate Limited** | 30 votes/hour exceeded | Inline "You're voting fast! Try again in a bit." — buttons stay disabled |
+
+## Post-Vote: Distribution Bar
+
+After voting, the three buttons collapse and are replaced by:
+
+1. **Confirmation**: "Thanks! You voted: {choice}" with gold star `✦`
+2. **Distribution bar**: Three colored segments showing community vote percentages
+ - Lower: `#f59090` (red, 25% opacity background)
+ - About right: `#6ee7a0` (green, 20% opacity background)
+ - Higher: `#60b5f5` (blue, 20% opacity background)
+3. **Vote count**: "{N} votes on this pair"
+4. **Disabled "Rate in detail →"** teaser
+
+### First voter
+
+When total votes = 1 (the user is the first), show:
+- Confirmation line (same as above)
+- Gold badge: "First to rate this pair!" with `✦` pulse animation
+- "Distribution will show after more votes"
+
+No minimum vote threshold for showing the distribution bar — it shows at 2+ votes. At exactly 1 vote (the user is the only voter), show the first voter badge instead. On return visits, fetch fresh distribution: if total is now > 1, show the bar; if still 1, show the badge again.
+
+## Animation
+
+### Button press (Ready → Submitting)
+
+- Selected button: `transform: scale(1.05)`, border shifts to distribution color, `box-shadow` glow, checkmark replaces text
+- Other buttons: `opacity: 0.3`, `pointer-events: none`
+- Duration: `200ms ease-out`
+
+### Result reveal (Submitting → Result)
+
+- Container height animates smoothly (no layout jump)
+- "Thanks! You voted:" fades in: `opacity 0→1, 300ms`
+- Distribution bar segments grow from `width: 0` to final percentage: `500ms ease-out`, staggered 50ms per segment
+- Vote count fades in after bar completes
+
+### First voter badge
+
+Gold star `✦` single pulse: `scale 1→1.15→1`, plays once.
+
+### Returning pair (localStorage hit)
+
+Skip to Result immediately — no animation. Distribution bar renders at full width. Fresh distribution fetched in background, updates silently if numbers changed.
+
+### No toast
+
+Unlike the random pair voting page, quick vote uses inline feedback only. A toast on top of a modal is noisy.
+
+## Data Layer
+
+### Submission
+
+Use existing `submitVote()` from `shared/lib/supabase.ts` with `QuickVote` type:
+
+```typescript
+submitVote({ cardA: pair.cardA.id, cardB: pair.cardB.id, accuracy: -1 | 0 | 1 })
+```
+
+No changes needed to the `submit_vote` RPC.
+
+### Supabase migration
+
+Add three columns to the `pair_scores` view for per-bucket accuracy breakdown:
+
+```sql
+count(*) filter (where accuracy = -1) as accuracy_lower,
+count(*) filter (where accuracy = 0) as accuracy_right,
+count(*) filter (where accuracy = 1) as accuracy_higher
+```
+
+### Distribution read
+
+New function in `supabase.ts`:
+
+```typescript
+getAccuracyDistribution(cardA: string, cardB: string): Promise<{
+ lower: number;
+ right: number;
+ higher: number;
+ total: number;
+} | null>
+```
+
+Queries `pair_scores` view for the three counts. Returns null if Supabase unavailable or query fails.
+
+### localStorage
+
+- **Key**: `inkweave:vote:{sortedCardA}:{sortedCardB}` (canonical pair ordering matches Supabase)
+- **Value**: `{ accuracy: -1 | 0 | 1, timestamp: number }`
+- Checked on modal open to determine initial state
+- Written after successful submission
+- No expiry (until user auth lands)
+
+## New Files
+
+### `apps/web/src/features/voting/hooks/useQuickVote.ts`
+
+Custom hook encapsulating the full quick vote lifecycle:
+
+- Check localStorage for existing vote on mount
+- `vote(accuracy)` — submit, store in localStorage, fetch distribution
+- Manage state machine (hidden/ready/submitting/result/error)
+- Expose: `state`, `vote`, `distribution`, `userChoice`, `error`
+
+Separate from `useVoteSession` (which is specific to the random pair page).
+
+### `apps/web/src/features/voting/components/QuickVoteControl.tsx`
+
+Presentational component rendering the vote UI based on state. Props:
+
+- `state`: current state from hook
+- `onVote(accuracy)`: callback
+- `distribution`: vote counts (if available)
+- `userChoice`: the user's vote (for result display)
+- `error`: error type (for message display)
+
+Handles desktop (horizontal) and mobile (vertical) layouts via `useResponsive()`.
+
+### `apps/web/src/features/voting/components/DistributionBar.tsx`
+
+Reusable animated bar component. Props:
+
+- `lower`, `right`, `higher`: vote counts
+- `animate`: boolean (true for fresh votes, false for returning pairs)
+
+### `supabase/migrations/YYYYMMDD_add_accuracy_distribution.sql`
+
+Migration adding accuracy_lower/right/higher to pair_scores view.
+
+## Modified Files
+
+- `SynergyDetailModal.tsx` — import and render `QuickVoteControl` between tier label and connections
+- `supabase.ts` — add `getAccuracyDistribution()` function
+- `database.types.ts` — regenerate after migration (adds new view columns)
+- `features/voting/hooks/index.ts` — export `useQuickVote`
+- `features/voting/components/index.ts` — export `QuickVoteControl`, `DistributionBar`
+
+## Testing
+
+### Unit tests (`useQuickVote`)
+
+- Returns hidden state when Supabase unavailable
+- Returns result state when pair found in localStorage
+- Submits accuracy value and transitions to result on success
+- Handles error and rate-limit states correctly
+- Fetches distribution after successful vote
+- Stores vote in localStorage after success
+
+### Component tests (`QuickVoteControl`)
+
+- Renders three buttons in ready state
+- Hides entirely when state is hidden
+- Disables buttons during submission
+- Shows distribution bar in result state
+- Shows first voter badge when total = 1
+- Shows error message on failure, re-enables buttons
+- Vertical layout on mobile, horizontal on desktop
+
+### Integration (within `SynergyDetailModal`)
+
+- Vote section appears below tier label
+- Opening modal for previously-voted pair shows result state
+
+### Storybook
+
+`QuickVoteControl.stories.tsx` with stories for: ready, submitting, result, result-first-voter, error, rate-limited, hidden.
+
+### E2E
+
+Skipped — quick vote hits real Supabase without test environment isolation. Unit/component tests cover the interaction flow. Voting E2E is covered by the random pair page tests.
+
+## Decisions Log
+
+| Decision | Choice | Rationale |
+|----------|--------|-----------|
+| Placement | Below tier label | Prominent, gut-reaction before reading details |
+| Button labels | "Should be lower/higher" | Unambiguous, references the score directionally |
+| Mobile layout | Vertical stack | Better touch targets, validate during implementation |
+| Post-vote reveal | Distribution bar | Gamification — community signal drives engagement |
+| First voter | Gold badge | Rewards early participation |
+| Returning pairs | localStorage → result state | Avoids "didn't I already vote?" confusion |
+| Supabase down | Hide section | Per issue spec, no broken UI |
+| "Rate in Detail" | Disabled teaser | Placeholder for #213 |
+| Vote threshold | None | Show distribution from 1 vote |
+| Separate hook | `useQuickVote` | Different lifecycle from `useVoteSession` |
diff --git a/supabase/migrations/20260406000001_add_accuracy_distribution.sql b/supabase/migrations/20260406000001_add_accuracy_distribution.sql
new file mode 100644
index 0000000..bfb0727
--- /dev/null
+++ b/supabase/migrations/20260406000001_add_accuracy_distribution.sql
@@ -0,0 +1,30 @@
+drop view if exists pair_scores;
+
+create view pair_scores with (security_invoker = true) as
+select
+ card_a_id,
+ card_b_id,
+ count(*) as total_votes,
+ count(score) as score_votes,
+ count(accuracy) as accuracy_votes,
+
+ internal.trimmed_mean(
+ array_agg(score::numeric) filter (where score is not null)
+ )::numeric(4,2) as avg_score,
+
+ avg(accuracy)::numeric(3,2) as accuracy_sentiment,
+ count(*) filter (where accuracy = -1) as accuracy_lower,
+ count(*) filter (where accuracy = 0) as accuracy_right,
+ count(*) filter (where accuracy = 1) as accuracy_higher,
+ avg(is_real::int)::numeric(3,2) as pct_real,
+ avg(would_play::int)::numeric(3,2) as pct_would_play,
+ count(*) filter (where who_carries = 'a') as carries_a,
+ count(*) filter (where who_carries = 'b') as carries_b,
+ count(*) filter (where who_carries = 'both') as carries_both,
+ count(*) filter (where who_carries = 'neither') as carries_neither,
+ avg(difficulty)::numeric(3,2) as avg_difficulty
+
+from votes
+group by card_a_id, card_b_id;
+
+grant select on pair_scores to anon;