2026-01-27 16:21:58 +00:00
|
|
|
---
|
|
|
|
|
/**
|
|
|
|
|
* VotingMethodCard - Visual explainer card for a voting method
|
|
|
|
|
* Shows icon, name, description, and interactive example
|
|
|
|
|
*/
|
|
|
|
|
interface Props {
|
|
|
|
|
method: 'approval' | 'ranked_choice' | 'schulze' | 'star' | 'quadratic';
|
2026-02-06 18:31:06 +00:00
|
|
|
compact?: boolean;
|
|
|
|
|
selected?: boolean;
|
|
|
|
|
interactive?: boolean;
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { method, compact = false, selected = false, interactive = false } = Astro.props;
|
|
|
|
|
|
|
|
|
|
const methodData = {
|
|
|
|
|
approval: {
|
|
|
|
|
name: 'Approval Voting',
|
|
|
|
|
icon: 'icon-approval',
|
|
|
|
|
color: '#22c55e',
|
|
|
|
|
shortDesc: 'Select all options you approve',
|
|
|
|
|
fullDesc: 'Vote for as many options as you like. The option with the most approvals wins. Simple and reduces strategic voting.',
|
|
|
|
|
complexity: 1,
|
|
|
|
|
pros: ['Simple to understand', 'No spoiler effect', 'Encourages honest voting'],
|
|
|
|
|
cons: ['No preference intensity', 'May produce ties'],
|
|
|
|
|
},
|
|
|
|
|
ranked_choice: {
|
|
|
|
|
name: 'Ranked Choice',
|
|
|
|
|
icon: 'icon-ranked-choice',
|
|
|
|
|
color: '#3b82f6',
|
|
|
|
|
shortDesc: 'Rank options in order of preference',
|
|
|
|
|
fullDesc: 'Rank candidates from first to last choice. If no majority, lowest candidate eliminated and votes redistributed until majority.',
|
|
|
|
|
complexity: 2,
|
|
|
|
|
pros: ['Eliminates spoiler effect', 'Majority winner', 'Expresses preferences'],
|
|
|
|
|
cons: ['More complex counting', 'Non-monotonic edge cases'],
|
|
|
|
|
},
|
|
|
|
|
schulze: {
|
|
|
|
|
name: 'Schulze Method',
|
|
|
|
|
icon: 'icon-schulze',
|
|
|
|
|
color: '#8b5cf6',
|
|
|
|
|
shortDesc: 'Pairwise comparison tournament',
|
|
|
|
|
fullDesc: 'Condorcet-consistent method using strongest paths. Compares every pair of options and finds the winner who beats all others.',
|
|
|
|
|
complexity: 3,
|
|
|
|
|
pros: ['Condorcet winner guaranteed', 'Handles cycles', 'Clone-proof'],
|
|
|
|
|
cons: ['Complex to explain', 'Requires all rankings'],
|
|
|
|
|
},
|
|
|
|
|
star: {
|
|
|
|
|
name: 'STAR Voting',
|
|
|
|
|
icon: 'icon-star',
|
|
|
|
|
color: '#f59e0b',
|
|
|
|
|
shortDesc: 'Score options 0-5, top two face runoff',
|
|
|
|
|
fullDesc: 'Score Then Automatic Runoff: Rate each option 0-5 stars. Top two scoring options go to automatic runoff based on preferences.',
|
|
|
|
|
complexity: 2,
|
|
|
|
|
pros: ['Express intensity', 'Automatic runoff', 'Reduces strategy'],
|
|
|
|
|
cons: ['Two-phase complexity', 'Score interpretation varies'],
|
|
|
|
|
},
|
|
|
|
|
quadratic: {
|
|
|
|
|
name: 'Quadratic Voting',
|
|
|
|
|
icon: 'icon-quadratic',
|
|
|
|
|
color: '#ec4899',
|
|
|
|
|
shortDesc: 'Spend credits where cost = votes²',
|
|
|
|
|
fullDesc: 'Allocate credits to options. Cost grows quadratically: 1 vote = 1 credit, 2 votes = 4 credits, 3 = 9. Express intensity of preference.',
|
|
|
|
|
complexity: 3,
|
|
|
|
|
pros: ['Intensity expression', 'Prevents vote buying', 'Fair allocation'],
|
|
|
|
|
cons: ['Budget strategy', 'Math complexity'],
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const data = methodData[method];
|
|
|
|
|
const complexityBars = Array(3).fill(0).map((_, i) => i < data.complexity);
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
<div class:list={['method-card', { compact, selected, interactive }]} data-method={method}>
|
|
|
|
|
<div class="method-header">
|
|
|
|
|
<div class="method-icon" style={`--method-color: ${data.color}`}>
|
|
|
|
|
<svg class="icon icon-lg"><use href={`#${data.icon}`}/></svg>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="method-title">
|
|
|
|
|
<h3>{data.name}</h3>
|
|
|
|
|
<div class="complexity">
|
|
|
|
|
<span class="complexity-label">Complexity:</span>
|
|
|
|
|
<div class="complexity-bars">
|
|
|
|
|
{complexityBars.map((filled) => (
|
|
|
|
|
<div class:list={['complexity-bar', { filled }]} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{selected && (
|
|
|
|
|
<div class="selected-badge">
|
|
|
|
|
<svg class="icon"><use href="#icon-check"/></svg>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{!compact && (
|
|
|
|
|
<>
|
|
|
|
|
<p class="method-desc">{data.fullDesc}</p>
|
|
|
|
|
|
|
|
|
|
<div class="method-traits">
|
|
|
|
|
<div class="pros">
|
|
|
|
|
<span class="trait-label">Advantages</span>
|
|
|
|
|
<ul>
|
|
|
|
|
{data.pros.map(pro => (
|
|
|
|
|
<li>
|
|
|
|
|
<svg class="icon icon-sm"><use href="#icon-check"/></svg>
|
|
|
|
|
{pro}
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="cons">
|
|
|
|
|
<span class="trait-label">Considerations</span>
|
|
|
|
|
<ul>
|
|
|
|
|
{data.cons.map(con => (
|
|
|
|
|
<li>
|
|
|
|
|
<svg class="icon icon-sm"><use href="#icon-info"/></svg>
|
|
|
|
|
{con}
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{compact && (
|
|
|
|
|
<p class="method-short-desc">{data.shortDesc}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
.method-card {
|
|
|
|
|
background: var(--color-surface);
|
|
|
|
|
border: 2px solid var(--color-border);
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
padding: 1.25rem;
|
|
|
|
|
transition: all 0.2s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.method-card.interactive {
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.method-card.interactive:hover {
|
|
|
|
|
border-color: var(--method-color, var(--color-primary));
|
|
|
|
|
transform: translateY(-2px);
|
|
|
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.method-card.selected {
|
|
|
|
|
border-color: var(--method-color, var(--color-primary));
|
|
|
|
|
background: color-mix(in srgb, var(--method-color, var(--color-primary)) 5%, var(--color-surface));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.method-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
margin-bottom: 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.method-icon {
|
|
|
|
|
width: 48px;
|
|
|
|
|
height: 48px;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
background: color-mix(in srgb, var(--method-color) 15%, transparent);
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
color: var(--method-color);
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.method-title {
|
|
|
|
|
flex: 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.method-title h3 {
|
|
|
|
|
margin: 0 0 0.25rem;
|
|
|
|
|
font-size: 1.125rem;
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.complexity {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.complexity-label {
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.complexity-bars {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 3px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.complexity-bar {
|
|
|
|
|
width: 16px;
|
|
|
|
|
height: 6px;
|
|
|
|
|
border-radius: 3px;
|
|
|
|
|
background: var(--color-border);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.complexity-bar.filled {
|
|
|
|
|
background: var(--method-color, var(--color-primary));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.selected-badge {
|
|
|
|
|
width: 28px;
|
|
|
|
|
height: 28px;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
background: var(--color-success);
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
color: white;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.method-desc {
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
font-size: 0.9375rem;
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
margin: 0 0 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.method-short-desc {
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
font-size: 0.875rem;
|
|
|
|
|
margin: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.method-traits {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: 1fr 1fr;
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.trait-label {
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
letter-spacing: 0.05em;
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
display: block;
|
|
|
|
|
margin-bottom: 0.5rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.method-traits ul {
|
|
|
|
|
list-style: none;
|
|
|
|
|
padding: 0;
|
|
|
|
|
margin: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.method-traits li {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
font-size: 0.8125rem;
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
margin-bottom: 0.375rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.method-traits li svg {
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
margin-top: 2px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.pros li svg {
|
|
|
|
|
color: var(--color-success);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.cons li svg {
|
|
|
|
|
color: var(--color-warning);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.compact .method-header {
|
|
|
|
|
margin-bottom: 0.5rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.compact .method-icon {
|
|
|
|
|
width: 40px;
|
|
|
|
|
height: 40px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.compact .method-title h3 {
|
|
|
|
|
font-size: 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@media (max-width: 640px) {
|
|
|
|
|
.method-traits {
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|