negative voting via bidirectional funnels - #10
Merged
Conversation
Each ballot item is now a vertical diamond — two 90°-apex V-troughs
meeting at a midline at vote = 0. Hold "+" to move the water level
UP regardless of where it currently is; hold "−" to move it DOWN.
Crossing the midline is a smooth continuation of the same gesture;
cost is votes² regardless of sign, so 3 against costs 9 credits just
as 3 for would.
Conservation generalises cleanly: pool + Σ votes_i² = budget.
Math layer
----------
- costForVotes now accepts negative input. The early return for
v ≤ 0 is gone; squaring handles sign already, and conservation
needs negative votes to count their cost.
- New minVotes(budget) for the symmetric negative cap.
- clampVotesAgainstBudget is signed-aware: floors |v|, preserves
sign, applies caps in both directions, normalises -0 → 0.
- New snapVotesToInteger uses round-HALF-AWAY-from-zero (so −0.5 → −1
and +0.5 → +1; JavaScript's Math.round rounds toward +∞ which
would map −0.5 to 0, so we round |live| and reapply the sign)
followed by a signed clamp.
- 4 new tests (37 total): negative input across all primitives,
ties at ±0.5, mixed-sign clamp, mixed-sign conservation.
LiquidQV state machine
----------------------
ActivePour now tracks startSigned = sign(v0) × v0² instead of
startCredits. During any hold, signed credits change monotonically:
+POUR_RATE/sec when "+" is held, −POUR_RATE/sec when "−" is held.
The active vote count comes back as v = sign(s) × √|s|.
This is the load-bearing change that lets the user cross v = 0 in a
single hold without any special case at the apex. The pour stream
visual flips direction naturally — while v > 0 and "−" is held, the
stream goes funnel → pool (s falling toward 0); once v < 0, the
stream flips to pool → funnel (|s| growing on the negative side).
startPour's early-exit checks `±ceilingAbs` in both directions.
canPour / canDrain split: "+" enabled when startVotes can still move
up; "−" enabled when it can still move down. Reset enables whenever
votes !== 0.
Funnel rendering
----------------
Full SVG rewrite, but every layer is the symmetric pair of what
existed before. The cavity is now twice as tall (upper V + lower V
sharing an apex at the midline). The water polygon is a unified
path:
M cx midY L (cx − h) surfaceY L (cx + h) surfaceY Z
with h = |v| × SCALE and surfaceY = midY − sign(v) × h. At v = 0 the
path collapses to a point at the apex (still a valid 4-vertex path
so Framer Motion can interpolate without warnings).
The midline is a faint horizontal line at the apex; it brightens
slightly at v = 0 so the rest state reads. Ruler spans −10 to +10
with major ticks (and signed labels +N / 0 / −N) at every even
integer; minor ticks at the odd integers between.
Display
-------
- Under-funnel: "+3 votes 9 credits" / "0 votes 0 credits" /
"−4 votes 16 credits". Real Unicode minus (U+2212), not hyphen.
- Pool: "75 / 100 credits" — non-negative integer, unchanged.
- ARIA aria-valuemin = -cap, aria-valuemax = +cap, aria-valuetext
formatted to match the visible readout.
- PourControl labels updated for directional semantics: "Move
${title}'s vote down/up. Hold to continue; release to stop.
Crosses zero into negative/positive votes."
Verified end-to-end
-------------------
- Page load with all funnels at zero: each diamond has both Vs
outlined, midline visible, ruler spans −10 to +10 with signed
labels, every readout reads "0 votes 0 credits". Pool 100 / 100.
- Hold "+" on Harris ~1.5 s, release: snaps to "+3 votes 9 credits",
pool 91 / 100. Conservation 100 − 9 = 91.
- Hold "−" on Harris from 0 ~1.5 s, release: snaps to "−3 votes
9 credits", pool 91 / 100. Same cost — sign drops out.
- Cap at +10 / −10 enforced; pool floor enforced; mixed-sign
conservation tested across multiple funnels.
- 37 tests passing (was 33; +4 for signed math), ESLint clean,
typecheck clean, all three build targets succeed, no console
warnings.
What stayed the same
--------------------
The hold-to-pour gesture mechanics (constant volumetric rate, smooth
water motion during a hold, snap-on-release), the pool reservoir,
the pour stream visual, the intro copy, the on-load explainer, the
footer disclaimer, the default ballot.
The intro copy still works as a high-level statement; the ruler
going negative makes the bidirectional behaviour self-evident in the
visual. A separate copy round can follow once the mechanic is
settled, per the brief's "DO NOT in this round" list.
Docs
----
docs/round-12/README.md describes each state with verified DOM
snapshots from the live preview.
✅ Deploy Preview for liquid-qv ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Each ballot item is now a vertical diamond — two 90°-apex V-troughs meeting at a midline at vote = 0. Holding "+" moves the water level UP regardless of where it currently is; holding "−" moves it DOWN. Crossing the midline is a smooth continuation of the same gesture; cost is
votes²regardless of sign, so 3 against costs 9 credits just as 3 for would.Conservation generalises cleanly:
pool + Σ votes_i² = budget.Math layer
costForVotesnow accepts negative input. The early return forv ≤ 0is gone; squaring already handles sign, and conservation needs negative votes to count their cost.minVotes(budget)for the symmetric negative cap.clampVotesAgainstBudgetis signed-aware: floors|v|, preserves sign, applies caps in both directions, normalises-0→0.snapVotesToIntegeruses round-half-AWAY-from-zero — so−0.5→−1and+0.5→+1. JavaScript'sMath.roundrounds toward+∞(would map−0.5to0), so we round|live|and reapply the sign. Followed by a signed clamp.±0.5, mixed-sign clamp, mixed-sign conservation.State machine: signed credits during a hold
ActivePournow tracksstartSigned = sign(v₀) × v₀²instead ofstartCredits. During any hold, signed credits change monotonically:The active vote count comes back as
v = sign(s) × √|s|. This is the load-bearing change that lets the user crossv = 0in a single hold without any special case at the apex —sjust passes through zero and the rendering stays continuous. The pour stream visual flips direction naturally: whilev > 0and "−" is held, the stream goes funnel → pool (sfalling toward 0); oncev < 0, the stream flips to pool → funnel (|s|growing on the negative side).startPourchecks±ceilingAbsin both directions.canPour/canDrainsplit: "+" enabled whenstartVotescan still move up; "−" enabled when it can still move down. Reset enables whenevervotes !== 0.Funnel rendering
Full SVG rewrite, but every layer is the symmetric pair of what existed before. The cavity is twice as tall (upper V + lower V sharing an apex at the midline). The water polygon is a unified path:
At
v = 0the path collapses to a single point (still a valid 4-vertex path so Framer Motion can interpolate). The midline is a faint horizontal at the apex; it brightens slightly atv = 0so the rest state reads. Ruler spans−10to+10with major ticks (and signed labels+N / 0 / −N) at every even integer; minor ticks at the odd integers between.Display
+3 votes 9 credits/0 votes 0 credits/−4 votes 16 credits(real Unicode minus, U+2212)91 / 100 credits— non-negative integer, unchangedaria-valuemin/aria-valuemax-cap/+caparia-valuetextVerified end-to-end
aria-valuetext: "0 votes, 0 credits"on every funnel; pool100 of 100 credits remainingaria-valuetext: "+3 votes, 9 credits"; pool91 of 100 credits remainingaria-valuetext: "−3 votes, 9 credits"; pool91 of 100 credits remaining(same cost)Conservation across mixed-sign committed states verified by tests:
37 tests passing (was 33; +4 for signed math), ESLint clean,
tsc -b --noEmitclean, all three build targets succeed, no console warnings during interaction.What stayed the same
clampVotesAgainstBudget).Per the brief's "DO NOT" list, the explainer copy is unchanged this round. The ruler going negative makes the bidirectional behaviour self-evident in the visual; a separate copy round can follow once the mechanic is settled.
Screenshots
GitHub doesn't accept binary uploads via
gh pr create.docs/round-12/README.mdon this branch describes each state with verified DOM snapshots from the live preview. To see the diamond + bidirectional pour in action:npm run dev.Test plan
npm install && npm run dev0 votes 0 credits.+3 votes 9 credits; pool 91 / 100.−2 votes 4 credits; pool 96 / 100. Net pool change from the start of the hold: +9 − 4 = pool back up by 5.npm test && npm run lint && npm run typecheck— green.Don't merge
Per the brief: hold for review.
🤖 Generated with Claude Code