diff --git a/docs/round-11/README.md b/docs/round-11/README.md
new file mode 100644
index 0000000..c82f34b
--- /dev/null
+++ b/docs/round-11/README.md
@@ -0,0 +1,72 @@
+# Round 11 — Measuring stick + integer snap on release
+
+Replaces the gauge from PR #8 with a persistent 0–10 measuring stick, and snaps committed votes to whole numbers on release. The hold-to-pour gesture stays — slowing water during a hold remains the load-bearing pedagogy. Only the *committed* value is integer.
+
+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.
+
+## State 1 — All funnels at 0
+
+What renders: each funnel shows the persistent 0–10 ruler on its outer right edge — major ticks with labels at `0/2/4/6/8/10`, minor ticks (no labels) at `1/3/5/7/9`. Pool reads `100 / 100 credits`. Each card readout shows `0 votes 0 credits`.
+
+Verified DOM:
+
+```
+[
+ { label: "Votes for Kamala Harris", aria-valuenow: "0",
+ aria-valuetext: "0 votes, 0 credits" },
+ { label: "Votes for Gavin Newsom", aria-valuenow: "0",
+ aria-valuetext: "0 votes, 0 credits" },
+ ...same for all six funnels
+]
+```
+
+Pool meter: `aria-valuetext = "100 of 100 credits remaining"`.
+
+## State 2 — Mid-hold (Harris ~1.5 s from empty)
+
+What renders: water rises continuously inside Harris's funnel, slowing as the funnel widens. Other funnels stay at 0. The 0–10 ruler is unchanged on every funnel — no fade, no state-dependent visibility.
+
+The eval probe captured the post-release state below; mid-hold the underlying `votes` is fractional (~2.7) but the displayed under-funnel readout rounds at the boundary, so the user sees `3 votes 9 credits` jump in real time as the rounded value changes.
+
+## State 3 — Post-release: snap to integer
+
+Verified DOM after a ~1.5 s hold:
+
+```
+{
+ "harrisAriaText": "3 votes, 9 credits",
+ "harrisReadout": "3 votes 9 credits",
+ "harrisVoteValue": "3",
+ "pool": "91 of 100 credits remaining",
+ "poolDisplay": "91 / 100 credits"
+}
+```
+
+Conservation: `100 − 9 = 91` ✓. Underlying `liveVotes ≈ √7.5 ≈ 2.74` rounded up to `3`; `clamp(3, cap=10, ⌊√(100 − 0)⌋ = 10) = 3`; reducer commits the integer.
+
+## Snap behaviour summary
+
+`snapVotesToInteger(live, item, votes, budget)` (in `src/math/qv.ts`):
+
+```
+committed = clamp(round(live), 0, ⌊√budget⌋, ⌊√(budget − Σ others²)⌋)
+```
+
+Tests (in `src/math/qv.test.ts`) pin both round-up paths:
+
+- 9.6 with `b = 5` (others using 25 credits, available = 75) → round = 10 → clamps to `⌊√75⌋ = 8`.
+- 9.7 with `b = 4` (others using 16, available = 84) → round = 10 → clamps to `⌊√84⌋ = 9`.
+
+23 math tests + 10 reducer tests, all passing.
+
+## What changed
+
+- `src/math/qv.ts` — added `snapVotesToInteger`; reverted `maxVotes` to return `⌊√budget⌋`; reverted `clampVotesAgainstBudget` to floor any fractional input it sees (defence-in-depth at the reducer).
+- `src/math/qv.test.ts` — updated `costForVotes` / `clampVotesAgainstBudget` tests for integer cap behaviour; added a `snapVotesToInteger` block covering both round-up edge cases.
+- `src/components/Funnel.tsx` — removed the gauge from PR #8 (live arrow + two reference ticks, `GAUGE_W` reservation, `isAnyPouring` prop). Restored the funnel cavity to its pre-#8 width. Added the persistent 0–10 ruler in extended viewBox space past the V's right edge (the V itself is full size again).
+- `src/components/LiquidQV.tsx` — replaced `clampVotesAgainstBudget` with `snapVotesToInteger` in `endPour`. Display formatter `fmt(n)` now returns `Math.round(n).toString()` instead of `toFixed(1)`. Dropped `isAnyPouring={Boolean(activePour)}` from the Funnel props.
+- `src/components/CreditPool.tsx` — readout uses integer rounding.
+
+## What stayed the same
+
+The 2D triangle funnel rendering, the hold-to-pour gesture mechanics (constant volumetric rate, water rises smoothly during a hold), the conservation invariant, the pool reservoir, the pour stream, the intro copy, the on-load explainer, the footer disclaimer, and the default ballot.
diff --git a/src/components/CreditPool.tsx b/src/components/CreditPool.tsx
index f801706..48e5d5d 100644
--- a/src/components/CreditPool.tsx
+++ b/src/components/CreditPool.tsx
@@ -35,10 +35,11 @@ interface Props {
export const CreditPool = ({ remaining, budget, readout, height = 84 }: Props) => {
const reduceMotion = useReducedMotion();
const fillRatio = budget > 0 ? Math.max(0, Math.min(1, remaining / budget)) : 0;
- const display = readout ?? (Math.round(remaining * 10) / 10).toFixed(1);
- // ARIA mirrors the displayed one-decimal readout — what a sighted
- // user reads is what a screen-reader user hears.
- const remainingDisplay = Math.round(remaining * 10) / 10;
+ // Round 11 — integer display everywhere. Underlying `remaining` may
+ // still be fractional during a hold (the live derivation), but the
+ // visible number rounds at the boundary.
+ const remainingInt = Math.round(remaining);
+ const display = readout ?? remainingInt.toString();
return (
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.
+ * The vote axis is *linear in height* (votes = water height) so the
+ * tick spacing is even. The quadratic lives in the credits readout
+ * under the funnel; the ruler counts votes directly.
*
- * `votes` may be fractional (real-valued) at any time. ARIA reports
- * the one-decimal-rounded value — same number a sighted user reads.
+ * `votes` is an integer at rest and may be fractional during an active
+ * hold (the parent passes the live continuous value). The water polygon
+ * and surface highlight render directly from it. ARIA reports the
+ * integer-rounded value — the same number the under-funnel readout
+ * shows.
*/
interface FunnelProps {
- /** Real-valued vote level (rest or live). */
+ /** Vote level — integer at rest, fractional during a live hold. */
votes: number;
- /** Maximum allowed votes here (= √budget). */
+ /** Maximum allowed votes here (= ⌊√budget⌋, integer cap). */
maxVotes: number;
/** Visible label for screen readers and the slider's aria-valuetext. */
label: string;
@@ -43,35 +39,27 @@ interface FunnelProps {
onPourEnd: () => void;
/**
* Disable the water polygon's interpolation animation. Set during an
- * active hold so the water tracks the live value frame-for-frame
- * instead of lagging behind a moving Framer-Motion target.
+ * active hold so the water tracks the live `votes` prop frame-for-
+ * frame; off otherwise so the snap-on-release transition gets a soft
+ * settle.
*/
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. */
style?: CSSProperties;
}
-/** 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;
+// Ruler layout constants. The funnel cavity stays at its pre-#8
+// proportions (size − pads, no GAUGE_W subtraction); the ruler lives
+// in extra viewBox width past the V's right edge.
+const RULER_GAP = 4; // gap from V's right edge to the ruler's tick anchor
+const MAJOR_TICK_W = 10;
+const MINOR_TICK_W = 5;
+const LABEL_OFFSET = 4;
+const LABEL_FONT_SIZE = 10;
+const LABEL_RESERVE = 14; // approx pixel room for "10" / "0" labels
+const RULER_RIGHT_PAD = 4;
const POSITION_EASE = [0.22, 1, 0.36, 1] as const;
export const Funnel = ({
@@ -81,27 +69,22 @@ export const Funnel = ({
onPourStart,
onPourEnd,
instantUpdate = false,
- isAnyPouring = false,
size = 220,
style,
}: FunnelProps) => {
const reduceMotion = useReducedMotion();
const sliderId = useId();
- // Track which key is currently driving a hold-pour. Only one hold at a
- // time per funnel — pressing a second key while holding the first is
- // ignored. Release of the original key ends the pour.
const holdKeyRef = useRef(null);
- // SVG layout — width-driven. Funnel cavity = (size − pads − gauge);
- // V height = funnel cavity / 2 (45° walls).
+ // SVG layout — width-driven. Funnel cavity = size − pads (no gauge
+ // subtraction); 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 - GAUGE_W;
+ const funnelWidth = size - PAD_LEFT - PAD_RIGHT;
const usableHeight = funnelWidth / 2;
- const viewBoxH = PAD_TOP + usableHeight + PAD_BOTTOM;
const cx = PAD_LEFT + funnelWidth / 2;
const apexY = PAD_TOP + usableHeight;
const SCALE = maxVotes > 0 ? usableHeight / maxVotes : 1;
@@ -116,44 +99,41 @@ 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.
+ // Ruler geometry. Tick "axis" is at rulerAxisX; ticks point LEFT
+ // (toward the water) so a major tick spans [rulerAxisX − MAJOR_TICK_W,
+ // rulerAxisX]. Labels sit just to the right of the axis.
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 rulerAxisX = rightEdgeX + RULER_GAP + MAJOR_TICK_W;
+ const labelX = rulerAxisX + LABEL_OFFSET;
+ const viewBoxW = labelX + LABEL_RESERVE + RULER_RIGHT_PAD;
+ const viewBoxH = PAD_TOP + usableHeight + PAD_BOTTOM;
- const announcedVotes = round1(votes);
- const showIndicator = announcedVotes > 0;
- const showTicks = announcedVotes <= 0 && !isAnyPouring;
+ // The y-position of vote level v on the ruler is the same as the
+ // water-surface y at that vote level — apexY − v × SCALE — so the
+ // ruler reads directly off the water.
+ const tickY = (voteLevel: number) => apexY - voteLevel * SCALE;
+
+ // 0/2/4/6/8/10 — major. 1/3/5/7/9 — minor.
+ const MAJOR_VALUES = [0, 2, 4, 6, 8, 10];
+ const MINOR_VALUES = [1, 3, 5, 7, 9];
// Keyboard:
// Space / Enter held → continuous pour-in (release ends pour)
// Shift + Space/Enter held → continuous pour-out (drain)
- //
- // Arrow keys, Page Up/Dn, Home/End — all gone. There are no tap
- // shortcuts. Every input goes through the same physics.
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === ' ' || e.key === 'Spacebar' || e.key === 'Enter') {
- // Don't restart the pour on OS-level repeat events.
if (e.repeat || holdKeyRef.current) return;
e.preventDefault();
holdKeyRef.current = e.key;
onPourStart(e.shiftKey ? 'out' : 'in');
}
};
-
const handleKeyUp = (e: KeyboardEvent) => {
if (holdKeyRef.current && e.key === holdKeyRef.current) {
holdKeyRef.current = null;
onPourEnd();
}
};
-
- // Defensive: if focus is lost mid-hold, end the pour.
useEffect(() => {
const cancel = () => {
if (!holdKeyRef.current) return;
@@ -164,17 +144,18 @@ export const Funnel = ({
return () => window.removeEventListener('blur', cancel);
}, [onPourEnd]);
- const announcedCredits = round1(votes * votes);
+ const announcedVotes = Math.round(votes);
+ const announcedCredits = announcedVotes * announcedVotes;
return (
) : (
@@ -208,7 +190,7 @@ export const Funnel = ({
initial={false}
fill="var(--lqv-water)"
animate={{ d: waterPath }}
- transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
+ transition={{ duration: 0.15, ease: POSITION_EASE }}
style={{ pointerEvents: 'none' }}
/>
)}
@@ -237,7 +219,7 @@ export const Funnel = ({
strokeWidth={1.25}
strokeOpacity={0.7}
animate={{ x1: cx - h, x2: cx + h, y1: apexY - h, y2: apexY - h }}
- transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
+ transition={{ duration: 0.15, ease: POSITION_EASE }}
style={{ pointerEvents: 'none' }}
/>
))}
@@ -263,91 +245,51 @@ export const Funnel = ({
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 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 ? (
-
-
+ {/* Measuring stick — persistent 0–10 ruler on the outer right
+ edge. Major ticks (with labels) at 0/2/4/6/8/10; minor ticks
+ (no labels) at 1/3/5/7/9. Tick lines extend LEFT from
+ `rulerAxisX` toward the water; labels sit just right of the
+ axis. Always visible — no fade behaviour. */}
+
+ {MINOR_VALUES.map((v) => (
+
+ ))}
+ {MAJOR_VALUES.map((v) => (
+
+
+
+ {v}
+
- ) : (
-
-
-
- )}
+ ))}
);
};
-
-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 8f7d902..69033f2 100644
--- a/src/components/LiquidQV.tsx
+++ b/src/components/LiquidQV.tsx
@@ -10,6 +10,7 @@ import {
costForVotes,
maxVotes as capFor,
remainingCredits,
+ snapVotesToInteger,
} from '../math/qv';
import { initialState, reducer } from '../lib/reducer';
import { defaultBallot, BALLOT_PROMPT } from '../data/defaultBallot';
@@ -70,8 +71,10 @@ const themeToCssVars = (theme: ThemeOverrides | undefined): Record (Math.round(n * 10) / 10).toFixed(1);
+/** Display formatter — integers everywhere. State may still be
+ * fractional during a hold (the live derivation), but every visible
+ * number rounds at the boundary. */
+const fmt = (n: number): string => Math.round(n).toString();
/**
* Compute the live (in-flight) vote count for the active item at a
@@ -201,15 +204,18 @@ export const LiquidQV = ({
if (!pour) return;
const startVotes = state.votes[pour.itemId] ?? 0;
- // Use the live continuous value at the moment of release. No
- // rounding to integer — the value is what duration × rate produced.
+ // Snap to the nearest integer that fits the cap and the remaining
+ // pool. Round-half-up via Math.round, then clamp DOWN if the
+ // rounded value would overdraw — see the brief's edge cases:
+ // a release at 9.6 with others holding the pool to 75 credits
+ // rounds to 10, then clamps to ⌊√75⌋ = 8.
const liveAtRelease = computeLiveVotes(
pour,
state.votes,
state.budget,
performance.now(),
);
- const finalVotes = clampVotesAgainstBudget(
+ const finalVotes = snapVotesToInteger(
liveAtRelease.activeVotes,
pour.itemId,
state.votes,
@@ -346,7 +352,6 @@ export const LiquidQV = ({
maxVotes={cap}
label={`Votes for ${item.title}`}
instantUpdate={isActive}
- isAnyPouring={Boolean(activePour)}
onPourStart={(direction) =>
direction === 'in' ? handlers.startPourIn() : handlers.startPourOut()
}
diff --git a/src/math/qv.test.ts b/src/math/qv.test.ts
index 03ba1f6..372a635 100644
--- a/src/math/qv.test.ts
+++ b/src/math/qv.test.ts
@@ -5,11 +5,12 @@ import {
costForVotes,
maxVotes,
remainingCredits,
+ snapVotesToInteger,
totalCreditsSpent,
} from './qv';
describe('costForVotes', () => {
- it('squares vote count exactly (no flooring)', () => {
+ it('squares vote count exactly', () => {
expect(costForVotes(0)).toBe(0);
expect(costForVotes(1)).toBe(1);
expect(costForVotes(2)).toBe(4);
@@ -17,11 +18,9 @@ describe('costForVotes', () => {
expect(costForVotes(10)).toBe(100);
});
- it('passes fractional inputs through unchanged', () => {
+ it('squares fractional inputs (used by the live derivation during a hold)', () => {
expect(costForVotes(2.5)).toBeCloseTo(6.25);
- expect(costForVotes(2.9)).toBeCloseTo(8.41);
expect(costForVotes(0.5)).toBeCloseTo(0.25);
- expect(costForVotes(0.1)).toBeCloseTo(0.01);
});
it('returns zero for negative or non-finite inputs', () => {
@@ -32,18 +31,17 @@ describe('costForVotes', () => {
});
describe('maxVotes', () => {
- it('returns √budget without flooring', () => {
+ it('returns ⌊√budget⌋ — integer cap per funnel', () => {
expect(maxVotes(100)).toBe(10);
expect(maxVotes(81)).toBe(9);
- expect(maxVotes(50)).toBeCloseTo(7.0710678);
+ expect(maxVotes(50)).toBe(7); // ⌊√50⌋ = 7; leaves 1 credit at the cap
expect(maxVotes(0)).toBe(0);
});
});
describe('totalCreditsSpent', () => {
- it('sums squared real-valued votes across items', () => {
+ it('sums squared votes across items', () => {
expect(totalCreditsSpent({ a: 3, b: 4 })).toBe(9 + 16);
- expect(totalCreditsSpent({ a: 2.5, b: 1.5 })).toBeCloseTo(6.25 + 2.25);
expect(totalCreditsSpent({})).toBe(0);
});
});
@@ -54,11 +52,6 @@ describe('remainingCredits', () => {
expect(remainingCredits(100, {})).toBe(100);
});
- it('handles fractional votes correctly', () => {
- // a=2.6 (6.76), b=4.1 (16.81), c=1.7 (2.89) → spent 26.46
- expect(remainingCredits(100, { a: 2.6, b: 4.1, c: 1.7 })).toBeCloseTo(73.54);
- });
-
it('floors at zero rather than going negative', () => {
expect(remainingCredits(10, { a: 5 })).toBe(0);
});
@@ -73,74 +66,95 @@ describe('availableCreditsFor', () => {
it('ignores the item being asked about', () => {
// a's own contribution is excluded — the function answers
// "if a's slot were empty, how many credits would be left?"
- expect(availableCreditsFor('a', { a: 2.5, b: 4 }, 100)).toBe(100 - 16);
+ expect(availableCreditsFor('a', { a: 5, b: 4 }, 100)).toBe(100 - 16);
});
});
describe('clampVotesAgainstBudget', () => {
- it('returns the proposed value when there is room (and stays fractional)', () => {
+ it('returns the proposed integer when there is room', () => {
expect(clampVotesAgainstBudget(2, 'a', {}, 100)).toBe(2);
- expect(clampVotesAgainstBudget(3.7, 'a', {}, 100)).toBeCloseTo(3.7);
- expect(clampVotesAgainstBudget(0.4, 'a', {}, 100)).toBeCloseTo(0.4);
});
- it('caps at √budget when proposing more', () => {
+ it('caps at ⌊√budget⌋ when proposing more', () => {
expect(clampVotesAgainstBudget(99, 'a', {}, 100)).toBe(10);
- expect(clampVotesAgainstBudget(99, 'a', {}, 50)).toBeCloseTo(Math.sqrt(50));
- });
-
- it('respects fractional credits already locked by other items', () => {
- // b at 2.5 → 6.25 spent → 93.75 left → max √93.75 ≈ 9.6824 votes here.
- expect(clampVotesAgainstBudget(99, 'a', { b: 2.5 }, 100)).toBeCloseTo(Math.sqrt(93.75));
+ expect(clampVotesAgainstBudget(99, 'a', {}, 50)).toBe(7);
});
- it('respects integer-cap-equivalent when other items use whole credits', () => {
+ it("respects integer credits already locked by other items", () => {
// b at 8 → 64 spent → 36 left → max √36 = 6 votes here.
expect(clampVotesAgainstBudget(99, 'a', { b: 8 }, 100)).toBe(6);
});
+ it('floors any fractional input it receives', () => {
+ expect(clampVotesAgainstBudget(3.7, 'a', {}, 100)).toBe(3);
+ expect(clampVotesAgainstBudget(0.4, 'a', {}, 100)).toBe(0);
+ });
+
it('returns zero for invalid input', () => {
expect(clampVotesAgainstBudget(-1, 'a', {}, 100)).toBe(0);
expect(clampVotesAgainstBudget(NaN, 'a', {}, 100)).toBe(0);
});
- it('preserves the conservation invariant after clamping (real-valued)', () => {
+ it('preserves the conservation invariant after clamping', () => {
const budget = 100;
- const votes = { a: 6.2, b: 3.8, c: 2.1 }; // 38.44 + 14.44 + 4.41 = 57.29 spent
+ const votes = { a: 6, b: 4, c: 2 }; // 36 + 16 + 4 = 56 spent
const clamped = clampVotesAgainstBudget(99, 'd', votes, budget);
- const total =
- costForVotes(clamped) + costForVotes(6.2) + costForVotes(3.8) + costForVotes(2.1);
- expect(total).toBeLessThanOrEqual(budget + 1e-9);
+ const total = costForVotes(clamped) + 36 + 16 + 4;
+ expect(total).toBeLessThanOrEqual(budget);
+ });
+
+ it('returns an integer for any input', () => {
+ for (const v of [0, 1, 2.4, 3.999, 7.5, 9.9, 10, 99]) {
+ expect(Number.isInteger(clampVotesAgainstBudget(v, 'a', {}, 100))).toBe(true);
+ }
});
});
-describe('press duration physics', () => {
- // The hold-only interaction model: transferred credits = duration × rate,
- // applied to the funnel via votes = √(startCredits + transferred). These
- // tests pin the math the UI relies on, not any UI behaviour itself.
- const RATE = 5; // credits per second — must match LiquidQV's POUR_RATE
-
- it('a 200 ms press from empty transfers ~1 credit, ~1 vote', () => {
- const transferred = 0.2 * RATE; // 1 credit
- const votes = Math.sqrt(0 + transferred);
- expect(transferred).toBeCloseTo(1);
- expect(votes).toBeCloseTo(1);
- });
-
- it('a 1.4 s press from empty lands near 2.65 votes, 7 credits', () => {
- const transferred = 1.4 * RATE; // 7 credits
- const votes = Math.sqrt(0 + transferred);
- expect(transferred).toBeCloseTo(7);
- expect(votes).toBeCloseTo(2.6457513);
- });
-
- it('the same hold duration yields a smaller delta as the funnel fills', () => {
- // From empty: 1.4 s of pour → √7 ≈ 2.65 votes (delta from 0 ≈ 2.65)
- const fromEmpty = Math.sqrt(0 + 1.4 * RATE);
- // From 2.6 votes (6.76 credits): another 1.4 s → √(6.76 + 7) ≈ 3.71
- const fromMid = Math.sqrt(6.76 + 1.4 * RATE);
- expect(fromEmpty).toBeCloseTo(2.6457513);
- expect(fromMid).toBeCloseTo(3.71, 1);
- expect(fromMid - 2.6).toBeLessThan(fromEmpty - 0); // delta shrinks
+describe('snapVotesToInteger', () => {
+ it('rounds to the nearest integer when there is room', () => {
+ expect(snapVotesToInteger(2.4, 'a', {}, 100)).toBe(2);
+ expect(snapVotesToInteger(2.5, 'a', {}, 100)).toBe(3); // ties go up
+ expect(snapVotesToInteger(2.6, 'a', {}, 100)).toBe(3);
+ expect(snapVotesToInteger(0.49, 'a', {}, 100)).toBe(0);
+ expect(snapVotesToInteger(0.5, 'a', {}, 100)).toBe(1);
+ });
+
+ it('caps at ⌊√budget⌋', () => {
+ // Empty budget pool, live held to 9.9 → rounds to 10, fits.
+ expect(snapVotesToInteger(9.9, 'a', {}, 100)).toBe(10);
+ // Live held above the cap (e.g. user held past the rim somehow).
+ expect(snapVotesToInteger(11, 'a', {}, 100)).toBe(10);
+ });
+
+ it('clamps DOWN when round-up would exceed the remaining pool (the brief example)', () => {
+ // Others have used 25 credits (e.g. b=5). Available = 75.
+ // ⌊√75⌋ = 8. Live ≈ 9.6 → round = 10 → clamps to 8.
+ expect(snapVotesToInteger(9.6, 'a', { b: 5 }, 100)).toBe(8);
+ });
+
+ it('clamps DOWN when round-up would overdraw a partially-spent pool', () => {
+ // Others used 16 (b=4). Available = 84. ⌊√84⌋ = 9.
+ // Live held to 9.7 → round = 10 → clamps to 9.
+ expect(snapVotesToInteger(9.7, 'a', { b: 4 }, 100)).toBe(9);
+ });
+
+ it('returns zero for invalid or non-positive input', () => {
+ expect(snapVotesToInteger(0, 'a', {}, 100)).toBe(0);
+ expect(snapVotesToInteger(-1, 'a', {}, 100)).toBe(0);
+ expect(snapVotesToInteger(NaN, 'a', {}, 100)).toBe(0);
+ });
+
+ it('always returns an integer', () => {
+ for (const v of [0, 0.5, 1.4, 2.7, 3.0001, 4.99, 5.5, 7.4, 9.99]) {
+ expect(Number.isInteger(snapVotesToInteger(v, 'a', {}, 100))).toBe(true);
+ }
+ });
+
+ it('preserves the conservation invariant across all funnels', () => {
+ const budget = 100;
+ const votes = { a: 6, b: 4, c: 2 }; // 36 + 16 + 4 = 56 spent
+ const snapped = snapVotesToInteger(9.7, 'd', votes, budget);
+ const total = costForVotes(snapped) + 36 + 16 + 4;
+ expect(total).toBeLessThanOrEqual(budget);
});
});
diff --git a/src/math/qv.ts b/src/math/qv.ts
index eea8547..dd10001 100644
--- a/src/math/qv.ts
+++ b/src/math/qv.ts
@@ -11,14 +11,16 @@
* width at height h is 2h, so area below height h is h². Pouring water is
* pouring credits; the visible surface level is the vote count.
*
- * Round 6 (continuous votes): votes and credits are real-valued
- * end-to-end. There is no integer mode and no rounding in the math
- * layer. A single press obeys the same physics as a long hold —
- * transferred credits = duration × rate. Display-time rounding to one
- * decimal lives in the components, never here.
+ * Round 11 (measuring stick + integer snap on release): committed votes
+ * are whole numbers. The hold-to-pour gesture remains the load-bearing
+ * pedagogy — water rises continuously and slows visibly during a hold —
+ * but on release the value snaps to the nearest integer that fits the
+ * cap and the remaining pool. Display formatters round at the boundary;
+ * conservation math is defined on the committed (integer) state.
*
- * All functions are pure and clamped at zero — negative votes are out of
- * scope (see project README for v2 plans).
+ * The live derivation during a hold still works in continuous values
+ * (uses Math.sqrt and v*v directly in LiquidQV's `computeLiveVotes`).
+ * It doesn't reach for these primitives mid-pour; only at commit.
*/
export const costForVotes = (votes: number): number => {
@@ -27,16 +29,16 @@ export const costForVotes = (votes: number): number => {
};
/**
- * Maximum votes a single funnel can hold given the budget. For an
- * integer budget of 100, this is exactly √100 = 10 — a single
- * fully-loaded funnel drains the pool exactly.
+ * Maximum integer votes a single funnel can hold given the budget.
+ * Floor of √budget — for budget 100 this is exactly 10. For non-square
+ * budgets (e.g. 50) it leaves a small remainder at the cap.
*/
export const maxVotes = (budget: number): number => {
if (!Number.isFinite(budget) || budget <= 0) return 0;
- return Math.sqrt(budget);
+ return Math.floor(Math.sqrt(budget));
};
-/** Sum of credits spent across all vote allocations (real-valued). */
+/** Sum of credits spent across all vote allocations. */
export const totalCreditsSpent = (votes: Record): number => {
let sum = 0;
for (const v of Object.values(votes)) sum += costForVotes(v);
@@ -67,9 +69,10 @@ export const availableCreditsFor = (
};
/**
- * Clamp a proposed (real-valued) vote level to the legal range — at
- * most √budget per funnel, and at most √(budget − others' credits).
- * Returns a real number; rounding is the display layer's problem.
+ * Clamp a proposed vote level to the largest *integer* that respects
+ * the per-funnel cap and the remaining pool. Used by the reducer as
+ * a safety net for any 'set' dispatch — anything reaching this point
+ * gets floored.
*/
export const clampVotesAgainstBudget = (
proposedVotes: number,
@@ -79,6 +82,33 @@ export const clampVotesAgainstBudget = (
): number => {
if (!Number.isFinite(proposedVotes) || proposedVotes <= 0) return 0;
const cap = maxVotes(budget);
- const ceilingFromBudget = Math.sqrt(availableCreditsFor(itemId, votes, budget));
- return Math.max(0, Math.min(proposedVotes, cap, ceilingFromBudget));
+ const ceilingFromBudget = Math.floor(Math.sqrt(availableCreditsFor(itemId, votes, budget)));
+ return Math.max(0, Math.min(Math.floor(proposedVotes), cap, ceilingFromBudget));
+};
+
+/**
+ * Snap a live (typically fractional) vote level to the nearest *integer*
+ * that fits the cap and the remaining pool. This is the "release"
+ * commit path: take where the user lifted, round to nearest, then clamp
+ * down if that would overdraw.
+ *
+ * committed = clamp(round(live), 0, cap, ⌊√availableCredits⌋)
+ *
+ * Rounding uses Math.round (ties go up: 0.5 → 1, 1.5 → 2, …). Clamp is
+ * applied AFTER rounding so that a release at 9.6 rounds to 10 first,
+ * then clamps to whatever integer actually fits the pool — matching
+ * the spec's "snap-up exceeds cap" / "snap-up would overdraw pool"
+ * cases (both fall through to the clamp).
+ */
+export const snapVotesToInteger = (
+ liveVotes: number,
+ itemId: string,
+ votes: Record,
+ budget: number,
+): number => {
+ if (!Number.isFinite(liveVotes) || liveVotes <= 0) return 0;
+ const rounded = Math.round(liveVotes);
+ const cap = maxVotes(budget);
+ const ceilingFromBudget = Math.floor(Math.sqrt(availableCreditsFor(itemId, votes, budget)));
+ return Math.max(0, Math.min(rounded, cap, ceilingFromBudget));
};