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:
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user