Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 208 additions & 0 deletions client/src/components/mana/AmountInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { useId } from "react";
import { useTranslation } from "react-i18next";

import { gameButtonClass } from "../ui/buttonStyles.ts";

/**
* Shared bounded-amount control for the engine's amount prompts
* (PayAmountChoice / ChooseXValue / AssistPayment).
*
* The `[min, max]` window is ENGINE-OWNED and arrives as props β€” this component holds no bound of
* its own, no default, and no fallback. `parseAmount` is the single sanitization authority; it
* REJECTS (returns null) rather than coercing, so a player never submits a number they did not type.
*/

/** Digit reading of `raw`, ignoring the window. Recovery uses this; SUBMISSION uses `parseAmount`. */
function digitsOf(raw: string): number | null {
return /^\d+$/.test(raw) ? Number(raw) : null;
}

export function parseAmount(raw: string, min: number, max: number): number | null {
// Digits only. `Number()` alone is NOT sufficient: MEASURED, Number("") === 0,
// Number(" 7 ") === 7, Number("1.5") === 1.5, Number("1e3") === 1000, Number("+2") === 2 and
// Number("0x10") === 16 all land INSIDE a typical window.
const value = digitsOf(raw);
return value !== null && value >= min && value <= max ? value : null;
}

export interface AmountInputLabels {
/** aria-label for the numeric text box. */
input: string;
/** aria-label for the βˆ’ stepper. */
decrease: string;
/** aria-label for the + stepper. */
increase: string;
}

export function AmountInput({
raw,
onRawChange,
min,
max,
onSubmit,
labels,
}: {
raw: string;
onRawChange: (raw: string) => void;
min: number;
max: number;
/** Called on Enter. MUST itself reject an invalid amount β€” AmountInput deliberately does not
* re-guard, because a second guard would make the caller's guard unobservable and untestable. */
onSubmit: () => void;
labels: AmountInputLabels;
}) {
const { t } = useTranslation("game");
const amount = parseAmount(raw, min, max);
const hintId = useId();
const errorId = useId();

// Recovery anchor. With the slider deleted the steppers are the only non-typing way out of an
// invalid entry, so they stay LIVE while `amount === null` and snap back into [min, max]. They
// step from the DIGIT reading, not from `amount`: `parseAmount` collapses "junk" and "out of
// range" into the same null, so stepping from `amount ?? min` would throw away a perfectly
// readable 1001 and jump to min. `parseAmount` gates SUBMISSION; `step` performs RECOVERY
// toward the window.
const step = (delta: number) =>
onRawChange(String(Math.min(Math.max((digitsOf(raw) ?? min) + delta, min), max)));
const decDisabled = amount !== null && amount <= min;
const incDisabled = amount !== null && amount >= max;

// ponytail: no showSlider/showSteppers flag β€” the slider is deleted, not configurable.
// ponytail: no role="alert" β€” assertive per-keystroke announcements are the anti-pattern;
// aria-invalid + aria-describedby is the association.
// ponytail: no pattern="[0-9]*" β€” inputMode="numeric" carries the modern-iOS keypad; add back
// only on a legacy-iOS report.
// ponytail: the null-guard lives once, in the caller's handleCommit β€” a second guard in
// onKeyDown would make it unobservable.
// role="spinbutton" IS carried, reversing an earlier note here that claimed no in-repo
// precedent and unwanted aria-value* upkeep. Both premises were wrong: `ManaCurve.tsx` already
// uses aria-valuenow, and the three controls this box replaced announced their value NATIVELY
// (`type="range"` β‡’ role slider, `type="number"` β‡’ role spinbutton). Dropping to a bare
// `type="text"` therefore made the ACCEPTED amount inaudible β€” pressing +/βˆ’ or the arrow keys
// mutated a value nothing exposed. The role is descriptive rather than decorative because the
// box implements the pattern's CORE keyboard interaction (ArrowUp/ArrowDown step, clamped to
// the window) β€” not the full APG list. Home/End (jump to min/max) are deliberately NOT
// remapped: the host is a real editable text field where they carry load-bearing caret
// semantics, and native `<input type="number">` β€” whose implicit role is already spinbutton β€”
// does not remap them either.
// `aria-valuenow` uses the VALIDATED amount, so it is absent while the entry is out of range
// rather than contradicting aria-valuemin/max; `aria-invalid` carries that state instead.
return (
<div className="mb-4 px-2">
<div className="flex items-center justify-center gap-2">
<button
type="button"
onClick={() => step(-1)}
disabled={decDisabled}
aria-label={labels.decrease}
className={gameButtonClass({
tone: "neutral",
size: "xs",
disabled: decDisabled,
// 44px REAL size, not a 36px box with an expanded `::before`. The pseudo-element
// trick (as in `board/ManualManaToggle`) measured 42px here, not 44: `gameButtonClass`
// adds `border`, and an absolutely positioned pseudo resolves against its ancestor's
// PADDING box (36 βˆ’ 2Γ—1 = 34), so `-inset-1` yields 34 + 8. It works in
// `ManualManaToggle` only because that control uses `ring-1`, which adds no layout
// border. A size that must be derived to be checked is a size that will silently
// regress; these steppers are the only non-typing recovery path out of an invalid
// entry, so they get the boring, directly-readable 44px.
// No `px-0`: `SIZE_CLASSES.xs` emits `px-2.5` later in the compiled sheet and wins,
// so a `px-0` here would read as load-bearing while doing nothing. `w-11` pins the
// border box at 44px regardless of padding (border-box sizing).
className: "h-11 w-11 text-base",
})}
>
βˆ’
</button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<input
type="text"
inputMode="numeric"
autoComplete="off"
value={raw}
onChange={(e) => onRawChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onSubmit();
return;
}
// type="text" has no native stepping; the accessibility floor requires arrows to step.
if (e.key === "ArrowUp") {
e.preventDefault();
step(1);
} else if (e.key === "ArrowDown") {
e.preventDefault();
step(-1);
}
}}
aria-label={labels.input}
role="spinbutton"
aria-valuenow={amount ?? undefined}
aria-valuemin={min}
aria-valuemax={max}
aria-invalid={amount === null}
// The window is ANNOUNCED, not merely displayed. `type="range"` announced min/max/now
// natively; a text box announces nothing, so the range hint is permanently associated
// and the error message is appended to it while invalid.
aria-describedby={amount === null ? `${hintId} ${errorId}` : hintId}
// `w-24` not `w-20`: four digits must fit for the 1000 case. `h-11` (44px) because this
// is the PRIMARY tap target of the control β€” fixing only the steppers would leave the
// main one short. The `::before` idiom cannot be used here regardless: `<input>` is a
// replaced element and renders no pseudo-elements.
className={`h-11 w-24 rounded-lg border bg-gray-950/80 px-2 text-center font-mono text-base font-semibold shadow-inner outline-none transition focus:ring-2 ${
amount === null
? "border-red-400/60 text-red-200 focus:ring-red-400/30"
: "border-cyan-400/30 text-cyan-100 focus:ring-cyan-400/30"
}`}
/>
<button
type="button"
onClick={() => step(1)}
disabled={incDisabled}
aria-label={labels.increase}
className={gameButtonClass({
tone: "neutral",
size: "xs",
disabled: incDisabled,
// 44px REAL size, not a 36px box with an expanded `::before`. The pseudo-element
// trick (as in `board/ManualManaToggle`) measured 42px here, not 44: `gameButtonClass`
// adds `border`, and an absolutely positioned pseudo resolves against its ancestor's
// PADDING box (36 βˆ’ 2Γ—1 = 34), so `-inset-1` yields 34 + 8. It works in
// `ManualManaToggle` only because that control uses `ring-1`, which adds no layout
// border. A size that must be derived to be checked is a size that will silently
// regress; these steppers are the only non-typing recovery path out of an invalid
// entry, so they get the boring, directly-readable 44px.
// No `px-0`: `SIZE_CLASSES.xs` emits `px-2.5` later in the compiled sheet and wins,
// so a `px-0` here would read as load-bearing while doing nothing. `w-11` pins the
// border box at 44px regardless of padding (border-box sizing).
className: "h-11 w-11 text-base",
})}
>
+
</button>
<span id={hintId} className="shrink-0 text-xs text-gray-500">
{min > 0 ? t("mana.minMax", { min, max }) : t("mana.maxOnly", { max })}
</span>
</div>

{/* PERMANENTLY MOUNTED, and a live region β€” both properties are load-bearing.
`aria-invalid`/`aria-describedby` are resolved by a screen reader when focus ARRIVES at
the box, but here they flip while focus is already inside it, so association alone
announces nothing: the player would have to tab away and back to learn the entry was
refused. Mounting the node INTO a live region is equally unreliable (regions announce
MUTATIONS of existing content), so the region pre-exists and only its text changes.
`role="status"` (polite) rather than "alert": the entry is being corrected mid-typing
and must not interrupt what is already being read. `min-h-4` reserves the line so
recovering from an invalid entry does not shift the panel under the pointer. */}
<p
id={errorId}
role="status"
aria-live="polite"
className="mt-2 min-h-4 text-center text-xs text-red-300"
>
{amount === null ? t("mana.amountOutOfRange", { min, max }) : ""}
</p>
</div>
);
}
75 changes: 49 additions & 26 deletions client/src/components/mana/AssistPaymentUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { useCanActForWaitingState } from "../../hooks/usePlayerId.ts";
import { useGameStore } from "../../stores/gameStore.ts";
import { gameButtonClass } from "../ui/buttonStyles.ts";
import { AmountInput, parseAmount } from "./AmountInput.tsx";

/**
* CR 702.132a: Assist β€” the chosen helper decides how much of the spell's
Expand All @@ -21,15 +22,30 @@ export function AssistPaymentUI() {

const isAssistPayment = waitingFor?.type === "AssistPayment";
const max = isAssistPayment ? waitingFor.data.max_generic : 0;
const [value, setValue] = useState(0);
// Prompt identity, not just bounds: a successor prompt with the SAME max would otherwise leave
// `raw` holding the previous decision, letting the player submit an amount they chose for a
// different prompt. BOTH seats are keyed on, not just `caster` β€” `chosen` is the seat actually
// being asked to pay, so one caster polling two different players in succession changes only
// `chosen`, and keying on `caster` alone would not fire.
const caster = isAssistPayment ? waitingFor.data.caster : null;
const chosen = isAssistPayment ? waitingFor.data.chosen : null;
const [raw, setRaw] = useState("0");

useEffect(() => {
if (isAssistPayment) setValue(0);
}, [isAssistPayment, max]);
if (isAssistPayment) setRaw("0");
}, [isAssistPayment, max, caster, chosen]);

// CR 702.132a: "the player you chose may pay for any amount of the generic mana in the
// spell's total cost" β€” the variant's domain is 0..max_generic. This is NOT a
// frontend-invented bound: `WaitingFor::AssistPayment` carries no `min` field, and the
// engine's own prompt projection (`game::interaction::number_projection`) synthesizes
// `min: 0` for this variant. The client mirrors the engine's projection.
const amount = parseAmount(raw, 0, max);

const handleCommit = useCallback(() => {
dispatch({ type: "CommitAssistPayment", data: { generic: value } });
}, [dispatch, value]);
if (amount === null) return;
dispatch({ type: "CommitAssistPayment", data: { generic: amount } });
}, [dispatch, amount]);

if (!isAssistPayment || !canAct) return null;

Expand All @@ -47,32 +63,39 @@ export function AssistPaymentUI() {
{t("assist.payment.title")}
</h3>

<div className="mb-4 px-2">
<label className="flex items-center gap-3 text-sm text-gray-200">
<span className="shrink-0 font-mono text-base text-cyan-300">
{value}
</span>
<input
type="range"
min={0}
max={max}
value={value}
onChange={(e) => setValue(Number(e.target.value))}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-gray-700 accent-cyan-500"
aria-label={t("assist.payment.title")}
/>
<span className="shrink-0 text-xs text-gray-500">/ {max}</span>
</label>
</div>
{/* Reusing `assist.payment.title` as the box's accessible name preserves today's
accessible name byte-for-byte (the slider already carried it) and adds no key. */}
<AmountInput
raw={raw}
onRawChange={setRaw}
min={0}
max={max}
onSubmit={handleCommit}
labels={{
input: t("assist.payment.title"),
decrease: t("mana.decreaseAmount"),
increase: t("mana.increaseAmount"),
}}
/>

<div className="flex justify-center">
<button
onClick={handleCommit}
className={gameButtonClass({ tone: "emerald", size: "md" })}
disabled={amount === null}
className={gameButtonClass({
tone: "emerald",
size: "md",
disabled: amount === null,
})}
>
{value === 0
? t("assist.payment.decline")
: t("assist.payment.commit", { value })}
{/* `(amount ?? 0) === 0` collapsed "invalid entry" into "declining to pay", so an
over-max entry labelled the button "Pay nothing" β€” the OPPOSITE of the pending
intent. Declining is amount === 0 exactly; null is no decision yet. */}
{amount === null
? t("mana.confirmAmount")
: amount === 0
? t("assist.payment.decline")
: t("assist.payment.commit", { value: amount })}
</button>
</div>
</div>
Expand Down
Loading
Loading