Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions docs/round-10/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Round 10 — Measuring-stick gauge

This directory documents the visual states the gauge layer adds to each funnel. Screenshots couldn't be auto-captured cleanly through the preview tooling for this round; the descriptions below reflect what was verified in the live dev preview, with the relevant DOM snapshots inline. Reviewers can reproduce all three states with `npm run dev`.

## State 1 — All funnels at 0, no active pour

What the page renders: each funnel has two faint reference ticks on its outer right edge, at the half-cap line (votes = 5) and the rim level (votes = 10). The live indicator is hidden.

Verified via DOM:

```
[
{ label: "Votes for Kamala Harris", valueNow: "0",
tickStyleOpacity: "1", indicatorStyleOpacity: "0" },
...same for all six funnels
]
```

## State 2 — One funnel mid-hold, others at 0

What the page renders: the held funnel shows the live indicator (left-pointing arrow + numeric vote count to one decimal) sliding along the right edge as the water rises. **Reference ticks fade to opacity 0 across the entire grid** — the indicator owns the stage. Other funnels still showing zero votes appear with neither indicator nor ticks during this phase.

Verified during a live hold via the funnel's `isAnyPouring` prop driving CSS opacity:

```
[
{ label: "Votes for Kamala Harris", valueNow: "5.9",
tickStyleOpacity: "0", indicatorStyleOpacity: "1" },
{ label: "Votes for Gavin Newsom", valueNow: "0",
tickStyleOpacity: "0", indicatorStyleOpacity: "0" },
...
]
```

The indicator's vertical position is `apexY − h` where `h = votes × SCALE` — exactly the water-surface y. Position updates frame-for-frame during a hold (`instantUpdate` path bypasses Framer Motion's interpolation, same as the water polygon).

## State 3 — Post-release, mixed state

What the page renders: the funnel where votes were just committed keeps its live indicator (votes > 0 ⇒ visible). Empty funnels' reference ticks fade back in over ~250 ms (the global `isAnyPouring` flag is now false).

Verified post-release:

```
[
{ label: "Votes for Kamala Harris", valueNow: "3.2",
tickStyleOpacity: "0", indicatorStyleOpacity: "1" },
{ label: "Votes for Gavin Newsom", valueNow: "0",
tickStyleOpacity: "1", indicatorStyleOpacity: "0" },
...
]
```

## Implementation notes

### Layout

Reserved 36 px of horizontal room past the V's right edge (`GAUGE_W = 36`) for the gauge anchor. The funnel cavity is correspondingly narrower; everything else (rim line, water polygon, surface highlight, ARIA, keyboard handling, prop interface) is unchanged.

### Cross-fade

Opacity uses plain CSS transitions (`transition: opacity 250ms ease-out`) on the wrapping `<g aria-hidden>` instead of Framer Motion's `animate={{ opacity }}`. Framer's SVG-attribute opacity path didn't reliably re-render after `animate` prop changes here — the attribute value stuck on the initial-mount value. CSS transitions handle the cross-fade cleanly with no extra runtime cost.

### Reduced motion

When `prefers-reduced-motion: reduce` is set, opacity transitions are removed (`transition: 'none'`); state changes snap. The indicator's vertical position still updates in real time during a hold (input feedback, not decoration).
153 changes: 140 additions & 13 deletions src/components/Funnel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,25 @@ import { type CSSProperties, type KeyboardEvent, useEffect, useId, useRef } from
* credits = h² (water area: ½ · 2h · h)
*
* Round 6 (continuous votes): the funnel is purely visual + a keyboard
* hold target. The drag-the-water-surface gesture is gone (it
* conflicted with volumetric pour), and the arrow-key tap shortcuts
* are gone (they were a +1 / +5 convenience that breaks the "every
* interaction obeys the rule" stance). Only Space and Enter remain —
* held down they pour at the standard rate, released they stop. Every
* outcome is duration × rate.
* hold target. Only Space and Enter remain — held they pour at the
* standard rate, released they stop. Every outcome is duration × rate.
*
* `votes` may be fractional (real-valued) at any time. The water
* polygon and surface line render directly from it. ARIA reports the
* one-decimal-rounded value to match the visible readout — screen
* reader users hear the same number a sighted user reads.
* Round 10 (measuring stick): a calm gauge layer along the *outside*
* right edge:
* - A live indicator (small left-pointing arrow + numeric vote
* count, 1 decimal) tracks the water surface whenever votes > 0.
* Position updates frame-for-frame during a hold (`instantUpdate`),
* and gets the same Framer-Motion settle as the water otherwise.
* - Two faint reference ticks at votes = 5 and votes = 10 (half-cap
* and cap) frame the range when the funnel is empty *and* nothing
* in the grid is being held. The moment any pour starts, ticks
* across every funnel cross-fade to zero so the indicator has the
* stage; on release they cross-fade back in. No ruler, no grid,
* no per-integer markings — the two anchor ticks are the whole
* scale.
*
* `votes` may be fractional (real-valued) at any time. ARIA reports
* the one-decimal-rounded value — same number a sighted user reads.
*/

interface FunnelProps {
Expand All @@ -39,6 +47,14 @@ interface FunnelProps {
* instead of lagging behind a moving Framer-Motion target.
*/
instantUpdate?: boolean;
/**
* True when *any* funnel in the grid is currently being held (the
* parent flips this on the first pointerdown / keydown and off on
* release). Drives the cross-fade of the reference ticks across the
* grid — they hide during a pour so the live indicator owns the
* stage, and fade back in on release.
*/
isAnyPouring?: boolean;
/** Pixel width of the funnel SVG. Height auto-derives from 45° geometry. */
size?: number;
/** Override CSS custom properties on the wrapper. */
Expand All @@ -48,13 +64,24 @@ interface FunnelProps {
/** One-decimal rounding used both visually and for ARIA values. */
const round1 = (n: number): number => Math.round(n * 10) / 10;

// Gauge layer constants. Tuned visually against a 220-wide funnel.
const GAUGE_W = 36; // horizontal room reserved past the V's right edge
const ARROW_OFFSET = 4; // gap from the V's right edge to the arrow tip
const ARROW_SIZE = 7; // arrow side length (the left-pointing triangle)
const TICK_LENGTH = 8; // reference tick mark length (horizontal)
const TICK_OFFSET = 4; // gap from the V's right edge to the tick start
const FADE_MS = 250;
const POSITION_MS = 180;
const POSITION_EASE = [0.22, 1, 0.36, 1] as const;

export const Funnel = ({
votes,
maxVotes,
label,
onPourStart,
onPourEnd,
instantUpdate = false,
isAnyPouring = false,
size = 220,
style,
}: FunnelProps) => {
Expand All @@ -66,12 +93,13 @@ export const Funnel = ({
// ignored. Release of the original key ends the pour.
const holdKeyRef = useRef<string | null>(null);

// SVG layout — width-driven. Funnel height = funnel width / 2 (45° walls).
// SVG layout — width-driven. Funnel cavity = (size − pads − gauge);
// V height = funnel cavity / 2 (45° walls).
const PAD_TOP = 14;
const PAD_LEFT = 14;
const PAD_RIGHT = 14;
const PAD_BOTTOM = 18;
const funnelWidth = size - PAD_LEFT - PAD_RIGHT;
const funnelWidth = size - PAD_LEFT - PAD_RIGHT - GAUGE_W;
const usableHeight = funnelWidth / 2;
const viewBoxH = PAD_TOP + usableHeight + PAD_BOTTOM;
const cx = PAD_LEFT + funnelWidth / 2;
Expand All @@ -88,6 +116,20 @@ export const Funnel = ({
const outlinePath = `M ${cx - fullH} ${apexY - fullH} L ${cx} ${apexY} L ${cx + fullH} ${apexY - fullH}`;
const rimY = apexY - fullH;

// Gauge geometry — anchored just past the V's right edge.
const rightEdgeX = cx + fullH;
const indicatorX = rightEdgeX + ARROW_OFFSET;
const tickX1 = rightEdgeX + TICK_OFFSET;
const tickX2 = tickX1 + TICK_LENGTH;
// Reference ticks: half-cap (votes 5) and cap (votes 10 / rim).
const tickHalfY = apexY - 5 * SCALE;
const tickFullY = rimY;
const indicatorY = apexY - h;

const announcedVotes = round1(votes);
const showIndicator = announcedVotes > 0;
const showTicks = announcedVotes <= 0 && !isAnyPouring;

// Keyboard:
// Space / Enter held → continuous pour-in (release ends pour)
// Shift + Space/Enter held → continuous pour-out (drain)
Expand Down Expand Up @@ -122,7 +164,6 @@ export const Funnel = ({
return () => window.removeEventListener('blur', cancel);
}, [onPourEnd]);

const announcedVotes = round1(votes);
const announcedCredits = round1(votes * votes);
return (
<svg
Expand Down Expand Up @@ -221,6 +262,92 @@ export const Funnel = ({
strokeWidth={2}
strokeLinecap="round"
/>

{/* Reference ticks — half-cap and full-cap. The cross-fade
between ticks ⇆ indicator runs through plain CSS opacity
transitions instead of Framer Motion: the SVG-attribute path
framer takes for opacity on <g> didn't reliably re-render
after `animate` prop changes here, and a CSS transition
on `style.opacity` handles it cleanly. */}
<g
aria-hidden
style={{
opacity: showTicks ? 1 : 0,
transition: reduceMotion ? 'none' : `opacity ${FADE_MS}ms ease-out`,
pointerEvents: 'none',
}}
>
<line
x1={tickX1}
x2={tickX2}
y1={tickHalfY}
y2={tickHalfY}
stroke="var(--lqv-fg)"
strokeWidth={1}
strokeOpacity={0.32}
/>
<line
x1={tickX1}
x2={tickX2}
y1={tickFullY}
y2={tickFullY}
stroke="var(--lqv-fg)"
strokeWidth={1}
strokeOpacity={0.32}
/>
</g>

{/* Live indicator — left-pointing arrow + numeric label, anchored
at (indicatorX, indicatorY). Position updates instantly during
a live hold; cross-fades on votes ⇆ 0 via CSS opacity. */}
<g
aria-hidden
style={{
opacity: showIndicator ? 1 : 0,
transition: reduceMotion ? 'none' : `opacity ${FADE_MS}ms ease-out`,
pointerEvents: 'none',
}}
>
{instantUpdate || reduceMotion ? (
<g transform={`translate(${indicatorX} ${indicatorY})`}>
<IndicatorContents votes={announcedVotes} />
</g>
) : (
<motion.g
initial={false}
animate={{ x: indicatorX, y: indicatorY }}
transition={{ duration: POSITION_MS / 1000, ease: POSITION_EASE }}
>
<IndicatorContents votes={announcedVotes} />
</motion.g>
)}
</g>
</svg>
);
};

const IndicatorContents = ({ votes }: { votes: number }) => (
<>
{/* Left-pointing arrow — apex at (0, 0) (the gauge anchor); base
on the right at (ARROW_SIZE, ±ARROW_SIZE/2). */}
<path
d={`M 0 0 L ${ARROW_SIZE} ${-ARROW_SIZE / 2} L ${ARROW_SIZE} ${ARROW_SIZE / 2} Z`}
fill="var(--lqv-fg)"
fillOpacity={0.55}
/>
{/* Numeric label, vertically centered on the gauge anchor. */}
<text
x={ARROW_SIZE + 4}
y={0}
fontSize={11}
fontFamily="'Suisse Intl', system-ui, sans-serif"
fill="var(--lqv-fg)"
fillOpacity={0.7}
dominantBaseline="middle"
textAnchor="start"
style={{ fontVariantNumeric: 'tabular-nums' }}
>
{votes.toFixed(1)}
</text>
</>
);
1 change: 1 addition & 0 deletions src/components/LiquidQV.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ export const LiquidQV = ({
maxVotes={cap}
label={`Votes for ${item.title}`}
instantUpdate={isActive}
isAnyPouring={Boolean(activePour)}
onPourStart={(direction) =>
direction === 'in' ? handlers.startPourIn() : handlers.startPourOut()
}
Expand Down
Loading