Let a bot take any colour
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`.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user