slider round 4: flush extremes, solid ticks, arrows always live, disc rim reverted

- 0/100 at the very edges: the paint moved off the input onto a rail
  overlay spanning exactly the thumb-CENTER range (inset 8px = half
  the 16px disc per side) — the disc rests flush against the rail's
  ends, no bare stub poking past it. The input is now pure interaction:
  full width, transparent, 16px-tall hit area, native drag.
- Ticks solid, not dashed; plain p% positions (the overlay IS the
  center range), still background layers under the fill.
- Typing guard (owner): a range input is NOT a typing field — after a
  click or drag on the slider the ↑/↓ option walk and ←/→ confidence
  cycling keep working. Only real text fields (thinking, answer,
  search, renames) own their keys. The slider's own keydown is gone —
  the global machine owns arrows, and its preventDefault cancels the
  native 1% step.
- Disc rim reverted: plain bg-current disc again.
This commit is contained in:
amosblomqvist
2026-10-02 01:10:07 +03:00
parent f5fa3ca568
commit a365438833
+64 -60
View File
@@ -387,7 +387,15 @@ function QuestionDockForm({
if (!active || rowCount === 0) return; if (!active || rowCount === 0) return;
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
const t = e.target as HTMLElement | null; const t = e.target as HTMLElement | null;
const typing = !!t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable); // A TYPING field is where keys mean text (owner round 4): the
// thinking/answer fields, search boxes, renames — never a range
// input, whose focus after a click or drag must not kill the
// arrow cycling. Everywhere else the arrows act.
const typing =
!!t &&
((t.tagName === "INPUT" && (t as HTMLInputElement).type !== "range") ||
t.tagName === "TEXTAREA" ||
t.isContentEditable);
// Alt/Cmd/Ctrl combos are never ours (Alt+arrows cycle sessions // Alt/Cmd/Ctrl combos are never ours (Alt+arrows cycle sessions
// globally — they must not ALSO walk the question rows). // globally — they must not ALSO walk the question rows).
const modded = e.altKey || e.metaKey || e.ctrlKey; const modded = e.altKey || e.metaKey || e.ctrlKey;
@@ -612,18 +620,18 @@ function QuestionDockForm({
</div> </div>
{/* Confidence (owner, 2026-10-01; UI rounds same day): a snapped {/* Confidence (owner, 2026-10-01; UI rounds same day): a snapped
slider below the options and above "Your thinking" — the slider below the options and above "Your thinking" — the
learner's stated probability that his selected answer is learner's stated probability that his selected answer is
correct. The thumb's POSITION is the raw fraction (50 sits correct. The thumb's POSITION is the raw fraction; the painted
halfway); fill + handle wear the stop's ramp color (the disc RAIL overlays exactly the thumb-center range (inset half a disc
carries a dark rim so it never bleeds into its own fill); per side), so 0 and 100 rest flush against the rail's very
the bar is the original thin rail over #141414, with dashed ends. Fill + handle wear the stop's ramp color; SOLID stop
stop ticks INSIDE — background layers behind the fill, so ticks are background layers behind the fill (empty side only,
they show only on the empty side, never over the color. never over the color; 0/100 need no tick — the flush ends say
Keyboard: GLOBAL ←/→ (the ↑/↓ pattern; the ring is gone them). Keyboard: GLOBAL ←/→ (the ↑/↓ pattern; the ring is gone
app-wide). Clicking and dragging are native. "I don't know" app-wide). Clicking and dragging are native. "I don't know"
GRAYS it out — the layout holds, no value is sent, the IDK GRAYS it out — the layout holds, no value is sent, the IDK
signal stands alone. */} signal stands alone. */}
{kind === "quiz" && ( {kind === "quiz" && (
<div className="border-t border-border/60 px-4 py-2.5"> <div className="border-t border-border/60 px-4 py-2.5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -635,7 +643,38 @@ function QuestionDockForm({
> >
Confidence Confidence
</span> </span>
<div className="relative min-w-0 flex-1" data-testid="confidence-track"> <div
className={cn("relative min-w-0 flex-1", sel.dontKnow && "opacity-40")}
data-testid="confidence-track"
>
{/* The RAIL is painted here, not on the input: this overlay spans
exactly the thumb-CENTER range (inset 8px = half the 16px disc,
left and right), so at 0 and 100 the disc sits flush against the
rail's very ends — no bare stub poking past it (owner round 4).
The input on top is pure interaction: full width, transparent,
native drag and click. */}
<div
aria-hidden
data-testid="confidence-rail"
className="pointer-events-none absolute top-1/2 left-2 right-2 h-1.5 -translate-y-1/2 rounded-full border border-border"
style={{
// Stack: #141414 base (the darker main background), SOLID stop
// ticks as background LAYERS, fill on top — TRANSPARENT past the
// stop, so the ticks read as background and never paint over the
// color. Ticks skip 0 and 100 (the rail's flush ends already say
// those); positions are plain p% — the overlay IS the center range.
backgroundColor: "#141414",
backgroundImage: [
`linear-gradient(90deg, ${CONFIDENCE_COLORS[confIdx]} 0%, ${CONFIDENCE_COLORS[confIdx]} ${CONFIDENCE_STOPS[confIdx]}%, transparent ${CONFIDENCE_STOPS[confIdx]}%, transparent 100%)`,
...[30, 50, 70, 90].map(
() => "linear-gradient(90deg, rgba(230,230,230,0.35), rgba(230,230,230,0.35))",
),
].join(", "),
backgroundSize: ["100% 100%", "1px 100%", "1px 100%", "1px 100%", "1px 100%"].join(", "),
backgroundPosition: ["0 0", ...[30, 50, 70, 90].map((p) => `${p}% 0`)].join(", "),
backgroundRepeat: "no-repeat",
}}
/>
<input <input
type="range" type="range"
min={0} min={0}
@@ -647,28 +686,12 @@ function QuestionDockForm({
aria-valuetext={`${CONFIDENCE_STOPS[confIdx]} percent`} aria-valuetext={`${CONFIDENCE_STOPS[confIdx]} percent`}
title="The probability your selected answer is correct — 50% is a coin flip. Adjust with ←/→." title="The probability your selected answer is correct — 50% is a coin flip. Adjust with ←/→."
data-testid="confidence-slider" data-testid="confidence-slider"
onKeyDown={(e) => {
// Focused-here arrows step the STOP index (native
// would step 1% and snap straight back — dead keys);
// Enter submits, dock-wide intuition. The global
// handler skips INPUT targets, so no double-step.
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
setConfIdx((i) =>
Math.min(
CONFIDENCE_STOPS.length - 1,
Math.max(0, i + (e.key === "ArrowRight" ? 1 : -1)),
),
);
} else if (e.key === "Enter" && canSubmit) {
e.preventDefault();
submit();
}
}}
onChange={(e) => { onChange={(e) => {
// Snap to the nearest stop; the rendered VALUE is // Snap to the nearest stop; the rendered VALUE is the stop's own
// the stop's own percent, so the thumb always rests // percent, so the thumb always rests at its true fraction (50 =
// at its true fraction (50 = halfway). // halfway). Keyboard needs no handler here: the dock machine treats
// a focused range as non-typing (see the guard above) and its ←/→
// branch preventDefaults, which also cancels the native 1% step.
const v = Number(e.target.value); const v = Number(e.target.value);
let best = 0; let best = 0;
for (let i = 1; i < CONFIDENCE_STOPS.length; i++) { for (let i = 1; i < CONFIDENCE_STOPS.length; i++) {
@@ -676,32 +699,13 @@ function QuestionDockForm({
} }
setConfIdx(best); setConfIdx(best);
}} }}
style={{ style={{ color: CONFIDENCE_COLORS[confIdx] }}
color: CONFIDENCE_COLORS[confIdx],
// The stack (owner, round 3): #141414 base (the darker
// main background), dashed stop ticks as background
// LAYERS, fill on top — TRANSPARENT past the thumb, so
// the ticks read as background and never paint over the
// filled portion. Ticks skip 0 and 100 (the rail's ends
// already say those) and sit at the thumb-center inset:
// calc(8px + (100% - 16px) * p).
backgroundColor: "#141414",
backgroundImage: [
`linear-gradient(90deg, ${CONFIDENCE_COLORS[confIdx]} 0%, ${CONFIDENCE_COLORS[confIdx]} ${CONFIDENCE_STOPS[confIdx]}%, transparent ${CONFIDENCE_STOPS[confIdx]}%, transparent 100%)`,
...[30, 50, 70, 90].map(
() => "repeating-linear-gradient(180deg, rgba(230,230,230,0.35) 0 1.5px, transparent 1.5px 3.5px)",
),
].join(", "),
backgroundSize: ["100% 100%", "1px 100%", "1px 100%", "1px 100%", "1px 100%"].join(", "),
backgroundPosition: ["0 0", ...[30, 50, 70, 90].map((p) => `calc(8px + (100% - 16px) * ${p / 100}) 0`)].join(", "),
backgroundRepeat: "no-repeat",
}}
className={cn( className={cn(
"h-1.5 w-full cursor-pointer appearance-none rounded-full border border-border outline-none disabled:opacity-40", "h-4 w-full cursor-pointer appearance-none bg-transparent outline-none",
"[&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-transparent", "[&::-webkit-slider-runnable-track]:h-4 [&::-webkit-slider-runnable-track]:bg-transparent",
"[&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-[#141414] [&::-webkit-slider-thumb]:bg-current [&::-webkit-slider-thumb]:-mt-1.5", "[&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-current",
"[&::-moz-range-track]:h-1 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-transparent", "[&::-moz-range-track]:h-4 [&::-moz-range-track]:bg-transparent",
"[&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-[#141414] [&::-moz-range-thumb]:bg-current", "[&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-current",
)} )}
/> />
</div> </div>