1
0
Fork 0

feat(qualification): add crosshair preferences module and settings popover

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-11 03:25:21 -04:00
parent c204581366
commit 51f9d57aaa
2 changed files with 294 additions and 0 deletions

View file

@ -0,0 +1,201 @@
"use client";
import { useId, type ChangeEvent } from "react";
import { Crosshair } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
AIM_TRAINER_CROSSHAIR_SCALE_MAX,
AIM_TRAINER_CROSSHAIR_SCALE_MIN,
AIM_TRAINER_CROSSHAIR_SCALE_STEP,
AIM_TRAINER_CROSSHAIR_STYLES,
normalizeAimTrainerCrosshairScale,
type AimTrainerCrosshairPrefs,
type AimTrainerCrosshairStyle,
} from "./crosshair";
const STYLE_LABELS: Record<AimTrainerCrosshairStyle, string> = {
classic: "Classic",
dot: "Dot",
cross: "Cross",
circle: "Circle",
chevron: "Chevron",
};
const COLOR_PRESETS = [
"#d8d3b6",
"#4ade80",
"#f87171",
"#22d3ee",
"#e879f9",
"#ffffff",
] as const;
function CrosshairPreview({ style }: { style: AimTrainerCrosshairStyle }) {
const lineProps = {
stroke: "currentColor",
strokeWidth: 1.75,
strokeLinecap: "butt" as const,
strokeLinejoin: "miter" as const,
};
switch (style) {
case "classic":
return (
<svg viewBox="-12 -12 24 24" className="size-5" aria-hidden="true">
<path d="M0 -11V-3 M0 3V11 M-11 0H-3 M3 0H11" fill="none" {...lineProps} />
<circle cx="0" cy="0" r="0.75" fill="currentColor" />
</svg>
);
case "dot":
return (
<svg viewBox="-12 -12 24 24" className="size-5" aria-hidden="true">
<circle cx="0" cy="0" r="1.5" fill="currentColor" />
</svg>
);
case "cross":
return (
<svg viewBox="-12 -12 24 24" className="size-5" aria-hidden="true">
<path d="M0 -11V11 M-11 0H11" fill="none" {...lineProps} />
</svg>
);
case "circle":
return (
<svg viewBox="-12 -12 24 24" className="size-5" aria-hidden="true">
<circle cx="0" cy="0" r="9" fill="none" {...lineProps} />
<circle cx="0" cy="0" r="0.75" fill="currentColor" />
</svg>
);
case "chevron":
return (
<svg viewBox="-12 -12 24 24" className="size-5" aria-hidden="true">
<path d="M-5 0L0 -6L5 0" fill="none" {...lineProps} />
<circle cx="0" cy="0" r="0.75" fill="currentColor" />
</svg>
);
}
}
export function CrosshairSettings({
value,
onPrefsChangeAction,
}: {
readonly value: AimTrainerCrosshairPrefs;
readonly onPrefsChangeAction: (prefs: AimTrainerCrosshairPrefs) => void;
}) {
const colorInputId = useId();
const handleCustomColorChange = (event: ChangeEvent<HTMLInputElement>) => {
onPrefsChangeAction({ ...value, color: event.target.value });
};
const handleScaleChange = (event: ChangeEvent<HTMLInputElement>) => {
onPrefsChangeAction({
...value,
scale: normalizeAimTrainerCrosshairScale(Number(event.target.value)),
});
};
return (
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="ghost" size="icon" aria-label="Crosshair settings">
<Crosshair aria-hidden="true" className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" collisionPadding={8} className="w-[300px] p-3">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Crosshair style
</span>
<div className="grid grid-cols-5 gap-1">
{AIM_TRAINER_CROSSHAIR_STYLES.map((style) => (
<Button
key={style}
type="button"
variant={value.style === style ? "default" : "outline"}
className="h-auto min-w-0 flex-col gap-1 whitespace-normal px-1 py-1.5 text-center text-xs"
aria-label={`${STYLE_LABELS[style]} crosshair`}
aria-pressed={value.style === style}
onClick={() => onPrefsChangeAction({ ...value, style })}
>
<CrosshairPreview style={style} />
<span>{STYLE_LABELS[style]}</span>
</Button>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Crosshair color
</span>
<div className="flex flex-wrap items-center gap-1.5">
{COLOR_PRESETS.map((color) => {
const selected = value.color.toLowerCase() === color.toLowerCase();
return (
<Button
key={color}
type="button"
variant="ghost"
size="icon"
className={
selected ? "size-7 p-1 ring-2 ring-ring ring-offset-2" : "size-7 p-1"
}
aria-label={`Use ${color} crosshair color`}
aria-pressed={selected}
onClick={() => onPrefsChangeAction({ ...value, color })}
>
<span
aria-hidden="true"
className="size-4 rounded-sm border border-black/30"
style={{ backgroundColor: color }}
/>
</Button>
);
})}
</div>
<label
htmlFor={colorInputId}
className="flex w-full items-center justify-between gap-3 text-xs text-muted-foreground"
>
<span>Custom</span>
<Input
id={colorInputId}
type="color"
value={value.color}
aria-label="Custom crosshair color"
className="h-8 w-10 cursor-pointer p-1"
onChange={handleCustomColorChange}
/>
</label>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Crosshair scale
</span>
<span className="font-mono text-xs tabular-nums text-muted-foreground">
{value.scale.toFixed(1)}x
</span>
</div>
<input
type="range"
min={AIM_TRAINER_CROSSHAIR_SCALE_MIN}
max={AIM_TRAINER_CROSSHAIR_SCALE_MAX}
step={AIM_TRAINER_CROSSHAIR_SCALE_STEP}
value={value.scale}
aria-label="Crosshair scale"
className="h-4 w-full cursor-pointer accent-primary"
onChange={handleScaleChange}
/>
</div>
</div>
</PopoverContent>
</Popover>
);
}

View file

@ -0,0 +1,93 @@
export type AimTrainerCrosshairStyle = "classic" | "dot" | "cross" | "circle" | "chevron";
export type AimTrainerCrosshairPrefs = {
style: AimTrainerCrosshairStyle;
color: string;
scale: number;
};
export const AIM_TRAINER_CROSSHAIR_STORAGE_KEY = "ptf.aimTrainer.crosshair";
export const AIM_TRAINER_CROSSHAIR_SCALE_MIN = 1;
export const AIM_TRAINER_CROSSHAIR_SCALE_MAX = 2.5;
export const AIM_TRAINER_CROSSHAIR_SCALE_STEP = 0.1;
export const AIM_TRAINER_CROSSHAIR_STYLES = [
"classic",
"dot",
"cross",
"circle",
"chevron",
] as const satisfies readonly AimTrainerCrosshairStyle[];
export const DEFAULT_AIM_TRAINER_CROSSHAIR_PREFS: AimTrainerCrosshairPrefs = {
style: "classic",
color: "#d8d3b6",
scale: AIM_TRAINER_CROSSHAIR_SCALE_MIN,
};
function createDefaultAimTrainerCrosshairPrefs(): AimTrainerCrosshairPrefs {
return { ...DEFAULT_AIM_TRAINER_CROSSHAIR_PREFS };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isAimTrainerCrosshairStyle(value: unknown): value is AimTrainerCrosshairStyle {
return (
typeof value === "string" &&
AIM_TRAINER_CROSSHAIR_STYLES.some((style) => style === value)
);
}
function isHexColor(value: unknown): value is string {
return typeof value === "string" && /^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i.test(value);
}
export function normalizeAimTrainerCrosshairScale(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return AIM_TRAINER_CROSSHAIR_SCALE_MIN;
}
const clamped = Math.min(
AIM_TRAINER_CROSSHAIR_SCALE_MAX,
Math.max(AIM_TRAINER_CROSSHAIR_SCALE_MIN, value),
);
return Number(
(Math.round(clamped / AIM_TRAINER_CROSSHAIR_SCALE_STEP) * AIM_TRAINER_CROSSHAIR_SCALE_STEP).toFixed(
1,
),
);
}
export function loadAimTrainerCrosshairPrefs(): AimTrainerCrosshairPrefs {
const defaults = createDefaultAimTrainerCrosshairPrefs();
if (typeof window === "undefined") return defaults;
try {
const stored = window.localStorage.getItem(AIM_TRAINER_CROSSHAIR_STORAGE_KEY);
if (!stored) return defaults;
const parsed: unknown = JSON.parse(stored);
if (!isRecord(parsed) || !isAimTrainerCrosshairStyle(parsed.style)) return defaults;
if (!isHexColor(parsed.color)) return defaults;
return {
style: parsed.style,
color: parsed.color,
scale: normalizeAimTrainerCrosshairScale(parsed.scale),
};
} catch {
return defaults;
}
}
export function saveAimTrainerCrosshairPrefs(prefs: AimTrainerCrosshairPrefs): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(AIM_TRAINER_CROSSHAIR_STORAGE_KEY, JSON.stringify(prefs));
} catch {
return;
}
}