diff --git a/src/chrome/EffortMeter.test.ts b/src/chrome/EffortMeter.test.ts new file mode 100644 index 00000000..038791b5 --- /dev/null +++ b/src/chrome/EffortMeter.test.ts @@ -0,0 +1,322 @@ +// @vitest-environment happy-dom +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + EffortMeter, + orderEffortOptions, + type EffortTier, +} from "./EffortMeter"; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +const GROK_OPTIONS = [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High" }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, +]; + +const TIERS = orderEffortOptions(GROK_OPTIONS); + +function renderMeter(props: { + value: string; + onChange?: (value: string) => void; + onClose?: () => void; + tiers?: EffortTier[]; + defaultValue?: string; +}) { + act(() => + root.render( + createElement(EffortMeter, { + tiers: props.tiers ?? TIERS, + value: props.value, + defaultValue: props.defaultValue ?? "high", + modelName: "Grok 4.6", + onChange: props.onChange ?? vi.fn(), + onClose: props.onClose ?? vi.fn(), + }), + ), + ); + return container.querySelector('[role="slider"]')!; +} + +function keyDown(target: EventTarget, key: string) { + act(() => { + target.dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), + ); + }); +} + +describe("orderEffortOptions", () => { + it("sorts catalog order into ascending effort", () => { + expect(TIERS.map((tier) => tier.value)).toEqual([ + "low", + "medium", + "high", + "xhigh", + ]); + }); + + it("puts auto first and unknown values last", () => { + const tiers = orderEffortOptions([ + { value: "high", label: "High" }, + { value: "turbo", label: "Turbo" }, + { value: "auto", label: "Auto" }, + { value: "low", label: "Low" }, + ]); + expect(tiers.map((tier) => tier.value)).toEqual([ + "auto", + "low", + "high", + "turbo", + ]); + expect(tiers[0].kind).toBe("auto"); + }); +}); + +describe("EffortMeter", () => { + it("commits on End and steps with the wheel", () => { + const onChange = vi.fn(); + const slider = renderMeter({ value: "low", onChange }); + expect(slider.getAttribute("aria-valuemin")).toBe("0"); + expect(slider.getAttribute("aria-valuemax")).toBe("3"); + expect(slider.getAttribute("aria-valuenow")).toBe("0"); + expect(slider.getAttribute("aria-valuetext")).toBe("Low"); + + keyDown(slider, "End"); + expect(onChange).toHaveBeenLastCalledWith("xhigh"); + + // The committed value comes back through props; without a re-render the + // slider still sits on "low", so a wheel-up steps to "medium". + act(() => { + slider.dispatchEvent( + new WheelEvent("wheel", { + deltaY: -40, + bubbles: true, + cancelable: true, + }), + ); + }); + expect(onChange).toHaveBeenLastCalledWith("medium"); + }); + + it("only offers reset away from the default", () => { + const onChange = vi.fn(); + renderMeter({ value: "high", onChange }); + const resetDefault = container.querySelector( + 'button[aria-label="Reset to default"]', + )!; + expect(resetDefault.style.visibility).toBe("hidden"); + + renderMeter({ value: "xhigh", onChange }); + const reset = container.querySelector( + 'button[aria-label="Reset to default"]', + )!; + expect(reset.style.visibility).toBe("visible"); + act(() => reset.click()); + expect(onChange).toHaveBeenCalledWith("high"); + }); + + it("marks the fill as top tier only at the last tier", () => { + renderMeter({ value: "xhigh" }); + expect(container.querySelector(".effort-top-halo")).toBeNull(); + expect( + container.querySelector(".effort-top-tint[data-top-tier]"), + ).not.toBeNull(); + + renderMeter({ value: "medium" }); + expect(container.querySelector("[data-top-tier]")).toBeNull(); + }); + + it("reveals a full-width fill using the shared rail fraction", () => { + const slider = renderMeter({ value: "medium" }); + const rail = container.querySelector(".effort-rail")!; + const fill = container.querySelector(".effort-rail-fill")!; + // "medium" is index 1 of 4 tiers → one third of the rail. + expect(rail.style.getPropertyValue("--effort-frac")).toContain("0.333"); + expect(fill.style.width).toBe(""); + expect(rail.style.getPropertyValue("--effort-frac")).toContain("13.5px"); + expect( + container.querySelectorAll(".effort-ticks .effort-rail-tick"), + ).toHaveLength(4); + + expect(container.querySelector(".effort-specks-highlight")).not.toBeNull(); + expect(slider.hasAttribute("data-pressed")).toBe(false); + act(() => { + slider.dispatchEvent( + new PointerEvent("pointerdown", { + clientX: 50, + bubbles: true, + cancelable: true, + }), + ); + }); + expect(slider.hasAttribute("data-pressed")).toBe(true); + act(() => { + slider.dispatchEvent( + new PointerEvent("pointerup", { + clientX: 50, + bubbles: true, + cancelable: true, + }), + ); + }); + expect(slider.hasAttribute("data-pressed")).toBe(false); + }); + + it.each(["pointercancel", "lostpointercapture"])( + "preserves pickup, interrupts settle, and restores the commit on %s", + (cancelEvent) => { + let now = 0; + let id = 0; + const frames = new Map(); + vi.stubGlobal( + "requestAnimationFrame", + (callback: FrameRequestCallback) => { + frames.set(++id, callback); + return id; + }, + ); + vi.stubGlobal("cancelAnimationFrame", (key: number) => + frames.delete(key), + ); + const clock = vi.spyOn(performance, "now").mockImplementation(() => now); + const advance = (ms: number) => { + now += ms; + const callbacks = [...frames.values()]; + frames.clear(); + act(() => callbacks.forEach((callback) => callback(now))); + }; + const onChange = vi.fn(); + const slider = renderMeter({ value: "medium", onChange }); + const rail = container.querySelector(".effort-rail")!; + vi.spyOn(rail, "getBoundingClientRect").mockReturnValue({ + left: 0, + top: 0, + width: 227, + height: 22, + } as DOMRect); + const frac = () => + Number( + rail.style + .getPropertyValue("--effort-frac") + .match(/\+ ([\d.]+) \*/)?.[1], + ); + const pointer = (type: string, x: number) => + act(() => { + slider.dispatchEvent( + new PointerEvent(type, { + pointerId: 1, + clientX: x, + clientY: 11, + button: 0, + bubbles: true, + cancelable: true, + }), + ); + }); + const center = 13.5 + 200 / 3; + pointer("pointerdown", center + 10); + advance(16); + expect(frac()).toBeCloseTo(1 / 3); + pointer("pointermove", center + 30); + // No event-rate rendering: the next frame drives all layers. + expect(frac()).toBeCloseTo(1 / 3); + advance(16); + expect(frac()).toBeCloseTo(1 / 3 + 0.1); + expect( + Number(slider.style.getPropertyValue("--effort-light")), + ).toBeGreaterThan(0.55); + pointer(cancelEvent, center + 30); + advance(200); + expect(frac()).toBeCloseTo(1 / 3); + expect(onChange).not.toHaveBeenCalled(); + expect(slider.hasAttribute("data-pressed")).toBe(false); + expect(slider.style.getPropertyValue("--effort-sx")).toBe("1"); + + // Bare rail presses use zero offset; release snaps without overshoot. + pointer("pointerdown", 113.5); + advance(16); + expect(frac()).toBeCloseTo(0.5); + pointer("pointerup", 113.5); + expect(onChange).toHaveBeenLastCalledWith("high"); + expect( + parseFloat(slider.style.getPropertyValue("--effort-settle-ms")), + ).toBeCloseTo(110 + 2 * (200 / 6)); + renderMeter({ value: "high", onChange }); + advance(40); + const interrupted = frac(); + expect(interrupted).toBeGreaterThan(0.5); + expect(interrupted).toBeLessThan(2 / 3); + const pickup = 13.5 + 200 * interrupted + 8; + pointer("pointerdown", pickup); + advance(16); + expect(frac()).toBeCloseTo(interrupted); + pointer("pointermove", 1000); + advance(16); + expect(frac()).toBe(1); + pointer("pointermove", -1000); + advance(16); + expect(frac()).toBe(0); + pointer(cancelEvent, -1000); + advance(200); + expect(frac()).toBeCloseTo(2 / 3); + expect(onChange).toHaveBeenCalledTimes(1); + + keyDown(slider, "Home"); + renderMeter({ value: "low", onChange }); + expect(slider.style.getPropertyValue("--effort-settle-ms")).toBe("140ms"); + let previous = frac(); + for (let i = 0; i < 9; i++) { + advance(16); + expect(frac()).toBeLessThanOrEqual(previous); + expect(frac()).toBeGreaterThanOrEqual(0); + previous = frac(); + } + expect(frac()).toBe(0); + pointer("pointerdown", 13.5); + advance(16); + pointer("pointerup", 13.5); + expect(slider.style.getPropertyValue("--effort-settle-ms")).toBe("0ms"); + clock.mockRestore(); + }, + ); + + it("uses a balanced static field with only four twinkling sites", () => { + renderMeter({ value: "high" }); + const field = container.querySelector(".effort-specks")!; + const sites = [...field.querySelectorAll(".effort-speck")]; + expect(sites).toHaveLength(30); + expect(sites.filter((site) => site.style.width === "1px")).toHaveLength(20); + expect(sites.filter((site) => site.style.width === "1.5px")).toHaveLength( + 8, + ); + expect(sites.filter((site) => site.style.width === "2px")).toHaveLength(2); + expect(field.querySelectorAll("[data-twinkle]")).toHaveLength(4); + expect(field.querySelectorAll("[data-drift]")).toHaveLength(0); + }); + + it("closes on Enter", () => { + const onClose = vi.fn(); + const slider = renderMeter({ value: "high", onClose }); + keyDown(slider, "Enter"); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/src/chrome/EffortMeter.tsx b/src/chrome/EffortMeter.tsx new file mode 100644 index 00000000..a5dd8169 --- /dev/null +++ b/src/chrome/EffortMeter.tsx @@ -0,0 +1,557 @@ +import { + useEffect, + useLayoutEffect, + useRef, + useState, + type CSSProperties, +} from "react"; +import { RotateCcw } from "./icons"; + +export type EffortTier = { + value: string; + label: string; + kind: "auto" | "level" | "beyond"; +}; + +// Option order varies by harness (Grok lists xhigh first), so the meter +// ranks by name rather than trusting catalog order. Unknown values keep +// their catalog order after the named ones. +const EFFORT_RANK: Record = { + auto: 0, + off: 1, + none: 1, + minimal: 2, + low: 3, + medium: 4, + high: 5, + xhigh: 6, + max: 7, + ultracode: 8, + ultrathink: 9, +}; + +const BEYOND_VALUES = new Set(["ultracode", "ultrathink"]); + +export function orderEffortOptions( + options: { value: string; label: string }[], +): EffortTier[] { + return options + .map((option, index) => ({ option, index })) + .sort( + (a, b) => + (EFFORT_RANK[a.option.value] ?? Number.MAX_SAFE_INTEGER) - + (EFFORT_RANK[b.option.value] ?? Number.MAX_SAFE_INTEGER) || + a.index - b.index, + ) + .map(({ option }) => ({ + value: option.value, + label: option.label, + kind: + option.value === "auto" + ? "auto" + : BEYOND_VALUES.has(option.value) + ? "beyond" + : "level", + })); +} + +const THUMB = 27; +const INSET = THUMB / 2; +const position = (frac: number) => + `calc(${INSET}px + ${frac} * (100% - ${THUMB}px))`; +const clamp = (value: number) => Math.min(1, Math.max(0, value)); + +// Invert the x coordinate of cubic-bezier(.22, .75, .18, 1). +function settleEase(progress: number) { + const bezier = (t: number, a: number, b: number) => + 3 * (1 - t) ** 2 * t * a + 3 * (1 - t) * t * t * b + t ** 3; + let low = 0; + let high = 1; + for (let i = 0; i < 16; i++) { + const mid = (low + high) / 2; + if (bezier(mid, 0.22, 0.18) < progress) low = mid; + else high = mid; + } + return bezier((low + high) / 2, 0.75, 1); +} + +// Ten irregular sites per third; spacing is at least 4% in the 2D field. +// Twenty 1px, eight 1.5px, two 2px. Larger particles stay dimmer. +const FULL_SPECKS = [ + [2, 57], + [5, 26], + [9, 73], + [14, 41], + [17, 64], + [21, 23], + [24, 49], + [27, 79], + [30, 34], + [32, 61], + [35, 21], + [38, 68], + [42, 43], + [45, 77], + [48, 29], + [51, 56], + [55, 22], + [58, 71], + [61, 38], + [65, 59], + [68, 79], + [71, 31], + [74, 54], + [78, 23], + [81, 67], + [84, 42], + [88, 76], + [91, 27], + [95, 53], + [98, 72], +].map(([left, top], index) => { + const size = + index === 9 || index === 24 + ? 2 + : [1, 5, 11, 14, 18, 21, 26, 28].includes(index) + ? 1.5 + : 1; + const base = + size === 2 ? 0.22 : size === 1.5 ? 0.27 : 0.32 + (index % 5) * 0.02; + const duration = ( + { 3: 6400, 12: 7300, 20: 8100, 27: 9200 } as Record + )[index]; + return { left, top, size, base, duration }; +}); + +function renderSpeck( + site: (typeof FULL_SPECKS)[number], + key: number, + isHighlight = false, +) { + return ( + + + + ); +} + +/** + * The rail only: a continuous track that fills to the selected tier, with a + * tick mark per tier, stationary speck field, separate thumb, and top-tier accent aura. + */ +export function EffortMeterSpark({ + tiers, + selectedIndex, + size, + className, + dragFrac, +}: { + tiers: EffortTier[]; + selectedIndex: number; + size: "mini" | "full"; + className?: string; + dragFrac?: number | null; +}) { + const n = tiers.length; + if (n === 0) return null; + const tierFrac = n > 1 ? selectedIndex / (n - 1) : 0; + const frac = dragFrac != null ? dragFrac : tierFrac; + const topTier = selectedIndex === n - 1; + + if (size === "mini") { + return ( +