Sheet: light/dark switch, slimmer lobby header, remove wide view

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-21 17:24:48 +00:00
parent 8711b7fe0e
commit 968d168ff1
5 changed files with 64 additions and 109 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ Each wizard page has a **Reset this page** button that clears only that page's d
### Character sheet (A4)
The Summary page and the sheet tabs in the lobbies show the character as **A4 pages**: page 1 has name, attributes, derived stats and all skills (proficiency dots, attribute, points, total), page 2 has talents, mutations and implants, equipment, background and a ruled notes area. **Print / save as PDF (A4)** opens just the pages in a clean window; choose "Save as PDF" in the print dialog (margins none, background graphics on if your browser asks). Hover or tap a value to see how it is made up (not printed). In a lobby, **A4 sheet / Wide** switches between the compact page and the full-width table view (your choice is remembered). The Word and PDF downloads on the Summary page are separate exports with their own layout.
The Summary page and the sheet tabs in the lobbies show the character as **A4 pages**: page 1 has name, attributes, derived stats and all skills (proficiency dots, attribute, points, total), page 2 has talents, mutations and implants, equipment, background and a ruled notes area. **Print / PDF (A4)** opens just the pages in a clean window; choose "Save as PDF" in the print dialog (margins none, background graphics on if your browser asks). Hover or tap a value to see how it is made up (not printed). **Light / Dark** switches the colours of the sheet on screen (remembered in the browser; printed pages are always light). The Word and PDF downloads on the Summary page are separate exports with their own layout.
### Lobbies
+11 -19
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import SheetView from './SheetView.jsx'
import PaperSheet from './PaperSheet.jsx'
const J = { 'Content-Type': 'application/json' }
@@ -321,24 +320,17 @@ function CharTab({ cid, owner, rules, onOpen, name }) {
const t = setInterval(() => !document.hidden && load(), 15000)
return () => clearInterval(t)
}, [cid])
const [view, setView] = useState(() => { try { return localStorage.getItem('aw_sheetview') === 'wide' ? 'wide' : 'paper' } catch { return 'paper' } })
const pick = (v) => { setView(v); try { localStorage.setItem('aw_sheetview', v) } catch { /* storage may be blocked */ } }
const viewSwitch = (
<span className="seg" role="group" aria-label="Sheet layout">
<button className={view === 'paper' ? 'on' : 'ghost'} onClick={() => pick('paper')} title="A4 page layout, compact">A4 sheet</button>
<button className={view === 'wide' ? 'on' : 'ghost'} onClick={() => pick('wide')} title="Expand to the full width">Wide</button>
</span>
)
return (
<div>
<div className="row">
<h2>{data?.name || name || 'Nameless'}</h2>
<button className="ghost" onClick={onOpen}>{owner ? 'Open in the Character tab' : 'Edit in the Character tab'}</button>
</div>
<p className="muted small">{owner ? `Played by ${owner}. ` : ''}Read-only, hover or tap a value to see how it is made up.</p>
{err && <p className="warn">{err}</p>}
{data && view === 'paper' && <PaperSheet data={data} baseRules={rules} tools={viewSwitch} />}
{data && view === 'wide' && <><div className="papertools">{viewSwitch}</div><SheetView data={data} baseRules={rules} /></>}
{data && (
<PaperSheet data={data} baseRules={rules} tools={
<>
<span className="muted small">{owner ? `Played by ${owner}` : 'Your character'} · read-only, hover or tap a value for details</span>
<button className="ghost" onClick={onOpen}>{owner ? 'Open in Character tab' : 'Edit in Character tab'}</button>
</>
} />
)}
</div>
)
}
@@ -392,10 +384,10 @@ function LobbyView({ id, rules, user, onOpen }) {
return (
<section className="page lobbypage">
<p className="small"><a href="#lobbies">← All lobbies</a></p>
<div className="row">
<div className="lobbyhead">
<a className="small" href="#lobbies">← Lobbies</a>
<h1>{lobby.name}</h1>
<span className="muted">DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}</span>
<span className="muted small">DM: {lobby.dm}{lobby.is_dm ? ' (you)' : ''}</span>
</div>
{err && <p className="warn">{err}</p>}
{info && <p className="small">{info}</p>}
+13 -4
View File
@@ -12,6 +12,7 @@ export function printSheet(root, title) {
if (!win) { window.alert('Please allow pop-ups for this site to print the sheet.'); return }
const clone = root.cloneNode(true)
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('')
win.document.open()
win.document.write(`<!doctype html><html><head><meta charset="utf-8"><title>${(title || 'Character').replace(/[<>&]/g, '')}</title>${head}
@@ -27,7 +28,7 @@ export function printSheet(root, title) {
}
// Scales the A4 pages down when the space is narrower than the paper
function Fit({ children, rootRef }) {
function Fit({ children, rootRef, dark }) {
const wrap = useRef(null)
const [z, setZ] = useState(1)
useLayoutEffect(() => {
@@ -37,7 +38,7 @@ function Fit({ children, rootRef }) {
ro.observe(wrap.current)
return () => ro.disconnect()
}, [])
return <div ref={wrap} className="paperfit"><div ref={rootRef} className="papers" style={{ zoom: z }}>{children}</div></div>
return <div ref={wrap} className="paperfit"><div ref={rootRef} className={`papers${dark ? ' dark' : ''}`} style={{ zoom: z }}>{children}</div></div>
}
const Pips = ({ n, locked }) => (
@@ -53,6 +54,8 @@ export default function PaperSheet({ data, baseRules, tools }) {
const c = useMemo(() => compute(ch, rules), [ch, rules])
const x = { ch, rules, c }
const root = useRef(null)
const [dark, setDark] = useState(() => { try { return localStorage.getItem('aw_sheettheme') === 'dark' } catch { return false } })
const pickTheme = (d) => { setDark(d); try { localStorage.setItem('aw_sheettheme', d ? 'dark' : 'light') } catch { /* storage may be blocked */ } }
const prof = rules.professions.find((p) => p.id === ch.profession)
const cls = rules.classes.find((k) => k.id === ch.class_id)
@@ -72,9 +75,15 @@ export default function PaperSheet({ data, baseRules, tools }) {
<div className="papersheet">
<div className="papertools">
{tools}
<button className="ghost" onClick={() => printSheet(root.current, name)}>Print / save as PDF (A4)</button>
<span className="toolright">
<span className="seg" role="group" aria-label="Sheet colours">
<button className={dark ? 'ghost' : 'on'} onClick={() => pickTheme(false)} title="Light sheet">Light</button>
<button className={dark ? 'on' : 'ghost'} onClick={() => pickTheme(true)} title="Dark sheet (prints light)">Dark</button>
</span>
<button className="ghost" onClick={() => printSheet(root.current, name)} title="Opens the pages for printing, choose Save as PDF there">Print / PDF (A4)</button>
</span>
</div>
<Fit rootRef={root}>
<Fit rootRef={root} dark={dark}>
<section className="paper">
<header className="phead">
<div className="pname">{name}</div>
-57
View File
@@ -1,57 +0,0 @@
import { useMemo } from 'react'
import Tip from './Tip.jsx'
import { normalize } from './calc.js'
import { sheetModel } from './sheetModel.js'
function Rows({ rows, valueLabel }) {
if (!rows.length) return <p className="muted small">–</p>
return (
<table className="difftable"><tbody>
{rows.map((r) => (
<tr key={r.k} className={r.dim ? 'dim' : ''}>
<td><Tip text={r.tip}>{r.label}</Tip>{r.desc && <div className="rowdesc">{r.desc}</div>}</td>
<td>{valueLabel ? valueLabel(r) : r.val}</td>
{r.extra !== undefined && <td className="muted">{r.extra}</td>}
</tr>
))}
</tbody></table>
)
}
// Read-only character sheet (used in lobbies). Hover or tap a value to see how it is made up.
export default function SheetView({ data, baseRules }) {
const m = useMemo(() => sheetModel(normalize(data, baseRules), baseRules), [data, baseRules])
const c = m.c
const trained = m.skills.filter((s) => c.skills.find((x) => x.id === s.k && (x.points > 0 || x.pips > 0 || (x.type === 'advanced' && x.usable))))
const rest = m.skills.filter((s) => !trained.includes(s))
return (
<div className="sheetview">
<Rows rows={m.head.filter((h) => h.k !== 'name')} />
<div className="sheetcols">
<div>
<h4>Attributes</h4>
<Rows rows={m.attrs} />
<h4>Derived</h4>
<Rows rows={m.derived} />
</div>
<div>
<h4>Skills</h4>
<Rows rows={trained} />
{rest.length > 0 && (
<details>
<summary className="small muted">All other skills ({rest.length})</summary>
<Rows rows={rest} />
</details>
)}
</div>
</div>
<h4>Talents</h4>
<Rows rows={m.talents} />
<h4><Tip text={m.tips.capacity}>Mutations / implants <span className="muted small">{c.capacityUsed}/{c.capacityMax}</span></Tip></h4>
<Rows rows={m.augments} />
<h4><Tip text={m.tips.carry}>Equipment <span className="muted small">{c.weight}/{c.carryMax} kg</span></Tip></h4>
<Rows rows={m.gear} valueLabel={(r) => (r.val > 1 ? `x${r.val}` : '')} />
</div>
)
}
+39 -28
View File
@@ -253,67 +253,78 @@ body:has(.lobbychat) main { max-width: none; margin: 0; padding-right: calc(var(
.lobbycard .chip { align-self: flex-start; }
/* ---- A4 character sheet (fixed colours on purpose: it is printed on white paper) ---- */
.papertools { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin: .4rem 0 .8rem; }
.papertools { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin: 0 0 .5rem; }
.papertools .toolright { margin-left: auto; display: flex; gap: .5rem; align-items: center; }
.papertools button { padding: .3rem .8rem; }
.seg { display: inline-flex; }
.seg button { border-radius: 0; padding: .4rem .9rem; }
.seg button:first-child { border-radius: var(--radius) 0 0 var(--radius); }
.seg button:last-child { border-radius: 0 var(--radius) var(--radius) 0; margin-left: -1px; }
.seg button.on { background: var(--noxium); color: #12150f; border-color: var(--noxium); }
.paperfit { width: 100%; }
.papers { display: grid; gap: 14px; justify-items: start; width: 210mm; }
.paper { box-sizing: border-box; width: 210mm; min-height: 297mm; padding: 11mm 12mm 8mm; background: #fff; color: #1a1f14; border: 1px solid #cfd5c3; box-shadow: 0 2px 12px rgba(0, 0, 0, .14);
.papers { display: grid; gap: 14px; justify-items: start; width: 210mm;
--pi: #1a1f14; --pg: #2f6a0d; --pbar: #2f6a0d; --pm: #6a7260; --pl: #cfd5c3; --pd: #c3cab6; --pt: #eef3e6; --pb: #fff; --p2: #4d5645; --p3: #3b4433; --p4: #5c6553; --pn: #b9c1ab; }
.papers.dark { --pi: #e4e9d8; --pg: #8fd44c; --pbar: #3c7a16; --pm: #98a28a; --pl: #343b2d; --pd: #353c2e; --pt: #252b1f; --pb: #161a12; --p2: #b4bda6; --p3: #c5cdb7; --p4: #a1aa93; --pn: #454d3a; }
.papers.dark .paper { box-shadow: 0 2px 12px rgba(0, 0, 0, .5); }
.paper { box-sizing: border-box; width: 210mm; min-height: 297mm; padding: 11mm 12mm 8mm; background: var(--pb); color: var(--pi); border: 1px solid var(--pl); box-shadow: 0 2px 12px rgba(0, 0, 0, .14);
display: flex; flex-direction: column; gap: 3.5mm; font-size: 8.6pt; line-height: 1.25; break-after: page; }
.paper * { color: inherit; }
.paper .tipped { border-bottom: 0; text-decoration: none; cursor: default; }
.paper p { margin: 0; }
.phead { display: flex; justify-content: space-between; align-items: flex-end; gap: 8mm; border-bottom: 2px solid #2f6a0d; padding-bottom: 2mm; }
.phead { display: flex; justify-content: space-between; align-items: flex-end; gap: 8mm; border-bottom: 2px solid var(--pg); padding-bottom: 2mm; }
.pname { font-size: 24pt; font-weight: 800; line-height: 1.05; letter-spacing: .01em; overflow-wrap: anywhere; }
.phead.small .pname { font-size: 15pt; }
.pbrand { text-align: right; font-weight: 800; text-transform: uppercase; letter-spacing: .12em; font-size: 8.5pt; color: #2f6a0d !important; white-space: nowrap; }
.pbrand span { display: block; font-weight: 500; letter-spacing: .08em; font-size: 7pt; color: #6a7260 !important; }
.pbrand { text-align: right; font-weight: 800; text-transform: uppercase; letter-spacing: .12em; font-size: 8.5pt; color: var(--pg) !important; white-space: nowrap; }
.pbrand span { display: block; font-weight: 500; letter-spacing: .08em; font-size: 7pt; color: var(--pm) !important; }
.pfields { display: grid; grid-template-columns: 1fr 1.6fr 1.3fr 1fr .8fr; gap: 3mm; }
.pfield { border-bottom: 1px solid #1a1f14; padding-bottom: .6mm; min-width: 0; }
.pfield span { display: block; font-size: 6.2pt; text-transform: uppercase; letter-spacing: .1em; color: #6a7260 !important; }
.pfield { border-bottom: 1px solid var(--pi); padding-bottom: .6mm; min-width: 0; }
.pfield span { display: block; font-size: 6.2pt; text-transform: uppercase; letter-spacing: .1em; color: var(--pm) !important; }
.pfield b { font-weight: 600; font-size: 9pt; overflow-wrap: anywhere; }
.pattrs { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2.5mm; }
.pattr { border: 1.4px solid #1a1f14; border-radius: 3mm; text-align: center; padding: 1.6mm 1mm 1.4mm; display: grid; gap: .2mm; }
.pattr { border: 1.4px solid var(--pi); border-radius: 3mm; text-align: center; padding: 1.6mm 1mm 1.4mm; display: grid; gap: .2mm; }
.pattr-n { font-size: 6.6pt; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
.pattr-v { font-size: 20pt; line-height: 1; }
.pattr-m { font-size: 7pt; color: #4d5645 !important; border-top: 1px solid #cfd5c3; padding-top: .8mm; }
.pattr-m { font-size: 7pt; color: var(--p2) !important; border-top: 1px solid var(--pl); padding-top: .8mm; }
.pderived { display: grid; grid-template-columns: repeat(auto-fit, minmax(28mm, 1fr)); gap: 2.5mm; }
.pstat { background: #eef3e6; border-radius: 2mm; padding: 1.4mm 2.4mm; display: flex; justify-content: space-between; align-items: baseline; font-weight: 600; font-size: 8pt; }
.pstat { background: var(--pt); border-radius: 2mm; padding: 1.4mm 2.4mm; display: flex; justify-content: space-between; align-items: baseline; font-weight: 600; font-size: 8pt; }
.pstat b { font-size: 13pt; }
.psec h3 { margin: 0 0 1.6mm; font-size: 9pt; text-transform: uppercase; letter-spacing: .12em; color: #fff !important; background: #2f6a0d; padding: .8mm 2.4mm; border-radius: 1.4mm; display: flex; justify-content: space-between; align-items: center; gap: 3mm; }
.psec h3 { margin: 0 0 1.6mm; font-size: 9pt; text-transform: uppercase; letter-spacing: .12em; color: #fff !important; background: var(--pbar); padding: .8mm 2.4mm; border-radius: 1.4mm; display: flex; justify-content: space-between; align-items: center; gap: 3mm; }
.psec h3 * { color: #fff !important; }
.psec h3 small { font-size: 6.6pt; font-weight: 500; letter-spacing: .04em; text-transform: none; }
.pskills { column-count: 2; column-gap: 6mm; }
.pgroup { break-inside: avoid; margin-bottom: 3mm; }
.pgroup h4 { margin: 0 0 .6mm; font-size: 7.4pt; text-transform: uppercase; letter-spacing: .1em; color: #2f6a0d !important; border-bottom: 1px solid #2f6a0d; }
.prow { display: grid; grid-template-columns: 6.5mm 1fr 11mm 6mm 8mm; align-items: center; gap: 1mm; padding: .35mm 0; border-bottom: 1px dotted #c3cab6; }
.pgroup h4 { margin: 0 0 .6mm; font-size: 7.4pt; text-transform: uppercase; letter-spacing: .1em; color: var(--pg) !important; border-bottom: 1px solid var(--pg); }
.prow { display: grid; grid-template-columns: 6.5mm 1fr 11mm 6mm 8mm; align-items: center; gap: 1mm; padding: .35mm 0; border-bottom: 1px dotted var(--pd); }
.prow .pn { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.prow .pn em { font-size: 6pt; color: #6a7260 !important; font-style: normal; text-transform: uppercase; letter-spacing: .05em; margin-left: .8mm; }
.prow .pa { font-size: 6.4pt; color: #6a7260 !important; text-align: center; }
.prow .pp { font-size: 7pt; color: #4d5645 !important; text-align: right; }
.prow .pt { text-align: center; border: 1px solid #1a1f14; border-radius: 1.4mm; font-size: 8.6pt; padding: .1mm 0; background: #fff; }
.prow .pn em { font-size: 6pt; color: var(--pm) !important; font-style: normal; text-transform: uppercase; letter-spacing: .05em; margin-left: .8mm; }
.prow .pa { font-size: 6.4pt; color: var(--pm) !important; text-align: center; }
.prow .pp { font-size: 7pt; color: var(--p2) !important; text-align: right; }
.prow .pt { text-align: center; border: 1px solid var(--pi); border-radius: 1.4mm; font-size: 8.6pt; padding: .1mm 0; background: var(--pb); }
.prow.locked { opacity: .45; }
.prow.locked .pn { text-decoration: line-through; }
.ppips { display: inline-flex; gap: .7mm; }
.ppips i { width: 2.1mm; height: 2.1mm; border: 1px solid #1a1f14; border-radius: 50%; background: #fff; display: inline-block; }
.ppips i.on { background: #2f6a0d; border-color: #2f6a0d; }
.ppips i { width: 2.1mm; height: 2.1mm; border: 1px solid var(--pi); border-radius: 50%; background: var(--pb); display: inline-block; }
.ppips i.on { background: var(--pg); border-color: var(--pg); }
.pcols { column-count: 2; column-gap: 6mm; }
.pitem { break-inside: avoid; margin-bottom: 2.2mm; }
.pitem b { font-size: 9pt; }
.pitem p { font-size: 7.8pt; color: #3b4433 !important; }
.ptag { font-size: 6.6pt; color: #6a7260 !important; text-transform: uppercase; letter-spacing: .05em; }
.pnone { color: #6a7260 !important; }
.pitem p { font-size: 7.8pt; color: var(--p3) !important; }
.ptag { font-size: 6.6pt; color: var(--pm) !important; text-transform: uppercase; letter-spacing: .05em; }
.pnone { color: var(--pm) !important; }
.pgear { width: 100%; border-collapse: collapse; font-size: 8pt; }
.pgear th { text-align: left; font-size: 6.4pt; text-transform: uppercase; letter-spacing: .08em; color: #6a7260 !important; border-bottom: 1px solid #1a1f14; padding: .6mm 1mm; }
.pgear td { padding: .45mm 1mm; border-bottom: 1px dotted #c3cab6; vertical-align: top; }
.pgear th { text-align: left; font-size: 6.4pt; text-transform: uppercase; letter-spacing: .08em; color: var(--pm) !important; border-bottom: 1px solid var(--pi); padding: .6mm 1mm; }
.pgear td { padding: .45mm 1mm; border-bottom: 1px dotted var(--pd); vertical-align: top; }
.pgear td:nth-child(n+4), .pgear th:nth-child(n+4) { text-align: right; white-space: nowrap; }
.pnote { font-size: 6.6pt; color: #5c6553 !important; }
.pnote { font-size: 6.6pt; color: var(--p4) !important; }
.pother { margin-top: 1.6mm; font-size: 8pt; }
.pbg { font-size: 8pt; white-space: pre-wrap; }
.pnotes { flex: 1; display: flex; flex-direction: column; min-height: 18mm; }
.plines { flex: 1; background-image: repeating-linear-gradient(to bottom, transparent 0, transparent 6.4mm, #b9c1ab 6.4mm, #b9c1ab calc(6.4mm + 1px)); border-radius: 1mm; }
.pfoot { margin-top: auto; display: flex; justify-content: space-between; font-size: 6.4pt; color: #6a7260 !important; text-transform: uppercase; letter-spacing: .08em; border-top: 1px solid #cfd5c3; padding-top: 1.2mm; }
.plines { flex: 1; background-image: repeating-linear-gradient(to bottom, transparent 0, transparent 6.4mm, var(--pn) 6.4mm, var(--pn) calc(6.4mm + 1px)); border-radius: 1mm; }
.pfoot { margin-top: auto; display: flex; justify-content: space-between; font-size: 6.4pt; color: var(--pm) !important; text-transform: uppercase; letter-spacing: .08em; border-top: 1px solid var(--pl); padding-top: 1.2mm; }
@media print { .paper { box-shadow: none; } }
/* lobby header: one slim line instead of three */
.lobbyhead { display: flex; align-items: baseline; gap: .8rem; flex-wrap: wrap; margin: 0 0 .3rem; }
.lobbyhead h1 { font-size: 1.3rem; margin: 0; }
.lobbypage .tabs { margin: .4rem 0 .7rem; }
.lobbypage .tabs button { padding: .3rem .8rem; }