From 4c0d7906cd8cb41384623f106c7553d3895fd1e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:40:03 +0000 Subject: [PATCH] Add explanation tooltips for derived values Hover/focus/tap tooltips show how attributes, derived stats, skill target numbers and limits, capacity, carry weight and point pools are calculated. Used in the wizard pages, the side sheet and the summary. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K --- README.md | 2 ++ frontend/src/Creator.jsx | 33 +++++++++-------- frontend/src/SheetPanel.jsx | 9 ++--- frontend/src/Tip.jsx | 40 +++++++++++++++++++++ frontend/src/explain.js | 70 +++++++++++++++++++++++++++++++++++++ frontend/src/sheetModel.js | 9 +++-- frontend/src/styles.css | 7 ++++ 7 files changed, 148 insertions(+), 22 deletions(-) create mode 100644 frontend/src/Tip.jsx create mode 100644 frontend/src/explain.js diff --git a/README.md b/README.md index fafa75c..03e7f18 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ cd frontend && npm install && npm run dev Characters are saved in your account ("My characters" page). Only the owner can see a character, change it or delete it. On "My characters" the **Share** button lets you add usernames (e.g. your DM): they get read-only access, see the character under "Shared with me" and can open it through `/#creator/`. Saving a viewed character creates their own copy. Use the JSON backup (Summary page) to move a character between servers. +Hover (or tap / focus) the dotted-underlined labels and the small "i" icons to see where a number comes from, e.g. how a skill's target number and maximum are derived, or what a derived stat is made of. The explanations are generated from the rules, so they follow presets and `rules.json` changes. + While creating, the full character sheet stays visible next to the wizard. Edits are shown as a difference (old → new, added / removed items) until you press **Apply** (or **Apply & next**); **Discard** drops them. Each wizard page has a **Reset this page** button that clears only that page's data, plus **Start over** for the whole character. diff --git a/frontend/src/Creator.jsx b/frontend/src/Creator.jsx index f8b5850..20ba1b9 100644 --- a/frontend/src/Creator.jsx +++ b/frontend/src/Creator.jsx @@ -1,6 +1,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { applyPreset, applyProfession, compute, effectiveRules, emptyCharacter, kitItems, normalize, resetStep, setProfessionPicks } from './calc.js' import SheetPanel from './SheetPanel.jsx' +import Tip from './Tip.jsx' +import { explainAttr, explainAttrPool, explainCapacity, explainCarry, explainDerived, explainProfPool, explainSkill, explainSkillPool } from './explain.js' const STEPS = ['Concept', 'Attributes', 'Profession', 'Skills', 'Talents', 'Mutations', 'Equipment', 'Summary'] const SOURCE = { noxium: 'Noxium', diesium: 'Diesium', hybrid: 'Hybrid', precyber: 'Pre-meteor cybernetics' } @@ -40,6 +42,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad const sub = cls?.subclasses.find((x) => x.id === ch.subclass_id) const set = (patch) => setCh((x) => ({ ...x, ...patch })) const cr = rules.creation + const ctx = { ch, rules, c } useEffect(() => { onName?.(ch.name) }, [ch.name]) // eslint-disable-line @@ -190,7 +193,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad <>

Attributes

All attributes start at {cr.attr_start}. Distribute {cr.attr_points} points (at most +{cr.attr_max_raise} per attribute, cap {cr.attr_max}).

-
Points left: {c.attrLeft} / {cr.attr_points}
+
Points left: {c.attrLeft} / {cr.attr_points}
{rules.attributes.map((a) => { const v = ch.attributes[a.id] @@ -198,14 +201,14 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad const setV = (n) => set({ attributes: { ...ch.attributes, [a.id]: n } }) return (
-
{a.name} {a.short}
{a.desc}
+
{a.name} {a.short}
{a.desc}
{v} - Mod {c.mods[a.id]} + Mod {c.mods[a.id]}
) @@ -263,11 +266,11 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad (free if your profession grants them). The pool size comes from the rules preset.

-
- Points left: {c.skillLeft} / {cr.skill_points_default} +
+ Points left: {c.skillLeft} / {cr.skill_points_default}
-
- Proficiency picks left: {c.profLeft} / {c.profAllowed} +
+ Proficiency picks left: {c.profLeft} / {c.profAllowed}

@@ -283,7 +286,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad return (

- {s.name} {s.placeholder && } + {s.name} {s.placeholder && }
{s.attrs.map((id) => rules.attributes.find((a) => a.id === id).short).join('/')} · {s.type === 'basic' ? 'Basic' : s.usable ? 'Advanced (unlocked)' : 'Advanced (locked)'} {s.pips === 1 && ' · Proficient'}{s.pips === 2 && ' · Expertise'} @@ -312,7 +315,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad {s.points} - {s.total} + {s.total} max {s.max}
)}
@@ -379,7 +382,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad <>

Equipment

Pick everything your character carries. Ask your GM what is allowed. Use + and − on a tile to change the amount.

-
Carried: {c.weight} / {c.carryMax} kg (Strength Mod x {cr.carry_per_strength_mod}){overWeight && ' – overloaded'}
+
Carried: {c.weight} / {c.carryMax} kg (Strength Mod x {cr.carry_per_strength_mod}){overWeight && ' – overloaded'}
{rules.equipment_categories.map((cat) => { const list = items.filter((e) => e.cat === cat.id) const n = list.reduce((sum, e) => sum + (ch.kit.extras[e.id] || 0), 0) @@ -435,18 +438,18 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad

Attributes

- {rules.attributes.map((a) => )}
{a.name}{c.attrs[a.id]}Mod {c.mods[a.id]}
+ {rules.attributes.map((a) => )}
{a.name}{c.attrs[a.id]}Mod {c.mods[a.id]}

Derived stats

- {rules.derived.map((d) => )}
{d.name}{c.derived[d.id]}
+ {rules.derived.map((d) => )}
{d.name}{c.derived[d.id]}

Talents

    {rules.talents.filter((t) => ch.talents.includes(t.id)).map((t) =>
  • {t.name}
    {t.desc}
  • )}
{!ch.talents.length &&

–

} -

Mutations / implants ({c.capacityUsed}/{c.capacityMax})

+

Mutations / implants ({c.capacityUsed}/{c.capacityMax})

    {rules.augments.filter((a) => ch.augments.includes(a.id)).map((a) =>
  • {a.name} cost {a.cost}
    {a.desc}
  • )}
{!ch.augments.length &&

–

} -

Equipment ({c.weight}/{c.carryMax} kg)

+

Equipment ({c.weight}/{c.carryMax} kg)

    {gearLines.map((g) =>
  • {g.item.name}{g.qty > 1 ? ` x${g.qty}` : ''}
    {[g.item.stat, `${Math.round(g.item.kg * g.qty * 10) / 10} kg`, g.item.note].filter(Boolean).join(' · ')}
  • )}
{!gearLines.length &&

–

} {ch.gear.other &&

Other: {ch.gear.other}

} @@ -460,7 +463,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad {c.skills.filter((s) => s.group === g.id).map((s) => ( - + diff --git a/frontend/src/SheetPanel.jsx b/frontend/src/SheetPanel.jsx index 6416a91..b328460 100644 --- a/frontend/src/SheetPanel.jsx +++ b/frontend/src/SheetPanel.jsx @@ -1,4 +1,5 @@ import { useMemo } from 'react' +import Tip from './Tip.jsx' import { countChanges, diffRows, sheetModel } from './sheetModel.js' function Rows({ now, before, valueLabel }) { @@ -8,7 +9,7 @@ function Rows({ now, before, valueLabel }) {
{s.name}{s.type === 'advanced' && (adv.)}{s.name}{s.type === 'advanced' && (adv.)} {s.total} {s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : !s.usable ? 'Locked' : s.points ? `+${s.points}` : ''}
{rows.map((r) => ( - +
{r.status === 'added' ? '+ ' : r.status === 'removed' ? '− ' : ''}{r.label}{r.desc &&
{r.desc}
}
{r.status === 'added' ? '+ ' : r.status === 'removed' ? '− ' : ''}{r.label}{r.desc &&
{r.desc}
}
{r.status === 'changed' && r.old.val !== r.val && r.old.val !== undefined && {r.old.val} → } {valueLabel ? valueLabel(r) : r.val} @@ -51,12 +52,12 @@ export default function SheetPanel({ ch, committed, baseRules, onApply, onDiscar

Talents

-

Mutations / implants {c.capacityUsed}/{c.capacityMax}

+

Mutations / implants {c.capacityUsed}/{c.capacityMax}

-

Equipment {c.weight}/{c.carryMax} kg

+

Equipment {c.weight}/{c.carryMax} kg

(r.val > 1 ? `x${r.val}` : '')} />

Points left

-
Attributes {c.attrLeft} · Skills {c.skillLeft} · Proficiency {c.profLeft}
+
Attributes {c.attrLeft} · Skills {c.skillLeft} · Proficiency {c.profLeft}
) } diff --git a/frontend/src/Tip.jsx b/frontend/src/Tip.jsx new file mode 100644 index 0000000..48787fa --- /dev/null +++ b/frontend/src/Tip.jsx @@ -0,0 +1,40 @@ +import { useId, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' + +// Tooltip that works with mouse, keyboard focus and touch. `text` is a string or a list of lines. +// Without `icon` the children get a dotted underline; with `icon` a small "i" is shown after them. +export default function Tip({ text, children, icon }) { + const lines = (Array.isArray(text) ? text : [text]).filter(Boolean) + const ref = useRef(null) + const pop = useRef(null) + const id = useId() + const [open, setOpen] = useState(false) + const [pos, setPos] = useState({ left: 0, top: 0, ready: false }) + + useLayoutEffect(() => { + if (!open || !ref.current || !pop.current) return + const r = ref.current.getBoundingClientRect() + const w = pop.current.offsetWidth, h = pop.current.offsetHeight + const left = Math.max(8, Math.min(window.innerWidth - w - 8, r.left + r.width / 2 - w / 2)) + const below = r.bottom + 6 + h < window.innerHeight + setPos({ left, top: below ? r.bottom + 6 : Math.max(8, r.top - 6 - h), ready: true }) + }, [open, lines.join('|')]) // eslint-disable-line + + if (!lines.length) return children + const show = () => setOpen(true) + const hide = () => { setOpen(false); setPos((p) => ({ ...p, ready: false })) } + + return ( + <> + e.key === 'Escape' && hide()}> + {children}{icon && i} + + {open && createPortal( + , document.body)} + + ) +} diff --git a/frontend/src/explain.js b/frontend/src/explain.js new file mode 100644 index 0000000..65ae9a5 --- /dev/null +++ b/frontend/src/explain.js @@ -0,0 +1,70 @@ +// Plain-language explanations of where numbers come from, shown in tooltips. +// `x` is { ch, rules, c } (character, effective rules, result of compute()). + +const attrName = (rules, id) => rules.attributes.find((a) => a.id === id)?.name || id +const attrShort = (rules, id) => rules.attributes.find((a) => a.id === id)?.short || id + +export function explainAttr(a, { ch, rules, c }) { + const cr = rules.creation + const base = ch.attributes[a.id] ?? cr.attr_start + const bonus = ch.profession && ch.profession_bonus_attr === a.id ? cr.profession_attr_bonus : 0 + const cap = Math.min(cr.attr_max, cr.attr_start + cr.attr_max_raise) + const used = rules.derived.filter((d) => d.formula.includes(a.id)).map((d) => d.name) + return [ + `${a.name} = ${cr.attr_start} start + ${base - cr.attr_start} from your points${bonus ? ` + ${bonus} profession bonus` : ''} = ${c.attrs[a.id]}`, + `Modifier = value ÷ 10, rounded down = ${c.mods[a.id]}`, + `Highest value you can reach with your points: ${cap} (start ${cr.attr_start}, at most +${cr.attr_max_raise}, cap ${cr.attr_max}). The profession bonus comes on top.`, + used.length ? `Its modifier feeds: ${used.join(', ')}.` : '', + ].filter(Boolean) +} + +export function explainDerived(d, { rules, c }) { + const parts = d.formula.map((id) => `${attrName(rules, id)} mod ${c.mods[id]}`) + return [`${d.name} = ${parts.join(' + ')} = ${c.derived[d.id]}`, d.note || ''].filter(Boolean) +} + +export function explainSkill(s, { ch, rules, c }) { + const cr = rules.creation + const best = Math.max(...s.attrs.map((id) => c.attrs[id])) + const list = s.attrs.map((id) => `${attrShort(rules, id)} ${c.attrs[id]}`).join(', ') + const training = s.pips === 2 ? `Expertise +${s.prof}` : s.pips === 1 ? `Proficient +${s.prof}` : 'no training +0' + const lines = [ + `Target number = best attribute ${best} (${s.attrs.length > 1 ? `higher of ${list}` : list}) + ${s.points} skill points + ${training} = ${s.total}`, + `Most points you can put in: best attribute ÷ ${cr.skill_max_divisor}, rounded down = ${s.max}`, + ] + if (s.type === 'advanced') { + lines.push(s.free ? 'Advanced skill: unlocked for free by a proficiency.' + : s.unlocked ? `Advanced skill: unlocked (cost ${c.unlockCost} points from the pool).` + : `Advanced skill: locked. Unlock it for ${c.unlockCost} points, or get a proficiency in it.`) + } + if (s.pips === 1 && s.profPip) lines.push('Proficient through your profession.') + return lines +} + +export function explainCapacity({ rules, c }) { + const d = rules.derived.find((x) => x.id === 'augmentation') + return [`Capacity = ${d ? d.formula.map((id) => `${attrName(rules, id)} mod ${c.mods[id]}`).join(' + ') : 'see rules'} = ${c.capacityMax}`, + `Each mutation or implant costs capacity; you used ${c.capacityUsed} of ${c.capacityMax}.`] +} + +export function explainCarry({ rules, c }) { + return [`Carry capacity = Strength mod ${c.mods.strength} × ${rules.creation.carry_per_strength_mod} kg = ${c.carryMax} kg`, + `Carried now: ${c.weight} kg (sum of all chosen items).`] +} + +export function explainAttrPool({ rules, c }) { + return [`Attribute points: ${c.attrSpent} spent of ${rules.creation.attr_points}. Each point raises an attribute by 1.`] +} + +export function explainSkillPool({ rules, c }) { + const cr = rules.creation + const paid = c.skills.filter((s) => s.type === 'advanced' && s.unlocked && !s.prof).length + return [`Skill pool: ${c.skillSpent} spent of ${cr.skill_points_default}.`, + `Skill points count 1 each; unlocking an advanced skill costs ${c.unlockCost}${paid ? ` (${paid} unlocked = ${paid * c.unlockCost})` : ''}.`] +} + +export function explainProfPool({ rules, c }) { + const cr = rules.creation + return [`Proficiency picks: ${c.profSpent} used of ${c.profAllowed} (${cr.free_proficiencies} free plus talents like Versatile Training).`, + `One pick = Proficient (+${cr.proficiency_bonus}). Two picks on one skill = Expertise (+${cr.expertise_bonus}); a profession proficiency counts as one.`] +} diff --git a/frontend/src/sheetModel.js b/frontend/src/sheetModel.js index 4925c2b..82f4196 100644 --- a/frontend/src/sheetModel.js +++ b/frontend/src/sheetModel.js @@ -1,9 +1,11 @@ import { compute, effectiveRules, kitItems } from './calc.js' +import { explainAttr, explainCapacity, explainCarry, explainDerived, explainSkill, explainAttrPool, explainSkillPool, explainProfPool } from './explain.js' // Flat, comparable view of a character, used by the side panel to show what changed. export function sheetModel(ch, baseRules) { const rules = effectiveRules(baseRules, ch) const c = compute(ch, rules) + const x = { ch, rules, c } const prof = rules.professions.find((p) => p.id === ch.profession) const cls = rules.classes.find((k) => k.id === ch.class_id) const sub = cls?.subclasses.find((x) => x.id === ch.subclass_id) @@ -11,15 +13,16 @@ export function sheetModel(ch, baseRules) { const training = (s) => (s.pips === 2 ? 'Expertise' : s.pips === 1 ? 'Proficient' : !s.usable ? 'Locked' : s.type === 'advanced' ? 'Unlocked' : '') return { c, + tips: { capacity: explainCapacity(x), carry: explainCarry(x), pools: [...explainAttrPool(x), ...explainSkillPool(x), ...explainProfPool(x)] }, head: [ { k: 'name', label: 'Name', val: ch.name || 'Nameless' }, { k: 'profession', label: 'Profession', val: prof?.name || '–' }, { k: 'class', label: 'Class', val: cls ? [cls.name, sub?.name].filter(Boolean).join(' / ') : '–' }, { k: 'preset', label: 'Rules', val: preset }, ], - attrs: rules.attributes.map((a) => ({ k: a.id, label: a.name, val: c.attrs[a.id], extra: `Mod ${c.mods[a.id]}` })), - derived: rules.derived.map((d) => ({ k: d.id, label: d.name, val: c.derived[d.id] })), - skills: c.skills.map((s) => ({ k: s.id, label: s.name, val: s.total, extra: training(s), dim: !s.usable })), + attrs: rules.attributes.map((a) => ({ k: a.id, label: a.name, val: c.attrs[a.id], extra: `Mod ${c.mods[a.id]}`, tip: explainAttr(a, x) })), + derived: rules.derived.map((d) => ({ k: d.id, label: d.name, val: c.derived[d.id], tip: explainDerived(d, x) })), + skills: c.skills.map((s) => ({ k: s.id, label: s.name, val: s.total, extra: training(s), dim: !s.usable, tip: explainSkill(s, x) })), talents: rules.talents.filter((t) => ch.talents.includes(t.id)).map((t) => ({ k: t.id, label: t.name, desc: t.desc })), augments: rules.augments.filter((a) => ch.augments.includes(a.id)).map((a) => ({ k: a.id, label: a.name, desc: `${a.kind === 'implant' ? 'Implant' : 'Mutation'} · cost ${a.cost}${a.desc ? ` · ${a.desc}` : ''}` })), gear: kitItems(ch, rules).map((g) => ({ k: g.item.id, label: g.item.name, val: g.qty, desc: [g.item.stat, `${g.item.kg} kg`, g.item.note].filter(Boolean).join(' · ') })), diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 589267e..2b71974 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -162,3 +162,10 @@ tr.dim td { opacity: .55; } .card.tile { display: flex; flex-direction: column; gap: .3rem; } .card.tile.on { border-color: var(--noxium); box-shadow: inset 0 0 0 1px var(--noxium); background: var(--panel2); } .tilestepper { margin-top: auto; padding-top: .4rem; justify-content: flex-end; } + +.tipped { border-bottom: 1px dotted var(--muted); cursor: help; } +.tipwrap { cursor: help; } +.tipicon { display: inline-grid; place-items: center; width: 1.05rem; height: 1.05rem; margin-left: .3rem; border: 1px solid var(--muted); border-radius: 50%; color: var(--muted); font-size: .7rem; font-style: italic; font-weight: 700; vertical-align: middle; } +.tipwrap:hover .tipicon, .tipwrap:focus .tipicon { color: var(--noxium); border-color: var(--noxium); } +.tippop { position: fixed; z-index: 1000; max-width: 340px; padding: .55rem .75rem; background: var(--panel2); color: var(--text); border: 1px solid var(--diesium); border-radius: var(--radius); box-shadow: 0 6px 20px rgba(0,0,0,.35); font-size: .85rem; line-height: 1.4; pointer-events: none; } +.tippop div + div { margin-top: .35rem; }