UI round 2: drag restored, no-ring law, #141414 empty side + outline, dashed stop ticks

Root-cause find: the sidebar 'highlighting' on ←/→ was :focus-visible —
pointer-clicked elements match it only after a keypress, so any key in
the quiz lit the still-focused session row. The arrows never moved
focus. Fix: the no-ring law (owner) — *:focus/:focus-visible outlines
removed app-wide in globals.css; surfaces keep their own affordances
and the dock's keyboard walk keeps its ring utilities.

Slider repairs:
- mousedown-preventDefault had killed click + drag in Chromium —
  removed (with tabIndex -1); the slider is natively interactive again,
  and the ring law makes its focus invisible anyway.
- Focused arrows step the STOP index on the input itself (native would
  step 1% and snap straight back — dead keys); the global handler
  skips INPUT targets, so no double-step. Enter still submits.
- Empty side: var(--muted) matched the card and vanished — now #141414
  (the owner's main-background pick), and the pill carries a border
  again (border-border).
- Dashed tick per snap stop below the bar, inset by the thumb's
  half-width so each tick sits under the thumb's center at that stop.
This commit is contained in:
amosblomqvist
2026-10-02 00:52:08 +03:00
parent 62cfb8112b
commit 2f301322aa
2 changed files with 93 additions and 45 deletions
+12
View File
@@ -15,6 +15,18 @@
* future settings UI reassigns them (configurable per the owner). * future settings UI reassigns them (configurable per the owner).
*/ */
/* The no-ring law (owner, UI round 2026-10-01): the browser's default
focus/focus-visible outline appears NOWHERE in the app. Pointer focus
never showed it; any keypress while a clicked element still held focus
DID (:focus-visible re-evaluates on key input — a clicked sidebar
session lit up when the quiz's ←/→ were pressed). Surfaces carry their
own hover/active/selected affordances; the dock's keyboard walk uses
its own ring utilities, unaffected by an outline reset. */
*:focus,
*:focus-visible {
outline: none;
}
/* Palette (owner's hexes): main #141414, raised #1C1C1C, edges #434343. /* Palette (owner's hexes): main #141414, raised #1C1C1C, edges #434343.
Mono chrome = JetBrains Mono; prose = Source Sans 3; titles = CMU Serif. */ Mono chrome = JetBrains Mono; prose = Source Sans 3; titles = CMU Serif. */
:root { :root {
+81 -45
View File
@@ -611,16 +611,18 @@ function QuestionDockForm({
)} )}
</div> </div>
{/* Confidence (owner, 2026-10-01; UI round 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 (50 sits
halfway), the fill + handle wear the stop's ramp color, and halfway), the fill + handle wear the stop's ramp color, the
the bar is a taller pill. Keyboard: GLOBAL ←/→ (the ↑/↓ bar is a taller pill with an outline and a #141414 empty
pattern — works wherever focus sits); the element itself side, and dashed ticks mark each stop. Keyboard: GLOBAL ←/→
takes no focus and never rings (tabIndex -1, mousedown (the ↑/↓ pattern — works wherever focus sits; the ring is
prevented). "I don't know" GRAYS it out — the layout holds, gone app-wide, so focus anywhere is invisible). Clicking and
no value is sent, the IDK signal stands alone. */} dragging work natively. "I don't know" GRAYS it out — the
layout holds, no value is sent, the IDK 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">
@@ -632,44 +634,78 @@ function QuestionDockForm({
> >
Confidence Confidence
</span> </span>
<input <div className="relative min-w-0 flex-1" data-testid="confidence-track">
type="range" <input
min={0} type="range"
max={100} min={0}
step={1} max={100}
value={CONFIDENCE_STOPS[confIdx]} step={1}
disabled={sel.dontKnow} value={CONFIDENCE_STOPS[confIdx]}
tabIndex={-1} disabled={sel.dontKnow}
aria-label="Confidence — the probability your selected answer is correct" aria-label="Confidence — the probability your selected answer is correct"
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"
// No focus, ever: the owner's global-←/→ pattern owns onKeyDown={(e) => {
// the keyboard; clicking adjusts without ringing. // Focused-here arrows step the STOP index (native
onMouseDown={(e) => e.preventDefault()} // would step 1% and snap straight back — dead keys);
onChange={(e) => { // Enter submits, dock-wide intuition. The global
// Snap to the nearest stop; the rendered VALUE is // handler skips INPUT targets, so no double-step.
// the stop's own percent, so the thumb always rests if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
// at its true fraction (50 = halfway). e.preventDefault();
const v = Number(e.target.value); setConfIdx((i) =>
let best = 0; Math.min(
for (let i = 1; i < CONFIDENCE_STOPS.length; i++) { CONFIDENCE_STOPS.length - 1,
if (Math.abs(CONFIDENCE_STOPS[i]! - v) < Math.abs(CONFIDENCE_STOPS[best]! - v)) best = i; Math.max(0, i + (e.key === "ArrowRight" ? 1 : -1)),
} ),
setConfIdx(best); );
}} } else if (e.key === "Enter" && canSubmit) {
style={{ e.preventDefault();
color: CONFIDENCE_COLORS[confIdx], submit();
background: `linear-gradient(90deg, ${CONFIDENCE_COLORS[confIdx]} 0%, ${CONFIDENCE_COLORS[confIdx]} ${CONFIDENCE_STOPS[confIdx]}%, var(--muted) ${CONFIDENCE_STOPS[confIdx]}%, var(--muted) 100%)`, }
}} }}
className={cn( onChange={(e) => {
"h-2.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full outline-none disabled:opacity-40", // Snap to the nearest stop; the rendered VALUE is
"[&::-webkit-slider-runnable-track]:h-2.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-transparent", // the stop's own percent, so the thumb always rests
"[&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-current [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:-mt-[3px]", // at its true fraction (50 = halfway).
"[&::-moz-range-track]:h-2.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-transparent", const v = Number(e.target.value);
"[&::-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", let best = 0;
)} for (let i = 1; i < CONFIDENCE_STOPS.length; i++) {
/> if (Math.abs(CONFIDENCE_STOPS[i]! - v) < Math.abs(CONFIDENCE_STOPS[best]! - v)) best = i;
}
setConfIdx(best);
}}
style={{
color: CONFIDENCE_COLORS[confIdx],
// The empty side is the darker main background
// (#141414, owner pick 2026-10-01) — --muted matched
// the card and vanished.
background: `linear-gradient(90deg, ${CONFIDENCE_COLORS[confIdx]} 0%, ${CONFIDENCE_COLORS[confIdx]} ${CONFIDENCE_STOPS[confIdx]}%, #141414 ${CONFIDENCE_STOPS[confIdx]}%, #141414 100%)`,
}}
className={cn(
"h-2.5 w-full cursor-pointer appearance-none rounded-full border border-border outline-none disabled:opacity-40",
"[&::-webkit-slider-runnable-track]:h-2 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-transparent",
"[&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-current [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:-mt-[3px]",
"[&::-moz-range-track]:h-2 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-transparent",
"[&::-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",
)}
/>
{/* Dashed stop ticks (owner, clarity): one vertical dash
per snap stop, inset by the thumb's half-width so each
tick sits under the thumb's CENTER at that stop. */}
{CONFIDENCE_STOPS.map((stop) => (
<span
key={stop}
data-testid={`confidence-tick-${stop}`}
aria-hidden
style={{ left: `calc(8px + (100% - 16px) * ${stop / 100})` }}
className={cn(
"absolute top-[14px] h-1.5 w-0 border-l border-dashed",
sel.dontKnow ? "border-muted-foreground/25" : "border-muted-foreground/45",
)}
/>
))}
</div>
<span <span
data-testid="confidence-value" data-testid="confidence-value"
className={cn( className={cn(