Add luck token toggle, color picker, and click-to-edit ability scores

- Luck token: star toggle (filled/empty) in header, always clickable
- Color picker: input[type=color] next to name in edit mode
- Ability scores: InlineNumber click-to-edit replaces +/- buttons
- Server: added color and luck_token to allowed update fields
- DB: luck_token column migration (default 1)
- Fix dice color for hex color values (was only handling HSL)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aaron Wood 2026-04-09 21:35:49 -04:00
parent 51ffb0033a
commit b88fa0cb3e
8 changed files with 87 additions and 52 deletions

View file

@ -104,6 +104,37 @@
font-weight: 600;
}
.luckBtn {
background: none;
border: none;
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
padding: 0.1rem;
transition: transform 0.15s;
}
.luckBtn:hover {
transform: scale(1.2);
}
.colorPicker {
width: 2rem;
height: 1.5rem;
border: 1px solid rgba(var(--gold-rgb), 0.3);
border-radius: 3px;
padding: 0;
cursor: pointer;
background: none;
}
.colorRow {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.3rem;
}
.nameInput {
font-family: "Cinzel", Georgia, serif;
font-size: 1.3rem;

View file

@ -89,11 +89,22 @@ export default function CharacterSheet({
<div className={styles.identity}>
{mode === "edit" ? (
<div>
<input
className={styles.nameInput}
defaultValue={character.name}
onChange={(e) => handleNameField("name", e.target.value)}
/>
<div className={styles.colorRow}>
<input
className={styles.nameInput}
defaultValue={character.name}
onChange={(e) => handleNameField("name", e.target.value)}
/>
<input
type="color"
className={styles.colorPicker}
value={character.color || "#888888"}
onChange={(e) =>
onUpdate(character.id, { color: e.target.value })
}
title="Character color"
/>
</div>
<input
className={styles.titleInput}
defaultValue={character.title}
@ -169,6 +180,25 @@ export default function CharacterSheet({
<span className={styles.xpThreshold}>/ {xpThreshold}</span>
</div>
</div>
{/* Luck token — always toggleable */}
<div className={styles.vital}>
<button
className={styles.luckBtn}
onClick={() =>
onUpdate(character.id, {
luck_token: character.luck_token ? 0 : 1,
})
}
title={
character.luck_token
? "Luck token available"
: "Luck token spent"
}
>
{character.luck_token ? "\u2605" : "\u2606"}
</button>
</div>
</div>
</div>

View file

@ -138,9 +138,12 @@ function buildNotation(expression: string, rolls: number[]): string | null {
return `${count}d${sides}@${values}`;
}
/** Convert an HSL string like "hsl(210, 70%, 50%)" to a hex color */
function hslToHex(hsl: string): string {
const match = hsl.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/);
/** Convert a color string (HSL or hex) to hex */
function hslToHex(color: string): string {
// Already hex — pass through
if (color.startsWith("#")) return color;
const match = color.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/);
if (!match) return "#8B2020";
const h = parseInt(match[1]) / 360;

View file

@ -54,30 +54,3 @@
color: var(--hp);
font-size: 0.65rem;
}
.btn {
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid rgba(var(--gold-rgb), 0.25);
background: var(--bg-inset);
color: var(--text-primary);
cursor: pointer;
font-size: 0.8rem;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition:
border-color 0.15s,
color 0.15s;
}
.btn:hover {
border-color: var(--gold);
color: var(--gold);
}
.rollSpace {
width: 2.5rem;
}

View file

@ -1,5 +1,6 @@
import type { Stat } from "../types";
import { getModifier, formatModifier } from "../utils/modifiers";
import InlineNumber from "./InlineNumber";
import DiceButton from "./DiceButton";
import styles from "./StatBlock.module.css";
@ -39,14 +40,6 @@ export default function StatBlock({
<div key={name} className={styles.stat}>
<span className={styles.statName}>{name}</span>
<div className={styles.statRow}>
{mode === "edit" && (
<button
className={styles.btn}
onClick={() => onStatChange(name, baseValue - 1)}
>
</button>
)}
<span className={styles.modifier}>{formatModifier(mod)}</span>
{mode === "view" && campaignId && (
<DiceButton
@ -59,17 +52,18 @@ export default function StatBlock({
label={`${name} check`}
/>
)}
{mode === "edit" && (
<button
className={styles.btn}
onClick={() => onStatChange(name, baseValue + 1)}
>
+
</button>
)}
</div>
<span className={styles.score}>
{value}
{mode === "edit" ? (
<InlineNumber
value={baseValue}
onChange={(v) => onStatChange(name, v)}
min={1}
max={30}
/>
) : (
value
)}
{bonus > 0 && <span className={styles.bonus}> (+{bonus})</span>}
</span>
</div>

View file

@ -54,6 +54,7 @@ export interface Character {
gear_slots_max: number;
overrides: Record<string, unknown>;
color: string;
luck_token: number;
stats: Stat[];
gear: Gear[];
talents: Talent[];

View file

@ -111,6 +111,7 @@ const v2Columns: Array<[string, string, string]> = [
["character_gear", "effects", "TEXT DEFAULT '{}'"],
["character_talents", "game_talent_id", "INTEGER"],
["characters", "color", "TEXT DEFAULT ''"],
["characters", "luck_token", "INTEGER DEFAULT 1"],
["roll_log", "nat20", "INTEGER DEFAULT 0"],
["roll_log", "character_color", "TEXT DEFAULT ''"],
];

View file

@ -148,6 +148,8 @@ router.patch("/:id", (req, res) => {
"cp",
"gear_slots_max",
"overrides",
"color",
"luck_token",
];
const updates: string[] = [];