diff --git a/app/src/color.css b/app/src/color.css new file mode 100644 index 00000000..4f48e559 --- /dev/null +++ b/app/src/color.css @@ -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); +} diff --git a/app/src/features/bots/BotDialog.tsx b/app/src/features/bots/BotDialog.tsx index 756ae5da..1b5153cc 100644 --- a/app/src/features/bots/BotDialog.tsx +++ b/app/src/features/bots/BotDialog.tsx @@ -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"; @@ -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"]; @@ -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(editing?.color); + const custom = color !== undefined && !(AVATAR_PALETTE as readonly string[]).includes(color); + const [picking, setPicking] = useState(custom); const [model, setModel] = useState(editing?.model ?? "default"); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -126,7 +129,7 @@ export function BotDialog(props: Props) { {t.bots.dialog.color} -
+
{AVATAR_PALETTE.map((swatch) => (
+ {picking && } {!editing && color === undefined && (

{t.bots.dialog.colorUnset}

)} diff --git a/app/src/features/bots/ColorPicker.tsx b/app/src/features/bots/ColorPicker.tsx new file mode 100644 index 00000000..ed7a5475 --- /dev/null +++ b/app/src/features/bots/ColorPicker.tsx @@ -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(() => rgbToHsv(parseHex(value) ?? [255, 122, 89])); + const [hexDraft, setHexDraft] = useState(value); + const area = useRef(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) => { + 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) => { + const step = event.shiftKey ? 0.1 : 0.02; + const moves: Record> = { + 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 ( +
+
{ + 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)", + }} + > + +
+ pick({ ...hsv, h: Number(event.target.value) })} + className="color-hue h-3 w-full cursor-pointer appearance-none rounded-full" + style={{ backgroundImage: HUES }} + /> +
+ + {(["r", "g", "b"] as const).map((channel, index) => ( + + ))} +
+
+ ); +} diff --git a/app/src/features/bots/color.test.tsx b/app/src/features/bots/color.test.tsx new file mode 100644 index 00000000..10916d52 --- /dev/null +++ b/app/src/features/bots/color.test.tsx @@ -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"); + }); +}); diff --git a/app/src/features/bots/colorSpace.ts b/app/src/features/bots/colorSpace.ts new file mode 100644 index 00000000..3ddcf1af --- /dev/null +++ b/app/src/features/bots/colorSpace.ts @@ -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)]; +} diff --git a/app/src/i18n/en/bots.ts b/app/src/i18n/en/bots.ts index 63a1d0ca..a94dd385 100644 --- a/app/src/i18n/en/bots.ts +++ b/app/src/i18n/en/bots.ts @@ -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`, diff --git a/app/src/i18n/es/bots.ts b/app/src/i18n/es/bots.ts index d89aeb49..5e8134b0 100644 --- a/app/src/i18n/es/bots.ts +++ b/app/src/i18n/es/bots.ts @@ -98,6 +98,15 @@ export const bots: Messages["bots"] = { color: "Color", swatch: (color: string) => `Color ${color}`, colorUnset: "Si no eliges uno, el bot recibe el siguiente color del equipo.", + custom: "Elegir cualquier color", + picker: { + area: "Saturación y brillo", + areaValue: (saturation: string, brightness: string) => + `Saturación ${saturation}, brillo ${brightness}`, + hue: "Tono", + hex: "Hex", + channels: { r: "R", g: "G", b: "B" }, + }, }, notices: { crewPaused: (crew: string) => `El equipo ${crew} está en pausa`, diff --git a/app/src/i18n/pt-BR/bots.ts b/app/src/i18n/pt-BR/bots.ts index 9b2d6ec0..ecfed4c5 100644 --- a/app/src/i18n/pt-BR/bots.ts +++ b/app/src/i18n/pt-BR/bots.ts @@ -97,6 +97,15 @@ export const bots: Messages["bots"] = { color: "Cor", swatch: (color: string) => `Cor ${color}`, colorUnset: "Se você não escolher, o bot recebe a próxima cor da equipe.", + custom: "Escolher qualquer cor", + picker: { + area: "Saturação e brilho", + areaValue: (saturation: string, brightness: string) => + `Saturação ${saturation}, brilho ${brightness}`, + hue: "Matiz", + hex: "Hex", + channels: { r: "R", g: "G", b: "B" }, + }, }, notices: { crewPaused: (crew: string) => `A equipe ${crew} está pausada`, diff --git a/app/src/index.css b/app/src/index.css index 4d5a6468..4d16c775 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -10,6 +10,7 @@ @import "./panels.css"; @import "./sidebar.css"; @import "./effort.css"; +@import "./color.css"; @import "./files.css"; @import "./motion-less.css"; @import "./paused.css"; diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index deabdf92..5f5dcc3d 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -4,7 +4,7 @@ export const PROTOCOL_VERSION = 2; /** Avatar colors the daemon assigns in order (spec 15.3). */ -export const AVATAR_PALETTE = ["#FF7A59", "#5EC8FF", "#A48BFF", "#9BE564", "#FFC857", "#FF7EB6", "#3DD9C1", "#E8D5B0"] as const; +export const AVATAR_PALETTE = ["#FF7A59", "#5EC8FF", "#A48BFF", "#9BE564", "#FFC857", "#FF7EB6", "#3DD9C1", "#E8D5B0", "#F25F5C", "#4C7BFF", "#E26BE0", "#3FBF6F", "#FF9F43", "#8C9BB0", "#B07A54", "#E9EEF2"] as const; /** Longest values the daemon accepts, in characters (files, for attachments). */ export const FIELD_LIMITS = { diff --git a/crates/botloft-core/src/avatar.rs b/crates/botloft-core/src/avatar.rs index 59a63176..7949575f 100644 --- a/crates/botloft-core/src/avatar.rs +++ b/crates/botloft-core/src/avatar.rs @@ -1,9 +1,11 @@ //! Avatar colors. Every bot is drawn as the Botloft mascot in its own color //! (spec 15.3); the color identifies the bot and never encodes its state. -/// Default palette, assigned in order as bots join a crew. -pub const PALETTE: [&str; 8] = [ +/// Default palette, assigned in order as bots join a crew. The owner may +/// also pick any other `#RRGGBB`. +pub const PALETTE: [&str; 16] = [ "#FF7A59", "#5EC8FF", "#A48BFF", "#9BE564", "#FFC857", "#FF7EB6", "#3DD9C1", "#E8D5B0", + "#F25F5C", "#4C7BFF", "#E26BE0", "#3FBF6F", "#FF9F43", "#8C9BB0", "#B07A54", "#E9EEF2", ]; /// Palette color for the `index`-th bot of a crew, wrapping around. diff --git a/docs/spec.md b/docs/spec.md index 3f12c9f4..6153fb88 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -863,7 +863,7 @@ No chat: - Anexos aparecem como miniatura (imagem) ou cartão com nome, tipo e tamanho. - Arquivos que o bot compartilha (`share_file`) aparecem como cartões na fala dele, com Abrir e Salvar como… (15.1). -Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/app-icon.svg`, de onde `pnpm tauri icon app-icon.svg` gera os PNG, o ICO e o ICNS; as pastas `android` e `ios` que ele cria são apagadas) é o mascote laranja, na cor que mostra o desenho como foi feito, sobre um quadrado preto, montado com os mesmos contornos e camadas de `mascotArt.ts`. Cada bot usa o mesmo personagem como avatar, com uma cor própria escolhida na criação, sem fundo e com um contorno fino e discreto (escuro no tema claro, claro no escuro) para as cores claras não sumirem. O avatar é a primeira pose da folha de poses da chama, vetorizada (`mascotArt.ts`): o corpo com as labaredas, uma gotinha de fogo solta acima da ponta, camadas de luz e sombra recortadas pelo contorno e olhos pretos com brilhos e reflexo avermelhado embaixo, tudo retingido para a cor do bot (matiz, saturação e luminosidade), então todo bot tem a mesma luz e sombra. A referência da retintura é a primeira cor da paleta (`#FF7A59`): um bot laranja fica igual ao desenho, e as cores claras da paleta mantêm a profundidade em vez de desbotar. A gotinha passa um pouco acima do quadrado do avatar, como as faíscas. O mascote do próprio Botloft (barra de título, mensagens do daemon) é o do ícone: laranja sobre o quadrado preto. A cor do avatar identifica o bot e não comunica estado: estado continua sendo cor + ícone + texto, como descrito acima. O mascote se mexe conforme o que o bot faz (ver Movimento), sem substituir isso. +Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/app-icon.svg`, de onde `pnpm tauri icon app-icon.svg` gera os PNG, o ICO e o ICNS; as pastas `android` e `ios` que ele cria são apagadas) é o mascote laranja, na cor que mostra o desenho como foi feito, sobre um quadrado preto, montado com os mesmos contornos e camadas de `mascotArt.ts`. Cada bot usa o mesmo personagem como avatar, com uma cor própria escolhida na criação (e mudada ao editar), sem fundo e com um contorno fino e discreto (escuro no tema claro, claro no escuro) para as cores claras não sumirem. O avatar é a primeira pose da folha de poses da chama, vetorizada (`mascotArt.ts`): o corpo com as labaredas, uma gotinha de fogo solta acima da ponta, camadas de luz e sombra recortadas pelo contorno e olhos pretos com brilhos e reflexo avermelhado embaixo, tudo retingido para a cor do bot (matiz, saturação e luminosidade), então todo bot tem a mesma luz e sombra. A referência da retintura é a primeira cor da paleta (`#FF7A59`): um bot laranja fica igual ao desenho, e as cores claras da paleta mantêm a profundidade em vez de desbotar. A gotinha passa um pouco acima do quadrado do avatar, como as faíscas. O mascote do próprio Botloft (barra de título, mensagens do daemon) é o do ícone: laranja sobre o quadrado preto. Na janela de criar e editar bot, a cor sai de uma paleta de 16 (`PALETTE`, que o daemon também dá em ordem a quem não escolhe) ou de "Escolher qualquer cor", que abre um seletor livre: um quadrado de saturação e brilho sobre o matiz escolhido (também com as setas do teclado; Shift anda mais), uma barra de matiz e a cor em hex e em R, G e B, tudo editando a mesma cor. O daemon aceita qualquer `#RRGGBB`. Um bot com cor fora da paleta abre a janela com o seletor aberto. A cor do avatar identifica o bot e não comunica estado: estado continua sendo cor + ícone + texto, como descrito acima. O mascote se mexe conforme o que o bot faz (ver Movimento), sem substituir isso. ### 15.4 Instalador e sidecar