A bot's identity was one of seven hues, and the hue picked both its colour and its stone shape. Now its colour is free and its stone is its own: - `bots.color` holds any `#rrggbb`, `bots.stone` the flat-cut shape it's drawn on. `bots.hue` stays as the palette column they replaced, read only as a fallback; `src/scripts/move-bot-colour-to-owners.ts` backfills an install from it (safe to rerun). - `src/utils/bot-color.ts` is the only place a colour is validated or resolved (`resolveBotColor`/`resolveBotStone` carry the old hue forward, `botColorNeedsPaperFace` flips the eyes to paper on a very dark body), and `src/utils/bot-stone-props.ts` gives every row its props, so a row saved before this still renders as it did. - `BotStone` takes the colour as the body's SVG `fill`, the one documented exception to the token rule: an arbitrary hex has no token to point at. `UnreadDot` follows it. - The picker keeps the palette tiles (still shape and colour together) and adds a native swatch and a hex field, so a custom colour keeps its stone. Same place as before: the header avatar's modal and Settings > Bots, for crew managers. The coordinator stays hue-less. - A recolour reaches other open tabs through `pushBotUpdated`.
114 lines
4.1 KiB
TypeScript
114 lines
4.1 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import Input from "@moduletrace/twui/form/Input";
|
|
import Row from "@moduletrace/twui/layout/Row";
|
|
import Span from "@moduletrace/twui/layout/Span";
|
|
import Stack from "@moduletrace/twui/layout/Stack";
|
|
import { BotHues, type BotStoneShape } from "@/src/dict/bot-dict";
|
|
import { normalizeBotColor, paletteColorTitle } from "@/src/utils/bot-color";
|
|
import BotColourOption from "./bot-colour-option";
|
|
import BotColourSwatch from "./bot-colour-swatch";
|
|
|
|
type Props = {
|
|
/**
|
|
* The bot's colour as `#rrggbb`.
|
|
*/
|
|
color: string;
|
|
/**
|
|
* Which stone shape it's drawn on. Picking a palette tile sets both, so a
|
|
* palette bot looks exactly as it always did.
|
|
*/
|
|
stone: BotStoneShape;
|
|
disabled?: boolean;
|
|
onChange: (picked: { color: string; stone: BotStoneShape }) => void;
|
|
};
|
|
|
|
/**
|
|
* Picks a bot's colour: the palette tiles (each on its own stone), or any
|
|
* colour at all from the swatch or the hex field. A custom colour keeps the
|
|
* stone the bot already has, so its shape stays its own.
|
|
*/
|
|
export default function BotColourPicker({ color, stone, disabled, onChange }: Props) {
|
|
const [hex, setHex] = useState(color);
|
|
const [invalid, setInvalid] = useState(false);
|
|
const picked = paletteValueFor({ color });
|
|
const palette_title = paletteColorTitle({ color });
|
|
|
|
useEffect(() => setHex(color), [color]);
|
|
|
|
const commitHex = (value: string) => {
|
|
setHex(value);
|
|
|
|
const normalized = normalizeBotColor({ color: value });
|
|
|
|
setInvalid(Boolean(value.trim()) && !normalized);
|
|
|
|
if (normalized) onChange({ color: normalized, stone });
|
|
};
|
|
|
|
return (
|
|
<Stack className="w-full gap-2!">
|
|
<Span className="text-xs text-ink-secondary">Colour</Span>
|
|
<Row
|
|
role="radiogroup"
|
|
aria-label="Colour"
|
|
className="w-full grid grid-cols-4 sm:grid-cols-7 gap-2!"
|
|
>
|
|
{BotHues.map((option) => (
|
|
<BotColourOption
|
|
key={option.value}
|
|
{...option}
|
|
selected={option.value == picked}
|
|
disabled={disabled}
|
|
onSelect={() =>
|
|
onChange({ color: option.color, stone: option.value })
|
|
}
|
|
/>
|
|
))}
|
|
</Row>
|
|
<Row className="w-full flex-nowrap items-end gap-2!">
|
|
<Stack className="shrink-0 gap-1!">
|
|
<Span className="text-xs text-ink-secondary">Any colour</Span>
|
|
<BotColourSwatch
|
|
color={color}
|
|
disabled={disabled}
|
|
title="Pick a colour"
|
|
onChange={(next) => onChange({ color: next, stone })}
|
|
/>
|
|
</Stack>
|
|
<Input
|
|
type="text"
|
|
id="bot-form-color-hex"
|
|
title="Hex value"
|
|
label="Hex value"
|
|
showLabel
|
|
autoComplete="off"
|
|
placeholder="#7c5cd6"
|
|
value={hex}
|
|
disabled={disabled}
|
|
className="grow"
|
|
variant={invalid ? "error" : "normal"}
|
|
info={invalid ? "Use a colour like #7c5cd6" : undefined}
|
|
onChange={(e) => commitHex(e.target.value)}
|
|
onBlur={() => !invalid && setHex(color)}
|
|
/>
|
|
</Row>
|
|
<Span className="text-xs text-ink-secondary">
|
|
{palette_title
|
|
? "A palette colour. Pick any colour, or type a hex, to make it its own."
|
|
: "Its own colour. Its stone keeps its shape."}
|
|
</Span>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The palette entry a colour is, by comparing hexes.
|
|
*/
|
|
function paletteValueFor({ color }: { color: string }): string | null {
|
|
const normalized = normalizeBotColor({ color });
|
|
|
|
if (!normalized) return null;
|
|
|
|
return BotHues.find((entry) => entry.color == normalized)?.value || null;
|
|
}
|