Compare commits

...
Author SHA1 Message Date
jared be8e49a2bb Merge pull request #247: readable poll card (#246)
CI / Build & Quality Checks (push) Successful in 1m34s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 7m57s
2026-09-27 23:28:48 -04:00
Lotus CIandClaude Opus 5.5 1e217592fc fix(polls): readable poll card: counts, visible bars/indicators, no chip overflow (#246)
CI / Build & Quality Checks (pull_request) Successful in 1m35s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 7m55s
Rendering only; the vote logic (tally, optimistic votes, end poll, keyboard
radiogroup, voters) is unchanged.

- Every row shows "N votes · P%". Multiple choice looked broken as
  "100% / 100%" with no counts; the footer now says "voters" there.
- Results are a thin progress bar under each answer: accent for your pick,
  success for the winner, neutral otherwise. The old full-row fill was the
  same grey as the row, so a 100% answer just looked disabled.
- Radio and checkbox indicators are 18px with a 2px border in a colour mixed
  from the theme's text colour. Primary.ContainerLine was nearly invisible,
  especially in dark themes.
- The winner shows a star and "Winner" instead of a second check mark.
- Bordered card, 460px wide (max 100%, same width for every poll). The header
  is now "Poll · Single choice | Pick up to N | Results hidden until the end |
  Final results", replacing the letter-spaced "◉ POLL · …" line.
- Footer: plain muted text that wraps, and chips ("Who voted", "End poll")
  that never wrap. Previously the chip label broke onto two lines inside a
  one-line chip and spilled out of it. Reproduced before/after in Lotus
  Terminal at 150% zoom, a 360px phone, and dark at 125%.
- Lotus Terminal theme: data-winner gets its own green border rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 23:18:25 -04:00
jared ffe1d5a81e Merge pull request #245: desktop without WebRTC offers "Open in browser"
CI / Build & Quality Checks (push) Successful in 1m53s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 3s
CI / Playwright smoke (e2e) (push) Successful in 9m54s
2026-09-27 17:10:50 -04:00
2 changed files with 178 additions and 114 deletions
+170 -114
View File
@@ -261,52 +261,69 @@ export function PollContent({
handleVote(answers[idx].id); handleVote(answers[idx].id);
}; };
const headerLabel = isEnded // [Gitea #246] Type line under the "Poll" label.
? 'Poll closed · Final results' const typeLabel = (() => {
: `Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`; if (isEnded) return 'Final results';
if (isUndisclosed) return 'Results hidden until the end';
return isMultiple ? `Pick up to ${maxSelections}` : 'Single choice';
})();
// Multiple choice counts people, not answers ("1 voter" who picked two).
const unit = isMultiple ? 'voter' : 'vote';
const footerNote = (() => { const footerNote = (() => {
const totalPart = total > 0 ? `${total} ${unit}${total === 1 ? '' : 's'}` : `No ${unit}s yet`;
if (isUndisclosed && !isEnded) { if (isUndisclosed && !isEnded) {
return canVote return canVote
? 'Voting open · Results hidden until the poll ends' ? `${myVotes.size > 0 ? 'You voted' : 'Voting open'} · results shown when the poll ends`
: 'Results hidden until the poll ends'; : 'Results shown when the poll ends';
} }
const votesPart = total > 0 ? `${total} vote${total === 1 ? '' : 's'}` : 'No votes yet'; if (isEnded) return `${totalPart} · Poll closed`;
if (isEnded) return `${votesPart} · Poll closed`; if (!canVote) return totalPart;
if (!canVote) return votesPart; return `${totalPart} · ${myVotes.size > 0 ? 'Select another answer to change your vote' : 'Select an answer to vote'}`;
if (isMultiple) return `${votesPart} · Select up to ${maxSelections}`;
return `${votesPart} · ${myVotes.size > 0 ? 'Click to change' : 'Click to vote'}`;
})(); })();
// Neutral tones derived from the theme's text colour, so they have contrast
// in every theme (the Container/ContainerLine tokens were near-invisible).
const muted = (pct: number) =>
`color-mix(in srgb, ${color.SurfaceVariant.OnContainer} ${pct}%, transparent)`;
return ( return (
<Box <Box
data-poll-content data-poll-content
direction="Column" direction="Column"
gap="200" gap="300"
style={{ maxWidth: '340px', paddingTop: '2px', paddingBottom: '4px' }} style={{
// Same width for every poll (the message body shrink-wraps its content).
width: toRem(460),
maxWidth: '100%',
margin: `${config.space.S100} 0`,
padding: config.space.S300,
borderRadius: config.radii.R400,
border: `${config.borderWidth.B300} solid ${muted(18)}`,
background: muted(4),
boxSizing: 'border-box',
}}
> >
<Text <Box direction="Column" gap="100">
as="div" <Box alignItems="Center" gap="200" wrap="Wrap" data-poll-content-label>
size="T200" <Icon size="100" src={Icons.OrderList} style={{ opacity: 0.8 }} />
priority="300" <Text as="span" size="L400" style={{ fontWeight: 600 }}>
data-poll-content-label Poll
style={{ </Text>
fontWeight: 700, <Text as="span" size="T200" priority="300">
letterSpacing: '0.12em', ·
textTransform: 'uppercase', </Text>
marginBottom: config.space.S100, <Text as="span" size="T200" priority="300">
}} {typeLabel}
> </Text>
<span aria-hidden>◉ </span> </Box>
{headerLabel} <Text size="T400" style={{ fontWeight: 600, overflowWrap: 'anywhere' }}>
</Text> {question}
<Text size="T400" style={{ fontWeight: 600 }}> </Text>
{question} </Box>
</Text>
<Box <Box
direction="Column" direction="Column"
gap="100" gap="200"
style={{ marginTop: '2px' }}
role={isMultiple ? 'group' : 'radiogroup'} role={isMultiple ? 'group' : 'radiogroup'}
aria-label={question} aria-label={question}
onKeyDown={handleRadioKeyDown} onKeyDown={handleRadioKeyDown}
@@ -316,20 +333,32 @@ export function PollContent({
const text = answer.text; const text = answer.text;
const selected = myVotes.has(id); const selected = myVotes.has(id);
const voteCount = counts.get(id) ?? 0; const voteCount = counts.get(id) ?? 0;
const pct = showResults && total > 0 ? Math.round((voteCount / total) * 100) : 0; const showBar = showResults && total > 0;
const pct = showBar ? Math.round((voteCount / total) * 100) : 0;
const isWinner = winners.has(id); const isWinner = winners.has(id);
const resultText = `${voteCount} ${voteCount === 1 ? 'vote' : 'votes'} · ${pct}%`;
// Roving tabindex for the single-choice radiogroup; checkboxes stay tabbable. // Roving tabindex for the single-choice radiogroup; checkboxes stay tabbable.
const tabIndex = isMultiple ? 0 : selected || (myVotes.size === 0 && i === 0) ? 0 : -1; const tabIndex = isMultiple ? 0 : selected || (myVotes.size === 0 && i === 0) ? 0 : -1;
let barColor = muted(40);
if (isWinner) barColor = color.Success.Main;
else if (selected) barColor = color.Primary.Main;
let rowBorder = muted(22);
if (isWinner) rowBorder = color.Success.Main;
else if (selected) rowBorder = color.Primary.Main;
const labelParts = [text];
if (showBar) labelParts.push(resultText);
if (isWinner) labelParts.push('winning answer');
return ( return (
<React.Fragment key={id}> <React.Fragment key={id}>
<button <button
type="button" type="button"
data-poll-answer data-poll-answer
data-selected={selected} data-selected={selected}
data-winner={isWinner || undefined}
role={isMultiple ? 'checkbox' : 'radio'} role={isMultiple ? 'checkbox' : 'radio'}
aria-checked={selected} aria-checked={selected}
aria-disabled={!canVote} aria-disabled={!canVote}
aria-label={isWinner ? `${text}, winning answer` : undefined} aria-label={labelParts.join(', ')}
aria-describedby={ aria-describedby={
showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0 showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0
? `poll-voters-${eventId}-${id}` ? `poll-voters-${eventId}-${id}`
@@ -340,60 +369,36 @@ export function PollContent({
style={{ style={{
padding: `${config.space.S200} ${config.space.S300}`, padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
background: selected ? color.Primary.Container : color.SurfaceVariant.Container, background: selected ? muted(8) : 'transparent',
border: `${config.borderWidth.B300} solid ${ border: `${config.borderWidth.B300} solid ${rowBorder}`,
isWinner
? color.Success.Main
: selected
? color.Primary.Main
: color.SurfaceVariant.ContainerLine
}`,
lineHeight: 1.4, lineHeight: 1.4,
textAlign: 'left', textAlign: 'left',
cursor: canVote ? 'pointer' : 'default', cursor: canVote ? 'pointer' : 'default',
color: 'inherit', color: 'inherit',
font: 'inherit',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: config.space.S100, gap: config.space.S200,
width: '100%', width: '100%',
position: 'relative',
overflow: 'hidden',
transition: 'border-color 0.15s, background 0.15s', transition: 'border-color 0.15s, background 0.15s',
}} }}
> >
{showResults && total > 0 && (
<span
aria-hidden
style={{
position: 'absolute',
inset: 0,
right: 'auto',
width: `${pct}%`,
background: selected
? color.Primary.ContainerActive
: color.SurfaceVariant.ContainerActive,
pointerEvents: 'none',
transition: 'width 0.3s ease',
}}
/>
)}
<span <span
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: config.space.S200, gap: config.space.S200,
position: 'relative', width: '100%',
}} }}
> >
<span <span
aria-hidden aria-hidden
style={{ style={{
flexShrink: 0, flexShrink: 0,
width: toRem(14), boxSizing: 'border-box',
height: toRem(14), width: toRem(18),
border: `${config.borderWidth.B300} solid ${ height: toRem(18),
selected ? color.Primary.Main : color.Primary.ContainerLine border: `2px solid ${selected ? color.Primary.Main : muted(55)}`,
}`,
borderRadius: isMultiple ? config.radii.R300 : config.radii.Pill, borderRadius: isMultiple ? config.radii.R300 : config.radii.Pill,
background: selected ? color.Primary.Main : 'transparent', background: selected ? color.Primary.Main : 'transparent',
display: 'flex', display: 'flex',
@@ -405,29 +410,72 @@ export function PollContent({
> >
{selected ? <Icon size="50" src={Icons.Check} /> : null} {selected ? <Icon size="50" src={Icons.Check} /> : null}
</span> </span>
<Text as="span" size="T300" style={{ flexGrow: 1 }}> <Text
as="span"
size="T300"
style={{ flexGrow: 1, minWidth: 0, overflowWrap: 'anywhere' }}
>
{text} {text}
</Text> </Text>
{isWinner && ( {isWinner && (
<Icon <Text
size="50" as="span"
src={Icons.Check} size="T200"
style={{ flexShrink: 0, color: color.Success.Main }} style={{
/> flexShrink: 0,
display: 'inline-flex',
alignItems: 'center',
gap: config.space.S100,
color: color.Success.Main,
fontWeight: 600,
}}
>
<Icon size="50" src={Icons.Star} filled />
Winner
</Text>
)} )}
{showResults && total > 0 && ( {showBar && (
<Text as="span" size="T200" priority="300" style={{ flexShrink: 0 }}> <Text
{pct}% as="span"
size="T200"
priority="300"
style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}
>
{resultText}
</Text> </Text>
)} )}
</span> </span>
{showBar && (
<span
aria-hidden
style={{
display: 'block',
width: '100%',
height: toRem(6),
borderRadius: config.radii.Pill,
background: muted(12),
overflow: 'hidden',
}}
>
<span
style={{
display: 'block',
height: '100%',
width: `${pct}%`,
borderRadius: config.radii.Pill,
background: barColor,
transition: 'width 0.3s ease',
}}
/>
</span>
)}
</button> </button>
{showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0 && ( {showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0 && (
<Text <Text
id={`poll-voters-${eventId}-${id}`} id={`poll-voters-${eventId}-${id}`}
size="T200" size="T200"
priority="300" priority="300"
style={{ padding: `0 ${config.space.S300} ${config.space.S100}` }} style={{ padding: `0 ${config.space.S300}`, marginTop: `-${config.space.S100}` }}
> >
{`Voted by ${(voters.get(id) ?? []).map((s) => getMemberName(room, s)).join(', ')}`} {`Voted by ${(voters.get(id) ?? []).map((s) => getMemberName(room, s)).join(', ')}`}
</Text> </Text>
@@ -436,48 +484,56 @@ export function PollContent({
); );
})} })}
</Box> </Box>
{canShowVoters && ( <Box alignItems="Center" justifyContent="SpaceBetween" gap="200" wrap="Wrap">
<Box> <Text size="T200" priority="300" style={{ minWidth: 0, flex: '1 1 12rem' }}>
<Chip {footerNote}
variant={showVoters ? 'Primary' : 'SurfaceVariant'}
radii="Pill"
aria-pressed={showVoters}
onClick={() => setShowVoters((v) => !v)}
before={<Icon size="50" src={Icons.User} />}
>
<Text size="T200">{showVoters ? 'Hide voters' : 'Show who voted'}</Text>
</Chip>
</Box>
)}
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="T200" priority="300" style={{ minWidth: 0 }}>
<i>{footerNote}</i>
</Text> </Text>
{canEnd && <Box gap="100" shrink="No" alignItems="Center" wrap="Wrap">
(confirmEnd ? ( {canShowVoters && (
<Box gap="100" shrink="No" alignItems="Center">
<Chip
variant="Critical"
radii="Pill"
aria-disabled={ending}
onClick={ending ? undefined : handleEndPoll}
>
<Text size="T200">{ending ? 'Ending…' : 'End poll'}</Text>
</Chip>
<Chip variant="Secondary" radii="Pill" onClick={() => setConfirmEnd(false)}>
<Text size="T200">Cancel</Text>
</Chip>
</Box>
) : (
<Chip <Chip
variant="SurfaceVariant" variant={showVoters ? 'Primary' : 'SurfaceVariant'}
radii="Pill" radii="Pill"
onClick={() => setConfirmEnd(true)} aria-pressed={showVoters}
before={<Icon size="50" src={Icons.Cross} />} onClick={() => setShowVoters((v) => !v)}
before={<Icon size="50" src={Icons.User} />}
> >
<Text size="T200">End poll</Text> <Text size="T200" style={{ whiteSpace: 'nowrap' }}>
{showVoters ? 'Hide voters' : 'Who voted'}
</Text>
</Chip> </Chip>
))} )}
{canEnd &&
(confirmEnd ? (
<>
<Chip
variant="Critical"
radii="Pill"
aria-disabled={ending}
onClick={ending ? undefined : handleEndPoll}
>
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
{ending ? 'Ending…' : 'End poll'}
</Text>
</Chip>
<Chip variant="Secondary" radii="Pill" onClick={() => setConfirmEnd(false)}>
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
Cancel
</Text>
</Chip>
</>
) : (
<Chip
variant="SurfaceVariant"
radii="Pill"
onClick={() => setConfirmEnd(true)}
before={<Icon size="50" src={Icons.Cross} />}
>
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
End poll
</Text>
</Chip>
))}
</Box>
</Box> </Box>
</Box> </Box>
); );
+8
View File
@@ -898,6 +898,14 @@ globalStyle(
boxShadow: '0 0 10px rgba(255,107,0,0.10)', boxShadow: '0 0 10px rgba(255,107,0,0.10)',
}, },
); );
// [Gitea #246] The poll's winning answer (after it ends).
globalStyle(
`body.${lotusTerminalBodyClass} [data-poll-content] [data-poll-answer][data-winner="true"]`,
{
border: '1px solid rgba(0,255,136,0.55) !important',
boxShadow: '0 0 10px rgba(0,255,136,0.10)',
},
);
globalStyle(`body.${lotusTerminalBodyClass} [data-poll-content] [data-poll-content-label]`, { globalStyle(`body.${lotusTerminalBodyClass} [data-poll-content] [data-poll-content-label]`, {
color: 'rgba(0,212,255,0.60) !important', color: 'rgba(0,212,255,0.60) !important',
opacity: '1 !important', opacity: '1 !important',