Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions app/src/color.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/* The hue slider of the bot color picker (spec 15.3): the rainbow is the
track itself, with a ring for a thumb so the hue under it shows. */

.color-hue {
appearance: none;
display: block;
margin: 0;
}

.color-hue::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
border: 3px solid #fff;
border-radius: 999px;
background: transparent;
box-shadow:
0 0 0 1px rgb(0 0 0 / 0.5),
inset 0 0 0 1px rgb(0 0 0 / 0.5);
}
27 changes: 25 additions & 2 deletions app/src/features/bots/BotDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Check } from "lucide-react";
import { Check, Pipette } from "lucide-react";
import { type FormEvent, useState } from "react";
import { useT } from "../../i18n";
import { errorText } from "../../lib/api";
Expand All @@ -14,6 +14,7 @@ import { Button } from "../../ui/Button";
import { Dialog } from "../../ui/Dialog";
import { SelectField, TextArea, TextField } from "../../ui/Field";
import { BotAvatar } from "./BotAvatar";
import { ColorPicker } from "./ColorPicker";

const MODELS: BotModel[] = ["default", "fable", "opus", "sonnet", "haiku"];

Expand All @@ -30,6 +31,8 @@ export function BotDialog(props: Props) {
const [role, setRole] = useState(editing?.role ?? "");
const [instructions, setInstructions] = useState(editing?.instructions ?? "");
const [color, setColor] = useState<string | undefined>(editing?.color);
const custom = color !== undefined && !(AVATAR_PALETTE as readonly string[]).includes(color);
const [picking, setPicking] = useState(custom);
const [model, setModel] = useState<BotModel>(editing?.model ?? "default");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
Expand Down Expand Up @@ -126,7 +129,7 @@ export function BotDialog(props: Props) {
<legend className="mb-1.5 font-medium text-ink-soft text-sm">
{t.bots.dialog.color}
</legend>
<div className="flex items-center gap-1.5">
<div className="flex flex-wrap items-center gap-1.5">
{AVATAR_PALETTE.map((swatch) => (
<button
key={swatch}
Expand All @@ -142,7 +145,27 @@ export function BotDialog(props: Props) {
)}
</button>
))}
<button
type="button"
aria-label={t.bots.dialog.custom}
title={t.bots.dialog.custom}
aria-expanded={picking}
aria-pressed={custom}
onClick={() => setPicking(!picking)}
className={`relative flex size-[30px] items-center justify-center rounded-lg ${
custom
? "ring-2 ring-accent"
: "border border-line-strong border-dashed text-muted hover:text-ink"
}`}
>
{custom && color ? (
<BotAvatar color={color} size={26} />
) : (
<Pipette aria-hidden size={14} />
)}
</button>
</div>
{picking && <ColorPicker value={color ?? AVATAR_PALETTE[0]} onChange={setColor} />}
{!editing && color === undefined && (
<p className="text-muted text-xs">{t.bots.dialog.colorUnset}</p>
)}
Expand Down
153 changes: 153 additions & 0 deletions app/src/features/bots/ColorPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// A free color picker for a bot's avatar: a saturation/brightness square
// over the chosen hue, a hue slider, and the color as hex and as red, green
// and blue. Everything edits the same color, and `onChange` gets it as
// `#RRGGBB`. The square answers to the arrow keys as well as the pointer.

import { type KeyboardEvent, type PointerEvent, useEffect, useRef, useState } from "react";
import { useT } from "../../i18n";
import { type Hsv, hsvToRgb, parseHex, type Rgb, rgbToHsv, toHexColor } from "./colorSpace";

const HUES =
"linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)";

const unit = (value: number) => Math.max(0, Math.min(1, value));

export function ColorPicker({ value, onChange }: { value: string; onChange(hex: string): void }) {
const t = useT().bots.dialog.picker;
const [hsv, setHsv] = useState<Hsv>(() => rgbToHsv(parseHex(value) ?? [255, 122, 89]));
const [hexDraft, setHexDraft] = useState(value);
const area = useRef<HTMLDivElement>(null);
const rgb = hsvToRgb(hsv);
const hex = toHexColor(rgb);

// A color set from outside (a palette swatch) moves the picker too, but
// the picker's own changes keep the hue it holds.
useEffect(() => {
const outside = parseHex(value);
if (outside) {
setHsv((held) =>
toHexColor(hsvToRgb(held)) === toHexColor(outside) ? held : rgbToHsv(outside),
);
}
setHexDraft(value.toUpperCase());
}, [value]);

const pick = (next: Hsv) => {
setHsv(next);
const nextHex = toHexColor(hsvToRgb(next));
setHexDraft(nextHex);
onChange(nextHex);
};

const pickRgb = (next: Rgb) => pick(rgbToHsv(next));

const fromPointer = (event: PointerEvent<HTMLDivElement>) => {
const box = area.current?.getBoundingClientRect();
if (!box || box.width === 0 || box.height === 0) {
return;
}
pick({
h: hsv.h,
s: unit((event.clientX - box.left) / box.width),
v: 1 - unit((event.clientY - box.top) / box.height),
});
};

const onAreaKey = (event: KeyboardEvent<HTMLDivElement>) => {
const step = event.shiftKey ? 0.1 : 0.02;
const moves: Record<string, Partial<Hsv>> = {
ArrowLeft: { s: unit(hsv.s - step) },
ArrowRight: { s: unit(hsv.s + step) },
ArrowUp: { v: unit(hsv.v + step) },
ArrowDown: { v: unit(hsv.v - step) },
};
const move = moves[event.key];
if (move) {
event.preventDefault();
pick({ ...hsv, ...move });
}
};

const percent = (n: number) => `${Math.round(n * 100)}%`;

return (
<div className="flex flex-col gap-2.5 rounded-xl border border-line bg-sunken p-3">
<div
ref={area}
role="slider"
tabIndex={0}
aria-label={t.area}
aria-valuetext={t.areaValue(percent(hsv.s), percent(hsv.v))}
aria-valuenow={Math.round(hsv.s * 100)}
onKeyDown={onAreaKey}
onPointerDown={(event) => {
event.currentTarget.setPointerCapture(event.pointerId);
fromPointer(event);
}}
onPointerMove={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
fromPointer(event);
}
}}
className="relative h-36 cursor-crosshair touch-none rounded-lg"
style={{
backgroundColor: toHexColor(hsvToRgb({ h: hsv.h, s: 1, v: 1 })),
backgroundImage:
"linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)",
}}
>
<span
aria-hidden
className="pointer-events-none absolute size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.5)]"
style={{ left: percent(hsv.s), top: percent(1 - hsv.v), backgroundColor: hex }}
/>
</div>
<input
type="range"
min={0}
max={359}
value={Math.round(hsv.h)}
aria-label={t.hue}
onChange={(event) => pick({ ...hsv, h: Number(event.target.value) })}
className="color-hue h-3 w-full cursor-pointer appearance-none rounded-full"
style={{ backgroundImage: HUES }}
/>
<div className="flex items-end gap-2">
<label className="flex min-w-0 flex-[1.6] flex-col gap-1 text-muted text-xs">
{t.hex}
<input
value={hexDraft}
maxLength={7}
spellCheck={false}
onChange={(event) => {
setHexDraft(event.target.value);
const parsed = parseHex(event.target.value);
if (parsed) {
pickRgb(parsed);
}
}}
onBlur={() => setHexDraft(hex)}
className="h-8 w-full rounded-lg border border-line-strong bg-canvas px-2 font-mono text-ink text-sm uppercase outline-none focus:border-accent"
/>
</label>
{(["r", "g", "b"] as const).map((channel, index) => (
<label key={channel} className="flex min-w-0 flex-1 flex-col gap-1 text-muted text-xs">
{t.channels[channel]}
<input
type="number"
min={0}
max={255}
value={rgb[index]}
onChange={(event) => {
const next: Rgb = [...rgb];
next[index] = Math.max(0, Math.min(255, Number(event.target.value) || 0));
pickRgb(next);
}}
className="h-8 w-full rounded-lg border border-line-strong bg-canvas px-2 font-mono text-ink text-sm outline-none focus:border-accent"
/>
</label>
))}
</div>
</div>
);
}
86 changes: 86 additions & 0 deletions app/src/features/bots/color.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { AVATAR_PALETTE } from "../../lib/protocol.gen";
import { crewOpened, openBot, renderApp } from "../../test/app";
import { hsvToRgb, parseHex, rgbToHsv, toHexColor } from "./colorSpace";

afterEach(cleanup);

describe("color space", () => {
test("hex, rgb and hsv round-trip", () => {
for (const hex of [...AVATAR_PALETTE, "#000000", "#FFFFFF", "#123ABC"]) {
const rgb = parseHex(hex);
expect(rgb).not.toBeNull();
if (rgb) {
expect(toHexColor(hsvToRgb(rgbToHsv(rgb)))).toBe(hex);
}
}
});

test("reads hex with or without #, in any case, and nothing else", () => {
expect(parseHex("a48bff")).toEqual([164, 139, 255]);
expect(parseHex(" #A48BFF ")).toEqual([164, 139, 255]);
expect(parseHex("#abc")).toBeNull();
expect(parseHex("#GGGGGG")).toBeNull();
});
});

describe("picking a bot's color", () => {
async function editScout() {
const fake = new FakeBotloft();
const crew = fake.addCrew("Ops");
const scout = fake.addBot(crew.id, "Scout");
renderApp(fake);
await crewOpened("Ops");
openBot("Scout");
await screen.findByRole("heading", { level: 1, name: "Scout" });
fireEvent.click(screen.getByRole("button", { name: "More bot actions" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Edit" }));
return { fake, scout, dialog: screen.getByRole("dialog", { name: "Edit Scout" }) };
}

test("offers the whole palette and any color typed as hex", async () => {
const { fake, scout, dialog } = await editScout();
for (const swatch of AVATAR_PALETTE) {
expect(within(dialog).getByRole("button", { name: `Color ${swatch}` })).toBeDefined();
}
fireEvent.click(within(dialog).getByRole("button", { name: "Pick any color" }));
fireEvent.change(within(dialog).getByLabelText("Hex"), { target: { value: "#123abc" } });
expect((within(dialog).getByLabelText("R") as HTMLInputElement).value).toBe("18");
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() => expect(fake.bots.get(scout.id)?.color).toBe("#123ABC"));
});

test("red, green and blue set the color too", async () => {
const { fake, scout, dialog } = await editScout();
fireEvent.click(within(dialog).getByRole("button", { name: "Pick any color" }));
for (const [channel, value] of [
["R", "0"],
["G", "128"],
["B", "255"],
]) {
fireEvent.change(within(dialog).getByLabelText(channel as string), {
target: { value },
});
}
expect((within(dialog).getByLabelText("Hex") as HTMLInputElement).value).toBe("#0080FF");
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
await waitFor(() => expect(fake.bots.get(scout.id)?.color).toBe("#0080FF"));
});

test("a bot with a color outside the palette opens with the picker", async () => {
const fake = new FakeBotloft();
const crew = fake.addCrew("Ops");
const scout = fake.addBot(crew.id, "Scout");
scout.color = "#123ABC";
renderApp(fake);
await crewOpened("Ops");
openBot("Scout");
await screen.findByRole("heading", { level: 1, name: "Scout" });
fireEvent.click(screen.getByRole("button", { name: "More bot actions" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Edit" }));
const dialog = screen.getByRole("dialog", { name: "Edit Scout" });
expect((within(dialog).getByLabelText("Hex") as HTMLInputElement).value).toBe("#123ABC");
});
});
51 changes: 51 additions & 0 deletions app/src/features/bots/colorSpace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Conversions for the color picker: `#RRGGBB`, red/green/blue channels
// (0 to 255) and hue/saturation/value (hue in degrees, the rest 0 to 1).
// The picker keeps the color as HSV, so dragging to gray or black does not
// lose the hue the owner chose.

export type Rgb = [number, number, number];
export type Hsv = { h: number; s: number; v: number };

/** `#RRGGBB` (any case, `#` optional) as channels, or null if it is not one. */
export function parseHex(input: string): Rgb | null {
const hex = input.trim().replace(/^#/, "");
if (!/^[0-9a-fA-F]{6}$/.test(hex)) {
return null;
}
const value = Number.parseInt(hex, 16);
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
}

/** Channels as `#RRGGBB`, uppercased like the daemon stores it. */
export function toHexColor([r, g, b]: Rgb): string {
const byte = (channel: number) =>
Math.round(Math.max(0, Math.min(255, channel)))
.toString(16)
.padStart(2, "0");
return `#${byte(r)}${byte(g)}${byte(b)}`.toUpperCase();
}

export function rgbToHsv([r, g, b]: Rgb): Hsv {
const [red, green, blue] = [r / 255, g / 255, b / 255];
const max = Math.max(red, green, blue);
const chroma = max - Math.min(red, green, blue);
let h = 0;
if (chroma > 0) {
const sector =
max === red
? ((green - blue) / chroma) % 6
: max === green
? (blue - red) / chroma + 2
: (red - green) / chroma + 4;
h = (sector * 60 + 360) % 360;
}
return { h, s: max === 0 ? 0 : chroma / max, v: max };
}

export function hsvToRgb({ h, s, v }: Hsv): Rgb {
const at = (n: number) => {
const k = (n + h / 60) % 6;
return Math.round((v - v * s * Math.max(0, Math.min(k, 4 - k, 1))) * 255);
};
return [at(5), at(3), at(1)];
}
9 changes: 9 additions & 0 deletions app/src/i18n/en/bots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@ export const bots = {
color: "Color",
swatch: (color: string) => `Color ${color}`,
colorUnset: "Left unset, the bot gets the crew's next color.",
custom: "Pick any color",
picker: {
area: "Saturation and brightness",
areaValue: (saturation: string, brightness: string) =>
`Saturation ${saturation}, brightness ${brightness}`,
hue: "Hue",
hex: "Hex",
channels: { r: "R", g: "G", b: "B" },
},
},
notices: {
crewPaused: (crew: string) => `${crew} is paused`,
Expand Down
Loading
Loading