Sheet: large portrait slot, writable notes in summary and lobby

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
Claude
2026-10-24 18:10:12 +00:00
parent 7940fcfa3f
commit a2103566a0
8 changed files with 83 additions and 25 deletions
+1 -1
View File
@@ -461,7 +461,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
<>
<h2>Summary</h2>
{warnings.length > 0 && <ul className="warn">{warnings.map((w) => <li key={w}>{w}</li>)}</ul>}
<PaperSheet data={ch} baseRules={baseRules} />
<PaperSheet data={ch} baseRules={baseRules} onNotes={(p) => setBoth({ ...ch, ...p })} />
<div className="actions wrap">
{!npc && <button onClick={() => download('pdf')}>Download as PDF</button>}
{!npc && <button onClick={() => download('docx')}>Download as Word</button>}
+14 -5
View File
@@ -524,23 +524,32 @@ function DifficultyBar({ lobby, onSet }) {
}
// ---- sheet of one character, refreshed regularly so the DM sees changes the player saves
function CharTab({ cid, rules, onRoll, tick }) {
function CharTab({ cid, rules, onRoll, tick, editable }) {
const [data, setData] = useState(null)
const [err, setErr] = useState('')
const typing = useRef(0) // time of the last keystroke: refreshes must not overwrite what is being typed
const timer = useRef(null)
const saveNotes = (patch) => {
typing.current = Date.now()
setData((d) => ({ ...d, ...patch }))
clearTimeout(timer.current)
timer.current = setTimeout(() => { send(`/api/characters/${cid}/notes`, 'PUT', patch).catch(() => setErr('Could not save the notes.')) }, 700)
}
useEffect(() => () => clearTimeout(timer.current), [])
useEffect(() => {
setData(null); setErr('')
const load = () => fetch(`/api/characters/${cid}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }).then(setData).catch(() => setErr('Could not load this character.'))
const load = () => fetch(`/api/characters/${cid}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }).then((d) => { if (Date.now() - typing.current > 4000) setData(d) }).catch(() => setErr('Could not load this character.'))
load()
const t = setInterval(() => !document.hidden && load(), 15000)
return () => clearInterval(t)
}, [cid])
useEffect(() => { // pushed by the server when the player saved the character
if (tick) fetch(`/api/characters/${cid}`).then((r) => (r.ok ? r.json() : null)).then((d) => d && setData(d)).catch(() => {})
if (tick) fetch(`/api/characters/${cid}`).then((r) => (r.ok ? r.json() : null)).then((d) => d && Date.now() - typing.current > 4000 && setData(d)).catch(() => {})
}, [tick]) // eslint-disable-line
return (
<div>
{err && <p className="warn">{err}</p>}
{data && <PaperSheet data={data} baseRules={rules} bare onRoll={onRoll} />}
{data && <PaperSheet data={data} baseRules={rules} bare onRoll={onRoll} onNotes={editable ? saveNotes : undefined} />}
</div>
)
}
@@ -804,7 +813,7 @@ function LobbyView({ id, rules, user }) {
{lobby.is_dm && leftTab === 'lobby'
? <><MapLibrary activeId={mapState.map?.id} lobbyId={id} act={act} /><SoundSettings lobby={lobby} id={id} act={act} sfx={sfx.current} /><Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} /></>
: npcTok ? <CharTab key={`npc${npcTok.npc_id}`} cid={npcTok.npc_id} rules={rules} tick={sheetTick.cid === npcTok.npc_id ? sheetTick.n : 0} onRoll={(k, i, n) => check(k, i, n, npcTok.id)} />
: cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} tick={sheetTick.cid === cur.character_id ? sheetTick.n : 0} onRoll={cur.me && canCheck ? check : undefined} />
: cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} tick={sheetTick.cid === cur.character_id ? sheetTick.n : 0} onRoll={cur.me && canCheck ? check : undefined} editable={cur.me} />
: <p className="muted small">{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}</p>}
</div>
)}
+36 -15
View File
@@ -31,6 +31,8 @@ export function printSheet(root, title) {
const win = window.open('', '_blank')
if (!win) { window.alert('Please allow pop-ups for this site to print the sheet.'); return }
const clone = root.cloneNode(true)
const src = root.querySelectorAll('textarea'), dst = clone.querySelectorAll('textarea')
dst.forEach((t, i) => { t.textContent = src[i].value }) // typed text does not survive cloning on its own
clone.style.zoom = ''
clone.classList.remove('dark') // printed pages are always light
const head = [...document.querySelectorAll('link[rel=stylesheet], style')].map((n) => n.outerHTML).join('')
@@ -47,6 +49,17 @@ export function printSheet(root, title) {
links.forEach((l) => { l.addEventListener('load', () => { if (--pending === 0) setTimeout(go, 150) }); l.addEventListener('error', () => { if (--pending === 0) go() }) })
}
// Ruled note area. With onChange (own sheet in the summary or the lobby) it can be typed into, otherwise it just shows the text.
function Notes({ title, value, onChange }) {
return (
<div className="psec pnotes"><h3>{title}</h3>
{onChange
? <textarea className="plines pwrite" value={value || ''} maxLength={5000} onChange={(e) => onChange(e.target.value)} placeholder="Click here to write …" aria-label={title} />
: <div className="plines pwrite">{value}</div>}
</div>
)
}
// Scales the A4 pages down when the space is narrower than the paper
function Fit({ children, rootRef, dark }) {
const wrap = useRef(null)
@@ -68,7 +81,7 @@ const Pips = ({ n, locked }) => (
)
// The character sheet as A4 pages (page 1: attributes and skills, page 2: talents, mutations, equipment, notes).
export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
export default function PaperSheet({ data, baseRules, tools, bare, onRoll, onNotes }) {
const ch = useMemo(() => normalize(data, baseRules), [data, baseRules])
const rules = useMemo(() => effectiveRules(baseRules, ch), [baseRules, ch])
const c = useMemo(() => compute(ch, rules), [ch, rules])
@@ -108,18 +121,26 @@ export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
)}
<Fit rootRef={root} dark={dark}>
<section className="paper">
<header className="phead">
<div className="pname">{name}</div>
{ch.portrait && <img className="pportrait" src={ch.portrait} alt="" />}
<div className="pbrand">Arcane Wasteland<span>Character sheet</span></div>
</header>
<div className="pfields">
<Field label="Player">{ch.player}</Field>
<Field label="Concept">{ch.concept}</Field>
<Field label="Class">{cls && [cls.name, sub?.name].filter(Boolean).join(' / ')}</Field>
<Field label="Profession">{prof?.name}</Field>
<Field label="Rules">{preset}</Field>
</div>
{(() => {
const head = (
<header className="phead">
<div className="pname">{name}</div>
<div className="pbrand">Arcane Wasteland<span>Character sheet</span></div>
</header>
)
const fields = (
<div className="pfields">
<Field label="Player">{ch.player}</Field>
<Field label="Concept">{ch.concept}</Field>
<Field label="Class">{cls && [cls.name, sub?.name].filter(Boolean).join(' / ')}</Field>
<Field label="Profession">{prof?.name}</Field>
<Field label="Rules">{preset}</Field>
</div>
)
return ch.portrait
? <div className="pidentity"><img className="pportrait" src={ch.portrait} alt="" /><div className="pidright">{head}{fields}</div></div>
: <>{head}{fields}</>
})()}
<div className="pattrs">
{rules.attributes.map((a) => (
@@ -165,7 +186,7 @@ export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
{data.loot && <><h3>Loot</h3><p className="pre">{data.loot}</p></>}
</div>
)}
<div className="psec pnotes"><h3>Conditions &amp; notes</h3><div className="plines" /></div>
<Notes title="Conditions &amp; notes" value={ch.conditions} onChange={onNotes && ((v) => onNotes({ conditions: v }))} />
<Foot n={1} />
</section>
@@ -208,7 +229,7 @@ export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
{ch.background && <div className="psec"><h3>Background</h3><p className="pbg">{ch.background}</p></div>}
<div className="psec pnotes"><h3>Notes</h3><div className="plines" /></div>
<Notes title="Notes" value={ch.notes} onChange={onNotes && ((v) => onNotes({ notes: v }))} />
<Foot n={2} />
</section>
</Fit>
+1 -1
View File
@@ -14,7 +14,7 @@ export function emptyCharacter(rules) {
talents: [], augments: [],
kit: { extras: {} }, // item id -> quantity
gear: { other: '' },
notes: '',
notes: '', conditions: '',
}
}
+11 -1
View File
@@ -476,7 +476,6 @@ label.chk input[type=checkbox] { width: auto; margin: 0; }
.cropwin { position: relative; overflow: hidden; border-radius: 10px; border: 2px solid var(--noxium, #7aa63c); background: #000; cursor: move; touch-action: none; flex: none; }
.cropwin img { position: absolute; max-width: none; user-select: none; }
.cropctl { display: grid; gap: .5rem; }
.pportrait { width: 21mm; height: 21mm; object-fit: cover; border: 1.5px solid var(--pg); border-radius: 1.5mm; flex: none; margin-left: auto; }
.pavatar { overflow: hidden; border: 2px solid var(--pc); }
.pavatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.maptoken .tokpic { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 50%; object-fit: cover; pointer-events: none; }
@@ -564,3 +563,14 @@ body.resizing { cursor: col-resize; user-select: none; }
.lobbygrid, .lobbygrid:not(.nosheet) { grid-template-columns: minmax(0, 1fr); }
.sheetsplit, .chatsplit { display: none; }
}
/* portrait next to name and identity fields */
.pidentity { display: flex; gap: 5mm; align-items: stretch; }
.pidright { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3mm; justify-content: flex-end; }
.pportrait { width: 36mm; height: 42mm; object-fit: cover; border: 1.5px solid var(--pg); border-radius: 1.5mm; flex: none; align-self: flex-start; }
.pidright .pfields { grid-template-columns: 1fr 1fr 1fr; }
.pidright .pfield:nth-child(2) { grid-column: span 2; }
.pwrite { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; line-height: calc(6.4mm + 1px); padding: 0; color: var(--pi); border: 0; resize: none; width: 100%; box-sizing: border-box; background-attachment: local; min-height: 18mm; }
textarea.pwrite { background-color: transparent; outline: none; field-sizing: content; }
textarea.pwrite:focus { box-shadow: 0 0 0 1px var(--pg); }
.pwrite::placeholder { color: var(--pm); opacity: .6; }