Redesign vote buttons with color-coded choices for improved usability and refactor proposal components with new styles, filters, and status indicators.

This commit is contained in:
MartinBraquet
2026-07-29 23:38:31 +02:00
parent f2c93e3220
commit df1ecbf75e
3 changed files with 256 additions and 127 deletions

View File

@@ -1,33 +1,49 @@
import clsx from 'clsx'
import {useEffect, useRef, useState} from 'react'
import toast from 'react-hot-toast'
import {Button} from 'web/components/buttons/button'
import {Row} from 'web/components/layout/row'
import {surface} from 'web/components/widgets/surface'
import {useUser} from 'web/hooks/use-user'
import {api} from 'web/lib/api'
import {useT} from 'web/lib/locale'
export type VoteChoice = 'for' | 'abstain' | 'against'
// Solid pill per choice — outline-only would read as three identical buttons distinguished only by a
// label, which is easy to misclick. Filling them lets a voter recognize "for" vs "against" by color
// alone, the same way the priority menu below relies on shape rather than reading text under time
// pressure.
// `teal` is remapped in tailwind.config.js to the "yes" ramp, which is greyscale, not a color — using
// it here would render "for" as a grey button indistinguishable from a disabled one. `green` is the
// real green ramp.
const CHOICE_COLOR: Record<VoteChoice, string> = {
for: 'bg-green-500 hover:bg-green-600 text-white',
abstain: 'bg-yellow-400 hover:bg-yellow-500 text-ink-900',
against: 'bg-red-500 hover:bg-red-600 text-white',
}
function VoteButton(props: {
color: string
choice: VoteChoice
count: number
title: string
disabled?: boolean
onClick?: () => void
}) {
const {color, count, title, disabled, onClick} = props
const {choice, count, title, disabled, onClick} = props
return (
<Button
size="xs"
<button
type="button"
disabled={disabled}
className={clsx('px-2 xs:px-4 py-2 rounded-lg', color)}
onClick={onClick}
color={'gray-white'}
className={clsx(
'inline-flex items-center gap-1.5 rounded-full px-3 xs:px-4 py-2 text-sm font-medium',
'transition-colors disabled:cursor-not-allowed disabled:opacity-50',
CHOICE_COLOR[choice],
)}
>
<div className="font-semibold mx-1 xs:mx-2">{count}</div>
<div className="text-sm">{title}</div>
</Button>
<span className="font-semibold">{count}</span>
<span>{title}</span>
</button>
)
}
@@ -113,32 +129,24 @@ export function VoteButtons(props: {
}
return (
<Row className={clsx('gap-2 xs:gap-4 mt-2 flex-wrap', className)}>
<Row className={clsx('gap-2 xs:gap-3 flex-wrap', className)}>
<div className="relative" ref={containerRef}>
<VoteButton
color={clsx('bg-[rgb(22,163,74)] hover:bg-[rgb(21,128,61)] text-white hover:text-white')}
choice="for"
count={counts.for}
title={t('vote.for', 'For')}
disabled={disabled}
onClick={() => handleVote('for')}
/>
{showPriority && (
<div
className={clsx(
'absolute z-10 mt-2 w-40 rounded-md border border-ink-200 bg-canvas-50 shadow-lg',
'dark:bg-ink-900',
)}
>
<div className="px-3 py-2 text-sm font-semibold bg-canvas-25">
<div className={clsx(surface, 'absolute z-10 mt-2 w-44 overflow-hidden p-1')}>
<div className="px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-ink-500">
{t('vote.priority', 'Priority')}
</div>
{priorities.map((p) => (
<button
key={p.value}
className={clsx(
'w-full text-left px-3 py-2 text-sm hover:bg-ink-100 bg-canvas-50',
'dark:hover:bg-canvas-25',
)}
className="w-full rounded-lg px-3 py-2 text-left text-sm text-ink-800 hover:bg-canvas-100"
onClick={async () => {
setShowPriority(false)
await sendVote('for', p.value)
@@ -151,14 +159,14 @@ export function VoteButtons(props: {
)}
</div>
<VoteButton
color={clsx('bg-[rgb(217,119,6)] hover:bg-[rgb(180,83,9)] text-white hover:text-white')}
choice="abstain"
count={counts.abstain}
title={t('vote.abstain', 'Abstain')}
disabled={disabled}
onClick={() => handleVote('abstain')}
/>
<VoteButton
color={clsx('bg-[rgb(220,38,38)] hover:bg-[rgb(185,28,28)] text-white hover:text-white')}
choice="against"
count={counts.against}
title={t('vote.against', 'Against')}
disabled={disabled}

View File

@@ -1,20 +1,22 @@
import {JSONContent} from '@tiptap/core'
import clsx from 'clsx'
import {formLink} from 'common/constants'
import {MAX_DESCRIPTION_LENGTH} from 'common/envs/constants'
import {debug} from 'common/logger'
import {ORDER_BY, ORDER_BY_CHOICES, OrderBy} from 'common/votes/constants'
import {STATUS_CHOICES} from 'common/votes/constants'
import Link from 'next/link'
import {useEffect, useState} from 'react'
import {useEffect, useMemo, useState} from 'react'
import toast from 'react-hot-toast'
import {Button} from 'web/components/buttons/button'
import {linkifyTrailingUrl} from 'web/components/editor/autolink'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {EnglishOnlyWarning} from 'web/components/news/english-only-warning'
import {Vote, VoteItem} from 'web/components/votes/vote-item'
import {STATUS_COLOR, Vote, VoteItem} from 'web/components/votes/vote-item'
import {TextEditor, useTextEditor} from 'web/components/widgets/editor'
import {Input} from 'web/components/widgets/input'
import {Title} from 'web/components/widgets/title'
import {eyebrow, surface} from 'web/components/widgets/surface'
import {useGetter} from 'web/hooks/use-getter'
import {useUser} from 'web/hooks/use-user'
import {api} from 'web/lib/api'
@@ -33,24 +35,44 @@ export function VoteComponent() {
const [title, setTitle] = useState<string>('')
const [editor, setEditor] = useState<any>(null)
const [isAnonymous, setIsAnonymous] = useState<boolean>(false)
const [statusFilter, setStatusFilter] = useState<string>('all')
const hideButton = title.length == 0
// Only offer statuses that actually appear in this batch of votes — a filter chip for a status
// nothing currently has is a dead end, not a choice.
const availableStatuses = useMemo(
() =>
Object.keys(STATUS_CHOICES).filter((status) => votes?.some((v: Vote) => v.status === status)),
[votes],
)
const filteredVotes = useMemo(
() =>
statusFilter === 'all' ? votes : votes?.filter((vote: Vote) => vote.status === statusFilter),
[votes, statusFilter],
)
return (
<Col className="mx-2">
<Row className="items-center justify-between flex-col xxs:flex-row mb-4 xxs:mb-0 gap-2">
<Title className="text-3xl">{t('vote.title', 'Proposals')}</Title>
<div className="flex items-center gap-2 text-sm justify-end">
<label htmlFor="orderBy" className="text-ink-700">
{t('vote.order.label', 'Order by:')}
</label>
<Col className="mx-2 max-w-3xl w-full mx-auto">
<Row className="items-start justify-between flex-col xxs:flex-row mb-1 gap-3">
<div>
<p className={clsx(eyebrow, 'text-primary-700 mb-1')}>
{t('vote.eyebrow', 'Community governance')}
</p>
<h1 className="font-heading text-3xl text-ink-900 tracking-tight">
{t('vote.title', 'Proposals')}
</h1>
</div>
<label className="flex items-center gap-2 text-sm shrink-0">
<span className="text-ink-500">{t('vote.order.label', 'Order by:')}</span>
<select
id="orderBy"
value={orderBy}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setOrderBy(e.target.value as OrderBy)
}
className="rounded-md border border-gray-300 px-2 py-1 text-sm bg-canvas-50"
className="rounded-full border border-canvas-200 bg-canvas-50 px-3 py-1.5 text-sm text-ink-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-500/40"
>
{ORDER_BY.map((key) => (
<option key={key} value={key}>
@@ -58,9 +80,48 @@ export function VoteComponent() {
</option>
))}
</select>
</div>
</label>
</Row>
<p className={'custom-link'}>
{availableStatuses.length > 1 && (
<Row className="mt-3 items-center gap-2 flex-wrap">
<Row className="gap-1.5 flex-wrap">
<button
type="button"
onClick={() => setStatusFilter('all')}
className={clsx(
'rounded-full px-2.5 py-1 text-xs font-semibold transition-colors',
statusFilter === 'all'
? 'bg-ink-900 text-white dark:bg-ink-100 dark:text-ink-900'
: 'bg-canvas-100 text-ink-600 hover:bg-canvas-200',
)}
>
{t('vote.filter.all', 'All')}
</button>
{availableStatuses.map((status) => (
<button
key={status}
type="button"
onClick={() => setStatusFilter(status)}
className={clsx(
'rounded-full px-2.5 py-1 text-xs font-semibold transition-opacity',
STATUS_COLOR[status],
statusFilter === status ? 'opacity-100' : 'opacity-50 hover:opacity-80',
)}
>
{t(`vote.status.${status}`, STATUS_CHOICES[status])}
</button>
))}
</Row>
{votes && (
<span className="text-xs text-ink-500 tabular-nums">
{filteredVotes?.length ?? 0} / {votes.length}
</span>
)}
</Row>
)}
<p className="custom-link text-sm text-ink-600 mt-3">
{t('vote.discuss.prefix', 'You can discuss any of those proposals through the ')}
<Link href={'/contact'}>{t('vote.discuss.link_contact', 'contact form')}</Link>
{t('vote.discuss.middle', ', the ')}
@@ -71,76 +132,94 @@ export function VoteComponent() {
</p>
{user && (
<Col>
<Col className="mt-4">
<ShowMore
labelClosed={t('vote.showmore.closed', 'Add a new proposal')}
labelClosed={t('vote.showmore.closed', '+ Add a new proposal')}
labelOpen={t('vote.showmore.open', 'Hide')}
>
<Input
value={title}
placeholder={t('vote.form.title_placeholder', 'Title')}
className={'w-full mb-2'}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value)
}}
/>
<VoteCreator onEditor={(e) => setEditor(e)} />
<Row className="mx-2 mb-2 items-center gap-2 text-sm">
<input
type="checkbox"
id="anonymous"
checked={isAnonymous}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setIsAnonymous(e.target.checked)
}
className="h-4 w-4 rounded-md border-gray-300 text-blue-600 focus:ring-blue-500"
<Col className={clsx(surface, 'p-4 sm:p-5 gap-3')}>
<Input
value={title}
placeholder={t('vote.form.title_placeholder', 'Title')}
className={'w-full'}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value)
}}
/>
<label htmlFor="anonymous">{t('vote.form.anonymous', 'Anonymous?')}</label>
</Row>
{!hideButton && (
<Row className="right-1 justify-between gap-2">
<Button
size="xs"
onClick={async () => {
linkifyTrailingUrl(editor)
const data = {
title: title,
description: editor.getJSON() as JSONContent,
isAnonymous: isAnonymous,
}
const newVote = await api('create-vote', data).catch(() => {
toast.error(
t(
'vote.toast.create_failed',
'Failed to create vote — try again or contact us...',
),
)
})
if (!newVote) return
setTitle('')
editor.commands.clearContent()
toast.success(t('vote.toast.created', 'Vote created'))
debug('Vote created', newVote)
refreshVotes()
}}
>
{t('vote.form.submit', 'Submit')}
</Button>
<VoteCreator onEditor={(e) => setEditor(e)} />
<Row className="items-center gap-2 text-sm">
<input
type="checkbox"
id="anonymous"
checked={isAnonymous}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setIsAnonymous(e.target.checked)
}
className="h-4 w-4 rounded-md border-canvas-300 text-primary-600 focus:ring-primary-500"
/>
<label htmlFor="anonymous" className="text-ink-700">
{t('vote.form.anonymous', 'Anonymous?')}
</label>
</Row>
)}
{!hideButton && (
<Row className="justify-end">
<Button
size="sm"
color="cta"
onClick={async () => {
linkifyTrailingUrl(editor)
const data = {
title: title,
description: editor.getJSON() as JSONContent,
isAnonymous: isAnonymous,
}
const newVote = await api('create-vote', data).catch(() => {
toast.error(
t(
'vote.toast.create_failed',
'Failed to create vote — try again or contact us...',
),
)
})
if (!newVote) return
setTitle('')
editor.commands.clearContent()
toast.success(t('vote.toast.created', 'Vote created'))
debug('Vote created', newVote)
refreshVotes()
}}
>
{t('vote.form.submit', 'Submit')}
</Button>
</Row>
)}
</Col>
</ShowMore>
</Col>
)}
<EnglishOnlyWarning />
{votes && votes.length > 0 && (
<Col className={'mt-4'}>
{votes.map((vote: Vote) => {
{filteredVotes && filteredVotes.length > 0 ? (
<Col className={'mt-6'}>
{filteredVotes.map((vote: Vote) => {
return <VoteItem key={vote.id} vote={vote} onVoted={refreshVotes} />
})}
</Col>
)}
) : filteredVotes && filteredVotes.length === 0 ? (
<Col className={clsx(surface, 'mt-6 items-center gap-1 px-6 py-12 text-center')}>
<p className="font-heading text-lg text-ink-900">
{statusFilter === 'all'
? t('vote.empty.title', 'No proposals yet')
: t('vote.empty.filtered_title', 'No proposals with this status')}
</p>
<p className="text-sm text-ink-600 max-w-sm">
{statusFilter === 'all'
? t('vote.empty.subtitle', 'Be the first to suggest a change to how Compass works.')
: t('vote.empty.filtered_subtitle', 'Try a different filter above.')}
</p>
</Col>
) : null}
</Col>
)
}

View File

@@ -1,11 +1,14 @@
import {JSONContent} from '@tiptap/core'
import clsx from 'clsx'
import {Row as rowFor} from 'common/supabase/utils'
import {STATUS_CHOICES} from 'common/votes/constants'
import Link from 'next/link'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {VoteButtons} from 'web/components/votes/vote-buttons'
import {Avatar} from 'web/components/widgets/avatar'
import {Content} from 'web/components/widgets/editor'
import {surface} from 'web/components/widgets/surface'
import {useUserInStore} from 'web/hooks/use-user-supabase'
import {useT} from 'web/lib/locale'
@@ -17,45 +20,89 @@ export type Vote = rowFor<'votes'> & {
status?: string
}
function Username(props: {creatorId: string}) {
// Status pill colors. Grouped by outcome rather than by exact status so a reader can tell "this is
// settled and good" from "this is settled and bad" from "this is still moving" at a glance, without
// reading the label.
// No `dark:` overrides here: this palette's ramps (primary-*, green-*, red-*) already invert their
// CSS variables per theme — bg-primary-100/text-primary-700 resolves to a dark bronze pill with light
// legible text under the `dark` class with no extra classes needed. Adding `dark:text-primary-300`
// fought that inversion and landed on a dark-on-dark combination that was unreadable.
export const STATUS_COLOR: Record<string, string> = {
draft: 'bg-canvas-200 text-ink-600',
under_review: 'bg-primary-100 text-primary-700',
voting_open: 'bg-primary-100 text-primary-700',
voting_closed: 'bg-canvas-200 text-ink-600',
accepted: 'bg-green-100 text-green-700',
pending: 'bg-green-100 text-green-700',
implemented: 'bg-green-100 text-green-700',
rejected: 'bg-red-100 text-red-700',
cancelled: 'bg-red-100 text-red-700',
superseded: 'bg-canvas-200 text-ink-600',
expired: 'bg-canvas-200 text-ink-600',
archived: 'bg-canvas-200 text-ink-600',
}
function StatusPill(props: {status: string}) {
const {status} = props
const t = useT()
return (
<span
className={clsx(
'rounded-full px-2.5 py-1 text-xs font-semibold whitespace-nowrap',
STATUS_COLOR[status] ?? 'bg-canvas-200 text-ink-600',
)}
>
{t(`vote.status.${status}`, STATUS_CHOICES[status])}
</span>
)
}
function Creator(props: {creatorId: string}) {
const {creatorId} = props
const creator = useUserInStore(creatorId)
if (!creator?.username) return null
return (
<>
{creator?.username && (
<Link href={`/${creator.username}`} className="custom-link">
{creator.username}
</Link>
)}
</>
<Link
href={`/${creator.username}`}
className="flex items-center gap-1.5 text-ink-600 hover:text-ink-900"
>
<Avatar username={creator.username} avatarUrl={creator.avatarUrl} size="2xs" noLink />
{creator.username}
</Link>
)
}
export function VoteItem(props: {vote: Vote; onVoted?: () => void | Promise<void>}) {
const {vote, onVoted} = props
const t = useT()
// console.debug('creator', creator, vote)
return (
<Col className={'mb-4 rounded-lg border border-canvas-200 p-4 bg-canvas-50'}>
<Row className={'mb-2'}>
<Col className={'flex-grow'}>
<p className={'text-2xl'}>{vote.title}</p>
<Col className="text-sm italic">
<Content className="w-full" content={vote.description as JSONContent} />
</Col>
<Row className={'gap-2 mt-2 items-center justify-between w-full custom-link flex-wrap'}>
{vote.priority ? (
<div>
{t('vote.priority', 'Priority')}: {vote.priority.toFixed(0)}%
</div>
) : (
<p></p>
)}
{!vote.is_anonymous && <Username creatorId={vote.creator_id} />}
</Row>
</Col>
<Col
className={clsx(
surface,
'mb-4 p-4 sm:p-5 transition-[--tw-ring-color] duration-200 hover:ring-primary-300',
)}
>
<Row className="items-start justify-between gap-3">
<p className="font-heading font-medium text-xl text-ink-900 leading-snug">{vote.title}</p>
{vote.status && <StatusPill status={vote.status} />}
</Row>
<Row className="flex-wrap gap-2 items-center justify-between">
<Col className="mt-1 text-sm text-ink-600">
<Content className="w-full" content={vote.description as JSONContent} />
</Col>
<Row className="mt-3 items-center justify-between gap-2 flex-wrap text-sm">
{!vote.is_anonymous ? <Creator creatorId={vote.creator_id} /> : <span />}
{!!vote.priority && (
<span className="rounded-full bg-primary-100 px-2.5 py-1 text-xs font-semibold text-primary-700">
{t('vote.priority', 'Priority')} {vote.priority.toFixed(0)}%
</span>
)}
</Row>
<div className="mt-4 h-px bg-canvas-200/60" />
<Row className="mt-4">
<VoteButtons
voteId={vote.id}
counts={{
@@ -66,11 +113,6 @@ export function VoteItem(props: {vote: Vote; onVoted?: () => void | Promise<void
onVoted={onVoted}
disabled={vote.status !== 'voting_open'}
/>
{vote.status && (
<p className="text-ink-500">
{t(`vote.status.${vote.status}`, STATUS_CHOICES[vote.status])}
</p>
)}
</Row>
</Col>
)