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; 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 { .nameInput {
font-family: "Cinzel", Georgia, serif; font-family: "Cinzel", Georgia, serif;
font-size: 1.3rem; font-size: 1.3rem;

View file

@ -89,11 +89,22 @@ export default function CharacterSheet({
<div className={styles.identity}> <div className={styles.identity}>
{mode === "edit" ? ( {mode === "edit" ? (
<div> <div>
<input <div className={styles.colorRow}>
className={styles.nameInput} <input
defaultValue={character.name} className={styles.nameInput}
onChange={(e) => handleNameField("name", e.target.value)} 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 <input
className={styles.titleInput} className={styles.titleInput}
defaultValue={character.title} defaultValue={character.title}
@ -169,6 +180,25 @@ export default function CharacterSheet({
<span className={styles.xpThreshold}>/ {xpThreshold}</span> <span className={styles.xpThreshold}>/ {xpThreshold}</span>
</div> </div>
</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>
</div> </div>

View file

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

View file

@ -54,30 +54,3 @@
color: var(--hp); color: var(--hp);
font-size: 0.65rem; 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 type { Stat } from "../types";
import { getModifier, formatModifier } from "../utils/modifiers"; import { getModifier, formatModifier } from "../utils/modifiers";
import InlineNumber from "./InlineNumber";
import DiceButton from "./DiceButton"; import DiceButton from "./DiceButton";
import styles from "./StatBlock.module.css"; import styles from "./StatBlock.module.css";
@ -39,14 +40,6 @@ export default function StatBlock({
<div key={name} className={styles.stat}> <div key={name} className={styles.stat}>
<span className={styles.statName}>{name}</span> <span className={styles.statName}>{name}</span>
<div className={styles.statRow}> <div className={styles.statRow}>
{mode === "edit" && (
<button
className={styles.btn}
onClick={() => onStatChange(name, baseValue - 1)}
>
</button>
)}
<span className={styles.modifier}>{formatModifier(mod)}</span> <span className={styles.modifier}>{formatModifier(mod)}</span>
{mode === "view" && campaignId && ( {mode === "view" && campaignId && (
<DiceButton <DiceButton
@ -59,17 +52,18 @@ export default function StatBlock({
label={`${name} check`} label={`${name} check`}
/> />
)} )}
{mode === "edit" && (
<button
className={styles.btn}
onClick={() => onStatChange(name, baseValue + 1)}
>
+
</button>
)}
</div> </div>
<span className={styles.score}> <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>} {bonus > 0 && <span className={styles.bonus}> (+{bonus})</span>}
</span> </span>
</div> </div>

View file

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

View file

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

View file

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