From 535049c5bc9f7620f9833131f03aec2ac5cb002b Mon Sep 17 00:00:00 2001 From: Jack Henderson Date: Sat, 9 May 2026 14:07:48 -0400 Subject: [PATCH 1/2] active-ruler: ticks light up at integer crossings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ruler on the right edge of every funnel was passive: a static 0–10 reference scale unaffected by the user's pour. Make it active. Each tick now has one of three states driven by Math.floor(|votes|): unfilled tick > floor(|votes|), or tick = 0 (neutral) filled 1 ≤ tick < floor(|votes|) (sign-coloured) current tick = floor(|votes|), tick ≥ 1 (sign-coloured, fully opaque, 1.5× thicker) Tick 0 is always passive — it's the baseline reference, not a milestone the user reaches. Sign drives colour (via voteColor), not state: tickState(t, +m) === tickState(t, −m) for every t and m. Cross-zero is unambiguous: as |votes| drains through 0 every tick unfills, then re-fills in the new sign's colour as magnitude builds on the other side. The cadence of tick fills during a hold externalizes the cost ramp. Tick 1 fires at 0.2 s, tick 2 at 0.8 s, tick 5 at 5.0 s, tick 10 at 20.0 s — the (2n − 1) / 5 pattern is what users *feel* as "votes get expensive at the top." Implementation: - New src/lib/rulerState.ts: pure tickState(tick, votes) helper. - New src/lib/rulerState.test.ts: 12 unit tests covering integer crossings, drains, sign symmetry, saturation, and non-finite votes. - src/components/Funnel.tsx: minor and major tick lines and major labels now read their inline style (stroke/opacity/width or fill/opacity/weight) from per-tick state. 160 ms CSS transition; collapses to 'none' under prefers-reduced-motion. Funnel geometry, water rendering, ARIA contract, math, reducer, state machine — all unchanged. The active ruler lives entirely inside the existing aria-hidden ruler ; screen readers continue to consume the under-funnel readout as the source of truth. 49/49 tests pass (37 prior + 12 new). Lint, typecheck, all 3 build targets clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/Funnel.tsx | 143 ++++++++++++++++++++++++++++--------- src/lib/rulerState.test.ts | 108 ++++++++++++++++++++++++++++ src/lib/rulerState.ts | 39 ++++++++++ 3 files changed, 256 insertions(+), 34 deletions(-) create mode 100644 src/lib/rulerState.test.ts create mode 100644 src/lib/rulerState.ts diff --git a/src/components/Funnel.tsx b/src/components/Funnel.tsx index 6278ea8..080b5c8 100644 --- a/src/components/Funnel.tsx +++ b/src/components/Funnel.tsx @@ -1,5 +1,6 @@ import { motion, useReducedMotion } from 'framer-motion'; import { type CSSProperties, type KeyboardEvent, useEffect, useId, useRef } from 'react'; +import { type TickState, tickState } from '../lib/rulerState'; import { voteColor, voteColorDark } from '../lib/voteColor'; /* @@ -61,6 +62,55 @@ const LABEL_RESERVE = 14; const RULER_RIGHT_PAD = 4; const POSITION_EASE = [0.22, 1, 0.36, 1] as const; +/** + * Round 15: map a tick's `TickState` to its stroke style. Unfilled + * ticks keep the muted neutral look from earlier rounds; filled and + * current ticks pick up the sign colour. The `current` tick gets a + * thicker stroke and full opacity so it reads as the milestone the + * user just reached without needing a separate halo element. + */ +const tickStrokeStyle = ( + state: TickState, + filledColor: string, + isMajor: boolean, +): { stroke: string; strokeOpacity: number; strokeWidth: number } => { + if (state === 'unfilled') { + return { + stroke: 'var(--lqv-fg)', + strokeOpacity: isMajor ? 0.55 : 0.32, + strokeWidth: isMajor ? 1.5 : 1, + }; + } + if (state === 'filled') { + return { + stroke: filledColor, + strokeOpacity: 0.85, + strokeWidth: isMajor ? 2 : 1.5, + }; + } + // current — slightly heavier and fully opaque + return { + stroke: filledColor, + strokeOpacity: 1, + strokeWidth: isMajor ? 3 : 2, + }; +}; + +/** Round 15: label style follows the tick state. Current label + * also gets a slightly heavier weight so it reads as a peak. */ +const tickLabelStyle = ( + state: TickState, + filledColor: string, +): { fill: string; fillOpacity: number; fontWeight: number } => { + if (state === 'unfilled') { + return { fill: 'var(--lqv-fg)', fillOpacity: 0.6, fontWeight: 400 }; + } + if (state === 'filled') { + return { fill: filledColor, fillOpacity: 0.85, fontWeight: 400 }; + } + return { fill: filledColor, fillOpacity: 1, fontWeight: 600 }; +}; + export const Funnel = ({ votes, maxVotes, @@ -253,46 +303,71 @@ export const Funnel = ({ {/* Measuring stick — unsigned 0 to 10 magnitude. Direction is conveyed by water colour and the under-funnel readout's sign, - not by the ruler. */} + not by the ruler. + Round 15: ticks become an active vote indicator. Each tick + fills in the sign-colour at its integer milestone; the most + recently crossed tick is highlighted as `current`. The state + is purely a function of `Math.floor(|votes|)` — see + `src/lib/rulerState.ts`. Tick 0 is always passive; it's the + baseline reference, not a milestone the user reaches. */} ); diff --git a/src/lib/rulerState.test.ts b/src/lib/rulerState.test.ts new file mode 100644 index 0000000..c117d03 --- /dev/null +++ b/src/lib/rulerState.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { tickState } from './rulerState'; + +describe('tickState', () => { + describe('zero votes', () => { + it('returns unfilled for every tick at votes = 0', () => { + for (const tick of [0, 1, 2, 3, 5, 7, 10]) { + expect(tickState(tick, 0)).toBe('unfilled'); + } + }); + }); + + describe('tick 0 (baseline reference)', () => { + it('is always unfilled regardless of vote magnitude or sign', () => { + expect(tickState(0, 0)).toBe('unfilled'); + expect(tickState(0, 1)).toBe('unfilled'); + expect(tickState(0, 5)).toBe('unfilled'); + expect(tickState(0, 10)).toBe('unfilled'); + expect(tickState(0, -3)).toBe('unfilled'); + expect(tickState(0, -10)).toBe('unfilled'); + }); + }); + + describe('integer crossings during a hold (magnitude rising)', () => { + it('lights tick 1 as current the moment magnitude reaches 1.0', () => { + expect(tickState(1, 0.99)).toBe('unfilled'); + expect(tickState(1, 1.0)).toBe('current'); + expect(tickState(1, 1.01)).toBe('current'); + expect(tickState(1, 1.999)).toBe('current'); + }); + + it('promotes tick 2 to current at 2.0 and demotes tick 1 to filled', () => { + expect(tickState(1, 2.0)).toBe('filled'); + expect(tickState(2, 2.0)).toBe('current'); + expect(tickState(3, 2.0)).toBe('unfilled'); + }); + + it('handles a mid-tick magnitude consistently', () => { + // |votes| = 4.6: ticks 1–3 filled, tick 4 current, tick 5+ unfilled. + expect(tickState(1, 4.6)).toBe('filled'); + expect(tickState(2, 4.6)).toBe('filled'); + expect(tickState(3, 4.6)).toBe('filled'); + expect(tickState(4, 4.6)).toBe('current'); + expect(tickState(5, 4.6)).toBe('unfilled'); + expect(tickState(10, 4.6)).toBe('unfilled'); + }); + }); + + describe('integer crossings during a drain (magnitude falling)', () => { + it('un-fills the highest tick first as magnitude drops below it', () => { + // |votes| just above 3 → tick 3 current. + expect(tickState(3, 3.0)).toBe('current'); + // |votes| just below 3 → tick 3 unfilled, tick 2 current. + expect(tickState(3, 2.999)).toBe('unfilled'); + expect(tickState(2, 2.999)).toBe('current'); + }); + }); + + describe('sign change inverts ruler colors but not tick state', () => { + it('produces the same tick state for +mag and −mag', () => { + const magnitudes = [0, 0.5, 1, 1.5, 2, 3, 4.6, 7, 10]; + const ticks = [0, 1, 2, 3, 5, 7, 10]; + for (const m of magnitudes) { + for (const t of ticks) { + expect(tickState(t, m)).toBe(tickState(t, -m)); + } + } + }); + + it('lights the same ticks at +3 and −3', () => { + expect(tickState(1, 3)).toBe('filled'); + expect(tickState(2, 3)).toBe('filled'); + expect(tickState(3, 3)).toBe('current'); + + expect(tickState(1, -3)).toBe('filled'); + expect(tickState(2, -3)).toBe('filled'); + expect(tickState(3, -3)).toBe('current'); + }); + }); + + describe('saturation at the ±10 cap', () => { + it('marks ticks 1–9 filled and tick 10 current at |votes| = 10', () => { + expect(tickState(1, 10)).toBe('filled'); + expect(tickState(5, 10)).toBe('filled'); + expect(tickState(9, 10)).toBe('filled'); + expect(tickState(10, 10)).toBe('current'); + }); + + it('matches at |votes| = -10 too', () => { + expect(tickState(10, -10)).toBe('current'); + expect(tickState(9, -10)).toBe('filled'); + }); + }); + + describe('non-finite votes', () => { + it('returns unfilled for NaN', () => { + expect(tickState(1, NaN)).toBe('unfilled'); + expect(tickState(5, NaN)).toBe('unfilled'); + expect(tickState(0, NaN)).toBe('unfilled'); + }); + + it('returns unfilled for ±Infinity', () => { + expect(tickState(1, Infinity)).toBe('unfilled'); + expect(tickState(1, -Infinity)).toBe('unfilled'); + expect(tickState(10, Infinity)).toBe('unfilled'); + }); + }); +}); diff --git a/src/lib/rulerState.ts b/src/lib/rulerState.ts new file mode 100644 index 0000000..3cc28e9 --- /dev/null +++ b/src/lib/rulerState.ts @@ -0,0 +1,39 @@ +/* + * Ruler tick-state derivation (round 15). + * + * Each ruler tick on a Funnel has one of three visual states: + * + * - 'unfilled' the water hasn't reached this milestone yet + * - 'filled' the water has passed this milestone + * - 'current' the highest milestone the water has reached; + * visually highlighted as a "you just hit this" + * marker during a live hold + * + * The state is a pure function of the integer-floor of |votes|. + * Sign of `votes` does NOT affect tick state — only color, which + * is computed separately by `voteColor` (so a +3 funnel and a −3 + * funnel light the same ticks; they just light them in different + * colours). + * + * Tick 0 is the baseline reference at the funnel apex. It is + * always 'unfilled' — the user hasn't *earned* zero votes; that's + * the starting state. Milestones begin at ±1. + */ + +export type TickState = 'unfilled' | 'filled' | 'current'; + +export const tickState = (tickValue: number, votes: number): TickState => { + // Tick 0 is a passive baseline, never lit. + if (tickValue < 1) return 'unfilled'; + + // Defensive against NaN / ±Infinity from upstream interpolation + // glitches; treat anything non-finite as zero magnitude. + const mag = Math.abs(votes); + if (!Number.isFinite(mag)) return 'unfilled'; + + const floored = Math.floor(mag); + if (floored < 1) return 'unfilled'; + if (tickValue > floored) return 'unfilled'; + if (tickValue === floored) return 'current'; + return 'filled'; +}; From e03be9ad7c777f4975123963762182a5c63c71bd Mon Sep 17 00:00:00 2001 From: Jack Henderson Date: Sat, 9 May 2026 14:07:58 -0400 Subject: [PATCH 2/2] docs(round-15): describe active-ruler states and cadence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-state walkthrough (empty / +5 / −3 / cross-zero / mixed grid) with verified-DOM tick stroke/opacity/width snapshots, the (2n − 1)/5 mid-hold cadence table, and notes on the prefers-reduced-motion snap-vs-fade path. Same docs pattern as rounds 13 and 14. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/round-15/README.md | 233 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 docs/round-15/README.md diff --git a/docs/round-15/README.md b/docs/round-15/README.md new file mode 100644 index 0000000..6ec1494 --- /dev/null +++ b/docs/round-15/README.md @@ -0,0 +1,233 @@ +# Round 15 — Active ruler ticks + +The 0–10 ruler on the right edge of every funnel was passive: a static +reference scale unaffected by the user's pour. The water polygon +inside the funnel was the only thing that moved, and water reads as +*area* before *height* — but in QV, the magnitude that matters is the +height (votes earned), not the area (credits spent). + +This round makes the ruler an active vote indicator. Ticks light up +at integer crossings during a hold; the most recently crossed tick +gets a "current" highlight. Three things happen at once: + +- The **ticks** become the discrete-progress indicator: every integer + vote earned is now an event the user can see on the side of the + funnel, not just a colour change in the water. +- The **water** continues as the cost-ramp visualization: the same + smooth quadratic fill, but now reading explicitly as the *cost* of + the votes lit up beside it. +- The **rhythm** of tick fills externalizes the cost ramp. Tick 1 + takes 1 credit's worth of pouring, tick 2 takes 3 more, tick 5 + takes 9 more, tick 10 takes 19 more. The user feels the cost curve + as the cadence of the flashes — fast at the bottom, slow at the + top. This is the load-bearing artifact of the round. + +## Three tick states + +| State | Trigger | Stroke | Opacity | Width | +| ---------- | ------------------------------------ | ------------------------------- | ------- | ---------------- | +| `unfilled` | tick > floor(\|votes\|), or tick = 0 | `var(--lqv-fg)` (neutral) | 0.32 / 0.55 | 1px / 1.5px (minor / major) | +| `filled` | tick ≤ floor(\|votes\|) − 1 | `voteColor(votes)` (sign-coloured) | 0.85 | 1.5px / 2px | +| `current` | tick = floor(\|votes\|), and ≥ 1 | `voteColor(votes)` | 1.0 | 2px / 3px | + +Tick 0 is the baseline reference at the funnel apex; it is *always* +unfilled. The user hasn't earned zero votes — that's the starting +state. Milestones begin at ±1. + +`tickState(tickValue, votes)` lives in `src/lib/rulerState.ts` as a +pure function. It is independent of sign — `tickState(3, +3.5)` and +`tickState(3, −3.5)` both return `'current'`. Sign drives colour +(`voteColor(votes)`), not tick state. + +## State walk-throughs + +(Screenshots couldn't be auto-captured cleanly through the preview +tooling; descriptions below reflect what was verified live, with DOM +snapshots inline. Reproduce with `npm run dev`.) + +### Empty (page load) + +Every tick on every funnel is unfilled. No current tick anywhere. +Pool reads `100 / 100 credits`. + +``` +all 6 funnels: 11 ticks each, every tick stroke = "rgb(242, 241, 234)" (--lqv-fg), + opacity 0.32 (minor) or 0.55 (major), width 1px or 1.5px +``` + +### Funnel at +5 (positive, mid-magnitude) + +Held `+` on Kamala Harris ~5 s, released. Snaps to `+5 votes 25 credits`. + +Verified DOM (Harris ruler, listed in source order — minors `1, 3, 5, +7, 9` then majors `0, 2, 4, 6, 8, 10`): + +``` +minor 1: stroke=rgb(52, 211, 153) opacity=0.85 width=1.5px → filled (green) +minor 3: stroke=rgb(52, 211, 153) opacity=0.85 width=1.5px → filled (green) +minor 5: stroke=rgb(52, 211, 153) opacity=1 width=2px → CURRENT (green, brighter, thicker) +minor 7: stroke=rgb(242, 241, 234) opacity=0.32 width=1px → unfilled +minor 9: unfilled +major 0: stroke=rgb(242, 241, 234) opacity=0.55 width=1.5px → unfilled (passive baseline) +major 2: stroke=rgb(52, 211, 153) opacity=0.85 width=2px → filled (green) +major 4: stroke=rgb(52, 211, 153) opacity=0.85 width=2px → filled (green) +major 6: unfilled +major 8: unfilled +major 10: unfilled +``` + +Major label `2` and `4` render in green at 0.85 opacity (filled). +Labels `6, 8, 10` stay in the neutral `--lqv-fg` at 0.6 opacity. +There is no `5` label — tick 5 is a minor tick — so the current +highlight on +5 reads as a brighter, thicker tick mark with no label +treatment. (At +4, the current treatment additionally highlights the +"4" label in green at full opacity with a heavier `font-weight: 600`.) + +### Funnel at −3 (negative, low magnitude) + +Held `−` on Gavin Newsom ~1.9 s, released. Snaps to `−3 votes 9 credits`. + +``` +minor 1: filled (red, rgb(248, 113, 113), opacity 0.85, width 1.5px) +minor 3: CURRENT (red, opacity 1, width 2px) +minor 5–9: unfilled +major 0: unfilled (passive baseline) +major 2: filled (red, opacity 0.85, width 2px) — label "2" also red +major 4–10: unfilled +``` + +Same tick states as the `+3` case would be — only the colour differs +(red instead of green). This is the property the unit tests verify: +`tickState(t, +m) === tickState(t, −m)` for every `t` and `m`. + +### Cross-zero hold (green drain → empty → red fill) + +Started Harris at `+5` (25 credits). Held `−` for ~6.8 s, watching +the rulers in real time: + +1. Magnitude drops below 5.0 → minor tick 5 un-fills, minor tick 4 takes + over as `current` (green). +2. Below 4.0 → minor tick 4 un-fills, major tick 4 had been filled — + wait, the *integer* crossings here are 5 → 4 → 3 → 2 → 1 → 0. + At each integer the current marker steps down one position. +3. At 0, every tick is unfilled. No current tick. (Ticks don't pick + the new sign's colour while at exactly zero — the sign is + ambiguous; the rule is "tick 0 is always unfilled and there is no + current at zero magnitude.") +4. Below 0, magnitudes start rising again on the negative side. As + |votes| crosses 1.0 the ticks begin filling once more, this time + in red. + +End state after release at the 6.8-s mark: + +``` +{ + harrisReadout: "−4 votes 16 credits", + pool: "75 of 100 credits remaining" +} +``` + +At `−4`, the major label `4` renders bright red and bold (current), +and the label `2` renders dimmer red (filled). Conservation: +`16 + 9 = 25`, `75 + 25 = 100` ✓. + +### Mid-hold cadence (the felt quadratic) + +Holding `+` from zero on a fresh funnel: + +| Time after pointerdown | Live `s` (signed credits) | Live `v = √s` | Tick that just lit | +| ---------------------- | ------------------------- | ------------- | ------------------- | +| 0.20 s | 1.0 | 1.0 | tick 1 | +| 0.80 s | 4.0 | 2.0 | tick 2 | +| 1.80 s | 9.0 | 3.0 | tick 3 | +| 3.20 s | 16.0 | 4.0 | tick 4 | +| 5.00 s | 25.0 | 5.0 | tick 5 | +| 7.20 s | 36.0 | 6.0 | tick 6 | +| 9.80 s | 49.0 | 7.0 | tick 7 | +| 12.80 s | 64.0 | 8.0 | tick 8 | +| 16.20 s | 81.0 | 9.0 | tick 9 | +| 20.00 s | 100.0 | 10.0 | tick 10 (saturated) | + +Pour rate is the same constant 5 credits/s the rest of the demo uses; +the visible cadence is a direct read of the `dt = (n² − (n−1)²) / 5 += (2n − 1) / 5` pattern. At n = 1 the gap to the next tick is 0.2 s; +at n = 10 it's 3.8 s. That ramp **is** what QV is, played out as +flashes on the side of the funnel. + +### Mixed grid (independent rulers) + +With Harris at `+5` (green ticks 1–5, tick 5 current), Newsom at `−3` +(red ticks 1–3, tick 3 current), and the four other cards at zero +(every tick unfilled), each ruler reflects only its own funnel's +state. The grid reads as a row of independent meters — no +cross-talk, no shared colour cycle. The tick-state computation is +re-run inside each `Funnel` render, so each funnel renders against +its own `votes` prop. + +## Animation and reduced motion + +Tick state changes use a 160-ms CSS transition on `stroke`, +`stroke-opacity`, and `stroke-width` so the lift from unfilled → +filled → current reads as a soft fade rather than a hard switch. +Labels likewise transition `fill` and `fill-opacity` over 160 ms. + +When `prefers-reduced-motion: reduce` is honoured (via framer-motion's +`useReducedMotion()` hook), the inline `transition` value flips to +`'none'` on every tick line and label. The states still update in +real time during a hold — the brief explicitly says "the state itself +still updates" — but each transition snaps instantly. Verified by +inspecting the live `style` attribute on a tick line: + +``` +transition: stroke 160ms, stroke-opacity 160ms, stroke-width 160ms; // default +transition: none; // reduce-motion +``` + +## Tests + +Twelve new unit tests in `src/lib/rulerState.test.ts`: + +- Zero magnitude → every tick unfilled. +- Tick 0 is always unfilled regardless of magnitude or sign. +- Integer crossings produce the right `current`/`filled` transitions + on the way up. +- Drains un-fill the highest tick first and demote the previous + current to `current`. +- `tickState(t, +m) === tickState(t, −m)` for every relevant + magnitude and tick value (sign drives colour, not state). +- Saturation at the ±10 cap: ticks 1–9 filled, tick 10 current. +- NaN and ±Infinity defensively return `'unfilled'`. + +All 49 tests pass (37 prior + 12 new). Lint, typecheck, and all +three build targets clean. No console warnings. + +## What didn't change + +- `Funnel.tsx` geometry — same upward V, same water polygon, same + surface line, same outline path, same rim, same ARIA contract. +- The water rendering — sign colours, smooth motion during a hold, + snap on release. +- The under-funnel readout's position above the funnel (PR #14), + format, and full type weight. +- `PourControl`, `PourStream`, `CreditPool`. +- The math layer (`src/math/qv.ts`), the reducer, and the + active-pour state machine in `LiquidQV`. +- The intro copy, on-load explainer, footer, default ballot. + +## What changed + +- New file: `src/lib/rulerState.ts` — pure helper exporting + `tickState(tickValue, votes): TickState`. +- New file: `src/lib/rulerState.test.ts` — 12 unit tests. +- `src/components/Funnel.tsx`: + - Imports `tickState`, `TickState` from the new helper. + - Two new module-private helpers `tickStrokeStyle` and + `tickLabelStyle` map a `TickState` + sign-colour to inline + `stroke` / `stroke-opacity` / `stroke-width` and `fill` / + `fill-opacity` / `font-weight`. + - Each minor and major tick line now reads its style from those + helpers; major-tick labels likewise. Every tick has a 160-ms + CSS transition, gated to `'none'` under reduced-motion. +- No public-API changes; no theme overrides added; no behaviour + visible to screen readers (the active ruler is decorative — the + `` wrapper is unchanged).