From 3906948dcc64c101df3f97420c49d82b0579b30d Mon Sep 17 00:00:00 2001 From: Jack Henderson Date: Thu, 7 May 2026 10:23:45 -0400 Subject: [PATCH] measuring-stick: per-funnel gauge with live indicator + reference ticks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2D triangle funnel is mathematically right but reads as abstract. This makes the function more legible without redesigning the form: a small live indicator on the outer right edge tracks the water surface and shows the current vote count, plus two faint reference ticks frame the range (half-cap and cap). The funnel becomes a measuring instrument — calm, honest, no extra chrome. Layered on top of the existing Funnel.tsx, not a rewrite. Behaviour --------- - Live indicator (left-pointing arrow + numeric label, 1 decimal) visible whenever votes > 0. Position updates frame-for-frame during a hold (`instantUpdate` path), Framer-Motion-smoothed otherwise. Anchored at `cx + usableHeight + ARROW_OFFSET` horizontally and `apexY − h` vertically — exactly the water-surface y, so the indicator and the surface highlight stay co-located at every level. - Two faint reference ticks at votes 5 and 10 (half-cap and rim). No labels, no integer marks at every level — the two anchor ticks are the whole scale. - Cross-fade rule: showIndicator = round1(votes) > 0 showTicks = round1(votes) <= 0 && !isAnyPouring Result: at rest with votes=0 ⇒ ticks; with votes>0 ⇒ indicator; during *any* pour anywhere in the grid ⇒ ticks hide globally, indicators show on the funnels with votes>0. - Cross-fade timing: 250 ms ease-out via CSS opacity. (Framer Motion's `animate={{ opacity }}` on `` didn't reliably re-render after prop changes here — the attribute path stuck at the initial-mount value. Plain CSS opacity transitions handle the cross-fade cleanly with no extra cost.) - Reduced motion: opacity transitions disabled; state changes snap. Indicator's vertical position still updates in real time during a hold (input feedback, not decoration). Layout ------ 36 px reserved 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. Wiring ------ LiquidQV computes `isAnyPouring = Boolean(activePour)` and passes it to each Funnel. Funnel uses that flag plus its own `votes` to drive the indicator/ticks cross-fade. No new state at any level. What's preserved ---------------- - All math (costForVotes, maxVotes, clampVotesAgainstBudget, conservation), all interaction (hold-to-pour, continuous values), the pool, the pour stream, intro copy, "How it works" explainer, framing prompt, footer disclaimer, default ballot, and the under-funnel readout ("4.3 votes 18.2 credits"). Verified -------- - Empty funnel at rest, no pour: reference ticks visible at half-cap and rim. Indicator opacity 0. - Mid-hold: ticks fade to 0 across all funnels; indicator on the held funnel slides up tracking the water surface, label updates frame- for-frame. - Post-release with mixed states (e.g., Harris=3.9, Newsom=0): Harris shows indicator only; Newsom shows ticks only. - 29 tests passing, ESLint clean, typecheck clean, all three build targets succeed. Docs ---- docs/round-10/README.md describes each state with the verified DOM snapshots from the live preview. --- docs/round-10/README.md | 65 +++++++++++++++ src/components/Funnel.tsx | 153 +++++++++++++++++++++++++++++++++--- src/components/LiquidQV.tsx | 1 + 3 files changed, 206 insertions(+), 13 deletions(-) create mode 100644 docs/round-10/README.md diff --git a/docs/round-10/README.md b/docs/round-10/README.md new file mode 100644 index 0000000..a147a6a --- /dev/null +++ b/docs/round-10/README.md @@ -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 `` 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). diff --git a/src/components/Funnel.tsx b/src/components/Funnel.tsx index d5f9079..d29eee2 100644 --- a/src/components/Funnel.tsx +++ b/src/components/Funnel.tsx @@ -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 { @@ -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. */ @@ -48,6 +64,16 @@ 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, @@ -55,6 +81,7 @@ export const Funnel = ({ onPourStart, onPourEnd, instantUpdate = false, + isAnyPouring = false, size = 220, style, }: FunnelProps) => { @@ -66,12 +93,13 @@ export const Funnel = ({ // ignored. Release of the original key ends the pour. const holdKeyRef = useRef(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; @@ -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) @@ -122,7 +164,6 @@ export const Funnel = ({ return () => window.removeEventListener('blur', cancel); }, [onPourEnd]); - const announcedVotes = round1(votes); const announcedCredits = round1(votes * votes); return ( + + {/* 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 didn't reliably re-render + after `animate` prop changes here, and a CSS transition + on `style.opacity` handles it cleanly. */} + + + + + + {/* 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. */} + + {instantUpdate || reduceMotion ? ( + + + + ) : ( + + + + )} + ); }; + +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). */} + + {/* Numeric label, vertically centered on the gauge anchor. */} + + {votes.toFixed(1)} + + +); diff --git a/src/components/LiquidQV.tsx b/src/components/LiquidQV.tsx index 43f4eb9..8f7d902 100644 --- a/src/components/LiquidQV.tsx +++ b/src/components/LiquidQV.tsx @@ -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() }