Skip to content

negative voting via bidirectional funnels - #10

Merged
jackhenderson12 merged 1 commit into
mainfrom
negative-voting
May 7, 2026
Merged

negative voting via bidirectional funnels#10
jackhenderson12 merged 1 commit into
mainfrom
negative-voting

Conversation

@jackhenderson12

Copy link
Copy Markdown
Contributor

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.

   +10  ─────────────────────  upper rim
         \                  /
          \      v > 0     /     (water in upper V)
           \              /
    0  ────●──────────────●────  midline (apex of both Vs)
           /              \
          /      v < 0     \     (water in lower V)
         /                  \
   −10  ─────────────────────  lower rim

Conservation generalises cleanly: pool + Σ votes_i² = budget.

Math layer

  • costForVotes now accepts negative input. The early return for v ≤ 0 is gone; squaring already handles sign, 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 -00.
  • snapVotesToInteger uses round-half-AWAY-from-zero — so −0.5−1 and +0.5+1. JavaScript's Math.round rounds toward +∞ (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.

State machine: signed credits during a hold

ActivePour now tracks startSigned = sign(v₀) × v₀² instead of startCredits. During any hold, signed credits change monotonically:

ds/dt = +POUR_RATE  while "+" is held
ds/dt = −POUR_RATE  while "−" 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 — s just passes through zero and the rendering stays continuous. 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 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 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
      surfaceY = midY − sign(v) × h

At v = 0 the 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 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

Element Format
Under-funnel readout +3 votes 9 credits / 0 votes 0 credits / −4 votes 16 credits (real Unicode minus, U+2212)
Pool 91 / 100 credits — non-negative integer, unchanged
ARIA aria-valuemin / aria-valuemax -cap / +cap
ARIA aria-valuetext matches the visible readout
PourControl labels "Move ${title}'s vote down/up. Hold to continue; release to stop. Crosses zero into negative/positive votes."

Verified end-to-end

Scenario DOM at the moment of capture
All funnels at 0 aria-valuetext: "0 votes, 0 credits" on every funnel; pool 100 of 100 credits remaining
Hold "+" on Harris ~1.5 s, release aria-valuetext: "+3 votes, 9 credits"; pool 91 of 100 credits remaining
Hold "−" on Harris ~1.5 s from zero, release aria-valuetext: "−3 votes, 9 credits"; pool 91 of 100 credits remaining (same cost)

Conservation across mixed-sign committed states verified by tests:

const votes = { a: 6, b: -4, c: 2 }; // 36 + 16 + 4 = 56 spent
const snapped = snapVotesToInteger(-9.7, 'd', votes, 100);
const total = costForVotes(snapped) + 36 + 16 + 4;
expect(total).toBeLessThanOrEqual(100);

37 tests passing (was 33; +4 for signed math), ESLint clean, tsc -b --noEmit clean, all three build targets succeed, no console warnings during interaction.

What stayed the same

  • The hold-to-pour gesture mechanics (constant volumetric rate, smooth water motion during a hold, snap-on-release).
  • Integer commits at rest. Continuous (now signed) values during a live hold.
  • The pool reservoir, pour stream visual, intro copy, on-load explainer, footer disclaimer, default ballot.
  • The reducer (no shape change — already accepts signed integer values via 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.md on 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 dev
  • Page load → each funnel is a diamond at zero with midline + −10 to +10 ruler. Readout 0 votes 0 credits.
  • Hold "+" on Harris ~1.5 s, release → water in upper V; readout +3 votes 9 credits; pool 91 / 100.
  • Hold "−" on Harris from +3, watch it drain back to zero, then continue past into the lower V; release at, say, ~−2 → readout −2 votes 4 credits; pool 96 / 100. Net pool change from the start of the hold: +9 − 4 = pool back up by 5.
  • Hold "+" on Newsom while Harris has −2 → pool draws normally; conservation across mixed-sign votes.
  • Cap at +10 / −10 enforced (the corresponding direction button disables); pool floor enforced.
  • OS reduced-motion → still works (water snaps without animation; ruler always visible).
  • npm test && npm run lint && npm run typecheck — green.

Don't merge

Per the brief: hold for review.

🤖 Generated with Claude Code

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.
@netlify

netlify Bot commented May 7, 2026

Copy link
Copy Markdown

Deploy Preview for liquid-qv ready!

Name Link
🔨 Latest commit be5cd8c
🔍 Latest deploy log https://app.netlify.com/projects/liquid-qv/deploys/69fcdc05132cc700089687fe
😎 Deploy Preview https://deploy-preview-10--liquid-qv.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@jackhenderson12
jackhenderson12 merged commit 9f59613 into main May 7, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant