From 4decce9cd47c16ad70006a6f807f08b621abded8 Mon Sep 17 00:00:00 2001 From: elijah Date: Tue, 15 Sep 2026 11:47:51 +0800 Subject: [PATCH 1/7] Turn the composer effort picker into a segmented meter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Effort tiers are discrete and vary per model (3–7 steps, sometimes with non-ordinal ultracode/ultrathink modes), so a continuous slider would misrepresent them. The meter lights bars up to the selected tier, echoes the terminal live-bar and thinking-pulse motifs, and reserves the accent for the model's top tier. Harness option order differs (Grok lists xhigh first), so tiers are ranked by name before rendering. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/chrome/EffortMeter.test.ts | 150 ++++++++++++++++++ src/chrome/EffortMeter.tsx | 277 +++++++++++++++++++++++++++++++++ src/chrome/ModelPicker.test.ts | 10 +- src/chrome/ModelPicker.tsx | 98 ++++-------- src/index.css | 46 ++++++ 5 files changed, 510 insertions(+), 71 deletions(-) create mode 100644 src/chrome/EffortMeter.test.ts create mode 100644 src/chrome/EffortMeter.tsx diff --git a/src/chrome/EffortMeter.test.ts b/src/chrome/EffortMeter.test.ts new file mode 100644 index 00000000..cccbaa9c --- /dev/null +++ b/src/chrome/EffortMeter.test.ts @@ -0,0 +1,150 @@ +// @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 }); + expect( + container.querySelector('button[aria-label="Reset to default"]'), + ).toBeNull(); + + renderMeter({ value: "xhigh", onChange }); + const reset = container.querySelector( + 'button[aria-label="Reset to default"]', + )!; + expect(reset).not.toBeNull(); + act(() => reset.click()); + expect(onChange).toHaveBeenCalledWith("high"); + }); + + it("marks the peak bar as top tier only at the last tier", () => { + renderMeter({ value: "xhigh" }); + expect(container.querySelector("rect[data-top-tier]")).not.toBeNull(); + + renderMeter({ value: "medium" }); + expect(container.querySelector("rect[data-top-tier]")).toBeNull(); + }); + + 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..20ddc006 --- /dev/null +++ b/src/chrome/EffortMeter.tsx @@ -0,0 +1,277 @@ +import { useEffect, useRef, useState } 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"]); +const TOP_TIER_VALUES = new Set(["max", "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 MINI = { barWidth: 2, gap: 1.5, height: 10, radius: 0.5 }; +// preserveAspectRatio="none" stretches the full meter, so rx is mini-only — +// a corner radius there would distort with the non-uniform scale. +const FULL = { barWidth: 24, gap: 8, height: 22, radius: 1 }; + +/** The bars only: an SVG sparkline of effort tiers, bottom-aligned. */ +export function EffortMeterBars({ + tiers, + selectedIndex, + size, + className, +}: { + tiers: EffortTier[]; + selectedIndex: number; + size: "mini" | "full"; + className?: string; +}) { + const { barWidth, gap, height, radius } = size === "mini" ? MINI : FULL; + const n = tiers.length; + if (n === 0) return null; + const width = n * barWidth + (n - 1) * gap; + return ( + + ); +} + +/** + * Discrete effort control: a segmented meter that acts as a slider. Bars + * fill up to the selected tier, the peak bar breathes, and the top tier + * takes the accent color. + */ +export function EffortMeter({ + tiers, + value, + defaultValue, + modelName, + onChange, + onClose, +}: { + tiers: EffortTier[]; + value: string; + defaultValue: string; + modelName: string; + onChange: (value: string) => void; + onClose: () => void; +}) { + const track = useRef(null); + const dragging = useRef(false); + const [dragIndex, setDragIndex] = useState(null); + const selectedIndex = Math.max( + 0, + tiers.findIndex((tier) => tier.value === value), + ); + const shownIndex = dragIndex ?? selectedIndex; + const shownTier = tiers[shownIndex]; + const defaultLabel = + tiers.find((tier) => tier.value === defaultValue)?.label ?? defaultValue; + + useEffect(() => { + track.current?.focus(); + }, []); + + const indexAt = (clientX: number) => { + const el = track.current; + const rect = el?.getBoundingClientRect(); + const ratio = + el && rect && rect.width > 0 ? (clientX - rect.left) / rect.width : 0; + return Math.min( + tiers.length - 1, + Math.max(0, Math.floor(ratio * tiers.length)), + ); + }; + + const commit = (index: number) => { + const clamped = Math.min(tiers.length - 1, Math.max(0, index)); + const tier = tiers[clamped]; + if (tier && clamped !== selectedIndex) onChange(tier.value); + }; + + // React attaches wheel listeners passively, so the step-on-scroll handler + // has to be native for preventDefault to stick. + const stepRef = useRef((_delta: number) => {}); + stepRef.current = (delta) => commit(selectedIndex + delta); + useEffect(() => { + const el = track.current; + if (!el) return; + const onWheel = (event: WheelEvent) => { + event.preventDefault(); + if (event.deltaY !== 0) stepRef.current(event.deltaY < 0 ? 1 : -1); + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, []); + + if (!shownTier) return null; + + return ( +
+
+ + {shownTier.label} + + {value !== defaultValue ? ( + + ) : null} +
+
+ {modelName} +
+
{ + const last = tiers.length - 1; + let next: number | null = null; + if (event.key === "ArrowRight" || event.key === "ArrowUp") { + next = selectedIndex + 1; + } else if (event.key === "ArrowLeft" || event.key === "ArrowDown") { + next = selectedIndex - 1; + } else if (event.key === "Home") { + next = 0; + } else if (event.key === "End") { + next = last; + } else if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onClose(); + return; + } + if (next == null) return; + event.preventDefault(); + commit(next); + }} + onPointerDown={(event) => { + event.preventDefault(); + track.current?.setPointerCapture(event.pointerId); + track.current?.focus(); + dragging.current = true; + setDragIndex(indexAt(event.clientX)); + }} + onPointerMove={(event) => { + if (dragging.current) setDragIndex(indexAt(event.clientX)); + }} + onPointerUp={(event) => { + if (!dragging.current) return; + dragging.current = false; + const index = indexAt(event.clientX); + setDragIndex(null); + commit(index); + }} + onPointerCancel={() => { + dragging.current = false; + setDragIndex(null); + }} + className="mt-2 cursor-ew-resize rounded-sm outline-none focus-visible:ring-1 focus-visible:ring-accent" + > + +
+
+ {tiers[0]?.label} + {tiers[tiers.length - 1]?.label} +
+
+ ); +} diff --git a/src/chrome/ModelPicker.test.ts b/src/chrome/ModelPicker.test.ts index c330b01c..1c533ee7 100644 --- a/src/chrome/ModelPicker.test.ts +++ b/src/chrome/ModelPicker.test.ts @@ -246,12 +246,12 @@ describe("model picker", () => { expect(effortTrigger.textContent).toBe("High"); expect(effortTrigger.querySelector("svg")).not.toBeNull(); act(() => effortTrigger.click()); - const effortMenu = container.querySelector( - '[role="menu"][aria-label="Effort"]', + const effortSlider = container.querySelector( + '[role="slider"][aria-label="Effort"]', )!; - expect(effortMenu).not.toBeNull(); - keyDown(effortMenu, "ArrowUp"); - keyDown(effortMenu, "Enter"); + expect(effortSlider).not.toBeNull(); + expect(effortSlider.getAttribute("aria-valuetext")).toBe("High"); + keyDown(effortSlider, "ArrowRight"); expect(onSettingsChange).toHaveBeenCalledWith({ effort: "xhigh" }); }); diff --git a/src/chrome/ModelPicker.tsx b/src/chrome/ModelPicker.tsx index e0de3ece..6457216d 100644 --- a/src/chrome/ModelPicker.tsx +++ b/src/chrome/ModelPicker.tsx @@ -1,4 +1,4 @@ -import { Check, ChevronDown, ChevronRight, Gauge, Search, Star } from "./icons"; +import { Check, ChevronDown, ChevronRight, Search, Star } from "./icons"; import { useEffect, useId, @@ -39,6 +39,11 @@ import { HARNESSES, HARNESS_TITLE, type HarnessId } from "../lib/session"; import { useLockOverscroll } from "../hooks/useLockOverscroll"; import { LAYER } from "../lib/layers"; import { HarnessIcon } from "./HarnessIcon"; +import { + EffortMeter, + EffortMeterBars, + orderEffortOptions, +} from "./EffortMeter"; import { Popover } from "./Popover"; import { MOD } from "../lib/platform"; @@ -849,32 +854,24 @@ export function EffortPicker({ getModelSnapshot, ); const [open, setOpen] = useState(false); - const [active, setActive] = useState(0); const button = useRef(null); - const menuId = useId(); const current = resolveModel(harness, model); void catalogVersion; const setting = effortSetting(current); if (!setting) return null; + const tiers = orderEffortOptions(setting.options); const value = settingValue(setting, values); const valueLabel = settingValueLabel(setting, values); + const selectedIndex = Math.max( + 0, + tiers.findIndex((tier) => tier.value === value), + ); const dismiss = (restoreFocus: boolean) => { setOpen(false); if (restoreFocus) onClose?.(); }; - const openPicker = () => { - const selectedIndex = setting.options.findIndex( - (option) => option.value === value, - ); - setActive(selectedIndex >= 0 ? selectedIndex : 0); - setOpen(true); - }; - const pick = (optionValue: string) => { - onSettingsChange({ ...values, [setting.id]: optionValue }); - dismiss(true); - }; return ( <> @@ -884,16 +881,21 @@ export function EffortPicker({ title={`Effort: ${valueLabel}`} aria-label={`Effort: ${valueLabel}`} aria-expanded={open} - aria-haspopup="menu" + aria-haspopup="dialog" onMouseDown={(event) => event.preventDefault()} - onClick={() => (open ? dismiss(true) : openPicker())} + onClick={() => (open ? dismiss(true) : setOpen(true))} className={`flex h-6.5 max-w-28 items-center gap-1 rounded-md px-1.5 ${ open ? "bg-content/10 text-content" : "bg-content/10 text-content hover:bg-content/15" }`} > - + {valueLabel} dismiss(reason === "escape")} - role="menu" + role="dialog" aria-label="Effort" - aria-activedescendant={`${menuId}-${active}`} - tabIndex={-1} - onKeyDown={(event) => { - if (event.key === "ArrowDown" || event.key === "ArrowUp") { - event.preventDefault(); - const direction = event.key === "ArrowDown" ? 1 : -1; - setActive( - (index) => - (index + direction + setting.options.length) % - setting.options.length, - ); - return; - } - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - const option = setting.options[active]; - if (option) pick(option.value); - }} data-effort-picker - className="p-1 font-sans" + className="font-sans" > - {setting.options.map((option, index) => { - const selected = option.value === value; - const highlighted = index === active; - return ( - - ); - })} + + onSettingsChange({ ...values, [setting.id]: optionValue }) + } + onClose={() => dismiss(true)} + /> ) : null} diff --git a/src/index.css b/src/index.css index 3c06f428..cadaf307 100644 --- a/src/index.css +++ b/src/index.css @@ -1434,6 +1434,52 @@ html.theme-light .popover-backdrop { } } +/* Effort meter: bars light up to the selected tier and the peak bar + breathes like the thinking line. The top tier takes the accent — the + default, or the user's when one is configured. */ +:root { + --effort-accent: var(--color-accent); +} + +html.has-user-accent { + --effort-accent: var(--user-accent-color); +} + +.effort-meter-bar { + transition: opacity var(--motion-feedback-duration) var(--motion-ease-out); +} + +@keyframes effort-meter-breathe { + 0%, + 100% { + opacity: 0.7; + } + 50% { + opacity: 1; + } +} + +.effort-meter-peak { + animation: effort-meter-breathe 1.8s ease-in-out infinite; +} + +.effort-meter-bar[data-top-tier] { + fill: var(--effort-accent); + opacity: 1; + filter: drop-shadow(0 0 4px var(--effort-accent)); +} + +@media (prefers-reduced-motion: reduce) { + .effort-meter-peak { + animation: none; + opacity: 1; + } + + .effort-meter-bar { + transition: none; + } +} + /* Graph node cutouts so lane strokes do not show through the circles. */ .git-history-item svg { display: block; From 4b6ef2f80569f387cdaeb46d0b0426ce42da0da7 Mon Sep 17 00:00:00 2001 From: elijah Date: Tue, 15 Sep 2026 12:07:56 +0800 Subject: [PATCH 2/7] Render the effort meter as a continuous rail with quantized glow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The segmented bars read as a discrete stepper; a smooth track better matches the mental model of "turn effort up". Tiers remain discrete — the fill snaps to tick marks and only specks appear inside the filled portion, brightening toward the right. The model's top tier takes the accent with a trapped inner glow so the rail reads as luminescent. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/chrome/EffortMeter.test.ts | 18 ++++- src/chrome/EffortMeter.tsx | 114 ++++++++++++++-------------- src/chrome/ModelPicker.test.ts | 4 +- src/chrome/ModelPicker.tsx | 6 +- src/index.css | 132 +++++++++++++++++++++++++++++---- 5 files changed, 198 insertions(+), 76 deletions(-) diff --git a/src/chrome/EffortMeter.test.ts b/src/chrome/EffortMeter.test.ts index cccbaa9c..0d147db1 100644 --- a/src/chrome/EffortMeter.test.ts +++ b/src/chrome/EffortMeter.test.ts @@ -133,12 +133,24 @@ describe("EffortMeter", () => { expect(onChange).toHaveBeenCalledWith("high"); }); - it("marks the peak bar as top tier only at the last tier", () => { + it("marks the fill as top tier only at the last tier", () => { renderMeter({ value: "xhigh" }); - expect(container.querySelector("rect[data-top-tier]")).not.toBeNull(); + expect( + container.querySelector(".effort-rail-fill[data-top-tier]"), + ).not.toBeNull(); + + renderMeter({ value: "medium" }); + expect( + container.querySelector(".effort-rail-fill[data-top-tier]"), + ).toBeNull(); + }); + it("sizes the fill to the selected tier's fraction of the rail", () => { renderMeter({ value: "medium" }); - expect(container.querySelector("rect[data-top-tier]")).toBeNull(); + const fill = container.querySelector(".effort-rail-fill")!; + // "medium" is index 1 of 4 tiers → one third of the rail. + expect(fill.style.width).toContain("%"); + expect(Number.parseFloat(fill.style.width)).toBeCloseTo(33.3, 1); }); it("closes on Enter", () => { diff --git a/src/chrome/EffortMeter.tsx b/src/chrome/EffortMeter.tsx index 20ddc006..543dc08d 100644 --- a/src/chrome/EffortMeter.tsx +++ b/src/chrome/EffortMeter.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type CSSProperties } from "react"; import { RotateCcw } from "./icons"; export type EffortTier = { @@ -50,13 +50,24 @@ export function orderEffortOptions( })); } -const MINI = { barWidth: 2, gap: 1.5, height: 10, radius: 0.5 }; -// preserveAspectRatio="none" stretches the full meter, so rx is mini-only — -// a corner radius there would distort with the non-uniform scale. -const FULL = { barWidth: 24, gap: 8, height: 22, radius: 1 }; +const SPECK_COUNT: Record<"mini" | "full", number> = { mini: 6, full: 18 }; -/** The bars only: an SVG sparkline of effort tiers, bottom-aligned. */ -export function EffortMeterBars({ +// Deterministic speck layout — stable across renders so the twinkle field +// does not jump when the selection moves. +function speckStyle(index: number): CSSProperties { + return { + left: `${(index * 37 + 11) % 90}%`, + top: `${((index * 53 + 7) % 60) + 20}%`, + animationDuration: `${1.2 + (index % 5) * 0.3}s`, + animationDelay: `${-index * 0.17}s`, + }; +} + +/** + * The rail only: a continuous track that fills to the selected tier, with a + * tick mark per tier and twinkling specks inside the fill. + */ +export function EffortMeterSpark({ tiers, selectedIndex, size, @@ -67,61 +78,55 @@ export function EffortMeterBars({ size: "mini" | "full"; className?: string; }) { - const { barWidth, gap, height, radius } = size === "mini" ? MINI : FULL; const n = tiers.length; if (n === 0) return null; - const width = n * barWidth + (n - 1) * gap; + const frac = n > 1 ? selectedIndex / (n - 1) : 1; + // A floor keeps a sliver visible at the lowest tier. + const widthPct = Math.max(frac * 100, 10); + const topTier = + selectedIndex === n - 1 || + TOP_TIER_VALUES.has(tiers[selectedIndex]?.value ?? ""); return ( - +
+ {/* Specks sit on a layer stretched to track width inside the clipped + fill, so the fill edge reveals more of them as it moves right. */} +
+ {Array.from({ length: SPECK_COUNT[size] }, (_, index) => ( + + ))} +
+
+ {tiers.map((tier, index) => ( + 1 ? (index / (n - 1)) * 100 : 50}%` }} + /> + ))} + ); } /** - * Discrete effort control: a segmented meter that acts as a slider. Bars - * fill up to the selected tier, the peak bar breathes, and the top tier - * takes the accent color. + * Discrete effort control: a glowing rail that acts as a slider. The fill + * edge snaps between tier ticks, specks twinkle harder toward the top, and + * the top tier takes the accent color. */ export function EffortMeter({ tiers, @@ -260,9 +265,10 @@ export function EffortMeter({ dragging.current = false; setDragIndex(null); }} + data-dragging={dragIndex != null ? "" : undefined} className="mt-2 cursor-ew-resize rounded-sm outline-none focus-visible:ring-1 focus-visible:ring-accent" > - { 'button[aria-label="Effort: High"]', )!; expect(effortTrigger.textContent).toBe("High"); - expect(effortTrigger.querySelector("svg")).not.toBeNull(); + expect( + effortTrigger.querySelector('[data-effort-rail="mini"]'), + ).not.toBeNull(); act(() => effortTrigger.click()); const effortSlider = container.querySelector( '[role="slider"][aria-label="Effort"]', diff --git a/src/chrome/ModelPicker.tsx b/src/chrome/ModelPicker.tsx index 6457216d..638110e6 100644 --- a/src/chrome/ModelPicker.tsx +++ b/src/chrome/ModelPicker.tsx @@ -41,7 +41,7 @@ import { LAYER } from "../lib/layers"; import { HarnessIcon } from "./HarnessIcon"; import { EffortMeter, - EffortMeterBars, + EffortMeterSpark, orderEffortOptions, } from "./EffortMeter"; import { Popover } from "./Popover"; @@ -890,11 +890,11 @@ export function EffortPicker({ : "bg-content/10 text-content hover:bg-content/15" }`} > - {valueLabel} Date: Tue, 15 Sep 2026 12:32:46 +0800 Subject: [PATCH 3/7] Rebuild the effort slider as a carved rail with a separate thumb Combines two design reviews into a physical model: a recessed track with inner shadow, a fill that travels to a detached white thumb center, a fixed speck field revealed through a clip-path so particles never stretch mid-transition, and an accent confined to a tint overlay plus an unclipped halo at the final tier only. Dragging tracks the pointer continuously while the value snaps to the nearest tier on release; keyboard and wheel steps settle with the shared popover curve. The chip keeps a static 3-speck mini rail and no longer clips long tier names. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/chrome/EffortMeter.test.ts | 22 +- src/chrome/EffortMeter.tsx | 449 ++++++++++++++++++++++++++------- src/chrome/ModelPicker.tsx | 4 +- src/index.css | 416 +++++++++++++++++++++++++----- 4 files changed, 727 insertions(+), 164 deletions(-) diff --git a/src/chrome/EffortMeter.test.ts b/src/chrome/EffortMeter.test.ts index 0d147db1..f9771835 100644 --- a/src/chrome/EffortMeter.test.ts +++ b/src/chrome/EffortMeter.test.ts @@ -120,37 +120,35 @@ describe("EffortMeter", () => { it("only offers reset away from the default", () => { const onChange = vi.fn(); renderMeter({ value: "high", onChange }); - expect( - container.querySelector('button[aria-label="Reset to default"]'), - ).toBeNull(); + 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).not.toBeNull(); + 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-rail-fill[data-top-tier]"), - ).not.toBeNull(); + expect(container.querySelector("[data-top-tier]")).not.toBeNull(); renderMeter({ value: "medium" }); - expect( - container.querySelector(".effort-rail-fill[data-top-tier]"), - ).toBeNull(); + expect(container.querySelector("[data-top-tier]")).toBeNull(); }); it("sizes the fill to the selected tier's fraction of the rail", () => { 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(fill.style.width).toContain("%"); - expect(Number.parseFloat(fill.style.width)).toBeCloseTo(33.3, 1); + expect(rail.style.getPropertyValue("--effort-frac")).toContain("0.333"); + expect(fill.style.width).toContain("--effort-frac"); }); it("closes on Enter", () => { diff --git a/src/chrome/EffortMeter.tsx b/src/chrome/EffortMeter.tsx index 543dc08d..a7f1101a 100644 --- a/src/chrome/EffortMeter.tsx +++ b/src/chrome/EffortMeter.tsx @@ -25,7 +25,6 @@ const EFFORT_RANK: Record = { }; const BEYOND_VALUES = new Set(["ultracode", "ultrathink"]); -const TOP_TIER_VALUES = new Set(["max", "ultracode", "ultrathink"]); export function orderEffortOptions( options: { value: string; label: string }[], @@ -50,83 +49,330 @@ export function orderEffortOptions( })); } -const SPECK_COUNT: Record<"mini" | "full", number> = { mini: 6, full: 18 }; +type SpeckSite = { + left: string; + top: string; + size: number; + baseOpacity: number; + peakOpacity?: number; + echoOpacity?: number; + duration?: number; + delay?: number; + twinkle?: "echo" | "no-echo"; + drift?: 1 | 2; + driftDuration?: number; + glow?: boolean; +}; + +// Deterministic 26-site particle layout: +// 5 in first third, 8 in middle third, 13 in final third. +// 15 × 1px, 8 × 1.5px, 3 × 2px. +// 16 steady sites (opacity 0.16 → 0.34), 10 twinkling (peak 0.42 → 0.78, echo 45%). +// 4 small particles drift alternating over 4300, 5100, 5900, 6700ms. +const FULL_SPECKS: SpeckSite[] = [ + // First third (5 sites) + { left: "7%", top: "35%", size: 1, baseOpacity: 0.17 }, + { + left: "14%", + top: "68%", + size: 1, + baseOpacity: 0.18, + peakOpacity: 0.47, + echoOpacity: 0.21, + twinkle: "echo", + duration: 2710, + delay: -950, + }, + { + left: "19%", + top: "28%", + size: 1.5, + baseOpacity: 0.19, + drift: 1, + driftDuration: 4300, + }, + { left: "23%", top: "72%", size: 1, baseOpacity: 0.2 }, + { + left: "29%", + top: "42%", + size: 1, + baseOpacity: 0.21, + peakOpacity: 0.52, + echoOpacity: 0.23, + twinkle: "no-echo", + duration: 3670, + delay: -1420, + }, + + // Middle third (8 sites) + { left: "37%", top: "24%", size: 1.5, baseOpacity: 0.23 }, + { + left: "41%", + top: "76%", + size: 1, + baseOpacity: 0.23, + peakOpacity: 0.57, + echoOpacity: 0.26, + twinkle: "echo", + duration: 2300, + delay: -820, + }, + { + left: "45%", + top: "38%", + size: 1, + baseOpacity: 0.24, + drift: 2, + driftDuration: 5100, + }, + { left: "48%", top: "65%", size: 1.5, baseOpacity: 0.25 }, + { + left: "53%", + top: "26%", + size: 1, + baseOpacity: 0.26, + peakOpacity: 0.61, + echoOpacity: 0.27, + twinkle: "no-echo", + duration: 4190, + delay: -1980, + }, + { left: "57%", top: "78%", size: 1.5, baseOpacity: 0.26 }, + { + left: "61%", + top: "34%", + size: 1, + baseOpacity: 0.27, + peakOpacity: 0.64, + echoOpacity: 0.29, + twinkle: "echo", + duration: 3130, + delay: -1120, + }, + { left: "64%", top: "62%", size: 1, baseOpacity: 0.28 }, + + // Final third (13 sites) + { + left: "69%", + top: "22%", + size: 1.5, + baseOpacity: 0.28, + drift: 1, + driftDuration: 5900, + }, + { + left: "71%", + top: "74%", + size: 1, + baseOpacity: 0.29, + peakOpacity: 0.68, + echoOpacity: 0.31, + twinkle: "no-echo", + duration: 2710, + delay: -1640, + }, + { left: "74%", top: "44%", size: 2, baseOpacity: 0.29, glow: true }, + { left: "77%", top: "26%", size: 1, baseOpacity: 0.3 }, + { + left: "80%", + top: "68%", + size: 1.5, + baseOpacity: 0.3, + peakOpacity: 0.71, + echoOpacity: 0.32, + twinkle: "echo", + duration: 3670, + delay: -890, + }, + { + left: "82%", + top: "32%", + size: 1, + baseOpacity: 0.31, + drift: 2, + driftDuration: 6700, + }, + { + left: "85%", + top: "76%", + size: 2, + baseOpacity: 0.31, + peakOpacity: 0.73, + echoOpacity: 0.33, + twinkle: "no-echo", + duration: 2300, + delay: -1350, + glow: true, + }, + { left: "87%", top: "24%", size: 1, baseOpacity: 0.32 }, + { left: "89%", top: "56%", size: 1.5, baseOpacity: 0.32 }, + { + left: "91%", + top: "36%", + size: 1, + baseOpacity: 0.32, + peakOpacity: 0.75, + echoOpacity: 0.34, + twinkle: "echo", + duration: 4190, + delay: -2410, + }, + { left: "93%", top: "72%", size: 2, baseOpacity: 0.33, glow: true }, + { + left: "94%", + top: "28%", + size: 1.5, + baseOpacity: 0.33, + peakOpacity: 0.76, + echoOpacity: 0.34, + twinkle: "no-echo", + duration: 3130, + delay: -1790, + }, + { left: "95%", top: "52%", size: 1, baseOpacity: 0.33 }, +]; -// Deterministic speck layout — stable across renders so the twinkle field -// does not jump when the selection moves. -function speckStyle(index: number): CSSProperties { - return { - left: `${(index * 37 + 11) % 90}%`, - top: `${((index * 53 + 7) % 60) + 20}%`, - animationDuration: `${1.2 + (index % 5) * 0.3}s`, - animationDelay: `${-index * 0.17}s`, +function renderSpeck(site: SpeckSite, key: number) { + const outerStyle: CSSProperties = { + left: site.left, + top: site.top, + width: `${site.size}px`, + height: `${site.size}px`, + ...(site.driftDuration + ? ({ "--drift-duration": `${site.driftDuration}ms` } as CSSProperties) + : {}), }; + + const innerStyle = { + "--speck-base": site.baseOpacity, + ...(site.peakOpacity != null ? { "--speck-peak": site.peakOpacity } : {}), + ...(site.echoOpacity != null ? { "--speck-echo": site.echoOpacity } : {}), + ...(site.duration != null + ? { "--speck-duration": `${site.duration}ms` } + : {}), + ...(site.delay != null ? { "--speck-delay": `${site.delay}ms` } : {}), + } as CSSProperties; + + return ( + + + + ); } /** * The rail only: a continuous track that fills to the selected tier, with a - * tick mark per tier and twinkling specks inside the fill. + * tick mark per tier, stationary speck field, separate thumb, and top-tier accent halo. */ 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 frac = n > 1 ? selectedIndex / (n - 1) : 1; - // A floor keeps a sliver visible at the lowest tier. - const widthPct = Math.max(frac * 100, 10); - const topTier = - selectedIndex === n - 1 || - TOP_TIER_VALUES.has(tiers[selectedIndex]?.value ?? ""); + const tierFrac = n > 1 ? selectedIndex / (n - 1) : 0; + const frac = dragFrac != null ? dragFrac : tierFrac; + const topTier = selectedIndex === n - 1; + + if (size === "mini") { + return ( +