From df1ecbf75e13bb0c29c19aaabf9396d6a2e9624b Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Wed, 29 Jul 2026 23:38:31 +0200 Subject: [PATCH] Redesign vote buttons with color-coded choices for improved usability and refactor proposal components with new styles, filters, and status indicators. --- web/components/votes/vote-buttons.tsx | 58 ++++--- web/components/votes/vote-info.tsx | 217 ++++++++++++++++++-------- web/components/votes/vote-item.tsx | 108 +++++++++---- 3 files changed, 256 insertions(+), 127 deletions(-) diff --git a/web/components/votes/vote-buttons.tsx b/web/components/votes/vote-buttons.tsx index cc4c253a..11d0306b 100644 --- a/web/components/votes/vote-buttons.tsx +++ b/web/components/votes/vote-buttons.tsx @@ -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 = { + 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 ( - + {count} + {title} + ) } @@ -113,32 +129,24 @@ export function VoteButtons(props: { } return ( - +
handleVote('for')} /> {showPriority && ( -
-
+
+
{t('vote.priority', 'Priority')}
{priorities.map((p) => (
handleVote('abstain')} /> ('') const [editor, setEditor] = useState(null) const [isAnonymous, setIsAnonymous] = useState(false) + const [statusFilter, setStatusFilter] = useState('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 ( - - - {t('vote.title', 'Proposals')} -
- + + +
+

+ {t('vote.eyebrow', 'Community governance')} +

+

+ {t('vote.title', 'Proposals')} +

+
+
+
-

+ + {availableStatuses.length > 1 && ( + + + + {availableStatuses.map((status) => ( + + ))} + + {votes && ( + + {filteredVotes?.length ?? 0} / {votes.length} + + )} + + )} + +

{t('vote.discuss.prefix', 'You can discuss any of those proposals through the ')} {t('vote.discuss.link_contact', 'contact form')} {t('vote.discuss.middle', ', the ')} @@ -71,76 +132,94 @@ export function VoteComponent() {

{user && ( - + - ) => { - setTitle(e.target.value) - }} - /> - setEditor(e)} /> - - ) => - setIsAnonymous(e.target.checked) - } - className="h-4 w-4 rounded-md border-gray-300 text-blue-600 focus:ring-blue-500" + + ) => { + setTitle(e.target.value) + }} /> - - - {!hideButton && ( - - + setEditor(e)} /> + + ) => + setIsAnonymous(e.target.checked) + } + className="h-4 w-4 rounded-md border-canvas-300 text-primary-600 focus:ring-primary-500" + /> + - )} + {!hideButton && ( + + + + )} + )} - {votes && votes.length > 0 && ( - - {votes.map((vote: Vote) => { + {filteredVotes && filteredVotes.length > 0 ? ( + + {filteredVotes.map((vote: Vote) => { return })} - )} + ) : filteredVotes && filteredVotes.length === 0 ? ( + +

+ {statusFilter === 'all' + ? t('vote.empty.title', 'No proposals yet') + : t('vote.empty.filtered_title', 'No proposals with this status')} +

+

+ {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.')} +

+ + ) : null} ) } diff --git a/web/components/votes/vote-item.tsx b/web/components/votes/vote-item.tsx index c595a7de..f94860f7 100644 --- a/web/components/votes/vote-item.tsx +++ b/web/components/votes/vote-item.tsx @@ -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 = { + 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 ( + + {t(`vote.status.${status}`, STATUS_CHOICES[status])} + + ) +} + +function Creator(props: {creatorId: string}) { const {creatorId} = props const creator = useUserInStore(creatorId) + if (!creator?.username) return null return ( - <> - {creator?.username && ( - - {creator.username} - - )} - + + + {creator.username} + ) } export function VoteItem(props: {vote: Vote; onVoted?: () => void | Promise}) { const {vote, onVoted} = props const t = useT() - // console.debug('creator', creator, vote) return ( - - - -

{vote.title}

- - - - - {vote.priority ? ( -
- {t('vote.priority', 'Priority')}: {vote.priority.toFixed(0)}% -
- ) : ( -

- )} - {!vote.is_anonymous && } -
- + + +

{vote.title}

+ {vote.status && }
- + + + + + + + {!vote.is_anonymous ? : } + {!!vote.priority && ( + + {t('vote.priority', 'Priority')} {vote.priority.toFixed(0)}% + + )} + + +
+ + void | Promise - {vote.status && ( -

- {t(`vote.status.${vote.status}`, STATUS_CHOICES[vote.status])} -

- )}
)